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:
Anton Panasenko
2026-03-11 17:51:51 -07:00
committed by GitHub
parent 72631755e0
commit 77b0c75267
52 changed files with 2619 additions and 1890 deletions

View File

@@ -240,6 +240,13 @@ pub enum ResponseInputItem {
call_id: String,
output: FunctionCallOutputPayload,
},
ToolSearchOutput {
call_id: String,
status: String,
execution: String,
#[ts(type = "unknown[]")]
tools: Vec<serde_json::Value>,
},
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, JsonSchema, TS)]
@@ -320,12 +327,27 @@ pub enum ResponseItem {
#[ts(skip)]
id: Option<String>,
name: String,
#[serde(default, skip_serializing_if = "Option::is_none")]
#[ts(optional)]
namespace: Option<String>,
// The Responses API returns the function call arguments as a *string* that contains
// JSON, not as an alreadyparsed object. We keep it as a raw string here and let
// Session::handle_function_call parse it into a Value.
arguments: String,
call_id: String,
},
ToolSearchCall {
#[serde(default, skip_serializing)]
#[ts(skip)]
id: Option<String>,
call_id: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
#[ts(optional)]
status: Option<String>,
execution: String,
#[ts(type = "unknown")]
arguments: serde_json::Value,
},
// NOTE: The `output` field for `function_call_output` uses a dedicated payload type with
// custom serialization. On the wire it is either:
// - a plain string (`content`)
@@ -354,6 +376,13 @@ pub enum ResponseItem {
call_id: String,
output: FunctionCallOutputPayload,
},
ToolSearchOutput {
call_id: Option<String>,
status: String,
execution: String,
#[ts(type = "unknown[]")]
tools: Vec<serde_json::Value>,
},
// Emitted by the Responses API when the agent triggers a web search.
// Example payload (from SSE `response.output_item.done`):
// {
@@ -883,6 +912,17 @@ impl From<ResponseInputItem> for ResponseItem {
ResponseInputItem::CustomToolCallOutput { call_id, output } => {
Self::CustomToolCallOutput { call_id, output }
}
ResponseInputItem::ToolSearchOutput {
call_id,
status,
execution,
tools,
} => Self::ToolSearchOutput {
call_id: Some(call_id),
status,
execution,
tools,
},
}
}
}
@@ -988,6 +1028,13 @@ impl From<Vec<UserInput>> for ResponseInputItem {
}
}
}
#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, JsonSchema, TS)]
pub struct SearchToolCallParams {
pub query: String,
#[serde(default, skip_serializing_if = "Option::is_none")]
#[ts(optional)]
pub limit: Option<usize>,
}
/// If the `name` of a `ResponseItem::FunctionCall` is either `container.exec`
/// or `shell`, the `arguments` field should deserialize to this struct.
@@ -1721,6 +1768,29 @@ mod tests {
assert_eq!(text, Some("line 1".to_string()));
}
#[test]
fn function_call_deserializes_optional_namespace() {
let item: ResponseItem = serde_json::from_value(serde_json::json!({
"type": "function_call",
"name": "mcp__codex_apps__gmail_get_recent_emails",
"namespace": "mcp__codex_apps__gmail",
"arguments": "{\"top_k\":5}",
"call_id": "call-1",
}))
.expect("function_call should deserialize");
assert_eq!(
item,
ResponseItem::FunctionCall {
id: None,
name: "mcp__codex_apps__gmail_get_recent_emails".to_string(),
namespace: Some("mcp__codex_apps__gmail".to_string()),
arguments: "{\"top_k\":5}".to_string(),
call_id: "call-1".to_string(),
}
);
}
#[test]
fn converts_sandbox_mode_into_developer_instructions() {
let workspace_write: DeveloperInstructions = SandboxMode::WorkspaceWrite.into();
@@ -2193,6 +2263,169 @@ mod tests {
Ok(())
}
#[test]
fn tool_search_call_roundtrips() -> Result<()> {
let parsed: ResponseItem = serde_json::from_str(
r#"{
"type": "tool_search_call",
"call_id": "search-1",
"execution": "client",
"arguments": {
"query": "calendar create",
"limit": 1
}
}"#,
)?;
assert_eq!(
parsed,
ResponseItem::ToolSearchCall {
id: None,
call_id: Some("search-1".to_string()),
status: None,
execution: "client".to_string(),
arguments: serde_json::json!({
"query": "calendar create",
"limit": 1,
}),
}
);
assert_eq!(
serde_json::to_value(&parsed)?,
serde_json::json!({
"type": "tool_search_call",
"call_id": "search-1",
"execution": "client",
"arguments": {
"query": "calendar create",
"limit": 1,
}
})
);
Ok(())
}
#[test]
fn tool_search_output_roundtrips() -> Result<()> {
let input = ResponseInputItem::ToolSearchOutput {
call_id: "search-1".to_string(),
status: "completed".to_string(),
execution: "client".to_string(),
tools: vec![serde_json::json!({
"type": "function",
"name": "mcp__codex_apps__calendar_create_event",
"description": "Create a calendar event.",
"defer_loading": true,
"parameters": {
"type": "object",
"properties": {
"title": {"type": "string"}
},
"required": ["title"],
"additionalProperties": false,
}
})],
};
assert_eq!(
ResponseItem::from(input.clone()),
ResponseItem::ToolSearchOutput {
call_id: Some("search-1".to_string()),
status: "completed".to_string(),
execution: "client".to_string(),
tools: vec![serde_json::json!({
"type": "function",
"name": "mcp__codex_apps__calendar_create_event",
"description": "Create a calendar event.",
"defer_loading": true,
"parameters": {
"type": "object",
"properties": {
"title": {"type": "string"}
},
"required": ["title"],
"additionalProperties": false,
}
})],
}
);
assert_eq!(
serde_json::to_value(input)?,
serde_json::json!({
"type": "tool_search_output",
"call_id": "search-1",
"status": "completed",
"execution": "client",
"tools": [{
"type": "function",
"name": "mcp__codex_apps__calendar_create_event",
"description": "Create a calendar event.",
"defer_loading": true,
"parameters": {
"type": "object",
"properties": {
"title": {"type": "string"}
},
"required": ["title"],
"additionalProperties": false,
}
}]
})
);
Ok(())
}
#[test]
fn tool_search_server_items_allow_null_call_id() -> Result<()> {
let parsed_call: ResponseItem = serde_json::from_str(
r#"{
"type": "tool_search_call",
"execution": "server",
"call_id": null,
"status": "completed",
"arguments": {
"paths": ["crm"]
}
}"#,
)?;
assert_eq!(
parsed_call,
ResponseItem::ToolSearchCall {
id: None,
call_id: None,
status: Some("completed".to_string()),
execution: "server".to_string(),
arguments: serde_json::json!({
"paths": ["crm"],
}),
}
);
let parsed_output: ResponseItem = serde_json::from_str(
r#"{
"type": "tool_search_output",
"execution": "server",
"call_id": null,
"status": "completed",
"tools": []
}"#,
)?;
assert_eq!(
parsed_output,
ResponseItem::ToolSearchOutput {
call_id: None,
status: "completed".to_string(),
execution: "server".to_string(),
tools: vec![],
}
);
Ok(())
}
#[test]
fn mixed_remote_and_local_images_share_label_sequence() -> Result<()> {
let image_url = "data:image/png;base64,abc".to_string();