Add namespaces to dynamic tools

This commit is contained in:
pash
2026-04-18 21:36:26 -07:00
parent 680c4102ae
commit 7a8eb680cb
54 changed files with 455 additions and 47 deletions
@@ -515,6 +515,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"
}
}
@@ -3003,6 +3003,12 @@
"id": {
"type": "string"
},
"namespace": {
"type": [
"string",
"null"
]
},
"status": {
"$ref": "#/definitions/DynamicToolCallStatus"
},
@@ -417,6 +417,12 @@
"callId": {
"type": "string"
},
"namespace": {
"type": [
"string",
"null"
]
},
"threadId": {
"type": "string"
},
@@ -1951,6 +1951,12 @@
"callId": {
"type": "string"
},
"namespace": {
"type": [
"string",
"null"
]
},
"threadId": {
"type": "string"
},
@@ -7278,6 +7284,12 @@
"inputSchema": true,
"name": {
"type": "string"
},
"namespace": {
"type": [
"string",
"null"
]
}
},
"required": [
@@ -13572,6 +13584,12 @@
"id": {
"type": "string"
},
"namespace": {
"type": [
"null",
"string"
]
},
"status": {
"$ref": "#/definitions/v2/DynamicToolCallStatus"
},
@@ -16060,4 +16078,4 @@
},
"title": "CodexAppServerProtocol",
"type": "object"
}
}
@@ -3875,6 +3875,12 @@
"inputSchema": true,
"name": {
"type": "string"
},
"namespace": {
"type": [
"string",
"null"
]
}
},
"required": [
@@ -11416,6 +11422,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.
*/
@@ -468,6 +468,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,
@@ -491,6 +492,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,
@@ -1973,6 +1975,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"}),
},
@@ -1980,6 +1983,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 {
@@ -2002,6 +2006,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,
@@ -608,6 +608,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,
@@ -618,6 +620,7 @@ pub struct DynamicToolSpec {
#[derive(Deserialize)]
#[serde(rename_all = "camelCase")]
struct DynamicToolSpecDe {
namespace: Option<String>,
name: String,
description: String,
input_schema: JsonValue,
@@ -631,6 +634,7 @@ impl<'de> Deserialize<'de> for DynamicToolSpec {
D: serde::Deserializer<'de>,
{
let DynamicToolSpecDe {
namespace,
name,
description,
input_schema,
@@ -639,6 +643,7 @@ impl<'de> Deserialize<'de> for DynamicToolSpec {
} = DynamicToolSpecDe::deserialize(deserializer)?;
Ok(Self {
namespace,
name,
description,
input_schema,
@@ -4680,6 +4685,7 @@ pub enum ThreadItem {
#[ts(rename_all = "camelCase")]
DynamicToolCall {
id: String,
namespace: Option<String>,
tool: String,
arguments: JsonValue,
status: DynamicToolCallStatus,
@@ -6405,6 +6411,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,
}
@@ -8783,6 +8790,7 @@ mod tests {
assert_eq!(
actual,
DynamicToolSpec {
namespace: None,
name: "lookup_ticket".to_string(),
description: "Fetch a ticket".to_string(),
input_schema: json!({
@@ -937,10 +937,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,
@@ -960,6 +962,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(),
};
@@ -998,6 +1001,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,
@@ -2539,6 +2539,7 @@ impl CodexMessageProcessor {
dynamic_tools
.into_iter()
.map(|tool| CoreDynamicToolSpec {
namespace: tool.namespace,
name: tool.name,
description: tool.description,
input_schema: tool.input_schema,
@@ -9208,6 +9209,29 @@ fn validate_dynamic_tools(tools: &[ApiDynamicToolSpec]) -> Result<(), String> {
if !seen.insert(name.to_string()) {
return Err(format!("duplicate dynamic tool name: {name}"));
}
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 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!(
@@ -10160,6 +10184,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"}),
@@ -10172,6 +10197,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.
@@ -10184,6 +10210,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!({
@@ -10199,6 +10226,76 @@ mod tests {
validate_dynamic_tools(&tools).expect("valid schema");
}
#[test]
fn validate_dynamic_tools_requires_namespace_for_deferred_tools() {
let tools = vec![ApiDynamicToolSpec {
namespace: None,
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("missing namespace");
assert!(err.contains("my_tool"), "unexpected error: {err}");
assert!(err.contains("namespace"), "unexpected error: {err}");
}
#[test]
fn validate_dynamic_tools_accepts_namespace_for_deferred_tools() {
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,
}];
validate_dynamic_tools(&tools).expect("valid schema");
}
#[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!({}),
},
@@ -63,6 +63,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(),
@@ -136,6 +137,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!({
@@ -222,6 +224,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: None,
name: tool_name.to_string(),
description: "Demo dynamic tool".to_string(),
input_schema: json!({
@@ -273,6 +276,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,
@@ -284,6 +288,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, None);
assert_eq!(tool, tool_name);
assert_eq!(arguments, tool_args);
assert_eq!(status, DynamicToolCallStatus::InProgress);
@@ -306,6 +311,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: None,
tool: tool_name.to_string(),
arguments: tool_args.clone(),
};
@@ -326,6 +332,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,
@@ -337,6 +344,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, None);
assert_eq!(tool, tool_name);
assert_eq!(arguments, tool_args);
assert_eq!(status, DynamicToolCallStatus::Completed);
@@ -391,6 +399,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!({
@@ -454,6 +463,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
@@ -92,7 +92,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;
@@ -952,7 +954,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()
@@ -960,7 +962,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()
};
@@ -974,6 +976,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,
+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,
@@ -75,9 +75,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();
@@ -99,6 +101,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(),
});
@@ -109,6 +112,7 @@ async fn request_dynamic_tool(
Some(response) => EventMsg::DynamicToolCallResponse(DynamicToolCallResponseEvent {
call_id,
turn_id,
namespace,
tool,
arguments,
content_items: response.content_items.clone(),
@@ -119,6 +123,7 @@ async fn request_dynamic_tool(
None => EventMsg::DynamicToolCallResponse(DynamicToolCallResponseEvent {
call_id,
turn_id,
namespace,
tool,
arguments,
content_items: Vec::new(),
+20 -15
View File
@@ -209,6 +209,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!({
@@ -277,21 +278,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,
ToolSearchOutputTool::Namespace(ResponsesApiNamespace {
name: "codex_app".to_string(),
description: "Tools provided by the current Codex thread.".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
@@ -1587,7 +1587,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)
}
})
}
@@ -1821,6 +1821,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!({
@@ -1920,6 +1921,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!({
@@ -1995,6 +1997,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!({
+17 -1
View File
@@ -1,5 +1,7 @@
use codex_mcp::ToolInfo;
use codex_protocol::dynamic_tools::DynamicToolSpec;
use codex_tools::ResponsesApiNamespace;
use codex_tools::ResponsesApiNamespaceTool;
use codex_tools::ToolSearchOutputTool;
use codex_tools::ToolSearchResultSource;
use codex_tools::dynamic_tool_to_responses_api_tool;
@@ -68,9 +70,19 @@ fn mcp_tool_search_entry(info: &ToolInfo) -> Result<ToolSearchEntry, serde_json:
}
fn dynamic_tool_search_entry(tool: &DynamicToolSpec) -> Result<ToolSearchEntry, serde_json::Error> {
let output_tool = dynamic_tool_to_responses_api_tool(tool)?;
let output = match tool.namespace.as_ref() {
Some(namespace) => ToolSearchOutputTool::Namespace(ResponsesApiNamespace {
name: namespace.clone(),
description: "Tools provided by the current Codex thread.".to_string(),
tools: vec![ResponsesApiNamespaceTool::Function(output_tool)],
}),
None => ToolSearchOutputTool::Function(output_tool),
};
Ok(ToolSearchEntry {
search_text: build_dynamic_search_text(tool),
output: ToolSearchOutputTool::Function(dynamic_tool_to_responses_api_tool(tool)?),
output,
limit_bucket: None,
})
}
@@ -135,6 +147,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")
+2
View File
@@ -2555,6 +2555,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!({
@@ -2635,6 +2636,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
+14 -6
View File
@@ -731,6 +731,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,
}
@@ -755,6 +756,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(),
@@ -794,6 +796,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);
@@ -833,12 +836,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 provided by the current Codex thread.",
"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
@@ -2433,6 +2433,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,
@@ -767,16 +768,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)
+14 -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,18 @@ fn code_mode_tool_definitions_for_spec(spec: &ToolSpec) -> Vec<CodeModeToolDefin
}
}
pub fn code_mode_name_for_tool_name(tool_name: &ToolName) -> String {
if tool_name
.namespace
.as_deref()
.is_some_and(|namespace| namespace.starts_with("mcp__"))
{
tool_name.display()
} else {
tool_name.name.clone()
}
}
#[cfg(test)]
#[path = "code_mode_tests.rs"]
mod tests;
+1
View File
@@ -4,6 +4,7 @@ use codex_protocol::dynamic_tools::DynamicToolSpec;
pub fn parse_dynamic_tool(tool: &DynamicToolSpec) -> Result<ToolDefinition, serde_json::Error> {
let DynamicToolSpec {
namespace: _,
name,
description,
input_schema,
+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!({
+1
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;
@@ -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!({
+13 -2
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;
@@ -558,12 +559,22 @@ pub fn build_tool_registry_plan(
for tool in params.dynamic_tools {
match dynamic_tool_to_responses_api_tool(tool) {
Ok(converted_tool) => {
let handler_name = ToolName::new(tool.namespace.clone(), tool.name.clone());
let spec = if let Some(namespace) = tool.namespace.as_ref() {
ToolSpec::Namespace(ResponsesApiNamespace {
name: namespace.clone(),
description: default_namespace_description(namespace),
tools: vec![ResponsesApiNamespaceTool::Function(converted_tool)],
})
} else {
ToolSpec::Function(converted_tool)
};
plan.push_spec(
ToolSpec::Function(converted_tool),
spec,
/*supports_parallel_tool_calls*/ false,
config.code_mode_enabled,
);
plan.register_handler(tool.name.clone(), ToolHandlerKind::DynamicTool);
plan.register_handler(handler_name, ToolHandlerKind::DynamicTool);
}
Err(error) => {
tracing::error!(
@@ -1424,6 +1424,7 @@ fn search_tool_registers_for_deferred_dynamic_tools() {
windows_sandbox_level: WindowsSandboxLevel::Disabled,
});
let dynamic_tool = 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!({
@@ -1447,13 +1448,15 @@ 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"]);
let dynamic_tool = find_namespace_function_tool(&tools, "codex_app", "automation_update");
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,
}));
}
@@ -653,6 +653,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!({}),
},