[tool search] support namespaced deferred dynamic tools (#18413)

Deferred dynamic tools need to round-trip a namespace so a tool returned
by `tool_search` can be called through the same registry key that core
uses for dispatch.

This change adds namespace support for dynamic tool specs/calls,
persists it through app-server thread state, and routes dynamic tool
calls by full `ToolName` while still sending the app the leaf tool name.
Deferred dynamic tools must provide a namespace; non-deferred dynamic
tools may remain top-level.

It also introduces `LoadableToolSpec` as the shared
function-or-namespace Responses shape used by both `tool_search` output
and dynamic tool registration, so dynamic tools use the same wrapping
logic in both paths.

Validation:
- `cargo test -p codex-tools`
- `cargo test -p codex-core tool_search`

---------

Co-authored-by: Sayan Sisodiya <sayan@openai.com>
This commit is contained in:
pash-openai
2026-04-20 23:13:08 -07:00
committed by GitHub
Unverified
parent 1dcea729d3
commit dc1a8f2190
60 changed files with 676 additions and 147 deletions
@@ -532,6 +532,12 @@
"inputSchema": true,
"name": {
"type": "string"
},
"namespace": {
"type": [
"string",
"null"
]
}
},
"required": [
@@ -5,6 +5,12 @@
"callId": {
"type": "string"
},
"namespace": {
"type": [
"string",
"null"
]
},
"threadId": {
"type": "string"
},
@@ -24,4 +30,4 @@
],
"title": "DynamicToolCallParams",
"type": "object"
}
}
@@ -3343,6 +3343,12 @@
"id": {
"type": "string"
},
"namespace": {
"type": [
"string",
"null"
]
},
"status": {
"$ref": "#/definitions/DynamicToolCallStatus"
},
@@ -434,6 +434,12 @@
"callId": {
"type": "string"
},
"namespace": {
"type": [
"string",
"null"
]
},
"threadId": {
"type": "string"
},
@@ -1965,6 +1965,12 @@
"callId": {
"type": "string"
},
"namespace": {
"type": [
"string",
"null"
]
},
"threadId": {
"type": "string"
},
@@ -7381,6 +7387,12 @@
"inputSchema": true,
"name": {
"type": "string"
},
"namespace": {
"type": [
"string",
"null"
]
}
},
"required": [
@@ -14085,6 +14097,12 @@
"id": {
"type": "string"
},
"namespace": {
"type": [
"null",
"string"
]
},
"status": {
"$ref": "#/definitions/v2/DynamicToolCallStatus"
},
@@ -16594,4 +16612,4 @@
},
"title": "CodexAppServerProtocol",
"type": "object"
}
}
@@ -3998,6 +3998,12 @@
"inputSchema": true,
"name": {
"type": "string"
},
"namespace": {
"type": [
"string",
"null"
]
}
},
"required": [
@@ -11989,6 +11995,12 @@
"id": {
"type": "string"
},
"namespace": {
"type": [
"string",
"null"
]
},
"status": {
"$ref": "#/definitions/DynamicToolCallStatus"
},
@@ -854,6 +854,12 @@
"id": {
"type": "string"
},
"namespace": {
"type": [
"string",
"null"
]
},
"status": {
"$ref": "#/definitions/DynamicToolCallStatus"
},
@@ -854,6 +854,12 @@
"id": {
"type": "string"
},
"namespace": {
"type": [
"string",
"null"
]
},
"status": {
"$ref": "#/definitions/DynamicToolCallStatus"
},
@@ -997,6 +997,12 @@
"id": {
"type": "string"
},
"namespace": {
"type": [
"string",
"null"
]
},
"status": {
"$ref": "#/definitions/DynamicToolCallStatus"
},
@@ -1511,6 +1511,12 @@
"id": {
"type": "string"
},
"namespace": {
"type": [
"string",
"null"
]
},
"status": {
"$ref": "#/definitions/DynamicToolCallStatus"
},
@@ -1273,6 +1273,12 @@
"id": {
"type": "string"
},
"namespace": {
"type": [
"string",
"null"
]
},
"status": {
"$ref": "#/definitions/DynamicToolCallStatus"
},
@@ -1273,6 +1273,12 @@
"id": {
"type": "string"
},
"namespace": {
"type": [
"string",
"null"
]
},
"status": {
"$ref": "#/definitions/DynamicToolCallStatus"
},
@@ -1273,6 +1273,12 @@
"id": {
"type": "string"
},
"namespace": {
"type": [
"string",
"null"
]
},
"status": {
"$ref": "#/definitions/DynamicToolCallStatus"
},
@@ -1511,6 +1511,12 @@
"id": {
"type": "string"
},
"namespace": {
"type": [
"string",
"null"
]
},
"status": {
"$ref": "#/definitions/DynamicToolCallStatus"
},
@@ -1273,6 +1273,12 @@
"id": {
"type": "string"
},
"namespace": {
"type": [
"string",
"null"
]
},
"status": {
"$ref": "#/definitions/DynamicToolCallStatus"
},
@@ -70,6 +70,12 @@
"inputSchema": true,
"name": {
"type": "string"
},
"namespace": {
"type": [
"string",
"null"
]
}
},
"required": [
@@ -1511,6 +1511,12 @@
"id": {
"type": "string"
},
"namespace": {
"type": [
"string",
"null"
]
},
"status": {
"$ref": "#/definitions/DynamicToolCallStatus"
},
@@ -1273,6 +1273,12 @@
"id": {
"type": "string"
},
"namespace": {
"type": [
"string",
"null"
]
},
"status": {
"$ref": "#/definitions/DynamicToolCallStatus"
},
@@ -997,6 +997,12 @@
"id": {
"type": "string"
},
"namespace": {
"type": [
"string",
"null"
]
},
"status": {
"$ref": "#/definitions/DynamicToolCallStatus"
},
@@ -1273,6 +1273,12 @@
"id": {
"type": "string"
},
"namespace": {
"type": [
"string",
"null"
]
},
"status": {
"$ref": "#/definitions/DynamicToolCallStatus"
},
@@ -997,6 +997,12 @@
"id": {
"type": "string"
},
"namespace": {
"type": [
"string",
"null"
]
},
"status": {
"$ref": "#/definitions/DynamicToolCallStatus"
},
@@ -997,6 +997,12 @@
"id": {
"type": "string"
},
"namespace": {
"type": [
"string",
"null"
]
},
"status": {
"$ref": "#/definitions/DynamicToolCallStatus"
},
@@ -997,6 +997,12 @@
"id": {
"type": "string"
},
"namespace": {
"type": [
"string",
"null"
]
},
"status": {
"$ref": "#/definitions/DynamicToolCallStatus"
},
@@ -3,4 +3,4 @@
// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually.
import type { JsonValue } from "../serde_json/JsonValue";
export type DynamicToolCallParams = { threadId: string, turnId: string, callId: string, tool: string, arguments: JsonValue, };
export type DynamicToolCallParams = { threadId: string, turnId: string, callId: string, namespace: string | null, tool: string, arguments: JsonValue, };
@@ -3,4 +3,4 @@
// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually.
import type { JsonValue } from "../serde_json/JsonValue";
export type DynamicToolSpec = { name: string, description: string, inputSchema: JsonValue, deferLoading?: boolean, };
export type DynamicToolSpec = { namespace?: string, name: string, description: string, inputSchema: JsonValue, deferLoading?: boolean, };
@@ -57,7 +57,7 @@ durationMs: number | null, } | { "type": "fileChange", id: string, changes: Arra
/**
* The duration of the MCP tool call in milliseconds.
*/
durationMs: number | null, } | { "type": "dynamicToolCall", id: string, tool: string, arguments: JsonValue, status: DynamicToolCallStatus, contentItems: Array<DynamicToolCallOutputContentItem> | null, success: boolean | null,
durationMs: number | null, } | { "type": "dynamicToolCall", id: string, namespace: string | null, tool: string, arguments: JsonValue, status: DynamicToolCallStatus, contentItems: Array<DynamicToolCallOutputContentItem> | null, success: boolean | null,
/**
* The duration of the dynamic tool call in milliseconds.
*/
@@ -470,6 +470,7 @@ impl ThreadHistoryBuilder {
) {
let item = ThreadItem::DynamicToolCall {
id: payload.call_id.clone(),
namespace: payload.namespace.clone(),
tool: payload.tool.clone(),
arguments: payload.arguments.clone(),
status: DynamicToolCallStatus::InProgress,
@@ -493,6 +494,7 @@ impl ThreadHistoryBuilder {
let duration_ms = i64::try_from(payload.duration.as_millis()).ok();
let item = ThreadItem::DynamicToolCall {
id: payload.call_id.clone(),
namespace: payload.namespace.clone(),
tool: payload.tool.clone(),
arguments: payload.arguments.clone(),
status,
@@ -1975,6 +1977,7 @@ mod tests {
codex_protocol::dynamic_tools::DynamicToolCallRequest {
call_id: "dyn-1".into(),
turn_id: "turn-1".into(),
namespace: Some("codex_app".into()),
tool: "lookup_ticket".into(),
arguments: serde_json::json!({"id":"ABC-123"}),
},
@@ -1982,6 +1985,7 @@ mod tests {
EventMsg::DynamicToolCallResponse(DynamicToolCallResponseEvent {
call_id: "dyn-1".into(),
turn_id: "turn-1".into(),
namespace: Some("codex_app".into()),
tool: "lookup_ticket".into(),
arguments: serde_json::json!({"id":"ABC-123"}),
content_items: vec![CoreDynamicToolCallOutputContentItem::InputText {
@@ -2004,6 +2008,7 @@ mod tests {
turns[0].items[1],
ThreadItem::DynamicToolCall {
id: "dyn-1".into(),
namespace: Some("codex_app".into()),
tool: "lookup_ticket".into(),
arguments: serde_json::json!({"id":"ABC-123"}),
status: DynamicToolCallStatus::Completed,
@@ -613,6 +613,8 @@ pub struct ToolsV2 {
#[serde(rename_all = "camelCase")]
#[ts(export_to = "v2/")]
pub struct DynamicToolSpec {
#[ts(optional)]
pub namespace: Option<String>,
pub name: String,
pub description: String,
pub input_schema: JsonValue,
@@ -623,6 +625,7 @@ pub struct DynamicToolSpec {
#[derive(Deserialize)]
#[serde(rename_all = "camelCase")]
struct DynamicToolSpecDe {
namespace: Option<String>,
name: String,
description: String,
input_schema: JsonValue,
@@ -636,6 +639,7 @@ impl<'de> Deserialize<'de> for DynamicToolSpec {
D: serde::Deserializer<'de>,
{
let DynamicToolSpecDe {
namespace,
name,
description,
input_schema,
@@ -644,6 +648,7 @@ impl<'de> Deserialize<'de> for DynamicToolSpec {
} = DynamicToolSpecDe::deserialize(deserializer)?;
Ok(Self {
namespace,
name,
description,
input_schema,
@@ -4887,6 +4892,7 @@ pub enum ThreadItem {
#[ts(rename_all = "camelCase")]
DynamicToolCall {
id: String,
namespace: Option<String>,
tool: String,
arguments: JsonValue,
status: DynamicToolCallStatus,
@@ -6650,6 +6656,7 @@ pub struct DynamicToolCallParams {
pub thread_id: String,
pub turn_id: String,
pub call_id: String,
pub namespace: Option<String>,
pub tool: String,
pub arguments: JsonValue,
}
@@ -9303,6 +9310,7 @@ mod tests {
assert_eq!(
actual,
DynamicToolSpec {
namespace: None,
name: "lookup_ticket".to_string(),
description: "Fetch a ticket".to_string(),
input_schema: json!({
@@ -954,10 +954,12 @@ pub(crate) async fn apply_bespoke_event_handling(
if matches!(api_version, ApiVersion::V2) {
let call_id = request.call_id;
let turn_id = request.turn_id;
let namespace = request.namespace;
let tool = request.tool;
let arguments = request.arguments;
let item = ThreadItem::DynamicToolCall {
id: call_id.clone(),
namespace: namespace.clone(),
tool: tool.clone(),
arguments: arguments.clone(),
status: DynamicToolCallStatus::InProgress,
@@ -977,6 +979,7 @@ pub(crate) async fn apply_bespoke_event_handling(
thread_id: conversation_id.to_string(),
turn_id: turn_id.clone(),
call_id: call_id.clone(),
namespace,
tool: tool.clone(),
arguments: arguments.clone(),
};
@@ -1015,6 +1018,7 @@ pub(crate) async fn apply_bespoke_event_handling(
let duration_ms = i64::try_from(response.duration.as_millis()).ok();
let item = ThreadItem::DynamicToolCall {
id: response.call_id,
namespace: response.namespace,
tool: response.tool,
arguments: response.arguments,
status,
@@ -2662,6 +2662,7 @@ impl CodexMessageProcessor {
dynamic_tools
.into_iter()
.map(|tool| CoreDynamicToolSpec {
namespace: tool.namespace,
name: tool.name,
description: tool.description,
input_schema: tool.input_schema,
@@ -9476,9 +9477,37 @@ fn validate_dynamic_tools(tools: &[ApiDynamicToolSpec]) -> Result<(), String> {
if name == "mcp" || name.starts_with("mcp__") {
return Err(format!("dynamic tool name is reserved: {name}"));
}
if !seen.insert(name.to_string()) {
let namespace = tool.namespace.as_deref().map(str::trim);
if let Some(namespace) = namespace {
if namespace.is_empty() {
return Err(format!(
"dynamic tool namespace must not be empty for {name}"
));
}
if Some(namespace) != tool.namespace.as_deref() {
return Err(format!(
"dynamic tool namespace has leading/trailing whitespace for {name}: {namespace}",
));
}
if namespace == "mcp" || namespace.starts_with("mcp__") {
return Err(format!(
"dynamic tool namespace is reserved for {name}: {namespace}"
));
}
}
if !seen.insert((namespace, name)) {
if let Some(namespace) = namespace {
return Err(format!(
"duplicate dynamic tool name in namespace {namespace}: {name}"
));
}
return Err(format!("duplicate dynamic tool name: {name}"));
}
if tool.defer_loading && namespace.is_none() {
return Err(format!(
"deferred dynamic tool must include a namespace: {name}"
));
}
if let Err(err) = codex_tools::parse_tool_input_schema(&tool.input_schema) {
return Err(format!(
@@ -10514,6 +10543,7 @@ mod tests {
#[test]
fn validate_dynamic_tools_rejects_unsupported_input_schema() {
let tools = vec![ApiDynamicToolSpec {
namespace: None,
name: "my_tool".to_string(),
description: "test".to_string(),
input_schema: json!({"type": "null"}),
@@ -10526,6 +10556,7 @@ mod tests {
#[test]
fn validate_dynamic_tools_accepts_sanitizable_input_schema() {
let tools = vec![ApiDynamicToolSpec {
namespace: None,
name: "my_tool".to_string(),
description: "test".to_string(),
// Missing `type` is common; core sanitizes these to a supported schema.
@@ -10538,6 +10569,7 @@ mod tests {
#[test]
fn validate_dynamic_tools_accepts_nullable_field_schema() {
let tools = vec![ApiDynamicToolSpec {
namespace: None,
name: "my_tool".to_string(),
description: "test".to_string(),
input_schema: json!({
@@ -10553,6 +10585,102 @@ mod tests {
validate_dynamic_tools(&tools).expect("valid schema");
}
#[test]
fn validate_dynamic_tools_accepts_same_name_in_different_namespaces() {
let tools = vec![
ApiDynamicToolSpec {
namespace: Some("codex_app".to_string()),
name: "my_tool".to_string(),
description: "test".to_string(),
input_schema: json!({
"type": "object",
"properties": {},
"additionalProperties": false
}),
defer_loading: true,
},
ApiDynamicToolSpec {
namespace: Some("other_app".to_string()),
name: "my_tool".to_string(),
description: "test".to_string(),
input_schema: json!({
"type": "object",
"properties": {},
"additionalProperties": false
}),
defer_loading: true,
},
];
validate_dynamic_tools(&tools).expect("valid schema");
}
#[test]
fn validate_dynamic_tools_rejects_duplicate_name_in_same_namespace() {
let tools = vec![
ApiDynamicToolSpec {
namespace: Some("codex_app".to_string()),
name: "my_tool".to_string(),
description: "test".to_string(),
input_schema: json!({
"type": "object",
"properties": {},
"additionalProperties": false
}),
defer_loading: true,
},
ApiDynamicToolSpec {
namespace: Some("codex_app".to_string()),
name: "my_tool".to_string(),
description: "test".to_string(),
input_schema: json!({
"type": "object",
"properties": {},
"additionalProperties": false
}),
defer_loading: true,
},
];
let err = validate_dynamic_tools(&tools).expect_err("duplicate name");
assert!(err.contains("codex_app"), "unexpected error: {err}");
assert!(err.contains("my_tool"), "unexpected error: {err}");
}
#[test]
fn validate_dynamic_tools_rejects_empty_namespace() {
let tools = vec![ApiDynamicToolSpec {
namespace: Some("".to_string()),
name: "my_tool".to_string(),
description: "test".to_string(),
input_schema: json!({
"type": "object",
"properties": {},
"additionalProperties": false
}),
defer_loading: false,
}];
let err = validate_dynamic_tools(&tools).expect_err("empty namespace");
assert!(err.contains("my_tool"), "unexpected error: {err}");
assert!(err.contains("namespace"), "unexpected error: {err}");
}
#[test]
fn validate_dynamic_tools_rejects_reserved_namespace() {
let tools = vec![ApiDynamicToolSpec {
namespace: Some("mcp__server__".to_string()),
name: "my_tool".to_string(),
description: "test".to_string(),
input_schema: json!({
"type": "object",
"properties": {},
"additionalProperties": false
}),
defer_loading: false,
}];
let err = validate_dynamic_tools(&tools).expect_err("reserved namespace");
assert!(err.contains("my_tool"), "unexpected error: {err}");
assert!(err.contains("reserved"), "unexpected error: {err}");
}
#[test]
fn summary_from_stored_thread_preserves_millisecond_precision() {
let created_at =
@@ -1067,6 +1067,7 @@ mod tests {
thread_id: thread_id.to_string(),
turn_id: "turn-1".to_string(),
call_id: "call-0".to_string(),
namespace: None,
tool: "tool".to_string(),
arguments: json!({}),
},
@@ -1124,6 +1125,7 @@ mod tests {
thread_id: thread_id.to_string(),
turn_id: "turn-1".to_string(),
call_id: "call-0".to_string(),
namespace: None,
tool: "tool".to_string(),
arguments: json!({}),
},
@@ -64,6 +64,7 @@ async fn thread_start_injects_dynamic_tools_into_model_requests() -> Result<()>
"additionalProperties": false,
});
let dynamic_tool = DynamicToolSpec {
namespace: None,
name: "demo_tool".to_string(),
description: "Demo dynamic tool".to_string(),
input_schema: input_schema.clone(),
@@ -137,6 +138,7 @@ async fn thread_start_keeps_hidden_dynamic_tools_out_of_model_requests() -> Resu
timeout(DEFAULT_READ_TIMEOUT, mcp.initialize()).await??;
let dynamic_tool = DynamicToolSpec {
namespace: Some("codex_app".to_string()),
name: "hidden_tool".to_string(),
description: "Hidden dynamic tool".to_string(),
input_schema: json!({
@@ -197,10 +199,51 @@ async fn thread_start_keeps_hidden_dynamic_tools_out_of_model_requests() -> Resu
Ok(())
}
#[tokio::test]
async fn thread_start_rejects_hidden_dynamic_tools_without_namespace() -> Result<()> {
let server = MockServer::start().await;
let codex_home = TempDir::new()?;
create_config_toml(codex_home.path(), &server.uri())?;
let mut mcp = McpProcess::new(codex_home.path()).await?;
timeout(DEFAULT_READ_TIMEOUT, mcp.initialize()).await??;
let dynamic_tool = DynamicToolSpec {
namespace: None,
name: "hidden_tool".to_string(),
description: "Hidden dynamic tool".to_string(),
input_schema: json!({
"type": "object",
"properties": {},
"additionalProperties": false,
}),
defer_loading: true,
};
let thread_req = mcp
.send_thread_start_request(ThreadStartParams {
dynamic_tools: Some(vec![dynamic_tool]),
..Default::default()
})
.await?;
let error = timeout(
DEFAULT_READ_TIMEOUT,
mcp.read_stream_until_error_message(RequestId::Integer(thread_req)),
)
.await??;
assert_eq!(error.error.code, -32600);
assert!(error.error.message.contains("hidden_tool"));
assert!(error.error.message.contains("namespace"));
Ok(())
}
/// Exercises the full dynamic tool call path (server request, client response, model output).
#[tokio::test]
async fn dynamic_tool_call_round_trip_sends_text_content_items_to_model() -> Result<()> {
let call_id = "dyn-call-1";
let tool_namespace = "codex_app";
let tool_name = "demo_tool";
let tool_args = json!({ "city": "Paris" });
let tool_call_arguments = serde_json::to_string(&tool_args)?;
@@ -209,7 +252,16 @@ async fn dynamic_tool_call_round_trip_sends_text_content_items_to_model() -> Res
let responses = vec![
responses::sse(vec![
responses::ev_response_created("resp-1"),
responses::ev_function_call(call_id, tool_name, &tool_call_arguments),
json!({
"type": "response.output_item.done",
"item": {
"type": "function_call",
"call_id": call_id,
"namespace": tool_namespace,
"name": tool_name,
"arguments": tool_call_arguments,
}
}),
responses::ev_completed("resp-1"),
]),
create_final_assistant_message_sse_response("Done")?,
@@ -223,6 +275,7 @@ async fn dynamic_tool_call_round_trip_sends_text_content_items_to_model() -> Res
timeout(DEFAULT_READ_TIMEOUT, mcp.initialize()).await??;
let dynamic_tool = DynamicToolSpec {
namespace: Some(tool_namespace.to_string()),
name: tool_name.to_string(),
description: "Demo dynamic tool".to_string(),
input_schema: json!({
@@ -274,6 +327,7 @@ async fn dynamic_tool_call_round_trip_sends_text_content_items_to_model() -> Res
assert_eq!(started.turn_id, turn_id.clone());
let ThreadItem::DynamicToolCall {
id,
namespace,
tool,
arguments,
status,
@@ -285,6 +339,7 @@ async fn dynamic_tool_call_round_trip_sends_text_content_items_to_model() -> Res
panic!("expected dynamic tool call item");
};
assert_eq!(id, call_id);
assert_eq!(namespace.as_deref(), Some(tool_namespace));
assert_eq!(tool, tool_name);
assert_eq!(arguments, tool_args);
assert_eq!(status, DynamicToolCallStatus::InProgress);
@@ -307,6 +362,7 @@ async fn dynamic_tool_call_round_trip_sends_text_content_items_to_model() -> Res
thread_id: thread_id.clone(),
turn_id: turn_id.clone(),
call_id: call_id.to_string(),
namespace: Some(tool_namespace.to_string()),
tool: tool_name.to_string(),
arguments: tool_args.clone(),
};
@@ -327,6 +383,7 @@ async fn dynamic_tool_call_round_trip_sends_text_content_items_to_model() -> Res
assert_eq!(completed.turn_id, turn_id);
let ThreadItem::DynamicToolCall {
id,
namespace,
tool,
arguments,
status,
@@ -338,6 +395,7 @@ async fn dynamic_tool_call_round_trip_sends_text_content_items_to_model() -> Res
panic!("expected dynamic tool call item");
};
assert_eq!(id, call_id);
assert_eq!(namespace.as_deref(), Some(tool_namespace));
assert_eq!(tool, tool_name);
assert_eq!(arguments, tool_args);
assert_eq!(status, DynamicToolCallStatus::Completed);
@@ -392,6 +450,7 @@ async fn dynamic_tool_call_round_trip_sends_content_items_to_model() -> Result<(
timeout(DEFAULT_READ_TIMEOUT, mcp.initialize()).await??;
let dynamic_tool = DynamicToolSpec {
namespace: None,
name: tool_name.to_string(),
description: "Demo dynamic tool".to_string(),
input_schema: json!({
@@ -455,6 +514,7 @@ async fn dynamic_tool_call_round_trip_sends_content_items_to_model() -> Result<(
thread_id,
turn_id: turn_id.clone(),
call_id: call_id.to_string(),
namespace: None,
tool: tool_name.to_string(),
arguments: tool_args,
};
@@ -127,6 +127,7 @@ async fn thread_unsubscribe_during_turn_keeps_turn_running() -> Result<()> {
.send_thread_start_request(ThreadStartParams {
model: Some("mock-model".to_string()),
dynamic_tools: Some(vec![DynamicToolSpec {
namespace: None,
name: tool_name.to_string(),
description: "Deterministic wait tool".to_string(),
input_schema: json!({
@@ -194,6 +195,7 @@ async fn thread_unsubscribe_during_turn_keeps_turn_running() -> Result<()> {
thread_id: thread_id.clone(),
turn_id: started.turn_id,
call_id: call_id.to_string(),
namespace: None,
tool: tool_name.to_string(),
arguments: tool_args,
}
+33 -2
View File
@@ -94,7 +94,9 @@ use codex_protocol::protocol::ReasoningRawContentDeltaEvent;
use codex_protocol::protocol::TurnDiffEvent;
use codex_protocol::protocol::WarningEvent;
use codex_protocol::user_input::UserInput;
use codex_tools::ResponsesApiNamespaceTool;
use codex_tools::ToolName;
use codex_tools::ToolSpec;
use codex_tools::filter_tool_suggest_discoverable_tools_for_client;
use codex_utils_stream_parser::AssistantTextChunk;
use codex_utils_stream_parser::AssistantTextStreamParser;
@@ -979,7 +981,7 @@ pub(crate) fn build_prompt(
.dynamic_tools
.iter()
.filter(|tool| tool.defer_loading)
.map(|tool| tool.name.as_str())
.map(|tool| ToolName::new(tool.namespace.clone(), tool.name.clone()))
.collect::<HashSet<_>>();
let tools = if deferred_dynamic_tools.is_empty() {
router.model_visible_specs()
@@ -987,7 +989,7 @@ pub(crate) fn build_prompt(
router
.model_visible_specs()
.into_iter()
.filter(|spec| !deferred_dynamic_tools.contains(spec.name()))
.filter_map(|spec| filter_deferred_dynamic_tool_spec(spec, &deferred_dynamic_tools))
.collect()
};
@@ -1001,6 +1003,35 @@ pub(crate) fn build_prompt(
}
}
fn filter_deferred_dynamic_tool_spec(
spec: ToolSpec,
deferred_dynamic_tools: &HashSet<ToolName>,
) -> Option<ToolSpec> {
match spec {
ToolSpec::Function(tool) => {
if deferred_dynamic_tools.contains(&ToolName::plain(tool.name.as_str())) {
None
} else {
Some(ToolSpec::Function(tool))
}
}
ToolSpec::Namespace(mut namespace) => {
let namespace_name = namespace.name.clone();
namespace.tools.retain(|tool| match tool {
ResponsesApiNamespaceTool::Function(tool) => !deferred_dynamic_tools.contains(
&ToolName::namespaced(namespace_name.as_str(), tool.name.as_str()),
),
});
if namespace.tools.is_empty() {
None
} else {
Some(ToolSpec::Namespace(namespace))
}
}
spec => Some(spec),
}
}
#[allow(clippy::too_many_arguments)]
#[instrument(level = "trace",
skip_all,
+2 -2
View File
@@ -15,8 +15,8 @@ use codex_protocol::models::ResponseInputItem;
use codex_protocol::models::SearchToolCallParams;
use codex_protocol::models::ShellToolCallParams;
use codex_protocol::models::function_call_output_content_items_to_text;
use codex_tools::LoadableToolSpec;
use codex_tools::ToolName;
use codex_tools::ToolSearchOutputTool;
use codex_utils_output_truncation::TruncationPolicy;
use codex_utils_output_truncation::formatted_truncate_text;
use codex_utils_string::take_bytes_at_char_boundary;
@@ -186,7 +186,7 @@ impl McpToolOutput {
#[derive(Clone)]
pub struct ToolSearchOutput {
pub tools: Vec<ToolSearchOutputTool>,
pub tools: Vec<LoadableToolSpec>,
}
impl ToolOutput for ToolSearchOutput {
+12 -14
View File
@@ -284,20 +284,18 @@ fn tool_search_payloads_roundtrip_as_tool_search_outputs() {
},
};
let response = ToolSearchOutput {
tools: vec![ToolSearchOutputTool::Function(
codex_tools::ResponsesApiTool {
name: "create_event".to_string(),
description: String::new(),
strict: false,
defer_loading: Some(true),
parameters: codex_tools::JsonSchema::object(
/*properties*/ Default::default(),
/*required*/ None,
/*additional_properties*/ None,
),
output_schema: None,
},
)],
tools: vec![LoadableToolSpec::Function(codex_tools::ResponsesApiTool {
name: "create_event".to_string(),
description: String::new(),
strict: false,
defer_loading: Some(true),
parameters: codex_tools::JsonSchema::object(
/*properties*/ Default::default(),
/*required*/ None,
/*additional_properties*/ None,
),
output_schema: None,
})],
}
.to_response_item("search-1", &payload);
+14 -9
View File
@@ -12,6 +12,7 @@ use codex_protocol::dynamic_tools::DynamicToolResponse;
use codex_protocol::models::FunctionCallOutputContentItem;
use codex_protocol::protocol::DynamicToolCallResponseEvent;
use codex_protocol::protocol::EventMsg;
use codex_tools::ToolName;
use serde_json::Value;
use std::time::Instant;
use tokio::sync::oneshot;
@@ -50,14 +51,13 @@ impl ToolHandler for DynamicToolHandler {
};
let args: Value = parse_arguments(&arguments)?;
let response =
request_dynamic_tool(&session, turn.as_ref(), call_id, tool_name.display(), args)
.await
.ok_or_else(|| {
FunctionCallError::RespondToModel(
"dynamic tool call was cancelled before receiving a response".to_string(),
)
})?;
let response = request_dynamic_tool(&session, turn.as_ref(), call_id, tool_name, args)
.await
.ok_or_else(|| {
FunctionCallError::RespondToModel(
"dynamic tool call was cancelled before receiving a response".to_string(),
)
})?;
let DynamicToolResponse {
content_items,
@@ -79,9 +79,11 @@ async fn request_dynamic_tool(
session: &Session,
turn_context: &TurnContext,
call_id: String,
tool: String,
tool_name: ToolName,
arguments: Value,
) -> Option<DynamicToolResponse> {
let namespace = tool_name.namespace;
let tool = tool_name.name;
let turn_id = turn_context.sub_id.clone();
let (tx_response, rx_response) = oneshot::channel();
let event_id = call_id.clone();
@@ -103,6 +105,7 @@ async fn request_dynamic_tool(
let event = EventMsg::DynamicToolCallRequest(DynamicToolCallRequest {
call_id: call_id.clone(),
turn_id: turn_id.clone(),
namespace: namespace.clone(),
tool: tool.clone(),
arguments: arguments.clone(),
});
@@ -113,6 +116,7 @@ async fn request_dynamic_tool(
Some(response) => EventMsg::DynamicToolCallResponse(DynamicToolCallResponseEvent {
call_id,
turn_id,
namespace,
tool,
arguments,
content_items: response.content_items.clone(),
@@ -123,6 +127,7 @@ async fn request_dynamic_tool(
None => EventMsg::DynamicToolCallResponse(DynamicToolCallResponseEvent {
call_id,
turn_id,
namespace,
tool,
arguments,
content_items: Vec::new(),
+28 -46
View File
@@ -9,9 +9,10 @@ use bm25::Document;
use bm25::Language;
use bm25::SearchEngine;
use bm25::SearchEngineBuilder;
use codex_tools::LoadableToolSpec;
use codex_tools::TOOL_SEARCH_DEFAULT_LIMIT;
use codex_tools::TOOL_SEARCH_TOOL_NAME;
use codex_tools::ToolSearchOutputTool;
use codex_tools::coalesce_loadable_tool_specs;
use std::collections::HashMap;
const COMPUTER_USE_MCP_SERVER_NAME: &str = "computer-use";
@@ -93,7 +94,7 @@ impl ToolSearchHandler {
query: &str,
limit: usize,
use_default_limit: bool,
) -> Result<Vec<ToolSearchOutputTool>, FunctionCallError> {
) -> Result<Vec<LoadableToolSpec>, FunctionCallError> {
let results = self.search_result_entries(query, limit, use_default_limit);
self.search_output_tools(results)
}
@@ -135,34 +136,10 @@ impl ToolSearchHandler {
fn search_output_tools<'a>(
&self,
results: impl IntoIterator<Item = &'a ToolSearchEntry>,
) -> Result<Vec<ToolSearchOutputTool>, FunctionCallError> {
let mut tools = Vec::new();
// Preserve search order: group namespace children, emit standalone tools directly.
for entry in results {
match &entry.output {
ToolSearchOutputTool::Function(tool) => {
tools.push(ToolSearchOutputTool::Function(tool.clone()));
}
ToolSearchOutputTool::Namespace(namespace) => {
if let Some(output) = tools.iter_mut().find_map(|tool| match tool {
ToolSearchOutputTool::Namespace(output)
if output.name == namespace.name =>
{
Some(output)
}
ToolSearchOutputTool::Namespace(_) | ToolSearchOutputTool::Function(_) => {
None
}
}) {
output.tools.extend(namespace.tools.clone());
} else {
tools.push(ToolSearchOutputTool::Namespace(namespace.clone()));
}
}
}
}
Ok(tools)
) -> Result<Vec<LoadableToolSpec>, FunctionCallError> {
Ok(coalesce_loadable_tool_specs(
results.into_iter().map(|entry| entry.output.clone()),
))
}
}
@@ -209,6 +186,7 @@ mod tests {
#[test]
fn mixed_search_results_coalesce_mcp_namespaces() {
let dynamic_tools = vec![DynamicToolSpec {
namespace: Some("codex_app".to_string()),
name: "automation_update".to_string(),
description: "Create, update, view, or delete recurring automations.".to_string(),
input_schema: serde_json::json!({
@@ -247,7 +225,7 @@ mod tests {
assert_eq!(
tools,
vec![
ToolSearchOutputTool::Namespace(ResponsesApiNamespace {
LoadableToolSpec::Namespace(ResponsesApiNamespace {
name: "mcp__calendar__".to_string(),
description: "Tools in the mcp__calendar__ namespace.".to_string(),
tools: vec![
@@ -277,21 +255,25 @@ mod tests {
}),
],
}),
ToolSearchOutputTool::Function(ResponsesApiTool {
name: "automation_update".to_string(),
description: "Create, update, view, or delete recurring automations."
.to_string(),
strict: false,
defer_loading: Some(true),
parameters: codex_tools::JsonSchema::object(
std::collections::BTreeMap::from([(
"mode".to_string(),
codex_tools::JsonSchema::string(/*description*/ None),
)]),
Some(vec!["mode".to_string()]),
Some(false.into()),
),
output_schema: None,
LoadableToolSpec::Namespace(ResponsesApiNamespace {
name: "codex_app".to_string(),
description: "Tools in the codex_app namespace.".to_string(),
tools: vec![ResponsesApiNamespaceTool::Function(ResponsesApiTool {
name: "automation_update".to_string(),
description: "Create, update, view, or delete recurring automations."
.to_string(),
strict: false,
defer_loading: Some(true),
parameters: codex_tools::JsonSchema::object(
std::collections::BTreeMap::from([(
"mode".to_string(),
codex_tools::JsonSchema::string(/*description*/ None),
)]),
Some(vec!["mode".to_string()]),
Some(false.into()),
),
output_schema: None,
})],
}),
],
);
+5 -1
View File
@@ -1619,7 +1619,11 @@ impl JsReplManager {
ResponsesApiNamespaceTool::Function(tool) => {
let tool_name =
ToolName::namespaced(namespace.name.clone(), tool.name.clone());
(tool_name.display() == req.tool_name).then_some(tool_name)
let code_mode_name =
codex_tools::code_mode_name_for_tool_name(&tool_name);
(code_mode_name == req.tool_name
|| tool_name.display() == req.tool_name)
.then_some(tool_name)
}
})
}
+4 -1
View File
@@ -1819,6 +1819,7 @@ async fn js_repl_emit_image_rejects_mixed_content() -> anyhow::Result<()> {
let (session, turn, rx_event) =
make_session_and_context_with_dynamic_tools_and_rx(vec![DynamicToolSpec {
namespace: None,
name: "inline_image".to_string(),
description: "Returns inline text and image content.".to_string(),
input_schema: serde_json::json!({
@@ -1918,6 +1919,7 @@ async fn js_repl_dynamic_tool_response_preserves_js_line_separator_text() -> any
] {
let (session, turn, rx_event) =
make_session_and_context_with_dynamic_tools_and_rx(vec![DynamicToolSpec {
namespace: None,
name: tool_name.to_string(),
description: description.to_string(),
input_schema: serde_json::json!({
@@ -1993,6 +1995,7 @@ async fn js_repl_can_call_hidden_dynamic_tools() -> anyhow::Result<()> {
let (session, turn, rx_event) =
make_session_and_context_with_dynamic_tools_and_rx(vec![DynamicToolSpec {
namespace: Some("codex_app".to_string()),
name: "hidden_dynamic_tool".to_string(),
description: "A hidden dynamic tool.".to_string(),
input_schema: serde_json::json!({
@@ -2012,7 +2015,7 @@ async fn js_repl_can_call_hidden_dynamic_tools() -> anyhow::Result<()> {
let tracker = Arc::new(tokio::sync::Mutex::new(TurnDiffTracker::default()));
let manager = turn.js_repl.manager().await?;
let code = r#"
const out = await codex.tool("hidden_dynamic_tool", { city: "Paris" });
const out = await codex.tool("codex_app_hidden_dynamic_tool", { city: "Paris" });
console.log(JSON.stringify(out));
"#;
+11 -7
View File
@@ -1,15 +1,15 @@
use codex_mcp::ToolInfo;
use codex_protocol::dynamic_tools::DynamicToolSpec;
use codex_tools::ToolSearchOutputTool;
use codex_tools::LoadableToolSpec;
use codex_tools::ToolSearchResultSource;
use codex_tools::dynamic_tool_to_responses_api_tool;
use codex_tools::tool_search_result_source_to_output_tool;
use codex_tools::dynamic_tool_to_loadable_tool_spec;
use codex_tools::tool_search_result_source_to_loadable_tool_spec;
use std::collections::HashMap;
#[derive(Clone)]
pub(crate) struct ToolSearchEntry {
pub(crate) search_text: String,
pub(crate) output: ToolSearchOutputTool,
pub(crate) output: LoadableToolSpec,
pub(crate) limit_bucket: Option<String>,
}
@@ -36,7 +36,7 @@ pub(crate) fn build_tool_search_entries(
}
let mut dynamic_tools = dynamic_tools.iter().collect::<Vec<_>>();
dynamic_tools.sort_by(|a, b| a.name.cmp(&b.name));
dynamic_tools.sort_by(|a, b| a.namespace.cmp(&b.namespace).then(a.name.cmp(&b.name)));
for tool in dynamic_tools {
match dynamic_tool_search_entry(tool) {
Ok(entry) => entries.push(entry),
@@ -55,7 +55,7 @@ pub(crate) fn build_tool_search_entries(
fn mcp_tool_search_entry(info: &ToolInfo) -> Result<ToolSearchEntry, serde_json::Error> {
Ok(ToolSearchEntry {
search_text: build_mcp_search_text(info),
output: tool_search_result_source_to_output_tool(ToolSearchResultSource {
output: tool_search_result_source_to_loadable_tool_spec(ToolSearchResultSource {
server_name: info.server_name.as_str(),
tool_namespace: info.callable_namespace.as_str(),
tool_name: info.callable_name.as_str(),
@@ -70,7 +70,7 @@ fn mcp_tool_search_entry(info: &ToolInfo) -> Result<ToolSearchEntry, serde_json:
fn dynamic_tool_search_entry(tool: &DynamicToolSpec) -> Result<ToolSearchEntry, serde_json::Error> {
Ok(ToolSearchEntry {
search_text: build_dynamic_search_text(tool),
output: ToolSearchOutputTool::Function(dynamic_tool_to_responses_api_tool(tool)?),
output: dynamic_tool_to_loadable_tool_spec(tool)?,
limit_bucket: None,
})
}
@@ -135,6 +135,10 @@ fn build_dynamic_search_text(tool: &DynamicToolSpec) -> String {
tool.description.clone(),
];
if let Some(namespace) = &tool.namespace {
parts.push(namespace.clone());
}
parts.extend(
tool.input_schema
.get("properties")
+6 -4
View File
@@ -2556,6 +2556,7 @@ async fn code_mode_can_call_hidden_dynamic_tools() -> Result<()> {
.start_thread_with_tools(
base_test.config.clone(),
vec![DynamicToolSpec {
namespace: Some("codex_app".to_string()),
name: "hidden_dynamic_tool".to_string(),
description: "A hidden dynamic tool.".to_string(),
input_schema: serde_json::json!({
@@ -2576,8 +2577,8 @@ async fn code_mode_can_call_hidden_dynamic_tools() -> Result<()> {
test.session_configured = new_thread.session_configured;
let code = r#"
const tool = ALL_TOOLS.find(({ name }) => name === "hidden_dynamic_tool");
const out = await tools.hidden_dynamic_tool({ city: "Paris" });
const tool = ALL_TOOLS.find(({ name }) => name === "codex_app_hidden_dynamic_tool");
const out = await tools.codex_app_hidden_dynamic_tool({ city: "Paris" });
text(
JSON.stringify({
name: tool?.name ?? null,
@@ -2636,6 +2637,7 @@ text(
_ => None,
})
.await;
assert_eq!(request.namespace.as_deref(), Some("codex_app"));
assert_eq!(request.tool, "hidden_dynamic_tool");
assert_eq!(request.arguments, serde_json::json!({ "city": "Paris" }));
test.codex
@@ -2669,7 +2671,7 @@ text(
)?;
assert_eq!(
parsed.get("name"),
Some(&Value::String("hidden_dynamic_tool".to_string()))
Some(&Value::String("codex_app_hidden_dynamic_tool".to_string()))
);
assert_eq!(
parsed.get("out"),
@@ -2682,7 +2684,7 @@ text(
.is_some_and(|description| {
description.contains("A hidden dynamic tool.")
&& description.contains("declare const tools:")
&& description.contains("hidden_dynamic_tool(args:")
&& description.contains("codex_app_hidden_dynamic_tool(args:")
})
);
+14 -6
View File
@@ -730,6 +730,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",
"name": tool_name,
"arguments": tool_call_arguments,
}
@@ -754,6 +755,7 @@ async fn tool_search_returns_deferred_dynamic_tool_and_routes_follow_up_call() -
"additionalProperties": false,
});
let dynamic_tool = DynamicToolSpec {
namespace: Some("codex_app".to_string()),
name: tool_name.to_string(),
description: tool_description.to_string(),
input_schema: input_schema.clone(),
@@ -793,6 +795,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.tool, tool_name);
assert_eq!(request.arguments, tool_args);
@@ -832,12 +835,17 @@ async fn tool_search_returns_deferred_dynamic_tool_and_routes_follow_up_call() -
assert_eq!(
tools,
vec![json!({
"type": "function",
"name": tool_name,
"description": tool_description,
"strict": false,
"defer_loading": true,
"parameters": input_schema,
"type": "namespace",
"name": "codex_app",
"description": "Tools in the codex_app namespace.",
"tools": [{
"type": "function",
"name": tool_name,
"description": tool_description,
"strict": false,
"defer_loading": true,
"parameters": input_schema,
}],
})]
);
@@ -103,6 +103,7 @@ async fn backfill_scans_existing_rollouts() -> Result<()> {
let dynamic_tools = vec![
DynamicToolSpec {
namespace: Some("codex_app".to_string()),
name: "geo_lookup".to_string(),
description: "lookup a city".to_string(),
input_schema: json!({
@@ -113,6 +114,7 @@ async fn backfill_scans_existing_rollouts() -> Result<()> {
defer_loading: true,
},
DynamicToolSpec {
namespace: None,
name: "weather_lookup".to_string(),
description: "lookup weather".to_string(),
input_schema: json!({
+8
View File
@@ -8,6 +8,8 @@ use ts_rs::TS;
#[derive(Debug, Clone, Serialize, PartialEq, JsonSchema, TS)]
#[serde(rename_all = "camelCase")]
pub struct DynamicToolSpec {
#[serde(default, skip_serializing_if = "Option::is_none")]
pub namespace: Option<String>,
pub name: String,
pub description: String,
pub input_schema: JsonValue,
@@ -20,6 +22,8 @@ pub struct DynamicToolSpec {
pub struct DynamicToolCallRequest {
pub call_id: String,
pub turn_id: String,
#[serde(default)]
pub namespace: Option<String>,
pub tool: String,
pub arguments: JsonValue,
}
@@ -44,6 +48,7 @@ pub enum DynamicToolCallOutputContentItem {
#[derive(Deserialize)]
#[serde(rename_all = "camelCase")]
struct DynamicToolSpecDe {
namespace: Option<String>,
name: String,
description: String,
input_schema: JsonValue,
@@ -57,6 +62,7 @@ impl<'de> Deserialize<'de> for DynamicToolSpec {
D: Deserializer<'de>,
{
let DynamicToolSpecDe {
namespace,
name,
description,
input_schema,
@@ -65,6 +71,7 @@ impl<'de> Deserialize<'de> for DynamicToolSpec {
} = DynamicToolSpecDe::deserialize(deserializer)?;
Ok(Self {
namespace,
name,
description,
input_schema,
@@ -99,6 +106,7 @@ mod tests {
assert_eq!(
actual,
DynamicToolSpec {
namespace: None,
name: "lookup_ticket".to_string(),
description: "Fetch a ticket".to_string(),
input_schema: json!({
+3
View File
@@ -2441,6 +2441,9 @@ pub struct DynamicToolCallResponseEvent {
pub call_id: String,
/// Turn ID that this dynamic tool call belongs to.
pub turn_id: String,
/// Dynamic tool namespace, when one was provided.
#[serde(default)]
pub namespace: Option<String>,
/// Dynamic tool name.
pub tool: String,
/// Dynamic tool call arguments.
@@ -0,0 +1,2 @@
ALTER TABLE thread_dynamic_tools
ADD COLUMN namespace TEXT;
+5 -2
View File
@@ -56,7 +56,7 @@ WHERE threads.id = ?
) -> anyhow::Result<Option<Vec<DynamicToolSpec>>> {
let rows = sqlx::query(
r#"
SELECT name, description, input_schema, defer_loading
SELECT namespace, name, description, input_schema, defer_loading
FROM thread_dynamic_tools
WHERE thread_id = ?
ORDER BY position ASC
@@ -73,6 +73,7 @@ ORDER BY position ASC
let input_schema: String = row.try_get("input_schema")?;
let input_schema = serde_json::from_str::<Value>(input_schema.as_str())?;
tools.push(DynamicToolSpec {
namespace: row.try_get("namespace")?,
name: row.try_get("name")?,
description: row.try_get("description")?,
input_schema,
@@ -778,16 +779,18 @@ ON CONFLICT(id) DO UPDATE SET
INSERT INTO thread_dynamic_tools (
thread_id,
position,
namespace,
name,
description,
input_schema,
defer_loading
) VALUES (?, ?, ?, ?, ?, ?)
) VALUES (?, ?, ?, ?, ?, ?, ?)
ON CONFLICT(thread_id, position) DO NOTHING
"#,
)
.bind(thread_id.as_str())
.bind(position)
.bind(tool.namespace.as_deref())
.bind(tool.name.as_str())
.bind(tool.description.as_str())
.bind(input_schema)
+2 -1
View File
@@ -16,7 +16,7 @@ schema and Responses API tool primitives that no longer need to live in
- `ResponsesApiTool`
- `FreeformTool`
- `FreeformToolFormat`
- `ToolSearchOutputTool`
- `LoadableToolSpec`
- `ResponsesApiWebSearchFilters`
- `ResponsesApiWebSearchUserLocation`
- `ResponsesApiNamespace`
@@ -35,6 +35,7 @@ schema and Responses API tool primitives that no longer need to live in
- `create_tools_json_for_responses_api()`
- `mcp_call_tool_result_output_schema()`
- `tool_definition_to_responses_api_tool()`
- `dynamic_tool_to_loadable_tool_spec()`
- `dynamic_tool_to_responses_api_tool()`
- `mcp_tool_to_responses_api_tool()`
- `mcp_tool_to_deferred_responses_api_tool()`
+12 -2
View File
@@ -37,7 +37,7 @@ pub fn augment_tool_spec_for_code_mode(spec: ToolSpec) -> ToolSpec {
let tool_name =
ToolName::namespaced(namespace.name.clone(), tool.name.clone());
let definition = CodeModeToolDefinition {
name: tool_name.display(),
name: code_mode_name_for_tool_name(&tool_name),
tool_name,
description: tool.description.clone(),
kind: CodeModeToolKind::Function,
@@ -208,7 +208,7 @@ fn code_mode_tool_definitions_for_spec(spec: &ToolSpec) -> Vec<CodeModeToolDefin
ResponsesApiNamespaceTool::Function(tool) => {
let tool_name = ToolName::namespaced(namespace.name.clone(), tool.name.clone());
CodeModeToolDefinition {
name: tool_name.display(),
name: code_mode_name_for_tool_name(&tool_name),
tool_name,
description: tool.description.clone(),
kind: CodeModeToolKind::Function,
@@ -225,6 +225,16 @@ fn code_mode_tool_definitions_for_spec(spec: &ToolSpec) -> Vec<CodeModeToolDefin
}
}
pub fn code_mode_name_for_tool_name(tool_name: &ToolName) -> String {
match tool_name.namespace.as_deref() {
Some(namespace) if namespace.ends_with('_') || tool_name.name.starts_with('_') => {
format!("{namespace}{}", tool_name.name)
}
Some(namespace) => format!("{namespace}_{}", tool_name.name),
None => tool_name.name.clone(),
}
}
#[cfg(test)]
#[path = "code_mode_tests.rs"]
mod tests;
+4 -10
View File
@@ -3,18 +3,12 @@ use crate::parse_tool_input_schema;
use codex_protocol::dynamic_tools::DynamicToolSpec;
pub fn parse_dynamic_tool(tool: &DynamicToolSpec) -> Result<ToolDefinition, serde_json::Error> {
let DynamicToolSpec {
name,
description,
input_schema,
defer_loading,
} = tool;
Ok(ToolDefinition {
name: name.clone(),
description: description.clone(),
input_schema: parse_tool_input_schema(input_schema)?,
name: tool.name.clone(),
description: tool.description.clone(),
input_schema: parse_tool_input_schema(&tool.input_schema)?,
output_schema: None,
defer_loading: *defer_loading,
defer_loading: tool.defer_loading,
})
}
+2
View File
@@ -8,6 +8,7 @@ use std::collections::BTreeMap;
#[test]
fn parse_dynamic_tool_sanitizes_input_schema() {
let tool = DynamicToolSpec {
namespace: None,
name: "lookup_ticket".to_string(),
description: "Fetch a ticket".to_string(),
input_schema: serde_json::json!({
@@ -42,6 +43,7 @@ fn parse_dynamic_tool_sanitizes_input_schema() {
#[test]
fn parse_dynamic_tool_preserves_defer_loading() {
let tool = DynamicToolSpec {
namespace: None,
name: "lookup_ticket".to_string(),
description: "Fetch a ticket".to_string(),
input_schema: serde_json::json!({
+5 -2
View File
@@ -44,6 +44,7 @@ pub use apply_patch_tool::ApplyPatchToolArgs;
pub use apply_patch_tool::create_apply_patch_freeform_tool;
pub use apply_patch_tool::create_apply_patch_json_tool;
pub use code_mode::augment_tool_spec_for_code_mode;
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::create_code_mode_tool;
@@ -82,11 +83,13 @@ pub use request_user_input_tool::request_user_input_tool_description;
pub use request_user_input_tool::request_user_input_unavailable_message;
pub use responses_api::FreeformTool;
pub use responses_api::FreeformToolFormat;
pub use responses_api::LoadableToolSpec;
pub use responses_api::ResponsesApiNamespace;
pub use responses_api::ResponsesApiNamespaceTool;
pub use responses_api::ResponsesApiTool;
pub use responses_api::ToolSearchOutputTool;
pub use responses_api::coalesce_loadable_tool_specs;
pub(crate) use responses_api::default_namespace_description;
pub use responses_api::dynamic_tool_to_loadable_tool_spec;
pub use responses_api::dynamic_tool_to_responses_api_tool;
pub use responses_api::mcp_tool_to_deferred_responses_api_tool;
pub use responses_api::mcp_tool_to_responses_api_tool;
@@ -114,7 +117,7 @@ pub use tool_discovery::collect_tool_suggest_entries;
pub use tool_discovery::create_tool_search_tool;
pub use tool_discovery::create_tool_suggest_tool;
pub use tool_discovery::filter_tool_suggest_discoverable_tools_for_client;
pub use tool_discovery::tool_search_result_source_to_output_tool;
pub use tool_discovery::tool_search_result_source_to_loadable_tool_spec;
pub use tool_registry_plan::build_tool_registry_plan;
pub use tool_registry_plan_types::ToolHandlerKind;
pub use tool_registry_plan_types::ToolHandlerSpec;
+46 -1
View File
@@ -40,7 +40,7 @@ pub struct ResponsesApiTool {
#[derive(Debug, Clone, Serialize, PartialEq)]
#[serde(tag = "type")]
#[allow(clippy::large_enum_variant)]
pub enum ToolSearchOutputTool {
pub enum LoadableToolSpec {
#[allow(dead_code)]
#[serde(rename = "function")]
Function(ResponsesApiTool),
@@ -74,6 +74,51 @@ pub fn dynamic_tool_to_responses_api_tool(
)?))
}
pub fn dynamic_tool_to_loadable_tool_spec(
tool: &DynamicToolSpec,
) -> Result<LoadableToolSpec, serde_json::Error> {
let output_tool = dynamic_tool_to_responses_api_tool(tool)?;
Ok(match tool.namespace.as_ref() {
Some(namespace) => LoadableToolSpec::Namespace(ResponsesApiNamespace {
name: namespace.clone(),
// the user doesn't provide a description for dynamic tools, so we use the default
description: default_namespace_description(namespace),
tools: vec![ResponsesApiNamespaceTool::Function(output_tool)],
}),
None => LoadableToolSpec::Function(output_tool),
})
}
pub fn coalesce_loadable_tool_specs(
specs: impl IntoIterator<Item = LoadableToolSpec>,
) -> Vec<LoadableToolSpec> {
let mut coalesced_specs = Vec::new();
for spec in specs {
match spec {
LoadableToolSpec::Function(tool) => {
coalesced_specs.push(LoadableToolSpec::Function(tool));
}
LoadableToolSpec::Namespace(mut namespace) => {
if let Some(existing_namespace) =
coalesced_specs.iter_mut().find_map(|spec| match spec {
LoadableToolSpec::Namespace(existing_namespace)
if existing_namespace.name == namespace.name =>
{
Some(existing_namespace)
}
LoadableToolSpec::Function(_) | LoadableToolSpec::Namespace(_) => None,
})
{
existing_namespace.tools.append(&mut namespace.tools);
} else {
coalesced_specs.push(LoadableToolSpec::Namespace(namespace));
}
}
}
}
coalesced_specs
}
pub fn mcp_tool_to_responses_api_tool(
tool_name: &ToolName,
tool: &rmcp::model::Tool,
+4 -3
View File
@@ -1,7 +1,7 @@
use super::LoadableToolSpec;
use super::ResponsesApiNamespace;
use super::ResponsesApiNamespaceTool;
use super::ResponsesApiTool;
use super::ToolSearchOutputTool;
use super::dynamic_tool_to_responses_api_tool;
use super::mcp_tool_to_deferred_responses_api_tool;
use super::tool_definition_to_responses_api_tool;
@@ -51,6 +51,7 @@ fn tool_definition_to_responses_api_tool_omits_false_defer_loading() {
#[test]
fn dynamic_tool_to_responses_api_tool_preserves_defer_loading() {
let tool = DynamicToolSpec {
namespace: None,
name: "lookup_order".to_string(),
description: "Look up an order".to_string(),
input_schema: json!({
@@ -130,8 +131,8 @@ fn mcp_tool_to_deferred_responses_api_tool_sets_defer_loading() {
}
#[test]
fn tool_search_output_namespace_serializes_with_deferred_child_tools() {
let namespace = ToolSearchOutputTool::Namespace(ResponsesApiNamespace {
fn loadable_tool_spec_namespace_serializes_with_deferred_child_tools() {
let namespace = LoadableToolSpec::Namespace(ResponsesApiNamespace {
name: "mcp__codex_apps__calendar".to_string(),
description: "Plan events".to_string(),
tools: vec![ResponsesApiNamespaceTool::Function(ResponsesApiTool {
+4 -4
View File
@@ -1,9 +1,9 @@
use crate::JsonSchema;
use crate::LoadableToolSpec;
use crate::ResponsesApiNamespace;
use crate::ResponsesApiNamespaceTool;
use crate::ResponsesApiTool;
use crate::ToolName;
use crate::ToolSearchOutputTool;
use crate::ToolSpec;
use crate::default_namespace_description;
use crate::mcp_tool_to_deferred_responses_api_tool;
@@ -203,10 +203,10 @@ pub fn create_tool_search_tool(
}
}
pub fn tool_search_result_source_to_output_tool(
pub fn tool_search_result_source_to_loadable_tool_spec(
source: ToolSearchResultSource<'_>,
) -> Result<ToolSearchOutputTool, serde_json::Error> {
Ok(ToolSearchOutputTool::Namespace(ResponsesApiNamespace {
) -> Result<LoadableToolSpec, serde_json::Error> {
Ok(LoadableToolSpec::Namespace(ResponsesApiNamespace {
name: source.tool_namespace.to_string(),
description: tool_search_result_source_namespace_description(source),
tools: vec![tool_search_result_source_to_namespace_tool(source)?],
+16 -9
View File
@@ -8,6 +8,7 @@ use crate::TOOL_SEARCH_DEFAULT_LIMIT;
use crate::TOOL_SEARCH_TOOL_NAME;
use crate::TOOL_SUGGEST_TOOL_NAME;
use crate::ToolHandlerKind;
use crate::ToolName;
use crate::ToolRegistryPlan;
use crate::ToolRegistryPlanParams;
use crate::ToolSearchSource;
@@ -16,6 +17,7 @@ use crate::ToolSpec;
use crate::ToolsConfig;
use crate::ViewImageToolOptions;
use crate::WebSearchToolOptions;
use crate::coalesce_loadable_tool_specs;
use crate::collect_code_mode_exec_prompt_tool_definitions;
use crate::collect_tool_search_source_infos;
use crate::collect_tool_suggest_entries;
@@ -57,7 +59,7 @@ use crate::create_wait_tool;
use crate::create_web_search_tool;
use crate::create_write_stdin_tool;
use crate::default_namespace_description;
use crate::dynamic_tool_to_responses_api_tool;
use crate::dynamic_tool_to_loadable_tool_spec;
use crate::mcp_tool_to_responses_api_tool;
use crate::request_permissions_tool_description;
use crate::request_user_input_tool_description;
@@ -555,15 +557,13 @@ pub fn build_tool_registry_plan(
}
}
let mut dynamic_tool_specs = Vec::new();
for tool in params.dynamic_tools {
match dynamic_tool_to_responses_api_tool(tool) {
Ok(converted_tool) => {
plan.push_spec(
ToolSpec::Function(converted_tool),
/*supports_parallel_tool_calls*/ false,
config.code_mode_enabled,
);
plan.register_handler(tool.name.clone(), ToolHandlerKind::DynamicTool);
match dynamic_tool_to_loadable_tool_spec(tool) {
Ok(loadable_tool) => {
let handler_name = ToolName::new(tool.namespace.clone(), tool.name.clone());
dynamic_tool_specs.push(loadable_tool);
plan.register_handler(handler_name, ToolHandlerKind::DynamicTool);
}
Err(error) => {
tracing::error!(
@@ -573,6 +573,13 @@ pub fn build_tool_registry_plan(
}
}
}
for spec in coalesce_loadable_tool_specs(dynamic_tool_specs) {
plan.push_spec(
spec.into(),
/*supports_parallel_tool_calls*/ false,
config.code_mode_enabled,
);
}
plan
}
+49 -14
View File
@@ -1423,23 +1423,36 @@ fn search_tool_registers_for_deferred_dynamic_tools() {
sandbox_policy: &SandboxPolicy::DangerFullAccess,
windows_sandbox_level: WindowsSandboxLevel::Disabled,
});
let dynamic_tool = DynamicToolSpec {
name: "automation_update".to_string(),
description: "Create, update, view, or delete recurring automations.".to_string(),
input_schema: json!({
"type": "object",
"properties": {
"mode": { "type": "string" },
},
}),
defer_loading: true,
};
let dynamic_tools = vec![
DynamicToolSpec {
namespace: Some("codex_app".to_string()),
name: "automation_update".to_string(),
description: "Create, update, view, or delete recurring automations.".to_string(),
input_schema: json!({
"type": "object",
"properties": {
"mode": { "type": "string" },
},
}),
defer_loading: true,
},
DynamicToolSpec {
namespace: Some("codex_app".to_string()),
name: "automation_list".to_string(),
description: "List recurring automations.".to_string(),
input_schema: json!({
"type": "object",
"properties": {},
}),
defer_loading: true,
},
];
let (tools, handlers) = build_specs(
&tools_config,
/*mcp_tools*/ None,
/*deferred_mcp_tools*/ None,
&[dynamic_tool],
&dynamic_tools,
);
let search_tool = find_tool(&tools, TOOL_SEARCH_TOOL_NAME);
@@ -1447,13 +1460,35 @@ fn search_tool_registers_for_deferred_dynamic_tools() {
panic!("expected tool_search tool");
};
assert!(description.contains("- Dynamic tools: Tools provided by the current Codex thread."));
assert_contains_tool_names(&tools, &[TOOL_SEARCH_TOOL_NAME, "automation_update"]);
assert_contains_tool_names(&tools, &[TOOL_SEARCH_TOOL_NAME, "codex_app"]);
assert_eq!(
tools
.iter()
.filter(|tool| tool.name() == "codex_app")
.count(),
1
);
assert_eq!(
namespace_function_names(&tools, "codex_app"),
vec![
"automation_update".to_string(),
"automation_list".to_string()
]
);
for tool_name in ["automation_update", "automation_list"] {
let dynamic_tool = find_namespace_function_tool(&tools, "codex_app", tool_name);
assert_eq!(dynamic_tool.defer_loading, Some(true));
}
assert!(handlers.contains(&ToolHandlerSpec {
name: ToolName::plain(TOOL_SEARCH_TOOL_NAME),
kind: ToolHandlerKind::ToolSearch,
}));
assert!(handlers.contains(&ToolHandlerSpec {
name: ToolName::plain("automation_update"),
name: ToolName::namespaced("codex_app", "automation_update"),
kind: ToolHandlerKind::DynamicTool,
}));
assert!(handlers.contains(&ToolHandlerSpec {
name: ToolName::namespaced("codex_app", "automation_list"),
kind: ToolHandlerKind::DynamicTool,
}));
}
+10
View File
@@ -1,5 +1,6 @@
use crate::FreeformTool;
use crate::JsonSchema;
use crate::LoadableToolSpec;
use crate::ResponsesApiNamespace;
use crate::ResponsesApiTool;
use codex_protocol::config_types::WebSearchConfig;
@@ -70,6 +71,15 @@ impl ToolSpec {
}
}
impl From<LoadableToolSpec> for ToolSpec {
fn from(value: LoadableToolSpec) -> Self {
match value {
LoadableToolSpec::Function(tool) => ToolSpec::Function(tool),
LoadableToolSpec::Namespace(namespace) => ToolSpec::Namespace(namespace),
}
}
}
pub fn create_local_shell_tool() -> ToolSpec {
ToolSpec::LocalShell {}
}
@@ -655,6 +655,7 @@ mod tests {
thread_id: "thread-1".to_string(),
turn_id: "turn-1".to_string(),
call_id: "tool-1".to_string(),
namespace: None,
tool: "tool".to_string(),
arguments: json!({}),
},