mirror of
https://github.com/pchuan98/codex.git
synced 2026-07-01 00:31:56 +08:00
feat: search_tool migrate to bring you own tool of Responses API (#14274)
## Why to support a new bring your own search tool in Responses API(https://developers.openai.com/api/docs/guides/tools-tool-search#client-executed-tool-search) we migrating our bm25 search tool to use official way to execute search on client and communicate additional tools to the model. ## What - replace the legacy `search_tool_bm25` flow with client-executed `tool_search` - add protocol, SSE, history, and normalization support for `tool_search_call` and `tool_search_output` - return namespaced Codex Apps search results and wire namespaced follow-up tool calls back into MCP dispatch
This commit is contained in:
committed by
GitHub
Unverified
parent
72631755e0
commit
77b0c75267
@@ -374,7 +374,10 @@ fn enabled_tool_from_spec(spec: ToolSpec) -> Option<EnabledTool> {
|
||||
let (description, kind) = match spec {
|
||||
ToolSpec::Function(tool) => (tool.description, CodeModeToolKind::Function),
|
||||
ToolSpec::Freeform(tool) => (tool.description, CodeModeToolKind::Freeform),
|
||||
ToolSpec::LocalShell {} | ToolSpec::ImageGeneration { .. } | ToolSpec::WebSearch { .. } => {
|
||||
ToolSpec::LocalShell {}
|
||||
| ToolSpec::ImageGeneration { .. }
|
||||
| ToolSpec::ToolSearch { .. }
|
||||
| ToolSpec::WebSearch { .. } => {
|
||||
return None;
|
||||
}
|
||||
};
|
||||
@@ -423,25 +426,27 @@ async fn call_nested_tool(
|
||||
let router = build_nested_router(&exec).await;
|
||||
|
||||
let specs = router.specs();
|
||||
let payload = if let Some((server, tool)) = exec.session.parse_mcp_tool_name(&tool_name).await {
|
||||
match serialize_function_tool_arguments(&tool_name, input) {
|
||||
Ok(raw_arguments) => ToolPayload::Mcp {
|
||||
server,
|
||||
tool,
|
||||
raw_arguments,
|
||||
},
|
||||
Err(error) => return JsonValue::String(error),
|
||||
}
|
||||
} else {
|
||||
match build_nested_tool_payload(&specs, &tool_name, input) {
|
||||
Ok(payload) => payload,
|
||||
Err(error) => return JsonValue::String(error),
|
||||
}
|
||||
};
|
||||
let payload =
|
||||
if let Some((server, tool)) = exec.session.parse_mcp_tool_name(&tool_name, &None).await {
|
||||
match serialize_function_tool_arguments(&tool_name, input) {
|
||||
Ok(raw_arguments) => ToolPayload::Mcp {
|
||||
server,
|
||||
tool,
|
||||
raw_arguments,
|
||||
},
|
||||
Err(error) => return JsonValue::String(error),
|
||||
}
|
||||
} else {
|
||||
match build_nested_tool_payload(&specs, &tool_name, input) {
|
||||
Ok(payload) => payload,
|
||||
Err(error) => return JsonValue::String(error),
|
||||
}
|
||||
};
|
||||
|
||||
let call = ToolCall {
|
||||
tool_name: tool_name.clone(),
|
||||
call_id: format!("{PUBLIC_TOOL_NAME}-{}", uuid::Uuid::new_v4()),
|
||||
tool_namespace: None,
|
||||
payload,
|
||||
};
|
||||
let result = router
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
use crate::client_common::tools::ToolSearchOutputTool;
|
||||
use crate::codex::Session;
|
||||
use crate::codex::TurnContext;
|
||||
use crate::tools::TELEMETRY_PREVIEW_MAX_BYTES;
|
||||
@@ -12,6 +13,7 @@ use codex_protocol::models::FunctionCallOutputBody;
|
||||
use codex_protocol::models::FunctionCallOutputContentItem;
|
||||
use codex_protocol::models::FunctionCallOutputPayload;
|
||||
use codex_protocol::models::ResponseInputItem;
|
||||
use codex_protocol::models::SearchToolCallParams;
|
||||
use codex_protocol::models::ShellToolCallParams;
|
||||
use codex_protocol::models::function_call_output_content_items_to_text;
|
||||
use codex_utils_string::take_bytes_at_char_boundary;
|
||||
@@ -38,6 +40,7 @@ pub struct ToolInvocation {
|
||||
pub tracker: SharedTurnDiffTracker,
|
||||
pub call_id: String,
|
||||
pub tool_name: String,
|
||||
pub tool_namespace: Option<String>,
|
||||
pub payload: ToolPayload,
|
||||
}
|
||||
|
||||
@@ -46,6 +49,9 @@ pub enum ToolPayload {
|
||||
Function {
|
||||
arguments: String,
|
||||
},
|
||||
ToolSearch {
|
||||
arguments: SearchToolCallParams,
|
||||
},
|
||||
Custom {
|
||||
input: String,
|
||||
},
|
||||
@@ -63,6 +69,7 @@ impl ToolPayload {
|
||||
pub fn log_payload(&self) -> Cow<'_, str> {
|
||||
match self {
|
||||
ToolPayload::Function { arguments } => Cow::Borrowed(arguments),
|
||||
ToolPayload::ToolSearch { arguments } => Cow::Owned(arguments.query.clone()),
|
||||
ToolPayload::Custom { input } => Cow::Borrowed(input),
|
||||
ToolPayload::LocalShell { params } => Cow::Owned(params.command.join(" ")),
|
||||
ToolPayload::Mcp { raw_arguments, .. } => Cow::Borrowed(raw_arguments),
|
||||
@@ -107,6 +114,47 @@ impl ToolOutput for CallToolResult {
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Clone)]
|
||||
pub struct ToolSearchOutput {
|
||||
pub tools: Vec<ToolSearchOutputTool>,
|
||||
}
|
||||
|
||||
impl ToolOutput for ToolSearchOutput {
|
||||
fn log_preview(&self) -> String {
|
||||
let tools = self
|
||||
.tools
|
||||
.iter()
|
||||
.map(|tool| {
|
||||
serde_json::to_value(tool).unwrap_or_else(|err| {
|
||||
JsonValue::String(format!("failed to serialize tool_search output: {err}"))
|
||||
})
|
||||
})
|
||||
.collect();
|
||||
telemetry_preview(&JsonValue::Array(tools).to_string())
|
||||
}
|
||||
|
||||
fn success_for_logging(&self) -> bool {
|
||||
true
|
||||
}
|
||||
|
||||
fn to_response_item(&self, call_id: &str, _payload: &ToolPayload) -> ResponseInputItem {
|
||||
ResponseInputItem::ToolSearchOutput {
|
||||
call_id: call_id.to_string(),
|
||||
status: "completed".to_string(),
|
||||
execution: "client".to_string(),
|
||||
tools: self
|
||||
.tools
|
||||
.iter()
|
||||
.map(|tool| {
|
||||
serde_json::to_value(tool).unwrap_or_else(|err| {
|
||||
JsonValue::String(format!("failed to serialize tool_search output: {err}"))
|
||||
})
|
||||
})
|
||||
.collect(),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
pub struct FunctionToolOutput {
|
||||
pub body: Vec<FunctionCallOutputContentItem>,
|
||||
pub success: Option<bool>,
|
||||
@@ -277,6 +325,7 @@ fn response_input_to_code_mode_result(response: ResponseInputItem) -> JsonValue
|
||||
content_items_to_code_mode_result(&items)
|
||||
}
|
||||
},
|
||||
ResponseInputItem::ToolSearchOutput { tools, .. } => JsonValue::Array(tools),
|
||||
ResponseInputItem::McpToolCallOutput { output, .. } => {
|
||||
output.code_mode_result(&ToolPayload::Mcp {
|
||||
server: String::new(),
|
||||
@@ -379,6 +428,7 @@ mod tests {
|
||||
use super::*;
|
||||
use core_test_support::assert_regex_match;
|
||||
use pretty_assertions::assert_eq;
|
||||
use serde_json::json;
|
||||
|
||||
#[test]
|
||||
fn custom_tool_calls_should_roundtrip_as_custom_outputs() {
|
||||
@@ -505,6 +555,61 @@ mod tests {
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn tool_search_payloads_roundtrip_as_tool_search_outputs() {
|
||||
let payload = ToolPayload::ToolSearch {
|
||||
arguments: SearchToolCallParams {
|
||||
query: "calendar".to_string(),
|
||||
limit: None,
|
||||
},
|
||||
};
|
||||
let response = ToolSearchOutput {
|
||||
tools: vec![ToolSearchOutputTool::Function(
|
||||
crate::client_common::tools::ResponsesApiTool {
|
||||
name: "create_event".to_string(),
|
||||
description: String::new(),
|
||||
strict: false,
|
||||
defer_loading: Some(true),
|
||||
parameters: crate::tools::spec::JsonSchema::Object {
|
||||
properties: Default::default(),
|
||||
required: None,
|
||||
additional_properties: None,
|
||||
},
|
||||
output_schema: None,
|
||||
},
|
||||
)],
|
||||
}
|
||||
.to_response_item("search-1", &payload);
|
||||
|
||||
match response {
|
||||
ResponseInputItem::ToolSearchOutput {
|
||||
call_id,
|
||||
status,
|
||||
execution,
|
||||
tools,
|
||||
} => {
|
||||
assert_eq!(call_id, "search-1");
|
||||
assert_eq!(status, "completed");
|
||||
assert_eq!(execution, "client");
|
||||
assert_eq!(
|
||||
tools,
|
||||
vec![json!({
|
||||
"type": "function",
|
||||
"name": "create_event",
|
||||
"description": "",
|
||||
"strict": false,
|
||||
"defer_loading": true,
|
||||
"parameters": {
|
||||
"type": "object",
|
||||
"properties": {}
|
||||
}
|
||||
})]
|
||||
);
|
||||
}
|
||||
other => panic!("expected ToolSearchOutput, got {other:?}"),
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn log_preview_uses_content_items_when_plain_text_is_missing() {
|
||||
let output = FunctionToolOutput::from_content(
|
||||
|
||||
@@ -422,6 +422,7 @@ It is important to remember:
|
||||
"#
|
||||
.to_string(),
|
||||
strict: false,
|
||||
defer_loading: None,
|
||||
parameters: JsonSchema::Object {
|
||||
properties,
|
||||
required: Some(vec!["input".to_string()]),
|
||||
|
||||
@@ -13,9 +13,9 @@ mod plan;
|
||||
mod read_file;
|
||||
mod request_permissions;
|
||||
mod request_user_input;
|
||||
mod search_tool_bm25;
|
||||
mod shell;
|
||||
mod test_sync;
|
||||
mod tool_search;
|
||||
pub(crate) mod unified_exec;
|
||||
mod view_image;
|
||||
|
||||
@@ -50,12 +50,12 @@ pub use request_permissions::RequestPermissionsHandler;
|
||||
pub(crate) use request_permissions::request_permissions_tool_description;
|
||||
pub use request_user_input::RequestUserInputHandler;
|
||||
pub(crate) use request_user_input::request_user_input_tool_description;
|
||||
pub(crate) use search_tool_bm25::DEFAULT_LIMIT as SEARCH_TOOL_BM25_DEFAULT_LIMIT;
|
||||
pub(crate) use search_tool_bm25::SEARCH_TOOL_BM25_TOOL_NAME;
|
||||
pub use search_tool_bm25::SearchToolBm25Handler;
|
||||
pub use shell::ShellCommandHandler;
|
||||
pub use shell::ShellHandler;
|
||||
pub use test_sync::TestSyncHandler;
|
||||
pub(crate) use tool_search::DEFAULT_LIMIT as TOOL_SEARCH_DEFAULT_LIMIT;
|
||||
pub(crate) use tool_search::TOOL_SEARCH_TOOL_NAME;
|
||||
pub use tool_search::ToolSearchHandler;
|
||||
pub use unified_exec::UnifiedExecHandler;
|
||||
pub use view_image::ViewImageHandler;
|
||||
|
||||
|
||||
@@ -1117,6 +1117,7 @@ mod tests {
|
||||
tracker: Arc::new(Mutex::new(TurnDiffTracker::default())),
|
||||
call_id: "call-1".to_string(),
|
||||
tool_name: tool_name.to_string(),
|
||||
tool_namespace: None,
|
||||
payload,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -52,6 +52,7 @@ At most one step can be in_progress at a time.
|
||||
"#
|
||||
.to_string(),
|
||||
strict: false,
|
||||
defer_loading: None,
|
||||
parameters: JsonSchema::Object {
|
||||
properties,
|
||||
required: Some(vec!["plan".to_string()]),
|
||||
|
||||
@@ -1,349 +0,0 @@
|
||||
use async_trait::async_trait;
|
||||
use bm25::Document;
|
||||
use bm25::Language;
|
||||
use bm25::SearchEngineBuilder;
|
||||
use codex_app_server_protocol::AppInfo;
|
||||
use serde::Deserialize;
|
||||
use serde_json::json;
|
||||
use std::collections::HashMap;
|
||||
use std::collections::HashSet;
|
||||
|
||||
use crate::connectors;
|
||||
use crate::function_tool::FunctionCallError;
|
||||
use crate::mcp::CODEX_APPS_MCP_SERVER_NAME;
|
||||
use crate::mcp_connection_manager::ToolInfo;
|
||||
use crate::tools::context::FunctionToolOutput;
|
||||
use crate::tools::context::ToolInvocation;
|
||||
use crate::tools::context::ToolPayload;
|
||||
use crate::tools::handlers::parse_arguments;
|
||||
use crate::tools::registry::ToolHandler;
|
||||
use crate::tools::registry::ToolKind;
|
||||
|
||||
pub struct SearchToolBm25Handler;
|
||||
|
||||
pub(crate) const SEARCH_TOOL_BM25_TOOL_NAME: &str = "search_tool_bm25";
|
||||
pub(crate) const DEFAULT_LIMIT: usize = 8;
|
||||
|
||||
fn default_limit() -> usize {
|
||||
DEFAULT_LIMIT
|
||||
}
|
||||
|
||||
#[derive(Deserialize)]
|
||||
struct SearchToolBm25Args {
|
||||
query: String,
|
||||
#[serde(default = "default_limit")]
|
||||
limit: usize,
|
||||
}
|
||||
|
||||
#[derive(Clone)]
|
||||
struct ToolEntry {
|
||||
name: String,
|
||||
server_name: String,
|
||||
title: Option<String>,
|
||||
description: Option<String>,
|
||||
connector_name: Option<String>,
|
||||
input_keys: Vec<String>,
|
||||
search_text: String,
|
||||
}
|
||||
|
||||
impl ToolEntry {
|
||||
fn new(name: String, info: ToolInfo) -> Self {
|
||||
let input_keys = info
|
||||
.tool
|
||||
.input_schema
|
||||
.get("properties")
|
||||
.and_then(serde_json::Value::as_object)
|
||||
.map(|map| map.keys().cloned().collect::<Vec<_>>())
|
||||
.unwrap_or_default();
|
||||
let search_text = build_search_text(&name, &info, &input_keys);
|
||||
Self {
|
||||
name,
|
||||
server_name: info.server_name,
|
||||
title: info.tool.title,
|
||||
description: info
|
||||
.tool
|
||||
.description
|
||||
.map(|description| description.to_string()),
|
||||
connector_name: info.connector_name,
|
||||
input_keys,
|
||||
search_text,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[async_trait]
|
||||
impl ToolHandler for SearchToolBm25Handler {
|
||||
type Output = FunctionToolOutput;
|
||||
|
||||
fn kind(&self) -> ToolKind {
|
||||
ToolKind::Function
|
||||
}
|
||||
|
||||
async fn handle(&self, invocation: ToolInvocation) -> Result<Self::Output, FunctionCallError> {
|
||||
let ToolInvocation {
|
||||
payload,
|
||||
session,
|
||||
turn,
|
||||
..
|
||||
} = invocation;
|
||||
|
||||
let arguments = match payload {
|
||||
ToolPayload::Function { arguments } => arguments,
|
||||
_ => {
|
||||
return Err(FunctionCallError::Fatal(format!(
|
||||
"{SEARCH_TOOL_BM25_TOOL_NAME} handler received unsupported payload"
|
||||
)));
|
||||
}
|
||||
};
|
||||
|
||||
let args: SearchToolBm25Args = parse_arguments(&arguments)?;
|
||||
let query = args.query.trim();
|
||||
if query.is_empty() {
|
||||
return Err(FunctionCallError::RespondToModel(
|
||||
"query must not be empty".to_string(),
|
||||
));
|
||||
}
|
||||
|
||||
if args.limit == 0 {
|
||||
return Err(FunctionCallError::RespondToModel(
|
||||
"limit must be greater than zero".to_string(),
|
||||
));
|
||||
}
|
||||
|
||||
let limit = args.limit;
|
||||
|
||||
let mcp_tools = session
|
||||
.services
|
||||
.mcp_connection_manager
|
||||
.read()
|
||||
.await
|
||||
.list_all_tools()
|
||||
.await;
|
||||
|
||||
let connectors = connectors::with_app_enabled_state(
|
||||
connectors::accessible_connectors_from_mcp_tools(&mcp_tools),
|
||||
&turn.config,
|
||||
);
|
||||
let mcp_tools = filter_codex_apps_mcp_tools(mcp_tools, &connectors);
|
||||
let mcp_tools = connectors::filter_codex_apps_tools_by_policy(mcp_tools, &turn.config);
|
||||
|
||||
let mut entries: Vec<ToolEntry> = mcp_tools
|
||||
.into_iter()
|
||||
.map(|(name, info)| ToolEntry::new(name, info))
|
||||
.collect();
|
||||
entries.sort_by(|a, b| a.name.cmp(&b.name));
|
||||
|
||||
if entries.is_empty() {
|
||||
let active_selected_tools = session.get_mcp_tool_selection().await.unwrap_or_default();
|
||||
let content = json!({
|
||||
"query": query,
|
||||
"total_tools": 0,
|
||||
"active_selected_tools": active_selected_tools,
|
||||
"tools": [],
|
||||
})
|
||||
.to_string();
|
||||
return Ok(FunctionToolOutput::from_text(content, Some(true)));
|
||||
}
|
||||
|
||||
let documents: Vec<Document<usize>> = entries
|
||||
.iter()
|
||||
.enumerate()
|
||||
.map(|(idx, entry)| Document::new(idx, entry.search_text.clone()))
|
||||
.collect();
|
||||
let search_engine =
|
||||
SearchEngineBuilder::<usize>::with_documents(Language::English, documents).build();
|
||||
let results = search_engine.search(query, limit);
|
||||
|
||||
let mut selected_tools = Vec::new();
|
||||
let mut result_payloads = Vec::new();
|
||||
for result in results {
|
||||
let Some(entry) = entries.get(result.document.id) else {
|
||||
continue;
|
||||
};
|
||||
selected_tools.push(entry.name.clone());
|
||||
result_payloads.push(json!({
|
||||
"name": entry.name.clone(),
|
||||
"server": entry.server_name.clone(),
|
||||
"title": entry.title.clone(),
|
||||
"description": entry.description.clone(),
|
||||
"connector_name": entry.connector_name.clone(),
|
||||
"input_keys": entry.input_keys.clone(),
|
||||
"score": result.score,
|
||||
}));
|
||||
}
|
||||
|
||||
let active_selected_tools = session.merge_mcp_tool_selection(selected_tools).await;
|
||||
|
||||
let content = json!({
|
||||
"query": query,
|
||||
"total_tools": entries.len(),
|
||||
"active_selected_tools": active_selected_tools,
|
||||
"tools": result_payloads,
|
||||
})
|
||||
.to_string();
|
||||
|
||||
Ok(FunctionToolOutput::from_text(content, Some(true)))
|
||||
}
|
||||
}
|
||||
|
||||
fn filter_codex_apps_mcp_tools(
|
||||
mut mcp_tools: HashMap<String, ToolInfo>,
|
||||
connectors: &[AppInfo],
|
||||
) -> HashMap<String, ToolInfo> {
|
||||
let enabled_connectors: HashSet<&str> = connectors
|
||||
.iter()
|
||||
.filter(|connector| connector.is_enabled)
|
||||
.map(|connector| connector.id.as_str())
|
||||
.collect();
|
||||
|
||||
mcp_tools.retain(|_, tool| {
|
||||
if tool.server_name != CODEX_APPS_MCP_SERVER_NAME {
|
||||
return false;
|
||||
}
|
||||
|
||||
tool.connector_id
|
||||
.as_deref()
|
||||
.is_some_and(|connector_id| enabled_connectors.contains(connector_id))
|
||||
});
|
||||
mcp_tools
|
||||
}
|
||||
|
||||
fn build_search_text(name: &str, info: &ToolInfo, input_keys: &[String]) -> String {
|
||||
let mut parts = vec![
|
||||
name.to_string(),
|
||||
info.tool_name.clone(),
|
||||
info.server_name.clone(),
|
||||
];
|
||||
|
||||
if let Some(title) = info.tool.title.as_deref()
|
||||
&& !title.trim().is_empty()
|
||||
{
|
||||
parts.push(title.to_string());
|
||||
}
|
||||
|
||||
if let Some(description) = info.tool.description.as_deref()
|
||||
&& !description.trim().is_empty()
|
||||
{
|
||||
parts.push(description.to_string());
|
||||
}
|
||||
|
||||
if let Some(connector_name) = info.connector_name.as_deref()
|
||||
&& !connector_name.trim().is_empty()
|
||||
{
|
||||
parts.push(connector_name.to_string());
|
||||
}
|
||||
|
||||
if !input_keys.is_empty() {
|
||||
parts.extend(input_keys.iter().cloned());
|
||||
}
|
||||
|
||||
parts.join(" ")
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use codex_app_server_protocol::AppInfo;
|
||||
use pretty_assertions::assert_eq;
|
||||
use rmcp::model::JsonObject;
|
||||
use rmcp::model::Tool;
|
||||
use std::sync::Arc;
|
||||
|
||||
fn make_connector(id: &str, enabled: bool) -> AppInfo {
|
||||
AppInfo {
|
||||
id: id.to_string(),
|
||||
name: id.to_string(),
|
||||
description: None,
|
||||
logo_url: None,
|
||||
logo_url_dark: None,
|
||||
distribution_channel: None,
|
||||
branding: None,
|
||||
app_metadata: None,
|
||||
labels: None,
|
||||
install_url: None,
|
||||
is_accessible: true,
|
||||
is_enabled: enabled,
|
||||
plugin_display_names: Vec::new(),
|
||||
}
|
||||
}
|
||||
|
||||
fn make_tool(
|
||||
qualified_name: &str,
|
||||
server_name: &str,
|
||||
tool_name: &str,
|
||||
connector_id: Option<&str>,
|
||||
) -> (String, ToolInfo) {
|
||||
(
|
||||
qualified_name.to_string(),
|
||||
ToolInfo {
|
||||
server_name: server_name.to_string(),
|
||||
tool_name: tool_name.to_string(),
|
||||
tool: Tool {
|
||||
name: tool_name.to_string().into(),
|
||||
title: None,
|
||||
description: Some(format!("Test tool: {tool_name}").into()),
|
||||
input_schema: Arc::new(JsonObject::default()),
|
||||
output_schema: None,
|
||||
annotations: None,
|
||||
execution: None,
|
||||
icons: None,
|
||||
meta: None,
|
||||
},
|
||||
connector_id: connector_id.map(str::to_string),
|
||||
connector_name: connector_id.map(str::to_string),
|
||||
plugin_display_names: Vec::new(),
|
||||
},
|
||||
)
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn filter_codex_apps_mcp_tools_keeps_enabled_apps_only() {
|
||||
let mcp_tools = HashMap::from([
|
||||
make_tool(
|
||||
"mcp__codex_apps__calendar_create_event",
|
||||
CODEX_APPS_MCP_SERVER_NAME,
|
||||
"calendar_create_event",
|
||||
Some("calendar"),
|
||||
),
|
||||
make_tool(
|
||||
"mcp__codex_apps__drive_search",
|
||||
CODEX_APPS_MCP_SERVER_NAME,
|
||||
"drive_search",
|
||||
Some("drive"),
|
||||
),
|
||||
make_tool("mcp__rmcp__echo", "rmcp", "echo", None),
|
||||
]);
|
||||
let connectors = vec![
|
||||
make_connector("calendar", false),
|
||||
make_connector("drive", true),
|
||||
];
|
||||
|
||||
let mut filtered: Vec<String> = filter_codex_apps_mcp_tools(mcp_tools, &connectors)
|
||||
.into_keys()
|
||||
.collect();
|
||||
filtered.sort();
|
||||
|
||||
assert_eq!(filtered, vec!["mcp__codex_apps__drive_search".to_string()]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn filter_codex_apps_mcp_tools_drops_apps_without_connector_id() {
|
||||
let mcp_tools = HashMap::from([
|
||||
make_tool(
|
||||
"mcp__codex_apps__unknown",
|
||||
CODEX_APPS_MCP_SERVER_NAME,
|
||||
"unknown",
|
||||
None,
|
||||
),
|
||||
make_tool("mcp__rmcp__echo", "rmcp", "echo", None),
|
||||
]);
|
||||
|
||||
let mut filtered: Vec<String> =
|
||||
filter_codex_apps_mcp_tools(mcp_tools, &[make_connector("calendar", true)])
|
||||
.into_keys()
|
||||
.collect();
|
||||
filtered.sort();
|
||||
|
||||
assert_eq!(filtered, Vec::<String>::new());
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,390 @@
|
||||
use crate::client_common::tools::ResponsesApiNamespace;
|
||||
use crate::client_common::tools::ResponsesApiNamespaceTool;
|
||||
use crate::client_common::tools::ToolSearchOutputTool;
|
||||
use crate::function_tool::FunctionCallError;
|
||||
use crate::mcp_connection_manager::ToolInfo;
|
||||
use crate::tools::context::ToolInvocation;
|
||||
use crate::tools::context::ToolPayload;
|
||||
use crate::tools::context::ToolSearchOutput;
|
||||
use crate::tools::registry::ToolHandler;
|
||||
use crate::tools::registry::ToolKind;
|
||||
use crate::tools::spec::mcp_tool_to_deferred_openai_tool;
|
||||
use async_trait::async_trait;
|
||||
use bm25::Document;
|
||||
use bm25::Language;
|
||||
use bm25::SearchEngineBuilder;
|
||||
use std::collections::BTreeMap;
|
||||
use std::collections::HashMap;
|
||||
|
||||
#[cfg(test)]
|
||||
use crate::client_common::tools::ResponsesApiTool;
|
||||
|
||||
pub struct ToolSearchHandler {
|
||||
tools: HashMap<String, ToolInfo>,
|
||||
}
|
||||
|
||||
pub(crate) const TOOL_SEARCH_TOOL_NAME: &str = "tool_search";
|
||||
pub(crate) const DEFAULT_LIMIT: usize = 8;
|
||||
|
||||
impl ToolSearchHandler {
|
||||
pub fn new(tools: HashMap<String, ToolInfo>) -> Self {
|
||||
Self { tools }
|
||||
}
|
||||
}
|
||||
|
||||
#[async_trait]
|
||||
impl ToolHandler for ToolSearchHandler {
|
||||
type Output = ToolSearchOutput;
|
||||
|
||||
fn kind(&self) -> ToolKind {
|
||||
ToolKind::Function
|
||||
}
|
||||
|
||||
async fn handle(
|
||||
&self,
|
||||
invocation: ToolInvocation,
|
||||
) -> Result<ToolSearchOutput, FunctionCallError> {
|
||||
let ToolInvocation { payload, .. } = invocation;
|
||||
|
||||
let args = match payload {
|
||||
ToolPayload::ToolSearch { arguments } => arguments,
|
||||
_ => {
|
||||
return Err(FunctionCallError::Fatal(format!(
|
||||
"{TOOL_SEARCH_TOOL_NAME} handler received unsupported payload"
|
||||
)));
|
||||
}
|
||||
};
|
||||
|
||||
let query = args.query.trim();
|
||||
if query.is_empty() {
|
||||
return Err(FunctionCallError::RespondToModel(
|
||||
"query must not be empty".to_string(),
|
||||
));
|
||||
}
|
||||
let limit = args.limit.unwrap_or(DEFAULT_LIMIT);
|
||||
|
||||
if limit == 0 {
|
||||
return Err(FunctionCallError::RespondToModel(
|
||||
"limit must be greater than zero".to_string(),
|
||||
));
|
||||
}
|
||||
|
||||
let mut entries: Vec<(String, ToolInfo)> = self.tools.clone().into_iter().collect();
|
||||
entries.sort_by(|a, b| a.0.cmp(&b.0));
|
||||
|
||||
if entries.is_empty() {
|
||||
return Ok(ToolSearchOutput { tools: Vec::new() });
|
||||
}
|
||||
|
||||
let documents: Vec<Document<usize>> = entries
|
||||
.iter()
|
||||
.enumerate()
|
||||
.map(|(idx, (name, info))| Document::new(idx, build_search_text(name, info)))
|
||||
.collect();
|
||||
let search_engine =
|
||||
SearchEngineBuilder::<usize>::with_documents(Language::English, documents).build();
|
||||
let results = search_engine.search(query, limit);
|
||||
|
||||
let matched_entries = results
|
||||
.into_iter()
|
||||
.filter_map(|result| entries.get(result.document.id))
|
||||
.collect::<Vec<_>>();
|
||||
let tools = serialize_tool_search_output_tools(&matched_entries).map_err(|err| {
|
||||
FunctionCallError::Fatal(format!("failed to encode tool_search output: {err}"))
|
||||
})?;
|
||||
|
||||
Ok(ToolSearchOutput { tools })
|
||||
}
|
||||
}
|
||||
|
||||
fn serialize_tool_search_output_tools(
|
||||
matched_entries: &[&(String, ToolInfo)],
|
||||
) -> Result<Vec<ToolSearchOutputTool>, serde_json::Error> {
|
||||
let grouped: BTreeMap<String, Vec<ToolInfo>> =
|
||||
matched_entries
|
||||
.iter()
|
||||
.fold(BTreeMap::new(), |mut acc, (_name, tool)| {
|
||||
acc.entry(tool.tool_namespace.clone())
|
||||
.or_default()
|
||||
.push(tool.clone());
|
||||
|
||||
acc
|
||||
});
|
||||
|
||||
let mut results = Vec::with_capacity(grouped.len());
|
||||
for (namespace, tools) in grouped {
|
||||
let Some(first_tool) = tools.first() else {
|
||||
continue;
|
||||
};
|
||||
|
||||
let description = first_tool.connector_description.clone().or_else(|| {
|
||||
first_tool
|
||||
.connector_name
|
||||
.as_deref()
|
||||
.map(str::trim)
|
||||
.filter(|connector_name| !connector_name.is_empty())
|
||||
.map(|connector_name| format!("Tools for working with {connector_name}."))
|
||||
});
|
||||
|
||||
let tools = tools
|
||||
.iter()
|
||||
.map(|tool| {
|
||||
mcp_tool_to_deferred_openai_tool(tool.tool_name.clone(), tool.tool.clone())
|
||||
.map(ResponsesApiNamespaceTool::Function)
|
||||
})
|
||||
.collect::<Result<Vec<_>, _>>()?;
|
||||
|
||||
results.push(ToolSearchOutputTool::Namespace(ResponsesApiNamespace {
|
||||
name: namespace,
|
||||
description: description.unwrap_or_default(),
|
||||
tools,
|
||||
}));
|
||||
}
|
||||
|
||||
Ok(results)
|
||||
}
|
||||
|
||||
fn build_search_text(name: &str, info: &ToolInfo) -> String {
|
||||
let mut parts = vec![
|
||||
name.to_string(),
|
||||
info.tool_name.clone(),
|
||||
info.server_name.clone(),
|
||||
];
|
||||
|
||||
if let Some(title) = info.tool.title.as_deref()
|
||||
&& !title.trim().is_empty()
|
||||
{
|
||||
parts.push(title.to_string());
|
||||
}
|
||||
|
||||
if let Some(description) = info.tool.description.as_deref()
|
||||
&& !description.trim().is_empty()
|
||||
{
|
||||
parts.push(description.to_string());
|
||||
}
|
||||
|
||||
if let Some(connector_name) = info.connector_name.as_deref()
|
||||
&& !connector_name.trim().is_empty()
|
||||
{
|
||||
parts.push(connector_name.to_string());
|
||||
}
|
||||
|
||||
if let Some(connector_description) = info.connector_description.as_deref()
|
||||
&& !connector_description.trim().is_empty()
|
||||
{
|
||||
parts.push(connector_description.to_string());
|
||||
}
|
||||
|
||||
parts.extend(
|
||||
info.tool
|
||||
.input_schema
|
||||
.get("properties")
|
||||
.and_then(serde_json::Value::as_object)
|
||||
.map(|map| map.keys().cloned().collect::<Vec<_>>())
|
||||
.unwrap_or_default(),
|
||||
);
|
||||
|
||||
parts.join(" ")
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use crate::mcp::CODEX_APPS_MCP_SERVER_NAME;
|
||||
use pretty_assertions::assert_eq;
|
||||
use rmcp::model::JsonObject;
|
||||
use rmcp::model::Tool;
|
||||
use serde_json::json;
|
||||
use std::sync::Arc;
|
||||
|
||||
#[test]
|
||||
fn serialize_tool_search_output_tools_groups_results_by_namespace() {
|
||||
let entries = [
|
||||
(
|
||||
"mcp__codex_apps__calendar-create-event".to_string(),
|
||||
ToolInfo {
|
||||
server_name: CODEX_APPS_MCP_SERVER_NAME.to_string(),
|
||||
tool_name: "-create-event".to_string(),
|
||||
tool_namespace: "mcp__codex_apps__calendar".to_string(),
|
||||
tool: Tool {
|
||||
name: "calendar-create-event".to_string().into(),
|
||||
title: None,
|
||||
description: Some("Create a calendar event.".into()),
|
||||
input_schema: Arc::new(JsonObject::from_iter([(
|
||||
"type".to_string(),
|
||||
json!("object"),
|
||||
)])),
|
||||
output_schema: None,
|
||||
annotations: None,
|
||||
execution: None,
|
||||
icons: None,
|
||||
meta: None,
|
||||
},
|
||||
connector_id: Some("calendar".to_string()),
|
||||
connector_name: Some("Calendar".to_string()),
|
||||
plugin_display_names: Vec::new(),
|
||||
connector_description: Some("Plan events".to_string()),
|
||||
},
|
||||
),
|
||||
(
|
||||
"mcp__codex_apps__gmail-read-email".to_string(),
|
||||
ToolInfo {
|
||||
server_name: CODEX_APPS_MCP_SERVER_NAME.to_string(),
|
||||
tool_name: "-read-email".to_string(),
|
||||
tool_namespace: "mcp__codex_apps__gmail".to_string(),
|
||||
tool: Tool {
|
||||
name: "gmail-read-email".to_string().into(),
|
||||
title: None,
|
||||
description: Some("Read an email.".into()),
|
||||
input_schema: Arc::new(JsonObject::from_iter([(
|
||||
"type".to_string(),
|
||||
json!("object"),
|
||||
)])),
|
||||
output_schema: None,
|
||||
annotations: None,
|
||||
execution: None,
|
||||
icons: None,
|
||||
meta: None,
|
||||
},
|
||||
connector_id: Some("gmail".to_string()),
|
||||
connector_name: Some("Gmail".to_string()),
|
||||
plugin_display_names: Vec::new(),
|
||||
connector_description: Some("Read mail".to_string()),
|
||||
},
|
||||
),
|
||||
(
|
||||
"mcp__codex_apps__calendar-list-events".to_string(),
|
||||
ToolInfo {
|
||||
server_name: CODEX_APPS_MCP_SERVER_NAME.to_string(),
|
||||
tool_name: "-list-events".to_string(),
|
||||
tool_namespace: "mcp__codex_apps__calendar".to_string(),
|
||||
tool: Tool {
|
||||
name: "calendar-list-events".to_string().into(),
|
||||
title: None,
|
||||
description: Some("List calendar events.".into()),
|
||||
input_schema: Arc::new(JsonObject::from_iter([(
|
||||
"type".to_string(),
|
||||
json!("object"),
|
||||
)])),
|
||||
output_schema: None,
|
||||
annotations: None,
|
||||
execution: None,
|
||||
icons: None,
|
||||
meta: None,
|
||||
},
|
||||
connector_id: Some("calendar".to_string()),
|
||||
connector_name: Some("Calendar".to_string()),
|
||||
plugin_display_names: Vec::new(),
|
||||
connector_description: Some("Plan events".to_string()),
|
||||
},
|
||||
),
|
||||
];
|
||||
|
||||
let tools = serialize_tool_search_output_tools(&[&entries[0], &entries[1], &entries[2]])
|
||||
.expect("serialize tool search output");
|
||||
|
||||
assert_eq!(
|
||||
tools,
|
||||
vec![
|
||||
ToolSearchOutputTool::Namespace(ResponsesApiNamespace {
|
||||
name: "mcp__codex_apps__calendar".to_string(),
|
||||
description: "Plan events".to_string(),
|
||||
tools: vec![
|
||||
ResponsesApiNamespaceTool::Function(ResponsesApiTool {
|
||||
name: "-create-event".to_string(),
|
||||
description: "Create a calendar event.".to_string(),
|
||||
strict: false,
|
||||
defer_loading: Some(true),
|
||||
parameters: crate::tools::spec::JsonSchema::Object {
|
||||
properties: Default::default(),
|
||||
required: None,
|
||||
additional_properties: None,
|
||||
},
|
||||
output_schema: None,
|
||||
}),
|
||||
ResponsesApiNamespaceTool::Function(ResponsesApiTool {
|
||||
name: "-list-events".to_string(),
|
||||
description: "List calendar events.".to_string(),
|
||||
strict: false,
|
||||
defer_loading: Some(true),
|
||||
parameters: crate::tools::spec::JsonSchema::Object {
|
||||
properties: Default::default(),
|
||||
required: None,
|
||||
additional_properties: None,
|
||||
},
|
||||
output_schema: None,
|
||||
}),
|
||||
],
|
||||
}),
|
||||
ToolSearchOutputTool::Namespace(ResponsesApiNamespace {
|
||||
name: "mcp__codex_apps__gmail".to_string(),
|
||||
description: "Read mail".to_string(),
|
||||
tools: vec![ResponsesApiNamespaceTool::Function(ResponsesApiTool {
|
||||
name: "-read-email".to_string(),
|
||||
description: "Read an email.".to_string(),
|
||||
strict: false,
|
||||
defer_loading: Some(true),
|
||||
parameters: crate::tools::spec::JsonSchema::Object {
|
||||
properties: Default::default(),
|
||||
required: None,
|
||||
additional_properties: None,
|
||||
},
|
||||
output_schema: None,
|
||||
})],
|
||||
})
|
||||
]
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn serialize_tool_search_output_tools_falls_back_to_connector_name_description() {
|
||||
let entries = [(
|
||||
"mcp__codex_apps__gmail-batch-read-email".to_string(),
|
||||
ToolInfo {
|
||||
server_name: CODEX_APPS_MCP_SERVER_NAME.to_string(),
|
||||
tool_name: "-batch-read-email".to_string(),
|
||||
tool_namespace: "mcp__codex_apps__gmail".to_string(),
|
||||
tool: Tool {
|
||||
name: "gmail-batch-read-email".to_string().into(),
|
||||
title: None,
|
||||
description: Some("Read multiple emails.".into()),
|
||||
input_schema: Arc::new(JsonObject::from_iter([(
|
||||
"type".to_string(),
|
||||
json!("object"),
|
||||
)])),
|
||||
output_schema: None,
|
||||
annotations: None,
|
||||
execution: None,
|
||||
icons: None,
|
||||
meta: None,
|
||||
},
|
||||
connector_id: Some("connector_gmail_456".to_string()),
|
||||
connector_name: Some("Gmail".to_string()),
|
||||
plugin_display_names: Vec::new(),
|
||||
connector_description: None,
|
||||
},
|
||||
)];
|
||||
|
||||
let tools = serialize_tool_search_output_tools(&[&entries[0]]).expect("serialize");
|
||||
|
||||
assert_eq!(
|
||||
tools,
|
||||
vec![ToolSearchOutputTool::Namespace(ResponsesApiNamespace {
|
||||
name: "mcp__codex_apps__gmail".to_string(),
|
||||
description: "Tools for working with Gmail.".to_string(),
|
||||
tools: vec![ResponsesApiNamespaceTool::Function(ResponsesApiTool {
|
||||
name: "-batch-read-email".to_string(),
|
||||
description: "Read multiple emails.".to_string(),
|
||||
strict: false,
|
||||
defer_loading: Some(true),
|
||||
parameters: crate::tools::spec::JsonSchema::Object {
|
||||
properties: Default::default(),
|
||||
required: None,
|
||||
additional_properties: None,
|
||||
},
|
||||
output_schema: None,
|
||||
})],
|
||||
})]
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -637,6 +637,16 @@ impl JsReplManager {
|
||||
summary.result_is_error = Some(!output.success());
|
||||
summary
|
||||
}
|
||||
ResponseInputItem::ToolSearchOutput { tools, .. } => JsReplToolCallResponseSummary {
|
||||
response_type: Some("tool_search_output".to_string()),
|
||||
payload_kind: Some(JsReplToolCallPayloadKind::FunctionText),
|
||||
payload_text_preview: Some(serde_json::Value::Array(tools.clone()).to_string()),
|
||||
payload_text_length: Some(
|
||||
serde_json::Value::Array(tools.clone()).to_string().len(),
|
||||
),
|
||||
payload_item_count: Some(tools.len()),
|
||||
..Default::default()
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1360,26 +1370,30 @@ impl JsReplManager {
|
||||
exec.turn.dynamic_tools.as_slice(),
|
||||
);
|
||||
|
||||
let payload =
|
||||
if let Some((server, tool)) = exec.session.parse_mcp_tool_name(&req.tool_name).await {
|
||||
crate::tools::context::ToolPayload::Mcp {
|
||||
server,
|
||||
tool,
|
||||
raw_arguments: req.arguments.clone(),
|
||||
}
|
||||
} else if is_freeform_tool(&router.specs(), &req.tool_name) {
|
||||
crate::tools::context::ToolPayload::Custom {
|
||||
input: req.arguments.clone(),
|
||||
}
|
||||
} else {
|
||||
crate::tools::context::ToolPayload::Function {
|
||||
arguments: req.arguments.clone(),
|
||||
}
|
||||
};
|
||||
let payload = if let Some((server, tool)) = exec
|
||||
.session
|
||||
.parse_mcp_tool_name(&req.tool_name, &None)
|
||||
.await
|
||||
{
|
||||
crate::tools::context::ToolPayload::Mcp {
|
||||
server,
|
||||
tool,
|
||||
raw_arguments: req.arguments.clone(),
|
||||
}
|
||||
} else if is_freeform_tool(&router.specs(), &req.tool_name) {
|
||||
crate::tools::context::ToolPayload::Custom {
|
||||
input: req.arguments.clone(),
|
||||
}
|
||||
} else {
|
||||
crate::tools::context::ToolPayload::Function {
|
||||
arguments: req.arguments.clone(),
|
||||
}
|
||||
};
|
||||
|
||||
let tool_name = req.tool_name.clone();
|
||||
let call = crate::tools::router::ToolCall {
|
||||
tool_name: tool_name.clone(),
|
||||
tool_namespace: None,
|
||||
call_id: req.id.clone(),
|
||||
payload,
|
||||
};
|
||||
|
||||
@@ -122,6 +122,12 @@ impl ToolCallRuntime {
|
||||
..Default::default()
|
||||
},
|
||||
},
|
||||
ToolPayload::ToolSearch { .. } => ResponseInputItem::ToolSearchOutput {
|
||||
call_id: call.call_id.clone(),
|
||||
status: "completed".to_string(),
|
||||
execution: "client".to_string(),
|
||||
tools: Vec::new(),
|
||||
},
|
||||
ToolPayload::Mcp { .. } => ResponseInputItem::McpToolCallOutput {
|
||||
call_id: call.call_id.clone(),
|
||||
output: codex_protocol::mcp::CallToolResult::from_error_text(Self::abort_message(
|
||||
|
||||
@@ -40,6 +40,7 @@ pub trait ToolHandler: Send + Sync {
|
||||
matches!(
|
||||
(self.kind(), payload),
|
||||
(ToolKind::Function, ToolPayload::Function { .. })
|
||||
| (ToolKind::Function, ToolPayload::ToolSearch { .. })
|
||||
| (ToolKind::Mcp, ToolPayload::Mcp { .. })
|
||||
)
|
||||
}
|
||||
@@ -121,6 +122,14 @@ where
|
||||
}
|
||||
}
|
||||
|
||||
pub(crate) fn tool_handler_key(tool_name: &str, namespace: Option<&str>) -> String {
|
||||
if let Some(namespace) = namespace {
|
||||
format!("{namespace}:{tool_name}")
|
||||
} else {
|
||||
tool_name.to_string()
|
||||
}
|
||||
}
|
||||
|
||||
pub struct ToolRegistry {
|
||||
handlers: HashMap<String, Arc<dyn AnyToolHandler>>,
|
||||
}
|
||||
@@ -130,8 +139,15 @@ impl ToolRegistry {
|
||||
Self { handlers }
|
||||
}
|
||||
|
||||
fn handler(&self, name: &str) -> Option<Arc<dyn AnyToolHandler>> {
|
||||
self.handlers.get(name).map(Arc::clone)
|
||||
fn handler(&self, name: &str, namespace: Option<&str>) -> Option<Arc<dyn AnyToolHandler>> {
|
||||
self.handlers
|
||||
.get(&tool_handler_key(name, namespace))
|
||||
.map(Arc::clone)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
pub(crate) fn has_handler(&self, name: &str, namespace: Option<&str>) -> bool {
|
||||
self.handler(name, namespace).is_some()
|
||||
}
|
||||
|
||||
// TODO(jif) for dynamic tools.
|
||||
@@ -147,6 +163,7 @@ impl ToolRegistry {
|
||||
invocation: ToolInvocation,
|
||||
) -> Result<AnyToolResult, FunctionCallError> {
|
||||
let tool_name = invocation.tool_name.clone();
|
||||
let tool_namespace = invocation.tool_namespace.clone();
|
||||
let call_id_owned = invocation.call_id.clone();
|
||||
let otel = invocation.turn.session_telemetry.clone();
|
||||
let payload_for_response = invocation.payload.clone();
|
||||
@@ -192,11 +209,14 @@ impl ToolRegistry {
|
||||
}
|
||||
}
|
||||
|
||||
let handler = match self.handler(tool_name.as_ref()) {
|
||||
let handler = match self.handler(tool_name.as_ref(), tool_namespace.as_deref()) {
|
||||
Some(handler) => handler,
|
||||
None => {
|
||||
let message =
|
||||
unsupported_tool_call_message(&invocation.payload, tool_name.as_ref());
|
||||
let message = unsupported_tool_call_message(
|
||||
&invocation.payload,
|
||||
tool_name.as_ref(),
|
||||
tool_namespace.as_deref(),
|
||||
);
|
||||
otel.tool_result_with_tags(
|
||||
tool_name.as_ref(),
|
||||
&call_id_owned,
|
||||
@@ -377,7 +397,12 @@ impl ToolRegistryBuilder {
|
||||
}
|
||||
}
|
||||
|
||||
fn unsupported_tool_call_message(payload: &ToolPayload, tool_name: &str) -> String {
|
||||
fn unsupported_tool_call_message(
|
||||
payload: &ToolPayload,
|
||||
tool_name: &str,
|
||||
namespace: Option<&str>,
|
||||
) -> String {
|
||||
let tool_name = tool_handler_key(tool_name, namespace);
|
||||
match payload {
|
||||
ToolPayload::Custom { .. } => format!("unsupported custom tool call: {tool_name}"),
|
||||
_ => format!("unsupported call: {tool_name}"),
|
||||
@@ -401,6 +426,13 @@ impl From<&ToolPayload> for HookToolInput {
|
||||
ToolPayload::Function { arguments } => HookToolInput::Function {
|
||||
arguments: arguments.clone(),
|
||||
},
|
||||
ToolPayload::ToolSearch { arguments } => HookToolInput::Function {
|
||||
arguments: serde_json::json!({
|
||||
"query": arguments.query,
|
||||
"limit": arguments.limit,
|
||||
})
|
||||
.to_string(),
|
||||
},
|
||||
ToolPayload::Custom { input } => HookToolInput::Custom {
|
||||
input: input.clone(),
|
||||
},
|
||||
@@ -513,3 +545,60 @@ async fn dispatch_after_tool_use_hook(
|
||||
|
||||
None
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use crate::tools::context::ToolInvocation;
|
||||
use async_trait::async_trait;
|
||||
use pretty_assertions::assert_eq;
|
||||
|
||||
struct TestHandler;
|
||||
|
||||
#[async_trait]
|
||||
impl ToolHandler for TestHandler {
|
||||
type Output = crate::tools::context::FunctionToolOutput;
|
||||
|
||||
fn kind(&self) -> ToolKind {
|
||||
ToolKind::Function
|
||||
}
|
||||
|
||||
async fn handle(
|
||||
&self,
|
||||
_invocation: ToolInvocation,
|
||||
) -> Result<Self::Output, FunctionCallError> {
|
||||
unreachable!("test handler should not be invoked")
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn handler_looks_up_namespaced_aliases_explicitly() {
|
||||
let plain_handler = Arc::new(TestHandler) as Arc<dyn AnyToolHandler>;
|
||||
let namespaced_handler = Arc::new(TestHandler) as Arc<dyn AnyToolHandler>;
|
||||
let namespace = "mcp__codex_apps__gmail";
|
||||
let tool_name = "gmail_get_recent_emails";
|
||||
let namespaced_name = tool_handler_key(tool_name, Some(namespace));
|
||||
let registry = ToolRegistry::new(HashMap::from([
|
||||
(tool_name.to_string(), Arc::clone(&plain_handler)),
|
||||
(namespaced_name, Arc::clone(&namespaced_handler)),
|
||||
]));
|
||||
|
||||
let plain = registry.handler(tool_name, None);
|
||||
let namespaced = registry.handler(tool_name, Some(namespace));
|
||||
let missing_namespaced = registry.handler(tool_name, Some("mcp__codex_apps__calendar"));
|
||||
|
||||
assert_eq!(plain.is_some(), true);
|
||||
assert_eq!(namespaced.is_some(), true);
|
||||
assert_eq!(missing_namespaced.is_none(), true);
|
||||
assert!(
|
||||
plain
|
||||
.as_ref()
|
||||
.is_some_and(|handler| Arc::ptr_eq(handler, &plain_handler))
|
||||
);
|
||||
assert!(
|
||||
namespaced
|
||||
.as_ref()
|
||||
.is_some_and(|handler| Arc::ptr_eq(handler, &namespaced_handler))
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -8,6 +8,7 @@ use crate::tools::context::FunctionToolOutput;
|
||||
use crate::tools::context::SharedTurnDiffTracker;
|
||||
use crate::tools::context::ToolInvocation;
|
||||
use crate::tools::context::ToolPayload;
|
||||
use crate::tools::context::ToolSearchOutput;
|
||||
use crate::tools::registry::AnyToolResult;
|
||||
use crate::tools::registry::ConfiguredToolSpec;
|
||||
use crate::tools::registry::ToolRegistry;
|
||||
@@ -17,6 +18,7 @@ use codex_protocol::dynamic_tools::DynamicToolSpec;
|
||||
use codex_protocol::models::LocalShellAction;
|
||||
use codex_protocol::models::ResponseInputItem;
|
||||
use codex_protocol::models::ResponseItem;
|
||||
use codex_protocol::models::SearchToolCallParams;
|
||||
use codex_protocol::models::ShellToolCallParams;
|
||||
use rmcp::model::Tool;
|
||||
use std::collections::HashMap;
|
||||
@@ -28,6 +30,7 @@ pub use crate::tools::context::ToolCallSource;
|
||||
#[derive(Clone, Debug)]
|
||||
pub struct ToolCall {
|
||||
pub tool_name: String,
|
||||
pub tool_namespace: Option<String>,
|
||||
pub call_id: String,
|
||||
pub payload: ToolPayload,
|
||||
}
|
||||
@@ -72,13 +75,15 @@ impl ToolRouter {
|
||||
match item {
|
||||
ResponseItem::FunctionCall {
|
||||
name,
|
||||
namespace,
|
||||
arguments,
|
||||
call_id,
|
||||
..
|
||||
} => {
|
||||
if let Some((server, tool)) = session.parse_mcp_tool_name(&name).await {
|
||||
if let Some((server, tool)) = session.parse_mcp_tool_name(&name, &namespace).await {
|
||||
Ok(Some(ToolCall {
|
||||
tool_name: name,
|
||||
tool_namespace: namespace,
|
||||
call_id,
|
||||
payload: ToolPayload::Mcp {
|
||||
server,
|
||||
@@ -89,11 +94,32 @@ impl ToolRouter {
|
||||
} else {
|
||||
Ok(Some(ToolCall {
|
||||
tool_name: name,
|
||||
tool_namespace: namespace,
|
||||
call_id,
|
||||
payload: ToolPayload::Function { arguments },
|
||||
}))
|
||||
}
|
||||
}
|
||||
ResponseItem::ToolSearchCall {
|
||||
call_id: Some(call_id),
|
||||
execution,
|
||||
arguments,
|
||||
..
|
||||
} if execution == "client" => {
|
||||
let arguments: SearchToolCallParams =
|
||||
serde_json::from_value(arguments).map_err(|err| {
|
||||
FunctionCallError::RespondToModel(format!(
|
||||
"failed to parse tool_search arguments: {err}"
|
||||
))
|
||||
})?;
|
||||
Ok(Some(ToolCall {
|
||||
tool_name: "tool_search".to_string(),
|
||||
tool_namespace: None,
|
||||
call_id,
|
||||
payload: ToolPayload::ToolSearch { arguments },
|
||||
}))
|
||||
}
|
||||
ResponseItem::ToolSearchCall { .. } => Ok(None),
|
||||
ResponseItem::CustomToolCall {
|
||||
name,
|
||||
input,
|
||||
@@ -101,6 +127,7 @@ impl ToolRouter {
|
||||
..
|
||||
} => Ok(Some(ToolCall {
|
||||
tool_name: name,
|
||||
tool_namespace: None,
|
||||
call_id,
|
||||
payload: ToolPayload::Custom { input },
|
||||
})),
|
||||
@@ -127,6 +154,7 @@ impl ToolRouter {
|
||||
};
|
||||
Ok(Some(ToolCall {
|
||||
tool_name: "local_shell".to_string(),
|
||||
tool_namespace: None,
|
||||
call_id,
|
||||
payload: ToolPayload::LocalShell { params },
|
||||
}))
|
||||
@@ -163,10 +191,12 @@ impl ToolRouter {
|
||||
) -> Result<AnyToolResult, FunctionCallError> {
|
||||
let ToolCall {
|
||||
tool_name,
|
||||
tool_namespace,
|
||||
call_id,
|
||||
payload,
|
||||
} = call;
|
||||
let payload_outputs_custom = matches!(payload, ToolPayload::Custom { .. });
|
||||
let payload_outputs_tool_search = matches!(payload, ToolPayload::ToolSearch { .. });
|
||||
let failure_call_id = call_id.clone();
|
||||
|
||||
if source == ToolCallSource::Direct
|
||||
@@ -180,6 +210,7 @@ impl ToolRouter {
|
||||
return Ok(Self::failure_result(
|
||||
failure_call_id,
|
||||
payload_outputs_custom,
|
||||
payload_outputs_tool_search,
|
||||
err,
|
||||
));
|
||||
}
|
||||
@@ -190,6 +221,7 @@ impl ToolRouter {
|
||||
tracker,
|
||||
call_id,
|
||||
tool_name,
|
||||
tool_namespace,
|
||||
payload,
|
||||
};
|
||||
|
||||
@@ -199,6 +231,7 @@ impl ToolRouter {
|
||||
Err(err) => Ok(Self::failure_result(
|
||||
failure_call_id,
|
||||
payload_outputs_custom,
|
||||
payload_outputs_tool_search,
|
||||
err,
|
||||
)),
|
||||
}
|
||||
@@ -207,10 +240,22 @@ impl ToolRouter {
|
||||
fn failure_result(
|
||||
call_id: String,
|
||||
payload_outputs_custom: bool,
|
||||
payload_outputs_tool_search: bool,
|
||||
err: FunctionCallError,
|
||||
) -> AnyToolResult {
|
||||
let message = err.to_string();
|
||||
if payload_outputs_custom {
|
||||
if payload_outputs_tool_search {
|
||||
AnyToolResult {
|
||||
call_id,
|
||||
payload: ToolPayload::ToolSearch {
|
||||
arguments: SearchToolCallParams {
|
||||
query: String::new(),
|
||||
limit: None,
|
||||
},
|
||||
},
|
||||
result: Box::new(ToolSearchOutput { tools: Vec::new() }),
|
||||
}
|
||||
} else if payload_outputs_custom {
|
||||
AnyToolResult {
|
||||
call_id,
|
||||
payload: ToolPayload::Custom {
|
||||
@@ -237,6 +282,7 @@ mod tests {
|
||||
use crate::tools::context::ToolPayload;
|
||||
use crate::turn_diff_tracker::TurnDiffTracker;
|
||||
use codex_protocol::models::ResponseInputItem;
|
||||
use codex_protocol::models::ResponseItem;
|
||||
|
||||
use super::ToolCall;
|
||||
use super::ToolCallSource;
|
||||
@@ -271,6 +317,7 @@ mod tests {
|
||||
|
||||
let call = ToolCall {
|
||||
tool_name: "shell".to_string(),
|
||||
tool_namespace: None,
|
||||
call_id: "call-1".to_string(),
|
||||
payload: ToolPayload::Function {
|
||||
arguments: "{}".to_string(),
|
||||
@@ -324,6 +371,7 @@ mod tests {
|
||||
|
||||
let call = ToolCall {
|
||||
tool_name: "shell".to_string(),
|
||||
tool_namespace: None,
|
||||
call_id: "call-2".to_string(),
|
||||
payload: ToolPayload::Function {
|
||||
arguments: "{}".to_string(),
|
||||
@@ -347,4 +395,39 @@ mod tests {
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn build_tool_call_uses_namespace_for_registry_name() -> anyhow::Result<()> {
|
||||
let (session, _) = make_session_and_context().await;
|
||||
let session = Arc::new(session);
|
||||
let tool_name = "create_event".to_string();
|
||||
|
||||
let call = ToolRouter::build_tool_call(
|
||||
&session,
|
||||
ResponseItem::FunctionCall {
|
||||
id: None,
|
||||
name: tool_name.clone(),
|
||||
namespace: Some("mcp__codex_apps__calendar".to_string()),
|
||||
arguments: "{}".to_string(),
|
||||
call_id: "call-namespace".to_string(),
|
||||
},
|
||||
)
|
||||
.await?
|
||||
.expect("function_call should produce a tool call");
|
||||
|
||||
assert_eq!(call.tool_name, tool_name);
|
||||
assert_eq!(
|
||||
call.tool_namespace,
|
||||
Some("mcp__codex_apps__calendar".to_string())
|
||||
);
|
||||
assert_eq!(call.call_id, "call-namespace");
|
||||
match call.payload {
|
||||
ToolPayload::Function { arguments } => {
|
||||
assert_eq!(arguments, "{}");
|
||||
}
|
||||
other => panic!("expected function payload, got {other:?}"),
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
+257
-58
@@ -11,8 +11,8 @@ use crate::original_image_detail::can_request_original_image_detail;
|
||||
use crate::tools::code_mode::PUBLIC_TOOL_NAME;
|
||||
use crate::tools::code_mode_description::augment_tool_spec_for_code_mode;
|
||||
use crate::tools::handlers::PLAN_TOOL;
|
||||
use crate::tools::handlers::SEARCH_TOOL_BM25_DEFAULT_LIMIT;
|
||||
use crate::tools::handlers::SEARCH_TOOL_BM25_TOOL_NAME;
|
||||
use crate::tools::handlers::TOOL_SEARCH_DEFAULT_LIMIT;
|
||||
use crate::tools::handlers::TOOL_SEARCH_TOOL_NAME;
|
||||
use crate::tools::handlers::agent_jobs::BatchJobHandler;
|
||||
use crate::tools::handlers::apply_patch::create_apply_patch_freeform_tool;
|
||||
use crate::tools::handlers::apply_patch::create_apply_patch_json_tool;
|
||||
@@ -22,6 +22,7 @@ use crate::tools::handlers::multi_agents::MIN_WAIT_TIMEOUT_MS;
|
||||
use crate::tools::handlers::request_permissions_tool_description;
|
||||
use crate::tools::handlers::request_user_input_tool_description;
|
||||
use crate::tools::registry::ToolRegistryBuilder;
|
||||
use crate::tools::registry::tool_handler_key;
|
||||
use codex_protocol::config_types::WebSearchConfig;
|
||||
use codex_protocol::config_types::WebSearchMode;
|
||||
use codex_protocol::dynamic_tools::DynamicToolSpec;
|
||||
@@ -41,7 +42,7 @@ use serde_json::json;
|
||||
use std::collections::BTreeMap;
|
||||
use std::collections::HashMap;
|
||||
|
||||
const SEARCH_TOOL_BM25_DESCRIPTION_TEMPLATE: &str =
|
||||
const TOOL_SEARCH_DESCRIPTION_TEMPLATE: &str =
|
||||
include_str!("../../templates/search_tool/tool_description.md");
|
||||
const WEB_SEARCH_CONTENT_TYPES: [&str; 2] = ["text", "image"];
|
||||
|
||||
@@ -519,6 +520,7 @@ fn create_exec_command_tool(allow_login_shell: bool, request_permission_enabled:
|
||||
"Runs a command in a PTY, returning output or a session ID for ongoing interaction."
|
||||
.to_string(),
|
||||
strict: false,
|
||||
defer_loading: None,
|
||||
parameters: JsonSchema::Object {
|
||||
properties,
|
||||
required: Some(vec!["cmd".to_string()]),
|
||||
@@ -567,6 +569,7 @@ fn create_write_stdin_tool() -> ToolSpec {
|
||||
"Writes characters to an existing unified exec session and returns recent output."
|
||||
.to_string(),
|
||||
strict: false,
|
||||
defer_loading: None,
|
||||
parameters: JsonSchema::Object {
|
||||
properties,
|
||||
required: Some(vec!["session_id".to_string()]),
|
||||
@@ -621,6 +624,7 @@ Examples of valid command strings:
|
||||
name: "shell".to_string(),
|
||||
description,
|
||||
strict: false,
|
||||
defer_loading: None,
|
||||
parameters: JsonSchema::Object {
|
||||
properties,
|
||||
required: Some(vec!["command".to_string()]),
|
||||
@@ -689,6 +693,7 @@ Examples of valid command strings:
|
||||
name: "shell_command".to_string(),
|
||||
description,
|
||||
strict: false,
|
||||
defer_loading: None,
|
||||
parameters: JsonSchema::Object {
|
||||
properties,
|
||||
required: Some(vec!["command".to_string()]),
|
||||
@@ -722,6 +727,7 @@ fn create_view_image_tool(can_request_original_image_detail: bool) -> ToolSpec {
|
||||
description: "View a local image from the filesystem (only use if given a full filepath by the user, and the image isn't already attached to the thread context within <image ...> tags)."
|
||||
.to_string(),
|
||||
strict: false,
|
||||
defer_loading: None,
|
||||
parameters: JsonSchema::Object {
|
||||
properties,
|
||||
required: Some(vec!["path".to_string()]),
|
||||
@@ -870,6 +876,7 @@ fn create_spawn_agent_tool(config: &ToolsConfig) -> ToolSpec {
|
||||
- The key is to find opportunities to spawn multiple independent subtasks in parallel within the same round, while ensuring each subtask is well-defined, self-contained, and materially advances the main task."#
|
||||
),
|
||||
strict: false,
|
||||
defer_loading: None,
|
||||
parameters: JsonSchema::Object {
|
||||
properties,
|
||||
required: None,
|
||||
@@ -976,6 +983,7 @@ fn create_spawn_agents_on_csv_tool() -> ToolSpec {
|
||||
description: "Process a CSV by spawning one worker sub-agent per row. The instruction string is a template where `{column}` placeholders are replaced with row values. Each worker must call `report_agent_job_result` with a JSON object (matching `output_schema` when provided); missing reports are treated as failures. This call blocks until all rows finish and automatically exports results to `output_csv_path` (or a default path)."
|
||||
.to_string(),
|
||||
strict: false,
|
||||
defer_loading: None,
|
||||
parameters: JsonSchema::Object {
|
||||
properties,
|
||||
required: Some(vec!["csv_path".to_string(), "instruction".to_string()]),
|
||||
@@ -1022,6 +1030,7 @@ fn create_report_agent_job_result_tool() -> ToolSpec {
|
||||
"Worker-only tool to report a result for an agent job item. Main agents should not call this."
|
||||
.to_string(),
|
||||
strict: false,
|
||||
defer_loading: None,
|
||||
parameters: JsonSchema::Object {
|
||||
properties,
|
||||
required: Some(vec![
|
||||
@@ -1069,6 +1078,7 @@ fn create_send_input_tool() -> ToolSpec {
|
||||
description: "Send a message to an existing agent. Use interrupt=true to redirect work immediately. You should reuse the agent by send_input if you believe your assigned task is highly dependent on the context of a previous task."
|
||||
.to_string(),
|
||||
strict: false,
|
||||
defer_loading: None,
|
||||
parameters: JsonSchema::Object {
|
||||
properties,
|
||||
required: Some(vec!["id".to_string()]),
|
||||
@@ -1093,6 +1103,7 @@ fn create_resume_agent_tool() -> ToolSpec {
|
||||
"Resume a previously closed agent by id so it can receive send_input and wait calls."
|
||||
.to_string(),
|
||||
strict: false,
|
||||
defer_loading: None,
|
||||
parameters: JsonSchema::Object {
|
||||
properties,
|
||||
required: Some(vec!["id".to_string()]),
|
||||
@@ -1128,6 +1139,7 @@ fn create_wait_tool() -> ToolSpec {
|
||||
description: "Wait for agents to reach a final status. Completed statuses may include the agent's final message. Returns empty status when timed out. Once the agent reaches a final status, a notification message will be received containing the same completed status."
|
||||
.to_string(),
|
||||
strict: false,
|
||||
defer_loading: None,
|
||||
parameters: JsonSchema::Object {
|
||||
properties,
|
||||
required: Some(vec!["ids".to_string()]),
|
||||
@@ -1214,6 +1226,7 @@ fn create_request_user_input_tool(
|
||||
collaboration_modes_config.default_mode_request_user_input,
|
||||
),
|
||||
strict: false,
|
||||
defer_loading: None,
|
||||
parameters: JsonSchema::Object {
|
||||
properties,
|
||||
required: Some(vec!["questions".to_string()]),
|
||||
@@ -1239,6 +1252,7 @@ fn create_request_permissions_tool() -> ToolSpec {
|
||||
name: "request_permissions".to_string(),
|
||||
description: request_permissions_tool_description(),
|
||||
strict: false,
|
||||
defer_loading: None,
|
||||
parameters: JsonSchema::Object {
|
||||
properties,
|
||||
required: Some(vec!["permissions".to_string()]),
|
||||
@@ -1261,6 +1275,7 @@ fn create_close_agent_tool() -> ToolSpec {
|
||||
name: "close_agent".to_string(),
|
||||
description: "Close an agent when it is no longer needed and return its last known status. Don't keep agents open for too long if they are not needed anymore.".to_string(),
|
||||
strict: false,
|
||||
defer_loading: None,
|
||||
parameters: JsonSchema::Object {
|
||||
properties,
|
||||
required: Some(vec!["id".to_string()]),
|
||||
@@ -1329,6 +1344,7 @@ fn create_test_sync_tool() -> ToolSpec {
|
||||
name: "test_sync_tool".to_string(),
|
||||
description: "Internal synchronization helper used by Codex integration tests.".to_string(),
|
||||
strict: false,
|
||||
defer_loading: None,
|
||||
parameters: JsonSchema::Object {
|
||||
properties,
|
||||
required: None,
|
||||
@@ -1381,6 +1397,7 @@ fn create_grep_files_tool() -> ToolSpec {
|
||||
time."
|
||||
.to_string(),
|
||||
strict: false,
|
||||
defer_loading: None,
|
||||
parameters: JsonSchema::Object {
|
||||
properties,
|
||||
required: Some(vec!["pattern".to_string()]),
|
||||
@@ -1390,7 +1407,7 @@ fn create_grep_files_tool() -> ToolSpec {
|
||||
})
|
||||
}
|
||||
|
||||
fn create_search_tool_bm25_tool(app_tools: &HashMap<String, ToolInfo>) -> ToolSpec {
|
||||
fn create_tool_search_tool(app_tools: &HashMap<String, ToolInfo>) -> ToolSpec {
|
||||
let properties = BTreeMap::from([
|
||||
(
|
||||
"query".to_string(),
|
||||
@@ -1402,7 +1419,7 @@ fn create_search_tool_bm25_tool(app_tools: &HashMap<String, ToolInfo>) -> ToolSp
|
||||
"limit".to_string(),
|
||||
JsonSchema::Number {
|
||||
description: Some(format!(
|
||||
"Maximum number of tools to return (defaults to {SEARCH_TOOL_BM25_DEFAULT_LIMIT})."
|
||||
"Maximum number of tools to return (defaults to {TOOL_SEARCH_DEFAULT_LIMIT})."
|
||||
)),
|
||||
},
|
||||
),
|
||||
@@ -1416,24 +1433,22 @@ fn create_search_tool_bm25_tool(app_tools: &HashMap<String, ToolInfo>) -> ToolSp
|
||||
let app_names = app_names.join(", ");
|
||||
|
||||
let description = if app_names.is_empty() {
|
||||
SEARCH_TOOL_BM25_DESCRIPTION_TEMPLATE
|
||||
TOOL_SEARCH_DESCRIPTION_TEMPLATE
|
||||
.replace("({{app_names}})", "(None currently enabled)")
|
||||
.replace("{{app_names}}", "available apps")
|
||||
} else {
|
||||
SEARCH_TOOL_BM25_DESCRIPTION_TEMPLATE.replace("{{app_names}}", app_names.as_str())
|
||||
TOOL_SEARCH_DESCRIPTION_TEMPLATE.replace("{{app_names}}", app_names.as_str())
|
||||
};
|
||||
|
||||
ToolSpec::Function(ResponsesApiTool {
|
||||
name: SEARCH_TOOL_BM25_TOOL_NAME.to_string(),
|
||||
ToolSpec::ToolSearch {
|
||||
execution: "client".to_string(),
|
||||
description,
|
||||
strict: false,
|
||||
parameters: JsonSchema::Object {
|
||||
properties,
|
||||
required: Some(vec!["query".to_string()]),
|
||||
additional_properties: Some(false.into()),
|
||||
},
|
||||
output_schema: None,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
fn create_read_file_tool() -> ToolSpec {
|
||||
@@ -1531,6 +1546,7 @@ fn create_read_file_tool() -> ToolSpec {
|
||||
"Reads a local file with 1-indexed line numbers, supporting slice and indentation-aware block modes."
|
||||
.to_string(),
|
||||
strict: false,
|
||||
defer_loading: None,
|
||||
parameters: JsonSchema::Object {
|
||||
properties,
|
||||
required: Some(vec!["file_path".to_string()]),
|
||||
@@ -1578,6 +1594,7 @@ fn create_list_dir_tool() -> ToolSpec {
|
||||
"Lists entries in a local directory with 1-indexed entry numbers and simple type labels."
|
||||
.to_string(),
|
||||
strict: false,
|
||||
defer_loading: None,
|
||||
parameters: JsonSchema::Object {
|
||||
properties,
|
||||
required: Some(vec!["dir_path".to_string()]),
|
||||
@@ -1653,6 +1670,7 @@ fn create_js_repl_reset_tool() -> ToolSpec {
|
||||
"Restarts the js_repl kernel for this run and clears persisted top-level bindings."
|
||||
.to_string(),
|
||||
strict: false,
|
||||
defer_loading: None,
|
||||
parameters: JsonSchema::Object {
|
||||
properties: BTreeMap::new(),
|
||||
required: None,
|
||||
@@ -1718,6 +1736,7 @@ fn create_list_mcp_resources_tool() -> ToolSpec {
|
||||
name: "list_mcp_resources".to_string(),
|
||||
description: "Lists resources provided by MCP servers. Resources allow servers to share data that provides context to language models, such as files, database schemas, or application-specific information. Prefer resources over web search when possible.".to_string(),
|
||||
strict: false,
|
||||
defer_loading: None,
|
||||
parameters: JsonSchema::Object {
|
||||
properties,
|
||||
required: None,
|
||||
@@ -1753,6 +1772,7 @@ fn create_list_mcp_resource_templates_tool() -> ToolSpec {
|
||||
name: "list_mcp_resource_templates".to_string(),
|
||||
description: "Lists resource templates provided by MCP servers. Parameterized resource templates allow servers to share data that takes parameters and provides context to language models, such as files, database schemas, or application-specific information. Prefer resource templates over web search when possible.".to_string(),
|
||||
strict: false,
|
||||
defer_loading: None,
|
||||
parameters: JsonSchema::Object {
|
||||
properties,
|
||||
required: None,
|
||||
@@ -1790,6 +1810,7 @@ fn create_read_mcp_resource_tool() -> ToolSpec {
|
||||
"Read a specific resource from an MCP server given the server name and resource URI."
|
||||
.to_string(),
|
||||
strict: false,
|
||||
defer_loading: None,
|
||||
parameters: JsonSchema::Object {
|
||||
properties,
|
||||
required: Some(vec!["server".to_string(), "uri".to_string()]),
|
||||
@@ -1839,6 +1860,59 @@ pub(crate) fn mcp_tool_to_openai_tool(
|
||||
fully_qualified_name: String,
|
||||
tool: rmcp::model::Tool,
|
||||
) -> Result<ResponsesApiTool, serde_json::Error> {
|
||||
let (description, input_schema, output_schema) = mcp_tool_to_openai_tool_parts(tool)?;
|
||||
|
||||
Ok(ResponsesApiTool {
|
||||
name: fully_qualified_name,
|
||||
description,
|
||||
strict: false,
|
||||
defer_loading: None,
|
||||
parameters: input_schema,
|
||||
output_schema,
|
||||
})
|
||||
}
|
||||
|
||||
pub(crate) fn mcp_tool_to_deferred_openai_tool(
|
||||
name: String,
|
||||
tool: rmcp::model::Tool,
|
||||
) -> Result<ResponsesApiTool, serde_json::Error> {
|
||||
let (description, input_schema, _) = mcp_tool_to_openai_tool_parts(tool)?;
|
||||
|
||||
Ok(ResponsesApiTool {
|
||||
name,
|
||||
description,
|
||||
strict: false,
|
||||
defer_loading: Some(true),
|
||||
parameters: input_schema,
|
||||
output_schema: None,
|
||||
})
|
||||
}
|
||||
|
||||
fn dynamic_tool_to_openai_tool(
|
||||
tool: &DynamicToolSpec,
|
||||
) -> Result<ResponsesApiTool, serde_json::Error> {
|
||||
let input_schema = parse_tool_input_schema(&tool.input_schema)?;
|
||||
|
||||
Ok(ResponsesApiTool {
|
||||
name: tool.name.clone(),
|
||||
description: tool.description.clone(),
|
||||
strict: false,
|
||||
defer_loading: None,
|
||||
parameters: input_schema,
|
||||
output_schema: None,
|
||||
})
|
||||
}
|
||||
|
||||
/// Parse the tool input_schema or return an error for invalid schema
|
||||
pub fn parse_tool_input_schema(input_schema: &JsonValue) -> Result<JsonSchema, serde_json::Error> {
|
||||
let mut input_schema = input_schema.clone();
|
||||
sanitize_json_schema(&mut input_schema);
|
||||
serde_json::from_value::<JsonSchema>(input_schema)
|
||||
}
|
||||
|
||||
fn mcp_tool_to_openai_tool_parts(
|
||||
tool: rmcp::model::Tool,
|
||||
) -> Result<(String, JsonSchema, Option<JsonValue>), serde_json::Error> {
|
||||
let rmcp::model::Tool {
|
||||
description,
|
||||
input_schema,
|
||||
@@ -1873,35 +1947,9 @@ pub(crate) fn mcp_tool_to_openai_tool(
|
||||
let output_schema = Some(mcp_call_tool_result_output_schema(
|
||||
structured_content_schema,
|
||||
));
|
||||
let description = description.map(Into::into).unwrap_or_default();
|
||||
|
||||
Ok(ResponsesApiTool {
|
||||
name: fully_qualified_name,
|
||||
description: description.map(Into::into).unwrap_or_default(),
|
||||
strict: false,
|
||||
parameters: input_schema,
|
||||
output_schema,
|
||||
})
|
||||
}
|
||||
|
||||
fn dynamic_tool_to_openai_tool(
|
||||
tool: &DynamicToolSpec,
|
||||
) -> Result<ResponsesApiTool, serde_json::Error> {
|
||||
let input_schema = parse_tool_input_schema(&tool.input_schema)?;
|
||||
|
||||
Ok(ResponsesApiTool {
|
||||
name: tool.name.clone(),
|
||||
description: tool.description.clone(),
|
||||
strict: false,
|
||||
parameters: input_schema,
|
||||
output_schema: None,
|
||||
})
|
||||
}
|
||||
|
||||
/// Parse the tool input_schema or return an error for invalid schema
|
||||
pub fn parse_tool_input_schema(input_schema: &JsonValue) -> Result<JsonSchema, serde_json::Error> {
|
||||
let mut input_schema = input_schema.clone();
|
||||
sanitize_json_schema(&mut input_schema);
|
||||
serde_json::from_value::<JsonSchema>(input_schema)
|
||||
Ok((description, input_schema, output_schema))
|
||||
}
|
||||
|
||||
fn mcp_call_tool_result_output_schema(structured_content_schema: JsonValue) -> JsonValue {
|
||||
@@ -2056,10 +2104,10 @@ pub(crate) fn build_specs(
|
||||
use crate::tools::handlers::ReadFileHandler;
|
||||
use crate::tools::handlers::RequestPermissionsHandler;
|
||||
use crate::tools::handlers::RequestUserInputHandler;
|
||||
use crate::tools::handlers::SearchToolBm25Handler;
|
||||
use crate::tools::handlers::ShellCommandHandler;
|
||||
use crate::tools::handlers::ShellHandler;
|
||||
use crate::tools::handlers::TestSyncHandler;
|
||||
use crate::tools::handlers::ToolSearchHandler;
|
||||
use crate::tools::handlers::UnifiedExecHandler;
|
||||
use crate::tools::handlers::ViewImageHandler;
|
||||
use std::sync::Arc;
|
||||
@@ -2079,7 +2127,6 @@ pub(crate) fn build_specs(
|
||||
let request_user_input_handler = Arc::new(RequestUserInputHandler {
|
||||
default_mode_request_user_input: config.default_mode_request_user_input,
|
||||
});
|
||||
let search_tool_handler = Arc::new(SearchToolBm25Handler);
|
||||
let code_mode_handler = Arc::new(CodeModeHandler);
|
||||
let js_repl_handler = Arc::new(JsReplHandler);
|
||||
let js_repl_reset_handler = Arc::new(JsReplResetHandler);
|
||||
@@ -2237,15 +2284,24 @@ pub(crate) fn build_specs(
|
||||
builder.register_handler("request_permissions", request_permissions_handler);
|
||||
}
|
||||
|
||||
if config.search_tool {
|
||||
let app_tools = app_tools.unwrap_or_default();
|
||||
if config.search_tool
|
||||
&& let Some(app_tools) = app_tools
|
||||
{
|
||||
let search_tool_handler = Arc::new(ToolSearchHandler::new(app_tools.clone()));
|
||||
push_tool_spec(
|
||||
&mut builder,
|
||||
create_search_tool_bm25_tool(&app_tools),
|
||||
create_tool_search_tool(&app_tools),
|
||||
true,
|
||||
config.code_mode_enabled,
|
||||
);
|
||||
builder.register_handler(SEARCH_TOOL_BM25_TOOL_NAME, search_tool_handler);
|
||||
builder.register_handler(TOOL_SEARCH_TOOL_NAME, search_tool_handler);
|
||||
|
||||
for tool in app_tools.values() {
|
||||
let alias_name =
|
||||
tool_handler_key(tool.tool_name.as_str(), Some(tool.tool_namespace.as_str()));
|
||||
|
||||
builder.register_handler(alias_name, mcp_handler.clone());
|
||||
}
|
||||
}
|
||||
|
||||
if let Some(apply_patch_tool_type) = &config.apply_patch_tool_type {
|
||||
@@ -2668,9 +2724,73 @@ mod tests {
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn search_tool_deferred_tools_always_set_defer_loading_true() {
|
||||
let tool = mcp_tool(
|
||||
"lookup_order",
|
||||
"Look up an order",
|
||||
serde_json::json!({
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"order_id": {"type": "string"}
|
||||
},
|
||||
"required": ["order_id"],
|
||||
"additionalProperties": false,
|
||||
}),
|
||||
);
|
||||
|
||||
let openai_tool =
|
||||
mcp_tool_to_deferred_openai_tool("mcp__codex_apps__lookup_order".to_string(), tool)
|
||||
.expect("convert deferred tool");
|
||||
|
||||
assert_eq!(openai_tool.defer_loading, Some(true));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn deferred_responses_api_tool_serializes_with_defer_loading() {
|
||||
let tool = mcp_tool(
|
||||
"lookup_order",
|
||||
"Look up an order",
|
||||
serde_json::json!({
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"order_id": {"type": "string"}
|
||||
},
|
||||
"required": ["order_id"],
|
||||
"additionalProperties": false,
|
||||
}),
|
||||
);
|
||||
|
||||
let serialized = serde_json::to_value(ToolSpec::Function(
|
||||
mcp_tool_to_deferred_openai_tool("mcp__codex_apps__lookup_order".to_string(), tool)
|
||||
.expect("convert deferred tool"),
|
||||
))
|
||||
.expect("serialize deferred tool");
|
||||
|
||||
assert_eq!(
|
||||
serialized,
|
||||
serde_json::json!({
|
||||
"type": "function",
|
||||
"name": "mcp__codex_apps__lookup_order",
|
||||
"description": "Look up an order",
|
||||
"strict": false,
|
||||
"defer_loading": true,
|
||||
"parameters": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"order_id": {"type": "string"}
|
||||
},
|
||||
"required": ["order_id"],
|
||||
"additionalProperties": false,
|
||||
}
|
||||
})
|
||||
);
|
||||
}
|
||||
|
||||
fn tool_name(tool: &ToolSpec) -> &str {
|
||||
match tool {
|
||||
ToolSpec::Function(ResponsesApiTool { name, .. }) => name,
|
||||
ToolSpec::ToolSearch { .. } => "tool_search",
|
||||
ToolSpec::LocalShell {} => "local_shell",
|
||||
ToolSpec::ImageGeneration { .. } => "image_generation",
|
||||
ToolSpec::WebSearch { .. } => "web_search",
|
||||
@@ -2759,6 +2879,7 @@ mod tests {
|
||||
|
||||
fn strip_descriptions_tool(spec: &mut ToolSpec) {
|
||||
match spec {
|
||||
ToolSpec::ToolSearch { parameters, .. } => strip_descriptions_schema(parameters),
|
||||
ToolSpec::Function(ResponsesApiTool { parameters, .. }) => {
|
||||
strip_descriptions_schema(parameters);
|
||||
}
|
||||
@@ -3863,6 +3984,7 @@ mod tests {
|
||||
description: "Do something cool".to_string(),
|
||||
strict: false,
|
||||
output_schema: Some(mcp_call_tool_result_output_schema(serde_json::json!({}))),
|
||||
defer_loading: None,
|
||||
})
|
||||
);
|
||||
}
|
||||
@@ -3948,18 +4070,20 @@ mod tests {
|
||||
])),
|
||||
Some(HashMap::from([
|
||||
(
|
||||
"mcp__codex_apps__calendar_create_event".to_string(),
|
||||
"mcp__codex_apps__calendar-create-event".to_string(),
|
||||
ToolInfo {
|
||||
server_name: crate::mcp::CODEX_APPS_MCP_SERVER_NAME.to_string(),
|
||||
tool_name: "calendar_create_event".to_string(),
|
||||
tool_name: "-create-event".to_string(),
|
||||
tool_namespace: "mcp__codex_apps__calendar".to_string(),
|
||||
tool: mcp_tool(
|
||||
"calendar_create_event",
|
||||
"calendar-create-event",
|
||||
"Create calendar event",
|
||||
serde_json::json!({"type": "object"}),
|
||||
),
|
||||
connector_id: Some("calendar".to_string()),
|
||||
connector_name: Some("Calendar".to_string()),
|
||||
plugin_display_names: Vec::new(),
|
||||
connector_description: None,
|
||||
},
|
||||
),
|
||||
(
|
||||
@@ -3967,10 +4091,12 @@ mod tests {
|
||||
ToolInfo {
|
||||
server_name: "rmcp".to_string(),
|
||||
tool_name: "echo".to_string(),
|
||||
tool_namespace: "rmcp".to_string(),
|
||||
tool: mcp_tool("echo", "Echo", serde_json::json!({"type": "object"})),
|
||||
connector_id: None,
|
||||
connector_name: None,
|
||||
plugin_display_names: Vec::new(),
|
||||
connector_description: None,
|
||||
},
|
||||
),
|
||||
])),
|
||||
@@ -3978,10 +4104,11 @@ mod tests {
|
||||
)
|
||||
.build();
|
||||
|
||||
let search_tool = find_tool(&tools, SEARCH_TOOL_BM25_TOOL_NAME);
|
||||
let ToolSpec::Function(ResponsesApiTool { description, .. }) = &search_tool.spec else {
|
||||
panic!("expected function tool");
|
||||
let search_tool = find_tool(&tools, TOOL_SEARCH_TOOL_NAME);
|
||||
let ToolSpec::ToolSearch { description, .. } = &search_tool.spec else {
|
||||
panic!("expected tool_search tool");
|
||||
};
|
||||
let description = description.as_str();
|
||||
assert!(description.contains("Calendar"));
|
||||
assert!(!description.contains("mcp__rmcp__echo"));
|
||||
}
|
||||
@@ -3996,6 +4123,7 @@ mod tests {
|
||||
ToolInfo {
|
||||
server_name: crate::mcp::CODEX_APPS_MCP_SERVER_NAME.to_string(),
|
||||
tool_name: "calendar_create_event".to_string(),
|
||||
tool_namespace: "mcp__codex_apps__calendar".to_string(),
|
||||
tool: mcp_tool(
|
||||
"calendar_create_event",
|
||||
"Create calendar event",
|
||||
@@ -4003,6 +4131,7 @@ mod tests {
|
||||
),
|
||||
connector_id: Some("calendar".to_string()),
|
||||
connector_name: Some("Calendar".to_string()),
|
||||
connector_description: None,
|
||||
plugin_display_names: Vec::new(),
|
||||
},
|
||||
)]));
|
||||
@@ -4017,7 +4146,7 @@ mod tests {
|
||||
session_source: SessionSource::Cli,
|
||||
});
|
||||
let (tools, _) = build_specs(&tools_config, None, app_tools.clone(), &[]).build();
|
||||
assert_lacks_tool_name(&tools, SEARCH_TOOL_BM25_TOOL_NAME);
|
||||
assert_lacks_tool_name(&tools, TOOL_SEARCH_TOOL_NAME);
|
||||
|
||||
let mut features = Features::with_defaults();
|
||||
features.enable(Feature::Apps);
|
||||
@@ -4030,7 +4159,7 @@ mod tests {
|
||||
session_source: SessionSource::Cli,
|
||||
});
|
||||
let (tools, _) = build_specs(&tools_config, None, app_tools, &[]).build();
|
||||
assert_contains_tool_names(&tools, &[SEARCH_TOOL_BM25_TOOL_NAME]);
|
||||
assert_contains_tool_names(&tools, &[TOOL_SEARCH_TOOL_NAME]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
@@ -4050,16 +4179,80 @@ mod tests {
|
||||
});
|
||||
|
||||
let (tools, _) = build_specs(&tools_config, None, Some(HashMap::new()), &[]).build();
|
||||
let search_tool = find_tool(&tools, SEARCH_TOOL_BM25_TOOL_NAME);
|
||||
let ToolSpec::Function(ResponsesApiTool { description, .. }) = &search_tool.spec else {
|
||||
panic!("expected function tool");
|
||||
let search_tool = find_tool(&tools, TOOL_SEARCH_TOOL_NAME);
|
||||
let ToolSpec::ToolSearch { description, .. } = &search_tool.spec else {
|
||||
panic!("expected tool_search tool");
|
||||
};
|
||||
|
||||
assert!(description.contains("(None currently enabled)"));
|
||||
assert!(description.contains("available apps."));
|
||||
assert!(!description.contains("{{app_names}}"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn search_tool_registers_namespaced_app_tool_aliases() {
|
||||
let config = test_config();
|
||||
let model_info =
|
||||
ModelsManager::construct_model_info_offline_for_tests("gpt-5-codex", &config);
|
||||
let mut features = Features::with_defaults();
|
||||
features.enable(Feature::Apps);
|
||||
let available_models = Vec::new();
|
||||
let tools_config = ToolsConfig::new(&ToolsConfigParams {
|
||||
model_info: &model_info,
|
||||
available_models: &available_models,
|
||||
features: &features,
|
||||
web_search_mode: Some(WebSearchMode::Cached),
|
||||
session_source: SessionSource::Cli,
|
||||
});
|
||||
|
||||
let (_, registry) = build_specs(
|
||||
&tools_config,
|
||||
None,
|
||||
Some(HashMap::from([
|
||||
(
|
||||
"mcp__codex_apps__calendar-create-event".to_string(),
|
||||
ToolInfo {
|
||||
server_name: crate::mcp::CODEX_APPS_MCP_SERVER_NAME.to_string(),
|
||||
tool_name: "-create-event".to_string(),
|
||||
tool_namespace: "mcp__codex_apps__calendar".to_string(),
|
||||
tool: mcp_tool(
|
||||
"calendar-create-event",
|
||||
"Create calendar event",
|
||||
serde_json::json!({"type": "object"}),
|
||||
),
|
||||
connector_id: Some("calendar".to_string()),
|
||||
connector_name: Some("Calendar".to_string()),
|
||||
connector_description: None,
|
||||
plugin_display_names: Vec::new(),
|
||||
},
|
||||
),
|
||||
(
|
||||
"mcp__codex_apps__calendar-list-events".to_string(),
|
||||
ToolInfo {
|
||||
server_name: crate::mcp::CODEX_APPS_MCP_SERVER_NAME.to_string(),
|
||||
tool_name: "-list-events".to_string(),
|
||||
tool_namespace: "mcp__codex_apps__calendar".to_string(),
|
||||
tool: mcp_tool(
|
||||
"calendar-list-events",
|
||||
"List calendar events",
|
||||
serde_json::json!({"type": "object"}),
|
||||
),
|
||||
connector_id: Some("calendar".to_string()),
|
||||
connector_name: Some("Calendar".to_string()),
|
||||
connector_description: None,
|
||||
plugin_display_names: Vec::new(),
|
||||
},
|
||||
),
|
||||
])),
|
||||
&[],
|
||||
)
|
||||
.build();
|
||||
|
||||
let alias = tool_handler_key("-create-event", Some("mcp__codex_apps__calendar"));
|
||||
|
||||
assert!(registry.has_handler(TOOL_SEARCH_TOOL_NAME, None));
|
||||
assert!(registry.has_handler(alias.as_str(), None));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_mcp_tool_property_missing_type_defaults_to_string() {
|
||||
let config = test_config();
|
||||
@@ -4114,6 +4307,7 @@ mod tests {
|
||||
description: "Search docs".to_string(),
|
||||
strict: false,
|
||||
output_schema: Some(mcp_call_tool_result_output_schema(serde_json::json!({}))),
|
||||
defer_loading: None,
|
||||
})
|
||||
);
|
||||
}
|
||||
@@ -4168,6 +4362,7 @@ mod tests {
|
||||
description: "Pagination".to_string(),
|
||||
strict: false,
|
||||
output_schema: Some(mcp_call_tool_result_output_schema(serde_json::json!({}))),
|
||||
defer_loading: None,
|
||||
})
|
||||
);
|
||||
}
|
||||
@@ -4226,6 +4421,7 @@ mod tests {
|
||||
description: "Tags".to_string(),
|
||||
strict: false,
|
||||
output_schema: Some(mcp_call_tool_result_output_schema(serde_json::json!({}))),
|
||||
defer_loading: None,
|
||||
})
|
||||
);
|
||||
}
|
||||
@@ -4282,6 +4478,7 @@ mod tests {
|
||||
description: "AnyOf Value".to_string(),
|
||||
strict: false,
|
||||
output_schema: Some(mcp_call_tool_result_output_schema(serde_json::json!({}))),
|
||||
defer_loading: None,
|
||||
})
|
||||
);
|
||||
}
|
||||
@@ -4543,6 +4740,7 @@ Examples of valid command strings:
|
||||
description: "Do something cool".to_string(),
|
||||
strict: false,
|
||||
output_schema: Some(mcp_call_tool_result_output_schema(serde_json::json!({}))),
|
||||
defer_loading: None,
|
||||
})
|
||||
);
|
||||
}
|
||||
@@ -4636,6 +4834,7 @@ Examples of valid command strings:
|
||||
name: "demo".to_string(),
|
||||
description: "A demo tool".to_string(),
|
||||
strict: false,
|
||||
defer_loading: None,
|
||||
parameters: JsonSchema::Object {
|
||||
properties,
|
||||
required: None,
|
||||
|
||||
Reference in New Issue
Block a user