* fix(tools): wire ActivatedToolSet into tool dispatch and spec advertisement When deferred MCP tools are activated via tool_search, they are stored in ActivatedToolSet but never consulted by the tool call loop. tool_specs is built once before the iteration loop and never refreshed, so the provider API tools[] parameter never includes activated tools. find_tool only searches the static registry, so execution dispatch also fails silently. Thread Arc<Mutex<ActivatedToolSet>> from creation sites through to run_tool_call_loop. Rebuild tool_specs each iteration to merge base registry specs with activated specs. Add fallback in execute_one_tool to check the activated set when the static registry lookup misses. Change ActivatedToolSet internal storage from Box<dyn Tool> to Arc<dyn Tool> so we can clone the Arc out of the mutex guard before awaiting tool.execute() (std::sync::MutexGuard is not Send). * fix(tools): add activated_tools field to new ChannelRuntimeContext test site
362 lines
12 KiB
Rust
362 lines
12 KiB
Rust
//! Deferred MCP tool loading — stubs and activated-tool tracking.
|
|
//!
|
|
//! When `mcp.deferred_loading` is enabled, MCP tool schemas are NOT eagerly
|
|
//! included in the LLM context window. Instead, only lightweight stubs (name +
|
|
//! description) are exposed in the system prompt. The LLM must call the built-in
|
|
//! `tool_search` tool to fetch full schemas, which moves them into the
|
|
//! [`ActivatedToolSet`] for the current conversation.
|
|
|
|
use std::collections::HashMap;
|
|
use std::sync::Arc;
|
|
|
|
use crate::tools::mcp_client::McpRegistry;
|
|
use crate::tools::mcp_protocol::McpToolDef;
|
|
use crate::tools::mcp_tool::McpToolWrapper;
|
|
use crate::tools::traits::{Tool, ToolSpec};
|
|
|
|
// ── DeferredMcpToolStub ──────────────────────────────────────────────────
|
|
|
|
/// A lightweight stub representing a known-but-not-yet-loaded MCP tool.
|
|
/// Contains only the prefixed name, a human-readable description, and enough
|
|
/// information to construct the full [`McpToolWrapper`] on activation.
|
|
#[derive(Debug, Clone)]
|
|
pub struct DeferredMcpToolStub {
|
|
/// Prefixed name: `<server_name>__<tool_name>`.
|
|
pub prefixed_name: String,
|
|
/// Human-readable description (extracted from the MCP tool definition).
|
|
pub description: String,
|
|
/// The full tool definition — stored so we can construct a wrapper later.
|
|
def: McpToolDef,
|
|
}
|
|
|
|
impl DeferredMcpToolStub {
|
|
pub fn new(prefixed_name: String, def: McpToolDef) -> Self {
|
|
let description = def
|
|
.description
|
|
.clone()
|
|
.unwrap_or_else(|| "MCP tool".to_string());
|
|
Self {
|
|
prefixed_name,
|
|
description,
|
|
def,
|
|
}
|
|
}
|
|
|
|
/// Materialize this stub into a live [`McpToolWrapper`].
|
|
pub fn activate(&self, registry: Arc<McpRegistry>) -> McpToolWrapper {
|
|
McpToolWrapper::new(self.prefixed_name.clone(), self.def.clone(), registry)
|
|
}
|
|
}
|
|
|
|
// ── DeferredMcpToolSet ───────────────────────────────────────────────────
|
|
|
|
/// Collection of all deferred MCP tool stubs discovered at startup.
|
|
/// Provides keyword search for `tool_search`.
|
|
#[derive(Clone)]
|
|
pub struct DeferredMcpToolSet {
|
|
/// All stubs — exposed for test construction.
|
|
pub stubs: Vec<DeferredMcpToolStub>,
|
|
/// Shared registry — exposed for test construction.
|
|
pub registry: Arc<McpRegistry>,
|
|
}
|
|
|
|
impl DeferredMcpToolSet {
|
|
/// Build the set from a connected [`McpRegistry`].
|
|
pub async fn from_registry(registry: Arc<McpRegistry>) -> Self {
|
|
let names = registry.tool_names();
|
|
let mut stubs = Vec::with_capacity(names.len());
|
|
for name in names {
|
|
if let Some(def) = registry.get_tool_def(&name).await {
|
|
stubs.push(DeferredMcpToolStub::new(name, def));
|
|
}
|
|
}
|
|
Self { stubs, registry }
|
|
}
|
|
|
|
/// All stub names (for rendering in the system prompt).
|
|
pub fn stub_names(&self) -> Vec<&str> {
|
|
self.stubs
|
|
.iter()
|
|
.map(|s| s.prefixed_name.as_str())
|
|
.collect()
|
|
}
|
|
|
|
/// Number of deferred stubs.
|
|
pub fn len(&self) -> usize {
|
|
self.stubs.len()
|
|
}
|
|
|
|
/// Whether the set is empty.
|
|
pub fn is_empty(&self) -> bool {
|
|
self.stubs.is_empty()
|
|
}
|
|
|
|
/// Look up stubs by exact name. Used for `select:name1,name2` queries.
|
|
pub fn get_by_name(&self, name: &str) -> Option<&DeferredMcpToolStub> {
|
|
self.stubs.iter().find(|s| s.prefixed_name == name)
|
|
}
|
|
|
|
/// Keyword search — returns stubs whose name or description contains any
|
|
/// of the query terms (case-insensitive). Results are ranked by number of
|
|
/// matching terms (descending).
|
|
pub fn search(&self, query: &str, max_results: usize) -> Vec<&DeferredMcpToolStub> {
|
|
let terms: Vec<String> = query
|
|
.split_whitespace()
|
|
.map(|t| t.to_ascii_lowercase())
|
|
.collect();
|
|
if terms.is_empty() {
|
|
return self.stubs.iter().take(max_results).collect();
|
|
}
|
|
|
|
let mut scored: Vec<(&DeferredMcpToolStub, usize)> = self
|
|
.stubs
|
|
.iter()
|
|
.filter_map(|stub| {
|
|
let haystack = format!(
|
|
"{} {}",
|
|
stub.prefixed_name.to_ascii_lowercase(),
|
|
stub.description.to_ascii_lowercase()
|
|
);
|
|
let hits = terms
|
|
.iter()
|
|
.filter(|t| haystack.contains(t.as_str()))
|
|
.count();
|
|
if hits > 0 {
|
|
Some((stub, hits))
|
|
} else {
|
|
None
|
|
}
|
|
})
|
|
.collect();
|
|
|
|
scored.sort_by(|a, b| b.1.cmp(&a.1));
|
|
scored
|
|
.into_iter()
|
|
.take(max_results)
|
|
.map(|(s, _)| s)
|
|
.collect()
|
|
}
|
|
|
|
/// Activate a stub by name, returning a boxed [`Tool`].
|
|
pub fn activate(&self, name: &str) -> Option<Box<dyn Tool>> {
|
|
self.get_by_name(name).map(|stub| {
|
|
let wrapper = stub.activate(Arc::clone(&self.registry));
|
|
Box::new(wrapper) as Box<dyn Tool>
|
|
})
|
|
}
|
|
|
|
/// Return the full [`ToolSpec`] for a stub (for inclusion in `tool_search` results).
|
|
pub fn tool_spec(&self, name: &str) -> Option<ToolSpec> {
|
|
self.get_by_name(name).map(|stub| {
|
|
let wrapper = stub.activate(Arc::clone(&self.registry));
|
|
wrapper.spec()
|
|
})
|
|
}
|
|
}
|
|
|
|
// ── ActivatedToolSet ─────────────────────────────────────────────────────
|
|
|
|
/// Per-conversation mutable state tracking which deferred tools have been
|
|
/// activated (i.e. their full schemas have been fetched via `tool_search`).
|
|
/// The agent loop consults this each iteration to decide which tool_specs
|
|
/// to include in the LLM request.
|
|
pub struct ActivatedToolSet {
|
|
tools: HashMap<String, Arc<dyn Tool>>,
|
|
}
|
|
|
|
impl ActivatedToolSet {
|
|
pub fn new() -> Self {
|
|
Self {
|
|
tools: HashMap::new(),
|
|
}
|
|
}
|
|
|
|
pub fn activate(&mut self, name: String, tool: Arc<dyn Tool>) {
|
|
self.tools.insert(name, tool);
|
|
}
|
|
|
|
pub fn is_activated(&self, name: &str) -> bool {
|
|
self.tools.contains_key(name)
|
|
}
|
|
|
|
/// Clone the Arc so the caller can drop the mutex guard before awaiting.
|
|
pub fn get(&self, name: &str) -> Option<Arc<dyn Tool>> {
|
|
self.tools.get(name).cloned()
|
|
}
|
|
|
|
pub fn tool_specs(&self) -> Vec<ToolSpec> {
|
|
self.tools.values().map(|t| t.spec()).collect()
|
|
}
|
|
|
|
pub fn tool_names(&self) -> Vec<&str> {
|
|
self.tools.keys().map(|s| s.as_str()).collect()
|
|
}
|
|
}
|
|
|
|
impl Default for ActivatedToolSet {
|
|
fn default() -> Self {
|
|
Self::new()
|
|
}
|
|
}
|
|
|
|
// ── System prompt helper ─────────────────────────────────────────────────
|
|
|
|
/// Build the `<available-deferred-tools>` section for the system prompt.
|
|
/// Lists only tool names so the LLM knows what is available without
|
|
/// consuming context window on full schemas.
|
|
pub fn build_deferred_tools_section(deferred: &DeferredMcpToolSet) -> String {
|
|
if deferred.is_empty() {
|
|
return String::new();
|
|
}
|
|
let mut out = String::from("<available-deferred-tools>\n");
|
|
for stub in &deferred.stubs {
|
|
out.push_str(&stub.prefixed_name);
|
|
out.push('\n');
|
|
}
|
|
out.push_str("</available-deferred-tools>\n");
|
|
out
|
|
}
|
|
|
|
#[cfg(test)]
|
|
mod tests {
|
|
use super::*;
|
|
|
|
fn make_stub(name: &str, desc: &str) -> DeferredMcpToolStub {
|
|
let def = McpToolDef {
|
|
name: name.to_string(),
|
|
description: Some(desc.to_string()),
|
|
input_schema: serde_json::json!({"type": "object", "properties": {}}),
|
|
};
|
|
DeferredMcpToolStub::new(name.to_string(), def)
|
|
}
|
|
|
|
#[test]
|
|
fn stub_uses_description_from_def() {
|
|
let stub = make_stub("fs__read", "Read a file");
|
|
assert_eq!(stub.description, "Read a file");
|
|
}
|
|
|
|
#[test]
|
|
fn stub_defaults_description_when_none() {
|
|
let def = McpToolDef {
|
|
name: "mystery".into(),
|
|
description: None,
|
|
input_schema: serde_json::json!({}),
|
|
};
|
|
let stub = DeferredMcpToolStub::new("srv__mystery".into(), def);
|
|
assert_eq!(stub.description, "MCP tool");
|
|
}
|
|
|
|
#[test]
|
|
fn activated_set_tracks_activation() {
|
|
use crate::tools::traits::ToolResult;
|
|
use async_trait::async_trait;
|
|
|
|
struct FakeTool;
|
|
#[async_trait]
|
|
impl Tool for FakeTool {
|
|
fn name(&self) -> &str {
|
|
"fake"
|
|
}
|
|
fn description(&self) -> &str {
|
|
"fake tool"
|
|
}
|
|
fn parameters_schema(&self) -> serde_json::Value {
|
|
serde_json::json!({})
|
|
}
|
|
async fn execute(&self, _: serde_json::Value) -> anyhow::Result<ToolResult> {
|
|
Ok(ToolResult {
|
|
success: true,
|
|
output: String::new(),
|
|
error: None,
|
|
})
|
|
}
|
|
}
|
|
|
|
let mut set = ActivatedToolSet::new();
|
|
assert!(!set.is_activated("fake"));
|
|
set.activate("fake".into(), Arc::new(FakeTool));
|
|
assert!(set.is_activated("fake"));
|
|
assert!(set.get("fake").is_some());
|
|
assert_eq!(set.tool_specs().len(), 1);
|
|
}
|
|
|
|
#[test]
|
|
fn build_deferred_section_empty_when_no_stubs() {
|
|
let set = DeferredMcpToolSet {
|
|
stubs: vec![],
|
|
registry: std::sync::Arc::new(
|
|
tokio::runtime::Runtime::new()
|
|
.unwrap()
|
|
.block_on(McpRegistry::connect_all(&[]))
|
|
.unwrap(),
|
|
),
|
|
};
|
|
assert!(build_deferred_tools_section(&set).is_empty());
|
|
}
|
|
|
|
#[test]
|
|
fn build_deferred_section_lists_names() {
|
|
let stubs = vec![
|
|
make_stub("fs__read_file", "Read a file"),
|
|
make_stub("git__status", "Git status"),
|
|
];
|
|
let set = DeferredMcpToolSet {
|
|
stubs,
|
|
registry: std::sync::Arc::new(
|
|
tokio::runtime::Runtime::new()
|
|
.unwrap()
|
|
.block_on(McpRegistry::connect_all(&[]))
|
|
.unwrap(),
|
|
),
|
|
};
|
|
let section = build_deferred_tools_section(&set);
|
|
assert!(section.contains("<available-deferred-tools>"));
|
|
assert!(section.contains("fs__read_file"));
|
|
assert!(section.contains("git__status"));
|
|
assert!(section.contains("</available-deferred-tools>"));
|
|
}
|
|
|
|
#[test]
|
|
fn keyword_search_ranks_by_hits() {
|
|
let stubs = vec![
|
|
make_stub("fs__read_file", "Read a file from disk"),
|
|
make_stub("fs__write_file", "Write a file to disk"),
|
|
make_stub("git__log", "Show git log"),
|
|
];
|
|
let set = DeferredMcpToolSet {
|
|
stubs,
|
|
registry: std::sync::Arc::new(
|
|
tokio::runtime::Runtime::new()
|
|
.unwrap()
|
|
.block_on(McpRegistry::connect_all(&[]))
|
|
.unwrap(),
|
|
),
|
|
};
|
|
|
|
// "file read" should rank fs__read_file highest (2 hits vs 1)
|
|
let results = set.search("file read", 5);
|
|
assert!(!results.is_empty());
|
|
assert_eq!(results[0].prefixed_name, "fs__read_file");
|
|
}
|
|
|
|
#[test]
|
|
fn get_by_name_returns_correct_stub() {
|
|
let stubs = vec![
|
|
make_stub("a__one", "Tool one"),
|
|
make_stub("b__two", "Tool two"),
|
|
];
|
|
let set = DeferredMcpToolSet {
|
|
stubs,
|
|
registry: std::sync::Arc::new(
|
|
tokio::runtime::Runtime::new()
|
|
.unwrap()
|
|
.block_on(McpRegistry::connect_all(&[]))
|
|
.unwrap(),
|
|
),
|
|
};
|
|
assert!(set.get_by_name("a__one").is_some());
|
|
assert!(set.get_by_name("nonexistent").is_none());
|
|
}
|
|
}
|