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
+9 -1
View File
@@ -376,6 +376,8 @@ impl ContextManager {
| ResponseItem::Reasoning { .. }
| ResponseItem::LocalShellCall { .. }
| ResponseItem::FunctionCall { .. }
| ResponseItem::ToolSearchCall { .. }
| ResponseItem::ToolSearchOutput { .. }
| ResponseItem::WebSearchCall { .. }
| ResponseItem::ImageGenerationCall { .. }
| ResponseItem::CustomToolCall { .. }
@@ -413,6 +415,8 @@ fn is_api_message(message: &ResponseItem) -> bool {
ResponseItem::Message { role, .. } => role.as_str() != "system",
ResponseItem::FunctionCallOutput { .. }
| ResponseItem::FunctionCall { .. }
| ResponseItem::ToolSearchCall { .. }
| ResponseItem::ToolSearchOutput { .. }
| ResponseItem::CustomToolCall { .. }
| ResponseItem::CustomToolCallOutput { .. }
| ResponseItem::LocalShellCall { .. }
@@ -605,12 +609,14 @@ fn is_model_generated_item(item: &ResponseItem) -> bool {
ResponseItem::Message { role, .. } => role == "assistant",
ResponseItem::Reasoning { .. }
| ResponseItem::FunctionCall { .. }
| ResponseItem::ToolSearchCall { .. }
| ResponseItem::WebSearchCall { .. }
| ResponseItem::ImageGenerationCall { .. }
| ResponseItem::CustomToolCall { .. }
| ResponseItem::LocalShellCall { .. }
| ResponseItem::Compaction { .. } => true,
ResponseItem::FunctionCallOutput { .. }
| ResponseItem::ToolSearchOutput { .. }
| ResponseItem::CustomToolCallOutput { .. }
| ResponseItem::GhostSnapshot { .. }
| ResponseItem::Other => false,
@@ -620,7 +626,9 @@ fn is_model_generated_item(item: &ResponseItem) -> bool {
pub(crate) fn is_codex_generated_item(item: &ResponseItem) -> bool {
matches!(
item,
ResponseItem::FunctionCallOutput { .. } | ResponseItem::CustomToolCallOutput { .. }
ResponseItem::FunctionCallOutput { .. }
| ResponseItem::ToolSearchOutput { .. }
| ResponseItem::CustomToolCallOutput { .. }
) || matches!(item, ResponseItem::Message { role, .. } if role == "developer")
}
@@ -271,6 +271,7 @@ fn for_prompt_strips_images_when_model_does_not_support_images() {
ResponseItem::FunctionCall {
id: None,
name: "view_image".to_string(),
namespace: None,
arguments: "{}".to_string(),
call_id: "call-1".to_string(),
},
@@ -332,6 +333,7 @@ fn for_prompt_strips_images_when_model_does_not_support_images() {
ResponseItem::FunctionCall {
id: None,
name: "view_image".to_string(),
namespace: None,
arguments: "{}".to_string(),
call_id: "call-1".to_string(),
},
@@ -547,6 +549,7 @@ fn remove_first_item_removes_matching_output_for_function_call() {
ResponseItem::FunctionCall {
id: None,
name: "do_it".to_string(),
namespace: None,
arguments: "{}".to_string(),
call_id: "call-1".to_string(),
},
@@ -570,6 +573,7 @@ fn remove_first_item_removes_matching_call_for_output() {
ResponseItem::FunctionCall {
id: None,
name: "do_it".to_string(),
namespace: None,
arguments: "{}".to_string(),
call_id: "call-2".to_string(),
},
@@ -586,6 +590,7 @@ fn remove_last_item_removes_matching_call_for_output() {
ResponseItem::FunctionCall {
id: None,
name: "do_it".to_string(),
namespace: None,
arguments: "{}".to_string(),
call_id: "call-delete-last".to_string(),
},
@@ -1059,6 +1064,7 @@ fn normalize_adds_missing_output_for_function_call() {
let items = vec![ResponseItem::FunctionCall {
id: None,
name: "do_it".to_string(),
namespace: None,
arguments: "{}".to_string(),
call_id: "call-x".to_string(),
}];
@@ -1072,6 +1078,7 @@ fn normalize_adds_missing_output_for_function_call() {
ResponseItem::FunctionCall {
id: None,
name: "do_it".to_string(),
namespace: None,
arguments: "{}".to_string(),
call_id: "call-x".to_string(),
},
@@ -1193,6 +1200,7 @@ fn normalize_mixed_inserts_and_removals() {
ResponseItem::FunctionCall {
id: None,
name: "f1".to_string(),
namespace: None,
arguments: "{}".to_string(),
call_id: "c1".to_string(),
},
@@ -1233,6 +1241,7 @@ fn normalize_mixed_inserts_and_removals() {
ResponseItem::FunctionCall {
id: None,
name: "f1".to_string(),
namespace: None,
arguments: "{}".to_string(),
call_id: "c1".to_string(),
},
@@ -1276,6 +1285,7 @@ fn normalize_adds_missing_output_for_function_call_inserts_output() {
let items = vec![ResponseItem::FunctionCall {
id: None,
name: "do_it".to_string(),
namespace: None,
arguments: "{}".to_string(),
call_id: "call-x".to_string(),
}];
@@ -1287,6 +1297,7 @@ fn normalize_adds_missing_output_for_function_call_inserts_output() {
ResponseItem::FunctionCall {
id: None,
name: "do_it".to_string(),
namespace: None,
arguments: "{}".to_string(),
call_id: "call-x".to_string(),
},
@@ -1298,6 +1309,39 @@ fn normalize_adds_missing_output_for_function_call_inserts_output() {
);
}
#[test]
fn normalize_adds_missing_output_for_tool_search_call() {
let items = vec![ResponseItem::ToolSearchCall {
id: None,
call_id: Some("search-call-x".to_string()),
status: Some("completed".to_string()),
execution: "client".to_string(),
arguments: "{}".into(),
}];
let mut h = create_history_with_items(items);
h.normalize_history(&default_input_modalities());
assert_eq!(
h.raw_items(),
vec![
ResponseItem::ToolSearchCall {
id: None,
call_id: Some("search-call-x".to_string()),
status: Some("completed".to_string()),
execution: "client".to_string(),
arguments: "{}".into(),
},
ResponseItem::ToolSearchOutput {
call_id: Some("search-call-x".to_string()),
status: "completed".to_string(),
execution: "client".to_string(),
tools: Vec::new(),
},
]
);
}
#[cfg(debug_assertions)]
#[test]
#[should_panic]
@@ -1357,6 +1401,59 @@ fn normalize_removes_orphan_custom_tool_call_output_panics_in_debug() {
h.normalize_history(&default_input_modalities());
}
#[cfg(not(debug_assertions))]
#[test]
fn normalize_removes_orphan_client_tool_search_output() {
let items = vec![ResponseItem::ToolSearchOutput {
call_id: Some("orphan-search".to_string()),
status: "completed".to_string(),
execution: "client".to_string(),
tools: Vec::new(),
}];
let mut h = create_history_with_items(items);
h.normalize_history(&default_input_modalities());
assert_eq!(h.raw_items(), vec![]);
}
#[cfg(debug_assertions)]
#[test]
#[should_panic]
fn normalize_removes_orphan_client_tool_search_output_panics_in_debug() {
let items = vec![ResponseItem::ToolSearchOutput {
call_id: Some("orphan-search".to_string()),
status: "completed".to_string(),
execution: "client".to_string(),
tools: Vec::new(),
}];
let mut h = create_history_with_items(items);
h.normalize_history(&default_input_modalities());
}
#[test]
fn normalize_keeps_server_tool_search_output_without_matching_call() {
let items = vec![ResponseItem::ToolSearchOutput {
call_id: Some("server-search".to_string()),
status: "completed".to_string(),
execution: "server".to_string(),
tools: Vec::new(),
}];
let mut h = create_history_with_items(items);
h.normalize_history(&default_input_modalities());
assert_eq!(
h.raw_items(),
vec![ResponseItem::ToolSearchOutput {
call_id: Some("server-search".to_string()),
status: "completed".to_string(),
execution: "server".to_string(),
tools: Vec::new(),
}]
);
}
#[cfg(debug_assertions)]
#[test]
#[should_panic]
@@ -1365,6 +1462,7 @@ fn normalize_mixed_inserts_and_removals_panics_in_debug() {
ResponseItem::FunctionCall {
id: None,
name: "f1".to_string(),
namespace: None,
arguments: "{}".to_string(),
call_id: "c1".to_string(),
},
@@ -38,6 +38,31 @@ pub(crate) fn ensure_call_outputs_present(items: &mut Vec<ResponseItem>) {
));
}
}
ResponseItem::ToolSearchCall {
call_id: Some(call_id),
..
} => {
let has_output = items.iter().any(|i| match i {
ResponseItem::ToolSearchOutput {
call_id: Some(existing),
..
} => existing == call_id,
_ => false,
});
if !has_output {
info!("Tool search output is missing for call id: {call_id}");
missing_outputs_to_insert.push((
idx,
ResponseItem::ToolSearchOutput {
call_id: Some(call_id.clone()),
status: "completed".to_string(),
execution: "client".to_string(),
tools: Vec::new(),
},
));
}
}
ResponseItem::CustomToolCall { call_id, .. } => {
let has_output = items.iter().any(|i| match i {
ResponseItem::CustomToolCallOutput {
@@ -102,6 +127,17 @@ pub(crate) fn remove_orphan_outputs(items: &mut Vec<ResponseItem>) {
})
.collect();
let tool_search_call_ids: HashSet<String> = items
.iter()
.filter_map(|i| match i {
ResponseItem::ToolSearchCall {
call_id: Some(call_id),
..
} => Some(call_id.clone()),
_ => None,
})
.collect();
let local_shell_call_ids: HashSet<String> = items
.iter()
.filter_map(|i| match i {
@@ -141,6 +177,18 @@ pub(crate) fn remove_orphan_outputs(items: &mut Vec<ResponseItem>) {
}
has_match
}
ResponseItem::ToolSearchOutput { execution, .. } if execution == "server" => true,
ResponseItem::ToolSearchOutput {
call_id: Some(call_id),
..
} => {
let has_match = tool_search_call_ids.contains(call_id);
if !has_match {
error_or_panic(format!("Orphan tool search output for call id: {call_id}"));
}
has_match
}
ResponseItem::ToolSearchOutput { call_id: None, .. } => true,
_ => true,
});
}
@@ -168,6 +216,37 @@ pub(crate) fn remove_corresponding_for(items: &mut Vec<ResponseItem>, item: &Res
items.remove(pos);
}
}
ResponseItem::ToolSearchCall {
call_id: Some(call_id),
..
} => {
remove_first_matching(items, |i| {
matches!(
i,
ResponseItem::ToolSearchOutput {
call_id: Some(existing),
..
} if existing == call_id
)
});
}
ResponseItem::ToolSearchOutput {
call_id: Some(call_id),
..
} => {
remove_first_matching(
items,
|i| {
matches!(
i,
ResponseItem::ToolSearchCall {
call_id: Some(existing),
..
} if existing == call_id
)
},
);
}
ResponseItem::CustomToolCall { call_id, .. } => {
remove_first_matching(items, |i| {
matches!(