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

17.2 Coordination and Human Gates

"The developer agent and the reviewer agent agreed to disagree, so they created a third agent to mediate. The mediator sided with the developer. The reviewer appealed to a fourth agent. We now have a Supreme Court of code review."

An Escalation Policy That Escalated

Prerequisites

This section builds on the agent roles and workflow graphs from Section 17.1. You should understand the five canonical roles, the four workflow topologies (sequential, parallel, conditional, cyclic), and the state machine formalism. Familiarity with the reasoning patterns from Chapter 4 helps for understanding debate structures.

The Big Picture

Section 17.1 defined who the agents are and what order they execute in. This section tackles the harder coordination questions: How do agents share information? How do they resolve disagreements? When must a human step in? These are the same coordination challenges that real software teams face (shared repositories, design debates, approval workflows), translated into the language of multi-agent systems. The patterns here apply beyond software engineering: the debate protocols reappear in Chapter 54 for scientific hypothesis evaluation, and the human gates become critical in Chapter 55 where autonomous agents control physical laboratory instruments.

Figure 1 illustrates the full coordination architecture covered in this section. The blackboard sits at the center; view functions filter its contents for each agent; and human gates pause the pipeline at security-critical, cost-critical, or quality-critical decision points before the workflow continues.

Blackboard (shared state) pm_view arch_view dev_view rev_view PM Agent Architect Developer Reviewer Security Gate human review Cost Gate human review PR Approval human review approve / reject / modify approve / reject / modify Agent node View function Blackboard Human gate
Figure 1: Coordination architecture for a multi-agent software team. The blackboard holds shared state; view functions filter it per agent; human gates pause the workflow at security, cost, and quality checkpoints.

1. Shared State: The Blackboard Pattern

Picture five AI agents working on a single pull request: the PM writes requirements, the architect designs a solution, and the developer starts coding, only to discover that the requirements changed two steps ago and nobody relayed the update. Without a coordination protocol, multi-agent teams fail the same way human teams do: through lost context, contradictory decisions, and unreviewed commits reaching production.

In a multi-agent workflow, every agent needs access to the evolving state of the software artifact: the requirements, the design, the current code, the test results, the review comments. The simplest coordination pattern is the blackboard: a shared data structure that all agents can read and write. Each agent reads the fields it needs, performs its work, and writes its output back to the blackboard. The workflow executor passes the blackboard to each agent in sequence.

A blackboard is a single mutable object (typically a dataclass or dictionary) that every agent in the pipeline reads from and writes to. Without this shared memory, agents would require explicit point-to-point wiring for every possible pair, scaling quadratically. The workflow executor holds one blackboard instance, passes it to each agent in turn, and the agent mutates the fields it owns before returning control. Use a blackboard when your team is small (fewer than roughly ten agents) and agents need overlapping subsets of the state. Switch to typed message channels (Section 2 below) when context window pressure or strict interface contracts outweigh implementation simplicity. In short: Without shared state, structured disagreement, and human checkpoints at high-stakes decisions, even capable agents produce correct code that breaks real systems.

from dataclasses import dataclass, field
from datetime import datetime

@dataclass
class Blackboard:
    """Shared state for a multi-agent software team.

    Every agent reads from and writes to this structure.
    The workflow executor passes it between agent invocations.
    """
    # input
    issue: dict = field(default_factory=dict)
    repository_context: dict = field(default_factory=dict)

    # PM output
    requirements: dict | None = None

    # Architect output
    design: dict | None = None
    api_contracts: list[dict] = field(default_factory=list)

    # Developer output
    file_changes: dict[str, str] = field(default_factory=dict)
    diff: str = ""

    # Tester output
    test_files: dict[str, str] = field(default_factory=dict)
    test_results: dict | None = None

    # Reviewer output
    review: dict | None = None
    approved: bool = False

    # Metadata
    iteration: int = 0
    total_tokens: int = 0
    history: list[dict] = field(default_factory=list)

    def record_step(self, agent_name: str, tokens: int, summary: str):
        """Log an agent execution for observability."""
        self.history.append({
            "agent": agent_name,
            "iteration": self.iteration,
            "tokens": tokens,
            "summary": summary,
            "timestamp": datetime.utcnow().isoformat(),
        })
        self.total_tokens += tokens
A Blackboard dataclass serving as shared state for the entire multi-agent team, with per-step observability logging via record_step.

The blackboard pattern is simple but has a limitation: every agent sees the entire state, which can be overwhelming for the large language model (LLM) context window. A 200-line diff, three pages of requirements, an architect's design document, and 50 test results can easily exceed 100,000 tokens. We need a way to give each agent only the state it needs.

