diff --git a/codex-rs/core/src/tools/handlers/dynamic.rs b/codex-rs/core/src/tools/handlers/dynamic.rs index 28af3434ff..7f07fa940d 100644 --- a/codex-rs/core/src/tools/handlers/dynamic.rs +++ b/codex-rs/core/src/tools/handlers/dynamic.rs @@ -2,6 +2,7 @@ use crate::function_tool::FunctionCallError; use crate::session::session::Session; use crate::session::turn_context::TurnContext; use crate::tools::context::FunctionToolOutput; +use crate::tools::context::ToolCallSource; use crate::tools::context::ToolInvocation; use crate::tools::context::ToolPayload; use crate::tools::context::boxed_tool_output; @@ -121,6 +122,7 @@ impl DynamicToolHandler { turn, call_id, payload, + source, .. } = invocation; @@ -156,10 +158,19 @@ impl DynamicToolHandler { .into_iter() .map(FunctionCallOutputContentItem::from) .collect::>(); - Ok(boxed_tool_output(FunctionToolOutput::from_content( - body, - Some(success), - ))) + let output = FunctionToolOutput::from_content(body, Some(success)); + if !success && matches!(source, ToolCallSource::CodeMode { .. }) { + let message = output.into_text(); + return Err(FunctionCallError::RespondToModel( + if message.trim().is_empty() { + "dynamic tool call failed".to_string() + } else { + message + }, + )); + } + + Ok(boxed_tool_output(output)) } } diff --git a/codex-rs/core/tests/suite/code_mode.rs b/codex-rs/core/tests/suite/code_mode.rs index 14bee0fb8d..26c8f5a273 100644 --- a/codex-rs/core/tests/suite/code_mode.rs +++ b/codex-rs/core/tests/suite/code_mode.rs @@ -3423,7 +3423,7 @@ text(JSON.stringify(tool)); } #[tokio::test(flavor = "multi_thread", worker_threads = 2)] -async fn code_mode_can_call_hidden_dynamic_tools() -> Result<()> { +async fn code_mode_can_call_hidden_dynamic_tools_and_catch_failures() -> Result<()> { skip_if_no_network!(Ok(())); let server = responses::start_mock_server().await; @@ -3463,11 +3463,18 @@ async fn code_mode_can_call_hidden_dynamic_tools() -> Result<()> { let code = r#" const tool = ALL_TOOLS.find(({ name }) => name === "codex_app__hidden_dynamic_tool"); const out = await tools.codex_app__hidden_dynamic_tool({ city: "Paris" }); +let error = null; +try { + await tools.codex_app__hidden_dynamic_tool({ city: "London" }); +} catch (caught) { + error = caught?.message ?? String(caught); +} text( JSON.stringify({ name: tool?.name ?? null, description: tool?.description ?? null, out, + error, }) ); "#; @@ -3549,6 +3556,25 @@ text( }, }) .await?; + let request = wait_for_event_match(&test.codex, |event| match event { + EventMsg::DynamicToolCallRequest(request) => Some(request.clone()), + _ => None, + }) + .await; + assert_eq!(request.namespace.as_deref(), Some("codex_app")); + assert_eq!(request.tool, "hidden_dynamic_tool"); + assert_eq!(request.arguments, serde_json::json!({ "city": "London" })); + test.codex + .submit(Op::DynamicToolResponse { + id: request.call_id, + response: DynamicToolResponse { + content_items: vec![DynamicToolCallOutputContentItem::InputText { + text: "automation_update received invalid arguments.".to_string(), + }], + success: false, + }, + }) + .await?; wait_for_event(&test.codex, |event| match event { EventMsg::TurnComplete(event) => event.turn_id == turn_id, _ => false, @@ -3575,6 +3601,12 @@ text( parsed.get("out"), Some(&Value::String("hidden-ok".to_string())) ); + assert_eq!( + parsed.get("error"), + Some(&Value::String( + "automation_update received invalid arguments.".to_string() + )) + ); assert!( parsed .get("description")