fix: terminalize interrupted tool executions

This commit is contained in:
2026-08-27 20:54:13 +09:00
parent ccabea59c9
commit 58cc94d4b7
16 changed files with 914 additions and 98 deletions
+13
View File
@@ -763,6 +763,19 @@ pub(crate) fn wire_event_bridges_on_engine<C, St>(
id: result.tool_use_id.clone(),
summary: result.summary.clone(),
output: result.content.clone(),
disposition: Some(match result.disposition {
agen::ToolResultDisposition::Success => protocol::ToolResultDisposition::Success,
agen::ToolResultDisposition::Error => protocol::ToolResultDisposition::Error,
agen::ToolResultDisposition::Interrupted => {
protocol::ToolResultDisposition::Interrupted
}
agen::ToolResultDisposition::Cancelled => {
protocol::ToolResultDisposition::Cancelled
}
agen::ToolResultDisposition::OutcomeUnknown => {
protocol::ToolResultDisposition::OutcomeUnknown
}
}),
is_error: result.is_error,
});
});
+13 -2
View File
@@ -13,7 +13,7 @@
#[cfg(test)]
use crate::prompt::catalog::PromptCatalog;
use agen::Item;
use agen::{Item, ToolResultDisposition};
/// Build synthetic `Item::ToolResult` items for every unanswered
/// `Item::ToolCall` in `history`, preserving order.
@@ -28,7 +28,16 @@ pub(crate) fn orphan_tool_result_closures(history: &[Item], summary: &str) -> Ve
for item in history {
if let Item::ToolCall { call_id, .. } = item {
if !answered.contains(call_id.as_str()) {
out.push(Item::tool_result(call_id.clone(), summary));
out.push(Item::tool_result_item_with_disposition_and_attachments(
call_id.clone(),
summary,
Some(
"Execution ended before completion could be confirmed. Completion and side effects are unknown."
.to_string(),
),
ToolResultDisposition::OutcomeUnknown,
Vec::new(),
));
}
}
}
@@ -77,10 +86,12 @@ mod tests {
Item::ToolResult {
call_id,
summary: got,
disposition,
..
} => {
assert_eq!(call_id, "c1");
assert_eq!(got, &summary);
assert_eq!(*disposition, ToolResultDisposition::OutcomeUnknown);
}
other => panic!("expected ToolResult, got {other:?}"),
}
+114 -34
View File
@@ -2953,50 +2953,52 @@ impl<C: LlmClient + 'static, St: Store> Worker<C, St> {
Ok(())
}
/// Stage the post-interruption cleanup at the front of worker
/// history: close every unanswered `Item::ToolCall` with a synthetic
/// `Item::ToolResult` (Anthropic wire-validity), then append a
/// system note so the LLM understands the prior turn was cut
/// short. Called from `Worker::run` when the worker's
/// `last_run_interrupted` flag is set (i.e. the Worker just transitioned
/// out of Paused via a new user input).
fn apply_interrupt_prep(&mut self) -> Result<(), WorkerError> {
/// Durably close every unanswered ToolCall before the interrupted run's
/// final lifecycle record/status is published.
fn terminalize_orphan_tool_calls(&mut self) -> Result<(), WorkerError> {
let tool_result_summary = self
.prompts()
.load_full()
.interrupt_tool_result_summary()
.map_err(WorkerError::from)?;
let history_items = self.history();
let closures = crate::interrupt_prep::orphan_tool_result_closures(
&history_items,
&tool_result_summary,
);
if closures.is_empty() {
return Ok(());
}
let subject = worker_subject(self.session.session_id());
for item in closures {
let entry = HistoryEntry::new(
item,
new_history_metadata(
WorkerHistoryProvenance::ToolOutput {
worker: subject.clone(),
},
None,
),
);
self.commit_entry(LogEntry::AnnotatedToolResult {
ts: segment_log::now_millis(),
entry: to_logged_history_entry(&entry),
})?;
self.session.history_mut().push_entry(entry);
self.session.note_mutation();
}
Ok(())
}
fn apply_interrupt_prep(&mut self) -> Result<(), WorkerError> {
self.terminalize_orphan_tool_calls()?;
let system_note = self
.prompts()
.load_full()
.interrupt_system_note()
.map_err(WorkerError::from)?;
let history_items = self.history();
let closures = crate::interrupt_prep::orphan_tool_result_closures(
&history_items,
&tool_result_summary,
);
if !closures.is_empty() {
let subject = worker_subject(self.session.session_id());
for item in closures {
let entry = HistoryEntry::new(
item,
new_history_metadata(
WorkerHistoryProvenance::ToolOutput {
worker: subject.clone(),
},
None,
),
);
self.commit_entry(LogEntry::AnnotatedToolResult {
ts: segment_log::now_millis(),
entry: to_logged_history_entry(&entry),
})?;
self.session.history_mut().push_entry(entry);
self.session.note_mutation();
}
}
let interrupt_prompt_provenance =
self.prompt_render_provenance("internal.interrupt_system_note");
let interrupt_metadata = new_history_metadata(
@@ -3262,6 +3264,12 @@ impl<C: LlmClient + 'static, St: Store> Worker<C, St> {
where
St: Clone + 'static,
{
if matches!(
&result,
EngineRunExit::Paused | EngineRunExit::Interrupted(_)
) {
self.terminalize_orphan_tool_calls()?;
}
self.persist_turn(history_before, &result).await?;
if matches!(result, EngineRunExit::Yielded) {
@@ -5928,7 +5936,8 @@ fn stop_reason_error_code(reason: &StopReason) -> ErrorCode {
EngineError::Aborted(_)
| EngineError::Cancelled
| EngineError::ConfigWarnings(_)
| EngineError::HistoryAppend(_),
| EngineError::HistoryAppend(_)
| EngineError::ToolAttemptFence(_),
) => ErrorCode::Internal,
}
}
@@ -7862,6 +7871,7 @@ mod build_summary_prompt_tests {
summary: "wrote a file".into(),
content: None,
attachments: Vec::new(),
disposition: Default::default(),
is_error: false,
},
},
@@ -7904,6 +7914,7 @@ mod build_summary_prompt_tests {
summary: "wrote a file".into(),
content: None,
attachments: Vec::new(),
disposition: Default::default(),
is_error: false,
},
},
@@ -7948,6 +7959,7 @@ mod build_summary_prompt_tests {
summary: "side effect".into(),
content: None,
attachments: Vec::new(),
disposition: Default::default(),
is_error: false,
},
metadata: new_history_metadata(
@@ -8047,6 +8059,74 @@ mod build_summary_prompt_tests {
assert!(err.contains("session head changed"));
}
#[tokio::test]
async fn interrupted_result_terminalizes_orphan_before_run_completed() {
let dir = tempfile::tempdir().unwrap();
let manifest = minimal_manifest();
let store = session_store::FsStore::new(dir.path().join("sessions")).unwrap();
let cwd = dir.path().join("workspace");
std::fs::create_dir_all(&cwd).unwrap();
let scope = Scope::writable(&cwd).unwrap();
let authority = WorkerFilesystemAuthority::local(cwd.clone(), cwd.clone());
let mut worker = Worker::new(
manifest,
Engine::<_, Mutable, SessionHistoryMetadata>::new_annotated(NoopClient),
store,
WorkerWorkspaceContext::local_filesystem(None),
authority,
scope,
)
.await
.unwrap();
worker.ensure_segment_head().unwrap();
worker.wire_history_persistence();
worker.set_history_for_test(vec![Item::tool_call("call-1", "Bash", "{}")]);
let _ = worker
.handle_worker_result(
EngineRunExit::Interrupted(StopReason::Cancelled),
worker.history().len(),
)
.await
.unwrap();
let entries = worker
.store
.read_all(
worker.segment_state.session_id(),
worker.segment_state.segment_id(),
)
.unwrap();
let terminal_index = entries
.iter()
.position(|entry| {
matches!(
entry,
LogEntry::AnnotatedToolResult {
entry: session_store::LoggedHistoryEntry {
item: session_store::LoggedItem::ToolResult {
disposition: agen::ToolResultDisposition::OutcomeUnknown,
..
},
..
},
..
}
)
})
.expect("durable OutcomeUnknown closure");
let final_index = entries
.iter()
.position(|entry| {
matches!(
entry,
LogEntry::RunCompleted { .. } | LogEntry::RunErrored { .. }
)
})
.expect("durable final run status");
assert!(terminal_index < final_index);
}
#[tokio::test]
async fn apply_interrupt_prep_appends_via_callback_and_logs_independent_entries() {
let dir = tempfile::tempdir().unwrap();
+11 -4
View File
@@ -2169,9 +2169,13 @@ async fn paused_then_run_closes_orphan_tool_use_for_next_request() {
for item in items {
match item {
agen::Item::ToolResult {
call_id, summary, ..
call_id,
summary,
disposition,
..
} if call_id == "call_orphan" => {
assert_eq!(summary, "[Interrupted by user]");
assert_eq!(summary, "Tool execution outcome unknown");
assert_eq!(*disposition, agen::ToolResultDisposition::OutcomeUnknown);
saw_synthetic_tool_result = true;
}
agen::Item::Message { role, content, .. } if *role == agen::Role::System => {
@@ -2362,8 +2366,11 @@ async fn paused_cancel_abandons_resume_and_next_input_is_fresh_run() {
assert!(
items.iter().any(|item| matches!(
item,
agen::Item::ToolResult { call_id, summary, .. }
if call_id == "call_cancelled" && summary == "[Interrupted by user]"
agen::Item::ToolResult {
call_id,
disposition: agen::ToolResultDisposition::OutcomeUnknown,
..
} if call_id == "call_cancelled"
)),
"paused cancel should close orphan tool_use before future requests: {items:?}"
);