Files
2026-07-10 15:02:09 +01:00

16 KiB

Agents

Directory Layout

agents/
  <id>/
    meta.json   ← required: metadata, LLM preferences
    AGENT.md    ← required: system prompt
  common/       ← shared include files; skipped by discover()

agents::discover() scans every subdirectory except common/. A directory without both files is skipped with a WARN log.


meta.json Schema

Field Type Required Default Description
name string yes Display name
description string yes Routing text for the orchestrator LLM ("when to delegate / what it returns"). Used in list_items (type=agents) output and the AGENTS_LIST directive.
friendly_description string | null no null Human-facing blurb shown to the user on the frontend Agents page. Frontend falls back to description when absent. Applies to every agent type. Never sent to the LLM.
instructions string | null no null Note for the calling LLM on how to invoke the agent well (expected inputs, format, gotchas). Kept short. Surfaced only in list_items (type=agents) output — and only meaningful for task agents, since that listing already includes task agents only (no extra gating needed). Not in AGENTS_LIST.
inject_memory string[] no [] Paths to files injected into the system prompt as <memory_file> blocks. Relative paths resolve against Skald's process cwd. The $WD placeholder expands to the session's effective working directory (RunContext WD, or process cwd when unset) — e.g. "$WD/SKALD.md" loads a project-local file. The path shown in the block is relative to the working directory when the file is under it, absolute otherwise — so it always resolves back to the same file under the loop's working-directory injection. Missing files inject a (file not created yet) placeholder.
client string | null no null Pin to a specific named LLM model (must exist in DB)
scope string | null no null Task domain for AUTO client selection
strength LlmStrength | null no null Minimum LLM capability for AUTO selection
type "chat" | "task" | "system" yes The agent's role. chat: a user-facing conversational entry-point (e.g. main, project-coordinator) — not dispatchable, not a task root. task: a dispatchable sub-agent and valid root of a scheduled/async task. system: a hidden background agent wired into the runtime by id (e.g. tic) — never listed, never dispatchable from the tool surface. Only task agents appear in list_items (type=agents) / AGENTS_LIST and can be dispatched or run as a task. A meta.json without type is skipped at discovery (warn) and rejected on direct load.
inject_skills bool no true When true (the default, including when the key is absent), the skills registry (skills/index.md) is injected into the system prompt as a <skills_index> block so the agent can discover installed skills. Path resolution follows the inject_memory rule (relative under the working directory, absolute otherwise). Skipped silently if no skills are installed. Set false for background agents that don't need them.

Three descriptive fields, three audiences

The three descriptive fields exist because one string would have to serve three readers with different needs:

  • description → the orchestrator LLM deciding whether/when to delegate (routing).
  • friendly_description → the user browsing the frontend Agents page.
  • instructions → the calling LLM on how to drive the agent for the best result.

Keep instructions distinct from AGENT.md: AGENT.md is the agent's own system prompt ("who I am, how I think"); instructions is outward-facing ("how you, the caller, should ask me, and what I return").

The frontend Agents page (web/components/agents.js) groups cards into three sections by typeChat, Task Executors, and System — and shows friendly_description (falling back to description).

Tool restriction

Tool restriction is not declared in the agent file (the per-agent allow_tools whitelist and the run_context default were both removed). Tool visibility and execution-time approval are governed uniformly by permission groups bound to run contexts (see approval/index.md).

