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::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 = tool.execute(arguments, tctx) => out,
665 _ = cancel.cancelled() => ToolOutput {
666 output: "aborted: cancelled before the tool returned"
667 .to_string(),
668 is_error: true,
669 diff: None,
670 path: None,
671 },
672 };
673 // `after_tool_call` is the pair of `before_tool_call`,
674 // which already ran — keep the pair even for the
675 // synthetic abort output.
676 hooks.after_tool_call(&hook_call, &mut out).await;
677 let duration_ms = started.elapsed().as_millis() as u64;
678 (i, out, duration_ms)
679 });
680 }
681 for (i, out, duration_ms) in futures::stream::iter(running)
682 .buffer_unordered(MAX_PARALLEL_TOOLS)
683 .collect::<Vec<_>>()
684 .await
685 {
686 results[i] = Some(out);
687 durations[i] = Some(duration_ms);
688 }
689
690 // End events, session records and ctx pushes in call order.
691 for (i, c) in group.iter().enumerate() {
692 let out = results[i].take().unwrap_or_else(|| ToolOutput {
693 output: "aborted before execution".to_string(),
694 is_error: true,
695 diff: None,
696 path: None,
697 });
698 let sent = started[i]
699 && sink
700 .send(AgentEvent::ToolExecutionEnd {
701 call_id: c.id.clone(),
702 name: c.name.clone(),
703 output: out.output.clone(),
704 is_error: out.is_error,
705 diff: out.diff.clone(),
706 path: out.path.clone(),
707 duration_ms: durations[i],
708 })
709 .is_ok();
710 // Push the result before honoring a dead sink so ctx never
711 // keeps a ToolCall without its ToolResult.
712 let result = AgentMessage::ToolResult {
713 tool_call_id: c.id.clone(),
714 name: c.name.clone(),
715 output: out.output,
716 is_error: out.is_error,
717 };
718 record_session(&mut cfg, &result)?;
719 ctx.push(result);
720 if !sent {
721 aborted = true;
722 }
723 }
724
725 processed += width;
726 if aborted {
727 // Sink died mid-tool-loop: synthesize an error ToolResult
728 // for every unexecuted call (event sends are best-effort;
729 // the abort path already returns Aborted).
730 let rest = all_calls.iter().skip(processed).cloned();
731 if let Err(e) = synthesize_unexecuted(&mut cfg, &sink, ctx, rest) {
732 let _ = sink.send(AgentEvent::AgentEnd);
733 return Err(e);
734 }
735 break;
736 }
737 }
738
739 if cfg.cancel.is_cancelled() {
740 // A cancel that fired mid-tool-loop ends the run right here
741 // instead of rolling into another LLM turn: tools already saw
742 // the token via ToolContext.cancel and pushed their results, so
743 // the history is complete (no orphan ToolCalls to synthesize).
744 aborted = true;
745 }
746 if aborted {
747 let _ = sink.send(AgentEvent::AgentEnd);
748 return finish(StopReason::Aborted, cfg.steering, cfg.follow_ups);
749 }
750 if cfg.hooks.should_stop_after_turn(ctx).await {
751 let _ = sink.send(AgentEvent::AgentEnd);
752 return finish(StopReason::Stop, cfg.steering, cfg.follow_ups);
753 }
754 }
755
756 // Soft-interrupt point B: drain a follow-up OR a steer at this shared
757 // boundary. A `steer` that arrived during a tool-free turn used to be
758 // deferred to a future run; now it injects here and starts the next turn.
759 // The stop hook already ran on the way in, so `should_stop_after_turn`
760 // stays authoritative.
761 match next_injected(&mut cfg) {
762 Some(m) => {
763 if sink
764 .send(AgentEvent::MessageStart { message: m.clone() })
765 .is_err()
766 {
767 let _ = sink.send(AgentEvent::AgentEnd);
768 return finish(StopReason::Aborted, cfg.steering, cfg.follow_ups);
769 }
770 record_session(&mut cfg, &m)?;
771 ctx.push(m.clone());
772 if sink.send(AgentEvent::MessageEnd { message: m }).is_err() {
773 let _ = sink.send(AgentEvent::AgentEnd);
774 return finish(StopReason::Aborted, cfg.steering, cfg.follow_ups);
775 }
776 continue 'outer;
777 }
778 None => {
779 let _ = sink.send(AgentEvent::AgentEnd);
780 return finish(StopReason::Stop, cfg.steering, cfg.follow_ups);
781 }
782 }
783 }
784}
785
786fn assistant(
787 content: &[ContentBlock],
788 stop_reason: StopReason,
789 usage: Option<Usage>,
790 model: &str,
791) -> AgentMessage {
792 AgentMessage::Assistant {
793 content: content.to_vec(),
794 stop_reason,
795 usage,
796 // Provenance only: the wire converter ignores `model`; the session keeps it.
797 model: (!model.is_empty()).then(|| model.to_string()),
798 }
799}
800
801/// Synthesizes an error ToolResult for every unexecuted tool call so ctx
802/// never keeps a ToolCall without its ToolResult. Event sends are
803/// best-effort (the sink may already be dead). Pushes are persisted to the
804/// session like every other produced message.
805/// One tool call after preflight: either ready to run, or already failed
806/// (blocked by a hook, unknown tool, dead sink).
807struct Planned {
808 id: String,
809 name: String,
810 /// `Some` = ready to execute.
811 run: Option<PlannedRun>,
812 /// `Some` = the result to record without executing.
813 fail: Option<ToolOutput>,
814 /// May run concurrently with its group peers.
815 parallel_safe: bool,
816}
817
818impl Planned {
819 fn fail(id: String, name: String, output: String) -> Self {
820 Planned {
821 id,
822 name,
823 run: None,
824 fail: Some(ToolOutput {
825 output,
826 is_error: true,
827 diff: None,
828 path: None,
829 }),
830 parallel_safe: false,
831 }
832 }
833}
834
835/// A prepared execution: the resolved tool plus its hook-transformed input.
836struct PlannedRun {
837 tool: Tool,
838 arguments: serde_json::Value,
839}
840
841fn synthesize_unexecuted(
842 cfg: &mut LoopConfig<'_>,
843 sink: &tokio::sync::mpsc::UnboundedSender<AgentEvent>,
844 ctx: &mut Vec<AgentMessage>,
845 calls: impl Iterator<Item = (String, String, serde_json::Value)>,
846) -> Result<(), LoopError> {
847 for (rid, rname, _) in calls {
848 let out = ToolOutput {
849 output: "aborted before execution".to_string(),
850 is_error: true,
851 diff: None,
852 path: None,
853 };
854 let _ = sink.send(AgentEvent::ToolExecutionEnd {
855 call_id: rid.clone(),
856 name: rname.clone(),
857 output: out.output.clone(),
858 is_error: out.is_error,
859 diff: out.diff.clone(),
860 path: out.path.clone(),
861 duration_ms: None,
862 });
863 let result = AgentMessage::ToolResult {
864 tool_call_id: rid,
865 name: rname,
866 output: out.output,
867 is_error: out.is_error,
868 };
869 record_session(cfg, &result)?;
870 ctx.push(result);
871 }
872 Ok(())
873}
874
875/// Persist a just-produced message to the configured session, if any. Runs at
876/// every `ctx.push` so a crash mid-run loses only the in-flight message
877/// instead of the whole turn.
878fn record_session(cfg: &mut LoopConfig<'_>, msg: &AgentMessage) -> Result<(), LoopError> {
879 let Some(session) = cfg.session.as_deref_mut() else {
880 return Ok(());
881 };
882 session
883 .append(SessionEntry::Message {
884 id: uuid::Uuid::new_v4().to_string(),
885 parent_id: None,
886 message: msg.clone(),
887 })
888 .map_err(LoopError::Session)
889}
890
891/// Count `AgentMessage::Assistant` in `ctx` — W031 **D2**'s definition of "a
892/// turn". Derived from persisted state, so it survives a `--resume` and agrees
893/// with what the session file holds; a compaction cannot inflate it (the
894/// synthetic summary is a `User` message), and it counts a long `-p` agentic run
895/// and a plain chat the same way — unlike the loop's own `turns` counter, which
896/// resets every run so a plain chat would never reach 2.
897fn assistant_turns(ctx: &[AgentMessage]) -> usize {
898 ctx.iter()
899 .filter(|m| matches!(m, AgentMessage::Assistant { .. }))
900 .count()
901}
902
903/// Append a `Title` entry and stream [`AgentEvent::SessionTitle`] — best-effort.
904///
905/// # Contract (W031 D1 + §5.5)
906///
907/// Silent no-op, in THIS order, when any of:
908/// - `cfg.title_after_turns == 0` (off);
909/// - there is no session (`--no-session`). Why: with no file there is no `Title`
910/// entry, so "no entry exists" would be true at EVERY boundary and the run
911/// would re-title and re-request at each one — the churn §3 rules out;
912/// - `session.title().is_some()` — checked BEFORE any count, so a `--resume`d
913/// titled session never re-titles and never restarts a counter (**D2**), and a
914/// `/rename` suppresses the auto-titler (§5.5);
915/// - `assistant_turns(ctx) < cfg.title_after_turns`.
916///
917/// Else one [`title::generate`] call with TITLE-LOCAL opts — the same model, with
918/// `effort` cleared so a reasoning model is not asked to think about a name. On
919/// `Ok`, append the entry through [`LoopConfig::session`] and stream the event; on
920/// `Err`, NOTHING at all — no entry, no event, no `AgentEvent::Error`. The
921/// caller's per-run latch is what keeps a persistent failure from becoming a
922/// per-turn retry storm.
923///
924/// Deliberately does NOT fall back to `ThinkingDelta` text: a thinking block's
925/// first line is planning prose ("The user is asking about…"), which would name
926/// the session after the request instead of the work.
927async fn maybe_title(
928 cfg: &mut LoopConfig<'_>,
929 ctx: &[AgentMessage],
930 sink: &tokio::sync::mpsc::UnboundedSender<AgentEvent>,
931) {
932 if cfg.title_after_turns == 0 {
933 return;
934 }
935 let Some(session) = cfg.session.as_deref() else {
936 return;
937 };
938 if session.title().is_some() {
939 return;
940 }
941 if assistant_turns(ctx) < cfg.title_after_turns {
942 return;
943 }
944
945 let mut title_llm = cfg.llm.clone();
946 title_llm.effort = None;
947 let Ok(title) = title::generate(&cfg.stream_fn, &title_llm, ctx).await else {
948 return;
949 };
950
951 // `let … else`, not `if let`: a `None` session must NOT fall through to the
952 // event send, or the UI would announce a title the file does not have.
953 let Some(session) = cfg.session.as_deref_mut() else {
954 return;
955 };
956 if session
957 .append(SessionEntry::Title {
958 id: uuid::Uuid::new_v4().to_string(),
959 title: title.clone(),
960 })
961 .is_err()
962 {
963 return;
964 }
965 let _ = sink.send(AgentEvent::SessionTitle { title });
966}
967
968/// Provider-reported input tokens of the most recent assistant message: the
969/// size of the last request, used to decide whether to compact.
970fn last_input_tokens(ctx: &[AgentMessage]) -> Option<u64> {
971 ctx.iter().rev().find_map(|m| match m {
972 AgentMessage::Assistant { usage: Some(u), .. } => Some(u.input_tokens),
973 _ => None,
974 })
975}
976
977fn append_text(content: &mut Vec<ContentBlock>, delta: &str) {
978 match content.last_mut() {
979 Some(ContentBlock::Text { text }) => text.push_str(delta),
980 _ => content.push(ContentBlock::Text {
981 text: delta.to_string(),
982 }),
983 }
984}
985
986fn append_thinking(content: &mut Vec<ContentBlock>, delta: &str) {
987 match content.last_mut() {
988 Some(ContentBlock::Thinking { text }) => text.push_str(delta),
989 _ => content.push(ContentBlock::Thinking {
990 text: delta.to_string(),
991 }),
992 }
993}
994
995/// Replacement semantics for a complete Thinking block: any thinking
996/// accumulated from deltas is dropped and the block holds just `text`.
997fn replace_thinking(content: &mut Vec<ContentBlock>, text: &str) {
998 content.retain(|b| !matches!(b, ContentBlock::Thinking { .. }));
999 content.push(ContentBlock::Thinking {
1000 text: text.to_string(),
1001 });
1002}
1003
1004#[cfg(test)]
1005mod cancel_in_tool_tests {
1006 use super::*;
1007 use std::time::Duration;
1008
1009 use async_trait::async_trait;
1010 use tokio::sync::mpsc;
1011
1012 use crate::tool::{TypedTool, erased};
1013
1014 /// Empty tool args — serde deserializes a struct with no fields from `{}`.
1015 #[derive(serde::Deserialize, schemars::JsonSchema)]
1016 struct NoArgs {}
1017
1018 /// A `StreamFn` that emits one tool call per `(id, name)` pair plus a
1019 /// tool-use finish, then ends — so the loop runs one tool batch.
1020 fn stream_of_calls(calls: Vec<(&'static str, &'static str)>) -> StreamFn {
1021 std::sync::Arc::new(move |_ctx, _system, _tools, _opts| {
1022 let mut events: Vec<LlmStreamEvent> = calls
1023 .iter()
1024 .map(|(id, name)| LlmStreamEvent::ToolCall {
1025 id: (*id).to_string(),
1026 name: (*name).to_string(),
1027 arguments: serde_json::json!({}),
1028 })
1029 .collect();
1030 events.push(LlmStreamEvent::Done {
1031 stop_reason: StopReason::ToolUse,
1032 usage: None,
1033 });
1034 Box::pin(futures::stream::iter(events))
1035 })
1036 }
1037
1038 /// Signals `started`, then sleeps far longer than any test — and IGNORES
1039 /// `ctx.cancel`, so only the loop's race can end it (bug 2).
1040 #[derive(Clone)]
1041 struct SlowTool {
1042 started: mpsc::UnboundedSender<()>,
1043 }
1044
1045 #[async_trait]
1046 impl TypedTool for SlowTool {
1047 type Args = NoArgs;
1048 fn name(&self) -> &str {
1049 "slow"
1050 }
1051 fn description(&self) -> &str {
1052 "sleeps for an hour, ignoring cancel"
1053 }
1054 async fn execute(&self, _args: NoArgs, _ctx: &ToolContext) -> ToolOutput {
1055 let _ = self.started.send(());
1056 tokio::time::sleep(Duration::from_secs(3600)).await;
1057 ToolOutput {
1058 output: "slow finished".to_string(),
1059 is_error: false,
1060 ..ToolOutput::default()
1061 }
1062 }
1063 }
1064
1065 /// Signals `started`, then WATCHES `ctx.cancel` and returns its OWN output
1066 /// when the token fires — the race's `biased` tool arm must let it win.
1067 #[derive(Clone)]
1068 struct WatcherTool {
1069 started: mpsc::UnboundedSender<()>,
1070 }
1071
1072 #[async_trait]
1073 impl TypedTool for WatcherTool {
1074 type Args = NoArgs;
1075 fn name(&self) -> &str {
1076 "watcher"
1077 }
1078 fn description(&self) -> &str {
1079 "returns its own output once cancel fires"
1080 }
1081 async fn execute(&self, _args: NoArgs, ctx: &ToolContext) -> ToolOutput {
1082 let _ = self.started.send(());
1083 ctx.cancel.cancelled().await;
1084 ToolOutput {
1085 output: "watcher saw cancel".to_string(),
1086 is_error: false,
1087 ..ToolOutput::default()
1088 }
1089 }
1090 }
1091
1092 fn config(tool: Tool, cancel: CancellationToken, stream_fn: StreamFn) -> LoopConfig<'static> {
1093 let (_steer_tx, steering) = mpsc::unbounded_channel();
1094 let (_follow_tx, follow_ups) = mpsc::unbounded_channel();
1095 let (ctx_snapshot, _ctx_rx) = tokio::sync::watch::channel(Vec::new());
1096 LoopConfig {
1097 system: String::new(),
1098 tools: vec![tool],
1099 llm: LlmOpts::default(),
1100 stream_fn,
1101 hooks: HooksSet::default(),
1102 steering,
1103 follow_ups,
1104 cancel,
1105 working_dir: std::env::temp_dir(),
1106 max_turns: DEFAULT_MAX_TURNS,
1107 parallel: false,
1108 compaction: CompactionPolicy::default(),
1109 ctx_snapshot,
1110 session: None,
1111 // W031: these tests exercise the tool loop, not the titler — off, so no
1112 // test can spend a model request on a name.
1113 title_after_turns: 0,
1114 }
1115 }
1116
1117 /// Cancel `token` once the tool has signalled `started_rx` — a helper so
1118 /// each test cancels mid-tool, not before the turn starts.
1119 fn cancel_on_start(
1120 token: CancellationToken,
1121 mut started_rx: mpsc::UnboundedReceiver<()>,
1122 ) {
1123 let worker = token.clone();
1124 tokio::spawn(async move {
1125 let _ = started_rx.recv().await;
1126 worker.cancel();
1127 });
1128 }
1129
1130 fn tool_result(ctx: &[AgentMessage]) -> (String, bool, String) {
1131 ctx.iter()
1132 .find_map(|m| match m {
1133 AgentMessage::ToolResult {
1134 tool_call_id,
1135 output,
1136 is_error,
1137 ..
1138 } => Some((tool_call_id.clone(), *is_error, output.clone())),
1139 _ => None,
1140 })
1141 .expect("the aborted call must still push a ToolResult")
1142 }
1143
1144 #[tokio::test]
1145 async fn a_cancel_aborts_a_running_tool() {
1146 let cancel = CancellationToken::new();
1147 let (started_tx, started_rx) = mpsc::unbounded_channel();
1148 cancel_on_start(cancel.clone(), started_rx);
1149
1150 let mut ctx: Vec<AgentMessage> = Vec::new();
1151 let cfg = config(
1152 erased(SlowTool { started: started_tx }),
1153 cancel,
1154 stream_of_calls(vec![("c1", "slow")]),
1155 );
1156 let (sink, _events) = mpsc::unbounded_channel();
1157 let run = tokio::time::timeout(Duration::from_secs(5), run_loop(&mut ctx, cfg, sink))
1158 .await
1159 .expect("run_loop must abort a stuck tool, not hang");
1160 assert_eq!(run.unwrap().stop_reason, StopReason::Aborted);
1161 }
1162
1163 #[tokio::test]
1164 async fn the_aborted_result_is_recorded_in_ctx() {
1165 let cancel = CancellationToken::new();
1166 let (started_tx, started_rx) = mpsc::unbounded_channel();
1167 cancel_on_start(cancel.clone(), started_rx);
1168
1169 let mut ctx: Vec<AgentMessage> = Vec::new();
1170 let cfg = config(
1171 erased(SlowTool { started: started_tx }),
1172 cancel,
1173 stream_of_calls(vec![("c1", "slow")]),
1174 );
1175 let (sink, _events) = mpsc::unbounded_channel();
1176 let _ = tokio::time::timeout(Duration::from_secs(5), run_loop(&mut ctx, cfg, sink))
1177 .await
1178 .expect("run_loop must abort a stuck tool, not hang");
1179
1180 let (id, is_error, output) = tool_result(&ctx);
1181 assert_eq!(id, "c1");
1182 assert!(is_error, "the aborted result is an error ToolResult");
1183 assert_eq!(output, "aborted: cancelled before the tool returned");
1184 }
1185
1186 #[tokio::test]
1187 async fn a_cancel_watching_tool_keeps_its_own_output() {
1188 let cancel = CancellationToken::new();
1189 let (started_tx, started_rx) = mpsc::unbounded_channel();
1190 cancel_on_start(cancel.clone(), started_rx);
1191
1192 let mut ctx: Vec<AgentMessage> = Vec::new();
1193 let cfg = config(
1194 erased(WatcherTool { started: started_tx }),
1195 cancel,
1196 stream_of_calls(vec![("c1", "watcher")]),
1197 );
1198 let (sink, _events) = mpsc::unbounded_channel();
1199 let run = tokio::time::timeout(Duration::from_secs(5), run_loop(&mut ctx, cfg, sink))
1200 .await
1201 .expect("run_loop must not hang");
1202 assert_eq!(run.unwrap().stop_reason, StopReason::Aborted);
1203
1204 let (_id, is_error, output) = tool_result(&ctx);
1205 assert_eq!(
1206 output, "watcher saw cancel",
1207 "the tool's OWN output wins under biased"
1208 );
1209 assert!(!is_error);
1210 }
1211}