diff --git a/main/CMakeLists.txt b/main/CMakeLists.txt index 970d300..f7e302a 100644 --- a/main/CMakeLists.txt +++ b/main/CMakeLists.txt @@ -13,6 +13,8 @@ idf_component_register( "cli/serial_cli.c" "ota/ota_manager.c" "proxy/http_proxy.c" + "tools/tool_registry.c" + "tools/tool_web_search.c" INCLUDE_DIRS "." REQUIRES diff --git a/main/llm/llm_proxy.c b/main/llm/llm_proxy.c index 5b2a212..7983952 100644 --- a/main/llm/llm_proxy.c +++ b/main/llm/llm_proxy.c @@ -7,6 +7,7 @@ #include "esp_log.h" #include "esp_http_client.h" #include "esp_crt_bundle.h" +#include "esp_heap_caps.h" #include "nvs.h" #include "cJSON.h" @@ -15,113 +16,84 @@ static const char *TAG = "llm"; static char s_api_key[128] = {0}; static char s_model[64] = MIMI_LLM_DEFAULT_MODEL; -/* Streaming response accumulator */ +/* ── Response buffer ──────────────────────────────────────────── */ + typedef struct { - char *buf; + char *data; size_t len; size_t cap; - /* SSE line buffer for partial lines */ - char line_buf[1024]; - size_t line_len; - /* Accumulated response text */ - char *response; - size_t resp_len; - size_t resp_cap; -} sse_ctx_t; +} resp_buf_t; -static void sse_process_line(sse_ctx_t *ctx, const char *line) +static esp_err_t resp_buf_init(resp_buf_t *rb, size_t initial_cap) { - /* SSE format: "data: {...}" */ - if (strncmp(line, "data: ", 6) != 0) return; - const char *json_str = line + 6; - - /* Check for stream end */ - if (strcmp(json_str, "[DONE]") == 0) return; - - cJSON *root = cJSON_Parse(json_str); - if (!root) return; - - cJSON *type = cJSON_GetObjectItem(root, "type"); - if (!type || !cJSON_IsString(type)) { - cJSON_Delete(root); - return; - } - - if (strcmp(type->valuestring, "content_block_delta") == 0) { - cJSON *delta = cJSON_GetObjectItem(root, "delta"); - if (delta) { - cJSON *delta_type = cJSON_GetObjectItem(delta, "type"); - if (delta_type && strcmp(delta_type->valuestring, "text_delta") == 0) { - cJSON *text = cJSON_GetObjectItem(delta, "text"); - if (text && cJSON_IsString(text)) { - size_t tlen = strlen(text->valuestring); - /* Grow response buffer if needed */ - while (ctx->resp_len + tlen >= ctx->resp_cap) { - size_t new_cap = ctx->resp_cap * 2; - char *tmp = realloc(ctx->response, new_cap); - if (!tmp) break; - ctx->response = tmp; - ctx->resp_cap = new_cap; - } - memcpy(ctx->response + ctx->resp_len, text->valuestring, tlen); - ctx->resp_len += tlen; - ctx->response[ctx->resp_len] = '\0'; - } - } - } - } else if (strcmp(type->valuestring, "error") == 0) { - cJSON *error = cJSON_GetObjectItem(root, "error"); - if (error) { - cJSON *msg = cJSON_GetObjectItem(error, "message"); - if (msg && cJSON_IsString(msg)) { - ESP_LOGE(TAG, "API error: %s", msg->valuestring); - } - } - } - - cJSON_Delete(root); + rb->data = heap_caps_calloc(1, initial_cap, MALLOC_CAP_SPIRAM); + if (!rb->data) return ESP_ERR_NO_MEM; + rb->len = 0; + rb->cap = initial_cap; + return ESP_OK; } -static void sse_feed(sse_ctx_t *ctx, const char *data, size_t len) +static esp_err_t resp_buf_append(resp_buf_t *rb, const char *data, size_t len) { - for (size_t i = 0; i < len; i++) { - char c = data[i]; - if (c == '\n') { - ctx->line_buf[ctx->line_len] = '\0'; - if (ctx->line_len > 0) { - sse_process_line(ctx, ctx->line_buf); - } - ctx->line_len = 0; - } else if (c != '\r') { - if (ctx->line_len < sizeof(ctx->line_buf) - 1) { - ctx->line_buf[ctx->line_len++] = c; - } - } + while (rb->len + len >= rb->cap) { + size_t new_cap = rb->cap * 2; + char *tmp = heap_caps_realloc(rb->data, new_cap, MALLOC_CAP_SPIRAM); + if (!tmp) return ESP_ERR_NO_MEM; + rb->data = tmp; + rb->cap = new_cap; } + memcpy(rb->data + rb->len, data, len); + rb->len += len; + rb->data[rb->len] = '\0'; + return ESP_OK; } +static void resp_buf_free(resp_buf_t *rb) +{ + free(rb->data); + rb->data = NULL; + rb->len = 0; + rb->cap = 0; +} + +/* ── HTTP event handler (for esp_http_client direct path) ─────── */ + static esp_err_t http_event_handler(esp_http_client_event_t *evt) { - sse_ctx_t *ctx = (sse_ctx_t *)evt->user_data; + resp_buf_t *rb = (resp_buf_t *)evt->user_data; if (evt->event_id == HTTP_EVENT_ON_DATA) { - sse_feed(ctx, (const char *)evt->data, evt->data_len); + resp_buf_append(rb, (const char *)evt->data, evt->data_len); } return ESP_OK; } +/* ── Init ─────────────────────────────────────────────────────── */ + esp_err_t llm_proxy_init(void) { - nvs_handle_t nvs; - esp_err_t err = nvs_open(MIMI_NVS_LLM, NVS_READONLY, &nvs); - if (err == ESP_OK) { - size_t len = sizeof(s_api_key); - nvs_get_str(nvs, MIMI_NVS_KEY_API_KEY, s_api_key, &len); + /* Build-time secrets take highest priority */ + if (MIMI_SECRET_API_KEY[0] != '\0') { + strncpy(s_api_key, MIMI_SECRET_API_KEY, sizeof(s_api_key) - 1); + } + if (MIMI_SECRET_MODEL[0] != '\0') { + strncpy(s_model, MIMI_SECRET_MODEL, sizeof(s_model) - 1); + } - len = sizeof(s_model); - if (nvs_get_str(nvs, MIMI_NVS_KEY_MODEL, s_model, &len) != ESP_OK) { - strncpy(s_model, MIMI_LLM_DEFAULT_MODEL, sizeof(s_model) - 1); + /* Fall back to NVS for values not set at build time */ + if (s_api_key[0] == '\0' || s_model[0] == '\0') { + nvs_handle_t nvs; + esp_err_t err = nvs_open(MIMI_NVS_LLM, NVS_READONLY, &nvs); + if (err == ESP_OK) { + if (s_api_key[0] == '\0') { + size_t len = sizeof(s_api_key); + nvs_get_str(nvs, MIMI_NVS_KEY_API_KEY, s_api_key, &len); + } + if (strcmp(s_model, MIMI_LLM_DEFAULT_MODEL) == 0) { + size_t len = sizeof(s_model); + nvs_get_str(nvs, MIMI_NVS_KEY_MODEL, s_model, &len); + } + nvs_close(nvs); } - nvs_close(nvs); } if (s_api_key[0]) { @@ -134,12 +106,12 @@ esp_err_t llm_proxy_init(void) /* ── Direct path: esp_http_client ───────────────────────────── */ -static esp_err_t llm_chat_direct(const char *post_data, sse_ctx_t *ctx, int *out_status) +static esp_err_t llm_http_direct(const char *post_data, resp_buf_t *rb, int *out_status) { esp_http_client_config_t config = { .url = MIMI_LLM_API_URL, .event_handler = http_event_handler, - .user_data = ctx, + .user_data = rb, .timeout_ms = 120 * 1000, .buffer_size = 4096, .buffer_size_tx = 4096, @@ -163,12 +135,11 @@ static esp_err_t llm_chat_direct(const char *post_data, sse_ctx_t *ctx, int *out /* ── Proxy path: manual HTTP over CONNECT tunnel ────────────── */ -static esp_err_t llm_chat_via_proxy(const char *post_data, sse_ctx_t *ctx, int *out_status) +static esp_err_t llm_http_via_proxy(const char *post_data, resp_buf_t *rb, int *out_status) { proxy_conn_t *conn = proxy_conn_open("api.anthropic.com", 443, 30000); if (!conn) return ESP_ERR_HTTP_CONNECT; - /* Build HTTP request */ int body_len = strlen(post_data); char header[512]; int hlen = snprintf(header, sizeof(header), @@ -187,55 +158,71 @@ static esp_err_t llm_chat_via_proxy(const char *post_data, sse_ctx_t *ctx, int * return ESP_ERR_HTTP_WRITE_DATA; } - /* Read response — first line is status */ - size_t raw_len = 0; - size_t raw_cap = 32768; - char *raw = calloc(1, raw_cap); - if (!raw) { proxy_conn_close(conn); return ESP_ERR_NO_MEM; } - + /* Read full response into buffer */ + char tmp[4096]; while (1) { - if (raw_len + 4096 >= raw_cap) { - raw_cap *= 2; - char *tmp = realloc(raw, raw_cap); - if (!tmp) break; - raw = tmp; - } - int n = proxy_conn_read(conn, raw + raw_len, 4096, 120000); + int n = proxy_conn_read(conn, tmp, sizeof(tmp), 120000); if (n <= 0) break; - raw_len += n; + if (resp_buf_append(rb, tmp, n) != ESP_OK) break; } - raw[raw_len] = '\0'; proxy_conn_close(conn); /* Parse status line */ *out_status = 0; - if (strncmp(raw, "HTTP/", 5) == 0) { - const char *sp = strchr(raw, ' '); + if (rb->len > 5 && strncmp(rb->data, "HTTP/", 5) == 0) { + const char *sp = strchr(rb->data, ' '); if (sp) *out_status = atoi(sp + 1); } - /* Find body after \r\n\r\n */ - char *body = strstr(raw, "\r\n\r\n"); + /* Strip HTTP headers, keep body only */ + char *body = strstr(rb->data, "\r\n\r\n"); if (body) { body += 4; - size_t body_len = raw_len - (body - raw); - if (*out_status == 200) { - /* Feed body to SSE parser */ - sse_feed(ctx, body, body_len); - } else { - /* For error responses, capture raw body */ - size_t copy_len = body_len < ctx->resp_cap - 1 ? body_len : ctx->resp_cap - 1; - memcpy(ctx->response, body, copy_len); - ctx->response[copy_len] = '\0'; - ctx->resp_len = copy_len; - ESP_LOGE(TAG, "API error body: %.500s", body); - } + size_t blen = rb->len - (body - rb->data); + memmove(rb->data, body, blen); + rb->len = blen; + rb->data[rb->len] = '\0'; } - free(raw); return ESP_OK; } +/* ── Shared HTTP dispatch ─────────────────────────────────────── */ + +static esp_err_t llm_http_call(const char *post_data, resp_buf_t *rb, int *out_status) +{ + if (http_proxy_is_enabled()) { + return llm_http_via_proxy(post_data, rb, out_status); + } else { + return llm_http_direct(post_data, rb, out_status); + } +} + +/* ── Parse text from JSON response ────────────────────────────── */ + +static void extract_text(cJSON *root, char *buf, size_t size) +{ + buf[0] = '\0'; + cJSON *content = cJSON_GetObjectItem(root, "content"); + if (!content || !cJSON_IsArray(content)) return; + + size_t off = 0; + cJSON *block; + cJSON_ArrayForEach(block, content) { + cJSON *btype = cJSON_GetObjectItem(block, "type"); + if (!btype || strcmp(btype->valuestring, "text") != 0) continue; + cJSON *text = cJSON_GetObjectItem(block, "text"); + if (!text || !cJSON_IsString(text)) continue; + size_t tlen = strlen(text->valuestring); + size_t copy = (tlen < size - off - 1) ? tlen : size - off - 1; + memcpy(buf + off, text->valuestring, copy); + off += copy; + } + buf[off] = '\0'; +} + +/* ── Public: simple chat (backward compat) ────────────────────── */ + esp_err_t llm_chat(const char *system_prompt, const char *messages_json, char *response_buf, size_t buf_size) { @@ -244,21 +231,16 @@ esp_err_t llm_chat(const char *system_prompt, const char *messages_json, return ESP_ERR_INVALID_STATE; } - /* Build request body */ + /* Build request body (non-streaming) */ cJSON *body = cJSON_CreateObject(); cJSON_AddStringToObject(body, "model", s_model); cJSON_AddNumberToObject(body, "max_tokens", MIMI_LLM_MAX_TOKENS); - cJSON_AddBoolToObject(body, "stream", 1); - - /* System prompt — Anthropic format: top-level "system" field */ cJSON_AddStringToObject(body, "system", system_prompt); - /* Messages array (parse from JSON string) */ cJSON *messages = cJSON_Parse(messages_json); if (messages) { cJSON_AddItemToObject(body, "messages", messages); } else { - /* Fallback: single user message */ cJSON *arr = cJSON_CreateArray(); cJSON *msg = cJSON_CreateObject(); cJSON_AddStringToObject(msg, "role", "user"); @@ -269,65 +251,224 @@ esp_err_t llm_chat(const char *system_prompt, const char *messages_json, char *post_data = cJSON_PrintUnformatted(body); cJSON_Delete(body); - if (!post_data) { snprintf(response_buf, buf_size, "Error: Failed to build request"); return ESP_ERR_NO_MEM; } - ESP_LOGI(TAG, "Calling Claude API (model: %s, body: %d bytes)", s_model, (int)strlen(post_data)); + ESP_LOGI(TAG, "Calling Claude API (model: %s, body: %d bytes)", + s_model, (int)strlen(post_data)); - /* SSE context */ - sse_ctx_t ctx = {0}; - ctx.response = calloc(1, MIMI_LLM_STREAM_BUF_SIZE); - ctx.resp_cap = MIMI_LLM_STREAM_BUF_SIZE; - if (!ctx.response) { + resp_buf_t rb; + if (resp_buf_init(&rb, MIMI_LLM_STREAM_BUF_SIZE) != ESP_OK) { free(post_data); snprintf(response_buf, buf_size, "Error: Out of memory"); return ESP_ERR_NO_MEM; } - esp_err_t err; int status = 0; - - if (http_proxy_is_enabled()) { - err = llm_chat_via_proxy(post_data, &ctx, &status); - } else { - err = llm_chat_direct(post_data, &ctx, &status); - } + esp_err_t err = llm_http_call(post_data, &rb, &status); free(post_data); if (err != ESP_OK) { ESP_LOGE(TAG, "HTTP request failed: %s", esp_err_to_name(err)); - free(ctx.response); - snprintf(response_buf, buf_size, "Error: HTTP request failed (%s)", esp_err_to_name(err)); + resp_buf_free(&rb); + snprintf(response_buf, buf_size, "Error: HTTP request failed (%s)", + esp_err_to_name(err)); return err; } if (status != 200) { ESP_LOGE(TAG, "API returned status %d", status); - if (ctx.resp_len > 0) { - snprintf(response_buf, buf_size, "API error (HTTP %d): %.200s", status, ctx.response); - } else { - snprintf(response_buf, buf_size, "API error (HTTP %d)", status); - } - free(ctx.response); + snprintf(response_buf, buf_size, "API error (HTTP %d): %.200s", + status, rb.data ? rb.data : ""); + resp_buf_free(&rb); return ESP_FAIL; } - /* Copy accumulated response */ - if (ctx.resp_len > 0) { - strncpy(response_buf, ctx.response, buf_size - 1); - response_buf[buf_size - 1] = '\0'; - ESP_LOGI(TAG, "Claude response: %d bytes", (int)ctx.resp_len); - } else { - snprintf(response_buf, buf_size, "No response from Claude API"); + /* Parse JSON response */ + cJSON *root = cJSON_Parse(rb.data); + resp_buf_free(&rb); + + if (!root) { + snprintf(response_buf, buf_size, "Error: Failed to parse response"); + return ESP_FAIL; + } + + extract_text(root, response_buf, buf_size); + cJSON_Delete(root); + + if (response_buf[0] == '\0') { + snprintf(response_buf, buf_size, "No response from Claude API"); + } else { + ESP_LOGI(TAG, "Claude response: %d bytes", (int)strlen(response_buf)); } - free(ctx.response); return ESP_OK; } +/* ── Public: chat with tools (non-streaming) ──────────────────── */ + +void llm_response_free(llm_response_t *resp) +{ + free(resp->text); + resp->text = NULL; + resp->text_len = 0; + for (int i = 0; i < resp->call_count; i++) { + free(resp->calls[i].input); + resp->calls[i].input = NULL; + } + resp->call_count = 0; + resp->tool_use = false; +} + +esp_err_t llm_chat_tools(const char *system_prompt, + cJSON *messages, + const char *tools_json, + llm_response_t *resp) +{ + memset(resp, 0, sizeof(*resp)); + + if (s_api_key[0] == '\0') return ESP_ERR_INVALID_STATE; + + /* Build request body (non-streaming) */ + cJSON *body = cJSON_CreateObject(); + cJSON_AddStringToObject(body, "model", s_model); + cJSON_AddNumberToObject(body, "max_tokens", MIMI_LLM_MAX_TOKENS); + cJSON_AddStringToObject(body, "system", system_prompt); + + /* Deep-copy messages so caller keeps ownership */ + cJSON *msgs_copy = cJSON_Duplicate(messages, 1); + cJSON_AddItemToObject(body, "messages", msgs_copy); + + /* Add tools array if provided */ + if (tools_json) { + cJSON *tools = cJSON_Parse(tools_json); + if (tools) { + cJSON_AddItemToObject(body, "tools", tools); + } + } + + char *post_data = cJSON_PrintUnformatted(body); + cJSON_Delete(body); + if (!post_data) return ESP_ERR_NO_MEM; + + ESP_LOGI(TAG, "Calling Claude API with tools (model: %s, body: %d bytes)", + s_model, (int)strlen(post_data)); + + /* HTTP call */ + resp_buf_t rb; + if (resp_buf_init(&rb, MIMI_LLM_STREAM_BUF_SIZE) != ESP_OK) { + free(post_data); + return ESP_ERR_NO_MEM; + } + + int status = 0; + esp_err_t err = llm_http_call(post_data, &rb, &status); + free(post_data); + + if (err != ESP_OK) { + ESP_LOGE(TAG, "HTTP request failed: %s", esp_err_to_name(err)); + resp_buf_free(&rb); + return err; + } + + if (status != 200) { + ESP_LOGE(TAG, "API error %d: %.500s", status, rb.data ? rb.data : ""); + resp_buf_free(&rb); + return ESP_FAIL; + } + + /* Parse full JSON response */ + cJSON *root = cJSON_Parse(rb.data); + resp_buf_free(&rb); + + if (!root) { + ESP_LOGE(TAG, "Failed to parse API response JSON"); + return ESP_FAIL; + } + + /* stop_reason */ + cJSON *stop_reason = cJSON_GetObjectItem(root, "stop_reason"); + if (stop_reason && cJSON_IsString(stop_reason)) { + resp->tool_use = (strcmp(stop_reason->valuestring, "tool_use") == 0); + } + + /* Iterate content blocks */ + cJSON *content = cJSON_GetObjectItem(root, "content"); + if (content && cJSON_IsArray(content)) { + /* Accumulate total text length first */ + size_t total_text = 0; + cJSON *block; + cJSON_ArrayForEach(block, content) { + cJSON *btype = cJSON_GetObjectItem(block, "type"); + if (btype && strcmp(btype->valuestring, "text") == 0) { + cJSON *text = cJSON_GetObjectItem(block, "text"); + if (text && cJSON_IsString(text)) { + total_text += strlen(text->valuestring); + } + } + } + + /* Allocate and copy text */ + if (total_text > 0) { + resp->text = calloc(1, total_text + 1); + if (resp->text) { + cJSON_ArrayForEach(block, content) { + cJSON *btype = cJSON_GetObjectItem(block, "type"); + if (!btype || strcmp(btype->valuestring, "text") != 0) continue; + cJSON *text = cJSON_GetObjectItem(block, "text"); + if (!text || !cJSON_IsString(text)) continue; + size_t tlen = strlen(text->valuestring); + memcpy(resp->text + resp->text_len, text->valuestring, tlen); + resp->text_len += tlen; + } + resp->text[resp->text_len] = '\0'; + } + } + + /* Extract tool_use blocks */ + cJSON_ArrayForEach(block, content) { + cJSON *btype = cJSON_GetObjectItem(block, "type"); + if (!btype || strcmp(btype->valuestring, "tool_use") != 0) continue; + if (resp->call_count >= MIMI_MAX_TOOL_CALLS) break; + + llm_tool_call_t *call = &resp->calls[resp->call_count]; + + cJSON *id = cJSON_GetObjectItem(block, "id"); + if (id && cJSON_IsString(id)) { + strncpy(call->id, id->valuestring, sizeof(call->id) - 1); + } + + cJSON *name = cJSON_GetObjectItem(block, "name"); + if (name && cJSON_IsString(name)) { + strncpy(call->name, name->valuestring, sizeof(call->name) - 1); + } + + cJSON *input = cJSON_GetObjectItem(block, "input"); + if (input) { + char *input_str = cJSON_PrintUnformatted(input); + if (input_str) { + call->input = input_str; + call->input_len = strlen(input_str); + } + } + + resp->call_count++; + } + } + + cJSON_Delete(root); + + 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"); + + return ESP_OK; +} + +/* ── NVS helpers ──────────────────────────────────────────────── */ + esp_err_t llm_set_api_key(const char *api_key) { nvs_handle_t nvs; diff --git a/main/llm/llm_proxy.h b/main/llm/llm_proxy.h index 13eb4b2..1b9ae50 100644 --- a/main/llm/llm_proxy.h +++ b/main/llm/llm_proxy.h @@ -1,7 +1,11 @@ #pragma once #include "esp_err.h" +#include "cJSON.h" #include +#include + +#include "mimi_config.h" /** * Initialize the LLM proxy. Reads API key and model from NVS. @@ -29,3 +33,36 @@ esp_err_t llm_set_api_key(const char *api_key); * Save the model identifier to NVS. */ esp_err_t llm_set_model(const char *model); + +/* ── Tool Use Support ──────────────────────────────────────────── */ + +typedef struct { + char id[64]; /* "toolu_xxx" */ + char name[32]; /* "web_search" */ + char *input; /* heap-allocated JSON string */ + size_t input_len; +} llm_tool_call_t; + +typedef struct { + char *text; /* accumulated text blocks */ + size_t text_len; + llm_tool_call_t calls[MIMI_MAX_TOOL_CALLS]; + int call_count; + bool tool_use; /* stop_reason == "tool_use" */ +} llm_response_t; + +void llm_response_free(llm_response_t *resp); + +/** + * Send a chat completion request with tools to Anthropic Messages API (streaming). + * + * @param system_prompt System prompt string + * @param messages cJSON array of messages (caller owns) + * @param tools_json Pre-built JSON string of tools array, or NULL for no tools + * @param resp Output: structured response with text and tool calls + * @return ESP_OK on success + */ +esp_err_t llm_chat_tools(const char *system_prompt, + cJSON *messages, + const char *tools_json, + llm_response_t *resp); diff --git a/main/mimi_config.h b/main/mimi_config.h index 7f9868b..972abdd 100644 --- a/main/mimi_config.h +++ b/main/mimi_config.h @@ -2,6 +2,36 @@ /* MimiClaw Global Configuration */ +/* Build-time secrets (highest priority, override NVS) */ +#if __has_include("mimi_secrets.h") +#include "mimi_secrets.h" +#endif + +#ifndef MIMI_SECRET_WIFI_SSID +#define MIMI_SECRET_WIFI_SSID "" +#endif +#ifndef MIMI_SECRET_WIFI_PASS +#define MIMI_SECRET_WIFI_PASS "" +#endif +#ifndef MIMI_SECRET_TG_TOKEN +#define MIMI_SECRET_TG_TOKEN "" +#endif +#ifndef MIMI_SECRET_API_KEY +#define MIMI_SECRET_API_KEY "" +#endif +#ifndef MIMI_SECRET_MODEL +#define MIMI_SECRET_MODEL "" +#endif +#ifndef MIMI_SECRET_PROXY_HOST +#define MIMI_SECRET_PROXY_HOST "" +#endif +#ifndef MIMI_SECRET_PROXY_PORT +#define MIMI_SECRET_PROXY_PORT "" +#endif +#ifndef MIMI_SECRET_SEARCH_KEY +#define MIMI_SECRET_SEARCH_KEY "" +#endif + /* WiFi */ #define MIMI_WIFI_MAX_RETRY 10 #define MIMI_WIFI_RETRY_BASE_MS 1000 @@ -19,6 +49,8 @@ #define MIMI_AGENT_PRIO 6 #define MIMI_AGENT_CORE 1 #define MIMI_AGENT_MAX_HISTORY 20 +#define MIMI_AGENT_MAX_TOOL_ITER 10 +#define MIMI_MAX_TOOL_CALLS 4 /* LLM */ #define MIMI_LLM_DEFAULT_MODEL "claude-opus-4-6" @@ -58,6 +90,7 @@ #define MIMI_NVS_TG "tg_config" #define MIMI_NVS_LLM "llm_config" #define MIMI_NVS_PROXY "proxy_config" +#define MIMI_NVS_SEARCH "search_config" /* NVS Keys */ #define MIMI_NVS_KEY_SSID "ssid" diff --git a/main/tools/tool_registry.c b/main/tools/tool_registry.c new file mode 100644 index 0000000..17b0d29 --- /dev/null +++ b/main/tools/tool_registry.c @@ -0,0 +1,92 @@ +#include "tool_registry.h" +#include "tools/tool_web_search.h" + +#include +#include "esp_log.h" +#include "cJSON.h" + +static const char *TAG = "tools"; + +#define MAX_TOOLS 8 + +static mimi_tool_t s_tools[MAX_TOOLS]; +static int s_tool_count = 0; +static char *s_tools_json = NULL; /* cached JSON array string */ + +static void register_tool(const mimi_tool_t *tool) +{ + if (s_tool_count >= MAX_TOOLS) { + ESP_LOGE(TAG, "Tool registry full"); + return; + } + s_tools[s_tool_count++] = *tool; + ESP_LOGI(TAG, "Registered tool: %s", tool->name); +} + +static void build_tools_json(void) +{ + cJSON *arr = cJSON_CreateArray(); + + for (int i = 0; i < s_tool_count; i++) { + cJSON *tool = cJSON_CreateObject(); + cJSON_AddStringToObject(tool, "name", s_tools[i].name); + cJSON_AddStringToObject(tool, "description", s_tools[i].description); + + cJSON *schema = cJSON_Parse(s_tools[i].input_schema_json); + if (schema) { + cJSON_AddItemToObject(tool, "input_schema", schema); + } + + cJSON_AddItemToArray(arr, tool); + } + + free(s_tools_json); + s_tools_json = cJSON_PrintUnformatted(arr); + cJSON_Delete(arr); + + ESP_LOGI(TAG, "Tools JSON built (%d tools)", s_tool_count); +} + +esp_err_t tool_registry_init(void) +{ + s_tool_count = 0; + + /* Register web_search */ + tool_web_search_init(); + + mimi_tool_t ws = { + .name = "web_search", + .description = "Search the web for current information. Use this when you need up-to-date facts, news, weather, or anything beyond your training data.", + .input_schema_json = + "{\"type\":\"object\"," + "\"properties\":{\"query\":{\"type\":\"string\",\"description\":\"The search query\"}}," + "\"required\":[\"query\"]}", + .execute = tool_web_search_execute, + }; + register_tool(&ws); + + build_tools_json(); + + ESP_LOGI(TAG, "Tool registry initialized"); + return ESP_OK; +} + +const char *tool_registry_get_tools_json(void) +{ + return s_tools_json; +} + +esp_err_t tool_registry_execute(const char *name, const char *input_json, + char *output, size_t output_size) +{ + for (int i = 0; i < s_tool_count; i++) { + if (strcmp(s_tools[i].name, name) == 0) { + ESP_LOGI(TAG, "Executing tool: %s", name); + return s_tools[i].execute(input_json, output, output_size); + } + } + + ESP_LOGW(TAG, "Unknown tool: %s", name); + snprintf(output, output_size, "Error: unknown tool '%s'", name); + return ESP_ERR_NOT_FOUND; +} diff --git a/main/tools/tool_registry.h b/main/tools/tool_registry.h new file mode 100644 index 0000000..7b28de0 --- /dev/null +++ b/main/tools/tool_registry.h @@ -0,0 +1,34 @@ +#pragma once + +#include "esp_err.h" +#include + +typedef struct { + const char *name; + const char *description; + const char *input_schema_json; /* JSON Schema string for input */ + esp_err_t (*execute)(const char *input_json, char *output, size_t output_size); +} mimi_tool_t; + +/** + * Initialize tool registry and register all built-in tools. + */ +esp_err_t tool_registry_init(void); + +/** + * Get the pre-built tools JSON array string for the API request. + * Returns NULL if no tools are registered. + */ +const char *tool_registry_get_tools_json(void); + +/** + * Execute a tool by name. + * + * @param name Tool name (e.g. "web_search") + * @param input_json JSON string of tool input + * @param output Output buffer for tool result text + * @param output_size Size of output buffer + * @return ESP_OK on success, ESP_ERR_NOT_FOUND if tool unknown + */ +esp_err_t tool_registry_execute(const char *name, const char *input_json, + char *output, size_t output_size); diff --git a/main/tools/tool_web_search.c b/main/tools/tool_web_search.c new file mode 100644 index 0000000..cebe5ca --- /dev/null +++ b/main/tools/tool_web_search.c @@ -0,0 +1,308 @@ +#include "tool_web_search.h" +#include "mimi_config.h" +#include "proxy/http_proxy.h" + +#include +#include +#include "esp_log.h" +#include "esp_http_client.h" +#include "esp_crt_bundle.h" +#include "esp_heap_caps.h" +#include "nvs.h" +#include "cJSON.h" + +static const char *TAG = "web_search"; + +static char s_search_key[128] = {0}; + +#define SEARCH_BUF_SIZE (16 * 1024) +#define SEARCH_RESULT_COUNT 5 + +/* ── Response accumulator ─────────────────────────────────────── */ + +typedef struct { + char *data; + size_t len; + size_t cap; +} search_buf_t; + +static esp_err_t http_event_handler(esp_http_client_event_t *evt) +{ + search_buf_t *sb = (search_buf_t *)evt->user_data; + if (evt->event_id == HTTP_EVENT_ON_DATA) { + size_t needed = sb->len + evt->data_len; + if (needed < sb->cap) { + memcpy(sb->data + sb->len, evt->data, evt->data_len); + sb->len += evt->data_len; + sb->data[sb->len] = '\0'; + } + } + return ESP_OK; +} + +/* ── Init ─────────────────────────────────────────────────────── */ + +esp_err_t tool_web_search_init(void) +{ + /* Build-time secret takes highest priority */ + if (MIMI_SECRET_SEARCH_KEY[0] != '\0') { + strncpy(s_search_key, MIMI_SECRET_SEARCH_KEY, sizeof(s_search_key) - 1); + } else { + nvs_handle_t nvs; + esp_err_t err = nvs_open(MIMI_NVS_SEARCH, NVS_READONLY, &nvs); + if (err == ESP_OK) { + size_t len = sizeof(s_search_key); + nvs_get_str(nvs, MIMI_NVS_KEY_API_KEY, s_search_key, &len); + nvs_close(nvs); + } + } + + if (s_search_key[0]) { + ESP_LOGI(TAG, "Web search initialized (key configured)"); + } else { + ESP_LOGW(TAG, "No search API key. Use CLI: set_search_key "); + } + return ESP_OK; +} + +esp_err_t tool_web_search_set_key(const char *api_key) +{ + nvs_handle_t nvs; + ESP_ERROR_CHECK(nvs_open(MIMI_NVS_SEARCH, NVS_READWRITE, &nvs)); + ESP_ERROR_CHECK(nvs_set_str(nvs, MIMI_NVS_KEY_API_KEY, api_key)); + ESP_ERROR_CHECK(nvs_commit(nvs)); + nvs_close(nvs); + + strncpy(s_search_key, api_key, sizeof(s_search_key) - 1); + ESP_LOGI(TAG, "Search API key saved"); + return ESP_OK; +} + +/* ── URL-encode a query string ────────────────────────────────── */ + +static size_t url_encode(const char *src, char *dst, size_t dst_size) +{ + static const char hex[] = "0123456789ABCDEF"; + size_t pos = 0; + + for (; *src && pos < dst_size - 3; src++) { + unsigned char c = (unsigned char)*src; + if ((c >= 'A' && c <= 'Z') || (c >= 'a' && c <= 'z') || + (c >= '0' && c <= '9') || c == '-' || c == '_' || c == '.' || c == '~') { + dst[pos++] = c; + } else if (c == ' ') { + dst[pos++] = '+'; + } else { + dst[pos++] = '%'; + dst[pos++] = hex[c >> 4]; + dst[pos++] = hex[c & 0x0F]; + } + } + dst[pos] = '\0'; + return pos; +} + +/* ── Format results as readable text ──────────────────────────── */ + +static void format_results(cJSON *root, char *output, size_t output_size) +{ + cJSON *web = cJSON_GetObjectItem(root, "web"); + if (!web) { + snprintf(output, output_size, "No web results found."); + return; + } + + cJSON *results = cJSON_GetObjectItem(web, "results"); + if (!results || !cJSON_IsArray(results) || cJSON_GetArraySize(results) == 0) { + snprintf(output, output_size, "No web results found."); + return; + } + + size_t off = 0; + int idx = 0; + cJSON *item; + cJSON_ArrayForEach(item, results) { + if (idx >= SEARCH_RESULT_COUNT) break; + + cJSON *title = cJSON_GetObjectItem(item, "title"); + cJSON *url = cJSON_GetObjectItem(item, "url"); + cJSON *desc = cJSON_GetObjectItem(item, "description"); + + off += snprintf(output + off, output_size - off, + "%d. %s\n %s\n %s\n\n", + idx + 1, + (title && cJSON_IsString(title)) ? title->valuestring : "(no title)", + (url && cJSON_IsString(url)) ? url->valuestring : "", + (desc && cJSON_IsString(desc)) ? desc->valuestring : ""); + + if (off >= output_size - 1) break; + idx++; + } +} + +/* ── Direct HTTPS request ─────────────────────────────────────── */ + +static esp_err_t search_direct(const char *url, search_buf_t *sb) +{ + esp_http_client_config_t config = { + .url = url, + .event_handler = http_event_handler, + .user_data = sb, + .timeout_ms = 15000, + .buffer_size = 4096, + .crt_bundle_attach = esp_crt_bundle_attach, + }; + + esp_http_client_handle_t client = esp_http_client_init(&config); + if (!client) return ESP_FAIL; + + esp_http_client_set_header(client, "Accept", "application/json"); + esp_http_client_set_header(client, "X-Subscription-Token", s_search_key); + + esp_err_t err = esp_http_client_perform(client); + int status = esp_http_client_get_status_code(client); + esp_http_client_cleanup(client); + + if (err != ESP_OK) return err; + if (status != 200) { + ESP_LOGE(TAG, "Search API returned %d", status); + return ESP_FAIL; + } + return ESP_OK; +} + +/* ── Proxy HTTPS request ──────────────────────────────────────── */ + +static esp_err_t search_via_proxy(const char *path, search_buf_t *sb) +{ + proxy_conn_t *conn = proxy_conn_open("api.search.brave.com", 443, 15000); + if (!conn) return ESP_ERR_HTTP_CONNECT; + + char header[512]; + int hlen = snprintf(header, sizeof(header), + "GET %s HTTP/1.1\r\n" + "Host: api.search.brave.com\r\n" + "Accept: application/json\r\n" + "X-Subscription-Token: %s\r\n" + "Connection: close\r\n\r\n", + path, s_search_key); + + if (proxy_conn_write(conn, header, hlen) < 0) { + proxy_conn_close(conn); + return ESP_ERR_HTTP_WRITE_DATA; + } + + /* Read full response */ + char tmp[4096]; + size_t total = 0; + while (1) { + int n = proxy_conn_read(conn, tmp, sizeof(tmp), 15000); + if (n <= 0) break; + size_t copy = (total + n < sb->cap - 1) ? (size_t)n : sb->cap - 1 - total; + if (copy > 0) { + memcpy(sb->data + total, tmp, copy); + total += copy; + } + } + sb->data[total] = '\0'; + sb->len = total; + proxy_conn_close(conn); + + /* Check status */ + int status = 0; + if (total > 5 && strncmp(sb->data, "HTTP/", 5) == 0) { + const char *sp = strchr(sb->data, ' '); + if (sp) status = atoi(sp + 1); + } + + /* Strip headers */ + char *body = strstr(sb->data, "\r\n\r\n"); + if (body) { + body += 4; + size_t blen = total - (body - sb->data); + memmove(sb->data, body, blen); + sb->len = blen; + sb->data[sb->len] = '\0'; + } + + if (status != 200) { + ESP_LOGE(TAG, "Search API returned %d via proxy", status); + return ESP_FAIL; + } + return ESP_OK; +} + +/* ── Execute ──────────────────────────────────────────────────── */ + +esp_err_t tool_web_search_execute(const char *input_json, char *output, size_t output_size) +{ + if (s_search_key[0] == '\0') { + snprintf(output, output_size, "Error: No search API key configured. Use set_search_key command."); + return ESP_ERR_INVALID_STATE; + } + + /* Parse input to get query */ + cJSON *input = cJSON_Parse(input_json); + if (!input) { + snprintf(output, output_size, "Error: Invalid input JSON"); + return ESP_ERR_INVALID_ARG; + } + + cJSON *query = cJSON_GetObjectItem(input, "query"); + if (!query || !cJSON_IsString(query) || query->valuestring[0] == '\0') { + cJSON_Delete(input); + snprintf(output, output_size, "Error: Missing 'query' field"); + return ESP_ERR_INVALID_ARG; + } + + ESP_LOGI(TAG, "Searching: %s", query->valuestring); + + /* Build URL */ + char encoded_query[256]; + url_encode(query->valuestring, encoded_query, sizeof(encoded_query)); + cJSON_Delete(input); + + char path[384]; + snprintf(path, sizeof(path), + "/res/v1/web/search?q=%s&count=%d", encoded_query, SEARCH_RESULT_COUNT); + + /* Allocate response buffer from PSRAM */ + search_buf_t sb = {0}; + sb.data = heap_caps_calloc(1, SEARCH_BUF_SIZE, MALLOC_CAP_SPIRAM); + if (!sb.data) { + snprintf(output, output_size, "Error: Out of memory"); + return ESP_ERR_NO_MEM; + } + sb.cap = SEARCH_BUF_SIZE; + + /* Make HTTP request */ + esp_err_t err; + if (http_proxy_is_enabled()) { + err = search_via_proxy(path, &sb); + } else { + char url[512]; + snprintf(url, sizeof(url), "https://api.search.brave.com%s", path); + err = search_direct(url, &sb); + } + + if (err != ESP_OK) { + free(sb.data); + snprintf(output, output_size, "Error: Search request failed"); + return err; + } + + /* Parse and format results */ + cJSON *root = cJSON_Parse(sb.data); + free(sb.data); + + if (!root) { + snprintf(output, output_size, "Error: Failed to parse search results"); + return ESP_FAIL; + } + + format_results(root, output, output_size); + cJSON_Delete(root); + + ESP_LOGI(TAG, "Search complete, %d bytes result", (int)strlen(output)); + return ESP_OK; +} diff --git a/main/tools/tool_web_search.h b/main/tools/tool_web_search.h new file mode 100644 index 0000000..5cb4d96 --- /dev/null +++ b/main/tools/tool_web_search.h @@ -0,0 +1,24 @@ +#pragma once + +#include "esp_err.h" +#include + +/** + * Initialize web search tool — loads API key from NVS. + */ +esp_err_t tool_web_search_init(void); + +/** + * Execute a web search. + * + * @param input_json JSON string with "query" field + * @param output Output buffer for formatted search results + * @param output_size Size of output buffer + * @return ESP_OK on success + */ +esp_err_t tool_web_search_execute(const char *input_json, char *output, size_t output_size); + +/** + * Save Brave Search API key to NVS. + */ +esp_err_t tool_web_search_set_key(const char *api_key);