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

@@ -12,6 +12,7 @@ use wiremock::matchers::path_regex;
const CONNECTOR_ID: &str = "calendar";
const CONNECTOR_NAME: &str = "Calendar";
const CONNECTOR_DESCRIPTION: &str = "Plan events and manage your calendar.";
const PROTOCOL_VERSION: &str = "2025-11-25";
const SERVER_NAME: &str = "codex-apps-test";
const SERVER_VERSION: &str = "1.0.0";
@@ -31,7 +32,12 @@ impl AppsTestServer {
connector_name: &str,
) -> Result<Self> {
mount_oauth_metadata(server).await;
mount_streamable_http_json_rpc(server, connector_name.to_string()).await;
mount_streamable_http_json_rpc(
server,
connector_name.to_string(),
CONNECTOR_DESCRIPTION.to_string(),
)
.await;
Ok(Self {
chatgpt_base_url: server.uri(),
})
@@ -50,16 +56,24 @@ async fn mount_oauth_metadata(server: &MockServer) {
.await;
}
async fn mount_streamable_http_json_rpc(server: &MockServer, connector_name: String) {
async fn mount_streamable_http_json_rpc(
server: &MockServer,
connector_name: String,
connector_description: String,
) {
Mock::given(method("POST"))
.and(path_regex("^/api/codex/apps/?$"))
.respond_with(CodexAppsJsonRpcResponder { connector_name })
.respond_with(CodexAppsJsonRpcResponder {
connector_name,
connector_description,
})
.mount(server)
.await;
}
struct CodexAppsJsonRpcResponder {
connector_name: String,
connector_description: String,
}
impl Respond for CodexAppsJsonRpcResponder {
@@ -126,7 +140,8 @@ impl Respond for CodexAppsJsonRpcResponder {
},
"_meta": {
"connector_id": CONNECTOR_ID,
"connector_name": self.connector_name.clone()
"connector_name": self.connector_name.clone(),
"connector_description": self.connector_description.clone()
}
},
{
@@ -142,7 +157,8 @@ impl Respond for CodexAppsJsonRpcResponder {
},
"_meta": {
"connector_id": CONNECTOR_ID,
"connector_name": self.connector_name.clone()
"connector_name": self.connector_name.clone(),
"connector_description": self.connector_description.clone()
}
}
],
@@ -150,6 +166,33 @@ impl Respond for CodexAppsJsonRpcResponder {
}
}))
}
"tools/call" => {
let id = body.get("id").cloned().unwrap_or(Value::Null);
let tool_name = body
.pointer("/params/name")
.and_then(Value::as_str)
.unwrap_or_default();
let title = body
.pointer("/params/arguments/title")
.and_then(Value::as_str)
.unwrap_or_default();
let starts_at = body
.pointer("/params/arguments/starts_at")
.and_then(Value::as_str)
.unwrap_or_default();
ResponseTemplate::new(200).set_body_json(json!({
"jsonrpc": "2.0",
"id": id,
"result": {
"content": [{
"type": "text",
"text": format!("called {tool_name} for {title} at {starts_at}")
}],
"isError": false
}
}))
}
method if method.starts_with("notifications/") => ResponseTemplate::new(202),
_ => {
let id = body.get("id").cloned().unwrap_or(Value::Null);

View File

@@ -207,6 +207,10 @@ impl ResponsesRequest {
self.call_output(call_id, "custom_tool_call_output")
}
pub fn tool_search_output(&self, call_id: &str) -> Value {
self.call_output(call_id, "tool_search_output")
}
pub fn call_output(&self, call_id: &str, call_type: &str) -> Value {
self.input()
.iter()
@@ -774,6 +778,18 @@ pub fn ev_function_call(call_id: &str, name: &str, arguments: &str) -> Value {
})
}
pub fn ev_tool_search_call(call_id: &str, arguments: &serde_json::Value) -> Value {
serde_json::json!({
"type": "response.output_item.done",
"item": {
"type": "tool_search_call",
"call_id": call_id,
"execution": "client",
"arguments": arguments,
}
})
}
pub fn ev_custom_tool_call(call_id: &str, name: &str, input: &str) -> Value {
serde_json::json!({
"type": "response.output_item.done",
@@ -1484,11 +1500,13 @@ pub async fn mount_response_sequence(
/// Validate invariants on the request body sent to `/v1/responses`.
///
/// - No `function_call_output`/`custom_tool_call_output` with missing/empty `call_id`.
/// - `tool_search_output` must have a `call_id` unless it is a server-executed legacy item.
/// - Every `function_call_output` must match a prior `function_call` or
/// `local_shell_call` with the same `call_id` in the same `input`.
/// - Every `custom_tool_call_output` must match a prior `custom_tool_call`.
/// - Additionally, enforce symmetry: every `function_call`/`custom_tool_call`
/// in the `input` must have a matching output entry.
/// - Every `tool_search_output` must match a prior `tool_search_call`.
/// - Additionally, enforce symmetry: every `function_call`/`custom_tool_call`/
/// `tool_search_call` in the `input` must have a matching output entry.
fn validate_request_body_invariants(request: &wiremock::Request) {
// Skip GET requests (e.g., /models)
if request.method != "POST" || !request.url.path().ends_with("/responses") {
@@ -1538,7 +1556,24 @@ fn validate_request_body_invariants(request: &wiremock::Request) {
.collect()
}
fn gather_tool_search_output_ids(items: &[Value]) -> HashSet<String> {
items
.iter()
.filter(|item| item.get("type").and_then(Value::as_str) == Some("tool_search_output"))
.filter_map(|item| {
if let Some(id) = get_call_id(item) {
return Some(id.to_string());
}
if item.get("execution").and_then(Value::as_str) == Some("server") {
return None;
}
panic!("orphan tool_search_output with empty call_id should be dropped");
})
.collect()
}
let function_calls = gather_ids(items, "function_call");
let tool_search_calls = gather_ids(items, "tool_search_call");
let custom_tool_calls = gather_ids(items, "custom_tool_call");
let local_shell_calls = gather_ids(items, "local_shell_call");
let function_call_outputs = gather_output_ids(
@@ -1546,6 +1581,7 @@ fn validate_request_body_invariants(request: &wiremock::Request) {
"function_call_output",
"orphan function_call_output with empty call_id should be dropped",
);
let tool_search_outputs = gather_tool_search_output_ids(items);
let custom_tool_call_outputs = gather_output_ids(
items,
"custom_tool_call_output",
@@ -1564,6 +1600,12 @@ fn validate_request_body_invariants(request: &wiremock::Request) {
"custom_tool_call_output without matching call in input: {cid}",
);
}
for cid in &tool_search_outputs {
assert!(
tool_search_calls.contains(cid),
"tool_search_output without matching call in input: {cid}",
);
}
for cid in &function_calls {
assert!(
@@ -1577,4 +1619,10 @@ fn validate_request_body_invariants(request: &wiremock::Request) {
"Custom tool call output is missing for call id: {cid}",
);
}
for cid in &tool_search_calls {
assert!(
tool_search_outputs.contains(cid),
"Tool search output is missing for call id: {cid}",
);
}
}