Multi-Agent Collaboration¶
Arcana's constitution is explicit about multi-agent: the framework provides coordination infrastructure, never orchestration strategy. This guide shows what that looks like in practice.
If you are looking for declarative agent graphs, role hierarchies, or a
built-in "who speaks next" scheduler, Arcana does not provide them — and that
is not an oversight. They belong to the LLM and the user, not to the
framework. See Principle 8 in CONSTITUTION.md for the full argument.
What Arcana does provide:
AgentPool— named ChatSessions sharing one budget, one message channel, one key-value context.Channel— async, name-addressed message queue for when directsession.send(...)control flow is not enough.SharedContext— thread-safe key-value store agents can read and write.- Per-agent cognitive primitives — each pool
member has its own
PinState, its own recall log, its own compression budget. - Pool-aware trace replay — every emitted event carries the originating agent name.
The rest is plain Python: async for, if, await, your own loops, your
own stop conditions.
Getting started¶
import arcana
runtime = arcana.Runtime(providers={"deepseek": "sk-..."})
async with runtime.collaborate() as pool:
planner = pool.add("planner", system="You break tasks into steps.")
executor = pool.add("executor", system="You execute one step at a time.",
tools=[run_shell])
plan = await planner.send(f"Plan: {goal}")
for step in parse_steps(plan.content):
result = await executor.send(f"Execute: {step}")
print(result.content)
runtime.collaborate() returns an AgentPool — enter it with async with
for automatic cleanup on exit. Inside the pool, every pool.add(...) creates
an independent ChatSession that shares the pool's Channel,
SharedContext, and BudgetTracker but has its own system prompt, tools,
and history.
The pool is not a runner
Nothing runs until you call await session.send(...). Two pool agents
do not take turns unless your code makes them. This is deliberate.
Four patterns¶
These are the most common multi-agent shapes. None of them require framework support beyond the three primitives above — they are all plain Python.
1. Planner / Executor¶
One agent plans, another executes. Sequential, no loop.
async with runtime.collaborate() as pool:
planner = pool.add("planner", system="You plan.")
executor = pool.add("executor", system="You execute.", tools=[...])
plan = await planner.send(f"Plan a migration for {project}")
for step in parse_steps(plan.content):
await executor.send(f"Execute step: {step}")
2. Critic loop¶
A worker drafts, a critic reviews, the worker revises — capped at N rounds.
async with runtime.collaborate(cognitive_primitives=["pin"]) as pool:
worker = pool.add("worker", system="You write code.")
critic = pool.add("critic", system="You review code strictly.")
draft = await worker.send(f"Write: {spec}")
for _ in range(MAX_ITERS):
review = await critic.send(f"Review this draft:\n{draft.content}")
if "LGTM" in review.content:
break
draft = await worker.send(f"Revise based on:\n{review.content}")
Why pin here?
The worker can pin the latest spec so repeated revisions do not
accidentally compress it out of context. Each pool agent's pins are
private — the critic's pins do not leak to the worker.
3. Specialization¶
Different agents, different tools. Useful when tool surface area matters.
async with runtime.collaborate() as pool:
researcher = pool.add("researcher",
system="You research.",
tools=[web_search, fetch_url])
writer = pool.add("writer",
system="You write structured notes.",
tools=[save_note])
findings = await researcher.send(f"Research {topic}; cite sources.")
await writer.send(f"Turn this into a report:\n{findings.content}")
4. Debate via Channel¶
When the shape is not a linear pipeline. Channel is an async queue; each
agent picks up messages on its own schedule.
from arcana.contracts.multi_agent import ChannelMessage
async with runtime.collaborate() as pool:
bull = pool.add("bull", system="Argue for position X.")
bear = pool.add("bear", system="Argue against position X.")
opening = await bull.send("State your thesis.")
await pool.channel.send(
ChannelMessage(sender="bull", recipient="bear",
content=opening.content)
)
for _ in range(MAX_ROUNDS):
incoming = await pool.channel.receive("bear")
if not incoming:
break
rebuttal = await bear.send(f"Rebut: {incoming[-1].content}")
await pool.channel.send(
ChannelMessage(sender="bear", recipient="bull",
content=rebuttal.content)
)
# ... symmetric loop continues
ChannelMessage is immutable — a single instance fans out to every
recipient and to pool.channel.history without any mutation hazard.
Per-agent cognitive primitives¶
Each pool member is an independent cognitive instance. This is the feature that meaningfully distinguishes multi-agent Arcana from orchestration-first frameworks.
async with runtime.collaborate(cognitive_primitives=["pin"]) as pool:
a = pool.add("a") # inherits ["pin"]
b = pool.add("b", cognitive_primitives=["recall"]) # overrides
c = pool.add("c", cognitive_primitives=[]) # explicit opt-out
Resolution order:
- Per-agent
cognitive_primitives=[...](including[]) - Pool default passed to
runtime.collaborate(...) - Runtime default from
RuntimeConfig.cognitive_primitives
Every pool agent gets its own PinState, its own recall log, its
own pin_budget_fraction cap. Cognitive state never crosses between
pool members — see Principle 8 in CONSTITUTION.md.
Tool-name collisions raise
If you enable cognitive_primitives=["pin"] on an agent and also pass a
user tool named pin (or unpin, which the pin primitive implicitly
reserves), pool.add(...) raises ValueError. Silent shadowing would
drop information either way; rename the user tool or drop the primitive
deliberately.
Shared budget¶
All agents in a single pool share one BudgetTracker:
One agent's overspending starves the others — that is what "pool budget" means. If you want independent budgets, create independent pools (or run the agents outside a pool entirely).
Pin budget (pin_budget_fraction, default 50%) is per agent, not per
pool — it is computed against each agent's own context window.
Shared state¶
pool.shared is a thread-safe key-value store. Any agent can read or write.
There is no access control — all agents see everything, per Principle 8.
async with runtime.collaborate() as pool:
researcher = pool.add("researcher")
reviewer = pool.add("reviewer")
findings = await researcher.send(f"Research {topic}")
pool.shared.set("findings", findings.content)
# Reviewer reads the same value
await reviewer.send(
f"Review: {pool.shared.get('findings')}"
)
Use shared for small, structured hand-offs between agents.
For unstructured back-and-forth, Channel is usually the better fit.
Bounding channel history (v0.8.1+)¶
Channel.history retains every message ever sent. For short-lived pools
this is fine. For daemon-style pools that run indefinitely, it turns into
a slow memory leak, so collaborate() accepts an opt-in bound:
async with runtime.collaborate(channel_history_limit=500) as pool:
planner = pool.add("planner", system="...")
executor = pool.add("executor", system="...")
# pool.channel.history now retains at most the 500 most recent messages;
# oldest entries are evicted FIFO. Delivery is unaffected.
None(default) — unbounded history, matches pre-v0.8.1 behaviour.- positive
int— retain at most that many past messages. 0— disable history entirely (useful when you only need live delivery and never introspectchannel.history).- negative — raises
ValueError.
This bounds only channel.history. Per-agent delivery queues are driven
by your receive() calls; an agent that is registered but never reads
from its queue will still accumulate messages there, which is a consumer
concern rather than a retention one.
Tracing pool runs¶
Every event emitted during a pool run carries
metadata["source_agent"] = <pool_name>. The TraceEvent schema itself is
unchanged, so v0.6.0/v0.7.0 tooling keeps working.
# Summary: which agents participated, how many events, replayable turns
arcana trace pool-replay <run_id>
# Scope output to one agent
arcana trace show <run_id> --agent planner
arcana trace replay <run_id> --agent planner --turn 3
# Cognitive-primitive events show [source_agent] in the event listing
arcana trace show <run_id> --cognitive
Pin state is reconstructed per agent during trace replay — the "active
pins at turn N" section belongs to the agent you scoped to.
What you will not find in Arcana¶
The following features are deliberately absent. They are not on a roadmap and will not be added.
- Graph DSL. No
StateGraph-like construct for multi-agent flows. - Turn scheduler. No built-in
GroupChat. Who talks when is your code. - Role hierarchy. No
supervisor/workerprimitives. Roles live in system prompts, not in framework types. - Auto stop conditions. No convergence detection, no "if consensus, halt." Stop when your code decides to stop.
- Cross-agent cognitive inheritance. Agent A's pins are not visible to
agent B, ever. If you want agent B to see A's conclusions, call
pool.shared.set(...)explicitly.
If any of these are load-bearing for your use case, LangGraph / AutoGen / CrewAI will serve you better than Arcana. The constitutional contrast is the point, not a gap.
Migration from runtime.team()¶
runtime.team() was deprecated in v0.8.0 and removed in v1.0.0. Use
runtime.collaborate() instead.
# Old (removed in v1.0.0)
result = await runtime.team(
"Write a blog post about X",
agents=[AgentConfig(name="researcher", prompt="..."),
AgentConfig(name="writer", prompt="...")],
mode="shared",
)
# New
async with runtime.collaborate() as pool:
researcher = pool.add("researcher", system="...")
writer = pool.add("writer", system="...")
findings = await researcher.send("Research X")
post = await writer.send(f"Write a blog post using:\n{findings.content}")
The new shape gives you explicit control over turn order, per-agent tool
surface, and per-agent cognition — none of which runtime.team() exposed.
Migration from TeamOrchestrator and MessageBus¶
arcana.multi_agent.team.TeamOrchestrator, arcana.multi_agent.team.RoleConfig,
and arcana.multi_agent.message_bus.MessageBus were removed in the
2026-05-03 cleanup (Constitution Amendment 3, v3.4). The deprecation
DeprecationWarning and the physical removal landed in the same release
because arcana.multi_agent.* is internal-not-stable per
specs/v1.0.0-stability.md §2 and the deprecated classes had not been
published to PyPI in any release. If you imported them: switch to the
recipe below.
These classes encoded a framework-prescribed Planner→Executor→Critic
topology via the AgentRole enum (PLANNER / EXECUTOR / CRITIC).
Amendment 3 (and Principle 8 before it) makes that shape the user's
decision, not the framework's. The replacement is plain runtime.collaborate()
with whatever loop you want to write.
# Old (deprecated)
from arcana.multi_agent.team import TeamOrchestrator, RoleConfig
from arcana.contracts.trace import AgentRole
orchestrator = TeamOrchestrator(
role_configs={
AgentRole.PLANNER: RoleConfig(role=AgentRole.PLANNER, policy=..., reducer=...),
AgentRole.EXECUTOR: RoleConfig(role=AgentRole.EXECUTOR, policy=..., reducer=...),
AgentRole.CRITIC: RoleConfig(role=AgentRole.CRITIC, policy=..., reducer=...),
},
gateway=gateway,
max_rounds=5,
)
result = await orchestrator.run(goal)
# New — your code drives the loop
async with runtime.collaborate() as pool:
planner = pool.add("planner", system="You break tasks into plans.")
executor = pool.add("executor", system="You execute one step at a time.",
tools=[...])
critic = pool.add("critic", system="You verify execution. Reply 'pass' or feedback.")
feedback = ""
for _ in range(MAX_ROUNDS):
plan_msg = await planner.send(f"Goal: {goal}\nFeedback so far: {feedback}")
result_msg = await executor.send(f"Execute: {plan_msg.content}")
verdict = await critic.send(f"Verify:\n{result_msg.content}")
if verdict.content.lower().strip().startswith("pass"):
break
feedback = verdict.content
For role-addressed message-passing (MessageBus), the replacement is the
name-addressed Channel already in arcana.multi_agent.channel — same
publish/subscribe shape, but addressing is by free-form agent name rather
than by a fixed AgentRole enum. See the four patterns at the top of this
guide for Channel examples.
The AgentRole enum itself stays for now. It is the type of
TraceEvent.role (a field on the stable arcana.contracts.trace.TraceEvent
name), and replacing it would be a stable-surface break per
specs/v1.0.0-stability.md §5. The enum is documented as vestigial in
its docstring; the PLANNER / EXECUTOR / CRITIC members are kept so
historical trace files keep parsing. Replacement of the field with
agent_name: str is queued for v2.0 and tracked in
specs/constitution-amendment-3-multi-agent-os.md
under "Implementation follow-up."