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:

ConceptDefinitionScope
StepOne model request + every tool it calls before the next requestAtomic unit of work
TurnZero or more Steps triggered by one external promptUser-facing boundary
RoundAn 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.

DeepSeek Harness GitHub Repository

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

EventModePurpose
turn/startemitOpens the turn boundary
agent/pre-stepwaterfallRewrite/reject messages before the model sees them
step/startemitOpens one model-request cycle
system-prompt/assemblecooperative waterfallBuilds the full system prompt from registered sections
agent/requestwaterfallSwap model config (provider, temperature, etc.)
llm/streamemitSignals streaming has begun
assistant/chunkemitOne streaming token batch
assistant/messageemitComplete assistant response
tool/callemitOne tool invocation logged
tools/pre-executewaterfallAllow, deny, or ask before dispatch
tools/executewaterfallAround-dispatch (timeout, retry, metrics)
tools/post-executewaterfallAccept, replace, enrich, or block result
tool/resultemitFrozen authoritative outcome
step/endemitStep completed
agent/turn-stoppingemitTurn 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

DSH Packages Directory

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:

MechanismBehavior
BarrierAll tool calls from one response are grouped into a batch
Bounded rolling poolWithin the batch, up to maxParallelToolCalls run concurrently
exclusive executionModeTool runs alone, blocking all others in the batch
parallel executionModeTool 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:

  1. agent/pre-step: Measures context pressure. If approaching the limit, triggers the compaction cycle.
  2. 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.

Cordis Paper on GitHub

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:

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.