Files
Skald-Circle/dev-docs/agent-loop.md
T
Daniele 902f47ecd8
Nightly Build / build (push) Successful in 10s
docs: split CLAUDE.md into an always-loaded core plus dev-docs/
CLAUDE.md had grown to 152 KB (~21k words, ~40k tokens) and is loaded into
every coding-agent session. The cost is not the cache read, it is attention:
the rules that are genuinely invariant were drowning in the mechanics of
subsystems that most tasks never touch.

The split criterion is blast radius, not importance. A rule a change anywhere
could violate stays in CLAUDE.md — the commit rule, the production/schema
constraint, domain neutrality, the event-bus rule, the crate boundaries, and
the module map. The mechanism of one subsystem moves to dev-docs/, opened on
entry to that subsystem via a routing table at the top of CLAUDE.md.

Nothing was rewritten: every section was moved verbatim by line range and
verified line-by-line against the original. The only edits are cross-reference
repairs ("see the DB section" -> a link), the promotion of headings in the
extracted files, and a condensed "Current state" whose full text now lives in
dev-docs/users-auth-and-boot.md.

CLAUDE.md: 152 KB -> 31 KB. Twelve subsystem files plus an index under
dev-docs/, which now carries the same standing rule as docs/ and CHANGELOG.md:
a change to a subsystem updates its dev-doc in the same change.

No CHANGELOG entry: this is documentation for coding agents with no observable
effect on the application.
2026-08-24 18:04:43 +01:00

10 KiB

Skald dev-docs — architectural reference for coding agents. Index: README.md · Entry point: ../CLAUDE.md

Read this when: you touch crates/agent-loop/, loop_adapters/, session/handler/, sub-agents, cancellation, restart recovery or the approval gate.


The agent loop

The LLM loop (agent-loop)

The loop is a standalone crate (crates/agent-loop/) that knows nothing about Skald: it owns control flow (rounds, model fallback, tool fan-out, recording), the projection of history into wire messages, sub-agent delegation, restart recovery and compaction. Skald supplies content through the traits in crates/skald-core/src/loop_adapters/. Nothing in session/handler/ shapes a Value anymore — there is exactly one projection in the workspace.

One LoopManager per user (UserLoopRuntime, loop_adapters/runtime.rs, blueprint D12), built by ChatSessionManager: it owns the event bus, the live-loop registry (which conversations are running, /stop, recovery, shutdown), the store, the approval gate, the hooks, the agent catalog and the delegate tool. A turn contributes only what is its own — the agent's prompt, its tool set, its model pin — via turn_params.

Per-turn state rides the Extensions type-map (loop_adapters/scope.rs::TurnScope): the gate and the catalog live as long as the user, so they cannot capture a session id or a permission group — they read the turn's scope from the call's extensions. A call with no scope is denied, never run with permissive defaults.

Three entry points, all in session/handler/kernel_turn.rs:

entry when what it does
run_kernel_turn a user message repairs a dangling call from a crashed turn, then manager.start_turn
recover_turn WS connect, async result delivery, background wake-up Recovery::run — no new message, continue what was interrupted
resolve_pending_call an approval answered after a restart run the call with the gate skipped, then continue

The event translator (loop_adapters/translate.rs) is the ONE bus subscriber turning LoopEvents into the session's ServerEvents; byte-parity with the pre-kernel event sequence is its contract.

Sub-agents

  • A sub-agent is a tool, not an interception: DelegateTool (registered under the legacy names execute_task / execute_subtask, D11, each keeping its exact legacy schema) opens a child frame and runs a normal loop in it. The parent simply awaits a slow tool call. Max depth MAX_AGENT_DEPTH = 5.
  • Parallel batches are the kernel's generic fan-out: a round whose calls are all concurrency_safe (a sync delegate is) runs concurrently, bounded by max_parallel_calls. The ordering invariant is unchanged — ids allocated in call order (phase 1) → concurrent execution (phase 2) → recording in call order (phase 3) — so the model reconstructs results by id. Any mixed batch stays sequential. Siblings share the session scratchpad; concurrent writes to the same key are last-writer-wins by design.
  • mode: "async" submits a durable scheduled_jobs row through loop_adapters/async_task.rs::CronExecutor and returns a receipt immediately; when the job finishes, DurableSink writes the result into the parent conversation (synthetic assistant + a completed task_completed call) and resumes it. mode: "cron" is scheduling, not delegation, and stays on the cron interface tool.
  • An async task ends in the conversation that started it, whatever happened to it — and cron::run_job is shaped so it cannot do otherwise: one JobOutcome classification, then one match job.kind delivery site for every ending. It used to branch on Ok/Err first and route by kind only inside Ok, so a failure or a kill went out as a "Cron job … failed" notification to the home source (/sethome) while the parent sat waiting for a task_completed that never came — the wrong chat and a wedged conversation. The sink has a single channel by design: to the model, "it broke" is a result like any other and must not be overlookable, so the failure is delivered as prose (with whatever partial output the run produced). A cron job has no parent conversation and keeps the home notification — the future plan is to let its creator name a destination. Cancellation is a third outcome, not a flavour of failure: job_runs.status always had 'cancelled' in its CHECK and nothing wrote it, and the classifier keys on the typed session::handler::TurnCancelled error, never on the message text.
  • The chat shows what it started. ServerEvent::TaskUpdate announces an async task's state to the source of its parent conversation only (a cron job belongs to nobody's chat), and GET /api/{source}/tasks (db::scheduled_jobs::list_for_parent_session) answers the same question at load time — running tasks plus failures from the last 30 minutes, because the event is a broadcast with no replay and a browser reload would otherwise empty a chat that still has work under it. Successes are absent from that query on purpose: a finished task's result is already a message in the conversation. The strip itself is web/components/shared/agent-tasks.js (renderTaskStrip), rendered above the composer on desktop and mobile from state owned by ChatSession; the drill-in is #session/{id}, gated on _canOpenTaskSession because the mobile shell routes a fixed set of sections and would silently swallow that hash.
  • A child's model is never inherited from the parent: passing a concrete name would bypass AUTO selection, so sub-agents auto-select unless explicitly overridden (args.clientmeta.json client → AUTO by strength).
  • list_agents returns task agents only (never chat/system ones like the entry agent).

