From 253aa4b5fc655e862d4f21e2338eb1fa5e47e9ef Mon Sep 17 00:00:00 2001 From: Your Name Date: Wed, 18 Mar 2026 14:16:10 -0400 Subject: [PATCH] v0.0.81 - Add loop progress controls and admin limit diagnostics for DM, HTTP API, and triggers --- README.md | 4 +- config.jsonc.example | 9 +-- src/agent.c | 127 +++++++++++++++++++++++++++++++++++++++++- src/agent.h | 2 +- src/config.c | 16 ++++-- src/config.h | 1 + src/http_api.c | 127 +++++++++++++++++++++++++++++++++++++++++- src/http_api.h | 2 +- src/main.h | 4 +- src/prompt_template.h | 2 +- src/setup_wizard.c | 9 +-- src/tools/tools.h | 2 +- 12 files changed, 281 insertions(+), 24 deletions(-) diff --git a/README.md b/README.md index 365e133..a7d65f7 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.80 +## Current Status — v0.0.81 **Active build — this project is barely working. Experiment at your own risk.** -> Last release update: v0.0.80 — Fix wizard/runtime config persistence and add --admin CLI override for agent_config updates +> Last release update: v0.0.81 — Add loop progress controls and admin limit diagnostics for DM, HTTP API, and triggers - 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/config.jsonc.example b/config.jsonc.example index 1d601f6..af50355 100644 --- a/config.jsonc.example +++ b/config.jsonc.example @@ -40,10 +40,11 @@ // triggered skills, HTTP API prompt runs, and local HTTP fetch tool. "tools": { "enabled": true, - "max_turns": 8, // default max turns for normal agent tool loops - "trigger_max_turns": 6, // max turns for triggered-skill executions - "api_default_max_turns": 4, // default when HTTP body omits max_turns - "api_max_turns_ceiling": 16, // hard cap applied to API-provided max_turns + "max_turns": 20, // default max turns for normal agent tool loops + "trigger_max_turns": 12, // max turns for triggered-skill executions + "api_default_max_turns": 8, // default when HTTP body omits max_turns + "api_max_turns_ceiling": 32, // hard cap applied to API-provided max_turns + "stall_repeat_threshold": 3, // stop early when identical tool-call turns repeat this many times "local_http_fetch_default_timeout_seconds": 20, "local_http_fetch_max_timeout_seconds": 120, "shell": { diff --git a/src/agent.c b/src/agent.c index 8646960..5c3981f 100644 --- a/src/agent.c +++ b/src/agent.c @@ -79,6 +79,63 @@ static uint64_t message_fingerprint(const char* sender_pubkey_hex, const char* m return a ^ (b + 0x9e3779b97f4a7c15ULL + (a << 6) + (a >> 2)); } +static uint64_t tool_calls_fingerprint(const llm_response_t* resp) { + if (!resp || resp->tool_call_count <= 0 || !resp->tool_calls) { + return 1469598103934665603ULL; + } + + uint64_t h = 1469598103934665603ULL; + for (int i = 0; i < resp->tool_call_count; i++) { + const llm_tool_call_t* tc = &resp->tool_calls[i]; + uint64_t name_h = fnv1a64(tc->name ? tc->name : ""); + uint64_t args_h = fnv1a64(tc->arguments_json ? tc->arguments_json : "{}"); + uint64_t part = name_h ^ (args_h + 0x9e3779b97f4a7c15ULL + (name_h << 6) + (name_h >> 2)); + h ^= part + (uint64_t)(i + 1) * 1099511628211ULL; + h *= 1099511628211ULL; + } + + h ^= (uint64_t)resp->tool_call_count; + h *= 1099511628211ULL; + return h; +} + +static void notify_admin_limit_diagnostic(const char* source, + const char* reason, + const char* subject, + int max_turns, + int turns_run, + int stall_repeat_threshold, + int repeated_tool_turns, + const char* final_answer) { + if (!g_cfg || g_cfg->admin.pubkey[0] == '\0') { + return; + } + + char diag[1400]; + snprintf(diag, + sizeof(diag), + "⚠️ Didactyl limit diagnostic\n" + "source=%s\n" + "reason=%s\n" + "subject=%s\n" + "max_turns=%d\n" + "turns_run=%d\n" + "stall_repeat_threshold=%d\n" + "repeated_tool_turns=%d\n" + "final_answer_preview=%.*s", + source ? source : "unknown", + reason ? reason : "unknown", + subject ? subject : "n/a", + max_turns, + turns_run, + stall_repeat_threshold, + repeated_tool_turns, + 360, + final_answer ? final_answer : ""); + + (void)nostr_handler_send_dm_auto(g_cfg->admin.pubkey, diag); +} + static int agent_message_is_debounced(const char* sender_pubkey_hex, const char* message) { time_t now = time(NULL); uint64_t fp = message_fingerprint(sender_pubkey_hex, message); @@ -1815,7 +1872,10 @@ void agent_on_trigger(const char* skill_d_tag, ? g_cfg->tools.trigger_max_turns : (g_cfg->tools.max_turns > 0 ? g_cfg->tools.max_turns : 8); + int turns_run = 0; + int trigger_completed = 0; for (int turn = 0; turn < max_turns; turn++) { + turns_run = turn + 1; char* messages_json = cJSON_PrintUnformatted(messages); if (!messages_json) { break; @@ -1836,6 +1896,7 @@ void agent_on_trigger(const char* skill_d_tag, } if (resp.tool_call_count <= 0) { + trigger_completed = 1; llm_response_free(&resp); break; } @@ -1871,6 +1932,17 @@ void agent_on_trigger(const char* skill_d_tag, llm_response_free(&resp); } + if (!trigger_completed && turns_run >= max_turns) { + notify_admin_limit_diagnostic("trigger_skill_loop", + "max_turns_exhausted", + skill_d_tag, + max_turns, + turns_run, + g_cfg->tools.stall_repeat_threshold > 1 ? g_cfg->tools.stall_repeat_threshold : 3, + 0, + ""); + } + if (g_trigger_manager) { (void)trigger_manager_fire_chains(g_trigger_manager, skill_d_tag, @@ -2097,10 +2169,16 @@ void agent_on_message(const char* sender_pubkey_hex, cJSON_AddNumberToObject(live_user_msg, "_ts", (double)time(NULL)); } - int max_turns = g_cfg->tools.max_turns > 0 ? g_cfg->tools.max_turns : 8; + int max_turns = g_cfg->tools.max_turns > 0 ? g_cfg->tools.max_turns : 20; + int stall_repeat_threshold = g_cfg->tools.stall_repeat_threshold > 1 ? g_cfg->tools.stall_repeat_threshold : 3; + uint64_t last_tool_fp = 0; + int repeated_tool_turns = 0; + int exited_on_stall = 0; char* final_answer_owned = NULL; + int turns_run = 0; for (int turn = 0; turn < max_turns; turn++) { + turns_run = turn + 1; char* messages_json = cJSON_PrintUnformatted(messages); if (!messages_json) { break; @@ -2128,6 +2206,20 @@ void agent_on_message(const char* sender_pubkey_hex, break; } + uint64_t current_tool_fp = tool_calls_fingerprint(&resp); + if (turn == 0 || current_tool_fp != last_tool_fp) { + repeated_tool_turns = 1; + last_tool_fp = current_tool_fp; + } else { + repeated_tool_turns++; + } + + if (repeated_tool_turns >= stall_repeat_threshold) { + exited_on_stall = 1; + llm_response_free(&resp); + break; + } + if (append_assistant_tool_calls_message(messages, &resp) != 0) { llm_response_free(&resp); break; @@ -2162,10 +2254,41 @@ void agent_on_message(const char* sender_pubkey_hex, } if (!final_answer_owned) { - final_answer_owned = strdup("I hit my tool-use limit for this request."); + char* messages_json = cJSON_PrintUnformatted(messages); + if (messages_json) { + llm_response_t final_resp; + int final_rc = llm_chat_with_tools_messages(messages_json, tools_json, "none", &final_resp); + free(messages_json); + if (final_rc == 0) { + const char* forced_answer = final_resp.content ? final_resp.content : ""; + if (forced_answer[0] != '\0') { + final_answer_owned = strdup(forced_answer); + } + llm_response_free(&final_resp); + } + } + } + + int exhausted_on_max_turns = (!final_answer_owned && !exited_on_stall && turns_run >= max_turns); + + if (!final_answer_owned) { + final_answer_owned = strdup(exited_on_stall + ? "I stopped repeated tool calls after detecting a loop and could not produce a final summary." + : "I hit my tool-use limit for this request."); } const char* final_answer = final_answer_owned ? final_answer_owned : "I hit my tool-use limit for this request."; + + if (exited_on_stall || exhausted_on_max_turns) { + notify_admin_limit_diagnostic("dm_agent_loop", + exited_on_stall ? "stall_repetition_detected" : "max_turns_exhausted", + sender_pubkey_hex, + max_turns, + turns_run, + stall_repeat_threshold, + repeated_tool_turns, + final_answer); + } fprintf(stdout, "[didactyl] final response: %.240s%s\n", final_answer, strlen(final_answer) > 240 ? "..." : ""); diff --git a/src/agent.h b/src/agent.h index 8a5a159..cc82d9f 100644 --- a/src/agent.h +++ b/src/agent.h @@ -4,7 +4,7 @@ #include "config.h" #include "nostr_handler.h" #include "cjson/cJSON.h" -#include "tools.h" +#include "tools/tools.h" struct trigger_manager; diff --git a/src/config.c b/src/config.c index 2e4ae43..86731bc 100644 --- a/src/config.c +++ b/src/config.c @@ -225,6 +225,7 @@ static int parse_tools_config(cJSON* root, didactyl_config_t* config) { cJSON* trigger_max_turns = cJSON_GetObjectItemCaseSensitive(tools, "trigger_max_turns"); cJSON* api_default_max_turns = cJSON_GetObjectItemCaseSensitive(tools, "api_default_max_turns"); cJSON* api_max_turns_ceiling = cJSON_GetObjectItemCaseSensitive(tools, "api_max_turns_ceiling"); + cJSON* stall_repeat_threshold = cJSON_GetObjectItemCaseSensitive(tools, "stall_repeat_threshold"); cJSON* local_http_fetch_default_timeout_seconds = cJSON_GetObjectItemCaseSensitive(tools, "local_http_fetch_default_timeout_seconds"); cJSON* local_http_fetch_max_timeout_seconds = @@ -244,6 +245,9 @@ static int parse_tools_config(cJSON* root, didactyl_config_t* config) { if (api_max_turns_ceiling && cJSON_IsNumber(api_max_turns_ceiling)) { config->tools.api_max_turns_ceiling = (int)api_max_turns_ceiling->valuedouble; } + if (stall_repeat_threshold && cJSON_IsNumber(stall_repeat_threshold)) { + config->tools.stall_repeat_threshold = (int)stall_repeat_threshold->valuedouble; + } if (local_http_fetch_default_timeout_seconds && cJSON_IsNumber(local_http_fetch_default_timeout_seconds)) { config->tools.local_http_fetch_default_timeout_seconds = (int)local_http_fetch_default_timeout_seconds->valuedouble; @@ -294,6 +298,9 @@ static int parse_tools_config(cJSON* root, didactyl_config_t* config) { if (config->tools.api_default_max_turns > config->tools.api_max_turns_ceiling) { config->tools.api_default_max_turns = config->tools.api_max_turns_ceiling; } + if (config->tools.stall_repeat_threshold < 2) { + config->tools.stall_repeat_threshold = 3; + } if (config->tools.local_http_fetch_default_timeout_seconds < 1) { config->tools.local_http_fetch_default_timeout_seconds = 20; } @@ -1124,10 +1131,11 @@ int config_load(const char* path, didactyl_config_t* config) { config->dm_protocol = DM_PROTOCOL_NIP04; config->tools.enabled = 1; - config->tools.max_turns = 8; - config->tools.trigger_max_turns = 6; - config->tools.api_default_max_turns = 4; - config->tools.api_max_turns_ceiling = 16; + config->tools.max_turns = 20; + config->tools.trigger_max_turns = 12; + config->tools.api_default_max_turns = 8; + config->tools.api_max_turns_ceiling = 32; + config->tools.stall_repeat_threshold = 3; config->tools.local_http_fetch_default_timeout_seconds = 20; config->tools.local_http_fetch_max_timeout_seconds = 120; config->tools.shell.enabled = 1; diff --git a/src/config.h b/src/config.h index 3d1a49b..dc210b3 100644 --- a/src/config.h +++ b/src/config.h @@ -50,6 +50,7 @@ typedef struct { int trigger_max_turns; int api_default_max_turns; int api_max_turns_ceiling; + int stall_repeat_threshold; int local_http_fetch_default_timeout_seconds; int local_http_fetch_max_timeout_seconds; shell_tools_config_t shell; diff --git a/src/http_api.c b/src/http_api.c index a38f3bb..80393ad 100644 --- a/src/http_api.c +++ b/src/http_api.c @@ -5,6 +5,7 @@ #include #include #include +#include #include #include "agent.h" @@ -81,6 +82,73 @@ static int estimate_tokens_from_chars(int chars) { return (chars + 3) / 4; } +static uint64_t fnv1a64_local(const char* s) { + uint64_t h = 1469598103934665603ULL; + if (!s) return h; + while (*s) { + h ^= (unsigned char)(*s++); + h *= 1099511628211ULL; + } + return h; +} + +static uint64_t tool_calls_fingerprint_local(const llm_response_t* resp) { + if (!resp || resp->tool_call_count <= 0 || !resp->tool_calls) { + return 1469598103934665603ULL; + } + + uint64_t h = 1469598103934665603ULL; + for (int i = 0; i < resp->tool_call_count; i++) { + const llm_tool_call_t* tc = &resp->tool_calls[i]; + uint64_t name_h = fnv1a64_local(tc->name ? tc->name : ""); + uint64_t args_h = fnv1a64_local(tc->arguments_json ? tc->arguments_json : "{}"); + uint64_t part = name_h ^ (args_h + 0x9e3779b97f4a7c15ULL + (name_h << 6) + (name_h >> 2)); + h ^= part + (uint64_t)(i + 1) * 1099511628211ULL; + h *= 1099511628211ULL; + } + + h ^= (uint64_t)resp->tool_call_count; + h *= 1099511628211ULL; + return h; +} + +static void notify_admin_limit_diagnostic_http(const char* source, + const char* reason, + const char* subject, + int max_turns, + int turns_run, + int stall_repeat_threshold, + int repeated_tool_turns, + const char* final_answer) { + if (!g_api_ctx.cfg || g_api_ctx.cfg->admin.pubkey[0] == '\0') { + return; + } + + char diag[1400]; + snprintf(diag, + sizeof(diag), + "⚠️ Didactyl limit diagnostic\n" + "source=%s\n" + "reason=%s\n" + "subject=%s\n" + "max_turns=%d\n" + "turns_run=%d\n" + "stall_repeat_threshold=%d\n" + "repeated_tool_turns=%d\n" + "final_answer_preview=%.*s", + source ? source : "unknown", + reason ? reason : "unknown", + subject ? subject : "n/a", + max_turns, + turns_run, + stall_repeat_threshold, + repeated_tool_turns, + 360, + final_answer ? final_answer : ""); + + (void)nostr_handler_send_dm_auto(g_api_ctx.cfg->admin.pubkey, diag); +} + static int uri_extract_after_prefix(const struct mg_str* uri, const char* prefix, char* out, @@ -844,9 +912,19 @@ static cJSON* run_prompt_with_tools_convo(cJSON* convo, return NULL; } + int stall_repeat_threshold = 3; + if (g_api_ctx.cfg && g_api_ctx.cfg->tools.stall_repeat_threshold > 1) { + stall_repeat_threshold = g_api_ctx.cfg->tools.stall_repeat_threshold; + } + char* final_response = NULL; + uint64_t last_tool_fp = 0; + int repeated_tool_turns = 0; + int exited_on_stall = 0; + int turns_run = 0; for (int turn = 0; turn < max_turns; turn++) { + turns_run = turn + 1; char* messages_json = cJSON_PrintUnformatted(convo); if (!messages_json) break; @@ -877,6 +955,20 @@ static cJSON* run_prompt_with_tools_convo(cJSON* convo, break; } + uint64_t current_tool_fp = tool_calls_fingerprint_local(&resp); + if (turn == 0 || current_tool_fp != last_tool_fp) { + repeated_tool_turns = 1; + last_tool_fp = current_tool_fp; + } else { + repeated_tool_turns++; + } + + if (repeated_tool_turns >= stall_repeat_threshold) { + exited_on_stall = 1; + llm_response_free(&resp); + break; + } + if (append_assistant_tool_calls_message_local(convo, &resp) != 0) { llm_response_free(&resp); final_response = strdup("Failed to append assistant tool calls."); @@ -917,9 +1009,40 @@ static cJSON* run_prompt_with_tools_convo(cJSON* convo, llm_response_free(&resp); } -done: +done:; + int hit_max_turn_limit = (!final_response && !exited_on_stall && turns_run >= max_turns); + if (!final_response) { - final_response = strdup(tool_limit_message ? tool_limit_message : "I hit my tool-use limit for this prompt run."); + char* messages_json = cJSON_PrintUnformatted(convo); + if (messages_json) { + llm_response_t final_resp; + int final_rc = llm_chat_with_tools_messages(messages_json, tools_json, "none", &final_resp); + free(messages_json); + if (final_rc == 0) { + const char* forced_answer = final_resp.content ? final_resp.content : ""; + if (forced_answer[0] != '\0') { + final_response = strdup(forced_answer); + } + llm_response_free(&final_resp); + } + } + } + + if (!final_response) { + final_response = strdup(exited_on_stall + ? "Stopped repeated tool calls after detecting a loop, and no final summary was produced." + : (tool_limit_message ? tool_limit_message : "I hit my tool-use limit for this prompt run.")); + } + + if (exited_on_stall || hit_max_turn_limit) { + notify_admin_limit_diagnostic_http("http_api_tool_loop", + exited_on_stall ? "stall_repetition_detected" : "max_turns_exhausted", + log_sender, + max_turns, + turns_run, + stall_repeat_threshold, + repeated_tool_turns, + final_response ? final_response : ""); } cJSON_AddBoolToObject(root, "success", 1); diff --git a/src/http_api.h b/src/http_api.h index 6485df9..b712ad3 100644 --- a/src/http_api.h +++ b/src/http_api.h @@ -2,7 +2,7 @@ #define DIDACTYL_HTTP_API_H #include "config.h" -#include "tools.h" +#include "tools/tools.h" struct trigger_manager; diff --git a/src/main.h b/src/main.h index 217ec3a..3bc4ca0 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 80 -#define DIDACTYL_VERSION "v0.0.80" +#define DIDACTYL_VERSION_PATCH 81 +#define DIDACTYL_VERSION "v0.0.81" // Agent metadata #define DIDACTYL_NAME "Didactyl" diff --git a/src/prompt_template.h b/src/prompt_template.h index 65af807..5de1ca1 100644 --- a/src/prompt_template.h +++ b/src/prompt_template.h @@ -2,7 +2,7 @@ #define DIDACTYL_PROMPT_TEMPLATE_H #include "cjson/cJSON.h" -#include "tools.h" +#include "tools/tools.h" #define PROMPT_TEMPLATE_MAX_SECTIONS 32 #define PROMPT_TEMPLATE_MAX_NAME_LEN 64 diff --git a/src/setup_wizard.c b/src/setup_wizard.c index 775e392..294bbc8 100644 --- a/src/setup_wizard.c +++ b/src/setup_wizard.c @@ -271,10 +271,11 @@ static void config_set_defaults(didactyl_config_t* cfg) { cfg->dm_protocol = DM_PROTOCOL_NIP04; cfg->tools.enabled = 1; - cfg->tools.max_turns = 8; - cfg->tools.trigger_max_turns = 6; - cfg->tools.api_default_max_turns = 4; - cfg->tools.api_max_turns_ceiling = 16; + cfg->tools.max_turns = 20; + cfg->tools.trigger_max_turns = 12; + cfg->tools.api_default_max_turns = 8; + cfg->tools.api_max_turns_ceiling = 32; + cfg->tools.stall_repeat_threshold = 3; cfg->tools.local_http_fetch_default_timeout_seconds = 20; cfg->tools.local_http_fetch_max_timeout_seconds = 120; cfg->tools.shell.enabled = 1; diff --git a/src/tools/tools.h b/src/tools/tools.h index d222332..cd7f51a 100644 --- a/src/tools/tools.h +++ b/src/tools/tools.h @@ -1,7 +1,7 @@ #ifndef DIDACTYL_TOOLS_H #define DIDACTYL_TOOLS_H -#include "config.h" +#include "../config.h" struct trigger_manager;