Compare commits

...
3 Commits
17 changed files with 1187 additions and 51 deletions
+1 -1
View File
@@ -10,7 +10,7 @@ test_keys.txt
/mongoose/
/Trash/
deploy_lt.sh
# Build artifacts
/build/
+2 -2
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.78
## Current Status — v0.0.81
**Active build — this project is barely working. Experiment at your own risk.**
> Last release update: v0.0.78 — Seed default skill into self-skill cache during startup reconcile
> 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
+5 -4
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": {
+8
View File
@@ -17,6 +17,14 @@ if [ ! -f "$SOURCE_BINARY" ]; then
exit 1
fi
echo "Building static binary first..."
"$SCRIPT_DIR/build_static.sh"
if [ ! -f "$SOURCE_BINARY" ]; then
echo "ERROR: Build completed but source binary not found: $SOURCE_BINARY"
exit 1
fi
echo "Deploying $SOURCE_BINARY to $REMOTE_TARGET"
rsync -avz --progress "$SOURCE_BINARY" "$REMOTE_TARGET"
echo "Deployment complete."
+149
View File
@@ -0,0 +1,149 @@
# Enhanced Existing Agent Wizard Flow
## Problem
When choosing "existing agent" in the wizard, the current flow:
1. Asks for nsec
2. Recovers kind 10002 relay list from Nostr
3. Immediately boots the agent
This is insufficient when installing an existing agent on a **new server** because:
- You cannot review or change the admin pubkey
- You cannot review or change the LLM provider/model/API key
- You cannot install a systemd service with a dedicated user
- You cannot see what config was recovered from Nostr
- The agent name is not recovered from the kind 0 profile
## Current Code
- [`existing_agent_flow()`](src/setup_wizard.c:1795) — 22 lines, minimal recovery
- [`recover_existing_config_from_nostr()`](src/setup_wizard.c:792) — only recovers kind 10002 relays
- Returns `SETUP_WIZARD_RC_EXISTING` (2) which does NOT set `bootstrap_mode`
- In `main.c`, existing agents skip `reconcile_startup_events()` unless `first_run` is detected
## Data Available on Nostr for an Existing Agent
| Data | Kind | Storage | Recovery Method |
|------|------|---------|-----------------|
| Relay list | 10002 | Public tags | `query_and_extract_kind10002_relays()` |
| Agent profile/name | 0 | Public JSON content | Query kind 0 by agent pubkey |
| LLM config | 30078 d=llm_config | NIP-44 encrypted to self | `fetch_self_config_plaintext()` |
| Agent config - admin pubkey, DM protocol | 30078 d=agent_config | NIP-44 encrypted to self | `fetch_self_config_plaintext()` |
| Default skill | 31124 d=didactyl-default | Public content | Query kind 31124 by agent pubkey |
| Adoption list | 10123 | Public tags | Query kind 10123 by agent pubkey |
## Proposed Enhanced Flow
```mermaid
flowchart TD
A[Enter nsec] --> B[Connect to default relays]
B --> C[Recover kind 10002 relay list]
C --> D{Relay list found?}
D -->|No| E{Offer to create new agent with this nsec}
E -->|Yes| E2[Jump to new_agent_flow with nsec pre-loaded]
E -->|No| E3[Return to main menu]
D -->|Yes| F[Reconnect with recovered relays]
F --> G[Recover all config from Nostr]
G --> H[Display recovered config summary]
H --> I{Review each setting}
I -->|Keep all| J[Review summary + launch options]
I -->|Change admin| K[Prompt new admin pubkey]
I -->|Change LLM| L[Prompt LLM config]
I -->|Change relays| M[Prompt relay config]
K --> I
L --> I
M --> I
J --> N{Launch option}
N -->|Boot now| O[Return EXISTING]
N -->|Install systemd| P[Install dedicated-user service]
N -->|Quit| Q[Exit]
```
### Step-by-step
#### Step 1: Identity — Enter nsec
Same as current. Derive keys from nsec.
#### Step 2: Recovery — Connect and fetch config from Nostr
1. Init nostr handler with default relays
2. Wait for relay connections
3. Query kind 10002 for relay list — if not found, offer to create a new agent with this nsec (jump to `new_agent_flow` with keys pre-loaded, skipping identity step)
4. Cleanup and re-init with recovered relays
5. Wait for relay connections on recovered relays
6. Query kind 0 for agent profile — extract display_name/name
7. Query kind 30078 d=llm_config — decrypt and parse LLM settings
8. Query kind 30078 d=agent_config — decrypt and parse admin pubkey + DM protocol
9. Cleanup nostr handler
#### Step 3: Review — Present recovered config
Display all recovered values:
```
┌─────────────────────────────────────────────┐
│ Existing Agent -- Recovered Configuration │
├─────────────────────────────────────────────┤
│ Agent name: Simon │
│ Identity: b27072b7fc2edf45... │
│ Admin: a1b2c3d4e5f6... │
│ LLM Provider: ppq │
│ LLM Model: claude-haiku-4.5 │
│ LLM Base URL: https://api.ppq.ai │
│ LLM API Key: sk-...**** │
│ DM Protocol: nip04 │
│ Relays: 5 configured │
└─────────────────────────────────────────────┘
```
Then offer a menu:
```
[a] change Admin pubkey
[l] change LLM provider/model/key
[r] change Relay configuration
[c] continue with these settings
[q] quit
```
Each change option reuses the existing prompt functions (`prompt_admin_pubkey`, `prompt_llm_config`, `prompt_relay_configuration`) but with context-appropriate headers.
After any change, redisplay the summary and menu.
#### Step 4: Launch — Boot or install systemd
Same as the new-agent flow's final step:
```
[b] boot the agent now
[i] install dedicated-user systemd service and boot
[q] quit
```
The systemd install reuses `install_system_service_with_dedicated_user()`.
### Return Codes
- If user chooses "boot now": return `SETUP_WIZARD_RC_EXISTING` (2) — same as current
- If user chooses "install systemd": return `SETUP_WIZARD_RC_EXIT` (1) — agent runs as systemd service
- If user changed config values: the `main()` flow should still work because `recover_missing_runtime_config_from_nostr()` at line 1108 will fill in any gaps, and the existing agent path at line 1163 loads system context from adopted skills
### Key Consideration: bootstrap_mode for changed configs
If the user changes LLM or admin config in the wizard, those changes need to be persisted back to Nostr. Currently, `persist_runtime_config_to_nostr()` is only called when `bootstrap_mode || first_run`.
**Solution**: When the existing-agent wizard detects that config was changed, return `SETUP_WIZARD_RC_BOOTSTRAP` (0) instead of `SETUP_WIZARD_RC_EXISTING` (2). This triggers the full reconcile path which persists the updated config.
## Files to Modify
1. **`src/setup_wizard.c`**:
- Add `recover_full_config_from_nostr()` — fetches kind 0, kind 30078 llm_config, kind 30078 agent_config
- Add `query_self_kind0_name()` — queries agent's own kind 0 profile for name
- Add `fetch_and_decrypt_self_config()` — replicates `fetch_self_config_plaintext()` logic from main.c for use in wizard context
- Rewrite `existing_agent_flow()` with the enhanced multi-step flow
- Make `prompt_admin_pubkey()`, `prompt_llm_config()`, `prompt_relay_configuration()` accept a context string parameter for the page header, or add wrapper versions for the existing-agent context
2. **`src/main.c`**:
- Potentially expose `fetch_self_config_plaintext()`, `apply_recalled_llm_config()`, `apply_recalled_agent_config()` as non-static, OR duplicate the logic in setup_wizard.c
- Better approach: move these to a shared location or make them accessible via a header
## Implementation Notes
- The wizard already calls `nostr_handler_init()` / `nostr_handler_cleanup()` for validation queries. The enhanced flow will do the same but with two init/cleanup cycles: first with default relays to get kind 10002, then with recovered relays to get everything else.
- The `prompt_admin_pubkey()` and `prompt_llm_config()` functions currently have hardcoded step headers like "Step 3 of 7". These should be parameterized or have existing-agent variants.
- The `fetch_self_config_plaintext()` function in main.c requires an active nostr handler. The wizard will need to have the handler initialized when calling it.
- API key display should be masked — show only first 4 and last 4 characters.
@@ -0,0 +1,69 @@
# Fix: Kind 30078 `d=agent_config` Publish Gaps
## Problem
The agent stores its admin pubkey and DM protocol in a NIP-44 self-encrypted kind 30078 replaceable event (`d=agent_config`). This event is critical for the `--nsec`-only startup path (used by systemd services) to recover the admin identity.
**Multiple startup paths fail to publish this event**, causing:
- Agents installed via wizard "Install Service" to lose their admin pubkey on restart
- Agents recovering on new servers to use stale/old admin pubkeys from relays
- Agents with genesis files to silently ignore newer kind 30078 values
## Affected Files
- `src/setup_wizard.c` — wizard flows
- `src/main.c` — main startup logic
## Fixes
### Fix A: `new_agent_flow` — publish before service install
**File:** `src/setup_wizard.c` ~line 2184
**Problem:** When user chooses "Install systemd service" in new agent flow, `persist_runtime_config_to_nostr_wizard_online` is never called. The service starts with `--nsec` only and has no kind 30078 to recover from.
**Fix:** Call `persist_runtime_config_to_nostr_wizard_online(cfg)` before `install_system_service_with_dedicated_user()`. Fail the install if publish fails (same pattern as existing_agent_flow).
### Fix B: `new_agent_flow` — publish before "boot now" return
**File:** `src/setup_wizard.c` ~line 2177
**Problem:** When user chooses "Boot now", the wizard returns `SETUP_WIZARD_RC_BOOTSTRAP` and relies on `main()` to publish later. This works but is fragile — if the bootstrap publish in `main()` fails, the kind 30078 is never created.
**Fix:** Call `persist_runtime_config_to_nostr_wizard_online(cfg)` before returning 0. This is belt-and-suspenders — `main()` will also publish, but the wizard ensures it happens at least once. If the wizard publish fails, log a warning but continue (non-fatal since main will retry).
### Fix C: `existing_agent_flow` — always publish before service install
**File:** `src/setup_wizard.c` ~line 2368
**Problem:** `persist_runtime_config_to_nostr_wizard_online` is only called `if (config_changed)`. If the user accepts the recovered config as-is, the kind 30078 is not re-published. The event may have been lost from relays.
**Fix:** Remove the `config_changed` gate. Always call `persist_runtime_config_to_nostr_wizard_online(cfg)` before installing the service. Kind 30078 is a replaceable event, so re-publishing is safe and idempotent.
### Fix D: `existing_agent_flow` — always return BOOTSTRAP
**File:** `src/setup_wizard.c` ~line 2364
**Problem:** When user chooses "Boot now" and `config_changed == 0`, the flow returns `SETUP_WIZARD_RC_EXISTING` which means `bootstrap_mode = 0` in `main()`. If `first_run = 0` (kind 10002 exists), `persist_runtime_config_to_nostr` is skipped.
**Fix:** Always return `SETUP_WIZARD_RC_BOOTSTRAP` from the "boot now" path, regardless of `config_changed`. This ensures `main()` always re-publishes the kind 30078. The wizard has all the config in memory — it should always be treated as authoritative.
### Fix E: `persist_runtime_config_to_nostr_wizard_online` — ensure relay delivery
**File:** `src/setup_wizard.c` ~line 1159
**Problem:** The function publishes the event and immediately calls `nostr_handler_cleanup()`. The event may not have been delivered to relays yet (fire-and-forget).
**Fix:** Add a brief poll loop (e.g., 2-3 seconds of `nostr_handler_poll()`) after the publish call and before `nostr_handler_cleanup()` to give the event time to propagate. This matches the pattern used elsewhere for relay operations.
### Fix F: `main()` — always fetch kind 30078 and compare
**File:** `src/main.c` ~line 847 (`recover_missing_runtime_config_from_nostr`)
**Problem:** The recovery only runs when `admin_config_is_complete()` returns false. If a genesis file provides an admin pubkey, the kind 30078 is never checked, even if it has a different (newer) value.
**Fix:** Always fetch the kind 30078 `d=agent_config` regardless of whether the genesis already provided values. Compare the fetched admin_pubkey with the loaded one. If they differ, log a `DEBUG_WARN` with both values. Genesis wins (operator intent), but the warning makes the conflict visible in logs.
### Fix G: `main()` — always re-publish kind 30078
**File:** `src/main.c` ~line 1152
**Problem:** `persist_runtime_config_to_nostr` only runs when `bootstrap_mode || first_run`. On subsequent runs, the kind 30078 is never refreshed. If relays purge the event, it's gone.
**Fix:** Move `persist_runtime_config_to_nostr(&cfg)` outside the `if (bootstrap_mode || first_run)` block so it runs on every startup. Kind 30078 is a replaceable event — re-publishing is safe, idempotent, and ensures relay persistence. Log the result but don't fail startup if it doesn't succeed.
## Execution Order
1. Fix E first (relay delivery) — this makes all other wizard publishes more reliable
2. Fixes A + B (new_agent_flow) — the most critical bug
3. Fixes C + D (existing_agent_flow) — second most critical
4. Fix G (always re-publish in main) — ensures long-term relay persistence
5. Fix F (conflict detection) — nice-to-have logging improvement
## Testing
- Wizard → New Agent → Install Service: verify kind 30078 exists on relays after service starts
- Wizard → New Agent → Boot: verify kind 30078 exists on relays
- Wizard → Existing Agent → Install Service (no changes): verify kind 30078 re-published
- Wizard → Existing Agent → Boot (no changes): verify kind 30078 re-published
- `--nsec` only restart: verify kind 30078 recovered and re-published
- `--config genesis.jsonc` with different admin than kind 30078: verify warning logged, genesis wins, kind 30078 updated
+125 -2
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 ? "..." : "");
+1 -1
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;
+12 -4
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;
+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;
+125 -2
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);
+1 -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;
+41 -15
View File
@@ -75,6 +75,8 @@ static void print_usage(const char* prog) {
" If file is missing, startup falls back to minimal nsec-only mode.\n"
" --nsec <nsec_or_hex>\n"
" Private key as nsec1... or 64-char hex; overrides DIDACTYL_NSEC.\n"
" --admin <npub_or_hex>\n"
" Administrator pubkey override; accepts npub1... or 64-char hex.\n"
" --api-port <port>\n"
" Enable HTTP API and bind to this port (1-65535).\n"
" --api-bind <addr>\n"
@@ -863,24 +865,32 @@ static void recover_missing_runtime_config_from_nostr(didactyl_config_t* cfg) {
free(llm_plaintext);
}
if (!admin_config_is_complete(cfg)) {
char* agent_plaintext = NULL;
if (fetch_self_config_plaintext(cfg, "agent_config", &agent_plaintext) == 0 && agent_plaintext) {
if (apply_recalled_agent_config(cfg, agent_plaintext) == 0) {
char* agent_plaintext = NULL;
if (fetch_self_config_plaintext(cfg, "agent_config", &agent_plaintext) == 0 && agent_plaintext) {
didactyl_config_t recalled = *cfg;
if (apply_recalled_agent_config(&recalled, agent_plaintext) == 0) {
if (!admin_config_is_complete(cfg)) {
snprintf(cfg->admin.pubkey, sizeof(cfg->admin.pubkey), "%s", recalled.admin.pubkey);
cfg->dm_protocol = recalled.dm_protocol;
DEBUG_INFO("[didactyl] startup phase: recovered agent_config from Nostr");
} else {
DEBUG_WARN("[didactyl] startup phase: failed to apply recalled agent_config");
} else if (recalled.admin.pubkey[0] != '\0' && strcmp(cfg->admin.pubkey, recalled.admin.pubkey) != 0) {
DEBUG_WARN("[didactyl] startup phase: agent_config admin pubkey differs from loaded config (loaded=%.16s..., nostr=%.16s...); loaded config wins",
cfg->admin.pubkey,
recalled.admin.pubkey);
}
} else {
DEBUG_WARN("[didactyl] startup phase: agent_config recall unavailable");
DEBUG_WARN("[didactyl] startup phase: failed to parse/decrypt recalled agent_config");
}
free(agent_plaintext);
} else {
DEBUG_WARN("[didactyl] startup phase: agent_config recall unavailable");
}
free(agent_plaintext);
}
int main(int argc, char** argv) {
const char* config_path = "./genesis.jsonc";
const char* cli_nsec = NULL;
const char* cli_admin_pubkey = NULL;
const char* api_bind_override = NULL;
int api_port_override = 0;
int debug_level = DEBUG_LEVEL_NONE;
@@ -944,6 +954,8 @@ int main(int argc, char** argv) {
debug_level = atoi(argv[++i]);
} else if (strcmp(argv[i], "--nsec") == 0 && i + 1 < argc) {
cli_nsec = argv[++i];
} else if (strcmp(argv[i], "--admin") == 0 && i + 1 < argc) {
cli_admin_pubkey = argv[++i];
} else if (strcmp(argv[i], "--api-port") == 0 && i + 1 < argc) {
api_port_override = atoi(argv[++i]);
} else if (strcmp(argv[i], "--api-bind") == 0 && i + 1 < argc) {
@@ -997,6 +1009,18 @@ int main(int argc, char** argv) {
}
}
if (cli_admin_pubkey && cli_admin_pubkey[0] != '\0') {
char decoded_admin[65] = {0};
if (decode_pubkey_hex_or_npub_local(cli_admin_pubkey, decoded_admin) != 0) {
fprintf(stderr, "Invalid admin pubkey provided via --admin (expected npub1... or 64-char hex)\n");
config_free(&cfg);
nostr_cleanup();
return 1;
}
snprintf(cfg.admin.pubkey, sizeof(cfg.admin.pubkey), "%s", decoded_admin);
DEBUG_INFO("[didactyl] startup phase: admin override applied from --admin (%.16s...)", cfg.admin.pubkey);
}
if (config_ensure_default_skill_startup_events(&cfg) != 0) {
fprintf(stderr, "Failed to synthesize startup events from default_skill\n");
config_free(&cfg);
@@ -1124,7 +1148,7 @@ int main(int argc, char** argv) {
if (!admin_config_is_complete(&cfg)) {
startup_step_fail(4, "Validate admin config", "missing admin pubkey");
fprintf(stderr,
"Missing admin config: provide admin.pubkey in genesis or store d=agent_config via config_store\n");
"Missing admin config: provide admin.pubkey in genesis, pass --admin, or store d=agent_config via config_store\n");
nostr_handler_cleanup();
config_free(&cfg);
nostr_cleanup();
@@ -1153,17 +1177,19 @@ int main(int argc, char** argv) {
if (nostr_handler_reconcile_startup_events() != 0) {
DEBUG_WARN("[didactyl] startup phase: reconcile startup events failed (continuing)");
}
if (persist_runtime_config_to_nostr(&cfg) != 0) {
DEBUG_WARN("[didactyl] startup phase: failed to persist encrypted runtime config to Nostr");
} else {
DEBUG_INFO("[didactyl] startup phase: persisted encrypted runtime config to Nostr");
}
} else {
DEBUG_INFO("[didactyl] startup phase: existing-agent mode; skipping bootstrap publish on subsequent run");
DEBUG_INFO("[didactyl] startup phase: existing-agent mode; skipping startup-event reconcile on subsequent run");
if (nostr_handler_load_system_context_from_adopted_skills() != 0) {
DEBUG_WARN("[didactyl] startup phase: failed to load system context from adopted skills (continuing with fallback)");
}
}
if (persist_runtime_config_to_nostr(&cfg) != 0) {
DEBUG_WARN("[didactyl] startup phase: failed to persist encrypted runtime config to Nostr");
} else {
DEBUG_INFO("[didactyl] startup phase: persisted encrypted runtime config to Nostr");
}
startup_step_ok(7, "Reconcile/persist startup state", NULL);
const char* system_context = nostr_handler_get_system_context();
+2 -2
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 78
#define DIDACTYL_VERSION "v0.0.78"
#define DIDACTYL_VERSION_PATCH 81
#define DIDACTYL_VERSION "v0.0.81"
// Agent metadata
#define DIDACTYL_NAME "Didactyl"
+1 -1
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
+643 -15
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;
@@ -801,6 +802,422 @@ static int recover_existing_config_from_nostr(didactyl_config_t* cfg, int* out_r
return -1;
}
static int query_self_kind0_name(const didactyl_config_t* cfg,
char* out_name,
size_t out_name_size,
int* out_found) {
if (!cfg || !out_name || out_name_size == 0 || !out_found || cfg->keys.public_key_hex[0] == '\0') {
return -1;
}
out_name[0] = '\0';
*out_found = 0;
cJSON* filter = cJSON_CreateObject();
cJSON* kinds = cJSON_CreateArray();
cJSON* authors = cJSON_CreateArray();
if (!filter || !kinds || !authors) {
cJSON_Delete(filter);
cJSON_Delete(kinds);
cJSON_Delete(authors);
return -1;
}
cJSON_AddItemToArray(kinds, cJSON_CreateNumber(0));
cJSON_AddItemToObject(filter, "kinds", kinds);
cJSON_AddItemToArray(authors, cJSON_CreateString(cfg->keys.public_key_hex));
cJSON_AddItemToObject(filter, "authors", authors);
cJSON_AddNumberToObject(filter, "limit", 1);
char* events_json = nostr_handler_query_json(filter, 3000);
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);
return 0;
}
cJSON* ev = cJSON_GetArrayItem(arr, 0);
cJSON* content = ev ? cJSON_GetObjectItemCaseSensitive(ev, "content") : NULL;
if (!content || !cJSON_IsString(content) || !content->valuestring || content->valuestring[0] == '\0') {
cJSON_Delete(arr);
return 0;
}
cJSON* profile = cJSON_Parse(content->valuestring);
if (!profile || !cJSON_IsObject(profile)) {
cJSON_Delete(profile);
cJSON_Delete(arr);
return 0;
}
cJSON* display_name = cJSON_GetObjectItemCaseSensitive(profile, "display_name");
cJSON* name = cJSON_GetObjectItemCaseSensitive(profile, "name");
cJSON* chosen = NULL;
if (display_name && cJSON_IsString(display_name) && display_name->valuestring && display_name->valuestring[0] != '\0') {
chosen = display_name;
} else if (name && cJSON_IsString(name) && name->valuestring && name->valuestring[0] != '\0') {
chosen = name;
}
if (chosen && chosen->valuestring) {
snprintf(out_name, out_name_size, "%s", chosen->valuestring);
*out_found = 1;
}
cJSON_Delete(profile);
cJSON_Delete(arr);
return 0;
}
static int fetch_and_decrypt_self_config_wizard(const didactyl_config_t* cfg, const char* d_tag, char** out_plaintext) {
if (!cfg || !d_tag || !out_plaintext || cfg->keys.public_key_hex[0] == '\0') {
return -1;
}
*out_plaintext = NULL;
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(30078));
cJSON_AddItemToObject(filter, "kinds", kinds);
cJSON_AddItemToArray(authors, cJSON_CreateString(cfg->keys.public_key_hex));
cJSON_AddItemToObject(filter, "authors", authors);
cJSON_AddItemToArray(d_vals, cJSON_CreateString(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);
return -1;
}
cJSON* ev = cJSON_GetArrayItem(arr, 0);
cJSON* content = ev ? cJSON_GetObjectItemCaseSensitive(ev, "content") : NULL;
if (!content || !cJSON_IsString(content) || !content->valuestring || content->valuestring[0] == '\0') {
cJSON_Delete(arr);
return -1;
}
size_t out_cap = strlen(content->valuestring) + 1024U;
char* plaintext = (char*)malloc(out_cap);
if (!plaintext) {
cJSON_Delete(arr);
return -1;
}
int dec_rc = nostr_nip44_decrypt(cfg->keys.private_key,
cfg->keys.public_key,
content->valuestring,
plaintext,
out_cap);
cJSON_Delete(arr);
if (dec_rc != NOSTR_SUCCESS) {
free(plaintext);
return -1;
}
*out_plaintext = plaintext;
return 0;
}
static int apply_recalled_llm_config_wizard(didactyl_config_t* cfg, const char* plaintext) {
if (!cfg || !plaintext) return -1;
cJSON* root = cJSON_Parse(plaintext);
if (!root || !cJSON_IsObject(root)) {
cJSON_Delete(root);
return -1;
}
cJSON* provider = cJSON_GetObjectItemCaseSensitive(root, "provider");
cJSON* api_key = cJSON_GetObjectItemCaseSensitive(root, "api_key");
cJSON* model = cJSON_GetObjectItemCaseSensitive(root, "model");
cJSON* base_url = cJSON_GetObjectItemCaseSensitive(root, "base_url");
cJSON* max_tokens = cJSON_GetObjectItemCaseSensitive(root, "max_tokens");
cJSON* temperature = cJSON_GetObjectItemCaseSensitive(root, "temperature");
if (!api_key || !cJSON_IsString(api_key) || !api_key->valuestring || api_key->valuestring[0] == '\0' ||
!model || !cJSON_IsString(model) || !model->valuestring || model->valuestring[0] == '\0' ||
!base_url || !cJSON_IsString(base_url) || !base_url->valuestring || base_url->valuestring[0] == '\0') {
cJSON_Delete(root);
return -1;
}
if (provider && cJSON_IsString(provider) && provider->valuestring && provider->valuestring[0] != '\0') {
snprintf(cfg->llm.provider, sizeof(cfg->llm.provider), "%s", provider->valuestring);
} else if (cfg->llm.provider[0] == '\0') {
snprintf(cfg->llm.provider, sizeof(cfg->llm.provider), "%s", "openai");
}
snprintf(cfg->llm.api_key, sizeof(cfg->llm.api_key), "%s", api_key->valuestring);
snprintf(cfg->llm.model, sizeof(cfg->llm.model), "%s", model->valuestring);
snprintf(cfg->llm.base_url, sizeof(cfg->llm.base_url), "%s", base_url->valuestring);
if (max_tokens && cJSON_IsNumber(max_tokens)) {
cfg->llm.max_tokens = (int)max_tokens->valuedouble;
}
if (temperature && cJSON_IsNumber(temperature)) {
cfg->llm.temperature = temperature->valuedouble;
}
cJSON_Delete(root);
return 0;
}
static int apply_recalled_agent_config_wizard(didactyl_config_t* cfg, const char* plaintext) {
if (!cfg || !plaintext) return -1;
cJSON* root = cJSON_Parse(plaintext);
if (!root || !cJSON_IsObject(root)) {
cJSON_Delete(root);
return -1;
}
cJSON* admin_pubkey = cJSON_GetObjectItemCaseSensitive(root, "admin_pubkey");
cJSON* dm_protocol = cJSON_GetObjectItemCaseSensitive(root, "dm_protocol");
if (admin_pubkey && cJSON_IsString(admin_pubkey) && admin_pubkey->valuestring && admin_pubkey->valuestring[0] != '\0') {
char decoded[65] = {0};
if (decode_pubkey_hex_or_npub_local(admin_pubkey->valuestring, decoded) == 0) {
snprintf(cfg->admin.pubkey, sizeof(cfg->admin.pubkey), "%s", decoded);
}
}
if (dm_protocol && cJSON_IsString(dm_protocol) && dm_protocol->valuestring && dm_protocol->valuestring[0] != '\0') {
if (strcmp(dm_protocol->valuestring, "nip17") == 0) {
cfg->dm_protocol = DM_PROTOCOL_NIP17;
} else if (strcmp(dm_protocol->valuestring, "both") == 0) {
cfg->dm_protocol = DM_PROTOCOL_BOTH;
} else {
cfg->dm_protocol = DM_PROTOCOL_NIP04;
}
}
cJSON_Delete(root);
return 0;
}
static int recover_full_config_from_nostr(didactyl_config_t* cfg,
char* out_agent_name,
size_t out_agent_name_size,
int* out_agent_name_found) {
if (!cfg || !out_agent_name || out_agent_name_size == 0 || !out_agent_name_found) {
return -1;
}
out_agent_name[0] = '\0';
*out_agent_name_found = 0;
didactyl_config_t tmp = *cfg;
if (nostr_handler_init(&tmp) != 0) {
return -1;
}
(void)wait_connected_relays_ms(5000);
(void)query_self_kind0_name(cfg, out_agent_name, out_agent_name_size, out_agent_name_found);
char* llm_plaintext = NULL;
if (fetch_and_decrypt_self_config_wizard(cfg, "llm_config", &llm_plaintext) == 0 && llm_plaintext) {
(void)apply_recalled_llm_config_wizard(cfg, llm_plaintext);
}
free(llm_plaintext);
char* agent_plaintext = NULL;
if (fetch_and_decrypt_self_config_wizard(cfg, "agent_config", &agent_plaintext) == 0 && agent_plaintext) {
(void)apply_recalled_agent_config_wizard(cfg, agent_plaintext);
}
free(agent_plaintext);
nostr_handler_cleanup();
return 0;
}
static const char* dm_protocol_to_string_local(dm_protocol_t protocol) {
switch (protocol) {
case DM_PROTOCOL_NIP17: return "nip17";
case DM_PROTOCOL_BOTH: return "both";
case DM_PROTOCOL_NIP04:
default:
return "nip04";
}
}
static int publish_encrypted_self_config_wizard(const didactyl_config_t* cfg,
const char* d_tag,
const char* content_json) {
if (!cfg || !d_tag || !content_json || d_tag[0] == '\0') return -1;
size_t cipher_cap = (strlen(content_json) * 4U) + 1024U;
char* ciphertext = (char*)malloc(cipher_cap);
if (!ciphertext) return -1;
int enc_rc = nostr_nip44_encrypt(cfg->keys.private_key,
cfg->keys.public_key,
content_json,
ciphertext,
cipher_cap);
if (enc_rc != NOSTR_SUCCESS) {
free(ciphertext);
return -1;
}
cJSON* tags = cJSON_CreateArray();
cJSON* d = cJSON_CreateArray();
cJSON* app = cJSON_CreateArray();
if (!tags || !d || !app) {
cJSON_Delete(tags);
cJSON_Delete(d);
cJSON_Delete(app);
free(ciphertext);
return -1;
}
cJSON_AddItemToArray(d, cJSON_CreateString("d"));
cJSON_AddItemToArray(d, cJSON_CreateString(d_tag));
cJSON_AddItemToArray(tags, d);
cJSON_AddItemToArray(app, cJSON_CreateString("app"));
cJSON_AddItemToArray(app, cJSON_CreateString("didactyl"));
cJSON_AddItemToArray(tags, app);
nostr_publish_result_t result;
memset(&result, 0, sizeof(result));
int rc = nostr_handler_publish_kind_event(30078, ciphertext, tags, &result);
nostr_handler_publish_result_free(&result);
cJSON_Delete(tags);
free(ciphertext);
return rc;
}
static int persist_runtime_config_to_nostr_wizard(const didactyl_config_t* cfg) {
if (!cfg) return -1;
cJSON* llm = cJSON_CreateObject();
if (!llm) return -1;
cJSON_AddStringToObject(llm, "provider", cfg->llm.provider);
cJSON_AddStringToObject(llm, "api_key", cfg->llm.api_key);
cJSON_AddStringToObject(llm, "model", cfg->llm.model);
cJSON_AddStringToObject(llm, "base_url", cfg->llm.base_url);
cJSON_AddNumberToObject(llm, "max_tokens", cfg->llm.max_tokens);
cJSON_AddNumberToObject(llm, "temperature", cfg->llm.temperature);
char* llm_json = cJSON_PrintUnformatted(llm);
cJSON_Delete(llm);
if (!llm_json) return -1;
int llm_rc = publish_encrypted_self_config_wizard(cfg, "llm_config", llm_json);
free(llm_json);
cJSON* agent = cJSON_CreateObject();
if (!agent) return llm_rc;
cJSON_AddStringToObject(agent, "admin_pubkey", cfg->admin.pubkey);
cJSON_AddStringToObject(agent, "dm_protocol", dm_protocol_to_string_local(cfg->dm_protocol));
char* agent_json = cJSON_PrintUnformatted(agent);
cJSON_Delete(agent);
if (!agent_json) return llm_rc;
int agent_rc = publish_encrypted_self_config_wizard(cfg, "agent_config", agent_json);
free(agent_json);
return (llm_rc == 0 && agent_rc == 0) ? 0 : -1;
}
static int persist_runtime_config_to_nostr_wizard_online(const didactyl_config_t* cfg) {
if (!cfg) return -1;
didactyl_config_t tmp = *cfg;
if (nostr_handler_init(&tmp) != 0) {
return -1;
}
(void)wait_connected_relays_ms(5000);
int rc = persist_runtime_config_to_nostr_wizard(cfg);
/* Give relays time to acknowledge the published events before tearing
down the connection. Without this, the fire-and-forget publish may
not reach any relay before cleanup closes the sockets. */
for (int i = 0; i < 30; i++) {
nostr_handler_poll(100);
}
nostr_handler_cleanup();
return rc;
}
static int publish_kind10002_relays_wizard_online(const didactyl_config_t* cfg) {
if (!cfg) return -1;
didactyl_config_t tmp = *cfg;
if (nostr_handler_init(&tmp) != 0) {
return -1;
}
(void)wait_connected_relays_ms(5000);
char* tags_json = NULL;
if (build_kind10002_tags_json(cfg, &tags_json) != 0 || !tags_json) {
free(tags_json);
nostr_handler_cleanup();
return -1;
}
cJSON* tags = cJSON_Parse(tags_json);
free(tags_json);
if (!tags || !cJSON_IsArray(tags)) {
cJSON_Delete(tags);
nostr_handler_cleanup();
return -1;
}
nostr_publish_result_t result;
memset(&result, 0, sizeof(result));
int rc = nostr_handler_publish_kind_event(10002, "", tags, &result);
nostr_handler_publish_result_free(&result);
cJSON_Delete(tags);
nostr_handler_cleanup();
return rc;
}
static int ensure_startup_kind10002_from_current_relays(didactyl_config_t* cfg) {
if (!cfg) return -1;
char* tags_json = NULL;
if (build_kind10002_tags_json(cfg, &tags_json) != 0 || !tags_json) {
free(tags_json);
return -1;
}
int rc = upsert_startup_event(cfg, 10002, "", tags_json);
free(tags_json);
return rc;
}
static int wizard_llm_test(const llm_config_t* llm_cfg) {
if (!llm_cfg) return -1;
if (llm_init(llm_cfg) != 0) {
@@ -1037,10 +1454,12 @@ static int prompt_model_selection(const cJSON* models,
}
}
static int prompt_admin_pubkey(didactyl_config_t* cfg) {
static int prompt_admin_pubkey_with_header(didactyl_config_t* cfg,
const char* step_title,
const char* section_title) {
char input[WIZARD_LINE_MAX];
for (;;) {
render_wizard_page_header("Step 3 of 7", "New Agent Setup -- Administrator");
render_wizard_page_header(step_title, section_title);
if (read_line_prompt(" Enter the admin's Nostr public key (npub1... or hex):\n> ", input, sizeof(input)) != 0) {
return -1;
}
@@ -1056,9 +1475,15 @@ static int prompt_admin_pubkey(didactyl_config_t* cfg) {
}
}
static int prompt_llm_config(didactyl_config_t* cfg) {
static int prompt_admin_pubkey(didactyl_config_t* cfg) {
return prompt_admin_pubkey_with_header(cfg, "Step 3 of 7", "New Agent Setup -- Administrator");
}
static int prompt_llm_config_with_header(didactyl_config_t* cfg,
const char* step_title,
const char* section_title) {
for (;;) {
render_wizard_page_header("Step 5 of 7", "New Agent Setup -- LLM Provider");
render_wizard_page_header(step_title, section_title);
print_option('1', " ppq.ai");
print_option('2', " Routstr");
print_option('3', " OpenAI-compatible");
@@ -1179,11 +1604,17 @@ static int prompt_llm_config(didactyl_config_t* cfg) {
}
}
static int prompt_relay_configuration(didactyl_config_t* cfg) {
static int prompt_llm_config(didactyl_config_t* cfg) {
return prompt_llm_config_with_header(cfg, "Step 5 of 7", "New Agent Setup -- LLM Provider");
}
static int prompt_relay_configuration_with_header(didactyl_config_t* cfg,
const char* step_title,
const char* section_title) {
char input[WIZARD_LINE_MAX];
for (;;) {
render_wizard_page_header("Step 6 of 7", "New Agent Setup -- Relay Configuration");
render_wizard_page_header(step_title, section_title);
fprintf(stderr, " Current relays:\n");
for (int i = 0; i < cfg->relay_count; i++) {
fprintf(stderr, " %d. %s\n", i + 1, cfg->relays[i] ? cfg->relays[i] : "");
@@ -1235,6 +1666,10 @@ static int prompt_relay_configuration(didactyl_config_t* cfg) {
}
}
static int prompt_relay_configuration(didactyl_config_t* cfg) {
return prompt_relay_configuration_with_header(cfg, "Step 6 of 7", "New Agent Setup -- Relay Configuration");
}
static int run_command_local(char* const argv[]) {
pid_t pid = fork();
if (pid < 0) return -1;
@@ -1749,6 +2184,13 @@ static int new_agent_flow(didactyl_config_t* cfg, char* genesis_path_out, size_t
if (c == 's') return 2;
if (c == 'b') {
/* Publish kind 30078 agent_config + llm_config so the --nsec-only
restart path can recover them. Non-fatal: main() will also
publish during bootstrap, but doing it here is belt-and-suspenders. */
if (persist_runtime_config_to_nostr_wizard_online(cfg) != 0) {
fprintf(stderr, "%sWarning: failed to publish runtime config to Nostr (will retry on boot).%s\n",
ANSI_YELLOW, ANSI_RESET);
}
fprintf(stderr, "%sStep 7 of 7 -- Booting new agent. Check your messages for initial greeting.%s\n",
ANSI_YELLOW,
ANSI_RESET);
@@ -1756,6 +2198,16 @@ static int new_agent_flow(didactyl_config_t* cfg, char* genesis_path_out, size_t
}
if (c == 'i') {
/* Publish kind 30078 agent_config + llm_config BEFORE installing the
service. The systemd service starts with --nsec only and depends
on recovering these from relays. */
if (persist_runtime_config_to_nostr_wizard_online(cfg) != 0) {
fprintf(stderr,
"%sFailed to publish runtime config to Nostr; refusing install to avoid missing admin/LLM on first boot.%s\n",
ANSI_RED, ANSI_RESET);
return -1;
}
if (install_system_service_with_dedicated_user(cfg, agent_name) != 0) {
fprintf(stderr, "%sFailed to install dedicated-user system service.%s\n", ANSI_RED, ANSI_RESET);
return -1;
@@ -1794,6 +2246,10 @@ static int new_agent_flow(didactyl_config_t* cfg, char* genesis_path_out, size_t
static int existing_agent_flow(didactyl_config_t* cfg) {
char nsec_in[OW_MAX_KEY_LEN] = {0};
char agent_name[OW_MAX_NAME_LEN] = {0};
int agent_name_found = 0;
int relay_changed = 0;
render_wizard_page_header("Existing Agent", "Identity");
if (read_secret_prompt("Enter your agent nsec (nsec1... or 64-char hex): ", nsec_in, sizeof(nsec_in)) != 0) return -1;
if (line_is_quit(nsec_in)) return -1;
@@ -1807,13 +2263,183 @@ static int existing_agent_flow(didactyl_config_t* cfg) {
render_wizard_page_header("Existing Agent", "Config Recovery");
fprintf(stderr, " Relay list (kind 10002): NOT FOUND\n\n");
fprintf(stderr, "%sUnable to find a kind 10002 relay list for this identity.%s\n", ANSI_RED, ANSI_RESET);
fprintf(stderr, "\n");
print_option('n', "ew agent setup with this identity");
print_option('q', "uit to main menu");
wizard_option_t opts[] = {{'n', ""}, {'q', ""}};
char c = read_menu_choice(opts, 2);
if (c == 'n') {
int rc = new_agent_flow(cfg, NULL, 0);
if (rc == 0) return SETUP_WIZARD_RC_BOOTSTRAP;
if (rc == 1) return SETUP_WIZARD_RC_EXIT;
return -1;
}
return -1;
}
render_wizard_page_header("Existing Agent", "Config Recovery");
fprintf(stderr, " Relay list (kind 10002): FOUND (%d relays)\n\n", cfg->relay_count);
if (recover_full_config_from_nostr(cfg, agent_name, sizeof(agent_name), &agent_name_found) != 0) {
fprintf(stderr, "%sWarning: unable to recover full config from Nostr, continuing with partial recovery.%s\n",
ANSI_YELLOW,
ANSI_RESET);
}
return 0;
if (!agent_name_found || agent_name[0] == '\0') {
snprintf(agent_name, sizeof(agent_name), "%s", "Didactyl");
}
for (;;) {
char masked_key[64] = {0};
size_t api_len = strlen(cfg->llm.api_key);
if (api_len >= 8U) {
snprintf(masked_key,
sizeof(masked_key),
"%.4s...%s",
cfg->llm.api_key,
cfg->llm.api_key + api_len - 4U);
} else if (api_len > 0U) {
snprintf(masked_key, sizeof(masked_key), "****");
} else {
snprintf(masked_key, sizeof(masked_key), "(not set)");
}
render_wizard_page_header("Existing Agent", "Recovered Configuration");
fprintf(stderr, "Your agent was found. Change any of the following:\n\n");
fprintf(stderr, " Name: %s\n", agent_name);
fprintf(stderr, " Identity: %.16s...\n", cfg->keys.public_key_hex);
fprintf(stderr, " Admin: %s\n", cfg->admin.pubkey[0] ? cfg->admin.pubkey : "(not set)");
fprintf(stderr, " LLM Provider: %s\n", cfg->llm.provider[0] ? cfg->llm.provider : "(not set)");
fprintf(stderr, " LLM Model: %s\n", cfg->llm.model[0] ? cfg->llm.model : "(not set)");
fprintf(stderr, " LLM Base URL: %s\n", cfg->llm.base_url[0] ? cfg->llm.base_url : "(not set)");
fprintf(stderr, " LLM API Key: %s\n", masked_key);
fprintf(stderr, " Relays: %d configured\n", cfg->relay_count);
fprintf(stderr, "\n");
print_option('a', "dmin pubkey");
print_option('l', "lm config");
print_option('r', "elay configuration");
print_option('c', "ontinue to launch options");
print_option('q', "uit");
wizard_option_t opts[] = {{'a', ""}, {'l', ""}, {'r', ""}, {'c', ""}, {'q', ""}};
char c = read_menu_choice(opts, 5);
if (c == 'q') {
return -1;
}
if (c == 'c') {
break;
}
if (c == 'a') {
if (prompt_admin_pubkey_with_header(cfg,
"Existing Agent -- Review",
"Existing Agent -- Administrator") != 0) {
return -1;
}
continue;
}
if (c == 'l') {
if (prompt_llm_config_with_header(cfg,
"Existing Agent -- Review",
"Existing Agent -- LLM Provider") != 0) {
return -1;
}
continue;
}
if (c == 'r') {
for (;;) {
int rc = prompt_relay_configuration_with_header(cfg,
"Existing Agent -- Review",
"Existing Agent -- Relay Configuration");
if (rc < 0) return -1;
if (rc == 1) break;
relay_changed = 1;
break;
}
continue;
}
}
render_wizard_page_header("Existing Agent", "Launch Options");
fprintf(stderr, " Name: %s\n", agent_name);
fprintf(stderr, " Identity: %.16s...\n", cfg->keys.public_key_hex);
fprintf(stderr, " Admin: %.16s...\n", cfg->admin.pubkey);
fprintf(stderr, " LLM: %s @ %s\n", cfg->llm.model, cfg->llm.base_url);
fprintf(stderr, " Relays: %d configured\n\n", cfg->relay_count);
print_option('b', "oot the agent now");
print_option('i', "nstall dedicated-user systemd service and boot");
print_option('q', "uit");
wizard_option_t launch_opts[] = {{'b', ""}, {'i', ""}, {'q', ""}};
char lc = read_menu_choice(launch_opts, 3);
if (lc == 'q') return -1;
if (lc == 'b') {
if (relay_changed) {
if (ensure_startup_kind10002_from_current_relays(cfg) != 0) {
fprintf(stderr,
"%sFailed to stage updated relay list for startup publish.%s\n",
ANSI_RED,
ANSI_RESET);
return -1;
}
}
/* Always return BOOTSTRAP so main() re-publishes kind 30078.
The wizard has the authoritative config in memory. */
return SETUP_WIZARD_RC_BOOTSTRAP;
}
if (lc == 'i') {
/* Always publish kind 30078 before installing the service, even if
config was not changed. The service depends on recovering these
from relays and the event may have been purged. */
if (persist_runtime_config_to_nostr_wizard_online(cfg) != 0) {
fprintf(stderr,
"%sFailed to publish runtime config to Nostr; refusing install to avoid stale admin/LLM on first boot.%s\n",
ANSI_RED,
ANSI_RESET);
return -1;
}
if (relay_changed) {
if (publish_kind10002_relays_wizard_online(cfg) != 0) {
fprintf(stderr,
"%sFailed to publish updated relay list (kind 10002) to Nostr; refusing install.%s\n",
ANSI_RED,
ANSI_RESET);
return -1;
}
}
if (install_system_service_with_dedicated_user(cfg, agent_name) != 0) {
fprintf(stderr, "%sFailed to install dedicated-user system service.%s\n", ANSI_RED, ANSI_RESET);
return -1;
}
char service_user[64] = {0};
sanitize_service_user_from_name(agent_name, service_user, sizeof(service_user));
char service_name[96] = {0};
snprintf(service_name, sizeof(service_name), "%s.service", service_user);
sleep(2);
char* is_active_argv[] = {"systemctl", "is-active", "--quiet", service_name, NULL};
if (run_privileged_command_local(is_active_argv) == 0) {
fprintf(stderr,
"%sService %s is active. Setup is complete; this wizard will now exit.%s\n",
ANSI_YELLOW,
service_name,
ANSI_RESET);
} else {
fprintf(stderr,
"%sService installed but not active yet. Check status with: sudo systemctl status %s%s\n",
ANSI_RED,
service_name,
ANSI_RESET);
}
return SETUP_WIZARD_RC_EXIT;
}
return -1;
}
static int load_genesis_path_flow(didactyl_config_t* cfg) {
@@ -1876,8 +2502,10 @@ int setup_wizard_run(didactyl_config_t* config, char* genesis_path_out, size_t g
if (c == 'e') {
int rc = existing_agent_flow(config);
if (rc == 0) {
return SETUP_WIZARD_RC_EXISTING;
if (rc == SETUP_WIZARD_RC_BOOTSTRAP ||
rc == SETUP_WIZARD_RC_EXISTING ||
rc == SETUP_WIZARD_RC_EXIT) {
return rc;
}
return rc;
}
+1 -1
View File
@@ -1,7 +1,7 @@
#ifndef DIDACTYL_TOOLS_H
#define DIDACTYL_TOOLS_H
#include "config.h"
#include "../config.h"
struct trigger_manager;