A run context is assigned to a session at runtime, never in meta.json:

  • explicitly via the UI / API (set_session_run_context),
  • via a dedicated config property for system sessions (e.g. TIC's tic.run_context),
  • per cron job (run_context_id).

When a session has no run context it uses the built-in "default" group. The visibility filter (hide tools whose effective action for the session's group is Deny) runs in src/core/session/handler/config.rs (depth 0) and agent_dispatch.rs (sub-agents). MCP tools are excluded from this filter — the Approval gate governs them.


AGENT.md Directives

Directive Behavior
<!-- INCLUDE: path/to/file.md --> Replaced with the content of agents/path/to/file.md at load time. Supports recursive includes.
<!-- AGENTS_LIST --> Replaced with a bullet list of agents where type == "task": - **id** — description. Injected only into the five delegating agents (main, project-coordinator, tech-lead, software-architect, spec-writer); the leaf task agents and tic omit it.
<!-- MCP_LIST --> Replaced at request time (in build_openai_messages) with the available MCP servers — active and inactive — so the agent knows what it can activate via activate_tools. Resolves inside INCLUDEd files: it is the sentinel that ships inside common/mcp.md. Present in every agent.
<!-- KEY --> (any uppercase name) Runtime substitution sentinel. Replaced at request time via SendMessageOptions::system_substitutions. The agent's system prompt contains __KEY__ which is swapped for the provided value before the LLM call.

Shared includes (agents/common/)

Reusable prompt fragments pulled in via <!-- INCLUDE: common/<file>.md --> (the common/ directory is skipped by discover()):

File Content Included by
mcp.md MCP activation prose paired with the <!-- MCP_LIST --> sentinel — keeps the "how to activate" text and the server list together. every agent except tic (which has its own inline MCP block)
tools.md update_scratchpad (shared blackboard) vs write_todos (private list) guidance. main, project-coordinator, tech-lead, software-architect, software-engineer, spec-writer
memory.md Persistent-memory conventions (data/memory/). main, spec-writer, tic
core_rules.md Baseline behavioural rules (read-before-edit, respond in the user's language). main

Available Agents

The required type field declares each agent's role: chat (user talks to it directly; not dispatchable, not a task root), task (dispatchable sub-agent and valid task/cron root), or system (hidden, runtime-wired only). Only task agents appear in AGENTS_LIST / list_items(type=agents) and can be dispatched or run as a task.

id name type scope strength description
main Main Assistant chat General-purpose; persists notes in data/memory/index.md
project-coordinator Project Coordinator chat reasoning average Conversational coordinator for a single project's interactive chat (source project-{id}) — any kind of project (software, travel, study, writing, personal goals…), adapting to the injected description. Receives the project context via its session RunContext (working dir, description, fs-write grants), does everyday planning/writing itself, and delegates specialized work (research, or code via tech-lead/software-architect/software-engineer) via execute_task. Maintains the project's SKALD.md diary.
software-architect Software Architect task reasoning high Plans code changes and delegates to software-engineer
software-engineer Software Engineer task coding high Writes and modifies source files across any file type
code-explorer Code Explorer task reasoning high Studies code, investigates bugs, analyses architecture, produces structured Markdown reports in data/explorer/. No implementation.
spec-writer Spec Writer task reasoning very_high Transforms project ideas into comprehensive spec documents in data/. Never writes code. Saves output path to scratchpad.
tech-lead Tech Lead task reasoning very_high Reads project documentation, breaks scope into implementation tasks, sequences them by dependency, and orchestrates software-architect/software-engineer sub-agents to deliver end-to-end. Tracks its plan with write_todos (private, not update_scratchpad) and owns the single authoritative build+test run.
researcher Researcher task general average Multi-step web research; writes structured Markdown reports (default data/research/, or wherever the caller specifies) and saves the path to scratchpad
generalist Generalist task general average General-purpose task executor: carries out well-defined work ordered by the calling agent — file edits, shell commands, batch operations. No planning or QA.
business-analyst Business Analyst task reasoning very_high Stress-tests a business idea/plan against provided evidence; finds flaws, proposes fixes, gives a GO/NO-GO/PIVOT verdict. Does no web research — reasons from inputs.
tic TIC system Background event processor; calls notify when something is worth surfacing. Ephemeral. notify is injected as an InterfaceTool by TicManager. Tool access is restricted via the run context set from the tic.run_context property.

Orchestration model (tech-lead + software-engineer)

Two conventions keep multi-agent builds efficient and avoid context pollution:

  • Private plan, not shared state. tech-lead records its task plan and progress with write_todos — a stateless, per-stack list that lives only in its own tool-result history. It must not use update_scratchpad for the plan: the scratchpad is a shared blackboard injected into every sub-agent, so a plan written there would pollute each software-engineer's context. The scratchpad stays reserved for genuine cross-agent communication (e.g. a discovered path or type).
  • Verify once, at the top. software-engineer runs only a fast compile-check (e.g. cargo check) after writing — never the test suite. tech-lead owns the single full build + test run in its integration phase, against the merged result. This replaces the old pattern of N engineers each running a full build+test.

Sub-agent Mechanics

A synchronous sub-agent call (execute_task mode=sync / execute_subtask) is not in ToolRegistry. It is intercepted in run_agent_turn before any registry lookup, then handled by dispatch_sub_agent (src/core/session/handler/agent_dispatch.rs):

  1. Validate agent_id and prompt args.
  2. Reject self-calls.
  3. Load target agent's meta.json via load_task_meta and reject anything that is not a task agent — chat (e.g. main, project-coordinator) and system (e.g. tic) agents are invisible as sub-agents, and unknown ids surface a not-found error, all in one gate.
  4. Check depth: parent_frame.depth + 1 <= MAX_AGENT_DEPTH.
  5. Resolve target client (see below).
  6. Create child chat_sessions_stack row (depth = parent + 1, parent_tool_call_id set).
  7. Load any existing stack_mcp_grants for the child stack (restart recovery) → populate active_mcp_grants.
  8. Build child AgentRunConfig via for_sub_agent(). That call inherits the parent's base_tool_defs but strips the per-level augmentations (ask_user_clarification, execute_subtask) — they are re-derived below, so stripping prevents the inherited copy from duplicating the freshly added one (the OpenAI-compat APIs reject non-unique tool names with HTTP 400). Then:
    • Replace active_mcp_grants with the pre-populated arc from step 7.
    • Append sub_agents_only tools and ask_user_clarification.
    • Append execute_subtask (so the child can dispatch its own sub-agents, e.g. tech-leadsoftware-architect/software-engineer) — only when depth + 1 < MAX_AGENT_DEPTH, since at the limit the call would be rejected anyway (and the inherited copy was already stripped, so a max-depth agent advertises no tool it cannot use). for_sub_agent() clears all interface tools (including the root's execute_task/execute_subtask interface tools), so without this re-injection a sub-agent has no tool definition to delegate further; the call itself is then intercepted in run_agent_turn and routed back through dispatch_sub_agent.
    • Inject activate_tools (stack-scoped, stack_id = Some(child.id)) as interface tool.
  9. Append prompt as role = agent message in child stack.
  10. Emit AgentStart event.
  11. Run the child inlineresume_pending_tools + run_agent_turn on the child stack, awaited recursively in the same task, holding the same processing lock and the same CancellationToken clone.
  12. Delete stack_mcp_grants for the child stack; emit AgentDone; terminate the child stack frame.
  13. Map the child TurnOutcome to the return value: Final{content}Ok(content); CancelledOk("…cancelled") (the shared token also stops the parent at its next check); ExhaustedOk("…exceeded rounds"); ErrErr.

The returned string becomes the parent's tool-call result via the normal Ok(result) branch in run_agent_turn (which calls chat_llm_tools::complete and emits ToolDone) — so completion logic lives in exactly one place. There is no task spawn, no WaitingChild signal, and no resume cascade for the sync path.

Mutex / token invariant

One user message = one logical critical section: the processing lock is acquired once in handle_message and held for the whole parent+child recursion. Parent and child share one CancellationToken clone, so a /stop that cancels a running child stops the parent by construction (its next round/tool check observes is_cancelled()).

resume_turn and its cascade are retained only for app-restart recovery of an active child stack, async task result injection (inject_async_result), and the WS resume message — not for normal sync dispatch.


Client Resolution Order

For a sub-agent dispatch (execute_task mode=sync / execute_subtask) to agent X:

  1. args.client (explicit override in the tool call)
  2. X/meta.jsonclient field (pinned model name)
  3. AUTO selection using X/meta.jsonscope + strength

Important: the parent agent's resolved client is not inherited by sub-agents. Passing a concrete model name to resolve() bypasses scope/strength checks entirely, so sub-agents always auto-select unless an explicit override is provided via (1) or (2). This ensures strength: high in meta.json is always respected regardless of which model the caller is using.


Depth Limit

MAX_AGENT_DEPTH = 5 (hardcoded in src/core/session/handler/mod.rs).

Depth 0 = root main session. Each sub-agent dispatch (execute_task mode=sync / execute_subtask) increments depth by 1. Attempting to exceed the limit returns an error to the LLM without calling the sub-agent.

An agent cannot call itself or the main agent.


Adding an Agent

  1. Create agents/<id>/meta.json with at minimum name and description.
  2. Create agents/<id>/AGENT.md with the system prompt.
  3. Optionally restrict tools by assigning the agent's sessions a run context (permission group) at runtime — see approval/index.md.
  4. No restart required — agents are discovered on every request.

When to Update This File

  • An agent is added, removed, or its meta fields change
  • MAX_AGENT_DEPTH constant changes
  • sub-agent dispatch (execute_task / execute_subtask) validation logic changes (new restrictions or resolution order)
  • meta.json schema gains a new field