feat(mcp): consolidate retrieval into recall (4→1, HOT PATH)

Tool Consolidation v2.2.0, Layer 1 commit 6/6. Advertised tools 15 → 12 (target).

Folds search + deep_reference + cross_reference + contradictions into one
mode-dispatched tool:

  mode = lookup (DEFAULT) | reason | contradictions

HOT-PATH INVARIANT: with no `mode` set, `recall` is a zero-overhead pass-through
to search_unified::execute — plain recall never pays the 5–10× reasoning cost.
Only mode='reason' runs spreading activation + FSRS trust scoring. Verified by
test_recall_lookup_matches_search_shape (recall default == search byte-for-byte).

- Schema derived from search_unified::schema() so every lookup param carries
  through; the global `required: ["query"]` is dropped (contradictions uses
  `topic`) and validated per-mode at runtime; mode/depth/topic/since/min_trust
  added.
- `recall` becomes the primary retrieval verb and first advertised tool. The old
  `recall`/`semantic_search`/`hybrid_search` search aliases now redirect to it;
  search/deep_reference/cross_reference/contradictions are hidden warn!+redirect
  aliases (removed v2.3.0).
- Size annotation moved search (300k) → recall, synced across loop, helper, and
  both annotation tests. test_meta_wire_shape updated to probe `recall`.
- emit_tool_event normalizes `recall` → SearchPerformed only on lookup;
  reason/contradictions do not emit.
- Tests: count 15→12, 4 negatives, recall modes/aliases + lookup-shape tests.

Final advertised surface (12): recall, memory, codebase, intention, smart_ingest,
source_sync, memory_status, dedup, graph, maintain, session_start, suppress.

Gates: cargo test --workspace (435 mcp tests green), cargo clippy -D warnings.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
Sam Valladares 2026-06-28 18:08:39 -05:00
parent fa87186724
commit 7398f0c1b3
3 changed files with 309 additions and 43 deletions

View file

