AI agents · Architecture · TypeScript

Build a Plan–Execute–Review Agent Without Infinite Loops

Implement an agent loop with typed plans, evidence-based review, bounded revision, explicit stop states, and safe tool execution.

Published
Updated
Code examples
Illustrative
Reading time
5 minutes

A safe plan–execute–review agent is a bounded state machine: the model proposes a typed plan, deterministic code validates it, tools execute authorized steps, and a reviewer checks observable evidence against acceptance criteria. The orchestrator—not the model—enforces retries, budgets, approvals, and terminal states.

This pattern helps multi-step tasks that benefit from revision. It is unnecessary for a simple answer or one idempotent tool call.

Separate responsibilities before writing prompts

Use four components:

  • Planner: turns the goal and current state into a small set of proposed steps.
  • Validator: rejects malformed, unauthorized, circular, or over-budget plans.
  • Executor: invokes allowed tools and records results with stable identities.
  • Reviewer: compares the artifact and evidence with explicit acceptance criteria.

The ReAct paper studied interleaving model-generated reasoning and actions, showing why observations can update a plan rather than forcing all reasoning to happen before execution. Its reported results apply to its specified benchmarks and models; they are not evidence that every production agent should expose or persist free-form reasoning traces. Yao et al., ReAct

Persist concise, observable decisions: proposed steps, tool calls, evidence, validation errors, and review findings. Do not require or store hidden chain-of-thought.

Make the state machine explicit

type Status =
  | "planning"
  | "validating"
  | "executing"
  | "reviewing"
  | "revising"
  | "awaiting_input"
  | "awaiting_approval"
  | "succeeded"
  | "failed"
  | "cancelled";

interface Limits {
  readonly maxRevisions: number;
  readonly maxSteps: number;
  readonly maxModelCalls: number;
  readonly maxToolCalls: number;
  readonly maxTotalTokens: number;
  readonly deadlineAt: string;
}

interface RunState {
  readonly id: string;
  readonly status: Status;
  readonly revision: number;
  readonly modelCalls: number;
  readonly toolCalls: number;
  readonly consumedTokens: number;
  readonly limits: Limits;
}

Every transition should be enumerated in code. reviewing → revising is allowed only when the revision budget remains. awaiting_approval → executing is allowed only for the exact proposed action that was approved. No model response can transition a terminal run back to execution.

The durable-agent guide covers crash recovery, idempotency, and parameter-bound approval for workflows that outlive one process.

Define a plan as data

The plan is not an essay. It is a proposed dependency graph with acceptance criteria:

interface PlanStep {
  readonly id: string;
  readonly objective: string;
  readonly dependsOn: readonly string[];
  readonly tool?: string;
  readonly acceptanceCriteria: readonly string[];
  readonly expectedEvidence: readonly string[];
  readonly risk: "read" | "reversible_write" | "high_impact";
}

interface Plan {
  readonly goal: string;
  readonly assumptions: readonly string[];
  readonly steps: readonly PlanStep[];
}

Validate before execution:

  1. IDs are unique and dependencies exist.
  2. The dependency graph has no cycle.
  3. Step count fits the remaining budget.
  4. Every named tool is currently available and authorized.
  5. High-impact steps route to approval.
  6. Acceptance criteria describe observable outcomes.
  7. Assumptions that materially change the result route to user input.

A model may return schema-valid nonsense. Validation proves shape and local invariants, not factual correctness.

Execute one ready step at a time first

Sequential execution is easier to debug and safer while the workflow is new. Add parallel execution only for independent, read-only steps after evaluation demonstrates a latency benefit. The bounded-parallelism tutorial explains how to enforce concurrency and retry limits.

interface StepResult {
  readonly stepId: string;
  readonly status: "completed" | "failed" | "outcome_unknown";
  readonly evidence: readonly {
    source: string;
    digest?: string;
    summary: string;
  }[];
  readonly toolCallId?: string;
  readonly errorCode?: string;
}

