Files
didactyl/plans/trigger_scoped_skill_architecture.md
T

8.8 KiB

Implementation Plan: Trigger-Scoped Skill Architecture

Target spec: docs/SKILLS.md · docs/CONTEXT.md · docs/TOOLS.md

Current State

  • g_system_context is a monolithic global string (from the default skill) prepended to every LLM call
  • agent_on_trigger() prepends g_system_context to every triggered skill execution
  • agent_on_message() falls through to g_system_context chat if no DM trigger fires
  • trigger_manager_fire_dm() fires all matching DM triggers independently (separate LLM calls each)
  • trigger_manager_load_from_skills() loads triggers from adopted skills only
  • Template variables resolve to tools only — no skill-to-skill {{d_tag}} resolution
  • apply_trigger_runtime_to_llm_config() parses provider/model but discards the provider
  • Skill content is JSON with description and template fields (spec says content IS the template)
  • ["tools", "true/false/csv"] tag controls tool access (spec says use requires_tool instead)
  • ["enabled", "true/false"] tag on triggers (spec says adoption IS enablement)

Phase 0: Provider Override Fix

File: src/trigger_manager.c Function: apply_trigger_runtime_to_llm_config() (line 592)

Currently at line 602-611, when a slash is found in the llm_spec, only the model (after slash) is extracted. The provider (before slash) is discarded.

Change: When slash is found, also copy the provider prefix into cfg->provider.

// Current: only extracts model
if (slash) {
    const char* model = slash + 1;
    // ... sets cfg->model only
}

// New: extract both provider and model
if (slash) {
    size_t provider_len = (size_t)(slash - spec);
    if (provider_len > 0 && provider_len < sizeof(cfg->provider)) {
        snprintf(cfg->provider, sizeof(cfg->provider), "%.*s", (int)provider_len, spec);
    }
    const char* model = slash + 1;
    // ... sets cfg->model as before
}

Phase 1: Two-Layer Context Assembly

1a. Skill-to-skill template resolution

File: src/prompt_template.c Function: map_variable_tool_name() (line 100)

Currently returns a tool name for known variables, or NULL for unknown ones (which resolve to empty).

Change: Add a fallback path. If the variable name doesn't match a known tool, look it up as a skill d-tag in the adoption list cache. If found, return the skill's content.

This requires access to the skill cache from the template resolver. Options:

  • Pass a skill lookup callback into the template builder
  • Add a skill_content_lookup function pointer to tools_context_t

File: src/prompt_template.c Function: prompt_template_build_messages() (line 37)

When resolving a {{variable}} that doesn't match a tool, call the skill lookup function to check adopted skills by d-tag.

1b. Trigger-matched context assembly

File: src/agent.c New function: build_context_from_triggers()

char* build_context_from_triggers(
    trigger_type_t trigger_type,
    const char* trigger_filter,
    cJSON* trigger_event,
    const char* relay_url);

Implementation:

  1. Load the adoption list (kind 10123) — already cached at startup
  2. For each adopted skill, check if it has a trigger matching trigger_type and trigger_filter
  3. If match: resolve the skill's template (expanding {{...}} references via Phase 1a)
  4. Concatenate all matched skill templates in adoption-list order
  5. Append the triggering event payload
  6. For DM triggers: always append raw message content at the end
  7. Return the assembled system prompt

1c. Replace g_system_context in trigger execution

File: src/agent.c Function: agent_on_trigger() (line 1817)

Current (line 1852-1858):

snprintf(system_prompt, system_len, "%s\n\n%s%s\nRelay: %s\n\nSkill instructions:\n%s",
         g_system_context,
         trigger_prefix, skill_d_tag, relay, skill_content);

Replace with: Call build_context_from_triggers() instead of prepending g_system_context.

1d. Skill content format change

File: src/nostr_handler.c and src/config.c

Currently skill content is parsed as JSON to extract template field. The spec says content IS the template (plain string).

Change: When loading a skill's content, check if it's a JSON object with a template field (backward compat) or a plain string (new format). Use the template/string directly.

