v0.0.81 - Add loop progress controls and admin limit diagnostics for DM, HTTP API, and triggers

This commit is contained in:
Your Name
2026-03-18 14:16:10 -04:00
parent 82362e4f3f
commit 253aa4b5fc
12 changed files with 281 additions and 24 deletions

View File

@@ -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.80Fix wizard/runtime config persistence and add --admin CLI override for agent_config updates
> Last release update: v0.0.81Add 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

View File

@@ -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": {

View File

@@ -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 ? "..." : "");

View File

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

View File

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

View File

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

View File

@@ -5,6 +5,7 @@
#include <stdio.h>
#include <stdlib.h>
#include <string.h>
#include <stdint.h>
#include <time.h>
#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);

View File

@@ -2,7 +2,7 @@
#define DIDACTYL_HTTP_API_H
#include "config.h"
#include "tools.h"
#include "tools/tools.h"
struct trigger_manager;

View File

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

View File

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

View File

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

View File

@@ -1,7 +1,7 @@
#ifndef DIDACTYL_TOOLS_H
#define DIDACTYL_TOOLS_H
#include "config.h"
#include "../config.h"
struct trigger_manager;