Move tool search metadata onto ToolExecutor (#25684)

Deferred tools need to be searchable even when they are not implemented
inside `codex-core`. Extension-provided tools can be registered for
later discovery, but the search metadata path was still owned by
core-specific runtime hooks, which meant the shared `ToolExecutor`
abstraction could not describe how a deferred extension tool should
appear in `tool_search`.

## Changes

- Move `ToolSearchEntry` and `ToolSearchInfo` into `codex-tools` and
re-export them from the shared tools crate.
- Add a default `ToolExecutor::search_info` implementation that derives
loadable tool-search metadata from function and namespace specs.
- Forward search metadata through extension adapters and exposure
overrides while keeping custom search text/source metadata for dynamic,
MCP, and multi-agent tools.
- Remove the old core-local `tool_search_entry` module now that search
metadata lives with the shared executor APIs.

## Testing

- Added `deferred_extension_tools_are_discoverable_with_tool_search`
coverage in `core/src/tools/spec_plan_tests.rs`.
This commit is contained in:
jif-oai
2026-06-02 00:24:41 +02:00
committed by GitHub
Unverified
parent 8ee49a2f74
commit 8d720feb69
18 changed files with 302 additions and 149 deletions
+4
View File
@@ -17,6 +17,7 @@ mod tool_discovery;
mod tool_executor;
mod tool_output;
mod tool_payload;
mod tool_search;
mod tool_spec;
pub use code_mode::augment_tool_spec_for_code_mode;
@@ -97,6 +98,9 @@ pub use tool_executor::ToolExposure;
pub use tool_output::JsonToolOutput;
pub use tool_output::ToolOutput;
pub use tool_payload::ToolPayload;
pub use tool_search::ToolSearchEntry;
pub use tool_search::ToolSearchInfo;
pub use tool_search::default_tool_search_text;
pub use tool_spec::ResponsesApiWebSearchFilters;
pub use tool_spec::ResponsesApiWebSearchUserLocation;
pub use tool_spec::ToolSpec;
+9 -1
View File
@@ -1,6 +1,7 @@
use crate::FunctionCallError;
use crate::ToolName;
use crate::ToolOutput;
use crate::ToolSearchInfo;
use crate::ToolSpec;
/// Controls where a tool is exposed to the model.
@@ -13,7 +14,9 @@ pub enum ToolExposure {
Direct,
/// Register this tool for later discovery, but omit it from the initial
/// model-visible tool list.
/// model-visible tool list. Deferred tools must provide search metadata via
/// [`ToolExecutor::search_info`]. The default implementation derives
/// metadata from function and namespace specs.
Deferred,
/// Include this tool in the initial model-visible tool list only.
@@ -48,6 +51,11 @@ pub trait ToolExecutor<Invocation>: Send + Sync {
ToolExposure::Direct
}
fn search_info(&self) -> Option<ToolSearchInfo> {
let spec = self.spec();
ToolSearchInfo::from_tool_spec(&self.tool_name(), spec, /*source_info*/ None)
}
fn supports_parallel_tool_calls(&self) -> bool {
false
}
+139
View File
@@ -0,0 +1,139 @@
use crate::JsonSchema;
use crate::LoadableToolSpec;
use crate::ResponsesApiNamespaceTool;
use crate::ResponsesApiTool;
use crate::ToolName;
use crate::ToolSearchSourceInfo;
use crate::ToolSpec;
use crate::default_namespace_description;
#[derive(Clone)]
pub struct ToolSearchEntry {
pub search_text: String,
pub output: LoadableToolSpec,
}
#[derive(Clone)]
pub struct ToolSearchInfo {
pub entry: ToolSearchEntry,
pub source_info: Option<ToolSearchSourceInfo>,
}
impl ToolSearchInfo {
pub fn from_tool_spec(
tool_name: &ToolName,
spec: ToolSpec,
source_info: Option<ToolSearchSourceInfo>,
) -> Option<Self> {
let search_text = default_tool_search_text(tool_name, &spec);
Self::from_spec(search_text, spec, source_info)
}
pub fn from_spec(
search_text: String,
spec: ToolSpec,
source_info: Option<ToolSearchSourceInfo>,
) -> Option<Self> {
let output = match spec {
ToolSpec::Function(mut tool) => {
tool.defer_loading = Some(true);
tool.output_schema = None;
LoadableToolSpec::Function(tool)
}
ToolSpec::Namespace(mut namespace) => {
if namespace.description.trim().is_empty() {
namespace.description = default_namespace_description(&namespace.name);
}
for tool in &mut namespace.tools {
let ResponsesApiNamespaceTool::Function(tool) = tool;
tool.defer_loading = Some(true);
tool.output_schema = None;
}
LoadableToolSpec::Namespace(namespace)
}
ToolSpec::ToolSearch { .. }
| ToolSpec::ImageGeneration { .. }
| ToolSpec::WebSearch { .. }
| ToolSpec::Freeform(_) => return None,
};
Some(Self {
entry: ToolSearchEntry {
search_text,
output,
},
source_info,
})
}
}
pub fn default_tool_search_text(tool_name: &ToolName, spec: &ToolSpec) -> String {
let mut parts = Vec::new();
push_search_part(&mut parts, tool_name.to_string());
push_search_part(&mut parts, tool_name.name.replace('_', " "));
if let Some(namespace) = &tool_name.namespace {
push_search_part(&mut parts, namespace.clone());
}
match spec {
ToolSpec::Function(tool) => append_function_search_text(tool, &mut parts),
ToolSpec::Namespace(namespace) => {
push_search_part(&mut parts, namespace.name.clone());
push_search_part(&mut parts, namespace.description.clone());
for tool in &namespace.tools {
let ResponsesApiNamespaceTool::Function(tool) = tool;
append_function_search_text(tool, &mut parts);
}
}
ToolSpec::ToolSearch { description, .. } => {
push_search_part(&mut parts, description.clone());
}
ToolSpec::ImageGeneration { .. } => {
push_search_part(&mut parts, "image generation".to_string());
}
ToolSpec::WebSearch { .. } => {
push_search_part(&mut parts, "web search".to_string());
}
ToolSpec::Freeform(tool) => {
push_search_part(&mut parts, tool.name.clone());
push_search_part(&mut parts, tool.description.clone());
push_search_part(&mut parts, tool.format.syntax.clone());
}
}
parts.join(" ")
}
fn append_function_search_text(tool: &ResponsesApiTool, parts: &mut Vec<String>) {
push_search_part(parts, tool.name.clone());
push_search_part(parts, tool.name.replace('_', " "));
push_search_part(parts, tool.description.clone());
append_schema_search_text(&tool.parameters, parts);
}
fn append_schema_search_text(schema: &JsonSchema, parts: &mut Vec<String>) {
if let Some(description) = &schema.description {
push_search_part(parts, description.clone());
}
if let Some(properties) = &schema.properties {
for (name, schema) in properties {
push_search_part(parts, name.clone());
append_schema_search_text(schema, parts);
}
}
if let Some(items) = &schema.items {
append_schema_search_text(items, parts);
}
if let Some(variants) = &schema.any_of {
for variant in variants {
append_schema_search_text(variant, parts);
}
}
}
fn push_search_part(parts: &mut Vec<String>, part: String) {
let part = part.trim();
if !part.is_empty() {
parts.push(part.to_string());
}
}