DEV Community

Cover image for Multi-Agent Systems: Planners, Executors, and Review Loops
Gokulnath P
Gokulnath P

Posted on AI-assisted

Multi-Agent Systems: Planners, Executors, and Review Loops

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
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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 ──▶ ┘
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

No new dependencies. Use qwen2.5 for these exercises:

ollama pull qwen2.5
Enter fullscreen mode Exit fullscreen mode

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")
Enter fullscreen mode Exit fullscreen mode

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")
Enter fullscreen mode Exit fullscreen mode

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")
Enter fullscreen mode Exit fullscreen mode

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
    }
]
Enter fullscreen mode Exit fullscreen mode

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)

Collapse
 
raknaos profile image
Raknaos

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.