Part II: Discovery Through Software Engineering and Vibe Coding
Chapter 17: Multi-Agent Software Teams

17.3 Building a Software Agent Team

"I built a four-agent team. Three of them work beautifully. The fourth keeps rewriting the other three agents' code and submitting it as its own contribution."

A Tech Lead Discovering the Free-Rider Problem

Prerequisites

This section synthesizes everything from the chapter. You need the agent roles and workflow graphs from Section 17.1 and the coordination patterns, debate protocols, and human gates from Section 17.2. You should have a working Python environment with access to a large language model (LLM) API (OpenAI or Anthropic). Familiarity with GitHub's API (creating branches, committing files, opening PRs) is helpful; the Model Context Protocol (MCP) server patterns from Chapter 12 show how to wrap such APIs as agent tools.

The Big Picture

This section is a recipe. We build a complete multi-agent team that takes a GitHub issue as input and produces a reviewed pull request (PR) as output. The pipeline flows through four agents: a planner (combines the PM and architect roles for simplicity), a developer, a tester, and a reviewer, connected by a cyclic workflow with human approval at the end. We implement this pipeline three times (OpenAI Agents SDK, LangGraph, and CrewAI), with AutoGen covered in the framework comparison table, so you can compare their programming models side by side. The recipe integrates with the Discovery Workbench as a reusable component that later chapters extend for scientific workflows.

1. The Recipe: Issue to Pull Request

Imagine filing a GitHub issue at 9 AM and finding a reviewed, tested pull request waiting for your approval by lunch, planned, coded, tested, and critiqued by four AI agents without a single human keystroke in between. The concrete steps that make this possible are:

Without a structured pipeline, a single agent attempting to plan, code, test, and review its own work tends to skip steps, lose context halfway through, and produce changes that pass no tests and miss the original requirements entirely. Splitting those responsibilities across specialized agents, each with its own prompt and tools, is what turns a fragile demo into a pipeline that reliably closes issues.

A multi-agent pipeline chains specialized LLM-powered agents, each handling one stage of a larger task. Typed data contracts connect them so that one agent's output feeds directly into the next. This design lets you decompose complex, open-ended work (like resolving a GitHub issue end to end) into focused subtasks. You can prompt, tool, and evaluate each agent independently. A shared state object flows through the pipeline. Each agent reads the fields it needs, calls its tools, and writes output fields for downstream agents. Use a multi-agent pipeline when the task requires distinct competencies (planning, coding, testing, reviewing) that benefit from separate prompts and tool sets. For single-step tasks, a single agent with the right tools is faster and cheaper.

  1. Plan: Read the issue, analyze the codebase, produce requirements and a technical design.
  2. Implement: Write the code changes according to the plan.
  3. Test: Generate tests for the new code and run them.
  4. Review: Check the implementation against the plan, look for bugs, verify test coverage.
  5. Revise (conditional): If the reviewer finds blockers, return to step 2 with feedback.
  6. Approve (human gate): Present the final diff to a human for approval before opening the PR.
Multi-agent pipeline state flow from GitHub issue to pull request
Figure 17.3.1: The issue-to-PR multi-agent pipeline, showing typed data contracts between agents, the conditional review loop, and the human approval gate.

Figure 17.3 below shows these six steps as a workflow graph. Notice the conditional back-edge from the review node to the implement node: this is the revision cycle that runs up to three times before the pipeline terminates, whether or not the reviewer has approved. Figure 17.3.1 illustrates multi-agent pipeline state flow from GitHub issue to pull request.

Plan Implement Test Review Human Gate END approved revise (max 3)
Figure 17.3: The issue-to-PR workflow graph. Solid arrows show the forward pipeline; the dashed back-edge from Review to Implement represents the conditional revision cycle (up to three rounds). The Human Gate pauses the pipeline for human approval before the PR is created.

The shared state that flows through all four implementations: In short: Define the contract between agents as typed data (using Pydantic, a Python library that enforces type constraints and serializes objects to JSON), wire them into a graph with a review loop, and let the framework handle the rest.

from pydantic import BaseModel, Field

class IssueInput(BaseModel):
    """Input: a GitHub issue to resolve."""
    title: str
    body: str
    labels: list[str] = []
    repo_owner: str
    repo_name: str
    base_branch: str = "main"

class PlanOutput(BaseModel):
    """Output from the planner agent."""
    summary: str = Field(description="One-paragraph summary of the change")
    requirements: list[str] = Field(description="Testable acceptance criteria")
    files_to_modify: list[dict] = Field(
        description="List of {path, action, description} for each file"
    )
    branch_name: str = Field(description="Git branch name for the PR")

class ImplementOutput(BaseModel):
    """Output from the developer agent."""
    files: dict[str, str] = Field(description="Map of file path to new content")
    commit_message: str
    implementation_notes: str

