Files
didactyl/plans/run_skill_architecture.md

29 KiB
Raw Permalink Blame History

Run Skill Architecture — Tradeoff Analysis

The Question

Skills today are adopted — their content is injected into the agent's context window for every matching trigger. This is "always on" and costs context tokens on every LLM call, even when the skill's capability isn't needed.

What if an agent could use a skill once and be done with it — like calling a tool?

Two approaches exist. This document analyzes both.


Approach A: Sub-Invocation (Separate LLM Call)

Concept: A skill_run tool that spins up an entirely separate LLM execution with the skill's instructions as the system prompt, runs its own tool loop to completion, and returns the result to the calling context.

sequenceDiagram
    participant Admin
    participant Agent as Agent - Main Context
    participant SkillRun as skill_run Executor
    participant LLM2 as LLM - Skill Context

    Admin->>Agent: Hey, summarize this thread
    Agent->>Agent: I need the summarize-thread skill
    Agent->>SkillRun: skill_run d_tag=summarize-thread, args=thread_id
    SkillRun->>SkillRun: Fetch skill content from Nostr
    SkillRun->>SkillRun: Build isolated context window
    SkillRun->>LLM2: System: skill instructions, User: args
    LLM2->>SkillRun: Tool calls + reasoning
    SkillRun->>SkillRun: Execute tool loop to completion
    LLM2->>SkillRun: Final text response
    SkillRun->>Agent: Return result as tool output
    Agent->>Admin: Here is the summary...

How It Works

  1. The LLM in the main conversation calls skill_run with a d_tag (or full kind:pubkey:d_tag address) and args
  2. The runtime fetches the skill event from Nostr (or adopted skills cache)
  3. A new, isolated context window is built:
    • System prompt = skill content (with {{...}} template variables resolved)
    • User message = the args parameter
    • Tools = the skill's requires_tool tags (or full tool set for own skills)
    • LLM config = the skill's llm, temperature, max_tokens tags (or agent defaults)
  4. A separate tool loop runs (same pattern as agent_on_trigger())
  5. The final LLM text response is returned as the skill_run tool result
  6. The calling context continues with that result

Advantages

Advantage Detail
Zero context cost when idle The skill's instructions never enter the main context window unless invoked
Isolation The skill runs in its own context — it can't see or interfere with the main conversation
Per-skill model Each skill can use a different LLM model/provider via its llm tag
Sandboxing External skills can be restricted to safe tools only
Composability Skills can call other skills via nested skill_run (with depth limits)
Try-before-adopt Run someone else's skill once without permanently adopting it
Clean separation The skill is a black box — input in, result out

Disadvantages

Disadvantage Detail
Extra LLM call Every skill_run invocation costs at least one additional LLM API call (possibly more if the skill uses tools)
Latency The main conversation blocks while the sub-invocation runs its tool loop
No shared context The skill doesn't know about the ongoing conversation — it only sees its args
Complexity Requires managing nested execution contexts, preventing infinite recursion, handling timeouts
Token overhead The skill's system prompt is paid for separately — no sharing with the main context

Existing Design

This approach is already designed in detail in plans/tool_orchestration.md as the skill_run tool (Section 2B), including:

  • Schema definition
  • Execution flow with sandbox for external skills
  • Tool safety classification
  • /run slash command for admin direct invocation

Approach B: Lazy Injection (On-Demand Context Loading)

Concept: A skill_load tool that fetches a skill's content and injects it into the current context window mid-conversation. The LLM then uses those instructions in subsequent reasoning within the same call.

sequenceDiagram
    participant Admin
    participant Agent as Agent - Single Context
    participant LLM as LLM

    Admin->>Agent: Hey, summarize this thread
    Agent->>LLM: System + User message
    LLM->>Agent: I need the summarize-thread skill. Call skill_load.
    Agent->>Agent: Fetch skill content, append to messages
    Agent->>LLM: ...previous messages + skill content as new system message
    LLM->>Agent: Now executing with skill instructions in context...
    Agent->>Admin: Here is the summary...

