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