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
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()
}
}
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
The distinction matters:
-
followupopens a brand new Turn, for normal user input -
steeris picked up at the next Step boundary within the current Turn — good for mid-run steering ("wait, read the README first") -
injectqueues 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)
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
}
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()
})
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
}
})
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
})
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:
-
The model stops calling tools (no
tool_callin the response) - The
next-stepinbox 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
}
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`)
})
}
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/messageetc. are Session events — persisted, replayable, migratable -
agent/pre-step,agent/request,agent/assistant-streametc. 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)