Swarm — Peer-to-Peer Multi-Agent¶
A Swarm is a mesh of agents that can hand off control to each other by calling handoff_to_<peer> tools. Unlike Supervisor, there is no central coordinator — the currently active agent itself decides when to transfer control and to whom.
Use a Swarm when:
- The routing decision is best made by the specialist, not a coordinator (the specialist knows when they're out of their depth).
- Agents should loop naturally (writer → critic → writer → critic → done) without a hub mediating every round.
- You want lower latency and fewer tokens than a supervisor's fan-in/fan-out.
Use a Supervisor instead when:
- A single LLM should synthesize multiple workers' outputs into one answer.
- The coordinator needs to run the same worker multiple times with different inputs.
- You want the supervisor to be accountable for the final answer.
Quickstart¶
from fastaiagent import Agent, LLMClient, Swarm
llm = LLMClient(provider="openai", model="gpt-4o-mini")
researcher = Agent(
name="researcher",
system_prompt=(
"Research the user's topic. When you have enough material, hand off "
"to the writer to produce the draft."
),
llm=llm,
)
writer = Agent(
name="writer",
system_prompt=(
"Turn research notes into a polished draft. After drafting, hand off "
"to the critic for review."
),
llm=llm,
)
critic = Agent(
name="critic",
system_prompt=(
"Review the draft. If it needs revision, hand off back to the writer "
"with specific feedback. If it's good, produce the final answer."
),
llm=llm,
)
swarm = Swarm(
name="content_team",
agents=[researcher, writer, critic],
entrypoint="researcher",
handoffs={
"researcher": ["writer"],
"writer": ["critic"],
"critic": ["writer"], # critic may send back to writer for revision
},
max_handoffs=6,
)
result = swarm.run("Write a 500-word brief on large language models.")
print(result.output)
How it works¶
Every turn, the currently active agent is cloned and given one handoff_to_<peer> FunctionTool per peer listed in handoffs[current_agent]. When the agent's LLM calls one of those tools:
- The swarm's outer loop notices the handoff in the agent's
tool_calls. - It enforces the allowlist: the target must be in
handoffs[current]. - It bumps
state.handoff_countand checksmax_handoffs. - It constructs a briefing message for the next agent: "
researcherhanded off to you with reason: '...'. Earlier request: '...'. Current shared state: {...}. Please continue." - The next agent runs. When it either produces a final response with no tool call, or calls another handoff, the loop continues.
No central LLM. No state graph. A plain while loop plus tool-call inspection.
API¶
class Swarm:
def __init__(
self,
name: str,
agents: Sequence[Agent],
entrypoint: str,
handoffs: dict[str, list[str]] | None = None,
max_handoffs: int = 8,
): ...
def run(self, input: str, *, context: RunContext | None = None) -> AgentResult: ...
async def arun(self, input: str, *, context=None, **kwargs) -> AgentResult: ...
async def astream(self, input: str, *, context=None, **kwargs) -> AsyncGenerator[StreamEvent, None]: ...
def stream(self, input: str, *, context=None) -> AgentResult: ...
def resume(self, execution_id: str, *, resume_value=None, context=None, **kwargs) -> AgentResult: ...
async def aresume(self, execution_id: str, *, resume_value=None, context=None, **kwargs) -> AgentResult: ...
def to_dict(self) -> dict: ...
@classmethod
def from_dict(cls, data: dict, agents: Sequence[Agent]) -> Swarm: ...
A Swarm implements the same run / arun / astream / stream surface as Agent, so it drops into a Chain node, wraps inside another Swarm, or plugs into anything else that takes an agent-shaped object.
handoffs allowlist¶
The default is full mesh — every agent may hand off to every other agent:
Swarm(name="s", agents=[a, b, c], entrypoint="a")
# equivalent to handoffs={"a": ["b", "c"], "b": ["a", "c"], "c": ["a", "b"]}
Explicit allowlists constrain routing and clarify intent:
Swarm(
name="triage",
agents=[triage, coder, writer, support],
entrypoint="triage",
handoffs={
"triage": ["coder", "writer", "support"], # triage fans out
"coder": [], # specialists terminate
"writer": [],
"support": [],
},
)
Attempting to hand off to a peer not in the allowlist raises SwarmError.
max_handoffs¶
Hard cap on handoffs per run. Exceeding it raises SwarmError with the full path so you can see where the cycle formed.
Default is 8. Tune to your topology: a linear pipeline of 4 agents needs at most 3 handoffs.
Shared blackboard (SwarmState.shared)¶
Handoff tools accept an optional context= dict whose entries merge into SwarmState.shared and are visible to every subsequent agent through the briefing message:
researcher calls handoff_to_writer(
reason="Research complete, draft incoming",
context={"sources": ["arxiv:2024.12345"], "tone": "academic"},
)
The writer sees Current shared state: {'sources': ['arxiv:2024.12345'], 'tone': 'academic'} in its briefing.
Streaming¶
swarm.astream(input) yields the full stream of the currently active agent (TextDelta, ToolCallStart, ToolCallEnd, Usage). When a handoff fires, a single HandoffEvent(from_agent, to_agent, reason) is emitted before the target agent starts streaming:
from fastaiagent.llm.stream import TextDelta, HandoffEvent
async for event in swarm.astream("Write a poem about bridges."):
if isinstance(event, TextDelta):
print(event.text, end="", flush=True)
elif isinstance(event, HandoffEvent):
print(f"\n[{event.from_agent} → {event.to_agent}: {event.reason}]\n")
Composing with other primitives¶
With agent tools¶
Each agent keeps its own tools. Handoff tools are added to the agent's tool list per turn — they don't replace the agent's normal tools:
researcher = Agent(
name="researcher",
llm=llm,
tools=[web_search, read_url], # still available when researcher is active
# …plus any other Agent kwargs you normally pass
)
With ComposableMemory¶
Each agent keeps its own memory. You can share memory across the swarm by passing the same ComposableMemory to multiple agents — they'll all write to, and read from, the same store:
from fastaiagent.agent import ComposableMemory, AgentMemory
from fastaiagent.agent.memory_blocks import VectorBlock, FactExtractionBlock
from fastaiagent.kb.backends.faiss import FaissVectorStore
shared_memory = ComposableMemory(
blocks=[
VectorBlock(store=FaissVectorStore(dimension=384)),
FactExtractionBlock(llm=llm, max_facts=100),
],
primary=AgentMemory(max_messages=30),
)
swarm = Swarm(
name="team",
agents=[
Agent(name="a", llm=llm, memory=shared_memory),
Agent(name="b", llm=llm, memory=shared_memory),
],
entrypoint="a",
)
A swarm run is recorded as one turn: the user's original request and the final answer. The agent that hands off writes nothing, and the agent that answers records the original request rather than the "X handed off to you…" text it received. With separate memories, only the agent that answered records the turn.
See Memory for the full block reference.
With a KB tool¶
An agent that uses a LocalKB as a tool keeps that capability inside the swarm:
from fastaiagent.kb import LocalKB
kb = LocalKB(name="product-docs")
kb.add("docs/")
support = Agent(
name="support",
llm=llm,
tools=[kb.as_tool()],
system_prompt="Search product-docs before answering.",
)
swarm = Swarm(
name="triage_swarm",
agents=[triage, support, billing],
entrypoint="triage",
)
Serialization¶
data = swarm.to_dict()
# {'name': 'triage_swarm', 'agent_names': ['triage', 'support'], 'entrypoint': ..., 'handoffs': {...}, 'max_handoffs': 8}
restored = Swarm.from_dict(data, agents=[triage, support])
to_dict captures the structural data only. The caller must supply the live Agent instances when rehydrating (we don't auto-reconstruct agents — see Agent.to_dict/from_dict).
Swarm vs Supervisor — when to use which¶
| Swarm | Supervisor | |
|---|---|---|
| Coordinator | None | Central LLM |
| Agent wrapping | Raw Agent |
Worker(agent, role, description) |
| Tool auto-injected | handoff_to_<peer> — transfers control |
delegate_to_<role> — executes inline, returns result |
| Control flow | Mesh, peer-to-peer | Star, hub-and-spoke |
| Final answer from | Whichever agent produces a non-handoff response | Supervisor synthesizes |
| Iteration cap | max_handoffs |
max_delegation_rounds |
| Typical use | Specialist networks, loops | Fan-out / synthesis patterns |
Errors¶
from fastaiagent import SwarmError
try:
swarm.run("...")
except SwarmError as e:
# Covers: missing entrypoint, duplicate agent names, unknown peer in
# handoffs, disallowed handoff attempt, max_handoffs exceeded.
print(e)
SwarmError subclasses AgentError, so except AgentError catches both supervisor and swarm failures.
Next Steps¶
- Supervisor / Worker Teams — Centralized delegation topology
- Memory — Add long-term memory to swarm agents
- KB Backends — Give swarm agents searchable knowledge
- Chains — Wrap a swarm as a chain node
What the result carries¶
run, arun, stream and resume all return an AgentResult describing the
whole swarm, not just its last agent:
| Field | Notes |
|---|---|
output |
The final agent's answer |
tool_calls |
Every hop's calls, each tagged with the agent that made it |
tokens_used |
Summed across hops, and across every turn within each hop. Whole-run since 1.68.0 — it used to be the last response of the last hop. |
cost / cost_known |
Summed across hops. cost_known is False if any hop ran a model with no rate — a partial sum is not a total |
guardrails |
Every firing from every agent in the swarm, in order |
trace_id |
The swarm.<name> root span — one trace for the whole run |
execution_id |
Shared by every hop, so resume() picks up the right one |
1.68.0 — stream() reports spend now
Swarm.stream() built its result by hand and left tokens_used, cost and
cost_known at their defaults, so a streamed swarm looked free while the
identical arun() reported real numbers. It now sums the Usage events every
hop yields and reads the same run-scoped cost accumulator arun() uses.
1.67.0
trace_id and guardrails used to be empty on every swarm path. A warn
or mask rule that fired inside a swarm was invisible to the caller (the
hole Agent closed in 1.64.0), and a swarm run had no trace for an eval
case, a replay or the UI to point at. resume() opened no root span at all,
so a resumed swarm's agent spans were emitted as unrelated orphan traces.