# Conflicts: # docs/capability-seams.md # docs/config-catalog.md # docs/cordis-catalog/services.md # docs/module-graph.md # examples/acp-agent/tests/snapshots/cordis-inspect-jsdoc/session.jsonl # packages/cordis/tool-cordis/src/api-catalog.ts # packages/ui/acp/README.md # packages/ui/acp/package.json # packages/ui/acp/tsconfig.json # packages/ui/tui/package.json # packages/ui/tui/src/index.ts # packages/ui/tui/tests/tui.spec.ts # packages/ui/tui/tsconfig.json # pnpm-lock.yaml # python/sdk-runtime/package.json # scripts/gen-doc-graphs.ts # scripts/type-equiv.manifest.json
16 KiB
dsh-agent-loop
THE concrete agent plugin and loop driver. Its package-internal implementation satisfies 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
Creation and resume are one rollback-covered transaction: construct a private session, concrete agent, and scoped context; await optional setup; enter both registries; announce session/created then agent/created; emit agent/session-start; and only then start the driver. Setup receives the full scoped Context as trusted same-process composition code and must not drive the unpublished agent. Ordinary typed identity and option inputs are borrowed under their readonly contract, while seed events and session metadata are validated and snapshotted because they cross the durable session boundary. An optional AbortSignal cancels only load/setup/publication and is detached before the returned handle becomes visible.
The caller fiber and the AgentLoop provider are co-owners. AgentFactory.createAgent(ownerCtx, options) and resume(ownerCtx, options) receive caller ownership explicitly, while the factory keeps its own dependency context for sessions/llm/tools/systemPrompt; this lets a caller inject only agents without shrinking the new agent's service surface. Caller unload, handle disposal, or provider unload converge on one memoized quiescence boundary. Provider shutdown waits both resource teardown and the public create/resume wrapper that observed deactivation, so no continuation can publish after dependencies disappear.
Each agent and its session share one caller-chosen SessionId, assumed globally unique; accidental UUID collisions are outside the supported model. Two concurrent operations with the same id may both prepare, but the final enter() calls arbitrate publication and every loser rolls its private resources back. Each detach is bound to the exact entered object, so a stale disposer cannot remove a later same-id replacement. A detach requested during a synchronous creation notification waits for that dispatch to unwind, preserving created/disposed pairing. Teardown runs stop and drain (including outstanding idle-injection flushes) → detach agent → detach session → unwind scope; the id becomes reusable at detach even if private scope cleanup is still finishing. Ordinary non-vetoing agent/* notifications go through agentEvents(ctx, agent), per-step assembly goes through assembleContextFor(agent), and turn-end durability checkpoints go through ctx.sessions.flush(session).
ctx.agentLoop.create(id: SessionId, options?: AgentOptions, meta?: { cwd?: string }): Agent— synchronous no-setup create under the exact shared agent/session id, disposed with the calling fiber. Declarative config treatsagents[].idas a stable label and normally mints${label}-session-<uuid>before calling this boundary. An app may instead supply a stable exactsessionId: first use creates it, while a remount with persistence already present resumes its materialized history.resumeSessionIdrequires and loads an existing persisted id and is mutually exclusive withsessionId. This keeps default fresh restarts collision-free without retaining a second live routing identity.
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({ sessionId, meta?, seed?, agentOptions?, setup?, signal? }): Promise<AgentHandle>— programmatic create under the caller-supplied shared id. It awaits the unpublished setup transaction before returning;metacarries cwd/lineage/seed-boundary metadata andseedreconstructs a forked child prefix after the session boundary validates and snapshots the durable values.signalapplies only until this promise settles. The resolvedAgentHandleowns exact teardown.ctx.agents.resume({ resumeSessionId, agentOptions?, setup?, signal? }): Promise<AgentHandle>— load a persisted session viactx.sessionPersistence(session persistence), register the agent under that same id, reconstruct its history, then await setup against a fresh unpublished agent scope before rollback-covered publication. 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).signalis creation-only. Returns anAgentHandle.
The config-driven ctx.agentLoop.create() path keeps its agent owned by the loop fiber (it discards the handle). For a programmatic agent, the handle holder is the only consumer-facing teardown capability; AgentLoop provider unload is the independent structural teardown edge, not another handle exposed to application code.
Injected services
agents, sessions, llm, tools, systemPrompt — all five interface services.
Invariant companion
The optional @deepseek-ai/dsh-agent-loop/invariant companion registers request reconstruction with ctx.invariants. The loop records each exact frozen request in the process-local identity set owned by dsh-llm; the companion then requires a live session and independently rebuilds the message boundary and folded request header from the log. Direct one-shot calls remain outside this contract even when callers freeze them or attach a session id.
Configuration (schemastery)
interface Config {
maxParallelToolCalls?: number // default 10; 1 is serial
agents: Array<{
id: string // required
provider?: string
model?: string
resumeSessionId?: string // load this persisted session instead of creating one
cwd?: string // optional workspace cwd for the fresh session
}>
}
Configured agents start automatically. A model call requires both provider and model; agent/request may supply a missing pair before dispatch. maxParallelToolCalls bounds every agent's rolling pool for parallel-safe calls and defaults to 10. cwd applies only to fresh sessions, while resumeSessionId retains persisted metadata. Configured agents use the deployment persona, and programmatic setup can shadow it per agent. This plugin supplies the per-agent provider, model, and cwd prompt variables; harness identity and deployment persona belong to dsh-system-prompt.
Internal concrete driver
The concrete Agent class, its Inbox, runLoop, and instance-bound publication/start controls are package-internal. The package root exports only the plugin/service/config contract, and the package exports map exposes no ./src/* escape hatch; lifecycle owners create agents through ctx.agents rather than naming, constructing, or starting driver internals. One prepared session can be claimed by only one concrete driver, and everything observable happens through session events and the agent/* event taxonomy.
Each concrete send() materializes content, resolved source, and attached contexts once as a detached, deeply frozen lossless-JSON FIFO item. If claimed, it is the sole ordinary message in its turn; its contexts are the prompt waterfall's default additional contexts and therefore append only after admission. The waterfall's returned allow is authoritative, so a listener wrapping next() preserves downstream content and additionalContexts unless it intentionally replaces them. A successor waits for the preceding ordinary turn's checkpoint to settle, while cancellation, disposal, a prompt block, or a pre-start failure may drop its contexts with the message. Running steer() enters the same record shape in the steering FIFO without dispatching agent/prompt-submit: an open turn records the steering message followed by its contexts at the next steering checkpoint before a request or continuation decision, but policy can still stop before another step; steering left after turn close and its checkpoint becomes later queued input with contexts intact unless terminal turn policy, cancellation, or disposal discards it. Open-turn inject() uses the same accepted-value boundary but defers in a FIFO while the current step executes assistant tool calls; successful batches place it after all results, and interrupted batches drain it before turn close. Malformed data throws before enqueue or append.
Loop lifecycle (loop.ts)
The driver owns one agent for its lifetime and runs inside ctx.agents.withInitiator(agent, ...). Package-private orchestration entry points recover the exact Agent, derive agent.session once, and let operation-local helpers capture it instead of forwarding the concrete driver or per-operation Session through shallow interfaces. A helper keeps an explicit Session when that is its actual interface, while creation, persistence load, unpublished setup, services, workers, processes, persistence, and wire protocols retain their explicit identities. The agent service owns propagation, teardown, and detached-work rules.
Every provider call that reaches a successful finish appends exactly one assistant/message completion anchor, including content-less calls and max-tokens finishes. A successful agent/step-result stores its transformed content; a rejected result records empty content before the original failure continues. The anchor retains exact chunk provenance ([] for a stream with no chunks) and usage when available, while empty content stays out of derived message history.
Plugin failure ends the current turn, not the loop. Only final adapter dispatch/iteration failures and terminal in-band error or aborted finishes enter agent/request-error; middleware, result processing, tools, and agent/post-step remain ordinary turn failures. Recovery receives the exact live error, immutable provider facts, and immutable prior failures after the failed step closes. A retry rebuilds from the durable log in a new numbered step, success clears the consecutive history, and exhaustion records the structured failure once on turn/end. AgentLoop privately owns one cancellation holder whose explicit signal spans prompt policy, assembly, every step, model and tool work, recovery, continuation, and terminal stop; it retires the holder immediately before publishing turn/end, while the driver may remain running through the durability flush. An effective cancel() emits the typed runtime-only user | parent cause before clearing pending work and cooperatively aborting the holder; notification failures cannot veto cancellation, work queued by a notification observer is cleared, work queued by a later abort observer belongs to the next turn, and idle cancellation emits nothing. Durable turn/end remains coarse aborted; undispatched model tool calls receive synthetic tool/call and ABORTED_BEFORE_DISPATCH result pairs. Disposal wins terminal classification, and work that ignores the signal must settle before quiescence. The explicit-cancellation decision owns the lifecycle and race contract. Terminal continuation stops remain authoritative through turn close and durability flush.
Within a step, exclusive calls form barriers; parallel-safe calls use a bounded rolling pool and are reclassified before start. Only dispatch/body overlaps. Policy, durable results, and result context remain model-ordered. Abort stops new calls, drains started results, then drains accepted batch context before the turn closes through the normal abort path.
What belongs to plugins
Everything that goes beyond "call the model, run the tools, repeat" belongs to plugins listening on the event taxonomy:
- Hooks and policy: the relevant
agent/*checkpoints plus the guardedtools/pre-execute→tools/execute→tools/post-execute→tools/resultpipeline; exact signatures and modes live in the generated event catalog - Compaction: pressure on
agent/post-step; canonical context overflow onagent/request-error - Transient model recovery:
dsh-llm-retryonagent/request-error, with finite code-specific budgets and non-surfacellm/retrystatus events - Sandbox, permission, plan mode:
tools/pre-executefor extensible deny/ask,tools.guard()for monotonic owner policy,tools/post-executefor result decisions, andtools/resultfor final observation - Sub-agents: implemented outside the loop as
ctx.subagentsproviders; in-process providers usectx.agents.create()and ownedAgentHandleteardown, while genericctx.tasksplusdsh-tool-subagentown background collection. - Persistence:
session/event+session/flush - UI:
session/event(assistant token stream, boundaries, tool activity) +agent/*control events (agent/status,agent/created/agent/disposed)
Model Experience
Complete conversation request
What the model sees
For each step, the loop sends the rendered per-agent system prompt, visible tool schemas, the frozen session prefix, and the session's derived messages. It supplies model and cwd variable values but no additional fixed prose.
Token effect
System text, schemas, and prefix are paid again on every step. Per-agent scoping chooses the initial contributions, while the authoritative assembly waterfall can alter the final request and makes its listener responsible for protocol coherence.
KV Cache effect
Append-only only while system text, schemas, session prefix, and earlier history remain byte-identical under the same provider and model route. A token-bearing assembly rewrite or composition change may invalidate reuse from the first altered request token.
Retained message history
What the model sees
Accepted user messages, assistant messages, tool calls and results, injected context, and steering are logged and sent on later steps. Raw stream chunks, lifecycle boundaries, and other log-only events are excluded.
Token effect
Input grows with every surface message until a compaction replacement shadows older nodes; a multi-step tool turn resends the accumulated prefix and history each step.
KV Cache effect
Ordinary history growth is append-only and preserves reusable entries. A surface replacement or compaction invalidates reuse from the first shadowed history token.
Undispatched calls after cancellation
What the model sees
If a later request replays an aborted step, each tool call that cancellation prevented from dispatching has error code ABORTED_BEFORE_DISPATCH and result text Error: tool call aborted before dispatch.
Token effect
One fixed error result per skipped call remains in history until compaction shadows it.
KV Cache effect
Append-only; each synthetic result follows the reusable request prefix and does not invalidate existing KV-cache entries.
Known Limitations and Deferred Work
- Classification is unary — calls whose safety depends on comparing siblings or resources must remain exclusive (rationale).
- Config labels are fresh by default — omitting
sessionIdcreates a fresh${id}-session-<uuid>on every startup; exact resume-or-create behavior requires an explicit stablesessionId, whileresumeSessionIdrequires existing persisted history. - Config agents have no per-agent persona field or setup hook — they use the deployment persona; scoped persona/tool composition is available only through the programmatic
ctx.agents.create()/resume()factory options. - No built-in turn budget — the default continuation is
continuewhenever a step had tool calls or steering; bounding a runaway turn requires anagent/turn-continuationforce-stop plugin.