Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 2 additions & 1 deletion codex-rs/app-server/src/request_processors.rs
Original file line number Diff line number Diff line change
Expand Up @@ -376,7 +376,8 @@ use codex_protocol::config_types::Personality;
use codex_protocol::config_types::ReasoningSummary;
use codex_protocol::config_types::TrustLevel;
use codex_protocol::config_types::WindowsSandboxLevel;
use codex_protocol::dynamic_tools::DynamicToolSpec as CoreDynamicToolSpec;
use codex_protocol::dynamic_tools::DynamicToolFunctionSpec;
use codex_protocol::dynamic_tools::group_dynamic_tools_by_namespace;
use codex_protocol::error::CodexErr;
use codex_protocol::error::Result as CodexResult;
#[cfg(test)]
Expand Down
25 changes: 16 additions & 9 deletions codex-rs/app-server/src/request_processors/thread_processor.rs
Original file line number Diff line number Diff line change
Expand Up @@ -1075,22 +1075,29 @@ impl ThreadRequestProcessor {
.default_environment_selections(&config.cwd)
});
let dynamic_tools = dynamic_tools.unwrap_or_default();
// Count callable tools before grouping changes the outer list length.
let core_dynamic_tool_count = dynamic_tools.len();
let core_dynamic_tools = if dynamic_tools.is_empty() {
Vec::new()
} else {
validate_dynamic_tools(&dynamic_tools).map_err(invalid_request)?;
dynamic_tools
// Normalize the flat app-server input into core's function and namespace types.
let tools = dynamic_tools
.into_iter()
.map(|tool| CoreDynamicToolSpec {
namespace: tool.namespace,
name: tool.name,
description: tool.description,
input_schema: tool.input_schema,
defer_loading: tool.defer_loading,
.map(|tool| {
(
tool.namespace,
DynamicToolFunctionSpec {
name: tool.name,
description: tool.description,
input_schema: tool.input_schema,
defer_loading: tool.defer_loading,
},
)
})
.collect()
.collect();
group_dynamic_tools_by_namespace(tools)
};
let core_dynamic_tool_count = core_dynamic_tools.len();
let mut thread_extension_init = ExtensionDataInit::new();
if !selected_capability_roots.is_empty() {
thread_extension_init.insert(selected_capability_roots);
Expand Down
96 changes: 63 additions & 33 deletions codex-rs/app-server/tests/suite/v2/dynamic_tools.rs
Original file line number Diff line number Diff line change
Expand Up @@ -42,9 +42,8 @@ const DEFAULT_READ_TIMEOUT: Duration = Duration::from_secs(60);
#[cfg(not(any(target_os = "macos", windows)))]
const DEFAULT_READ_TIMEOUT: Duration = Duration::from_secs(10);

/// Ensures dynamic tool specs are serialized into the model request payload.
#[tokio::test]
async fn thread_start_injects_dynamic_tools_into_model_requests() -> Result<()> {
async fn thread_start_normalizes_legacy_dynamic_tools_into_model_request() -> Result<()> {

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

integration test for old client compat; flat legacy tools are accepted + grouped into a namespace

let responses = vec![create_final_assistant_message_sse_response("Done")?];
let server = create_mock_responses_server_sequence_unchecked(responses).await;

Expand All @@ -54,29 +53,45 @@ async fn thread_start_injects_dynamic_tools_into_model_requests() -> Result<()>
let mut mcp = TestAppServer::new(codex_home.path()).await?;
timeout(DEFAULT_READ_TIMEOUT, mcp.initialize()).await??;

// Use a minimal JSON schema so we can assert the tool payload round-trips.
let input_schema = json!({
let visible_schema = json!({
"type": "object",
"properties": {
"city": { "type": "string" }
"ticket_id": { "type": "string" }
},
"required": ["city"],
"required": ["ticket_id"],
"additionalProperties": false,
});
let dynamic_tool = DynamicToolSpec {
namespace: None,
name: "demo_tool".to_string(),
description: "Demo dynamic tool".to_string(),
input_schema: input_schema.clone(),
defer_loading: false,
};

// Thread start injects dynamic tools into the thread's tool registry.
let thread_req = mcp
.send_thread_start_request(ThreadStartParams {
dynamic_tools: Some(vec![dynamic_tool.clone()]),
..Default::default()
})
.send_raw_request(
"thread/start",
Some(json!({
"dynamicTools": [
{
"name": "lookup_ticket",
"description": "Look up a ticket",
"inputSchema": visible_schema,
},
{
"namespace": "legacy_app",
"name": "lookup_status",
"description": "Look up a ticket status",
"inputSchema": visible_schema,
"exposeToContext": true
},
{
"namespace": "legacy_app",
"name": "update_ticket",
"description": "Update a ticket",
"inputSchema": {
"type": "object",
"properties": {},
"additionalProperties": false
},
"exposeToContext": false
}
]
})),
)
.await?;
let thread_resp: JSONRPCResponse = timeout(
DEFAULT_READ_TIMEOUT,
Expand All @@ -85,13 +100,12 @@ async fn thread_start_injects_dynamic_tools_into_model_requests() -> Result<()>
.await??;
let ThreadStartResponse { thread, .. } = to_response::<ThreadStartResponse>(thread_resp)?;

// Start a turn so a model request is issued.
let turn_req = mcp
.send_turn_start_request(TurnStartParams {
thread_id: thread.id.clone(),
thread_id: thread.id,
client_user_message_id: None,
input: vec![V2UserInput::Text {
text: "Hello".to_string(),
text: "Look up the ticket".to_string(),
text_elements: Vec::new(),
}],
..Default::default()
Expand All @@ -103,26 +117,42 @@ async fn thread_start_injects_dynamic_tools_into_model_requests() -> Result<()>
)
.await??;
let _turn: TurnStartResponse = to_response::<TurnStartResponse>(turn_resp)?;

timeout(
DEFAULT_READ_TIMEOUT,
mcp.read_stream_until_notification_message("turn/completed"),
)
.await??;

// Inspect the captured model request to assert the tool spec made it through.
let bodies = responses_bodies(&server).await?;
let body = bodies
.first()
.context("expected at least one responses request")?;
let tool = find_tool(body, &dynamic_tool.name)
.context("expected dynamic tool to be injected into request")?;

let function =
find_tool(&bodies[0], "lookup_ticket").context("expected normalized legacy function")?;
assert_eq!(
function,
&json!({
"type": "function",
"name": "lookup_ticket",
"description": "Look up a ticket",
"strict": false,
"parameters": visible_schema,
})
);
let namespace =
find_tool(&bodies[0], "legacy_app").context("expected normalized legacy namespace")?;
assert_eq!(
tool.get("description"),
Some(&Value::String(dynamic_tool.description.clone()))
namespace,
&json!({
"type": "namespace",
"name": "legacy_app",
"description": "Tools in the legacy_app namespace.",
"tools": [{
"type": "function",
"name": "lookup_status",
"description": "Look up a ticket status",
"strict": false,
"parameters": visible_schema,
}],
})
);
assert_eq!(tool.get("parameters"), Some(&input_schema));

Ok(())
}
Expand Down
3 changes: 3 additions & 0 deletions codex-rs/core-api/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -64,6 +64,9 @@ pub use codex_protocol::config_types::AutoCompactTokenLimitScope;
pub use codex_protocol::config_types::CollaborationModeMask;
pub use codex_protocol::config_types::ShellEnvironmentPolicy;
pub use codex_protocol::config_types::WebSearchMode;
pub use codex_protocol::dynamic_tools::DynamicToolFunctionSpec;
pub use codex_protocol::dynamic_tools::DynamicToolNamespaceSpec;
pub use codex_protocol::dynamic_tools::DynamicToolNamespaceTool;
pub use codex_protocol::dynamic_tools::DynamicToolSpec;
pub use codex_protocol::error::Result as CodexResult;
pub use codex_protocol::models::PermissionProfile;
Expand Down
34 changes: 28 additions & 6 deletions codex-rs/core/src/tools/handlers/dynamic.rs
Original file line number Diff line number Diff line change
Expand Up @@ -11,8 +11,9 @@ use crate::tools::registry::ToolExecutor;
use crate::tools::registry::ToolExposure;
use crate::turn_timing::now_unix_timestamp_ms;
use codex_protocol::dynamic_tools::DynamicToolCallRequest;
use codex_protocol::dynamic_tools::DynamicToolFunctionSpec;
use codex_protocol::dynamic_tools::DynamicToolNamespaceSpec;
use codex_protocol::dynamic_tools::DynamicToolResponse;
use codex_protocol::dynamic_tools::DynamicToolSpec;
use codex_protocol::models::FunctionCallOutputContentItem;
use codex_protocol::protocol::DynamicToolCallResponseEvent;
use codex_protocol::protocol::EventMsg;
Expand All @@ -36,13 +37,34 @@ pub struct DynamicToolHandler {
}

impl DynamicToolHandler {
pub fn new(tool: &DynamicToolSpec) -> Option<Self> {
let tool_name = ToolName::new(tool.namespace.clone(), tool.name.clone());
pub fn new(tool: &DynamicToolFunctionSpec) -> Option<Self> {
Self::from_parts(tool, /*namespace*/ None)
}

pub fn new_in_namespace(
namespace: &DynamicToolNamespaceSpec,
tool: &DynamicToolFunctionSpec,
) -> Option<Self> {
Self::from_parts(tool, Some(namespace))
}

fn from_parts(
tool: &DynamicToolFunctionSpec,
namespace: Option<&DynamicToolNamespaceSpec>,
) -> Option<Self> {
let tool_name = ToolName::new(
namespace.map(|namespace| namespace.name.clone()),
tool.name.clone(),
);
let output_tool = dynamic_tool_to_responses_api_tool(tool).ok()?;
let spec = match tool.namespace.as_ref() {
let spec = match namespace {
Some(namespace) => ToolSpec::Namespace(ResponsesApiNamespace {
Comment thread
sayan-oai marked this conversation as resolved.
name: namespace.clone(),
description: default_namespace_description(namespace),
name: namespace.name.clone(),
description: if namespace.description.trim().is_empty() {
default_namespace_description(&namespace.name)
} else {
namespace.description.clone()
},
tools: vec![ResponsesApiNamespaceTool::Function(output_tool)],
}),
None => ToolSpec::Function(output_tool),
Expand Down
13 changes: 9 additions & 4 deletions codex-rs/core/src/tools/handlers/tool_search.rs
Original file line number Diff line number Diff line change
Expand Up @@ -144,7 +144,8 @@ mod tests {
use crate::tools::handlers::DynamicToolHandler;
use crate::tools::handlers::McpHandler;
use codex_mcp::ToolInfo;
use codex_protocol::dynamic_tools::DynamicToolSpec;
use codex_protocol::dynamic_tools::DynamicToolFunctionSpec;
use codex_protocol::dynamic_tools::DynamicToolNamespaceSpec;
use codex_tools::ResponsesApiNamespace;
use codex_tools::ResponsesApiNamespaceTool;
use codex_tools::ResponsesApiTool;
Expand All @@ -154,8 +155,12 @@ mod tests {

#[test]
fn mixed_search_results_coalesce_mcp_namespaces() {
let dynamic_tools = [DynamicToolSpec {
namespace: Some("codex_app".to_string()),
let dynamic_namespace = DynamicToolNamespaceSpec {
name: "codex_app".to_string(),
description: "Tools in the codex_app namespace.".to_string(),
tools: Vec::new(),
};
let dynamic_tools = [DynamicToolFunctionSpec {
name: "automation_update".to_string(),
description: "Create, update, view, or delete recurring automations.".to_string(),
input_schema: serde_json::json!({
Expand All @@ -182,7 +187,7 @@ mod tests {
})
.collect::<Vec<_>>();
search_infos.extend(dynamic_tools.iter().map(|tool| {
DynamicToolHandler::new(tool)
DynamicToolHandler::new_in_namespace(&dynamic_namespace, tool)
.expect("dynamic tool should convert")
.search_info()
.expect("dynamic handler should return search info")
Expand Down
49 changes: 27 additions & 22 deletions codex-rs/core/src/tools/router_tests.rs
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,9 @@ use codex_extension_api::ExtensionRegistryBuilder;
use codex_extension_api::ResponsesApiTool;
use codex_extension_api::ToolCall as ExtensionToolCall;
use codex_extension_api::ToolExecutor;
use codex_protocol::dynamic_tools::DynamicToolFunctionSpec;
use codex_protocol::dynamic_tools::DynamicToolNamespaceSpec;
use codex_protocol::dynamic_tools::DynamicToolNamespaceTool;
use codex_protocol::dynamic_tools::DynamicToolSpec;
use codex_protocol::models::ContentItem;
use codex_protocol::models::FunctionCallOutputBody;
Expand Down Expand Up @@ -249,30 +252,32 @@ async fn specs_filter_deferred_dynamic_tools() -> anyhow::Result<()> {
let (_, turn) = make_session_and_context().await;
let hidden_tool = "hidden_dynamic_tool";
let visible_tool = "visible_dynamic_tool";
let dynamic_tools = vec![
DynamicToolSpec {
namespace: Some("codex_app".to_string()),
name: hidden_tool.to_string(),
description: "Hidden until discovered.".to_string(),
input_schema: json!({
"type": "object",
"properties": {},
"additionalProperties": false,
let dynamic_tools = vec![DynamicToolSpec::Namespace(DynamicToolNamespaceSpec {
name: "codex_app".to_string(),
description: "Codex app tools.".to_string(),
tools: vec![
DynamicToolNamespaceTool::Function(DynamicToolFunctionSpec {
name: hidden_tool.to_string(),
description: "Hidden until discovered.".to_string(),
input_schema: json!({
"type": "object",
"properties": {},
"additionalProperties": false,
}),
defer_loading: true,
}),
defer_loading: true,
},
DynamicToolSpec {
namespace: Some("codex_app".to_string()),
name: visible_tool.to_string(),
description: "Visible immediately.".to_string(),
input_schema: json!({
"type": "object",
"properties": {},
"additionalProperties": false,
DynamicToolNamespaceTool::Function(DynamicToolFunctionSpec {
name: visible_tool.to_string(),
description: "Visible immediately.".to_string(),
input_schema: json!({
"type": "object",
"properties": {},
"additionalProperties": false,
}),
defer_loading: false,
}),
defer_loading: false,
},
];
],
})];

let router = ToolRouter::from_turn_context(
&turn,
Expand Down
Loading
Loading