Compare commits
6 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
09d45febdd | ||
|
|
b9e90f0b21 | ||
|
|
59bd2603e2 | ||
|
|
5e6610823f | ||
|
|
112fe8e5ef | ||
|
|
ed852f16df |
@@ -54,11 +54,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.2.35
|
||||
## Current Status — v0.2.41
|
||||
|
||||
**Active build — this project is barely working. Experiment at your own risk.**
|
||||
|
||||
> Last release update: v0.2.35 — Remove top-level dm_protocol from genesis configs and set user-settings kind 30078 dm_protocol to both
|
||||
> Last release update: v0.2.41 — Fix adopted-skills cache to select latest kind 10123 event by created_at in agent refresh path
|
||||
|
||||
- Connects to configured relays with auto-reconnect and relay state transition logging
|
||||
- Publishes configured startup events per relay as each relay becomes connected
|
||||
|
||||
247
plans/cron_trigger_robustness.md
Normal file
247
plans/cron_trigger_robustness.md
Normal file
@@ -0,0 +1,247 @@
|
||||
# Cron Trigger Pipeline: How It Works and How to Make It Robust
|
||||
|
||||
## Current Architecture: Skill Events → Trigger Registration
|
||||
|
||||
The pipeline has **two paths** for getting a cron trigger loaded, plus a **polling loop** that actually fires them.
|
||||
|
||||
### Path 1: Bulk load at startup (EOSE-driven)
|
||||
|
||||
```mermaid
|
||||
sequenceDiagram
|
||||
participant Main as main.c startup
|
||||
participant NH as nostr_handler
|
||||
participant Relays as Nostr Relays
|
||||
participant TM as trigger_manager
|
||||
|
||||
Main->>TM: trigger_manager_init - capacity=16, last_poll_at=0
|
||||
Main->>TM: trigger_manager_load_from_startup_events
|
||||
Note over TM: Loads triggers from genesis startup_events<br/>only dm triggers typically here
|
||||
Main->>NH: nostr_handler_set_self_skill_eose_callback<br/>registers on_self_skill_eose_load_triggers
|
||||
Main->>NH: nostr_handler_subscribe_self_skills
|
||||
NH->>Relays: REQ kinds 31123, 31124, 10123<br/>authors = agent pubkey
|
||||
Relays-->>NH: EVENT skill events arrive one by one
|
||||
NH->>NH: on_self_skill_event for each<br/>caches in g_self_skill_events<br/>calls register_trigger_from_self_skill_event
|
||||
Relays-->>NH: EOSE
|
||||
NH->>NH: on_self_skill_eose sets g_self_skill_eose_received=1
|
||||
NH->>Main: calls on_self_skill_eose_load_triggers callback
|
||||
Main->>TM: trigger_manager_load_from_skills<br/>reads ALL cached skills via<br/>nostr_handler_get_self_events_by_kind_json
|
||||
Note over TM: For each skill with trigger+filter tags:<br/>calls trigger_manager_add
|
||||
Main->>Main: nostr_handler_wait_for_self_skill_eose returns 0
|
||||
Main->>Main: Startup step 14 passes
|
||||
Main->>Main: Enter main poll loop
|
||||
```
|
||||
|
||||
### Path 2: Live skill event (post-startup)
|
||||
|
||||
```mermaid
|
||||
sequenceDiagram
|
||||
participant Relays as Nostr Relays
|
||||
participant NH as nostr_handler
|
||||
participant TM as trigger_manager
|
||||
|
||||
Note over NH: Subscription stays open after EOSE
|
||||
Relays-->>NH: New/updated skill EVENT arrives
|
||||
NH->>NH: on_self_skill_event
|
||||
NH->>NH: register_trigger_from_self_skill_event
|
||||
Note over NH: Checks: kind 31123/31124?<br/>Has trigger+filter tags?<br/>Is adopted via kind 10123?
|
||||
NH->>TM: trigger_manager_add or update
|
||||
NH->>NH: self_skill_cache_upsert_event_locked
|
||||
```
|
||||
|
||||
### Path 3: Cron polling in main loop
|
||||
|
||||
```mermaid
|
||||
sequenceDiagram
|
||||
participant Main as main loop
|
||||
participant TM as trigger_manager
|
||||
participant Agent as agent.c
|
||||
|
||||
loop Every iteration
|
||||
Main->>TM: trigger_manager_poll
|
||||
Note over TM: Throttled to once per 30 seconds
|
||||
TM->>TM: For each trigger where type==CRON:<br/>1. Validate cron_expr<br/>2. Check cron_matches_now<br/>3. Check last_cron_fire dedup - 50s guard
|
||||
TM->>Agent: execute_llm_action with synthetic cron event
|
||||
end
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## The Six Failure Points
|
||||
|
||||
I have identified six places where a cron trigger can silently fail to load or fire. They are ordered from most likely to least likely given the logs from laantungir.net.
|
||||
|
||||
### 1. EOSE timeout kills startup entirely
|
||||
|
||||
**Where:** [`main.c:1494–1515`](src/main.c:1494)
|
||||
|
||||
The agent waits 15 seconds for self-skill EOSE. If relays are slow or the connection is flaky, this times out and **the entire agent process exits**:
|
||||
|
||||
```c
|
||||
const int self_skill_eose_timeout_ms = 15000;
|
||||
if (nostr_handler_wait_for_self_skill_eose(self_skill_eose_timeout_ms) != 0) {
|
||||
// ... prints error, cleans up, return 1
|
||||
}
|
||||
```
|
||||
|
||||
From [`debug.log`](debug.log) line 670:
|
||||
```
|
||||
[2026-04-06 16:16:49] [ERROR] startup checklist [14] Subscribe self-skill cache: failed
|
||||
(self-skill EOSE not received within timeout)
|
||||
```
|
||||
|
||||
**Impact:** If the agent crashes at startup, no triggers load at all. If systemd restarts it and it crashes again, you get a restart loop with zero cron execution.
|
||||
|
||||
### 2. Adoption check gates live trigger registration
|
||||
|
||||
**Where:** [`nostr_handler.c:1462`](src/nostr_handler.c:1462)
|
||||
|
||||
When a skill event arrives live via [`on_self_skill_event()`](src/nostr_handler.c:2428), it calls [`register_trigger_from_self_skill_event()`](src/nostr_handler.c:1418) which checks:
|
||||
|
||||
```c
|
||||
if (!self_skill_is_adopted_local(kind_val, pubkey->valuestring, d_tag)) {
|
||||
DEBUG_INFO("live self-skill trigger ignored (not adopted) d_tag=%s", d_tag);
|
||||
return;
|
||||
}
|
||||
```
|
||||
|
||||
[`self_skill_is_adopted_local()`](src/nostr_handler.c:1366) scans `g_self_skill_events` for a kind 10123 event with an `["a", "31123:<pubkey>:<d_tag>"]` tag. If the adoption event hasn't arrived yet (race condition — skill event arrives before adoption event), the trigger is silently skipped.
|
||||
|
||||
**Critically:** The bulk load path in [`trigger_manager_load_from_skills()`](src/trigger_manager.c:818) does **NOT** check adoption at all — it loads every skill with trigger+filter tags. So the adoption gate only affects the live path.
|
||||
|
||||
### 3. Bulk load is one-shot with `loaded_once` guard
|
||||
|
||||
**Where:** [`main.c:42`](src/main.c:42)
|
||||
|
||||
```c
|
||||
if (ctx->loaded_once) {
|
||||
DEBUG_TRACE("deferred trigger load skipped (already loaded once)");
|
||||
return;
|
||||
}
|
||||
```
|
||||
|
||||
The EOSE callback fires once, calls [`trigger_manager_load_from_skills()`](src/trigger_manager.c:818), sets `loaded_once = 1`, and never runs again. If a skill event arrives **after** EOSE but **before** the live path processes it, it falls into a gap:
|
||||
- The bulk load already ran and won't run again
|
||||
- The live [`register_trigger_from_self_skill_event()`](src/nostr_handler.c:1418) may reject it due to the adoption check
|
||||
|
||||
### 4. Cron expression validation rejects silently at add time
|
||||
|
||||
**Where:** [`trigger_manager.c:1106–1112`](src/trigger_manager.c:1106)
|
||||
|
||||
```c
|
||||
if (t->trigger_type == TRIGGER_TYPE_CRON) {
|
||||
if (!cron_expr_is_valid(filter_json, normalized_cron, sizeof(normalized_cron))) {
|
||||
DEBUG_WARN("trigger add rejected: invalid cron expression d_tag=%s expr=%s", ...);
|
||||
memset(t, 0, sizeof(*t));
|
||||
return -1;
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
This does log a warning, but the caller in [`trigger_manager_load_from_skills()`](src/trigger_manager.c:896) just silently skips to the next skill. The `considered` count increments but `loaded` does not — the only evidence is in the log line `trigger manager loaded X trigger(s) from self skills (considered=Y)` where X < Y.
|
||||
|
||||
The existing plan in [`fix_cron_triggers_not_firing.md`](plans/fix_cron_triggers_not_firing.md) documents that a bare `*` was being used instead of `* * * * *`, and that shorthand expansion was added. This has been fixed.
|
||||
|
||||
### 5. Poll throttle delays first evaluation by up to 30 seconds
|
||||
|
||||
**Where:** [`trigger_manager.c:1395`](src/trigger_manager.c:1395)
|
||||
|
||||
```c
|
||||
if (mgr->last_poll_at > 0 && (now - mgr->last_poll_at) < 30) {
|
||||
return 0;
|
||||
}
|
||||
```
|
||||
|
||||
After init, `last_poll_at` starts at 0 (fixed from the original bug where it was `time(NULL)`), so the first poll runs immediately. But subsequent polls are throttled to every 30 seconds. For a daily cron this is fine, but it means the cron match window is checked at most twice per minute.
|
||||
|
||||
### 6. Cron dedup guard can skip legitimate fires
|
||||
|
||||
**Where:** [`trigger_manager.c:1422`](src/trigger_manager.c:1422)
|
||||
|
||||
```c
|
||||
if (t->last_cron_fire > 0 && (now - t->last_cron_fire) < 50) {
|
||||
continue;
|
||||
}
|
||||
```
|
||||
|
||||
With a 30-second poll interval and a 50-second dedup window, a cron that matches at minute X will fire once and then be suppressed for the next poll. This is correct behavior for preventing double-fires within the same minute. But if the system clock jumps or NTP adjusts, this could cause unexpected skips.
|
||||
|
||||
---
|
||||
|
||||
## Robustness Suggestions
|
||||
|
||||
### A. Retry EOSE with backoff instead of hard abort
|
||||
|
||||
**Current:** EOSE timeout → process exit.
|
||||
**Proposed:** Retry 2–3 times with increasing timeout (15s, 30s, 60s). If all retries fail, start the agent in degraded mode with only startup-config triggers active, and schedule a background re-attempt.
|
||||
|
||||
```mermaid
|
||||
flowchart TD
|
||||
SUB[Subscribe self-skills] --> WAIT{Wait for EOSE}
|
||||
WAIT -->|Received| LOAD[Load triggers from skills]
|
||||
WAIT -->|Timeout 15s| RETRY1{Retry 1 - 30s timeout}
|
||||
RETRY1 -->|Received| LOAD
|
||||
RETRY1 -->|Timeout| RETRY2{Retry 2 - 60s timeout}
|
||||
RETRY2 -->|Received| LOAD
|
||||
RETRY2 -->|Timeout| DEGRADED[Start in degraded mode<br/>startup triggers only<br/>schedule background retry]
|
||||
LOAD --> READY[Agent READY]
|
||||
DEGRADED --> READY
|
||||
```
|
||||
|
||||
### B. Remove adoption gate from live trigger registration
|
||||
|
||||
The bulk load path doesn't check adoption. The live path does. This inconsistency means a skill created via `skill_create` with `auto_adopt=true` might not register its trigger if the adoption event arrives on a different relay or with different timing.
|
||||
|
||||
**Proposed:** Either:
|
||||
1. Remove the adoption check from [`register_trigger_from_self_skill_event()`](src/nostr_handler.c:1462) to match the bulk path, OR
|
||||
2. Add the adoption check to the bulk path too for consistency, but with a fallback: if adoption events haven't loaded yet, defer the check.
|
||||
|
||||
Option 1 is simpler and more robust. The agent already only subscribes to its own pubkey's events, so all skills in the cache are self-authored.
|
||||
|
||||
### C. Allow re-running bulk trigger load
|
||||
|
||||
Remove the `loaded_once` guard or replace it with a debounced re-load. When new skill events arrive after EOSE, schedule a re-scan of all cached skills after a short delay (e.g., 5 seconds). This closes the gap between EOSE and late-arriving events.
|
||||
|
||||
### D. Add a periodic trigger reconciliation
|
||||
|
||||
Every N minutes (e.g., 5), compare the set of cached skills that have trigger+filter tags against the active trigger list. Register any missing triggers. This is a safety net that catches any trigger that was missed during startup.
|
||||
|
||||
```c
|
||||
// In trigger_manager_poll, after cron evaluation:
|
||||
if (now - mgr->last_reconcile_at > 300) {
|
||||
trigger_manager_load_from_skills(mgr); // idempotent via update path
|
||||
mgr->last_reconcile_at = now;
|
||||
}
|
||||
```
|
||||
|
||||
### E. Log trigger registration summary at startup
|
||||
|
||||
After the deferred trigger load, log not just the count but also which trigger types were loaded:
|
||||
|
||||
```
|
||||
trigger manager loaded 3 trigger(s): dm=2 cron=1 webhook=0 chain=0 nostr-sub=0
|
||||
```
|
||||
|
||||
This makes it immediately obvious from the log whether a cron trigger was picked up.
|
||||
|
||||
### F. Add an HTTP API endpoint for trigger status
|
||||
|
||||
Expose `/api/triggers` that returns the same JSON as the `trigger_list` tool. This allows external monitoring (e.g., a health check script) to verify that expected cron triggers are loaded without going through the LLM.
|
||||
|
||||
---
|
||||
|
||||
## Summary of What Happened on laantungir.net
|
||||
|
||||
Based on the logs:
|
||||
|
||||
1. The agent started successfully multiple times (startup step 14 passed with `skills=3 adoptions=1` or similar).
|
||||
2. The deferred trigger load status shows **only dm-type triggers** were loaded — no cron triggers appear in any log entry.
|
||||
3. On 2026-04-06, the agent failed at startup step 14 with EOSE timeout, meaning it never even got to trigger loading.
|
||||
4. There is **zero** evidence of `cron trigger matched` or `cron trigger skipped` in any log file, confirming the cron trigger was never in the active trigger set.
|
||||
|
||||
The most likely explanation: the cron skill event exists on relays but either:
|
||||
- It was missing the `["trigger", "cron"]` and/or `["filter", "<expr>"]` tags when it was published
|
||||
- It was not adopted (no kind 10123 event referencing it) and arrived via the live path where adoption is checked
|
||||
- The cron expression was invalid and was silently rejected at add time
|
||||
- The EOSE timeout prevented the agent from ever reaching the trigger loading phase
|
||||
|
||||
Running `trigger_list` on the live server will definitively answer which of these is the case.
|
||||
127
plans/nip17_gift_wrap_investigation.md
Normal file
127
plans/nip17_gift_wrap_investigation.md
Normal file
@@ -0,0 +1,127 @@
|
||||
# NIP-17 Gift Wrap Send Failure — Investigation
|
||||
|
||||
## Status: Resolved — Fixed in code (2026-04-11)
|
||||
|
||||
## Date Discovered: 2026-04-11
|
||||
|
||||
## Symptom
|
||||
|
||||
Every outbound NIP-17 DM send from the Anvil agent fails with:
|
||||
```
|
||||
NIP-17 send aborted: gift_wrap creation failed for 1ec454734dcbf6fe...
|
||||
auto DM fallback to NIP-04 for 1ec454734dcbf6fe... after NIP-17 send failure
|
||||
```
|
||||
|
||||
The agent successfully falls back to NIP-04 (kind 4), so DMs are still delivered. However, NIP-17 outbound is completely broken.
|
||||
|
||||
**Inbound NIP-17 works fine** — the agent successfully receives and decrypts kind 1059 gift wraps from the admin.
|
||||
|
||||
## Affected Code Path
|
||||
|
||||
### Send path (broken):
|
||||
1. `nostr_handler_send_dm_auto_with_role()` in `src/nostr_handler.c:3193` — routes to NIP-17 when `dm_protocol` is `nip17` or `both`
|
||||
2. `nostr_handler_send_dm_nip17_with_role()` in `src/nostr_handler.c:4050` — creates chat event, calls `nostr_nip17_send_dm()`
|
||||
3. `nostr_nip17_send_dm()` in `nostr_core_lib/nostr_core/nip017.c:260` — iterates recipients, creates seal + gift wrap
|
||||
4. `nostr_nip59_create_seal()` in `nostr_core_lib/nostr_core/nip059.c:150` — **encrypts rumor JSON with NIP-44**
|
||||
5. `nostr_nip59_create_gift_wrap()` in `nostr_core_lib/nostr_core/nip059.c:226` — wraps seal with random key
|
||||
|
||||
### Receive path (working):
|
||||
1. `on_event()` receives kind 1059
|
||||
2. `nostr_nip17_receive_dm()` in `nostr_core_lib/nostr_core/nip017.c:326` — unwraps gift, unseals rumor
|
||||
3. Successfully extracts kind 14 content
|
||||
|
||||
## Probable Root Cause: Fixed Buffer Overflow in Seal Encryption
|
||||
|
||||
In `nostr_core_lib/nostr_core/nip059.c:163-165`:
|
||||
|
||||
```c
|
||||
char encrypted_content[4096]; // Should be large enough for most events
|
||||
int encrypt_result = nostr_nip44_encrypt(sender_private_key, recipient_public_key,
|
||||
rumor_json, encrypted_content, sizeof(encrypted_content));
|
||||
```
|
||||
|
||||
The seal creation uses a **fixed 4096-byte stack buffer** for the NIP-44 encrypted output. The rumor JSON includes the full DM message content. When the agent sends long responses (the FIPS mesh conversation had multi-KB markdown tables), the rumor JSON easily exceeds 4096 bytes after NIP-44 encryption overhead (base64 encoding roughly 4/3x expansion + padding + nonce).
|
||||
|
||||
**Evidence:**
|
||||
- The agent's DM responses in the log were 2-5KB of markdown content
|
||||
- After JSON serialization of the kind 14 rumor (adding pubkey, tags, created_at, kind fields), the total is larger
|
||||
- NIP-44 encryption adds: 32-byte nonce + padding + base64 encoding → roughly 1.5-2x expansion
|
||||
- A 3KB message → ~4.5KB rumor JSON → ~6-9KB encrypted → overflows 4096 buffer
|
||||
- `nostr_nip44_encrypt()` returns error, `nostr_nip59_create_seal()` returns NULL
|
||||
- `nostr_nip17_send_dm()` skips the recipient, returns 0 wraps
|
||||
- `nostr_handler_send_dm_nip17_with_role()` logs "gift_wrap creation failed"
|
||||
|
||||
**Why receive works:** The receive path uses `nostr_nip44_decrypt()` which likely allocates dynamically or has a larger buffer.
|
||||
|
||||
## Secondary Suspect: NIP-44 Encryption Implementation
|
||||
|
||||
The NIP-44 encrypt function itself may have issues with large payloads. Need to check:
|
||||
- `nostr_nip44_encrypt()` in `nostr_core_lib/nostr_core/nip044.c`
|
||||
- Whether it handles the output buffer size parameter correctly
|
||||
- Whether it returns an error code that distinguishes "buffer too small" from other failures
|
||||
|
||||
## Anvil Config Context
|
||||
|
||||
The Anvil agent's genesis config (`/home/user/anvil/genesis.jsonc`) has:
|
||||
```json
|
||||
"dm_protocol": "nip04"
|
||||
```
|
||||
|
||||
But the encrypted user-settings on Nostr (kind 30078) may have overridden this to `"both"` at runtime, which is why NIP-17 send is being attempted.
|
||||
|
||||
## Recommended Fix
|
||||
|
||||
### Option A: Dynamic allocation (preferred)
|
||||
Replace the fixed 4096-byte buffer in `nostr_nip59_create_seal()` with dynamic allocation:
|
||||
```c
|
||||
size_t rumor_len = strlen(rumor_json);
|
||||
size_t encrypted_buf_size = rumor_len * 2 + 256; // generous overhead for NIP-44
|
||||
char* encrypted_content = malloc(encrypted_buf_size);
|
||||
```
|
||||
|
||||
### Option B: Larger fixed buffer (quick fix)
|
||||
Increase the buffer to 64KB or 128KB:
|
||||
```c
|
||||
char encrypted_content[131072]; // 128KB should handle any reasonable DM
|
||||
```
|
||||
Note: This is a stack allocation, so very large buffers could cause stack overflow.
|
||||
|
||||
### Also check `nostr_nip59_create_gift_wrap()`
|
||||
The gift wrap function at `nip059.c:226` may have a similar fixed-buffer issue for encrypting the seal JSON.
|
||||
|
||||
## Log Evidence
|
||||
|
||||
From `/home/user/anvil/debug.log` (2026-04-11):
|
||||
|
||||
```
|
||||
[2026-04-11 09:45:14] [WARN ] NIP-17 send aborted: gift_wrap creation failed for 1ec454734dcbf6fe...
|
||||
[2026-04-11 09:45:14] [WARN ] auto DM fallback to NIP-04 for 1ec454734dcbf6fe... after NIP-17 send failure
|
||||
[2026-04-11 09:50:16] [WARN ] NIP-17 send aborted: gift_wrap creation failed for 1ec454734dcbf6fe...
|
||||
[2026-04-11 09:50:16] [WARN ] auto DM fallback to NIP-04 for 1ec454734dcbf6fe... after NIP-17 send failure
|
||||
[2026-04-11 09:53:42] [WARN ] NIP-17 send aborted: gift_wrap creation failed for 1ec454734dcbf6fe...
|
||||
[2026-04-11 09:53:42] [WARN ] auto DM fallback to NIP-04 for 1ec454734dcbf6fe... after NIP-17 send failure
|
||||
```
|
||||
|
||||
This pattern is 100% consistent — every outbound NIP-17 attempt fails, every time.
|
||||
|
||||
## Related Files
|
||||
|
||||
- `nostr_core_lib/nostr_core/nip059.c` — Gift wrap / seal creation (probable bug location)
|
||||
- `nostr_core_lib/nostr_core/nip044.c` — NIP-44 encryption (check buffer handling)
|
||||
- `nostr_core_lib/nostr_core/nip017.c` — NIP-17 DM send/receive orchestration
|
||||
- `src/nostr_handler.c:4050` — Agent-level NIP-17 send function
|
||||
- `plans/nip17_messaging.md` — Original NIP-17 implementation plan
|
||||
|
||||
## Related Existing Plan
|
||||
|
||||
The `plans/nip17_messaging.md` document covers the original NIP-17 implementation. This investigation is specifically about the send-side gift wrap failure that emerged in production.
|
||||
|
||||
## Resolution Summary
|
||||
|
||||
Implemented in `nostr_core_lib/nostr_core/nip059.c`:
|
||||
|
||||
- Replaced fixed encryption buffers in `nostr_nip59_create_seal()` and `nostr_nip59_create_gift_wrap()` with dynamically sized heap buffers calculated from NIP-44 padding/base64 overhead.
|
||||
- Replaced fixed decrypt buffers in `nostr_nip59_unwrap_gift()` and `nostr_nip59_unseal_rumor()` with dynamic buffers sized from ciphertext length.
|
||||
- Added missing cleanup path in `nostr_nip59_create_seal()` for failure after encryption allocation.
|
||||
|
||||
Expected outcome: outbound NIP-17 gift wrap creation no longer fails for multi-KB DM payloads due to fixed buffer limits.
|
||||
79
src/agent.c
79
src/agent.c
@@ -1449,7 +1449,22 @@ static int refresh_adopted_skills_cache_if_needed(void) {
|
||||
free(adoption_json);
|
||||
|
||||
if (adoption_events && cJSON_IsArray(adoption_events) && cJSON_GetArraySize(adoption_events) > 0) {
|
||||
cJSON* list_event = cJSON_GetArrayItem(adoption_events, 0);
|
||||
cJSON* list_event = NULL;
|
||||
double list_event_created_at = -1.0;
|
||||
int adoption_n = cJSON_GetArraySize(adoption_events);
|
||||
for (int ai = 0; ai < adoption_n; ai++) {
|
||||
cJSON* ev = cJSON_GetArrayItem(adoption_events, ai);
|
||||
if (!ev || !cJSON_IsObject(ev)) {
|
||||
continue;
|
||||
}
|
||||
cJSON* created_at = cJSON_GetObjectItemCaseSensitive(ev, "created_at");
|
||||
double created = (created_at && cJSON_IsNumber(created_at)) ? created_at->valuedouble : 0.0;
|
||||
if (!list_event || created > list_event_created_at) {
|
||||
list_event = ev;
|
||||
list_event_created_at = created;
|
||||
}
|
||||
}
|
||||
|
||||
cJSON* list_tags = list_event ? cJSON_GetObjectItemCaseSensitive(list_event, "tags") : NULL;
|
||||
|
||||
if (list_tags && cJSON_IsArray(list_tags)) {
|
||||
@@ -2182,9 +2197,11 @@ void agent_on_message(const char* sender_pubkey_hex,
|
||||
|
||||
int max_turns = g_cfg->tools.max_turns > 0 ? g_cfg->tools.max_turns : 40;
|
||||
int stall_repeat_threshold = g_cfg->tools.stall_repeat_threshold > 1 ? g_cfg->tools.stall_repeat_threshold : 3;
|
||||
int max_context_bytes = g_cfg->tools.max_context_bytes >= 4096 ? g_cfg->tools.max_context_bytes : (128 * 1024);
|
||||
uint64_t last_tool_fp = 0;
|
||||
int repeated_tool_turns = 0;
|
||||
int exited_on_stall = 0;
|
||||
int exited_on_context_limit = 0;
|
||||
int stall_due_to_length = 0;
|
||||
char* final_answer_owned = NULL;
|
||||
int turns_run = 0;
|
||||
@@ -2196,6 +2213,17 @@ void agent_on_message(const char* sender_pubkey_hex,
|
||||
break;
|
||||
}
|
||||
|
||||
size_t messages_json_len = strlen(messages_json);
|
||||
if ((int)messages_json_len > max_context_bytes) {
|
||||
DEBUG_WARN("[didactyl] DM tool loop context limit exceeded: bytes=%zu max_context_bytes=%d turn=%d",
|
||||
messages_json_len,
|
||||
max_context_bytes,
|
||||
turns_run);
|
||||
exited_on_context_limit = 1;
|
||||
free(messages_json);
|
||||
break;
|
||||
}
|
||||
|
||||
llm_response_t resp;
|
||||
int rc = llm_chat_with_tools_messages(messages_json, tools_json, "auto", &resp);
|
||||
free(messages_json);
|
||||
@@ -2275,28 +2303,35 @@ void agent_on_message(const char* sender_pubkey_hex,
|
||||
llm_response_free(&resp);
|
||||
}
|
||||
|
||||
if (!final_answer_owned) {
|
||||
if (!final_answer_owned && !exited_on_context_limit) {
|
||||
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);
|
||||
size_t messages_json_len = strlen(messages_json);
|
||||
if ((int)messages_json_len <= max_context_bytes) {
|
||||
llm_response_t final_resp;
|
||||
int final_rc = llm_chat_with_tools_messages(messages_json, tools_json, "none", &final_resp);
|
||||
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);
|
||||
}
|
||||
llm_response_free(&final_resp);
|
||||
} else {
|
||||
exited_on_context_limit = 1;
|
||||
}
|
||||
free(messages_json);
|
||||
}
|
||||
}
|
||||
|
||||
int exhausted_on_max_turns = (!final_answer_owned && !exited_on_stall && turns_run >= max_turns);
|
||||
int exhausted_on_max_turns = (!final_answer_owned && !exited_on_stall && !exited_on_context_limit && 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 reached the tool-turn limit before getting a final response from the model.");
|
||||
final_answer_owned = strdup(exited_on_context_limit
|
||||
? "I paused because the working context became too large while using tools. Please narrow the scope and ask me to continue from a smaller context."
|
||||
: (exited_on_stall
|
||||
? "I stopped repeated tool calls after detecting a loop and could not produce a final summary."
|
||||
: "I reached the tool-turn limit before getting a final response from the model."));
|
||||
}
|
||||
|
||||
if (exhausted_on_max_turns) {
|
||||
@@ -2313,17 +2348,25 @@ void agent_on_message(const char* sender_pubkey_hex,
|
||||
|
||||
const char* final_answer = final_answer_owned ? final_answer_owned : "I reached the tool-turn limit before getting a final response from the model.";
|
||||
|
||||
if (exited_on_stall || exhausted_on_max_turns) {
|
||||
if (exited_on_stall || exhausted_on_max_turns || exited_on_context_limit) {
|
||||
notify_admin_limit_diagnostic("dm_agent_loop",
|
||||
exited_on_stall ? "stall_repetition_detected" : "max_turns_exhausted",
|
||||
exited_on_context_limit
|
||||
? "max_context_bytes_exceeded"
|
||||
: (exited_on_stall ? "stall_repetition_detected" : "max_turns_exhausted"),
|
||||
sender_pubkey_hex,
|
||||
max_turns,
|
||||
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,
|
||||
exited_on_context_limit
|
||||
? "tool_context_growth"
|
||||
: ((exited_on_stall && stall_due_to_length) ? "max_tokens_truncation" : NULL),
|
||||
exited_on_context_limit
|
||||
? "Reduce tool-loop breadth or lower max_turns; context exceeded max_context_bytes."
|
||||
: ((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",
|
||||
|
||||
@@ -226,6 +226,7 @@ static int parse_tools_config(cJSON* root, didactyl_config_t* config) {
|
||||
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* max_context_bytes = cJSON_GetObjectItemCaseSensitive(tools, "max_context_bytes");
|
||||
cJSON* local_http_fetch_default_timeout_seconds =
|
||||
cJSON_GetObjectItemCaseSensitive(tools, "local_http_fetch_default_timeout_seconds");
|
||||
cJSON* local_http_fetch_max_timeout_seconds =
|
||||
@@ -252,6 +253,9 @@ static int parse_tools_config(cJSON* root, didactyl_config_t* config) {
|
||||
if (stall_repeat_threshold && cJSON_IsNumber(stall_repeat_threshold)) {
|
||||
config->tools.stall_repeat_threshold = (int)stall_repeat_threshold->valuedouble;
|
||||
}
|
||||
if (max_context_bytes && cJSON_IsNumber(max_context_bytes)) {
|
||||
config->tools.max_context_bytes = (int)max_context_bytes->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;
|
||||
@@ -311,6 +315,9 @@ static int parse_tools_config(cJSON* root, didactyl_config_t* config) {
|
||||
if (config->tools.stall_repeat_threshold < 2) {
|
||||
config->tools.stall_repeat_threshold = 3;
|
||||
}
|
||||
if (config->tools.max_context_bytes < 4096) {
|
||||
config->tools.max_context_bytes = 128 * 1024;
|
||||
}
|
||||
if (config->tools.local_http_fetch_default_timeout_seconds < 1) {
|
||||
config->tools.local_http_fetch_default_timeout_seconds = 20;
|
||||
}
|
||||
@@ -1441,6 +1448,7 @@ int config_load(const char* path, didactyl_config_t* config) {
|
||||
config->tools.api_default_max_turns = 8;
|
||||
config->tools.api_max_turns_ceiling = 32;
|
||||
config->tools.stall_repeat_threshold = 3;
|
||||
config->tools.max_context_bytes = 128 * 1024;
|
||||
config->tools.local_http_fetch_default_timeout_seconds = 20;
|
||||
config->tools.local_http_fetch_max_timeout_seconds = 120;
|
||||
config->tools.blossom_max_upload_bytes = 16 * 1024 * 1024;
|
||||
|
||||
@@ -51,6 +51,7 @@ typedef struct {
|
||||
int api_default_max_turns;
|
||||
int api_max_turns_ceiling;
|
||||
int stall_repeat_threshold;
|
||||
int max_context_bytes;
|
||||
int local_http_fetch_default_timeout_seconds;
|
||||
int local_http_fetch_max_timeout_seconds;
|
||||
shell_tools_config_t shell;
|
||||
|
||||
66
src/main.c
66
src/main.c
@@ -6,6 +6,9 @@
|
||||
#include <string.h>
|
||||
#include <time.h>
|
||||
#include <unistd.h>
|
||||
#include <errno.h>
|
||||
#include <sys/socket.h>
|
||||
#include <sys/un.h>
|
||||
|
||||
#include "../nostr_core_lib/nostr_core/nostr_core.h"
|
||||
#include "main.h"
|
||||
@@ -68,6 +71,59 @@ static void signal_handler(int signum) {
|
||||
g_running = 0;
|
||||
}
|
||||
|
||||
static int systemd_notify_send(const char* state) {
|
||||
if (!state || state[0] == '\0') {
|
||||
return 0;
|
||||
}
|
||||
|
||||
const char* notify_socket = getenv("NOTIFY_SOCKET");
|
||||
if (!notify_socket || notify_socket[0] == '\0') {
|
||||
return 0;
|
||||
}
|
||||
|
||||
if (notify_socket[0] != '/' && notify_socket[0] != '@') {
|
||||
return -1;
|
||||
}
|
||||
|
||||
int fd = socket(AF_UNIX, SOCK_DGRAM | SOCK_CLOEXEC, 0);
|
||||
if (fd < 0) {
|
||||
return -1;
|
||||
}
|
||||
|
||||
struct sockaddr_un addr;
|
||||
memset(&addr, 0, sizeof(addr));
|
||||
addr.sun_family = AF_UNIX;
|
||||
|
||||
size_t path_len = strlen(notify_socket);
|
||||
if (path_len >= sizeof(addr.sun_path)) {
|
||||
close(fd);
|
||||
return -1;
|
||||
}
|
||||
|
||||
memcpy(addr.sun_path, notify_socket, path_len + 1);
|
||||
socklen_t addr_len = (socklen_t)(sizeof(sa_family_t) + path_len + 1U);
|
||||
if (notify_socket[0] == '@') {
|
||||
addr.sun_path[0] = '\0';
|
||||
addr_len = (socklen_t)(sizeof(sa_family_t) + path_len);
|
||||
}
|
||||
|
||||
ssize_t sent = sendto(fd,
|
||||
state,
|
||||
strlen(state),
|
||||
MSG_NOSIGNAL,
|
||||
(const struct sockaddr*)&addr,
|
||||
addr_len);
|
||||
int saved_errno = errno;
|
||||
close(fd);
|
||||
errno = saved_errno;
|
||||
|
||||
if (sent < 0) {
|
||||
return -1;
|
||||
}
|
||||
|
||||
return 1;
|
||||
}
|
||||
|
||||
static void configure_mongoose_log_level_from_debug(void) {
|
||||
if (g_debug_level >= DEBUG_LEVEL_TRACE) {
|
||||
mg_log_set(MG_LL_INFO);
|
||||
@@ -1612,12 +1668,22 @@ int main(int argc, char** argv) {
|
||||
DEBUG_INFO("[didactyl] entering main poll loop");
|
||||
DEBUG_INFO("[didactyl] running with pubkey %s", cfg.keys.public_key_hex);
|
||||
|
||||
(void)systemd_notify_send("READY=1");
|
||||
time_t last_watchdog_ping = 0;
|
||||
|
||||
while (g_running) {
|
||||
(void)nostr_handler_poll(100);
|
||||
(void)trigger_manager_poll(&trigger_manager);
|
||||
if (http_api_started) {
|
||||
(void)http_api_poll(0);
|
||||
}
|
||||
|
||||
time_t now = time(NULL);
|
||||
if (now != (time_t)-1 && (last_watchdog_ping == 0 || (now - last_watchdog_ping) >= 10)) {
|
||||
(void)systemd_notify_send("WATCHDOG=1");
|
||||
last_watchdog_ping = now;
|
||||
}
|
||||
|
||||
struct timespec ts = {0, 10 * 1000 * 1000};
|
||||
nanosleep(&ts, NULL);
|
||||
}
|
||||
|
||||
@@ -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 2
|
||||
#define DIDACTYL_VERSION_PATCH 35
|
||||
#define DIDACTYL_VERSION "v0.2.35"
|
||||
#define DIDACTYL_VERSION_PATCH 41
|
||||
#define DIDACTYL_VERSION "v0.2.41"
|
||||
|
||||
// Agent metadata
|
||||
#define DIDACTYL_NAME "Didactyl"
|
||||
|
||||
@@ -2512,7 +2512,7 @@ int nostr_handler_init(didactyl_config_t* config) {
|
||||
nostr_pool_reconnect_config_t reconnect = *nostr_pool_reconnect_config_default();
|
||||
reconnect.enable_auto_reconnect = 1;
|
||||
reconnect.ping_interval_seconds = 20;
|
||||
reconnect.pong_timeout_seconds = 10;
|
||||
reconnect.pong_timeout_seconds = 30;
|
||||
|
||||
g_pool = nostr_relay_pool_create(&reconnect);
|
||||
if (!g_pool) {
|
||||
|
||||
@@ -276,6 +276,7 @@ static void config_set_defaults(didactyl_config_t* cfg) {
|
||||
cfg->tools.api_default_max_turns = 8;
|
||||
cfg->tools.api_max_turns_ceiling = 32;
|
||||
cfg->tools.stall_repeat_threshold = 3;
|
||||
cfg->tools.max_context_bytes = 128 * 1024;
|
||||
cfg->tools.local_http_fetch_default_timeout_seconds = 20;
|
||||
cfg->tools.local_http_fetch_max_timeout_seconds = 120;
|
||||
cfg->tools.blossom_max_upload_bytes = 16 * 1024 * 1024;
|
||||
@@ -2014,7 +2015,9 @@ static int install_system_service_with_dedicated_user(const didactyl_config_t* c
|
||||
"After=network-online.target\n"
|
||||
"Wants=network-online.target\n\n"
|
||||
"[Service]\n"
|
||||
"Type=simple\n"
|
||||
"Type=notify\n"
|
||||
"NotifyAccess=main\n"
|
||||
"WatchdogSec=120\n"
|
||||
"User=%s\n"
|
||||
"Group=%s\n"
|
||||
"WorkingDirectory=%s\n"
|
||||
|
||||
@@ -333,9 +333,25 @@ static int fetch_adoption_list_tags_local(tools_context_t* ctx, cJSON** out_tags
|
||||
free(events_json);
|
||||
|
||||
if (events && cJSON_IsArray(events) && cJSON_GetArraySize(events) > 0) {
|
||||
cJSON* ev0 = cJSON_GetArrayItem(events, 0);
|
||||
if (ev0 && cJSON_IsObject(ev0)) {
|
||||
cJSON* ev_content = cJSON_GetObjectItemCaseSensitive(ev0, "content");
|
||||
cJSON* best = NULL;
|
||||
double best_created = -1.0;
|
||||
int n = cJSON_GetArraySize(events);
|
||||
for (int i = 0; i < n; i++) {
|
||||
cJSON* ev = cJSON_GetArrayItem(events, i);
|
||||
if (!ev || !cJSON_IsObject(ev)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
cJSON* created_at = cJSON_GetObjectItemCaseSensitive(ev, "created_at");
|
||||
double created = (created_at && cJSON_IsNumber(created_at)) ? created_at->valuedouble : 0.0;
|
||||
if (!best || created > best_created) {
|
||||
best = ev;
|
||||
best_created = created;
|
||||
}
|
||||
}
|
||||
|
||||
if (best && cJSON_IsObject(best)) {
|
||||
cJSON* ev_content = cJSON_GetObjectItemCaseSensitive(best, "content");
|
||||
if (ev_content && cJSON_IsString(ev_content) && ev_content->valuestring) {
|
||||
free(content);
|
||||
content = strdup(ev_content->valuestring);
|
||||
@@ -346,7 +362,7 @@ static int fetch_adoption_list_tags_local(tools_context_t* ctx, cJSON** out_tags
|
||||
}
|
||||
}
|
||||
|
||||
cJSON* ev_tags = cJSON_GetObjectItemCaseSensitive(ev0, "tags");
|
||||
cJSON* ev_tags = cJSON_GetObjectItemCaseSensitive(best, "tags");
|
||||
if (ev_tags && cJSON_IsArray(ev_tags)) {
|
||||
cJSON* dup = cJSON_Duplicate(ev_tags, 1);
|
||||
if (!dup) {
|
||||
@@ -606,6 +622,21 @@ static int maybe_append_loaded_skill_instructions_local(cJSON* messages,
|
||||
return rc;
|
||||
}
|
||||
|
||||
static int is_tool_blocked_in_skill_run_local(const char* tool_name) {
|
||||
if (!tool_name || tool_name[0] == '\0') return 1;
|
||||
|
||||
static const char* blocked_tools[] = {
|
||||
"model_get", "model_set", "model_list",
|
||||
NULL
|
||||
};
|
||||
|
||||
for (int i = 0; blocked_tools[i] != NULL; i++) {
|
||||
if (strcmp(tool_name, blocked_tools[i]) == 0) return 1;
|
||||
}
|
||||
|
||||
return 0;
|
||||
}
|
||||
|
||||
static int is_tool_sandbox_safe_local(const char* tool_name) {
|
||||
if (!tool_name || tool_name[0] == '\0') return 0;
|
||||
|
||||
@@ -2283,7 +2314,9 @@ char* execute_skill_run(tools_context_t* ctx, const char* args_json) {
|
||||
llm_tool_call_t* tc = &resp.tool_calls[i];
|
||||
char* tool_result = NULL;
|
||||
|
||||
if (sandbox_enabled && !is_tool_sandbox_safe_local(tc->name)) {
|
||||
if (is_tool_blocked_in_skill_run_local(tc->name)) {
|
||||
tool_result = strdup("{\"success\":false,\"error\":\"tool blocked by skill_run policy\"}");
|
||||
} else if (sandbox_enabled && !is_tool_sandbox_safe_local(tc->name)) {
|
||||
tool_result = strdup("{\"success\":false,\"error\":\"tool blocked by skill_run sandbox\"}");
|
||||
} else {
|
||||
tool_result = tools_execute(ctx, tc->name, tc->arguments_json);
|
||||
|
||||
@@ -589,6 +589,126 @@ static void parse_trigger_runtime_tags(cJSON* tags,
|
||||
}
|
||||
}
|
||||
|
||||
static int trigger_manager_reconcile_from_relays(trigger_manager_t* mgr) {
|
||||
if (!mgr || !mgr->cfg) {
|
||||
return -1;
|
||||
}
|
||||
|
||||
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(31123));
|
||||
cJSON_AddItemToArray(kinds, cJSON_CreateNumber(31124));
|
||||
cJSON_AddItemToObject(filter, "kinds", kinds);
|
||||
cJSON_AddItemToArray(authors, cJSON_CreateString(mgr->cfg->keys.public_key_hex));
|
||||
cJSON_AddItemToObject(filter, "authors", authors);
|
||||
cJSON_AddNumberToObject(filter, "limit", 300);
|
||||
|
||||
char* events_json = nostr_handler_query_json(filter, 8000);
|
||||
cJSON_Delete(filter);
|
||||
if (!events_json) {
|
||||
DEBUG_WARN("[didactyl] trigger reconcile skipped: relay query returned no data");
|
||||
return -1;
|
||||
}
|
||||
|
||||
cJSON* events = cJSON_Parse(events_json);
|
||||
free(events_json);
|
||||
if (!events || !cJSON_IsArray(events)) {
|
||||
cJSON_Delete(events);
|
||||
DEBUG_WARN("[didactyl] trigger reconcile skipped: invalid JSON from relay query");
|
||||
return -1;
|
||||
}
|
||||
|
||||
int considered = 0;
|
||||
int loaded = 0;
|
||||
|
||||
int event_count = cJSON_GetArraySize(events);
|
||||
for (int i = 0; i < event_count; i++) {
|
||||
cJSON* skill_event = cJSON_GetArrayItem(events, i);
|
||||
cJSON* content = skill_event ? cJSON_GetObjectItemCaseSensitive(skill_event, "content") : NULL;
|
||||
cJSON* tags = skill_event ? cJSON_GetObjectItemCaseSensitive(skill_event, "tags") : NULL;
|
||||
if (!content || !cJSON_IsString(content) || !content->valuestring || !tags || !cJSON_IsArray(tags)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
cJSON* d = find_tag_value_string(tags, "d");
|
||||
cJSON* trigger = find_tag_value_string(tags, "trigger");
|
||||
cJSON* filter_j = find_tag_value_string(tags, "filter");
|
||||
cJSON* action = find_tag_value_string(tags, "action");
|
||||
|
||||
const char* d_tag = (d && cJSON_IsString(d) && d->valuestring) ? d->valuestring : NULL;
|
||||
const char* trigger_s = (trigger && cJSON_IsString(trigger) && trigger->valuestring) ? trigger->valuestring : NULL;
|
||||
const char* filter_s = (filter_j && cJSON_IsString(filter_j) && filter_j->valuestring) ? filter_j->valuestring : NULL;
|
||||
const char* action_s = (action && cJSON_IsString(action) && action->valuestring) ? action->valuestring : "llm";
|
||||
|
||||
considered++;
|
||||
if (!d_tag || d_tag[0] == '\0') {
|
||||
continue;
|
||||
}
|
||||
|
||||
int trigger_supported = trigger_s &&
|
||||
(strcmp(trigger_s, "nostr-subscription") == 0 ||
|
||||
strcmp(trigger_s, "webhook") == 0 ||
|
||||
strcmp(trigger_s, "cron") == 0 ||
|
||||
strcmp(trigger_s, "chain") == 0 ||
|
||||
strcmp(trigger_s, "dm") == 0);
|
||||
if (!trigger_supported || !filter_s || filter_s[0] == '\0') {
|
||||
continue;
|
||||
}
|
||||
|
||||
if (strcmp(action_s, "template") == 0) {
|
||||
DEBUG_WARN("[didactyl] trigger action template is deprecated; forcing llm for d_tag=%s", d_tag);
|
||||
}
|
||||
|
||||
const char* llm_s = NULL;
|
||||
const char* tools_s = NULL;
|
||||
int has_max_tokens = 0;
|
||||
int max_tokens = 0;
|
||||
int has_temperature = 0;
|
||||
double temperature = 0.0;
|
||||
int has_seed = 0;
|
||||
int seed = 0;
|
||||
parse_trigger_runtime_tags(tags,
|
||||
&llm_s,
|
||||
&tools_s,
|
||||
&has_max_tokens,
|
||||
&max_tokens,
|
||||
&has_temperature,
|
||||
&temperature,
|
||||
&has_seed,
|
||||
&seed);
|
||||
|
||||
if (trigger_manager_add(mgr,
|
||||
d_tag,
|
||||
content->valuestring,
|
||||
filter_s,
|
||||
TRIGGER_ACTION_LLM,
|
||||
trigger_s,
|
||||
1,
|
||||
llm_s,
|
||||
tools_s,
|
||||
has_max_tokens,
|
||||
max_tokens,
|
||||
has_temperature,
|
||||
temperature,
|
||||
has_seed,
|
||||
seed) == 0) {
|
||||
loaded++;
|
||||
}
|
||||
}
|
||||
|
||||
cJSON_Delete(events);
|
||||
DEBUG_INFO("[didactyl] trigger reconcile complete: loaded=%d considered=%d", loaded, considered);
|
||||
return 0;
|
||||
}
|
||||
|
||||
static void apply_trigger_runtime_to_llm_config(const active_trigger_t* t, llm_config_t* cfg) {
|
||||
if (!t || !cfg) return;
|
||||
|
||||
@@ -810,6 +930,7 @@ int trigger_manager_init(trigger_manager_t* mgr, didactyl_config_t* cfg) {
|
||||
}
|
||||
|
||||
mgr->last_poll_at = 0;
|
||||
mgr->last_reconcile_at = 0;
|
||||
|
||||
DEBUG_INFO("[didactyl] trigger manager initialized (capacity=%d)", mgr->capacity);
|
||||
return 0;
|
||||
@@ -1447,6 +1568,17 @@ int trigger_manager_poll(trigger_manager_t* mgr) {
|
||||
}
|
||||
pthread_mutex_unlock(&mgr->mutex);
|
||||
|
||||
/*
|
||||
* Temporarily disable periodic relay-backed trigger reconciliation.
|
||||
* Keep code path in place for quick re-enable after adoption/cache fix validation.
|
||||
*/
|
||||
int enable_periodic_reconcile = 0;
|
||||
if (enable_periodic_reconcile &&
|
||||
(mgr->last_reconcile_at <= 0 || (now - mgr->last_reconcile_at) >= 900)) {
|
||||
mgr->last_reconcile_at = now;
|
||||
(void)trigger_manager_reconcile_from_relays(mgr);
|
||||
}
|
||||
|
||||
return fired;
|
||||
}
|
||||
|
||||
|
||||
@@ -57,6 +57,7 @@ typedef struct trigger_manager {
|
||||
didactyl_config_t* cfg;
|
||||
pthread_mutex_t mutex;
|
||||
time_t last_poll_at;
|
||||
time_t last_reconcile_at;
|
||||
} trigger_manager_t;
|
||||
|
||||
int trigger_manager_init(trigger_manager_t* mgr, didactyl_config_t* cfg);
|
||||
|
||||
Reference in New Issue
Block a user