diff --git a/codex-rs/app-server-protocol/schema/json/ClientRequest.json b/codex-rs/app-server-protocol/schema/json/ClientRequest.json index 93d21d7e4..43871d74a 100644 --- a/codex-rs/app-server-protocol/schema/json/ClientRequest.json +++ b/codex-rs/app-server-protocol/schema/json/ClientRequest.json @@ -532,6 +532,12 @@ "inputSchema": true, "name": { "type": "string" + }, + "namespace": { + "type": [ + "string", + "null" + ] } }, "required": [ diff --git a/codex-rs/app-server-protocol/schema/json/DynamicToolCallParams.json b/codex-rs/app-server-protocol/schema/json/DynamicToolCallParams.json index 2ccc94ca0..0aa8b9c57 100644 --- a/codex-rs/app-server-protocol/schema/json/DynamicToolCallParams.json +++ b/codex-rs/app-server-protocol/schema/json/DynamicToolCallParams.json @@ -5,6 +5,12 @@ "callId": { "type": "string" }, + "namespace": { + "type": [ + "string", + "null" + ] + }, "threadId": { "type": "string" }, @@ -24,4 +30,4 @@ ], "title": "DynamicToolCallParams", "type": "object" -} \ No newline at end of file +} diff --git a/codex-rs/app-server-protocol/schema/json/ServerNotification.json b/codex-rs/app-server-protocol/schema/json/ServerNotification.json index cff20b30a..144344468 100644 --- a/codex-rs/app-server-protocol/schema/json/ServerNotification.json +++ b/codex-rs/app-server-protocol/schema/json/ServerNotification.json @@ -3343,6 +3343,12 @@ "id": { "type": "string" }, + "namespace": { + "type": [ + "string", + "null" + ] + }, "status": { "$ref": "#/definitions/DynamicToolCallStatus" }, diff --git a/codex-rs/app-server-protocol/schema/json/ServerRequest.json b/codex-rs/app-server-protocol/schema/json/ServerRequest.json index 298b97b4b..6bd386b6a 100644 --- a/codex-rs/app-server-protocol/schema/json/ServerRequest.json +++ b/codex-rs/app-server-protocol/schema/json/ServerRequest.json @@ -434,6 +434,12 @@ "callId": { "type": "string" }, + "namespace": { + "type": [ + "string", + "null" + ] + }, "threadId": { "type": "string" }, diff --git a/codex-rs/app-server-protocol/schema/json/codex_app_server_protocol.schemas.json b/codex-rs/app-server-protocol/schema/json/codex_app_server_protocol.schemas.json index 4d884204e..2cb5fb84d 100644 --- a/codex-rs/app-server-protocol/schema/json/codex_app_server_protocol.schemas.json +++ b/codex-rs/app-server-protocol/schema/json/codex_app_server_protocol.schemas.json @@ -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" -} \ No newline at end of file +} diff --git a/codex-rs/app-server-protocol/schema/json/codex_app_server_protocol.v2.schemas.json b/codex-rs/app-server-protocol/schema/json/codex_app_server_protocol.v2.schemas.json index ec729125c..4ff918e64 100644 --- a/codex-rs/app-server-protocol/schema/json/codex_app_server_protocol.v2.schemas.json +++ b/codex-rs/app-server-protocol/schema/json/codex_app_server_protocol.v2.schemas.json @@ -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" }, diff --git a/codex-rs/app-server-protocol/schema/json/v2/ItemCompletedNotification.json b/codex-rs/app-server-protocol/schema/json/v2/ItemCompletedNotification.json index c5a84c427..0831483a3 100644 --- a/codex-rs/app-server-protocol/schema/json/v2/ItemCompletedNotification.json +++ b/codex-rs/app-server-protocol/schema/json/v2/ItemCompletedNotification.json @@ -854,6 +854,12 @@ "id": { "type": "string" }, + "namespace": { + "type": [ + "string", + "null" + ] + }, "status": { "$ref": "#/definitions/DynamicToolCallStatus" }, diff --git a/codex-rs/app-server-protocol/schema/json/v2/ItemStartedNotification.json b/codex-rs/app-server-protocol/schema/json/v2/ItemStartedNotification.json index cb9201f0a..16bfeece1 100644 --- a/codex-rs/app-server-protocol/schema/json/v2/ItemStartedNotification.json +++ b/codex-rs/app-server-protocol/schema/json/v2/ItemStartedNotification.json @@ -854,6 +854,12 @@ "id": { "type": "string" }, + "namespace": { + "type": [ + "string", + "null" + ] + }, "status": { "$ref": "#/definitions/DynamicToolCallStatus" }, diff --git a/codex-rs/app-server-protocol/schema/json/v2/ReviewStartResponse.json b/codex-rs/app-server-protocol/schema/json/v2/ReviewStartResponse.json index 05dfaa4b7..e8500d7ae 100644 --- a/codex-rs/app-server-protocol/schema/json/v2/ReviewStartResponse.json +++ b/codex-rs/app-server-protocol/schema/json/v2/ReviewStartResponse.json @@ -997,6 +997,12 @@ "id": { "type": "string" }, + "namespace": { + "type": [ + "string", + "null" + ] + }, "status": { "$ref": "#/definitions/DynamicToolCallStatus" }, diff --git a/codex-rs/app-server-protocol/schema/json/v2/ThreadForkResponse.json b/codex-rs/app-server-protocol/schema/json/v2/ThreadForkResponse.json index 909c2af4d..a01f7bee7 100644 --- a/codex-rs/app-server-protocol/schema/json/v2/ThreadForkResponse.json +++ b/codex-rs/app-server-protocol/schema/json/v2/ThreadForkResponse.json @@ -1511,6 +1511,12 @@ "id": { "type": "string" }, + "namespace": { + "type": [ + "string", + "null" + ] + }, "status": { "$ref": "#/definitions/DynamicToolCallStatus" }, diff --git a/codex-rs/app-server-protocol/schema/json/v2/ThreadListResponse.json b/codex-rs/app-server-protocol/schema/json/v2/ThreadListResponse.json index 2db329651..d3da9b7b4 100644 --- a/codex-rs/app-server-protocol/schema/json/v2/ThreadListResponse.json +++ b/codex-rs/app-server-protocol/schema/json/v2/ThreadListResponse.json @@ -1273,6 +1273,12 @@ "id": { "type": "string" }, + "namespace": { + "type": [ + "string", + "null" + ] + }, "status": { "$ref": "#/definitions/DynamicToolCallStatus" }, diff --git a/codex-rs/app-server-protocol/schema/json/v2/ThreadMetadataUpdateResponse.json b/codex-rs/app-server-protocol/schema/json/v2/ThreadMetadataUpdateResponse.json index 40da3390e..33207ca11 100644 --- a/codex-rs/app-server-protocol/schema/json/v2/ThreadMetadataUpdateResponse.json +++ b/codex-rs/app-server-protocol/schema/json/v2/ThreadMetadataUpdateResponse.json @@ -1273,6 +1273,12 @@ "id": { "type": "string" }, + "namespace": { + "type": [ + "string", + "null" + ] + }, "status": { "$ref": "#/definitions/DynamicToolCallStatus" }, diff --git a/codex-rs/app-server-protocol/schema/json/v2/ThreadReadResponse.json b/codex-rs/app-server-protocol/schema/json/v2/ThreadReadResponse.json index 400c0b661..9ba440652 100644 --- a/codex-rs/app-server-protocol/schema/json/v2/ThreadReadResponse.json +++ b/codex-rs/app-server-protocol/schema/json/v2/ThreadReadResponse.json @@ -1273,6 +1273,12 @@ "id": { "type": "string" }, + "namespace": { + "type": [ + "string", + "null" + ] + }, "status": { "$ref": "#/definitions/DynamicToolCallStatus" }, diff --git a/codex-rs/app-server-protocol/schema/json/v2/ThreadResumeResponse.json b/codex-rs/app-server-protocol/schema/json/v2/ThreadResumeResponse.json index aef0cd737..6d504c600 100644 --- a/codex-rs/app-server-protocol/schema/json/v2/ThreadResumeResponse.json +++ b/codex-rs/app-server-protocol/schema/json/v2/ThreadResumeResponse.json @@ -1511,6 +1511,12 @@ "id": { "type": "string" }, + "namespace": { + "type": [ + "string", + "null" + ] + }, "status": { "$ref": "#/definitions/DynamicToolCallStatus" }, diff --git a/codex-rs/app-server-protocol/schema/json/v2/ThreadRollbackResponse.json b/codex-rs/app-server-protocol/schema/json/v2/ThreadRollbackResponse.json index 6a50d6d97..b531ed047 100644 --- a/codex-rs/app-server-protocol/schema/json/v2/ThreadRollbackResponse.json +++ b/codex-rs/app-server-protocol/schema/json/v2/ThreadRollbackResponse.json @@ -1273,6 +1273,12 @@ "id": { "type": "string" }, + "namespace": { + "type": [ + "string", + "null" + ] + }, "status": { "$ref": "#/definitions/DynamicToolCallStatus" }, diff --git a/codex-rs/app-server-protocol/schema/json/v2/ThreadStartParams.json b/codex-rs/app-server-protocol/schema/json/v2/ThreadStartParams.json index 21f4d7ef7..9b1cba6d1 100644 --- a/codex-rs/app-server-protocol/schema/json/v2/ThreadStartParams.json +++ b/codex-rs/app-server-protocol/schema/json/v2/ThreadStartParams.json @@ -70,6 +70,12 @@ "inputSchema": true, "name": { "type": "string" + }, + "namespace": { + "type": [ + "string", + "null" + ] } }, "required": [ diff --git a/codex-rs/app-server-protocol/schema/json/v2/ThreadStartResponse.json b/codex-rs/app-server-protocol/schema/json/v2/ThreadStartResponse.json index a3d20555f..0d0ff5d9d 100644 --- a/codex-rs/app-server-protocol/schema/json/v2/ThreadStartResponse.json +++ b/codex-rs/app-server-protocol/schema/json/v2/ThreadStartResponse.json @@ -1511,6 +1511,12 @@ "id": { "type": "string" }, + "namespace": { + "type": [ + "string", + "null" + ] + }, "status": { "$ref": "#/definitions/DynamicToolCallStatus" }, diff --git a/codex-rs/app-server-protocol/schema/json/v2/ThreadStartedNotification.json b/codex-rs/app-server-protocol/schema/json/v2/ThreadStartedNotification.json index c4b1e5449..165b4a310 100644 --- a/codex-rs/app-server-protocol/schema/json/v2/ThreadStartedNotification.json +++ b/codex-rs/app-server-protocol/schema/json/v2/ThreadStartedNotification.json @@ -1273,6 +1273,12 @@ "id": { "type": "string" }, + "namespace": { + "type": [ + "string", + "null" + ] + }, "status": { "$ref": "#/definitions/DynamicToolCallStatus" }, diff --git a/codex-rs/app-server-protocol/schema/json/v2/ThreadTurnsListResponse.json b/codex-rs/app-server-protocol/schema/json/v2/ThreadTurnsListResponse.json index 2c1dfb14f..8d4ee72e1 100644 --- a/codex-rs/app-server-protocol/schema/json/v2/ThreadTurnsListResponse.json +++ b/codex-rs/app-server-protocol/schema/json/v2/ThreadTurnsListResponse.json @@ -997,6 +997,12 @@ "id": { "type": "string" }, + "namespace": { + "type": [ + "string", + "null" + ] + }, "status": { "$ref": "#/definitions/DynamicToolCallStatus" }, diff --git a/codex-rs/app-server-protocol/schema/json/v2/ThreadUnarchiveResponse.json b/codex-rs/app-server-protocol/schema/json/v2/ThreadUnarchiveResponse.json index e55c9651f..0b414ef42 100644 --- a/codex-rs/app-server-protocol/schema/json/v2/ThreadUnarchiveResponse.json +++ b/codex-rs/app-server-protocol/schema/json/v2/ThreadUnarchiveResponse.json @@ -1273,6 +1273,12 @@ "id": { "type": "string" }, + "namespace": { + "type": [ + "string", + "null" + ] + }, "status": { "$ref": "#/definitions/DynamicToolCallStatus" }, diff --git a/codex-rs/app-server-protocol/schema/json/v2/TurnCompletedNotification.json b/codex-rs/app-server-protocol/schema/json/v2/TurnCompletedNotification.json index 284894008..05395f4f6 100644 --- a/codex-rs/app-server-protocol/schema/json/v2/TurnCompletedNotification.json +++ b/codex-rs/app-server-protocol/schema/json/v2/TurnCompletedNotification.json @@ -997,6 +997,12 @@ "id": { "type": "string" }, + "namespace": { + "type": [ + "string", + "null" + ] + }, "status": { "$ref": "#/definitions/DynamicToolCallStatus" }, diff --git a/codex-rs/app-server-protocol/schema/json/v2/TurnStartResponse.json b/codex-rs/app-server-protocol/schema/json/v2/TurnStartResponse.json index c3f31e532..c818dd8c7 100644 --- a/codex-rs/app-server-protocol/schema/json/v2/TurnStartResponse.json +++ b/codex-rs/app-server-protocol/schema/json/v2/TurnStartResponse.json @@ -997,6 +997,12 @@ "id": { "type": "string" }, + "namespace": { + "type": [ + "string", + "null" + ] + }, "status": { "$ref": "#/definitions/DynamicToolCallStatus" }, diff --git a/codex-rs/app-server-protocol/schema/json/v2/TurnStartedNotification.json b/codex-rs/app-server-protocol/schema/json/v2/TurnStartedNotification.json index 7e8ca749c..3c8eafcdb 100644 --- a/codex-rs/app-server-protocol/schema/json/v2/TurnStartedNotification.json +++ b/codex-rs/app-server-protocol/schema/json/v2/TurnStartedNotification.json @@ -997,6 +997,12 @@ "id": { "type": "string" }, + "namespace": { + "type": [ + "string", + "null" + ] + }, "status": { "$ref": "#/definitions/DynamicToolCallStatus" }, diff --git a/codex-rs/app-server-protocol/schema/typescript/v2/DynamicToolCallParams.ts b/codex-rs/app-server-protocol/schema/typescript/v2/DynamicToolCallParams.ts index 2659da350..0823ac66c 100644 --- a/codex-rs/app-server-protocol/schema/typescript/v2/DynamicToolCallParams.ts +++ b/codex-rs/app-server-protocol/schema/typescript/v2/DynamicToolCallParams.ts @@ -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, }; diff --git a/codex-rs/app-server-protocol/schema/typescript/v2/DynamicToolSpec.ts b/codex-rs/app-server-protocol/schema/typescript/v2/DynamicToolSpec.ts index 18596e31b..db486bf92 100644 --- a/codex-rs/app-server-protocol/schema/typescript/v2/DynamicToolSpec.ts +++ b/codex-rs/app-server-protocol/schema/typescript/v2/DynamicToolSpec.ts @@ -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, }; diff --git a/codex-rs/app-server-protocol/schema/typescript/v2/ThreadItem.ts b/codex-rs/app-server-protocol/schema/typescript/v2/ThreadItem.ts index 747a70c54..f7880c9d3 100644 --- a/codex-rs/app-server-protocol/schema/typescript/v2/ThreadItem.ts +++ b/codex-rs/app-server-protocol/schema/typescript/v2/ThreadItem.ts @@ -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 | null, success: boolean | null, +durationMs: number | null, } | { "type": "dynamicToolCall", id: string, namespace: string | null, tool: string, arguments: JsonValue, status: DynamicToolCallStatus, contentItems: Array | null, success: boolean | null, /** * The duration of the dynamic tool call in milliseconds. */ diff --git a/codex-rs/app-server-protocol/src/protocol/thread_history.rs b/codex-rs/app-server-protocol/src/protocol/thread_history.rs index 886d9c255..bd3911d0d 100644 --- a/codex-rs/app-server-protocol/src/protocol/thread_history.rs +++ b/codex-rs/app-server-protocol/src/protocol/thread_history.rs @@ -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, diff --git a/codex-rs/app-server-protocol/src/protocol/v2.rs b/codex-rs/app-server-protocol/src/protocol/v2.rs index d87e25c25..8ac6651ae 100644 --- a/codex-rs/app-server-protocol/src/protocol/v2.rs +++ b/codex-rs/app-server-protocol/src/protocol/v2.rs @@ -613,6 +613,8 @@ pub struct ToolsV2 { #[serde(rename_all = "camelCase")] #[ts(export_to = "v2/")] pub struct DynamicToolSpec { + #[ts(optional)] + pub namespace: Option, 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, 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, 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, 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!({ diff --git a/codex-rs/app-server/src/bespoke_event_handling.rs b/codex-rs/app-server/src/bespoke_event_handling.rs index c001a77bf..d2b435ef9 100644 --- a/codex-rs/app-server/src/bespoke_event_handling.rs +++ b/codex-rs/app-server/src/bespoke_event_handling.rs @@ -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, diff --git a/codex-rs/app-server/src/codex_message_processor.rs b/codex-rs/app-server/src/codex_message_processor.rs index dc7ec5554..7a2ada076 100644 --- a/codex-rs/app-server/src/codex_message_processor.rs +++ b/codex-rs/app-server/src/codex_message_processor.rs @@ -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 = diff --git a/codex-rs/app-server/src/outgoing_message.rs b/codex-rs/app-server/src/outgoing_message.rs index 1f02fca32..3c5bef3ea 100644 --- a/codex-rs/app-server/src/outgoing_message.rs +++ b/codex-rs/app-server/src/outgoing_message.rs @@ -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!({}), }, diff --git a/codex-rs/app-server/tests/suite/v2/dynamic_tools.rs b/codex-rs/app-server/tests/suite/v2/dynamic_tools.rs index 50dd071d8..7ee21a206 100644 --- a/codex-rs/app-server/tests/suite/v2/dynamic_tools.rs +++ b/codex-rs/app-server/tests/suite/v2/dynamic_tools.rs @@ -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, }; diff --git a/codex-rs/app-server/tests/suite/v2/thread_unsubscribe.rs b/codex-rs/app-server/tests/suite/v2/thread_unsubscribe.rs index 303e16687..c0188add8 100644 --- a/codex-rs/app-server/tests/suite/v2/thread_unsubscribe.rs +++ b/codex-rs/app-server/tests/suite/v2/thread_unsubscribe.rs @@ -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, } diff --git a/codex-rs/core/src/session/turn.rs b/codex-rs/core/src/session/turn.rs index 4f6b062f9..7b27c8080 100644 --- a/codex-rs/core/src/session/turn.rs +++ b/codex-rs/core/src/session/turn.rs @@ -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::>(); 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, +) -> Option { + 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, diff --git a/codex-rs/core/src/tools/context.rs b/codex-rs/core/src/tools/context.rs index 102eb49a9..0060d5897 100644 --- a/codex-rs/core/src/tools/context.rs +++ b/codex-rs/core/src/tools/context.rs @@ -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, + pub tools: Vec, } impl ToolOutput for ToolSearchOutput { diff --git a/codex-rs/core/src/tools/context_tests.rs b/codex-rs/core/src/tools/context_tests.rs index c62328dff..4b903347a 100644 --- a/codex-rs/core/src/tools/context_tests.rs +++ b/codex-rs/core/src/tools/context_tests.rs @@ -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); diff --git a/codex-rs/core/src/tools/handlers/dynamic.rs b/codex-rs/core/src/tools/handlers/dynamic.rs index c989d70f1..b7e07090d 100644 --- a/codex-rs/core/src/tools/handlers/dynamic.rs +++ b/codex-rs/core/src/tools/handlers/dynamic.rs @@ -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 { + 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(), diff --git a/codex-rs/core/src/tools/handlers/tool_search.rs b/codex-rs/core/src/tools/handlers/tool_search.rs index 7103023d9..f38b4ee88 100644 --- a/codex-rs/core/src/tools/handlers/tool_search.rs +++ b/codex-rs/core/src/tools/handlers/tool_search.rs @@ -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, FunctionCallError> { + ) -> Result, 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, - ) -> Result, 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, 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, + })], }), ], ); diff --git a/codex-rs/core/src/tools/js_repl/mod.rs b/codex-rs/core/src/tools/js_repl/mod.rs index ec4443aca..d38ecae44 100644 --- a/codex-rs/core/src/tools/js_repl/mod.rs +++ b/codex-rs/core/src/tools/js_repl/mod.rs @@ -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) } }) } diff --git a/codex-rs/core/src/tools/js_repl/mod_tests.rs b/codex-rs/core/src/tools/js_repl/mod_tests.rs index af53a5975..7c6a2d070 100644 --- a/codex-rs/core/src/tools/js_repl/mod_tests.rs +++ b/codex-rs/core/src/tools/js_repl/mod_tests.rs @@ -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)); "#; diff --git a/codex-rs/core/src/tools/tool_search_entry.rs b/codex-rs/core/src/tools/tool_search_entry.rs index 6fa493132..23535439f 100644 --- a/codex-rs/core/src/tools/tool_search_entry.rs +++ b/codex-rs/core/src/tools/tool_search_entry.rs @@ -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, } @@ -36,7 +36,7 @@ pub(crate) fn build_tool_search_entries( } let mut dynamic_tools = dynamic_tools.iter().collect::>(); - 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 { 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 Result { 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") diff --git a/codex-rs/core/tests/suite/code_mode.rs b/codex-rs/core/tests/suite/code_mode.rs index 40a13467e..3371ff45d 100644 --- a/codex-rs/core/tests/suite/code_mode.rs +++ b/codex-rs/core/tests/suite/code_mode.rs @@ -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:") }) ); diff --git a/codex-rs/core/tests/suite/search_tool.rs b/codex-rs/core/tests/suite/search_tool.rs index ea8728747..2f24fba33 100644 --- a/codex-rs/core/tests/suite/search_tool.rs +++ b/codex-rs/core/tests/suite/search_tool.rs @@ -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, + }], })] ); diff --git a/codex-rs/core/tests/suite/sqlite_state.rs b/codex-rs/core/tests/suite/sqlite_state.rs index e7125210b..52f1f4648 100644 --- a/codex-rs/core/tests/suite/sqlite_state.rs +++ b/codex-rs/core/tests/suite/sqlite_state.rs @@ -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!({ diff --git a/codex-rs/protocol/src/dynamic_tools.rs b/codex-rs/protocol/src/dynamic_tools.rs index 8572bb5e8..2bee24972 100644 --- a/codex-rs/protocol/src/dynamic_tools.rs +++ b/codex-rs/protocol/src/dynamic_tools.rs @@ -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, 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, pub tool: String, pub arguments: JsonValue, } @@ -44,6 +48,7 @@ pub enum DynamicToolCallOutputContentItem { #[derive(Deserialize)] #[serde(rename_all = "camelCase")] struct DynamicToolSpecDe { + namespace: Option, 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!({ diff --git a/codex-rs/protocol/src/protocol.rs b/codex-rs/protocol/src/protocol.rs index 418dc841c..b9a7a1395 100644 --- a/codex-rs/protocol/src/protocol.rs +++ b/codex-rs/protocol/src/protocol.rs @@ -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, /// Dynamic tool name. pub tool: String, /// Dynamic tool call arguments. diff --git a/codex-rs/state/migrations/0026_thread_dynamic_tools_namespace.sql b/codex-rs/state/migrations/0026_thread_dynamic_tools_namespace.sql new file mode 100644 index 000000000..56518d559 --- /dev/null +++ b/codex-rs/state/migrations/0026_thread_dynamic_tools_namespace.sql @@ -0,0 +1,2 @@ +ALTER TABLE thread_dynamic_tools +ADD COLUMN namespace TEXT; diff --git a/codex-rs/state/src/runtime/threads.rs b/codex-rs/state/src/runtime/threads.rs index b5dd797da..ec094a733 100644 --- a/codex-rs/state/src/runtime/threads.rs +++ b/codex-rs/state/src/runtime/threads.rs @@ -56,7 +56,7 @@ WHERE threads.id = ? ) -> anyhow::Result>> { 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::(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) diff --git a/codex-rs/tools/README.md b/codex-rs/tools/README.md index cafb4b89a..474f35e6a 100644 --- a/codex-rs/tools/README.md +++ b/codex-rs/tools/README.md @@ -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()` diff --git a/codex-rs/tools/src/code_mode.rs b/codex-rs/tools/src/code_mode.rs index e8a73c40a..459eb7e46 100644 --- a/codex-rs/tools/src/code_mode.rs +++ b/codex-rs/tools/src/code_mode.rs @@ -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 { 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 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; diff --git a/codex-rs/tools/src/dynamic_tool.rs b/codex-rs/tools/src/dynamic_tool.rs index 372dbce29..af74a3c1e 100644 --- a/codex-rs/tools/src/dynamic_tool.rs +++ b/codex-rs/tools/src/dynamic_tool.rs @@ -3,18 +3,12 @@ use crate::parse_tool_input_schema; use codex_protocol::dynamic_tools::DynamicToolSpec; pub fn parse_dynamic_tool(tool: &DynamicToolSpec) -> Result { - 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, }) } diff --git a/codex-rs/tools/src/dynamic_tool_tests.rs b/codex-rs/tools/src/dynamic_tool_tests.rs index 42afd4627..077a0622c 100644 --- a/codex-rs/tools/src/dynamic_tool_tests.rs +++ b/codex-rs/tools/src/dynamic_tool_tests.rs @@ -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!({ diff --git a/codex-rs/tools/src/lib.rs b/codex-rs/tools/src/lib.rs index 07e98ab5a..2dc7c165d 100644 --- a/codex-rs/tools/src/lib.rs +++ b/codex-rs/tools/src/lib.rs @@ -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; diff --git a/codex-rs/tools/src/responses_api.rs b/codex-rs/tools/src/responses_api.rs index 5317a082f..c3643fbba 100644 --- a/codex-rs/tools/src/responses_api.rs +++ b/codex-rs/tools/src/responses_api.rs @@ -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 { + 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, +) -> Vec { + 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, diff --git a/codex-rs/tools/src/responses_api_tests.rs b/codex-rs/tools/src/responses_api_tests.rs index 799a2385d..3ce13ebfe 100644 --- a/codex-rs/tools/src/responses_api_tests.rs +++ b/codex-rs/tools/src/responses_api_tests.rs @@ -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 { diff --git a/codex-rs/tools/src/tool_discovery.rs b/codex-rs/tools/src/tool_discovery.rs index 359b1d792..ddcd2f1ce 100644 --- a/codex-rs/tools/src/tool_discovery.rs +++ b/codex-rs/tools/src/tool_discovery.rs @@ -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 { - Ok(ToolSearchOutputTool::Namespace(ResponsesApiNamespace { +) -> Result { + 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)?], diff --git a/codex-rs/tools/src/tool_registry_plan.rs b/codex-rs/tools/src/tool_registry_plan.rs index 79ec8bb35..d3b075f5a 100644 --- a/codex-rs/tools/src/tool_registry_plan.rs +++ b/codex-rs/tools/src/tool_registry_plan.rs @@ -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 } diff --git a/codex-rs/tools/src/tool_registry_plan_tests.rs b/codex-rs/tools/src/tool_registry_plan_tests.rs index 798f7ec85..0b94ef64c 100644 --- a/codex-rs/tools/src/tool_registry_plan_tests.rs +++ b/codex-rs/tools/src/tool_registry_plan_tests.rs @@ -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, })); } diff --git a/codex-rs/tools/src/tool_spec.rs b/codex-rs/tools/src/tool_spec.rs index 81db2f558..4236dcaa6 100644 --- a/codex-rs/tools/src/tool_spec.rs +++ b/codex-rs/tools/src/tool_spec.rs @@ -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 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 {} } diff --git a/codex-rs/tui/src/app/app_server_requests.rs b/codex-rs/tui/src/app/app_server_requests.rs index 58ee962fd..4e6356734 100644 --- a/codex-rs/tui/src/app/app_server_requests.rs +++ b/codex-rs/tui/src/app/app_server_requests.rs @@ -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!({}), },