Skip to main content

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, &notice)?;
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}