The Agent Main Loop
One-liner:
@deepseek-ai/dsh-agent-loopis the only concrete loop in the harness: it drives the session/turn/step lifecycle, and everything else is either an abstract service or a plugin; write new behavior as a plugin — don't change this.
This is the piece of the whole site you should read most thoroughly. Understand agent-loop and you'll understand "what path a single user message actually travels".
1. Why it is the "only concrete loop"
One sentence in the source README defines the architectural boundary of the entire harness:
This is the only package in the harness that contains concrete loop logic. Everything else is either an abstract service or a plugin against the extension points: new behavior should go into a plugin, not here.
What this means:
- The core is extremely thin:
agent-loopdoes exactly one thing — "call the model, run tools, loop". It doesn't decide for you whether to search, to compact, or to ask the user. - Everything is an extension point: search (web), compaction (compact), recovery (llm-retry), sandbox/permissions (guard), subagents (subagent), UI rendering — all of it hangs off events as plugins.
- This boundary is intentional: before adding something "inside the loop", ask yourself — can it be done with the existing event/tool pipeline? 99% yes. The genuinely rare need is to change the loop itself.
Remember it in one sentence: DSH's philosophy = agent-loop handles "driving", everything else comes from plugin composition.
2. The three-level lifecycle: session / turn / step
Let's establish the basic vocabulary first; we'll use it everywhere below:
- A session holds one persistent conversation and can be
resumed (rebuilt from the log after a crash) orforked (derives a new session). - A turn starts when you send a message and ends when the agent fully responds; turns are numbered consecutively.
- A step is the smallest unit of progress inside a turn: either one model inference or one tool execution. A multi-tool turn = model step → tool step → model step → tool step… until the agent decides it's done.
The agent-loop README says it verbatim: it "drives the session/turn/step lifecycle". These three levels are the most fundamental structure in the event-sourced log (see Event System).
3. Two creation paths: declarative vs programmatic
An agent is not "an object you new". It has two entirely different birth paths, and understanding this determines how you use it.
3.1 Declarative (config-driven)
Declared in the config.agents of the agent-loop plugin:
- id: agent-loop
name: '@deepseek-ai/dsh-agent-loop'
config:
maxParallelToolCalls: 10 # default 10; 1 = serial
agents:
- id: main
provider: deepseek-official
model: deepseek-v4-flash
maxTokens: 65536 # per-request output token ceiling
cwd: /path/to/workspace
- id: resume-me
resumeSessionId: <existing-session-id> # reload this persisted session
- Declared agents start automatically when the service starts (created internally by
ctx.agentLoop.create()). - Ownership: the agent belongs to the loop fiber; the handle is discarded.
- No per-agent persona / setup hook: declarative agents use the deployment persona; to customize persona/tools per agent, you must go programmatic.
3.2 Programmatic (ctx.agents)
Via the factory interface ctx.agents (agent-loop registers itself as ctx.agents.setFactory(this)):
// programmatic create
const handle = await ctx.agents.create({
sessionId, // globally unique; can be prepared in parallel with a concurrent op, enter() adjudicates
meta?, // cwd / lineage / seed-boundary metadata
seed?, // prefix for a forked child session (used to rebuild history)
agentOptions?, // per-agent provider/model/tools
setup?, // trusted in-process composition code (can shadow persona)
signal?, // only affects the load/setup/publish phase
})
// reload a persisted session
const resumed = await ctx.agents.resume({
resumeSessionId, // required: an existing persisted session id
agentOptions?,
setup?,
signal?,
})
- The programmatic
AgentHandleis the only consumer-side teardown capability: whoever holds it is responsible for ending this agent's lifecycle. signalonly applies until the promise settles (publish/load phase); it does not keep cancelling after the handle becomes visible.
Ownership comparison
| Declarative | Programmatic | |
|---|---|---|
| Entry point | config agents: section | ctx.agents.create/resume |
| Start | automatic at service start | when you call it |
| Ownership | loop fiber | the caller holding the AgentHandle |
| persona | fixed by deployment persona | shadowable via setup/agentOptions |
| Typical use | stable main agent | subagents, runtime on-demand |
4. Transactional lifecycle: create and reload are the same thing
The single most important fact: create and resume belong to the same rollback-protected transaction. It is not "create a session, then request" in two steps, but one indivisible commit.
create / resume (the same rollback-protected transaction)
├── construct private session + concrete agent + scoped context
├── await setup (optional trusted in-process composition code; must not drive an unpublished agent)
├── enter the two registries (agents / sessions)
├── publish order: session/created → agent/created → agent/session-start
└── only at this point does driving the loop begin
teardown (the symmetric sequence at the end)
stop and drain → revoke scope → detach agent → detach session
The key concurrency and rollback invariants (key points from the source):
sessionIdis globally unique: two concurrent operations may prepare with the same id, but the finalenter()call adjudicates publication: each loser fully rolls back its own private resources.resumeSessionIdandsessionIdare mutually exclusive: a session is either created new or reloaded by an existing id.- detach is bound to the exact object entered: a stale disposer cannot accidentally kill a later replacement with the same id: this guarantees "unload one, recreate one" never tramples each other.
- Requesting detach during a synchronous notification within creation waits for that dispatch to unwind, keeping
agent/created↔agent/disposedpairing complete. - Plain identity/options passed through are borrowed under a readonly contract; but seed events and session metadata are validated and snapshotted, because they cross the durable session boundary.
Common misconception: thinking "resume re-reads the history and then runs a new turn". Actually: load the persisted session → await setup on a brand-new unpublished agent scope → then the rollback-protected publish. So a resumed agent and a new one go through the exact same safe path.
5. Resumability: the boundary of resume
ctx.agents.resume({ resumeSessionId, ... })
- It loads the persisted session via
ctx.sessionPersistence, registers the agent under the same id, rebuilds history, then awaits setup, then the rollback-protected publish. - Turn numbers and derived history continue from the loaded log (not from 0).
- Hard dependency:
resumeneeds a session-persistence backend. But it is not hard-injected: a demo without persistence still runs, onlyresumewill explicitly reject (reporting "persistence absent"). This lets you write pure in-memory demos while enabling resume seamlessly when you need it.
Practical value: after a crash / restart, a single resume continues the conversation from the persisted log instead of losing it. (Related skills in the out-of-repo ecosystem such as dsh-harness-ops cannot be verified against this source; please check on your own.)
6. send primitives and the inbox: how messages get into the loop
Content reaching an agent is not handed directly to the model; it goes through a send primitive based on an inbox (FIFO). The source funnels all of this into one unified send() primitive, routed by target × wakeup, with followup/steer/inject as its three fixed aliases:
| Primitive | target | wakeup | semantics |
|---|---|---|---|
followup() | next-turn FIFO | yes | append a "next-turn" input and wake the driver |
steer() | next-step inbox | yes | append and wake (driver moves to the next step) |
inject() | next-step inbox | no | append but don't wake; waited on together with a follow-up/steer |
Claim rules at the turn boundary:
- At the turn boundary, the driver opens the durable turn and then atomically claims pending next-step inputs plus one queued prompt.
- Between two steps, it claims only next-step inputs (never touches the next turn).
Event-izing the inbox: every mutation first emits the standardized transition events, then mutates the live projection:
| Event | Timing |
|---|---|
agent/inbox/inserted { message } | every insertion |
agent/inbox/claimed { message, turn } | each time a message is claimed into a step |
agent/inbox/discarded { message } | ordinary removal (with outcome:'canceled') |
agent/inbox/spliced | unified pre-announcement before every mutation (insert/edit/remove/claim/cancel all use the same splice coordinates) |
MessageId is globally unique across the two pending lists (next-turn / next-step); sync durable-event observers can rebuild removed values from the pre-splice projection.
7. Driving the loop and request assembly: what happens in one step
When a batch of inputs in the inbox is claimed into a step, the driver starts assembling this one model request.
7.1 What kind of request gets assembled
For each step, the loop sends:
per-agent system prompt (rendered by systemPrompt.assemble())
visible tool schemas (presented via tools, native/code/both)
session-derived messages (surface messages; turn/step basis below)
The loop only fills in the provider/model/cwd variables; it adds no extra fixed prose. The full assembly waterfall (agent/request + systemPrompt.assemble()) is in Context System.
7.2 The adapter-default marker: keeping HMR from mixing flavors
A subtle but critical mechanism that's easy to overlook. After an agent/request returns the provider/model call config, the loop calls ctx.llm.prepareCall() to:
- validate adapter-specific fields
- materialize the configured reasoning-effort and output-token defaults (under the active-turn signal)
Then request/header records which fields are effective and which came from the adapter. Before the next waterfall, the loop removes these marked fields so the current exact route re-materializes its own defaults: while unmarked explicit settings persist across steps / across routes.
During that one async resolution, HMR (hot reload) also can't mix one adapter's capability results into another adapter's request. Without this, hot-changing config could pick up "the previous provider's defaults".
7.3 Completion anchor
Every provider call that successfully reaches finish appends exactly one assistant/message completion anchor: including calls with no content and calls that hit the max-tokens ceiling. The anchor records the content assembled as-is, lists the exact chunk seqs in sourceEventSeqs (a stream without chunks is []), carries usage when present; empty content does not enter the derived history.
8. Failure, recovery, and cancellation
agent-loop's core principle for failures: a plugin's failure ends "the current turn", not the whole loop.
8.1 Two kinds of failures, two paths
| Failure source | Outcome |
|---|---|
| final adapter selection / dispatch / iteration failure | goes to agent/request-error as a terminal error or an aborted finish |
| middleware / result handling / tools / other extensions | throw closes directly, never enters agent/request-error |
Recovery from agent/request-error: a handling listener can return { kind: 'retry' } (wait for the exact-provider to succeed or back off unboundedly, implemented by dsh-llm-retry, which emits non-surface llm/retry status); no listener handling it = terminal failure.
8.2 Cancellation semantics
agent.cancel(cause, { keepInbox })
- An effective cancel clears pending work (unless
keepInbox) and cooperatively aborts the current signal - Cancelling while idle is a no-op
- Wake inputs that land after the abort but before activity converges to idle are locked (wakeRequested) and replayed at the driver's own convergence boundary: no need to wake again
- A
disposedcancel never locks - Submitting a wake again while already idle always opens a turn boundary (the state will show a transient
idle → running → idle) - Durable
turn/end:userandparentrecordaborted, disposal recordsdisposed - Undispatched model tool calls receive a synthesized
tool/call+ABORTED_BEFORE_DISPATCHresult pair: so the model won't be confused about the "vanished call" in later steps
8.3 The two kinds of parallel execution
Within a step, tool calls divide into two kinds:
- exclusive: forms a barrier, strictly serial around it
- parallel-safe: run in parallel via a bounded rolling pool; reclassified before execution
Only dispatch/body runs in parallel; policy, durable result, and result context keep model order. This is where isConcurrencySafe(args) semantics live (see Tool Execution).
Known limits: unary classification — calls whose safety depends on "comparing with siblings" must stay exclusive. There is no built-in turn budget: to cap runaway turns you cancel from existing extension points like
agent/turn-stopping.
9. What belongs in plugins (the extension surface)
The loop is explicitly "only call-model-run-tools-loop". Everything else must hang on events. This is DSH's most important programming convention:
| What you want to do | Where to hang it |
|---|---|
| hooks / policies | agent/* checkpoints + tools/pre-execute → tools/execute → tools/post-execute → finalizeContent → tools/result pipeline |
| context compaction | pressure in agent/pre-step; the canonical fix for spill in agent/request-error |
| model request recovery | dsh-llm-retry records and waits with backoff in agent/request-error |
| sandbox / permissions / plan | tools/pre-execute (deny/ask), tools.guard(), tools/post-execute, tools/result |
| subagents | ctx.subagents provider (in-process via ctx.agents.create() + owned handle); background via ctx.jobs + dsh-tool-subagent |
| persistence | eager write-behind from session/event; session/flush is the explicit observation barrier |
| UI | session/event (token stream/boundaries/tool activity) + agent/* control events (agent/status, created/disposed) |
Test whether you understand: to give an agent a "retrieve memory before answering every message" feature: don't change agent-loop — hang a plugin on agent/request (the assembly waterfall) or agent/pre-step to inject the retrieval results. That's exactly what Context System is about.
10. Model Experience: the model / token / KV-cache three perspectives
Understanding these effects tells you "why certain configs burn tokens, why changing schemas invalidates the prefix cache wholesale".
| Dimension | What the model sees | token effect | KV cache effect |
|---|---|---|---|
| Full request | per-agent system prompt + visible tool schemas + session-derived messages | system text and schemas are repaid every step | only system text/schema/history that is byte-for-byte unchanged (same provider+model route) is reused append-only |
| Retained history | accepted user/assistant messages, tool calls and results, injected context, steering | every surface message grows the input; multi-tool turns re-send accumulated history each step | ordinary history growth is append-only reusable; surface replacement or compaction invalidates the prefix |
| Cancelled undispatched calls | ABORTED_BEFORE_DISPATCH error code + fixed text | one fixed error result left per skipped call until compaction | append-only, doesn't invalidate existing entries |
Use this to understand the "compaction summary reuses the request head to align the prefix-cache" point in Context System: the goal is to keep system text/schema/history byte-for-byte unchanged, so the provider's KV cache never invalidates.
11. Source structure at a glance
The kernel of the agent-loop package (ReactLoopAgent, its inbox, run controls) is package-internal; the package root only exports plugin/service/config contracts, and the exports map has no ./src/* escape hatch. Lifecycle owners are created at the ctx.agents layer, not by naming/constructing/starting the driving kernel. This means:
- you always interact with the loop through
ctx.agents/ config - one prepared session can only be claimed by one concrete driver
- everything observable is exposed through session events and
agent/*events (you're never given access to internal state)
12. Config quick reference
agentLoop:
maxParallelToolCalls: 10 # rolling-pool ceiling of parallel-safe calls per agent; 1=serial
agents:
- id: main
provider: deepseek-official
model: deepseek-v4-flash
maxTokens: <positive integer, optional> # per-request output token ceiling, recorded in request/header
cwd: <optional, only affects new sessions>
resumeSessionId: <optional, mutually exclusive with sessionId>
Config points (from the source):
agentsis deliberately not in the Settings section: it is consumed once at service start, and changing the stored value only "looks" effective.maxParallelToolCallsis all of agent-loop's Settings section; hot-editing it caps the next tool group (and invalid values are rejected at write time).- A configured agent's
provider/model/cwdare provided as prompt variables; harness identity and deployment persona belong todsh-system-prompt. - One model call requires both provider + model;
agent/requestcan complete a missing pair before dispatch.
13. Verification
# 1. look at the session event stream (zstd-compressed by default, two-level --<cwd>--/<id>/ dirs)
zstdcat ~/.dsh/sessions/*/*/session.jsonl.zstd | tail -30
# 2. see the session/turn/step three-level structure in one turn of conversation
zstdcat ~/.dsh/sessions/*/*/session.jsonl.zstd \
| jq -r 'select(.type | test("turn/|step/|assistant/message|tool/call|tool/result")) | [.type, (.seq|tostring)] | @tsv'
# 3. look at agent control events
zstdcat ~/.dsh/sessions/*/*/session.jsonl.zstd | grep -E "agent/" | head
Next steps
- Tool Execution: how tools are invoked inside the loop (pipeline + parallelism/cancellation)
- Context: what the model sees on each request (assembly waterfall + KV cache)
- Events: the two sets of
agent/*andtool/*events - Subagents: delegating with
ctx.agents.create()(real use of the programmatic entry point)