diff --git a/codex-rs/core/src/tools/context.rs b/codex-rs/core/src/tools/context.rs index d1fc21e1df..7d78f1822f 100644 --- a/codex-rs/core/src/tools/context.rs +++ b/codex-rs/core/src/tools/context.rs @@ -69,6 +69,13 @@ pub enum ToolOutput { body: FunctionCallOutputBody, success: Option, }, + FunctionWithControl { + // Canonical output body for function-style tools plus internal control + // metadata consumed by core dispatch (not exposed on the wire). + body: FunctionCallOutputBody, + success: Option, + interrupt_turn: bool, + }, Mcp { result: Result, }, @@ -83,7 +90,7 @@ pub struct ToolDispatchOutput { impl ToolOutput { pub fn log_preview(&self) -> String { match self { - ToolOutput::Function { body, .. } => { + ToolOutput::Function { body, .. } | ToolOutput::FunctionWithControl { body, .. } => { telemetry_preview(&body.to_text().unwrap_or_default()) } ToolOutput::Mcp { result } => format!("{result:?}"), @@ -92,14 +99,23 @@ impl ToolOutput { pub fn success_for_logging(&self) -> bool { match self { - ToolOutput::Function { success, .. } => success.unwrap_or(true), + ToolOutput::Function { success, .. } + | ToolOutput::FunctionWithControl { success, .. } => success.unwrap_or(true), ToolOutput::Mcp { result } => result.is_ok(), } } + pub fn interrupt_turn_hint(&self) -> bool { + match self { + ToolOutput::FunctionWithControl { interrupt_turn, .. } => *interrupt_turn, + ToolOutput::Function { .. } | ToolOutput::Mcp { .. } => false, + } + } + pub fn into_response(self, call_id: &str, payload: &ToolPayload) -> ResponseInputItem { match self { - ToolOutput::Function { body, success } => { + ToolOutput::Function { body, success } + | ToolOutput::FunctionWithControl { body, success, .. } => { // `custom_tool_call` is the Responses API item type for freeform // tools (`ToolSpec::Freeform`, e.g. freeform `apply_patch` or // `js_repl`). @@ -216,6 +232,30 @@ mod tests { } } + #[test] + fn function_with_control_interrupt_hint_is_internal_only() { + let payload = ToolPayload::Function { + arguments: "{}".to_string(), + }; + let output = ToolOutput::FunctionWithControl { + body: FunctionCallOutputBody::Text("ok".to_string()), + success: Some(true), + interrupt_turn: true, + }; + + assert!(output.interrupt_turn_hint()); + + let response = output.into_response("fn-ctrl", &payload); + match response { + ResponseInputItem::FunctionCallOutput { call_id, output } => { + assert_eq!(call_id, "fn-ctrl"); + assert_eq!(output.text_content(), Some("ok")); + assert_eq!(output.success, Some(true)); + } + other => panic!("expected FunctionCallOutput, got {other:?}"), + } + } + #[test] fn custom_tool_calls_can_derive_text_from_content_items() { let payload = ToolPayload::Custom { diff --git a/codex-rs/core/src/tools/handlers/js_repl.rs b/codex-rs/core/src/tools/handlers/js_repl.rs index 362d25b81e..bc4a199d75 100644 --- a/codex-rs/core/src/tools/handlers/js_repl.rs +++ b/codex-rs/core/src/tools/handlers/js_repl.rs @@ -155,6 +155,7 @@ impl ToolHandler for JsReplHandler { }; let content = result.output; + let interrupt_turn = result.interrupt_turn; let mut items = Vec::with_capacity(result.content_items.len() + 1); if !content.is_empty() { items.push(FunctionCallOutputContentItem::InputText { @@ -173,14 +174,24 @@ impl ToolHandler for JsReplHandler { ) .await; - Ok(ToolOutput::Function { - body: if items.is_empty() { - FunctionCallOutputBody::Text(content) - } else { - FunctionCallOutputBody::ContentItems(items) - }, - success: Some(true), - }) + let body = if items.is_empty() { + FunctionCallOutputBody::Text(content) + } else { + FunctionCallOutputBody::ContentItems(items) + }; + + if interrupt_turn { + Ok(ToolOutput::FunctionWithControl { + body, + success: Some(true), + interrupt_turn: true, + }) + } else { + Ok(ToolOutput::Function { + body, + success: Some(true), + }) + } } } diff --git a/codex-rs/core/src/tools/js_repl/mod.rs b/codex-rs/core/src/tools/js_repl/mod.rs index e852127a20..fe69d10be7 100644 --- a/codex-rs/core/src/tools/js_repl/mod.rs +++ b/codex-rs/core/src/tools/js_repl/mod.rs @@ -105,6 +105,7 @@ pub struct JsReplArgs { pub struct JsExecResult { pub output: String, pub content_items: Vec, + pub interrupt_turn: bool, } struct KernelState { @@ -127,6 +128,7 @@ struct ExecContext { struct ExecToolCalls { in_flight: usize, content_items: Vec, + interrupted: bool, notify: Arc, cancel: CancellationToken, } @@ -411,6 +413,24 @@ impl JsReplManager { } } + async fn mark_exec_tool_call_interrupted( + exec_tool_calls: &Arc>>, + exec_id: &str, + ) { + let mut calls = exec_tool_calls.lock().await; + if let Some(state) = calls.get_mut(exec_id) { + state.interrupted = true; + } + } + + async fn exec_tool_calls_interrupted( + exec_tool_calls: &Arc>>, + exec_id: &str, + ) -> bool { + let calls = exec_tool_calls.lock().await; + calls.get(exec_id).is_some_and(|state| state.interrupted) + } + async fn wait_for_exec_tool_calls_map( exec_tool_calls: &Arc>>, exec_id: &str, @@ -794,11 +814,15 @@ impl JsReplManager { }; match response { - ExecResultMessage::Ok { content_items } => { + ExecResultMessage::Ok { + content_items, + interrupt_turn, + } => { let (output, content_items) = split_exec_result_content_items(content_items); Ok(JsExecResult { output, content_items, + interrupt_turn, }) } ExecResultMessage::Err { message } => Err(FunctionCallError::RespondToModel(message)), @@ -1111,6 +1135,8 @@ impl JsReplManager { .map(|state| state.content_items.clone()) .unwrap_or_default() }; + let interrupt_turn = + JsReplManager::exec_tool_calls_interrupted(&exec_tool_calls, &id).await; let mut pending = pending_execs.lock().await; if let Some(tx) = pending.remove(&id) { let payload = if ok { @@ -1119,6 +1145,7 @@ impl JsReplManager { output, content_items, ), + interrupt_turn, } } else { ExecResultMessage::Err { @@ -1142,6 +1169,7 @@ impl JsReplManager { ok: false, response: None, error: Some("js_repl exec context not found".to_string()), + interrupt_turn: false, }); if let Err(err) = JsReplManager::write_message(&stdin, &payload).await { let snapshot = @@ -1175,6 +1203,7 @@ impl JsReplManager { ok: false, response: None, error: Some("js_repl execution reset".to_string()), + interrupt_turn: false, }, result = JsReplManager::run_tool_request( ctx, @@ -1188,8 +1217,16 @@ impl JsReplManager { ok: false, response: None, error: Some("js_repl exec context not found".to_string()), + interrupt_turn: false, }, }; + if result.interrupt_turn { + JsReplManager::mark_exec_tool_call_interrupted( + &exec_tool_calls_for_task, + &exec_id, + ) + .await; + } JsReplManager::finish_exec_tool_call(&exec_tool_calls_for_task, &exec_id) .await; let payload = HostToKernel::RunToolResult(result); @@ -1288,6 +1325,7 @@ impl JsReplManager { ok: false, response: None, error: Some(error), + interrupt_turn: false, }; } @@ -1369,25 +1407,7 @@ impl JsReplManager { ok: true, response: Some(value), error: None, -======= - Ok(output) => { - if let ResponseInputItem::FunctionCallOutput { output, .. } = &output.response_input - && let Some(items) = output.content_items() - { - let mut has_image = false; - let mut content = Vec::with_capacity(items.len()); - for item in items { - match item { - FunctionCallOutputContentItem::InputText { text } => { - content.push(ContentItem::InputText { text: text.clone() }); - } - FunctionCallOutputContentItem::InputImage { image_url } => { - has_image = true; - content.push(ContentItem::InputImage { - image_url: image_url.clone(), - }); - } ->>>>>>> 2fbcb2ec4 (core: generalize interrupted tool-result handling) + interrupt_turn: output.interrupt_turn, } } Err(err) => { @@ -1399,6 +1419,7 @@ impl JsReplManager { ok: false, response: None, error: Some(error), + interrupt_turn: false, } } } @@ -1412,6 +1433,7 @@ impl JsReplManager { ok: false, response: None, error: Some(error), + interrupt_turn: false, } } } @@ -1546,12 +1568,15 @@ struct RunToolResult { response: Option, #[serde(default)] error: Option, + #[serde(default, skip_serializing_if = "std::ops::Not::not")] + interrupt_turn: bool, } #[derive(Debug)] enum ExecResultMessage { Ok { content_items: Vec, + interrupt_turn: bool, }, Err { message: String, diff --git a/codex-rs/core/src/tools/registry.rs b/codex-rs/core/src/tools/registry.rs index 3127889f51..5e80333188 100644 --- a/codex-rs/core/src/tools/registry.rs +++ b/codex-rs/core/src/tools/registry.rs @@ -186,7 +186,8 @@ impl ToolRegistry { Ok(output) => { let preview = output.log_preview(); let success = output.success_for_logging(); - let interrupt_turn = handler.should_interrupt_turn(&output); + let interrupt_turn = output.interrupt_turn_hint() + || handler.should_interrupt_turn(&output); let mut guard = output_cell.lock().await; *guard = Some((output, interrupt_turn)); Ok((preview, success))