Compare commits

...

2 Commits

Author SHA1 Message Date
jif-oai
f310fac31c Fix extension tool exposure fallback 2026-05-18 11:57:01 +02:00
jif-oai
a45caa4cc5 Keep extension tools directly visible 2026-05-15 12:02:05 +02:00
2 changed files with 164 additions and 4 deletions

View File

@@ -15,6 +15,7 @@ use crate::tools::registry::CoreToolRuntime;
use crate::tools::registry::PostToolUsePayload;
use crate::tools::registry::PreToolUsePayload;
use crate::tools::registry::ToolExecutor;
use crate::tools::registry::ToolExposure;
pub(crate) struct ExtensionToolAdapter(Arc<dyn codex_tools::ToolExecutor<ExtensionToolCall>>);
@@ -38,11 +39,35 @@ impl ToolExecutor<ToolInvocation> for ExtensionToolAdapter {
}
fn spec(&self) -> Option<ToolSpec> {
self.0.spec()
let mut spec = self.0.spec()?;
if self.0.exposure() == ToolExposure::Deferred {
match &mut spec {
ToolSpec::Function(tool) => {
tool.defer_loading = None;
}
ToolSpec::Namespace(namespace) => {
for tool in &mut namespace.tools {
let codex_tools::ResponsesApiNamespaceTool::Function(tool) = tool;
tool.defer_loading = None;
}
}
ToolSpec::ToolSearch { .. }
| ToolSpec::ImageGeneration { .. }
| ToolSpec::WebSearch { .. }
| ToolSpec::Freeform(_) => {}
}
}
Some(spec)
}
fn exposure(&self) -> crate::tools::registry::ToolExposure {
self.0.exposure()
fn exposure(&self) -> ToolExposure {
// Extension tools do not yet provide search metadata, so keep them in
// the model-visible list even if the shared executor requests deferral.
match self.0.exposure() {
ToolExposure::Direct => ToolExposure::Direct,
ToolExposure::Deferred => ToolExposure::Direct,
ToolExposure::DirectModelOnly => ToolExposure::DirectModelOnly,
}
}
fn supports_parallel_tool_calls(&self) -> bool {

View File

@@ -76,10 +76,47 @@ const MAX_WAIT_TIMEOUT_MS: i64 = 3_600_000;
fn extension_tool_executor(
name: &str,
description: &str,
) -> Arc<dyn ToolExecutor<ExtensionToolCall>> {
extension_tool_executor_with_options(
name,
description,
ExtensionToolExecutorOptions {
exposure: codex_tools::ToolExposure::Direct,
defer_loading: None,
},
)
}
fn extension_tool_executor_with_exposure(
name: &str,
description: &str,
exposure: codex_tools::ToolExposure,
) -> Arc<dyn ToolExecutor<ExtensionToolCall>> {
extension_tool_executor_with_options(
name,
description,
ExtensionToolExecutorOptions {
exposure,
defer_loading: None,
},
)
}
struct ExtensionToolExecutorOptions {
exposure: codex_tools::ToolExposure,
defer_loading: Option<bool>,
}
fn extension_tool_executor_with_options(
name: &str,
description: &str,
options: ExtensionToolExecutorOptions,
) -> Arc<dyn ToolExecutor<ExtensionToolCall>> {
struct SpecOnlyExtensionExecutor {
name: String,
description: String,
exposure: codex_tools::ToolExposure,
defer_loading: Option<bool>,
}
#[async_trait::async_trait]
@@ -102,10 +139,14 @@ fn extension_tool_executor(
Some(false.into()),
),
output_schema: None,
defer_loading: None,
defer_loading: self.defer_loading,
}))
}
fn exposure(&self) -> codex_tools::ToolExposure {
self.exposure
}
async fn handle(
&self,
_call: ExtensionToolCall,
@@ -117,6 +158,8 @@ fn extension_tool_executor(
Arc::new(SpecOnlyExtensionExecutor {
name: name.to_string(),
description: description.to_string(),
exposure: options.exposure,
defer_loading: options.defer_loading,
})
}
@@ -160,6 +203,98 @@ fn extension_tools_do_not_replace_builtin_tools() {
);
}
#[test]
fn deferred_extension_tools_remain_model_visible() {
let model_info = search_capable_model_info();
let mut features = Features::with_defaults();
features.enable(Feature::ToolSearch);
let available_models = Vec::new();
let tools_config = ToolsConfig::new(&ToolsConfigParams {
model_info: &model_info,
available_models: &available_models,
features: &features,
image_generation_tool_auth_allowed: true,
web_search_mode: Some(WebSearchMode::Cached),
session_source: SessionSource::Cli,
permission_profile: &PermissionProfile::Disabled,
windows_sandbox_level: WindowsSandboxLevel::Disabled,
});
let extension_tool_executors = vec![
extension_tool_executor_with_exposure(
"extension_echo",
"Echoes arguments through an extension tool.",
codex_tools::ToolExposure::Deferred,
),
extension_tool_executor_with_options(
"extension_lazy",
"Lazy extension tool.",
ExtensionToolExecutorOptions {
exposure: codex_tools::ToolExposure::Deferred,
defer_loading: Some(true),
},
),
extension_tool_executor_with_exposure(
"extension_model_only",
"Model-only extension tool.",
codex_tools::ToolExposure::DirectModelOnly,
),
];
let (tools, registry) = build_specs_with_inputs_for_test(
&tools_config,
/*mcp_tools*/ None,
/*deferred_mcp_tools*/ None,
/*discoverable_tools*/ None,
&extension_tool_executors,
&[],
);
assert_eq!(
find_tool(&tools, "extension_echo").clone(),
ToolSpec::Function(ResponsesApiTool {
name: "extension_echo".to_string(),
description: "Echoes arguments through an extension tool.".to_string(),
strict: true,
parameters: JsonSchema::object(
BTreeMap::from([(
"message".to_string(),
JsonSchema::string(/*description*/ None),
)]),
Some(vec!["message".to_string()]),
Some(false.into()),
),
output_schema: None,
defer_loading: None,
})
);
assert_eq!(
find_tool(&tools, "extension_lazy").clone(),
ToolSpec::Function(ResponsesApiTool {
name: "extension_lazy".to_string(),
description: "Lazy extension tool.".to_string(),
strict: true,
parameters: JsonSchema::object(
BTreeMap::from([(
"message".to_string(),
JsonSchema::string(/*description*/ None),
)]),
Some(vec!["message".to_string()]),
Some(false.into()),
),
output_schema: None,
defer_loading: None,
})
);
assert_eq!(
registry.tool_exposure(&ToolName::plain("extension_model_only")),
Some(codex_tools::ToolExposure::DirectModelOnly)
);
assert!(registry.has_tool(&ToolName::plain("extension_echo")));
assert!(registry.has_tool(&ToolName::plain("extension_lazy")));
assert!(registry.has_tool(&ToolName::plain("extension_model_only")));
assert_lacks_tool_name(&tools, TOOL_SEARCH_TOOL_NAME);
}
#[test]
fn test_full_toolset_specs_for_gpt5_codex_unified_exec_web_search() {
let model_info = model_info();