DSH Agent Loop & Tool Pipeline Internals: How Every Request Flows
Inside DeepSeek Harness's agent loop: steps, turns, three waterfalls, tool concurrency, compaction, and the session log invariant explained with code.
TL;DR: Every DSH request flows through a deterministic pipeline: a Turn contains zero or more Steps, each Step is one model request plus its tool calls. Three waterfalls (
agent/pre-step,agent/request,tools/pre-execute) give plugins complete interception power. Tool concurrency uses a barrier + bounded rolling pool. The session log enforces “model-visible means logged” — if the model saw it, it’s in the append-only log. All of this is reversible Cordis effects, hot-removable at any time.
Turns, Steps, and the Execution Model
Before diving into the event flow, you need the two structural primitives:
| Concept | Definition | Scope |
|---|---|---|
| Step | One model request + every tool it calls before the next request | Atomic unit of work |
| Turn | Zero or more Steps triggered by one external prompt | User-facing boundary |
| Round | An outer policy iteration containing a turn (e.g., a goal round) | Policy-level (not loop-level) |
A user sends a message. The agent loop opens a Turn, claims it, and enters the Step cycle. Each Step calls the model once, processes any tool calls, and either loops into another Step or closes the Turn. The distinction matters because hooks fire at Step boundaries, not arbitrary points.

The Complete Event Flow
Here is the full sequence within one Turn, from start to settlement:
turn/start → claim → agent/pre-step (waterfall)
→ step/start → user/message → system-prompt/assemble
→ agent/request (waterfall) → llm/stream
→ assistant/chunk* → assistant/message
→ tool/call* → tools/pre-execute → tools/execute → tools/post-execute → tool/result*
→ step/end → agent/turn-stopping
Each arrow represents a concrete session event or waterfall dispatch. The asterisk (*) means the event fires zero or more times per Step.
Event Flow Reference Table
| Event | Mode | Purpose |
|---|---|---|
turn/start | emit | Opens the turn boundary |
agent/pre-step | waterfall | Rewrite/reject messages before the model sees them |
step/start | emit | Opens one model-request cycle |
system-prompt/assemble | cooperative waterfall | Builds the full system prompt from registered sections |
agent/request | waterfall | Swap model config (provider, temperature, etc.) |
llm/stream | emit | Signals streaming has begun |
assistant/chunk | emit | One streaming token batch |
assistant/message | emit | Complete assistant response |
tool/call | emit | One tool invocation logged |
tools/pre-execute | waterfall | Allow, deny, or ask before dispatch |
tools/execute | waterfall | Around-dispatch (timeout, retry, metrics) |
tools/post-execute | waterfall | Accept, replace, enrich, or block result |
tool/result | emit | Frozen authoritative outcome |
step/end | emit | Step completed |
agent/turn-stopping | emit | Turn settling decision point |
The Three Key Waterfalls
Waterfalls are the interception mechanism. Each listener receives arguments plus a next() continuation. Calling next() delegates downstream; returning without it short-circuits the chain. This is how plugins compose behavior without coupling to each other.
1. agent/pre-step — Message Rewriting
Fires before each Step. Listeners can rewrite the pending messages the model will see, or reject the step entirely.
'agent/pre-step'(
this: Scoped<Agent>,
payload: {
agent: Agent;
messages: UserMessage[];
turn: number;
step: number;
signal: AbortSignal;
},
next: () => Promise<PreStepDecision>
): Promise<PreStepDecision>
Use cases:
- Compaction (
dsh-compaction-basic): detects context pressure here and triggers pruning - Content filtering: strip or redact sensitive content before it hits the model
- Injection: add system context that only applies to certain steps
2. agent/request — Model Configuration Swap
Fires after system-prompt assembly, before the actual LLM call. Listeners can replace the frozen call configuration entirely.
'agent/request'(
this: Scoped<Agent>,
payload: {
agent: Agent;
turn: number;
step: number;
signal: AbortSignal;
},
next: () => Promise<LlmCallConfig>
): Promise<LlmCallConfig>
Use cases:
- Model routing: swap to a cheaper model for simple follow-ups
- Temperature adjustment: increase creativity for brainstorming steps
- Provider failover: redirect to a backup provider transparently
3. tools/pre-execute — Permission Gate
Fires before every tool dispatch. The most powerful policy point in the system.
'tools/pre-execute'(
this: Scoped<ToolRuntime>,
exec: ToolExecution,
next: () => Promise<PreToolDecision>
): Promise<PreToolDecision>
Use cases:
- Approval flows: ask the user before destructive operations
- Sandboxing: deny filesystem writes outside a workspace
- Rate limiting: throttle expensive API calls

