Loops, harnesses and graphs

Matheus Cardoso

You've used an agent: Claude Code, Cursor, Copilot in agent mode. It reads your request, does one thing, looks at the result and decides the next one. Sometimes it feels like magic. Sometimes it tries the same wrong fix four times in a row. Both behaviours come out of the same structure, and you can understand that structure completely without a line of maths.


The loop: one thing at a time

Every agent is a while. It assembles a context (your request, what has happened so far, the list of available tools), sends it to the model, gets back one action, runs that action, appends the result to the history and goes back to the top. When the model answers “done” instead of asking for another action, the loop stops.

const history = [userRequest]

while (true) {
  const answer = await model.generate({ history, tools })

  if (answer.done) return answer.text

  const result = await runTool(answer.tool, answer.input)
  history.push(answer, result)
}

That's it. Every coding agent you have ever used is a twenty-line variation on this, and it is precisely because it is so little that it works so well on so much.

Now notice two properties of that code, because everything else in this piece falls out of them. First: on each turn there is exactly one thing to do. Not two. The model returns one action, you run it, you go back to the top. Second: the thing choosing that action is the model. There is no function in your code saying “step 3 is next”. the decision comes from inside an inference you cannot inspect and cannot reproduce exactly.

Those two properties produce the three problems you have already run into, even without having names for them:

The order only exists in the conversation
You asked for “edit the file, then run the tests”. Nothing in the system stops it running the tests first. The dependency is a sentence in the context, not a rule in the runner. With a short context it gets it right; with a long one, it forgets.
Nobody decided how many attempts
The test failed. Retry? Try a different approach? Replan? The model decides on the spot, and there is no maximum written down anywhere. This is where the agent that insists on the same wrong fix until you kill the process comes from.
The plan gets overwritten
It planned A, switched to B halfway through, and plan A is now an old message buried in the history. Weeks later, when you want to know which plan produced that strange commit, there is no way to answer.

Harness: everything that isn't the model

A harness is the seatbelt for someone doing something risky. The name fits. The model is the engine. The harness is the rest of the car: the loop, which tools you hand it, what goes into the context, how many attempts it gets, when to give up.

The distinction matters for a practical reason. You don't control the model: it is a third-party service you call over HTTP. The harness is entirely your code. When an agent of yours misbehaves, the odds of the problem living in the harness are far higher than the odds of it living in the model. And four lines of harness handle most of the previous section:

const MAX_STEPS = 40
const MAX_ATTEMPTS = 3
const HISTORY_TURNS = 30

const history = [userRequest]

for (let step = 0; step < MAX_STEPS; step++) {
  const answer = await model.generate({
    history: history.slice(-HISTORY_TURNS),
    tools,
    maxTokens: 4096,
  })

  if (answer.done) return answer.text

  const result = await withAttempts(
    () => runTool(answer.tool, answer.input),
    MAX_ATTEMPTS,
  )

  history.push(answer, result)
}

throw new StepLimitReached(MAX_STEPS)

None of that is clever, and that is the point. MAX_STEPS turns “infinite loop” into “loop that ends”. A per-step attempt ceiling turns “insists forever” into “insists three times”. The slice(-HISTORY_TURNS) turns “the conversation grows until it blows the context window” into “the conversation has a maximum size”. And maxTokens turns “the bill is a surprise at the end of the month” into “the bill has a ceiling”.

If you take one thing from this piece, take this: almost every hobby agent is missing those four lines, and almost every production agent has them. Before you change architecture, tighten the harness.


The graph: writing the plan before you start

The loop decides the next step along the way. The alternative is to decide every step before starting, write that decision in a format your code can read, and then just run what is written.

That format is a graph. If the word puts you off, swap it for one you already use every week: it is a CI pipeline. In GitHub Actions you write jobs and put needs: build on one of them. That is a graph: boxes, and arrows saying “this one only starts after that one”. A Makefile is the same idea. So are your package.json dependencies.

The technical name is a DAG, and the three parts are worth unpacking because each one carries a guarantee. Graph: boxes joined by arrows. Directed: the arrows have a point, so A → B is not B → A. Acyclic: no arrow ever loops back. That last one isn't vocabulary trivia: it is the structural guarantee that execution terminates. With no cycle, there is no path that can repeat forever.

In practice, the plan is an array:

const plan = [
  { id: "search_auth",  needs: [] },
  { id: "search_utils", needs: [] },
  { id: "read_auth",    needs: ["search_auth"] },
  { id: "read_utils",   needs: ["search_utils"] },
  { id: "analyze",      needs: ["read_auth", "read_utils"] },
  { id: "fix_a",        needs: ["analyze"] },
  { id: "fix_b",        needs: ["analyze"] },
  { id: "update_docs",  needs: ["analyze"] },
  { id: "run_tests",    needs: ["fix_a", "fix_b"], waitFor: "any" },
  { id: "report",       needs: ["run_tests", "update_docs"] },
]

Notice what changed. needs is not a sentence asking for good behaviour: it is data. The runner reads needs and simply does not dispatch analyze before read_auth and read_utils have finished. There is nothing to “forget”: the dependency stopped being the model's memory and became a condition in an if.

And a win appears that the loop cannot have at all. On each round the runner takes every step whose dependencies are done, not just one. If two steps have no arrow between them, they run together:

function readySteps(plan, settled) {
  return plan.filter((step) => {
    if (settled.has(step.id)) return false

    return step.waitFor === "any"
      ? step.needs.some((id) => settled.has(id))
      : step.needs.every((id) => settled.has(id))
  })
}

while (settled.size < plan.length) {
  const batch = readySteps(plan, settled)
  if (batch.length === 0) throw new NothingReady()

  await Promise.all(batch.map(run))
}

