From 75c7347c5de928eec67306dbfe17606014300410 Mon Sep 17 00:00:00 2001 From: Yuanhao Li Date: Thu, 8 Oct 2026 03:13:44 +0200 Subject: [PATCH] fix(extension): a cancelled run's withheld tool result says the run was cancelled The result stays withheld (a redactor may not have run), but the text no longer reads as an extension failure. Test with a failing-hook control, mutation-checked. Co-Authored-By: Claude Opus 5.5 (1M context) Claude-Session: https://claude.ai/code/session_01T7iq5hpndSiHQcnAsywKuG --- CHANGELOG.md | 1 + src/extension.rs | 21 +++++++++---- tests/extension_test.rs | 66 +++++++++++++++++++++++++++++++++++++++++ 3 files changed, 82 insertions(+), 6 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 65941fc..38c65bf 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -88,6 +88,7 @@ adheres to [Semantic Versioning](https://semver.org/). - **An `Agent` run carries the caller's tracing subscriber** into its spawned task (and the extension observer and compaction tasks do too), so a subscriber set with `tracing::subscriber::set_default` / `with_default` sees runs on a multi-thread runtime. - **Responses / Azure, encrypted reasoning:** a reasoning item whose following output item has no id is no longer stored or replayed (warned) — it would 400 every later request — and an unparseable reasoning `output_item.done` ends the previous item's range. - **yoagent-rutis: a TypeScript/Python handler's `on_event` delivery no longer fails silently.** A panic during delivery is caught, and a delivery task that ended (seen on `send` or `flush`) is recorded; either is the handler's failure (a required extension fails the run, an advisory one logs it). Before, the remaining events were dropped and the run never failed. +- **A cancelled run no longer blames an extension for a withheld tool result.** When the run is cancelled while an extension's `after_tool` is still working, the result is still withheld (a redactor may not have run), but the text now says the run was cancelled before the extension finished, instead of "extension '…' did not finish processing it", which reads like the extension failed. ## 0.24.3 diff --git a/src/extension.rs b/src/extension.rs index 118f00f..720e873 100644 --- a/src/extension.rs +++ b/src/extension.rs @@ -1087,13 +1087,22 @@ impl ActiveExtensions { Hooked::Panicked(panic) => Some(panic), Hooked::Cancelled => None, }; + // Withheld either way (a redactor may not have run), but say why: + // a cancelled run is not an extension's fault. + let text = if reason.is_none() { + format!( + "Tool result withheld: the run was cancelled before extension '{}' \ + finished processing it.", + a.name + ) + } else { + format!( + "Tool result withheld: extension '{}' did not finish processing it.", + a.name + ) + }; output.result = ToolResult { - content: vec![Content::Text { - text: format!( - "Tool result withheld: extension '{}' did not finish processing it.", - a.name - ), - }], + content: vec![Content::Text { text }], details: serde_json::Value::Null, }; output.is_error = true; diff --git a/tests/extension_test.rs b/tests/extension_test.rs index bce5797..b1b106b 100644 --- a/tests/extension_test.rs +++ b/tests/extension_test.rs @@ -3133,3 +3133,69 @@ async fn a_panicking_take_failure_is_contained() { assert!(!results[0].1, "the call ran: {results:?}"); assert!(turns_paired(&events)); } + +/// An `after_tool` that waits until the run is cancelled. +#[derive(Clone)] +struct SlowPost; + +#[async_trait::async_trait] +impl RunHooks for SlowPost { + async fn after_tool( + &self, + _call: &ToolCallRequest<'_>, + _out: &mut ToolOutput, + ) -> Result<(), ExtensionError> { + std::future::pending::<()>().await; + Ok(()) + } +} + +/// A run cancelled while an `after_tool` is still working: the result is +/// withheld (a redactor may not have run), and the text says the run was +/// cancelled rather than blaming the extension. Control: a failing +/// `after_tool` still says the extension did not finish. +#[tokio::test] +async fn a_cancelled_after_tool_says_the_run_was_cancelled() { + let (agent, _) = scripted(vec![ + call("echo", serde_json::json!({"text": "a"})), + text("never"), + ]); + let mut agent = agent.with_extension(ClonedHooks::new("post", SlowPost)); + let mut rx = agent.prompt("go").await; + let mut events = Vec::new(); + while let Some(event) = rx.recv().await { + if matches!(event, AgentEvent::ToolExecutionStart { .. }) { + // Let the tool finish and `after_tool` start waiting. + tokio::time::sleep(std::time::Duration::from_millis(50)).await; + agent.abort(); + } + events.push(event); + } + agent.finish().await; + let results = tool_results(&events); + assert_eq!(results.len(), 1, "{results:?}"); + assert!(results[0].1, "an error result: {results:?}"); + assert!( + results[0].0.contains("the run was cancelled"), + "{results:?}" + ); + + let (agent, _) = scripted(vec![ + call("echo", serde_json::json!({"text": "a"})), + text("done"), + ]); + let mut agent = agent.with_extension(ext( + "post", + Hooks { + fail_after_tool: true, + ..Default::default() + }, + )); + let events = run(&mut agent, "go").await; + let results = tool_results(&events); + assert!( + results[0].0.contains("did not finish processing it") + && !results[0].0.contains("cancelled"), + "{results:?}" + ); +}