fix(code-mode): improve tool signatures for LLM discovery (#6177)
Signed-off-by: Adrian Cole <adrian@tetrate.io>
This commit is contained in:
Generated
+13
@@ -3141,6 +3141,7 @@ dependencies = [
|
|||||||
"tracing",
|
"tracing",
|
||||||
"tracing-opentelemetry",
|
"tracing-opentelemetry",
|
||||||
"tracing-subscriber",
|
"tracing-subscriber",
|
||||||
|
"unbinder",
|
||||||
"unicode-normalization",
|
"unicode-normalization",
|
||||||
"url",
|
"url",
|
||||||
"urlencoding",
|
"urlencoding",
|
||||||
@@ -8485,6 +8486,18 @@ dependencies = [
|
|||||||
"zip 2.5.0",
|
"zip 2.5.0",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "unbinder"
|
||||||
|
version = "0.1.7"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "911624cb458604f41ac58db43ab8c2ccc9ad530791c1cd5d0ff8d824a38eeaa7"
|
||||||
|
dependencies = [
|
||||||
|
"cfg-if",
|
||||||
|
"rustc-hash 2.1.1",
|
||||||
|
"serde",
|
||||||
|
"serde_json",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "unicase"
|
name = "unicase"
|
||||||
version = "2.8.1"
|
version = "2.8.1"
|
||||||
|
|||||||
@@ -44,7 +44,7 @@ pub enum SaveAsFormat {
|
|||||||
pub struct WebScrapeParams {
|
pub struct WebScrapeParams {
|
||||||
/// The URL to fetch content from
|
/// The URL to fetch content from
|
||||||
pub url: String,
|
pub url: String,
|
||||||
/// How to interpret and save the content
|
/// Format of the response.
|
||||||
#[serde(default)]
|
#[serde(default)]
|
||||||
pub save_as: SaveAsFormat,
|
pub save_as: SaveAsFormat,
|
||||||
}
|
}
|
||||||
@@ -479,8 +479,7 @@ impl ComputerControllerServer {
|
|||||||
- text (for HTML pages)
|
- text (for HTML pages)
|
||||||
- json (for API responses)
|
- json (for API responses)
|
||||||
- binary (for images and other files)
|
- binary (for images and other files)
|
||||||
The content is cached locally and can be accessed later using the cache_path
|
Returns 'Content saved to: <path>'. Use cache to read the content.
|
||||||
returned in the response.
|
|
||||||
"
|
"
|
||||||
)]
|
)]
|
||||||
pub async fn web_scrape(
|
pub async fn web_scrape(
|
||||||
|
|||||||
@@ -115,6 +115,7 @@ ignore = { workspace = true }
|
|||||||
which = { workspace = true}
|
which = { workspace = true}
|
||||||
boa_engine = "0.21.0"
|
boa_engine = "0.21.0"
|
||||||
boa_gc = "0.21"
|
boa_gc = "0.21"
|
||||||
|
unbinder = "0.1.7"
|
||||||
|
|
||||||
[target.'cfg(target_os = "windows")'.dependencies]
|
[target.'cfg(target_os = "windows")'.dependencies]
|
||||||
winapi = { version = "0.3", features = ["wincred"] }
|
winapi = { version = "0.3", features = ["wincred"] }
|
||||||
|
|||||||
@@ -69,12 +69,86 @@ struct InputSchema {
|
|||||||
required: Vec<String>,
|
required: Vec<String>,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fn quote_join(vals: &[&str]) -> String {
|
||||||
|
format!("\"{}\"", vals.join("\" | \""))
|
||||||
|
}
|
||||||
|
|
||||||
|
fn infer_type(schema: &Value) -> Option<String> {
|
||||||
|
if schema.get("properties").is_some() {
|
||||||
|
Some("object".to_string())
|
||||||
|
} else if schema.get("items").is_some() {
|
||||||
|
Some("array".to_string())
|
||||||
|
} else {
|
||||||
|
None
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn extract_type_from_schema(schema: &Value) -> Option<String> {
|
||||||
|
// enum array (github-mcp style)
|
||||||
|
if let Some(arr) = schema.get("enum").and_then(|e| e.as_array()) {
|
||||||
|
let vals: Vec<_> = arr.iter().filter_map(|v| v.as_str()).collect();
|
||||||
|
if !vals.is_empty() {
|
||||||
|
return Some(quote_join(&vals));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// oneOf with const (schemars enums)
|
||||||
|
if let Some(arr) = schema.get("oneOf").and_then(|o| o.as_array()) {
|
||||||
|
let vals: Vec<_> = arr
|
||||||
|
.iter()
|
||||||
|
.filter_map(|v| v.get("const")?.as_str())
|
||||||
|
.collect();
|
||||||
|
if !vals.is_empty() {
|
||||||
|
return Some(quote_join(&vals));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// anyOf (Option<T> or unions)
|
||||||
|
if let Some(arr) = schema.get("anyOf").and_then(|o| o.as_array()) {
|
||||||
|
let non_null: Vec<_> = arr
|
||||||
|
.iter()
|
||||||
|
.filter(|v| v.get("type").and_then(|t| t.as_str()) != Some("null"))
|
||||||
|
.collect();
|
||||||
|
if non_null.len() == 1 {
|
||||||
|
return extract_type_from_schema(non_null[0]).or_else(|| infer_type(non_null[0]));
|
||||||
|
}
|
||||||
|
if non_null.len() > 1 {
|
||||||
|
let types: Vec<_> = non_null
|
||||||
|
.iter()
|
||||||
|
.filter_map(|v| extract_type_from_schema(v).or_else(|| infer_type(v)))
|
||||||
|
.collect();
|
||||||
|
if !types.is_empty() {
|
||||||
|
return Some(types.join(" | "));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// type field (string or array)
|
||||||
|
match schema.get("type") {
|
||||||
|
Some(Value::String(s)) => Some(s.clone()),
|
||||||
|
Some(Value::Array(arr)) => {
|
||||||
|
let non_null: Vec<_> = arr
|
||||||
|
.iter()
|
||||||
|
.filter_map(|v| v.as_str())
|
||||||
|
.filter(|s| *s != "null")
|
||||||
|
.collect();
|
||||||
|
match non_null.len() {
|
||||||
|
0 => None,
|
||||||
|
1 => Some(non_null[0].to_string()),
|
||||||
|
_ => Some(non_null.join(" | ")),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
_ => None,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
struct ToolInfo {
|
struct ToolInfo {
|
||||||
server_name: String,
|
server_name: String,
|
||||||
tool_name: String,
|
tool_name: String,
|
||||||
full_name: String,
|
full_name: String,
|
||||||
description: String,
|
description: String,
|
||||||
params: Vec<(String, String, bool)>,
|
params: Vec<(String, String, bool)>,
|
||||||
|
return_type: String,
|
||||||
}
|
}
|
||||||
|
|
||||||
impl ToolInfo {
|
impl ToolInfo {
|
||||||
@@ -82,9 +156,9 @@ impl ToolInfo {
|
|||||||
let (server_name, tool_name) = tool.name.as_ref().split_once("__")?;
|
let (server_name, tool_name) = tool.name.as_ref().split_once("__")?;
|
||||||
let param_names = get_parameter_names(tool);
|
let param_names = get_parameter_names(tool);
|
||||||
|
|
||||||
let schema: InputSchema =
|
let mut schema_value = Value::Object(tool.input_schema.as_ref().clone());
|
||||||
serde_json::from_value(Value::Object(tool.input_schema.as_ref().clone()))
|
let _ = unbinder::dereference_schema(&mut schema_value, unbinder::Options::default());
|
||||||
.unwrap_or_default();
|
let schema: InputSchema = serde_json::from_value(schema_value).unwrap_or_default();
|
||||||
|
|
||||||
let params = param_names
|
let params = param_names
|
||||||
.iter()
|
.iter()
|
||||||
@@ -92,14 +166,24 @@ impl ToolInfo {
|
|||||||
let ty = schema
|
let ty = schema
|
||||||
.properties
|
.properties
|
||||||
.get(name)
|
.get(name)
|
||||||
.and_then(|p| p.get("type"))
|
.and_then(extract_type_from_schema)
|
||||||
.and_then(|t| t.as_str())
|
.unwrap_or_else(|| "any".to_string());
|
||||||
.unwrap_or("any");
|
|
||||||
let required = schema.required.contains(name);
|
let required = schema.required.contains(name);
|
||||||
(name.clone(), ty.to_string(), required)
|
(name.clone(), ty, required)
|
||||||
})
|
})
|
||||||
.collect();
|
.collect();
|
||||||
|
|
||||||
|
let return_type = tool
|
||||||
|
.output_schema
|
||||||
|
.as_ref()
|
||||||
|
.and_then(|schema| {
|
||||||
|
let mut schema_value = Value::Object(schema.as_ref().clone());
|
||||||
|
let _ =
|
||||||
|
unbinder::dereference_schema(&mut schema_value, unbinder::Options::default());
|
||||||
|
extract_type_from_schema(&schema_value)
|
||||||
|
})
|
||||||
|
.unwrap_or_else(|| "string".to_string());
|
||||||
|
|
||||||
Some(Self {
|
Some(Self {
|
||||||
server_name: server_name.to_string(),
|
server_name: server_name.to_string(),
|
||||||
tool_name: tool_name.to_string(),
|
tool_name: tool_name.to_string(),
|
||||||
@@ -110,6 +194,7 @@ impl ToolInfo {
|
|||||||
.map(|d| d.as_ref().to_string())
|
.map(|d| d.as_ref().to_string())
|
||||||
.unwrap_or_default(),
|
.unwrap_or_default(),
|
||||||
params,
|
params,
|
||||||
|
return_type,
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -121,7 +206,10 @@ impl ToolInfo {
|
|||||||
.collect::<Vec<_>>()
|
.collect::<Vec<_>>()
|
||||||
.join(", ");
|
.join(", ");
|
||||||
let desc = self.description.lines().next().unwrap_or("");
|
let desc = self.description.lines().next().unwrap_or("");
|
||||||
format!("{}({{ {params} }}): string - {desc}", self.tool_name)
|
format!(
|
||||||
|
"{}({{ {params} }}): {} - {desc}",
|
||||||
|
self.tool_name, self.return_type
|
||||||
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -306,7 +394,7 @@ impl CodeExecutionClient {
|
|||||||
- RIGHT: One execute_code call with a script that calls all needed tools
|
- RIGHT: One execute_code call with a script that calls all needed tools
|
||||||
|
|
||||||
Workflow:
|
Workflow:
|
||||||
1. Use read_module("server") to discover tools and signatures
|
1. Use the read_module tool to discover tools and signatures
|
||||||
2. Write ONE script that imports and calls ALL tools needed for the task
|
2. Write ONE script that imports and calls ALL tools needed for the task
|
||||||
3. Chain results: use output from one tool as input to the next
|
3. Chain results: use output from one tool as input to the next
|
||||||
"#}.to_string()),
|
"#}.to_string()),
|
||||||
@@ -498,6 +586,7 @@ impl CodeExecutionClient {
|
|||||||
|
|
||||||
if !matching_tools.is_empty() {
|
if !matching_tools.is_empty() {
|
||||||
output.push_str("## Matching Tools\n");
|
output.push_str("## Matching Tools\n");
|
||||||
|
output.push_str("Use the read_module tool for full signature and import syntax\n\n");
|
||||||
for tool in &matching_tools {
|
for tool in &matching_tools {
|
||||||
output.push_str(&format!(
|
output.push_str(&format!(
|
||||||
"- {}/{}: {}\n",
|
"- {}/{}: {}\n",
|
||||||
@@ -611,7 +700,7 @@ impl McpClientTrait for CodeExecutionClient {
|
|||||||
- Last expression is the result
|
- Last expression is the result
|
||||||
- No comments in code
|
- No comments in code
|
||||||
|
|
||||||
BEFORE CALLING: Use read_module("server") to check required parameters.
|
BEFORE CALLING: Use the read_module tool to check required parameters.
|
||||||
"#}
|
"#}
|
||||||
.to_string(),
|
.to_string(),
|
||||||
schema::<ExecuteCodeParams>(),
|
schema::<ExecuteCodeParams>(),
|
||||||
@@ -656,9 +745,9 @@ impl McpClientTrait for CodeExecutionClient {
|
|||||||
Search for tools by name or description across all available modules.
|
Search for tools by name or description across all available modules.
|
||||||
|
|
||||||
USAGE:
|
USAGE:
|
||||||
- Single term: search_modules({ terms: "file" })
|
- Single term: search_modules with terms="file"
|
||||||
- Multiple terms: search_modules({ terms: ["git", "shell"] })
|
- Multiple terms: search_modules with terms=["git", "shell"]
|
||||||
- Regex patterns: search_modules({ terms: "sh.*", regex: true })
|
- Regex patterns: search_modules with terms="sh.*", regex=true
|
||||||
|
|
||||||
Returns matching servers and tools with descriptions.
|
Returns matching servers and tools with descriptions.
|
||||||
Use this when you don't know which module contains the tool you need.
|
Use this when you don't know which module contains the tool you need.
|
||||||
@@ -745,7 +834,7 @@ impl McpClientTrait for CodeExecutionClient {
|
|||||||
|
|
||||||
Modules: {}
|
Modules: {}
|
||||||
|
|
||||||
Use read_module("name") to see tool signatures before calling unfamiliar tools.
|
Use the read_module tool to see signatures before calling unfamiliar tools.
|
||||||
"#},
|
"#},
|
||||||
server_list.join(", ")
|
server_list.join(", ")
|
||||||
))
|
))
|
||||||
@@ -755,6 +844,8 @@ impl McpClientTrait for CodeExecutionClient {
|
|||||||
#[cfg(test)]
|
#[cfg(test)]
|
||||||
mod tests {
|
mod tests {
|
||||||
use super::*;
|
use super::*;
|
||||||
|
use std::sync::Arc;
|
||||||
|
use test_case::test_case;
|
||||||
|
|
||||||
#[tokio::test]
|
#[tokio::test]
|
||||||
async fn test_execute_code_simple() {
|
async fn test_execute_code_simple() {
|
||||||
@@ -809,6 +900,7 @@ mod tests {
|
|||||||
full_name: "developer__shell".to_string(),
|
full_name: "developer__shell".to_string(),
|
||||||
description: "Execute shell commands".to_string(),
|
description: "Execute shell commands".to_string(),
|
||||||
params: vec![("command".to_string(), "string".to_string(), true)],
|
params: vec![("command".to_string(), "string".to_string(), true)],
|
||||||
|
return_type: "string".to_string(),
|
||||||
},
|
},
|
||||||
ToolInfo {
|
ToolInfo {
|
||||||
server_name: "developer".to_string(),
|
server_name: "developer".to_string(),
|
||||||
@@ -816,6 +908,7 @@ mod tests {
|
|||||||
full_name: "developer__text_editor".to_string(),
|
full_name: "developer__text_editor".to_string(),
|
||||||
description: "Edit text files".to_string(),
|
description: "Edit text files".to_string(),
|
||||||
params: vec![("path".to_string(), "string".to_string(), true)],
|
params: vec![("path".to_string(), "string".to_string(), true)],
|
||||||
|
return_type: "string".to_string(),
|
||||||
},
|
},
|
||||||
ToolInfo {
|
ToolInfo {
|
||||||
server_name: "git".to_string(),
|
server_name: "git".to_string(),
|
||||||
@@ -823,6 +916,7 @@ mod tests {
|
|||||||
full_name: "git__commit".to_string(),
|
full_name: "git__commit".to_string(),
|
||||||
description: "Commit changes to git".to_string(),
|
description: "Commit changes to git".to_string(),
|
||||||
params: vec![("message".to_string(), "string".to_string(), true)],
|
params: vec![("message".to_string(), "string".to_string(), true)],
|
||||||
|
return_type: "string".to_string(),
|
||||||
},
|
},
|
||||||
];
|
];
|
||||||
|
|
||||||
@@ -883,6 +977,7 @@ mod tests {
|
|||||||
full_name: "developer__shell".to_string(),
|
full_name: "developer__shell".to_string(),
|
||||||
description: "Execute shell commands".to_string(),
|
description: "Execute shell commands".to_string(),
|
||||||
params: vec![],
|
params: vec![],
|
||||||
|
return_type: "string".to_string(),
|
||||||
},
|
},
|
||||||
ToolInfo {
|
ToolInfo {
|
||||||
server_name: "developer".to_string(),
|
server_name: "developer".to_string(),
|
||||||
@@ -890,6 +985,7 @@ mod tests {
|
|||||||
full_name: "developer__text_editor".to_string(),
|
full_name: "developer__text_editor".to_string(),
|
||||||
description: "Edit text files".to_string(),
|
description: "Edit text files".to_string(),
|
||||||
params: vec![],
|
params: vec![],
|
||||||
|
return_type: "string".to_string(),
|
||||||
},
|
},
|
||||||
];
|
];
|
||||||
|
|
||||||
@@ -916,4 +1012,73 @@ mod tests {
|
|||||||
assert!(result.is_err());
|
assert!(result.is_err());
|
||||||
assert!(result.unwrap_err().contains("Invalid regex"));
|
assert!(result.unwrap_err().contains("Invalid regex"));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[test_case(
|
||||||
|
"github__get_me",
|
||||||
|
serde_json::json!({"type": "object", "properties": {}}),
|
||||||
|
None,
|
||||||
|
"get_me({ }): string - Get details of the authenticated user";
|
||||||
|
"no params, no output schema"
|
||||||
|
)]
|
||||||
|
#[test_case(
|
||||||
|
"filesystem__read_file",
|
||||||
|
serde_json::json!({"type": "object", "properties": {"path": {"type": "string"}}, "required": ["path"]}),
|
||||||
|
Some(serde_json::json!({"type": "object"})),
|
||||||
|
"read_file({ path: string }): object - Read the complete contents of a file";
|
||||||
|
"string param, object output"
|
||||||
|
)]
|
||||||
|
#[test_case(
|
||||||
|
"memory__create_entities",
|
||||||
|
serde_json::json!({"type": "object", "properties": {"entities": {"type": "array"}}, "required": ["entities"]}),
|
||||||
|
Some(serde_json::json!({"type": "object"})),
|
||||||
|
"create_entities({ entities: array }): object - Create multiple new entities";
|
||||||
|
"array param, object output"
|
||||||
|
)]
|
||||||
|
#[test_case(
|
||||||
|
"github__dismiss_notification",
|
||||||
|
serde_json::json!({"type": "object", "properties": {
|
||||||
|
"threadID": {"type": "string"},
|
||||||
|
"state": {"type": "string", "enum": ["read", "done"]}
|
||||||
|
}, "required": ["threadID", "state"]}),
|
||||||
|
None,
|
||||||
|
"dismiss_notification({ state: \"read\" | \"done\", threadID: string }): string - Dismiss a notification";
|
||||||
|
"enum param, no output schema"
|
||||||
|
)]
|
||||||
|
#[test_case(
|
||||||
|
"computercontroller__web_scrape",
|
||||||
|
serde_json::json!({"type": "object", "properties": {
|
||||||
|
"url": {"type": "string"},
|
||||||
|
"save_as": {"oneOf": [{"const": "text"}, {"const": "json"}, {"const": "binary"}]}
|
||||||
|
}, "required": ["url"]}),
|
||||||
|
None,
|
||||||
|
"web_scrape({ save_as?: \"text\" | \"json\" | \"binary\", url: string }): string - Scrape content from URL";
|
||||||
|
"oneOf const param (schemars), no output schema"
|
||||||
|
)]
|
||||||
|
fn test_mcp_tool_signature(
|
||||||
|
name: &str,
|
||||||
|
input: serde_json::Value,
|
||||||
|
output: Option<serde_json::Value>,
|
||||||
|
expected: &str,
|
||||||
|
) {
|
||||||
|
let input_schema: serde_json::Map<String, serde_json::Value> =
|
||||||
|
serde_json::from_value(input).unwrap();
|
||||||
|
let output_schema = output.map(|v| {
|
||||||
|
Arc::new(
|
||||||
|
serde_json::from_value::<serde_json::Map<String, serde_json::Value>>(v).unwrap(),
|
||||||
|
)
|
||||||
|
});
|
||||||
|
let desc = expected.split(" - ").nth(1).unwrap_or("").to_string();
|
||||||
|
let tool = McpTool {
|
||||||
|
name: name.to_string().into(),
|
||||||
|
title: None,
|
||||||
|
description: Some(desc.into()),
|
||||||
|
input_schema: Arc::new(input_schema),
|
||||||
|
output_schema,
|
||||||
|
annotations: None,
|
||||||
|
icons: None,
|
||||||
|
meta: None,
|
||||||
|
};
|
||||||
|
let info = ToolInfo::from_mcp_tool(&tool).unwrap();
|
||||||
|
assert_eq!(info.to_signature(), expected);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -174,11 +174,14 @@ fn require_str_parameter<'a>(v: &'a serde_json::Value, name: &str) -> Result<&'a
|
|||||||
}
|
}
|
||||||
|
|
||||||
pub fn get_parameter_names(tool: &Tool) -> Vec<String> {
|
pub fn get_parameter_names(tool: &Tool) -> Vec<String> {
|
||||||
tool.input_schema
|
let mut names: Vec<String> = tool
|
||||||
|
.input_schema
|
||||||
.get("properties")
|
.get("properties")
|
||||||
.and_then(|props| props.as_object())
|
.and_then(|props| props.as_object())
|
||||||
.map(|props| props.keys().cloned().collect())
|
.map(|props| props.keys().cloned().collect())
|
||||||
.unwrap_or_default()
|
.unwrap_or_default();
|
||||||
|
names.sort();
|
||||||
|
names
|
||||||
}
|
}
|
||||||
|
|
||||||
impl Default for ExtensionManager {
|
impl Default for ExtensionManager {
|
||||||
|
|||||||
Reference in New Issue
Block a user