Multi-agent¶
Multi-agent composition in lovia is deliberately atomic: three primitives, all implemented as ordinary tools, and no orchestration DSL. Handoff transfers control — the specialist continues the same conversation. Agent-as-tool delegates — a sub-agent answers a bounded question while the parent waits. Subagents delegates without waiting — background children run concurrently and report back later. Everything larger is composed from these in plain Python.
Handoff¶
from lovia import Agent, Runner
billing = Agent(name="billing", instructions="Handle billing issues.", model="<model>")
support = Agent(name="support", instructions="Handle technical issues.", model="<model>")
triage = Agent(
name="triage",
instructions="Route the user to the right specialist.",
model="<model>",
handoffs=[billing, support],
)
result = await Runner.run(triage, "I was charged twice.")
print(result.final_agent.name) # "billing"
Each entry in handoffs becomes a transfer_to_<name> tool (names are
slugified to fit provider grammars — ASCII, 64 chars, with a stable digest
suffix when needed). When the model calls one, the loop swaps the active
agent and continues.
What the target sees. The conversation follows the handoff: the new
agent gets the full prior context — history, tool calls and results
included — with exactly one change: the leading system prompt is re-rendered
as the target's own (its instructions, workspace, plugins,
structured-output contract). A run-level extra_instructions addendum is
re-applied to every agent a handoff reaches.
What else changes. The target agent's providers, tools, plugins, and
workspace are resolved fresh (plugins run their own setup()); a
HandoffOccurred event fires (dispatched to both agents' hooks). What does
not change: the run's spine — max_turns, budget, cancel token, mailbox,
session, checkpoint, and the initial agent's retry/context posture all
carry through.
Customizing a handoff¶
Wrap the target in a Handoff for control over the tool:
from lovia import Agent, Handoff
triage = Agent(
name="triage",
model="<model>",
handoffs=[
Handoff(
target=billing,
description="Billing: refunds, double charges, invoices, payment methods.",
on_handoff=lambda args, ctx: audit_log(ctx.session_id, args.get("reason")),
),
support, # plain agents still work alongside
],
)
| Field | Default | Purpose |
|---|---|---|
target |
required | the agent to transfer to |
name |
transfer_to_<slug> |
override the tool name |
description |
generic transfer text | the routing signal — set it to the target's specialty whenever the parent must choose between similar agents |
on_handoff |
None |
sync or async callback (args, ctx) fired when the handoff triggers; args carries the model's optional reason |
The default description is deliberately thin (just the agent name), so
description is the knob that makes routing reliable.
Handoff semantics worth knowing¶
- First handoff wins. Handoff tools always execute as
barriers — never concurrently
with other calls — and a second handoff in the same turn is rejected
before its
on_handoffside effects fire. - The swap happens at turn end. Remaining tool results of the turn are processed first; the next model call is the target's.
- Resume survives handoffs. A checkpoint records the active agent by name; resuming resolves it from the entry agent's handoff graph and continues as that agent.
- Handoffs don't nest runs. However deep a chain of transfers goes, it is still one run, one transcript, one budget.
Agent-as-tool¶
Delegation instead of transfer — the sub-agent runs in its own loop, sees only the prompt it was handed (never the parent's history), and its final output comes back as the tool result:
summarizer = Agent(
name="summarizer",
instructions="Summarize text in five bullets.",
model="<model>",
)
manager = Agent(
name="manager",
instructions="Delegate summarization when useful.",
model="<model>",
tools=[summarizer.as_tool(description="Summarize a passage.")],
)
agent.as_tool(*, name=None, description=None, max_turns=50, budget=None,
retry=None, context_policy=None):
- The tool is named
ask_<slug>by default and takes one model-controlled argument:input, the delegated prompt. The execution-policy keywords are fixed by you and never exposed to the model — boundmax_turnsespecially, since a delegated agent loops on its own. budgetis copied per invocation, so its limits apply to each sub-run individually rather than accumulating across calls.- The sub-run inherits the parent's
context(deps),cancel_token(one cancel stops the whole tree), and tracer (spans join one trace); its token usage folds into the parent'susage. - The sub-run gets its own mailbox — deliberately not the parent's, since mailbox drains are destructive and an injected message is addressed to one conversation.
- A sub-run that exhausts its own budget surfaces as a tool-error result the parent can react to — a recoverable delegation failure, not a run-ending one (error semantics).
Background subagents¶
The Subagents plugin is the asynchronous sibling of agent-as-tool:
spawn_subagent returns immediately with a task id, the child runs
concurrently on the event loop while the parent keeps working, and the
child's report re-enters the conversation later — as a user-side message at
a turn boundary, never as a late tool result:
from lovia import Agent, Runner, Subagents
researcher = Agent(name="researcher", instructions="Research and report.",
model="<model>", tools=[...])
agent = Agent(
name="assistant",
model="<model>",
plugins=[Subagents([researcher])], # or Subagents(): clone the current
# agent (minus plugins/handoffs)
)
result = await Runner.run(agent, "Research X and Y, then compare them.")
The model gets three tools and a per-turn status reminder:
spawn_subagent(prompt, agent=...)— start a child on a self-contained prompt (the child sees nothing else). Declines beyondmax_concurrent.wait_subagents(ids=None, timeout_seconds=60)— block until a targeted child finishes (or the timeout passes) and collect its report. A report whose automatic mailbox push has not been seen yet is withdrawn and returned directly, so nothing arrives twice.cancel_subagent(id)— cooperative stop, no report.
Subagents(agents=(), deliver=None, max_concurrent=4, max_turns=50,
budget=None, max_result_chars=16_000, instructions=None):
- Children inherit the parent's
context(deps) and tracer, and their token usage folds into the parent'susage— likeas_toolsub-runs. Each child gets its own cancel token (that is whatcancel_subagenttrips) and a fresh copy ofbudgetper spawn. - Lifecycle is keyed on
deliver. With the default (deliver=None, bounded), reports push into the run's mailbox and anything still running when the run ends is cancelled — children never outlive the run, and the built-in instructions tell the model towait_subagentsor cancel before finishing. With a callback (detached), every report goes through it instead and children may outlive the run — that is the serving-layer mode (web delivery). - Children run headless: an approval request nobody resolves is denied by default, so give children approval-free toolsets for unattended work. (Under the web UI, children become observable task sessions where a human can answer approvals — see web delivery.)
Subagents()with no catalog spawns a clone of the current agent withpluginsandhandoffsstripped — no recursive spawning, no shared plugin state; model, instructions, tools, and workspace carry over.run_child(advanced) swaps how a child executes: an override receives aChildSpecand returns the child'sRunResult— this is the seam the web layer uses to route children through its run supervisor. Pair it withdeliver; on its own, bounded-mode teardown would orphan the children it cannot cancel.
Choosing between them¶
| You want... | Use |
|---|---|
| The user to continue talking to a specialist | handoff |
| An answer to a bounded subtask, then carry on | agent-as-tool |
| The specialist to see the full conversation | handoff |
| Isolation — the child must not see parent history | agent-as-tool / subagents |
The final answer attributed to the specialist (result.final_agent) |
handoff |
| The parent to keep working while the child runs | subagents |
| Fire-and-forget research that reports back later | subagents |
Larger patterns — chaining, routing, parallelization,
orchestrator-workers, evaluator loops — need no framework support: they are
plain Python around Runner.run. The
examples/workflows/ directory implements each
of Anthropic's Building effective agents patterns in a page of code.
Sharp edges¶
- Handoff targets need discoverable descriptions. Two specialists with default descriptions look nearly identical to the router; mis-routing is a prompt problem before it is a framework problem.
output_typefollows the run, not the agent, when overridden. ARunner.run(..., output_type=...)override binds every agent a handoff reaches; without it, each agent's ownoutput_typeapplies — a triage → specialist chain with different output types changes contract mid-run.- Non-ASCII agent names produce digest tool names.
transfer_to_agent_a1b2c3d4routes fine but reads poorly in logs; setHandoff(name=...)/as_tool(name=...)for readable overrides. - Deep as-tool trees multiply cost quietly. Usage folds upward, so read
result.usageas the tree total — budget the root run accordingly.
See also¶
- Tools — both primitives are ordinary tools underneath
- Sessions & checkpoints — resume across handoffs
- Examples:
07_handoff.py,08_agent_as_tool.py,30_subagents.py,workflows/