Skip to content

fix(handoff): dedicated handoff MCP tool with required recipient - #153

Merged
getappz merged 2 commits into
masterfrom
fix/handoff-requires-recipient
Jul 11, 2026
Merged

fix(handoff): dedicated handoff MCP tool with required recipient#153
getappz merged 2 commits into
masterfrom
fix/handoff-requires-recipient

Conversation

@getappz

@getappz getappz commented Jul 11, 2026

Copy link
Copy Markdown
Owner

Problem

A handoff routes a work product to another agent's inbox — artifact_list filters on the recipient envelope field. Handoffs were published through artifact_publish, where recipient is optional. An intended handoff published without one lands in no inbox and vanishes silently — exactly what happened to an opencode → claude-code handoff (the "for Claude" intent lived only in the free-text name, not a routing field).

Why not a store-layer guard

The first attempt rejected publish() when sender was set but recipient was missing. That was wrong: mcp_server.rs auto-defaults sender to the agent identity on every publish, so a plain /artifact publish (a shared design doc, a review) also has sender set and legitimately no recipient. The guard rejected those too, breaking the normal publish path (caught by artifact_publish_defaults_sender_to_agent_identity). At the store layer, "intended handoff, forgot recipient" and "plain publish" are indistinguishable.

Fix: make it unrepresentable

Add a dedicated handoff MCP tool whose recipient is a required, non-Option field — the JSON schema itself rejects an unaddressed handoff before it reaches the store. The tool sets sender to the runtime's own identity and routes to the recipient's inbox. The handoff prompt now directs <recipient> <brief> to this tool instead of raw artifact_publish.

artifact_publish is unchanged — plain shareable pages keep working, sender-default intact.

Tests

  • handoff_tool_requires_recipient_and_routes_to_inbox — blank recipient → INVALID_PARAMS; a real handoff lands in the recipient's inbox with the correct sender.
  • Reworded the 3 handoff-prompt tests for the new tool-routed wording.
  • artifact_publish_defaults_sender_to_agent_identity passes again (regression gone).
  • Full suite: 21 crate + 286 binary tests pass.

Summary by CodeRabbit

  • New Features
    • Added an MCP handoff tool to send work products directly to another agent’s inbox.
    • Includes optional handoff metadata (thread continuity, reply-to, session grouping, content type, description).
    • Adds sender/recipient details and returns the published artifact info.
  • Bug Fixes
    • Updated handoff guidance to call the correct tool and preserve conversation context.
    • Improved input validation (rejects blank recipients; trims whitespace for stored recipient values).

@coderabbitai

coderabbitai Bot commented Jul 11, 2026

Copy link
Copy Markdown

Review Change Stack

No actionable comments were generated in the recent review. 🎉

ℹ️ Recent review info
⚙️ Run configuration

Configuration used: Path: .coderabbit.yaml

Review profile: CHILL

Plan: Pro Plus

Run ID: 858e7b14-f1e2-4255-b92a-c0c0da5b1057

📥 Commits

Reviewing files that changed from the base of the PR and between 09356d9 and 182a982.

📒 Files selected for processing (1)
  • src/mcp_server.rs
🚧 Files skipped from review as they are similar to previous changes (1)
  • src/mcp_server.rs

📝 Walkthrough

Walkthrough

The MCP server now supports recipient-directed handoffs with validated, trimmed metadata and runtime sender identity. Prompt guidance and tests map recipient briefs to the new handoff tool and verify identity wording and inbox delivery.

Changes

Agent handoff flow

Layer / File(s) Summary
Handoff request and publication
src/mcp_server.rs
Adds a recipient-required handoff MCP tool that validates and trims input, publishes recipient-routed artifacts with the server identity, returns artifact metadata, and tests rejection and inbox delivery.
Handoff prompt mapping
src/mcp_prompts.rs
Updates the handoff grammar to invoke handoff, use identity-based sender guidance, and preserve inbox thread context; related assertions are updated.

