feat: search_tool (#10657)

**Why We Did This**
- The goal is to reduce MCP tool context pollution by not exposing the
full MCP tool list up front
- It forces an explicit discovery step (`search_tool_bm25`) so the model
narrows tool scope before making MCP calls, which helps relevance and
lowers prompt/tool clutter.

**What It Changed**
- Added a new experimental feature flag `search_tool` in
`core/src/features.rs:90` and `core/src/features.rs:430`.
- Added config/schema support for that flag in
`core/config.schema.json:214` and `core/config.schema.json:1235`.
- Added BM25 dependency (`bm25`) in `Cargo.toml:129` and
`core/Cargo.toml:23`.
- Added new tool handler `search_tool_bm25` in
`core/src/tools/handlers/search_tool_bm25.rs:18`.
- Registered the handler and tool spec in
`core/src/tools/handlers/mod.rs:11` and `core/src/tools/spec.rs:780` and
`core/src/tools/spec.rs:1344`.
- Extended `ToolsConfig` to carry `search_tool` enablement in
`core/src/tools/spec.rs:32` and `core/src/tools/spec.rs:56`.
- Injected dedicated developer instructions for tool-discovery workflow
in `core/src/codex.rs:483` and `core/src/codex.rs:1976`, using
`core/templates/search_tool/developer_instructions.md:1`.
- Added session state to store one-shot selected MCP tools in
`core/src/state/session.rs:27` and `core/src/state/session.rs:131`.
- Added filtering so when feature is enabled, only selected MCP tools
are exposed on the next request (then consumed) in
`core/src/codex.rs:3800` and `core/src/codex.rs:3843`.
- Added E2E suite coverage for
enablement/instructions/hide-until-search/one-turn-selection in
`core/tests/suite/search_tool.rs:72`,
`core/tests/suite/search_tool.rs:109`,
`core/tests/suite/search_tool.rs:147`, and
`core/tests/suite/search_tool.rs:218`.
- Refactored test helper utilities to support config-driven tool
collection in `core/tests/suite/tools.rs:281`.

**Net Behavioral Effect**
- With `search_tool` **off**: existing MCP behavior (tools exposed
normally).
- With `search_tool` **on**: MCP tools start hidden, model must call
`search_tool_bm25`, and only returned `selected_tools` are available for
the next model call.
This commit is contained in:
Anton Panasenko
2026-02-09 12:53:50 -08:00
committed by GitHub
Unverified
parent 9450cd9ce5
commit becc3a0424
15 changed files with 1238 additions and 1 deletions
+199 -1
View File
@@ -139,6 +139,8 @@ use crate::mcp::effective_mcp_servers;
use crate::mcp::maybe_prompt_and_install_mcp_dependencies;
use crate::mcp::with_codex_apps_mcp;
use crate::mcp_connection_manager::McpConnectionManager;
use crate::mcp_connection_manager::filter_codex_apps_mcp_tools_only;
use crate::mcp_connection_manager::filter_mcp_tools_by_name;
use crate::mentions::build_connector_slug_counts;
use crate::mentions::build_skill_name_counts;
use crate::mentions::collect_explicit_app_paths;
@@ -504,6 +506,9 @@ pub(crate) struct Session {
next_internal_sub_id: AtomicU64,
}
const SEARCH_TOOL_DEVELOPER_INSTRUCTIONS: &str =
include_str!("../templates/search_tool/developer_instructions.md");
/// The context needed for a single turn of the thread.
#[derive(Debug)]
pub(crate) struct TurnContext {
@@ -1257,6 +1262,21 @@ impl Session {
}
}
pub(crate) async fn merge_mcp_tool_selection(&self, tool_names: Vec<String>) -> Vec<String> {
let mut state = self.state.lock().await;
state.merge_mcp_tool_selection(tool_names)
}
pub(crate) async fn get_mcp_tool_selection(&self) -> Option<Vec<String>> {
let state = self.state.lock().await;
state.get_mcp_tool_selection()
}
pub(crate) async fn clear_mcp_tool_selection(&self) {
let mut state = self.state.lock().await;
state.clear_mcp_tool_selection();
}
async fn record_initial_history(&self, conversation_history: InitialHistory) {
let turn_context = self.new_default_turn().await;
match conversation_history {
@@ -2182,6 +2202,11 @@ impl Session {
if let Some(developer_instructions) = turn_context.developer_instructions.as_deref() {
items.push(DeveloperInstructions::new(developer_instructions.to_string()).into());
}
if turn_context.tools_config.search_tool {
items.push(
DeveloperInstructions::new(SEARCH_TOOL_DEVELOPER_INSTRUCTIONS.to_string()).into(),
);
}
// Add developer instructions from collaboration_mode if they exist and are non-empty
let (collaboration_mode, base_instructions) = {
let state = self.state.lock().await;
@@ -4119,6 +4144,7 @@ async fn run_sampling_request(
.list_all_tools()
.or_cancel(&cancellation_token)
.await?;
let connectors_for_tools = if turn_context.config.features.enabled(Feature::Apps) {
let connectors = connectors::accessible_connectors_from_mcp_tools(&mcp_tools);
Some(filter_connectors_for_input(
@@ -4130,9 +4156,25 @@ async fn run_sampling_request(
} else {
None
};
if let Some(connectors) = connectors_for_tools.as_ref() {
if turn_context.config.features.enabled(Feature::SearchTool) {
let mut selected_mcp_tools =
if let Some(selected_tools) = sess.get_mcp_tool_selection().await {
filter_mcp_tools_by_name(mcp_tools.clone(), &selected_tools)
} else {
HashMap::new()
};
if let Some(connectors) = connectors_for_tools.as_ref() {
let apps_mcp_tools = filter_codex_apps_mcp_tools_only(mcp_tools, connectors);
selected_mcp_tools.extend(apps_mcp_tools);
}
mcp_tools = selected_mcp_tools;
} else if let Some(connectors) = connectors_for_tools.as_ref() {
mcp_tools = filter_codex_apps_mcp_tools(mcp_tools, connectors);
}
let router = Arc::new(ToolRouter::from_config(
&turn_context.tools_config,
Some(
@@ -4958,6 +5000,8 @@ pub(super) fn get_last_assistant_message_from_turn(responses: &[ResponseItem]) -
pub(crate) use tests::make_session_and_context;
#[cfg(test)]
pub(crate) use tests::make_session_and_context_with_rx;
#[cfg(test)]
pub(crate) use tests::make_session_configuration_for_tests;
#[cfg(test)]
mod tests {
@@ -4967,6 +5011,7 @@ mod tests {
use crate::config::test_config;
use crate::exec::ExecToolCallOutput;
use crate::function_tool::FunctionCallError;
use crate::mcp_connection_manager::ToolInfo;
use crate::shell::default_user_shell;
use crate::tools::format_exec_output_str;
@@ -5006,6 +5051,8 @@ mod tests {
use codex_protocol::mcp::CallToolResult as McpCallToolResult;
use pretty_assertions::assert_eq;
use rmcp::model::JsonObject;
use rmcp::model::Tool;
use serde::Deserialize;
use serde_json::json;
use std::path::PathBuf;
@@ -5042,6 +5089,30 @@ mod tests {
}
}
fn make_mcp_tool(
server_name: &str,
tool_name: &str,
connector_id: Option<&str>,
connector_name: Option<&str>,
) -> ToolInfo {
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,
icons: None,
meta: None,
},
connector_id: connector_id.map(str::to_string),
connector_name: connector_name.map(str::to_string),
}
}
#[tokio::test]
async fn get_base_instructions_no_user_content() {
let prompt_with_apply_patch_instructions =
@@ -5145,6 +5216,93 @@ mod tests {
assert_eq!(selected, Vec::new());
}
#[test]
fn search_tool_selection_keeps_codex_apps_tools_without_mentions() {
let selected_tool_names = vec![
"mcp__codex_apps__calendar_create_event".to_string(),
"mcp__rmcp__echo".to_string(),
];
let mcp_tools = HashMap::from([
(
"mcp__codex_apps__calendar_create_event".to_string(),
make_mcp_tool(
CODEX_APPS_MCP_SERVER_NAME,
"calendar_create_event",
Some("calendar"),
Some("Calendar"),
),
),
(
"mcp__rmcp__echo".to_string(),
make_mcp_tool("rmcp", "echo", None, None),
),
]);
let mut selected_mcp_tools =
filter_mcp_tools_by_name(mcp_tools.clone(), &selected_tool_names);
let connectors = connectors::accessible_connectors_from_mcp_tools(&mcp_tools);
let connectors = filter_connectors_for_input(
connectors,
&[user_message("run the selected tools")],
&[],
&HashMap::new(),
);
let apps_mcp_tools = filter_codex_apps_mcp_tools_only(mcp_tools, &connectors);
selected_mcp_tools.extend(apps_mcp_tools);
let mut tool_names: Vec<String> = selected_mcp_tools.into_keys().collect();
tool_names.sort();
assert_eq!(
tool_names,
vec![
"mcp__codex_apps__calendar_create_event".to_string(),
"mcp__rmcp__echo".to_string(),
]
);
}
#[test]
fn apps_mentions_add_codex_apps_tools_to_search_selected_set() {
let selected_tool_names = vec!["mcp__rmcp__echo".to_string()];
let mcp_tools = HashMap::from([
(
"mcp__codex_apps__calendar_create_event".to_string(),
make_mcp_tool(
CODEX_APPS_MCP_SERVER_NAME,
"calendar_create_event",
Some("calendar"),
Some("Calendar"),
),
),
(
"mcp__rmcp__echo".to_string(),
make_mcp_tool("rmcp", "echo", None, None),
),
]);
let mut selected_mcp_tools =
filter_mcp_tools_by_name(mcp_tools.clone(), &selected_tool_names);
let connectors = connectors::accessible_connectors_from_mcp_tools(&mcp_tools);
let connectors = filter_connectors_for_input(
connectors,
&[user_message("use $calendar and then echo the response")],
&[],
&HashMap::new(),
);
let apps_mcp_tools = filter_codex_apps_mcp_tools_only(mcp_tools, &connectors);
selected_mcp_tools.extend(apps_mcp_tools);
let mut tool_names: Vec<String> = selected_mcp_tools.into_keys().collect();
tool_names.sort();
assert_eq!(
tool_names,
vec![
"mcp__codex_apps__calendar_create_event".to_string(),
"mcp__rmcp__echo".to_string(),
]
);
}
#[tokio::test]
async fn reconstruct_history_matches_live_compactions() {
let (session, turn_context) = make_session_and_context().await;
@@ -5849,6 +6007,46 @@ mod tests {
)
}
pub(crate) async fn make_session_configuration_for_tests() -> SessionConfiguration {
let codex_home = tempfile::tempdir().expect("create temp dir");
let config = build_test_config(codex_home.path()).await;
let config = Arc::new(config);
let model = ModelsManager::get_model_offline(config.model.as_deref());
let model_info = ModelsManager::construct_model_info_offline(model.as_str(), &config);
let reasoning_effort = config.model_reasoning_effort;
let collaboration_mode = CollaborationMode {
mode: ModeKind::Default,
settings: Settings {
model,
reasoning_effort,
developer_instructions: None,
},
};
SessionConfiguration {
provider: config.model_provider.clone(),
collaboration_mode,
model_reasoning_summary: config.model_reasoning_summary,
developer_instructions: config.developer_instructions.clone(),
user_instructions: config.user_instructions.clone(),
personality: config.personality,
base_instructions: config
.base_instructions
.clone()
.unwrap_or_else(|| model_info.get_model_instructions(config.personality)),
compact_prompt: config.compact_prompt.clone(),
approval_policy: config.approval_policy.clone(),
sandbox_policy: config.sandbox_policy.clone(),
windows_sandbox_level: WindowsSandboxLevel::from_config(&config),
cwd: config.cwd.clone(),
codex_home: config.codex_home.clone(),
thread_name: None,
original_config_do_not_use: Arc::clone(&config),
session_source: SessionSource::Exec,
dynamic_tools: Vec::new(),
}
}
pub(crate) async fn make_session_and_context() -> (Session, TurnContext) {
let (tx_event, _rx_event) = async_channel::unbounded();
let codex_home = tempfile::tempdir().expect("create temp dir");
+8
View File
@@ -87,6 +87,8 @@ pub enum Feature {
/// Allow the model to request web searches that fetch cached content.
/// Takes precedence over `WebSearchRequest`.
WebSearchCached,
/// Allow the model to search MCP tools via BM25 before exposing them.
SearchTool,
/// Use the bubblewrap-based Linux sandbox pipeline.
UseLinuxSandboxBwrap,
/// Allow the model to request approval and propose exec rules.
@@ -432,6 +434,12 @@ pub const FEATURES: &[FeatureSpec] = &[
stage: Stage::Deprecated,
default_enabled: false,
},
FeatureSpec {
id: Feature::SearchTool,
key: "search_tool",
stage: Stage::UnderDevelopment,
default_enabled: false,
},
// Experimental program. Rendered in the `/experimental` menu for users.
FeatureSpec {
id: Feature::RuntimeMetrics,
@@ -843,6 +843,37 @@ fn filter_tools(tools: Vec<ToolInfo>, filter: ToolFilter) -> Vec<ToolInfo> {
.collect()
}
pub(crate) fn filter_codex_apps_mcp_tools_only(
mut mcp_tools: HashMap<String, ToolInfo>,
connectors: &[crate::connectors::AppInfo],
) -> HashMap<String, ToolInfo> {
let allowed: HashSet<&str> = connectors
.iter()
.map(|connector| connector.id.as_str())
.collect();
mcp_tools.retain(|_, tool| {
if tool.server_name != CODEX_APPS_MCP_SERVER_NAME {
return false;
}
let Some(connector_id) = tool.connector_id.as_deref() else {
return false;
};
allowed.contains(connector_id)
});
mcp_tools
}
pub(crate) fn filter_mcp_tools_by_name(
mut mcp_tools: HashMap<String, ToolInfo>,
selected_tools: &[String],
) -> HashMap<String, ToolInfo> {
let allowed: HashSet<&str> = selected_tools.iter().map(String::as_str).collect();
mcp_tools.retain(|name, _| allowed.contains(name.as_str()));
mcp_tools
}
fn normalize_codex_apps_tool_title(
server_name: &str,
connector_name: Option<&str>,
+104
View File
@@ -29,6 +29,7 @@ pub(crate) struct SessionState {
pub(crate) pending_resume_previous_model: Option<String>,
/// Startup regular task pre-created during session initialization.
pub(crate) startup_regular_task: Option<RegularTask>,
pub(crate) active_mcp_tool_selection: Option<Vec<String>>,
}
impl SessionState {
@@ -45,6 +46,7 @@ impl SessionState {
initial_context_seeded: false,
pending_resume_previous_model: None,
startup_regular_task: None,
active_mcp_tool_selection: None,
}
}
@@ -140,6 +142,32 @@ impl SessionState {
pub(crate) fn take_startup_regular_task(&mut self) -> Option<RegularTask> {
self.startup_regular_task.take()
}
pub(crate) fn merge_mcp_tool_selection(&mut self, tool_names: Vec<String>) -> Vec<String> {
if tool_names.is_empty() {
return self.active_mcp_tool_selection.clone().unwrap_or_default();
}
let mut merged = self.active_mcp_tool_selection.take().unwrap_or_default();
let mut seen: HashSet<String> = merged.iter().cloned().collect();
for tool_name in tool_names {
if seen.insert(tool_name.clone()) {
merged.push(tool_name);
}
}
self.active_mcp_tool_selection = Some(merged.clone());
merged
}
pub(crate) fn get_mcp_tool_selection(&self) -> Option<Vec<String>> {
self.active_mcp_tool_selection.clone()
}
pub(crate) fn clear_mcp_tool_selection(&mut self) {
self.active_mcp_tool_selection = None;
}
}
// Sometimes new snapshots don't include credits or plan information.
@@ -155,3 +183,79 @@ fn merge_rate_limit_fields(
}
snapshot
}
#[cfg(test)]
mod tests {
use super::*;
use crate::codex::make_session_configuration_for_tests;
use pretty_assertions::assert_eq;
#[tokio::test]
async fn merge_mcp_tool_selection_deduplicates_and_preserves_order() {
let session_configuration = make_session_configuration_for_tests().await;
let mut state = SessionState::new(session_configuration);
let merged = state.merge_mcp_tool_selection(vec![
"mcp__rmcp__echo".to_string(),
"mcp__rmcp__image".to_string(),
"mcp__rmcp__echo".to_string(),
]);
assert_eq!(
merged,
vec![
"mcp__rmcp__echo".to_string(),
"mcp__rmcp__image".to_string(),
]
);
let merged = state.merge_mcp_tool_selection(vec![
"mcp__rmcp__image".to_string(),
"mcp__rmcp__search".to_string(),
]);
assert_eq!(
merged,
vec![
"mcp__rmcp__echo".to_string(),
"mcp__rmcp__image".to_string(),
"mcp__rmcp__search".to_string(),
]
);
}
#[tokio::test]
async fn merge_mcp_tool_selection_empty_input_is_noop() {
let session_configuration = make_session_configuration_for_tests().await;
let mut state = SessionState::new(session_configuration);
state.merge_mcp_tool_selection(vec![
"mcp__rmcp__echo".to_string(),
"mcp__rmcp__image".to_string(),
]);
let merged = state.merge_mcp_tool_selection(Vec::new());
assert_eq!(
merged,
vec![
"mcp__rmcp__echo".to_string(),
"mcp__rmcp__image".to_string(),
]
);
assert_eq!(
state.get_mcp_tool_selection(),
Some(vec![
"mcp__rmcp__echo".to_string(),
"mcp__rmcp__image".to_string(),
])
);
}
#[tokio::test]
async fn clear_mcp_tool_selection_removes_selection() {
let session_configuration = make_session_configuration_for_tests().await;
let mut state = SessionState::new(session_configuration);
state.merge_mcp_tool_selection(vec!["mcp__rmcp__echo".to_string()]);
state.clear_mcp_tool_selection();
assert_eq!(state.get_mcp_tool_selection(), None);
}
}
+1
View File
@@ -120,6 +120,7 @@ impl Session {
task: T,
) {
self.abort_all_tasks(TurnAbortReason::Replaced).await;
self.clear_mcp_tool_selection().await;
self.seed_initial_context_if_needed(turn_context.as_ref())
.await;
+3
View File
@@ -8,6 +8,7 @@ mod mcp_resource;
mod plan;
mod read_file;
mod request_user_input;
mod search_tool_bm25;
mod shell;
mod test_sync;
mod unified_exec;
@@ -28,6 +29,8 @@ pub use plan::PlanHandler;
pub use read_file::ReadFileHandler;
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 use search_tool_bm25::SearchToolBm25Handler;
pub use shell::ShellCommandHandler;
pub use shell::ShellHandler;
pub use test_sync::TestSyncHandler;
@@ -0,0 +1,217 @@
use async_trait::async_trait;
use bm25::Document;
use bm25::Language;
use bm25::SearchEngineBuilder;
use codex_protocol::models::FunctionCallOutputBody;
use serde::Deserialize;
use serde_json::json;
use crate::function_tool::FunctionCallError;
use crate::mcp_connection_manager::ToolInfo;
use crate::tools::context::ToolInvocation;
use crate::tools::context::ToolOutput;
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 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_id: 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_id: info.connector_id,
connector_name: info.connector_name,
input_keys,
search_text,
}
}
}
#[async_trait]
impl ToolHandler for SearchToolBm25Handler {
fn kind(&self) -> ToolKind {
ToolKind::Function
}
async fn handle(&self, invocation: ToolInvocation) -> Result<ToolOutput, FunctionCallError> {
let ToolInvocation {
payload, session, ..
} = invocation;
let arguments = match payload {
ToolPayload::Function { arguments } => arguments,
_ => {
return Err(FunctionCallError::Fatal(
"search_tool_bm25 handler received unsupported payload".to_string(),
));
}
};
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 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(ToolOutput::Function {
body: FunctionCallOutputBody::Text(content),
success: 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_id": entry.connector_id.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(ToolOutput::Function {
body: FunctionCallOutputBody::Text(content),
success: Some(true),
})
}
}
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 let Some(connector_id) = info.connector_id.as_deref()
&& !connector_id.trim().is_empty()
{
parts.push(connector_id.to_string());
}
if !input_keys.is_empty() {
parts.extend(input_keys.iter().cloned());
}
parts.join(" ")
}
+41
View File
@@ -4,6 +4,7 @@ use crate::client_common::tools::ToolSpec;
use crate::features::Feature;
use crate::features::Features;
use crate::tools::handlers::PLAN_TOOL;
use crate::tools::handlers::SEARCH_TOOL_BM25_DEFAULT_LIMIT;
use crate::tools::handlers::apply_patch::create_apply_patch_freeform_tool;
use crate::tools::handlers::apply_patch::create_apply_patch_json_tool;
use crate::tools::handlers::collab::DEFAULT_WAIT_TIMEOUT_MS;
@@ -31,6 +32,7 @@ pub(crate) struct ToolsConfig {
pub apply_patch_tool_type: Option<ApplyPatchToolType>,
pub web_search_mode: Option<WebSearchMode>,
pub supports_image_input: bool,
pub search_tool: bool,
pub collab_tools: bool,
pub collaboration_modes_tools: bool,
pub request_rule_enabled: bool,
@@ -54,6 +56,7 @@ impl ToolsConfig {
let include_collab_tools = features.enabled(Feature::Collab);
let include_collaboration_modes_tools = features.enabled(Feature::CollaborationModes);
let request_rule_enabled = features.enabled(Feature::RequestRule);
let include_search_tool = features.enabled(Feature::SearchTool);
let shell_type = if !features.enabled(Feature::ShellTool) {
ConfigShellToolType::Disabled
@@ -85,6 +88,7 @@ impl ToolsConfig {
apply_patch_tool_type,
web_search_mode: *web_search_mode,
supports_image_input: model_info.input_modalities.contains(&InputModality::Image),
search_tool: include_search_tool,
collab_tools: include_collab_tools,
collaboration_modes_tools: include_collaboration_modes_tools,
request_rule_enabled,
@@ -800,6 +804,36 @@ fn create_grep_files_tool() -> ToolSpec {
})
}
fn create_search_tool_bm25_tool() -> ToolSpec {
let properties = BTreeMap::from([
(
"query".to_string(),
JsonSchema::String {
description: Some("Search query for MCP tools.".to_string()),
},
),
(
"limit".to_string(),
JsonSchema::Number {
description: Some(format!(
"Maximum number of tools to return (defaults to {SEARCH_TOOL_BM25_DEFAULT_LIMIT})."
)),
},
),
]);
ToolSpec::Function(ResponsesApiTool {
name: "search_tool_bm25".to_string(),
description: "Searches MCP tool metadata with BM25 and exposes matching tools for the next model call.".to_string(),
strict: false,
parameters: JsonSchema::Object {
properties,
required: Some(vec!["query".to_string()]),
additional_properties: Some(false.into()),
},
})
}
fn create_read_file_tool() -> ToolSpec {
let indentation_properties = BTreeMap::from([
(
@@ -1261,6 +1295,7 @@ pub(crate) fn build_specs(
use crate::tools::handlers::PlanHandler;
use crate::tools::handlers::ReadFileHandler;
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;
@@ -1280,6 +1315,7 @@ pub(crate) fn build_specs(
let mcp_resource_handler = Arc::new(McpResourceHandler);
let shell_command_handler = Arc::new(ShellCommandHandler);
let request_user_input_handler = Arc::new(RequestUserInputHandler);
let search_tool_handler = Arc::new(SearchToolBm25Handler);
match &config.shell_type {
ConfigShellToolType::Default => {
@@ -1334,6 +1370,11 @@ pub(crate) fn build_specs(
builder.register_handler("request_user_input", request_user_input_handler);
}
if config.search_tool {
builder.push_spec_with_parallel_support(create_search_tool_bm25_tool(), true);
builder.register_handler("search_tool_bm25", search_tool_handler);
}
if let Some(apply_patch_tool_type) = &config.apply_patch_tool_type {
match apply_patch_tool_type {
ApplyPatchToolType::Freeform => {