How It Works

  1. The LLM calls skill_load with a d_tag
  2. The runtime fetches the skill content
  3. The skill content is returned as the tool result (or injected as a new system message)
  4. The LLM continues in the same context window, now with the skill instructions available
  5. No separate LLM call — the skill instructions become part of the ongoing conversation

Advantages

Advantage Detail
No extra LLM call The skill instructions are loaded into the current context — no sub-invocation overhead
Shared context The skill has full access to the conversation history and can reason about it
Lower latency No separate tool loop — the LLM just continues with more information
Simpler implementation Just fetch content and return it as a tool result — no nested execution management
Natural flow The LLM decides when it needs more instructions and loads them dynamically

Disadvantages

Disadvantage Detail
Context window cost Once loaded, the skill content stays in the context for the rest of the conversation
No isolation The skill instructions mix with everything else — potential for instruction conflicts
No per-skill model The skill runs on whatever model the current conversation is using
No sandboxing The skill's instructions execute with full tool access (same as the main context)
Accumulation Loading multiple skills grows the context window — could hit token limits
No clean boundary The LLM might partially follow skill instructions or blend them with other context

Implementation

This is essentially what skill_get already does — it returns skill content. The difference would be:

  • skill_get returns metadata + content as a JSON blob for the LLM to read
  • skill_load would inject the content as a system-level instruction that the LLM should follow

In practice, the LLM can already do this today by calling skill_get and then reasoning about the returned content. The question is whether a dedicated skill_load tool that injects content at the system level would be meaningfully different.


Approach C: Hybrid — Both, With Different Use Cases

The two approaches serve different needs and are not mutually exclusive:

graph TD
    A[Agent needs a skill capability] --> B{What kind of need?}
    B -->|One-shot task with clear input/output| C[skill_run - Sub-invocation]
    B -->|Need skill knowledge in ongoing conversation| D[skill_load - Lazy injection]
    B -->|Always need this skill| E[skill_adopt - Permanent adoption]

    C --> F[Isolated execution, result returned]
    D --> G[Instructions loaded into current context]
    E --> H[Instructions in every matching trigger context]

    style C fill:#e1f5fe
    style D fill:#fff3e0
    style E fill:#e8f5e9

When to Use Each

Scenario Best Approach Why
Run a friend's skill to try it skill_run Isolation + sandbox for untrusted code
Spell-check this message skill_run Clear input/output, no conversation context needed
Deploy my website skill_run Task-oriented, uses different tools, benefits from isolation
I need to know the formatting rules for Nostr posts skill_load Reference material for the current conversation
Help me write a skill (load the skill-authoring guide) skill_load The LLM needs the instructions as ongoing context
Always respond in a certain personality skill_adopt Needed in every conversation

The Key Distinction

  • skill_run = "Do this task for me and give me the result" (function call semantics)
  • skill_load = "Teach me how to do this so I can do it myself" (knowledge injection)
  • skill_adopt = "I always need to know this" (permanent context)

Relationship to Existing Architecture

Chain Triggers vs skill_run

Chain triggers already provide sequential skill execution — Skill A completes, then Skill B fires. But chains are pre-configured (declared in skill tags) and event-driven (fire automatically).

skill_run is dynamic — the LLM decides at runtime which skill to invoke based on the conversation. It's the difference between a cron job and a function call.

Maturity Levels

The tool_orchestration.md plan defines three maturity levels that apply to skill_run:

Level Execution LLM?
draft LLM interprets skill instructions Yes
guided LLM with forced tool_choice Yes, constrained
hardened Deterministic step executor No

A hardened skill via skill_run is essentially a tool — no LLM call, just sequential tool execution. This is the ultimate "skill as tool" pattern.

Recursive skill_run

skill_run can call skill_run (a skill invokes another skill). This needs a depth limit (like the chain depth limit of 5 in trigger_manager_fire_chains()). Each level gets its own context window and tool loop.


Implementation Considerations

For skill_run (Sub-Invocation)

The core implementation reuses the existing agent_on_trigger() pattern:

  1. New function: execute_skill_run() in tool_skill.c
  2. Context building: Reuse build_context_from_triggers() pattern but for a single skill
  3. Tool loop: Same pattern as agent_on_trigger() — call LLM, execute tools, repeat
  4. LLM config: Save/restore pattern from apply_trigger_runtime_to_llm_config()
  5. Sandbox: Filter tools array based on skill origin (own vs external)
  6. Depth tracking: Static or thread-local counter to prevent infinite recursion
  7. Result capture: Capture the final LLM text response and return it as the tool result

