Go beyond single-agent loops by coordinating multiple specialised agents. This lesson covers orchestration, message passing, shared state, and error propagation across agent boundaries.
A single agent with many tools works well for moderate tasks. But some problems benefit from splitting into distinct roles:
| Concern | Single Agent | Multi-Agent |
|---|---|---|
| Tool overload | One agent sees 20+ tools; confused selection | Each agent sees only its 3–5 relevant tools |
| Role clarity | System prompt must cover all roles | Each agent has a focused system prompt |
| Parallelism | Sequential tool calls | Fan-out sub-agents in parallel |
| Context budget | One long context | Each agent has a fresh, short context |
Rule of thumb: Start with a single agent. Only move to multi-agent when you observe tool confusion, context overflow, or you need genuinely different expertise in each role.
The most common multi-agent architecture. One orchestrator breaks the task into sub-tasks and delegates each to a specialised sub-agent.
User Task
|
v
+-------------------+
| ORCHESTRATOR | plans, delegates, merges
+-------------------+
| |
v v
+----------+ +--------+
| Researcher| | Writer |
+----------+ +--------+
| |
v v
facts draft
\ /
v v
Final Answer
The orchestrator is itself an LLM call with a system prompt that says: “You are a project manager. Break the task into sub-tasks and delegate each to the appropriate agent.”
Key benefit: Each sub-agent has a narrow system prompt, a small tool set, and a fresh context window — leading to better focus and fewer errors.
Agents communicate by passing structured messages. Keep the contract explicit so agents do not misinterpret each other.
from dataclasses import dataclass
@dataclass
class AgentMessage:
sender: str # "orchestrator", "researcher", "writer"
receiver: str
content: str # natural-language payload
metadata: dict # structured data (e.g. citations, confidence)
# The orchestrator sends a request to the researcher
msg = AgentMessage(
sender="orchestrator",
receiver="researcher",
content="Find 3 key facts about Python GIL.",
metadata={"max_sources": 3}
)
Sometimes agents need to read and write a common data structure — a shared scratchpad, a knowledge base, or a document draft.
import time
class SharedState:
"""Thread-safe shared state for multi-agent systems."""
def __init__(self):
self.data = {}
def write(self, key: str, value: str, author: str):
self.data[key] = {
"value": value,
"author": author,
"updated_at": time.time()
}
def read(self, key: str) -> str | None:
entry = self.data.get(key)
return entry["value"] if entry else None
def read_all(self) -> dict:
return {k: v["value"] for k, v in self.data.items()}
Best practice: Expose shared state through tool definitions so each agent interacts with it via the standard tool-use API rather than through ad-hoc side channels.
In a multi-agent system, a sub-agent failure must bubble up to the orchestrator gracefully.
max_steps without finishing@dataclass
class AgentResult:
status: str # "success", "partial", "error"
content: str # the actual output
error: str = "" # error message if status != "success"
steps_used: int = 0
Live demonstration: an orchestrator coordinating a Researcher and a Writer agent.
Agent class with name, system_prompt, and toolsorchestrate(task, agents) function that plans, delegates, and mergesAgent class and create Researcher + Writer agentsorchestrate(task, agents) that plans and delegatesasyncio.gather