From c221104bc01ea0497cb7895f9bd685840bf41415 Mon Sep 17 00:00:00 2001 From: Your Name Date: Wed, 18 Mar 2026 15:58:33 -0400 Subject: [PATCH] v0.0.82 - Move model/task persistence to kind 30078, add finish_reason stall diagnostics, and remove local config/tasks file coupling --- README.md | 4 +- deploy_lt.sh | 35 ++- ...l_file_persistence_and_stall_diagnostic.md | 146 +++++++++++ src/agent.c | 19 +- src/http_api.c | 16 +- src/llm.c | 6 + src/llm.h | 1 + src/main.h | 4 +- src/tools/tool_model.c | 146 ++++------- src/tools/tool_task.c | 236 +++++++++++------- src/tools/tools_schema.c | 12 +- 11 files changed, 432 insertions(+), 193 deletions(-) create mode 100644 plans/fix_local_file_persistence_and_stall_diagnostic.md diff --git a/README.md b/README.md index a7d65f7..e6a3599 100644 --- a/README.md +++ b/README.md @@ -55,11 +55,11 @@ Skills compose by adoption-list order (`10123`) and trigger tags carry runtime e 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.81 +## Current Status — v0.0.82 **Active build — this project is barely working. Experiment at your own risk.** -> Last release update: v0.0.81 — Add loop progress controls and admin limit diagnostics for DM, HTTP API, and triggers +> Last release update: v0.0.82 — Move model/task persistence to kind 30078, add finish_reason stall diagnostics, and remove local config/tasks file coupling - 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/deploy_lt.sh b/deploy_lt.sh index 106976d..dea1051 100755 --- a/deploy_lt.sh +++ b/deploy_lt.sh @@ -4,16 +4,23 @@ set -euo pipefail SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" SOURCE_BINARY="$SCRIPT_DIR/didactyl_static_x86_64" -REMOTE_TARGET="ubuntu@laantungir.net:~/didactyl" +REMOTE_HOST="ubuntu@laantungir.net" +REMOTE_TARGETS=( + "/home/simon/didactyl" + "/home/didactyl/didactyl" +) +REMOTE_SERVICES=( + "simon.service" + "didactyl.service" +) if ! command -v rsync >/dev/null 2>&1; then echo "ERROR: rsync is not installed or not in PATH" exit 1 fi -if [ ! -f "$SOURCE_BINARY" ]; then - echo "ERROR: Source binary not found: $SOURCE_BINARY" - echo "Build it first so didactyl_static_x86_64 exists." +if ! command -v ssh >/dev/null 2>&1; then + echo "ERROR: ssh is not installed or not in PATH" exit 1 fi @@ -25,6 +32,20 @@ if [ ! -f "$SOURCE_BINARY" ]; then exit 1 fi -echo "Deploying $SOURCE_BINARY to $REMOTE_TARGET" -rsync -avz --progress "$SOURCE_BINARY" "$REMOTE_TARGET" -echo "Deployment complete." \ No newline at end of file +REMOTE_STAGE="/tmp/didactyl_static_x86_64" + +echo "Uploading $SOURCE_BINARY to staging path $REMOTE_HOST:$REMOTE_STAGE" +rsync -avz --progress "$SOURCE_BINARY" "$REMOTE_HOST:$REMOTE_STAGE" + +for target in "${REMOTE_TARGETS[@]}"; do + echo "Installing staged binary to $target via sudo" + ssh "$REMOTE_HOST" "sudo install -m 0755 '$REMOTE_STAGE' '$target'" +done + +echo "Restarting services on $REMOTE_HOST: ${REMOTE_SERVICES[*]}" +ssh "$REMOTE_HOST" "sudo systemctl restart ${REMOTE_SERVICES[*]}" + +echo "Cleaning up remote staging file $REMOTE_STAGE" +ssh "$REMOTE_HOST" "rm -f '$REMOTE_STAGE'" + +echo "Deployment complete for all targets and services." \ No newline at end of file diff --git a/plans/fix_local_file_persistence_and_stall_diagnostic.md b/plans/fix_local_file_persistence_and_stall_diagnostic.md new file mode 100644 index 0000000..b21937b --- /dev/null +++ b/plans/fix_local_file_persistence_and_stall_diagnostic.md @@ -0,0 +1,146 @@ +# Fix: Local File Persistence & Stall Diagnostic Improvements + +## Context + +The agent got stuck in a stall loop when trying to create a skill via DM. The root cause is `max_tokens=512` truncating the LLM's tool call JSON. The admin tried to fix it with `/model_set {"max_tokens": 10000}` but got `"failed to persist llm config to config file"` because `model_set` tries to write to a local config file that doesn't exist in the Nostr-native deployment model. + +Investigation revealed two tools that incorrectly persist state to local files instead of kind 30078 Nostr events, plus missing diagnostic information in the stall detector. + +## Hardcoded File Locations to Remove + +### tool_model.c +- `ctx->cfg->config_path` — references a local config file (genesis.jsonc or config.jsonc) +- The entire `persist_llm_config()` function (lines 82-139) reads/writes this file +- The `read_entire_file_local()` helper (lines 42-80) exists only for this purpose + +### tool_task.c +- `"tasks.json"` — hardcoded at line 188: `build_tool_path_local(ctx, "tasks.json", ...)` +- `tasks_load_root_local()` (lines 82-141) reads from this file +- `tasks_save_root_local()` (lines 143-159) writes to this file + +### tools_schema.c +- Line 985: `"Update active LLM configuration and persist it to config.jsonc"` — mentions config.jsonc +- Line 1285: `"Build current task list context block from tasks.json"` — mentions tasks.json +- Line 1314: `"Manage agent short-term task memory stored in tasks.json"` — mentions tasks.json + +## Fixes + +### Fix 1: model_set — Persist to Kind 30078 + +**File:** `src/tools/tool_model.c` + +Replace `persist_llm_config()` (lines 82-139) with a function that: +1. Builds a JSON object with LLM config fields (provider, model, base_url, max_tokens, temperature — NOT api_key for security) +2. NIP-44 self-encrypts it using the same pattern as `config_store` in `tool_config.c` +3. Publishes as kind 30078 with tags `["d", "llm_config"]` and `["app", "didactyl"]` + +Delete the `read_entire_file_local()` helper (lines 42-80) and the `json_object_set_string()` helper (lines 33-40) — they only exist for the file-based persist. + +Also update `execute_model_set()` (line 234) to call the new Nostr-based persist, and return success with a warning if persist fails (since runtime update already succeeded at line 229). + +### Fix 2: model_set — Don't Fail When Only Persist Fails + +**File:** `src/tools/tool_model.c`, lines 229-236 + +Currently: +```c +if (llm_set_config(&cfg) != 0) { + return json_error_local("failed to update runtime llm config"); +} +ctx->cfg->llm = cfg; +if (persist_llm_config(ctx, &cfg) != 0) { + return json_error_local("failed to persist llm config to config file"); +} +``` + +Change to: return success with a `"persist_warning"` field if persist fails, since the runtime update already took effect. + +### Fix 3: task_manage — Persist to Kind 30078 + +**File:** `src/tools/tool_task.c` + +Replace `tasks_load_root_local()` and `tasks_save_root_local()` with: +- `tasks_load_root_nostr()` — queries kind 30078 `d=tasks` from self, NIP-44 decrypts, parses JSON +- `tasks_save_root_nostr()` — serializes JSON, NIP-44 self-encrypts, publishes kind 30078 `d=tasks` + +This follows the exact same pattern as `config_store`/`config_recall` in `tool_config.c` and `memory_save`/`memory_recall` in `tool_memory.c`. + +Remove the hardcoded `"tasks.json"` string at line 188 and the `build_tool_path_local()` call for it. + +Remove `tasks_path` from the response JSON at line 424. + +### Fix 4: Update Schema Descriptions + +**File:** `src/tools/tools_schema.c` + +- Line 985: Change `"Update active LLM configuration and persist it to config.jsonc"` to `"Update active LLM configuration and persist it to Nostr"` +- Line 1285: Change `"Build current task list context block from tasks.json"` to `"Build current task list context block from agent task memory"` +- Line 1314: Change `"Manage agent short-term task memory stored in tasks.json (list/add/update/remove/clear/replace)"` to `"Manage agent short-term task memory stored on Nostr (list/add/update/remove/clear/replace)"` + +### Fix 5: Add finish_reason to LLM Response + +**File:** `src/llm.h` + +Add `char* finish_reason;` to `llm_response_t`: +```c +typedef struct { + char* content; + llm_tool_call_t* tool_calls; + int tool_call_count; + char* finish_reason; +} llm_response_t; +``` + +**File:** `src/llm.c` + +In `parse_llm_response()` (line 276), after extracting `msg` from `choices[0]`, extract `finish_reason`: +```c +cJSON* fr = first ? cJSON_GetObjectItemCaseSensitive(first, "finish_reason") : NULL; +if (fr && cJSON_IsString(fr) && fr->valuestring) { + out->finish_reason = strdup(fr->valuestring); +} +``` + +In `llm_response_free()`, add `free(response->finish_reason);`. + +### Fix 6: Add max_tokens Truncation Hint to Stall Diagnostic + +**File:** `src/agent.c` + +In the stall detection block at line 2217, before freeing `resp`, check `finish_reason`: +```c +if (repeated_tool_turns >= stall_repeat_threshold) { + int truncated = resp.finish_reason && strcmp(resp.finish_reason, "length") == 0; + exited_on_stall = 1; + llm_response_free(&resp); + break; +} +``` + +In `notify_admin_limit_diagnostic()` (line 106), add fields: +- `possible_cause` — "max_tokens_truncation" when finish_reason was "length" +- `current_max_tokens` — the current max_tokens value +- `hint` — "Increase max_tokens: /model_set {\"max_tokens\": 4096}" + +**File:** `src/http_api.c` + +Same changes in the HTTP API tool loop stall detection at line 966. + +## Execution Order + +1. Fix 5 (finish_reason in llm_response_t) — no dependencies +2. Fix 6 (stall diagnostic) — depends on Fix 5 +3. Fix 1 (model_set persist to Nostr) — independent +4. Fix 2 (model_set graceful failure) — can be done with Fix 1 +5. Fix 3 (task_manage persist to Nostr) — independent +6. Fix 4 (schema descriptions) — do last, trivial + +## Files Modified + +- `src/llm.h` — add finish_reason field +- `src/llm.c` — parse finish_reason, free it +- `src/agent.c` — stall diagnostic with truncation hint +- `src/http_api.c` — stall diagnostic with truncation hint +- `src/tools/tool_model.c` — replace file persist with kind 30078, graceful failure +- `src/tools/tool_task.c` — replace file I/O with kind 30078 +- `src/tools/tools_schema.c` — update 3 descriptions diff --git a/src/agent.c b/src/agent.c index 5c3981f..2fac31e 100644 --- a/src/agent.c +++ b/src/agent.c @@ -106,12 +106,15 @@ static void notify_admin_limit_diagnostic(const char* source, int turns_run, int stall_repeat_threshold, int repeated_tool_turns, + int current_max_tokens, + const char* possible_cause, + const char* hint, const char* final_answer) { if (!g_cfg || g_cfg->admin.pubkey[0] == '\0') { return; } - char diag[1400]; + char diag[1800]; snprintf(diag, sizeof(diag), "⚠️ Didactyl limit diagnostic\n" @@ -122,6 +125,9 @@ static void notify_admin_limit_diagnostic(const char* source, "turns_run=%d\n" "stall_repeat_threshold=%d\n" "repeated_tool_turns=%d\n" + "current_max_tokens=%d\n" + "possible_cause=%s\n" + "hint=%s\n" "final_answer_preview=%.*s", source ? source : "unknown", reason ? reason : "unknown", @@ -130,6 +136,9 @@ static void notify_admin_limit_diagnostic(const char* source, turns_run, stall_repeat_threshold, repeated_tool_turns, + current_max_tokens, + possible_cause ? possible_cause : "n/a", + hint ? hint : "n/a", 360, final_answer ? final_answer : ""); @@ -1940,6 +1949,9 @@ void agent_on_trigger(const char* skill_d_tag, turns_run, g_cfg->tools.stall_repeat_threshold > 1 ? g_cfg->tools.stall_repeat_threshold : 3, 0, + g_cfg->llm.max_tokens, + NULL, + NULL, ""); } @@ -2174,6 +2186,7 @@ void agent_on_message(const char* sender_pubkey_hex, uint64_t last_tool_fp = 0; int repeated_tool_turns = 0; int exited_on_stall = 0; + int stall_due_to_length = 0; char* final_answer_owned = NULL; int turns_run = 0; @@ -2216,6 +2229,7 @@ void agent_on_message(const char* sender_pubkey_hex, if (repeated_tool_turns >= stall_repeat_threshold) { exited_on_stall = 1; + stall_due_to_length = (resp.finish_reason && strcmp(resp.finish_reason, "length") == 0) ? 1 : 0; llm_response_free(&resp); break; } @@ -2287,6 +2301,9 @@ void agent_on_message(const char* sender_pubkey_hex, turns_run, stall_repeat_threshold, repeated_tool_turns, + g_cfg->llm.max_tokens, + (exited_on_stall && stall_due_to_length) ? "max_tokens_truncation" : NULL, + (exited_on_stall && stall_due_to_length) ? "Increase max_tokens (example: /model_set {\"max_tokens\": 4096})" : NULL, final_answer); } fprintf(stdout, "[didactyl] final response: %.240s%s\n", diff --git a/src/http_api.c b/src/http_api.c index 80393ad..cc75ab5 100644 --- a/src/http_api.c +++ b/src/http_api.c @@ -119,12 +119,15 @@ static void notify_admin_limit_diagnostic_http(const char* source, int turns_run, int stall_repeat_threshold, int repeated_tool_turns, + int current_max_tokens, + const char* possible_cause, + const char* hint, const char* final_answer) { if (!g_api_ctx.cfg || g_api_ctx.cfg->admin.pubkey[0] == '\0') { return; } - char diag[1400]; + char diag[1800]; snprintf(diag, sizeof(diag), "⚠️ Didactyl limit diagnostic\n" @@ -135,6 +138,9 @@ static void notify_admin_limit_diagnostic_http(const char* source, "turns_run=%d\n" "stall_repeat_threshold=%d\n" "repeated_tool_turns=%d\n" + "current_max_tokens=%d\n" + "possible_cause=%s\n" + "hint=%s\n" "final_answer_preview=%.*s", source ? source : "unknown", reason ? reason : "unknown", @@ -143,6 +149,9 @@ static void notify_admin_limit_diagnostic_http(const char* source, turns_run, stall_repeat_threshold, repeated_tool_turns, + current_max_tokens, + possible_cause ? possible_cause : "n/a", + hint ? hint : "n/a", 360, final_answer ? final_answer : ""); @@ -921,6 +930,7 @@ static cJSON* run_prompt_with_tools_convo(cJSON* convo, uint64_t last_tool_fp = 0; int repeated_tool_turns = 0; int exited_on_stall = 0; + int stall_due_to_length = 0; int turns_run = 0; for (int turn = 0; turn < max_turns; turn++) { @@ -965,6 +975,7 @@ static cJSON* run_prompt_with_tools_convo(cJSON* convo, if (repeated_tool_turns >= stall_repeat_threshold) { exited_on_stall = 1; + stall_due_to_length = (resp.finish_reason && strcmp(resp.finish_reason, "length") == 0) ? 1 : 0; llm_response_free(&resp); break; } @@ -1042,6 +1053,9 @@ done:; turns_run, stall_repeat_threshold, repeated_tool_turns, + g_api_ctx.cfg ? g_api_ctx.cfg->llm.max_tokens : 0, + (exited_on_stall && stall_due_to_length) ? "max_tokens_truncation" : NULL, + (exited_on_stall && stall_due_to_length) ? "Increase max_tokens (example: /model_set {\"max_tokens\": 4096})" : NULL, final_response ? final_response : ""); } diff --git a/src/llm.c b/src/llm.c index 0373a87..f6cc804 100644 --- a/src/llm.c +++ b/src/llm.c @@ -289,6 +289,11 @@ static int parse_llm_response(const char* json, llm_response_t* out) { return -1; } + cJSON* finish_reason = first ? cJSON_GetObjectItemCaseSensitive(first, "finish_reason") : NULL; + if (finish_reason && cJSON_IsString(finish_reason) && finish_reason->valuestring) { + out->finish_reason = strdup(finish_reason->valuestring); + } + cJSON* content = cJSON_GetObjectItemCaseSensitive(msg, "content"); if (content && cJSON_IsString(content) && content->valuestring) { out->content = strdup(content->valuestring); @@ -459,6 +464,7 @@ int llm_chat_with_tools(const char* system_prompt, void llm_response_free(llm_response_t* response) { if (!response) return; free(response->content); + free(response->finish_reason); for (int i = 0; i < response->tool_call_count; i++) { free(response->tool_calls[i].id); free(response->tool_calls[i].name); diff --git a/src/llm.h b/src/llm.h index 1bc328e..c8ecec9 100644 --- a/src/llm.h +++ b/src/llm.h @@ -13,6 +13,7 @@ typedef struct { char* content; llm_tool_call_t* tool_calls; int tool_call_count; + char* finish_reason; } llm_response_t; int llm_init(const llm_config_t* config); diff --git a/src/main.h b/src/main.h index 3bc4ca0..9ba2b64 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 81 -#define DIDACTYL_VERSION "v0.0.81" +#define DIDACTYL_VERSION_PATCH 82 +#define DIDACTYL_VERSION "v0.0.82" // Agent metadata #define DIDACTYL_NAME "Didactyl" diff --git a/src/tools/tool_model.c b/src/tools/tool_model.c index 424f316..1da38b4 100644 --- a/src/tools/tool_model.c +++ b/src/tools/tool_model.c @@ -30,113 +30,72 @@ static cJSON* parse_args_local(const char* args_json) { return args; } -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 char* read_entire_file_local(const char* file_path, size_t* out_bytes) { - if (!file_path) return NULL; - - FILE* fp = fopen(file_path, "rb"); - if (!fp) return NULL; - - if (fseek(fp, 0, SEEK_END) != 0) { - fclose(fp); - return NULL; +static int persist_llm_config_nostr(tools_context_t* ctx, const llm_config_t* cfg, char** out_error) { + if (!ctx || !ctx->cfg || !cfg) { + if (out_error) *out_error = strdup("invalid persist context"); + return -1; } - long len = ftell(fp); - if (len < 0) { - fclose(fp); - return NULL; + if (out_error) *out_error = NULL; + + cJSON* args = cJSON_CreateObject(); + cJSON* content = cJSON_CreateObject(); + if (!args || !content) { + cJSON_Delete(args); + cJSON_Delete(content); + if (out_error) *out_error = strdup("allocation failure while building llm_config payload"); + return -1; } - if (fseek(fp, 0, SEEK_SET) != 0) { - fclose(fp); - return NULL; + cJSON_AddStringToObject(args, "d_tag", "llm_config"); + cJSON_AddStringToObject(content, "provider", cfg->provider); + cJSON_AddStringToObject(content, "api_key", cfg->api_key); + cJSON_AddStringToObject(content, "model", cfg->model); + cJSON_AddStringToObject(content, "base_url", cfg->base_url); + cJSON_AddNumberToObject(content, "max_tokens", cfg->max_tokens); + cJSON_AddNumberToObject(content, "temperature", cfg->temperature); + cJSON_AddItemToObject(args, "content", content); + + char* store_args_json = cJSON_PrintUnformatted(args); + cJSON_Delete(args); + if (!store_args_json) { + if (out_error) *out_error = strdup("failed to serialize llm_config payload"); + return -1; } - char* buf = (char*)malloc((size_t)len + 1U); - if (!buf) { - fclose(fp); - return NULL; + char* store_result = execute_config_store(ctx, store_args_json); + free(store_args_json); + if (!store_result) { + if (out_error) *out_error = strdup("config_store returned no response"); + return -1; } - size_t n = fread(buf, 1, (size_t)len, fp); - fclose(fp); - if (n != (size_t)len) { - free(buf); - return NULL; - } - - buf[len] = '\0'; - if (out_bytes) *out_bytes = (size_t)len; - return buf; -} - -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* raw = read_entire_file_local(ctx->cfg->config_path, &src_len); - if (!raw) return -1; - - char* src = jsonc_strip_comments(raw, src_len); - free(raw); - if (!src) return -1; - - cJSON* root = cJSON_ParseWithLength(src, strlen(src)); - free(src); + cJSON* root = cJSON_Parse(store_result); if (!root || !cJSON_IsObject(root)) { cJSON_Delete(root); + if (out_error) *out_error = strdup("config_store returned invalid JSON"); + free(store_result); 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* success = cJSON_GetObjectItemCaseSensitive(root, "success"); + if (!success || !cJSON_IsBool(success) || !cJSON_IsTrue(success)) { + cJSON* err = cJSON_GetObjectItemCaseSensitive(root, "error"); + if (out_error) { + if (err && cJSON_IsString(err) && err->valuestring) { + *out_error = strdup(err->valuestring); + } else { + *out_error = strdup("config_store failed"); + } } - 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); + free(store_result); 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; + free(store_result); + return 0; } static int assign_string_field(cJSON* item, char* dst, size_t dst_size, int* changed) { @@ -231,9 +190,9 @@ char* execute_model_set(tools_context_t* ctx, const char* args_json) { } ctx->cfg->llm = cfg; - if (persist_llm_config(ctx, &cfg) != 0) { - return json_error_local("failed to persist llm config to config file"); - } + + char* persist_error = NULL; + int persisted = (persist_llm_config_nostr(ctx, &cfg, &persist_error) == 0) ? 1 : 0; cJSON* out = cJSON_CreateObject(); if (!out) return NULL; @@ -243,9 +202,14 @@ char* execute_model_set(tools_context_t* ctx, const char* args_json) { cJSON_AddStringToObject(out, "base_url", cfg.base_url); cJSON_AddNumberToObject(out, "max_tokens", cfg.max_tokens); cJSON_AddNumberToObject(out, "temperature", cfg.temperature); + cJSON_AddBoolToObject(out, "persisted", persisted ? 1 : 0); + if (!persisted && persist_error && persist_error[0] != '\0') { + cJSON_AddStringToObject(out, "persist_warning", persist_error); + } char* json = cJSON_PrintUnformatted(out); cJSON_Delete(out); + free(persist_error); return json; } diff --git a/src/tools/tool_task.c b/src/tools/tool_task.c index 73257f6..5d78a88 100644 --- a/src/tools/tool_task.c +++ b/src/tools/tool_task.c @@ -2,14 +2,18 @@ #include "tools_internal.h" -#include #include #include #include #include -#include #include "cjson/cJSON.h" +#include "../nostr_handler.h" +#include "../../nostr_core_lib/nostr_core/nostr_core.h" + +#define TASKS_KIND 30078 +#define TASKS_D_TAG "tasks" +#define TASKS_APP_TAG "didactyl" static char* json_error_local(const char* msg) { cJSON* root = cJSON_CreateObject(); @@ -31,33 +35,6 @@ static cJSON* parse_args_local(const char* args_json) { return args; } -static int is_safe_relative_path_local(const char* path) { - if (!path || path[0] == '\0') return 0; - if (path[0] == '/') return 0; - if (strstr(path, "..") != NULL) return 0; - if (strchr(path, '\\') != NULL) return 0; - return 1; -} - -static int build_tool_path_local(tools_context_t* ctx, const char* rel_path, char* out, size_t out_size) { - if (!ctx || !ctx->cfg || !rel_path || !out || out_size == 0) return -1; - if (!is_safe_relative_path_local(rel_path)) return -1; - - const char* cwd = ctx->cfg->tools.shell.working_directory[0] != '\0' - ? ctx->cfg->tools.shell.working_directory - : "."; - - int n = 0; - if (strcmp(cwd, ".") == 0) { - n = snprintf(out, out_size, "%s", rel_path); - } else { - n = snprintf(out, out_size, "%s/%s", cwd, rel_path); - } - - if (n < 0 || (size_t)n >= out_size) return -1; - return 0; -} - static cJSON* tasks_create_empty_root_local(void) { cJSON* root = cJSON_CreateObject(); cJSON* tasks = cJSON_CreateArray(); @@ -79,47 +56,118 @@ static const char* normalize_task_status_local(const char* status) { return NULL; } -static cJSON* tasks_load_root_local(const char* path) { - if (!path) return NULL; +static int nip44_encrypt_self_local(tools_context_t* ctx, const char* plaintext, char** out_ciphertext) { + if (!ctx || !ctx->cfg || !out_ciphertext) return -1; - FILE* fp = fopen(path, "rb"); - if (!fp) { - if (access(path, F_OK) == 0) { - return NULL; - } + const char* plain = plaintext ? plaintext : ""; + size_t out_cap = (strlen(plain) * 4U) + 1024U; + char* ciphertext = (char*)malloc(out_cap); + if (!ciphertext) return -1; + + int rc = nostr_nip44_encrypt(ctx->cfg->keys.private_key, + ctx->cfg->keys.public_key, + plain, + ciphertext, + out_cap); + if (rc != NOSTR_SUCCESS) { + free(ciphertext); + return -1; + } + + *out_ciphertext = ciphertext; + return 0; +} + +static int nip44_decrypt_self_local(tools_context_t* ctx, const char* ciphertext, char** out_plaintext) { + if (!ctx || !ctx->cfg || !ciphertext || !out_plaintext) return -1; + + size_t out_cap = strlen(ciphertext) + 1024U; + char* plaintext = (char*)malloc(out_cap); + if (!plaintext) return -1; + + int rc = nostr_nip44_decrypt(ctx->cfg->keys.private_key, + ctx->cfg->keys.public_key, + ciphertext, + plaintext, + out_cap); + if (rc != NOSTR_SUCCESS) { + free(plaintext); + return -1; + } + + *out_plaintext = plaintext; + return 0; +} + +static int query_tasks_ciphertext_local(tools_context_t* ctx, char** out_ciphertext) { + if (!ctx || !ctx->cfg || !out_ciphertext) return -1; + + cJSON* filter = cJSON_CreateObject(); + cJSON* kinds = cJSON_CreateArray(); + cJSON* authors = cJSON_CreateArray(); + cJSON* d_vals = cJSON_CreateArray(); + if (!filter || !kinds || !authors || !d_vals) { + cJSON_Delete(filter); + cJSON_Delete(kinds); + cJSON_Delete(authors); + cJSON_Delete(d_vals); + return -1; + } + + cJSON_AddItemToArray(kinds, cJSON_CreateNumber(TASKS_KIND)); + cJSON_AddItemToObject(filter, "kinds", kinds); + + cJSON_AddItemToArray(authors, cJSON_CreateString(ctx->cfg->keys.public_key_hex)); + cJSON_AddItemToObject(filter, "authors", authors); + + cJSON_AddItemToArray(d_vals, cJSON_CreateString(TASKS_D_TAG)); + cJSON_AddItemToObject(filter, "#d", d_vals); + + cJSON_AddNumberToObject(filter, "limit", 1); + + char* events_json = nostr_handler_query_json(filter, 4000); + cJSON_Delete(filter); + if (!events_json) { + return -1; + } + + cJSON* arr = cJSON_Parse(events_json); + free(events_json); + if (!arr || !cJSON_IsArray(arr) || cJSON_GetArraySize(arr) <= 0) { + cJSON_Delete(arr); + *out_ciphertext = strdup(""); + return (*out_ciphertext != NULL) ? 0 : -1; + } + + cJSON* ev = cJSON_GetArrayItem(arr, 0); + cJSON* content = ev ? cJSON_GetObjectItemCaseSensitive(ev, "content") : NULL; + const char* cipher = (content && cJSON_IsString(content) && content->valuestring) ? content->valuestring : ""; + + *out_ciphertext = strdup(cipher); + cJSON_Delete(arr); + return (*out_ciphertext != NULL) ? 0 : -1; +} + +static cJSON* tasks_load_root_nostr_local(tools_context_t* ctx) { + char* ciphertext = NULL; + if (query_tasks_ciphertext_local(ctx, &ciphertext) != 0) { + return NULL; + } + + if (!ciphertext || ciphertext[0] == '\0') { + free(ciphertext); return tasks_create_empty_root_local(); } - if (fseek(fp, 0, SEEK_END) != 0) { - fclose(fp); - return NULL; - } - long len = ftell(fp); - if (len < 0) { - fclose(fp); - return NULL; - } - if (fseek(fp, 0, SEEK_SET) != 0) { - fclose(fp); + char* plaintext = NULL; + if (nip44_decrypt_self_local(ctx, ciphertext, &plaintext) != 0) { + free(ciphertext); return NULL; } + free(ciphertext); - char* buf = (char*)malloc((size_t)len + 1U); - if (!buf) { - fclose(fp); - return NULL; - } - - size_t n = fread(buf, 1, (size_t)len, fp); - fclose(fp); - if (n != (size_t)len) { - free(buf); - return NULL; - } - buf[len] = '\0'; - - cJSON* root = cJSON_Parse(buf); - free(buf); + cJSON* root = cJSON_Parse(plaintext); + free(plaintext); if (!root || !cJSON_IsObject(root)) { cJSON_Delete(root); return NULL; @@ -140,22 +188,45 @@ static cJSON* tasks_load_root_local(const char* path) { return root; } -static int tasks_save_root_local(const char* path, cJSON* root) { - if (!path || !root) return -1; - char* raw = cJSON_PrintUnformatted(root); - if (!raw) return -1; +static int tasks_save_root_nostr_local(tools_context_t* ctx, cJSON* root) { + if (!ctx || !ctx->cfg || !root) return -1; - FILE* fp = fopen(path, "wb"); - if (!fp) { - free(raw); + char* plaintext = cJSON_PrintUnformatted(root); + if (!plaintext) return -1; + + char* ciphertext = NULL; + if (nip44_encrypt_self_local(ctx, plaintext, &ciphertext) != 0) { + free(plaintext); + return -1; + } + free(plaintext); + + cJSON* tags = cJSON_CreateArray(); + cJSON* d_tag = cJSON_CreateArray(); + cJSON* app_tag = cJSON_CreateArray(); + if (!tags || !d_tag || !app_tag) { + cJSON_Delete(tags); + cJSON_Delete(d_tag); + cJSON_Delete(app_tag); + free(ciphertext); return -1; } - size_t len = strlen(raw); - size_t n = fwrite(raw, 1, len, fp); - fclose(fp); - free(raw); - return (n == len) ? 0 : -1; + cJSON_AddItemToArray(d_tag, cJSON_CreateString("d")); + cJSON_AddItemToArray(d_tag, cJSON_CreateString(TASKS_D_TAG)); + cJSON_AddItemToArray(tags, d_tag); + + cJSON_AddItemToArray(app_tag, cJSON_CreateString("app")); + cJSON_AddItemToArray(app_tag, cJSON_CreateString(TASKS_APP_TAG)); + cJSON_AddItemToArray(tags, app_tag); + + nostr_publish_result_t publish_result; + memset(&publish_result, 0, sizeof(publish_result)); + int rc = nostr_handler_publish_kind_event(TASKS_KIND, ciphertext, tags, &publish_result); + cJSON_Delete(tags); + free(ciphertext); + nostr_handler_publish_result_free(&publish_result); + return rc == 0 ? 0 : -1; } static cJSON* task_find_by_id_local(cJSON* tasks, int id, int* out_index) { @@ -184,16 +255,10 @@ char* execute_task_manage(tools_context_t* ctx, const char* args_json) { return json_error_local("task_manage requires string action"); } - char tasks_path[PATH_MAX]; - if (build_tool_path_local(ctx, "tasks.json", tasks_path, sizeof(tasks_path)) != 0) { - cJSON_Delete(args); - return json_error_local("failed to resolve tasks file path"); - } - - cJSON* root = tasks_load_root_local(tasks_path); + cJSON* root = tasks_load_root_nostr_local(ctx); if (!root) { cJSON_Delete(args); - return json_error_local("failed to load tasks file"); + return json_error_local("failed to load tasks memory"); } cJSON* tasks = cJSON_GetObjectItemCaseSensitive(root, "tasks"); @@ -405,10 +470,10 @@ char* execute_task_manage(tools_context_t* ctx, const char* args_json) { if (mutated) { cJSON_DeleteItemFromObjectCaseSensitive(root, "next_id"); cJSON_AddNumberToObject(root, "next_id", next_id); - if (tasks_save_root_local(tasks_path, root) != 0) { + if (tasks_save_root_nostr_local(ctx, root) != 0) { cJSON_Delete(args); cJSON_Delete(root); - return json_error_local("failed to save tasks file"); + return json_error_local("failed to persist tasks memory"); } } @@ -421,7 +486,6 @@ char* execute_task_manage(tools_context_t* ctx, const char* args_json) { cJSON_AddBoolToObject(out, "success", 1); cJSON_AddStringToObject(out, "action", action_s); - cJSON_AddStringToObject(out, "path", tasks_path); cJSON_AddBoolToObject(out, "mutated", mutated ? 1 : 0); cJSON_AddNumberToObject(out, "count", cJSON_GetArraySize(tasks)); diff --git a/src/tools/tools_schema.c b/src/tools/tools_schema.c index 2e2bdd0..f7c7fc6 100644 --- a/src/tools/tools_schema.c +++ b/src/tools/tools_schema.c @@ -687,6 +687,9 @@ char* tools_build_openai_schema_json_legacy(const tools_context_t* ctx) { cJSON* p_skill_create_llm = cJSON_CreateObject(); cJSON_AddStringToObject(p_skill_create_llm, "type", "string"); + cJSON_AddStringToObject(p_skill_create_llm, + "description", + "Model to use when this trigger fires (e.g. 'gpt-4o-mini'); overrides the agent default model for this triggered skill execution only."); cJSON_AddItemToObject(t22_props, "llm", p_skill_create_llm); cJSON* p_skill_create_tools = cJSON_CreateObject(); @@ -840,6 +843,9 @@ char* tools_build_openai_schema_json_legacy(const tools_context_t* ctx) { cJSON* p_skill_edit_llm = cJSON_CreateObject(); cJSON_AddStringToObject(p_skill_edit_llm, "type", "string"); + cJSON_AddStringToObject(p_skill_edit_llm, + "description", + "Model to use when this trigger fires (e.g. 'gpt-4o-mini'); overrides the agent default model for this triggered skill execution only."); cJSON_AddItemToObject(t25b_props, "llm", p_skill_edit_llm); cJSON* p_skill_edit_tools = cJSON_CreateObject(); @@ -976,7 +982,7 @@ char* tools_build_openai_schema_json_legacy(const tools_context_t* ctx) { 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.jsonc"); + cJSON_AddStringToObject(t31_fn, "description", "Update active LLM configuration and persist it to Nostr kind 30078 (d=llm_config)"); cJSON_AddStringToObject(t31_params, "type", "object"); cJSON_AddItemToObject(t31_params, "properties", t31_props); @@ -1276,7 +1282,7 @@ char* tools_build_openai_schema_json_legacy(const tools_context_t* ctx) { cJSON_AddStringToObject(t42, "type", "function"); cJSON_AddStringToObject(t42_fn, "name", "task_list"); - cJSON_AddStringToObject(t42_fn, "description", "Build current task list context block from tasks.json"); + cJSON_AddStringToObject(t42_fn, "description", "Build current task list context block from agent task memory on Nostr"); cJSON_AddStringToObject(t42_params, "type", "object"); cJSON_AddItemToObject(t42_params, "properties", t42_props); cJSON_AddItemToObject(t42_fn, "parameters", t42_params); @@ -1305,7 +1311,7 @@ char* tools_build_openai_schema_json_legacy(const tools_context_t* ctx) { cJSON_AddStringToObject(t45, "type", "function"); cJSON_AddStringToObject(t45_fn, "name", "task_manage"); - cJSON_AddStringToObject(t45_fn, "description", "Manage agent short-term task memory stored in tasks.json (list/add/update/remove/clear/replace)"); + cJSON_AddStringToObject(t45_fn, "description", "Manage agent short-term task memory stored on Nostr kind 30078 (d=tasks): list/add/update/remove/clear/replace"); cJSON_AddStringToObject(t45_params, "type", "object"); cJSON_AddItemToObject(t45_params, "properties", t45_props); cJSON_AddItemToObject(t45_params, "required", t45_required);