-
Pick the simplest topology that works — escalate only when forced. Default down this ladder, not up.
| Pattern | Use when | Control flow | Failure mode to fear |
|---|
| Single-agent tool loop | One goal, <~15 steps, work fits one context | Model decides each next tool | Infinite loop / drift |
| Code-orchestrated workflow (DAG) | Steps + order are known ahead of time | Your code sequences; model fills each node | Rigid; can't adapt mid-run |
| Multi-agent (planner + subagents) | One context literally can't hold the work, or independent parallel branches | Orchestrator spawns sub-loops, merges summaries | Coordination cost, lost context at handoff |
Start with the loop. Move to a coded DAG the moment the step graph is fixed (cheaper, deterministic, testable). Reach for multi-agent only when a single context window can't hold the task — never for "it feels big."
-
Make the loop terminate — two independent kills. Every loop needs BOTH a semantic stop and a hard cap. One alone is insufficient: the model's "I'm done" can be wrong, and a raw cap truncates good work.
MAX_STEPS, MAX_USD, t0 = 12, 0.50, time.time()
for step in range(MAX_STEPS):
msg = model(messages, tools)
if msg.stop_reason == "end_turn":
return msg.text
result = run_tool(msg.tool_call)
messages += [msg, tool_result(result)]
if spent_usd() > MAX_USD or time.time()-t0 > 120:
return escalate("budget exceeded", messages)
return escalate("max steps reached", messages)
Also detect a no-progress loop: hash (tool_name, args); if the same call repeats ≥2× with no state change, break and replan — don't wait for MAX_STEPS.
-
Carry state in an explicit scratchpad — not the raw message list. The growing transcript is not memory; it's noise that costs tokens and dilutes the goal. Keep a small structured state object the loop reads/writes every turn:
{"goal": "<one immutable sentence>", "facts": [...], "open_questions": [...],
"done": ["fetched X", "parsed Y"], "next": "draft summary", "artifacts": {"pr_url": null}}
Re-inject goal + next into the prompt every step (anti-drift re-grounding). When the transcript grows large, summarize old turns into facts/done and drop the raw tool dumps — keep state, discard chatter.
-
Verify each step's output before continuing — gate, don't trust. After every tool result, check it actually advanced the goal before the model plans the next move. Cheapest sufficient check wins:
- Schema/shape check on tool output (parses? non-empty? expected fields?) — pure code, no model.
- Goal-relevance check: "does this result move us toward
goal, or sideways?" If sideways → discard and re-ground, don't append it as progress.
- For generated artifacts (code, SQL, configs): run the real check (compile/lint/
pytest/dry-run), not a model's self-assessment. A failing check feeds back into the loop as a tool result.
-
Retry then replan on tool failure — distinguish transient from logical. Tool error ≠ retry-forever.
- Transient (timeout, 429, 5xx): bounded retry with backoff — but that's transport reliability; delegate it to harden-llm-app-reliability, don't re-implement here.
- Logical (bad args, "not found", validation reject): do not retry the identical call. Feed the error text back as an observation and let the model replan (fix args, pick another tool, or revise the plan). Cap replans (≤2) per subgoal; exceeding it → escalate, don't thrash.
-
Decompose to subagents only when one context can't hold the work — and return summaries, not dumps. A subagent gets a narrow objective, its own fresh context and tool subset, and returns a compact result (the answer + key facts + artifact refs), never its raw transcript. The orchestrator merges summaries into parent state. This is the whole point: parallel/large work happens in child contexts so the parent stays lean. If you find yourself piping a subagent's full message history back up, you've defeated it.
-
Gate irreversible actions behind explicit approval; meter spend. Classify each tool: read-only / reversible-write / irreversible (delete, deploy, send money, email customers, DROP). Irreversible tools require a human-approval checkpoint (or a strict policy allowlist) before execution — the loop pauses and surfaces the proposed action + args. Track cumulative tokens/$ per run (step 2's MAX_USD); a runaway agent is a billing incident. Emit a structured per-step trace (step #, tool, args, result-status, cost) so a stuck run is debuggable after the fact → build-audit-logging.
-
Prove it on a multi-step harness with machine-checkable success criteria — built before you ship. A loop that works on one happy-path demo proves nothing. Write the harness first: define each scenario's success oracle in code (artifact exists / pytest green / expected end-state reached / final answer matches a regex), not a model's "looks good." Cover ≥3 scenarios — one happy path, one designed-to-fail (asserts escalation, never an infinite loop), one with a distractor in tool output (asserts the goal held). Run it in CI on every change to the loop, prompt, or tool set; a passing harness is the only evidence the orchestration is reliable. Spec the checks in the Verify section below.
Done = on the multi-step harness the agent finishes happy-path tasks via the semantic stop, every failure/runaway path escalates within the step+budget caps (no infinite loops, no partial-as-success), each step is verified and re-grounded on the goal, irreversible actions are gated, and subagents return summaries — all evidenced by the per-step trace.