v0.0.25 - Add model_get/model_set/model_list tools with persisted LLM config updates and model discovery

This commit is contained in:
Your Name
2026-03-02 07:08:19 -04:00
parent a2d3f840c7
commit 4a400f1582
10 changed files with 696 additions and 11 deletions

View File

@@ -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. 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.** **Active build — this project is barely working. Experiment at your own risk.**
> Last release update: v0.0.24Handle SIGPIPE disconnect crash and fix nostr_list_manage use-after-free > Last release update: v0.0.25Add 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 - Connects to configured relays with auto-reconnect and relay state transition logging
- Publishes configured startup events per relay as each relay becomes connected - Publishes configured startup events per relay as each relay becomes connected

268
docs/CRASH_FIXES.md Normal file
View File

@@ -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

View File

@@ -602,6 +602,7 @@ int config_load(const char* path, didactyl_config_t* config) {
config_set_error("unknown configuration error"); config_set_error("unknown configuration error");
memset(config, 0, sizeof(*config)); memset(config, 0, sizeof(*config));
snprintf(config->config_path, sizeof(config->config_path), "%s", path);
config->tools.enabled = 1; config->tools.enabled = 1;
config->tools.max_turns = 8; config->tools.max_turns = 8;
config->tools.shell.enabled = 1; config->tools.shell.enabled = 1;

View File

@@ -92,6 +92,7 @@ typedef struct {
triggers_config_t triggers; triggers_config_t triggers;
startup_event_t* startup_events; startup_event_t* startup_events;
int startup_event_count; int startup_event_count;
char config_path[OW_MAX_URL_LEN];
} didactyl_config_t; } didactyl_config_t;
int config_load(const char* path, didactyl_config_t* config); int config_load(const char* path, didactyl_config_t* config);

View File

