core: preserve js_repl tool interrupt metadata

This commit is contained in:
Charles Cunningham
2026-02-19 22:29:07 -08:00
parent 8d064684dd
commit f06b6f238f
4 changed files with 109 additions and 32 deletions

View File

@@ -69,6 +69,13 @@ pub enum ToolOutput {
body: FunctionCallOutputBody,
success: Option<bool>,
},
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<bool>,
interrupt_turn: bool,
},
Mcp {
result: Result<CallToolResult, String>,
},
@@ -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 {

View File

@@ -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),
})
}
}
}

View File

@@ -105,6 +105,7 @@ pub struct JsReplArgs {
pub struct JsExecResult {
pub output: String,
pub content_items: Vec<FunctionCallOutputContentItem>,
pub interrupt_turn: bool,
}
struct KernelState {
@@ -127,6 +128,7 @@ struct ExecContext {
struct ExecToolCalls {
in_flight: usize,
content_items: Vec<FunctionCallOutputContentItem>,
interrupted: bool,
notify: Arc<Notify>,
cancel: CancellationToken,
}
@@ -411,6 +413,24 @@ impl JsReplManager {
}
}
async fn mark_exec_tool_call_interrupted(
exec_tool_calls: &Arc<Mutex<HashMap<String, ExecToolCalls>>>,
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<Mutex<HashMap<String, ExecToolCalls>>>,
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<Mutex<HashMap<String, ExecToolCalls>>>,
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<JsonValue>,
#[serde(default)]
error: Option<String>,
#[serde(default, skip_serializing_if = "std::ops::Not::not")]
interrupt_turn: bool,
}
#[derive(Debug)]
enum ExecResultMessage {
Ok {
content_items: Vec<FunctionCallOutputContentItem>,
interrupt_turn: bool,
},
Err {
message: String,

View File

@@ -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))