Module 1 — Foundations

The Agent Loop: Steps, Turns, and Stopping

Reading time ~17 min · Last reviewed Sep 2026 · Quiz at the end

Lesson 2 listed the orchestration loop first among the seven subsystems of a harness, named its failure modes (infinite loops, premature termination, silent stalls after an unexpected tool result), and then moved on. Every lesson after it assumes the loop exists. This one opens it up. The loop is the smallest piece of a harness by line count and the one every other piece hangs off: context assembly runs before each model call, guardrails run before each tool call, compaction runs when the loop notices pressure, telemetry records what the loop did. If you don't have a precise picture of the loop, you can't say where any of those things belong.

OpenAI's Codex team opens its write-up of its own harness with the line "At the heart of every AI agent is something called 'the agent loop'" (OpenAI, 2026). That is right, and the loop itself is almost embarrassingly simple: call the model, run whatever tools it asked for, feed the results back, repeat until it stops asking. The engineering is in the edges. When does it stop? What counts as one unit of work? What happens when a tool throws, when the model asks for six tools at once, when it asks for the same tool eight times in a row? This lesson answers those questions with two real harnesses as reference points, DeepSeek Harness and Codex, and ends with an argument for keeping the loop as dumb as you can.

The smallest loop that works

Start with code. Here is a complete, provider-neutral agent loop. It is Python-shaped pseudocode (the model and tools objects stand in for whatever SDK you use), but every line corresponds to something a production harness does.

import json

def run_turn(model, tools, history, user_msg, max_steps=40, max_cost=2.00):
    """One turn: zero or more steps, ending when the model owes nothing."""
    history.append({"role": "user", "content": user_msg})
    spent = 0.0

    for step in range(max_steps):
        # 1. Call the model with the full history and the tool schemas.
        reply = model.generate(history, tools=tools.schemas())
        spent += reply.cost
        # Keep the reply verbatim: text, reasoning, and tool calls.
        history.append(reply.as_message())

        # 2. Parse tool calls. No calls means the model is done.
        if not reply.tool_calls:
            return {"status": "done", "text": reply.text, "steps": step + 1}

        # 3. Execute each call. 4. Append each result, in the order requested.
        for call in reply.tool_calls:
            history.append({"role": "tool", "tool_call_id": call.id,
                            "content": execute(tools, call)})

        # 5. Check the harness-owned stop conditions.
        if spent >= max_cost:
            return {"status": "budget_exhausted", "steps": step + 1}

    return {"status": "max_steps", "steps": max_steps}


def execute(tools, call):
    """Every failure becomes text the model can read, never an exception."""
    tool = tools.get(call.name)
    if tool is None:
        return f"Error: no tool named '{call.name}'. Available: {tools.names()}"
    try:
        args = json.loads(call.arguments)
    except json.JSONDecodeError as e:
        return f"Error: arguments were not valid JSON ({e}). Resend valid JSON."
    try:
        return str(tool.run(**args))
    except Exception as e:
        return f"Error: {type(e).__name__}: {e}"

Five things in these forty lines are easy to get wrong in practice.

The history is append-only. The loop never edits or reorders an earlier message; it only appends. Codex does the same, and its write-up points out that because each request's prompt is an exact prefix of the next, the provider's prompt cache can reuse the computation, which turns an otherwise quadratic loop into roughly linear sampling cost (OpenAI, 2026). Why that matters, and how to manage it once the window fills, is lesson 6's subject.

The reply is appended whole. reply.as_message() keeps the reasoning and the tool calls alongside the text. Dropping the reasoning to save tokens is one of the most expensive "optimizations" in this field, as we'll see below.

The natural stop is the absence of tool calls. The model says it is done by answering instead of acting. Codex describes it the same way: the loop repeats until the model stops emitting tool calls and produces an assistant message, and that message marks the end of the turn (OpenAI, 2026). The OpenAI Agents SDK spells out the same rule: output counts as final when it has the desired type and contains no tool calls (OpenAI Agents SDK docs).

