Canonicalize default tools under the functions namespace (#37022)

## What changed

- Group top-level function and custom tool definitions into a single `functions` namespace for Responses Lite providers that support namespaced tools.
- Normalize missing, empty, and explicit `functions` namespaces to the same tool identity across registration, routing, lifecycle hooks, configuration, and tool search.
- Keep default tool names unprefixed in code mode, display output, and dispatch traces while preserving explicit non-default namespaces.

## Testing

- Add coverage for Responses Lite serialization, tool search results, namespace normalization and collision handling, routing, lifecycle events, and code-mode namespace policies.

GitOrigin-RevId: d48414005b5d22d39b11a198e19c47814d3a19f2
This commit is contained in:
rka-oai
2026-08-05 04:07:59 +00:00
committed by copyberry
parent 1e489adad0
commit f21dc46388
29 changed files with 522 additions and 78 deletions
+6 -1
View File
@@ -89,6 +89,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_tools::create_tools_raw_json_for_responses_api;
use eventsource_stream::Event;
use eventsource_stream::EventStreamError;
@@ -859,7 +860,11 @@ impl ModelClient {
}
}
let (instructions, tools) = if model_info.use_responses_lite {
let tools = create_tools_json_for_responses_api(&prompt.tools)?;
let tools = if self.state.provider.capabilities().namespace_tools {
create_tools_json_for_responses_lite(&prompt.tools)?
} else {
create_tools_json_for_responses_api(&prompt.tools)?
};
let mut prefix = vec![ResponseItem::AdditionalTools {
id: None,
role: "developer".to_string(),
+8 -7
View File
@@ -31,16 +31,17 @@ pub(crate) fn shell_script_for_invocation(invocation: &ToolInvocation) -> Option
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,
}
}
+3 -3
View File
@@ -62,9 +62,9 @@ pub(crate) fn default_exec_yield_time_override_ms(features: &Features) -> Option
.then_some(BUFFERED_EXEC_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)]
@@ -339,7 +339,7 @@ async fn call_nested_tool(
};
let call = ToolCall {
tool_name,
tool_name: tool_name.with_default_namespace(),
call_id: format!("{PUBLIC_TOOL_NAME}-{}", uuid::Uuid::new_v4()),
payload,
encrypted_function_args: None,
@@ -82,7 +82,8 @@ impl CodeModeWaitHandler {
);
let result = 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).inspect_err(|_error| {
telemetry.finish(/*success*/ false);
@@ -70,7 +70,7 @@ impl ExecutedToolCallRecorder {
) {
if matches!(source, ToolCallSource::Direct)
&& matches!(tool_mode, ToolMode::CodeMode | ToolMode::CodeModeOnly)
&& call.tool_name.namespace.is_none()
&& call.tool_name.is_default_namespace()
&& matches!(
(call.tool_name.name.as_str(), &call.payload),
(
+4
View File
@@ -40,6 +40,10 @@ 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
@@ -266,7 +266,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"
+18 -14
View File
@@ -290,7 +290,8 @@ impl ToolRegistry {
runtime: Arc<dyn CoreToolRuntime>,
exposure: ToolExposure,
) {
match self.tools.entry(runtime.tool_name()) {
let tool_name = runtime.tool_name().with_default_namespace();
match self.tools.entry(tool_name) {
Entry::Vacant(entry) => {
entry.insert(RegisteredTool { runtime, exposure });
}
@@ -302,7 +303,7 @@ impl ToolRegistry {
}
pub(crate) fn prepend_trusted(&mut self, runtime: Arc<dyn CoreToolRuntime>) {
let tool_name = runtime.tool_name();
let tool_name = runtime.tool_name().with_default_namespace();
if self.tools.contains_key(&tool_name) {
error_or_panic(format!("tool {tool_name} already registered"));
return;
@@ -323,8 +324,8 @@ impl ToolRegistry {
runtime: Arc<dyn CoreToolRuntime>,
exposure: ToolExposure,
) -> bool {
let tool_name = runtime.tool_name();
if tool_name.namespace.is_none() && tool_name.name == "shell_command" {
let tool_name = runtime.tool_name().with_default_namespace();
if tool_name.is_default_namespace() && tool_name.name == "shell_command" {
tracing::warn!(tool_name = %tool_name, "skipping external tool with reserved name");
if self.tools.contains_key(&tool_name) {
self.record_collision(tool_name);
@@ -358,7 +359,9 @@ impl ToolRegistry {
}
pub(crate) fn remove(&mut self, tool_name: &ToolName) -> Option<Arc<dyn CoreToolRuntime>> {
self.tools.shift_remove(tool_name).map(|tool| tool.runtime)
self.tools
.shift_remove(&tool_name.clone().with_default_namespace())
.map(|tool| tool.runtime)
}
pub(crate) fn entries(&self) -> impl Iterator<Item = &RegisteredTool> {
@@ -372,7 +375,7 @@ impl ToolRegistry {
pub(crate) fn deferred_tool_namespaces(&self) -> BTreeMap<String, String> {
let mut namespaces = BTreeMap::<String, String>::new();
for (name, tool) in &self.tools {
if !tool.exposure.is_deferred() {
if !tool.exposure.is_deferred() || name.is_default_namespace() {
continue;
}
let Some(namespace) = &name.namespace else {
@@ -410,7 +413,9 @@ impl ToolRegistry {
}
pub(crate) fn tool(&self, name: &ToolName) -> Option<Arc<dyn CoreToolRuntime>> {
self.tools.get(name).map(|tool| Arc::clone(&tool.runtime))
self.tools
.get(&name.clone().with_default_namespace())
.map(|tool| Arc::clone(&tool.runtime))
}
#[cfg(test)]
@@ -422,7 +427,9 @@ impl ToolRegistry {
#[cfg(test)]
pub(crate) fn tool_exposure(&self, name: &ToolName) -> Option<ToolExposure> {
self.tools.get(name).map(|tool| tool.exposure)
self.tools
.get(&name.clone().with_default_namespace())
.map(|tool| tool.exposure)
}
pub(crate) fn create_diff_consumer(
@@ -433,7 +440,7 @@ impl ToolRegistry {
}
pub(crate) fn supports_parallel_tool_calls(&self, name: &ToolName) -> Option<bool> {
let tool = self.tools.get(name)?;
let tool = self.tools.get(&name.clone().with_default_namespace())?;
Some(tool.exposure != ToolExposure::Hidden && tool.runtime.supports_parallel_tool_calls())
}
@@ -504,7 +511,6 @@ impl ToolRegistry {
return Err(err);
}
};
let telemetry_tags = tool.telemetry_tags(&invocation);
let mut tool_result_tags =
Vec::with_capacity(base_tool_result_tags.len() + telemetry_tags.len() + 1);
@@ -773,10 +779,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();
}
+77 -7
View File
@@ -1,6 +1,7 @@
use super::*;
use crate::session::session::Session;
use crate::session::step_context::StepContext;
use codex_protocol::DEFAULT_FUNCTION_NAMESPACE;
use futures::future::BoxFuture;
use pretty_assertions::assert_eq;
use std::sync::atomic::AtomicUsize;
@@ -80,7 +81,11 @@ impl ToolExecutor<ToolInvocation> for LifecycleTestHandler {
test_spec(&self.tool_name)
}
fn handle(&self, _invocation: ToolInvocation) -> codex_tools::ToolExecutorFuture<'_> {
fn handle(&self, invocation: ToolInvocation) -> codex_tools::ToolExecutorFuture<'_> {
assert_eq!(
invocation.tool_name,
self.tool_name.clone().with_default_namespace()
);
Box::pin(self.handle_call())
}
}
@@ -172,7 +177,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);
@@ -187,6 +192,11 @@ fn handler_looks_up_namespaced_aliases_explicitly() {
ToolRegistry::from_tools([Arc::clone(&plain_handler), Arc::clone(&namespaced_handler)]);
let plain = registry.tool(&plain_name);
let default_namespaced = registry.tool(&codex_tools::ToolName::namespaced(
DEFAULT_FUNCTION_NAMESPACE,
tool_name,
));
let empty_namespaced = registry.tool(&codex_tools::ToolName::namespaced("", tool_name));
let namespaced = registry.tool(&namespaced_name);
let missing_namespaced = registry.tool(&codex_tools::ToolName::namespaced(
"mcp__codex_apps__calendar",
@@ -201,6 +211,16 @@ 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!(
empty_namespaced
.as_ref()
.is_some_and(|handler| Arc::ptr_eq(handler, &plain_handler))
);
assert!(
namespaced
.as_ref()
@@ -208,6 +228,45 @@ fn handler_looks_up_namespaced_aliases_explicitly() {
);
}
#[test]
fn registry_rejects_default_namespace_alias_collisions() {
let plain_name = codex_tools::ToolName::plain("lookup");
let namespaced_name = codex_tools::ToolName::namespaced(DEFAULT_FUNCTION_NAMESPACE, "lookup");
for [first_name, duplicate_name] in [
[plain_name.clone(), namespaced_name.clone()],
[namespaced_name, plain_name],
] {
let winner = Arc::new(TestHandler {
tool_name: first_name.clone(),
}) as Arc<dyn CoreToolRuntime>;
let mut registry = ToolRegistry::from_tools([Arc::clone(&winner)]);
assert!(!registry.register_external(Arc::new(TestHandler {
tool_name: duplicate_name.clone(),
})));
assert!(
registry
.tool(&duplicate_name)
.is_some_and(|handler| Arc::ptr_eq(&handler, &winner))
);
assert_eq!(
registry.tool_exposure(&duplicate_name),
Some(ToolExposure::Direct)
);
assert_eq!(
registry.supports_parallel_tool_calls(&duplicate_name),
Some(false)
);
assert!(
registry
.remove(&duplicate_name)
.is_some_and(|handler| Arc::ptr_eq(&handler, &winner))
);
assert!(registry.tool(&first_name).is_none());
}
}
#[test]
fn registry_preserves_external_winners_and_trusted_synthetic_order() {
let handler = |tool_name| Arc::new(TestHandler { tool_name }) as Arc<dyn CoreToolRuntime>;
@@ -248,6 +307,12 @@ fn reserved_shell_command_rejects_external_runtimes_without_a_builtin() {
handler(shell_command_name.clone()),
ToolExposure::Direct,
));
assert!(
!registry.register_external(handler(codex_tools::ToolName::namespaced(
DEFAULT_FUNCTION_NAMESPACE,
"shell_command",
)))
);
assert!(registry.tool(&shell_command_name).is_none());
assert_eq!(registry.first_collision(), None);
@@ -449,6 +514,7 @@ async fn spawn_agent_function_tools_use_agent_matcher_alias() {
let hook_payloads = [
codex_tools::ToolName::plain("spawn_agent"),
codex_tools::ToolName::namespaced(DEFAULT_FUNCTION_NAMESPACE, "spawn_agent"),
codex_tools::ToolName::namespaced(MULTI_AGENT_V1_NAMESPACE, "spawn_agent"),
]
.into_iter()
@@ -477,6 +543,10 @@ async fn spawn_agent_function_tools_use_agent_matcher_alias() {
tool_name: HookToolName::spawn_agent(),
tool_input: serde_json::json!({ "message": "inspect this repo" }),
}),
Some(PreToolUsePayload {
tool_name: HookToolName::spawn_agent(),
tool_input: serde_json::json!({ "message": "inspect this repo" }),
}),
]
);
}
@@ -565,7 +635,7 @@ fn post_tool_use_feedback_output_keeps_code_mode_result_typed() {
}
#[tokio::test]
async fn dispatch_notifies_tool_lifecycle_contributors() -> anyhow::Result<()> {
async fn dispatch_uses_canonical_tool_names_for_lifecycle_contributors() -> anyhow::Result<()> {
let (mut session, turn) = crate::session::tests::make_session_and_context().await;
let records = Arc::new(std::sync::Mutex::new(Vec::new()));
let mut builder = codex_extension_api::ExtensionRegistryBuilder::<crate::config::Config>::new();
@@ -575,7 +645,7 @@ async fn dispatch_notifies_tool_lifecycle_contributors() -> anyhow::Result<()> {
session.services.extensions = Arc::new(builder.build());
let ok_tool = codex_tools::ToolName::plain("ok_tool");
let failing_tool = codex_tools::ToolName::plain("failing_tool");
let failing_tool = codex_tools::ToolName::namespaced("extensions", "failing_tool");
let ok_handler = Arc::new(LifecycleTestHandler {
tool_name: ok_tool.clone(),
result: LifecycleTestResult::Ok { success: false },
@@ -594,7 +664,7 @@ async fn dispatch_notifies_tool_lifecycle_contributors() -> anyhow::Result<()> {
Arc::clone(&session),
Arc::clone(&turn),
"ok-call",
ok_tool.clone(),
codex_tools::ToolName::namespaced(DEFAULT_FUNCTION_NAMESPACE, "ok_tool"),
),
/*terminal_outcome_reached*/ None,
)
@@ -619,11 +689,11 @@ async fn dispatch_notifies_tool_lifecycle_contributors() -> anyhow::Result<()> {
let expected = vec![
RecordedToolLifecycle::Start {
call_id: "ok-call".to_string(),
tool_name: ok_tool.clone(),
tool_name: ok_tool.clone().with_default_namespace(),
},
RecordedToolLifecycle::Finish {
call_id: "ok-call".to_string(),
tool_name: ok_tool,
tool_name: ok_tool.with_default_namespace(),
outcome: codex_extension_api::ToolCallOutcome::Completed { success: false },
},
RecordedToolLifecycle::Start {
+2 -2
View File
@@ -161,7 +161,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,
@@ -196,7 +196,7 @@ impl ToolRouter {
call_id,
..
} => Ok(Some(ToolCall {
tool_name: ToolName::new(namespace, name),
tool_name: ToolName::new(namespace, name).with_default_namespace(),
call_id,
payload: ToolPayload::Custom { input },
encrypted_function_args: None,
+37
View File
@@ -19,6 +19,7 @@ use codex_extension_api::ExtensionRegistryBuilder;
use codex_extension_api::ResponsesApiTool;
use codex_extension_api::ToolCall as ExtensionToolCall;
use codex_extension_api::ToolExecutor;
use codex_protocol::DEFAULT_FUNCTION_NAMESPACE;
use codex_protocol::dynamic_tools::DynamicToolFunctionSpec;
use codex_protocol::dynamic_tools::DynamicToolNamespaceSpec;
use codex_protocol::dynamic_tools::DynamicToolNamespaceTool;
@@ -268,6 +269,42 @@ async fn build_custom_tool_call_uses_namespace_for_registry_name() -> anyhow::Re
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(),
encrypted_function_args: None,
call_id: "call-function".to_string(),
internal_chat_message_metadata_passthrough: None,
})?
.expect("function_call should produce a tool call");
let custom_call = ToolRouter::build_tool_call(ResponseItem::CustomToolCall {
id: None,
status: None,
call_id: "call-custom".to_string(),
name: "apply_patch".to_string(),
namespace: namespace.map(str::to_string),
input: "patch".to_string(),
internal_chat_message_metadata_passthrough: None,
})?
.expect("custom_tool_call should produce a tool call");
assert_eq!(
[function_call.tool_name, custom_call.tool_name],
[
ToolName::namespaced(DEFAULT_FUNCTION_NAMESPACE, "lookup"),
ToolName::namespaced(DEFAULT_FUNCTION_NAMESPACE, "apply_patch"),
]
);
}
Ok(())
}
#[tokio::test]
async fn mcp_parallel_support_uses_handler_data() -> anyhow::Result<()> {
let (_, turn) = make_session_and_context().await;
+3
View File
@@ -205,6 +205,7 @@ fn apply_mcp_tool_exposure_policy(
let Some(omitted_exposures) = omitted_exposures_by_tool.get(&tool_name) else {
continue;
};
let tool_name = tool_name.with_default_namespace();
let mut exposures = ToolExposures::ALL.difference(*omitted_exposures);
if tool_name.namespace.as_ref().is_some_and(|namespace| {
@@ -368,6 +369,7 @@ fn apply_direct_model_only_namespace_overrides(
let configured = tool
.runtime
.tool_name()
.with_default_namespace()
.namespace
.as_ref()
.is_some_and(|namespace| {
@@ -591,6 +593,7 @@ fn is_hidden_by_code_mode_only(
}
fn is_excluded_from_code_mode(turn_context: &TurnContext, tool_name: &ToolName) -> bool {
let tool_name = tool_name.clone().with_default_namespace();
tool_name.namespace.as_ref().is_some_and(|namespace| {
turn_context
.config
@@ -1824,6 +1824,25 @@ async fn code_mode_only_exposes_configured_dynamic_namespace_directly() {
assert!(!exec.description.contains("direct_only_lookup(args:"));
}
#[tokio::test]
async fn code_mode_only_exposes_default_namespace_tools_directly() {
let plan = probe(|turn| {
set_features(turn, &[Feature::CodeMode, Feature::CodeModeOnly]);
update_config(turn, |config| {
config.code_mode.direct_only_tool_namespaces = vec!["functions".to_string()];
});
})
.await;
plan.assert_visible_contains(&["update_plan"]);
assert_eq!(plan.exposure("update_plan"), ToolExposure::DirectModelOnly);
let ToolSpec::Freeform(exec) = plan.visible_spec(codex_code_mode::PUBLIC_TOOL_NAME) else {
panic!("expected code mode exec tool");
};
assert!(!exec.description.contains("update_plan(args:"));
}
#[tokio::test]
async fn excluded_deferred_namespaces_do_not_enable_nested_tool_guidance() {
let plan = probe_with(
@@ -1860,6 +1879,26 @@ async fn excluded_deferred_namespaces_do_not_enable_nested_tool_guidance() {
]);
}
#[tokio::test]
async fn code_mode_excludes_default_namespace_tools() {
let plan = probe(|turn| {
set_feature(turn, Feature::CodeMode, /*enabled*/ true);
update_config(turn, |config| {
config.code_mode.excluded_tool_namespaces = vec!["functions".to_string()];
});
})
.await;
plan.assert_visible_contains(&["update_plan"]);
plan.assert_registered_contains(&["update_plan"]);
assert_eq!(plan.exposure("update_plan"), ToolExposure::Direct);
let ToolSpec::Freeform(exec) = plan.visible_spec(codex_code_mode::PUBLIC_TOOL_NAME) else {
panic!("expected code mode exec tool");
};
assert!(!exec.description.contains("update_plan(args:"));
}
#[tokio::test]
async fn multi_agent_feature_selects_one_agent_tool_family() {
let v1 = probe(|turn| {
@@ -80,7 +80,12 @@ fn tool_dispatch_invocation(invocation: &ToolInvocation) -> Option<ToolDispatchI
codex_turn_id: invocation.turn.sub_id.clone(),
tool_call_id: invocation.call_id.clone(),
tool_name: invocation.tool_name.name.clone(),
tool_namespace: invocation.tool_name.namespace.clone(),
tool_namespace: invocation
.tool_name
.namespace
.as_ref()
.filter(|_| !invocation.tool_name.is_default_namespace())
.cloned(),
requester,
payload: tool_dispatch_payload(&invocation.payload),
})
@@ -287,18 +287,24 @@ async fn missing_code_mode_wait_traces_only_the_wait_tool_call() -> anyhow::Resu
let session = Arc::new(session);
let turn = Arc::new(turn);
let mut invocation = test_invocation(
session,
turn,
"wait-call",
WAIT_TOOL_NAME,
ToolCallSource::Direct,
r#"{"cell_id":"noop","terminate":true}"#,
);
invocation.tool_name = invocation.tool_name.with_default_namespace();
assert!(
super::tool_dispatch_invocation(&invocation)
.expect("wait calls should produce a trace invocation")
.tool_namespace
.is_none()
);
registry
.dispatch_any_with_terminal_outcome(
test_invocation(
session,
turn,
"wait-call",
WAIT_TOOL_NAME,
ToolCallSource::Direct,
r#"{"cell_id":"noop","terminate":true}"#,
),
/*terminal_outcome_reached*/ None,
)
.dispatch_any_with_terminal_outcome(invocation, /*terminal_outcome_reached*/ None)
.await?;
let replayed = codex_rollout_trace::replay_bundle(single_bundle_dir(temp.path())?)?;
+8 -1
View File
@@ -4623,7 +4623,14 @@ text(JSON.stringify({
})
})
.and_then(|item| item["tools"].as_array())
.expect("the Responses Lite request should contain its additional tools")
.and_then(|tools| {
tools.iter().find(|tool| {
tool.get("type").and_then(Value::as_str) == Some("namespace")
&& tool.get("name").and_then(Value::as_str) == Some("functions")
})
})
.and_then(|namespace| namespace["tools"].as_array())
.expect("the Responses Lite request should contain its default-namespace tools")
} else {
first_body["tools"]
.as_array()
+13 -1
View File
@@ -129,7 +129,19 @@ async fn responses_lite_uses_input_items_for_instructions_and_tools() -> Result<
);
let tools = additional_tools(&body)?;
assert!(!tools.is_empty());
let functions_namespaces = tools
.iter()
.filter(|tool| tool["type"] == "namespace" && tool["name"] == "functions")
.collect::<Vec<_>>();
assert_eq!(functions_namespaces.len(), 1);
assert_eq!(functions_namespaces[0]["description"], "");
assert!(has_namespaced_tool(tools, "functions", "wait"));
assert!(has_namespaced_tool(tools, "functions", "exec"));
assert!(
tools
.iter()
.all(|tool| { !matches!(tool["type"].as_str(), Some("function" | "custom")) })
);
let client_metadata = body["client_metadata"]
.as_object()
.context("Responses request should include client metadata")?;
+17 -12
View File
@@ -55,7 +55,7 @@ use core_test_support::apps_test_server::search_capable_apps_builder as configur
use core_test_support::responses::ResponsesRequest;
use core_test_support::responses::ev_assistant_message;
use core_test_support::responses::ev_completed;
use core_test_support::responses::ev_custom_tool_call;
use core_test_support::responses::ev_custom_tool_call_with_namespace;
use core_test_support::responses::ev_function_call_with_namespace;
use core_test_support::responses::ev_response_created;
use core_test_support::responses::ev_tool_search_call;
@@ -1001,7 +1001,7 @@ async fn tool_search_returns_deferred_custom_tool_and_routes_follow_up_call() ->
]),
sse(vec![
ev_response_created("resp-2"),
ev_custom_tool_call("custom-1", "custom_echo", "hello"),
ev_custom_tool_call_with_namespace("custom-1", "functions", "custom_echo", "hello"),
ev_completed("resp-2"),
]),
sse(vec![
@@ -1035,15 +1035,20 @@ async fn tool_search_returns_deferred_custom_tool_and_routes_follow_up_call() ->
assert_eq!(
tool_search_output_tools(&requests[1], "search-1"),
vec![json!({
"type": "custom",
"name": "custom_echo",
"description": "Echo a custom payload.",
"defer_loading": true,
"format": {
"type": "grammar",
"syntax": "lark",
"definition": "start: /.+/",
},
"type": "namespace",
"name": "functions",
"description": "",
"tools": [{
"type": "custom",
"name": "custom_echo",
"description": "Echo a custom payload.",
"defer_loading": true,
"format": {
"type": "grammar",
"syntax": "lark",
"definition": "start: /.+/",
},
}],
})]
);
let output = requests[2].custom_tool_call_output("custom-1");
@@ -1052,7 +1057,7 @@ async fn tool_search_returns_deferred_custom_tool_and_routes_follow_up_call() ->
.as_str()
.expect("custom tool output should contain serialized JSON"),
)?;
assert_eq!(output, json!({ "echo": "hello", "namespace": null }));
assert_eq!(output, json!({ "echo": "hello", "namespace": "functions" }));
Ok(())
}
+1 -1
View File
@@ -372,7 +372,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;
+1
View File
@@ -9,6 +9,7 @@ pub use agent_path::AgentPath;
pub use response_item_id::ResponseItemId;
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;
+18
View File
@@ -3,6 +3,9 @@ use serde::Serialize;
use std::cmp::Ordering;
use std::fmt;
/// Namespace used 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)]
@@ -32,11 +35,26 @@ impl ToolName {
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 {
matches!(
self.namespace.as_deref(),
None | Some("") | Some(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),
}
+4
View File
@@ -178,6 +178,10 @@ 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)
+16
View File
@@ -1,4 +1,5 @@
use super::augment_tool_spec_for_code_mode;
use super::code_mode_name_for_tool_name;
use super::tool_spec_to_code_mode_tool_definition;
use crate::AdditionalProperties;
use crate::FreeformTool;
@@ -13,6 +14,21 @@ use pretty_assertions::assert_eq;
use serde_json::json;
use std::collections::BTreeMap;
#[test]
fn code_mode_tool_names_do_not_prefix_the_default_namespace() {
for tool_name in [
ToolName::plain("apply_patch"),
ToolName::namespaced("functions", "apply_patch"),
] {
assert_eq!(code_mode_name_for_tool_name(&tool_name), "apply_patch");
}
assert_eq!(
code_mode_name_for_tool_name(&ToolName::namespaced("editor", "apply_patch")),
"editor__apply_patch"
);
}
#[test]
fn augment_tool_spec_for_code_mode_augments_function_tools() {
assert_eq!(
+1
View File
@@ -106,4 +106,5 @@ 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;
pub use tool_spec::create_tools_raw_json_for_responses_api;
+9 -8
View File
@@ -3,6 +3,7 @@ use crate::ToolDefinition;
use crate::ToolName;
use crate::parse_dynamic_tool;
use crate::parse_mcp_tool;
use codex_protocol::DEFAULT_FUNCTION_NAMESPACE;
use codex_protocol::dynamic_tools::DynamicToolFunctionSpec;
use serde::Deserialize;
use serde::Serialize;
@@ -46,8 +47,6 @@ pub enum LoadableToolSpec {
#[allow(dead_code)]
#[serde(rename = "function")]
Function(ResponsesApiTool),
#[serde(rename = "custom")]
Custom(FreeformTool),
#[serde(rename = "namespace")]
Namespace(ResponsesApiNamespace),
}
@@ -60,7 +59,11 @@ pub struct ResponsesApiNamespace {
}
pub fn default_namespace_description(namespace_name: &str) -> String {
format!("Tools in the {namespace_name} namespace.")
if namespace_name == DEFAULT_FUNCTION_NAMESPACE {
String::new()
} else {
format!("Tools in the {namespace_name} namespace.")
}
}
#[derive(Debug, Clone, Serialize, PartialEq)]
@@ -87,8 +90,8 @@ pub fn coalesce_loadable_tool_specs(
let mut coalesced_specs = Vec::new();
for spec in specs {
match spec {
tool @ (LoadableToolSpec::Function(_) | LoadableToolSpec::Custom(_)) => {
coalesced_specs.push(tool);
LoadableToolSpec::Function(tool) => {
coalesced_specs.push(LoadableToolSpec::Function(tool));
}
LoadableToolSpec::Namespace(mut namespace) => {
if let Some(existing_namespace) =
@@ -98,9 +101,7 @@ pub fn coalesce_loadable_tool_specs(
{
Some(existing_namespace)
}
LoadableToolSpec::Function(_)
| LoadableToolSpec::Custom(_)
| LoadableToolSpec::Namespace(_) => None,
LoadableToolSpec::Function(_) | LoadableToolSpec::Namespace(_) => None,
})
{
existing_namespace.tools.append(&mut namespace.tools);
+12 -2
View File
@@ -1,10 +1,12 @@
use crate::JsonSchema;
use crate::LoadableToolSpec;
use crate::ResponsesApiNamespace;
use crate::ResponsesApiNamespaceTool;
use crate::ResponsesApiTool;
use crate::ToolSearchSourceInfo;
use crate::ToolSpec;
use crate::default_namespace_description;
use codex_protocol::DEFAULT_FUNCTION_NAMESPACE;
#[derive(Clone, PartialEq)]
pub struct ToolSearchEntry {
@@ -36,11 +38,19 @@ 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::Freeform(mut tool) => {
tool.defer_loading = Some(true);
LoadableToolSpec::Custom(tool)
LoadableToolSpec::Namespace(ResponsesApiNamespace {
name: DEFAULT_FUNCTION_NAMESPACE.to_string(),
description: default_namespace_description(DEFAULT_FUNCTION_NAMESPACE),
tools: vec![ResponsesApiNamespaceTool::Custom(tool)],
})
}
ToolSpec::Namespace(mut namespace) => {
if namespace.description.trim().is_empty() {
+44 -3
View File
@@ -2,6 +2,43 @@ use super::*;
use pretty_assertions::assert_eq;
use std::collections::BTreeMap;
#[test]
fn top_level_function_search_results_use_the_default_namespace() {
let function_tool = ResponsesApiTool {
name: "lookup_order".to_string(),
description: "Look up an order".to_string(),
strict: false,
defer_loading: None,
parameters: JsonSchema::object(
BTreeMap::new(),
/*required*/ None,
/*additional_properties*/ None,
),
output_schema: Some(serde_json::json!({ "type": "object" })),
};
let search_info = ToolSearchInfo::from_tool_spec(
ToolSpec::Function(function_tool.clone()),
/*source_info*/ None,
)
.expect("top-level function should be searchable");
assert_eq!(
(search_info.entry.search_text, search_info.entry.output),
(
"lookup_order lookup order Look up an order".to_string(),
LoadableToolSpec::Namespace(ResponsesApiNamespace {
name: "functions".to_string(),
description: String::new(),
tools: vec![ResponsesApiNamespaceTool::Function(ResponsesApiTool {
defer_loading: Some(true),
output_schema: None,
..function_tool
})],
}),
)
);
}
#[test]
fn top_level_custom_tools_are_searchable() {
let custom_tool = crate::FreeformTool {
@@ -24,9 +61,13 @@ fn top_level_custom_tools_are_searchable() {
(search_info.entry.search_text, search_info.entry.output),
(
"apply_patch Apply a patch lark".to_string(),
LoadableToolSpec::Custom(crate::FreeformTool {
defer_loading: Some(true),
..custom_tool
LoadableToolSpec::Namespace(ResponsesApiNamespace {
name: "functions".to_string(),
description: String::new(),
tools: vec![ResponsesApiNamespaceTool::Custom(crate::FreeformTool {
defer_loading: Some(true),
..custom_tool
})],
}),
)
);
+52 -1
View File
@@ -2,7 +2,10 @@ 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::DEFAULT_FUNCTION_NAMESPACE;
use codex_protocol::config_types::WebSearchContextSize;
use codex_protocol::config_types::WebSearchFilters as ConfigWebSearchFilters;
use codex_protocol::config_types::WebSearchUserLocation as ConfigWebSearchUserLocation;
@@ -68,7 +71,6 @@ impl From<LoadableToolSpec> for ToolSpec {
fn from(value: LoadableToolSpec) -> Self {
match value {
LoadableToolSpec::Function(tool) => ToolSpec::Function(tool),
LoadableToolSpec::Custom(tool) => ToolSpec::Freeform(tool),
LoadableToolSpec::Namespace(namespace) => ToolSpec::Namespace(namespace),
}
}
@@ -90,6 +92,55 @@ 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 = ResponsesApiNamespace {
name: DEFAULT_FUNCTION_NAMESPACE.to_string(),
description: default_namespace_description(DEFAULT_FUNCTION_NAMESPACE),
tools: Vec::new(),
};
let mut functions_index = None;
let mut tools_json = Vec::new();
for tool in tools {
match tool {
ToolSpec::Function(tool) => {
functions
.tools
.push(ResponsesApiNamespaceTool::Function(tool.clone()));
}
ToolSpec::Freeform(tool) => {
functions
.tools
.push(ResponsesApiNamespaceTool::Custom(tool.clone()));
}
ToolSpec::Namespace(namespace) if namespace.name == DEFAULT_FUNCTION_NAMESPACE => {
if !namespace.description.trim().is_empty() {
functions.description = namespace.description.clone();
}
functions.tools.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.tools.is_empty()
{
tools_json.insert(
functions_index,
serde_json::to_value(ToolSpec::Namespace(functions))?,
);
}
Ok(tools_json)
}
/// Returns raw JSON that can be embedded directly in a Responses API request.
pub fn create_tools_raw_json_for_responses_api(
tools: &[ToolSpec],
+102
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 crate::create_tools_raw_json_for_responses_api;
use codex_protocol::config_types::WebSearchContextSize;
use codex_protocol::config_types::WebSearchFilters as ConfigWebSearchFilters;
@@ -145,6 +146,107 @@ fn create_tools_json_for_responses_api_includes_top_level_name() {
);
}
fn responses_lite_function(name: &str) -> ResponsesApiTool {
ResponsesApiTool {
name: name.to_string(),
description: format!("{name} tool"),
strict: false,
defer_loading: None,
parameters: JsonSchema::object(
BTreeMap::new(),
/*required*/ None,
/*additional_properties*/ None,
),
output_schema: None,
}
}
#[test]
fn responses_lite_groups_default_function_and_custom_tools() {
let tools = create_tools_json_for_responses_lite(&[
ToolSpec::ToolSearch {
execution: "client".to_string(),
description: "Search tools".to_string(),
parameters: JsonSchema::object(
BTreeMap::new(),
/*required*/ None,
/*additional_properties*/ None,
),
},
ToolSpec::Function(responses_lite_function("lookup_order")),
ToolSpec::Namespace(ResponsesApiNamespace {
name: "editor".to_string(),
description: "Editing tools".to_string(),
tools: vec![ResponsesApiNamespaceTool::Function(
responses_lite_function("edit"),
)],
}),
ToolSpec::Freeform(FreeformTool {
name: "exec".to_string(),
description: "Run code".to_string(),
defer_loading: None,
format: FreeformToolFormat {
r#type: "grammar".to_string(),
syntax: "lark".to_string(),
definition: "start: /.+/".to_string(),
},
}),
ToolSpec::Namespace(ResponsesApiNamespace {
name: "functions".to_string(),
description: "Existing default tools".to_string(),
tools: vec![ResponsesApiNamespaceTool::Function(
responses_lite_function("existing"),
)],
}),
ToolSpec::Function(responses_lite_function("last")),
])
.expect("serialize Responses Lite tools");
assert_eq!(tools.len(), 3);
assert_eq!(tools[0]["type"], "tool_search");
assert_eq!(tools[2]["type"], "namespace");
assert_eq!(tools[2]["name"], "editor");
assert_eq!(tools[2]["tools"][0]["name"], "edit");
assert_eq!(tools[1]["type"], "namespace");
assert_eq!(tools[1]["name"], "functions");
assert_eq!(tools[1]["description"], "Existing default tools");
assert_eq!(
tools[1]["tools"]
.as_array()
.expect("functions namespace should contain tools")
.iter()
.map(|tool| (tool["type"].as_str(), tool["name"].as_str()))
.collect::<Vec<_>>(),
[
(Some("function"), Some("lookup_order")),
(Some("custom"), Some("exec")),
(Some("function"), Some("existing")),
(Some("function"), Some("last")),
]
);
}
#[test]
fn responses_lite_preserves_empty_functions_namespace_description() {
let tools = create_tools_json_for_responses_lite(&[ToolSpec::Function(
responses_lite_function("lookup_order"),
)])
.expect("serialize Responses Lite tools");
assert_eq!(tools[0]["description"], "");
}
#[test]
fn responses_lite_does_not_add_an_empty_functions_namespace() {
let tools =
create_tools_json_for_responses_lite(&[ToolSpec::Namespace(ResponsesApiNamespace {
name: "functions".to_string(),
description: "Empty default tools".to_string(),
tools: Vec::new(),
})])
.expect("serialize Responses Lite tools");
assert!(tools.is_empty());
}
#[test]
fn raw_tool_json_matches_value_encoding() {
let specs = vec![ToolSpec::Function(ResponsesApiTool {