Add ranked tool discovery to JavaScript code mode (#51209)

## What changed

- Add the default-disabled `code_mode_tool_search` feature to expose `await tools.tool_search({query: "...", limit: 8})` in code mode when the model supports tool search.
- Return BM25-ranked deferred tools as `{tools: [{name, description}]}`, including callable names and TypeScript declarations for use in later code cells.
- Filter results through the current step's nested-tool policy before applying the limit, preserving namespace restrictions, normalized identities, description prefixes, and schema budgets.
- Add ranked-search guidance while retaining `ALL_TOOLS` catalog discovery and description overrides.

## Testing

Add coverage for feature and model-capability gating, namespace restrictions, argument validation, schema budgets, and discovery followed by deferred MCP and dynamic tool calls. Extend the scenario snapshot to cover search, invocation, yielding, and completion through `wait`.

GitOrigin-RevId: 092a29dd5f0773c518f53a001b9cafb917422f13
This commit is contained in:
Alex Zamoshchin
2026-10-06 00:36:25 +00:00
committed by copyberry
parent 5ad6891696
commit 4d15794336
23 changed files with 1125 additions and 248 deletions
+32 -1
View File
@@ -223,7 +223,38 @@ fn code_mode_tool_definitions_for_spec(
}
})
.collect(),
ToolSpec::ToolSearch { .. } | ToolSpec::WebSearch { .. } => Vec::new(),
ToolSpec::ToolSearch { parameters, .. } => {
let name = spec.name().to_string();
vec![CodeModeToolDefinition {
tool_name: ToolName::plain(name.clone()),
name,
description: codex_code_mode::TOOL_SEARCH_GUIDANCE.to_string(),
kind: CodeModeToolKind::Function,
input_schema: serde_json::to_value(parameters).ok(),
input_schema_max_bytes: Some(effective_input_schema_max_bytes(
parameters.mcp_input_schema_max_bytes,
code_mode_input_schema_max_bytes,
)),
output_schema: Some(serde_json::json!({
"type": "object",
"properties": {
"tools": {
"type": "array",
"items": {
"type": "object",
"properties": {
"name": { "type": "string" },
"description": { "type": "string" }
},
"required": ["name", "description"]
}
}
},
"required": ["tools"]
})),
}]
}
ToolSpec::WebSearch { .. } => Vec::new(),
}
}
+44 -11
View File
@@ -238,17 +238,50 @@ declare const tools: { editor__apply_patch(input: string): Promise<unknown>; };
}
#[test]
fn tool_spec_to_code_mode_tool_definition_skips_unsupported_variants() {
fn tool_search_spec_converts_to_a_callable_code_mode_definition() {
let definition = tool_spec_to_code_mode_tool_definition(&ToolSpec::ToolSearch {
execution: "client".to_string(),
description: "Native tool discovery".to_string(),
parameters: JsonSchema::object(
BTreeMap::from([(
"query".to_string(),
JsonSchema::string(/*description*/ None),
)]),
Some(vec!["query".to_string()]),
Some(AdditionalProperties::Boolean(false)),
),
})
.expect("tool search should be callable from Code Mode");
assert_eq!(
tool_spec_to_code_mode_tool_definition(&ToolSpec::ToolSearch {
execution: "sync".to_string(),
description: "Search".to_string(),
parameters: JsonSchema::object(
BTreeMap::new(),
/*required*/ None,
/*additional_properties*/ None
),
}),
None
(
&definition.name,
&definition.tool_name,
&definition.input_schema,
definition.input_schema_max_bytes,
),
(
&"tool_search".to_string(),
&ToolName::plain("tool_search"),
&Some(json!({
"type": "object",
"properties": {
"query": { "type": "string" },
},
"required": ["query"],
"additionalProperties": false,
})),
Some(codex_code_mode::DEFAULT_INPUT_SCHEMA_MAX_BYTES),
),
);
assert!(
definition
.description
.contains("tool_search(args: { query: string; })")
);
assert!(
definition
.description
.contains("tools[result.name](arguments)")
);
assert!(!definition.description.contains("Native tool discovery"));
}
@@ -83,9 +83,14 @@ impl<'a> IndirectNamespacePrefixes<'a> {
return;
}
for tool in tools {
if let Some(prefix) = self.prefix(namespace_name(&tool.tool_name)) {
prepend(prefix, &mut tool.description);
}
self.apply_code_mode_description(&tool.tool_name, &mut tool.description);
}
}
/// Adds guidance to one owned description without copying the tool's schemas.
pub fn apply_code_mode_description(&self, tool_name: &ToolName, description: &mut String) {
if let Some(prefix) = self.prefix(namespace_name(tool_name)) {
prepend(prefix, description);
}
}
@@ -38,6 +38,7 @@ fn plain_tools_join_an_existing_functions_group_only_with_a_prefix() {
codex_code_mode::DEFAULT_EXEC_YIELD_TIME_MS,
/*code_mode_only*/ true,
codex_code_mode::ImageDetailVisibility::Visible,
codex_code_mode::DeferredToolDiscovery::Catalog,
/*messages*/ None,
)
};
+22
View File
@@ -3,6 +3,7 @@ use crate::LoadableToolSpec;
use crate::ResponsesApiNamespace;
use crate::ResponsesApiNamespaceTool;
use crate::ResponsesApiTool;
use crate::ToolName;
use crate::ToolSearchSourceInfo;
use crate::ToolSpec;
use crate::default_namespace_description;
@@ -16,6 +17,27 @@ pub struct ToolSearchEntry {
}
impl ToolSearchEntry {
/// Iterate candidate identities without materializing their tool schemas.
pub fn tool_names(&self) -> impl Iterator<Item = ToolName> + '_ {
let (namespace, name, tools): (&str, Option<&str>, &[ResponsesApiNamespaceTool]) =
match self.spec.as_ref() {
ToolSpec::Function(tool) => (DEFAULT_FUNCTION_NAMESPACE, Some(&tool.name), &[]),
ToolSpec::Freeform(tool) => (DEFAULT_FUNCTION_NAMESPACE, Some(&tool.name), &[]),
ToolSpec::Namespace(namespace) => {
(&namespace.name, None, namespace.tools.as_slice())
}
ToolSpec::ToolSearch { .. } | ToolSpec::WebSearch { .. } => {
unreachable!("search entries contain only loadable tools");
}
};
name.into_iter()
.chain(tools.iter().map(|tool| match tool {
ResponsesApiNamespaceTool::Function(tool) => tool.name.as_str(),
ResponsesApiNamespaceTool::Custom(tool) => tool.name.as_str(),
}))
.map(move |name| ToolName::namespaced(namespace, name))
}
/// Materialize only selected results; output schemas remain shared until discarded.
pub fn to_loadable_spec(&self) -> LoadableToolSpec {
let Some(output) = normalize_search_spec(self.spec.as_ref().clone()) else {
+38 -14
View File
@@ -26,23 +26,46 @@ fn shared_search_specs_preserve_results_and_release_the_source() {
definition: "start: \"patch\"".to_string(),
},
};
for spec in [
ToolSpec::Function(function.clone()),
ToolSpec::Freeform(custom.clone()),
ToolSpec::Namespace(ResponsesApiNamespace {
name: "example".to_string(),
description: String::new(),
tools: vec![
ResponsesApiNamespaceTool::Function(function),
ResponsesApiNamespaceTool::Custom(custom),
for (spec, expected_names) in [
(
ToolSpec::Function(function.clone()),
vec![ToolName::namespaced("functions", "lookup")],
),
(
ToolSpec::Freeform(custom.clone()),
vec![ToolName::namespaced("functions", "patch")],
),
(
ToolSpec::Namespace(ResponsesApiNamespace {
name: "example".to_string(),
description: String::new(),
tools: vec![
ResponsesApiNamespaceTool::Function(function),
ResponsesApiNamespaceTool::Custom(custom),
],
}),
vec![
ToolName::namespaced("example", "lookup"),
ToolName::namespaced("example", "patch"),
],
}),
),
(
ToolSpec::Namespace(ResponsesApiNamespace {
name: "empty".to_string(),
description: String::new(),
tools: Vec::new(),
}),
Vec::new(),
),
] {
let expected =
let normalized =
ToolSearchInfo::from_spec("query".to_string(), spec.clone(), /*source_info*/ None)
.unwrap()
.entry
.to_loadable_spec();
.unwrap();
assert_eq!(
normalized.entry.tool_names().collect::<Vec<_>>(),
expected_names
);
let expected = normalized.entry.to_loadable_spec();
let spec = Arc::new(spec);
let weak = Arc::downgrade(&spec);
let info = ToolSearchInfo::from_shared_spec(
@@ -57,6 +80,7 @@ fn shared_search_specs_preserve_results_and_release_the_source() {
"search retains the original spec rather than a deep copy"
);
assert_eq!(info.entry.to_loadable_spec(), expected);
assert_eq!(info.entry.tool_names().collect::<Vec<_>>(), expected_names);
drop(info);
assert!(
weak.upgrade().is_none(),