mirror of
https://github.com/samvallad33/vestige.git
synced 2026-07-02 22:01:01 +02:00
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:
parent
32e6a6cd8d
commit
e3378316ed
4 changed files with 231 additions and 32 deletions
|
|
@ -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))
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
140
crates/vestige-mcp/src/tools/graph_unified.rs
Normal file
140
crates/vestige-mcp/src/tools/graph_unified.rs
Normal 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");
|
||||
}
|
||||
}
|
||||
|
|
@ -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;
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue