1.3 Multi-Agent Handoff
The problem
Your planner agent just decomposed a complex task into six subtasks, ran three, and noticed that subtask four needs a code-execution sandbox the planner doesn't have. It hands the work to an executor agent. The executor wakes up with no idea who the planner is, what was tried, or which subtask is next. The planner dumps a 4,000-token prose summary into a shared context window. The executor re-reads everything, asks three clarifying questions, and redoes a subtask the planner already finished.
The fix is durable multi-agent handoff: the
handing-off agent writes a structured Handoff thought to
the chain. The receiving agent loads it via
mentisdb_recent_context and graph expansion. The chain
itself is the contract.
Why it's hard
- Identity: two agents writing to the same chain without explicit ids create a soup of unattributed thoughts.
- Scope: the executor shouldn't see the user's private brainstorming; the user shouldn't see the executor's retry logs.
- Granularity: dumping the full chain is too much; a one-line summary loses too much.
- Continuity: a handoff that doesn't reference the prior agent's final thought is a graph island.
- Conflict: two agents appending concurrently can interleave or contradict without explicit reconciliation.
The pattern
Treat the chain as the inter-agent bus and the handoff as a first-class thought type:
- The handing-off agent writes a
Summarythought withrole: Handoff: what is done, what is next, what is blocked, and the indices the next agent must graph-expand from. - The receiving agent calls
mentisdb_recent_contextwith the handoff's tag, then graph-expands from each referenced thought. - The receiving agent's first action is a
ContinuesFromthought pointing at the handoff. The graph stays continuous across the boundary. - For parallel branches, the agent opens a branch chain with
BranchesFromon the divergence point.
Decision already on the chain causes the
next agent to act on a stale belief.
Implementation
1. Define a handoff envelope
use mentisdb::{
MentisDb, ThoughtInput, ThoughtType, ThoughtRole, ThoughtRelationKind,
RankedSearchQuery, GraphExpansion,
};
use serde::{Serialize, Deserialize};
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct HandoffEnvelope {
pub from_agent: String,
pub to_agent: String,
pub task_id: String,
pub done: Vec<String>, // completed work
pub next: Vec<String>, // what the next agent must do
pub blocked: Vec<String>, // what NOT to redo
pub context_refs: Vec<u64>, // indices to graph-expand
pub artifacts: Vec<String>, // file paths, PR numbers, tool outputs
}
2. The prepare_handoff() side
pub fn prepare_handoff(
chain: &mut MentisDb,
envelope: &HandoffEnvelope,
) -> Result<u64> {
// 1. Search-first: avoid duplicating a prior handoff.
let existing = chain.query_ranked(
&RankedSearchQuery::new()
.with_text(&envelope.task_id)
.with_roles(vec![ThoughtRole::Handoff])
.with_limit(3)
)?;
if let Some(top) = existing.hits.first() {
if top.score > 0.85 {
// Same task, already handed off — extend instead.
let idx = chain.append_thought(&envelope.from_agent,
ThoughtInput::new(ThoughtType::Handoff,
format!("Updated handoff for {}: {} more items done.",
envelope.task_id, envelope.done.len()))
.with_role(ThoughtRole::Handoff)
.with_importance(0.7)
.with_tags(["handoff", &envelope.task_id])
.with_refs(vec![top.thought.index])
)?;
chain.add_relation(&envelope.from_agent,
idx, top.thought.index, ThoughtRelationKind::ContinuesFrom)?;
return Ok(idx);
}
}
// 2. Append the structured handoff thought.
let body = serde_json::to_string_pretty(envelope)?;
let handoff_index = chain.append_thought(&envelope.from_agent,
ThoughtInput::new(ThoughtType::Handoff, body)
.with_role(ThoughtRole::Handoff)
.with_importance(0.9)
.with_concepts(["agent-handoff", &envelope.task_id])
.with_tags(["handoff", &envelope.task_id])
.with_refs(envelope.context_refs.clone())
)?;
// 3. Backlink every context_ref so graph expansion
// surfaces them automatically.
for &ctx in &envelope.context_refs {
chain.add_relation(&envelope.from_agent,
handoff_index, ctx, ThoughtRelationKind::Summarizes)?;
}
Ok(handoff_index)
}
3. The accept_handoff() side
pub fn accept_handoff(
chain: &mut MentisDb, to_agent: &str, task_id: &str,
) -> Result<HandoffEnvelope> {
// 1. Find the most recent Handoff for this task.
let handoff = chain.query_ranked(
&RankedSearchQuery::new()
.with_text(task_id)
.with_roles(vec![ThoughtRole::Handoff])
.with_limit(5)
)?.hits.iter()
.max_by_key(|h| h.thought.timestamp)
.ok_or("No handoff found for this task")?;
let envelope: HandoffEnvelope =
serde_json::from_str(&handoff.thought.content)?;
// 2. Graph-expand from the lexical seeds the ranked query
// produces (the handoff's UUID is matched as part of the text
// search; the context_refs flow through the Subgoal backlinks
// and graph traversal follows them automatically).
let _expanded = chain.query_ranked(
&RankedSearchQuery::new()
.with_text(task_id)
.with_graph(
RankedSearchGraph::new()
.with_max_depth(2)
.with_max_visited(50)
)
)?;
// 3. Write a ContinuesFrom thought so the graph stays
// connected across the agent boundary.
let my_index = chain.append_thought(to_agent,
ThoughtInput::new(ThoughtType::Checkpoint,
format!("Accepting handoff of '{}' from '{}'. \
Continuing with: {:?}",
envelope.task_id, envelope.from_agent, envelope.next))
.with_role(ThoughtRole::Checkpoint)
.with_importance(0.6)
.with_tags(["handoff", &envelope.task_id, "accepted"])
.with_refs(vec![handoff.thought.index])
)?;
// NOTE: add_relation does not exist. Use .with_relations(vec![ThoughtRelation::new(kind, target_uuid)]) on the ThoughtInput being appended.
Ok(envelope)
}
4. Planner → executor → critic
use mentisdb::MentisDb;
fn main() -> io::Result<()> {
let dir = tempfile::tempdir()?;
let mut chain = MentisDb::open_with_key(dir.path(), "team-chain")?;
for (id, name, desc) in [
("planner", "Planner", "Decomposes tasks into subtasks."),
("executor", "Executor", "Runs code and tools."),
("critic", "Critic", "Reviews executor output for safety."),
] {
chain.upsert_agent(id, Some(name), Some("team"), Some(desc), None)?;
}
chain.add_agent_alias("planner", "plan-bot")?;
chain.add_agent_alias("executor", "sandbox-runner")?;
// === PHASE 1: PLANNER -> EXECUTOR ===
let plan_idx = chain.append_thought("planner",
ThoughtInput::new(ThoughtType::Plan,
"Refactor auth: extract JWT validator, add rate \
limiting, run tests, open PR.")
.with_importance(0.8)
.with_tags(["plan", "auth-refactor-2026-06-08"])
)?;
let h1 = prepare_handoff(&mut chain, &HandoffEnvelope {
from_agent: "planner".into(), to_agent: "executor".into(),
task_id: "auth-refactor-2026-06-08".into(),
done: vec!["Decomposed into 5 subtasks".into()],
next: vec!["Extract validate_token()".into(),
"Run cargo test".into()],
blocked: vec!["Do NOT modify test fixtures".into()],
context_refs: vec![plan_idx], artifacts: vec![],
})?;
// === PHASE 2: EXECUTOR ACCEPTS, WORKS, HANDS OFF TO CRITIC ===
accept_handoff(&mut chain, "executor", "auth-refactor-2026-06-08")?;
let exec_idx = chain.append_thought("executor",
ThoughtInput::new(ThoughtType::Subgoal,
"Extracted validate_token(). 47/47 tests pass.")
.with_importance(0.6)
.with_tags(["auth-refactor-2026-06-08", "executor-step"])
.with_refs(vec![h1])
)?;
let h2 = prepare_handoff(&mut chain, &HandoffEnvelope {
from_agent: "executor".into(), to_agent: "critic".into(),
task_id: "auth-refactor-2026-06-08".into(),
done: vec!["JWT extracted, 47 tests pass".into()],
next: vec!["Review jwt.rs for unsafe unwraps".into()],
blocked: vec!["Do NOT propose further refactors".into()],
context_refs: vec![h1, exec_idx], artifacts: vec![],
})?;
accept_handoff(&mut chain, "critic", "auth-refactor-2026-06-08")?;
// Chain path:
// plan -> handoff(plan->exec) -> check-in -> step
// -> handoff(exec->critic) -> check-in
let inbound = chain.inbound_relations(h2);
assert!(inbound.iter().any(|r|
r.kind == ThoughtRelationKind::ContinuesFrom ||
r.kind == ThoughtRelationKind::Summarizes
));
Ok(())
}
The "primer line" pattern
A receiving agent is just an LLM with an empty context window. Even
after loading the right thoughts from the chain, it needs a single
ready-to-paste prompt that says what to do right now. That
is the primer line — a short, structured string built from the
envelope plus chain.to_catchup_prompt_with(...) over the
last N thoughts. Keep it under 2KB: the chain holds the long tail,
the primer is for the LLM's working context. The agent can always
re-render the primer by calling
mentisdb_recent_context again.
Production notes
Scope: agent vs user vs session
Not all handoff thoughts are visible to all readers. Tag them explicitly:
scope:user— visible to the human and every agent. Use for planner → executor and any user-facing handoff.scope:agent— visible to other agents but not the human. Use for executor → critic internal review.scope:session— visible only inside one session. Use for retry loops and ephemeral debugging.
Shared chain vs separate chains
A single shared chain is simpler and lets any agent graph-expand
into any other agent's work. Separate per-agent chains are safer
for privacy but require explicit BranchesFrom joins.
Default to a shared chain unless you have a privacy boundary that
demands otherwise.
Conflict resolution
If two agents append at the same time, the chain's hash ordering
is the ground truth — but the meaning may be incoherent.
The fix is the search-first discipline: the second agent searches
before appending, detects the prior agent's thought, and either
ContinuesFrom, Supersedes, or
Corrects it rather than writing a parallel duplicate.
The chain becomes a CRDT-lite: append-only, with explicit
reconciliation relations.
Identity discipline
Every append_thought call takes an
agent_id string. Treat it as a stable, registered
identity. The agent registry is the source of truth — never let
an agent self-rename on the fly. Add aliases when an agent gains
a new role; do not change the canonical id.
Anti-patterns
Handoff without a summary
Agent A says "agent B, your turn" in a chat message but never
appends a Handoff thought. Agent B calls
mentisdb_recent_context, sees agent A's last routine
Subgoal, and has no idea that work was supposed to
transfer. Agent B either redoes everything or sits idle.
Handoff thought with
role: Handoff. Chitchat is not a handoff.
Handoff with too much context
Agent A dumps 50 thoughts of fine-grained reasoning into the handoff body. Agent B's primer becomes 40KB, the LLM gets lost, and the receiving agent contradicts prior decisions because it cannot tell which thoughts are still current. Keep the handoff body to: what is done, what is next, what is blocked, and indices to graph-expand. The chain holds the long tail.
Agent identity confusion
Two agents share the same agent_id. Their thoughts
interleave with no way to tell them apart at retrieval time.
The fix is strict upsert_agent discipline: one
canonical id per agent, plus aliases for renaming, plus per-agent
filters on every query.
Testing this pattern
#[test]
fn handoff_keeps_chain_connected_across_boundary() {
let mut chain = test_chain();
chain.upsert_agent("planner", None, None, None, None).unwrap();
chain.upsert_agent("executor", None, None, None, None).unwrap();
let plan = chain.append_thought("planner",
ThoughtInput::new(ThoughtType::Plan, "refactor auth")
).unwrap();
let env = HandoffEnvelope {
from_agent: "planner".into(), to_agent: "executor".into(),
task_id: "auth".into(), done: vec!["planned".into()],
next: vec!["extract jwt".into()], blocked: vec![],
context_refs: vec![plan], artifacts: vec![],
};
let h = prepare_handoff(&mut chain, &env).unwrap();
accept_handoff(&mut chain, "executor", "auth").unwrap();
let inbound = chain.inbound_relations(h);
assert!(inbound.iter().any(|r|
r.kind == ThoughtRelationKind::ContinuesFrom
), "chain is disconnected across the handoff");
}
#[test]
fn handoff_dedupes_when_task_already_handed_off() {
let mut chain = test_chain();
let env = sample_envelope("auth");
let h1 = prepare_handoff(&mut chain, &env).unwrap();
let h2 = prepare_handoff(&mut chain, &env).unwrap();
assert_ne!(h1, h2, "second handoff should extend, not duplicate");
}
What's next
Handoffs work for short-lived agent teams. For projects that span weeks or months with the same agent identity returning, see Long-Running Project Memory, which builds on these handoff primitives to maintain a stable project state across many sessions.