DEV Community

Cover image for DeepSeek Harness Deep Dive (4): Agent Loop — How One Prompt Turns Into Dozens of Actions
DA SEIN
DA SEIN

Posted on Originally published at daseinsblog.hashnode.dev AI-assisted

DeepSeek Harness Deep Dive (4): Agent Loop — How One Prompt Turns Into Dozens of Actions

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)
}
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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
│
└── ...
Enter fullscreen mode Exit fullscreen mode

A Turn can contain zero or more Steps.

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.
Enter fullscreen mode Exit fullscreen mode

The first model request may decide to run:

pytest tests/test_login.py
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

These names describe when the message is eligible to enter model-facing work.

An ordinary:

agent.followup(message)
Enter fullscreen mode Exit fullscreen mode

goes to next-turn and wakes the driver. It represents a normal new user request.

But DeepSeek Harness also exposes:

agent.steer(message)
Enter fullscreen mode Exit fullscreen mode

and:

agent.inject(message)
Enter fullscreen mode Exit fullscreen mode

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:

followup, steer, and inject enter different Inbox paths.

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.
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

This is one of the most important interception points in the whole Agent Loop.

A listener can decide:

reject
Enter fullscreen mode Exit fullscreen mode

or:

enter(messages)
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

The entered user-role messages are then appended to the Session as:

user/message
Enter fullscreen mode Exit fullscreen mode

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:

  1. It assembles the current System Prompt and Tool schemas from registered capabilities.
  2. It derives model history from the Session log.

Conceptually:

Session log
   ↓
derive model history

Plugin registrations
   ↓
assemble system prompt + tool schemas

both
   ↓
model request
Enter fullscreen mode Exit fullscreen mode

The actual request then passes through:

agent/request
   ↓
llm/stream
Enter fullscreen mode Exit fullscreen mode

The model streams back chunks. DeepSeek Harness records those raw chunks as:

assistant/chunk
Enter fullscreen mode Exit fullscreen mode

and, after a successful provider call, records the assembled result as:

assistant/message
Enter fullscreen mode Exit fullscreen mode

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")
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

This is why:

Turn ≠ Step
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

This gives interested Plugins one last place to react before the Turn is committed as finished.

Then:

turn/end
Enter fullscreen mode Exit fullscreen mode

is appended.

Only after the driver's work drains does the Agent return to:

idle
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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:

The core DeepSeek Harness Agent Loop from Inbox to Turn, Step, model request, Tool execution, and Turn end.

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
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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.

References

  1. DeepSeek Harness Architecture
  2. Agent Turn and Step Lifecycle
  3. DeepSeek Harness Core Subsystem
  4. DeepSeek Harness Session Subsystem
  5. DeepSeek Harness Tools Subsystem

Top comments (0)