2. Typed Message Channels

An alternative to the monolithic blackboard is typed message passing: each edge in the workflow graph carries a specific message type, and each agent receives only the messages sent to it. This is the multi-agent analogue of function parameters: the developer receives a DesignDocument, not the entire blackboard. The message schemas below use Pydantic, where BaseModel is a declarative class whose fields are automatically validated at construction time (see the Pydantic documentation for details).

from pydantic import BaseModel, Field

class DesignMessage(BaseModel):
    """Message from Architect to Developer."""
    design_summary: str
    file_changes: list[dict] = Field(
        description="List of {path, change_type, description} dicts"
    )
    api_contracts: list[dict]
    constraints: list[str] = Field(
        description="Non-functional requirements: perf, security, etc."
    )

class CodeMessage(BaseModel):
    """Message from Developer to Tester and Reviewer."""
    diff: str
    files_changed: list[str]
    implementation_notes: str
    known_limitations: list[str] = []

class TestResultMessage(BaseModel):
    """Message from Tester to Reviewer."""
    tests_written: int
    tests_passed: int
    tests_failed: int
    coverage_percent: float
    failure_details: list[dict] = []

class ReviewMessage(BaseModel):
    """Message from Reviewer back to Developer (on rejection)."""
    approved: bool
    blockers: list[dict]       # must-fix issues
    warnings: list[dict]       # should-fix issues
    suggestions: list[dict]    # nice-to-have improvements
    summary: str


class TypedChannel:
    """A typed communication channel between two agents."""

    def __init__(self, name: str, message_type: type[BaseModel]):
        self.name = name
        self.message_type = message_type
        self._messages: list[BaseModel] = []

    def send(self, message: BaseModel):
        """Validate and enqueue a message."""
        if not isinstance(message, self.message_type):
            raise TypeError(
                f"Channel '{self.name}' expects {self.message_type.__name__}, "
                f"got {type(message).__name__}"
            )
        self._messages.append(message)

    def receive(self) -> BaseModel | None:
        """Dequeue the next message, or None if empty."""
        return self._messages.pop(0) if self._messages else None
Pydantic-validated message types (DesignMessage, CodeMessage, TestResultMessage, ReviewMessage) and a TypedChannel that enforces schema contracts at every agent boundary.
Key Insight: Blackboard vs. Message Passing