Phase 2: DM Composition with Default Handler

File: src/agent.c Function: agent_on_message() (line 2085)

Currently at line 2120-2128, calls trigger_manager_fire_dm() which fires each matching DM trigger independently. If none fire, falls through to g_system_context chat.

Change:

  1. Use build_context_from_triggers() with TRIGGER_TYPE_DM and the sender tier
  2. This automatically finds all DM-triggered skills in adoption-list order and composes them
  3. Always append the raw admin message at the end (default DM handler)
  4. If no DM-triggered skill exists, use a minimal built-in default: "You are an AI agent. Respond to the message."
  5. Make one LLM call with the composed context

File: src/trigger_manager.c Function: trigger_manager_fire_dm() (line 1545)

Currently fires all matching DM triggers independently (each gets its own LLM call).

Change: Instead of firing independently, return the list of matching DM skill d-tags (in adoption-list order). Let the caller (agent_on_message) compose them into a single context via build_context_from_triggers().

New function signature:

int trigger_manager_get_dm_skills(trigger_manager_t* mgr,
                                   const char* sender_pubkey_hex,
                                   didactyl_sender_tier_t tier,
                                   char** out_d_tags,
                                   int max_d_tags);

Phase 3: Trigger Discovery Independent of Adoption

File: src/trigger_manager.c Function: trigger_manager_load_from_skills() (line ~varies)

Currently loads triggers only from adopted skills in the 10123 list.

Change: Scan all skill events published by the agent (query own pubkey for kinds 31123/31124), not just adopted ones. Arm any skill with trigger tags. The adoption list controls {{...}} resolution and ordering, not trigger arming.

Non-adopted skills with triggers fire in isolation (no layer 2 skill references available).

Phase 4: Context Compaction

File: src/agent.c Functions: Tool loops in agent_on_trigger() (line 1905) and agent_on_message() (line 2212)

New function:

static int estimate_context_tokens(cJSON* messages);

Approximate: sum character lengths of all message content fields, divide by 4.

Change in tool loops: Before each LLM call, check if estimate_context_tokens(messages) exceeds 70% of the model's context window. If so:

  1. Build a summarization request: "Summarize your progress so far, including key findings and remaining work."
  2. Send to LLM with tool_choice: "none" (text only)
  3. Replace all tool call/result messages with a single system message containing the summary
  4. Continue the tool loop with the compacted context

Phase 5: LLM Fallback Chain

File: src/trigger_manager.c Function: apply_trigger_runtime_to_llm_config() (line 592)

Currently parses the comma in llm_spec but only uses the first entry.

Change: Walk the comma-separated entries. For each:

  1. Parse provider/model or bare model or capability keyword
  2. Check if the model is available (query provider, or check a local model list)
  3. If available, use it and stop
  4. If not, try the next entry
  5. Capability keywords (cheap, fast, best, default) resolve to runtime-configured models

Phase 6: Remove Obsolete Code

File: src/agent.c

  • Remove g_system_context global variable
  • Remove g_system_context from agent_init() parameter
  • Remove g_system_context prepend from all code paths

File: src/nostr_handler.c

  • Remove g_system_context global variable (line 28)
  • Remove g_system_context initialization in nostr_handler_reconcile_startup_events() (line 3124-3157)
  • Remove nostr_handler_get_system_context() function

File: src/agent.h

  • Update agent_init() signature to remove system_context parameter

Migration Path

  1. Phase 0 — safe, independent fix. No behavioral change.
  2. Phase 1 — core change. Backward compatible if existing default skill has a DM trigger tag. The g_system_context is still used as fallback until Phase 6.
  3. Phase 2 — changes DM behavior. Existing agents work if default skill has ["trigger", "dm"].
  4. Phase 3 — additive. Skills without triggers are unaffected.
  5. Phase 4 — additive. New capability, no existing behavior changes.
  6. Phase 5 — additive. Currently only first entry is used; this adds fallback.
  7. Phase 6 — cleanup. Only after Phases 1-2 are stable.