@@ -64,15 +64,13 @@ static const char* detect_ca_bundle_path(void) {
return NULL; 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(); CURL* curl = curl_easy_init();
if (!curl || !body) { if (!curl || !url) {
if (curl) curl_easy_cleanup(curl); if (curl) curl_easy_cleanup(curl);
return NULL; 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}; response_buffer_t rb = {0};
struct curl_slist* headers = NULL; struct curl_slist* headers = NULL;
@@ -83,8 +81,11 @@ static char* perform_chat_request(const char* body) {
headers = curl_slist_append(headers, auth_header); headers = curl_slist_append(headers, auth_header);
curl_easy_setopt(curl, CURLOPT_URL, url); curl_easy_setopt(curl, CURLOPT_URL, url);
curl_easy_setopt(curl, CURLOPT_POST, 1L); curl_easy_setopt(curl, CURLOPT_HTTPGET, is_post ? 0L : 1L);
curl_easy_setopt(curl, CURLOPT_POSTFIELDS, body); 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_TIMEOUT, 60L);
curl_easy_setopt(curl, CURLOPT_WRITEFUNCTION, write_cb); curl_easy_setopt(curl, CURLOPT_WRITEFUNCTION, write_cb);
curl_easy_setopt(curl, CURLOPT_WRITEDATA, &rb); curl_easy_setopt(curl, CURLOPT_WRITEDATA, &rb);
@@ -132,6 +133,12 @@ static char* perform_chat_request(const char* body) {
return rb.data; 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) { static char* build_request_json(const char* system_prompt, const char* user_message) {
cJSON* root = cJSON_CreateObject(); cJSON* root = cJSON_CreateObject();
cJSON* messages = cJSON_CreateArray(); cJSON* messages = cJSON_CreateArray();
@@ -388,6 +395,36 @@ void llm_response_free(llm_response_t* response) {
memset(response, 0, sizeof(*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) { void llm_cleanup(void) {
if (!g_initialized) { if (!g_initialized) {
return; return;

View File

@@ -26,6 +26,9 @@ int llm_chat_with_tools_messages(const char* messages_json,
const char* tool_choice, const char* tool_choice,
llm_response_t* out_response); llm_response_t* out_response);
void llm_response_free(llm_response_t* 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); void llm_cleanup(void);
#endif #endif

View File

@@ -114,9 +114,17 @@ int main(int argc, char** argv) {
} }
if (dump_schemas || test_tool_name) { 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) { if (nostr_handler_init(&cfg) != 0) {
fprintf(stderr, "Failed to initialize nostr handler\n"); fprintf(stderr, "Failed to initialize nostr handler\n");
config_free(&cfg); config_free(&cfg);
llm_cleanup();
nostr_cleanup(); nostr_cleanup();
return 1; return 1;
} }
@@ -126,6 +134,7 @@ int main(int argc, char** argv) {
fprintf(stderr, "Failed to initialize tools context\n"); fprintf(stderr, "Failed to initialize tools context\n");
nostr_handler_cleanup(); nostr_handler_cleanup();
config_free(&cfg); config_free(&cfg);
llm_cleanup();
nostr_cleanup(); nostr_cleanup();
return 1; return 1;
} }
@@ -142,6 +151,7 @@ int main(int argc, char** argv) {
tools_cleanup(&tools_ctx); tools_cleanup(&tools_ctx);
nostr_handler_cleanup(); nostr_handler_cleanup();
config_free(&cfg); config_free(&cfg);
llm_cleanup();
nostr_cleanup(); nostr_cleanup();
return 1; return 1;
} }
@@ -171,6 +181,7 @@ int main(int argc, char** argv) {
tools_cleanup(&tools_ctx); tools_cleanup(&tools_ctx);
nostr_handler_cleanup(); nostr_handler_cleanup();
config_free(&cfg); config_free(&cfg);
llm_cleanup();
nostr_cleanup(); nostr_cleanup();
return exit_code; return exit_code;
} }

View File

@@ -12,8 +12,8 @@
// Using DIDACTYL_ prefix to avoid conflicts with nostr_core_lib VERSION macros // Using DIDACTYL_ prefix to avoid conflicts with nostr_core_lib VERSION macros
#define DIDACTYL_VERSION_MAJOR 0 #define DIDACTYL_VERSION_MAJOR 0
#define DIDACTYL_VERSION_MINOR 0 #define DIDACTYL_VERSION_MINOR 0
#define DIDACTYL_VERSION_PATCH 24 #define DIDACTYL_VERSION_PATCH 25
#define DIDACTYL_VERSION "v0.0.24" #define DIDACTYL_VERSION "v0.0.25"
// Agent metadata // Agent metadata
#define DIDACTYL_NAME "Didactyl" #define DIDACTYL_NAME "Didactyl"

View File

@@ -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) { static void on_event(cJSON* event, const char* relay_url, void* user_data) {
(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) { 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; return;
} }
@@ -447,6 +453,8 @@ static void on_event(cJSON* event, const char* relay_url, void* user_data) {
if (!kind || !pubkey || !content || !tags || if (!kind || !pubkey || !content || !tags ||
!cJSON_IsNumber(kind) || !cJSON_IsString(pubkey) || !cJSON_IsString(content)) { !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; return;
} }
@@ -454,16 +462,27 @@ static void on_event(cJSON* event, const char* relay_url, void* user_data) {
? id->valuestring ? id->valuestring
: NULL; : NULL;
DEBUG_TRACE("[didactyl] DEBUG on_event: kind=%d id=%.16s... from=%.16s... via %s",
(int)kind->valuedouble,
event_id_hex ? event_id_hex : "<no-id>",
pubkey->valuestring ? pubkey->valuestring : "<no-pk>",
relay_url ? relay_url : "unknown");
if ((int)kind->valuedouble != 4) { if ((int)kind->valuedouble != 4) {
DEBUG_TRACE("[didactyl] DEBUG on_event: ignoring non-kind4 (kind=%d)", (int)kind->valuedouble);
return; return;
} }
char recipient_pubkey_hex[65] = {0}; char recipient_pubkey_hex[65] = {0};
if (extract_first_p_tag(tags, recipient_pubkey_hex) != 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 : "<no-id>");
return; return;
} }
if (strcmp(recipient_pubkey_hex, g_cfg->keys.public_key_hex) != 0) { 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; return;
} }
@@ -474,6 +493,9 @@ static void on_event(cJSON* event, const char* relay_url, void* user_data) {
tier = DIDACTYL_SENDER_WOT; 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 (tier == DIDACTYL_SENDER_STRANGER) {
if (!g_cfg->security.stranger.enabled) { if (!g_cfg->security.stranger.enabled) {
DEBUG_LOG("[didactyl] ignored DM from stranger %.16s... via %s", 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) { static void on_eose(cJSON** events, int event_count, void* user_data) {
(void)events; (void)events;
(void)event_count;
(void)user_data; (void)user_data;
DEBUG_TRACE("[didactyl] DEBUG on_eose called: event_count=%d", event_count);
} }
static void free_admin_context_locked(void) { 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, "since", (double)g_start_time);
cJSON_AddNumberToObject(filter, "limit", 100); cJSON_AddNumberToObject(filter, "limit", 100);
{
char* filter_str = cJSON_PrintUnformatted(filter);
DEBUG_TRACE("[didactyl] DEBUG DM subscription filter: %s", filter_str ? filter_str : "<null>");
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( nostr_pool_subscription_t* sub = nostr_relay_pool_subscribe(
g_pool, g_pool,
(const char**)g_cfg->relays, (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_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; return 0;
} }

View File

@@ -17,6 +17,7 @@
#include "main.h" #include "main.h"
#include "nostr_handler.h" #include "nostr_handler.h"
#include "trigger_manager.h" #include "trigger_manager.h"
#include "llm.h"
#include "../../nostr_core_lib/nostr_core/nostr_core.h" #include "../../nostr_core_lib/nostr_core/nostr_core.h"
static char* json_error(const char* msg) { 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_AddItemToObject(t29, "function", t29_fn);
cJSON_AddItemToArray(tools, t29); 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); char* out = cJSON_PrintUnformatted(tools);
cJSON_Delete(tools); cJSON_Delete(tools);
return out; return out;
@@ -4278,6 +4352,256 @@ static char* execute_trigger_list(tools_context_t* ctx, const char* args_json) {
return status; 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) { char* tools_execute(tools_context_t* ctx, const char* tool_name, const char* args_json) {
if (!tool_name) return json_error("missing tool name"); 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) { if (strcmp(tool_name, "tool_list") == 0) {
return execute_tool_list(ctx, args_json); 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) { if (strcmp(tool_name, "nostr_post_readme") == 0) {
return execute_nostr_post_readme(ctx, args_json); return execute_nostr_post_readme(ctx, args_json);