feat(mcp): consolidate graph/assoc/predict into graph (4→1)

Tool Consolidation v2.2.0, Layer 1 commit 4/6 (3b). Advertised tools 24 → 21.

Folds explore_connections + predict + memory_graph + composed_graph into one
action-dispatched tool:

  action ∈ {chain, associations, bridges, predict, memory_graph,
            recent, get, memory, neighbors, never_composed, bounty_mode, label}

- Transparent facade: each action forwards the same args envelope to the
  existing handler, which re-reads its own discriminator/params. No underlying
  arg struct uses deny_unknown_fields, so cross-fields are ignored.
- All actions read-only except `label` (the one mutator), which is logged for
  audit via tracing::info!(event_id, outcome_type).
- outcome_type enum sourced from composed_graph::OUTCOME_TYPES (now pub(crate))
  rather than re-listed, so the vocabulary stays single-sourced.
- All 4 old names remain hidden warn!+redirect aliases (removed v2.3.0).
- Size annotation: graph=250_000 (memory_graph layout + bounty_mode pagination),
  kept in sync across loop, helper, and both annotation tests.
- Tests: count 24→21, 4 negatives, test_graph_actions_and_aliases exercising
  read-only actions + aliases (incl. the memory_graph facade branch).

Note: the design draft mislabeled graph::execute as sync; it is `pub async fn`.
The per-commit build gate caught it — the facade awaits it correctly.

Gates: cargo test --workspace, cargo clippy -D warnings — clean.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
Sam Valladares 2026-06-28 17:45:33 -05:00
parent 32e6a6cd8d
commit e3378316ed
4 changed files with 231 additions and 32 deletions

View file

