Canonicalize default tool namespaces

This commit is contained in:
Rohit Arunachalam
2026-06-22 00:03:59 -07:00
parent 5c5473789e
commit 46a03873c1
27 changed files with 352 additions and 78 deletions
+6 -1
View File
@@ -84,6 +84,7 @@ use codex_rollout_trace::CompactionTraceContext;
use codex_rollout_trace::InferenceTraceAttempt;
use codex_rollout_trace::InferenceTraceContext;
use codex_tools::create_tools_json_for_responses_api;
use codex_tools::create_tools_json_for_responses_lite;
use codex_utils_output_truncation::approx_tokens_from_byte_count;
use eventsource_stream::Event;
use eventsource_stream::EventStreamError;
@@ -785,7 +786,11 @@ impl ModelClient {
if !self.state.provider.info().is_openai() {
input.iter_mut().for_each(ResponseItem::clear_metadata);
}
let tools = create_tools_json_for_responses_api(&prompt.tools)?;
let tools = if model_info.use_responses_lite {
create_tools_json_for_responses_lite(&prompt.tools)?
} else {
create_tools_json_for_responses_api(&prompt.tools)?
};
let (instructions, tools) = if model_info.use_responses_lite {
let additional_tools = ResponseItem::AdditionalTools {
id: None,
+8 -7
View File
@@ -31,16 +31,17 @@ fn shell_script_for_invocation(invocation: &ToolInvocation) -> Option<String> {
return None;
};
match (
invocation.tool_name.namespace.as_deref(),
invocation.tool_name.name.as_str(),
) {
(None, "shell_command") => serde_json::from_str::<ShellCommandToolCallParams>(arguments)
if !invocation.tool_name.is_default_namespace() {
return None;
}
match invocation.tool_name.name.as_str() {
"shell_command" => serde_json::from_str::<ShellCommandToolCallParams>(arguments)
.ok()
.map(|params| params.command),
(None, "exec_command") => serde_json::from_str::<ExecCommandArgs>(arguments)
"exec_command" => serde_json::from_str::<ExecCommandArgs>(arguments)
.ok()
.map(|params| params.cmd),
(Some(_), _) | (None, _) => None,
_ => None,
}
}
+1 -1
View File
@@ -2113,7 +2113,7 @@ async fn try_run_sampling_request(
}
ResponseEvent::OutputItemAdded(item) => {
if let ResponseItem::CustomToolCall { call_id, name, .. } = &item {
let tool_name = ToolName::plain(name.as_str());
let tool_name = ToolName::function(name.as_str());
active_tool_argument_diff_consumer = tool_runtime
.create_diff_consumer(&tool_name)
.map(|consumer| (call_id.clone(), consumer));
+2 -2
View File
@@ -47,9 +47,9 @@ pub(crate) const PUBLIC_TOOL_NAME: &str = codex_code_mode::PUBLIC_TOOL_NAME;
pub(crate) const WAIT_TOOL_NAME: &str = codex_code_mode::WAIT_TOOL_NAME;
pub(crate) const DEFAULT_WAIT_YIELD_TIME_MS: u64 = codex_code_mode::DEFAULT_WAIT_YIELD_TIME_MS;
/// Returns true for the un-namespaced code-mode `exec` tool.
/// Returns true for the code-mode `exec` tool in the default namespace.
pub(crate) fn is_exec_tool_name(tool_name: &ToolName) -> bool {
tool_name.namespace.is_none() && tool_name.name == PUBLIC_TOOL_NAME
tool_name.is_default_namespace() && tool_name.name == PUBLIC_TOOL_NAME
}
#[derive(Clone)]
@@ -73,7 +73,8 @@ impl CodeModeWaitHandler {
match payload {
ToolPayload::Function { arguments }
if tool_name.namespace.is_none() && tool_name.name.as_str() == WAIT_TOOL_NAME =>
if tool_name.is_default_namespace()
&& tool_name.name.as_str() == WAIT_TOOL_NAME =>
{
let args: ExecWaitArgs = parse_arguments(&arguments)?;
let exec = ExecContext { session, turn };
+3
View File
@@ -37,6 +37,9 @@ pub(crate) const TELEMETRY_PREVIEW_TRUNCATION_NOTICE: &str =
/// names still require a single flattened string. Keep comparisons and sorting
/// on `ToolName` itself; use this only when crossing those boundaries.
pub(crate) fn flat_tool_name(tool_name: &ToolName) -> Cow<'_, str> {
if tool_name.is_default_namespace() {
return Cow::Borrowed(tool_name.name.as_str());
}
match tool_name.namespace.as_deref() {
Some(namespace) => {
let mut name = String::with_capacity(namespace.len() + tool_name.name.len());
+1 -1
View File
@@ -222,7 +222,7 @@ impl ToolCallRuntime {
}
fn abort_message(call: &ToolCall, secs: f32) -> String {
if call.tool_name.namespace.is_none()
if call.tool_name.is_default_namespace()
&& matches!(
call.tool_name.name.as_str(),
"shell_command" | "unified_exec"
+11 -7
View File
@@ -325,6 +325,10 @@ pub struct ToolRegistry {
impl ToolRegistry {
fn new(tools: HashMap<ToolName, Arc<dyn CoreToolRuntime>>) -> Self {
let tools = tools
.into_iter()
.map(|(name, tool)| (name.with_default_namespace(), tool))
.collect();
Self { tools }
}
@@ -332,7 +336,7 @@ impl ToolRegistry {
pub(crate) fn from_tools(tools: impl IntoIterator<Item = Arc<dyn CoreToolRuntime>>) -> Self {
let mut tools_by_name = HashMap::new();
for tool in tools {
let name = tool.tool_name();
let name = tool.tool_name().with_default_namespace();
if tools_by_name.contains_key(&name) {
error_or_panic(format!("tool {name} already registered"));
continue;
@@ -357,7 +361,9 @@ impl ToolRegistry {
}
fn tool(&self, name: &ToolName) -> Option<Arc<dyn CoreToolRuntime>> {
self.tools.get(name).map(Arc::clone)
self.tools
.get(&name.clone().with_default_namespace())
.map(Arc::clone)
}
#[cfg(test)]
@@ -369,7 +375,7 @@ impl ToolRegistry {
#[cfg(test)]
pub(crate) fn tool_exposure(&self, name: &ToolName) -> Option<ToolExposure> {
self.tools.get(name).map(|tool| tool.exposure())
self.tool(name).map(|tool| tool.exposure())
}
pub(crate) fn create_diff_consumer(
@@ -712,10 +718,8 @@ async fn handle_any_tool(
fn function_hook_tool_name(invocation: &ToolInvocation) -> HookToolName {
if invocation.tool_name.name == "spawn_agent"
&& matches!(
invocation.tool_name.namespace.as_deref(),
None | Some(MULTI_AGENT_V1_NAMESPACE)
)
&& (invocation.tool_name.is_default_namespace()
|| invocation.tool_name.namespace.as_deref() == Some(MULTI_AGENT_V1_NAMESPACE))
{
return HookToolName::spawn_agent();
}
+8 -1
View File
@@ -140,7 +140,7 @@ impl codex_extension_api::ToolLifecycleContributor for ToolLifecycleRecorder {
}
#[test]
fn handler_looks_up_namespaced_aliases_explicitly() {
fn handler_normalizes_only_the_default_namespace() {
let namespace = "mcp__codex_apps__gmail";
let tool_name = "gmail_get_recent_emails";
let plain_name = codex_tools::ToolName::plain(tool_name);
@@ -157,6 +157,7 @@ fn handler_looks_up_namespaced_aliases_explicitly() {
]));
let plain = registry.tool(&plain_name);
let default_namespaced = registry.tool(&codex_tools::ToolName::function(tool_name));
let namespaced = registry.tool(&namespaced_name);
let missing_namespaced = registry.tool(&codex_tools::ToolName::namespaced(
"mcp__codex_apps__calendar",
@@ -164,6 +165,7 @@ fn handler_looks_up_namespaced_aliases_explicitly() {
));
assert_eq!(plain.is_some(), true);
assert_eq!(default_namespaced.is_some(), true);
assert_eq!(namespaced.is_some(), true);
assert_eq!(missing_namespaced.is_none(), true);
assert!(
@@ -171,6 +173,11 @@ fn handler_looks_up_namespaced_aliases_explicitly() {
.as_ref()
.is_some_and(|handler| Arc::ptr_eq(handler, &plain_handler))
);
assert!(
default_namespaced
.as_ref()
.is_some_and(|handler| Arc::ptr_eq(handler, &plain_handler))
);
assert!(
namespaced
.as_ref()
+2 -2
View File
@@ -120,7 +120,7 @@ impl ToolRouter {
call_id,
..
} => {
let tool_name = ToolName::new(namespace, name);
let tool_name = ToolName::new(namespace, name).with_default_namespace();
Ok(Some(ToolCall {
tool_name,
call_id,
@@ -152,7 +152,7 @@ impl ToolRouter {
call_id,
..
} => Ok(Some(ToolCall {
tool_name: ToolName::plain(name),
tool_name: ToolName::function(name),
call_id,
payload: ToolPayload::Custom { input },
})),
+35 -3
View File
@@ -18,6 +18,7 @@ use codex_protocol::models::ContentItem;
use codex_protocol::models::FunctionCallOutputBody;
use codex_protocol::models::ResponseInputItem;
use codex_protocol::models::ResponseItem;
use codex_tools::DEFAULT_FUNCTION_NAMESPACE;
use codex_tools::ResponsesApiNamespace;
use codex_tools::ResponsesApiNamespaceTool;
use codex_tools::ToolName;
@@ -176,6 +177,39 @@ async fn build_tool_call_uses_namespace_for_registry_name() -> anyhow::Result<()
Ok(())
}
#[test]
fn build_tool_call_normalizes_default_function_and_custom_namespaces() -> anyhow::Result<()> {
for namespace in [None, Some(""), Some(DEFAULT_FUNCTION_NAMESPACE)] {
let function_call = ToolRouter::build_tool_call(ResponseItem::FunctionCall {
id: None,
name: "lookup".to_string(),
namespace: namespace.map(str::to_string),
arguments: "{}".to_string(),
call_id: "call-function".to_string(),
})?
.expect("function_call should produce a tool call");
assert_eq!(
function_call.tool_name,
ToolName::namespaced(DEFAULT_FUNCTION_NAMESPACE, "lookup")
);
}
let custom_call = ToolRouter::build_tool_call(ResponseItem::CustomToolCall {
id: None,
status: None,
call_id: "call-custom".to_string(),
name: "apply_patch".to_string(),
input: "*** Begin Patch\n*** End Patch".to_string(),
})?
.expect("custom_tool_call should produce a tool call");
assert_eq!(
custom_call.tool_name,
ToolName::namespaced(DEFAULT_FUNCTION_NAMESPACE, "apply_patch")
);
Ok(())
}
#[test]
fn build_tool_call_uses_canonical_tool_search_name() -> anyhow::Result<()> {
let call = ToolRouter::build_tool_call(ResponseItem::ToolSearchCall {
@@ -443,9 +477,7 @@ fn namespace_function_names(specs: &[ToolSpec], namespace_name: &str) -> Vec<Str
namespace
.tools
.iter()
.map(|tool| match tool {
ResponsesApiNamespaceTool::Function(tool) => tool.name.clone(),
})
.map(|tool| tool.name().to_string())
.collect(),
),
ToolSpec::Function(_)
+12 -12
View File
@@ -241,7 +241,7 @@ fn build_model_visible_specs_and_registry(
let mut specs = Vec::new();
let mut seen_tool_names = HashSet::new();
for runtime in &runtimes {
let tool_name = runtime.tool_name();
let tool_name = runtime.tool_name().with_default_namespace();
if !seen_tool_names.insert(tool_name.clone()) {
continue;
}
@@ -464,6 +464,9 @@ fn is_hidden_by_code_mode_only(
}
fn is_excluded_from_code_mode(turn_context: &TurnContext, tool_name: &ToolName) -> bool {
if tool_name.is_default_namespace() {
return false;
}
tool_name.namespace.as_ref().is_some_and(|namespace| {
turn_context
.config
@@ -565,12 +568,9 @@ fn merge_into_namespaces(specs: Vec<ToolSpec>) -> Vec<ToolSpec> {
continue;
};
namespace.tools.sort_by(|left, right| match (left, right) {
(
ResponsesApiNamespaceTool::Function(left),
ResponsesApiNamespaceTool::Function(right),
) => left.name.cmp(&right.name),
});
namespace
.tools
.sort_by(|left, right| left.name().cmp(right.name()));
if namespace.description.trim().is_empty() {
namespace.description = default_namespace_description(&namespace.name);
@@ -985,12 +985,12 @@ fn append_extension_tool_executors(
let mut reserved_tool_names = planned_tools
.runtimes()
.iter()
.map(|executor| executor.tool_name())
.map(|executor| executor.tool_name().with_default_namespace())
.collect::<HashSet<_>>();
let tool_mode = effective_tool_mode(turn_context);
if matches!(tool_mode, ToolMode::CodeMode | ToolMode::CodeModeOnly) {
reserved_tool_names.insert(ToolName::plain(codex_code_mode::PUBLIC_TOOL_NAME));
reserved_tool_names.insert(ToolName::plain(codex_code_mode::WAIT_TOOL_NAME));
reserved_tool_names.insert(ToolName::function(codex_code_mode::PUBLIC_TOOL_NAME));
reserved_tool_names.insert(ToolName::function(codex_code_mode::WAIT_TOOL_NAME));
}
if search_tool_enabled(turn_context)
&& namespace_tools_enabled(turn_context)
@@ -999,7 +999,7 @@ fn append_extension_tool_executors(
.iter()
.any(|executor| executor.exposure() == ToolExposure::Deferred)
{
reserved_tool_names.insert(ToolName::plain(TOOL_SEARCH_TOOL_NAME));
reserved_tool_names.insert(ToolName::function(TOOL_SEARCH_TOOL_NAME));
reserved_tool_names.insert(tool_search_tool_name());
}
@@ -1007,7 +1007,7 @@ fn append_extension_tool_executors(
let web_search_mode_on = turn_context.config.web_search_mode.value() != WebSearchMode::Disabled;
for executor in executors.iter().cloned() {
let tool_name = executor.tool_name();
let tool_name = executor.tool_name().with_default_namespace();
if tool_name == ToolName::namespaced("web", "run")
&& (!standalone_web_search_enabled || !web_search_mode_on)
{
+1 -3
View File
@@ -72,9 +72,7 @@ impl ToolPlanProbe {
namespace
.tools
.iter()
.map(|tool| match tool {
ResponsesApiNamespaceTool::Function(tool) => tool.name.clone(),
})
.map(|tool| tool.name().to_string())
.collect::<Vec<_>>(),
)),
ToolSpec::Function(_)
@@ -120,6 +120,13 @@ async fn responses_lite_uses_input_items_for_instructions_and_tools() -> Result<
Ok(())
}
fn functions_namespace(tools: &[Value]) -> Option<&Value> {
tools.iter().find(|tool| {
tool.get("type").and_then(Value::as_str) == Some("namespace")
&& tool.get("name").and_then(Value::as_str) == Some("functions")
})
}
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
async fn responses_lite_rejects_oversized_additional_tools_item() -> Result<()> {
skip_if_no_network!(Ok(()));
@@ -272,6 +279,17 @@ async fn responses_lite_uses_standalone_web_search_and_image_generation() -> Res
assert!(body.get("tools").is_none());
let tools = additional_tools(&body)?;
assert!(!tools.is_empty());
let function_tools = functions_namespace(tools)
.and_then(|namespace| namespace.get("tools"))
.and_then(Value::as_array)
.context("Responses Lite should group default tools under functions")?;
assert!(!function_tools.is_empty());
assert!(tools.iter().all(|tool| {
!matches!(
tool.get("type").and_then(Value::as_str),
Some("function" | "custom")
)
}));
assert!(!has_hosted_tool(tools, "web_search"));
assert!(!has_hosted_tool(tools, "image_generation"));
+9 -15
View File
@@ -896,7 +896,7 @@ async fn tool_search_returns_deferred_dynamic_tool_and_routes_follow_up_call() -
"item": {
"type": "function_call",
"call_id": dynamic_call_id,
"namespace": "codex_app",
"namespace": "functions",
"name": tool_name,
"arguments": tool_call_arguments,
}
@@ -920,17 +920,11 @@ async fn tool_search_returns_deferred_dynamic_tool_and_routes_follow_up_call() -
"required": ["mode"],
"additionalProperties": false,
});
let dynamic_tool = DynamicToolSpec::Namespace(DynamicToolNamespaceSpec {
name: "codex_app".to_string(),
description: "Automation tools.".to_string(),
tools: vec![DynamicToolNamespaceTool::Function(
DynamicToolFunctionSpec {
name: tool_name.to_string(),
description: tool_description.to_string(),
input_schema: input_schema.clone(),
defer_loading: true,
},
)],
let dynamic_tool = DynamicToolSpec::Function(DynamicToolFunctionSpec {
name: tool_name.to_string(),
description: tool_description.to_string(),
input_schema: input_schema.clone(),
defer_loading: true,
});
let mut builder = test_codex().with_config(configure_search_capable_model);
@@ -964,7 +958,7 @@ async fn tool_search_returns_deferred_dynamic_tool_and_routes_follow_up_call() -
unreachable!("event guard guarantees DynamicToolCallRequest");
};
assert_eq!(request.call_id, dynamic_call_id);
assert_eq!(request.namespace.as_deref(), Some("codex_app"));
assert_eq!(request.namespace, None);
assert_eq!(request.tool, tool_name);
assert_eq!(request.arguments, tool_args);
@@ -1006,8 +1000,8 @@ async fn tool_search_returns_deferred_dynamic_tool_and_routes_follow_up_call() -
tools,
vec![json!({
"type": "namespace",
"name": "codex_app",
"description": "Automation tools.",
"name": "functions",
"description": "Tools in the functions namespace.",
"tools": [{
"type": "function",
"name": tool_name,
+16 -3
View File
@@ -19,6 +19,7 @@ use core_test_support::responses::ev_apply_patch_custom_tool_call;
use core_test_support::responses::ev_assistant_message;
use core_test_support::responses::ev_completed;
use core_test_support::responses::ev_function_call;
use core_test_support::responses::ev_function_call_with_namespace;
use core_test_support::responses::ev_response_created;
use core_test_support::responses::sse;
use core_test_support::responses::start_mock_server;
@@ -29,6 +30,7 @@ use core_test_support::test_codex::turn_permission_fields;
use core_test_support::wait_for_event;
use serde_json::Value;
use serde_json::json;
use test_case::test_case;
fn call_output(req: &ResponsesRequest, call_id: &str) -> (String, Option<bool>) {
let raw = req.function_call_output(call_id);
assert_eq!(
@@ -134,8 +136,11 @@ async fn shell_command_tool_executes_command_and_streams_output() -> anyhow::Res
Ok(())
}
#[test_case(None; "omitted_namespace")]
#[test_case(Some(""); "empty_namespace")]
#[test_case(Some("functions"); "explicit_functions_namespace")]
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
async fn update_plan_tool_emits_plan_update_event() -> anyhow::Result<()> {
async fn update_plan_tool_emits_plan_update_event(namespace: Option<&str>) -> anyhow::Result<()> {
skip_if_no_network!(Ok(()));
let server = start_mock_server().await;
@@ -158,9 +163,15 @@ async fn update_plan_tool_emits_plan_update_event() -> anyhow::Result<()> {
})
.to_string();
let function_call = match namespace {
Some(namespace) => {
ev_function_call_with_namespace(call_id, namespace, "update_plan", &plan_args)
}
None => ev_function_call(call_id, "update_plan", &plan_args),
};
let first_response = sse(vec![
ev_response_created("resp-1"),
ev_function_call(call_id, "update_plan", &plan_args),
function_call,
ev_completed("resp-1"),
]);
responses::mount_sse_once(&server, first_response).await;
@@ -350,9 +361,11 @@ async fn apply_patch_tool_executes_and_emits_patch_events() -> anyhow::Result<()
*** End Patch"#
);
let mut custom_tool_call = ev_apply_patch_custom_tool_call(call_id, &patch_content);
custom_tool_call["namespace"] = json!("functions");
let first_response = sse(vec![
ev_response_created("resp-1"),
ev_apply_patch_custom_tool_call(call_id, &patch_content),
custom_tool_call,
ev_completed("resp-1"),
]);
responses::mount_sse_once(&server, first_response).await;
+1 -1
View File
@@ -363,7 +363,7 @@ where
};
let should_count_for_goal_progress = runtime.is_enabled()
&& tool_attempt_counts_for_goal_progress(input.outcome)
&& !(input.tool_name.namespace.is_none()
&& !(input.tool_name.is_default_namespace()
&& input.tool_name.name == UPDATE_GOAL_TOOL_NAME);
if !should_count_for_goal_progress {
return;
+3 -1
View File
@@ -33,7 +33,9 @@ fn uses_reserved_image_gen_namespace() {
panic!("imagegen should advertise a namespace tool");
};
assert_eq!(spec.name, IMAGE_GEN_NAMESPACE);
let ResponsesApiNamespaceTool::Function(function) = &spec.tools[0];
let ResponsesApiNamespaceTool::Function(function) = &spec.tools[0] else {
panic!("imagegen should advertise a function tool");
};
assert_eq!(function.name, IMAGEGEN_TOOL_NAME);
}
+1
View File
@@ -7,6 +7,7 @@ mod tool_name;
pub use agent_path::AgentPath;
pub use session_id::SessionId;
pub use thread_id::ThreadId;
pub use tool_name::DEFAULT_FUNCTION_NAMESPACE;
pub use tool_name::ToolName;
pub mod approvals;
pub mod capabilities;
+21
View File
@@ -3,6 +3,9 @@ use serde::Serialize;
use std::cmp::Ordering;
use std::fmt;
/// Namespace used internally for top-level function and custom tools.
pub const DEFAULT_FUNCTION_NAMESPACE: &str = "functions";
/// Identifies a callable tool, preserving the namespace split when the model
/// provides one.
#[derive(Clone, Debug, Deserialize, Eq, Hash, PartialEq, Serialize)]
@@ -26,17 +29,35 @@ impl ToolName {
}
}
pub fn function(name: impl Into<String>) -> Self {
Self::namespaced(DEFAULT_FUNCTION_NAMESPACE, name)
}
pub fn namespaced(namespace: impl Into<String>, name: impl Into<String>) -> Self {
Self {
name: name.into(),
namespace: Some(namespace.into()),
}
}
pub fn with_default_namespace(mut self) -> Self {
if self.namespace.as_deref().is_none_or(str::is_empty) {
self.namespace = Some(DEFAULT_FUNCTION_NAMESPACE.to_string());
}
self
}
pub fn is_default_namespace(&self) -> bool {
self.namespace
.as_deref()
.is_none_or(|namespace| namespace.is_empty() || namespace == DEFAULT_FUNCTION_NAMESPACE)
}
}
impl fmt::Display for ToolName {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
match &self.namespace {
Some(_) if self.is_default_namespace() => f.write_str(&self.name),
Some(namespace) => write!(f, "{namespace}{}", self.name),
None => f.write_str(&self.name),
}
+17 -11
View File
@@ -7,6 +7,7 @@
use std::fmt::Display;
use std::sync::Arc;
use codex_protocol::DEFAULT_FUNCTION_NAMESPACE;
use codex_protocol::models::AdditionalPermissionProfile;
use codex_protocol::models::ResponseInputItem;
use codex_protocol::models::SandboxPermissions;
@@ -193,7 +194,10 @@ impl ToolDispatchTraceContext {
fn suppresses_tool_dispatch_trace(invocation: &ToolDispatchInvocation) -> bool {
matches!(invocation.payload, ToolDispatchPayload::Custom { .. })
&& invocation.tool_namespace.is_none()
&& invocation
.tool_namespace
.as_deref()
.is_none_or(|namespace| namespace.is_empty() || namespace == DEFAULT_FUNCTION_NAMESPACE)
&& invocation.tool_name == codex_code_mode::PUBLIC_TOOL_NAME
}
@@ -393,16 +397,18 @@ mod tests {
#[test]
fn suppresses_only_noncanonical_dispatch_boundaries() {
assert!(suppresses_tool_dispatch_trace(&invocation(
codex_code_mode::PUBLIC_TOOL_NAME,
/*tool_namespace*/ None,
ToolDispatchRequester::Model {
model_visible_call_id: "call-exec".to_string(),
},
ToolDispatchPayload::Custom {
input: "1 + 1".to_string(),
},
)));
for namespace in [None, Some(""), Some(DEFAULT_FUNCTION_NAMESPACE)] {
assert!(suppresses_tool_dispatch_trace(&invocation(
codex_code_mode::PUBLIC_TOOL_NAME,
namespace.map(str::to_string),
ToolDispatchRequester::Model {
model_visible_call_id: "call-exec".to_string(),
},
ToolDispatchPayload::Custom {
input: "1 + 1".to_string(),
},
)));
}
assert!(!suppresses_tool_dispatch_trace(&invocation(
"custom_tool",
/*tool_namespace*/ None,
+28
View File
@@ -42,6 +42,20 @@ pub fn augment_tool_spec_for_code_mode(spec: ToolSpec) -> ToolSpec {
tool.description =
codex_code_mode::augment_tool_definition(definition).description;
}
ResponsesApiNamespaceTool::Freeform(tool) => {
let tool_name =
ToolName::namespaced(namespace.name.clone(), tool.name.clone());
let definition = CodeModeToolDefinition {
name: code_mode_name_for_tool_name(&tool_name),
tool_name,
description: tool.description.clone(),
kind: CodeModeToolKind::Freeform,
input_schema: None,
output_schema: None,
};
tool.description =
codex_code_mode::augment_tool_definition(definition).description;
}
}
}
ToolSpec::Namespace(namespace)
@@ -146,6 +160,17 @@ fn code_mode_tool_definitions_for_spec(spec: &ToolSpec) -> Vec<CodeModeToolDefin
output_schema: tool.output_schema.clone(),
}
}
ResponsesApiNamespaceTool::Freeform(tool) => {
let tool_name = ToolName::namespaced(namespace.name.clone(), tool.name.clone());
CodeModeToolDefinition {
name: code_mode_name_for_tool_name(&tool_name),
tool_name,
description: tool.description.clone(),
kind: CodeModeToolKind::Freeform,
input_schema: None,
output_schema: None,
}
}
})
.collect(),
ToolSpec::ImageGeneration { .. }
@@ -155,6 +180,9 @@ fn code_mode_tool_definitions_for_spec(spec: &ToolSpec) -> Vec<CodeModeToolDefin
}
pub fn code_mode_name_for_tool_name(tool_name: &ToolName) -> String {
if tool_name.is_default_namespace() {
return tool_name.name.clone();
}
match tool_name.namespace.as_deref() {
Some(namespace) if namespace.ends_with('_') || tool_name.name.starts_with('_') => {
format!("{namespace}{}", tool_name.name)
+2
View File
@@ -25,6 +25,7 @@ pub use code_mode::code_mode_name_for_tool_name;
pub use code_mode::collect_code_mode_exec_prompt_tool_definitions;
pub use code_mode::collect_code_mode_tool_definitions;
pub use code_mode::tool_spec_to_code_mode_tool_definition;
pub use codex_protocol::DEFAULT_FUNCTION_NAMESPACE;
pub use codex_protocol::ToolName;
pub use dynamic_tool::parse_dynamic_tool;
pub use function_call_error::FunctionCallError;
@@ -106,3 +107,4 @@ pub use tool_spec::ResponsesApiWebSearchFilters;
pub use tool_spec::ResponsesApiWebSearchUserLocation;
pub use tool_spec::ToolSpec;
pub use tool_spec::create_tools_json_for_responses_api;
pub use tool_spec::create_tools_json_for_responses_lite;
+12
View File
@@ -61,9 +61,21 @@ pub fn default_namespace_description(namespace_name: &str) -> String {
#[derive(Debug, Clone, Serialize, PartialEq)]
#[serde(tag = "type")]
#[allow(clippy::large_enum_variant)]
pub enum ResponsesApiNamespaceTool {
#[serde(rename = "function")]
Function(ResponsesApiTool),
#[serde(rename = "custom")]
Freeform(FreeformTool),
}
impl ResponsesApiNamespaceTool {
pub fn name(&self) -> &str {
match self {
Self::Function(tool) => &tool.name,
Self::Freeform(tool) => &tool.name,
}
}
}
pub fn dynamic_tool_to_responses_api_tool(
+21 -6
View File
@@ -1,5 +1,7 @@
use crate::DEFAULT_FUNCTION_NAMESPACE;
use crate::JsonSchema;
use crate::LoadableToolSpec;
use crate::ResponsesApiNamespace;
use crate::ResponsesApiNamespaceTool;
use crate::ResponsesApiTool;
use crate::ToolSearchSourceInfo;
@@ -36,16 +38,21 @@ impl ToolSearchInfo {
ToolSpec::Function(mut tool) => {
tool.defer_loading = Some(true);
tool.output_schema = None;
LoadableToolSpec::Function(tool)
LoadableToolSpec::Namespace(ResponsesApiNamespace {
name: DEFAULT_FUNCTION_NAMESPACE.to_string(),
description: default_namespace_description(DEFAULT_FUNCTION_NAMESPACE),
tools: vec![ResponsesApiNamespaceTool::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;
if let ResponsesApiNamespaceTool::Function(tool) = tool {
tool.defer_loading = Some(true);
tool.output_schema = None;
}
}
LoadableToolSpec::Namespace(namespace)
}
@@ -74,8 +81,16 @@ fn default_tool_search_text(spec: &ToolSpec) -> String {
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);
match tool {
ResponsesApiNamespaceTool::Function(tool) => {
append_function_search_text(tool, &mut parts);
}
ResponsesApiNamespaceTool::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());
}
}
}
}
ToolSpec::ToolSearch { description, .. } => {
+50
View File
@@ -1,8 +1,11 @@
use crate::DEFAULT_FUNCTION_NAMESPACE;
use crate::FreeformTool;
use crate::JsonSchema;
use crate::LoadableToolSpec;
use crate::ResponsesApiNamespace;
use crate::ResponsesApiNamespaceTool;
use crate::ResponsesApiTool;
use crate::default_namespace_description;
use codex_protocol::config_types::WebSearchContextSize;
use codex_protocol::config_types::WebSearchFilters as ConfigWebSearchFilters;
use codex_protocol::config_types::WebSearchUserLocation as ConfigWebSearchUserLocation;
@@ -90,6 +93,53 @@ pub fn create_tools_json_for_responses_api(
Ok(tools_json)
}
pub fn create_tools_json_for_responses_lite(
tools: &[ToolSpec],
) -> Result<Vec<Value>, serde_json::Error> {
let mut functions = Vec::new();
let mut functions_description = None;
let mut functions_index = None;
let mut tools_json = Vec::new();
for tool in tools {
match tool {
ToolSpec::Function(tool) => {
functions.push(ResponsesApiNamespaceTool::Function(tool.clone()));
}
ToolSpec::Freeform(tool) => {
functions.push(ResponsesApiNamespaceTool::Freeform(tool.clone()));
}
ToolSpec::Namespace(namespace) if namespace.name == DEFAULT_FUNCTION_NAMESPACE => {
if !namespace.description.trim().is_empty() {
functions_description = Some(namespace.description.clone());
}
functions.extend(namespace.tools.clone());
}
tool => {
tools_json.push(serde_json::to_value(tool)?);
continue;
}
}
functions_index.get_or_insert(tools_json.len());
}
if let Some(functions_index) = functions_index
&& !functions.is_empty()
{
tools_json.insert(
functions_index,
serde_json::to_value(ToolSpec::Namespace(ResponsesApiNamespace {
name: DEFAULT_FUNCTION_NAMESPACE.to_string(),
description: functions_description
.unwrap_or_else(|| default_namespace_description(DEFAULT_FUNCTION_NAMESPACE)),
tools: functions,
}))?,
);
}
Ok(tools_json)
}
#[derive(Debug, Clone, Serialize, PartialEq)]
pub struct ResponsesApiWebSearchFilters {
#[serde(skip_serializing_if = "Option::is_none")]
+61
View File
@@ -9,6 +9,7 @@ use crate::JsonSchema;
use crate::ResponsesApiNamespaceTool;
use crate::ResponsesApiTool;
use crate::create_tools_json_for_responses_api;
use crate::create_tools_json_for_responses_lite;
use codex_protocol::config_types::WebSearchContextSize;
use codex_protocol::config_types::WebSearchFilters as ConfigWebSearchFilters;
use codex_protocol::config_types::WebSearchUserLocation as ConfigWebSearchUserLocation;
@@ -150,6 +151,66 @@ fn create_tools_json_for_responses_api_includes_top_level_name() {
);
}
#[test]
fn responses_lite_groups_default_function_and_custom_tools() {
let function = |name: &str| ResponsesApiTool {
name: name.to_string(),
description: String::new(),
strict: false,
defer_loading: None,
parameters: JsonSchema::default(),
output_schema: None,
};
let tools = create_tools_json_for_responses_lite(&[
ToolSpec::ToolSearch {
execution: "client".to_string(),
description: "Search tools".to_string(),
parameters: JsonSchema::default(),
},
ToolSpec::Function(function("lookup_order")),
ToolSpec::Freeform(FreeformTool {
name: "exec".to_string(),
description: String::new(),
format: FreeformToolFormat {
r#type: "grammar".to_string(),
syntax: "lark".to_string(),
definition: "start: /.+/".to_string(),
},
}),
ToolSpec::Namespace(ResponsesApiNamespace {
name: "functions".to_string(),
description: "Default tools".to_string(),
tools: vec![ResponsesApiNamespaceTool::Function(function("existing"))],
}),
])
.expect("serialize Responses Lite tools");
assert_eq!(tools.len(), 2);
assert_eq!(tools[0]["type"], "tool_search");
assert_eq!(tools[1]["type"], "namespace");
assert_eq!(tools[1]["name"], "functions");
assert_eq!(tools[1]["description"], "Default tools");
let nested_tools = tools[1]["tools"]
.as_array()
.expect("functions namespace should contain tools");
assert_eq!(
nested_tools
.iter()
.map(|tool| {
(
tool["type"].as_str().expect("tool type should be a string"),
tool["name"].as_str().expect("tool name should be a string"),
)
})
.collect::<Vec<_>>(),
vec![
("function", "lookup_order"),
("custom", "exec"),
("function", "existing"),
]
);
}
#[test]
fn namespace_tool_spec_serializes_expected_wire_shape() {
assert_eq!(