For skill_load (Lazy Injection)

Minimal new code needed:

  1. New tool: skill_load in tool_skill.c
  2. Fetch: Same as skill_get — fetch skill content from cache or Nostr
  3. Return: Return the skill content as the tool result with a wrapper indicating these are instructions to follow
  4. Alternative: Inject as a system message in the messages array (requires access to the messages array from within tool execution, which the current architecture doesn't support)

The simpler version (return as tool result) works today with minimal changes. The LLM receives the skill content as a tool response and can reason about it. The more sophisticated version (system message injection) would require architectural changes to how tools interact with the message array.


Approach D: Modes — Skill Packages as Agent Personalities

The Roo Code Parallel

Roo Code has modes — Architect, Code, Ask, Debug — each with its own:

  • System prompt / personality
  • Set of allowed tools
  • File access restrictions
  • Model preferences

A mode is essentially a curated bundle of capabilities that the agent switches into. You don't load the Architect's planning instructions into the Code mode's context — you switch modes entirely.

This maps directly onto Didactyl's skill system.

Modes as Skill Packages

A mode is a named collection of skills that, when activated, replaces the agent's current context window composition. It's a different adoption list — a different set of Layer 1 skills, different tools, potentially a different LLM model.

graph TD
    A[Agent receives /mode architect] --> B[Look up mode: architect]
    B --> C[Mode defines skills: planning, analysis, design-patterns]
    C --> D[Mode defines model: best]
    C --> E[Mode defines tools: memory_*, nostr_query, skill_search]
    D --> F[Reconfigure agent context for this session]
    E --> F
    F --> G[Agent now operates as Architect]
    G --> H[All subsequent messages use architect context]

Mode Definition as a Skill

A mode is itself a skill — a meta-skill that declares which other skills compose it:

{
  "kind": 31123,
  "content": "You are operating in Architect mode.\n\nYour role is to plan, design, and analyze before implementation.\n- Break down complex problems into clear steps\n- Create technical specifications\n- Design system architecture\n- Do NOT write implementation code\n\n{{identity}}\n{{planning-guidelines}}",
  "tags": [
    ["d", "mode-architect"],
    ["description", "Architect mode - planning and design"],
    ["mode", "architect"],
    ["llm", "best"],
    ["temperature", "0.7"],
    ["requires_skill", "identity"],
    ["requires_skill", "planning-guidelines"],
    ["requires_tool", "memory_save"],
    ["requires_tool", "memory_recall"],
    ["requires_tool", "nostr_query"],
    ["requires_tool", "skill_search"],
    ["requires_tool", "skill_list"]
  ]
}
{
  "kind": 31123,
  "content": "You are operating in Cheap mode.\n\nYou are a fast, cost-effective assistant.\n- Answer questions directly and concisely\n- Use minimal tokens\n- Do not over-explain\n\n{{identity}}",
  "tags": [
    ["d", "mode-cheap"],
    ["description", "Cheap mode - fast answers with inexpensive model"],
    ["mode", "cheap"],
    ["llm", "openai/gpt-4o-mini, cheap"],
    ["temperature", "0.3"],
    ["max_tokens", "500"],
    ["requires_skill", "identity"],
    ["requires_tool", "nostr_query"],
    ["requires_tool", "memory_recall"]
  ]
}

How Modes Interact with skill_run

Your example — /skill_run cheap "how many files are in the directory" — reveals two distinct use cases:

1. One-shot mode invocation (skill_run): Run a single task using a mode's configuration, then return to normal. The mode's model, tools, and personality apply for just that one execution.

/skill_run mode-cheap "how many files are in the directory"

This uses the skill_run sub-invocation pattern: spin up an isolated context with the mode-cheap skill's instructions, use its llm tag (cheap model), execute the tool loop, return the result. The main conversation continues unchanged.

2. Persistent mode switch (/mode): Switch the agent's operating mode for all subsequent messages until switched again.

/mode architect
> Now operating in Architect mode. Using best model. Planning tools available.

/mode cheap
> Now operating in Cheap mode. Using gpt-4o-mini. Minimal tool set.

/mode default
> Restored default mode.

This changes which skills compose the agent's context window for DM conversations. Instead of the normal adoption-list-order composition, the mode's skill set takes over.

Mode Architecture

graph TD
    subgraph Current Architecture
        A[Adoption List - kind 10123] --> B[All adopted skills with dm trigger]
        B --> C[Context window for every DM]
    end

    subgraph Mode Architecture
        D[Adoption List - kind 10123] --> E{Active mode?}
        E -->|No mode| F[Default: all adopted dm-triggered skills]
        E -->|Mode active| G[Mode skill + its requires_skill dependencies]
        F --> H[Context window]
        G --> H
    end

    subgraph Mode Lifecycle
        I[/mode architect] --> J[Set active mode = mode-architect]
        J --> K[Subsequent DMs use architect context]
        K --> L[/mode default]
        L --> M[Clear active mode, restore normal]
    end

The Three Invocation Patterns (Updated)

Pattern Command Context Persists? Model
Adopted skills (automatic) All matching skills in adoption list Always Agent default
One-shot skill /skill_run d_tag args Isolated sub-invocation No Skill's llm tag
Mode switch /mode name Mode's skill set replaces DM context Until changed Mode's llm tag
One-shot mode /skill_run mode-name args Isolated sub-invocation with mode config No Mode's llm tag

Mode Storage

The active mode is ephemeral runtime state — not persisted to Nostr. It's a session concept:

// In agent.c or a new mode_manager
static char g_active_mode_d_tag[TRIGGER_SKILL_D_TAG_MAX] = {0};  // empty = default mode

void agent_set_mode(const char* mode_d_tag);  // NULL or empty = clear mode
const char* agent_get_mode(void);

When a mode is active, build_context_from_triggers() checks the mode skill first instead of scanning all DM-triggered skills. The mode skill's requires_skill tags define which other skills are included (Layer 2).

Mode Discovery

Modes are just skills with a ["mode", "name"] tag. They're discoverable the same way:

/skill_search mode                    # find all mode skills
/skill_run mode-architect "plan X"    # try a mode one-shot
/mode architect                       # switch to it persistently

Since modes are Nostr events, they're shareable. Someone publishes a "code-reviewer" mode with specific skills, model preferences, and tool restrictions. You adopt it and /mode code-reviewer to activate it.

Relationship to Roo Code Modes

Roo Code Didactyl Equivalent
Mode definition (.roomodes) Skill event with ["mode", "name"] tag
Mode's system prompt Skill content (markdown instructions)
Mode's allowed tools ["requires_tool", "..."] tags
Mode's file restrictions ["requires_tool", "local_file_read"] presence/absence
Mode's model ["llm", "..."] tag
/mode architect /mode architect
Mode switching /mode name slash command
Custom modes Create a skill with ["mode", "name"] tag

The key difference: Roo Code modes are local config files. Didactyl modes are Nostr events — portable, shareable, discoverable, and adoptable across agents.


Modes vs. Multiple Agents

The Tradeoff

Instead of switching one agent between Architect and Coder modes, you could run two separate agents — one Architect, one Coder. Each has its own nsec, its own skills, its own model. This is already possible today with no code changes.

Dimension Modes (one agent) Multiple Agents (separate binaries)
Resource cost One process, one relay connection, one LLM config N processes, N relay connections, N LLM configs
Context isolation Mode switch replaces context; previous mode's state is lost Each agent has its own persistent context and memory
Shared memory Same agent = same memory store. Architect's notes are available to Coder. Separate agents = separate memory. Must explicitly share via Nostr events.
Conversation continuity Mode switch mid-conversation is seamless — same DM thread Different agents = different DM threads. Admin must context-switch.
Identity One npub. External observers see one entity. N npubs. Each agent is a distinct Nostr identity.
Specialization depth Mode is a context overlay — personality + tools + model. Shallow specialization. Full agent is deeply specialized — its own memories, its own learned patterns, its own social graph.
Operational complexity One binary, one systemd service, one genesis config N binaries, N services, N configs, N API keys
Failure blast radius Agent crashes = all modes unavailable One agent crashes = others still running
Skill sharing All modes share the same adoption list (modes just filter it) Each agent has its own adoption list
Admin UX /mode architect — instant switch DM a different npub — requires client support or manual switching

When Modes Win

  • Quick context switching — "plan this, then code it" in one conversation
  • Shared state — the Architect's plan is in memory when you switch to Coder mode
  • Low overhead — one process, one set of relay connections
  • Simple admin UX/mode architect, /mode coder, done
  • Cost control/mode cheap for quick questions, /mode deep for complex analysis

When Separate Agents Win

  • Deep specialization — an agent that has spent weeks learning about your codebase vs. one that knows your financial data. These are fundamentally different knowledge bases.
  • Different trust boundaries — one agent has shell access, another only has Nostr access
  • Different LLM providers — one on Anthropic, one on OpenAI, one on local Ollama
  • Concurrent operation — both agents working simultaneously on different tasks
  • Fault isolation — one agent's crash doesn't affect the other
  • Team simulation — the admin can DM the Architect to plan, then DM the Coder to implement, and each agent maintains its own persistent context about its domain

The Hybrid: Modes + Clones

The most powerful pattern combines both:

  1. Start with one agent with modes for quick context switching
  2. Clone it (via agent_clone) when you need deep specialization
  3. Specialize the clone — different skills, different model, different tools
  4. Use modes within each clone for further flexibility
Admin's Agent Fleet:
├── Agent Alpha (generalist)
│   ├── /mode architect  — planning
│   ├── /mode coder      — implementation
│   └── /mode cheap      — quick questions
├── Agent Beta (clone, specialized: infrastructure)
│   ├── /mode deploy     — deployment workflows
│   └── /mode monitor    — system monitoring
└── Agent Gamma (clone, specialized: content)
    ├── /mode writer     — long-form content
    └── /mode social     — Nostr social engagement

Multi-Agent Didactyl: What Would It Take?

Current Single-Agent Architecture

The codebase is deeply single-agent. Global state is everywhere:

Global File Purpose
static didactyl_config_t* g_cfg agent.c, nostr_handler.c Agent config including keys
static tools_context_t g_tools_ctx agent.c Tool execution context
static trigger_manager* g_trigger_manager agent.c Trigger state
static nostr_relay_pool_t* g_pool nostr_handler.c Single relay pool
static llm_config_t g_cfg llm.c LLM configuration
static char* g_admin_kind0_json nostr_handler.c Admin context cache
static char* g_agent_kind0_json nostr_handler.c Agent self-context cache
static char* g_memory_plain tool_memory.c Agent memory
20+ pthread_mutex_t globals Various Thread safety for all the above

Every module assumes there is exactly one agent identity, one relay pool, one LLM config, one admin, one set of keys. The keys are used directly via g_cfg->keys.private_key in ~40 places across nostr_handler.c for signing, encrypting, and decrypting.

Three Paths to Multi-Agent

Path 1: Multiple Processes (Current — No Code Changes)

Run N separate Didactyl binaries, each with its own genesis config and nsec. This is what DECENTRALIZED_DIDACTYL.md describes.

Pros: Works today. Zero code changes. Full isolation. Cons: N × resource cost. N × operational complexity. No shared state.

Server
├── /home/didactyl-architect/  (systemd: didactyl-architect.service)
│   ├── genesis.jsonc          (nsec1..., model: best)
│   └── didactyl               (binary)
├── /home/didactyl-coder/      (systemd: didactyl-coder.service)
│   ├── genesis.jsonc          (nsec2..., model: fast)
│   └── didactyl               (binary)
└── /home/didactyl-cheap/      (systemd: didactyl-cheap.service)
    ├── genesis.jsonc          (nsec3..., model: cheap)
    └── didactyl               (binary)

Path 2: Multi-Agent Single Process (Major Refactor)

Refactor all global state into an agent_instance_t struct. Run multiple agent instances in one process, sharing the relay pool and HTTP server.

What changes:

typedef struct agent_instance {
    didactyl_config_t config;
    tools_context_t tools_ctx;
    trigger_manager_t trigger_manager;
    llm_config_t llm_config;
    // ... all per-agent state
} agent_instance_t;

// Instead of:
static didactyl_config_t* g_cfg;
// Becomes:
agent_instance_t* agents;
int agent_count;

Scope of refactor:

  • Every function that touches g_cfg, g_tools_ctx, g_trigger_manager, or any global state needs an agent_instance_t* parameter
  • nostr_handler.c — the largest file (~4500 lines) — uses g_cfg->keys in ~40 places. Every signing, encryption, and subscription call needs to know which agent's keys to use.
  • llm.c — the global g_cfg LLM config needs to become per-instance
  • agent.c — all context building, message handling, tool loops need per-instance state
  • DM routing — incoming DMs need to be dispatched to the correct agent based on the #p tag
  • The HTTP API needs agent-scoped endpoints (/api/agents/:id/prompt, etc.)

Estimated scope: Touch every .c file. ~2000-3000 lines of refactoring. High risk of regressions.

Pros: Shared relay pool (fewer connections). Shared HTTP server. Single process to manage. Cons: Massive refactor. High regression risk. Shared-process failure mode (one agent's crash kills all).

Modes give you 80% of the multi-agent benefit with 5% of the effort:

  • Different personality per mode — mode skill content
  • Different model per mode — mode's llm tag
  • Different tools per mode — mode's requires_tool tags
  • Different temperature per mode — mode's temperature tag
  • Instant switching/mode architect
  • Shared memory — same agent, same memory store
  • Shared identity — same npub, same social graph

What you lose vs. true multi-agent:

  • No concurrent operation (one mode at a time)
  • No independent persistent context per mode
  • No fault isolation between modes

This is the Roo Code model — and it works extremely well for the single-admin use case.

Recommendation

graph TD
    A[Start here] --> B[Implement modes - skill packages]
    B --> C{Need concurrent agents?}
    C -->|No| D[Modes are sufficient]
    C -->|Yes| E{How many?}
    E -->|2-3| F[Multiple processes - Path 1]
    E -->|Many| G{Shared resources matter?}
    G -->|No| F
    G -->|Yes| H[Multi-agent refactor - Path 2]

    style B fill:#e8f5e9
    style D fill:#e8f5e9
    style F fill:#fff3e0
    style H fill:#ffebee
  1. Now: Implement modes (skill packages). This covers the "Architect vs Coder" use case with minimal code.
  2. When needed: Run multiple Didactyl processes for true concurrent agents. Already works, just operational overhead.
  3. If justified: Multi-agent single-process refactor. Only if you're running 5+ agents on one server and the resource overhead matters.

Implementation Layers

The features build on each other in this order:

Layer 1: skill_run (Sub-Invocation)

Already designed in tool_orchestration.md. This is the foundation — an isolated LLM execution of any skill. Enables one-shot skill usage, external skill testing, and sandboxing.

Enables: /skill_run cheap "how many files are in the directory"

Layer 2: Slash Commands

Also in tool_orchestration.md. Direct tool execution via /tool_name args. Required for /mode and /skill_run as admin commands.

Enables: /skill_run, /mode, /help

Layer 3: Modes

Mode skills with ["mode", "name"] tags. /mode slash command to switch. Runtime tracks active mode and adjusts context composition.

Enables: /mode architect, /mode cheap, persistent mode switching

Layer 4: Hardened Skills

Deterministic step executor for skills that don't need LLM reasoning. A mode could include hardened skills as tools.

Enables: Skills that execute as pure tool sequences — zero LLM cost


Recommendation

Implement in this order:

  1. skill_run (sub-invocation) — the foundation. Already designed. Enables one-shot skill execution with isolated context, per-skill model, and sandboxing.

  2. Slash commands (/skill_run, /help) — admin direct invocation. Required for the mode UX.

  3. Modes (/mode name) — skill packages that reconfigure the agent's context. Builds on skill_run for one-shot mode usage and slash commands for persistent switching.

  4. Hardened skill execution — deterministic tool sequences. The ultimate "skill as tool" pattern with zero LLM cost.