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