Estimated code review effort: 3 (Moderate) | ~20 minutes

Sequence Diagram(s)

sequenceDiagram
  participant HandoffCaller
  participant MCPServer
  participant ArtifactStore
  participant RecipientInbox
  HandoffCaller->>MCPServer: call handoff with recipient, name, and content
  MCPServer->>MCPServer: validate and trim required fields
  MCPServer->>ArtifactStore: publish recipient-routed artifact with sender identity
  ArtifactStore->>RecipientInbox: store artifact in recipient inbox
  MCPServer-->>HandoffCaller: return artifact metadata and recipient
Loading
🚥 Pre-merge checks | ✅ 5
✅ Passed checks (5 passed)
Check name Status Explanation
Title check ✅ Passed The title is concise and accurately describes the main change: a dedicated handoff MCP tool with required recipient.
Description check ✅ Passed The description covers the problem, fix, and tests, but it doesn't follow the template's exact Summary, Test plan, and Notes sections.
Docstring Coverage ✅ Passed Docstring coverage is 100.00% which is sufficient. The required threshold is 80.00%.
Linked Issues check ✅ Passed Check skipped because no linked issues were found for this pull request.
Out of Scope Changes check ✅ Passed Check skipped because no linked issues were found for this pull request.
✨ Finishing Touches
📝 Generate docstrings
  • Create stacked PR
  • Commit on current branch
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Commit unit tests in branch fix/handoff-requires-recipient

Comment @coderabbitai help to get the list of available commands.

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 1

🧹 Nitpick comments (1)
crates/agentflare-artifacts/src/lib.rs (1)

482-513: 🎯 Functional Correctness | 🔵 Trivial | ⚡ Quick win

Cover the post-keep() update path.

