Skip to main content

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, &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 = 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}