Swarm
from loomflow.architecture import Swarm, HandoffSwarm is a group of peer agents with no coordinator: whichever
agent is active either answers or hands the conversation to another
peer with a handoff tool. Use it for exploratory systems where you
can’t map out the flow in advance. It behaves like OpenAI’s Swarm, the
OpenAI Agents SDK’s handoffs and LangGraph Swarm: a handoff ends the
current agent’s turn at once, the next agent sees the shared thread,
and a follow-up message goes to whoever answered last.
For the conceptual page see Swarm.
Production warning. Use only for exploratory or research-mode systems where flow can’t be pre-specified. For production, prefer Supervisor (clear authority) or Router (single specialist owns the answer). Swarm has goal-drift and deadlock failure modes that hierarchical / graph topologies don’t.
Class signature
class Swarm:
name: str = "swarm"
def __init__(
self,
*,
agents: dict[str, Agent | Handoff],
entry_agent: str,
max_handoffs: int = 8,
detect_cycles: bool = True,
pass_full_history: bool = True,
handoff_tool_name: str = "handoff",
handoffs: Mapping[str, Sequence[str]] | None = None,
resume_last_agent: bool = True,
worker_registry: dict[str, Any] | None = None,
role_to_worker_id: dict[str, str] | None = None,
) -> None: ...Constructor parameters
agents
| Type | dict[str, Agent | Handoff] |
| Default | required |
Peer agents keyed by name. Plain Agent values get a default
Handoff config (untyped, free-form messages); wrap a peer with
Handoff(...) to declare a typed handoff (a per-target tool with a
structured argument schema). Must be non-empty.
entry_agent
| Type | str |
| Default | required |
Name of the peer that receives the first user message. Must be a
key in agents; raises ValueError otherwise.
max_handoffs
| Type | int |
| Default | 8 |
Maximum handoffs in one run. When the cap is reached the swarm emits
swarm.max_handoffs, the peer just handed the conversation answers
once more with no handoff tools, and the run ends with
interrupted=True, interruption_reason="swarm:max_handoffs". Must
be >= 0.
detect_cycles
| Type | bool |
| Default | True |
When True (default), two peers bouncing the conversation back and
forth three times (the last 6 handoffs alternating A→B, B→A) trip
cycle detection: the swarm emits swarm.cycle_detected, the peer just
handed the conversation answers once more with no handoff tools, and
the run ends with interrupted=True,
interruption_reason="swarm:cycle_detected". Handing back once or
twice is allowed. Loops through three or more peers are left to
max_handoffs.
pass_full_history
| Type | bool |
| Default | True |
When True (default), the receiving agent’s prompt is the whole
thread: the request, then for each handoff the previous peer’s tool
calls with their results, its text, and a [Handoff: A → B] marker.
When False, it gets the request plus only the latest handoff segment.
A peer’s Handoff(input_filter=...) replaces either.
handoff_tool_name
| Type | str |
| Default | "handoff" |
Name of the single generic handoff tool used in untyped mode
(when no peer declares input_type). In typed mode (any Handoff
has input_type=), per-target tools transfer_to_<key> are generated
instead, using the peer’s key in the agents dict.
handoffs
| Type | Mapping[str, Sequence[str]] | None |
| Default | None |
Who may hand off to whom, e.g.
{"triage": ["billing", "tech"], "billing": ["triage"]}. With None
every peer may hand off to every other peer. A peer left out of the
map gets no handoff tool and has to answer. Unknown names or a peer
listing itself raise ValueError.
resume_last_agent
| Type | bool |
| Default | True |
When True, the next run with the same user_id and session_id
starts with the peer that answered last instead of entry_agent. The
mapping is kept in process (bounded, least recently used dropped), so
after a restart a conversation starts at entry_agent again.
worker_registry / role_to_worker_id
Internal wiring set by Team.swarm
when persistent_subagents=True (the default), so each peer keeps one
conversation per user across turns. You normally don’t pass these
yourself.
Methods
declared_workers
def declared_workers(self) -> dict[str, Agent]:
return dict(self._agents)Returns the peer mapping (the Handoff-wrapped agents are
unwrapped to the underlying Agent).
run
- Start with the peer that answered last in this session
(
resume_last_agent), orentry_agent. - Active turn. The active peer runs with handoff tools for the
peers it may hand off to. They are
ends_turntools: a successful call ends the peer’s turn without another model call. A call naming a peer it can’t hand off to fails, and the model sees the error. - Handoff. Append the peer’s tool calls, text and the handoff marker to the thread and switch to the target.
- Cycle / cap check. If
detect_cyclessees three round trips, ormax_handoffsis reached, the new peer answers once with no handoff tools and the run is marked interrupted. - Otherwise loop until a peer answers without handing off.
Events: swarm.started (with entry_agent and resumed), then
swarm.active and swarm.handoff per turn, and finally
swarm.completed, swarm.cycle_detected or swarm.max_handoffs. If a peer’s own run is interrupted (budget,
max_turns), the swarm stops with swarm.peer_interrupted and
interruption_reason = "swarm:<peer>:<reason>".
Related types
Handoff
@dataclass(frozen=True)
class Handoff:
agent: Agent
input_type: type[BaseModel] | None = None
input_filter: Callable[[list[str], dict[str, Any]], str] | None = None
description: str | None = None
tool_name: str | None = NonePer-peer handoff configuration.
| Field | Type | Default | Description |
|---|---|---|---|
agent | Agent | required | The peer Agent. |
input_type | type[BaseModel] | None | None | Pydantic model for typed handoff payloads. When set, the generated handoff tool’s input schema mirrors this model, so the calling model sends a structured payload instead of a free-form string. The validated payload is passed to input_filter and appears in the swarm.handoff event. |
input_filter | Callable[[list[str], dict[str, Any]], str] | None | None | Optional (history, payload) → prompt callback for selective context forwarding. payload is the handoff tool’s arguments as a dict, validated against input_type when it is set (empty for the generic handoff tool). Default behaviour respects the swarm’s pass_full_history flag. |
description | str | None | None | Override the generated tool’s description. Useful when the agent’s name is opaque ("billing_v2") but the description should be user-friendly. |
tool_name | str | None | None | Override the auto-generated tool name. Default is "transfer_to_<key>" where <key> is the peer’s key in the swarm’s agents dict. |
Two handoff modes
Untyped mode
When no peer has input_type=, the swarm exposes a single
generic handoff(target, message) tool. The model picks the
target name and writes a free-form message.
team = Team.swarm(
agents={"triage": triage, "billing": billing, "tech": tech},
entry_agent="triage",
model="claude-opus-4-7",
)Typed mode
When ANY peer declares input_type=, the swarm switches to
per-target tools transfer_to_<key>(...) with the peer’s input
schema. You can mix typed and untyped peers: a peer without
input_type gets a transfer_to_<key> tool that takes an optional
free-form message.
class BillingHandoff(BaseModel):
customer_id: str
issue_summary: str
priority: Literal["low", "medium", "high"]
team = Team.swarm(
agents={
"triage": triage,
"billing": Handoff(billing, input_type=BillingHandoff),
"tech": Handoff(tech, input_type=TechHandoff),
},
entry_agent="triage",
model="claude-opus-4-7",
)Source
loomflow/architecture/swarm.py
When (sparingly) to use Swarm. Exploratory pipelines where the flow will change weekly, or research systems where you’re testing which handoff patterns emerge. For production multi-agent systems, use Supervisor or Router.