use futures::StreamExt;
use tokio_util::sync::CancellationToken;
use crate::compaction::{self, CompactOutcome, CompactionPolicy};
use crate::event::{AgentEvent, LlmStreamEvent};
use crate::hooks::{HooksSet, ToolCall as HookToolCall};
use crate::message::{AgentMessage, ContentBlock, StopReason, Usage};
use crate::session::{Session, SessionEntry};
use crate::streamfn::{LlmOpts, StreamFn};
use crate::title;
use crate::tool::{Tool, ToolContext, ToolOutput};
pub const DEFAULT_MAX_TURNS: usize = 100;
pub const DEFAULT_MAX_STREAM_ERROR_TURNS: usize = 3;
const MAX_PARALLEL_TOOLS: usize = 8;
pub struct LoopConfig<'a> {
pub system: String,
pub tools: Vec<Tool>,
pub llm: LlmOpts,
pub stream_fn: StreamFn,
pub hooks: HooksSet,
pub steering: tokio::sync::mpsc::UnboundedReceiver<AgentMessage>,
pub follow_ups: tokio::sync::mpsc::UnboundedReceiver<AgentMessage>,
pub cancel: CancellationToken,
pub working_dir: std::path::PathBuf,
pub max_turns: usize,
pub parallel: bool,
pub compaction: CompactionPolicy,
pub ctx_snapshot: tokio::sync::watch::Sender<Vec<AgentMessage>>,
pub session: Option<&'a mut Session>,
pub title_after_turns: usize,
}
#[derive(Debug, thiserror::Error)]
pub enum LoopError {
#[error(transparent)]
Session(std::io::Error), #[error("{0}")]
Stream(String),
}
#[derive(Debug)]
pub struct RunResult {
pub stop_reason: StopReason,
pub steering: tokio::sync::mpsc::UnboundedReceiver<AgentMessage>,
pub follow_ups: tokio::sync::mpsc::UnboundedReceiver<AgentMessage>,
}
fn finish(
stop_reason: StopReason,
steering: tokio::sync::mpsc::UnboundedReceiver<AgentMessage>,
follow_ups: tokio::sync::mpsc::UnboundedReceiver<AgentMessage>,
) -> Result<RunResult, LoopError> {
Ok(RunResult {
stop_reason,
steering,
follow_ups,
})
}
fn next_injected(cfg: &mut LoopConfig<'_>) -> Option<AgentMessage> {
cfg.follow_ups
.try_recv()
.ok()
.or_else(|| cfg.steering.try_recv().ok())
}
pub async fn run_loop(
ctx: &mut Vec<AgentMessage>,
mut cfg: LoopConfig<'_>,
sink: tokio::sync::mpsc::UnboundedSender<AgentEvent>,
) -> Result<RunResult, LoopError> {
let tool_defs: Vec<rig::completion::ToolDefinition> =
cfg.tools.iter().map(|t| t.definition()).collect();
let model = cfg.llm.model.clone();
if sink.send(AgentEvent::AgentStart).is_err() {
return finish(StopReason::Aborted, cfg.steering, cfg.follow_ups);
}
let mut turns: usize = 0;
let mut title_tried = false;
let mut consecutive_stream_errors: usize = 0;
'outer: loop {
loop {
if turns >= cfg.max_turns {
let _ = sink.send(AgentEvent::AgentEnd);
return finish(StopReason::MaxTurns, cfg.steering, cfg.follow_ups);
}
turns += 1;
let mut aborted = false;
if sink.send(AgentEvent::TurnStart).is_err() {
aborted = true;
}
while let Ok(msg) = cfg.steering.try_recv() {
if sink
.send(AgentEvent::MessageStart {
message: msg.clone(),
})
.is_err()
{
aborted = true;
break;
}
record_session(&mut cfg, &msg)?;
ctx.push(msg.clone());
if sink.send(AgentEvent::MessageEnd { message: msg }).is_err() {
aborted = true;
break;
}
}
cfg.hooks.transform_context(ctx).await;
if let Some(used) = last_input_tokens(ctx) {
let window = cfg
.compaction
.context_window(cfg.llm.base_url.as_deref(), &cfg.llm.model);
if cfg.compaction.should_compact(window, used) {
let outcome = compaction::compact_ctx(
ctx,
&cfg.compaction,
&cfg.stream_fn,
&cfg.llm,
None,
cfg.session.as_deref_mut(),
)
.await;
match outcome {
Ok(CompactOutcome::Done { summarized, kept, .. }) => {
let _ = sink.send(AgentEvent::Compaction { summarized, kept });
}
Ok(CompactOutcome::NothingToDo) => {}
Err(reason) => {
let _ = sink.send(AgentEvent::CompactionSkipped { reason });
}
}
}
}
let _ = cfg.ctx_snapshot.send_replace(ctx.clone());
let mut content: Vec<ContentBlock> = Vec::new();
let mut captured: Option<StopReason> = None;
let mut usage: Option<Usage> = None;
let mut stream_error: Option<String> = None;
if sink
.send(AgentEvent::MessageStart {
message: assistant(&content, StopReason::Stop, None, &model),
})
.is_err()
{
aborted = true;
}
if !aborted && cfg.cancel.is_cancelled() {
aborted = true;
}
if !aborted {
let mut stream = (cfg.stream_fn)(ctx.as_slice(), &cfg.system, &tool_defs, &cfg.llm);
let idle = cfg.llm.retry.idle;
loop {
tokio::select! {
biased;
_ = cfg.cancel.cancelled() => {
aborted = true;
break;
}
item = stream.next() => match item {
None => break,
Some(LlmStreamEvent::TextDelta(delta)) => {
append_text(&mut content, &delta);
if sink
.send(AgentEvent::MessageUpdate {
message: assistant(&content, StopReason::Stop, None, &model),
})
.is_err()
{
aborted = true;
break;
}
}
Some(LlmStreamEvent::ThinkingDelta(delta)) => {
append_thinking(&mut content, &delta);
if sink
.send(AgentEvent::MessageUpdate {
message: assistant(&content, StopReason::Stop, None, &model),
})
.is_err()
{
aborted = true;
break;
}
}
Some(LlmStreamEvent::ThinkingReplace(delta)) => {
replace_thinking(&mut content, &delta);
if sink
.send(AgentEvent::MessageUpdate {
message: assistant(&content, StopReason::Stop, None, &model),
})
.is_err()
{
aborted = true;
break;
}
}
Some(LlmStreamEvent::ToolCall { id, name, arguments }) => {
content.push(ContentBlock::ToolCall { id, name, arguments });
if sink
.send(AgentEvent::MessageUpdate {
message: assistant(&content, StopReason::Stop, None, &model),
})
.is_err()
{
aborted = true;
break;
}
}
Some(LlmStreamEvent::Done { stop_reason, usage: u }) => {
captured = Some(stop_reason);
usage = u;
break;
}
Some(LlmStreamEvent::Retrying { attempt, max, reason }) => {
let _ = sink.send(AgentEvent::Retrying { attempt, max, reason });
}
Some(LlmStreamEvent::Error { message, fatal }) => {
let _ = sink.send(AgentEvent::Error { message: message.clone() });
if fatal {
captured = Some(StopReason::Error);
} else {
stream_error = Some(message);
captured = Some(StopReason::Error);
}
break;
}
},
_ = tokio::time::sleep(idle), if !idle.is_zero() => {
let message = format!("stream stalled {idle:?}");
let _ = sink.send(AgentEvent::Error { message: message.clone() });
stream_error = Some(message);
captured = Some(StopReason::Error);
break;
}
}
}
}
if !aborted
&& captured == Some(StopReason::ToolUse)
&& !content
.iter()
.any(|b| matches!(b, ContentBlock::ToolCall { .. }))
{
let message = "model requested tool use but no tool call was streamed".to_string();
let _ = sink.send(AgentEvent::Error { message: message.clone() });
stream_error = Some(message);
captured = Some(StopReason::Error);
}
if !aborted && stream_error.is_none() && captured.is_none() {
let message = "stream ended without a terminal record".to_string();
let _ = sink.send(AgentEvent::Error { message: message.clone() });
stream_error = Some(message);
captured = Some(StopReason::Error);
}
let stop = if aborted {
StopReason::Aborted
} else {
captured.unwrap_or(StopReason::Stop)
};
let assistant = assistant(&content, stop, usage, &model);
if sink
.send(AgentEvent::MessageEnd {
message: assistant.clone(),
})
.is_err()
{
aborted = true;
}
if !content.is_empty() {
record_session(&mut cfg, &assistant)?;
ctx.push(assistant.clone());
}
if sink
.send(AgentEvent::TurnEnd {
message: assistant.clone(),
})
.is_err()
{
aborted = true;
}
if !title_tried
&& !aborted
&& !matches!(stop, StopReason::Aborted | StopReason::Error)
&& assistant_turns(ctx) >= cfg.title_after_turns
{
title_tried = true;
maybe_title(&mut cfg, ctx, &sink).await;
}
let calls: Vec<(String, String, serde_json::Value)> = assistant
.tool_calls()
.iter()
.filter_map(|b| match b {
ContentBlock::ToolCall {
id,
name,
arguments,
} => Some((id.clone(), name.clone(), arguments.clone())),
_ => None,
})
.collect();
if aborted || matches!(captured, Some(StopReason::Aborted)) {
if let Err(e) = synthesize_unexecuted(&mut cfg, &sink, ctx, calls.into_iter()) {
let _ = sink.send(AgentEvent::AgentEnd);
return Err(e);
}
let _ = sink.send(AgentEvent::AgentEnd);
return finish(StopReason::Aborted, cfg.steering, cfg.follow_ups);
}
if matches!(captured, Some(StopReason::Error)) && stream_error.is_none() {
if let Err(e) = synthesize_unexecuted(&mut cfg, &sink, ctx, calls.into_iter()) {
let _ = sink.send(AgentEvent::AgentEnd);
return Err(e);
}
let _ = sink.send(AgentEvent::AgentEnd);
return finish(StopReason::Error, cfg.steering, cfg.follow_ups);
}
if let Some(err_msg) = stream_error {
consecutive_stream_errors += 1;
if let Err(e) = synthesize_unexecuted(&mut cfg, &sink, ctx, calls.into_iter()) {
let _ = sink.send(AgentEvent::AgentEnd);
return Err(e);
}
if consecutive_stream_errors > DEFAULT_MAX_STREAM_ERROR_TURNS {
let _ = sink.send(AgentEvent::AgentEnd);
return finish(StopReason::Error, cfg.steering, cfg.follow_ups);
}
let notice = AgentMessage::user_text(format!("your previous response failed: {err_msg}"));
record_session(&mut cfg, ¬ice)?;
ctx.push(notice);
continue;
}
consecutive_stream_errors = 0;
if calls.is_empty() {
if cfg.hooks.should_stop_after_turn(ctx).await {
let _ = sink.send(AgentEvent::AgentEnd);
return finish(StopReason::Stop, cfg.steering, cfg.follow_ups);
}
break; }
let all_calls = calls.clone();
let mut plan: Vec<Planned> = Vec::with_capacity(calls.len());
for (id, name, mut arguments) in calls {
let mut hook_call = HookToolCall {
id: id.clone(),
name: name.clone(),
arguments: arguments.clone(),
};
cfg.hooks.transform_tool_input(&mut hook_call).await;
arguments = hook_call.arguments.clone();
let planned = match cfg.hooks.before_tool_call(&hook_call).await {
Some(reason) => Planned::fail(id, name, format!("blocked: {reason}")),
None => match cfg.tools.iter().find(|t| t.name() == name).cloned() {
Some(tool) => {
let parallel_safe = cfg.parallel && tool.parallel_safe();
Planned {
id,
name,
run: Some(PlannedRun { tool, arguments }),
fail: None,
parallel_safe,
}
}
None => {
let msg = format!("unknown tool: {name}");
Planned::fail(id, name, msg)
}
},
};
plan.push(planned);
}
let mut groups: Vec<Vec<Planned>> = Vec::new();
let mut run: Vec<Planned> = Vec::new();
for p in plan {
if p.parallel_safe {
run.push(p);
} else {
if !run.is_empty() {
groups.push(std::mem::take(&mut run));
}
groups.push(vec![p]);
}
}
if !run.is_empty() {
groups.push(run);
}
let mut processed = 0usize;
for group in groups {
let width = group.len();
let mut started = vec![false; width];
let mut results: Vec<Option<ToolOutput>> = (0..width).map(|_| None).collect();
let mut durations: Vec<Option<u64>> = (0..width).map(|_| None).collect();
for (i, c) in group.iter().enumerate() {
let sent = sink
.send(AgentEvent::ToolExecutionStart {
call_id: c.id.clone(),
name: c.name.clone(),
})
.is_ok();
started[i] = sent;
if !sent {
results[i] = Some(ToolOutput {
output: "aborted before execution".to_string(),
is_error: true,
diff: None,
path: None,
});
}
}
let mut running = Vec::new();
let session_path = cfg
.session
.as_deref()
.and_then(|s| s.path())
.map(std::path::Path::to_path_buf);
for (i, c) in group.iter().enumerate() {
if !started[i] {
continue;
}
let Some(run) = &c.run else {
results[i] = c.fail.clone();
continue;
};
let tool = run.tool.clone();
let arguments = run.arguments.clone();
let hook_call = HookToolCall {
id: c.id.clone(),
name: c.name.clone(),
arguments: arguments.clone(),
};
let tctx = ToolContext {
call_id: c.id.clone(),
name: c.name.clone(),
working_dir: cfg.working_dir.clone(),
cancel: cfg.cancel.clone(),
events: sink.clone(),
session_path: session_path.clone(),
};
let hooks = cfg.hooks.clone();
let cancel = cfg.cancel.clone();
running.push(async move {
let started = std::time::Instant::now();
let mut out = tokio::select! {
biased;
out = tool.execute(arguments, tctx) => out,
_ = cancel.cancelled() => ToolOutput {
output: "aborted: cancelled before the tool returned"
.to_string(),
is_error: true,
diff: None,
path: None,
},
};
hooks.after_tool_call(&hook_call, &mut out).await;
let duration_ms = started.elapsed().as_millis() as u64;
(i, out, duration_ms)
});
}
for (i, out, duration_ms) in futures::stream::iter(running)
.buffer_unordered(MAX_PARALLEL_TOOLS)
.collect::<Vec<_>>()
.await
{
results[i] = Some(out);
durations[i] = Some(duration_ms);
}
for (i, c) in group.iter().enumerate() {
let out = results[i].take().unwrap_or_else(|| ToolOutput {
output: "aborted before execution".to_string(),
is_error: true,
diff: None,
path: None,
});
let sent = started[i]
&& sink
.send(AgentEvent::ToolExecutionEnd {
call_id: c.id.clone(),
name: c.name.clone(),
output: out.output.clone(),
is_error: out.is_error,
diff: out.diff.clone(),
path: out.path.clone(),
duration_ms: durations[i],
})
.is_ok();
let result = AgentMessage::ToolResult {
tool_call_id: c.id.clone(),
name: c.name.clone(),
output: out.output,
is_error: out.is_error,
};
record_session(&mut cfg, &result)?;
ctx.push(result);
if !sent {
aborted = true;
}
}
processed += width;
if aborted {
let rest = all_calls.iter().skip(processed).cloned();
if let Err(e) = synthesize_unexecuted(&mut cfg, &sink, ctx, rest) {
let _ = sink.send(AgentEvent::AgentEnd);
return Err(e);
}
break;
}
}
if cfg.cancel.is_cancelled() {
aborted = true;
}
if aborted {
let _ = sink.send(AgentEvent::AgentEnd);
return finish(StopReason::Aborted, cfg.steering, cfg.follow_ups);
}
if cfg.hooks.should_stop_after_turn(ctx).await {
let _ = sink.send(AgentEvent::AgentEnd);
return finish(StopReason::Stop, cfg.steering, cfg.follow_ups);
}
}
match next_injected(&mut cfg) {
Some(m) => {
if sink
.send(AgentEvent::MessageStart { message: m.clone() })
.is_err()
{
let _ = sink.send(AgentEvent::AgentEnd);
return finish(StopReason::Aborted, cfg.steering, cfg.follow_ups);
}
record_session(&mut cfg, &m)?;
ctx.push(m.clone());
if sink.send(AgentEvent::MessageEnd { message: m }).is_err() {
let _ = sink.send(AgentEvent::AgentEnd);
return finish(StopReason::Aborted, cfg.steering, cfg.follow_ups);
}
continue 'outer;
}
None => {
let _ = sink.send(AgentEvent::AgentEnd);
return finish(StopReason::Stop, cfg.steering, cfg.follow_ups);
}
}
}
}
fn assistant(
content: &[ContentBlock],
stop_reason: StopReason,
usage: Option<Usage>,
model: &str,
) -> AgentMessage {
AgentMessage::Assistant {
content: content.to_vec(),
stop_reason,
usage,
model: (!model.is_empty()).then(|| model.to_string()),
}
}
struct Planned {
id: String,
name: String,
run: Option<PlannedRun>,
fail: Option<ToolOutput>,
parallel_safe: bool,
}
impl Planned {
fn fail(id: String, name: String, output: String) -> Self {
Planned {
id,
name,
run: None,
fail: Some(ToolOutput {
output,
is_error: true,
diff: None,
path: None,
}),
parallel_safe: false,
}
}
}
struct PlannedRun {
tool: Tool,
arguments: serde_json::Value,
}
fn synthesize_unexecuted(
cfg: &mut LoopConfig<'_>,
sink: &tokio::sync::mpsc::UnboundedSender<AgentEvent>,
ctx: &mut Vec<AgentMessage>,
calls: impl Iterator<Item = (String, String, serde_json::Value)>,
) -> Result<(), LoopError> {
for (rid, rname, _) in calls {
let out = ToolOutput {
output: "aborted before execution".to_string(),
is_error: true,
diff: None,
path: None,
};
let _ = sink.send(AgentEvent::ToolExecutionEnd {
call_id: rid.clone(),
name: rname.clone(),
output: out.output.clone(),
is_error: out.is_error,
diff: out.diff.clone(),
path: out.path.clone(),
duration_ms: None,
});
let result = AgentMessage::ToolResult {
tool_call_id: rid,
name: rname,
output: out.output,
is_error: out.is_error,
};
record_session(cfg, &result)?;
ctx.push(result);
}
Ok(())
}
fn record_session(cfg: &mut LoopConfig<'_>, msg: &AgentMessage) -> Result<(), LoopError> {
let Some(session) = cfg.session.as_deref_mut() else {
return Ok(());
};
session
.append(SessionEntry::Message {
id: uuid::Uuid::new_v4().to_string(),
parent_id: None,
message: msg.clone(),
})
.map_err(LoopError::Session)
}
fn assistant_turns(ctx: &[AgentMessage]) -> usize {
ctx.iter()
.filter(|m| matches!(m, AgentMessage::Assistant { .. }))
.count()
}
async fn maybe_title(
cfg: &mut LoopConfig<'_>,
ctx: &[AgentMessage],
sink: &tokio::sync::mpsc::UnboundedSender<AgentEvent>,
) {
if cfg.title_after_turns == 0 {
return;
}
let Some(session) = cfg.session.as_deref() else {
return;
};
if session.title().is_some() {
return;
}
if assistant_turns(ctx) < cfg.title_after_turns {
return;
}
let mut title_llm = cfg.llm.clone();
title_llm.effort = None;
let Ok(title) = title::generate(&cfg.stream_fn, &title_llm, ctx).await else {
return;
};
let Some(session) = cfg.session.as_deref_mut() else {
return;
};
if session
.append(SessionEntry::Title {
id: uuid::Uuid::new_v4().to_string(),
title: title.clone(),
})
.is_err()
{
return;
}
let _ = sink.send(AgentEvent::SessionTitle { title });
}
fn last_input_tokens(ctx: &[AgentMessage]) -> Option<u64> {
ctx.iter().rev().find_map(|m| match m {
AgentMessage::Assistant { usage: Some(u), .. } => Some(u.input_tokens),
_ => None,
})
}
fn append_text(content: &mut Vec<ContentBlock>, delta: &str) {
match content.last_mut() {
Some(ContentBlock::Text { text }) => text.push_str(delta),
_ => content.push(ContentBlock::Text {
text: delta.to_string(),
}),
}
}
fn append_thinking(content: &mut Vec<ContentBlock>, delta: &str) {
match content.last_mut() {
Some(ContentBlock::Thinking { text }) => text.push_str(delta),
_ => content.push(ContentBlock::Thinking {
text: delta.to_string(),
}),
}
}
fn replace_thinking(content: &mut Vec<ContentBlock>, text: &str) {
content.retain(|b| !matches!(b, ContentBlock::Thinking { .. }));
content.push(ContentBlock::Thinking {
text: text.to_string(),
});
}
#[cfg(test)]
mod cancel_in_tool_tests {
use super::*;
use std::time::Duration;
use async_trait::async_trait;
use tokio::sync::mpsc;
use crate::tool::{TypedTool, erased};
#[derive(serde::Deserialize, schemars::JsonSchema)]
struct NoArgs {}
fn stream_of_calls(calls: Vec<(&'static str, &'static str)>) -> StreamFn {
std::sync::Arc::new(move |_ctx, _system, _tools, _opts| {
let mut events: Vec<LlmStreamEvent> = calls
.iter()
.map(|(id, name)| LlmStreamEvent::ToolCall {
id: (*id).to_string(),
name: (*name).to_string(),
arguments: serde_json::json!({}),
})
.collect();
events.push(LlmStreamEvent::Done {
stop_reason: StopReason::ToolUse,
usage: None,
});
Box::pin(futures::stream::iter(events))
})
}
#[derive(Clone)]
struct SlowTool {
started: mpsc::UnboundedSender<()>,
}
#[async_trait]
impl TypedTool for SlowTool {
type Args = NoArgs;
fn name(&self) -> &str {
"slow"
}
fn description(&self) -> &str {
"sleeps for an hour, ignoring cancel"
}
async fn execute(&self, _args: NoArgs, _ctx: &ToolContext) -> ToolOutput {
let _ = self.started.send(());
tokio::time::sleep(Duration::from_secs(3600)).await;
ToolOutput {
output: "slow finished".to_string(),
is_error: false,
..ToolOutput::default()
}
}
}
#[derive(Clone)]
struct WatcherTool {
started: mpsc::UnboundedSender<()>,
}
#[async_trait]
impl TypedTool for WatcherTool {
type Args = NoArgs;
fn name(&self) -> &str {
"watcher"
}
fn description(&self) -> &str {
"returns its own output once cancel fires"
}
async fn execute(&self, _args: NoArgs, ctx: &ToolContext) -> ToolOutput {
let _ = self.started.send(());
ctx.cancel.cancelled().await;
ToolOutput {
output: "watcher saw cancel".to_string(),
is_error: false,
..ToolOutput::default()
}
}
}
fn config(tool: Tool, cancel: CancellationToken, stream_fn: StreamFn) -> LoopConfig<'static> {
let (_steer_tx, steering) = mpsc::unbounded_channel();
let (_follow_tx, follow_ups) = mpsc::unbounded_channel();
let (ctx_snapshot, _ctx_rx) = tokio::sync::watch::channel(Vec::new());
LoopConfig {
system: String::new(),
tools: vec![tool],
llm: LlmOpts::default(),
stream_fn,
hooks: HooksSet::default(),
steering,
follow_ups,
cancel,
working_dir: std::env::temp_dir(),
max_turns: DEFAULT_MAX_TURNS,
parallel: false,
compaction: CompactionPolicy::default(),
ctx_snapshot,
session: None,
title_after_turns: 0,
}
}
fn cancel_on_start(
token: CancellationToken,
mut started_rx: mpsc::UnboundedReceiver<()>,
) {
let worker = token.clone();
tokio::spawn(async move {
let _ = started_rx.recv().await;
worker.cancel();
});
}
fn tool_result(ctx: &[AgentMessage]) -> (String, bool, String) {
ctx.iter()
.find_map(|m| match m {
AgentMessage::ToolResult {
tool_call_id,
output,
is_error,
..
} => Some((tool_call_id.clone(), *is_error, output.clone())),
_ => None,
})
.expect("the aborted call must still push a ToolResult")
}
#[tokio::test]
async fn a_cancel_aborts_a_running_tool() {
let cancel = CancellationToken::new();
let (started_tx, started_rx) = mpsc::unbounded_channel();
cancel_on_start(cancel.clone(), started_rx);
let mut ctx: Vec<AgentMessage> = Vec::new();
let cfg = config(
erased(SlowTool { started: started_tx }),
cancel,
stream_of_calls(vec![("c1", "slow")]),
);
let (sink, _events) = mpsc::unbounded_channel();
let run = tokio::time::timeout(Duration::from_secs(5), run_loop(&mut ctx, cfg, sink))
.await
.expect("run_loop must abort a stuck tool, not hang");
assert_eq!(run.unwrap().stop_reason, StopReason::Aborted);
}
#[tokio::test]
async fn the_aborted_result_is_recorded_in_ctx() {
let cancel = CancellationToken::new();
let (started_tx, started_rx) = mpsc::unbounded_channel();
cancel_on_start(cancel.clone(), started_rx);
let mut ctx: Vec<AgentMessage> = Vec::new();
let cfg = config(
erased(SlowTool { started: started_tx }),
cancel,
stream_of_calls(vec![("c1", "slow")]),
);
let (sink, _events) = mpsc::unbounded_channel();
let _ = tokio::time::timeout(Duration::from_secs(5), run_loop(&mut ctx, cfg, sink))
.await
.expect("run_loop must abort a stuck tool, not hang");
let (id, is_error, output) = tool_result(&ctx);
assert_eq!(id, "c1");
assert!(is_error, "the aborted result is an error ToolResult");
assert_eq!(output, "aborted: cancelled before the tool returned");
}
#[tokio::test]
async fn a_cancel_watching_tool_keeps_its_own_output() {
let cancel = CancellationToken::new();
let (started_tx, started_rx) = mpsc::unbounded_channel();
cancel_on_start(cancel.clone(), started_rx);
let mut ctx: Vec<AgentMessage> = Vec::new();
let cfg = config(
erased(WatcherTool { started: started_tx }),
cancel,
stream_of_calls(vec![("c1", "watcher")]),
);
let (sink, _events) = mpsc::unbounded_channel();
let run = tokio::time::timeout(Duration::from_secs(5), run_loop(&mut ctx, cfg, sink))
.await
.expect("run_loop must not hang");
assert_eq!(run.unwrap().stop_reason, StopReason::Aborted);
let (_id, is_error, output) = tool_result(&ctx);
assert_eq!(
output, "watcher saw cancel",
"the tool's OWN output wins under biased"
);
assert!(!is_error);
}
}