So far we've been building a single agent and giving it more and more capabilities — tools, retrieval, memory. But there's a limit to how far that approach takes you.
The more instructions and tools you pile into one agent, the more its behaviour degrades. The context gets crowded, tool selection gets less reliable, and the system prompt turns into a mess of competing concerns. At some point, a single generalist agent is just not the right tool.
The solution is the same one software engineering figured out a long time ago — specialisation and separation of concerns.
The idea behind multiple agents
Instead of one agent that does everything, you build a team of agents where each one has a single, narrow job. A planner that only plans. An executor that only executes. A reviewer that only reviews. Each one has a focused system prompt, a small set of relevant tools, and a clear boundary on what it does and doesn't do.
The handoff between them is simple — the output of one agent becomes the input of the next. No shared state, no complex APIs. Just text in, text out.
User request
│
▼
Planner ──── numbered plan ────▶ Executor ──── output ────▶ Reviewer
│
approved / needs changes
Sequential vs parallel
When agents depend on each other's output, they run sequentially — one after another, each waiting for the previous result.
Planner → Executor → Reviewer
But when two tasks are independent of each other, there's no reason to wait. You can run agents in parallel and merge their results afterward.
Research A ──▶ ┐
├──▶ Synthesiser
Research B ──▶ ┘
The general case — where some stages depend on others and some don't — is called a DAG (Directed Acyclic Graph). Some stages start immediately, others wait for their dependencies. We'll see this in Exercise 4.
The feedback loop
One of the most useful patterns is a review loop — the executor produces output, the reviewer checks it, and if it's not good enough it sends it back with specific feedback. The executor tries again. This repeats until the reviewer approves or a max revision cap is hit.
Always have the cap. A loop with an overly strict reviewer and a poorly specified task can run forever without one.
Setup
pip install ollama
No new dependencies. Use qwen2.5 for these exercises:
ollama pull qwen2.5
Exercise 1 — Sequential pipeline: Planner → Executor
Two agents, one feeds the other:
import ollama
def planner_agent(task: str) -> str:
response = ollama.chat(
model="qwen2.5",
options={"temperature": 0},
messages=[
{
"role": "system",
"content": (
"You are a planning agent. Your only job is to break down tasks into steps. "
"Output a numbered list of clear, concrete steps. "
"Each step must be self-contained and actionable. "
"Do NOT execute the steps. No explanation. Just the numbered list."
)
},
{"role": "user", "content": f"Break this task into steps:\n\n{task}"}
]
)
return response.message.content
def executor_agent(plan: str) -> str:
response = ollama.chat(
model="qwen2.5",
options={"temperature": 0},
messages=[
{
"role": "system",
"content": (
"You are an execution agent. You are given a plan as a numbered list. "
"Work through each step carefully and thoroughly. "
"For each step, describe what you did and the result."
)
},
{"role": "user", "content": f"Execute this plan step by step:\n\n{plan}"}
]
)
return response.message.content
def run_pipeline(task: str):
print(f"Task: {task}")
print("=" * 60)
print("\n[Stage 1: Planner]")
plan = planner_agent(task)
print(plan)
print("\n[Stage 2: Executor]")
result = executor_agent(plan)
print(result)
return result
run_pipeline("Write a beginner's guide to what an API is")
run_pipeline("Explain the difference between SQL and NoSQL databases")
Notice how the quality of the plan directly determines the quality of the execution. A vague plan produces vague output. A specific, well-structured plan produces specific, well-structured output. The planner's job is actually the most important one in this pipeline.
Exercise 2 — Adding a reviewer with a feedback loop
Now let's add a third agent that checks the output and can send it back for revision:
import ollama
def planner_agent(task: str) -> str:
response = ollama.chat(
model="qwen2.5", options={"temperature": 0},
messages=[
{"role": "system", "content": "You are a planning agent. Break the task into a numbered list of concrete steps. No explanation, just steps."},
{"role": "user", "content": f"Break into steps:\n\n{task}"}
]
)
return response.message.content
def executor_agent(plan: str, feedback: str = "") -> str:
content = f"Execute this plan:\n\n{plan}"
if feedback:
content += f"\n\nPrevious attempt was rejected.\nFeedback: {feedback}\nPlease fix these issues."
response = ollama.chat(
model="qwen2.5", options={"temperature": 0.3},
messages=[
{"role": "system", "content": "You are an execution agent. Work through each step carefully and produce thorough, accurate output."},
{"role": "user", "content": content}
]
)
return response.message.content
def reviewer_agent(task: str, output: str) -> dict:
response = ollama.chat(
model="qwen2.5", options={"temperature": 0},
messages=[
{
"role": "system",
"content": (
"You are a reviewer. Check if the output properly addresses the original task. "
"If good: respond with exactly APPROVED\n"
"If not: respond with NEEDS_CHANGES followed by specific actionable feedback."
)
},
{"role": "user", "content": f"Task:\n{task}\n\nOutput:\n{output}"}
]
)
content = response.message.content.strip()
if content.startswith("APPROVED"):
return {"approved": True}
feedback = content.replace("NEEDS_CHANGES", "").strip()
return {"approved": False, "feedback": feedback}
def run_pipeline_with_review(task: str, max_revisions: int = 3):
print(f"Task: {task}")
print("=" * 60)
print("\n[Stage 1: Planner]")
plan = planner_agent(task)
print(plan)
feedback = ""
for attempt in range(1, max_revisions + 1):
print(f"\n[Stage 2: Executor — attempt {attempt}]")
output = executor_agent(plan, feedback)
print(output[:300] + "..." if len(output) > 300 else output)
print(f"\n[Stage 3: Reviewer — attempt {attempt}]")
review = reviewer_agent(task, output)
if review["approved"]:
print("✓ APPROVED")
return output
else:
feedback = review["feedback"]
print(f"✗ NEEDS_CHANGES\nFeedback: {feedback}")
print(f"\n[Max revisions reached — returning last output]")
return output
run_pipeline_with_review("Write a clear explanation of REST APIs for a junior developer")
A well-specified task often gets approved on the first attempt. The feedback loop is a safety net — it catches cases where the executor misses something, not a guarantee that output will keep improving forever.
Exercise 3 — Parallel agents
Run two research agents at the same time and combine their output:
import ollama
import threading
import time
def research_agent(topic: str, results: dict, key: str):
response = ollama.chat(
model="qwen2.5", options={"temperature": 0},
messages=[
{"role": "system", "content": "You are a research agent. Provide a concise, factual summary in 3–5 bullet points."},
{"role": "user", "content": f"Research this topic: {topic}"}
]
)
results[key] = response.message.content
def synthesis_agent(topic_a: str, research_a: str, topic_b: str, research_b: str) -> str:
response = ollama.chat(
model="qwen2.5", options={"temperature": 0},
messages=[
{"role": "system", "content": "You are a synthesis agent. Receive research on two topics and produce a comparative analysis."},
{
"role": "user",
"content": (
f"Topic A: {topic_a}\n{research_a}\n\n"
f"Topic B: {topic_b}\n{research_b}\n\n"
"Synthesise into a comparative analysis."
)
}
]
)
return response.message.content
def run_parallel_pipeline(topic_a: str, topic_b: str):
print(f"Researching in parallel: '{topic_a}' and '{topic_b}'")
print("=" * 60)
results = {}
start = time.time()
thread_a = threading.Thread(target=research_agent, args=(topic_a, results, "a"))
thread_b = threading.Thread(target=research_agent, args=(topic_b, results, "b"))
thread_a.start()
thread_b.start()
thread_a.join()
thread_b.join()
print(f"[Both finished in {time.time() - start:.1f}s]\n")
print(f"[Research A: {topic_a}]\n{results['a']}")
print(f"\n[Research B: {topic_b}]\n{results['b']}")
print("\n[Synthesis]")
synthesis = synthesis_agent(topic_a, results["a"], topic_b, results["b"])
print(synthesis)
return synthesis
run_parallel_pipeline("PostgreSQL", "MongoDB")
run_parallel_pipeline("REST APIs", "GraphQL")
Both agents finish in roughly the same time as running one. That's the throughput gain from parallelism — two independent tasks done in the time of one.
Exercise 4 — How this maps to real agent frameworks
The pipeline you just built — planner, executor, reviewer with a feedback loop — is the same pattern you'll find in most agent frameworks. Here's how it typically looks in a framework that uses a stage-based DAG format:
# Sequential handoff — each stage waits for the previous one
stages = [
{
"name": "plan",
"prompt_template": "Plan this task: {task}"
# no depends_on → runs first
},
{
"name": "implement",
"prompt_template": "Implement based on the plan",
"depends_on": ["plan"] # waits for plan to finish
},
{
"name": "review",
"prompt_template": "Review the implementation",
"depends_on": ["implement"],
"loop_to": { # feedback loop
"target": "implement",
"trigger": "NEEDS_CHANGES",
"max_iterations": 3 # your max_revisions cap
}
}
]
# Parallel — stages with no depends_on run simultaneously
stages = [
{"name": "research_a", "prompt_template": "Research PostgreSQL"},
{"name": "research_b", "prompt_template": "Research MongoDB"},
{
"name": "synthesise",
"prompt_template": "Compare the two",
"depends_on": ["research_a", "research_b"] # waits for both
}
]
depends_on is the sequential handoff. Stages without it run in parallel. loop_to with a trigger is the feedback loop. max_iterations is the revision cap.
The concepts are identical to what you built from scratch in Exercises 1–3. Frameworks just give you a configuration format so you don't have to write the orchestration logic yourself every time.
Wrapping up
Multiple agents solve the problem that a single generalist agent runs into — too much context, too many competing concerns. By giving each agent one job and passing output between them as simple strings, you get something that's more reliable, easier to debug, and easier to extend.
The patterns here — sequential pipelines, parallel execution, feedback loops — are the same ones you'll find in every major agent framework. Having built them yourself means you understand what those frameworks are actually doing.
In Post #7, we look at MCP — the standard protocol that lets any agent talk to any tool without custom glue code. See you there. 🚀
Top comments (1)
Returning the last output when max_revisions is hit is the detail that decides whether the loop is real. As written, a task that never passed the reviewer and a task that passed on the third attempt return the same thing, and the caller cannot tell them apart. A boolean on the result plus a caller that is allowed to stop is what keeps revisions from becoming decorative.
Two small changes I would make: the reviewer answers APPROVED by string match on the first line, so any model that prefixes it ("Sure — APPROVED") costs a full round trip. A structured verdict with a category and a concrete instruction is usually no more expensive and gives you something to log and to trend. And each stage should persist its raw output — in a feedback loop the interesting failure is never the final attempt, it is the one that got rejected for the wrong reason.
The reviewer is also the stage that degrades first in practice. Same model, same context, reviewing output from its own family tends to approve whatever reads confidently. Switching the reviewer to a different model, or giving it a fixed list of things to check instead of a vague quality judgement, is what moves the approval rate back down to where an approval means something.