class TestOutput(BaseModel):
    """Output from the tester agent."""
    test_files: dict[str, str] = Field(description="Map of test file path to content")
    results: list[dict] = Field(description="List of {name, passed, error} dicts")
    all_passed: bool
    coverage_summary: str

class ReviewOutput(BaseModel):
    """Output from the reviewer agent."""
    approved: bool
    blockers: list[dict] = Field(description="Must-fix issues with file, line, message")
    warnings: list[dict] = Field(description="Should-fix issues")
    summary: str

class PRResult(BaseModel):
    """Final output: the pull request."""
    pr_url: str
    pr_number: int
    title: str
    files_changed: int
    iterations: int
    total_tokens: int
Pydantic models defining the typed interface between every agent in the pipeline, from issue input to PR output.

Checkpoint

So far: the pipeline takes a GitHub issue through six steps (plan, implement, test, review, revise, approve), each handled by a specialized agent, and all agents communicate through typed Pydantic models that define exactly what data flows between them.

2. Implementation: OpenAI Agents SDK

Each framework wires agents together and routes data through them differently.

The OpenAI Agents SDK provides a lightweight framework built around three concepts: agents (LLM configurations with tools), handoffs (transfer control between agents), and guardrails (input/output validators). The SDK uses Python's native async patterns and traces every agent invocation for observability.

from agents import Agent, Runner, handoff
from agents import function_tool

# --- Tools (simplified; production versions wrap MCP servers) ---

@function_tool
def read_file(path: str) -> str:
    """Read a file from the repository."""
    return repo.read_file(path)

@function_tool
def write_file(path: str, content: str) -> str:
    """Write content to a file in the working branch."""
    repo.write_file(path, content)
    return f"Written {len(content)} bytes to {path}"

@function_tool
def run_tests(test_dir: str = "tests") -> dict:
    """Run the test suite and return results."""
    return repo.run_pytest(test_dir)

@function_tool
def search_code(query: str, max_results: int = 10) -> list[dict]:
    """Search the codebase for a pattern."""
    return repo.grep(query, max_results=max_results)

# --- Agents ---

planner = Agent(
    name="planner",
    instructions="""You are a senior tech lead. Given a GitHub issue, analyze the
codebase and produce a structured plan with:
1. A summary of the required change
2. Testable acceptance criteria
3. A list of files to modify with descriptions
4. A git branch name

Use search_code and read_file to understand the codebase.""",
    tools=[read_file, search_code],
    output_type=PlanOutput,
    model="gpt-4o",
)

developer = Agent(
    name="developer",
    instructions="""You are a senior developer. Given a plan, implement the changes.
Write clean, well-commented code that follows existing patterns in the codebase.
If you received review feedback, address every blocker before resubmitting.""",
    tools=[read_file, write_file, search_code],
    output_type=ImplementOutput,
    model="gpt-4o",
)

tester = Agent(
    name="tester",
    instructions="""You are a QA engineer. Given implementation files, write
comprehensive tests covering:
1. Happy path for each acceptance criterion
2. Edge cases (empty input, large input, unicode)
3. Error paths (invalid input, missing dependencies)

Run the tests and report results.""",
    tools=[read_file, write_file, run_tests],
    output_type=TestOutput,
    model="gpt-4o",
)

reviewer = Agent(
    name="reviewer",
    instructions="""You are a senior code reviewer. Review the implementation
against the plan. Check for:
1. Correctness: does the code do what the plan says?
2. Tests: are all acceptance criteria covered?
3. Security: any injection, path traversal, or secret exposure?
4. Style: does it follow codebase conventions?

Set approved=true ONLY if there are zero blockers.""",
    tools=[read_file, search_code],
    output_type=ReviewOutput,
    model="gpt-4o",
)

# --- Handoff-based pipeline ---

# the reviewer hands off back to developer if not approved
reviewer_with_handoff = Agent(
    name="reviewer",
    instructions=reviewer.instructions,
    tools=[read_file, search_code],
    output_type=ReviewOutput,
    handoffs=[handoff(
        agent=developer,
        tool_name="request_revision",
        tool_description="Send the code back to the developer with feedback",
    )],
    model="gpt-4o",
)


