Many Hands, One Pen: Seven Rules for Orchestrating AI Agents
Seven rules for orchestrating AI agents: when a plain workflow wins, why the orchestrator holds no tools, typed subagent returns and written stop conditions.
This article is published in English.
Sources: Many Hands, One Pen: Orchestrating Agents Without Losing the Plot
Picture the US Navy in 1942, facing a coordination headache that says a lot about modern software. Warships had gained radar, sonar, radio intercepts and human lookouts, and every report hit the bridge simultaneously and in random order. A captain trying to fight a battle was buried under unprocessed signals.
The Navy's answer was a windowless room called the Combat Information Center. Each sensor report was marked on a single common plot using one agreed format. Operators who read instruments didn't command anything; their job was to keep the plot accurate, while one officer alone chose the ship's next move. Information fanned in broadly, decisions narrowed to a point, and the standard report format meant entries could be compared with each other.
Multi-agent systems keep rediscovering that room. The phase in which "add another agent" was the answer to everything is over, and 2026 has largely been spent measuring what that answer cost. The pattern that survives is the one the Navy landed on: gather in parallel, report in a disciplined format, decide in one place and define done explicitly.
A useful frame: most multi-agent problems aren't really agent problems. They're context problems and control-flow problems in an agent costume. A run usually goes wrong because two agents made incompatible decisions neither could see, because information lost its meaning crossing a boundary, or because nobody wrote down what finished looks like.
Seven practices follow, each drawn from failures in systems that were already shipping. Together they help you judge when a plain workflow beats an agent, when an orchestrator pays for its tokens, how to split work along context boundaries, why the orchestrator should keep its hands off the tools, how to give subagents a return contract you can validate and and how to end a run on purpose, not on the model's whim.
Default to plain workflows; autonomy has to be earned
What goes wrong. Take a refund process with four outcomes, each already covered by a written rule. A team wraps it in an autonomous loop, six tools and a goal string. Suddenly Tuesday's ticket follows a different path from Monday's, and each run costs somewhere between ten and fifty times the old code. Accuracy stays flat, because no part of the flow was ambiguous enough to need intelligence.
The remedy. Draw the flowchart first. Everything you can chart ahead of time belongs in ordinary control flow, so plain code takes each branch you can name. Call the model only at the step where the input is really open to interpretation. An agent should win authority over a branch only when you've shown that the branch can't be written down.
REFUND_LIMIT = 50.00
def llm_step(prompt: str) -> str:
"""Called only where the written rules run out."""
return model.complete(prompt).strip().lower()
def handle_refund(ticket):
if ticket.days_since_purchase > 30:
return deny(ticket, reason="outside window")
if ticket.amount <= REFUND_LIMIT:
return approve(ticket) # a rule, not a judgement call
intent = llm_step(
f"Classify as fraud, defect, or remorse:\n{ticket.body}"
)
if intent == "fraud":
return escalate(ticket, queue="risk")
if intent == "defect":
return approve(ticket)
return route_to_human(ticket)
The catch. You can overshoot into a thicket of nested conditionals that quietly encodes judgement rules can't actually make. When you're writing a fifth condition against free text, that's the ambiguity the model exists for.
Skip the orchestrator when the breakdown is already known
What goes wrong. A four-step report pipeline gets wrapped in an orchestrator loop capped at fifteen passes. Every pass spends a planning call on deciding what to do next, and each call reaches the answer you'd already settled: fetch, extract, summarise, format. That's fifteen reasoning calls at a few thousand tokens apiece to rediscover a list you could type in five lines.
The remedy. Use a quick rule before coding anything. When the full list of subtasks is known before the run starts, execute it as a simple loop with fixed dispatch and keep the tokens for something else. Model-driven orchestration belongs where the breakdown is itself unknown, or must evolve based on what earlier steps found.
# Before: up to 15 planning calls to rediscover a fixed list
def run_orchestrated(doc):
state = {"doc": doc}
for _ in range(15):
decision = orchestrator.plan(state) # 1 LLM call per pass
if decision.action == "finalize":
break
state = WORKERS[decision.worker](state)
return state
# After: 0 planning calls, same four workers, same result
PIPELINE = [fetch, extract, summarize, format_report]
def run_static(doc):
state = {"doc": doc}
for step in PIPELINE: # 0 LLM calls here
state = step(state)
return state
The catch. This advice has a flip side. A pipeline that grows a fresh special case every month, or where the output of step two truly decides whether step three runs, is doing real planning. Removing the planner there merely relocates the branching into some config file that no one will look after.
Divide agents by context, not by job title
What goes wrong. The org-chart version of multi-agent looks tidy: planner, implementer, tester, reviewer, working one feature in sequence. In practice every handoff drops assumptions nobody wrote down. The tester checks behaviour the implementer never meant, and the reviewer objects to a choice the planner made deliberately. You pay four times for telephone-game decay, and one agent doing all four jobs would have done better.
The remedy. Cut along context lines. Prior to adding a second agent, put one question to the design: could this subtask finish inside a context window that has nothing in common with its neighbour's? When the answer is no, both belong with one agent, which is exactly why the agent building a feature ought to write the tests for it as well.
from dataclasses import dataclass
@dataclass
class Subtask:
name: str
needs: set[str] # facts this subtask must read
produces: set[str] # facts this subtask decides
def should_split(a: Subtask, b: Subtask) -> bool:
"""Split only when neither side needs what the other decides."""
shared = (a.needs & b.produces) | (b.needs & a.produces)
return not shared
implement = Subtask("implement", {"spec"}, {"api_shape", "error_semantics"})
test = Subtask("test", {"spec", "api_shape", "error_semantics"}, {"cases"})
assert not should_split(implement, test) # one agent writes both
The catch. Truly isolated subtasks are rarer than an org chart implies, so the test will often say "one agent", and that's the right answer, not a lack of imagination. Splitting pays off for breadth work, such as five agents reading five unrelated sources, because nothing one decides alters what another needs.
Let the orchestrator output a decision and never call a tool
What goes wrong. An orchestrator gets a search tool for emergencies. Soon it quits handing work out and starts answering subtasks on its own. Raw tool output piles into its window, so its planning gets worse right as the run lengthens and planning matters most. Traceability goes too, since it's unclear which agent produced which finding.
The remedy. Whatever plans shouldn't also fetch, so give the orchestrator zero tools. All it emits is one compact, typed decision: delegate, replan or finalize, along with the worker to use and the task text. A surrounding loop reads that decision, runs the named worker and stores the result in shared state, ready for the next decision.
from typing import Literal
from pydantic import BaseModel
class OrchestratorDecision(BaseModel):
next_action: Literal["delegate", "replan", "finalize"]
target_worker: str | None = None
task_description: str | None = None
reasoning: str
def orchestrator_step(state):
d = decide(state) # model has zero tools attached
if d.next_action == "finalize":
return finalize(state, d.reasoning)
if d.next_action == "replan":
return state.reset_plan(d.reasoning)
worker = WORKERS[d.target_worker] # workers own every tool
result = worker.run(d.task_description)
return state.record(d.target_worker, result)
The catch. With no tools, an orchestrator sees only what workers send back. A worker that returns a thin summary leaves it planning blind. Offer a read-only window onto shared state instead of live tool access, and keep worker returns structured enough to plan from.
Hold each subagent to a typed return contract
What goes wrong. Consider a research subagent that returns a 2,500-word diary of everything it attempted, failed paths included. Multiply by seven workers and about 20,000 tokens drop into the parent's window before any synthesis starts. Delegation was supposed to shield that window. Instead the exploration is paid for twice, and the parent follows instructions worse exactly when it counts.
The remedy. Think of the return as an API boundary, with a response schema on every subagent. Its closing message is JSON holding named fields: findings, sources, unanswered questions and a completion status. Check it as it arrives, and bounce anything malformed or too long back for a retry so it never reaches the parent.
from pydantic import BaseModel, Field, ValidationError
class SubagentResult(BaseModel):
findings: list[str] = Field(max_length=5) # hard cap, one sentence each
sources: list[str]
open_questions: list[str] = []
completion_status: str # complete | partial | blocked
def parse_or_retry(worker, task, attempts=2):
for _ in range(attempts):
raw = worker.run(task, response_format=SubagentResult)
try:
return SubagentResult.model_validate_json(raw)
except ValidationError as err:
task = f"{task}\n\nRejected: {err}\nReturn only JSON in the schema."
raise RuntimeError(f"{worker.name} returned no valid result")
The catch. Schemas set a minimum and nothing more. A worker can still stuff a 400-word paragraph into one findings string and deliver identical bloat dressed as JSON. Limit both the number of items and each field's length, and have the rejection text name the field that overflowed, so the retry knows what to fix.
Parallelise reads and funnel writes through one agent
What goes wrong. Two agents work on one feature at the same time and each reports success. One drew a Super Mario style backdrop while the other drew a Flappy Bird sprite, and both pass tests written for their own private picture of the design. Merging can't resolve choices neither agent knew existed, so one result gets thrown out, or both.
The remedy. Reading can run in parallel; writing can't. Limit every worker to a read-only tool allowlist, which lets the gathering phase grow as wide as needed, and never give a worker any write tool. The artifact is then produced in one unconditioned call that receives every worker's results together.
import asyncio
READ_TOOLS = ["search_repo", "read_file", "fetch_docs"]
async def gather_context(subtasks):
workers = [Agent(name=t.name, tools=READ_TOOLS) for t in subtasks]
return await asyncio.gather(
*(w.run(t.prompt) for w, t in zip(workers, subtasks))
)
async def build_feature(spec, subtasks):
findings = await gather_context(subtasks) # wide, parallel, read-only
writer = Agent(name="writer", tools=["write_file", "apply_patch"])
return await writer.run(spec, context=findings) # one writer, one pass
The catch. That single writer is a bottleneck and has to carry all the workers' results at once, so you'll run into the token budget long before a logic limit. If the artifact really does split cleanly, as with three files that share no interface, assign each file its own writer and keep the same discipline inside each one.
Write termination conditions down
What goes wrong. A run finishes, no error appears and the job is unfinished. An analysis of 1,600 multi-agent traces over seven frameworks put 12.4% of failures down to agents unaware of their stopping condition, with 6.2% more halting prematurely. Nearly one failure in five therefore shows up as a tidy exit carrying a half-done result.
The remedy. Turn the finish line into something concrete instead of a feeling. Give each task an explicit completion checklist, and place an approval check, measured against the original objective, on the only route into the terminal state. In that same research, the verification gate reportedly lifted ChatDev's success by 9.4%, and checking high-level objectives lifted it by 15.6%.
CHECKLIST = [
"every requested section exists",
"each claim cites a retrieved source",
"open questions are listed, or explicitly none",
]
def verify_complete(objective, checklist, output) -> bool:
for item in checklist:
verdict = judge(f"Objective: {objective}\nCheck: {item}\n\n{output}")
if not verdict.passed:
log.info("termination blocked by: %s", item)
return False
return judge(f"Does this satisfy the objective?\n{objective}\n\n{output}").passed
def finish(state):
if verify_complete(state.objective, CHECKLIST, state.draft):
return state.done()
return state.keep_working(reason="checklist not satisfied")
The catch. Let the producing model grade its own checklist and it will wave its work through. Prefer mechanical items wherever possible, such as a file being present, a field being filled or a citation resolving. A gate that never opens is its own kind of outage, so cap the attempts and raise an error once they're spent.
Where to begin
If you only change one thing, pick the cheapest: define what finished means in writing. A checklist plus a verification step on the way into the terminal state takes an afternoon, works for one agent as well as a fleet and eliminates an entire class of silent failure. Make it this week's job.
Next comes a removal. Examine your orchestrator and test whether its choices reduce to a fixed list and an aggregation function. If so, take it out; token spend and latency drop and the results hold steady. Since it's simple to undo, it makes a safe first cut.
Hold back the heavier machinery until it hurts. Typed return contracts pay off from about five workers upward, and the read-only allowlist matters when two agents are truly writing simultaneously. The exception is the tool-free orchestrator: nearly free at the start and painful to add afterwards.
The setups that hold up in 2026 don't have the most agents. In them, the joints between agents are the smallest and most explicit part of the design. Fewer hands, firmer contracts, one pen.