diff --git a/codex-rs/app-server-protocol/src/protocol/common.rs b/codex-rs/app-server-protocol/src/protocol/common.rs index fcc35a60ddf..75acf96f2ad 100644 --- a/codex-rs/app-server-protocol/src/protocol/common.rs +++ b/codex-rs/app-server-protocol/src/protocol/common.rs @@ -524,6 +524,12 @@ server_request_definitions! { response: v2::ToolRequestUserInputResponse, }, + /// Execute a dynamic tool call on the client. + DynamicToolCall => "item/tool/call" { + params: v2::DynamicToolCallParams, + response: v2::DynamicToolCallResponse, + }, + /// DEPRECATED APIs below /// Request to approve a patch. /// This request is used for Turns started via the legacy APIs (i.e. SendUserTurn, SendUserMessage). diff --git a/codex-rs/app-server-protocol/src/protocol/v2.rs b/codex-rs/app-server-protocol/src/protocol/v2.rs index e282e924fc7..6d30aeeed11 100644 --- a/codex-rs/app-server-protocol/src/protocol/v2.rs +++ b/codex-rs/app-server-protocol/src/protocol/v2.rs @@ -325,6 +325,15 @@ pub struct ToolsV2 { pub view_image: Option, } +#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, JsonSchema, TS)] +#[serde(rename_all = "camelCase")] +#[ts(export_to = "v2/")] +pub struct DynamicToolSpec { + pub name: String, + pub description: String, + pub input_schema: JsonValue, +} + #[derive(Serialize, Deserialize, Debug, Clone, PartialEq, JsonSchema, TS)] #[serde(rename_all = "snake_case")] #[ts(export_to = "v2/")] @@ -1088,6 +1097,7 @@ pub struct ThreadStartParams { pub developer_instructions: Option, pub personality: Option, pub ephemeral: Option, + pub dynamic_tools: Option>, /// If true, opt into emitting raw response items on the event stream. /// /// This is for internal use only (e.g. Codex Cloud). @@ -2372,6 +2382,25 @@ pub struct FileChangeRequestApprovalResponse { pub decision: FileChangeApprovalDecision, } +#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, JsonSchema, TS)] +#[serde(rename_all = "camelCase")] +#[ts(export_to = "v2/")] +pub struct DynamicToolCallParams { + pub thread_id: String, + pub turn_id: String, + pub call_id: String, + pub tool: String, + pub arguments: JsonValue, +} + +#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, JsonSchema, TS)] +#[serde(rename_all = "camelCase")] +#[ts(export_to = "v2/")] +pub struct DynamicToolCallResponse { + pub output: String, + pub success: bool, +} + #[derive(Serialize, Deserialize, Debug, Clone, PartialEq, JsonSchema, TS)] #[serde(rename_all = "camelCase")] #[ts(export_to = "v2/")] diff --git a/codex-rs/app-server/README.md b/codex-rs/app-server/README.md index 659e2bf72c5..a4860ea5128 100644 --- a/codex-rs/app-server/README.md +++ b/codex-rs/app-server/README.md @@ -114,7 +114,20 @@ Start a fresh thread when you need a new Codex conversation. "cwd": "/Users/me/project", "approvalPolicy": "never", "sandbox": "workspaceWrite", - "personality": "friendly" + "personality": "friendly", + "dynamicTools": [ + { + "name": "lookup_ticket", + "description": "Fetch a ticket by id", + "inputSchema": { + "type": "object", + "properties": { + "id": { "type": "string" } + }, + "required": ["id"] + } + } + ], } } { "id": 10, "result": { "thread": { diff --git a/codex-rs/app-server/src/bespoke_event_handling.rs b/codex-rs/app-server/src/bespoke_event_handling.rs index 68e233b843b..0cb6fb1bc6a 100644 --- a/codex-rs/app-server/src/bespoke_event_handling.rs +++ b/codex-rs/app-server/src/bespoke_event_handling.rs @@ -25,6 +25,7 @@ use codex_app_server_protocol::CommandExecutionRequestApprovalResponse; use codex_app_server_protocol::CommandExecutionStatus; use codex_app_server_protocol::ContextCompactedNotification; use codex_app_server_protocol::DeprecationNoticeNotification; +use codex_app_server_protocol::DynamicToolCallParams; use codex_app_server_protocol::ErrorNotification; use codex_app_server_protocol::ExecCommandApprovalParams; use codex_app_server_protocol::ExecCommandApprovalResponse; @@ -85,6 +86,7 @@ use codex_core::protocol::TurnDiffEvent; use codex_core::review_format::format_review_findings_block; use codex_core::review_prompts; use codex_protocol::ThreadId; +use codex_protocol::dynamic_tools::DynamicToolResponse as CoreDynamicToolResponse; use codex_protocol::plan_tool::UpdatePlanArgs; use codex_protocol::protocol::ReviewOutputEvent; use codex_protocol::request_user_input::RequestUserInputAnswer as CoreRequestUserInputAnswer; @@ -318,6 +320,40 @@ pub(crate) async fn apply_bespoke_event_handling( } } } + EventMsg::DynamicToolCallRequest(request) => { + if matches!(api_version, ApiVersion::V2) { + let call_id = request.call_id; + let params = DynamicToolCallParams { + thread_id: conversation_id.to_string(), + turn_id: request.turn_id, + call_id: call_id.clone(), + tool: request.tool, + arguments: request.arguments, + }; + let rx = outgoing + .send_request(ServerRequestPayload::DynamicToolCall(params)) + .await; + tokio::spawn(async move { + crate::dynamic_tools::on_call_response(call_id, rx, conversation).await; + }); + } else { + error!( + "dynamic tool calls are only supported on api v2 (call_id: {})", + request.call_id + ); + let call_id = request.call_id; + let _ = conversation + .submit(Op::DynamicToolResponse { + id: call_id.clone(), + response: CoreDynamicToolResponse { + call_id, + output: "dynamic tool calls require api v2".to_string(), + success: false, + }, + }) + .await; + } + } // TODO(celia): properly construct McpToolCall TurnItem in core. EventMsg::McpToolCallBegin(begin_event) => { let notification = construct_mcp_tool_call_notification( diff --git a/codex-rs/app-server/src/codex_message_processor.rs b/codex-rs/app-server/src/codex_message_processor.rs index d0b8c7f949a..ab2f31fc51c 100644 --- a/codex-rs/app-server/src/codex_message_processor.rs +++ b/codex-rs/app-server/src/codex_message_processor.rs @@ -31,6 +31,7 @@ use codex_app_server_protocol::CollaborationModeListResponse; use codex_app_server_protocol::CommandExecParams; use codex_app_server_protocol::ConversationGitInfo; use codex_app_server_protocol::ConversationSummary; +use codex_app_server_protocol::DynamicToolSpec as ApiDynamicToolSpec; use codex_app_server_protocol::ExecOneOffCommandResponse; use codex_app_server_protocol::FeedbackUploadParams; use codex_app_server_protocol::FeedbackUploadResponse; @@ -171,6 +172,7 @@ use codex_login::run_login_server; use codex_protocol::ThreadId; use codex_protocol::config_types::ForcedLoginMethod; use codex_protocol::config_types::Personality; +use codex_protocol::dynamic_tools::DynamicToolSpec as CoreDynamicToolSpec; use codex_protocol::items::TurnItem; use codex_protocol::models::ResponseItem; use codex_protocol::protocol::AgentStatus; @@ -1411,35 +1413,81 @@ impl CodexMessageProcessor { } async fn thread_start(&mut self, request_id: RequestId, params: ThreadStartParams) { + let ThreadStartParams { + model, + model_provider, + cwd, + approval_policy, + sandbox, + config, + base_instructions, + developer_instructions, + dynamic_tools, + experimental_raw_events, + personality, + ephemeral, + } = params; let mut typesafe_overrides = self.build_thread_config_overrides( - params.model, - params.model_provider, - params.cwd, - params.approval_policy, - params.sandbox, - params.base_instructions, - params.developer_instructions, - params.personality, + model, + model_provider, + cwd, + approval_policy, + sandbox, + base_instructions, + developer_instructions, + personality, ); - typesafe_overrides.ephemeral = Some(params.ephemeral.unwrap_or_default()); + typesafe_overrides.ephemeral = ephemeral; - let config = - match derive_config_from_params(&self.cli_overrides, params.config, typesafe_overrides) - .await - { - Ok(config) => config, - Err(err) => { - let error = JSONRPCErrorError { - code: INVALID_REQUEST_ERROR_CODE, - message: format!("error deriving config: {err}"), - data: None, - }; - self.outgoing.send_error(request_id, error).await; - return; - } - }; + let config = match derive_config_from_params( + &self.cli_overrides, + config, + typesafe_overrides, + ) + .await + { + Ok(config) => config, + Err(err) => { + let error = JSONRPCErrorError { + code: INVALID_REQUEST_ERROR_CODE, + message: format!("error deriving config: {err}"), + data: None, + }; + self.outgoing.send_error(request_id, error).await; + return; + } + }; - match self.thread_manager.start_thread(config).await { + let dynamic_tools = dynamic_tools.unwrap_or_default(); + let core_dynamic_tools = if dynamic_tools.is_empty() { + Vec::new() + } else { + let snapshot = collect_mcp_snapshot(&config).await; + let mcp_tool_names = snapshot.tools.keys().cloned().collect::>(); + if let Err(message) = validate_dynamic_tools(&dynamic_tools, &mcp_tool_names) { + let error = JSONRPCErrorError { + code: INVALID_REQUEST_ERROR_CODE, + message, + data: None, + }; + self.outgoing.send_error(request_id, error).await; + return; + } + dynamic_tools + .into_iter() + .map(|tool| CoreDynamicToolSpec { + name: tool.name, + description: tool.description, + input_schema: tool.input_schema, + }) + .collect() + }; + + match self + .thread_manager + .start_thread_with_tools(config, core_dynamic_tools) + .await + { Ok(new_conv) => { let NewThread { thread_id, @@ -1489,7 +1537,7 @@ impl CodexMessageProcessor { if let Err(err) = self .attach_conversation_listener( thread_id, - params.experimental_raw_events, + experimental_raw_events, ApiVersion::V2, ) .await @@ -4322,6 +4370,41 @@ fn errors_to_info( .collect() } +fn validate_dynamic_tools( + tools: &[ApiDynamicToolSpec], + mcp_tool_names: &HashSet, +) -> Result<(), String> { + let mut seen = HashSet::new(); + for tool in tools { + let name = tool.name.trim(); + if name.is_empty() { + return Err("dynamic tool name must not be empty".to_string()); + } + if name != tool.name { + return Err(format!( + "dynamic tool name has leading/trailing whitespace: {}", + tool.name + )); + } + if name == "mcp" || name.starts_with("mcp__") { + return Err(format!("dynamic tool name is reserved: {name}")); + } + if mcp_tool_names.contains(name) { + return Err(format!("dynamic tool name conflicts with MCP tool: {name}")); + } + if !seen.insert(name.to_string()) { + return Err(format!("duplicate dynamic tool name: {name}")); + } + + if let Err(err) = codex_core::parse_tool_input_schema(&tool.input_schema) { + return Err(format!( + "dynamic tool input schema is not supported for {name}: {err}" + )); + } + } + Ok(()) +} + /// Derive the effective [`Config`] by layering three override sources. /// /// Precedence (lowest to highest): @@ -4602,6 +4685,28 @@ mod tests { use serde_json::json; use tempfile::TempDir; + #[test] + fn validate_dynamic_tools_rejects_unsupported_input_schema() { + let tools = vec![ApiDynamicToolSpec { + name: "my_tool".to_string(), + description: "test".to_string(), + input_schema: json!({"type": "null"}), + }]; + let err = validate_dynamic_tools(&tools, &HashSet::new()).expect_err("invalid schema"); + assert!(err.contains("my_tool"), "unexpected error: {err}"); + } + + #[test] + fn validate_dynamic_tools_accepts_sanitizable_input_schema() { + let tools = vec![ApiDynamicToolSpec { + name: "my_tool".to_string(), + description: "test".to_string(), + // Missing `type` is common; core sanitizes these to a supported schema. + input_schema: json!({"properties": {}}), + }]; + validate_dynamic_tools(&tools, &HashSet::new()).expect("valid schema"); + } + #[test] fn extract_conversation_summary_prefers_plain_user_messages() -> Result<()> { let conversation_id = ThreadId::from_string("3f941c35-29b3-493b-b0a4-e25800d9aeb0")?; diff --git a/codex-rs/app-server/src/dynamic_tools.rs b/codex-rs/app-server/src/dynamic_tools.rs new file mode 100644 index 00000000000..a1b424d0ee7 --- /dev/null +++ b/codex-rs/app-server/src/dynamic_tools.rs @@ -0,0 +1,58 @@ +use codex_app_server_protocol::DynamicToolCallResponse; +use codex_core::CodexThread; +use codex_protocol::dynamic_tools::DynamicToolResponse as CoreDynamicToolResponse; +use codex_protocol::protocol::Op; +use std::sync::Arc; +use tokio::sync::oneshot; +use tracing::error; + +pub(crate) async fn on_call_response( + call_id: String, + receiver: oneshot::Receiver, + conversation: Arc, +) { + let response = receiver.await; + let value = match response { + Ok(value) => value, + Err(err) => { + error!("request failed: {err:?}"); + let fallback = CoreDynamicToolResponse { + call_id: call_id.clone(), + output: "dynamic tool request failed".to_string(), + success: false, + }; + if let Err(err) = conversation + .submit(Op::DynamicToolResponse { + id: call_id.clone(), + response: fallback, + }) + .await + { + error!("failed to submit DynamicToolResponse: {err}"); + } + return; + } + }; + + let response = serde_json::from_value::(value).unwrap_or_else(|err| { + error!("failed to deserialize DynamicToolCallResponse: {err}"); + DynamicToolCallResponse { + output: "dynamic tool response was invalid".to_string(), + success: false, + } + }); + let response = CoreDynamicToolResponse { + call_id: call_id.clone(), + output: response.output, + success: response.success, + }; + if let Err(err) = conversation + .submit(Op::DynamicToolResponse { + id: call_id, + response, + }) + .await + { + error!("failed to submit DynamicToolResponse: {err}"); + } +} diff --git a/codex-rs/app-server/src/lib.rs b/codex-rs/app-server/src/lib.rs index fde208106d6..c6d943ae466 100644 --- a/codex-rs/app-server/src/lib.rs +++ b/codex-rs/app-server/src/lib.rs @@ -40,6 +40,7 @@ use tracing_subscriber::util::SubscriberInitExt; mod bespoke_event_handling; mod codex_message_processor; mod config_api; +mod dynamic_tools; mod error_code; mod fuzzy_file_search; mod message_processor; diff --git a/codex-rs/app-server/tests/suite/v2/dynamic_tools.rs b/codex-rs/app-server/tests/suite/v2/dynamic_tools.rs new file mode 100644 index 00000000000..dc985ac49f1 --- /dev/null +++ b/codex-rs/app-server/tests/suite/v2/dynamic_tools.rs @@ -0,0 +1,286 @@ +use anyhow::Context; +use anyhow::Result; +use app_test_support::McpProcess; +use app_test_support::create_final_assistant_message_sse_response; +use app_test_support::create_mock_responses_server_sequence_unchecked; +use app_test_support::to_response; +use codex_app_server_protocol::DynamicToolCallParams; +use codex_app_server_protocol::DynamicToolCallResponse; +use codex_app_server_protocol::DynamicToolSpec; +use codex_app_server_protocol::JSONRPCResponse; +use codex_app_server_protocol::RequestId; +use codex_app_server_protocol::ServerRequest; +use codex_app_server_protocol::ThreadStartParams; +use codex_app_server_protocol::ThreadStartResponse; +use codex_app_server_protocol::TurnStartParams; +use codex_app_server_protocol::TurnStartResponse; +use codex_app_server_protocol::UserInput as V2UserInput; +use core_test_support::responses; +use pretty_assertions::assert_eq; +use serde_json::Value; +use serde_json::json; +use std::path::Path; +use std::time::Duration; +use tempfile::TempDir; +use tokio::time::timeout; +use wiremock::MockServer; + +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<()> { + let responses = vec![create_final_assistant_message_sse_response("Done")?]; + let server = create_mock_responses_server_sequence_unchecked(responses).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??; + + // Use a minimal JSON schema so we can assert the tool payload round-trips. + let input_schema = json!({ + "type": "object", + "properties": { + "city": { "type": "string" } + }, + "required": ["city"], + "additionalProperties": false, + }); + let dynamic_tool = DynamicToolSpec { + name: "demo_tool".to_string(), + description: "Demo dynamic tool".to_string(), + input_schema: input_schema.clone(), + }; + + // 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() + }) + .await?; + let thread_resp: JSONRPCResponse = timeout( + DEFAULT_READ_TIMEOUT, + mcp.read_stream_until_response_message(RequestId::Integer(thread_req)), + ) + .await??; + let ThreadStartResponse { thread, .. } = to_response::(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(), + input: vec![V2UserInput::Text { + text: "Hello".to_string(), + text_elements: Vec::new(), + }], + ..Default::default() + }) + .await?; + let turn_resp: JSONRPCResponse = timeout( + DEFAULT_READ_TIMEOUT, + mcp.read_stream_until_response_message(RequestId::Integer(turn_req)), + ) + .await??; + let _turn: TurnStartResponse = to_response::(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")?; + + assert_eq!( + tool.get("description"), + Some(&Value::String(dynamic_tool.description.clone())) + ); + assert_eq!(tool.get("parameters"), Some(&input_schema)); + + Ok(()) +} + +/// Exercises the full dynamic tool call path (server request, client response, model output). +#[tokio::test] +async fn dynamic_tool_call_round_trip_sends_output_to_model() -> Result<()> { + let call_id = "dyn-call-1"; + let tool_name = "demo_tool"; + let tool_args = json!({ "city": "Paris" }); + let tool_call_arguments = serde_json::to_string(&tool_args)?; + + // First response triggers a dynamic tool call, second closes the turn. + let responses = vec![ + responses::sse(vec![ + responses::ev_response_created("resp-1"), + responses::ev_function_call(call_id, tool_name, &tool_call_arguments), + responses::ev_completed("resp-1"), + ]), + create_final_assistant_message_sse_response("Done")?, + ]; + let server = create_mock_responses_server_sequence_unchecked(responses).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 { + name: tool_name.to_string(), + description: "Demo dynamic tool".to_string(), + input_schema: json!({ + "type": "object", + "properties": { + "city": { "type": "string" } + }, + "required": ["city"], + "additionalProperties": false, + }), + }; + + let thread_req = mcp + .send_thread_start_request(ThreadStartParams { + dynamic_tools: Some(vec![dynamic_tool]), + ..Default::default() + }) + .await?; + let thread_resp: JSONRPCResponse = timeout( + DEFAULT_READ_TIMEOUT, + mcp.read_stream_until_response_message(RequestId::Integer(thread_req)), + ) + .await??; + let ThreadStartResponse { thread, .. } = to_response::(thread_resp)?; + + // Start a turn so the tool call is emitted. + let turn_req = mcp + .send_turn_start_request(TurnStartParams { + thread_id: thread.id.clone(), + input: vec![V2UserInput::Text { + text: "Run the tool".to_string(), + text_elements: Vec::new(), + }], + ..Default::default() + }) + .await?; + let turn_resp: JSONRPCResponse = timeout( + DEFAULT_READ_TIMEOUT, + mcp.read_stream_until_response_message(RequestId::Integer(turn_req)), + ) + .await??; + let TurnStartResponse { turn } = to_response::(turn_resp)?; + + // Read the tool call request from the app server. + let request = timeout( + DEFAULT_READ_TIMEOUT, + mcp.read_stream_until_request_message(), + ) + .await??; + let (request_id, params) = match request { + ServerRequest::DynamicToolCall { request_id, params } => (request_id, params), + other => panic!("expected DynamicToolCall request, got {other:?}"), + }; + + let expected = DynamicToolCallParams { + thread_id: thread.id, + turn_id: turn.id, + call_id: call_id.to_string(), + tool: tool_name.to_string(), + arguments: tool_args.clone(), + }; + assert_eq!(params, expected); + + // Respond to the tool call so the model receives a function_call_output. + let response = DynamicToolCallResponse { + output: "dynamic-ok".to_string(), + success: true, + }; + mcp.send_response(request_id, serde_json::to_value(response)?) + .await?; + + timeout( + DEFAULT_READ_TIMEOUT, + mcp.read_stream_until_notification_message("turn/completed"), + ) + .await??; + + let bodies = responses_bodies(&server).await?; + let output = bodies + .iter() + .find_map(|body| function_call_output_text(body, call_id)) + .context("expected function_call_output in follow-up request")?; + assert_eq!(output, "dynamic-ok"); + + Ok(()) +} + +async fn responses_bodies(server: &MockServer) -> Result> { + let requests = server + .received_requests() + .await + .context("failed to fetch received requests")?; + + requests + .into_iter() + .filter(|req| req.url.path().ends_with("/responses")) + .map(|req| { + req.body_json::() + .context("request body should be JSON") + }) + .collect() +} + +fn find_tool<'a>(body: &'a Value, name: &str) -> Option<&'a Value> { + body.get("tools") + .and_then(Value::as_array) + .and_then(|tools| { + tools + .iter() + .find(|tool| tool.get("name").and_then(Value::as_str) == Some(name)) + }) +} + +fn function_call_output_text(body: &Value, call_id: &str) -> Option { + body.get("input") + .and_then(Value::as_array) + .and_then(|items| { + items.iter().find(|item| { + item.get("type").and_then(Value::as_str) == Some("function_call_output") + && item.get("call_id").and_then(Value::as_str) == Some(call_id) + }) + }) + .and_then(|item| item.get("output")) + .and_then(Value::as_str) + .map(str::to_string) +} + +fn create_config_toml(codex_home: &Path, server_uri: &str) -> std::io::Result<()> { + let config_toml = codex_home.join("config.toml"); + std::fs::write( + config_toml, + format!( + r#" +model = "mock-model" +approval_policy = "never" +sandbox_mode = "read-only" + +model_provider = "mock_provider" + +[model_providers.mock_provider] +name = "Mock provider for test" +base_url = "{server_uri}/v1" +wire_api = "responses" +request_max_retries = 0 +stream_max_retries = 0 +"# + ), + ) +} diff --git a/codex-rs/app-server/tests/suite/v2/mod.rs b/codex-rs/app-server/tests/suite/v2/mod.rs index 95ec61f20a8..4d9e105147e 100644 --- a/codex-rs/app-server/tests/suite/v2/mod.rs +++ b/codex-rs/app-server/tests/suite/v2/mod.rs @@ -3,6 +3,7 @@ mod analytics; mod app_list; mod collaboration_mode_list; mod config_rpc; +mod dynamic_tools; mod initialize; mod model_list; mod output_schema; diff --git a/codex-rs/core/src/codex.rs b/codex-rs/core/src/codex.rs index 9fc2bae1654..8a66fca0e1c 100644 --- a/codex-rs/core/src/codex.rs +++ b/codex-rs/core/src/codex.rs @@ -38,6 +38,8 @@ use codex_protocol::approvals::ExecPolicyAmendment; use codex_protocol::config_types::ModeKind; use codex_protocol::config_types::Settings; use codex_protocol::config_types::WebSearchMode; +use codex_protocol::dynamic_tools::DynamicToolResponse; +use codex_protocol::dynamic_tools::DynamicToolSpec; use codex_protocol::items::TurnItem; use codex_protocol::items::UserMessageItem; use codex_protocol::models::BaseInstructions; @@ -236,6 +238,7 @@ fn maybe_push_chat_wire_api_deprecation( impl Codex { /// Spawn a new [`Codex`] and initialize the session. + #[allow(clippy::too_many_arguments)] pub(crate) async fn spawn( config: Config, auth_manager: Arc, @@ -244,16 +247,12 @@ impl Codex { conversation_history: InitialHistory, session_source: SessionSource, agent_control: AgentControl, + dynamic_tools: Vec, ) -> CodexResult { let (tx_sub, rx_sub) = async_channel::bounded(SUBMISSION_CHANNEL_CAPACITY); let (tx_event, rx_event) = async_channel::unbounded(); let loaded_skills = skills_manager.skills_for_config(&config); - // let loaded_skills = if config.features.enabled(Feature::Skills) { - // Some(skills_manager.skills_for_config(&config)) - // } else { - // None - // }; for err in &loaded_skills.errors { error!( @@ -320,6 +319,7 @@ impl Codex { cwd: config.cwd.clone(), original_config_do_not_use: Arc::clone(&config), session_source, + dynamic_tools, }; // Generate a unique ID for the lifetime of this Codex session. @@ -443,6 +443,7 @@ pub(crate) struct TurnContext { pub(crate) codex_linux_sandbox_exe: Option, pub(crate) tool_call_gate: Arc, pub(crate) truncation_policy: TruncationPolicy, + pub(crate) dynamic_tools: Vec, } impl TurnContext { @@ -500,6 +501,7 @@ pub(crate) struct SessionConfiguration { original_config_do_not_use: Arc, /// Source of the session (cli, vscode, exec, mcp, ...) session_source: SessionSource, + dynamic_tools: Vec, } impl SessionConfiguration { @@ -616,6 +618,7 @@ impl Session { codex_linux_sandbox_exe: per_turn_config.codex_linux_sandbox_exe.clone(), tool_call_gate: Arc::new(ReadinessFlag::new()), truncation_policy: model_info.truncation_policy.into(), + dynamic_tools: session_configuration.dynamic_tools.clone(), } } @@ -1495,6 +1498,27 @@ impl Session { } } + pub async fn notify_dynamic_tool_response(&self, call_id: &str, response: DynamicToolResponse) { + let entry = { + let mut active = self.active_turn.lock().await; + match active.as_mut() { + Some(at) => { + let mut ts = at.turn_state.lock().await; + ts.remove_pending_dynamic_tool(call_id) + } + None => None, + } + }; + match entry { + Some(tx_response) => { + tx_response.send(response).ok(); + } + None => { + warn!("No pending dynamic tool call found for call_id: {call_id}"); + } + } + } + pub async fn notify_approval(&self, sub_id: &str, decision: ReviewDecision) { let entry = { let mut active = self.active_turn.lock().await; @@ -2156,6 +2180,9 @@ async fn submission_loop(sess: Arc, config: Arc, rx_sub: Receiv Op::UserInputAnswer { id, response } => { handlers::request_user_input_response(&sess, id, response).await; } + Op::DynamicToolResponse { id, response } => { + handlers::dynamic_tool_response(&sess, id, response).await; + } Op::AddToHistory { text } => { handlers::add_to_history(&sess, &config, text).await; } @@ -2252,6 +2279,7 @@ mod handlers { use codex_protocol::config_types::CollaborationMode; use codex_protocol::config_types::ModeKind; use codex_protocol::config_types::Settings; + use codex_protocol::dynamic_tools::DynamicToolResponse; use codex_protocol::user_input::UserInput; use codex_rmcp_client::ElicitationAction; use codex_rmcp_client::ElicitationResponse; @@ -2489,6 +2517,14 @@ mod handlers { sess.notify_user_input_response(&id, response).await; } + pub async fn dynamic_tool_response( + sess: &Arc, + id: String, + response: DynamicToolResponse, + ) { + sess.notify_dynamic_tool_response(&id, response).await; + } + pub async fn add_to_history(sess: &Arc, config: &Arc, text: String) { let id = sess.conversation_id; let config = Arc::clone(config); @@ -2826,6 +2862,7 @@ async fn spawn_review_thread( final_output_json_schema: None, codex_linux_sandbox_exe: parent_turn_context.codex_linux_sandbox_exe.clone(), tool_call_gate: Arc::new(ReadinessFlag::new()), + dynamic_tools: parent_turn_context.dynamic_tools.clone(), truncation_policy: model_info.truncation_policy.into(), }; @@ -3162,6 +3199,7 @@ async fn run_sampling_request( .map(|(name, tool)| (name, tool.tool)) .collect(), ), + turn_context.dynamic_tools.as_slice(), )); let model_supports_parallel = turn_context @@ -3942,6 +3980,7 @@ mod tests { cwd: config.cwd.clone(), original_config_do_not_use: Arc::clone(&config), session_source: SessionSource::Exec, + dynamic_tools: Vec::new(), }; let mut state = SessionState::new(session_configuration); @@ -4021,6 +4060,7 @@ mod tests { cwd: config.cwd.clone(), original_config_do_not_use: Arc::clone(&config), session_source: SessionSource::Exec, + dynamic_tools: Vec::new(), }; let mut state = SessionState::new(session_configuration); @@ -4284,6 +4324,7 @@ mod tests { cwd: config.cwd.clone(), original_config_do_not_use: Arc::clone(&config), session_source: SessionSource::Exec, + dynamic_tools: Vec::new(), }; let per_turn_config = Session::build_per_turn_config(&session_configuration); let model_info = ModelsManager::construct_model_info_offline( @@ -4392,6 +4433,7 @@ mod tests { cwd: config.cwd.clone(), original_config_do_not_use: Arc::clone(&config), session_source: SessionSource::Exec, + dynamic_tools: Vec::new(), }; let per_turn_config = Session::build_per_turn_config(&session_configuration); let model_info = ModelsManager::construct_model_info_offline( @@ -4712,6 +4754,7 @@ mod tests { .map(|(name, tool)| (name, tool.tool)) .collect(), ), + turn_context.dynamic_tools.as_slice(), ); let item = ResponseItem::CustomToolCall { id: None, diff --git a/codex-rs/core/src/codex_delegate.rs b/codex-rs/core/src/codex_delegate.rs index 9a94f988d55..5e1de2ef1f8 100644 --- a/codex-rs/core/src/codex_delegate.rs +++ b/codex-rs/core/src/codex_delegate.rs @@ -57,6 +57,7 @@ pub(crate) async fn run_codex_thread_interactive( initial_history.unwrap_or(InitialHistory::New), SessionSource::SubAgent(SubAgentSource::Review), parent_session.services.agent_control.clone(), + Vec::new(), ) .await?; let codex = Arc::new(codex); diff --git a/codex-rs/core/src/lib.rs b/codex-rs/core/src/lib.rs index 2042e330efa..7e1d80ace5e 100644 --- a/codex-rs/core/src/lib.rs +++ b/codex-rs/core/src/lib.rs @@ -126,6 +126,7 @@ pub use safety::get_platform_sandbox; pub use safety::is_windows_elevated_sandbox_enabled; pub use safety::set_windows_elevated_sandbox_enabled; pub use safety::set_windows_sandbox_enabled; +pub use tools::spec::parse_tool_input_schema; // Re-export the protocol types from the standalone `codex-protocol` crate so existing // `codex_core::protocol::...` references continue to work across the workspace. pub use codex_protocol::protocol; diff --git a/codex-rs/core/src/rollout/policy.rs b/codex-rs/core/src/rollout/policy.rs index 2d4a79cc222..2d263c0539c 100644 --- a/codex-rs/core/src/rollout/policy.rs +++ b/codex-rs/core/src/rollout/policy.rs @@ -68,6 +68,7 @@ pub(crate) fn should_persist_event_msg(ev: &EventMsg) -> bool { | EventMsg::ExecCommandEnd(_) | EventMsg::ExecApprovalRequest(_) | EventMsg::RequestUserInput(_) + | EventMsg::DynamicToolCallRequest(_) | EventMsg::ElicitationRequest(_) | EventMsg::ApplyPatchApprovalRequest(_) | EventMsg::BackgroundEvent(_) diff --git a/codex-rs/core/src/state/turn.rs b/codex-rs/core/src/state/turn.rs index 66e2f694e9d..ccc50d066b4 100644 --- a/codex-rs/core/src/state/turn.rs +++ b/codex-rs/core/src/state/turn.rs @@ -8,6 +8,7 @@ use tokio::sync::Notify; use tokio_util::sync::CancellationToken; use tokio_util::task::AbortOnDropHandle; +use codex_protocol::dynamic_tools::DynamicToolResponse; use codex_protocol::models::ResponseInputItem; use codex_protocol::request_user_input::RequestUserInputResponse; use tokio::sync::oneshot; @@ -70,6 +71,7 @@ impl ActiveTurn { pub(crate) struct TurnState { pending_approvals: HashMap>, pending_user_input: HashMap>, + pending_dynamic_tools: HashMap>, pending_input: Vec, } @@ -92,6 +94,7 @@ impl TurnState { pub(crate) fn clear_pending(&mut self) { self.pending_approvals.clear(); self.pending_user_input.clear(); + self.pending_dynamic_tools.clear(); self.pending_input.clear(); } @@ -110,6 +113,21 @@ impl TurnState { self.pending_user_input.remove(key) } + pub(crate) fn insert_pending_dynamic_tool( + &mut self, + key: String, + tx: oneshot::Sender, + ) -> Option> { + self.pending_dynamic_tools.insert(key, tx) + } + + pub(crate) fn remove_pending_dynamic_tool( + &mut self, + key: &str, + ) -> Option> { + self.pending_dynamic_tools.remove(key) + } + pub(crate) fn push_pending_input(&mut self, input: ResponseInputItem) { self.pending_input.push(input); } diff --git a/codex-rs/core/src/thread_manager.rs b/codex-rs/core/src/thread_manager.rs index bc5cd4f19ed..9b633ded378 100644 --- a/codex-rs/core/src/thread_manager.rs +++ b/codex-rs/core/src/thread_manager.rs @@ -196,12 +196,21 @@ impl ThreadManager { } pub async fn start_thread(&self, config: Config) -> CodexResult { + self.start_thread_with_tools(config, Vec::new()).await + } + + pub async fn start_thread_with_tools( + &self, + config: Config, + dynamic_tools: Vec, + ) -> CodexResult { self.state .spawn_thread( config, InitialHistory::New, Arc::clone(&self.state.auth_manager), self.agent_control(), + dynamic_tools, ) .await } @@ -224,7 +233,13 @@ impl ThreadManager { auth_manager: Arc, ) -> CodexResult { self.state - .spawn_thread(config, initial_history, auth_manager, self.agent_control()) + .spawn_thread( + config, + initial_history, + auth_manager, + self.agent_control(), + Vec::new(), + ) .await } @@ -262,6 +277,7 @@ impl ThreadManager { history, Arc::clone(&self.state.auth_manager), self.agent_control(), + Vec::new(), ) .await } @@ -330,6 +346,7 @@ impl ThreadManagerState { Arc::clone(&self.auth_manager), agent_control, session_source, + Vec::new(), ) .await } @@ -341,6 +358,7 @@ impl ThreadManagerState { initial_history: InitialHistory, auth_manager: Arc, agent_control: AgentControl, + dynamic_tools: Vec, ) -> CodexResult { self.spawn_thread_with_source( config, @@ -348,6 +366,7 @@ impl ThreadManagerState { auth_manager, agent_control, self.session_source.clone(), + dynamic_tools, ) .await } @@ -359,6 +378,7 @@ impl ThreadManagerState { auth_manager: Arc, agent_control: AgentControl, session_source: SessionSource, + dynamic_tools: Vec, ) -> CodexResult { let CodexSpawnOk { codex, thread_id, .. @@ -370,6 +390,7 @@ impl ThreadManagerState { initial_history, session_source, agent_control, + dynamic_tools, ) .await?; self.finalize_thread_spawn(codex, thread_id).await diff --git a/codex-rs/core/src/tools/handlers/dynamic.rs b/codex-rs/core/src/tools/handlers/dynamic.rs new file mode 100644 index 00000000000..a68c70b98da --- /dev/null +++ b/codex-rs/core/src/tools/handlers/dynamic.rs @@ -0,0 +1,98 @@ +use crate::codex::Session; +use crate::codex::TurnContext; +use crate::function_tool::FunctionCallError; +use crate::tools::context::ToolInvocation; +use crate::tools::context::ToolOutput; +use crate::tools::context::ToolPayload; +use crate::tools::handlers::parse_arguments; +use crate::tools::registry::ToolHandler; +use crate::tools::registry::ToolKind; +use async_trait::async_trait; +use codex_protocol::dynamic_tools::DynamicToolCallRequest; +use codex_protocol::dynamic_tools::DynamicToolResponse; +use codex_protocol::protocol::EventMsg; +use serde_json::Value; +use tokio::sync::oneshot; +use tracing::warn; + +pub struct DynamicToolHandler; + +#[async_trait] +impl ToolHandler for DynamicToolHandler { + fn kind(&self) -> ToolKind { + ToolKind::Function + } + + async fn is_mutating(&self, _invocation: &ToolInvocation) -> bool { + true + } + + async fn handle(&self, invocation: ToolInvocation) -> Result { + let ToolInvocation { + session, + turn, + call_id, + tool_name, + payload, + .. + } = invocation; + + let arguments = match payload { + ToolPayload::Function { arguments } => arguments, + _ => { + return Err(FunctionCallError::RespondToModel( + "dynamic tool handler received unsupported payload".to_string(), + )); + } + }; + + let args: Value = parse_arguments(&arguments)?; + 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(), + ) + })?; + + Ok(ToolOutput::Function { + content: response.output, + content_items: None, + success: Some(response.success), + }) + } +} + +async fn request_dynamic_tool( + session: &Session, + turn_context: &TurnContext, + call_id: String, + tool: String, + arguments: Value, +) -> Option { + let _sub_id = turn_context.sub_id.clone(); + let (tx_response, rx_response) = oneshot::channel(); + let event_id = call_id.clone(); + let prev_entry = { + let mut active = session.active_turn.lock().await; + match active.as_mut() { + Some(at) => { + let mut ts = at.turn_state.lock().await; + ts.insert_pending_dynamic_tool(call_id.clone(), tx_response) + } + None => None, + } + }; + if prev_entry.is_some() { + warn!("Overwriting existing pending dynamic tool call for call_id: {event_id}"); + } + + let event = EventMsg::DynamicToolCallRequest(DynamicToolCallRequest { + call_id, + turn_id: turn_context.sub_id.clone(), + tool, + arguments, + }); + session.send_event(turn_context, event).await; + rx_response.await.ok() +} diff --git a/codex-rs/core/src/tools/handlers/mod.rs b/codex-rs/core/src/tools/handlers/mod.rs index 8b63c9567fb..a00c6eba517 100644 --- a/codex-rs/core/src/tools/handlers/mod.rs +++ b/codex-rs/core/src/tools/handlers/mod.rs @@ -1,5 +1,6 @@ pub mod apply_patch; pub(crate) mod collab; +mod dynamic; mod grep_files; mod list_dir; mod mcp; @@ -18,6 +19,7 @@ use serde::Deserialize; use crate::function_tool::FunctionCallError; pub use apply_patch::ApplyPatchHandler; pub use collab::CollabHandler; +pub use dynamic::DynamicToolHandler; pub use grep_files::GrepFilesHandler; pub use list_dir::ListDirHandler; pub use mcp::McpHandler; diff --git a/codex-rs/core/src/tools/router.rs b/codex-rs/core/src/tools/router.rs index 2bc19ddd03a..b1f41923bcc 100644 --- a/codex-rs/core/src/tools/router.rs +++ b/codex-rs/core/src/tools/router.rs @@ -10,6 +10,7 @@ use crate::tools::registry::ConfiguredToolSpec; use crate::tools::registry::ToolRegistry; use crate::tools::spec::ToolsConfig; use crate::tools::spec::build_specs; +use codex_protocol::dynamic_tools::DynamicToolSpec; use codex_protocol::models::LocalShellAction; use codex_protocol::models::ResponseInputItem; use codex_protocol::models::ResponseItem; @@ -34,8 +35,9 @@ impl ToolRouter { pub fn from_config( config: &ToolsConfig, mcp_tools: Option>, + dynamic_tools: &[DynamicToolSpec], ) -> Self { - let builder = build_specs(config, mcp_tools); + let builder = build_specs(config, mcp_tools, dynamic_tools); let (specs, registry) = builder.build(); Self { registry, specs } diff --git a/codex-rs/core/src/tools/spec.rs b/codex-rs/core/src/tools/spec.rs index e330d93a03b..c3ff4354a83 100644 --- a/codex-rs/core/src/tools/spec.rs +++ b/codex-rs/core/src/tools/spec.rs @@ -10,6 +10,7 @@ use crate::tools::handlers::collab::DEFAULT_WAIT_TIMEOUT_MS; use crate::tools::handlers::collab::MAX_WAIT_TIMEOUT_MS; use crate::tools::registry::ToolRegistryBuilder; use codex_protocol::config_types::WebSearchMode; +use codex_protocol::dynamic_tools::DynamicToolSpec; use codex_protocol::models::VIEW_IMAGE_TOOL_NAME; use codex_protocol::openai_models::ApplyPatchToolType; use codex_protocol::openai_models::ConfigShellToolType; @@ -87,7 +88,7 @@ impl ToolsConfig { /// Generic JSON‑Schema subset needed for our tool definitions #[derive(Debug, Clone, Serialize, Deserialize, PartialEq)] #[serde(tag = "type", rename_all = "lowercase")] -pub(crate) enum JsonSchema { +pub enum JsonSchema { Boolean { #[serde(skip_serializing_if = "Option::is_none")] description: Option, @@ -123,7 +124,7 @@ pub(crate) enum JsonSchema { /// Whether additional properties are allowed, and if so, any required schema #[derive(Debug, Clone, Serialize, Deserialize, PartialEq)] #[serde(untagged)] -pub(crate) enum AdditionalProperties { +pub enum AdditionalProperties { Boolean(bool), Schema(Box), } @@ -1101,6 +1102,26 @@ pub(crate) fn mcp_tool_to_openai_tool( }) } +fn dynamic_tool_to_openai_tool( + tool: &DynamicToolSpec, +) -> Result { + let input_schema = parse_tool_input_schema(&tool.input_schema)?; + + Ok(ResponsesApiTool { + name: tool.name.clone(), + description: tool.description.clone(), + strict: false, + parameters: input_schema, + }) +} + +/// Parse the tool input_schema or return an error for invalid schema +pub fn parse_tool_input_schema(input_schema: &JsonValue) -> Result { + let mut input_schema = input_schema.clone(); + sanitize_json_schema(&mut input_schema); + serde_json::from_value::(input_schema) +} + /// Sanitize a JSON Schema (as serde_json::Value) so it can fit our limited /// JsonSchema enum. This function: /// - Ensures every schema object has a "type". If missing, infers it from @@ -1216,9 +1237,11 @@ fn sanitize_json_schema(value: &mut JsonValue) { pub(crate) fn build_specs( config: &ToolsConfig, mcp_tools: Option>, + dynamic_tools: &[DynamicToolSpec], ) -> ToolRegistryBuilder { use crate::tools::handlers::ApplyPatchHandler; use crate::tools::handlers::CollabHandler; + use crate::tools::handlers::DynamicToolHandler; use crate::tools::handlers::GrepFilesHandler; use crate::tools::handlers::ListDirHandler; use crate::tools::handlers::McpHandler; @@ -1239,6 +1262,7 @@ pub(crate) fn build_specs( let unified_exec_handler = Arc::new(UnifiedExecHandler); let plan_handler = Arc::new(PlanHandler); let apply_patch_handler = Arc::new(ApplyPatchHandler); + let dynamic_tool_handler = Arc::new(DynamicToolHandler); let view_image_handler = Arc::new(ViewImageHandler); let mcp_handler = Arc::new(McpHandler); let mcp_resource_handler = Arc::new(McpResourceHandler); @@ -1384,6 +1408,23 @@ pub(crate) fn build_specs( } } + if !dynamic_tools.is_empty() { + for tool in dynamic_tools { + match dynamic_tool_to_openai_tool(tool) { + Ok(converted_tool) => { + builder.push_spec(ToolSpec::Function(converted_tool)); + builder.register_handler(tool.name.clone(), dynamic_tool_handler.clone()); + } + Err(e) => { + tracing::error!( + "Failed to convert dynamic tool {:?} to OpenAI tool: {e:?}", + tool.name + ); + } + } + } + } + builder } @@ -1496,7 +1537,7 @@ mod tests { features: &features, web_search_mode: Some(WebSearchMode::Live), }); - let (tools, _) = build_specs(&config, None).build(); + let (tools, _) = build_specs(&config, None, &[]).build(); // Build actual map name -> spec use std::collections::BTreeMap; @@ -1560,7 +1601,7 @@ mod tests { features: &features, web_search_mode: Some(WebSearchMode::Cached), }); - let (tools, _) = build_specs(&tools_config, None).build(); + let (tools, _) = build_specs(&tools_config, None, &[]).build(); assert_contains_tool_names( &tools, &["spawn_agent", "send_input", "wait", "close_agent"], @@ -1578,7 +1619,7 @@ mod tests { features: &features, web_search_mode: Some(WebSearchMode::Cached), }); - let (tools, _) = build_specs(&tools_config, None).build(); + let (tools, _) = build_specs(&tools_config, None, &[]).build(); assert!( !tools.iter().any(|t| t.spec.name() == "request_user_input"), "request_user_input should be disabled when collaboration_modes feature is off" @@ -1590,7 +1631,7 @@ mod tests { features: &features, web_search_mode: Some(WebSearchMode::Cached), }); - let (tools, _) = build_specs(&tools_config, None).build(); + let (tools, _) = build_specs(&tools_config, None, &[]).build(); assert_contains_tool_names(&tools, &["request_user_input"]); } @@ -1607,7 +1648,7 @@ mod tests { features, web_search_mode, }); - let (tools, _) = build_specs(&tools_config, Some(HashMap::new())).build(); + let (tools, _) = build_specs(&tools_config, Some(HashMap::new()), &[]).build(); let tool_names = tools.iter().map(|t| t.spec.name()).collect::>(); assert_eq!(&tool_names, &expected_tools,); } @@ -1623,7 +1664,7 @@ mod tests { features: &features, web_search_mode: Some(WebSearchMode::Cached), }); - let (tools, _) = build_specs(&tools_config, None).build(); + let (tools, _) = build_specs(&tools_config, None, &[]).build(); let tool = find_tool(&tools, "web_search"); assert_eq!( @@ -1645,7 +1686,7 @@ mod tests { features: &features, web_search_mode: Some(WebSearchMode::Live), }); - let (tools, _) = build_specs(&tools_config, None).build(); + let (tools, _) = build_specs(&tools_config, None, &[]).build(); let tool = find_tool(&tools, "web_search"); assert_eq!( @@ -1891,7 +1932,7 @@ mod tests { features: &features, web_search_mode: Some(WebSearchMode::Live), }); - let (tools, _) = build_specs(&tools_config, Some(HashMap::new())).build(); + let (tools, _) = build_specs(&tools_config, Some(HashMap::new()), &[]).build(); // Only check the shell variant and a couple of core tools. let mut subset = vec!["exec_command", "write_stdin", "update_plan"]; @@ -1913,7 +1954,7 @@ mod tests { features: &features, web_search_mode: Some(WebSearchMode::Cached), }); - let (tools, _) = build_specs(&tools_config, None).build(); + let (tools, _) = build_specs(&tools_config, None, &[]).build(); assert!(!find_tool(&tools, "exec_command").supports_parallel_tool_calls); assert!(!find_tool(&tools, "write_stdin").supports_parallel_tool_calls); @@ -1932,7 +1973,7 @@ mod tests { features: &features, web_search_mode: Some(WebSearchMode::Cached), }); - let (tools, _) = build_specs(&tools_config, None).build(); + let (tools, _) = build_specs(&tools_config, None, &[]).build(); assert!( tools @@ -1999,6 +2040,7 @@ mod tests { description: Some("Do something cool".to_string()), }, )])), + &[], ) .build(); @@ -2108,7 +2150,7 @@ mod tests { ), ]); - let (tools, _) = build_specs(&tools_config, Some(tools_map)).build(); + let (tools, _) = build_specs(&tools_config, Some(tools_map), &[]).build(); // Only assert that the MCP tools themselves are sorted by fully-qualified name. let mcp_names: Vec<_> = tools @@ -2157,6 +2199,7 @@ mod tests { description: Some("Search docs".to_string()), }, )])), + &[], ) .build(); @@ -2212,6 +2255,7 @@ mod tests { description: Some("Pagination".to_string()), }, )])), + &[], ) .build(); @@ -2266,6 +2310,7 @@ mod tests { description: Some("Tags".to_string()), }, )])), + &[], ) .build(); @@ -2322,6 +2367,7 @@ mod tests { description: Some("AnyOf Value".to_string()), }, )])), + &[], ) .build(); @@ -2459,6 +2505,7 @@ Examples of valid command strings: description: Some("Do something cool".to_string()), }, )])), + &[], ) .build(); diff --git a/codex-rs/exec/src/event_processor_with_human_output.rs b/codex-rs/exec/src/event_processor_with_human_output.rs index c4a5c27d9cd..76c22caa753 100644 --- a/codex-rs/exec/src/event_processor_with_human_output.rs +++ b/codex-rs/exec/src/event_processor_with_human_output.rs @@ -607,7 +607,8 @@ impl EventProcessor for EventProcessorWithHumanOutput { | EventMsg::UndoCompleted(_) | EventMsg::UndoStarted(_) | EventMsg::ThreadRolledBack(_) - | EventMsg::RequestUserInput(_) => {} + | EventMsg::RequestUserInput(_) + | EventMsg::DynamicToolCallRequest(_) => {} } CodexStatus::Running } diff --git a/codex-rs/mcp-server/src/codex_tool_runner.rs b/codex-rs/mcp-server/src/codex_tool_runner.rs index b400b4cc794..dcaf8a89e6c 100644 --- a/codex-rs/mcp-server/src/codex_tool_runner.rs +++ b/codex-rs/mcp-server/src/codex_tool_runner.rs @@ -360,6 +360,7 @@ async fn run_codex_tool_session_inner( | EventMsg::UndoCompleted(_) | EventMsg::ExitedReviewMode(_) | EventMsg::RequestUserInput(_) + | EventMsg::DynamicToolCallRequest(_) | EventMsg::ContextCompacted(_) | EventMsg::ThreadRolledBack(_) | EventMsg::CollabAgentSpawnBegin(_) diff --git a/codex-rs/protocol/src/dynamic_tools.rs b/codex-rs/protocol/src/dynamic_tools.rs new file mode 100644 index 00000000000..e55d372d8ec --- /dev/null +++ b/codex-rs/protocol/src/dynamic_tools.rs @@ -0,0 +1,30 @@ +use schemars::JsonSchema; +use serde::Deserialize; +use serde::Serialize; +use serde_json::Value as JsonValue; +use ts_rs::TS; + +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, JsonSchema, TS)] +#[serde(rename_all = "camelCase")] +pub struct DynamicToolSpec { + pub name: String, + pub description: String, + pub input_schema: JsonValue, +} + +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, JsonSchema, TS)] +#[serde(rename_all = "camelCase")] +pub struct DynamicToolCallRequest { + pub call_id: String, + pub turn_id: String, + pub tool: String, + pub arguments: JsonValue, +} + +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, JsonSchema, TS)] +#[serde(rename_all = "camelCase")] +pub struct DynamicToolResponse { + pub call_id: String, + pub output: String, + pub success: bool, +} diff --git a/codex-rs/protocol/src/lib.rs b/codex-rs/protocol/src/lib.rs index 69d09da089e..60b01bbd73a 100644 --- a/codex-rs/protocol/src/lib.rs +++ b/codex-rs/protocol/src/lib.rs @@ -4,6 +4,7 @@ pub use thread_id::ThreadId; pub mod approvals; pub mod config_types; pub mod custom_prompts; +pub mod dynamic_tools; pub mod items; pub mod message_history; pub mod models; diff --git a/codex-rs/protocol/src/protocol.rs b/codex-rs/protocol/src/protocol.rs index 7bf3c90eab6..e9cb8147d7b 100644 --- a/codex-rs/protocol/src/protocol.rs +++ b/codex-rs/protocol/src/protocol.rs @@ -17,6 +17,8 @@ use crate::config_types::CollaborationMode; use crate::config_types::Personality; use crate::config_types::ReasoningSummary as ReasoningSummaryConfig; use crate::custom_prompts::CustomPrompt; +use crate::dynamic_tools::DynamicToolCallRequest; +use crate::dynamic_tools::DynamicToolResponse; use crate::items::TurnItem; use crate::message_history::HistoryEntry; use crate::models::BaseInstructions; @@ -216,6 +218,14 @@ pub enum Op { response: RequestUserInputResponse, }, + /// Resolve a dynamic tool call request. + DynamicToolResponse { + /// Call id for the in-flight request. + id: String, + /// Tool output payload. + response: DynamicToolResponse, + }, + /// Append an entry to the persistent cross-session message history. /// /// Note the entry is not guaranteed to be logged if the user has @@ -750,6 +760,8 @@ pub enum EventMsg { RequestUserInput(RequestUserInputEvent), + DynamicToolCallRequest(DynamicToolCallRequest), + ElicitationRequest(ElicitationRequestEvent), ApplyPatchApprovalRequest(ApplyPatchApprovalRequestEvent), diff --git a/codex-rs/tui/src/chatwidget.rs b/codex-rs/tui/src/chatwidget.rs index 1252b792e39..87c2c0c6dbd 100644 --- a/codex-rs/tui/src/chatwidget.rs +++ b/codex-rs/tui/src/chatwidget.rs @@ -3031,7 +3031,8 @@ impl ChatWidget { | EventMsg::ItemCompleted(_) | EventMsg::AgentMessageContentDelta(_) | EventMsg::ReasoningContentDelta(_) - | EventMsg::ReasoningRawContentDelta(_) => {} + | EventMsg::ReasoningRawContentDelta(_) + | EventMsg::DynamicToolCallRequest(_) => {} } }