Restart recovery (agent_loop::recovery)

A crash loses RAM (the approval oneshot, the cancellation token), never truth: every state transition is a store write. So recovery does not have a mode of its own — it makes the history well-formed and then runs a normal loop on it:

  1. Reap an interrupted parallel batch (≥2 active frames at one depth is impossible for a linear stack): fail their spawning calls, close the frames. Deliberately lossy.
  2. Resolve the deepest frame's non-terminal calls. A Running one is re-gated and re-executed unless the tool says otherwiseexecute_cmd declares RestartHint::MarkInterrupted (D7), because a command may already have had its effect. An AwaitingHuman one is re-asked (the card reappears).
  3. Un-wedge: a child that finished but whose result never reached its parent propagates without calling the model again.
  4. Cascade to the root, resolving each parent call with its child's result — every frame running as its own agent, from the catalog, never the root's (B3).

Cancelled and Rejected are terminal and are never re-executed. Anti-double-driving goes through the manager's registry (a recovery claims the conversation like a live turn), not a host-side flag.

Cancellation (stop)

  • The turn's CancellationToken is minted by LoopManager::start_turn and cloned by value down the whole call tree; a delegate passes ctx.cancel.child_token(). It is never re-read from a field mid-turn, which is what makes /stop sticky across sub-agent recursion.
  • ChatSessionHandler::cancel()manager.cancel(&conversation). The token is checked at each round boundary and before each tool call, wrapped around the in-flight LLM call (tokio::select!, aborting the request), and around execute_cmd (dropping the future → kill_on_drop). Parent and child share the tree, so a cancelled child stops the parent by construction.

Approval gate

The rule engine ApprovalManager::check returns Allow/Deny/Require per tool call (default rules seeded on first boot; the catch-all * require @999999 gates anything not explicitly allowed — e.g. execute_cmd, execute_task, writes outside whitelisted paths). It is wired to the loop as loop_adapters/gate.rs::ApprovalGate (agent_loop::gate::Gate). A Require registers a oneshot in the in-memory pending map keyed by request_id and emits an approval event over WS.

Resolution is source-agnostic: the WS + Inbox paths resolve by request_id; the inline chat card resolves by the durable tool_call_id via POST /api/tools/:tool_call_id/resolve (resolve_tool in src/frontend/api/sessions.rs), which derives the owning session from the tool call's own stack row — never a hardcoded source. Live pending cards fire the oneshot. Post-restart there is one path for every tool, LoopManager::resolve_pending: the call runs with the gate skipped (the human just decided) but with the session's real ToolContext — owner pool, per-user container — so a resolved write_file/execute_cmd acts on the user's workspace, never the server cwd/host (this was a §6 escape); then the conversation continues, including a sub-agent dispatch, which simply opens its child frame like any other call. The endpoint returns as soon as the work is scheduled and the result streams over the bus.

The diff preview in a PendingWrite event (loop_adapters/preview.rs::read_current_content, driven by the SkaldWritePreviewHook) routes exactly like the fs-tools: user-memory//shared-memory/memory_docs on the right pool, every other agent path → the caller's host workspace via resolve_host_path(&self.fs, …). It must never use the cwd-relative fs::resolve — that showed a bogus "new file" on overwrites (or the diff of a same-named cwd file), so the user would approve the wrong diff.

Tool visibility in the Security-groups UI (GET /api/approval/tools): tools injected outside the ToolRegistry (interface/plugin/provider tools) would otherwise be un-configurable. ToolCatalog::list_all() covers registry tools + a static synthetic_tools() list of core interface tools; everything else is captured by crates/skald-core/src/tool_discovery.rs (ToolDiscovery), which taps the tool set the loop offers each round (SkaldToolSet::defs) and upserts every offered tool into the known_tools table (in-memory seen-set guard → background DB write). list_tools merges known_tools (deduped, category: "dynamic") so any tool offered at least once becomes gate-able. Drift-proof by construction; core never hardcodes plugin tool names.