These cases only publish new artifacts. Add an update that supplies sender while omitting recipient, so the inherited None is verified to produce InvalidInput and leave the existing artifact unchanged.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@crates/agentflare-artifacts/src/lib.rs` around lines 482 - 513, The test
publish_rejects_sender_without_recipient currently covers only new artifacts;
extend it with an update to an existing artifact using keep() that sets sender
while omitting recipient. Assert the update returns InvalidInput and verify the
original artifact remains unchanged, including its existing recipient/content or
other relevant fields.
🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Inline comments:
In `@crates/agentflare-artifacts/src/lib.rs`:
- Around line 499-512: Strengthen the whitespace-recipient rejection test around
publish: assert that the returned error is specifically the expected
InvalidInput kind rather than only checking is_err(). Replace the
store.list(None) assertion with direct inspection of store.base_path(),
confirming no files or directories were created after rejection.

---

Nitpick comments:
In `@crates/agentflare-artifacts/src/lib.rs`:
- Around line 482-513: The test publish_rejects_sender_without_recipient
currently covers only new artifacts; extend it with an update to an existing
artifact using keep() that sets sender while omitting recipient. Assert the
update returns InvalidInput and verify the original artifact remains unchanged,
including its existing recipient/content or other relevant fields.
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: Path: .coderabbit.yaml

Review profile: CHILL

Plan: Pro Plus

Run ID: 11bc70a6-30d4-443f-8767-92e42ad4291b

📥 Commits

Reviewing files that changed from the base of the PR and between d2a0f44 and 26893f3.

📒 Files selected for processing (2)
  • crates/agentflare-artifacts/src/lib.rs
  • crates/agentflare-artifacts/src/store.rs

Comment thread crates/agentflare-artifacts/src/lib.rs Outdated
Comment on lines +499 to +512
// A blank/whitespace recipient is treated the same as missing.
assert!(store
.publish(&PublishRequest {
name: "orphan-handoff".into(),
content: "for claude".into(),
session_id: "s".into(),
sender: Some("opencode".into()),
recipient: Some(" ".into()),
..Default::default()
})
.is_err());

// Nothing was written on rejection.
assert!(store.list(None).unwrap().is_empty());

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🎯 Functional Correctness | 🟡 Minor | ⚡ Quick win

Assert the blank-recipient error and filesystem state.

The whitespace case only checks is_err(), so it could accept the wrong error kind. list(None).is_empty() also misses orphan files/directories without valid metadata. Assert InvalidInput and inspect store.base_path() directly.

Proposed test strengthening
-        assert!(store
+        let blank_err = store
             .publish(&PublishRequest {
                 name: "orphan-handoff".into(),
                 content: "for claude".into(),
                 session_id: "s".into(),
                 sender: Some("opencode".into()),
                 recipient: Some("   ".into()),
                 ..Default::default()
             })
-            .is_err());
+            .unwrap_err();
+        assert_eq!(blank_err.kind(), std::io::ErrorKind::InvalidInput);

-        assert!(store.list(None).unwrap().is_empty());
+        assert!(std::fs::read_dir(store.base_path()).unwrap().next().is_none());
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
// A blank/whitespace recipient is treated the same as missing.
assert!(store
.publish(&PublishRequest {
name: "orphan-handoff".into(),
content: "for claude".into(),
session_id: "s".into(),
sender: Some("opencode".into()),
recipient: Some(" ".into()),
..Default::default()
})
.is_err());
// Nothing was written on rejection.
assert!(store.list(None).unwrap().is_empty());
// A blank/whitespace recipient is treated the same as missing.
let blank_err = store
.publish(&PublishRequest {
name: "orphan-handoff".into(),
content: "for claude".into(),
session_id: "s".into(),
sender: Some("opencode".into()),
recipient: Some(" ".into()),
..Default::default()
})
.unwrap_err();
assert_eq!(blank_err.kind(), std::io::ErrorKind::InvalidInput);
// Nothing was written on rejection.
assert!(std::fs::read_dir(store.base_path()).unwrap().next().is_none());
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@crates/agentflare-artifacts/src/lib.rs` around lines 499 - 512, Strengthen
the whitespace-recipient rejection test around publish: assert that the returned
error is specifically the expected InvalidInput kind rather than only checking
is_err(). Replace the store.list(None) assertion with direct inspection of
store.base_path(), confirming no files or directories were created after
rejection.

Handoffs were published via artifact_publish, whose recipient is an
optional field — an intended handoff published without one lands in no
inbox (artifact_list filters on recipient) and vanishes silently, as an
opencode handoff to claude-code did.