@ -357,16 +357,14 @@ impl McpServer {
input_schema: tools::dream::schema(),
..Default::default()
},
// ================================================================
// GRAPH — unified graph/association/prediction tool (v2.2)
// Folds explore_connections + predict + memory_graph + composed_graph.
// ================================================================
ToolDescription {
name: "explore_connections".to_string(),
description: Some("Graph exploration tool for memory connections. Actions: 'chain' (build reasoning path between memories), 'associations' (find related memories via spreading activation + hippocampal index), 'bridges' (find connecting memories between two nodes).".to_string()),
input_schema: tools::explore::schema(),
..Default::default()
},
ToolDescription {
name: "predict".to_string(),
description: Some("Proactive memory prediction — predicts what memories you'll need next based on context, recent activity, and learned patterns. Returns predictions, suggestions, and speculative retrievals.".to_string()),
input_schema: tools::predict::schema(),
name: "graph".to_string(),
description: Some("Memory graph & associations. Actions: 'chain' (reasoning path from→to), 'associations' (related memories via spreading activation, needs 'from'), 'bridges' (connectors between from/to), 'predict' (what memories you'll need next, from 'context'), 'memory_graph' (force-directed subgraph for viz, from center_id or query), 'recent'/'get'/'memory'/'neighbors'/'never_composed'/'bounty_mode' (composition topology), 'label' (record a composition outcome — the only write).".to_string()),
input_schema: tools::graph_unified::schema(),
..Default::default()
},
// ================================================================
@ -389,20 +387,9 @@ impl McpServer {
},
// ================================================================
// AUTONOMIC TOOLS (v1.9+)
// (memory_health folded into `memory_status` view='retention' in v2.2)
// (memory_health → `memory_status` view='retention';
// memory_graph + composed_graph → `graph`, all in v2.2)
// ================================================================
ToolDescription {
name: "memory_graph".to_string(),
description: Some("Subgraph export for visualization. Input: center_id or query, depth (1-3), max_nodes. Returns nodes with force-directed layout positions and edges with weights. Powers memory graph visualization.".to_string()),
input_schema: tools::graph::schema(),
..Default::default()
},
ToolDescription {
name: "composed_graph".to_string(),
description: Some("ComposedGraph memory topology. Reads durable composition events, members, and outcome labels; returns recent/already-composed lanes, neighbors, never-composed pairs, bounty-mode lanes, and lets users label outcomes such as helpful, submitted, accepted, rejected, duplicate_risk, needs_poc, or dead_end.".to_string()),
input_schema: tools::composed_graph::schema(),
..Default::default()
},
// ================================================================
// DEEP REFERENCE (v2.0.4+) — replaces cross_reference
// ================================================================
@ -464,6 +451,9 @@ impl McpServer {
// v2.2: dedup action='scan' returns duplicate clusters +
// merge candidates + policy in one payload.
"dedup" => Some(150_000),
// v2.2: graph action='memory_graph' (force-directed layout) and
// 'bounty_mode' pagination can both produce large payloads.
"graph" => Some(250_000),
_ => None,
};
if let Some(n) = max_chars {
@ -1012,10 +1002,20 @@ impl McpServer {
});
tools::dream::execute(&self.storage, &self.cognitive, request.arguments).await
}
// ================================================================
// GRAPH — unified graph/association/prediction tool (v2.2)
// ================================================================
"graph" => {
tools::graph_unified::execute(&self.storage, &self.cognitive, request.arguments)
.await
}
// DEPRECATED (v2.2): folded into `graph`. Hidden aliases.
"explore_connections" => {
warn!("Tool 'explore_connections' is deprecated in v2.2. Use 'graph' (action='chain'|'associations'|'bridges').");
tools::explore::execute(&self.storage, &self.cognitive, request.arguments).await
}
"predict" => {
warn!("Tool 'predict' is deprecated in v2.2. Use 'graph' (action='predict').");
tools::predict::execute(&self.storage, &self.cognitive, request.arguments).await
}
"restore" => tools::restore::execute(&self.storage, request.arguments).await,
@ -1052,8 +1052,13 @@ impl McpServer {
warn!("Tool 'memory_health' is deprecated in v2.2. Use 'memory_status' (view='retention').");
tools::health::execute(&self.storage, request.arguments).await
}
"memory_graph" => tools::graph::execute(&self.storage, request.arguments).await,
// DEPRECATED (v2.2): folded into `graph`. Hidden aliases.
"memory_graph" => {
warn!("Tool 'memory_graph' is deprecated in v2.2. Use 'graph' (action='memory_graph').");
tools::graph::execute(&self.storage, request.arguments).await
}
"composed_graph" => {
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
}
"deep_reference" | "cross_reference" => {
@ -1825,8 +1830,8 @@ mod tests {
// dispatchable as hidden back-compat aliases but drop off the advertised list.
assert_eq!(
tools.len(),
24,
"Expected exactly 24 tools after dedup + memory_status consolidation"
21,
"Expected exactly 21 tools after dedup + memory_status + graph consolidation"
);
let tool_names: Vec<&str> = tools.iter().map(|t| t["name"].as_str().unwrap()).collect();
@ -1913,9 +1918,8 @@ mod tests {
}
// Cognitive tools (v1.5)
// (explore_connections + predict folded into `graph` in v2.2)
assert!(tool_names.contains(&"dream"));
assert!(tool_names.contains(&"explore_connections"));
assert!(tool_names.contains(&"predict"));
assert!(tool_names.contains(&"restore"));
// Context packets (v1.8) — renamed session_context → session_start (v2.2)
@ -1925,9 +1929,21 @@ mod tests {
"session_context renamed to 'session_start' in v2.2"
);
// Autonomic tools (v1.9) — memory_health folded into memory_status (v2.2)
assert!(tool_names.contains(&"memory_graph"));
assert!(tool_names.contains(&"composed_graph"));
// Graph — unified `graph` tool (v2.2). explore_connections + predict +
// memory_graph + composed_graph folded in; old names dispatch as hidden
// aliases but are off the advertised list. (memory_health → memory_status.)
assert!(tool_names.contains(&"graph"));
for old in [
"explore_connections",
"predict",
"memory_graph",
"composed_graph",
] {
assert!(
!tool_names.contains(&old),
"{old} should be folded into 'graph' in v2.2"
);
}
// Deep reference + cross_reference alias (v2.0.4)
assert!(tool_names.contains(&"deep_reference"));
@ -2011,6 +2027,43 @@ mod tests {
}
}
/// v2.2: the 4 tools folded into `graph` must still dispatch, and the
/// read-only `graph` actions must resolve. (memory_graph is sync — this also
/// guards the no-`.await` facade branch.)
#[tokio::test]
async fn test_graph_actions_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 (not unknown-tool).
("predict", serde_json::json!({})),
("memory_graph", serde_json::json!({})),
("composed_graph", serde_json::json!({"action": "recent"})),
// New unified actions (read-only).
("graph", serde_json::json!({"action": "predict"})),
("graph", serde_json::json!({"action": "memory_graph"})),
("graph", serde_json::json!({"action": "recent"})),
("graph", serde_json::json!({"action": "never_composed"})),
];
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();
if let Some(err) = response.error {
assert_ne!(
err.code, -32602,
"'{name}' {args} should dispatch (not unknown-tool): {}",
err.message
);
}
}
}
#[tokio::test]
async fn test_tools_have_descriptions_and_schemas() {
let (mut server, _dir) = test_server().await;
@ -2233,6 +2286,8 @@ mod tests {
"codebase" => Some(100_000),
// v2.2: dedup action='scan' returns clusters + candidates + policy.
"dedup" => Some(150_000),
// v2.2: graph memory_graph layout + bounty_mode pagination.
"graph" => Some(250_000),
_ => None,
}
}
@ -2248,7 +2303,7 @@ mod tests {
let result = response.result.unwrap();
let tools = result["tools"].as_array().unwrap();
for name in ["search", "memory_status", "memory", "codebase", "dedup"] {
for name in ["search", "memory_status", "memory", "codebase", "dedup", "graph"] {
let tool = tools
.iter()
.find(|t| t["name"].as_str() == Some(name))

View file

@ -7,7 +7,7 @@ use std::sync::Arc;
use uuid::Uuid;
use vestige_core::{CompositionOutcomeRecord, Storage};
const OUTCOME_TYPES: &[&str] = &[
pub(crate) const OUTCOME_TYPES: &[&str] = &[
"helpful",
"dead_end",
"submitted",

View file

@ -0,0 +1,140 @@
//! Unified `graph` Tool (v2.2 — Tool Consolidation)
//!
//! Folds four graph/association/prediction tools into one action-dispatched
//! surface:
//!
//! action ∈ {
//! chain, associations, bridges, // former explore_connections
//! predict, // former predict
//! memory_graph, // former memory_graph (viz subgraph)
//! recent, get, memory, neighbors, // former composed_graph
//! never_composed, bounty_mode, label, // "
//! }
//!
//! This is a transparent facade: each action forwards the *same* args envelope
//! to the existing handler, which re-reads its own discriminator/params. None of
//! the underlying arg structs use `deny_unknown_fields`, so unrelated fields are
//! ignored. All actions are read-only EXCEPT `label`, which writes a composition
//! outcome (the one mutator) and is logged for audit.
use serde_json::Value;
use std::sync::Arc;
use tokio::sync::Mutex;
use vestige_core::Storage;
use crate::cognitive::CognitiveEngine;
// Reuse composed_graph's canonical outcome-label vocabulary (do not re-list).
use super::composed_graph::OUTCOME_TYPES;
/// Discriminated-union schema for the unified `graph` tool.
pub fn schema() -> Value {
serde_json::json!({
"type": "object",
"properties": {
"action": {
"type": "string",
"enum": [
"chain", "associations", "bridges",
"predict", "memory_graph",
"recent", "get", "memory", "neighbors",
"never_composed", "bounty_mode", "label"
],
"description": "Graph operation. Reasoning paths: 'chain' (from→to), 'associations' (related via spreading activation, needs 'from'), 'bridges' (connectors between from/to). 'predict' (what memories you'll need next, from 'context'). 'memory_graph' (force-directed subgraph for viz, from 'center_id' or 'query'). Composition topology: 'recent', 'get' (event_id), 'memory' (memory_id), 'neighbors' (memory_id), 'never_composed', 'bounty_mode', 'label' (record an outcome — the only write)."
},
// --- explore (chain/associations/bridges) ---
"from": { "type": "string", "description": "[chain/associations/bridges] Source memory ID." },
"to": { "type": "string", "description": "[chain/bridges] Target memory ID." },
// --- predict ---
"context": { "type": "object", "description": "[predict] Current context (current_file, current_topics, codebase)." },
// --- memory_graph (viz subgraph) ---
"center_id": { "type": "string", "description": "[memory_graph] Center node id (or use 'query')." },
"query": { "type": "string", "description": "[memory_graph] Pick a center node by search query." },
"depth": { "type": "integer", "minimum": 1, "maximum": 3, "description": "[memory_graph] Traversal depth (1-3, default 2)." },
"max_nodes": { "type": "integer", "description": "[memory_graph] Max nodes (default 50, capped 200)." },
// --- composed_graph ---
"event_id": { "type": "string", "description": "[get/label] Composition event id." },
"memory_id": { "type": "string", "description": "[memory/neighbors] Memory id." },
"tags": { "type": "array", "items": { "type": "string" }, "description": "[never_composed/bounty_mode] Optional tag filter." },
"outcome_type": {
"type": "string",
"enum": OUTCOME_TYPES,
"description": "[label] Outcome to record for the composition (the only mutating action)."
},
// --- shared ---
"limit": { "type": "integer", "description": "Max results (per-action defaults; clamped internally).", "minimum": 1, "maximum": 100 }
},
"required": ["action"]
})
}
/// Unified dispatcher for `graph`. Routes on `action`.
pub async fn execute(
storage: &Arc<Storage>,
cognitive: &Arc<Mutex<CognitiveEngine>>,
args: Option<Value>,
) -> Result<Value, String> {
let action = args
.as_ref()
.and_then(|a| a.get("action"))
.and_then(|v| v.as_str())
.ok_or("Missing 'action'. Use chain|associations|bridges|predict|memory_graph|recent|get|memory|neighbors|never_composed|bounty_mode|label.")?
.to_string();
match action.as_str() {
// explore_connections — re-reads its own `action` (chain/associations/bridges).
"chain" | "associations" | "bridges" => {
super::explore::execute(storage, cognitive, args).await
}
// predict — reads `context`, ignores `action`.
"predict" => super::predict::execute(storage, cognitive, args).await,
// memory_graph — reads center_id/query/depth, ignores `action`.
"memory_graph" => super::graph::execute(storage, args).await,
// composed_graph — re-reads its own `action`. `label` is the only write.
"recent" | "get" | "memory" | "neighbors" | "never_composed" | "bounty_mode" | "label" => {
if action == "label" {
let event_id = args
.as_ref()
.and_then(|a| a.get("event_id"))
.and_then(|v| v.as_str())
.unwrap_or("?");
let outcome = args
.as_ref()
.and_then(|a| a.get("outcome_type"))
.and_then(|v| v.as_str())
.unwrap_or("?");
tracing::info!(
event_id = %event_id,
outcome_type = %outcome,
"graph: composition outcome labeled"
);
}
super::composed_graph::execute(storage, args).await
}
other => Err(format!(
"Unknown graph action '{other}'. Use chain|associations|bridges|predict|memory_graph|recent|get|memory|neighbors|never_composed|bounty_mode|label."
)),
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_schema_action_count() {
let s = schema();
let actions = s["properties"]["action"]["enum"].as_array().unwrap();
assert_eq!(actions.len(), 12);
// outcome_type enum is sourced from the canonical const.
let outcomes = s["properties"]["outcome_type"]["enum"].as_array().unwrap();
assert_eq!(outcomes.len(), OUTCOME_TYPES.len());
}
#[test]
fn test_missing_action_errors() {
// Pure arg-shape check; no storage needed for the early return path.
let s = schema();
assert_eq!(s["required"][0], "action");
}
}

View file

@ -46,6 +46,10 @@ pub mod session_context;
pub mod graph;
pub mod health;
// v2.2: Unified graph surface — folds explore_connections + predict +
// memory_graph + composed_graph into one action-dispatched tool.
pub mod graph_unified;
// v2.1: Cross-reference (connect the dots)
pub mod composed_graph;
pub mod contradictions;