That is four lines of filter and one Promise.all. The same task the loop does in eleven serial turns comes out in six rounds:

  1. search_authsearch_utils
  2. read_authread_utils
  3. analyze
  4. fix_afix_bupdate_docs
  5. run_tests
  6. report
Six rounds instead of eleven turns. The two searches have no dependency between them, so they run together. And that isn't the model having a good day, it's what the array says.

Wait for all, or wait for one

When a step depends on two others, “depends” can mean two rather different things. Mixing them up is an expensive bug, and the loop has no way to express the second one.

Wait for all. report needs the tests and the docs. It only starts once both are done. This is the common case, and the default.

Wait for one. fix_a and fix_b are two alternative fixes for the same bug. run_tests needs one of them. If fix_b works, fix_a has stopped mattering, and the right move is to mark it as skipped, not to keep retrying it until the retry budget is gone on a path nobody will use.

In the loop, that second situation cannot be written down. The model tries A, fails, decides to try B, and giving up on A is a sentence in the history. In the graph it is a field: waitFor: "any". That is the difference between agreeing something and hoping someone remembers it.


When it fails: a three-rung ladder

The most annoying agent symptom is the one that spins: failed, replanned, failed, replanned, and forty thousand tokens later it is exactly where it started. This happens because “what to do when it fails” was delegated to the model, which has a strong bias towards trying something else rather than trying again.

The fix is to take that decision away from it and turn it into three fixed rungs:

  1. Try againSame step, same configuration. This is for the transient stuff: network blip, rate limit, timeout. Cheap.
  2. Adjust the stepSame step, different configuration: another prompt, another model, another tool. The structure of the plan stays intact.
  3. Redo the planGenerate a new plan from scratch. Expensive, slow, and the only rung that can fix a plan that was wrong from the start.

And the rule that makes the ladder work: you cannot skip a rung. Rung 3 only after 1 and 2 are exhausted. That is half a dozen lines: one counter per step, which only ever goes up by one:

const LADDER = ["retry", "patch", "replan"]

function nextAction(stepId) {
  const rung = attempts.get(stepId) ?? 0

  if (rung >= LADDER.length) throw new GaveUp(stepId)

  attempts.set(stepId, rung + 1)
  return LADDER[rung]
}

It isn't elegant and it doesn't need to be. The point is that there is now a place in your code that says how many times the agent may try before escalating, and that place is not a prompt.


The plan doesn't change mid-flight

A plan that can be edited during execution looks like flexibility and is, in practice, a debugging problem. If the agent changed the plan halfway and something went wrong later, you cannot tell whether the original plan, the change, or the interaction between them is at fault, because neither one exists in one piece any more.

The convention that solves this is one you already use daily: a commit. The plan has a version. During execution, nobody edits it. If it has to change, you generate version 2 and record that version 1 was abandoned and why. Every line of the execution log says which version was governing at that moment.

The cost is real: you lose the ability to adjust the plan with what you just discovered without paying for a whole replan. The gain is being able to answer “which plan produced this?” weeks later. On an exploratory task the cost outweighs the gain. On anything that touches production, it is the other way round.


Where the graph is worse

A graph is not the upgrade to a loop. It is a different choice with different arithmetic. Four situations where it loses, and all four are worth knowing before you rewrite anything:

The plan is only as good as whoever writes it

The parallelism only exists if someone drew the right arrows. If the planner writes a straight line (1 → 2 → 3 → 4), the graph runs one thing at a time, exactly like the loop, with far more code in the way. And the thing usually writing the plan is an LLM, which gets it wrong. The whole speed win lived in the structure, and the structure is not guaranteed.

Errors in parallel cost more

In the loop, if the model gets it wrong on turn 4 it often notices on turn 5 and corrects: one wasted step. In the graph, if the plan is missing an arrow, three branches run at once on top of the wrong premise. You parallelised the waste.

Exploratory work doesn't fit in a diagram

“Investigate the outage and fix whatever is broken.” You cannot list the steps upfront, because step 3 depends on what step 2 finds. A static graph cannot express that. A loop expresses it naturally: it is literally what a loop does.

The code is an order of magnitude bigger

An honest loop with a tight harness fits in a few hundred lines. A real graph runner has to validate the plan (any cycles? any step that never runs?), schedule in parallel while respecting rate limits, persist state for auditing, implement the recovery ladder and validate each step's output. That is a few thousand lines, and every one of them is yours to maintain.


How to choose

Practical order, cheapest first:

  1. Loop with a tight harnessThe right answer most of the time. Step ceiling, per-step attempt ceiling, a window on the history, a token ceiling. One afternoon of work, and it clears most of the symptoms that make people want to change architecture.
  2. Loop with a plan in the promptThe model writes the steps first and you keep them visible in the context. It helps the model not get lost on long tasks, but it is still one thing at a time: a plan in a prompt is a suggestion, not a rule, and it parallelises nothing.
  3. An actual graphWhen all three are true at once: you know the dependencies before starting, there is real parallelism to win, and someone will need to audit what happened afterwards. If only two are true, it probably isn't worth the cost.

The middle option is the most common trap. “My agent plans before it acts” sounds like a graph and isn't: if execution still asks the model for one action at a time, you improved the quality of the decisions and changed nothing structural. Parallelism and guaranteed ordering only show up once the plan leaves the prompt and becomes data the runner reads.


The short version

A loop is one thing at a time, chosen by the model. A graph is several things at a time, chosen by the structure. The harness is your code around both, and it is where most of an agent's quality lives, whichever of the two you pick.

If your agent is burning money or spinning in place, that's a harness problem and you can fix it today. If it's slow because it does unrelated things one after another, then it's worth looking at a graph.

As always, feel free to reach out if you have any questions on X.