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