async def run_issue_to_pr(issue: IssueInput) -> PRResult:
    """Run the full pipeline using OpenAI Agents SDK."""
    # step 1: plan
    plan_result = await Runner.run(planner, input=issue.model_dump_json())
    plan = plan_result.final_output_as(PlanOutput)

    # step 2-4: implement -> test -> review (with cycle)
    context = {"plan": plan.model_dump(), "issue": issue.model_dump()}
    max_iterations = 3
    for iteration in range(max_iterations):
        # implement
        impl_result = await Runner.run(developer, input=str(context))
        impl = impl_result.final_output_as(ImplementOutput)

        # test
        test_input = {**context, "implementation": impl.model_dump()}
        test_result = await Runner.run(tester, input=str(test_input))
        tests = test_result.final_output_as(TestOutput)

        # review
        review_input = {
            **context,
            "implementation": impl.model_dump(),
            "tests": tests.model_dump(),
            "iteration": iteration,
        }
        review_result = await Runner.run(
            reviewer_with_handoff, input=str(review_input)
        )
        review = review_result.final_output_as(ReviewOutput)

        if review.approved:
            break

        # feed review back into context for next iteration
        context["review_feedback"] = review.model_dump()

    # step 5: create PR (after human approval in production)
    pr = repo.create_pull_request(
        branch=plan.branch_name,
        title=f"Fix: {issue.title}",
        body=plan.summary,
        files={**impl.files, **tests.test_files},
    )
    return PRResult(
        pr_url=pr["url"],
        pr_number=pr["number"],
        title=pr["title"],
        files_changed=len(impl.files) + len(tests.test_files),
        iterations=iteration + 1,
        total_tokens=sum(r.usage.total_tokens for r in [
            plan_result, impl_result, test_result, review_result
        ]),
    )
Complete issue-to-PR pipeline using the OpenAI Agents SDK with handoff-based review loops and structured outputs.
Key Insight: Handoffs vs. Orchestration

The OpenAI Agents SDK uses handoffs to transfer control between agents. A handoff is a tool call that the current agent makes when it decides another agent should take over. This is a decentralized coordination model: the reviewer decides whether to hand off to the developer, not a central orchestrator. The advantage is simplicity (no separate workflow graph); the disadvantage is that control flow is embedded in agent behavior, making it harder to visualize, debug, and enforce invariants (like maximum iteration counts). For production systems, the centralized orchestration pattern (LangGraph, next subsection) is more predictable.

3. Implementation: LangGraph

