Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down
21 changes: 15 additions & 6 deletions src/extension.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down
66 changes: 66 additions & 0 deletions tests/extension_test.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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:?}"
);
}
Loading