8.8 KiB
Implementation Plan: Trigger-Scoped Skill Architecture
Target spec: docs/SKILLS.md · docs/CONTEXT.md · docs/TOOLS.md
Current State
g_system_contextis a monolithic global string (from the default skill) prepended to every LLM callagent_on_trigger()prependsg_system_contextto every triggered skill executionagent_on_message()falls through tog_system_contextchat if no DM trigger firestrigger_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()parsesprovider/modelbut discards the provider- Skill
contentis JSON withdescriptionandtemplatefields (spec says content IS the template) ["tools", "true/false/csv"]tag controls tool access (spec says userequires_toolinstead)["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_lookupfunction pointer totools_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:
- Load the adoption list (kind
10123) — already cached at startup - For each adopted skill, check if it has a trigger matching
trigger_typeandtrigger_filter - If match: resolve the skill's template (expanding
{{...}}references via Phase 1a) - Concatenate all matched skill templates in adoption-list order
- Append the triggering event payload
- For DM triggers: always append raw message content at the end
- 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:
- Use
build_context_from_triggers()withTRIGGER_TYPE_DMand the sender tier - This automatically finds all DM-triggered skills in adoption-list order and composes them
- Always append the raw admin message at the end (default DM handler)
- If no DM-triggered skill exists, use a minimal built-in default: "You are an AI agent. Respond to the message."
- 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:
- Build a summarization request: "Summarize your progress so far, including key findings and remaining work."
- Send to LLM with
tool_choice: "none"(text only) - Replace all tool call/result messages with a single system message containing the summary
- 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:
- Parse
provider/modelor baremodelor capability keyword - Check if the model is available (query provider, or check a local model list)
- If available, use it and stop
- If not, try the next entry
- Capability keywords (
cheap,fast,best,default) resolve to runtime-configured models
Phase 6: Remove Obsolete Code
File: src/agent.c
- Remove
g_system_contextglobal variable - Remove
g_system_contextfromagent_init()parameter - Remove
g_system_contextprepend from all code paths
File: src/nostr_handler.c
- Remove
g_system_contextglobal variable (line 28) - Remove
g_system_contextinitialization innostr_handler_reconcile_startup_events()(line 3124-3157) - Remove
nostr_handler_get_system_context()function
File: src/agent.h
- Update
agent_init()signature to removesystem_contextparameter
Migration Path
- Phase 0 — safe, independent fix. No behavioral change.
- Phase 1 — core change. Backward compatible if existing default skill has a DM trigger tag. The
g_system_contextis still used as fallback until Phase 6. - Phase 2 — changes DM behavior. Existing agents work if default skill has
["trigger", "dm"]. - Phase 3 — additive. Skills without triggers are unaffected.
- Phase 4 — additive. New capability, no existing behavior changes.
- Phase 5 — additive. Currently only first entry is used; this adds fallback.
- Phase 6 — cleanup. Only after Phases 1-2 are stable.