DEV Community

Cover image for DeepSeek Harness Series (04): Agent Loop — How a Conversation Turn Actually Runs
WonderLab
WonderLab

Posted on

DeepSeek Harness Series (04): Agent Loop — How a Conversation Turn Actually Runs

Starting with a Question

You send the Agent one message: "Find all functions in the project that lack unit tests, then write them."

For the next three minutes, the Agent uses search_files to find all .ts files, then read_file to scan twenty-odd files, then write_file to produce a batch of tests. It never asks you anything.

Who is scheduling those tool calls? When does the loop stop? If the model pauses mid-run to say "I need to check the directory structure first," how does that sentence trigger a fresh round of tool calls?

That's what the Agent Loop answers.


Two Core Concepts: Turn and Step

Understanding the Agent Loop comes down to two concepts:

Step: one complete model call plus all the tool calls it triggers. One Step = LLM output + zero-to-many tool executions.

Turn: the complete process from when the user sends input until the Agent no longer owes any response. One Turn contains one or more Steps.

Turn opens
  ↓
Step 1: model reply + call search_files
  ↓
Step 2: model analyzes search results + call read_file × 5
  ↓
Step 3: model writes tests + call write_file × 3
  ↓
Model: "Done. Wrote tests for 12 functions."
  ↓
Turn closes
Enter fullscreen mode Exit fullscreen mode

Why distinguish Turn from Step? Because one Turn can contain multiple consecutive model requests — after tools execute, results are appended to history and the model keeps deciding. This loop runs until the model stops calling tools and there's no new pending input.


How the Driver Works

The dsh Agent Loop's core is the ReactLoopAgent class (source: packages/core/agent-loop/src/agent.ts).

Its driver loop is strikingly simple:

// packages/core/agent-loop/src/agent.ts

/** Main loop: keep running Turns until there's no more pending work */
private async kick(): Promise<void> {
  try {
    // turn() returns true if there's still pending work; false to stop
    while (await this.turn()) {}
  } catch (_error) {
    // Failures are contained here; they don't crash the outer layer
  } finally {
    this.setPhase({ kind: 'idle', lastTurn: turn })
    // If there's a latched wake request, process it
    if (wakeRequested && this.inbox.hasPending) this.wakeDriver()
  }
}
Enter fullscreen mode Exit fullscreen mode

The driver has three states:

State Meaning
idle No active work; waiting to wake
running Executing a Turn
maintenance Running a non-Turn task (e.g., compacting conversation history)

When a new message arrives, wakeDriver() is called, the driver transitions from idle to running, and processing begins.


How Messages Enter the Loop

The Agent has an Inbox. Different input paths land in different inbox partitions:

// Three input methods, three scheduling semantics

// 1. followup: normal user message, starts a new Turn
agent.followup(message)   // → next-turn queue, wakes the driver

// 2. steer: inject at the nearest step boundary
agent.steer(message)      // → next-step queue, wakes the driver

// 3. inject: inject context without waking the driver
agent.inject(message)     // → next-step queue, does NOT wake
Enter fullscreen mode Exit fullscreen mode

The distinction matters:

  • followup opens a brand new Turn, for normal user input
  • steer is picked up at the next Step boundary within the current Turn — good for mid-run steering ("wait, read the README first")
  • inject queues context but doesn't wake the driver — it waits for the next natural step boundary, useful for background hint injection

The Full Execution Flow

A Turn's complete path from open to close:

turn/start (written to Session log)
  │
  ├── Claim messages (dequeue from inbox)
  ├── Assemble System Prompt (calls ctx.systemPrompt.assemble)
  │
  ▼
[agent/pre-step waterfall]  ← Extension point ①
  │  Returns reject or enter(messages)
  │  reject → Turn closes empty (no Step record)
  │
  ├── step/start (written to Session log)
  │
  ▼
[agent/request waterfall]  ← Extension point ②
  │  Replace the LLM call config (model, maxTokens, etc.)
  │
  ├── Commit system/message and user/message
  ├── Derive and freeze the request from the Session log
  │
  ▼
[llm/stream waterfall]  → model streams output
  │  → agent/assistant-stream (live push to UI)
  │  → record assistant/message or assistant/attempt
  │
  ▼
Tool calls (parallel pool + exclusive barriers)
  │  → tool/call logged to Session
  │  → tools/pre-execute → tools/execute → tools/post-execute
  │  → tool/result logged to Session
  │
  ├── step/end (written to Session log)
  │
  ├── Still tool results pending, or next-step has new messages?
  │   yes → proceed to next Step
  │   no  → check whether to end the Turn
  │
  ▼
[agent/turn-stopping serial]  ← Extension point ③
  │  Listeners can call agent.steer() to keep the Turn going
  │  No new work injected → close the Turn
  │
turn/end (written to Session log)
Enter fullscreen mode Exit fullscreen mode

Three Key Extension Points

Extension Point ①: agent/pre-step (waterfall)

Fires before every Step begins. You can inspect, rewrite, or reject the messages about to reach the model.

type PreStepDecision =
  | { kind: 'reject' }             // Reject this Step; the Turn closes empty
  | {
    kind: 'enter'
    messages: UserMessage[]        // The messages that actually enter (may be rewritten)
    startsRequestSeries?: true     // Whether to start a new request series
  }
