The previous three articles answered three increasingly concrete questions:
- Why does an LLM need a Harness at all?
- Why does DeepSeek Harness make almost everything a Plugin?
- How does Cordis keep those Plugins composable instead of letting them become another pile of tightly coupled modules?
At this point the Runtime is ready. Models, Tools, Sessions, events, and other capabilities have all been mounted.
So now comes the question that matters most in practice:
When the user sends one message, what actually happens next?
The easy answer is “the Agent Loop runs.” But that phrase hides most of the interesting engineering.
A toy Agent Loop is often written like this:
while (true) {
const response = await llm(messages)
const results = await executeTools(response.toolCalls)
messages.push(...results)
}
That is useful for teaching the basic idea, but DeepSeek Harness has to solve a harder problem. A user may send another instruction while the current task is still running. Plugins may rewrite or reject the next model step. A model call may produce several Tool Calls. Some of those calls can run in parallel, others must form barriers. A task may need another model request after the Tools finish—or may need to stop without making any model request at all.
So instead of starting from while (true), it is more useful to start from the three boundaries the Runtime actually manages:
Inbox → Turn → Step
Once these three are clear, the rest of the Agent Loop becomes much easier to understand.
Turn and Step Are Not the Same Thing
This distinction is the first place where the real Harness differs from a simple chatbot loop.
DeepSeek Harness defines a Step as one model request together with the Tool executions requested by that model response.
A Turn, on the other hand, is the larger unit of work. It can contain zero, one, or multiple Steps.
A useful mental model is:
Turn
├── Step 1
│ ├── one model request
│ └── the tools requested by that response
│
├── Step 2
│ ├── another model request
│ └── another group of tool executions
│
└── ...
Figure 1: A Step is one model request plus the Tools it requested. A Turn is the larger boundary that may contain zero or many Steps.
Suppose the user says:
Fix the failing login test.
The first model request may decide to run:
pytest tests/test_login.py
That model request and the resulting test execution belong to Step 1.
The Tool result says authentication failed because of an unexpected redirect. The Agent now needs the model to think again. That new model request begins Step 2.
The second response may read a file, edit it, and run another test. If another model request is needed afterward, that becomes Step 3.
All of them may still belong to the same Turn.
This sounds like a small naming detail, but it changes how we understand an Agent. A Turn is not “one prompt, one answer.” It is closer to:
One continuous unit of work that may require several rounds of model reasoning and environmental feedback before the Runtime considers it complete.
There is an even stranger possibility: a Turn may contain zero Steps.
Why would the Harness open a Turn and then never call the model?
To understand that, we first need to look at the Inbox.
A User Message Does Not Go Straight to the LLM
When a message reaches an Agent, DeepSeek Harness does not immediately append it to a messages[] array and call the model.
It first enters the Agent's Inbox.
The Inbox has two logical destinations:
next-turn
next-step
These names describe when the message is eligible to enter model-facing work.
An ordinary:
agent.followup(message)
goes to next-turn and wakes the driver. It represents a normal new user request.
But DeepSeek Harness also exposes:
agent.steer(message)
and:
agent.inject(message)
Both target next-step, but they behave differently.
steer() is an active intervention. If the Agent is already running, the message is intended for the nearest later Step boundary. If the Agent is idle, steering can wake it and start a Turn.
inject() is quieter. It queues model-facing context for the next Step but does not wake an idle Agent by itself. That makes it useful for things such as file-change notices, extra instructions, or other context that should be seen the next time the Agent is already doing work.
The routing looks roughly like this:
Figure 2: followup() targets the next Turn; steer() and inject() target the next Step, but only steering wakes the driver.
This design matters because a running Agent is not a closed pipeline.
Imagine the Agent is already investigating a bug and you suddenly send:
Do not modify the database layer.
A simplistic implementation might have to cancel the whole run, rebuild the prompt, and start over.
DeepSeek Harness instead has a place for steering to wait until the next safe Step boundary.
That is a much more useful abstraction for long-running Agents: the user can intervene while work is in progress without pretending that every interaction starts from an idle chatbot.
The Turn Opens Before the Model Is Called
Once waking work is available, the driver enters its running state and opens a durable Turn:
turn/start
Notice the order: the Turn begins before the first Step.
The driver then claims the input for the proposed Step. At a Turn boundary, that means:
all pending next-step input
+
one queued next-turn message
The claimed messages are no longer merely waiting in the Inbox. They are now the candidate input for the next model-facing Step.
But the model still has not been called.
Before that happens, DeepSeek Harness runs:
agent/pre-step
This is one of the most important interception points in the whole Agent Loop.
A listener can decide:
reject
or:
enter(messages)
And enter(messages) does not have to return the exact batch that was claimed. Plugins can rewrite what enters the Step.
That gives Context-management, compaction, policy, or steering-related extensions a clean place to intervene before the model request is derived.
It also explains the zero-Step Turn.
The sequence can legally be:
turn/start
↓
claim input
↓
agent/pre-step
↓
reject
↓
turn/end
No step/start occurs. No LLM request occurs.
This is a subtle but important design choice: the durable history can record that the Runtime opened and closed a Turn even when policy or preprocessing prevented any model work from happening.
Only After Pre-Step Does a Step Really Begin
If agent/pre-step returns enter(messages), the driver finally opens:
step/start
The entered user-role messages are then appended to the Session as:
user/message
At this point, the Harness has enough information to construct the request.
But even here it does not simply reuse a mutable messages[] object.
The Runtime does two different things:
- It assembles the current System Prompt and Tool schemas from registered capabilities.
- It derives model history from the Session log.
Conceptually:
Session log
↓
derive model history
Plugin registrations
↓
assemble system prompt + tool schemas
both
↓
model request
The actual request then passes through:
agent/request
↓
llm/stream
The model streams back chunks. DeepSeek Harness records those raw chunks as:
assistant/chunk
and, after a successful provider call, records the assembled result as:
assistant/message
This may seem excessively detailed for a simple model call, but it serves a larger design rule that we will examine in the next article:
Model-visible means logged.
The Session is not merely a chat transcript. It is the durable source from which model-visible history can be reconstructed.
For now, the important point is simpler: the Agent Loop never treats the model request as an isolated black box. The request is constructed from Runtime state, streamed through an adapter seam, and written back into the Session as durable facts.
A Step Includes the Tools Requested by That Model Call
Suppose the model response contains:
read_file("src/auth.ts")
run_test("tests/login.test.ts")
The Step does not end as soon as the model stops generating.
Those Tool Calls belong to the Step that produced them.
At a high level, the loop does this:
assistant/message
↓
classify Tool Calls
↓
tool/call
↓
Tool execution pipeline
↓
tool/result
↓
step/end
DeepSeek Harness can classify calls by execution mode. Some Tool Calls are allowed to overlap; exclusive calls form ordering barriers. Results are then committed in model order.
We will leave the permission checks, pre/execute/post waterfalls, timeouts, Sandbox providers, and parallel scheduling details for the dedicated Tool Pipeline article. The important point here is only the boundary:
One Step owns one model request and the Tool executions requested by that response.
The Step closes only after those calls have settled and their results have been written.
Why Does the Loop Start Another Step?
Now we reach the actual loop.
After:
step/end
the Runtime asks whether more model work is owed.
There are several reasons why the answer may be yes.
A Tool result may require the model to interpret what happened. A Tool may defer additional model-facing context. The user may have sent steering while the previous Step was running. Other next-step input may now be waiting.
If more work exists, the Runtime does not open a new Turn immediately.
Instead, the current Turn continues:
Step 1
↓
Tool results
↓
claim next-step input
↓
agent/pre-step
↓
Step 2
This is why:
Turn ≠ Step
is not just terminology.
The Turn is the continuity boundary. Steps are individual rounds of model interaction inside it.
A Coding Agent may therefore spend one Turn doing something like:
Step 1: run failing test
Step 2: inspect relevant files
Step 3: edit implementation
Step 4: rerun test
Step 5: inspect another failure
Step 6: final verification
From the user's point of view, that may still feel like one request.
From the Harness's point of view, it is a sequence of separately logged and interceptable model Steps.
When Does a Turn Actually End?
The obvious answer would be:
When the model says it is done.
But the Harness cannot rely on that alone.
A model response may look final while a Tool has requested continuation. Steering may have arrived during execution. Another piece of next-step context may already be queued.
DeepSeek Harness therefore checks whether the Turn still owes more work.
When there is no Tool continuation and the next-step Inbox is empty, the loop reaches a final checkpoint:
agent/turn-stopping
This gives interested Plugins one last place to react before the Turn is committed as finished.
Then:
turn/end
is appended.
Only after the driver's work drains does the Agent return to:
idle
One subtle point: running does not mean “a Turn is currently open.” It describes the broader driver activity interval and may span consecutive queued Turns.
Again, DeepSeek separates several notions that a toy Agent often collapses into one variable:
Agent status
Turn lifetime
Step lifetime
Inbox state
That separation is what lets the Runtime support steering, cancellation, replay, queued work, and extensions without turning the main loop into one giant stateful function.
The Whole Path
We can now put the lifecycle together:
Figure 3: The Agent Loop is not simply LLM → Tool → LLM. Inbox routing, durable Turn/Step boundaries, pre-step interception, Session logging, and continuation checks all sit around that familiar cycle.
In a compressed form:
User / plugin input
↓
Inbox
↓
turn/start
↓
claim proposed input
↓
agent/pre-step
↓
step/start
↓
append user/message
↓
assemble request
↓
agent/request
↓
llm/stream
↓
assistant/message
↓
Tool Calls + results
↓
step/end
↓
more work?
↙ ↘
yes no
↓ ↓
next Step agent/turn-stopping
↓
turn/end
This is the point where “Agent Loop” stops being a vague name for while (true).
It is better understood as:
A driver that continuously turns queued work into durable Turn and Step transitions, derives each model request from Runtime state, executes the actions the model asks for, and decides whether the current unit of work should continue or close.
That also explains why DeepSeek Harness keeps the Agent Loop itself replaceable behind a Service interface. The loop is enormously important, but it is still one Plugin in the larger composition rather than a privileged core that every extension must modify.
And one part of this lifecycle deserves its own article.
We repeatedly wrote events such as:
turn/start
user/message
assistant/message
tool/result
turn/end
Why log all of them instead of just maintaining the latest messages[] array?
Why does DeepSeek insist that model-visible information must be reconstructable from the log?
That is the next piece of the architecture:
Session is not chat history.
The next article will focus on the rule behind it: Model-visible means logged.
Top comments (0)