The other stops are the harness's. The step cap and the budget cap don't depend on the model's judgment. They exist because the model's judgment is sometimes wrong, and each returns a different status so the caller can tell "finished" from "ran out".

Errors never escape execute. A missing tool, malformed arguments, or a crashing tool all come back as strings the model reads on its next step. This is a design decision, and the section on errors below explains why it is the right one.

Key idea

The agent loop is a small, generic state machine: call model, execute tools, append results, check stop. Everything that makes a harness good (context policy, guardrails, compaction, loop detection, telemetry) should attach to the loop at well-defined points, not be written into it. A loop you can read in one screen is a loop you can reason about when it misbehaves at 3 a.m.

Naming the units: steps, turns, threads

The first thing production harnesses do to the forty-line loop is give its pieces names, because you can't set a limit, write a log line, or attach a hook to a thing without a name. The cleanest published vocabulary is DeepSeek Harness's (dsh), whose architecture document defines the loop as an explicit state machine: a step is "one model request plus the tools it calls," and a turn is zero or more steps, opening when input is claimed and closing once the agent owes nothing more (DeepSeek Harness, architecture.md (2026)). Turns live inside a session, whose append-only event log is the source of truth for everything the model sees.

That "zero" is deliberate. In dsh, a hook that runs before the first step can reject the input entirely, and the turn closes having spent no model call. The loop records turn/start, step/start, step/end and turn/end as durable events, so every unit of work has a boundary you can find in the log (DeepSeek Harness, agent-lifecycle.md (2026)).

Codex arrives at a compatible vocabulary from the protocol side. Its loop write-up calls the journey from user input to agent response one turn of a conversation (a conversation is a thread in Codex), and a turn can include many iterations of inference and tool calls (OpenAI, 2026). The follow-up post on the Codex App Server, the JSON-RPC interface that every Codex surface drives the same harness through, formalizes three primitives (OpenAI, 2026):

Codex doesn't publish a named "step" at the protocol level; the model-call iterations inside a turn show up as the items they produce. dsh names the step because its hooks and limits operate at that granularity. The two designs agree on the important thing: the turn is bounded by user input on one side and by the agent owing nothing more on the other, and it is a separate object from the model call.

ConceptDeepSeek HarnessCodexClaude Agent SDK
One model request plus its tool callsstep(iteration within a turn; surfaces as items)"turn"
User input to final responseturnturnone query run, ended by a ResultMessage
Durable conversationsession (event log)threadsession
Pitfall

"Turn" is not a standard unit. The Claude Agent SDK calls each tool-use round trip a turn, and its max_turns "counts tool-use turns only" (Claude Agent SDK docs), which is what dsh calls a step. The OpenAI Agents SDK's max_turns likewise counts loop iterations, raising MaxTurnsExceeded when exceeded (OpenAI Agents SDK docs). Codex and dsh use "turn" for the whole user-to-response exchange. A team that ports "max 30 turns" from one framework to another can change its limit by an order of magnitude without noticing. Define your own units in your own docs, and put the unit in the config key name.

Stopping: five ways a turn ends

A loop that never stops is a bill. A loop that stops early is a wrong answer delivered confidently. Production harnesses recognize at least five distinct stop conditions, and the most useful thing you can do is make them distinguishable in the result.

1. The model says it's done. The natural stop: a reply with no tool calls. It is also the stop you trust least, because a model can declare victory before verifying anything.

2. Step cap. A hard limit on model calls per turn. The Claude Agent SDK returns a result with subtype error_max_turns when it hits the cap, distinct from success (Claude Agent SDK docs).

3. Budget. A cap on spend, tokens, or wall-clock time. Step counts are a poor proxy for cost, since one step with a giant tool result can cost more than twenty small ones. The same SDK offers max_budget_usd, returns error_max_budget_usd when it trips, and its docs recommend a budget as the default for production agents (Claude Agent SDK docs).

