From 4a400f1582c00feeff0bfc8fcfd6f462ccf3ac09 Mon Sep 17 00:00:00 2001 From: Your Name Date: Mon, 2 Mar 2026 07:08:19 -0400 Subject: [PATCH] v0.0.25 - Add model_get/model_set/model_list tools with persisted LLM config updates and model discovery --- README.md | 4 +- docs/CRASH_FIXES.md | 268 +++++++++++++++++++++++++++++++++++ src/config.c | 1 + src/config.h | 1 + src/llm.c | 49 ++++++- src/llm.h | 3 + src/main.c | 11 ++ src/main.h | 4 +- src/nostr_handler.c | 33 ++++- src/tools.c | 333 ++++++++++++++++++++++++++++++++++++++++++++ 10 files changed, 696 insertions(+), 11 deletions(-) create mode 100644 docs/CRASH_FIXES.md diff --git a/README.md b/README.md index 24f7e62..e6b88db 100644 --- a/README.md +++ b/README.md @@ -51,11 +51,11 @@ Agents learn capabilities through skills — Nostr events that any agent can di Didactyl will support local inference, which is very privacy preserving. Remote inference does however have it's advantages, and in those cases Didactyl supports using Bitcoin Lightning and eCash inference providers. -## Current Status — v0.0.24 +## Current Status — v0.0.25 **Active build — this project is barely working. Experiment at your own risk.** -> Last release update: v0.0.24 — Handle SIGPIPE disconnect crash and fix nostr_list_manage use-after-free +> Last release update: v0.0.25 — Add model_get/model_set/model_list tools with persisted LLM config updates and model discovery - Connects to configured relays with auto-reconnect and relay state transition logging - Publishes configured startup events per relay as each relay becomes connected diff --git a/docs/CRASH_FIXES.md b/docs/CRASH_FIXES.md new file mode 100644 index 0000000..82bfe6b --- /dev/null +++ b/docs/CRASH_FIXES.md @@ -0,0 +1,268 @@ +# Crash Fixes Reference + +This document catalogues crashes that have been diagnosed and fixed in Didactyl. +Use it as a reference when investigating future crashes — the symptoms, root causes, +and investigation techniques described here may save significant debugging time. + +--- + +## 1. Silent process exit on relay disconnect — SIGPIPE (v0.0.24) + +**Date**: 2026-03-02 +**Version**: v0.0.23 → fixed in v0.0.24 +**Severity**: Critical — process terminates without any log output +**Files changed**: `src/main.c` + +### Symptoms + +- The process exits cleanly to the shell prompt with **no error message**. +- The `[didactyl] shutting down` log line does **not** appear. +- No coredump is generated. +- The last log lines show all relays transitioning from `connected → disconnected` + and then immediately `disconnected → connected`. +- The crash typically follows a period of network instability or DNS resolution + failure — for example, an LLM HTTP request failing with + `curl=Could not resolve hostname`. + +### Root cause + +`SIGPIPE` was never handled. The default OS action for `SIGPIPE` is to terminate +the process immediately — no signal handler runs, no cleanup occurs, no coredump +is written. + +The TLS write path in `nostr_core_lib` uses `SSL_write()`, which internally calls +`write()` on the underlying socket. When a `wss://` relay drops its TCP connection +and the relay pool attempts to write to that socket, the kernel delivers `SIGPIPE`. + +The plain-TCP path for `ws://` connections was already protected by using +`MSG_NOSIGNAL` on `send()` calls, but the TLS path had no equivalent protection. + +### How it was diagnosed + +1. **No crash message or coredump** ruled out `SIGSEGV`, `SIGABRT`, and OOM. +2. **No shutdown log** ruled out a graceful exit via the signal handler. +3. `dmesg` and `journalctl` showed no OOM-kill or segfault entries. +4. Searching the entire codebase for `SIGPIPE` or `SIG_IGN` returned zero results. +5. The websocket TLS transport was confirmed to use `SSL_write()` without + `MSG_NOSIGNAL` or any per-thread signal mask. +6. The timeline matched: relay disconnects occurred, the next poll cycle attempted + a write to a dead TLS socket, and the process vanished. + +### Fix + +Added `signal(SIGPIPE, SIG_IGN)` in `main()` alongside the existing `SIGINT` and +`SIGTERM` handlers: + +```c +signal(SIGINT, signal_handler); +signal(SIGTERM, signal_handler); +signal(SIGPIPE, SIG_IGN); /* ← added */ +``` + +This causes `SSL_write()` and any other write to a broken pipe to return `-1` with +`errno = EPIPE` instead of killing the process. The relay pool and libcurl already +handle write errors gracefully. + +### How to recognise this class of bug in the future + +- Process disappears without any log output or coredump. +- Happens after network disruption or relay disconnects. +- `ulimit -c` may be 0, but even with unlimited core size, `SIGPIPE` does not + produce a coredump by default. +- Any new network transport layer added to the project should be audited for + `SIGPIPE` protection. + +--- + +## 2. Use-after-free in `execute_nostr_list_manage` — SIGSEGV (v0.0.24) + +**Date**: 2026-03-01 (coredump), fixed 2026-03-02 +**Version**: v0.0.23 → fixed in v0.0.24 +**Severity**: Critical — segmentation fault during tool execution +**Files changed**: `src/tools.c` + +### Symptoms + +- `SIGSEGV` crash during execution of the `nostr_list_manage` tool. +- Coredump shows the crash at `src/tools.c:29` inside `json_error()`, but with + heavily corrupted stack frames — the real crash site is in + `execute_nostr_list_manage()`. +- The corrupted stack is characteristic of heap corruption from use-after-free. + +### Root cause + +In `execute_nostr_list_manage()`, the `action` pointer was obtained from the +`args` cJSON tree: + +```c +cJSON* action = cJSON_GetObjectItemCaseSensitive(args, "action"); +``` + +Later, `args` was freed: + +```c +cJSON_Delete(args); /* frees the entire tree including action */ +``` + +But `action->valuestring` was still accessed afterwards: + +```c +cJSON_AddStringToObject(out, "action", action->valuestring); /* dangling pointer */ +``` + +After `cJSON_Delete(args)`, the `action` pointer is dangling. Accessing +`action->valuestring` reads freed heap memory, which may contain arbitrary data +or may have been reallocated for another purpose. + +### How it was diagnosed + +1. The coredump was extracted from systemd-coredump storage: + ``` + zstd -d /var/lib/systemd/coredump/core.didactyl_static.*.zst -o /tmp/core.bin + gdb ./didactyl_static_x86_64_debug /tmp/core.bin -batch -ex "bt" + ``` +2. The backtrace showed `execute_nostr_list_manage` with corrupted frames. +3. Code review of the function identified the `cJSON_Delete(args)` call occurring + before the last use of `action->valuestring`. + +### Fix + +Deferred `cJSON_Delete(args)` until after `action->valuestring` has been consumed +by `cJSON_AddStringToObject()`. The free now occurs immediately after the last use: + +```c +cJSON_AddStringToObject(out, "action", action->valuestring); +cJSON_Delete(args); /* ← moved here, after last use of action */ +``` + +All early-return error paths before this point also received their own +`cJSON_Delete(args)` call to prevent leaks. + +### How to recognise this class of bug in the future + +- `SIGSEGV` with corrupted or nonsensical stack frames. +- Crash location reported by GDB does not match the actual buggy code — the + corruption happened earlier. +- Any function that calls `cJSON_Delete()` on a parent object should be audited + to ensure no child pointers are used afterwards. +- Pattern to watch for: + ```c + cJSON* child = cJSON_GetObjectItemCaseSensitive(parent, "key"); + /* ... */ + cJSON_Delete(parent); + /* ... */ + use(child->valuestring); /* BUG: child is dangling */ + ``` + +--- + +## 3. DM subscription not receiving incoming kind 4 events — INVESTIGATION (v0.0.24) + +**Date**: 2026-03-02 +**Version**: v0.0.24 +**Severity**: High — agent does not respond to incoming DMs +**Status**: Under investigation — diagnostic logging added +**Files changed**: `src/nostr_handler.c` + +### Symptoms + +- The agent starts up normally, connects to relays, and can **send** kind 4 DMs. +- Incoming kind 4 messages posted to the same relays are never processed. +- No error messages appear in the log — the agent simply sits idle after sending + its startup DM. +- The last log lines show successful outbound DM publishing but no inbound event + processing. + +### Possible root causes (under investigation) + +1. **Events never reach `on_event()` callback** — The relay pool library only + calls `on_event()` when it receives an `EVENT` message matching the + subscription ID. If the subscription was silently closed, errored, or not + re-established after a relay reconnect, no events would be delivered. The + relay could also be rejecting the subscription filter. + +2. **`since` filter timing mismatch** — `g_start_time` is set to `time(NULL)` + during `nostr_handler_init()` (early in startup), but the DM subscription + is created much later (after admin context subscription, startup event + reconciliation, and startup DM sending). If the sender's clock is behind + the server's clock, their `created_at` timestamp would be before + `g_start_time` and the relay would filter them out. + +3. **Sender tier filtering** — If the sender's pubkey doesn't match the admin + pubkey and isn't in the WoT contact list, the message is classified as + `DIDACTYL_SENDER_STRANGER` and silently dropped (only a DEBUG_LOG at + level 4). + +4. **Signature verification failure** — If `verify_signatures` is enabled and + the event has an invalid signature, it is dropped with only a WARN log. + +5. **Pool-level deduplication** — The relay pool's `is_event_seen()` cache is + shared across all subscriptions. If an event ID was somehow marked as seen + by another subscription, it would be silently dropped. + +### Diagnostic logging added + +TRACE-level (level 5 / `--debug 5`) logs were added at every decision point +in the `on_event()` callback and the `nostr_handler_subscribe_dms()` setup: + +| Log message prefix | What it tells you | +|---|---| +| `DEBUG on_event ENTRY` | Callback was called — events are reaching didactyl | +| `DEBUG on_event NULL guard` | Event, config, or callback pointer is NULL | +| `DEBUG on_event: missing required fields` | Event JSON is malformed | +| `DEBUG on_event: kind=N id=... from=...` | Event kind, ID, and sender pubkey | +| `DEBUG on_event: ignoring non-kind4` | Event is not kind 4 | +| `DEBUG on_event: no p-tag found` | Kind 4 event has no `p` tag | +| `DEBUG on_event: p-tag mismatch` | `p` tag doesn't match agent's pubkey | +| `DEBUG on_event: sender=... tier=N` | Sender tier classification | +| `DEBUG on_eose called` | EOSE received from relays | +| `DEBUG DM subscription filter` | Full subscription filter JSON | +| `DEBUG DM subscription g_start_time=...` | `since` timestamp and time delta | +| `DEBUG DM subscription sub=...` | Subscription pointer (confirms creation) | + +### How to use the diagnostic logs + +1. Build with `./build_static.sh --debug` +2. Run with `--debug 5` to enable TRACE output +3. Send a kind 4 DM to the agent +4. Check the output: + - **No `on_event ENTRY` lines** → problem is at the relay/subscription level + - **`on_event ENTRY` appears but processing stops** → the specific drop-point + log identifies the exact filter that rejected the event +5. Check the `DM subscription filter` log to verify the `since` timestamp and + `#p` tag are correct + +### How to recognise this class of bug in the future + +- Agent can send but not receive — asymmetric connectivity. +- No error messages in the log — silent event filtering. +- The subscription filter (`since`, `#p`, `kinds`) may not match what the + sender is actually publishing. +- Clock skew between sender and receiver can cause `since` filter mismatches. +- Relay reconnections may not automatically re-subscribe. + +--- + +## General debugging checklist + +When investigating a crash in Didactyl, work through these steps: + +1. **Check for coredumps**: `ls /var/lib/systemd/coredump/ | grep didactyl` +2. **Check dmesg/journalctl**: `dmesg | grep -i 'didactyl\|oom\|segfault'` +3. **Check for the shutdown log line**: If `[didactyl] shutting down` is missing, + the process was killed by a signal that bypassed the handler. +4. **Check ulimit**: `ulimit -c` — if 0, coredumps are disabled. +5. **Extract and analyse coredumps**: + ```bash + zstd -d /var/lib/systemd/coredump/core.didactyl*.zst -o /tmp/core.bin + gdb ./didactyl_static_x86_64_debug /tmp/core.bin -batch -ex "bt full" + ``` +6. **Common silent killers**: + - `SIGPIPE` — process writes to a broken socket/pipe + - `SIGKILL` — OOM killer or external kill + - `SIGBUS` — memory-mapped file issues +7. **Common crash causes**: + - Use-after-free on cJSON child pointers after parent deletion + - Buffer overflows in fixed-size stack buffers + - NULL pointer dereference on failed allocations + - Re-entrancy in callbacks during internal `nostr_relay_pool_poll()` calls diff --git a/src/config.c b/src/config.c index 2523f6b..673b937 100644 --- a/src/config.c +++ b/src/config.c @@ -602,6 +602,7 @@ int config_load(const char* path, didactyl_config_t* config) { config_set_error("unknown configuration error"); memset(config, 0, sizeof(*config)); + snprintf(config->config_path, sizeof(config->config_path), "%s", path); config->tools.enabled = 1; config->tools.max_turns = 8; config->tools.shell.enabled = 1; diff --git a/src/config.h b/src/config.h index 3ac555d..31fb6b6 100644 --- a/src/config.h +++ b/src/config.h @@ -92,6 +92,7 @@ typedef struct { triggers_config_t triggers; startup_event_t* startup_events; int startup_event_count; + char config_path[OW_MAX_URL_LEN]; } didactyl_config_t; int config_load(const char* path, didactyl_config_t* config); diff --git a/src/llm.c b/src/llm.c index 583bdf3..55d867e 100644 --- a/src/llm.c +++ b/src/llm.c @@ -64,15 +64,13 @@ static const char* detect_ca_bundle_path(void) { return NULL; } -static char* perform_chat_request(const char* body) { +static char* perform_http_request(const char* url, const char* body, int is_post) { CURL* curl = curl_easy_init(); - if (!curl || !body) { + if (!curl || !url) { if (curl) curl_easy_cleanup(curl); return NULL; } - char url[OW_MAX_URL_LEN + 64]; - snprintf(url, sizeof(url), "%s/chat/completions", g_cfg.base_url); response_buffer_t rb = {0}; struct curl_slist* headers = NULL; @@ -83,8 +81,11 @@ static char* perform_chat_request(const char* body) { headers = curl_slist_append(headers, auth_header); curl_easy_setopt(curl, CURLOPT_URL, url); - curl_easy_setopt(curl, CURLOPT_POST, 1L); - curl_easy_setopt(curl, CURLOPT_POSTFIELDS, body); + curl_easy_setopt(curl, CURLOPT_HTTPGET, is_post ? 0L : 1L); + if (is_post) { + curl_easy_setopt(curl, CURLOPT_POST, 1L); + curl_easy_setopt(curl, CURLOPT_POSTFIELDS, body ? body : "{}"); + } curl_easy_setopt(curl, CURLOPT_TIMEOUT, 60L); curl_easy_setopt(curl, CURLOPT_WRITEFUNCTION, write_cb); curl_easy_setopt(curl, CURLOPT_WRITEDATA, &rb); @@ -132,6 +133,12 @@ static char* perform_chat_request(const char* body) { return rb.data; } +static char* perform_chat_request(const char* body) { + char url[OW_MAX_URL_LEN + 64]; + snprintf(url, sizeof(url), "%s/chat/completions", g_cfg.base_url); + return perform_http_request(url, body, 1); +} + static char* build_request_json(const char* system_prompt, const char* user_message) { cJSON* root = cJSON_CreateObject(); cJSON* messages = cJSON_CreateArray(); @@ -388,6 +395,36 @@ void llm_response_free(llm_response_t* response) { memset(response, 0, sizeof(*response)); } +int llm_get_config(llm_config_t* out_config) { + if (!g_initialized || !out_config) { + return -1; + } + *out_config = g_cfg; + return 0; +} + +int llm_set_config(const llm_config_t* config) { + if (!g_initialized || !config) { + return -1; + } + g_cfg = *config; + return 0; +} + +char* llm_list_models_json(const char* base_url_override) { + if (!g_initialized) { + return NULL; + } + + const char* base_url = (base_url_override && base_url_override[0] != '\0') + ? base_url_override + : g_cfg.base_url; + + char url[OW_MAX_URL_LEN + 32]; + snprintf(url, sizeof(url), "%s/models", base_url); + return perform_http_request(url, NULL, 0); +} + void llm_cleanup(void) { if (!g_initialized) { return; diff --git a/src/llm.h b/src/llm.h index fb2f8a2..94caa02 100644 --- a/src/llm.h +++ b/src/llm.h @@ -26,6 +26,9 @@ int llm_chat_with_tools_messages(const char* messages_json, const char* tool_choice, llm_response_t* out_response); void llm_response_free(llm_response_t* response); +int llm_get_config(llm_config_t* out_config); +int llm_set_config(const llm_config_t* config); +char* llm_list_models_json(const char* base_url_override); void llm_cleanup(void); #endif \ No newline at end of file diff --git a/src/main.c b/src/main.c index 95d735d..52efa13 100644 --- a/src/main.c +++ b/src/main.c @@ -114,9 +114,17 @@ int main(int argc, char** argv) { } if (dump_schemas || test_tool_name) { + if (llm_init(&cfg.llm) != 0) { + fprintf(stderr, "Failed to initialize llm client\n"); + config_free(&cfg); + nostr_cleanup(); + return 1; + } + if (nostr_handler_init(&cfg) != 0) { fprintf(stderr, "Failed to initialize nostr handler\n"); config_free(&cfg); + llm_cleanup(); nostr_cleanup(); return 1; } @@ -126,6 +134,7 @@ int main(int argc, char** argv) { fprintf(stderr, "Failed to initialize tools context\n"); nostr_handler_cleanup(); config_free(&cfg); + llm_cleanup(); nostr_cleanup(); return 1; } @@ -142,6 +151,7 @@ int main(int argc, char** argv) { tools_cleanup(&tools_ctx); nostr_handler_cleanup(); config_free(&cfg); + llm_cleanup(); nostr_cleanup(); return 1; } @@ -171,6 +181,7 @@ int main(int argc, char** argv) { tools_cleanup(&tools_ctx); nostr_handler_cleanup(); config_free(&cfg); + llm_cleanup(); nostr_cleanup(); return exit_code; } diff --git a/src/main.h b/src/main.h index c2fdb22..b1b3b0a 100644 --- a/src/main.h +++ b/src/main.h @@ -12,8 +12,8 @@ // Using DIDACTYL_ prefix to avoid conflicts with nostr_core_lib VERSION macros #define DIDACTYL_VERSION_MAJOR 0 #define DIDACTYL_VERSION_MINOR 0 -#define DIDACTYL_VERSION_PATCH 24 -#define DIDACTYL_VERSION "v0.0.24" +#define DIDACTYL_VERSION_PATCH 25 +#define DIDACTYL_VERSION "v0.0.25" // Agent metadata #define DIDACTYL_NAME "Didactyl" diff --git a/src/nostr_handler.c b/src/nostr_handler.c index 8c2cb13..af42f2a 100644 --- a/src/nostr_handler.c +++ b/src/nostr_handler.c @@ -429,7 +429,13 @@ static void trace_plaintext_dm(const char* prefix, const char* plaintext) { static void on_event(cJSON* event, const char* relay_url, void* user_data) { (void)user_data; + DEBUG_TRACE("[didactyl] DEBUG on_event ENTRY from %s (event=%p g_cfg=%p g_dm_callback=%s)", + relay_url ? relay_url : "NULL", (void*)event, (void*)g_cfg, + g_dm_callback ? "set" : "NULL"); + if (!event || !g_cfg || !g_dm_callback) { + DEBUG_TRACE("[didactyl] DEBUG on_event NULL guard: event=%p g_cfg=%p g_dm_callback=%s", + (void*)event, (void*)g_cfg, g_dm_callback ? "set" : "NULL"); return; } @@ -447,6 +453,8 @@ static void on_event(cJSON* event, const char* relay_url, void* user_data) { if (!kind || !pubkey || !content || !tags || !cJSON_IsNumber(kind) || !cJSON_IsString(pubkey) || !cJSON_IsString(content)) { + DEBUG_TRACE("[didactyl] DEBUG on_event: missing required fields (kind=%p pubkey=%p content=%p tags=%p)", + (void*)kind, (void*)pubkey, (void*)content, (void*)tags); return; } @@ -454,16 +462,27 @@ static void on_event(cJSON* event, const char* relay_url, void* user_data) { ? id->valuestring : NULL; + DEBUG_TRACE("[didactyl] DEBUG on_event: kind=%d id=%.16s... from=%.16s... via %s", + (int)kind->valuedouble, + event_id_hex ? event_id_hex : "", + pubkey->valuestring ? pubkey->valuestring : "", + relay_url ? relay_url : "unknown"); + if ((int)kind->valuedouble != 4) { + DEBUG_TRACE("[didactyl] DEBUG on_event: ignoring non-kind4 (kind=%d)", (int)kind->valuedouble); return; } char recipient_pubkey_hex[65] = {0}; if (extract_first_p_tag(tags, recipient_pubkey_hex) != 0) { + DEBUG_TRACE("[didactyl] DEBUG on_event: no p-tag found in kind4 event %.16s...", + event_id_hex ? event_id_hex : ""); return; } if (strcmp(recipient_pubkey_hex, g_cfg->keys.public_key_hex) != 0) { + DEBUG_TRACE("[didactyl] DEBUG on_event: p-tag mismatch (got=%.16s... want=%.16s...)", + recipient_pubkey_hex, g_cfg->keys.public_key_hex); return; } @@ -474,6 +493,9 @@ static void on_event(cJSON* event, const char* relay_url, void* user_data) { tier = DIDACTYL_SENDER_WOT; } + DEBUG_TRACE("[didactyl] DEBUG on_event: sender=%.16s... tier=%d (admin=%.16s...)", + pubkey->valuestring, (int)tier, g_cfg->admin.pubkey); + if (tier == DIDACTYL_SENDER_STRANGER) { if (!g_cfg->security.stranger.enabled) { DEBUG_LOG("[didactyl] ignored DM from stranger %.16s... via %s", @@ -531,8 +553,8 @@ static void on_event(cJSON* event, const char* relay_url, void* user_data) { static void on_eose(cJSON** events, int event_count, void* user_data) { (void)events; - (void)event_count; (void)user_data; + DEBUG_TRACE("[didactyl] DEBUG on_eose called: event_count=%d", event_count); } static void free_admin_context_locked(void) { @@ -867,6 +889,14 @@ int nostr_handler_subscribe_dms(dm_callback_t callback, void* user_data) { cJSON_AddNumberToObject(filter, "since", (double)g_start_time); cJSON_AddNumberToObject(filter, "limit", 100); + { + char* filter_str = cJSON_PrintUnformatted(filter); + DEBUG_TRACE("[didactyl] DEBUG DM subscription filter: %s", filter_str ? filter_str : ""); + DEBUG_TRACE("[didactyl] DEBUG DM subscription g_start_time=%ld now=%ld delta=%ld relay_count=%d", + (long)g_start_time, (long)time(NULL), (long)(time(NULL) - g_start_time), g_cfg->relay_count); + free(filter_str); + } + nostr_pool_subscription_t* sub = nostr_relay_pool_subscribe( g_pool, (const char**)g_cfg->relays, @@ -888,6 +918,7 @@ int nostr_handler_subscribe_dms(dm_callback_t callback, void* user_data) { } DEBUG_INFO("[didactyl] DM subscription active for pubkey %.16s...", g_cfg->keys.public_key_hex); + DEBUG_TRACE("[didactyl] DEBUG DM subscription sub=%p close_on_eose=0 dedup=1", (void*)sub); return 0; } diff --git a/src/tools.c b/src/tools.c index 2e44a27..00e3e34 100644 --- a/src/tools.c +++ b/src/tools.c @@ -17,6 +17,7 @@ #include "main.h" #include "nostr_handler.h" #include "trigger_manager.h" +#include "llm.h" #include "../../nostr_core_lib/nostr_core/nostr_core.h" static char* json_error(const char* msg) { @@ -1707,6 +1708,79 @@ char* tools_build_openai_schema_json(const tools_context_t* ctx) { cJSON_AddItemToObject(t29, "function", t29_fn); cJSON_AddItemToArray(tools, t29); + cJSON* t30 = cJSON_CreateObject(); + cJSON* t30_fn = cJSON_CreateObject(); + cJSON* t30_params = cJSON_CreateObject(); + cJSON* t30_props = cJSON_CreateObject(); + + cJSON_AddStringToObject(t30, "type", "function"); + cJSON_AddStringToObject(t30_fn, "name", "model_get"); + cJSON_AddStringToObject(t30_fn, "description", "Get current active LLM runtime configuration (excluding API key)"); + cJSON_AddStringToObject(t30_params, "type", "object"); + cJSON_AddItemToObject(t30_params, "properties", t30_props); + + cJSON_AddItemToObject(t30_fn, "parameters", t30_params); + cJSON_AddItemToObject(t30, "function", t30_fn); + cJSON_AddItemToArray(tools, t30); + + cJSON* t31 = cJSON_CreateObject(); + cJSON* t31_fn = cJSON_CreateObject(); + cJSON* t31_params = cJSON_CreateObject(); + cJSON* t31_props = cJSON_CreateObject(); + + cJSON_AddStringToObject(t31, "type", "function"); + cJSON_AddStringToObject(t31_fn, "name", "model_set"); + cJSON_AddStringToObject(t31_fn, "description", "Update active LLM configuration and persist it to config.json"); + cJSON_AddStringToObject(t31_params, "type", "object"); + cJSON_AddItemToObject(t31_params, "properties", t31_props); + + cJSON* p_model_set_provider = cJSON_CreateObject(); + cJSON_AddStringToObject(p_model_set_provider, "type", "string"); + cJSON_AddItemToObject(t31_props, "provider", p_model_set_provider); + + cJSON* p_model_set_api_key = cJSON_CreateObject(); + cJSON_AddStringToObject(p_model_set_api_key, "type", "string"); + cJSON_AddItemToObject(t31_props, "api_key", p_model_set_api_key); + + cJSON* p_model_set_model = cJSON_CreateObject(); + cJSON_AddStringToObject(p_model_set_model, "type", "string"); + cJSON_AddItemToObject(t31_props, "model", p_model_set_model); + + cJSON* p_model_set_base_url = cJSON_CreateObject(); + cJSON_AddStringToObject(p_model_set_base_url, "type", "string"); + cJSON_AddItemToObject(t31_props, "base_url", p_model_set_base_url); + + cJSON* p_model_set_max_tokens = cJSON_CreateObject(); + cJSON_AddStringToObject(p_model_set_max_tokens, "type", "integer"); + cJSON_AddItemToObject(t31_props, "max_tokens", p_model_set_max_tokens); + + cJSON* p_model_set_temperature = cJSON_CreateObject(); + cJSON_AddStringToObject(p_model_set_temperature, "type", "number"); + cJSON_AddItemToObject(t31_props, "temperature", p_model_set_temperature); + + cJSON_AddItemToObject(t31_fn, "parameters", t31_params); + cJSON_AddItemToObject(t31, "function", t31_fn); + cJSON_AddItemToArray(tools, t31); + + cJSON* t32 = cJSON_CreateObject(); + cJSON* t32_fn = cJSON_CreateObject(); + cJSON* t32_params = cJSON_CreateObject(); + cJSON* t32_props = cJSON_CreateObject(); + + cJSON_AddStringToObject(t32, "type", "function"); + cJSON_AddStringToObject(t32_fn, "name", "model_list"); + cJSON_AddStringToObject(t32_fn, "description", "List available model IDs using provider OpenAI-compatible /models endpoint"); + cJSON_AddStringToObject(t32_params, "type", "object"); + cJSON_AddItemToObject(t32_params, "properties", t32_props); + + cJSON* p_model_list_base_url = cJSON_CreateObject(); + cJSON_AddStringToObject(p_model_list_base_url, "type", "string"); + cJSON_AddItemToObject(t32_props, "base_url", p_model_list_base_url); + + cJSON_AddItemToObject(t32_fn, "parameters", t32_params); + cJSON_AddItemToObject(t32, "function", t32_fn); + cJSON_AddItemToArray(tools, t32); + char* out = cJSON_PrintUnformatted(tools); cJSON_Delete(tools); return out; @@ -4278,6 +4352,256 @@ static char* execute_trigger_list(tools_context_t* ctx, const char* args_json) { return status; } +static int json_object_set_string(cJSON* obj, const char* key, const char* value) { + if (!obj || !key || !value) return -1; + cJSON_DeleteItemFromObjectCaseSensitive(obj, key); + cJSON* v = cJSON_CreateString(value); + if (!v) return -1; + cJSON_AddItemToObject(obj, key, v); + return 0; +} + +static int persist_llm_config(tools_context_t* ctx, const llm_config_t* cfg) { + if (!ctx || !ctx->cfg || !cfg) return -1; + if (ctx->cfg->config_path[0] == '\0') return -1; + + size_t src_len = 0; + char* src = read_entire_file(ctx->cfg->config_path, &src_len); + if (!src) return -1; + + cJSON* root = cJSON_ParseWithLength(src, src_len); + free(src); + if (!root || !cJSON_IsObject(root)) { + cJSON_Delete(root); + return -1; + } + + cJSON* llm = cJSON_GetObjectItemCaseSensitive(root, "llm"); + if (!llm || !cJSON_IsObject(llm)) { + cJSON_DeleteItemFromObjectCaseSensitive(root, "llm"); + llm = cJSON_CreateObject(); + if (!llm) { + cJSON_Delete(root); + return -1; + } + cJSON_AddItemToObject(root, "llm", llm); + } + + if (json_object_set_string(llm, "provider", cfg->provider) != 0 || + json_object_set_string(llm, "api_key", cfg->api_key) != 0 || + json_object_set_string(llm, "model", cfg->model) != 0 || + json_object_set_string(llm, "base_url", cfg->base_url) != 0) { + cJSON_Delete(root); + return -1; + } + + cJSON_DeleteItemFromObjectCaseSensitive(llm, "max_tokens"); + cJSON_AddNumberToObject(llm, "max_tokens", cfg->max_tokens); + cJSON_DeleteItemFromObjectCaseSensitive(llm, "temperature"); + cJSON_AddNumberToObject(llm, "temperature", cfg->temperature); + + char* out = cJSON_Print(root); + cJSON_Delete(root); + if (!out) return -1; + + FILE* fp = fopen(ctx->cfg->config_path, "wb"); + if (!fp) { + free(out); + return -1; + } + + size_t out_len = strlen(out); + size_t n = fwrite(out, 1, out_len, fp); + fclose(fp); + free(out); + return n == out_len ? 0 : -1; +} + +static int assign_string_field(cJSON* item, char* dst, size_t dst_size, int* changed) { + if (!item) return 0; + if (!cJSON_IsString(item) || !item->valuestring) return -1; + size_t n = strlen(item->valuestring); + if (n >= dst_size) return -1; + memcpy(dst, item->valuestring, n + 1U); + if (changed) *changed = 1; + return 0; +} + +static char* execute_model_get(const char* args_json) { + cJSON* args = parse_tool_args_json(args_json); + if (!args) return json_error("invalid arguments JSON"); + cJSON_Delete(args); + + llm_config_t cfg; + if (llm_get_config(&cfg) != 0) { + return json_error("llm runtime unavailable"); + } + + cJSON* out = cJSON_CreateObject(); + if (!out) return NULL; + cJSON_AddBoolToObject(out, "success", 1); + cJSON_AddStringToObject(out, "provider", cfg.provider); + cJSON_AddStringToObject(out, "model", cfg.model); + cJSON_AddStringToObject(out, "base_url", cfg.base_url); + cJSON_AddNumberToObject(out, "max_tokens", cfg.max_tokens); + cJSON_AddNumberToObject(out, "temperature", cfg.temperature); + + char* json = cJSON_PrintUnformatted(out); + cJSON_Delete(out); + return json; +} + +static char* execute_model_set(tools_context_t* ctx, const char* args_json) { + if (!ctx || !ctx->cfg) return json_error("tool context unavailable"); + + cJSON* args = parse_tool_args_json(args_json); + if (!args) return json_error("invalid arguments JSON"); + + llm_config_t cfg; + if (llm_get_config(&cfg) != 0) { + cJSON_Delete(args); + return json_error("llm runtime unavailable"); + } + + int changed = 0; + + cJSON* provider = cJSON_GetObjectItemCaseSensitive(args, "provider"); + cJSON* api_key = cJSON_GetObjectItemCaseSensitive(args, "api_key"); + cJSON* model = cJSON_GetObjectItemCaseSensitive(args, "model"); + cJSON* base_url = cJSON_GetObjectItemCaseSensitive(args, "base_url"); + cJSON* max_tokens = cJSON_GetObjectItemCaseSensitive(args, "max_tokens"); + cJSON* temperature = cJSON_GetObjectItemCaseSensitive(args, "temperature"); + + if (assign_string_field(provider, cfg.provider, sizeof(cfg.provider), &changed) != 0 || + assign_string_field(api_key, cfg.api_key, sizeof(cfg.api_key), &changed) != 0 || + assign_string_field(model, cfg.model, sizeof(cfg.model), &changed) != 0 || + assign_string_field(base_url, cfg.base_url, sizeof(cfg.base_url), &changed) != 0) { + cJSON_Delete(args); + return json_error("model_set string field invalid or too long"); + } + + if (max_tokens) { + if (!cJSON_IsNumber(max_tokens)) { + cJSON_Delete(args); + return json_error("model_set max_tokens must be a number"); + } + cfg.max_tokens = (int)max_tokens->valuedouble; + changed = 1; + } + + if (temperature) { + if (!cJSON_IsNumber(temperature)) { + cJSON_Delete(args); + return json_error("model_set temperature must be a number"); + } + cfg.temperature = temperature->valuedouble; + changed = 1; + } + + cJSON_Delete(args); + + if (!changed) { + return json_error("model_set requires at least one field to update"); + } + + if (llm_set_config(&cfg) != 0) { + return json_error("failed to update runtime llm config"); + } + + ctx->cfg->llm = cfg; + if (persist_llm_config(ctx, &cfg) != 0) { + return json_error("failed to persist llm config to config file"); + } + + cJSON* out = cJSON_CreateObject(); + if (!out) return NULL; + cJSON_AddBoolToObject(out, "success", 1); + cJSON_AddStringToObject(out, "provider", cfg.provider); + cJSON_AddStringToObject(out, "model", cfg.model); + cJSON_AddStringToObject(out, "base_url", cfg.base_url); + cJSON_AddNumberToObject(out, "max_tokens", cfg.max_tokens); + cJSON_AddNumberToObject(out, "temperature", cfg.temperature); + + char* json = cJSON_PrintUnformatted(out); + cJSON_Delete(out); + return json; +} + +static void append_model_id(cJSON* ids, cJSON* item) { + if (!ids || !item) return; + if (cJSON_IsString(item) && item->valuestring) { + cJSON_AddItemToArray(ids, cJSON_CreateString(item->valuestring)); + return; + } + if (cJSON_IsObject(item)) { + cJSON* id = cJSON_GetObjectItemCaseSensitive(item, "id"); + if (id && cJSON_IsString(id) && id->valuestring) { + cJSON_AddItemToArray(ids, cJSON_CreateString(id->valuestring)); + } + } +} + +static char* execute_model_list(const char* args_json) { + cJSON* args = parse_tool_args_json(args_json); + if (!args) return json_error("invalid arguments JSON"); + + cJSON* base_url = cJSON_GetObjectItemCaseSensitive(args, "base_url"); + if (base_url && (!cJSON_IsString(base_url) || !base_url->valuestring)) { + cJSON_Delete(args); + return json_error("model_list base_url must be a string"); + } + + const char* base_url_override = (base_url && base_url->valuestring && base_url->valuestring[0] != '\0') + ? base_url->valuestring + : NULL; + + char* raw = llm_list_models_json(base_url_override); + cJSON_Delete(args); + if (!raw) { + return json_error("model_list request failed"); + } + + cJSON* root = cJSON_Parse(raw); + free(raw); + if (!root) { + cJSON_Delete(root); + return json_error("model_list returned invalid JSON"); + } + + cJSON* ids = cJSON_CreateArray(); + cJSON* out = cJSON_CreateObject(); + if (!ids || !out) { + cJSON_Delete(ids); + cJSON_Delete(out); + cJSON_Delete(root); + return NULL; + } + + if (cJSON_IsArray(root)) { + int n = cJSON_GetArraySize(root); + for (int i = 0; i < n; i++) { + append_model_id(ids, cJSON_GetArrayItem(root, i)); + } + } else if (cJSON_IsObject(root)) { + cJSON* data = cJSON_GetObjectItemCaseSensitive(root, "data"); + if (data && cJSON_IsArray(data)) { + int n = cJSON_GetArraySize(data); + for (int i = 0; i < n; i++) { + append_model_id(ids, cJSON_GetArrayItem(data, i)); + } + } + } + + cJSON_AddBoolToObject(out, "success", 1); + cJSON_AddNumberToObject(out, "count", cJSON_GetArraySize(ids)); + cJSON_AddItemToObject(out, "models", ids); + + char* json = cJSON_PrintUnformatted(out); + cJSON_Delete(out); + cJSON_Delete(root); + return json; +} + char* tools_execute(tools_context_t* ctx, const char* tool_name, const char* args_json) { if (!tool_name) return json_error("missing tool name"); @@ -4362,6 +4686,15 @@ char* tools_execute(tools_context_t* ctx, const char* tool_name, const char* arg if (strcmp(tool_name, "tool_list") == 0) { return execute_tool_list(ctx, args_json); } + if (strcmp(tool_name, "model_get") == 0) { + return execute_model_get(args_json); + } + if (strcmp(tool_name, "model_set") == 0) { + return execute_model_set(ctx, args_json); + } + if (strcmp(tool_name, "model_list") == 0) { + return execute_model_list(args_json); + } if (strcmp(tool_name, "nostr_post_readme") == 0) { return execute_nostr_post_readme(ctx, args_json);