LangGraph models workflows as state graphs: nodes are functions that transform a typed state object, edges define the flow between nodes, and conditional edges enable branching. LangGraph provides built-in checkpointing (where checkpointing means saving the workflow's state at each node so that a failed or paused run can resume from the last saved point rather than restarting), streaming, and graph visualization.

Mental Model

Think of a state graph like a commercial kitchen preparing a banquet dish. The typed state object is a ticket clipped to a tray: it accumulates notes as the tray moves from station to station. The prep cook (planner) writes the recipe on the ticket; the line cook (developer) adds the prepared ingredients; the garnish station (tester) plates and checks presentation; the head chef (reviewer) tastes and either sends the dish out or routes the tray back to the line cook with corrections. Conditional edges are the head chef's decision: "send it out" versus "redo the sauce." Checkpointing is the snapshot a manager takes of every tray's position so that if the kitchen loses power, service can resume from the last recorded state rather than starting every dish over.

from langgraph.graph import StateGraph, END
from langgraph.checkpoint.memory import MemorySaver
from typing import TypedDict, Annotated
import operator

class TeamState(TypedDict):
    """Typed state flowing through the LangGraph pipeline."""
    issue: dict
    plan: dict | None
    implementation: dict | None
    tests: dict | None
    review: dict | None
    iteration: int
    messages: Annotated[list[str], operator.add]  # reducer: operator.add appends new items to the existing list

async def plan_node(state: TeamState) -> dict:
    """Planner node: analyze issue, produce plan."""
    # call_llm is a helper that sends a chat completion request
    # and parses the response into the given output_type; see the
    # mock version in Section 7 for testing without an API key.
    result = await call_llm(
        system="You are a senior tech lead...",
        user=f"Issue: {state['issue']}",
        output_type=PlanOutput,
        tools=[read_file, search_code],
    )
    return {
        "plan": result.model_dump(),
        "messages": [f"Plan: {result.summary}"],
    }

async def implement_node(state: TeamState) -> dict:
    """Developer node: implement the plan."""
    context = {"plan": state["plan"]}
    if state.get("review") and not state["review"].get("approved"):
        context["feedback"] = state["review"]
    result = await call_llm(
        system="You are a senior developer...",
        user=f"Context: {context}",
        output_type=ImplementOutput,
        tools=[read_file, write_file, search_code],
    )
    return {
        "implementation": result.model_dump(),
        "messages": [f"Implemented: {result.commit_message}"],
    }

async def test_node(state: TeamState) -> dict:
    """Tester node: write and run tests."""
    result = await call_llm(
        system="You are a QA engineer...",
        user=f"Implementation: {state['implementation']}",
        output_type=TestOutput,
        tools=[read_file, write_file, run_tests],
    )
    return {
        "tests": result.model_dump(),
        "messages": [f"Tests: {result.results}"],
    }

async def review_node(state: TeamState) -> dict:
    """Reviewer node: review code and tests."""
    result = await call_llm(
        system="You are a senior code reviewer...",
        user=f"Plan: {state['plan']}\nImpl: {state['implementation']}\n"
             f"Tests: {state['tests']}",
        output_type=ReviewOutput,
        tools=[read_file, search_code],
    )
    return {
        "review": result.model_dump(),
        "iteration": state["iteration"] + 1,
        "messages": [f"Review (round {state['iteration'] + 1}): "
                     f"{'approved' if result.approved else 'needs revision'}"],
    }

def should_continue(state: TeamState) -> str:
    """Conditional edge: continue revising or finish."""
    if state["review"]["approved"]:
        return "approved"
    if state["iteration"] >= 3:
        return "max_iterations"
    return "revise"

# Build the graph
workflow = StateGraph(TeamState)
workflow.add_node("plan", plan_node)
workflow.add_node("implement", implement_node)
workflow.add_node("test", test_node)
workflow.add_node("review", review_node)

workflow.set_entry_point("plan")
workflow.add_edge("plan", "implement")
workflow.add_edge("implement", "test")
workflow.add_edge("test", "review")

workflow.add_conditional_edges("review", should_continue, {
    "approved": END,
    "max_iterations": END,
    "revise": "implement",  # cycle back
})

# Compile with checkpointing
checkpointer = MemorySaver()
app = workflow.compile(checkpointer=checkpointer)

# Run
async def run_langgraph_pipeline(issue: IssueInput):
    config = {"configurable": {"thread_id": f"issue-{issue.title}"}}
    initial_state = {
        "issue": issue.model_dump(),
        "plan": None,
        "implementation": None,
        "tests": None,
        "review": None,
        "iteration": 0,
        "messages": [],
    }
    result = await app.ainvoke(initial_state, config=config)
    return result
LangGraph implementation with typed state, conditional review loops, and automatic checkpointing for failure recovery.
Practical Example: Visualizing the Workflow

LangGraph can render the compiled graph as a Mermaid diagram, which is invaluable for documentation and debugging. Calling app.get_graph().draw_mermaid() produces a diagram showing the four nodes (plan, implement, test, review), the forward edges, the conditional back-edge from review to implement, and the two terminal conditions (approved, max_iterations). When a production workflow hangs at the review node on its third iteration, the diagram immediately shows the cycle and the termination condition. This visual debugging connects to the observability patterns in Chapter 22, where workflow traces become a first-class monitoring concern.

4. Implementation: CrewAI

CrewAI models agents as crew members with roles, goals, and backstories. You assign tasks to crew members for sequential or parallel execution; the framework routes tools, parses outputs, and delegates automatically.

from crewai import Agent, Task, Crew, Process

# Define crew members
planner_agent = Agent(
    role="Senior Tech Lead",
    goal="Analyze GitHub issues and produce implementation plans",
    backstory="You have 15 years of experience leading engineering teams. "
              "You excel at breaking complex issues into clear, actionable plans.",
    tools=[read_file_tool, search_code_tool],
    verbose=True,
)

developer_agent = Agent(
    role="Senior Developer",
    goal="Implement code changes according to the technical plan",
    backstory="You write clean, well-tested Python code. You follow existing "
              "patterns in the codebase and never introduce unnecessary complexity.",
    tools=[read_file_tool, write_file_tool, search_code_tool],
    verbose=True,
)

tester_agent = Agent(
    role="QA Engineer",
    goal="Write comprehensive tests and verify implementation correctness",
    backstory="You think in edge cases. Every untested branch is a future bug. "
              "You write tests that document behavior, not just verify it.",
    tools=[read_file_tool, write_file_tool, run_tests_tool],
    verbose=True,
)

reviewer_agent = Agent(
    role="Senior Code Reviewer",
    goal="Find bugs, security issues, and design violations in code changes",
    backstory="You are the last line of defense before code reaches production. "
              "You have caught three critical security vulnerabilities this quarter.",
    tools=[read_file_tool, search_code_tool],
    verbose=True,
)

# Define tasks
plan_task = Task(
    description="Analyze issue '{issue_title}': {issue_body}\n"
                "Produce a plan with acceptance criteria and files to modify.",
    expected_output="A structured plan with summary, requirements, and file list.",
    agent=planner_agent,
)

implement_task = Task(
    description="Implement the changes described in the plan. "
                "Write clean code following existing codebase patterns.",
    expected_output="Implementation files with a commit message.",
    agent=developer_agent,
    context=[plan_task],  # receives planner's output
)

test_task = Task(
    description="Write tests for the implementation. Cover happy paths, "
                "edge cases, and error paths. Run them and report results.",
    expected_output="Test files and pass/fail results.",
    agent=tester_agent,
    context=[plan_task, implement_task],
)

review_task = Task(
    description="Review the implementation against the plan. Look for "
                "correctness bugs, security issues, and missing test coverage.",
    expected_output="Review with approved/rejected status and specific comments.",
    agent=reviewer_agent,
    context=[plan_task, implement_task, test_task],
)

# Assemble the crew
crew = Crew(
    agents=[planner_agent, developer_agent, tester_agent, reviewer_agent],
    tasks=[plan_task, implement_task, test_task, review_task],
    process=Process.sequential,
    verbose=True,
)

# Run
result = crew.kickoff(inputs={
    "issue_title": "Add CSV export to dashboard",
    "issue_body": "Users need to download experiment results as CSV files.",
})
CrewAI implementation using role-playing agents with backstories, task dependencies via context, and sequential execution.

5. Framework Comparison

Each framework makes different trade-offs. Five dimensions matter most for production multi-agent systems: (Early benchmarks on CodeArena show that teams using explicit state graphs waste 30 to 40 percent fewer coordination tokens than teams using free-form conversation, suggesting that structured workflows pay for themselves at scale.)

Dimension OpenAI Agents SDK LangGraph AutoGen CrewAI
Coordination model Handoffs (decentralized) State graph (centralized) Conversations (peer-to-peer) Task queue (sequential/hierarchical)
State management Implicit (in conversation) Typed state dict with reducers (where a reducer is a function that merges updates into the current state, such as appending to a list) Conversation history Task context chain
Cyclic workflows Via handoff loops Conditional edges (first-class) Conversation turn limits Manual (not built-in)
Human-in-the-loop Guardrails + custom Interrupt nodes UserProxyAgent (built-in) human_input=True flag
Observability Built-in tracing LangSmith integration Logging + callbacks Verbose mode + callbacks
Best for Simple pipelines, OpenAI ecosystem Complex stateful workflows Flexible conversations, research Rapid prototyping, role-based teams

Common Misconception

A frequent mistake is assuming that splitting a task across more agents always improves output quality. In practice, each agent boundary introduces serialization overhead, potential information loss (the downstream agent only sees the upstream agent's structured output, not its full reasoning), and additional LLM calls that increase cost and latency. For straightforward, single-file changes, a single well-prompted agent with the right tools will outperform a four-agent pipeline that spends most of its token budget on inter-agent coordination rather than on solving the actual problem.

Key Insight: Choose by Workflow Complexity

For a linear pipeline (plan, implement, test, review) with no cycles, any framework works and CrewAI or the OpenAI Agents SDK will get you there fastest. For workflows with conditional branches, cycles, parallel fan-out, and human gates, LangGraph's explicit state graph is the most maintainable choice. For research explorations where you want agents to freely debate and the conversation topology may change, AutoGen's flexible conversation model is the best fit (as of 2025, AutoGen 0.4 replaced the original conversation-based API with an event-driven, modular architecture; a community fork called AG2 continues the earlier design). The choice is not permanent: most teams start with CrewAI for prototyping and migrate to LangGraph when the workflow becomes complex enough to need explicit state management and visualization.

Real-World Application: GitHub Copilot Workspace
Real-World Application: GitHub Copilot Workspace

6. Adding Human Approval

None of the implementations above include the human approval gate from Section 17.2. In production, the final step before opening a PR must be human review. LangGraph makes this cleanest with its interrupt mechanism:

from langgraph.graph import StateGraph, END

async def human_approval_node(state: TeamState) -> dict:
    """This node is an interrupt point. LangGraph pauses here
    and waits for external input before continuing."""
    # in production, this sends a Slack message or email
    # and resumes when the human responds
    return {
        "messages": ["Waiting for human approval..."],
    }

# add to the graph between review and END
workflow.add_node("human_approval", human_approval_node)

# modify the conditional edges
workflow.add_conditional_edges("review", should_continue, {
    "approved": "human_approval",  # go to human gate, not END
    "max_iterations": "human_approval",
    "revise": "implement",
})
workflow.add_edge("human_approval", END)

# compile with interrupt_before to pause at the gate
app = workflow.compile(
    checkpointer=checkpointer,
    interrupt_before=["human_approval"],
)

# the workflow pauses at human_approval; to resume:
async def approve_and_continue(thread_id: str):
    config = {"configurable": {"thread_id": thread_id}}
    # resume from checkpoint, the human_approval node executes
    result = await app.ainvoke(None, config=config)
    return result
LangGraph interrupt mechanism: the workflow checkpoints and pauses at the human approval node, resuming only when a human triggers continuation.

7. Testing Multi-Agent Pipelines

A complete pipeline with human approval gates is only trustworthy if you can verify that every path through the workflow graph, including rejection cycles and budget limits, behaves as designed.

Three Strategies for Testable Pipelines

Multi-agent systems are particularly difficult to test because LLM outputs are non-deterministic. Three strategies make testing tractable:

Mock the LLM. Replace the LLM with deterministic functions that return predefined outputs. This lets you test the workflow logic (routing, state updates, gate triggers) without paying for API calls or dealing with stochastic outputs.

import pytest

class MockLLM:
    """Deterministic LLM replacement for testing workflow logic."""

    def __init__(self, responses: dict[str, dict]):
        self.responses = responses  # agent_name -> response dict
        self.call_log = []

    async def __call__(self, agent_name: str, input_data: dict) -> dict:
        self.call_log.append({"agent": agent_name, "input": input_data})
        return self.responses[agent_name]


@pytest.fixture
def mock_team():
    return MockLLM(responses={
        "planner": {
            "summary": "Add CSV export",
            "requirements": ["Export current view as CSV"],
            "files_to_modify": [{"path": "app/export.py", "action": "create"}],
            "branch_name": "feat/csv-export",
        },
        "developer": {
            "files": {"app/export.py": "def export_csv(): ..."},
            "commit_message": "Add CSV export functionality",
        },
        "tester": {
            "test_files": {"tests/test_export.py": "def test_csv(): ..."},
            "results": [{"name": "test_csv", "passed": True}],
            "all_passed": True,
        },
        "reviewer": {
            "approved": True,
            "blockers": [],
            "summary": "LGTM",
        },
    })


@pytest.mark.asyncio
async def test_happy_path(mock_team):
    """Test that a successful pipeline produces a PR."""
    result = await run_pipeline(mock_team, issue={
        "title": "Add CSV export",
        "body": "Users need CSV export",
    })
    assert result["status"] == "completed"
    assert result["iterations"] == 1  # no review cycles
    assert len(mock_team.call_log) == 4  # all four agents called


@pytest.mark.asyncio
async def test_review_rejection_cycle(mock_team):
    """Test that a review rejection triggers a developer revision."""
    # first review rejects, second approves
    mock_team.responses["reviewer"] = {
        "approved": False,
        "blockers": [{"message": "Missing input validation"}],
        "summary": "Needs revision",
    }
    # override to approve on second call
    call_count = {"reviewer": 0}
    original_call = mock_team.__call__

    async def counting_call(agent_name, input_data):
        if agent_name == "reviewer":
            call_count["reviewer"] += 1
            if call_count["reviewer"] >= 2:
                return {"approved": True, "blockers": [], "summary": "LGTM"}
        return await original_call(agent_name, input_data)

    mock_team.__call__ = counting_call

    result = await run_pipeline(mock_team, issue={
        "title": "Add CSV export",
        "body": "Users need CSV export",
    })
    assert result["iterations"] == 2
    assert call_count["reviewer"] == 2
Testing multi-agent workflows with mock LLMs: deterministic responses isolate workflow logic from model stochasticity.

Snapshot testing. Run the pipeline once with a real LLM, save the intermediate state at every checkpoint, and use those snapshots as regression tests. If a framework upgrade or prompt change alters the workflow routing, the snapshot test catches it.

Evaluation suites. Run the pipeline against a curated set of issues with known correct solutions (e.g., a subset of SWE-bench). Measure pass rate, number of review iterations, total token cost, and wall-clock time. This is the multi-agent analogue of the evaluation techniques in Chapter 23.

Library Shortcut: Tracing with LangSmith and OpenAI

Both LangGraph and the OpenAI Agents SDK provide built-in tracing. LangGraph integrates with LangSmith, which captures every node execution, state transition, and LLM call in a visual timeline. The OpenAI Agents SDK provides its own tracing via Runner.run() that logs agent handoffs, tool calls, and guardrail evaluations. In either case, you get a complete audit trail of a multi-agent workflow in about 2 lines of configuration (compared to the 50+ lines of manual logging in our Blackboard.record_step() from Section 17.2):

# LangSmith: set env vars and tracing is automatic
import os
os.environ["LANGCHAIN_TRACING_V2"] = "true"
os.environ["LANGCHAIN_API_KEY"] = "ls_..."

# OpenAI Agents SDK: tracing is on by default
from agents import Runner, trace
with trace("issue-to-pr"):
    result = await Runner.run(planner, input=issue_json)
Two lines of configuration enable full workflow tracing in LangSmith or the OpenAI Agents SDK (compared to 50+ lines of manual logging).

8. Integration with the Discovery Workbench

Once the pipeline is tested and observable, the final step is to package it so that other systems (and other agents) can invoke it without knowing its internal structure.

The multi-agent team pipeline becomes a component in the Discovery Workbench architecture from Chapter 6. The Workbench exposes the pipeline as an MCP tool that scientific users can invoke without understanding the multi-agent internals:

from mcp.server import Server
from mcp.types import Tool

server = Server("software-team")

@server.tool()
async def resolve_issue(
    repo_owner: str,
    repo_name: str,
    issue_number: int,
    base_branch: str = "main",
    max_review_rounds: int = 3,
) -> dict:
    """Resolve a GitHub issue by planning, implementing, testing,
    and reviewing code changes. Returns a PR URL.

    The multi-agent team handles the full software development lifecycle.
    A human approval gate fires before the PR is created.
    """
    # fetch issue from GitHub
    issue = await github.get_issue(repo_owner, repo_name, issue_number)

    # run the pipeline
    result = await run_langgraph_pipeline(IssueInput(
        title=issue["title"],
        body=issue["body"],
        labels=issue.get("labels", []),
        repo_owner=repo_owner,
        repo_name=repo_name,
        base_branch=base_branch,
    ))

    return {
        "pr_url": result.get("pr_url"),
        "iterations": result.get("iteration", 0),
        "status": "awaiting_approval" if not result.get("pr_url") else "created",
    }
Exposing the multi-agent team as an MCP tool in the Discovery Workbench, letting scientific users invoke it without understanding the agent internals.

Wrapping multi-agent workflows as MCP tools is how the Discovery Workbench grows. Each chapter adds a new capability (literature search in Chapter 36, experiment design in Chapter 46), and the Workbench integrates them all behind a uniform interface. By Chapter 53, the Workbench orchestrates multi-agent teams that build software, run experiments, and write papers.

Research Frontier: Benchmarking Multi-Agent Teams

SWE-bench (Jimenez et al., 2024) remains a foundational benchmark for AI software engineering, and SWE-bench Verified (Chowdhury et al., 2024) refined it with human-validated problem instances. More recently, CodeArena (Yang et al., 2025) introduced a competitive multi-agent benchmark where teams of agents collaborate on repository-level tasks with shared codebases, measuring not just correctness but coordination efficiency: how many tokens agents waste on redundant file reads, conflicting edits, and failed handoffs. Early CodeArena results show that teams using explicit state graphs (the LangGraph pattern) waste 30-40% fewer coordination tokens than teams using free-form conversation, suggesting that structured workflows can reduce overhead at scale. The frontier question is whether learned coordination protocols, where agents adapt their communication patterns based on task difficulty, can close the gap between structured and unstructured approaches without requiring hand-designed workflow graphs.

Try It: Build a Two-Agent Review Pipeline

You can build a minimal multi-agent pipeline on your laptop using only Python and an LLM API key. This project takes about 30 minutes and produces a working writer-reviewer loop.

1. Install dependencies: pip install openai pydantic. Create a file called mini_pipeline.py and define two Pydantic models: DraftOutput (with fields code: str and explanation: str) and ReviewOutput (with fields approved: bool, feedback: str).

2. Write a call_agent(system_prompt, user_message, output_model) function that sends a chat completion request with response_format set to your Pydantic model, parses the JSON response, and returns the validated object.

3. Implement a writer_agent(task_description, feedback=None) function that calls call_agent with a system prompt like "You are a Python developer. Write a function that satisfies the task. If feedback is provided, revise accordingly." Pass the task description (and any prior feedback) as the user message.

4. Implement a reviewer_agent(code, task_description) function that calls call_agent with a system prompt like "You are a code reviewer. Check whether the code correctly implements the task. Set approved to true only if the code is correct and handles edge cases."

5. Write a run_pipeline(task, max_rounds=3) loop: call the writer, then the reviewer. If approved, print the final code and exit. If not, pass the reviewer's feedback back to the writer and repeat. After max_rounds, print a warning. Run it with a simple task like "Write a function that checks whether a string is a valid email address" and observe how the reviewer's feedback improves the code across iterations.

Exercise 17.3.1

You have a four-agent pipeline (planner, developer, tester, reviewer) where the reviewer rejects the first implementation because the developer missed an acceptance criterion. On the second iteration, the developer addresses the feedback but introduces a new test failure. The reviewer rejects again. On the third iteration (the maximum), the developer fixes the test failure, all tests pass, and the reviewer approves. Draw the sequence of node activations and state transitions for this run through the LangGraph workflow. How many total LLM calls are made (counting each node activation as one call)? What is the final value of state["iteration"]?

Hint

The plan node runs once. Each review cycle activates implement, test, and review. Count the nodes activated in each cycle: cycle 1 (reject), cycle 2 (reject), cycle 3 (approve). The iteration counter increments inside review_node, so after three passes through review it equals 3.

Step-Through: Review Loop State Transitions

Trace the LangGraph pipeline with a concrete example where the reviewer rejects once before approving.

Initial state: iteration=0, review=None.
Node 1 (plan): Reads the issue "Add CSV export." Outputs plan.summary="Add CSV export to dashboard", plan.files_to_modify=[{path: "app/export.py"}].
Node 2 (implement, round 1): Reads plan. No review feedback yet. Outputs files={"app/export.py": "def export_csv(): ..."}.
Node 3 (test, round 1): Reads implementation. Outputs all_passed=True, results=[{name: "test_export", passed: True}].
Node 4 (review, round 1): Finds missing input validation. Sets approved=False, blockers=[{message: "No input validation"}]. Updates iteration=1.
Conditional edge: approved=False and iteration=1 < 3, so route to "revise" (back to implement).
Node 5 (implement, round 2): Reads review_feedback. Adds validation. Outputs updated files.
Node 6 (test, round 2): all_passed=True with 3 tests now.
Node 7 (review, round 2): Sets approved=True, blockers=[]. Updates iteration=2.
Conditional edge: approved=True, route to END.
Final state: iteration=2, 7 total node activations, 2 complete implement/test/review cycles.

Real-World Application: GitHub Copilot Workspace

GitHub's Copilot Workspace (2024) uses a multi-agent pipeline structurally similar to the one in this section, based on publicly available descriptions of its workflow. Given an issue, it generates a specification (planner), proposes file edits (developer), and runs integrated tests (tester), with the user acting as the reviewer in the loop. The system structures its internal state as a plan with file-level edit descriptions, exactly mirroring the PlanOutput and ImplementOutput schemas shown here.

The Agent That Refused to Ship

In early experiments with multi-agent coding teams (reported by researchers at Microsoft in their AutoGen case studies), reviewer agents prompted to be "thorough and critical" sometimes entered infinite rejection loops, finding new stylistic objections on every iteration and never setting approved=True. The fix was counterintuitive: giving the reviewer a "budget" of allowed objections per round (e.g., at most three blockers) forced it to prioritize and approve once the serious issues were resolved. This is why every production pipeline in this section enforces a max_iterations cap: without one, a perfectionist reviewer agent can burn through your entire API budget rewriting the same function.

Lab: Multi-Agent Pipeline with Mock LLMs

Goal: Build a working four-node LangGraph pipeline (plan, implement, test, review) using deterministic mock functions instead of real LLM calls, then observe how changing the reviewer's behavior alters pipeline dynamics.
Tools needed: Python 3.10+, pip install langgraph pydantic (no API key required since you are mocking the LLM).
Setup (10 min): Copy the TeamState TypedDict and the four node functions from this section. Replace each call_llm invocation with a deterministic function that returns a hardcoded Pydantic model (use the MockLLM pattern from Section 7). Build and compile the graph with MemorySaver checkpointing.
What to vary (15 min): (1) Change the mock reviewer to reject on the first round and approve on the second; verify that iteration reaches 2 and the implement node is called twice. (2) Make the reviewer always reject; confirm the pipeline terminates at max_iterations=3. (3) Add a fifth node (human_approval) with interrupt_before; confirm the graph pauses and resumes correctly.
What to observe: Print state["messages"] after each run to see the full trace of node activations. Call app.get_graph().draw_mermaid() and paste the output into a Mermaid renderer to visualize your workflow, including the conditional back-edge from review to implement.

Exercises

  1. Conceptual: You are tasked with adding a "documentation writer" agent to the team. Where should this agent appear in the workflow graph? What is its input schema (which agents' outputs does it read)? What is its output schema? Should it run in parallel with any existing agent, or must it be sequential? Justify your design with reference to the role design principles from Section 17.1.
  2. Coding: Implement the full issue-to-PR pipeline using one framework of your choice (OpenAI Agents SDK, LangGraph, AutoGen, or CrewAI). Use mock LLMs for testing. The pipeline should support at least two review iterations and include a human approval gate. Write three tests: happy path (approved on first review), one revision cycle (rejected then approved), and budget exceeded (max iterations reached). Bonus: add observability by logging every agent call with its token count and wall-clock time.
  3. Analysis: Run the same GitHub issue through a single-agent pipeline (one agent handles plan, implement, test, review) and a four-agent pipeline. Compare the output quality (correctness, test coverage, code style), token cost, and wall-clock time. Does the multi-agent version produce a better PR? At what issue complexity does the multi-agent advantage appear? Document your methodology and results; this is the kind of empirical evaluation you will formalize in Chapter 23.

What's Next

The multi-agent software team you built in this chapter is a general-purpose pattern. Chapter 18: AI Assisted Testing and QA takes the tester role from our pipeline and expands it into a full testing framework: property-based testing, mutation testing, test generation from specifications, and the feedback loop between test failures and code repair. The tester was one node in our workflow graph; Chapter 18 gives it the depth it deserves.