Tool Concurrency: Barrier + Bounded Rolling Pool
When the model returns multiple tool calls in one response, DSH doesn’t simply fire them all or serialize them. It uses a two-layer concurrency model:
| Mechanism | Behavior |
|---|---|
| Barrier | All tool calls from one response are grouped into a batch |
| Bounded rolling pool | Within the batch, up to maxParallelToolCalls run concurrently |
exclusive executionMode | Tool runs alone, blocking all others in the batch |
parallel executionMode | Tool participates in the rolling pool |
The maxParallelToolCalls config controls the pool size. An exclusive tool (like a filesystem write that needs atomicity) forces the pool to drain before it runs, then blocks until it finishes. This prevents race conditions without requiring tools to know about each other.
// In your agent config
{
maxParallelToolCalls: 4, // up to 4 tools run concurrently
}
Individual tool definitions declare their execution mode:
ctx.tools.register({
name: 'write_file',
executionMode: 'exclusive', // runs alone
// ...
})
ctx.tools.register({
name: 'web_search',
executionMode: 'parallel', // can overlap with others
// ...
})
The Tool Execution Pipeline (Full Graph)
The complete pipeline for a single tool call traverses multiple phases:
model response contains tool-call block
→ Session event: tool/call (logged before execution)
→ UI pending card
→ tools/pre-execute waterfall (hooks, permission, sandbox)
→ Registered monotonic guards (deny or abstain)
→ ctx.approval one-shot prompt (if ask)
→ tools/execute waterfall (timeout, retry, metrics)
→ Registered tool execute() body
→ fs/write-intent or fs/edit-intent (for file mutations)
→ tools/post-execute waterfall (accept, block, replace)
→ Registry outer normalization (snapshot throws become isError)
→ ToolDefinition.finalizeContent (content-only invariant)
→ tools/result synchronous notification (frozen outcome)
→ Session event: tool/result (model-facing)
→ Active-batch additionalContexts FIFO
The key insight: tools/pre-execute, tools/execute, and tools/post-execute are three separate waterfalls. A permission plugin hooks pre-execute; a metrics wrapper hooks execute; a result-enrichment plugin hooks post-execute. They compose without coupling.
Compaction: Staying Within Context Limits
Long sessions hit token limits. dsh-compaction-basic handles this through two hook points:
agent/pre-step: Measures context pressure. If approaching the limit, triggers the compaction cycle.agent/request-error: Catches overflow errors from the model provider. Triggers emergency compaction.
The compaction cycle itself:
Pruning → Remeasure → Summary
- Pruning: Removes the least-important messages from the session log projection
- Remeasure: Recalculates token count after pruning
- Summary: If still over budget, generates a condensed summary of pruned content
This is not a separate system — it’s just two waterfall listeners using the same hook points available to any plugin.
The Session Log Invariant
The session log has one absolute rule:
Model-visible means logged.
Anything that reaches a model request must be reconstructable from the append-only session log. This invariant is enforced at runtime.
deriveMessages() projects model history from the log. It doesn’t maintain separate state — it derives the current conversation from the authoritative event stream. This means:
- Fork, resume, and replay all work from the same source
- Telemetry and persistence read the same stream
- No hidden state can leak into model context
// The log is append-only. Model context is a projection.
const messages = deriveMessages(sessionLog)
// messages === exactly what the model will see
Adding new model-visible content requires extending SessionEventMap and rendering from the log. You cannot sneak content into model context without it being logged.

Reversible Cordis Effects
Every registration in the agent loop — tool definitions, waterfall listeners, prompt sections, model adapters — is a reversible Cordis effect. This means:
// Register a tool - returns a disposer
const dispose = ctx.tools.register({
name: 'my_tool',
execute: async (args) => { /* ... */ },
})
// Later: remove it cleanly
dispose()
// The tool is gone from the next prompt assembly
Hot-reload a plugin? Dispose all its effects, load the new version, re-register. The agent loop doesn’t restart — it just sees updated registrations on the next Step.
This is what makes DSH’s plugin model fundamentally different from static configuration. A plugin can add, remove, or replace behavior mid-session without any restart or state loss.
Practical Example: Building a Cost Guard
Here’s how these primitives compose. Say you want to stop a turn if it exceeds a token budget:
import { Context } from '@deepseek-ai/dsh-core'
export function apply(ctx: Context) {
let turnTokens = 0
// Reset on turn start
ctx.on('turn/start', () => { turnTokens = 0 })
// Count tokens from each assistant message
ctx.on('assistant/message', (msg) => {
turnTokens += msg.usage?.totalTokens ?? 0
})
// Intercept the next step if over budget
ctx.on('agent/pre-step', async (payload, next) => {
if (turnTokens > 50_000) {
return { kind: 'stop', reason: 'token-budget-exceeded' }
}
return next()
})
}
Three events, one waterfall, composable with every other plugin. No subclassing, no monkey-patching, no framework coupling.
How This Connects to the Broader Architecture
The agent loop is the execution core, but it’s not the whole picture:
- Capability Seams explain the Service Definition / Provider / Consumer pattern that makes all these hooks swappable
- DeepSeek Harness Developer Preview covers the full system architecture and package graph
- The GitHub repository contains all source code under MIT license
FAQ
Q: Can I add a new waterfall event to the loop?
A: No. The event catalog is fixed by SessionEventMap. You extend behavior by listening to existing events and composing waterfalls, not by adding new loop phases. New model-visible content requires extending the session event map — a deliberate, reviewed change.
Q: What happens if a waterfall listener throws?
A: It depends on the event’s error semantics. tools/post-execute catches throws and normalizes them as isError results. agent/pre-step propagates the error up to the turn driver. The general rule: waterfalls that control execution propagate; waterfalls that observe contain.
Q: How does exclusive executionMode interact with maxParallelToolCalls?
A: An exclusive tool forces the bounded pool to drain completely, runs alone, then releases the pool. It doesn’t consume a pool slot — it suspends the pool entirely. This is stronger than setting maxParallelToolCalls: 1 because it guarantees no overlap with the exclusive tool specifically.
Q: Can compaction lose information the model previously relied on?
A: Yes, by design. Compaction is lossy — that’s why it generates a summary. The session log still has everything (it’s append-only), but deriveMessages() projects a smaller window after compaction. Plugins that need to preserve specific context should use PromptSection (prompt sections are never pruned).
Q: Is the session log actually append-only or is that an abstraction?
A: Actually append-only. Events are never mutated or deleted from the log. Compaction works by changing how deriveMessages() projects from the log, not by modifying the log itself. Fork creates a new log seeded with a prefix of the parent’s log. This makes replay, telemetry export, and debugging fully deterministic.