async function executeReadyStep(
  step: PlanStep,
  signal: AbortSignal,
): Promise<StepResult> {
  if (step.risk === "high_impact") {
    throw new Error("Approval is required before execution");
  }

  // Resolve the tool through an authorization layer. Never let the
  // model supply arbitrary code or a credential-bearing endpoint.
  return invokeAuthorizedTool(step, signal);
}

Use an idempotency key for any side effect. If a write times out after submission, record outcome_unknown and reconcile with the external system before retrying. A timeout does not prove that nothing happened.

Review evidence, not confidence

Give the reviewer the goal, acceptance criteria, produced artifact, and evidence manifest. Do not ask “Is this good?” Ask for typed findings:

interface Review {
  readonly verdict: "accept" | "revise" | "blocked";
  readonly findings: readonly {
    criterion: string;
    status: "met" | "not_met" | "not_verifiable";
    evidenceIds: readonly string[];
    correction?: string;
  }[];
}

The reviewer must not mark a criterion met without a corresponding artifact or evidence identifier. Its verdict is still a model judgment; calibrate it against human labels and deterministic checks. For code, tests and static analysis should outrank reviewer prose. For cited research, a source checker should verify that links resolve and actually support the adjacent claim.

Using the same model as planner and reviewer may reproduce the same blind spot. A different prompt or model can add diversity, but independence of model calls is not independence of evidence. The decisive controls are external tests, source inspection, and human review for consequential outcomes.

Stop loops in code

An agent can keep “improving” indefinitely if completion is subjective. Define terminal rules before the first call:

function nextStatus(state: RunState, review: Review): Status {
  if (review.verdict === "accept") return "succeeded";
  if (review.verdict === "blocked") return "awaiting_input";

  if (state.revision >= state.limits.maxRevisions) return "failed";
  if (state.modelCalls >= state.limits.maxModelCalls) return "failed";
  if (state.toolCalls >= state.limits.maxToolCalls) return "failed";
  if (Date.now() >= Date.parse(state.limits.deadlineAt)) return "failed";

  return "revising";
}

Also stop when two reviews repeat the same unresolved finding, the required capability is unavailable, authorization is denied, or the outcome of a side effect is ambiguous. Return a structured incomplete result explaining what remains; do not polish failure into apparent success.

OWASP lists denial-of-wallet, excessive autonomy, recursive tool abuse, approval manipulation, and cascading failures among agent risks. Its recommended tests explicitly include chain depth, retry, token, and cost limits. OWASP AI Agent Security Cheat Sheet

Control context between phases

Do not pass every planner draft and tool transcript to every subsequent call. Give each phase the minimum required packet:

  • planner: goal, current state, constraints, relevant evidence;
  • executor: one validated step and authorized capability handles;
  • reviewer: goal, criteria, artifact, and evidence manifest;
  • reviser: failed criteria and allowed correction scope.

This both limits cost and reduces the chance that untrusted tool output becomes an instruction. The context-engineering guide provides a typed assembler for provenance and token budgets.

Failure modes and diagnosis

SymptomLikely causeCorrective control
Plan changes every iterationGoal or criteria are underspecifiedFreeze criteria; ask the user about material ambiguity
Reviewer always approvesVague rubric or evaluator biasRequire evidence IDs; calibrate against labeled failures
Repeated identical tool callsResults are not persisted or comparedStable call IDs, state updates, duplicate detection
Cost rises without coverageToo many revisions or context growthHard budgets, compaction, per-phase packets
Write happens twiceBlind retry after ambiguous timeoutIdempotency key and reconciliation
Unsafe step reaches executorAuthorization lives only in promptDeterministic tool-policy middleware

Production checklist

  • Represent plans, results, reviews, and state as validated data.
  • Reject cycles, unavailable tools, and steps beyond budget.
  • Keep authorization and transition logic outside the model.
  • Bind approvals to canonical action parameters.
  • Record evidence and preserve incomplete coverage.
  • Reconcile ambiguous side effects before retrying.
  • Enforce revision, call, token, and time ceilings.
  • Calibrate reviewers against deterministic checks and human labels.
  • Test hostile tool output and approval bypass attempts.
  • Prefer a simpler one-pass workflow when revision adds no measured value.