diff --git a/codex-rs/core/src/client_common.rs b/codex-rs/core/src/client_common.rs index 135166919..08613f0ea 100644 --- a/codex-rs/core/src/client_common.rs +++ b/codex-rs/core/src/client_common.rs @@ -160,6 +160,7 @@ pub(crate) mod tools { use codex_protocol::config_types::WebSearchUserLocationType; use serde::Deserialize; use serde::Serialize; + use serde_json::Value; /// When serialized as JSON, this produces a valid "Tool" in the OpenAI /// Responses API. @@ -268,6 +269,8 @@ pub(crate) mod tools { /// `properties` must be present in `required`. pub(crate) strict: bool, pub(crate) parameters: JsonSchema, + #[serde(skip)] + pub(crate) output_schema: Option, } } diff --git a/codex-rs/core/src/tools/code_mode.rs b/codex-rs/core/src/tools/code_mode.rs index 9c1df684c..7fef60f68 100644 --- a/codex-rs/core/src/tools/code_mode.rs +++ b/codex-rs/core/src/tools/code_mode.rs @@ -14,15 +14,10 @@ use crate::tools::context::ToolPayload; use crate::tools::js_repl::resolve_compatible_node; use crate::tools::router::ToolCall; use crate::tools::router::ToolCallSource; -use codex_protocol::models::ContentItem; -use codex_protocol::models::FunctionCallOutputBody; use codex_protocol::models::FunctionCallOutputContentItem; -use codex_protocol::models::FunctionCallOutputPayload; -use codex_protocol::models::ResponseInputItem; use serde::Deserialize; use serde::Serialize; use serde_json::Value as JsonValue; -use serde_json::json; use tokio::io::AsyncBufReadExt; use tokio::io::AsyncReadExt; use tokio::io::AsyncWriteExt; @@ -60,7 +55,7 @@ enum HostToNodeMessage { }, Response { id: String, - content_items: Vec, + code_mode_result: JsonValue, }, } @@ -90,11 +85,11 @@ pub(crate) fn instructions(config: &Config) -> Option { section.push_str("- `code_mode` is a freeform/custom tool. Direct `code_mode` calls must send raw JavaScript tool input. Do not wrap code in JSON, quotes, or markdown code fences.\n"); section.push_str("- Direct tool calls remain available while `code_mode` is enabled.\n"); section.push_str("- `code_mode` uses the same Node runtime resolution as `js_repl`. If needed, point `js_repl_node_path` at the Node binary you want Codex to use.\n"); - section.push_str("- Import nested tools from `tools.js`, for example `import { exec_command } from \"tools.js\"` or `import { tools } from \"tools.js\"`. `tools[name]` and identifier wrappers like `await exec_command(args)` remain available for compatibility. Nested tool calls resolve to arrays of content items.\n"); + section.push_str("- Import nested tools from `tools.js`, for example `import { exec_command } from \"tools.js\"` or `import { tools } from \"tools.js\"`. `tools[name]` and identifier wrappers like `await exec_command(args)` remain available for compatibility. Nested tool calls resolve to their code-mode result values.\n"); section.push_str( "- Function tools require JSON object arguments. Freeform tools require raw strings.\n", ); - section.push_str("- `add_content(value)` is synchronous. It accepts a content item or an array of content items, so `add_content(await exec_command(...))` returns the same content items a direct tool call would expose to the model.\n"); + section.push_str("- `add_content(value)` is synchronous. It accepts a content item, an array of content items, or a string. Structured nested-tool results should be converted to text first, for example with `JSON.stringify(...)`.\n"); section .push_str("- Only content passed to `add_content(value)` is surfaced back to the model."); Some(section) @@ -186,7 +181,7 @@ async fn execute_node( NodeToHostMessage::ToolCall { id, name, input } => { let response = HostToNodeMessage::Response { id, - content_items: call_nested_tool(exec.clone(), name, input).await, + code_mode_result: call_nested_tool(exec.clone(), name, input).await, }; write_message(&mut stdin, &response).await?; } @@ -290,9 +285,9 @@ async fn call_nested_tool( exec: ExecContext, tool_name: String, input: Option, -) -> Vec { +) -> JsonValue { if tool_name == "code_mode" { - return error_content_items_json("code_mode cannot invoke itself".to_string()); + return JsonValue::String("code_mode cannot invoke itself".to_string()); } let nested_config = exec.turn.tools_config.for_code_mode_nested_tools(); @@ -306,7 +301,7 @@ async fn call_nested_tool( let specs = router.specs(); let payload = match build_nested_tool_payload(&specs, &tool_name, input) { Ok(payload) => payload, - Err(error) => return error_content_items_json(error), + Err(error) => return JsonValue::String(error), }; let call = ToolCall { @@ -314,8 +309,8 @@ async fn call_nested_tool( call_id: format!("code_mode-{}", uuid::Uuid::new_v4()), payload, }; - let response = router - .dispatch_tool_call( + let result = router + .dispatch_tool_call_with_code_mode_result( Arc::clone(&exec.session), Arc::clone(&exec.turn), Arc::clone(&exec.tracker), @@ -324,11 +319,9 @@ async fn call_nested_tool( ) .await; - match response { - Ok(response) => { - json_values_from_output_content_items(content_items_from_response_input(response)) - } - Err(error) => error_content_items_json(error.to_string()), + match result { + Ok(result) => result.code_mode_result(), + Err(error) => JsonValue::String(error.to_string()), } } @@ -387,70 +380,6 @@ fn build_freeform_tool_payload( } } -fn content_items_from_response_input( - response: ResponseInputItem, -) -> Vec { - match response { - ResponseInputItem::Message { content, .. } => content - .into_iter() - .map(function_output_content_item_from_content_item) - .collect(), - ResponseInputItem::FunctionCallOutput { output, .. } => { - content_items_from_function_output(output) - } - ResponseInputItem::CustomToolCallOutput { output, .. } => { - content_items_from_function_output(output) - } - ResponseInputItem::McpToolCallOutput { result, .. } => match result { - Ok(result) => { - content_items_from_function_output(FunctionCallOutputPayload::from(&result)) - } - Err(error) => vec![FunctionCallOutputContentItem::InputText { text: error }], - }, - } -} - -fn content_items_from_function_output( - output: FunctionCallOutputPayload, -) -> Vec { - match output.body { - FunctionCallOutputBody::Text(text) => { - vec![FunctionCallOutputContentItem::InputText { text }] - } - FunctionCallOutputBody::ContentItems(items) => items, - } -} - -fn function_output_content_item_from_content_item( - item: ContentItem, -) -> FunctionCallOutputContentItem { - match item { - ContentItem::InputText { text } | ContentItem::OutputText { text } => { - FunctionCallOutputContentItem::InputText { text } - } - ContentItem::InputImage { image_url } => FunctionCallOutputContentItem::InputImage { - image_url, - detail: None, - }, - } -} - -fn json_values_from_output_content_items( - content_items: Vec, -) -> Vec { - content_items - .into_iter() - .map(|item| match item { - FunctionCallOutputContentItem::InputText { text } => { - json!({ "type": "input_text", "text": text }) - } - FunctionCallOutputContentItem::InputImage { image_url, detail } => { - json!({ "type": "input_image", "image_url": image_url, "detail": detail }) - } - }) - .collect() -} - fn output_content_items_from_json_values( content_items: Vec, ) -> Result, String> { @@ -463,7 +392,3 @@ fn output_content_items_from_json_values( }) .collect() } - -fn error_content_items_json(message: String) -> Vec { - vec![json!({ "type": "input_text", "text": message })] -} diff --git a/codex-rs/core/src/tools/code_mode_bridge.js b/codex-rs/core/src/tools/code_mode_bridge.js index eba69c9f3..aca85f735 100644 --- a/codex-rs/core/src/tools/code_mode_bridge.js +++ b/codex-rs/core/src/tools/code_mode_bridge.js @@ -22,13 +22,20 @@ function __codexCloneContentItem(item) { } } -function __codexNormalizeContentItems(value) { +function __codexNormalizeRawContentItems(value) { if (Array.isArray(value)) { - return value.flatMap((entry) => __codexNormalizeContentItems(entry)); + return value.flatMap((entry) => __codexNormalizeRawContentItems(entry)); } return [__codexCloneContentItem(value)]; } +function __codexNormalizeContentItems(value) { + if (typeof value === 'string') { + return [{ type: 'input_text', text: value }]; + } + return __codexNormalizeRawContentItems(value); +} + Object.defineProperty(globalThis, '__codexContentItems', { value: __codexContentItems, configurable: true, diff --git a/codex-rs/core/src/tools/code_mode_runner.cjs b/codex-rs/core/src/tools/code_mode_runner.cjs index 09fe9e8af..e2fac0817 100644 --- a/codex-rs/core/src/tools/code_mode_runner.cjs +++ b/codex-rs/core/src/tools/code_mode_runner.cjs @@ -44,7 +44,7 @@ function createProtocol() { return; } pending.delete(message.id); - entry.resolve(Array.isArray(message.content_items) ? message.content_items : []); + entry.resolve(message.code_mode_result ?? ''); return; } diff --git a/codex-rs/core/src/tools/context.rs b/codex-rs/core/src/tools/context.rs index a3521c466..b5e799566 100644 --- a/codex-rs/core/src/tools/context.rs +++ b/codex-rs/core/src/tools/context.rs @@ -15,6 +15,8 @@ use codex_protocol::models::ResponseInputItem; use codex_protocol::models::ShellToolCallParams; use codex_protocol::models::function_call_output_content_items_to_text; use codex_utils_string::take_bytes_at_char_boundary; +use serde::Serialize; +use serde_json::Value as JsonValue; use std::borrow::Cow; use std::sync::Arc; use std::time::Duration; @@ -73,7 +75,11 @@ pub trait ToolOutput: Send { fn success_for_logging(&self) -> bool; - fn into_response(self, call_id: &str, payload: &ToolPayload) -> ResponseInputItem; + fn to_response_item(&self, call_id: &str, payload: &ToolPayload) -> ResponseInputItem; + + fn code_mode_result(&self, payload: &ToolPayload) -> JsonValue { + response_input_to_code_mode_result(self.to_response_item("", payload)) + } } pub struct McpToolOutput { @@ -89,11 +95,10 @@ impl ToolOutput for McpToolOutput { self.result.is_ok() } - fn into_response(self, call_id: &str, _payload: &ToolPayload) -> ResponseInputItem { - let Self { result } = self; + fn to_response_item(&self, call_id: &str, _payload: &ToolPayload) -> ResponseInputItem { ResponseInputItem::McpToolCallOutput { call_id: call_id.to_string(), - result, + result: self.result.clone(), } } } @@ -137,9 +142,8 @@ impl ToolOutput for FunctionToolOutput { self.success.unwrap_or(true) } - fn into_response(self, call_id: &str, payload: &ToolPayload) -> ResponseInputItem { - let Self { body, success } = self; - function_tool_response(call_id, payload, body, success) + fn to_response_item(&self, call_id: &str, payload: &ToolPayload) -> ResponseInputItem { + function_tool_response(call_id, payload, self.body.clone(), self.success) } } @@ -166,7 +170,7 @@ impl ToolOutput for ExecCommandToolOutput { true } - fn into_response(self, call_id: &str, payload: &ToolPayload) -> ResponseInputItem { + fn to_response_item(&self, call_id: &str, payload: &ToolPayload) -> ResponseInputItem { function_tool_response( call_id, payload, @@ -176,6 +180,35 @@ impl ToolOutput for ExecCommandToolOutput { Some(true), ) } + + fn code_mode_result(&self, _payload: &ToolPayload) -> JsonValue { + #[derive(Serialize)] + struct UnifiedExecCodeModeResult { + #[serde(skip_serializing_if = "Option::is_none")] + chunk_id: Option, + wall_time_seconds: f64, + #[serde(skip_serializing_if = "Option::is_none")] + exit_code: Option, + #[serde(skip_serializing_if = "Option::is_none")] + session_id: Option, + #[serde(skip_serializing_if = "Option::is_none")] + original_token_count: Option, + output: String, + } + + let result = UnifiedExecCodeModeResult { + chunk_id: (!self.chunk_id.is_empty()).then(|| self.chunk_id.clone()), + wall_time_seconds: self.wall_time.as_secs_f64(), + exit_code: self.exit_code, + session_id: self.process_id.clone(), + original_token_count: self.original_token_count, + output: self.truncated_output(), + }; + + serde_json::to_value(result).unwrap_or_else(|err| { + JsonValue::String(format!("failed to serialize exec result: {err}")) + }) + } } impl ExecCommandToolOutput { @@ -214,6 +247,65 @@ impl ExecCommandToolOutput { } } +fn response_input_to_code_mode_result(response: ResponseInputItem) -> JsonValue { + match response { + ResponseInputItem::Message { content, .. } => content_items_to_code_mode_result( + &content + .into_iter() + .map(|item| match item { + codex_protocol::models::ContentItem::InputText { text } + | codex_protocol::models::ContentItem::OutputText { text } => { + FunctionCallOutputContentItem::InputText { text } + } + codex_protocol::models::ContentItem::InputImage { image_url } => { + FunctionCallOutputContentItem::InputImage { + image_url, + detail: None, + } + } + }) + .collect::>(), + ), + ResponseInputItem::FunctionCallOutput { output, .. } + | ResponseInputItem::CustomToolCallOutput { output, .. } => match output.body { + FunctionCallOutputBody::Text(text) => JsonValue::String(text), + FunctionCallOutputBody::ContentItems(items) => { + content_items_to_code_mode_result(&items) + } + }, + ResponseInputItem::McpToolCallOutput { result, .. } => match result { + Ok(result) => match FunctionCallOutputPayload::from(&result).body { + FunctionCallOutputBody::Text(text) => JsonValue::String(text), + FunctionCallOutputBody::ContentItems(items) => { + content_items_to_code_mode_result(&items) + } + }, + Err(error) => JsonValue::String(error), + }, + } +} + +fn content_items_to_code_mode_result(items: &[FunctionCallOutputContentItem]) -> JsonValue { + JsonValue::String( + items + .iter() + .filter_map(|item| match item { + FunctionCallOutputContentItem::InputText { text } if !text.trim().is_empty() => { + Some(text.clone()) + } + FunctionCallOutputContentItem::InputImage { image_url, .. } + if !image_url.trim().is_empty() => + { + Some(image_url.clone()) + } + FunctionCallOutputContentItem::InputText { .. } + | FunctionCallOutputContentItem::InputImage { .. } => None, + }) + .collect::>() + .join("\n"), + ) +} + fn function_tool_response( call_id: &str, payload: &ToolPayload, @@ -292,7 +384,7 @@ mod tests { input: "patch".to_string(), }; let response = FunctionToolOutput::from_text("patched".to_string(), Some(true)) - .into_response("call-42", &payload); + .to_response_item("call-42", &payload); match response { ResponseInputItem::CustomToolCallOutput { call_id, output } => { @@ -311,7 +403,7 @@ mod tests { arguments: "{}".to_string(), }; let response = FunctionToolOutput::from_text("ok".to_string(), Some(true)) - .into_response("fn-1", &payload); + .to_response_item("fn-1", &payload); match response { ResponseInputItem::FunctionCallOutput { call_id, output } => { @@ -344,7 +436,7 @@ mod tests { ], Some(true), ) - .into_response("call-99", &payload); + .to_response_item("call-99", &payload); match response { ResponseInputItem::CustomToolCallOutput { call_id, output } => { @@ -433,7 +525,7 @@ mod tests { original_token_count: Some(10), session_command: None, } - .into_response("call-42", &payload); + .to_response_item("call-42", &payload); match response { ResponseInputItem::FunctionCallOutput { call_id, output } => { diff --git a/codex-rs/core/src/tools/handlers/apply_patch.rs b/codex-rs/core/src/tools/handlers/apply_patch.rs index e31a47ab1..b98a721e3 100644 --- a/codex-rs/core/src/tools/handlers/apply_patch.rs +++ b/codex-rs/core/src/tools/handlers/apply_patch.rs @@ -420,13 +420,14 @@ It is important to remember: - You must prefix new lines with `+` even when creating a new file - File references can only be relative, NEVER ABSOLUTE. "# - .to_string(), + .to_string(), strict: false, parameters: JsonSchema::Object { properties, required: Some(vec!["input".to_string()]), additional_properties: Some(false.into()), }, + output_schema: None, }) } diff --git a/codex-rs/core/src/tools/handlers/mcp.rs b/codex-rs/core/src/tools/handlers/mcp.rs index 1564206be..fc921be96 100644 --- a/codex-rs/core/src/tools/handlers/mcp.rs +++ b/codex-rs/core/src/tools/handlers/mcp.rs @@ -33,10 +33,10 @@ impl crate::tools::context::ToolOutput for McpHandlerOutput { } } - fn into_response(self, call_id: &str, payload: &ToolPayload) -> ResponseInputItem { + fn to_response_item(&self, call_id: &str, payload: &ToolPayload) -> ResponseInputItem { match self { - Self::Mcp(output) => output.into_response(call_id, payload), - Self::Function(output) => output.into_response(call_id, payload), + Self::Mcp(output) => output.to_response_item(call_id, payload), + Self::Function(output) => output.to_response_item(call_id, payload), } } } diff --git a/codex-rs/core/src/tools/handlers/plan.rs b/codex-rs/core/src/tools/handlers/plan.rs index 6b810e0a6..bd70418a6 100644 --- a/codex-rs/core/src/tools/handlers/plan.rs +++ b/codex-rs/core/src/tools/handlers/plan.rs @@ -57,6 +57,7 @@ At most one step can be in_progress at a time. required: Some(vec!["plan".to_string()]), additional_properties: Some(false.into()), }, + output_schema: None, }) }); diff --git a/codex-rs/core/src/tools/registry.rs b/codex-rs/core/src/tools/registry.rs index eb74f90db..f78df2f1a 100644 --- a/codex-rs/core/src/tools/registry.rs +++ b/codex-rs/core/src/tools/registry.rs @@ -57,10 +57,28 @@ pub trait ToolHandler: Send + Sync { async fn handle(&self, invocation: ToolInvocation) -> Result; } -struct AnyToolResult { - preview: String, - success: bool, - response: ResponseInputItem, +pub(crate) struct AnyToolResult { + pub(crate) call_id: String, + pub(crate) payload: ToolPayload, + pub(crate) result: Box, +} + +impl AnyToolResult { + pub(crate) fn into_response(self) -> ResponseInputItem { + let Self { + call_id, + payload, + result, + } = self; + result.to_response_item(&call_id, &payload) + } + + pub(crate) fn code_mode_result(self) -> serde_json::Value { + let Self { + payload, result, .. + } = self; + result.code_mode_result(&payload) + } } #[async_trait] @@ -95,13 +113,10 @@ where let call_id = invocation.call_id.clone(); let payload = invocation.payload.clone(); let output = self.handle(invocation).await?; - let preview = output.log_preview(); - let success = output.success_for_logging(); - let response = output.into_response(&call_id, &payload); Ok(AnyToolResult { - preview, - success, - response, + call_id, + payload, + result: Box::new(output), }) } } @@ -127,10 +142,10 @@ impl ToolRegistry { // } // } - pub async fn dispatch( + pub(crate) async fn dispatch_any( &self, invocation: ToolInvocation, - ) -> Result { + ) -> Result { let tool_name = invocation.tool_name.clone(); let call_id_owned = invocation.call_id.clone(); let otel = invocation.turn.session_telemetry.clone(); @@ -237,13 +252,10 @@ impl ToolRegistry { } match handler.handle_any(invocation_for_tool).await { Ok(result) => { - let AnyToolResult { - preview, - success, - response, - } = result; + let preview = result.result.log_preview(); + let success = result.result.success_for_logging(); let mut guard = response_cell.lock().await; - *guard = Some(response); + *guard = Some(result); Ok((preview, success)) } Err(err) => Err(err), @@ -275,10 +287,10 @@ impl ToolRegistry { match result { Ok(_) => { let mut guard = response_cell.lock().await; - let response = guard.take().ok_or_else(|| { + let result = guard.take().ok_or_else(|| { FunctionCallError::Fatal("tool produced no output".to_string()) })?; - Ok(response) + Ok(result) } Err(err) => Err(err), } diff --git a/codex-rs/core/src/tools/router.rs b/codex-rs/core/src/tools/router.rs index a55fb5fd5..7095a38ce 100644 --- a/codex-rs/core/src/tools/router.rs +++ b/codex-rs/core/src/tools/router.rs @@ -4,15 +4,16 @@ use crate::codex::TurnContext; use crate::function_tool::FunctionCallError; use crate::mcp_connection_manager::ToolInfo; use crate::sandboxing::SandboxPermissions; +use crate::tools::context::FunctionToolOutput; use crate::tools::context::SharedTurnDiffTracker; use crate::tools::context::ToolInvocation; use crate::tools::context::ToolPayload; +use crate::tools::registry::AnyToolResult; 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::FunctionCallOutputBody; use codex_protocol::models::LocalShellAction; use codex_protocol::models::ResponseInputItem; use codex_protocol::models::ResponseItem; @@ -145,6 +146,21 @@ impl ToolRouter { call: ToolCall, source: ToolCallSource, ) -> Result { + Ok(self + .dispatch_tool_call_with_code_mode_result(session, turn, tracker, call, source) + .await? + .into_response()) + } + + #[instrument(level = "trace", skip_all, err)] + pub async fn dispatch_tool_call_with_code_mode_result( + &self, + session: Arc, + turn: Arc, + tracker: SharedTurnDiffTracker, + call: ToolCall, + source: ToolCallSource, + ) -> Result { let ToolCall { tool_name, call_id, @@ -161,7 +177,7 @@ impl ToolRouter { "direct tool calls are disabled; use js_repl and codex.tool(...) instead" .to_string(), ); - return Ok(Self::failure_response( + return Ok(Self::failure_result( failure_call_id, payload_outputs_custom, err, @@ -177,10 +193,10 @@ impl ToolRouter { payload, }; - match self.registry.dispatch(invocation).await { + match self.registry.dispatch_any(invocation).await { Ok(response) => Ok(response), Err(FunctionCallError::Fatal(message)) => Err(FunctionCallError::Fatal(message)), - Err(err) => Ok(Self::failure_response( + Err(err) => Ok(Self::failure_result( failure_call_id, payload_outputs_custom, err, @@ -188,27 +204,27 @@ impl ToolRouter { } } - fn failure_response( + fn failure_result( call_id: String, payload_outputs_custom: bool, err: FunctionCallError, - ) -> ResponseInputItem { + ) -> AnyToolResult { let message = err.to_string(); if payload_outputs_custom { - ResponseInputItem::CustomToolCallOutput { + AnyToolResult { call_id, - output: codex_protocol::models::FunctionCallOutputPayload { - body: FunctionCallOutputBody::Text(message), - success: Some(false), + payload: ToolPayload::Custom { + input: String::new(), }, + result: Box::new(FunctionToolOutput::from_text(message, Some(false))), } } else { - ResponseInputItem::FunctionCallOutput { + AnyToolResult { call_id, - output: codex_protocol::models::FunctionCallOutputPayload { - body: FunctionCallOutputBody::Text(message), - success: Some(false), + payload: ToolPayload::Function { + arguments: "{}".to_string(), }, + result: Box::new(FunctionToolOutput::from_text(message, Some(false))), } } } diff --git a/codex-rs/core/src/tools/spec.rs b/codex-rs/core/src/tools/spec.rs index 2b823e0a0..51bc84b23 100644 --- a/codex-rs/core/src/tools/spec.rs +++ b/codex-rs/core/src/tools/spec.rs @@ -40,6 +40,40 @@ use std::collections::HashMap; const SEARCH_TOOL_BM25_DESCRIPTION_TEMPLATE: &str = include_str!("../../templates/search_tool/tool_description.md"); const WEB_SEARCH_CONTENT_TYPES: [&str; 2] = ["text", "image"]; + +fn unified_exec_output_schema() -> JsonValue { + json!({ + "type": "object", + "properties": { + "chunk_id": { + "type": "string", + "description": "Chunk identifier included when the response reports one." + }, + "wall_time_seconds": { + "type": "number", + "description": "Elapsed wall time spent waiting for output in seconds." + }, + "exit_code": { + "type": "number", + "description": "Process exit code when the command finished during this call." + }, + "session_id": { + "type": "string", + "description": "Session identifier to pass to write_stdin when the process is still running." + }, + "original_token_count": { + "type": "number", + "description": "Approximate token count before output truncation." + }, + "output": { + "type": "string", + "description": "Command output text, possibly truncated." + } + }, + "required": ["wall_time_seconds", "output"], + "additionalProperties": false + }) +} #[derive(Debug, Clone, Copy, Eq, PartialEq)] pub enum ShellCommandBackendConfig { Classic, @@ -479,6 +513,7 @@ fn create_exec_command_tool(allow_login_shell: bool, request_permission_enabled: required: Some(vec!["cmd".to_string()]), additional_properties: Some(false.into()), }, + output_schema: Some(unified_exec_output_schema()), }) } @@ -526,6 +561,7 @@ fn create_write_stdin_tool() -> ToolSpec { required: Some(vec!["session_id".to_string()]), additional_properties: Some(false.into()), }, + output_schema: Some(unified_exec_output_schema()), }) } @@ -579,6 +615,7 @@ Examples of valid command strings: required: Some(vec!["command".to_string()]), additional_properties: Some(false.into()), }, + output_schema: None, }) } @@ -646,6 +683,7 @@ Examples of valid command strings: required: Some(vec!["command".to_string()]), additional_properties: Some(false.into()), }, + output_schema: None, }) } @@ -668,6 +706,7 @@ fn create_view_image_tool() -> ToolSpec { required: Some(vec!["path".to_string()]), additional_properties: Some(false.into()), }, + output_schema: None, }) } @@ -793,6 +832,7 @@ fn create_spawn_agent_tool(config: &ToolsConfig) -> ToolSpec { required: None, additional_properties: Some(false.into()), }, + output_schema: None, }) } @@ -869,6 +909,7 @@ fn create_spawn_agents_on_csv_tool() -> ToolSpec { required: Some(vec!["csv_path".to_string(), "instruction".to_string()]), additional_properties: Some(false.into()), }, + output_schema: None, }) } @@ -918,6 +959,7 @@ fn create_report_agent_job_result_tool() -> ToolSpec { ]), additional_properties: Some(false.into()), }, + output_schema: None, }) } @@ -960,6 +1002,7 @@ fn create_send_input_tool() -> ToolSpec { required: Some(vec!["id".to_string()]), additional_properties: Some(false.into()), }, + output_schema: None, }) } @@ -983,6 +1026,7 @@ fn create_resume_agent_tool() -> ToolSpec { required: Some(vec!["id".to_string()]), additional_properties: Some(false.into()), }, + output_schema: None, }) } @@ -1017,6 +1061,7 @@ fn create_wait_tool() -> ToolSpec { required: Some(vec!["ids".to_string()]), additional_properties: Some(false.into()), }, + output_schema: None, }) } @@ -1102,6 +1147,7 @@ fn create_request_user_input_tool( required: Some(vec!["questions".to_string()]), additional_properties: Some(false.into()), }, + output_schema: None, }) } @@ -1126,6 +1172,7 @@ fn create_request_permissions_tool() -> ToolSpec { required: Some(vec!["permissions".to_string()]), additional_properties: Some(false.into()), }, + output_schema: None, }) } @@ -1148,6 +1195,7 @@ fn create_close_agent_tool() -> ToolSpec { required: Some(vec!["id".to_string()]), additional_properties: Some(false.into()), }, + output_schema: None, }) } @@ -1215,6 +1263,7 @@ fn create_test_sync_tool() -> ToolSpec { required: None, additional_properties: Some(false.into()), }, + output_schema: None, }) } @@ -1266,6 +1315,7 @@ fn create_grep_files_tool() -> ToolSpec { required: Some(vec!["pattern".to_string()]), additional_properties: Some(false.into()), }, + output_schema: None, }) } @@ -1311,6 +1361,7 @@ fn create_search_tool_bm25_tool(app_tools: &HashMap) -> ToolSp required: Some(vec!["query".to_string()]), additional_properties: Some(false.into()), }, + output_schema: None, }) } @@ -1414,6 +1465,7 @@ fn create_read_file_tool() -> ToolSpec { required: Some(vec!["file_path".to_string()]), additional_properties: Some(false.into()), }, + output_schema: None, }) } @@ -1460,6 +1512,7 @@ fn create_list_dir_tool() -> ToolSpec { required: Some(vec!["dir_path".to_string()]), additional_properties: Some(false.into()), }, + output_schema: None, }) } @@ -1534,6 +1587,7 @@ fn create_js_repl_reset_tool() -> ToolSpec { required: None, additional_properties: Some(false.into()), }, + output_schema: None, }) } @@ -1549,7 +1603,7 @@ source: /[\s\S]+/ enabled_tool_names.join(", ") }; let description = format!( - "Runs JavaScript in a Node-backed `node:vm` context. This is a freeform tool: send raw JavaScript source text (no JSON/quotes/markdown fences). Direct tool calls remain available while `code_mode` is enabled. Inside JavaScript, import nested tools from `tools.js`, for example `import {{ exec_command }} from \"tools.js\"` or `import {{ tools }} from \"tools.js\"`. `tools[name]` and identifier wrappers like `await shell(args)` remain available for compatibility when the tool name is a valid JS identifier. Nested tool calls resolve to arrays of content items. Function tools require JSON object arguments. Freeform tools require raw strings. Use synchronous `add_content(value)` with a content item or content-item array, including `add_content(await exec_command(...))`, to return the same content items a direct tool call would expose to the model. Only content passed to `add_content(value)` is surfaced back to the model. Enabled nested tools: {enabled_list}." + "Runs JavaScript in a Node-backed `node:vm` context. This is a freeform tool: send raw JavaScript source text (no JSON/quotes/markdown fences). Direct tool calls remain available while `code_mode` is enabled. Inside JavaScript, import nested tools from `tools.js`, for example `import {{ exec_command }} from \"tools.js\"` or `import {{ tools }} from \"tools.js\"`. `tools[name]` and identifier wrappers like `await shell(args)` remain available for compatibility when the tool name is a valid JS identifier. Nested tool calls resolve to their code-mode result values. Function tools require JSON object arguments. Freeform tools require raw strings. Use synchronous `add_content(value)` with a content item, content-item array, or string. Structured nested-tool results should be converted to text first, for example with `JSON.stringify(...)`. Only content passed to `add_content(value)` is surfaced back to the model. Enabled nested tools: {enabled_list}." ); ToolSpec::Freeform(FreeformTool { @@ -1594,6 +1648,7 @@ fn create_list_mcp_resources_tool() -> ToolSpec { required: None, additional_properties: Some(false.into()), }, + output_schema: None, }) } @@ -1628,6 +1683,7 @@ fn create_list_mcp_resource_templates_tool() -> ToolSpec { required: None, additional_properties: Some(false.into()), }, + output_schema: None, }) } @@ -1664,6 +1720,7 @@ fn create_read_mcp_resource_tool() -> ToolSpec { required: Some(vec!["server".to_string(), "uri".to_string()]), additional_properties: Some(false.into()), }, + output_schema: None, }) } @@ -1726,6 +1783,7 @@ pub(crate) fn mcp_tool_to_openai_tool( description: description.map(Into::into).unwrap_or_default(), strict: false, parameters: input_schema, + output_schema: None, }) } @@ -1739,6 +1797,7 @@ fn dynamic_tool_to_openai_tool( description: tool.description.clone(), strict: false, parameters: input_schema, + output_schema: None, }) } @@ -3278,6 +3337,7 @@ mod tests { }, description: "Do something cool".to_string(), strict: false, + output_schema: None, }) ); } @@ -3516,6 +3576,7 @@ mod tests { }, description: "Search docs".to_string(), strict: false, + output_schema: None, }) ); } @@ -3567,6 +3628,7 @@ mod tests { }, description: "Pagination".to_string(), strict: false, + output_schema: None, }) ); } @@ -3622,6 +3684,7 @@ mod tests { }, description: "Tags".to_string(), strict: false, + output_schema: None, }) ); } @@ -3675,6 +3738,7 @@ mod tests { }, description: "AnyOf Value".to_string(), strict: false, + output_schema: None, }) ); } @@ -3933,6 +3997,7 @@ Examples of valid command strings: }, description: "Do something cool".to_string(), strict: false, + output_schema: None, }) ); } @@ -3950,6 +4015,7 @@ Examples of valid command strings: required: None, additional_properties: None, }, + output_schema: None, })]; let responses_json = create_tools_json_for_responses_api(&tools).unwrap(); diff --git a/codex-rs/core/tests/suite/code_mode.rs b/codex-rs/core/tests/suite/code_mode.rs index 55e23ce1c..a77ccf5a1 100644 --- a/codex-rs/core/tests/suite/code_mode.rs +++ b/codex-rs/core/tests/suite/code_mode.rs @@ -14,7 +14,7 @@ use core_test_support::skip_if_no_network; use core_test_support::test_codex::TestCodex; use core_test_support::test_codex::test_codex; use pretty_assertions::assert_eq; -use regex_lite::Regex; +use serde_json::Value; use std::fs; use wiremock::MockServer; @@ -75,7 +75,7 @@ async fn code_mode_can_return_exec_command_output() -> Result<()> { r#" import { exec_command } from "tools.js"; -add_content(await exec_command({ cmd: "printf code_mode_exec_marker" })); +add_content(JSON.stringify(await exec_command({ cmd: "printf code_mode_exec_marker" }))); "#, false, ) @@ -88,19 +88,20 @@ add_content(await exec_command({ cmd: "printf code_mode_exec_marker" })); Some(false), "code_mode call failed unexpectedly: {output}" ); - let regex = Regex::new( - r#"(?ms)^Chunk ID: [[:xdigit:]]+ -Wall time: [0-9]+(?:\.[0-9]+)? seconds -Process exited with code 0 -Original token count: [0-9]+ -Output: -code_mode_exec_marker -?$"#, - )?; + let parsed: Value = serde_json::from_str(&output)?; assert!( - regex.is_match(&output), - "expected exec_command output envelope to match regex, got: {output}" + parsed + .get("chunk_id") + .and_then(Value::as_str) + .is_some_and(|chunk_id| !chunk_id.is_empty()) ); + assert_eq!( + parsed.get("output").and_then(Value::as_str), + Some("code_mode_exec_marker"), + ); + assert_eq!(parsed.get("exit_code").and_then(Value::as_i64), Some(0)); + assert!(parsed.get("wall_time_seconds").is_some()); + assert!(parsed.get("session_id").is_none()); Ok(()) }