feat(mcp): platform extension for "code mode" MCP tool calling (#6030)

Co-authored-by: Michael Neale <michael.neale@gmail.com>
This commit is contained in:
Alex Hancock
2025-12-15 16:47:00 -05:00
committed by GitHub
parent e2c3c98cb4
commit 948ff499b7
9 changed files with 1898 additions and 180 deletions
Generated
+900 -167
View File
File diff suppressed because it is too large Load Diff
+3 -2
View File
@@ -107,12 +107,13 @@ oauth2 = "5.0.0"
schemars = { version = "1.0.4", default-features = false, features = ["derive"] }
insta = "1.43.2"
paste = "1.0.0"
posthog-rs = "0.3.7"
shellexpand = "3.1.1"
indexmap = "2.12.0"
ignore = "0.4.25"
which = "8.0.0"
posthog-rs = "0.3.7"
boa_engine = "0.21.0"
boa_gc = "0.21"
[target.'cfg(target_os = "windows")'.dependencies]
winapi = { version = "0.3", features = ["wincred"] }
@@ -0,0 +1,916 @@
use crate::agents::extension::PlatformExtensionContext;
use crate::agents::extension_manager::get_parameter_names;
use crate::agents::mcp_client::{Error, McpClientTrait};
use anyhow::Result;
use async_trait::async_trait;
use boa_engine::builtins::promise::PromiseState;
use boa_engine::module::{MapModuleLoader, Module, SyntheticModuleInitializer};
use boa_engine::property::Attribute;
use boa_engine::{js_string, Context, JsNativeError, JsString, JsValue, NativeFunction, Source};
use indoc::indoc;
use regex::Regex;
use rmcp::model::{
CallToolRequestParam, CallToolResult, Content, GetPromptResult, Implementation,
InitializeResult, JsonObject, ListPromptsResult, ListResourcesResult, ListToolsResult,
ProtocolVersion, RawContent, ReadResourceResult, ServerCapabilities, ServerNotification,
Tool as McpTool, ToolAnnotations, ToolsCapability,
};
use schemars::{schema_for, JsonSchema};
use serde::{Deserialize, Serialize};
use serde_json::Value;
use std::collections::{BTreeMap, BTreeSet};
use std::rc::Rc;
use tokio::sync::mpsc;
use tokio_util::sync::CancellationToken;
pub static EXTENSION_NAME: &str = "code_execution";
type ToolCallRequest = (
String,
String,
tokio::sync::oneshot::Sender<Result<String, String>>,
);
#[derive(Debug, Serialize, Deserialize, JsonSchema)]
struct ExecuteCodeParams {
/// JavaScript code with ES6 imports for MCP tools.
code: String,
}
#[derive(Debug, Serialize, Deserialize, JsonSchema)]
struct ReadModuleParams {
/// Module path format:
/// - For entire server: "server_name"
/// - For specific tool: "server_name/tool_name"
module_path: String,
}
#[derive(Debug, Serialize, Deserialize, JsonSchema)]
struct SearchModulesParams {
/// Search terms to find servers/tools (case-insensitive). Can be a single string or array of strings.
terms: SearchTerms,
/// If true, treat search terms as regex patterns
#[serde(default)]
regex: bool,
}
#[derive(Debug, Serialize, Deserialize, JsonSchema)]
#[serde(untagged)]
enum SearchTerms {
Single(String),
Multiple(Vec<String>),
}
#[derive(Debug, Default, Deserialize)]
struct InputSchema {
#[serde(default)]
properties: BTreeMap<String, Value>,
#[serde(default)]
required: Vec<String>,
}
struct ToolInfo {
server_name: String,
tool_name: String,
full_name: String,
description: String,
params: Vec<(String, String, bool)>,
}
impl ToolInfo {
fn from_mcp_tool(tool: &McpTool) -> Option<Self> {
let (server_name, tool_name) = tool.name.as_ref().split_once("__")?;
let param_names = get_parameter_names(tool);
let schema: InputSchema =
serde_json::from_value(Value::Object(tool.input_schema.as_ref().clone()))
.unwrap_or_default();
let params = param_names
.iter()
.map(|name| {
let ty = schema
.properties
.get(name)
.and_then(|p| p.get("type"))
.and_then(|t| t.as_str())
.unwrap_or("any");
let required = schema.required.contains(name);
(name.clone(), ty.to_string(), required)
})
.collect();
Some(Self {
server_name: server_name.to_string(),
tool_name: tool_name.to_string(),
full_name: tool.name.as_ref().to_string(),
description: tool
.description
.as_ref()
.map(|d| d.as_ref().to_string())
.unwrap_or_default(),
params,
})
}
fn to_signature(&self) -> String {
let params = self
.params
.iter()
.map(|(name, ty, req)| format!("{name}{}: {ty}", if *req { "" } else { "?" }))
.collect::<Vec<_>>()
.join(", ");
let desc = self.description.lines().next().unwrap_or("");
format!("{}({{ {params} }}): string - {desc}", self.tool_name)
}
}
thread_local! {
static CALL_TX: std::cell::RefCell<Option<mpsc::UnboundedSender<ToolCallRequest>>> =
const { std::cell::RefCell::new(None) };
}
fn create_server_module(server_tools: &[&ToolInfo], ctx: &mut Context) -> Module {
let (export_names, tool_data): (Vec<JsString>, Vec<(String, String)>) = server_tools
.iter()
.map(|t| {
(
js_string!(t.tool_name.as_str()),
(t.tool_name.clone(), t.full_name.clone()),
)
})
.unzip();
Module::synthetic(
&export_names,
SyntheticModuleInitializer::from_copy_closure_with_captures(
|module, tools, context| {
for (tool_name, full_name) in tools {
let func = create_tool_function(full_name.clone());
let js_func = func.to_js_function(context.realm());
module.set_export(&js_string!(tool_name.as_str()), js_func.into())?;
}
Ok(())
},
tool_data,
),
None,
None,
ctx,
)
}
fn create_tool_function(full_tool_name: String) -> NativeFunction {
NativeFunction::from_copy_closure_with_captures(
|_this, args, full_name: &String, ctx| {
let args_json = args
.first()
.cloned()
.unwrap_or(JsValue::undefined())
.to_json(ctx)
.map_err(|e| JsNativeError::error().with_message(e.to_string()))?
.unwrap_or(Value::Object(serde_json::Map::new()));
let args_str = serde_json::to_string(&args_json).unwrap_or_else(|_| "{}".to_string());
let (tx, rx) = tokio::sync::oneshot::channel();
CALL_TX
.with(|call_tx| {
call_tx
.borrow()
.as_ref()
.and_then(|sender| sender.send((full_name.clone(), args_str, tx)).ok())
})
.ok_or_else(|| JsNativeError::error().with_message("Channel unavailable"))?;
rx.blocking_recv()
.map_err(|e| e.to_string())
.and_then(|r| r)
.map(|result| JsValue::from(js_string!(result.as_str())))
.map_err(|e| JsNativeError::error().with_message(e).into())
},
full_tool_name,
)
}
fn run_js_module(
code: &str,
tools: &[ToolInfo],
call_tx: mpsc::UnboundedSender<ToolCallRequest>,
) -> Result<String, String> {
CALL_TX.with(|tx| *tx.borrow_mut() = Some(call_tx));
let loader = Rc::new(MapModuleLoader::new());
let mut ctx = Context::builder()
.module_loader(loader.clone())
.build()
.map_err(|e| format!("Failed to create JS context: {e}"))?;
ctx.register_global_property(
js_string!("__result__"),
JsValue::undefined(),
Attribute::WRITABLE,
)
.map_err(|e| format!("Failed to register __result__: {e}"))?;
let mut by_server: BTreeMap<&str, Vec<&ToolInfo>> = BTreeMap::new();
for tool in tools {
by_server.entry(&tool.server_name).or_default().push(tool);
}
for (server_name, server_tools) in &by_server {
let module = create_server_module(server_tools, &mut ctx);
loader.insert(*server_name, module);
}
let wrapped = {
let lines: Vec<&str> = code.trim().lines().collect();
let last_idx = lines
.iter()
.rposition(|l| !l.trim().is_empty() && !l.trim().starts_with("//"))
.unwrap_or(0);
let last = lines.get(last_idx).map(|s| s.trim()).unwrap_or("");
const NO_WRAP: &[&str] = &["import ", "export ", "function ", "class "];
if last.contains("__result__") || NO_WRAP.iter().any(|p| last.starts_with(p)) {
code.to_string()
} else {
let before = lines[..last_idx].join("\n");
let mut result = None;
for decl in ["const ", "let ", "var "] {
if let Some(rest) = last.strip_prefix(decl) {
if let Some(name) = rest.split('=').next().map(str::trim) {
result = Some(format!("{before}\n{last}\n__result__ = {name};"));
}
break;
}
}
result.unwrap_or_else(|| {
format!("{before}\n__result__ = {};", last.trim_end_matches(';'))
})
}
};
let user_module = Module::parse(Source::from_bytes(&wrapped), None, &mut ctx)
.map_err(|e| format!("Parse error: {e}"))?;
loader.insert("__main__", user_module.clone());
let promise = user_module.load_link_evaluate(&mut ctx);
ctx.run_jobs()
.map_err(|e| format!("Job execution error: {e}"))?;
match promise.state() {
PromiseState::Fulfilled(_) => {
let result = ctx
.global_object()
.get(js_string!("__result__"), &mut ctx)
.map_err(|e| format!("Failed to get result: {e}"))?;
Ok(result.display().to_string())
}
PromiseState::Rejected(err) => Err(format!("Module error: {}", err.display())),
PromiseState::Pending => Err("Module evaluation did not complete".to_string()),
}
}
pub struct CodeExecutionClient {
info: InitializeResult,
context: PlatformExtensionContext,
}
impl CodeExecutionClient {
pub fn new(context: PlatformExtensionContext) -> Result<Self> {
let info = InitializeResult {
protocol_version: ProtocolVersion::V_2025_03_26,
capabilities: ServerCapabilities {
tools: Some(ToolsCapability {
list_changed: Some(false),
}),
resources: None,
prompts: None,
completions: None,
experimental: None,
logging: None,
},
server_info: Implementation {
name: EXTENSION_NAME.to_string(),
title: Some("Code Execution".to_string()),
version: "1.0.0".to_string(),
icons: None,
website_url: None,
},
instructions: Some(indoc! {r#"
BATCH MULTIPLE TOOL CALLS INTO ONE execute_code CALL.
This extension exists to reduce round-trips. When a task requires multiple tool calls:
- WRONG: Multiple execute_code calls, each with one tool
- RIGHT: One execute_code call with a script that calls all needed tools
Workflow:
1. Use read_module("server") to discover tools and signatures
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
"#}.to_string()),
};
Ok(Self { info, context })
}
async fn get_tool_infos(&self) -> Vec<ToolInfo> {
let Some(manager) = self
.context
.extension_manager
.as_ref()
.and_then(|w| w.upgrade())
else {
return Vec::new();
};
match manager.get_prefixed_tools_excluding(EXTENSION_NAME).await {
Ok(tools) if !tools.is_empty() => {
tools.iter().filter_map(ToolInfo::from_mcp_tool).collect()
}
_ => Vec::new(),
}
}
async fn handle_execute_code(
&self,
arguments: Option<JsonObject>,
) -> Result<Vec<Content>, String> {
let code = arguments
.as_ref()
.and_then(|a| a.get("code"))
.and_then(|v| v.as_str())
.ok_or("Missing required parameter: code")?
.to_string();
let tools = self.get_tool_infos().await;
let (call_tx, call_rx) = mpsc::unbounded_channel();
let tool_handler = tokio::spawn(Self::run_tool_handler(
call_rx,
self.context.extension_manager.clone(),
));
let js_result = tokio::task::spawn_blocking(move || run_js_module(&code, &tools, call_tx))
.await
.map_err(|e| format!("JS execution task failed: {e}"))?;
tool_handler.abort();
js_result.map(|r| vec![Content::text(format!("Result: {r}"))])
}
async fn handle_read_module(
&self,
arguments: Option<JsonObject>,
) -> Result<Vec<Content>, String> {
let path = arguments
.as_ref()
.and_then(|a| a.get("path"))
.and_then(|v| v.as_str())
.ok_or("Missing required parameter: path")?;
let tools = self.get_tool_infos().await;
let parts: Vec<&str> = path.trim_start_matches('/').split('/').collect();
match parts.as_slice() {
[server] => {
let server_tools: Vec<_> =
tools.iter().filter(|t| t.server_name == *server).collect();
if server_tools.is_empty() {
return Err(format!("Module not found: {server}"));
}
let names: Vec<_> = server_tools.iter().map(|t| t.tool_name.as_str()).collect();
let sigs: Vec<_> = server_tools.iter().map(|t| t.to_signature()).collect();
Ok(vec![Content::text(format!(
"// import {{ {} }} from \"{server}\";\n\n{}",
names.join(", "),
sigs.join("\n")
))])
}
[server, tool] => {
let t = tools
.iter()
.find(|t| t.server_name == *server && t.tool_name == *tool)
.ok_or_else(|| format!("Tool not found: {server}/{tool}"))?;
Ok(vec![Content::text(format!(
"// import {{ {tool} }} from \"{server}\";\n\n{}\n\n{}",
t.to_signature(),
t.description
))])
}
_ => Err(format!(
"Invalid path: {path}. Use 'server' or 'server/tool'"
)),
}
}
async fn handle_search_modules(
&self,
arguments: Option<JsonObject>,
) -> Result<Vec<Content>, String> {
let terms = arguments
.as_ref()
.and_then(|a| a.get("terms"))
.ok_or("Missing required parameter: terms")?;
let terms_vec = if let Some(s) = terms.as_str() {
vec![s.to_string()]
} else if let Some(arr) = terms.as_array() {
arr.iter()
.filter_map(|v| v.as_str().map(String::from))
.collect()
} else {
return Err("Parameter 'terms' must be a string or array of strings".to_string());
};
if terms_vec.is_empty() {
return Err("Search terms cannot be empty".to_string());
}
let use_regex = arguments
.as_ref()
.and_then(|a| a.get("regex"))
.and_then(|v| v.as_bool())
.unwrap_or(false);
let tools = self.get_tool_infos().await;
Self::handle_search(&tools, &terms_vec, use_regex)
}
fn handle_search(
tools: &[ToolInfo],
terms: &[String],
use_regex: bool,
) -> Result<Vec<Content>, String> {
enum Matcher {
Regex(Vec<Regex>),
Plain(Vec<String>),
}
let matcher = if use_regex {
let patterns: Result<Vec<_>, _> = terms
.iter()
.map(|t| {
Regex::new(&format!("(?i){t}")).map_err(|e| format!("Invalid regex '{t}': {e}"))
})
.collect();
Matcher::Regex(patterns?)
} else {
Matcher::Plain(terms.iter().map(|t| t.to_lowercase()).collect())
};
let matches_any = |text: &str| -> bool {
match &matcher {
Matcher::Regex(patterns) => patterns.iter().any(|p| p.is_match(text)),
Matcher::Plain(terms) => {
let lower = text.to_lowercase();
terms.iter().any(|t| lower.contains(t))
}
}
};
let mut matching_servers: BTreeSet<&str> = BTreeSet::new();
let mut matching_tools: Vec<&ToolInfo> = Vec::new();
for tool in tools {
if matches_any(&tool.server_name) {
matching_servers.insert(&tool.server_name);
}
if matches_any(&tool.tool_name) || matches_any(&tool.description) {
matching_tools.push(tool);
}
}
if matching_servers.is_empty() && matching_tools.is_empty() {
return Err(format!("No matches found for: {}", terms.join(", ")));
}
let mut output = String::new();
if !matching_servers.is_empty() {
output.push_str("## Matching Servers\n");
for server in &matching_servers {
let count = tools.iter().filter(|t| t.server_name == *server).count();
output.push_str(&format!("- {server} ({count} tools)\n"));
}
output.push('\n');
}
if !matching_tools.is_empty() {
output.push_str("## Matching Tools\n");
for tool in &matching_tools {
output.push_str(&format!(
"- {}/{}: {}\n",
tool.server_name,
tool.tool_name,
tool.description.lines().next().unwrap_or("")
));
}
}
Ok(vec![Content::text(output)])
}
async fn run_tool_handler(
mut call_rx: mpsc::UnboundedReceiver<ToolCallRequest>,
extension_manager: Option<std::sync::Weak<crate::agents::ExtensionManager>>,
) {
while let Some((tool_name, arguments, response_tx)) = call_rx.recv().await {
let result = match extension_manager.as_ref().and_then(|w| w.upgrade()) {
Some(manager) => {
let tool_call = CallToolRequestParam {
name: tool_name.into(),
arguments: serde_json::from_str(&arguments).ok(),
};
match manager
.dispatch_tool_call(tool_call, CancellationToken::new())
.await
{
Ok(dispatch_result) => match dispatch_result.result.await {
Ok(result) => Ok(result
.content
.iter()
.filter_map(|c| match &c.raw {
RawContent::Text(t) => Some(t.text.clone()),
_ => None,
})
.collect::<Vec<_>>()
.join("\n")),
Err(e) => Err(format!("Tool error: {}", e.message)),
},
Err(e) => Err(format!("Dispatch error: {e}")),
}
}
None => Err("Extension manager not available".to_string()),
};
let _ = response_tx.send(result);
}
}
}
#[async_trait]
impl McpClientTrait for CodeExecutionClient {
async fn list_resources(
&self,
_next_cursor: Option<String>,
_cancellation_token: CancellationToken,
) -> Result<ListResourcesResult, Error> {
Err(Error::TransportClosed)
}
async fn read_resource(
&self,
_uri: &str,
_cancellation_token: CancellationToken,
) -> Result<ReadResourceResult, Error> {
Err(Error::TransportClosed)
}
async fn list_tools(
&self,
_next_cursor: Option<String>,
_cancellation_token: CancellationToken,
) -> Result<ListToolsResult, Error> {
fn schema<T: JsonSchema>() -> JsonObject {
serde_json::to_value(schema_for!(T))
.map(|v| v.as_object().unwrap().clone())
.expect("valid schema")
}
Ok(ListToolsResult {
tools: vec![
McpTool::new(
"execute_code".to_string(),
indoc! {r#"
Batch multiple MCP tool calls into ONE execution. This is the primary purpose of this tool.
CRITICAL: Always combine related operations into a single execute_code call.
- WRONG: execute_code to read → execute_code to write (2 calls)
- RIGHT: execute_code that reads AND writes in one script (1 call)
EXAMPLE - Read file and write to another (ONE call):
```javascript
import { text_editor } from "developer";
const content = text_editor({ path: "/path/to/source.md", command: "view" });
text_editor({ path: "/path/to/dest.md", command: "write", file_text: content });
```
EXAMPLE - Multiple operations chained:
```javascript
import { shell, text_editor } from "developer";
const files = shell({ command: "ls -la" });
const readme = text_editor({ path: "./README.md", command: "view" });
const status = shell({ command: "git status" });
{ files, readme, status }
```
SYNTAX:
- Import: import { tool1, tool2 } from "serverName";
- Call: toolName({ param1: value, param2: value })
- All calls are synchronous, return strings
- Last expression is the result
- No comments in code
BEFORE CALLING: Use read_module("server") to check required parameters.
"#}
.to_string(),
schema::<ExecuteCodeParams>(),
)
.annotate(ToolAnnotations {
title: Some("Execute JavaScript".to_string()),
read_only_hint: Some(false),
destructive_hint: Some(true),
idempotent_hint: Some(false),
open_world_hint: Some(true),
}),
McpTool::new(
"read_module".to_string(),
indoc! {r#"
Read tool definitions to understand how to call them correctly.
PATHS:
- "serverName" → lists all tools with signatures (shows required vs optional params)
- "serverName/toolName" → full details for one tool including description
USE THIS BEFORE execute_code when:
- You haven't used a tool before
- You're unsure of parameter names or which are required
- A previous call failed due to missing/wrong parameters
The signature format is: toolName({ param1: type, param2?: type }): string
Parameters with ? are optional; others are required.
"#}
.to_string(),
schema::<ReadModuleParams>(),
)
.annotate(ToolAnnotations {
title: Some("Read module".to_string()),
read_only_hint: Some(true),
destructive_hint: Some(false),
idempotent_hint: Some(true),
open_world_hint: Some(false),
}),
McpTool::new(
"search_modules".to_string(),
indoc! {r#"
Search for tools by name or description across all available modules.
USAGE:
- Single term: search_modules({ terms: "file" })
- Multiple terms: search_modules({ terms: ["git", "shell"] })
- Regex patterns: search_modules({ terms: "sh.*", regex: true })
Returns matching servers and tools with descriptions.
Use this when you don't know which module contains the tool you need.
"#}
.to_string(),
schema::<SearchModulesParams>(),
)
.annotate(ToolAnnotations {
title: Some("Search modules".to_string()),
read_only_hint: Some(true),
destructive_hint: Some(false),
idempotent_hint: Some(true),
open_world_hint: Some(false),
}),
],
next_cursor: None,
})
}
async fn call_tool(
&self,
name: &str,
arguments: Option<JsonObject>,
_cancellation_token: CancellationToken,
) -> Result<CallToolResult, Error> {
let content = match name {
"execute_code" => self.handle_execute_code(arguments).await,
"read_module" => self.handle_read_module(arguments).await,
"search_modules" => self.handle_search_modules(arguments).await,
_ => Err(format!("Unknown tool: {name}")),
};
match content {
Ok(content) => Ok(CallToolResult::success(content)),
Err(error) => Ok(CallToolResult::error(vec![Content::text(format!(
"Error: {error}"
))])),
}
}
async fn list_prompts(
&self,
_next_cursor: Option<String>,
_cancellation_token: CancellationToken,
) -> Result<ListPromptsResult, Error> {
Err(Error::TransportClosed)
}
async fn get_prompt(
&self,
_name: &str,
_arguments: Value,
_cancellation_token: CancellationToken,
) -> Result<GetPromptResult, Error> {
Err(Error::TransportClosed)
}
async fn subscribe(&self) -> mpsc::Receiver<ServerNotification> {
mpsc::channel(1).1
}
fn get_info(&self) -> Option<&InitializeResult> {
Some(&self.info)
}
async fn get_moim(&self) -> Option<String> {
let tools = self.get_tool_infos().await;
if tools.is_empty() {
return None;
}
let mut servers: BTreeSet<&str> = BTreeSet::new();
for tool in &tools {
servers.insert(&tool.server_name);
}
let server_list: Vec<_> = servers.into_iter().collect();
Some(format!(
indoc::indoc! {r#"
ALWAYS batch multiple tool operations into ONE execute_code call.
- WRONG: Separate execute_code calls for read file, then write file
- RIGHT: One execute_code with a script that reads AND writes
Modules: {}
Use read_module("name") to see tool signatures before calling unfamiliar tools.
"#},
server_list.join(", ")
))
}
}
#[cfg(test)]
mod tests {
use super::*;
#[tokio::test]
async fn test_execute_code_simple() {
let context = PlatformExtensionContext {
session_id: None,
extension_manager: None,
tool_route_manager: None,
};
let client = CodeExecutionClient::new(context).unwrap();
let mut args = JsonObject::new();
args.insert("code".to_string(), Value::String("2 + 2".to_string()));
let result = client
.call_tool("execute_code", Some(args), CancellationToken::new())
.await
.unwrap();
assert!(!result.is_error.unwrap_or(false));
if let RawContent::Text(text) = &result.content[0].raw {
assert_eq!(text.text, "Result: 4");
} else {
panic!("Expected text content");
}
}
#[tokio::test]
async fn test_read_module_not_found() {
let context = PlatformExtensionContext {
session_id: None,
extension_manager: None,
tool_route_manager: None,
};
let client = CodeExecutionClient::new(context).unwrap();
let mut args = JsonObject::new();
args.insert("path".to_string(), Value::String("nonexistent".to_string()));
let result = client.handle_read_module(Some(args)).await;
assert!(result.is_err());
}
#[test]
fn test_search_plain_text() {
let tools = vec![
ToolInfo {
server_name: "developer".to_string(),
tool_name: "shell".to_string(),
full_name: "developer__shell".to_string(),
description: "Execute shell commands".to_string(),
params: vec![("command".to_string(), "string".to_string(), true)],
},
ToolInfo {
server_name: "developer".to_string(),
tool_name: "text_editor".to_string(),
full_name: "developer__text_editor".to_string(),
description: "Edit text files".to_string(),
params: vec![("path".to_string(), "string".to_string(), true)],
},
ToolInfo {
server_name: "git".to_string(),
tool_name: "commit".to_string(),
full_name: "git__commit".to_string(),
description: "Commit changes to git".to_string(),
params: vec![("message".to_string(), "string".to_string(), true)],
},
];
// Search for "shell" - should match tool name
let result =
CodeExecutionClient::handle_search(&tools, &["shell".to_string()], false).unwrap();
let text = match &result[0].raw {
RawContent::Text(t) => &t.text,
_ => panic!("Expected text"),
};
assert!(text.contains("developer/shell"));
assert!(!text.contains("git/commit"));
// Search for "developer" - should match server name
let result =
CodeExecutionClient::handle_search(&tools, &["developer".to_string()], false).unwrap();
let text = match &result[0].raw {
RawContent::Text(t) => &t.text,
_ => panic!("Expected text"),
};
assert!(text.contains("developer (2 tools)"));
// Search for "edit" - should match description
let result =
CodeExecutionClient::handle_search(&tools, &["edit".to_string()], false).unwrap();
let text = match &result[0].raw {
RawContent::Text(t) => &t.text,
_ => panic!("Expected text"),
};
assert!(text.contains("developer/text_editor"));
// Search for multiple terms
let result = CodeExecutionClient::handle_search(
&tools,
&["shell".to_string(), "git".to_string()],
false,
)
.unwrap();
let text = match &result[0].raw {
RawContent::Text(t) => &t.text,
_ => panic!("Expected text"),
};
assert!(text.contains("developer/shell"));
assert!(text.contains("git/commit"));
// Search with no matches
let result =
CodeExecutionClient::handle_search(&tools, &["nonexistent".to_string()], false);
assert!(result.is_err());
}
#[test]
fn test_search_regex() {
let tools = vec![
ToolInfo {
server_name: "developer".to_string(),
tool_name: "shell".to_string(),
full_name: "developer__shell".to_string(),
description: "Execute shell commands".to_string(),
params: vec![],
},
ToolInfo {
server_name: "developer".to_string(),
tool_name: "text_editor".to_string(),
full_name: "developer__text_editor".to_string(),
description: "Edit text files".to_string(),
params: vec![],
},
];
// Regex search for "sh.*" - should match shell
let result =
CodeExecutionClient::handle_search(&tools, &["sh.*".to_string()], true).unwrap();
let text = match &result[0].raw {
RawContent::Text(t) => &t.text,
_ => panic!("Expected text"),
};
assert!(text.contains("developer/shell"));
// Regex search for "^text" - should match text_editor
let result =
CodeExecutionClient::handle_search(&tools, &["^text".to_string()], true).unwrap();
let text = match &result[0].raw {
RawContent::Text(t) => &t.text,
_ => panic!("Expected text"),
};
assert!(text.contains("developer/text_editor"));
// Invalid regex should error
let result = CodeExecutionClient::handle_search(&tools, &["[invalid".to_string()], true);
assert!(result.is_err());
assert!(result.unwrap_err().contains("Invalid regex"));
}
}
+13
View File
@@ -1,4 +1,5 @@
use crate::agents::chatrecall_extension;
use crate::agents::code_execution_extension;
use crate::agents::extension_manager_extension;
use crate::agents::skills_extension;
use crate::agents::todo_extension;
@@ -87,6 +88,18 @@ pub static PLATFORM_EXTENSIONS: Lazy<HashMap<&'static str, PlatformExtensionDef>
},
);
map.insert(
code_execution_extension::EXTENSION_NAME,
PlatformExtensionDef {
name: code_execution_extension::EXTENSION_NAME,
description: "Execute JavaScript code in a sandboxed environment",
default_enabled: false,
client_factory: |ctx| {
Box::new(code_execution_extension::CodeExecutionClient::new(ctx).unwrap())
},
},
);
map
},
);
+43 -11
View File
@@ -598,7 +598,7 @@ impl ExtensionManager {
.insert(name, Extension::new(config, client, info, temp_dir));
}
/// Get extensions info
/// Get extensions info for building the system prompt
pub async fn get_extensions_info(&self) -> Vec<ExtensionInfo> {
self.extensions
.lock()
@@ -637,6 +637,10 @@ impl ExtensionManager {
Ok(self.extensions.lock().await.keys().cloned().collect())
}
pub async fn is_extension_enabled(&self, name: &str) -> bool {
self.extensions.lock().await.contains_key(name)
}
pub async fn get_extension_configs(&self) -> Vec<ExtensionConfig> {
self.extensions
.lock()
@@ -650,6 +654,14 @@ impl ExtensionManager {
pub async fn get_prefixed_tools(
&self,
extension_name: Option<String>,
) -> ExtensionResult<Vec<Tool>> {
self.get_prefixed_tools_impl(extension_name, None).await
}
async fn get_prefixed_tools_impl(
&self,
extension_name: Option<String>,
exclude: Option<&str>,
) -> ExtensionResult<Vec<Tool>> {
// Filter clients based on the provided extension_name or include all if None
let filtered_clients: Vec<_> = self
@@ -658,6 +670,12 @@ impl ExtensionManager {
.await
.iter()
.filter(|(name, _ext)| {
if let Some(excluded) = exclude {
if name.as_str() == excluded {
return false;
}
}
if let Some(ref name_filter) = extension_name {
*name == name_filter
} else {
@@ -723,6 +741,10 @@ impl ExtensionManager {
Ok(tools)
}
pub async fn get_prefixed_tools_excluding(&self, exclude: &str) -> ExtensionResult<Vec<Tool>> {
self.get_prefixed_tools_impl(None, Some(exclude)).await
}
/// Get the extension prompt including client instructions
pub async fn get_planning_prompt(&self, tools_info: Vec<ToolInfo>) -> String {
let mut context: HashMap<&str, Value> = HashMap::new();
@@ -1249,16 +1271,26 @@ impl ExtensionManager {
let timestamp = chrono::Local::now().format("%Y-%m-%d %H:%M:%S").to_string();
let mut content = format!("<info-msg>\nIt is currently {}\n", timestamp);
let extensions = self.extensions.lock().await;
for (name, extension) in extensions.iter() {
if let ExtensionConfig::Platform { .. } = &extension.config {
let client = extension.get_client();
let client_guard = client.lock().await;
if let Some(moim_content) = client_guard.get_moim().await {
tracing::debug!("MOIM content from {}: {} chars", name, moim_content.len());
content.push('\n');
content.push_str(&moim_content);
}
let platform_clients: Vec<(String, McpClientBox)> = {
let extensions = self.extensions.lock().await;
extensions
.iter()
.filter_map(|(name, extension)| {
if let ExtensionConfig::Platform { .. } = &extension.config {
Some((name.clone(), extension.get_client()))
} else {
None
}
})
.collect()
};
for (name, client) in platform_clients {
let client_guard = client.lock().await;
if let Some(moim_content) = client_guard.get_moim().await {
tracing::debug!("MOIM content from {}: {} chars", name, moim_content.len());
content.push('\n');
content.push_str(&moim_content);
}
}
+1
View File
@@ -1,5 +1,6 @@
mod agent;
pub(crate) mod chatrecall_extension;
pub(crate) mod code_execution_extension;
pub mod extension;
pub mod extension_malware_check;
pub mod extension_manager;
@@ -43,6 +43,7 @@ struct SystemPromptContext {
enable_subagents: bool,
max_extensions: usize,
max_tools: usize,
code_execution_mode: bool,
}
pub struct SystemPromptBuilder<'a, M> {
@@ -54,6 +55,7 @@ pub struct SystemPromptBuilder<'a, M> {
router_enabled: bool,
subagents_enabled: bool,
hints: Option<String>,
code_execution_mode: bool,
}
impl<'a> SystemPromptBuilder<'a, PromptManager> {
@@ -88,6 +90,11 @@ impl<'a> SystemPromptBuilder<'a, PromptManager> {
self
}
pub fn with_code_execution_mode(mut self, enabled: bool) -> Self {
self.code_execution_mode = enabled;
self
}
pub fn with_hints(mut self, working_dir: &Path) -> Self {
let config = Config::global();
let hints_filenames = config
@@ -159,6 +166,7 @@ impl<'a> SystemPromptBuilder<'a, PromptManager> {
enable_subagents: self.subagents_enabled,
max_extensions: MAX_EXTENSIONS,
max_tools: MAX_TOOLS,
code_execution_mode: self.code_execution_mode,
};
let base_prompt = if let Some(override_prompt) = &self.manager.system_prompt_override {
@@ -242,6 +250,7 @@ impl PromptManager {
router_enabled: false,
subagents_enabled: false,
hints: None,
code_execution_mode: false,
}
}
+11
View File
@@ -16,6 +16,7 @@ use crate::providers::toolshim::{
modify_system_prompt_for_tool_json, OllamaInterpreter,
};
use crate::agents::code_execution_extension::EXTENSION_NAME as CODE_EXECUTION_EXTENSION;
use crate::session::SessionManager;
#[cfg(test)]
use crate::session::SessionType;
@@ -129,6 +130,15 @@ impl Agent {
tools.push(frontend_tool.tool.clone());
}
let code_execution_active = self
.extension_manager
.is_extension_enabled(CODE_EXECUTION_EXTENSION)
.await;
if code_execution_active {
let code_exec_prefix = format!("{CODE_EXECUTION_EXTENSION}__");
tools.retain(|tool| tool.name.starts_with(&code_exec_prefix));
}
if !router_enabled {
// Stable tool ordering is important for multi session prompt caching.
tools.sort_by(|a, b| a.name.cmp(&b.name));
@@ -150,6 +160,7 @@ impl Agent {
.with_frontend_instructions(self.frontend_instructions.lock().await.clone())
.with_extension_and_tool_counts(extension_count, tool_count)
.with_router_enabled(router_enabled)
.with_code_execution_mode(code_execution_active)
.with_hints(working_dir)
.with_enable_subagents(self.subagents_enabled().await)
.build();
+2
View File
@@ -5,6 +5,7 @@ goose uses LLM providers with tool calling capability. You can be used with diff
claude-sonnet-4, o1, llama-3.2, deepseek-r1, etc).
These models have varying knowledge cut-off dates depending on when they were trained, but typically it's between 5-10
months prior to the current date.
{% if not code_execution_mode %}
# Extensions
@@ -40,6 +41,7 @@ and platform__list_resources on this extension.
{% else %}
No extensions are defined. You should let the user know that they should add extensions.
{% endif %}
{% endif %}
{% if extension_tool_limits is defined %}
{% with (extension_count, tool_count) = extension_tool_limits %}