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:
- IDs are unique and dependencies exist.
- The dependency graph has no cycle.
- Step count fits the remaining budget.
- Every named tool is currently available and authorized.
- High-impact steps route to approval.
- Acceptance criteria describe observable outcomes.
- 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
| Symptom | Likely cause | Corrective control |
|---|---|---|
| Plan changes every iteration | Goal or criteria are underspecified | Freeze criteria; ask the user about material ambiguity |
| Reviewer always approves | Vague rubric or evaluator bias | Require evidence IDs; calibrate against labeled failures |
| Repeated identical tool calls | Results are not persisted or compared | Stable call IDs, state updates, duplicate detection |
| Cost rises without coverage | Too many revisions or context growth | Hard budgets, compaction, per-phase packets |
| Write happens twice | Blind retry after ambiguous timeout | Idempotency key and reconciliation |
| Unsafe step reaches executor | Authorization lives only in prompt | Deterministic 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.