dsh-agent-loop
THE concrete agent plugin: ReactLoopAgent and the loop driver. Implements the Agent interface and drives the session/turn/step lifecycle.
This is the only package in the harness that contains concrete loop logic. Everything else is an abstract service or a plugin against extension seams — new behavior goes into plugins, not here.
Service: AgentLoop (ctx key: agentLoop)
Public API
ctx.agentLoop.create(id: string, options?: AgentOptions, meta?: { cwd?: string }): ReactLoopAgent— config-driven create: an agent on a fresh per-run session id${id}-session-<uuid>with optional session metadata. Used forcordis.yml-configured agents. The per-run uuid avoids colliding with the on-disk log a prior run materialized once a durable persistence backend is loaded; each run is a new session (a deliberate demo simplification — a real resume-or-create policy is a TODO). Disposed with the calling fiber.
AgentLoop also implements the AgentFactory seam and registers itself via ctx.agents.setFactory(this), so plugins create/resume agents through ctx.agents (the interface):
ctx.agents.create({ agentId, sessionId, meta?, seed?, agentOptions? }): AgentHandle— programmatic create on a caller-suppliedsessionId(e.g. an ACP-generated id), NOT${id}-session;metacarries cwd/lineage/seed-boundary metadata andseedreconstructs a forked child prefix. Returns anAgentHandle— the owner disposes it to tear down exactly this agent (stop loop + await quiescence + unregister + remove session).ctx.agents.resume({ agentId, resumeSessionId, agentOptions? }): Promise<AgentHandle>— load a persisted session viactx.sessionPersistence(session persistence) and resume an agent on it. The live session id is the resumed id; turn numbering and derived history continue from the loaded log. Requires a session-persistence backend (NOT hard-injected — non-persistent demos still work;resumerejects with a clear error when persistence is absent). Returns anAgentHandle.
The config-driven ctx.agentLoop.create() path keeps its agent owned by the loop fiber (it discards the handle) — only the programmatic factory callers (the ACP bridge and in-process subagent backends) hold a handle and own per-agent teardown.
Injected services
agents, sessions, llm, tools, systemPrompt — all five interface services.
Configuration (schemastery)
interface Config {
agents: Array<{
id: string // required
model?: string
cwd?: string // optional workspace cwd for the fresh session
maxParallelToolCalls?: number // positive integer; per-agent parallel tool-call cap (default 10)
}>
}
Agents listed in config are auto-created at startup. cwd applies only to fresh config-created sessions; resumeSessionId keeps the persisted session header. maxParallelToolCalls (a positive integer, default DEFAULT_MAX_PARALLEL_TOOL_CALLS = 10) bounds how many parallel-safe calls one assistant step runs at once; 1 restores fully serial execution. It is validated in the schema (z.number().step(1).min(1)), so a bad value fails config load rather than being silently dropped. There is no per-agent persona: the deployment persona is dsh-system-prompt's own persona config, shared by every agent in the context. The plugin registers the built-in model/cwd prompt variables on ctx.systemPrompt, resolved per step from the assemble({ agent }) context — runtime facts of the agents THIS loop drives, unlike the harness:identity/deployment:persona sections, which live on dsh-system-prompt so they survive a swapped loop plugin.
Classes
ReactLoopAgent— the concreteAgentimplementation. Owns the inbox (Inbox), the per-stepAbortController, and the loop driver. Everything observable happens through session events and theagent/*event taxonomy.Inbox— per-agent queued + steering FIFOs (enqueue,steer,drainQueued,drainSteering,waitForQueued).
Loop lifecycle (loop.ts)
One invocation of runLoop() drives one agent for its whole lifetime:
create agent → emit agent/session-start(source) ⟵ once, before turn 1
forever:
wait for queued messages (idle)
TURN (error-contained):
'turn/start'
each queued: waterfall agent/prompt-submit → allow (→ session('user/message'),
inject additionalContext) | block (→ session('prompt/blocked'), drop)
if every prompt blocked: 'turn/end'(rejected), no step ⟵ zero-step turn
STEP loop:
drain steering
assembly = systemPrompt.assemble({agent}) ⟵ renderPrompt(assembly) IS the full prompt
prefix ??= waterfall agent/session-prefix ⟵ once per instance (first step): frozen
session prefix; on the header, never history
await serial agent/pre-step(…, prefix) ⟵ surface mutation (compaction) outside the step;
pressure gates see the prefix the request carries
boundary = session.deriveMessages() ⟵ reconstruction boundary: same sync frame,
session('step/start') strictly before step/start
config = waterfall agent/request ⟵ frozen seed; return a replacement to switch
session('request/header'[-delta]) ⟵ the header event this request owes the log
stream llm.stream(freeze({header..., messages: prefix+boundary})) → session('assistant/chunk')
message = waterfall agent/step-result
session('assistant/message')
schedule tool-calls: group by tools.executionMode (exclusive call = barrier;
run of parallel-safe calls = one rolling-pool group, ≤ maxParallelToolCalls in flight)
each STARTED call: session('tool/call') ⟵ model-order per started call; log positions
→ ordered tools/pre-execute → pooled dispatch/body → ordered tools/post-execute may interleave with sibling results as the pool replenishes
commit cursor appends session('tool/result') in MODEL order (slot-buffered)
append buffered post-execute additionalContext (model call order) as session('context/message')(s)
drain steering → session('steering/message')
cont = waterfall agent/turn-continuation → ContinuationDecision
({action:'continue', reason?} records reason as next-step steering)
if action==stop (and no pending steering): break
session('turn/end')
await session/flush
re-enqueue leftover steering as queued
idle unless more queued
Error containment: a throwing plugin ends the turn, never the loop. Dispose mid-turn emits agent/status('disposed') and ends with reason disposed. A step that hits the model's output-token ceiling makes the turn end max-tokens (the rule: any max-tokens step in the turn surfaces as max-tokens; disposed/aborted/error still take precedence) — distinct from a clean completed stop.
Tool scheduling: within one assistant step the loop partitions tool calls into ordered groups via ctx.tools.executionMode — an exclusive call is its own group (an ordering barrier), a run of consecutive parallel-safe calls is one group. A parallel group runs in a rolling pool: up to maxParallelToolCalls calls start in model order, and each settle starts the next until the group drains. Only dispatch/body overlaps — tools/pre-execute/tools/post-execute run in model call order, each STARTED call appends its own tool/call (whose log position may interleave with sibling tool/results), and a model-order commit cursor appends tool/result from slot-buffered settlements so derived history stays model-ordered (pairing by the assistant message + callId). additionalContext from the group is injected in model call order after every result. Abort stops replenishment, drains only already-started calls to results, drops buffered context, and re-raises so runTurn owns the end reason; a group not yet started appends no tool/call. maxParallelToolCalls: 1 is byte-for-byte the old serial path.
Cancellation: agent.cancel() is the single public stop primitive — it clears the queued + steering FIFOs, aborts the in-flight step, and drives a turn-scoped marker the driver checks at every point a turn could start or continue (right after the idle wait, after the running flip, before each step, and at the continuation gate) so a turn about to start is dropped. A cancelled turn ends aborted; a queued-but-not-started prompt never runs and cannot be batched into the cancelled turn. The marker is reset once per loop iteration, so a cancel governs exactly one turn and never leaks onto a later prompt. (The loop still aborts its own per-step AbortController directly on disposal and from cancel(); that controller is loop-internal, not a public verb.)
What is NOT here
Everything that goes beyond "call the model, run the tools, repeat" belongs to plugins listening on the event taxonomy:
- Hooks:
agent/session-start,agent/prompt-submit,agent/pre-step,agent/request,agent/session-prefix,agent/step-result,tools/pre-execute,tools/post-execute,agent/turn-continuation - Compaction:
agent/pre-step - Sandbox, permission, plan mode:
tools/pre-execute(deny/ask gate),tools/post-execute - Sub-agents: implemented outside the loop as
ctx.subagentsproviders; in-process providers usectx.agents.create()and ownedAgentHandleteardown, while child streaming/progress and background/poll collection remain deferred. - Persistence:
session/event+session/flush - UI:
session/event(assistant token stream, boundaries, tool activity) +agent/*control events (agent/status,agent/created/agent/disposed)