Enter fullscreen mode Exit fullscreen mode

In practice:

// Example: intercept instructions targeting sensitive paths
ctx.on('agent/pre-step', async (payload, next) => {
  const { messages } = payload

  for (const msg of messages) {
    if (messageContainsSensitivePath(msg)) {
      return { kind: 'reject' }
    }
  }

  return next()
})
Enter fullscreen mode Exit fullscreen mode

Note: this is a waterfall — you must either call next() or return a decision. agent/pre-step is the hardest gate in dsh: you intercept before the model even sees the message.

Extension Point ②: agent/request (waterfall)

Fires before each model request is sent, used to replace the call configuration:

ctx.on('agent/request', async (payload, next) => {
  const config = await next()     // Get the default config

  return {
    ...config,
    maxTokens: 8192,              // Give this session more output budget
    reasoningEffort: 'medium',    // Lower reasoning intensity to save tokens
  }
})
Enter fullscreen mode Exit fullscreen mode

This event runs after step/start but before system/message and user/message are committed. Cancellation during this event or the subsequent prepareCall() commits neither to the session log.

Extension Point ③: agent/turn-stopping (serial)

Fires just before a Turn closes naturally. Listeners can inject new work to keep the Turn going:

ctx.on('agent/turn-stopping', async (payload) => {
  const { agent, turn, signal } = payload

  // Check whether there's still a pending task for this session
  const pendingTask = await db.getPendingTask(agent.session.id)
  if (pendingTask) {
    // Inject new work; the Turn will continue
    agent.steer({
      type: 'user',
      content: [{ type: 'text', text: `One more task: ${pendingTask.description}` }],
    })
  }
  // If nothing injected, the Turn closes naturally
})
Enter fullscreen mode Exit fullscreen mode

agent/turn-stopping is a serial event (see Part 02) — no next(). Listeners get one chance to push data in, but they don't vote on whether to close. Data decides everything: new work means continue; no new work means stop.


assistant/message vs assistant/attempt

Two Session log events that are easy to confuse:

assistant/message assistant/attempt
When Model successfully completes output Failure / retry / cancel / stream error
Enters model history ✅ Yes (picked up by deriveMessages()) ❌ No (ignored)
Session log ✅ Recorded ✅ Recorded (for audit)
Example Normal response, even empty-content success Network error, context overflow, cancellation

This design keeps conversation history clean: failed attempts don't pollute the history the model sees on its next request, but every attempt is durably recorded for audit.


Why the Loop Stops

Many people are puzzled: the Agent called a bunch of tools — why did it stop at some particular moment?

The loop ends when:

  1. The model stops calling tools (no tool_call in the response)
  2. The next-step inbox has no new work

When both conditions hold, the loop enters agent/turn-stopping. If no listener injects new work, the Turn closes.

If a tool itself wants to end the Turn early, it can call exec.concludeTurn() inside execute:

// A tool that considers the task complete and ends the Turn proactively
async execute(args, exec) {
  const result = await doSomething()
  exec.concludeTurn()    // Tell the Loop: this Turn is done
  return result
}
Enter fullscreen mode Exit fullscreen mode

Walkthrough: Printing the Agent's Execution Trace

Combining the extension points above, here's a monitoring plugin that prints each Turn/Step event:

export const name = 'agent-tracer'
export const inject = ['tools']

export function apply(ctx: Context): void {
  // Monitor Turn status changes
  ctx.on('agent/status', (payload) => {
    console.log(`[Agent] status → ${payload.status}`)
  })

  // Log each Step as it begins
  ctx.on('agent/pre-step', async (payload, next) => {
    const { turn, step, messages } = payload
    console.log(`[Step] turn=${turn} step=${step} messages=${messages.length}`)
    return next()
  })

  // Log every tool call result
  ctx.on('tools/result', (exec, result) => {
    const icon = result.isError ? '' : ''
    console.log(`[Tool] ${icon} ${exec.name}`)
  })

  // Log when a Turn is about to stop
  ctx.on('agent/turn-stopping', (payload) => {
    console.log(`[Turn] ${payload.turn} stopping`)
  })
}
Enter fullscreen mode Exit fullscreen mode

Register this plugin and every Agent run produces a full execution trace — no changes to dsh core code required.


Design Principles Summary

The Agent Loop has two clear dividing lines in its design:

Durable vs real-time:

  • turn/*, step/*, assistant/message etc. are Session events — persisted, replayable, migratable
  • agent/pre-step, agent/request, agent/assistant-stream etc. are real-time extension points — exist only in the active driver, not persisted

Data-driven:
Every decision the Loop makes is driven by data, not external "vote" mechanisms:

  • Model stops calling tools → natural stopping point
  • Tool result carries concludesTurn → early exit
  • Listener injects new work in agent/turn-stopping → continue

This means you never need a "force stop" API — when there's no new work in the data, the Loop stops on its own.


What's Next in the Series

The next article on Sessions and Memory covers dsh's persistence design: why the Session log is append-only, how deriveMessages() reconstructs model history from the log, and how to carry memory across sessions.


Check out PrimeSkills — a curated marketplace of AI agents and skills validated in real-world, enterprise-grade workflows. Not demos — things that actually work in production.

Find more on my Homepage

Top comments (0)