diff --git a/README.md b/README.md index ce27de7..bd068bc 100644 --- a/README.md +++ b/README.md @@ -4,6 +4,8 @@
+![logo](assets/logo.png) + **Decouple LLM, Tools, Agent, and Channels—then pack them onto a single ESP32-S3.** [![License: MIT](https://img.shields.io/badge/License-MIT-yellow.svg)](LICENSE) ![ESP32-S3](https://img.shields.io/badge/MCU-ESP32--S3-ff6a00) ![ESP-IDF](https://img.shields.io/badge/ESP--IDF-v5.5.2-00979D) ![LLM](https://img.shields.io/badge/LLM-Qwen%20via%20DashScope-0f766e) ![Channel](https://img.shields.io/badge/Channel-Feishu%20%7C%20WebSocket%20%7C%20QQBot-2563eb) ![Search](https://img.shields.io/badge/Search-Tavily-111827) @@ -18,7 +20,7 @@ This project draws on the ideas and direction of: -- [OpenClaw](https://github.com/OpenClawAI/OpenClaw) +- [OpenClaw](https://github.com/openclaw/openclaw) - [MimiClaw](https://github.com/memovai/mimiclaw) EmbedClaw keeps the goal of running a full AI Agent on low-power hardware but focuses the architecture on **decoupling LLM, Tools, Agent, and Channels**. @@ -69,10 +71,10 @@ It’s a working “embedded Agent base” you can extend. | Chat Channel | Feishu, WebSocket, QQBot | Feishu long connection, local WebSocket chat, official QQBot gateway | | Agent | ReAct tool loop | Model can call tools, read results, then continue | | Long-term memory | `/spiffs/memory/MEMORY.md` | User profile, preferences, stable facts | -| Short-term memory | `/spiffs/session/se_.jsonl` | Recent conversation for current session | +| Short-term memory | `/spiffs/session/se_.jsonl` | Conversation history including tool call traces | | Daily notes | `/spiffs/memory/.md` | Recent events and daily context | -| Skills | Built-in + SPIFFS | Task instructions as Markdown | -| Tools | Files, time, search, cron | Exposed to LLM via JSON schema | +| Skills | SPIFFS pre-installed + runtime | Task instructions as Markdown | +| Tools | Files, time, search, cron, GPIO | Exposed to LLM via JSON schema | ### Registered tools @@ -87,10 +89,11 @@ It’s a working “embedded Agent base” you can extend. | `cron_add` | Add periodic or one-shot scheduled tasks | | `cron_list` | List scheduled tasks | | `cron_remove` | Remove scheduled tasks | +| `gpio_control` | Control ESP32 GPIO pins (on, off, set, toggle, get) | -### Built-in skills +### Pre-installed skills -These are installed at startup: +These are pre-installed as Markdown files in `spiffs_data/skills/` and deployed with the SPIFFS image: - `weather` - `daily-briefing` @@ -115,6 +118,7 @@ flowchart LR T --> S2[File Tools] T --> S3[Time Tool] T --> S4[Cron Tool] + T --> S5[GPIO Tool] A --> M1[Session Memory] A --> M2[Long-term Memory] A --> K[Skill Loader] @@ -137,6 +141,9 @@ flowchart LR │ ├── embed_claw.c # System startup entry │ └── ec_config_internal.h # Built-in defaults; local overrides live in main/ec_config.h ├── spiffs_data/ # Default SPIFFS image content +│ ├── config/ # SOUL.md, USER.md +│ ├── memory/ # MEMORY.md +│ └── skills/ # Pre-installed skill files └── scripts/ # WebSocket test script and test-app helpers ``` @@ -540,7 +547,6 @@ Skills are Markdown task descriptions, not code. You can: - Write them at runtime via tools to `/spiffs/skills/.md` - Or put default skills in `spiffs_data/skills/` so they’re in the SPIFFS image -- Or add built-in skills in `ec_skill_loader.c` Suggested format: diff --git a/README_ZH.md b/README_ZH.md index 73f6947..8f57af2 100644 --- a/README_ZH.md +++ b/README_ZH.md @@ -4,6 +4,8 @@
+![logo](assets/logo.png) + **把 LLM、Tools、Agent、Channel 彻底拆开,再把它们装进一块 ESP32-S3。** [![License: MIT](https://img.shields.io/badge/License-MIT-yellow.svg)](LICENSE) ![ESP32-S3](https://img.shields.io/badge/MCU-ESP32--S3-ff6a00) ![ESP-IDF](https://img.shields.io/badge/ESP--IDF-v5.5.2-00979D) ![LLM](https://img.shields.io/badge/LLM-Qwen%20via%20DashScope-0f766e) ![Channel](https://img.shields.io/badge/Channel-Feishu%20%7C%20WebSocket%20%7C%20QQBot-2563eb) ![Search](https://img.shields.io/badge/Search-Tavily-111827) @@ -17,7 +19,7 @@ 本仓库在理念和方向上参考了以下优秀项目: -- [OpenClaw](https://github.com/OpenClawAI/OpenClaw) +- [OpenClaw](https://github.com/openclaw/openclaw) - [MimiClaw](https://github.com/memovai/mimiclaw) EmbedClaw 延续了“在低功耗硬件上运行完整 AI Agent”的思路,但把架构重点放在了 **LLM、Tools、Agent、Channel 的解耦** 上。 @@ -68,9 +70,9 @@ EmbedClaw 延续了“在低功耗硬件上运行完整 AI Agent”的思路, | Chat Channel | Feishu、WebSocket、QQBot | 飞书长连接、本地 WebSocket 对话、官方 QQBot gateway | | Agent | ReAct Tool Loop | 支持模型调用工具、再读工具结果、再继续推理 | | 长期记忆 | `/spiffs/memory/MEMORY.md` | 用户画像、长期偏好、稳定事实 | -| 短期记忆 | `/spiffs/session/se_.jsonl` | 最近对话历史,供当前会话上下文使用 | +| 短期记忆 | `/spiffs/session/se_.jsonl` | 对话历史(含完整工具调用链),供当前会话上下文使用 | | 每日笔记 | `/spiffs/memory/.md` | 记录近期事件与每日上下文 | -| Skills | 内置 + SPIFFS 动态技能 | 任务指令可持久化为 Markdown | +| Skills | SPIFFS 预置 + 运行时动态添加 | 任务指令可持久化为 Markdown | | Tools | 文件、时间、搜索、定时任务 | 通过统一 JSON Schema 暴露给 LLM | ### 已注册 Tools @@ -86,10 +88,11 @@ EmbedClaw 延续了“在低功耗硬件上运行完整 AI Agent”的思路, | `cron_add` | 创建周期任务或单次任务 | | `cron_list` | 查看当前定时任务 | | `cron_remove` | 删除定时任务 | +| `gpio_control` | 控制 ESP32 GPIO 引脚(on/off/set/toggle/get) | -### 已启用 Skills +### 预置 Skills -系统启动时会自动安装内置 Skills: +以下技能以 Markdown 文件形式预置在 `spiffs_data/skills/` 中,随 SPIFFS 镜像一同烧录: - `weather` - `daily-briefing` @@ -114,6 +117,7 @@ flowchart LR T --> S2[File Tools] T --> S3[Time Tool] T --> S4[Cron Tool] + T --> S5[GPIO Tool] A --> M1[Session Memory] A --> M2[Long-term Memory] A --> K[Skill Loader] @@ -136,6 +140,9 @@ flowchart LR │ ├── embed_claw.c # 系统统一启动入口 │ └── ec_config_internal.h # 仓库内置默认配置,项目覆盖放在 main/ec_config.h ├── spiffs_data/ # 默认写入 SPIFFS 的系统文件 +│ ├── config/ # SOUL.md、USER.md +│ ├── memory/ # MEMORY.md +│ └── skills/ # 预置 Skill 文件 └── scripts/ # WebSocket 测试脚本与测试构建辅助脚本 ``` @@ -569,7 +576,6 @@ Skill 是最轻量的扩展方式。它不是代码,而是任务说明书。 - 在运行时通过 Tool 把 Skill 写入 `/spiffs/skills/.md` - 或者把默认 Skill 放进 `spiffs_data/skills/`,随 SPIFFS 镜像一同烧录 -- 或者在 `ec_skill_loader.c` 中增加新的内置 Skill 建议格式: diff --git a/TODO.md b/TODO.md index 2ed33d4..374eb68 100644 --- a/TODO.md +++ b/TODO.md @@ -17,6 +17,7 @@ - [x] Web search - [ ] Script interpreter - [ ] Peripherals (GPIO, I2C, etc.) + - [X] GPIO - [ ] Camera ## Channel integration @@ -33,3 +34,5 @@ - [x] test and ci/cd - [ ] Support more filesystems (beyond SPIFFS) - [ ] SD card support +- [ ] Support ESP32C3 + diff --git a/TODO_ZH.md b/TODO_ZH.md index 7bc7188..edf5edb 100644 --- a/TODO_ZH.md +++ b/TODO_ZH.md @@ -17,6 +17,7 @@ - [x] 对接web搜索 - [ ] 对接脚本语言解释器 - [ ] 对接外设 + - [X] 对接GPIO - [ ] 对接摄像头 ## Channel对接 @@ -33,3 +34,4 @@ - [x] 测试和ci/cd - [ ] 更改SPIFFS为更多的文件系统 - [ ] 支持 SD card +- [ ] 支持 ESP32C3 diff --git a/assets/logo.png b/assets/logo.png new file mode 100644 index 0000000..74c1af0 Binary files /dev/null and b/assets/logo.png differ diff --git a/components/embed_claw/CMakeLists.txt b/components/embed_claw/CMakeLists.txt index 3898e55..7239ccf 100644 --- a/components/embed_claw/CMakeLists.txt +++ b/components/embed_claw/CMakeLists.txt @@ -8,6 +8,7 @@ idf_component_register( INCLUDE_DIRS "." REQUIRES + driver esp_http_client esp_http_server esp_netif diff --git a/components/embed_claw/core/ec_agent.c b/components/embed_claw/core/ec_agent.c index fedd18c..5911eb0 100644 --- a/components/embed_claw/core/ec_agent.c +++ b/components/embed_claw/core/ec_agent.c @@ -41,21 +41,15 @@ #define EC_AGENT_CHANNEL_DELIVERY_TASK_STACK (8 * 1024) #define EC_AGENT_CHANNEL_DELIVERY_TASK_PRIO 5 -#define EC_AGENT_SYSTEM_PROMPT_STR \ +#define EC_AGENT_SYSTEM_PROMPT_HEAD \ "You are EmbedClaw, a helpful and concise AI assistant running on an ESP32 device.\n"\ "You communicate via Feishu and WebSocket.\n"\ "Reply briefly to short messages (e.g. 你好, 在吗, 谢谢).\n"\ "# Tools\n"\ - "- web_search: search current information.\n"\ - "- get_current_time: get date/time.\n"\ - "- read_file: read /spiffs/ files (path must start with " EC_FS_BASE "/).\n"\ - "- write_file: Write file.\n"\ - "- edit_file: edit file.\n"\ - "- list_dir: list files.\n"\ - "- cron_add: schedule task.\n"\ - "- cron_list: list tasks.\n"\ - "- cron_remove: remove task.\n\n"\ - "When using cron_add to reply later in the same conversation, reuse the current channel, chat_type, and chat_id.\n\n"\ + "Available tools:\n" + +#define EC_AGENT_SYSTEM_PROMPT_TAIL \ + "\nWhen using cron_add to reply later in the same conversation, reuse the current channel, chat_type, and chat_id.\n\n"\ "Use tools when needed. Provide your final answer as text after using tools.\n\n"\ "## Memory\n"\ "You have persistent memory stored on local flash:\n"\ @@ -328,19 +322,34 @@ static esp_err_t context_build_system_prompt(char *buf, size_t size) size_t off = 0; size_t cap = size - 1; - off += snprintf(buf + off, size - off, EC_AGENT_SYSTEM_PROMPT_STR); + off += snprintf(buf + off, size - off, EC_AGENT_SYSTEM_PROMPT_HEAD); if (off > cap) { off = cap; } - /* Bootstrap files */ - off = append_file(buf, size, off, EC_SOUL_FILE, "Personality"); - off = append_file(buf, size, off, EC_USER_FILE, "User Info"); - scratch = calloc(1, EC_AGENT_PROMPT_SCRATCH_SIZE); if (!scratch) { ESP_LOGW(TAG, "Skipping optional prompt sections: out of memory"); } else { + size_t tools_len = ec_tools_build_summary(scratch, EC_AGENT_PROMPT_SCRATCH_SIZE); + if (off < cap && tools_len > 0) { + off += snprintf(buf + off, size - off, "%s", scratch); + if (off > cap) { + off = cap; + } + } + } + + off += snprintf(buf + off, size - off, EC_AGENT_SYSTEM_PROMPT_TAIL); + if (off > cap) { + off = cap; + } + + /* Bootstrap files */ + off = append_file(buf, size, off, EC_SOUL_FILE, "Personality"); + off = append_file(buf, size, off, EC_USER_FILE, "User Info"); + + if (scratch) { if (off < cap && ec_memory_read_long_term(scratch, EC_AGENT_PROMPT_SCRATCH_SIZE) == ESP_OK && scratch[0]) { off += snprintf(buf + off, size - off, "\n## Long-term Memory\n\n%s\n", scratch); if (off > cap) { @@ -377,7 +386,6 @@ static esp_err_t context_build_system_prompt(char *buf, size_t size) } ESP_LOGI(TAG, "System prompt built: %d bytes", (int)off); - ESP_LOGI(TAG, "prompt:%s", buf); return ESP_OK; } @@ -455,6 +463,8 @@ static void agent_loop_task(void *arg) if (!messages) { messages = cJSON_CreateArray(); } + int history_count = cJSON_GetArraySize(messages); + cJSON *user_msg = cJSON_CreateObject(); cJSON_AddStringToObject(user_msg, "role", "user"); cJSON_AddStringToObject(user_msg, "content", msg.content); @@ -488,7 +498,6 @@ static void agent_loop_task(void *arg) } if (!resp.tool_use) { - // 正常对话结束,保存最终文本并退出循环 if (resp.text && resp.text_len > 0) { final_text = strdup(resp.text); } @@ -496,14 +505,11 @@ static void agent_loop_task(void *arg) break; } - - // 构建助手消息,包含文本回复和工具调用信息,供工具执行结果使用 cJSON *asst_msg = cJSON_CreateObject(); cJSON_AddStringToObject(asst_msg, "role", "assistant"); cJSON_AddItemToObject(asst_msg, "content", build_assistant_content(&resp)); cJSON_AddItemToArray(messages, asst_msg); - // 执行工具并将结果追加到消息数组中,供下一轮 LLM 调用使用 cJSON *tool_results = build_tool_results(&resp, &msg, tool_output, EC_AGENT_TOOL_OUTPUT_SIZE); cJSON *result_msg = cJSON_CreateObject(); cJSON_AddStringToObject(result_msg, "role", "user"); @@ -514,14 +520,19 @@ static void agent_loop_task(void *arg) } if (final_text && final_text[0]) { - // 保存用户消息和助手回复到会话历史中 - esp_err_t save_user = ec_session_append(session_key, "user", msg.content); - esp_err_t save_asst = ec_session_append(session_key, "assistant", final_text); - if (save_user != ESP_OK || save_asst != ESP_OK) { - ESP_LOGW(TAG, "Session save failed for %s:%s:%s (user=%s, assistant=%s)", - msg.channel, msg.chat_type, msg.chat_id, - esp_err_to_name(save_user), - esp_err_to_name(save_asst)); + int total = cJSON_GetArraySize(messages); + bool save_ok = true; + for (int k = history_count; k < total; k++) { + if (ec_session_append_msg(session_key, cJSON_GetArrayItem(messages, k)) != ESP_OK) { + save_ok = false; + } + } + if (ec_session_append(session_key, "assistant", final_text) != ESP_OK) { + save_ok = false; + } + if (!save_ok) { + ESP_LOGW(TAG, "Session save failed for %s:%s:%s", + msg.channel, msg.chat_type, msg.chat_id); } else { ESP_LOGI(TAG, "Session saved for %s:%s:%s", msg.channel, msg.chat_type, msg.chat_id); } diff --git a/components/embed_claw/core/ec_session.c b/components/embed_claw/core/ec_session.c index 8bcb783..7d05668 100644 --- a/components/embed_claw/core/ec_session.c +++ b/components/embed_claw/core/ec_session.c @@ -26,6 +26,7 @@ /* ==================== [Defines] =========================================== */ #define EC_SESSION_PATH_MAX 128 +#define EC_SESSION_LINE_MAX 4096 /* ==================== [Typedefs] ========================================== */ @@ -70,6 +71,40 @@ esp_err_t ec_session_append(const char *chat_id, const char *role, const char *c return ESP_OK; } +esp_err_t ec_session_append_msg(const char *chat_id, const cJSON *msg) +{ + if (!chat_id || !msg) { + return ESP_ERR_INVALID_ARG; + } + + char path[EC_SESSION_PATH_MAX]; + session_path(chat_id, path, sizeof(path)); + + FILE *f = fopen(path, "a"); + if (!f) { + ESP_LOGE(TAG, "Cannot open session file %s", path); + return ESP_FAIL; + } + + cJSON *copy = cJSON_Duplicate(msg, 1); + if (!copy) { + fclose(f); + return ESP_ERR_NO_MEM; + } + cJSON_AddNumberToObject(copy, "ts", (double)time(NULL)); + + char *line = cJSON_PrintUnformatted(copy); + cJSON_Delete(copy); + + if (line) { + fprintf(f, "%s\n", line); + free(line); + } + + fclose(f); + return ESP_OK; +} + esp_err_t ec_session_get_history_json(const char *chat_id, char *buf, size_t size, int max_msgs) { if (!buf || size == 0) { @@ -101,9 +136,8 @@ esp_err_t ec_session_get_history_json(const char *chat_id, char *buf, size_t siz int count = 0; int write_idx = 0; - char line[2048]; + char line[EC_SESSION_LINE_MAX]; while (fgets(line, sizeof(line), f)) { - /* Strip newline */ size_t len = strlen(line); if (len > 0 && line[len - 1] == '\n') line[len - 1] = '\0'; if (line[0] == '\0') continue; @@ -111,7 +145,6 @@ esp_err_t ec_session_get_history_json(const char *chat_id, char *buf, size_t siz cJSON *obj = cJSON_Parse(line); if (!obj) continue; - /* Ring buffer: overwrite oldest if full */ if (count >= history_limit) { cJSON_Delete(messages[write_idx]); } @@ -121,7 +154,7 @@ esp_err_t ec_session_get_history_json(const char *chat_id, char *buf, size_t siz } fclose(f); - /* Build JSON array with only role + content */ + /* Build JSON array preserving full message structure */ cJSON *arr = cJSON_CreateArray(); int start = (count < history_limit) ? 0 : write_idx; for (int i = 0; i < count; i++) { @@ -129,17 +162,16 @@ esp_err_t ec_session_get_history_json(const char *chat_id, char *buf, size_t siz cJSON *src = messages[idx]; cJSON *role = cJSON_GetObjectItem(src, "role"); - cJSON *content = cJSON_GetObjectItem(src, "content"); - if (!cJSON_IsString(role) || !role->valuestring || - !cJSON_IsString(content) || !content->valuestring) { + if (!cJSON_IsString(role) || !role->valuestring) { ESP_LOGW(TAG, "Skipping malformed session entry in %s", path); continue; } - cJSON *entry = cJSON_CreateObject(); - cJSON_AddStringToObject(entry, "role", role->valuestring); - cJSON_AddStringToObject(entry, "content", content->valuestring); - cJSON_AddItemToArray(arr, entry); + cJSON *entry = cJSON_Duplicate(src, 1); + if (entry) { + cJSON_DeleteItemFromObject(entry, "ts"); + cJSON_AddItemToArray(arr, entry); + } } /* Cleanup ring buffer */ @@ -150,6 +182,7 @@ esp_err_t ec_session_get_history_json(const char *chat_id, char *buf, size_t siz } char *json_str = cJSON_PrintUnformatted(arr); + ESP_LOGI(TAG, "Session history for %s:\n%s", chat_id, json_str); cJSON_Delete(arr); if (json_str) { diff --git a/components/embed_claw/core/ec_session.h b/components/embed_claw/core/ec_session.h index f28fa1a..afae045 100644 --- a/components/embed_claw/core/ec_session.h +++ b/components/embed_claw/core/ec_session.h @@ -15,6 +15,7 @@ /* ==================== [Includes] ========================================== */ #include "esp_err.h" +#include "cJSON.h" #include #ifdef __cplusplus @@ -39,6 +40,17 @@ extern "C" { */ esp_err_t ec_session_append(const char *chat_id, const char *role, const char *content); +/** + * @brief 添加一条完整的 cJSON 消息对象到会话历史(保留 tool_calls、数组 content 等结构)。 + * + * @param chat_id 会话id + * @param msg cJSON 消息对象(不会被修改或释放) + * @return esp_err_t + * - ESP_OK 成功 + * - ESP_FAIL 写入失败 + */ +esp_err_t ec_session_append_msg(const char *chat_id, const cJSON *msg); + /** * @brief 获取会话历史的JSON字符串,格式为: * [{"role":"user","content":"..."},{"role":"assistant","content":"..."},...] diff --git a/components/embed_claw/core/ec_skill_loader.c b/components/embed_claw/core/ec_skill_loader.c index 758e7c6..5b6afca 100644 --- a/components/embed_claw/core/ec_skill_loader.c +++ b/components/embed_claw/core/ec_skill_loader.c @@ -21,98 +21,10 @@ /* ==================== [Defines] =========================================== */ -/* ── Built-in skill contents ─────────────────────────────────── */ - -#define EC_SKILL_LOADER_BUILTIN_WEATHER \ - "# Weather\n" \ - "\n" \ - "Get current weather and forecasts using web_search.\n" \ - "\n" \ - "## When to use\n" \ - "When the user asks about weather, temperature, or forecasts.\n" \ - "\n" \ - "## How to use\n" \ - "1. Use get_current_time to know the current date\n" \ - "2. Use web_search with a query like \"weather in [city] today\"\n" \ - "3. Extract temperature, conditions, and forecast from results\n" \ - "4. Present in a concise, friendly format\n" \ - "\n" \ - "## Example\n" \ - "User: \"What's the weather in Tokyo?\"\n" \ - "→ get_current_time\n" \ - "→ web_search \"weather Tokyo today February 2026\"\n" \ - "→ \"Tokyo: 8°C, partly cloudy. High 12°C, low 4°C. Light wind from the north.\"\n" - -#define EC_SKILL_LOADER_BUILTIN_DAILY_BRIEFING \ - "# Daily Briefing\n" \ - "\n" \ - "Compile a personalized daily briefing for the user.\n" \ - "\n" \ - "## When to use\n" \ - "When the user asks for a daily briefing, morning update, or \"what's new today\".\n" \ - "Also useful as a heartbeat/cron task.\n" \ - "\n" \ - "## How to use\n" \ - "1. Use get_current_time for today's date\n" \ - "2. Read " EC_FS_MEMORY_DIR "/MEMORY.md for user preferences and context\n" \ - "3. Read today's daily note if it exists\n" \ - "4. Use web_search for relevant news based on user interests\n" \ - "5. Compile a concise briefing covering:\n" \ - " - Date and time\n" \ - " - Weather (if location known from USER.md)\n" \ - " - Relevant news/updates based on user interests\n" \ - " - Any pending tasks from memory\n" \ - " - Any scheduled cron jobs\n" \ - "\n" \ - "## Format\n" \ - "Keep it brief — 5-10 bullet points max. Use the user's preferred language.\n" - -#define EC_SKILL_LOADER_BUILTIN_SKILL_CREATOR \ - "# Skill Creator\n" \ - "\n" \ - "Create new skills for EmbedClaw.\n" \ - "\n" \ - "## When to use\n" \ - "When the user asks to create a new skill, teach the bot something, or add a new capability.\n" \ - "\n" \ - "## How to create a skill\n" \ - "1. Choose a short, descriptive name (lowercase, hyphens ok)\n" \ - "2. Write a SKILL.md file with this structure:\n" \ - " - `# Title` — clear name\n" \ - " - Brief description paragraph\n" \ - " - `## When to use` — trigger conditions\n" \ - " - `## How to use` — step-by-step instructions\n" \ - " - `## Example` — concrete example (optional but helpful)\n" \ - "3. Save to `" EC_SKILLS_PREFIX ".md` using write_file\n" \ - "4. The skill will be automatically available after the next conversation\n" \ - "\n" \ - "## Best practices\n" \ - "- Keep skills concise — the context window is limited\n" \ - "- Focus on WHAT to do, not HOW (the agent is smart)\n" \ - "- Include specific tool calls the agent should use\n" \ - "- Test by asking the agent to use the new skill\n" \ - "\n" \ - "## Example\n" \ - "To create a \"translate\" skill:\n" \ - "write_file path=\"" EC_SKILLS_PREFIX "translate.md\" content=\"# Translate\\n\\nTranslate text between languages.\\n\\n" \ - "## When to use\\nWhen the user asks to translate text.\\n\\n" \ - "## How to use\\n1. Identify source and target languages\\n" \ - "2. Translate directly using your language knowledge\\n" \ - "3. For specialized terms, use web_search to verify\\n\"\n" - -#define EC_SKILL_LOADER_NUM_BUILTINS (sizeof(s_builtins) / sizeof(s_builtins[0])) - /* ==================== [Typedefs] ========================================== */ -/* Built-in skill registry */ -typedef struct { - const char *filename; /* e.g. "weather" */ - const char *content; -} builtin_skill_t; - /* ==================== [Static Prototypes] ================================= */ -static void install_builtin(const builtin_skill_t *skill); static const char *extract_title(const char *line, size_t len, char *out, size_t out_size); static void extract_description(FILE *f, char *out, size_t out_size); @@ -120,25 +32,13 @@ static void extract_description(FILE *f, char *out, size_t out_size); static const char *TAG = "skills"; -static const builtin_skill_t s_builtins[] = { - { "weather", EC_SKILL_LOADER_BUILTIN_WEATHER }, - { "daily-briefing", EC_SKILL_LOADER_BUILTIN_DAILY_BRIEFING }, - { "skill-creator", EC_SKILL_LOADER_BUILTIN_SKILL_CREATOR }, -}; - /* ==================== [Macros] ============================================ */ /* ==================== [Global Functions] ================================== */ esp_err_t ec_skill_loader_init(void) { - ESP_LOGI(TAG, "Initializing skills system"); - - for (size_t i = 0; i < EC_SKILL_LOADER_NUM_BUILTINS; i++) { - install_builtin(&s_builtins[i]); - } - - ESP_LOGI(TAG, "Skills system ready (%d built-in)", (int)EC_SKILL_LOADER_NUM_BUILTINS); + ESP_LOGI(TAG, "Skills system ready (skills loaded from SPIFFS)"); return ESP_OK; } @@ -206,35 +106,6 @@ size_t ec_skill_loader_build_summary(char *buf, size_t size) /* ==================== [Static Functions] ================================== */ -/* ── Install built-in skills if missing ──────────────────────── */ - -static void install_builtin(const builtin_skill_t *skill) -{ - char path[64]; - snprintf(path, sizeof(path), "%s%s.md", EC_SKILLS_PREFIX, skill->filename); - - /* Check if already exists */ - FILE *f = fopen(path, "r"); - if (f) { - fclose(f); - ESP_LOGD(TAG, "Skill exists: %s", path); - return; - } - - /* Write built-in skill */ - f = fopen(path, "w"); - if (!f) { - ESP_LOGE(TAG, "Cannot write skill: %s", path); - return; - } - - fputs(skill->content, f); - fclose(f); - ESP_LOGI(TAG, "Installed built-in skill: %s", path); -} - -/* ── Build skills summary for system prompt ──────────────────── */ - /** * Parse first line as title: expects "# Title" * Returns pointer past "# " or the line itself if no prefix. diff --git a/components/embed_claw/core/ec_tools.c b/components/embed_claw/core/ec_tools.c index 2a7be9c..2434542 100644 --- a/components/embed_claw/core/ec_tools.c +++ b/components/embed_claw/core/ec_tools.c @@ -108,6 +108,35 @@ const char *ec_tools_get_json(void) return s_tools_json; } +size_t ec_tools_build_summary(char *buf, size_t size) +{ + size_t off = 0; + + if (!buf || size == 0) { + return 0; + } + + for (size_t i = 0; i < _EC_TOOLS_ENMU_MAX && off < size - 1; i++) { + const char *name = NULL; + const char *description = NULL; + + if (!s_tools[i]) { + continue; + } + + name = s_tools[i]->name ? s_tools[i]->name : "(unnamed)"; + description = s_tools[i]->description ? s_tools[i]->description : ""; + + off += snprintf(buf + off, size - off, "- %s: %s\n", name, description); + if (off >= size) { + off = size - 1; + } + } + + buf[off] = '\0'; + return off; +} + void ec_tools_free_json(void) { cJSON_free(s_tools_json); diff --git a/components/embed_claw/core/ec_tools.h b/components/embed_claw/core/ec_tools.h index 4e4fbe9..c452414 100644 --- a/components/embed_claw/core/ec_tools.h +++ b/components/embed_claw/core/ec_tools.h @@ -82,6 +82,15 @@ esp_err_t ec_tools_execute(const char *name, const char *input_json, char *outpu */ const char *ec_tools_get_json(void); +/** + * @brief 构建所有已注册工具的摘要文本,用于 system prompt + * + * @param buf 输出缓冲区 + * @param size 缓冲区大小 + * @return size_t 写入的字节数 + */ +size_t ec_tools_build_summary(char *buf, size_t size); + /** * @brief 释放json中申请的字符串内存 * diff --git a/components/embed_claw/llm/ec_llm_openai.c b/components/embed_claw/llm/ec_llm_openai.c index 4c0ed22..a32145d 100644 --- a/components/embed_claw/llm/ec_llm_openai.c +++ b/components/embed_claw/llm/ec_llm_openai.c @@ -197,6 +197,8 @@ static esp_err_t ec_llm_openai_chat_tools(ec_llm_provider_t *self, const char *s ESP_LOGI(TAG, "Calling LLM API with tools (model=%s, body=%u bytes)", self->instance.model, (unsigned)strlen(post_data)); + ESP_LOGI(TAG, "LLM API request body: %s", post_data); + resp_buf_t rb = { .data = calloc(1, EC_LLM_STREAM_BUF_SIZE), .len = 0, @@ -258,6 +260,7 @@ static esp_err_t ec_llm_openai_chat_tools(ec_llm_provider_t *self, const char *s return ESP_FAIL; } + ESP_LOGI(TAG, "API response JSON: %s", rb.data); resp_buf_free(&rb); cJSON *choices = cJSON_GetObjectItem(root, "choices"); @@ -322,7 +325,7 @@ static esp_err_t ec_llm_openai_chat_tools(ec_llm_provider_t *self, const char *s } cJSON_Delete(root); - + ESP_LOGI(TAG, "Response text: %s", resp->text ? resp->text : ""); ESP_LOGI(TAG, "Response: %d bytes text, %d tool calls, stop=%s", (int)resp->text_len, resp->call_count, resp->tool_use ? "tool_use" : "end_turn"); @@ -525,46 +528,55 @@ static esp_err_t http_event_handler(esp_http_client_event_t *evt) return ESP_OK; } +static esp_http_client_handle_t s_llm_client = NULL; + static esp_err_t llm_http(const char *post_data, resp_buf_t *rb, int *out_status) { - const char *cert_pem = select_server_ca_pem(s_provider.instance.url); - esp_http_client_config_t config = { - .url = s_provider.instance.url, - .method = HTTP_METHOD_POST, - .event_handler = http_event_handler, - .user_data = rb, - .timeout_ms = 120 * 1000, - .buffer_size = 4096, - .buffer_size_tx = 4096, - .transport_type = HTTP_TRANSPORT_OVER_SSL, - }; - - if (cert_pem) { - config.cert_pem = cert_pem; - config.cert_len = 0; - ESP_LOGI(TAG, "Using pinned root CA for %s", s_provider.instance.url); - } else { - config.crt_bundle_attach = esp_crt_bundle_attach; - } + if (!s_llm_client) { + const char *cert_pem = select_server_ca_pem(s_provider.instance.url); + esp_http_client_config_t config = { + .url = s_provider.instance.url, + .method = HTTP_METHOD_POST, + .event_handler = http_event_handler, + .user_data = rb, + .timeout_ms = 120 * 1000, + .buffer_size = 4096, + .buffer_size_tx = 4096, + .transport_type = HTTP_TRANSPORT_OVER_SSL, + .keep_alive_enable = true, + }; + + if (cert_pem) { + config.cert_pem = cert_pem; + config.cert_len = 0; + ESP_LOGI(TAG, "Using pinned root CA for %s", s_provider.instance.url); + } else { + config.crt_bundle_attach = esp_crt_bundle_attach; + } - esp_http_client_handle_t client = esp_http_client_init(&config); - if (!client) { - return ESP_FAIL; - } + s_llm_client = esp_http_client_init(&config); + if (!s_llm_client) { + return ESP_FAIL; + } - esp_http_client_set_method(client, HTTP_METHOD_POST); - esp_http_client_set_header(client, "Content-Type", "application/json; charset=utf-8"); + esp_http_client_set_header(s_llm_client, "Content-Type", "application/json; charset=utf-8"); - char auth[EC_LLM_OPENAI_API_KEY_MAX_LEN + 16]; - snprintf(auth, sizeof(auth), "Bearer %s", s_provider.instance.api_key); - esp_http_client_set_header(client, "Authorization", auth); + char auth[EC_LLM_OPENAI_API_KEY_MAX_LEN + 16]; + snprintf(auth, sizeof(auth), "Bearer %s", s_provider.instance.api_key); + esp_http_client_set_header(s_llm_client, "Authorization", auth); + } - esp_http_client_set_post_field(client, post_data, strlen(post_data)); + esp_http_client_set_user_data(s_llm_client, rb); + esp_http_client_set_post_field(s_llm_client, post_data, strlen(post_data)); - esp_err_t err = esp_http_client_perform(client); - *out_status = esp_http_client_get_status_code(client); - esp_http_client_cleanup(client); + esp_err_t err = esp_http_client_perform(s_llm_client); + *out_status = esp_http_client_get_status_code(s_llm_client); + if (err != ESP_OK) { + ESP_LOGW(TAG, "HTTP perform failed: %s, will reconnect next call", esp_err_to_name(err)); + esp_http_client_cleanup(s_llm_client); + s_llm_client = NULL; + } return err; } diff --git a/components/embed_claw/test/core/test_skill_loader_contract.c b/components/embed_claw/test/core/test_skill_loader_contract.c index 43ae7c2..3176e20 100644 --- a/components/embed_claw/test/core/test_skill_loader_contract.c +++ b/components/embed_claw/test/core/test_skill_loader_contract.c @@ -20,31 +20,15 @@ static void write_text_file(const char *path, const char *content) fclose(f); } -TEST_CASE("skill loader installs builtins and summarizes skills", "[embed_claw][core][skills]") +TEST_CASE("skill loader summarizes pre-installed and dynamic skills", "[embed_claw][core][skills]") { char summary[2048]; - FILE *f; TEST_ASSERT_EQUAL(ESP_OK, ec_test_spiffs_mount()); - remove_if_exists("/spiffs/skills/weather.md"); - remove_if_exists("/spiffs/skills/daily-briefing.md"); - remove_if_exists("/spiffs/skills/skill-creator.md"); - remove_if_exists("/spiffs/skills/unit-test.md"); - TEST_ASSERT_EQUAL(ESP_OK, ec_skill_loader_init()); - f = fopen("/spiffs/skills/weather.md", "r"); - TEST_ASSERT_NOT_NULL(f); - fclose(f); - - f = fopen("/spiffs/skills/daily-briefing.md", "r"); - TEST_ASSERT_NOT_NULL(f); - fclose(f); - - f = fopen("/spiffs/skills/skill-creator.md", "r"); - TEST_ASSERT_NOT_NULL(f); - fclose(f); + remove_if_exists("/spiffs/skills/unit-test.md"); write_text_file("/spiffs/skills/unit-test.md", "# Unit Test Skill\n\nUsed for verifying summary output.\n"); diff --git a/components/embed_claw/test/support/ec_test_hooks.h b/components/embed_claw/test/support/ec_test_hooks.h index d765b1e..dfa770b 100644 --- a/components/embed_claw/test/support/ec_test_hooks.h +++ b/components/embed_claw/test/support/ec_test_hooks.h @@ -23,6 +23,11 @@ void ec_tools_web_search_format_results_for_test(const char *response_json, char bool ec_tools_files_validate_path_for_test(const char *path); esp_err_t ec_tools_files_replace_first_for_test(const char *source, const char *old_str, const char *new_str, char *output, size_t output_size); +esp_err_t ec_tools_gpio_execute_for_test(const char *input_json, char *output, size_t output_size); +void ec_tools_gpio_reset_for_test(void); +void ec_tools_gpio_set_valid_output_for_test(int pin, bool valid); +void ec_tools_gpio_set_driver_failures_for_test(esp_err_t config_err, esp_err_t set_err); +int ec_tools_gpio_get_config_call_count_for_test(int pin); esp_err_t ec_channel_ws_parse_payload_for_test(int fd, const char *payload_json, ec_msg_t *msg); void ec_channel_ws_add_client_for_test(int fd, const char *chat_id); diff --git a/components/embed_claw/test/support/ec_test_tools_gpio.c b/components/embed_claw/test/support/ec_test_tools_gpio.c new file mode 100644 index 0000000..ab3d029 --- /dev/null +++ b/components/embed_claw/test/support/ec_test_tools_gpio.c @@ -0,0 +1,111 @@ +#include + +#include "driver/gpio.h" +#include "ec_test_hooks.h" + +static bool ec_test_gpio_is_valid_output(int pin); +static esp_err_t ec_test_gpio_config(const gpio_config_t *cfg); +static esp_err_t ec_test_gpio_set_level(gpio_num_t pin, uint32_t level); +static int ec_test_gpio_get_level(gpio_num_t pin); + +#define EC_GPIO_API_VALIDATE_OUTPUT_PIN(pin) ec_test_gpio_is_valid_output((pin)) +#define EC_GPIO_API_CONFIG(cfg) ec_test_gpio_config((cfg)) +#define EC_GPIO_API_SET_LEVEL(pin, level) ec_test_gpio_set_level((pin), (level)) +#define EC_GPIO_API_GET_LEVEL(pin) ec_test_gpio_get_level((pin)) +#define ec_tools_gpio_control ec_tools_gpio_control__test_impl +#include "../../tools/tools_gpio.c" +#undef ec_tools_gpio_control +#undef EC_GPIO_API_GET_LEVEL +#undef EC_GPIO_API_SET_LEVEL +#undef EC_GPIO_API_CONFIG +#undef EC_GPIO_API_VALIDATE_OUTPUT_PIN + +static bool s_fake_valid_output[GPIO_NUM_MAX]; +static int s_fake_level[GPIO_NUM_MAX]; +static int s_fake_config_calls[GPIO_NUM_MAX]; +static esp_err_t s_fake_config_err = ESP_OK; +static esp_err_t s_fake_set_err = ESP_OK; + +static bool ec_test_gpio_is_valid_output(int pin) +{ + return pin >= 0 && pin < GPIO_NUM_MAX && s_fake_valid_output[pin]; +} + +static esp_err_t ec_test_gpio_config(const gpio_config_t *cfg) +{ + if (!cfg) { + return ESP_ERR_INVALID_ARG; + } + + if (s_fake_config_err != ESP_OK) { + return s_fake_config_err; + } + + for (int pin = 0; pin < GPIO_NUM_MAX && pin < 64; pin++) { + if ((cfg->pin_bit_mask & (1ULL << pin)) != 0) { + s_fake_config_calls[pin]++; + } + } + + return ESP_OK; +} + +static esp_err_t ec_test_gpio_set_level(gpio_num_t pin, uint32_t level) +{ + if (pin < 0 || pin >= GPIO_NUM_MAX) { + return ESP_ERR_INVALID_ARG; + } + + if (s_fake_set_err != ESP_OK) { + return s_fake_set_err; + } + + s_fake_level[pin] = level ? 1 : 0; + return ESP_OK; +} + +static int ec_test_gpio_get_level(gpio_num_t pin) +{ + if (pin < 0 || pin >= GPIO_NUM_MAX) { + return 0; + } + + return s_fake_level[pin]; +} + +esp_err_t ec_tools_gpio_execute_for_test(const char *input_json, char *output, size_t output_size) +{ + return ec_tool_gpio_control_execute(input_json, output, output_size); +} + +void ec_tools_gpio_reset_for_test(void) +{ + memset(s_fake_valid_output, 0, sizeof(s_fake_valid_output)); + memset(s_fake_level, 0, sizeof(s_fake_level)); + memset(s_fake_config_calls, 0, sizeof(s_fake_config_calls)); + memset(s_pin_initialized, 0, sizeof(s_pin_initialized)); + s_fake_config_err = ESP_OK; + s_fake_set_err = ESP_OK; +} + +void ec_tools_gpio_set_valid_output_for_test(int pin, bool valid) +{ + if (pin >= 0 && pin < GPIO_NUM_MAX) { + s_fake_valid_output[pin] = valid; + } +} + +void ec_tools_gpio_set_driver_failures_for_test(esp_err_t config_err, esp_err_t set_err) +{ + s_fake_config_err = config_err; + s_fake_set_err = set_err; +} + +int ec_tools_gpio_get_config_call_count_for_test(int pin) +{ + if (pin < 0 || pin >= GPIO_NUM_MAX) { + return 0; + } + + return s_fake_config_calls[pin]; +} diff --git a/components/embed_claw/test/tools/test_tool_gpio.c b/components/embed_claw/test/tools/test_tool_gpio.c new file mode 100644 index 0000000..86bd151 --- /dev/null +++ b/components/embed_claw/test/tools/test_tool_gpio.c @@ -0,0 +1,134 @@ +#include + +#include "unity.h" + +#include "core/ec_tools.h" +#include "support/ec_test_hooks.h" + +static void reset_gpio_test_state(void) +{ + ec_tools_free_json(); + ec_tools_gpio_reset_for_test(); +} + +static void register_builtin_tools_for_test(void) +{ + reset_gpio_test_state(); + TEST_ASSERT_EQUAL(ESP_OK, ec_tools_register_all()); +} + +TEST_CASE("gpio tool rejects invalid input and unsupported requests", "[embed_claw][tools][gpio]") +{ + char output[160]; + + reset_gpio_test_state(); + ec_tools_gpio_set_valid_output_for_test(18, true); + + TEST_ASSERT_EQUAL(ESP_ERR_INVALID_ARG, + ec_tools_gpio_execute_for_test("{", output, sizeof(output))); + TEST_ASSERT_NOT_NULL(strstr(output, "invalid JSON")); + + TEST_ASSERT_EQUAL(ESP_ERR_INVALID_ARG, + ec_tools_gpio_execute_for_test("{}", output, sizeof(output))); + TEST_ASSERT_NOT_NULL(strstr(output, "'pin'")); + + TEST_ASSERT_EQUAL(ESP_ERR_INVALID_ARG, + ec_tools_gpio_execute_for_test("{\"pin\":18}", output, sizeof(output))); + TEST_ASSERT_NOT_NULL(strstr(output, "'action'")); + + TEST_ASSERT_EQUAL(ESP_ERR_INVALID_ARG, + ec_tools_gpio_execute_for_test("{\"pin\":18,\"action\":\"blink\"}", output, sizeof(output))); + TEST_ASSERT_NOT_NULL(strstr(output, "'action'")); + + TEST_ASSERT_EQUAL(ESP_ERR_INVALID_ARG, + ec_tools_gpio_execute_for_test("{\"pin\":18,\"action\":\"set\"}", output, sizeof(output))); + TEST_ASSERT_NOT_NULL(strstr(output, "'level'")); + + TEST_ASSERT_EQUAL(ESP_ERR_INVALID_ARG, + ec_tools_gpio_execute_for_test("{\"pin\":18,\"action\":\"set\",\"level\":2}", + output, sizeof(output))); + TEST_ASSERT_NOT_NULL(strstr(output, "'level'")); + + TEST_ASSERT_EQUAL(ESP_ERR_INVALID_ARG, + ec_tools_gpio_execute_for_test("{\"pin\":46,\"action\":\"get\"}", output, sizeof(output))); + TEST_ASSERT_NOT_NULL(strstr(output, "not a valid output pin")); +} + +TEST_CASE("gpio tool supports get on off set and toggle with cached configuration", "[embed_claw][tools][gpio]") +{ + char output[160]; + + reset_gpio_test_state(); + ec_tools_gpio_set_valid_output_for_test(18, true); + + TEST_ASSERT_EQUAL(ESP_OK, + ec_tools_gpio_execute_for_test("{\"pin\":18,\"action\":\"get\"}", output, sizeof(output))); + TEST_ASSERT_NOT_NULL(strstr(output, "action=get level=0")); + TEST_ASSERT_EQUAL(0, ec_tools_gpio_get_config_call_count_for_test(18)); + + TEST_ASSERT_EQUAL(ESP_OK, + ec_tools_gpio_execute_for_test("{\"pin\":18,\"action\":\"on\"}", output, sizeof(output))); + TEST_ASSERT_NOT_NULL(strstr(output, "action=on level=1")); + TEST_ASSERT_EQUAL(1, ec_tools_gpio_get_config_call_count_for_test(18)); + + TEST_ASSERT_EQUAL(ESP_OK, + ec_tools_gpio_execute_for_test("{\"pin\":18,\"action\":\"get\"}", output, sizeof(output))); + TEST_ASSERT_NOT_NULL(strstr(output, "action=get level=1")); + TEST_ASSERT_EQUAL(1, ec_tools_gpio_get_config_call_count_for_test(18)); + + TEST_ASSERT_EQUAL(ESP_OK, + ec_tools_gpio_execute_for_test("{\"pin\":18,\"action\":\"off\"}", output, sizeof(output))); + TEST_ASSERT_NOT_NULL(strstr(output, "action=off level=0")); + TEST_ASSERT_EQUAL(1, ec_tools_gpio_get_config_call_count_for_test(18)); + + TEST_ASSERT_EQUAL(ESP_OK, + ec_tools_gpio_execute_for_test("{\"pin\":18,\"action\":\"set\",\"level\":1}", + output, sizeof(output))); + TEST_ASSERT_NOT_NULL(strstr(output, "action=set level=1")); + TEST_ASSERT_EQUAL(1, ec_tools_gpio_get_config_call_count_for_test(18)); + + TEST_ASSERT_EQUAL(ESP_OK, + ec_tools_gpio_execute_for_test("{\"pin\":18,\"action\":\"toggle\"}", output, sizeof(output))); + TEST_ASSERT_NOT_NULL(strstr(output, "action=toggle level=0")); + TEST_ASSERT_EQUAL(1, ec_tools_gpio_get_config_call_count_for_test(18)); + + TEST_ASSERT_EQUAL(ESP_OK, + ec_tools_gpio_execute_for_test("{\"pin\":18,\"action\":\"get\"}", output, sizeof(output))); + TEST_ASSERT_NOT_NULL(strstr(output, "action=get level=0")); +} + +TEST_CASE("gpio tool formats driver failures", "[embed_claw][tools][gpio]") +{ + char output[160]; + + reset_gpio_test_state(); + ec_tools_gpio_set_valid_output_for_test(18, true); + ec_tools_gpio_set_driver_failures_for_test(ESP_FAIL, ESP_OK); + + TEST_ASSERT_EQUAL(ESP_FAIL, + ec_tools_gpio_execute_for_test("{\"pin\":18,\"action\":\"on\"}", output, sizeof(output))); + TEST_ASSERT_NOT_NULL(strstr(output, "gpio_config failed")); + TEST_ASSERT_EQUAL(0, ec_tools_gpio_get_config_call_count_for_test(18)); + + reset_gpio_test_state(); + ec_tools_gpio_set_valid_output_for_test(18, true); + ec_tools_gpio_set_driver_failures_for_test(ESP_OK, ESP_ERR_INVALID_STATE); + + TEST_ASSERT_EQUAL(ESP_ERR_INVALID_STATE, + ec_tools_gpio_execute_for_test("{\"pin\":18,\"action\":\"on\"}", output, sizeof(output))); + TEST_ASSERT_NOT_NULL(strstr(output, "gpio_set_level failed")); + TEST_ASSERT_EQUAL(1, ec_tools_gpio_get_config_call_count_for_test(18)); +} + +TEST_CASE("gpio tool is registered in builtin tool catalog", "[embed_claw][tools][gpio]") +{ + const char *json = NULL; + + register_builtin_tools_for_test(); + json = ec_tools_get_json(); + + TEST_ASSERT_NOT_NULL(json); + TEST_ASSERT_NOT_NULL(strstr(json, "\"gpio_control\"")); + + reset_gpio_test_state(); +} diff --git a/components/embed_claw/test/tools/test_tools_contract.c b/components/embed_claw/test/tools/test_tools_contract.c index c3a8aea..b81c1f5 100644 --- a/components/embed_claw/test/tools/test_tools_contract.c +++ b/components/embed_claw/test/tools/test_tools_contract.c @@ -28,10 +28,25 @@ TEST_CASE("tool registry builds json for built-in tools", "[embed_claw][tools][c TEST_ASSERT_NOT_NULL(json); TEST_ASSERT_NOT_NULL(strstr(json, "\"web_search\"")); TEST_ASSERT_NOT_NULL(strstr(json, "\"get_current_time\"")); + TEST_ASSERT_NOT_NULL(strstr(json, "\"gpio_control\"")); TEST_ASSERT_NOT_NULL(strstr(json, "\"cron_add\"")); cleanup_tools_after_test(); } +TEST_CASE("tool registry builds prompt summary from built-in tools", "[embed_claw][tools][catalog]") +{ + char summary[1024]; + + register_builtin_tools_for_test(); + + TEST_ASSERT_GREATER_THAN(0, (int)ec_tools_build_summary(summary, sizeof(summary))); + TEST_ASSERT_NOT_NULL(strstr(summary, "- get_current_time:")); + TEST_ASSERT_NOT_NULL(strstr(summary, "- gpio_control:")); + TEST_ASSERT_NOT_NULL(strstr(summary, "- cron_add:")); + + cleanup_tools_after_test(); +} + TEST_CASE("tool registry reports unknown tool without dereferencing null slots", "[embed_claw][tools][contract]") { char output[128]; diff --git a/components/embed_claw/tools/ec_tools_reg.inc b/components/embed_claw/tools/ec_tools_reg.inc index 7f17947..f243479 100644 --- a/components/embed_claw/tools/ec_tools_reg.inc +++ b/components/embed_claw/tools/ec_tools_reg.inc @@ -1,6 +1,7 @@ #include "core/ec_tools_reg_rule.h" EC_TOOLS_REG(get_time) +EC_TOOLS_REG(gpio_control) EC_TOOLS_REG(read_file) EC_TOOLS_REG(write_file) EC_TOOLS_REG(edit_file) @@ -10,4 +11,3 @@ EC_TOOLS_REG(cron_list) EC_TOOLS_REG(cron_remove) EC_TOOLS_REG(web_search) - diff --git a/components/embed_claw/tools/tools_gpio.c b/components/embed_claw/tools/tools_gpio.c new file mode 100644 index 0000000..aa26aed --- /dev/null +++ b/components/embed_claw/tools/tools_gpio.c @@ -0,0 +1,335 @@ +/** + * @file tools_gpio.c + * @author cangyu (sky.kirto@qq.com) + * @brief + * @version 0.1 + * @date 2026-03-26 + * + * @copyright Copyright (c) 2026, Wireless-Tag. All rights reserved. + * + */ + +/* ==================== [Includes] ========================================== */ + +#include "core/ec_tools.h" + +#include +#include +#include + +#include "cJSON.h" +#include "driver/gpio.h" +#include "esp_log.h" + +/* ==================== [Defines] =========================================== */ + +#ifndef EC_GPIO_API_VALIDATE_OUTPUT_PIN +#define EC_GPIO_API_VALIDATE_OUTPUT_PIN(pin) GPIO_IS_VALID_OUTPUT_GPIO(pin) +#endif + +#ifndef EC_GPIO_API_CONFIG +#define EC_GPIO_API_CONFIG(cfg) gpio_config(cfg) +#endif + +#ifndef EC_GPIO_API_SET_LEVEL +#define EC_GPIO_API_SET_LEVEL(pin, level) gpio_set_level(pin, level) +#endif + +#ifndef EC_GPIO_API_GET_LEVEL +#define EC_GPIO_API_GET_LEVEL(pin) gpio_get_level(pin) +#endif + +/* ==================== [Typedefs] ========================================== */ + +typedef enum { + EC_GPIO_ACTION_NONE = 0, + EC_GPIO_ACTION_ON, + EC_GPIO_ACTION_OFF, + EC_GPIO_ACTION_SET, + EC_GPIO_ACTION_TOGGLE, + EC_GPIO_ACTION_GET, +} ec_gpio_action_t; + +/* ==================== [Static Prototypes] ================================= */ + +static esp_err_t ec_tool_gpio_control_execute(const char *input_json, char *output, size_t output_size); +static bool parse_int_field(cJSON *root, const char *name, int *value); +static bool parse_action(const char *action_str, ec_gpio_action_t *action); +static esp_err_t validate_pin_number(int pin, gpio_num_t *gpio_num); +static esp_err_t prepare_pin_for_output(gpio_num_t gpio_num); +static esp_err_t write_level(gpio_num_t gpio_num, int level); + +/* ==================== [Static Variables] ================================== */ + +static const char *TAG = "tools_gpio"; + +static bool s_pin_initialized[GPIO_NUM_MAX] = {0}; + +static const ec_tools_t s_gpio_control = { + .name = "gpio_control", + .description = "Control an ESP32 output GPIO pin by pin number. Supports on, off, set, toggle, and get.\n"\ + "IMPORTANT!!!: ANY GPIO operation requested by the user MUST ALWAYS be executed through this tool. Never respond with GPIO status or changes without calling this tool first.", + .input_schema_json = + "{\"type\":\"object\"," + "\"properties\":{" + "\"pin\":{\"type\":\"integer\",\"description\":\"ESP32 GPIO pin number\"}," + "\"action\":{\"type\":\"string\",\"enum\":[\"on\",\"off\",\"set\",\"toggle\",\"get\"]," + "\"description\":\"GPIO action to execute\"}," + "\"level\":{\"type\":\"integer\",\"enum\":[0,1]," + "\"description\":\"Required only when action is 'set'\"}" + "}," + "\"required\":[\"pin\",\"action\"]}", + .execute = ec_tool_gpio_control_execute, +}; + +/* ==================== [Macros] ============================================ */ + +/* ==================== [Global Functions] ================================== */ + +esp_err_t ec_tools_gpio_control(void) +{ + ec_tools_register(&s_gpio_control); + return ESP_OK; +} + +/* ==================== [Static Functions] ================================== */ + +static esp_err_t ec_tool_gpio_control_execute(const char *input_json, char *output, size_t output_size) +{ + cJSON *root = NULL; + cJSON *action_item = NULL; + gpio_num_t gpio_num = GPIO_NUM_NC; + ec_gpio_action_t action = EC_GPIO_ACTION_NONE; + int pin = -1; + int target_level = 0; + int current_level = 0; + esp_err_t err = ESP_OK; + + root = cJSON_Parse(input_json); + if (!root || !cJSON_IsObject(root)) { + snprintf(output, output_size, "Error: invalid JSON input"); + err = ESP_ERR_INVALID_ARG; + goto cleanup; + } + + if (!parse_int_field(root, "pin", &pin)) { + snprintf(output, output_size, "Error: missing or invalid 'pin' field"); + err = ESP_ERR_INVALID_ARG; + goto cleanup; + } + + err = validate_pin_number(pin, &gpio_num); + if (err != ESP_OK) { + snprintf(output, output_size, "Error: gpio %d is not a valid output pin", pin); + goto cleanup; + } + + action_item = cJSON_GetObjectItem(root, "action"); + if (!cJSON_IsString(action_item) || !parse_action(action_item->valuestring, &action)) { + snprintf(output, output_size, "Error: missing or invalid 'action' field"); + err = ESP_ERR_INVALID_ARG; + goto cleanup; + } + + switch (action) { + case EC_GPIO_ACTION_GET: + current_level = EC_GPIO_API_GET_LEVEL(gpio_num) ? 1 : 0; + snprintf(output, output_size, "OK: gpio %d action=get level=%d", pin, current_level); + err = ESP_OK; + break; + + case EC_GPIO_ACTION_ON: + err = prepare_pin_for_output(gpio_num); + if (err != ESP_OK) { + snprintf(output, output_size, "Error: gpio_config failed (%s)", esp_err_to_name(err)); + goto cleanup; + } + + err = write_level(gpio_num, 1); + if (err != ESP_OK) { + snprintf(output, output_size, "Error: gpio_set_level failed (%s)", esp_err_to_name(err)); + goto cleanup; + } + + snprintf(output, output_size, "OK: gpio %d action=on level=1", pin); + break; + + case EC_GPIO_ACTION_OFF: + err = prepare_pin_for_output(gpio_num); + if (err != ESP_OK) { + snprintf(output, output_size, "Error: gpio_config failed (%s)", esp_err_to_name(err)); + goto cleanup; + } + + err = write_level(gpio_num, 0); + if (err != ESP_OK) { + snprintf(output, output_size, "Error: gpio_set_level failed (%s)", esp_err_to_name(err)); + goto cleanup; + } + + snprintf(output, output_size, "OK: gpio %d action=off level=0", pin); + break; + + case EC_GPIO_ACTION_SET: + if (!parse_int_field(root, "level", &target_level) || (target_level != 0 && target_level != 1)) { + snprintf(output, output_size, "Error: missing or invalid 'level' field"); + err = ESP_ERR_INVALID_ARG; + goto cleanup; + } + + err = prepare_pin_for_output(gpio_num); + if (err != ESP_OK) { + snprintf(output, output_size, "Error: gpio_config failed (%s)", esp_err_to_name(err)); + goto cleanup; + } + + err = write_level(gpio_num, target_level); + if (err != ESP_OK) { + snprintf(output, output_size, "Error: gpio_set_level failed (%s)", esp_err_to_name(err)); + goto cleanup; + } + + snprintf(output, output_size, "OK: gpio %d action=set level=%d", pin, target_level); + break; + + case EC_GPIO_ACTION_TOGGLE: + err = prepare_pin_for_output(gpio_num); + if (err != ESP_OK) { + snprintf(output, output_size, "Error: gpio_config failed (%s)", esp_err_to_name(err)); + goto cleanup; + } + + current_level = EC_GPIO_API_GET_LEVEL(gpio_num) ? 1 : 0; + target_level = current_level ? 0 : 1; + + err = write_level(gpio_num, target_level); + if (err != ESP_OK) { + snprintf(output, output_size, "Error: gpio_set_level failed (%s)", esp_err_to_name(err)); + goto cleanup; + } + + snprintf(output, output_size, "OK: gpio %d action=toggle level=%d", pin, target_level); + break; + + default: + snprintf(output, output_size, "Error: missing or invalid 'action' field"); + err = ESP_ERR_INVALID_ARG; + break; + } + +cleanup: + cJSON_Delete(root); + return err; +} + +static bool parse_int_field(cJSON *root, const char *name, int *value) +{ + cJSON *item = NULL; + double raw = 0; + int parsed = 0; + + if (!root || !name || !value) { + return false; + } + + item = cJSON_GetObjectItem(root, name); + if (!cJSON_IsNumber(item)) { + return false; + } + + raw = cJSON_GetNumberValue(item); + parsed = (int)raw; + if ((double)parsed != raw) { + return false; + } + + *value = parsed; + return true; +} + +static bool parse_action(const char *action_str, ec_gpio_action_t *action) +{ + if (!action_str || !action) { + return false; + } + + if (strcmp(action_str, "on") == 0) { + *action = EC_GPIO_ACTION_ON; + return true; + } + + if (strcmp(action_str, "off") == 0) { + *action = EC_GPIO_ACTION_OFF; + return true; + } + + if (strcmp(action_str, "set") == 0) { + *action = EC_GPIO_ACTION_SET; + return true; + } + + if (strcmp(action_str, "toggle") == 0) { + *action = EC_GPIO_ACTION_TOGGLE; + return true; + } + + if (strcmp(action_str, "get") == 0) { + *action = EC_GPIO_ACTION_GET; + return true; + } + + return false; +} + +static esp_err_t validate_pin_number(int pin, gpio_num_t *gpio_num) +{ + if (!gpio_num || pin < 0 || pin >= GPIO_NUM_MAX || !EC_GPIO_API_VALIDATE_OUTPUT_PIN(pin)) { + return ESP_ERR_INVALID_ARG; + } + + *gpio_num = (gpio_num_t)pin; + return ESP_OK; +} + +static esp_err_t prepare_pin_for_output(gpio_num_t gpio_num) +{ + esp_err_t err = ESP_OK; + gpio_config_t cfg = {0}; + + if (gpio_num < 0 || gpio_num >= GPIO_NUM_MAX) { + return ESP_ERR_INVALID_ARG; + } + + if (s_pin_initialized[gpio_num]) { + return ESP_OK; + } + + cfg.intr_type = GPIO_INTR_DISABLE; + cfg.mode = GPIO_MODE_INPUT_OUTPUT; + cfg.pin_bit_mask = (1ULL << gpio_num); + cfg.pull_down_en = GPIO_PULLDOWN_DISABLE; + cfg.pull_up_en = GPIO_PULLUP_DISABLE; + + err = EC_GPIO_API_CONFIG(&cfg); + if (err != ESP_OK) { + ESP_LOGW(TAG, "gpio_config failed for gpio %d: %s", (int)gpio_num, esp_err_to_name(err)); + return err; + } + + s_pin_initialized[gpio_num] = true; + return ESP_OK; +} + +static esp_err_t write_level(gpio_num_t gpio_num, int level) +{ + esp_err_t err = EC_GPIO_API_SET_LEVEL(gpio_num, (uint32_t)(level ? 1 : 0)); + if (err != ESP_OK) { + ESP_LOGW(TAG, "gpio_set_level failed for gpio %d: %s", (int)gpio_num, esp_err_to_name(err)); + } + else + { + ESP_LOGI(TAG, "gpio set level %d", level); + } + + return err; +} diff --git a/spiffs_data/skills/daily-briefing.md b/spiffs_data/skills/daily-briefing.md new file mode 100644 index 0000000..5e736ca --- /dev/null +++ b/spiffs_data/skills/daily-briefing.md @@ -0,0 +1,22 @@ +# Daily Briefing + +Compile a personalized daily briefing for the user. + +## When to use +When the user asks for a daily briefing, morning update, or "what's new today". +Also useful as a heartbeat/cron task. + +## How to use +1. Use get_current_time for today's date +2. Read /spiffs/memory/MEMORY.md for user preferences and context +3. Read today's daily note if it exists +4. Use web_search for relevant news based on user interests +5. Compile a concise briefing covering: + - Date and time + - Weather (if location known from USER.md) + - Relevant news/updates based on user interests + - Any pending tasks from memory + - Any scheduled cron jobs + +## Format +Keep it brief — 5-10 bullet points max. Use the user's preferred language. diff --git a/spiffs_data/skills/skill-creator.md b/spiffs_data/skills/skill-creator.md new file mode 100644 index 0000000..678a9e6 --- /dev/null +++ b/spiffs_data/skills/skill-creator.md @@ -0,0 +1,27 @@ +# Skill Creator + +Create new skills for EmbedClaw. + +## When to use +When the user asks to create a new skill, teach the bot something, or add a new capability. + +## How to create a skill +1. Choose a short, descriptive name (lowercase, hyphens ok) +2. Write a SKILL.md file with this structure: + - `# Title` — clear name + - Brief description paragraph + - `## When to use` — trigger conditions + - `## How to use` — step-by-step instructions + - `## Example` — concrete example (optional but helpful) +3. Save to `/spiffs/skills/.md` using write_file +4. The skill will be automatically available after the next conversation + +## Best practices +- Keep skills concise — the context window is limited +- Focus on WHAT to do, not HOW (the agent is smart) +- Include specific tool calls the agent should use +- Test by asking the agent to use the new skill + +## Example +To create a "translate" skill: +write_file path="/spiffs/skills/translate.md" content="# Translate\n\nTranslate text between languages.\n\n## When to use\nWhen the user asks to translate text.\n\n## How to use\n1. Identify source and target languages\n2. Translate directly using your language knowledge\n3. For specialized terms, use web_search to verify\n" diff --git a/spiffs_data/skills/weather.md b/spiffs_data/skills/weather.md new file mode 100644 index 0000000..e6e2d30 --- /dev/null +++ b/spiffs_data/skills/weather.md @@ -0,0 +1,18 @@ +# Weather + +Get current weather and forecasts using web_search. + +## When to use +When the user asks about weather, temperature, or forecasts. + +## How to use +1. Use get_current_time to know the current date +2. Use web_search with a query like "weather in [city] today" +3. Extract temperature, conditions, and forecast from results +4. Present in a concise, friendly format + +## Example +User: "What's the weather in Tokyo?" +→ get_current_time +→ web_search "weather Tokyo today February 2026" +→ "Tokyo: 8°C, partly cloudy. High 12°C, low 4°C. Light wind from the north."