Skip to content
Journal
9 min readAta Mohammadi

Deterministic Multi-Agent Orchestration: Escaping the Infinite ReAct Loop

Autonomous coding agents fail in a predictable way: context poisoning inside a freeform reason-act loop. The fix is not a better prompt — it is a finite state machine with programmatic gates, scoped tool access per phase, and automatic rollback.

You give an autonomous coding agent a task that sounds small: "Add an authentication check to the profile screen, update the tests, and verify the build."

Forty minutes and a meaningful amount of API credit later, the working tree has changes in fourteen files you never mentioned, an import of a package that does not exist, a lint rule disabled to silence an error the agent introduced, and a commit message reading fix: minor updates.

The reflex is to blame the model. It is almost never the model. It is the architecture: a freeform reason-act loop with no rigid guardrails, in which the model decides not only how to do the work but what phase of the work it is in.

Probabilistic autonomy is a liability. Deterministic orchestration is the fix.

The failure mode has a name

The naive agent loop is three steps on repeat:

Thought → Action → Observation → Thought → …

It works beautifully right up until something fails. Then the failure output — a stack trace, a type error, a red test — is appended to the context window. And now the model is reasoning about a context that contains its own mistake.

This is context poisoning, and it compounds. Each failed attempt adds more failure text, which raises the probability of the next attempt being a variation on the failure rather than a fresh approach. The observable symptom is an agent that stops fixing root causes and starts monkey-patching syntax: casting to any, adding @ts-expect-error, wrapping the failing call in a try/catch that swallows the error.

[agent writes code]
       │
       ▼
[compiler rejects it]
       │
       ▼
[error appended to context] ──► [context drift increases]
       │                                   │
       ▼                                   ▼
[agent writes a workaround] ◄──────────────┘

Three things are wrong here structurally, and no prompt fixes any of them:

  1. The model decides when it is done. So "done" is a probabilistic judgement, not a verified fact.
  2. The model has every tool at every moment. So the planning step can edit files and the verification step can rewrite the thing it is verifying.
  3. Failure accumulates in the same context that produces the next attempt. So retries get worse, not better.

The architecture: four roles, four gates

Replace the single generalist with four narrow agents and put a programmatic gate between each pair. The model supplies code generation and judgement. A finite state machine supplies control flow.

┌──────────────┐   plan parses     ┌──────────────┐
│  Architect   │ ────────────────► │ Implementer  │
│  read-only   │                   │ scoped write │
└──────────────┘                   └──────────────┘
                                          │
                                   tests + types pass
                                          │
                                          ▼
┌──────────────┐  diff within      ┌──────────────┐
│  Committer   │ ◄──────────────── │   Auditor    │
│  git only    │    boundary       │  no LLM      │
└──────────────┘                   └──────────────┘

The Architect. Reads files and documentation, produces a structured plan: which files change, what changes in each, what "done" looks like. Constraint: read-only filesystem, no shell, no edits. A planner that can edit will edit instead of planning.

The Implementer. Receives the plan and applies minimal edits as search/replace blocks. Constraint: write access limited to the exact paths the Architect named. This one constraint eliminates the fourteen-unrelated-files failure entirely.

The Auditor. Runs the type checker, the linter and the tests. Constraint: this stage contains no model at all. It is execFile and an exit code. A non-zero exit sends a filtered, minimal error payload back to the Implementer with a hard retry cap.

The Committer. Diffs the working tree, verifies that only the planned paths changed, stages and commits. Constraint: if the diff violates the boundary, it rolls back rather than asking.

The important property is not the number of agents. It is that every transition between them is decided by code, not by the model.

An orchestrator you can read in one sitting

import { execFileSync } from "node:child_process";

type Phase = "PLANNING" | "IMPLEMENTING" | "AUDITING" | "COMMITTING" | "FAILED";

export interface Plan {
  /** Paths the Implementer is permitted to touch. Nothing else. */
  files: string[];
  /** One instruction per file, so the edit prompt stays narrow. */
  steps: Array<{ file: string; instruction: string }>;
}

export interface Agents {
  plan(task: string): Promise<Plan>;
  /** Receives the original task, one file, and (on retry) only the error. */
  edit(input: { task: string; file: string; instruction: string; error?: string }): Promise<void>;
}

export interface GateResult {
  ok: boolean;
  /** Trimmed to the part an editor can act on — never the whole log. */
  error?: string;
}

/** Programmatic gates. No model runs inside these. */
export const gates = {
  types: (cwd: string): GateResult => run("npx", ["tsc", "--noEmit"], cwd),
  lint: (cwd: string): GateResult => run("npx", ["eslint", ".", "--max-warnings=0"], cwd),
  tests: (cwd: string): GateResult => run("npm", ["test", "--silent"], cwd),
};

function run(command: string, args: string[], cwd: string): GateResult {
  try {
    execFileSync(command, args, { cwd, stdio: "pipe" });
    return { ok: true };
  } catch (error) {
    const output = String(
      (error as { stdout?: Buffer; stderr?: Buffer }).stderr ??
        (error as { stdout?: Buffer }).stdout ??
        error,
    );
    // Only the first few diagnostics. A 400-line log is how you poison a
    // context window in one step.
    return { ok: false, error: output.split("\n").slice(0, 25).join("\n") };
  }
}

export class Orchestrator {
  #phase: Phase = "PLANNING";
  #plan: Plan | null = null;
  #attempts = 0;
  #lastError: string | undefined;

  constructor(
    private readonly task: string,
    private readonly cwd: string,
    private readonly agents: Agents,
    private readonly maxAttempts = 3,
  ) {}

  async run(): Promise<boolean> {
    while (this.#phase !== "COMMITTING" && this.#phase !== "FAILED") {
      switch (this.#phase) {
        case "PLANNING":
          this.#phase = await this.#planning();
          break;
        case "IMPLEMENTING":
          this.#phase = await this.#implementing();
          break;
        case "AUDITING":
          this.#phase = this.#auditing();
          break;
      }
    }

    if (this.#phase === "FAILED") {
      this.#rollback();
      return false;
    }

    this.#commit();
    return true;
  }

  async #planning(): Promise<Phase> {
    this.#plan = await this.agents.plan(this.task);
    // A plan that does not parse is a failed plan, not a prompt to improvise.
    return this.#plan.files.length > 0 ? "IMPLEMENTING" : "FAILED";
  }

  async #implementing(): Promise<Phase> {
    const plan = this.#plan!;
    for (const step of plan.steps) {
      await this.agents.edit({
        task: this.task,
        file: step.file,
        instruction: step.instruction,
        // Clean-context retry: the original task, this file, and the error.
        // Never the transcript of the previous failed attempt.
        error: this.#lastError,
      });
    }
    return "AUDITING";
  }

  #auditing(): Phase {
    for (const gate of [gates.types, gates.lint, gates.tests]) {
      const result = gate(this.cwd);
      if (result.ok) continue;

      this.#attempts += 1;
      this.#lastError = result.error;
      return this.#attempts >= this.maxAttempts ? "FAILED" : "IMPLEMENTING";
    }

    return this.#changedFilesWithinPlan() ? "COMMITTING" : "FAILED";
  }

  /** The boundary check. An agent that edited outside its plan does not commit. */
  #changedFilesWithinPlan(): boolean {
    const allowed = new Set(this.#plan!.files);
    const changed = execFileSync("git", ["diff", "--name-only", "HEAD"], {
      cwd: this.cwd,
      encoding: "utf8",
    })
      .split("\n")
      .filter(Boolean);

    return changed.every((file) => allowed.has(file));
  }

  #commit(): void {
    execFileSync("git", ["add", "--all"], { cwd: this.cwd });
    execFileSync("git", ["commit", "-m", `agent: ${this.task}`], { cwd: this.cwd });
  }

  #rollback(): void {
    execFileSync("git", ["reset", "--hard", "HEAD"], { cwd: this.cwd });
    execFileSync("git", ["clean", "-fd"], { cwd: this.cwd });
  }
}

Note what is not in there. No "are you sure you are finished?" prompt. No model deciding whether the tests really needed to pass. No retry that re-sends the conversation. The state machine is boring on purpose — boring is the property you are buying.

Four operational rules

1. Ban full-file rewrites

Never let an agent emit an entire 400-line file. It costs tokens proportional to the file rather than to the change, it silently drops edge-case branches it did not understand, and it makes review impossible because every line shows as modified.

Force a diff format:

<<<<<<< SEARCH
export function authenticate(user: User): boolean {
  return user.isAdmin;
}
=======
export function authenticate(user: User): boolean {
  return user.isAdmin && !user.isSuspended;
}
>>>>>>> REPLACE

The search block either matches exactly or the edit is rejected. That rejection is itself valuable: a search block that does not match means the model's picture of the file is wrong, and you want to find that out before it writes, not after.

2. Retry with a clean context, not a longer one

When the Auditor fails, the instinct is to append the error to the conversation and ask again. That is the loop you are trying to escape.

Send exactly three things instead:

  • The original task, verbatim.
  • The specific file or function that failed.
  • The raw compiler or test error, truncated.

Nothing about the previous attempt. The model cannot anchor on a hallucinated fix it cannot see, and the context stays roughly the same size on attempt three as on attempt one — which matters, because token budget is where the rest of the quality goes.

3. Run in an ephemeral worktree

Never let an agent operate on the branch you are standing on.

git worktree add ../agent-scratch -b agent/auth-fix

A worktree is a separate directory sharing one object database — cheap to create, cheap to destroy, fully isolated. Success means squash-merging the branch. Failure means git worktree remove --force and your working directory never knew it happened.

This is also the cleanest way to run several agents at once without them fighting over the index.

4. Make the gates non-negotiable and fast

The Auditor is the only part of this system that knows the truth, so its cost determines how often you can afford to check. Three practical consequences:

  • Order gates cheapest-first. Type check before lint before unit tests before integration tests. Most failures are caught by the first gate in under a second.
  • Run the narrowest scope that is still sound. vitest related <files> over the changed paths, not the whole suite, until the final pass.
  • Cap retries low. Three is generous. An agent that has failed the same gate three times is not one attempt away from success; it is in a loop, and the correct action is to stop and hand a human the diff plus the error.

Where this leaves the model

None of this makes the model smarter. That is the point. It makes the system reliable despite the model being probabilistic, in the same way a database is durable despite disks being unreliable — by putting the guarantee in the protocol rather than in the component.

An AI coding agent is not an autonomous senior engineer. It is a fast, probabilistic code generator with no memory of what it broke. Treat it like an untrusted worker node in a pipeline you control: constrain its context, scope its tools by phase, verify with code rather than with judgement, and roll back automatically when verification fails.

Do that and the output stops being a forty-minute mess and starts being something you can review in five.


Part of a series on the highest-friction problems in modern software engineering.

Tell us what is broken or what should exist.

Send the shape of the problem and any constraints you already know — budget, deadline, the stack you are stuck with. You will get a written reply from the engineer who would do the work, not a sales sequence.