wcode_harness/loop_.rs
1use futures::StreamExt;
2use tokio_util::sync::CancellationToken;
3
4use crate::compaction::{self, CompactOutcome, CompactionPolicy};
5use crate::event::{AgentEvent, LlmStreamEvent};
6use crate::hooks::{HooksSet, ToolCall as HookToolCall};
7use crate::message::{AgentMessage, ContentBlock, StopReason, Usage};
8use crate::session::{Session, SessionEntry};
9use crate::streamfn::{LlmOpts, StreamFn};
10use crate::tool::{Tool, ToolContext, ToolOutput};
11
12/// Default per-run turn cap for [`LoopConfig::max_turns`]. Generous enough for
13/// long agentic tasks, low enough that a runaway tool loop can't burn tokens
14/// unbounded. Unlike cancellation, hitting it is a clean, resumable stop.
15pub const DEFAULT_MAX_TURNS: usize = 100;
16
17/// Cap on *consecutive* turns that ended in a fed-back stream error. A
18/// transient-class stream failure no longer kills the run — the loop feeds the
19/// error back to the model and retries the turn — but a persistently broken
20/// request (or a model that cannot recover) must still terminate. After this
21/// many consecutive fed-back error turns the run ends with `StopReason::Error`
22/// exactly as it did before. Any turn that completes cleanly resets the count.
23pub const DEFAULT_MAX_STREAM_ERROR_TURNS: usize = 3;
24
25/// Cap on tools executing concurrently inside one group. The model chooses the
26/// batch size, but a runaway batch should not open unbounded file handles.
27const MAX_PARALLEL_TOOLS: usize = 8;
28
29pub struct LoopConfig<'a> {
30 pub system: String,
31 pub tools: Vec<Tool>,
32 pub llm: LlmOpts,
33 pub stream_fn: StreamFn,
34 pub hooks: HooksSet,
35 pub steering: tokio::sync::mpsc::UnboundedReceiver<AgentMessage>,
36 pub follow_ups: tokio::sync::mpsc::UnboundedReceiver<AgentMessage>,
37 pub cancel: CancellationToken,
38 /// Working directory for tool execution (bash cwd, file resolution).
39 /// Plumbed explicitly so embeddings can run against a directory other
40 /// than the process cwd; an empty path falls back to `current_dir()`.
41 pub working_dir: std::path::PathBuf,
42 /// Maximum number of LLM turns in a single run. A turn is one model call
43 /// plus the tool calls it produces. Caps a runaway tool loop (the model
44 /// re-issuing tool calls forever) that would otherwise run until cancelled;
45 /// the run ends on a completed turn boundary with `StopReason::MaxTurns`.
46 /// Counts every turn in the run, including follow-up-triggered ones.
47 pub max_turns: usize,
48 /// Run concurrency-safe tool calls in one batch in parallel (see
49 /// [`crate::tool::TypedTool::parallel_safe`]). `false` forces the old
50 /// strictly-sequential behaviour.
51 pub parallel: bool,
52 /// When to auto-summarize the older prefix, and how much recent context to
53 /// keep (see [`crate::compaction`]).
54 pub compaction: CompactionPolicy,
55 /// A read-only window onto the run's context. The loop republishes the whole
56 /// `ctx` at each turn boundary (see `run_loop`) so the actor can answer a
57 /// mid-run `GetHistory`/`SideAsk` without the `&mut` the run holds.
58 pub ctx_snapshot: tokio::sync::watch::Sender<Vec<AgentMessage>>,
59 /// Optional session to persist each produced message to as it is pushed
60 /// into context — incremental, so a crash mid-run loses at most the
61 /// in-flight message instead of the whole turn.
62 pub session: Option<&'a mut Session>,
63}
64
65#[derive(Debug, thiserror::Error)]
66pub enum LoopError {
67 #[error(transparent)]
68 Session(std::io::Error), // session append failure while persisting produced messages
69 /// A side-call (`Agent::side_ask`) failure: a stream error, or a stream that
70 /// produced no text. Not produced by `run_loop` (which surfaces a stream
71 /// failure as `AgentEvent::Error`).
72 #[error("{0}")]
73 Stream(String),
74}
75
76/// Return value of [`run_loop`]: the stop reason plus the two input channels
77/// that were lent in, handed back so messages queued but never drained (e.g. a
78/// follow-up sent while the loop was on its final turn) survive the run
79/// boundary instead of being dropped when the caller re-pairs channels.
80#[derive(Debug)]
81pub struct RunResult {
82 pub stop_reason: StopReason,
83 pub steering: tokio::sync::mpsc::UnboundedReceiver<AgentMessage>,
84 pub follow_ups: tokio::sync::mpsc::UnboundedReceiver<AgentMessage>,
85}
86
87/// Wrap a normal run exit together with the lent-in channels.
88fn finish(
89 stop_reason: StopReason,
90 steering: tokio::sync::mpsc::UnboundedReceiver<AgentMessage>,
91 follow_ups: tokio::sync::mpsc::UnboundedReceiver<AgentMessage>,
92) -> Result<RunResult, LoopError> {
93 Ok(RunResult {
94 stop_reason,
95 steering,
96 follow_ups,
97 })
98}
99
100/// The next message to inject at the outer tail: a queued follow-up first
101/// (preserving the original behavior), else a queued steer (soft interrupt,
102/// point B). One per outer iteration; `continue 'outer` re-runs the turn-start
103/// drain, which picks up any remainder.
104fn next_injected(cfg: &mut LoopConfig<'_>) -> Option<AgentMessage> {
105 cfg.follow_ups
106 .try_recv()
107 .ok()
108 .or_else(|| cfg.steering.try_recv().ok())
109}
110
111pub async fn run_loop(
112 ctx: &mut Vec<AgentMessage>,
113 mut cfg: LoopConfig<'_>,
114 sink: tokio::sync::mpsc::UnboundedSender<AgentEvent>,
115) -> Result<RunResult, LoopError> {
116 let tool_defs: Vec<rig::completion::ToolDefinition> =
117 cfg.tools.iter().map(|t| t.definition()).collect();
118 // The model producing this run's assistant messages, stamped on each for
119 // provenance. The session records it; the wire converter ignores it.
120 let model = cfg.llm.model.clone();
121
122 if sink.send(AgentEvent::AgentStart).is_err() {
123 // Consumer gone before the run started: nothing to finalize.
124 return finish(StopReason::Aborted, cfg.steering, cfg.follow_ups);
125 }
126
127 // Turns used so far, checked against `cfg.max_turns` at each turn start.
128 let mut turns: usize = 0;
129 // Consecutive fed-back stream-error turns; see
130 // [`DEFAULT_MAX_STREAM_ERROR_TURNS`]. Reset by any cleanly-completed turn.
131 let mut consecutive_stream_errors: usize = 0;
132 'outer: loop {
133 loop {
134 // Turn budget: caps a runaway tool loop (the model re-issuing tool
135 // calls forever). Checked before a turn starts, so the run ends on a
136 // completed turn boundary — the final turn's ToolResults are already
137 // in ctx and no ToolCall is left unpaired. Counts every turn in the
138 // run, including follow-up-triggered ones.
139 if turns >= cfg.max_turns {
140 let _ = sink.send(AgentEvent::AgentEnd);
141 return finish(StopReason::MaxTurns, cfg.steering, cfg.follow_ups);
142 }
143 turns += 1;
144 let mut aborted = false;
145 if sink.send(AgentEvent::TurnStart).is_err() {
146 aborted = true;
147 }
148
149 while let Ok(msg) = cfg.steering.try_recv() {
150 if sink
151 .send(AgentEvent::MessageStart {
152 message: msg.clone(),
153 })
154 .is_err()
155 {
156 aborted = true;
157 break;
158 }
159 record_session(&mut cfg, &msg)?;
160 ctx.push(msg.clone());
161 if sink.send(AgentEvent::MessageEnd { message: msg }).is_err() {
162 aborted = true;
163 break;
164 }
165 }
166 cfg.hooks.transform_context(ctx).await;
167
168 // Auto-compaction: when the last provider-reported context size is
169 // near the ceiling, summarize the older prefix before this turn's
170 // request. Best-effort — a summarizer failure must not abort a run
171 // that can still proceed (the request itself surfaces real errors).
172 if let Some(used) = last_input_tokens(ctx) {
173 let window = cfg
174 .compaction
175 .context_window(cfg.llm.base_url.as_deref(), &cfg.llm.model);
176 if cfg.compaction.should_compact(window, used) {
177 let outcome = compaction::compact_ctx(
178 ctx,
179 &cfg.compaction,
180 &cfg.stream_fn,
181 &cfg.llm,
182 None,
183 cfg.session.as_deref_mut(),
184 )
185 .await;
186 match outcome {
187 Ok(CompactOutcome::Done { summarized, kept, .. }) => {
188 let _ = sink.send(AgentEvent::Compaction { summarized, kept });
189 }
190 // Nothing to summarize is the quiet happy path — no event.
191 Ok(CompactOutcome::NothingToDo) => {}
192 // Best-effort: report the miss and keep the run going.
193 // Deliberately NOT `AgentEvent::Error` — that would trip
194 // the TUI `failed` state and the repl run-error line for a
195 // run that can still proceed.
196 Err(reason) => {
197 let _ = sink.send(AgentEvent::CompactionSkipped { reason });
198 }
199 }
200 }
201 }
202
203 // Turn boundary: republish the prepared context so a mid-run read
204 // (`GetHistory`/`SideAsk`) sees the latest completed turns, not the
205 // run's start — the loop is the only holder of `ctx`.
206 let _ = cfg.ctx_snapshot.send_replace(ctx.clone());
207
208 let mut content: Vec<ContentBlock> = Vec::new();
209 let mut captured: Option<StopReason> = None;
210 let mut usage: Option<Usage> = None;
211 // A final stream failure that is *feedable* (transient class that
212 // exhausted its retries, a mid-stream error, or the tool-use-with-
213 // no-call finish). When set, the turn's error is written back into
214 // ctx so the model can fix it, and the loop continues below.
215 let mut stream_error: Option<String> = None;
216
217 if sink
218 .send(AgentEvent::MessageStart {
219 message: assistant(&content, StopReason::Stop, None, &model),
220 })
221 .is_err()
222 {
223 aborted = true;
224 }
225
226 // Skip the LLM call entirely when the sink is already dead, or when a
227 // cancel landed between the post-tool check and this turn start —
228 // minting the request only to abort it on the first poll of the
229 // biased select would waste one LLM round-trip for nothing.
230 if !aborted && cfg.cancel.is_cancelled() {
231 aborted = true;
232 }
233
234 if !aborted {
235 let mut stream = (cfg.stream_fn)(ctx.as_slice(), &cfg.system, &tool_defs, &cfg.llm);
236 // The stall backstop's deadline, captured once (ZERO disables).
237 let idle = cfg.llm.retry.idle;
238 loop {
239 tokio::select! {
240 biased;
241 _ = cfg.cancel.cancelled() => {
242 aborted = true;
243 break;
244 }
245 item = stream.next() => match item {
246 None => break,
247 Some(LlmStreamEvent::TextDelta(delta)) => {
248 append_text(&mut content, &delta);
249 if sink
250 .send(AgentEvent::MessageUpdate {
251 message: assistant(&content, StopReason::Stop, None, &model),
252 })
253 .is_err()
254 {
255 aborted = true;
256 break;
257 }
258 }
259 Some(LlmStreamEvent::ThinkingDelta(delta)) => {
260 append_thinking(&mut content, &delta);
261 if sink
262 .send(AgentEvent::MessageUpdate {
263 message: assistant(&content, StopReason::Stop, None, &model),
264 })
265 .is_err()
266 {
267 aborted = true;
268 break;
269 }
270 }
271 Some(LlmStreamEvent::ThinkingReplace(delta)) => {
272 replace_thinking(&mut content, &delta);
273 if sink
274 .send(AgentEvent::MessageUpdate {
275 message: assistant(&content, StopReason::Stop, None, &model),
276 })
277 .is_err()
278 {
279 aborted = true;
280 break;
281 }
282 }
283 Some(LlmStreamEvent::ToolCall { id, name, arguments }) => {
284 content.push(ContentBlock::ToolCall { id, name, arguments });
285 if sink
286 .send(AgentEvent::MessageUpdate {
287 message: assistant(&content, StopReason::Stop, None, &model),
288 })
289 .is_err()
290 {
291 aborted = true;
292 break;
293 }
294 }
295 Some(LlmStreamEvent::Done { stop_reason, usage: u }) => {
296 captured = Some(stop_reason);
297 usage = u;
298 // Done ends the fold: a stalled stream must not
299 // hang the run or overwrite the captured reason.
300 break;
301 }
302 // A stream Error is surfaced first so consumers can show it. A
303 // fatal-class failure ends the run; a transient-class one that
304 // exhausted its retries (or landed mid-stream) is fed back below.
305 Some(LlmStreamEvent::Retrying { attempt, max, reason }) => {
306 let _ = sink.send(AgentEvent::Retrying { attempt, max, reason });
307 }
308 Some(LlmStreamEvent::Error { message, fatal }) => {
309 let _ = sink.send(AgentEvent::Error { message: message.clone() });
310 // Hard-fatal (bad request/auth/schema): retrying or re-feeding
311 // cannot help — end the run. Transient-class (retries exhausted
312 // or mid-stream after content): feed back and continue below.
313 if fatal {
314 captured = Some(StopReason::Error);
315 } else {
316 stream_error = Some(message);
317 captured = Some(StopReason::Error);
318 }
319 break;
320 }
321 },
322 // Kernel backstop: a `StreamFn` that never yields must
323 // not wedge the run. Feed the failure back — the same
324 // corrective-turn path as a stream error, bounded by
325 // DEFAULT_MAX_STREAM_ERROR_TURNS. `idle == ZERO` disables.
326 _ = tokio::time::sleep(idle), if !idle.is_zero() => {
327 let message = format!("stream stalled {idle:?}");
328 let _ = sink.send(AgentEvent::Error { message: message.clone() });
329 stream_error = Some(message);
330 captured = Some(StopReason::Error);
331 break;
332 }
333 }
334 }
335 }
336
337 // Finding #3: a tool-use finish that never streamed a tool call
338 // means the model's requested action was lost. rig only delivers
339 // the reassembled call on ToolInputEnd (deltas are dropped); a
340 // provider that ends input without closing it has no call to emit,
341 // so the call would vanish silently. Feed it back loudly instead of
342 // silently ending: the model sees the failure and can re-issue the
343 // call on the next turn.
344 if !aborted
345 && captured == Some(StopReason::ToolUse)
346 && !content
347 .iter()
348 .any(|b| matches!(b, ContentBlock::ToolCall { .. }))
349 {
350 let message = "model requested tool use but no tool call was streamed".to_string();
351 let _ = sink.send(AgentEvent::Error { message: message.clone() });
352 stream_error = Some(message);
353 captured = Some(StopReason::Error);
354 }
355
356 // Layer 2 kernel backstop: a `StreamFn` that ends a turn with no
357 // terminal event at all — no `Done`, no `Error` — is a truncated turn.
358 // The adapter (Layer 1) already synthesizes an error for the rig wire,
359 // but a custom `StreamFn` (a test double, an embedder's provider) can
360 // still reach here as a clean stop. Feed it back through the same
361 // corrective-turn path as a stream error (bounded by
362 // DEFAULT_MAX_STREAM_ERROR_TURNS) instead of silently stopping.
363 //
364 // Fires only when the select ended with NO terminal event: `captured`
365 // is None and `stream_error` is None. Setting `stream_error` to a
366 // non-None note deliberately bypasses the hard-fatal early-return below.
367 if !aborted && stream_error.is_none() && captured.is_none() {
368 let message = "stream ended without a terminal record".to_string();
369 let _ = sink.send(AgentEvent::Error { message: message.clone() });
370 stream_error = Some(message);
371 captured = Some(StopReason::Error);
372 }
373 // Cancellation finalizes the partial assistant as Aborted.
374 let stop = if aborted {
375 StopReason::Aborted
376 } else {
377 captured.unwrap_or(StopReason::Stop)
378 };
379 let assistant = assistant(&content, stop, usage, &model);
380 if sink
381 .send(AgentEvent::MessageEnd {
382 message: assistant.clone(),
383 })
384 .is_err()
385 {
386 aborted = true;
387 }
388 // Don't persist an empty assistant. An abort/error that lands
389 // before the model produced anything (dead sink, cancel before the
390 // first delta, stream error on the first item) leaves nothing worth
391 // keeping in ctx or the session; `to_rig_message` drops empty
392 // assistant content at the wire anyway. Partial messages (content
393 // streamed before the abort) are still recorded. Event sends above
394 // stay unconditional so the consumer's message framing stays
395 // balanced — MessageStart was already emitted.
396 if !content.is_empty() {
397 record_session(&mut cfg, &assistant)?;
398 ctx.push(assistant.clone());
399 }
400 if sink
401 .send(AgentEvent::TurnEnd {
402 message: assistant.clone(),
403 })
404 .is_err()
405 {
406 aborted = true;
407 }
408
409 let calls: Vec<(String, String, serde_json::Value)> = assistant
410 .tool_calls()
411 .iter()
412 .filter_map(|b| match b {
413 ContentBlock::ToolCall {
414 id,
415 name,
416 arguments,
417 } => Some((id.clone(), name.clone(), arguments.clone())),
418 _ => None,
419 })
420 .collect();
421
422 // Aborts and hard-fatal errors are values: end the run even if
423 // tool calls exist. Every unexecuted call first gets a synthesized
424 // error ToolResult so ctx never keeps a ToolCall without its
425 // ToolResult (invalid history → wire 400s on the next turn,
426 // persisted via session).
427 if aborted || matches!(captured, Some(StopReason::Aborted)) {
428 if let Err(e) = synthesize_unexecuted(&mut cfg, &sink, ctx, calls.into_iter()) {
429 let _ = sink.send(AgentEvent::AgentEnd);
430 return Err(e);
431 }
432 let _ = sink.send(AgentEvent::AgentEnd);
433 return finish(StopReason::Aborted, cfg.steering, cfg.follow_ups);
434 }
435 // Hard-fatal stream error (captured Error with no feed-back note):
436 // the request itself cannot succeed, so retrying or re-feeding is
437 // pointless — end the run as before.
438 if matches!(captured, Some(StopReason::Error)) && stream_error.is_none() {
439 if let Err(e) = synthesize_unexecuted(&mut cfg, &sink, ctx, calls.into_iter()) {
440 let _ = sink.send(AgentEvent::AgentEnd);
441 return Err(e);
442 }
443 let _ = sink.send(AgentEvent::AgentEnd);
444 return finish(StopReason::Error, cfg.steering, cfg.follow_ups);
445 }
446 // A feedable stream failure is written back into ctx — the model
447 // sees "your previous response failed: <msg>" and can fix its
448 // output, exactly like a malformed tool result. Consecutive such
449 // turns are capped ([`DEFAULT_MAX_STREAM_ERROR_TURNS`]) so a broken
450 // request or a model that cannot recover still terminates with
451 // StopReason::Error as it always did.
452 if let Some(err_msg) = stream_error {
453 consecutive_stream_errors += 1;
454 if let Err(e) = synthesize_unexecuted(&mut cfg, &sink, ctx, calls.into_iter()) {
455 let _ = sink.send(AgentEvent::AgentEnd);
456 return Err(e);
457 }
458 if consecutive_stream_errors > DEFAULT_MAX_STREAM_ERROR_TURNS {
459 let _ = sink.send(AgentEvent::AgentEnd);
460 return finish(StopReason::Error, cfg.steering, cfg.follow_ups);
461 }
462 let notice = AgentMessage::user_text(format!("your previous response failed: {err_msg}"));
463 record_session(&mut cfg, ¬ice)?;
464 ctx.push(notice);
465 continue;
466 }
467 consecutive_stream_errors = 0;
468 if calls.is_empty() {
469 // Turn with no tool calls: the turn's tool work (none) is
470 // done, so the stop hook — documented to run after a turn's
471 // tool execution — fires here too. Otherwise its contract
472 // silently skipped every tool-call-free final answer.
473 if cfg.hooks.should_stop_after_turn(ctx).await {
474 let _ = sink.send(AgentEvent::AgentEnd);
475 return finish(StopReason::Stop, cfg.steering, cfg.follow_ups);
476 }
477 break; // outer loop handles follow-ups
478 }
479
480 // Tool execution. Preflight every call in order (transform, block,
481 // tool lookup), then run each *group* of concurrency-safe calls in
482 // parallel. A call that is not `parallel_safe` is a barrier — it
483 // runs alone, so no read can race a mutation inside one batch.
484 // Results land in ctx in call order whatever the completion order.
485 let all_calls = calls.clone();
486 let mut plan: Vec<Planned> = Vec::with_capacity(calls.len());
487 for (id, name, mut arguments) in calls {
488 let mut hook_call = HookToolCall {
489 id: id.clone(),
490 name: name.clone(),
491 arguments: arguments.clone(),
492 };
493 // Rewrites/transforms run before everything else so what
494 // executes, blocks and is logged is the final command.
495 cfg.hooks.transform_tool_input(&mut hook_call).await;
496 arguments = hook_call.arguments.clone();
497
498 let planned = match cfg.hooks.before_tool_call(&hook_call).await {
499 Some(reason) => Planned::fail(id, name, format!("blocked: {reason}")),
500 None => match cfg.tools.iter().find(|t| t.name() == name).cloned() {
501 Some(tool) => {
502 let parallel_safe = cfg.parallel && tool.parallel_safe();
503 Planned {
504 id,
505 name,
506 run: Some(PlannedRun { tool, arguments }),
507 fail: None,
508 parallel_safe,
509 }
510 }
511 None => {
512 let msg = format!("unknown tool: {name}");
513 Planned::fail(id, name, msg)
514 }
515 },
516 };
517 plan.push(planned);
518 }
519
520 // Partition into groups: a maximal run of concurrency-safe calls
521 // fans out; anything else is a single-call barrier.
522 let mut groups: Vec<Vec<Planned>> = Vec::new();
523 let mut run: Vec<Planned> = Vec::new();
524 for p in plan {
525 if p.parallel_safe {
526 run.push(p);
527 } else {
528 if !run.is_empty() {
529 groups.push(std::mem::take(&mut run));
530 }
531 groups.push(vec![p]);
532 }
533 }
534 if !run.is_empty() {
535 groups.push(run);
536 }
537
538 let mut processed = 0usize;
539 for group in groups {
540 let width = group.len();
541 let mut started = vec![false; width];
542 let mut results: Vec<Option<ToolOutput>> = (0..width).map(|_| None).collect();
543 let mut durations: Vec<Option<u64>> = (0..width).map(|_| None).collect();
544
545 // Start events first, in call order, so the display pairs each
546 // ✓ with the ⚙ marker above it.
547 for (i, c) in group.iter().enumerate() {
548 let sent = sink
549 .send(AgentEvent::ToolExecutionStart {
550 call_id: c.id.clone(),
551 name: c.name.clone(),
552 })
553 .is_ok();
554 started[i] = sent;
555 if !sent {
556 // Dead sink: the tool never runs. Synthesize its error
557 // output so the ToolResult still lands in ctx.
558 results[i] = Some(ToolOutput {
559 output: "aborted before execution".to_string(),
560 is_error: true,
561 diff: None,
562 path: None,
563 });
564 }
565 }
566
567 // Fan out the runnable calls of this group. `after_tool_call`
568 // runs inside the task so a tool's hooks stay with its result.
569 let mut running = Vec::new();
570 // D4 — the live session path, threaded into the tool ctx so
571 // `session_search` can read this run's transcript. `cfg.session`
572 // is `Option<&mut Session>`; `Session::path()` is `Option<&Path>`.
573 let session_path = cfg
574 .session
575 .as_deref()
576 .and_then(|s| s.path())
577 .map(std::path::Path::to_path_buf);
578 for (i, c) in group.iter().enumerate() {
579 if !started[i] {
580 continue;
581 }
582 let Some(run) = &c.run else {
583 results[i] = c.fail.clone();
584 continue;
585 };
586 let tool = run.tool.clone();
587 let arguments = run.arguments.clone();
588 let hook_call = HookToolCall {
589 id: c.id.clone(),
590 name: c.name.clone(),
591 arguments: arguments.clone(),
592 };
593 let tctx = ToolContext {
594 call_id: c.id.clone(),
595 name: c.name.clone(),
596 working_dir: cfg.working_dir.clone(),
597 cancel: cfg.cancel.clone(),
598 events: sink.clone(),
599 session_path: session_path.clone(),
600 };
601 let hooks = cfg.hooks.clone();
602 // Race the tool against the run's cancel token (bug 2).
603 // Checked only at the turn boundary and after the whole
604 // tool loop, a tool that does NOT watch `ctx.cancel` would
605 // otherwise hold the run until it returns. `biased` polls
606 // the tool arm FIRST, so a tool that is already done — or a
607 // watcher that has just observed the token — wins; only a
608 // tool that ignores the token is force-dropped by the
609 // cancel arm. The synthetic output still flows through the
610 // existing tail (end event + `ToolResult` push), so history
611 // keeps one `ToolResult` per `ToolCall`.
612 //
613 // A tool that needs clean-up must watch `ToolContext.cancel`
614 // and finish before returning (`bash` kills its child); the
615 // race is only a backstop for tools that ignore it.
616 let cancel = cfg.cancel.clone();
617 running.push(async move {
618 let started = std::time::Instant::now();
619 let mut out = tokio::select! {
620 biased;
621 out = tool.execute(arguments, tctx) => out,
622 _ = cancel.cancelled() => ToolOutput {
623 output: "aborted: cancelled before the tool returned"
624 .to_string(),
625 is_error: true,
626 diff: None,
627 path: None,
628 },
629 };
630 // `after_tool_call` is the pair of `before_tool_call`,
631 // which already ran — keep the pair even for the
632 // synthetic abort output.
633 hooks.after_tool_call(&hook_call, &mut out).await;
634 let duration_ms = started.elapsed().as_millis() as u64;
635 (i, out, duration_ms)
636 });
637 }
638 for (i, out, duration_ms) in futures::stream::iter(running)
639 .buffer_unordered(MAX_PARALLEL_TOOLS)
640 .collect::<Vec<_>>()
641 .await
642 {
643 results[i] = Some(out);
644 durations[i] = Some(duration_ms);
645 }
646
647 // End events, session records and ctx pushes in call order.
648 for (i, c) in group.iter().enumerate() {
649 let out = results[i].take().unwrap_or_else(|| ToolOutput {
650 output: "aborted before execution".to_string(),
651 is_error: true,
652 diff: None,
653 path: None,
654 });
655 let sent = started[i]
656 && sink
657 .send(AgentEvent::ToolExecutionEnd {
658 call_id: c.id.clone(),
659 name: c.name.clone(),
660 output: out.output.clone(),
661 is_error: out.is_error,
662 diff: out.diff.clone(),
663 path: out.path.clone(),
664 duration_ms: durations[i],
665 })
666 .is_ok();
667 // Push the result before honoring a dead sink so ctx never
668 // keeps a ToolCall without its ToolResult.
669 let result = AgentMessage::ToolResult {
670 tool_call_id: c.id.clone(),
671 name: c.name.clone(),
672 output: out.output,
673 is_error: out.is_error,
674 };
675 record_session(&mut cfg, &result)?;
676 ctx.push(result);
677 if !sent {
678 aborted = true;
679 }
680 }
681
682 processed += width;
683 if aborted {
684 // Sink died mid-tool-loop: synthesize an error ToolResult
685 // for every unexecuted call (event sends are best-effort;
686 // the abort path already returns Aborted).
687 let rest = all_calls.iter().skip(processed).cloned();
688 if let Err(e) = synthesize_unexecuted(&mut cfg, &sink, ctx, rest) {
689 let _ = sink.send(AgentEvent::AgentEnd);
690 return Err(e);
691 }
692 break;
693 }
694 }
695
696 if cfg.cancel.is_cancelled() {
697 // A cancel that fired mid-tool-loop ends the run right here
698 // instead of rolling into another LLM turn: tools already saw
699 // the token via ToolContext.cancel and pushed their results, so
700 // the history is complete (no orphan ToolCalls to synthesize).
701 aborted = true;
702 }
703 if aborted {
704 let _ = sink.send(AgentEvent::AgentEnd);
705 return finish(StopReason::Aborted, cfg.steering, cfg.follow_ups);
706 }
707 if cfg.hooks.should_stop_after_turn(ctx).await {
708 let _ = sink.send(AgentEvent::AgentEnd);
709 return finish(StopReason::Stop, cfg.steering, cfg.follow_ups);
710 }
711 }
712
713 // Soft-interrupt point B: drain a follow-up OR a steer at this shared
714 // boundary. A `steer` that arrived during a tool-free turn used to be
715 // deferred to a future run; now it injects here and starts the next turn.
716 // The stop hook already ran on the way in, so `should_stop_after_turn`
717 // stays authoritative.
718 match next_injected(&mut cfg) {
719 Some(m) => {
720 if sink
721 .send(AgentEvent::MessageStart { message: m.clone() })
722 .is_err()
723 {
724 let _ = sink.send(AgentEvent::AgentEnd);
725 return finish(StopReason::Aborted, cfg.steering, cfg.follow_ups);
726 }
727 record_session(&mut cfg, &m)?;
728 ctx.push(m.clone());
729 if sink.send(AgentEvent::MessageEnd { message: m }).is_err() {
730 let _ = sink.send(AgentEvent::AgentEnd);
731 return finish(StopReason::Aborted, cfg.steering, cfg.follow_ups);
732 }
733 continue 'outer;
734 }
735 None => {
736 let _ = sink.send(AgentEvent::AgentEnd);
737 return finish(StopReason::Stop, cfg.steering, cfg.follow_ups);
738 }
739 }
740 }
741}
742
743fn assistant(
744 content: &[ContentBlock],
745 stop_reason: StopReason,
746 usage: Option<Usage>,
747 model: &str,
748) -> AgentMessage {
749 AgentMessage::Assistant {
750 content: content.to_vec(),
751 stop_reason,
752 usage,
753 // Provenance only: the wire converter ignores `model`; the session keeps it.
754 model: (!model.is_empty()).then(|| model.to_string()),
755 }
756}
757
758/// Synthesizes an error ToolResult for every unexecuted tool call so ctx
759/// never keeps a ToolCall without its ToolResult. Event sends are
760/// best-effort (the sink may already be dead). Pushes are persisted to the
761/// session like every other produced message.
762/// One tool call after preflight: either ready to run, or already failed
763/// (blocked by a hook, unknown tool, dead sink).
764struct Planned {
765 id: String,
766 name: String,
767 /// `Some` = ready to execute.
768 run: Option<PlannedRun>,
769 /// `Some` = the result to record without executing.
770 fail: Option<ToolOutput>,
771 /// May run concurrently with its group peers.
772 parallel_safe: bool,
773}
774
775impl Planned {
776 fn fail(id: String, name: String, output: String) -> Self {
777 Planned {
778 id,
779 name,
780 run: None,
781 fail: Some(ToolOutput {
782 output,
783 is_error: true,
784 diff: None,
785 path: None,
786 }),
787 parallel_safe: false,
788 }
789 }
790}
791
792/// A prepared execution: the resolved tool plus its hook-transformed input.
793struct PlannedRun {
794 tool: Tool,
795 arguments: serde_json::Value,
796}
797
798fn synthesize_unexecuted(
799 cfg: &mut LoopConfig<'_>,
800 sink: &tokio::sync::mpsc::UnboundedSender<AgentEvent>,
801 ctx: &mut Vec<AgentMessage>,
802 calls: impl Iterator<Item = (String, String, serde_json::Value)>,
803) -> Result<(), LoopError> {
804 for (rid, rname, _) in calls {
805 let out = ToolOutput {
806 output: "aborted before execution".to_string(),
807 is_error: true,
808 diff: None,
809 path: None,
810 };
811 let _ = sink.send(AgentEvent::ToolExecutionEnd {
812 call_id: rid.clone(),
813 name: rname.clone(),
814 output: out.output.clone(),
815 is_error: out.is_error,
816 diff: out.diff.clone(),
817 path: out.path.clone(),
818 duration_ms: None,
819 });
820 let result = AgentMessage::ToolResult {
821 tool_call_id: rid,
822 name: rname,
823 output: out.output,
824 is_error: out.is_error,
825 };
826 record_session(cfg, &result)?;
827 ctx.push(result);
828 }
829 Ok(())
830}
831
832/// Persist a just-produced message to the configured session, if any. Runs at
833/// every `ctx.push` so a crash mid-run loses only the in-flight message
834/// instead of the whole turn.
835fn record_session(cfg: &mut LoopConfig<'_>, msg: &AgentMessage) -> Result<(), LoopError> {
836 let Some(session) = cfg.session.as_deref_mut() else {
837 return Ok(());
838 };
839 session
840 .append(SessionEntry::Message {
841 id: uuid::Uuid::new_v4().to_string(),
842 parent_id: None,
843 message: msg.clone(),
844 })
845 .map_err(LoopError::Session)
846}
847
848/// Provider-reported input tokens of the most recent assistant message: the
849/// size of the last request, used to decide whether to compact.
850fn last_input_tokens(ctx: &[AgentMessage]) -> Option<u64> {
851 ctx.iter().rev().find_map(|m| match m {
852 AgentMessage::Assistant { usage: Some(u), .. } => Some(u.input_tokens),
853 _ => None,
854 })
855}
856
857fn append_text(content: &mut Vec<ContentBlock>, delta: &str) {
858 match content.last_mut() {
859 Some(ContentBlock::Text { text }) => text.push_str(delta),
860 _ => content.push(ContentBlock::Text {
861 text: delta.to_string(),
862 }),
863 }
864}
865
866fn append_thinking(content: &mut Vec<ContentBlock>, delta: &str) {
867 match content.last_mut() {
868 Some(ContentBlock::Thinking { text }) => text.push_str(delta),
869 _ => content.push(ContentBlock::Thinking {
870 text: delta.to_string(),
871 }),
872 }
873}
874
875/// Replacement semantics for a complete Thinking block: any thinking
876/// accumulated from deltas is dropped and the block holds just `text`.
877fn replace_thinking(content: &mut Vec<ContentBlock>, text: &str) {
878 content.retain(|b| !matches!(b, ContentBlock::Thinking { .. }));
879 content.push(ContentBlock::Thinking {
880 text: text.to_string(),
881 });
882}
883
884#[cfg(test)]
885mod cancel_in_tool_tests {
886 use super::*;
887 use std::time::Duration;
888
889 use async_trait::async_trait;
890 use tokio::sync::mpsc;
891
892 use crate::tool::{TypedTool, erased};
893
894 /// Empty tool args — serde deserializes a struct with no fields from `{}`.
895 #[derive(serde::Deserialize, schemars::JsonSchema)]
896 struct NoArgs {}
897
898 /// A `StreamFn` that emits one tool call per `(id, name)` pair plus a
899 /// tool-use finish, then ends — so the loop runs one tool batch.
900 fn stream_of_calls(calls: Vec<(&'static str, &'static str)>) -> StreamFn {
901 std::sync::Arc::new(move |_ctx, _system, _tools, _opts| {
902 let mut events: Vec<LlmStreamEvent> = calls
903 .iter()
904 .map(|(id, name)| LlmStreamEvent::ToolCall {
905 id: (*id).to_string(),
906 name: (*name).to_string(),
907 arguments: serde_json::json!({}),
908 })
909 .collect();
910 events.push(LlmStreamEvent::Done {
911 stop_reason: StopReason::ToolUse,
912 usage: None,
913 });
914 Box::pin(futures::stream::iter(events))
915 })
916 }
917
918 /// Signals `started`, then sleeps far longer than any test — and IGNORES
919 /// `ctx.cancel`, so only the loop's race can end it (bug 2).
920 #[derive(Clone)]
921 struct SlowTool {
922 started: mpsc::UnboundedSender<()>,
923 }
924
925 #[async_trait]
926 impl TypedTool for SlowTool {
927 type Args = NoArgs;
928 fn name(&self) -> &str {
929 "slow"
930 }
931 fn description(&self) -> &str {
932 "sleeps for an hour, ignoring cancel"
933 }
934 async fn execute(&self, _args: NoArgs, _ctx: &ToolContext) -> ToolOutput {
935 let _ = self.started.send(());
936 tokio::time::sleep(Duration::from_secs(3600)).await;
937 ToolOutput {
938 output: "slow finished".to_string(),
939 is_error: false,
940 ..ToolOutput::default()
941 }
942 }
943 }
944
945 /// Signals `started`, then WATCHES `ctx.cancel` and returns its OWN output
946 /// when the token fires — the race's `biased` tool arm must let it win.
947 #[derive(Clone)]
948 struct WatcherTool {
949 started: mpsc::UnboundedSender<()>,
950 }
951
952 #[async_trait]
953 impl TypedTool for WatcherTool {
954 type Args = NoArgs;
955 fn name(&self) -> &str {
956 "watcher"
957 }
958 fn description(&self) -> &str {
959 "returns its own output once cancel fires"
960 }
961 async fn execute(&self, _args: NoArgs, ctx: &ToolContext) -> ToolOutput {
962 let _ = self.started.send(());
963 ctx.cancel.cancelled().await;
964 ToolOutput {
965 output: "watcher saw cancel".to_string(),
966 is_error: false,
967 ..ToolOutput::default()
968 }
969 }
970 }
971
972 fn config(tool: Tool, cancel: CancellationToken, stream_fn: StreamFn) -> LoopConfig<'static> {
973 let (_steer_tx, steering) = mpsc::unbounded_channel();
974 let (_follow_tx, follow_ups) = mpsc::unbounded_channel();
975 let (ctx_snapshot, _ctx_rx) = tokio::sync::watch::channel(Vec::new());
976 LoopConfig {
977 system: String::new(),
978 tools: vec![tool],
979 llm: LlmOpts::default(),
980 stream_fn,
981 hooks: HooksSet::default(),
982 steering,
983 follow_ups,
984 cancel,
985 working_dir: std::env::temp_dir(),
986 max_turns: DEFAULT_MAX_TURNS,
987 parallel: false,
988 compaction: CompactionPolicy::default(),
989 ctx_snapshot,
990 session: None,
991 }
992 }
993
994 /// Cancel `token` once the tool has signalled `started_rx` — a helper so
995 /// each test cancels mid-tool, not before the turn starts.
996 fn cancel_on_start(
997 token: CancellationToken,
998 mut started_rx: mpsc::UnboundedReceiver<()>,
999 ) {
1000 let worker = token.clone();
1001 tokio::spawn(async move {
1002 let _ = started_rx.recv().await;
1003 worker.cancel();
1004 });
1005 }
1006
1007 fn tool_result(ctx: &[AgentMessage]) -> (String, bool, String) {
1008 ctx.iter()
1009 .find_map(|m| match m {
1010 AgentMessage::ToolResult {
1011 tool_call_id,
1012 output,
1013 is_error,
1014 ..
1015 } => Some((tool_call_id.clone(), *is_error, output.clone())),
1016 _ => None,
1017 })
1018 .expect("the aborted call must still push a ToolResult")
1019 }
1020
1021 #[tokio::test]
1022 async fn a_cancel_aborts_a_running_tool() {
1023 let cancel = CancellationToken::new();
1024 let (started_tx, started_rx) = mpsc::unbounded_channel();
1025 cancel_on_start(cancel.clone(), started_rx);
1026
1027 let mut ctx: Vec<AgentMessage> = Vec::new();
1028 let cfg = config(
1029 erased(SlowTool { started: started_tx }),
1030 cancel,
1031 stream_of_calls(vec![("c1", "slow")]),
1032 );
1033 let (sink, _events) = mpsc::unbounded_channel();
1034 let run = tokio::time::timeout(Duration::from_secs(5), run_loop(&mut ctx, cfg, sink))
1035 .await
1036 .expect("run_loop must abort a stuck tool, not hang");
1037 assert_eq!(run.unwrap().stop_reason, StopReason::Aborted);
1038 }
1039
1040 #[tokio::test]
1041 async fn the_aborted_result_is_recorded_in_ctx() {
1042 let cancel = CancellationToken::new();
1043 let (started_tx, started_rx) = mpsc::unbounded_channel();
1044 cancel_on_start(cancel.clone(), started_rx);
1045
1046 let mut ctx: Vec<AgentMessage> = Vec::new();
1047 let cfg = config(
1048 erased(SlowTool { started: started_tx }),
1049 cancel,
1050 stream_of_calls(vec![("c1", "slow")]),
1051 );
1052 let (sink, _events) = mpsc::unbounded_channel();
1053 let _ = tokio::time::timeout(Duration::from_secs(5), run_loop(&mut ctx, cfg, sink))
1054 .await
1055 .expect("run_loop must abort a stuck tool, not hang");
1056
1057 let (id, is_error, output) = tool_result(&ctx);
1058 assert_eq!(id, "c1");
1059 assert!(is_error, "the aborted result is an error ToolResult");
1060 assert_eq!(output, "aborted: cancelled before the tool returned");
1061 }
1062
1063 #[tokio::test]
1064 async fn a_cancel_watching_tool_keeps_its_own_output() {
1065 let cancel = CancellationToken::new();
1066 let (started_tx, started_rx) = mpsc::unbounded_channel();
1067 cancel_on_start(cancel.clone(), started_rx);
1068
1069 let mut ctx: Vec<AgentMessage> = Vec::new();
1070 let cfg = config(
1071 erased(WatcherTool { started: started_tx }),
1072 cancel,
1073 stream_of_calls(vec![("c1", "watcher")]),
1074 );
1075 let (sink, _events) = mpsc::unbounded_channel();
1076 let run = tokio::time::timeout(Duration::from_secs(5), run_loop(&mut ctx, cfg, sink))
1077 .await
1078 .expect("run_loop must not hang");
1079 assert_eq!(run.unwrap().stop_reason, StopReason::Aborted);
1080
1081 let (_id, is_error, output) = tool_result(&ctx);
1082 assert_eq!(
1083 output, "watcher saw cancel",
1084 "the tool's OWN output wins under biased"
1085 );
1086 assert!(!is_error);
1087 }
1088}