The MentisDB Agent Memory Cookbook

Patterns and recipes for building AI agents that remember

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

The pattern

Treat the chain as the inter-agent bus and the handoff as a first-class thought type:

  1. The handing-off agent writes a Summary thought with role: Handoff: what is done, what is next, what is blocked, and the indices the next agent must graph-expand from.
  2. The receiving agent calls mentisdb_recent_context with the handoff's tag, then graph-expands from each referenced thought.
  3. The receiving agent's first action is a ContinuesFrom thought pointing at the handoff. The graph stays continuous across the boundary.
  4. For parallel branches, the agent opens a branch chain with BranchesFrom on the divergence point.
Read before handoff, append during handoff. The search-first discipline applies even more strictly at agent boundaries. A handoff that duplicates a 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.

The primer line is the only thing the LLM sees. It should answer four questions: who am I, what task is this, what should I do, what should I not redo. The chain holds everything else.

Production notes

Scope: agent vs user vs session

Not all handoff thoughts are visible to all readers. Tag them explicitly:

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.

Rule: every cross-agent transfer of work requires a 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.