@ -245,14 +245,19 @@ impl McpServer {
// Deprecated tools still work via redirects in handle_tools_call.
let mut tools = vec![
// ================================================================
// UNIFIED TOOLS (v1.1+)
// RECALL — unified retrieval tool (v2.2). HOT PATH.
// Folds search + deep_reference + cross_reference + contradictions.
// mode='lookup' (default) is a zero-overhead pass-through to search.
// ================================================================
ToolDescription {
name: "search".to_string(),
description: Some("Unified search tool. Uses hybrid search (keyword + semantic + convex combination fusion) internally. Auto-strengthens memories on access (Testing Effect).".to_string()),
input_schema: tools::search_unified::schema(),
name: "recall".to_string(),
description: Some("Retrieve from memory. Modes: 'lookup' (default — fast hybrid search: keyword + semantic + convex fusion, auto-strengthens on access; use for plain recall), 'reason' (deep cognitive reasoning across memories with FSRS-6 trust scoring, spreading activation, supersession, and contradiction analysis; use when accuracy matters, needs 'query'), 'contradictions' (surface trust-weighted disagreement pairs for a 'topic'). Default mode is fast — only 'reason' pays the deep-analysis cost.".to_string()),
input_schema: tools::recall::schema(),
..Default::default()
},
// ================================================================
// UNIFIED TOOLS (v1.1+)
// ================================================================
ToolDescription {
name: "memory".to_string(),
description: Some("Unified memory management tool. Actions: 'get' (retrieve full node), 'purge' (irreversibly remove content/embeddings with confirm=true), 'delete' (legacy alias for purge), 'state' (get accessibility state), 'promote' (thumbs up — increases retrieval strength), 'demote' (thumbs down — decreases retrieval strength, does NOT delete), 'edit' (update content in-place, preserves FSRS state).".to_string()),
@ -356,26 +361,10 @@ impl McpServer {
// memory_graph + composed_graph → `graph`, all in v2.2)
// ================================================================
// ================================================================
// DEEP REFERENCE (v2.0.4+) — replaces cross_reference
// DEEP REFERENCE (v2.0.4+) — folded into `recall` (mode='reason' /
// 'contradictions') in v2.2. deep_reference/cross_reference/
// contradictions remain hidden dispatch aliases.
// ================================================================
ToolDescription {
name: "deep_reference".to_string(),
description: Some("Deep cognitive reasoning across memories. Combines FSRS-6 trust scoring, spreading activation, temporal supersession, dream insights, and contradiction analysis to build a complete understanding of a topic. Returns trust-scored evidence, fact evolution timeline, and a recommended answer. Use this when accuracy matters.".to_string()),
input_schema: tools::cross_reference::schema(),
..Default::default()
},
ToolDescription {
name: "cross_reference".to_string(),
description: Some("Alias for deep_reference. Connect the dots across memories with cognitive reasoning.".to_string()),
input_schema: tools::cross_reference::schema(),
..Default::default()
},
ToolDescription {
name: "contradictions".to_string(),
description: Some("Inspect memory disagreements directly. Scans a topic or recent memories for trust-weighted contradiction pairs using the same local logic as deep_reference.".to_string()),
input_schema: tools::contradictions::schema(),
..Default::default()
},
// ================================================================
// ACTIVE FORGETTING (v2.0.5) — top-down suppression
// Anderson et al. 2025 Nat Rev Neurosci + Davis Rac1
@ -409,7 +398,8 @@ impl McpServer {
// empirical measurement shows truncation under realistic use.
for tool in tools.iter_mut() {
let max_chars: Option<u64> = match tool.name.as_str() {
"search" => Some(300_000),
// v2.2: search folded into recall (mode='lookup'); annotation moved.
"recall" => Some(300_000),
"memory_status" => Some(200_000),
"memory" => Some(100_000),
"codebase" => Some(100_000),
@ -470,7 +460,20 @@ impl McpServer {
// ================================================================
// UNIFIED TOOLS (v1.1+) - Preferred API
// ================================================================
// RECALL — unified retrieval tool (v2.2). HOT PATH.
// mode = lookup (default, zero-overhead) | reason | contradictions
"recall" => {
tools::recall::execute(
&self.storage,
&self.cognitive,
&self.output_config,
request.arguments,
)
.await
}
// DEPRECATED (v2.2): folded into `recall` (mode='lookup'). Hidden alias.
"search" => {
warn!("Tool 'search' is deprecated in v2.2. Use 'recall' (mode='lookup', the default).");
tools::search_unified::execute(
&self.storage,
&self.cognitive,
@ -617,11 +620,12 @@ impl McpServer {
"mark_reviewed" => tools::review::execute(&self.storage, request.arguments).await,
// ================================================================
// DEPRECATED: Search tools - redirect to unified 'search'
// DEPRECATED: legacy search aliases — redirect to `recall` lookup.
// ('recall' itself is now the unified retrieval tool, handled above.)
// ================================================================
"recall" | "semantic_search" | "hybrid_search" => {
"semantic_search" | "hybrid_search" => {
warn!(
"Tool '{}' is deprecated. Use 'search' instead.",
"Tool '{}' is deprecated. Use 'recall' (mode='lookup') instead.",
request.name
);
tools::search_unified::execute(
@ -1075,11 +1079,14 @@ impl McpServer {
warn!("Tool 'composed_graph' is deprecated in v2.2. Use 'graph' (action='recent'|'get'|'memory'|'neighbors'|'never_composed'|'bounty_mode'|'label').");
tools::composed_graph::execute(&self.storage, request.arguments).await
}
// DEPRECATED (v2.2): folded into `recall`. Hidden aliases.
"deep_reference" | "cross_reference" => {
warn!("Tool '{}' is deprecated in v2.2. Use 'recall' (mode='reason').", request.name);
tools::cross_reference::execute(&self.storage, &self.cognitive, request.arguments)
.await
}
"contradictions" => {
warn!("Tool 'contradictions' is deprecated in v2.2. Use 'recall' (mode='contradictions').");
tools::contradictions::execute(&self.storage, request.arguments).await
}
@ -1323,6 +1330,18 @@ impl McpServer {
.and_then(|a| a.get("action"))
.and_then(|v| v.as_str())
.unwrap_or("maintain")
} else if tool_name == "recall" {
// The unified `recall` tool fires SearchPerformed only for the lookup
// path (the former `search`). reason/contradictions do not emit, so
// map them to a non-emitting name.
match args
.as_ref()
.and_then(|a| a.get("mode"))
.and_then(|v| v.as_str())
{
Some("reason") | Some("contradictions") => "recall_noemit",
_ => "search", // lookup (default) → SearchPerformed
}
} else {
tool_name
};
@ -1857,14 +1876,16 @@ mod tests {
// dispatchable as hidden back-compat aliases but drop off the advertised list.
assert_eq!(
tools.len(),
15,
"Expected exactly 15 tools after dedup + memory_status + graph + maintain consolidation"
12,
"Expected exactly 12 tools after v2.2 Layer-1 consolidation \
(dedup + memory_status + graph + maintain + recall; session_context renamed)"
);
let tool_names: Vec<&str> = tools.iter().map(|t| t["name"].as_str().unwrap()).collect();
// Unified tools
assert!(tool_names.contains(&"search"));
// (search folded into `recall` mode='lookup' in v2.2)
assert!(tool_names.contains(&"recall"));
assert!(tool_names.contains(&"memory"));
assert!(tool_names.contains(&"codebase"));
assert!(tool_names.contains(&"intention"));
@ -1981,10 +2002,20 @@ mod tests {
);
}
// Deep reference + cross_reference alias (v2.0.4)
assert!(tool_names.contains(&"deep_reference"));
assert!(tool_names.contains(&"cross_reference"));
assert!(tool_names.contains(&"contradictions"));
// Retrieval — unified `recall` tool (v2.2). search + deep_reference +
// cross_reference + contradictions folded in; old names dispatch as
// hidden aliases but are off the advertised list.
for old in [
"search",
"deep_reference",
"cross_reference",
"contradictions",
] {
assert!(
!tool_names.contains(&old),
"{old} should be folded into 'recall' in v2.2"
);
}
// Active forgetting (v2.0.5) — Anderson 2025 + Davis Rac1
assert!(tool_names.contains(&"suppress"));
@ -2168,6 +2199,71 @@ mod tests {
assert!(validated, "maintain action=restore must validate a missing path");
}
/// v2.2 HOT PATH: `recall` defaults to mode='lookup' (search), the folded
/// names still dispatch, and the reason/contradictions modes resolve.
#[tokio::test]
async fn test_recall_modes_and_aliases() {
let (mut server, _dir) = test_server().await;
let init_request = make_request("initialize", Some(init_params()));
server.handle_request(init_request).await;
let calls: Vec<(&str, serde_json::Value)> = vec![
// Deprecated aliases must still dispatch.
("search", serde_json::json!({"query": "x"})),
("deep_reference", serde_json::json!({"query": "x"})),
("cross_reference", serde_json::json!({"query": "x"})),
("contradictions", serde_json::json!({})),
("semantic_search", serde_json::json!({"query": "x"})),
// New unified modes.
("recall", serde_json::json!({"query": "x"})), // default mode = lookup
("recall", serde_json::json!({"mode": "lookup", "query": "x"})),
("recall", serde_json::json!({"mode": "reason", "query": "x"})),
("recall", serde_json::json!({"mode": "contradictions"})),
];
for (name, args) in calls {
let request = make_request(
"tools/call",
Some(serde_json::json!({ "name": name, "arguments": args })),
);
let response = server.handle_request(request).await.unwrap();
assert!(
response.error.is_none(),
"'{name}' {args} should resolve, got error: {:?}",
response.error
);
}
}
/// v2.2: `recall` mode='lookup' (the default) must produce the same result
/// shape as the former standalone `search` — i.e. the no-mode default is a
/// faithful pass-through, not a reasoning call.
#[tokio::test]
async fn test_recall_lookup_matches_search_shape() {
let (mut server, _dir) = test_server().await;
let init_request = make_request("initialize", Some(init_params()));
server.handle_request(init_request).await;
let args = serde_json::json!({ "query": "anything" });
let via_recall = make_request(
"tools/call",
Some(serde_json::json!({ "name": "recall", "arguments": args })),
);
let via_search = make_request(
"tools/call",
Some(serde_json::json!({ "name": "search", "arguments": args })),
);
let r1 = server.handle_request(via_recall).await.unwrap();
let r2 = server.handle_request(via_search).await.unwrap();
assert!(r1.error.is_none() && r2.error.is_none());
// The unified-tool wrapper text (the search payload) must match.
assert_eq!(
r1.result.unwrap()["content"][0]["text"],
r2.result.unwrap()["content"][0]["text"],
"recall(mode=lookup) must equal search byte-for-byte"
);
}
#[tokio::test]
async fn test_tools_have_descriptions_and_schemas() {
let (mut server, _dir) = test_server().await;
@ -2382,7 +2478,8 @@ mod tests {
/// (cargo-cult prevention).
fn expected_max_result_size(name: &str) -> Option<u64> {
match name {
"search" => Some(300_000),
// v2.2: search folded into recall (mode='lookup'); annotation moved.
"recall" => Some(300_000),
// v2.2: memory_timeline folded into memory_status (view='timeline');
// the high-payload annotation moved with it.
"memory_status" => Some(200_000),
@ -2407,7 +2504,7 @@ mod tests {
let result = response.result.unwrap();
let tools = result["tools"].as_array().unwrap();
for name in ["search", "memory_status", "memory", "codebase", "dedup", "graph"] {
for name in ["recall", "memory_status", "memory", "codebase", "dedup", "graph"] {
let tool = tools
.iter()
.find(|t| t["name"].as_str() == Some(name))
@ -2495,19 +2592,20 @@ mod tests {
let result = response.result.unwrap();
let tools = result["tools"].as_array().unwrap();
let search_tool = tools
// v2.2: `recall` is the annotated retrieval tool (search folded in).
let recall_tool = tools
.iter()
.find(|t| t["name"].as_str() == Some("search"))
.expect("'search' tool present");
.find(|t| t["name"].as_str() == Some("recall"))
.expect("'recall' tool present");
// Wire-form: `_meta` must exist; `meta` (un-renamed) must NOT exist.
assert!(
search_tool.get("_meta").is_some(),
"search tool missing `_meta` key (serde rename to _meta did not apply)"
recall_tool.get("_meta").is_some(),
"recall tool missing `_meta` key (serde rename to _meta did not apply)"
);
assert!(
search_tool.get("meta").is_none(),
"search tool has un-renamed `meta` key (regression — serde rename broke)"
recall_tool.get("meta").is_none(),
"recall tool has un-renamed `meta` key (regression — serde rename broke)"
);
}
}

View file

@ -11,6 +11,11 @@ pub mod codebase_unified;
pub mod intention_unified;
pub mod memory_unified;
pub mod search_unified;
// v2.2: Unified retrieval surface — folds search + deep_reference +
// cross_reference + contradictions into one mode-dispatched tool.
// mode=lookup (default) is a zero-overhead pass-through to search_unified.
pub mod recall;
pub mod smart_ingest;
// #57: external-source connectors (GitHub Issues / Redmine retrieval layer)
pub mod source_sync;

View file

@ -0,0 +1,163 @@
//! Unified `recall` Tool (v2.2 — Tool Consolidation, HOT PATH)
//!
//! Folds the four retrieval/reasoning tools into one mode-dispatched surface:
//!
//! mode = lookup (DEFAULT) | reason | contradictions
//!
//! - `lookup` (default) → hybrid search (the former `search`). This is the hot
//! path: with no `mode` set, `recall` is a ZERO-overhead pass-through to
//! `search_unified::execute` — it must never pay the cost of the reasoning
//! path. (`deep_reference`/`reason` runs spreading activation + FSRS trust
//! scoring + contradiction analysis and is 510× slower.)
//! - `reason` → deep cognitive reasoning across memories (former
//! `deep_reference` / `cross_reference`).
//! - `contradictions` → trust-weighted disagreement pairs (former
//! `contradictions`).
//!
//! The schema is derived from `search_unified::schema()` (so every lookup
//! parameter stays available and documented) plus the `mode` discriminator and
//! the reason/contradictions fields. `query` is NOT globally required because
//! the contradictions mode is scoped by `topic`; per-mode requirements are
//! validated at runtime.
use serde_json::Value;
use std::sync::Arc;
use tokio::sync::Mutex;
use vestige_core::{OutputConfig, Storage};
use crate::cognitive::CognitiveEngine;
/// Discriminated-union schema for the unified `recall` tool.
///
/// Built on top of `search_unified::schema()` so all lookup parameters carry
/// through verbatim; the `required: ["query"]` constraint is dropped (validated
/// per-mode at runtime) and the mode/reason/contradictions fields are added.
pub fn schema() -> Value {
let mut schema = super::search_unified::schema();
if let Some(obj) = schema.as_object_mut() {
// Drop the global `query` requirement — contradictions uses `topic`.
obj.remove("required");
if let Some(props) = obj.get_mut("properties").and_then(|p| p.as_object_mut()) {
props.insert(
"mode".to_string(),
serde_json::json!({
"type": "string",
"enum": ["lookup", "reason", "contradictions"],
"default": "lookup",
"description": "Retrieval mode. 'lookup' (default): fast hybrid search — use for plain recall. 'reason': deep cognitive reasoning across memories (FSRS-6 trust scoring, spreading activation, supersession, contradiction analysis) — use when accuracy matters; needs 'query'. 'contradictions': surface trust-weighted disagreement pairs for a 'topic' (or recent memories)."
}),
);
// reason (deep_reference) extra field.
props.insert(
"depth".to_string(),
serde_json::json!({
"type": "integer",
"description": "[reason mode] How many memories to analyze (default 20, max 50).",
"minimum": 5, "maximum": 50
}),
);
// contradictions extra fields.
props.insert(
"topic".to_string(),
serde_json::json!({
"type": "string",
"description": "[contradictions mode] Topic to scope contradiction detection. If omitted, scans recent memories."
}),
);
props.insert(
"since".to_string(),
serde_json::json!({
"type": "string",
"description": "[contradictions mode] RFC3339 timestamp; only memories updated after this are considered."
}),
);
props.insert(
"min_trust".to_string(),
serde_json::json!({
"type": "number",
"minimum": 0.0, "maximum": 1.0,
"description": "[contradictions mode] Minimum trust for both sides of a contradiction (default 0.3)."
}),
);
}
}
schema
}
/// Unified dispatcher for `recall`. Routes on `mode` (default `lookup`).
///
/// HOT-PATH INVARIANT: `mode` absent ⇒ `lookup` ⇒ direct pass-through to
/// `search_unified::execute`, no extra work.
pub async fn execute(
storage: &Arc<Storage>,
cognitive: &Arc<Mutex<CognitiveEngine>>,
output_config: &OutputConfig,
args: Option<Value>,
) -> Result<Value, String> {
let mode = args
.as_ref()
.and_then(|a| a.get("mode"))
.and_then(|v| v.as_str())
.unwrap_or("lookup");
match mode {
// Zero-overhead default: straight to hybrid search.
"lookup" => super::search_unified::execute(storage, cognitive, output_config, args).await,
// Deep reasoning (deep_reference / cross_reference share this handler).
"reason" => super::cross_reference::execute(storage, cognitive, args).await,
// Trust-weighted contradiction pairs (storage-only).
"contradictions" => super::contradictions::execute(storage, args).await,
other => Err(format!(
"Unknown recall mode '{other}'. Use lookup|reason|contradictions."
)),
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_schema_has_mode_and_no_required() {
let s = schema();
let modes = s["properties"]["mode"]["enum"].as_array().unwrap();
assert_eq!(modes.len(), 3);
assert_eq!(s["properties"]["mode"]["default"], "lookup");
// query must NOT be globally required (contradictions uses topic).
assert!(
s.get("required").is_none(),
"recall must not globally require 'query'"
);
// lookup params carried over from search schema.
assert!(s["properties"]["limit"].is_object());
assert!(s["properties"]["detail_level"].is_object());
}
#[tokio::test]
async fn test_lookup_is_default_and_resolves() {
let dir = tempfile::TempDir::new().unwrap();
let storage = Arc::new(Storage::new(Some(dir.path().join("test.db"))).unwrap());
let cognitive = Arc::new(Mutex::new(CognitiveEngine::new()));
let oc = OutputConfig::default();
// No mode → lookup → behaves like search (query required by search).
let args = Some(serde_json::json!({ "query": "anything" }));
let r = execute(&storage, &cognitive, &oc, args).await;
assert!(r.is_ok(), "default lookup should resolve: {r:?}");
}
#[tokio::test]
async fn test_contradictions_mode_resolves_without_query() {
let dir = tempfile::TempDir::new().unwrap();
let storage = Arc::new(Storage::new(Some(dir.path().join("test.db"))).unwrap());
let cognitive = Arc::new(Mutex::new(CognitiveEngine::new()));
let oc = OutputConfig::default();
// contradictions uses topic, not query — must resolve with no query.
let args = Some(serde_json::json!({ "mode": "contradictions" }));
let r = execute(&storage, &cognitive, &oc, args).await;
assert!(r.is_ok(), "contradictions mode should resolve: {r:?}");
}
}