4. Goal met, or not yet. The model saying "done" is a claim; a check that the goal was met is evidence. dsh gives this its own hook: agent/turn-stopping fires when the turn is about to close because the model owes nothing, and a listener that disagrees can inject a message, which runs another step instead of closing the turn. The inverse also exists: a tool result can carry a concludesTurn flag that ends the turn at that step (DeepSeek Harness, core subsystem docs (2026)). LangChain's deep agents harness uses the same idea as middleware that catches the agent just before it exits and reminds it to verify its work against the task spec (LangChain, 2026). Loops whose whole purpose is to keep going until a goal is verified, across many turns and sometimes on a schedule, are lesson 15's territory.

5. Interrupt. A human presses stop, a parent agent cancels a child, a policy hook aborts, or the process shuts down. dsh types exactly those four causes (user, parent, hook, disposed) and records the cause in the durable turn/end event, so an aborted turn is never confused with a finished one (DeepSeek Harness, core subsystem docs (2026)). A softer form of interrupt is the approval pause: in Codex, the server can ask the client for approval before running an action, which pauses the turn until the user allows or denies it (OpenAI, 2026).

Key idea

Every stop condition should produce a different, machine-readable outcome: done, capped, over budget, verified, interrupted. If your traces only record "turn ended," you can't distinguish an agent that finished from one that ran out of road, and your evals (lesson 18) will count both as completions.

Doom loops: when the model keeps doing the same thing

The step cap stops a runaway loop eventually. Loop detection stops it early, while there is still budget to do something useful. The failure has a name in the practitioner literature: LangChain calls it the doom loop, where an agent makes "small variations to the same broken approach," ten or more times in some of the traces they studied (LangChain, 2026).

Two published detectors show the design space.

Exact repeats (dsh). dsh's repeat-tool-reminder plugin, part of a guard package family its README describes as loop hygiene, watches for the same tool called with identical arguments. With the default settings it reminds the model at 3, 5 and 8 consecutive repeats: first a short nudge to analyze the previous result, then detailed reminders naming the tool and arguments. A new user message resets the count. It is explicitly advisory: it "never blocks or delays a legitimate repeated call" (DeepSeek Harness, repeat-tool-reminder (2026)). Its README is honest about the limit: only exact repeats count, so near-identical variants slip through.

Progress signals (LangChain). LangChain's loop-detection middleware tracks per-file edit counts through tool-call hooks, and after N edits to the same file it adds context suggesting the agent reconsider its approach (LangChain, 2026). This catches exactly the case exact matching misses: ten different edits to the same file, none of them working.

The two share a pattern worth copying. Neither kills the loop. Both inject a message the model reads as ordinary context, which leaves the decision with the model and keeps the intervention visible in the transcript. The hard stop stays where it was, in the step and budget caps. A sensible production policy layers them: nudge at the first sign of repetition, nudge harder at the second, and let the budget cap end things if the nudges fail.

Pitfall

Don't make a loop detector a veto without very good reason. Some repeats are legitimate: polling a build status, re-running a test after an edit, re-reading a file another process changed. A detector that blocks the third identical call breaks those workflows and teaches you nothing, because the model never sees why it was stopped. Advise first; enforce with caps.

Executing tools: order, parallelism, and barriers

Modern models often ask for several tools in one reply: read three files, grep two patterns. The minimal loop runs them one at a time. That's correct and slow. Running them all concurrently is fast and sometimes wrong, because "write the config, then run the tests" has an ordering the model expressed by the order of its calls.

The Claude Agent SDK's answer is a read/write split: read-only tools such as Read, Glob and Grep can run concurrently, while state-modifying tools such as Edit, Write and Bash run sequentially. Custom tools default to sequential unless marked read-only (Claude Agent SDK docs).

dsh formalizes the same idea. Each pending call is classified by an executionMode of either parallel or exclusive; an exclusive call runs alone and forms an ordering barrier. Classification fails closed: only an explicit opt-in is parallel, and unknown, undeclared, or throwing classifiers are treated as exclusive. The loop then runs calls through exclusive barriers and a bounded rolling pool of parallel ones, and, crucially, commits results to the session log in model order even when they finish out of order (DeepSeek Harness, tools subsystem docs (2026); agent-lifecycle.md).

A simplified version of that scheduler (batches rather than a rolling pool) fits in a few lines:

def plan_batches(calls, mode_of):
    """Runs of parallel-safe calls share a batch; exclusive calls stand alone."""
    batches, run = [], []
    for call in calls:
        if mode_of(call) == "parallel":      # fail closed: default is exclusive
            run.append(call)
        else:
            if run:
                batches.append(run)
                run = []
            batches.append([call])           # barrier
    if run:
        batches.append(run)
    return batches

async def execute_all(tools, calls, mode_of):
    results = {}
    for batch in plan_batches(calls, mode_of):
        outs = await gather(*(execute_async(tools, c) for c in batch))
        results.update({c.id: out for c, out in zip(batch, outs)})
    # Commit in the order the model asked, not the order tools finished.
    return [(c.id, results[c.id]) for c in calls]

Why insist on model order when the results are all there anyway? Three reasons. Determinism: the same run produces the same transcript, so a replay or a fork reconstructs the same context. Caching: a history whose tail order depends on which tool happened to finish first is a history whose prefix differs between otherwise identical runs. And legibility: the model paired each call with an ID and a position, and a transcript that preserves both is one a human debugger can read top to bottom.

Errors are observations, not exceptions

In ordinary software, an unexpected condition throws, the stack unwinds, and someone handles it. In an agent loop, that instinct is usually wrong. The model is the component best placed to recover from most tool failures (a wrong path, a typo in an argument, a command that needs a flag), but only if it sees the failure. An exception that unwinds the loop takes that chance away.

So production harnesses convert failures into tool results. dsh's tool registry resolves tool and listener failures as materialized error results, and a call to a tool the agent can't see returns an UNKNOWN_TOOL error rather than crashing (DeepSeek Harness, tools subsystem docs (2026)). In the Claude Agent SDK, a permission denial comes back to the model as a rejection message in the tool result, and the model usually tries a different approach (Claude Agent SDK docs). The Manus team argued for leaving failed actions in the context for the same reason: when the model sees a failed action and its stack trace, "it implicitly updates its internal beliefs" (Manus, 2025).

Not every failure belongs in the transcript, though. The useful distinction is who can fix it:

FailureWho can fix itWhat the loop does
Tool error, bad arguments, unknown tool, denied permissionThe modelReturn an error result with enough detail to act on; continue the loop
Model request failed (rate limit, timeout, context overflow)The harnessRetry or repair outside the model-visible history; dsh logs failed attempts as assistant/attempt events that never enter model history, and runs recovery (including compaction on context overflow) from an agent/request-error hook
Hung toolThe harness, then the modelEnforce a deadline and return a clear timeout error; dsh's timeout-policy guard does this for tools that declare a limit
Harness bug, corrupted state, cancelled turnA humanEnd the turn with a distinct error status; don't ask the model to reason about your stack trace

Sources for the dsh rows: architecture.md, agent-lifecycle.md and the guard package README (2026).

Illustrative example

A support-operations team's refund agent crashed whenever the billing API returned a 404 for a closed account: the tool wrapper raised, the loop died, and the user got a generic "something went wrong." Changing one wrapper to return Error: account 88213 is closed; closed accounts can't receive refunds, only store credit let the model offer store credit on its next step. No prompt changed. The failure had always been recoverable; the loop just hadn't let the model see it.

The quality of the error message matters as much as its presence. "Error: 404" gives the model nothing. "No file at src/auth.py; did you mean src/auth/__init__.py?" gives it the next action. This is the loop-level version of lint errors that carry their own remediation (lesson 9), and it is one of the cheapest reliability wins in a harness.

Keep the reasoning between tool calls

Reasoning models produce thinking before they act. In a tool loop, that thinking explains why the model chose this tool with these arguments, and the next step needs it. A harness that appends the tool call and the result but drops the reasoning hands the model a transcript of actions with the motives removed.

Every major provider now documents this. Anthropic's thinking docs are blunt: within a tool-use turn, you must pass the thinking blocks back "complete and unmodified," because they carry the reasoning that led to the tool requests and let the model pick up that reasoning where it stopped (Anthropic, Claude API docs). Codex's loop appends the model's reasoning output items to the input of the next request alongside the function call (OpenAI, 2026). DeepSeek went further at the model level: V3.2 kept reasoning across tool-result rounds but discarded it when a new user message arrived, and V4 "preserves reasoning content across user message boundaries" when the conversation contains tool calls, aiming for a cumulative chain of thought over long-horizon agent work (Hugging Face, DeepSeek-V4 (2026)).

The cost of getting this wrong has been measured in public. Anthropic's April 2026 Claude Code postmortem describes a change meant to clear old thinking once when resuming an idle session; a bug made it clear thinking "on every turn for the rest of the session." Users saw Claude become forgetful and repetitive, with odd tool choices: it kept working, but increasingly without memory of why it had chosen what it was doing. The dropped blocks also caused cache misses, which made usage limits drain faster (Anthropic, 2026).

Key idea

Reasoning is loop state, not decoration. Treat the model's reply as an opaque unit and append it whole, reasoning included, for at least the duration of a tool-use turn. If you need to reclaim that context later, do it deliberately with a compaction policy (lesson 6), not with a filter someone added to save tokens.

Hooks: where everything else attaches

The minimal loop has five numbered points. A production loop exposes those points as hooks, and that is how it stays generic while the harness around it grows. dsh is the clearest published example, because its loop package is itself a replaceable plugin and all behavior beyond the core cycle arrives through events (DeepSeek Harness, architecture.md (2026)). Its main extension points, and what attaches to each, are:

Loop pointdsh eventClaude Agent SDK analogueWhat typically attaches
Before a step is admittedagent/pre-step (can rewrite or reject input)UserPromptSubmit, PreCompactContext injection, compaction on token pressure (lesson 6), input filters
Building the model requestagent/request, llm/stream(internal)Model routing, request telemetry
Before a tool runstools/pre-executePreToolUsePermissions, approvals, sandbox policy (lesson 13, lesson 12)
Around a tooltools/execute(internal)Timeouts, retries, metrics
After a tool returnstools/post-executePostToolUseResult rewriting, spill of large outputs, loop detection, audit
Turn about to closeagent/turn-stoppingStopVerification gates, "not done yet" continuations
Model request failedagent/request-error(result subtype)Retry policy, context-overflow recovery

Sources: dsh architecture.md, dsh tool-execution-pipeline.md, Claude Agent SDK docs.

Look at the table from the other direction and it doubles as a map of this course. Guardrails live on the pre-tool hook. Compaction lives on pre-step and request-error. Loop detection lives on post-tool. Verification lives on turn-stopping. Telemetry observes all of them. When someone proposes a new harness feature, the first design question is "which hook?", and if the honest answer is "none, we need to change the loop," that's a signal to push back.

One rule applies to every hook that changes what the model sees: preserve the append-only history. Codex handles mid-conversation changes to sandbox settings or working directory by appending a new message rather than editing the earlier one, specifically to protect cache hits (OpenAI, 2026). dsh goes further: under a design rule it names model-visible means logged, a runtime invariant checks that every model request can be reconstructed from the session log (DeepSeek Harness, architecture.md (2026)). A hook that silently rewrites history breaks replay, forking, and caching at once. The deeper treatment of how that log becomes the model's context is in lesson 6, and the full dsh design is taken apart in lesson 23.

Keep the loop dumb

Everything in this lesson so far adds machinery. The last point subtracts it. Anthropic's write-up on harnesses for long-running application work puts it this way: "Every component in a harness encodes an assumption about what the model can't do," and those assumptions are worth stress-testing (Anthropic, 2026). The author describes dropping a whole sprint-decomposition layer when moving from Claude Opus 4.5 to 4.6, because the newer model no longer needed it.

Apply that lens to the loop. The repeat-call reminder assumes the model gets stuck and doesn't notice. The pre-completion check assumes it declares victory early. The step cap assumes it can't judge when to give up. Each of those is true of some models on some tasks today, and each will be less true next year. A loop with those behaviors hardcoded into its control flow has to be rewritten to remove them. A loop where they are hooks can drop one with a config change and an eval run. That's the practical argument for a small generic loop with everything else attached: not elegance, but the ability to delete things as models get better.

This is the same encapsulation argument lesson 2 made with Weng's operating-system analogy, applied to the scheduler at the center. It also sets up what comes later: lesson 15 wraps this loop in goal and schedule loops that span many turns, and lesson 16 runs several of them at once. Both are easier if the single loop underneath is boring.

In practice

This week, find your agent's loop, whether you wrote it or it lives in a framework, and answer five questions in writing. (1) What are your units? Write one sentence each for step, turn, and session, and check that every configured limit names its unit. (2) What are your stop conditions, and does each produce a distinct status in your traces? Pull 50 recent runs and count them by status. (3) What happens when a tool throws? Force one to fail and read what the model saw. (4) Does anything strip reasoning between tool calls? Diff one raw provider response against what you send back on the next request. (5) Which of your harness behaviors are hooks, and which are hardcoded in the loop? For each hardcoded one, write down the model limitation it assumes, and put the cheapest one on your list for a removal experiment against your eval suite.

Key takeaways

Check your understanding

1. An agent's file-read tool raises a Python exception when the path doesn't exist. The exception propagates, the loop exits, and the user sees "internal error." What is the best fix?

Restarting the turn discards progress and hides the failure from the model, which will likely make the same call again. The model never learns what went wrong.
Correct. A wrong path is a failure the model can fix, so it should see the failure as an observation. An actionable message gives it the next step; this is how dsh, the Claude Agent SDK, and Manus all treat tool failures.
The model can't know in advance which paths exist, so the instruction can't be followed reliably. The real bug is that the loop turns a recoverable condition into a crash.
A step cap bounds cost, but this turn ends on the first failure regardless. Lowering the cap does nothing for the crash.

2. Traces show an agent editing the same file eleven times with slightly different patches, none of which fix the test. Your repeat-call detector, which flags identical tool calls, never fired. What is the most sensible change?

The calls weren't identical, so a stricter exact-match rule still never fires. Turning it into a veto would also break legitimate repeats such as polling.
That changes the action surface without addressing the loop. The agent can just as easily rewrite the same file eleven times.
More attempts at a broken approach just make the doom loop more expensive. The agent needs a signal to change strategy, not more room.
Correct. Exact-match detection misses near-variants, which is the limitation the dsh reminder's own docs call out. A progress signal like LangChain's per-file edit count catches this pattern, and advisory nudges plus hard caps is the layered policy the lesson recommends.

3. A team parallelizes tool execution and appends each result to history as soon as it completes. Soon two runs of the same task produce different transcripts, replays don't match the originals, and prompt-cache hit rates drop. What should they change?

Correct. Completion order is nondeterministic; model order is not. dsh runs calls concurrently where safe but commits results in model order, which gives deterministic transcripts, faithful replay, and a stable history prefix.
That fixes the symptom at the cost of all the latency gains. Concurrency isn't the problem; the commit order is.
Alphabetical order is deterministic, but it breaks the pairing between the model's calls and their results and throws away the order the model expressed. Model order is the natural deterministic choice.
Caching works fine with parallel tools as long as history is appended deterministically. Turning it off makes every step pay full input cost.

4. To cut token costs, an engineer changes the loop to strip the model's reasoning blocks from history before each new request, keeping only tool calls and results. Soon the agent starts re-reading files it already read and choosing odd tools. What is the best explanation?

Tool schemas are sent separately on every request. Stripping reasoning doesn't remove them.
Shorter context doesn't overload a model. The problem is which information was removed, not how much.
Correct. Providers require thinking blocks to be passed back within a tool-use turn for reasoning continuity. When Claude Code's thinking was cleared every turn by mistake, the result was forgetful, repetitive behavior with odd tool choices, plus cache misses that made the change cost more, not less.
More steps without the reasoning just means more forgetful steps. The cap doesn't restore the missing state.