The blackboard and message-passing patterns represent a classic trade-off in distributed systems. The blackboard is simpler to implement and gives every agent full visibility, but it couples agents to a shared schema and can overwhelm context windows. Message passing is more modular (each agent depends only on its input channel type), scales better to large teams, and naturally limits context size. In practice, most multi-agent frameworks use a hybrid: a shared state object (like LangGraph's TypedDict) with per-agent view functions that select which fields each agent sees. This gives you the simplicity of a blackboard with the context control of message passing.

3. View Functions: Context-Aware State Projection

A view function projects the full blackboard state into the subset that a specific agent needs. This is the multi-agent analogue of the context engineering techniques from Chapter 11: just as you select which files to include in an LLM's context window, you select which state fields to include in an agent's input.

from typing import Callable

# Each view function takes the full state and returns
# a filtered dict for the agent's system prompt context
ViewFunction = Callable[[Blackboard], dict]

def pm_view(state: Blackboard) -> dict:
    """PM sees only the raw issue and repository structure."""
    return {
        "issue": state.issue,
        "repo_structure": state.repository_context.get("structure", {}),
    }

def architect_view(state: Blackboard) -> dict:
    """Architect sees PM output plus repository context."""
    return {
        "requirements": state.requirements,
        "repo_structure": state.repository_context.get("structure", {}),
        "existing_patterns": state.repository_context.get("patterns", []),
    }

def developer_view(state: Blackboard) -> dict:
    """Developer sees design, requirements, and review feedback if cycling."""
    view = {
        "design": state.design,
        "requirements": state.requirements,
        "api_contracts": state.api_contracts,
    }
    if state.review and not state.approved:
        # on review rejection, include the feedback
        view["review_feedback"] = state.review
        view["previous_diff"] = state.diff
    return view

def reviewer_view(state: Blackboard) -> dict:
    """Reviewer sees the diff, design, requirements, and test results."""
    return {
        "diff": state.diff,
        "design": state.design,
        "requirements": state.requirements,
        "test_results": state.test_results,
        "iteration": state.iteration,
    }
Four view functions (pm_view, architect_view, developer_view, reviewer_view) that project the full blackboard into agent-specific context slices.

4. Debate Patterns

Not every agent interaction is a handoff. Sometimes agents need to debate: argue for different approaches, critique each other's proposals, and converge on a decision. Du et al. (2023) showed that multi-agent debate improves factual accuracy on reasoning tasks. In software engineering, debate is useful for design decisions where multiple valid approaches exist.

4.1 Structured Debate Protocol

A structured debate has three phases: proposal (each agent states its position), critique (each agent responds to the others), and resolution (a judge agent or voting mechanism selects the winner). Debate must use typed proposals and explicit critique dimensions rather than free-form chat. LLMs are trained to be agreeable, so free-form debate tends to devolve into consensus; without explicit adversarial framing, agents converge on the first proposal.

Mental Model

Structured debate as a Chopped-style competitive cooking show with mandatory critique rounds

Think of a structured debate like a competitive cooking show (such as Chopped). In round one (proposal), each chef prepares a dish independently using the same basket of ingredients. In round two (critique), the chefs taste each other's dishes and point out what works, what does not, and what they would change. In round three (resolution), the judges weigh the critiques and pick a winner, often suggesting the winning chef incorporate a technique from one of the losing dishes. The structure matters: if you just let the chefs talk casually, they would politely agree that everyone's dish is good. The timed rounds, mandatory critique, and independent judge force genuine evaluation, exactly as typed proposals, explicit critique dimensions, and a separate judge agent do for LLM debate.

from enum import Enum

class DebatePhase(Enum):
    PROPOSAL = "proposal"
    CRITIQUE = "critique"
    RESOLUTION = "resolution"

@dataclass
class Proposal:
    """A design proposal from one agent."""
    agent_name: str
    approach: str
    rationale: str
    trade_offs: list[str]
    estimated_complexity: str
    code_sketch: str  # pseudocode or skeleton

@dataclass
class Critique:
    """A critique of another agent's proposal."""
    critic_name: str
    target_proposal: str      # agent_name of the proposal being critiqued
    strengths: list[str]
    weaknesses: list[str]
    questions: list[str]
    recommendation: str       # "accept", "modify", "reject"

@dataclass
class DebateResult:
    """The outcome of a structured debate."""
    winning_proposal: str
    rationale: str
    modifications: list[str]  # changes to the winning proposal
    dissenting_opinions: list[str]


async def run_debate(
    question: str,
    debaters: list[AgentRole],
    judge: AgentRole,
    run_agent: Callable,
    context: dict,
) -> DebateResult:
    """Run a structured three-phase debate between agents.

    AgentRole and WorkflowGraph are defined in Section 17.1.
    asyncio.gather runs all coroutines concurrently and returns
    their results as a list once every coroutine completes.
    """

    # Phase 1: Proposals (parallel)
    proposals = await asyncio.gather(*[
        run_agent(agent, {
            "phase": DebatePhase.PROPOSAL,
            "question": question,
            "context": context,
        })
        for agent in debaters
    ])

    # Phase 2: Critiques (each agent critiques all others, parallel)
    critique_tasks = []
    for i, agent in enumerate(debaters):
        other_proposals = [p for j, p in enumerate(proposals) if j != i]
        critique_tasks.append(
            run_agent(agent, {
                "phase": DebatePhase.CRITIQUE,
                "own_proposal": proposals[i],
                "other_proposals": other_proposals,
                "context": context,
            })
        )
    critiques = await asyncio.gather(*critique_tasks)

    # Phase 3: Resolution (judge evaluates all proposals and critiques)
    result = await run_agent(judge, {
        "phase": DebatePhase.RESOLUTION,
        "proposals": proposals,
        "critiques": critiques,
        "question": question,
        "context": context,
    })

    return result
Three-phase debate via run_debate: agents propose in parallel, cross-critique, then a judge agent selects and modifies the winning proposal.

4.2 When to Use Debate

Debate is valuable when the design space has multiple plausible solutions and the cost of choosing wrong is high. Use debate for:

Do not use debate for routine implementation tasks where the design is already specified. Having three agents debate the best way to write a for-loop wastes tokens and time.

Practical Example: Database Selection Debate

A research team needs to store 10 million molecular structures with similarity search capability. The PM specifies the requirements: sub-second similarity queries, batch import of 100K molecules per hour, and integration with the Discovery Workbench. Instead of letting the architect choose alone, we run a debate with two specialist agents: a relational advocate (proposes PostgreSQL with the RDKit cartridge for chemical similarity) and a vector advocate (proposes Chroma with molecular fingerprint embeddings). Each writes a proposal with benchmarks and trade-offs. The critique phase reveals that the relational approach handles exact substructure search better, while the vector approach handles fuzzy similarity better. The judge (the architect agent) resolves by recommending a hybrid: PostgreSQL for structured metadata and exact queries, Chroma for similarity search. Neither debater proposed this solution individually; it emerged from the adversarial process.

Debate resolves disagreements between agents, but some decisions are too consequential for any combination of agents to make alone.

5. Human Approval Gates

When an autonomous coding agent deploys a database migration without review, dropping a column that three downstream services depend on, the resulting outage is not a failure of code quality; it is a failure of coordination. The agent's diff was correct in isolation. What was missing was a checkpoint where a human could recognize the cross-service dependency before the change reached production.

The most important coordination mechanism in a multi-agent team is not between agents; it is between agents and humans. Human approval gates are checkpoints where the workflow pauses and presents a decision to a human operator. The human can approve (continue), reject (halt), or modify (edit the state before continuing). Figure 17.2.1 illustrates multi-agent workflow with human approval gates.

Multi-agent workflow with human approval gates
Figure 17.2.1: Multi-agent software pipeline with human approval gates at security-critical and quality-critical decision points, showing approve/reject/modify routing and the shared blackboard state layer

5.1 When to Gate

Not every agent output needs human review. Gates should be placed at decision points where the cost of a wrong decision is high relative to the cost of human review time. Three categories warrant gates:

Security-critical decisions. Any action that modifies access controls, handles credentials, or changes security-sensitive code. The reviewer agent may flag a potential SQL injection, but a human must decide whether the risk is real. See Chapter 20 for deeper treatment.

Cost-critical decisions. Actions that incur significant cost: deploying to production, provisioning cloud resources, purchasing API credits. The architect might recommend a solution that costs \$500/month in cloud compute; a human should approve that commitment.

Quality-critical decisions. The final pull request (PR) approval before merge. Even if the reviewer agent approves, a human should review the diff for a new feature that affects user-facing behavior. This is the "four-eyes principle" (the requirement that at least two people review any critical change before it takes effect) applied to AI-generated code.

from enum import Enum
from typing import Any

class GateDecision(Enum):
    APPROVE = "approve"
    REJECT = "reject"
    MODIFY = "modify"

class GateType(Enum):
    SECURITY = "security"
    COST = "cost"
    QUALITY = "quality"

@dataclass
class HumanGate:
    """A checkpoint where the workflow pauses for human review."""
    name: str
    gate_type: GateType
    description: str
    required_fields: list[str]  # state fields to show the human

    def should_trigger(self, state: Blackboard) -> bool:
        """Determine whether this gate activates given current state."""
        raise NotImplementedError

    def format_for_review(self, state: Blackboard) -> dict:
        """Extract and format the information the human needs to decide."""
        return {
            field: getattr(state, field, None)
            for field in self.required_fields
        }


class PRApprovalGate(HumanGate):
    """Gate before merging a pull request."""

    def __init__(self):
        super().__init__(
            name="pr_approval",
            gate_type=GateType.QUALITY,
            description="Review the final PR before merge",
            required_fields=["diff", "test_results", "review", "requirements"],
        )

    def should_trigger(self, state: Blackboard) -> bool:
        # always trigger for final PR approval
        return state.approved

class SecurityReviewGate(HumanGate):
    """Gate when security-sensitive files are modified."""

    SENSITIVE_PATTERNS = [
        "auth", "security", "password", "token", "secret",
        "credential", "permission", "acl", "rbac",
    ]

    def __init__(self):
        super().__init__(
            name="security_review",
            gate_type=GateType.SECURITY,
            description="Security-sensitive files were modified",
            required_fields=["diff", "file_changes", "review"],
        )

    def should_trigger(self, state: Blackboard) -> bool:
        changed_files = list(state.file_changes.keys())
        return any(
            pattern in f.lower()
            for f in changed_files
            for pattern in self.SENSITIVE_PATTERNS
        )
HumanGate base class with two concrete subclasses: PRApprovalGate (quality) and SecurityReviewGate (security), each with its own should_trigger logic.

5.2 Gate Integration in the Workflow

Gates are nodes in the WorkflowGraph (the directed graph of agent steps defined in Section 17.1), just like agent nodes. When the executor reaches a gate node, it pauses execution, formats the relevant state for human review, and waits for a response. The gate's output (approve, reject, modify) determines which edge the workflow follows next.

async def execute_with_gates(
    graph: WorkflowGraph,
    state: Blackboard,
    gates: list[HumanGate],
    run_agent: Callable,
    request_human_review: Callable,  # async fn that blocks until human responds
) -> Blackboard:
    """Execute a workflow graph with human approval gates."""
    current_node = graph.entry_point

    while current_node is not None:
        # check if any gate should trigger before this node
        for gate in gates:
            if gate.should_trigger(state):
                review_data = gate.format_for_review(state)
                decision = await request_human_review(
                    gate_name=gate.name,
                    gate_type=gate.gate_type.value,
                    description=gate.description,
                    data=review_data,
                )

                if decision.action == GateDecision.REJECT:
                    state.history.append({
                        "event": "human_rejection",
                        "gate": gate.name,
                        "reason": decision.reason,
                    })
                    return state  # halt the workflow

                if decision.action == GateDecision.MODIFY:
                    # apply human modifications to state
                    for key, value in decision.modifications.items():
                        setattr(state, key, value)

        # run the agent at this node
        role = graph.nodes[current_node]
        view_fn = graph.view_functions.get(current_node, lambda s: vars(s))
        agent_input = view_fn(state)

        result = await run_agent(role, agent_input)

        # update state with agent output
        state = update_state(state, current_node, result)

        # determine next node
        current_node = graph.get_next(current_node, result)

    return state
execute_with_gates: the workflow executor checks every registered gate before each node, pauses for human input when a gate triggers, and routes on approve, reject, or modify.

Common Misconception

A common misconception is that human gates exist only as a temporary safety net, to be removed once the agents "get good enough." In reality, some gates are permanent by design. Security-critical and cost-critical gates reflect organizational policy and regulatory requirements, not agent capability. Even a multi-agent team with a perfect track record should retain gates on actions like deploying to production or modifying access controls, because the gate enforces accountability and auditability, not just error correction.

Real-World Application: GitHub Copilot Workspace
Real-World Application: GitHub Copilot Workspace
Key Insight: Progressive Autonomy

The right number of human gates depends on trust, which builds over time. A new multi-agent team should gate every PR, every security-sensitive change, and every cost decision. As the team proves reliable (measured by the fraction of human approvals that are rubber-stamps), you can relax gates selectively. Start with gates everywhere and remove them as data justifies it. This mirrors how human teams earn autonomy: junior developers get every PR reviewed; senior developers get auto-merge privileges for low-risk changes. The evaluation framework in Chapter 23 provides the metrics (pass rate, revert rate, security incident rate) that drive these decisions.

6. Event-Driven Coordination

In production multi-agent systems, coordination is often event-driven rather than graph-traversal-driven. Agents subscribe to events (new issue filed, PR created, test suite completed) and react asynchronously. This is the publish-subscribe pattern, where publishers emit named events and subscribers register handler functions that fire when those events occur. Event-driven coordination is more flexible than a fixed workflow graph: it supports dynamic team composition, late-joining agents, and parallel independent workflows.

from collections import defaultdict
import asyncio

class EventBus:
    """A publish-subscribe event bus for agent coordination."""

    def __init__(self):
        self._subscribers: dict[str, list[Callable]] = defaultdict(list)

    def subscribe(self, event_type: str, handler: Callable):
        """Register a handler for an event type."""
        self._subscribers[event_type].append(handler)

    async def publish(self, event_type: str, payload: dict):
        """Publish an event to all subscribers."""
        handlers = self._subscribers.get(event_type, [])
        await asyncio.gather(*[
            handler(payload) for handler in handlers
        ])


# usage: wire agents to events
bus = EventBus()

async def on_issue_created(payload):
    """PM agent reacts to new issues."""
    requirements = await run_agent(pm_role, payload)
    await bus.publish("requirements_ready", requirements)

async def on_requirements_ready(payload):
    """Architect agent reacts to completed requirements."""
    design = await run_agent(architect_role, payload)
    await bus.publish("design_ready", design)

async def on_tests_failed(payload):
    """Developer agent reacts to test failures."""
    fix = await run_agent(developer_role, {
        "task": "fix_test_failures",
        "failures": payload["failure_details"],
        "current_code": payload["code"],
    })
    await bus.publish("code_updated", fix)

bus.subscribe("issue_created", on_issue_created)
bus.subscribe("requirements_ready", on_requirements_ready)
bus.subscribe("tests_failed", on_tests_failed)
An EventBus with publish-subscribe routing: each agent subscribes to specific event types and publishes new events to trigger downstream agents.
Library Shortcut: AutoGen Conversations

The event-driven coordination pattern we built in ~50 lines is the core abstraction of AutoGen. AutoGen models multi-agent coordination as conversations: agents send messages to each other (or to a group chat), and the framework handles routing, turn-taking, and termination. The same PM-to-Architect-to-Developer pipeline takes about 25 lines:

from autogen import AssistantAgent, UserProxyAgent, GroupChat, GroupChatManager

pm = AssistantAgent("pm", system_message="You are a product manager...")
architect = AssistantAgent("architect", system_message="You are an architect...")
developer = AssistantAgent("developer", system_message="You are a developer...")
reviewer = AssistantAgent("reviewer", system_message="You are a reviewer...")

# human proxy for approval gates
human = UserProxyAgent(
    "human",
    human_input_mode="TERMINATE",  # ask human only at the end
    code_execution_config={"work_dir": "workspace"},
)

group_chat = GroupChat(
    agents=[pm, architect, developer, reviewer, human],
    messages=[],
    max_round=20,
)
manager = GroupChatManager(groupchat=group_chat)
human.initiate_chat(manager, message="Implement CSV export for the dashboard")
AutoGen's GroupChat wiring four role agents and one UserProxyAgent for human-in-the-loop approval in ~25 lines.

AutoGen's strength is flexibility: agents can join and leave conversations, the conversation topology can change mid-execution, and the UserProxyAgent provides built-in human-in-the-loop support. The trade-off: the unstructured conversation format makes it harder to enforce strict workflow ordering and typed interfaces compared to LangGraph's graph-based approach. As of 2025, AutoGen has been substantially restructured into version 0.4 (also known as AG2), which replaces the AssistantAgent/UserProxyAgent/GroupChat API shown above with an event-driven, asynchronous architecture built on typed messages and agent runtimes. The core concepts (multi-agent conversations, human-in-the-loop proxies) remain, but production code should target the 0.4 API.

Whether coordination follows a fixed graph or a reactive event bus, any long-running pipeline is vulnerable to mid-flight failures that would force a complete restart without safeguards.

7. State Persistence and Recovery

Multi-agent workflows run for minutes to hours, and a mid-flight failure (API timeout, rate limit, model outage) should not force a full restart. State persistence checkpoints the blackboard after every agent step, letting the workflow resume from the last successful node.

import json
from pathlib import Path

class CheckpointManager:
    """Persist and recover workflow state across failures."""

    def __init__(self, checkpoint_dir: Path):
        self.checkpoint_dir = checkpoint_dir
        self.checkpoint_dir.mkdir(parents=True, exist_ok=True)

    def save(self, workflow_id: str, state: Blackboard, step: str):
        """Save a checkpoint after a successful agent step."""
        path = self.checkpoint_dir / f"{workflow_id}_{step}.json"
        data = {
            "step": step,
            "iteration": state.iteration,
            "total_tokens": state.total_tokens,
            "approved": state.approved,
            "requirements": state.requirements,
            "design": state.design,
            "diff": state.diff,
            "test_results": state.test_results,
            "review": state.review,
            "history": state.history,
        }
        path.write_text(json.dumps(data, indent=2, default=str))

    def load_latest(self, workflow_id: str) -> tuple[str, dict] | None:
        """Load the most recent checkpoint for a workflow."""
        checkpoints = sorted(
            self.checkpoint_dir.glob(f"{workflow_id}_*.json"),
            key=lambda p: p.stat().st_mtime,
            reverse=True,
        )
        if not checkpoints:
            return None
        data = json.loads(checkpoints[0].read_text())
        return data["step"], data

    def resume_from(self, workflow_id: str, graph: WorkflowGraph) -> str | None:
        """Determine the next node to execute after a checkpoint."""
        result = self.load_latest(workflow_id)
        if result is None:
            return graph.entry_point
        last_step, state_data = result
        # find the node after the last completed step
        return graph.get_next(last_step, state_data)
CheckpointManager: serializes the blackboard to JSON after every agent step and locates the resume point via resume_from after a failure.
Research Frontier: Learned Coordination Policies

Current multi-agent systems use hand-designed coordination protocols (fixed graphs, static debate rounds, manual gate placement). Recent work explores learned coordination: training a meta-agent (a higher-level agent whose sole job is to manage other agents) that decides which agent to invoke next, when to trigger debate, and where to place approval gates. DyLAN (Liu et al., 2023) introduced dynamic agent selection that outperforms fixed topologies by 10-15% on code generation benchmarks. More recently, AgentVerse (Chen et al., 2023) demonstrated an autonomous group of LLM agents that dynamically adjust their team composition, communication topology, and role assignments at runtime based on task difficulty. On collaborative coding and reasoning benchmarks, AgentVerse's adaptive coordination outperformed the best static multi-agent layouts by margins of 10-20%. Meanwhile, Anthropic's multi-agent research (2025) on "tool-use agents supervising tool-use agents" suggests that hierarchical coordination (arranging agents in a tree where a planning agent delegates to and audits specialist agents) with explicit approval checkpoints can reduce cascading errors in long-horizon coding tasks. These developments suggest that future coordination protocols will be partially learned and partially specified by policy, rather than fully hand-designed.

Checkpointing protects against wasted computation from failures, but even successful runs can become expensive when every agent in the pipeline consumes a full LLM call.

8. Cost and Latency Management

Multi-agent workflows multiply both cost and latency. A five-agent sequential pipeline with three review rounds makes \(5 + 3 \times 2 = 11\) LLM calls. If each call costs \$0.20 at typical 2024-2025 pricing and takes 15 seconds, the total is \$2.20 and 2.75 minutes. At just 100 issues per day, that single pipeline costs \$220 daily and occupies nearly five hours of serial wall-clock time, before any retry loops. For production systems processing hundreds of issues per day, cost management is essential.

Three strategies keep costs under control:


Tiered models. Not every agent needs the most capable model. The PM and architect benefit from a strong reasoning model (GPT-4o, Claude Sonnet); the developer may use a coding-specialized model (Claude Sonnet with coding prompts); and the tester can use a smaller, faster model for straightforward test generation. The reviewer, whose job is adversarial critique, benefits from a strong model.

Early termination. If the tester finds that all tests pass on the first try, skip the review loop entirely (or limit it to a single pass). If the PM determines that the issue is a one-line typo fix, bypass the architect and route directly to the developer.

Caching. If two issues require the same repository context (directory structure, coding patterns, test framework), cache the context extraction once and reuse it. LLM prompt caching (available in both OpenAI and Anthropic APIs) can reduce costs by 50-90% for the shared prefix portions of prompts.

Checkpoint

So far: three strategies keep multi-agent costs manageable: assign cheaper models to routine roles (tiered models), skip unnecessary steps when early signals are positive (early termination), and reuse repeated context across runs (caching).

Try It: Build a Gated Two-Agent Review Pipeline

Build a minimal multi-agent workflow with a human approval gate using only Python and the openai library (or any LLM API client).

  1. Define the blackboard. Create a Python dataclass with fields for task_description (str), draft_code (str), review_comments (str), and approved (bool). Initialize it with a small coding task such as "Write a function that computes the Fibonacci sequence up to n terms."
  2. Implement the developer agent. Write a function that sends the task_description (and any review_comments from a prior round) to an LLM with a system prompt like "You are a Python developer. Write clean, tested code." Store the response in draft_code.
  3. Implement the reviewer agent. Write a second function that sends the draft_code and task_description to an LLM with the system prompt "You are a code reviewer. List blockers, warnings, and suggestions as JSON." Parse the structured output and store it in review_comments.
  4. Add a human gate. After the reviewer runs, print the review summary to the console and prompt the user with input("Approve, reject, or modify? "). On "approve," set approved = True and exit. On "reject," halt the workflow. On "modify," let the user type additional instructions, append them to review_comments, and loop back to the developer agent.
  5. Run and observe. Execute the pipeline end to end. Note how many iterations the loop takes, how many tokens each agent consumes (print the usage from the API response), and whether the human gate catches issues the reviewer missed. Try adding a max_iterations = 3 guard to prevent runaway loops.

Exercises

  1. Conceptual: A multi-agent team processes a security-sensitive change that modifies the authentication module. List all the gates that should trigger, in what order, and what information each gate should present to the human reviewer. How does the gate placement change if the team has a proven track record of 500 successful security changes with zero incidents?
  2. Coding: Implement a DebateManager class that runs a two-round debate between three agents on a design question. In round one, each agent proposes an approach. In round two, each agent critiques the other two proposals. Use asyncio.gather for parallelism within each round. The manager should produce a structured DebateResult with the winning proposal and dissenting opinions. Test with three agents debating "REST vs. GraphQL vs. gRPC for a scientific data API."
  3. Analysis: Compare the token cost of a blackboard-based workflow (every agent receives the full state) vs. a message-passing workflow (each agent receives only its input channel) for a team processing an issue that generates a 500-line diff. Assume the blackboard contains 2,000 tokens of requirements, 3,000 tokens of design, the 500-line diff (5,000 tokens), 1,500 tokens of test results, and 1,000 tokens of review comments. How many tokens does each agent see in each approach? What is the total token consumption across all five agents?

Exercise 17.2.1

A five-agent pipeline (PM, Architect, Developer, Tester, Reviewer) processes an issue that modifies both an authentication module and a billing endpoint. Using the SecurityReviewGate pattern from this section, write the should_trigger method for a new CostReviewGate that fires whenever file paths contain "billing", "payment", "pricing", or "subscription". Then list all gates that would trigger for this issue and the order in which the workflow executor would evaluate them.

Hint

Model your CostReviewGate directly on SecurityReviewGate: define a COST_PATTERNS list and iterate over state.file_changes.keys() the same way. For ordering, remember that execute_with_gates checks gates in list order before each node, so both the security gate and your cost gate fire before the same node (whichever node runs after the Developer writes the diff). The human sees the security gate first, then the cost gate, both before the Reviewer executes.

Step-Through: Three-Phase Debate Resolution

Trace through run_debate with two debaters (Agent A, Agent B) and one judge, debating "SQL vs. NoSQL for a 10M-row sensor log."

Phase 1 (Proposal, parallel). Agent A proposes PostgreSQL with TimescaleDB (rationale: SQL joins for cross-sensor queries; trade-off: vertical scaling limits). Agent B proposes MongoDB with time-series collections (rationale: schema flexibility for heterogeneous sensors; trade-off: no cross-collection joins). Both calls run via asyncio.gather, so wall-clock time equals one LLM call (~4 s), not two.

Phase 2 (Critique, parallel). Agent A critiques Agent B: strength is schema flexibility, weakness is that the analytics team needs SQL joins, recommendation is "modify." Agent B critiques Agent A: strength is query expressiveness, weakness is that rigid schemas slow onboarding of new sensor types, recommendation is "modify." Again both calls are parallel (~4 s).

Phase 3 (Resolution, sequential). The judge sees two proposals and two critiques. It selects Agent A's PostgreSQL/TimescaleDB as the winner but adds a modification from Agent B's critique: use a JSONB column for sensor-specific metadata so new sensor types do not require schema migrations. Total wall-clock: ~12 s (three sequential phases), total LLM calls: 5 (2 + 2 + 1).

Real-World Application: GitHub Copilot Workspace

GitHub Copilot Workspace (launched 2024) uses a multi-agent pipeline with explicit human gates at two points: after the "specification" agent drafts a plan (the user can edit the plan before implementation begins) and after the "implementation" agent produces code (the user reviews the diff before creating a pull request). This two-gate design mirrors the PM-gate and PR-gate pattern from this section, and early reports from GitHub indicated that users modify the specification in roughly 40% of sessions, suggesting that human gates catch meaningful errors rather than serving as rubber stamps.

The Abilene Paradox, Now in Silicon

In 1974, management researcher Jerry Harvey described the "Abilene Paradox": a family drives 50 miles to Abilene, Texas for dinner, only to discover that nobody actually wanted to go; each person agreed because they assumed the others wanted to. LLM agents exhibit the same behavior. Because language models are trained on human text that rewards agreeableness, multi-agent debates without explicit adversarial framing tend to converge on the first proposal in a large majority of trials (Du et al., 2023). The structured debate protocol in this section exists precisely to prevent a committee of polite AIs from driving to Abilene.

Lab: Measuring Gate Effectiveness with a Simulated Review Pipeline

Goal: Quantify how often human gates catch errors that automated review agents miss, and measure the token cost of different gate placements.

Tools needed: Python 3.10+, the openai library (or any LLM API client), and a set of 10 small coding tasks (use LeetCode Easy problems or write your own).

Setup (5 min): Implement the two-agent pipeline from the "Try It" exercise (developer + reviewer) with a Blackboard dataclass. Add token counting by reading response.usage.total_tokens from each API call. Log every agent output and the final code to a JSON file.

Experiment (15 min): Run the pipeline on all 10 tasks in three configurations: (A) no gate, auto-approve after the reviewer says "approve"; (B) gate after reviewer, where you manually inspect and can reject; (C) gate after developer AND after reviewer. For each run, record: number of iterations, total tokens, and whether the final code is correct (test it).

What to vary: Try using a weaker model (e.g., GPT-4o-mini) for the developer and a stronger model for the reviewer, then swap. Observe how model assignment changes the number of rejections and gate interventions.

What to observe: Compare correctness rates across configurations A, B, and C. Calculate the "gate value" as the percentage of tasks where your manual intervention changed the outcome. Plot total tokens vs. correctness for each configuration to visualize the cost/quality trade-off.

What's Next

With coordination patterns and human gates in place, Section 17.3: Building a Software Agent Team puts everything together into a complete, runnable system. It implements a multi-agent team that takes a GitHub issue and produces a reviewed pull request, using four different frameworks (OpenAI Agents SDK, LangGraph, AutoGen, CrewAI) to compare their approaches to the same problem.