Add a dedicated `handoff` MCP tool whose recipient is a required,
non-Option field, so the schema itself makes an unaddressed handoff
unrepresentable. It sets sender to the runtime's own identity and routes
to the recipient's inbox. The handoff prompt now directs <recipient>
<brief> to this tool. artifact_publish is unchanged and stays for plain
shareable pages (a store-layer guard can't tell the two apart because
sender is auto-defaulted).
@getappz
getappz force-pushed the fix/handoff-requires-recipient branch from 26893f3 to 09356d9 Compare July 11, 2026 14:09
@getappz getappz changed the title fix(artifacts): reject handoff publish with sender but no recipient fix(handoff): dedicated handoff MCP tool with required recipient Jul 11, 2026
@getappz
getappz merged commit adf1080 into master Jul 11, 2026
10 checks passed
@getappz
getappz deleted the fix/handoff-requires-recipient branch July 11, 2026 18:54
getappz added a commit that referenced this pull request Jul 18, 2026
…r+hybrid), blobs, leases (#244)

* feat: rebrand embedded skill cards ponytail→flare + FLARE_OUTPUT_MODEL env-var

* chore: uninstall caveman — remove from KNOWN_COMPRESSION_PLUGINS, update backup path and prompt

* feat(agentflare-store): initial crate - kv, documents (CRUD+FTS+vector), blobs, leases

Imports the agentflare-store implementation for item #147, previously left as
uncommitted working-tree state with zero git history. Store skeleton, kv,
documents CRUD/FTS5/vector search/hybrid fusion, blob storage, and a leases
re-export of db_kit::claim. Wires the crate into the workspace members list.

Applies three plan-mandated fixes during import: drop deps unused anywhere in
the crate (agentflare-artifacts, sha2, hex, uuid, chrono, mime_guess), use
BLAKE3 instead of SHA-256 for blob hashing per the plan's global constraints,
and a clippy nit in the hybrid-search chain (redundant into_iter()).

Verified: cargo build --workspace --all-features, cargo clippy -p
agentflare-store -- -D warnings, cargo test --workspace (27/27 in this crate,
full workspace suite green).

Known gaps vs the 8-task plan, tracked as follow-up work:
- embeddings module is a 50-line stub (cosine_similarity/normalize only) -
  Task 5's vendoring of lean-ctx's model/tokenizer/download pipeline never
  happened, so doc_vec_search/doc_hybrid_search are untestable end-to-end
- blob storage chunks bytes into SQLite rather than content-addressed files
  on disk under a blobs dir, as the plan specified
- documents schema is missing title/doc_type/blob_hash/mime/tags/session_id/
  source/version/history and has no versioning API
- Store holds a bare rusqlite::Connection (not parking_lot::Mutex-wrapped),
  so it isn't Sync and can't be shared across threads as the plan's Arc<Store>
  design requires
- Task 8 (migrate state.json onto this crate's kv store) hasn't been started;
  src/state.rs is untouched and agentflare-store isn't yet a dependency of
  the main package

* feat(agentflare-store): initial crate - kv, documents (CRUD+FTS+vector), blobs, leases

* fix(agentflare-store): wrap doc upsert in a transaction, preserve blob_hash/mime in history, fix broken embedding dimension probe

- doc_upsert_with_opts issued ~8 unwrapped statements against a WAL-mode,
  multi-process-shared SQLite file; wrap the whole upsert in a transaction
  so concurrent readers/crashes can't observe a half-written document.
- store_doc_history never captured blob_hash/mime, so doc_history()/
  doc_get_version() always returned blank metadata for old versions; capture
  and store them, add a regression test.
- doc_vec_search silently swallowed row-decode errors via filter_map(Result::ok),
  making a real DB error look like zero matches; propagate them instead.
- detect_dimensions() passed a remote https:// model URL into ort's
  commit_from_file (which expects a local path), so EmbeddingEngine::load()
  always failed when the embeddings feature was used; pass the local model
  path that load_model() already resolves.
- removed dead .meta sidecar file writes/deletes in blobs.rs (never read),
  a duplicate is_bert_punctuation() in embedding_pipeline/mod.rs, a
  no-op if/else in embed() with identical branches, and two warnings
  (unused import, unused mut).

* fix(agentflare-store): dedupe normalize, cargo fmt, clippy clean

- embedding_pipeline::pooling::normalize_l2/l2_norm duplicated
  embed::normalize with a looser epsilon (f32::EPSILON vs 1e-12) and
  l2_norm had zero callers. Removed both; embed::normalize is now the
  single L2-normalize implementation, used by embed() and embed_query().
- cargo fmt across the crate (documents.rs, mod.rs, model_registry.rs,
  pooling.rs, kv.rs -- download.rs/tokenizer.rs left as-is, in-flight
  elsewhere).
- clippy -D warnings clean: dead model_id field (added a model_id()
  accessor alongside the existing dimensions() getter), a manual index
  loop over sum flagged by clippy::needless_range_loop, and a
  let-and-return in model_directory().

* fix(agentflare-store): dispatch tokenizer by model.type, implement BPE decoding

* fix(agentflare-store): re-verify size+SHA-256 of already-present model files

* fix(agentflare-store): cargo fmt download.rs after merging #153's fix

* feat(agentflare-store): wire state.json onto the store's kv table (Task 8)

src/store.rs::open() previously cached the store behind a OnceLock -- dead
code (nothing called it) and would have broken test isolation the moment
something did, since AGENTFLARE_HOME_OVERRIDE changes per-test but a
cached singleton would keep pointing at whichever home dir opened it
first. Open fresh per call instead, matching memory::store::open()'s
existing pattern in this codebase.

state::load()/save() now read/write through agentflare-store's kv table
under the active/version_cache keys, with a one-time import of any
legacy state.json via agentflare_store::migrate::migrate_state_json on
first load against a store that has neither key yet. Public API
unchanged (State, load(), save(), state_path()), so no callers change.

* fix(agentflare-store): stop colliding with db.rs's agentflare.db, use store.db

store_path() picked ~/.agentflare/agentflare.db -- the same file src/db.rs
already owns as its single source-of-truth relational store (claims,
handoffs, review_findings, gateway_secrets), with its own separate
migration list. Two independent migration systems targeting one file is
a real hazard, not just an aesthetic collision. agentflare-store is a
different kind of storage (blobs, FTS+vector docs, kv) so a separate
file is correct -- it just needed a name that doesn't collide.

* fix(agentflare-store): make blob/doc/migrate writes atomic and race-free

- blob_store/blob_unref: wrap the exists-check + insert/decrement + cascade
  in an Immediate transaction so concurrent connections can't race on the
  same hash; compensate for orphaned disk writes on failure.
- doc_upsert_with_opts: open the transaction before reading the current
  version so concurrent upserts can't both read version N and both write
  N+1; also makes new-document insert + FTS sync atomic.
- doc_hard_delete: delete history/vector/FTS rows before the parent row,
  all in one transaction.
- migrate_state_json: one transaction for every key plus the completion
  marker, so a partial failure can't desync the marker from what migrated.

* fix(agentflare-store): surface errors instead of silently swallowing them

- cosine_similarity: return None on mismatched vector lengths instead of
  letting zip() silently drop the excess coordinates.
- blob_get: propagate disk read I/O errors (permissions, disk failure)
  instead of folding them into 'blob not found'.
- read_lockfile: distinguish a missing lockfile (fine, first run) from an
  unreadable or malformed one (error) instead of treating both as 'no
  pins', which made every present file look unverified and skip the
  tamper check.
- ensure_model: abort if a corrupt model file can't be deleted, instead
  of proceeding while the corrupt file is still on disk.
- resolve_model: error on an invalid AGENTFLARE_EMBEDDING_MODEL instead
  of silently falling back to the default model.

* fix(agentflare-store): collision-resistant model slug, name-based ONNX I/O, correct WordPiece fallback

- CustomModelSpec::storage_slug: hash repo+revision into the cache dir
  name instead of truncating the revision to 16 chars, which let two
  different (repo, revision) pairs collide on the same cache directory.
- EmbeddingEngine::load_model: resolve ONNX input/output tensors by
  declared name (with a positional fallback for the two inputs every
  model has) instead of blind index 0/1/2, which breaks if a model
  export ever reorders its inputs/outputs.
- wordpiece_encode: an unsegmentable word now collapses to a single
  [UNK], matching standard WordPiece, instead of one UNK per unmatched
  character interleaved with whatever subwords did match.
- vec_search_ranks_by_similarity test: use directionally distinct
  embeddings; the old ones were collinear and scored identically
  regardless of magnitude, so the test wasn't exercising rank order.

* fix(flare-output): treat blank model env vars as unset, keep pre-rename backups discoverable

- call_via_api: a blank FLARE_OUTPUT_MODEL/CAVEMAN_MODEL value no longer
  wins over the next fallback in the chain.
- backup_path_for (OutOfTree): the backup namespace directory was renamed
  from 'caveman' to 'flare-output' with a hard rename and no fallback, so
  a user's pre-rename backups became invisible to both the 'backup
  already exists' guard and Report::original_path. Now resolves to an
  existing legacy path if the new one doesn't have one yet.

* fix(flare-output): build legacy caveman test path by component, not string replace
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant