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                    running.push(async move {
594                        let started = std::time::Instant::now();
595                        let mut out = tool.execute(arguments, tctx).await;
596                        hooks.after_tool_call(&hook_call, &mut out).await;
597                        let duration_ms = started.elapsed().as_millis() as u64;
598                        (i, out, duration_ms)
599                    });
600                }
601                for (i, out, duration_ms) in futures::stream::iter(running)
602                    .buffer_unordered(MAX_PARALLEL_TOOLS)
603                    .collect::<Vec<_>>()
604                    .await
605                {
606                    results[i] = Some(out);
607                    durations[i] = Some(duration_ms);
608                }
609
610                // End events, session records and ctx pushes in call order.
611                for (i, c) in group.iter().enumerate() {
612                    let out = results[i].take().unwrap_or_else(|| ToolOutput {
613                        output: "aborted before execution".to_string(),
614                        is_error: true,
615                        diff: None,
616                        path: None,
617                    });
618                    let sent = started[i]
619                        && sink
620                            .send(AgentEvent::ToolExecutionEnd {
621                                call_id: c.id.clone(),
622                                name: c.name.clone(),
623                                output: out.output.clone(),
624                                is_error: out.is_error,
625                                diff: out.diff.clone(),
626                                path: out.path.clone(),
627                                duration_ms: durations[i],
628                            })
629                            .is_ok();
630                    // Push the result before honoring a dead sink so ctx never
631                    // keeps a ToolCall without its ToolResult.
632                    let result = AgentMessage::ToolResult {
633                        tool_call_id: c.id.clone(),
634                        name: c.name.clone(),
635                        output: out.output,
636                        is_error: out.is_error,
637                    };
638                    record_session(&mut cfg, &result)?;
639                    ctx.push(result);
640                    if !sent {
641                        aborted = true;
642                    }
643                }
644
645                processed += width;
646                if aborted {
647                    // Sink died mid-tool-loop: synthesize an error ToolResult
648                    // for every unexecuted call (event sends are best-effort;
649                    // the abort path already returns Aborted).
650                    let rest = all_calls.iter().skip(processed).cloned();
651                    if let Err(e) = synthesize_unexecuted(&mut cfg, &sink, ctx, rest) {
652                        let _ = sink.send(AgentEvent::AgentEnd);
653                        return Err(e);
654                    }
655                    break;
656                }
657            }
658
659            if cfg.cancel.is_cancelled() {
660                // A cancel that fired mid-tool-loop ends the run right here
661                // instead of rolling into another LLM turn: tools already saw
662                // the token via ToolContext.cancel and pushed their results, so
663                // the history is complete (no orphan ToolCalls to synthesize).
664                aborted = true;
665            }
666            if aborted {
667                let _ = sink.send(AgentEvent::AgentEnd);
668                return finish(StopReason::Aborted, cfg.steering, cfg.follow_ups);
669            }
670            if cfg.hooks.should_stop_after_turn(ctx).await {
671                let _ = sink.send(AgentEvent::AgentEnd);
672                return finish(StopReason::Stop, cfg.steering, cfg.follow_ups);
673            }
674        }
675
676        // Soft-interrupt point B: drain a follow-up OR a steer at this shared
677        // boundary. A `steer` that arrived during a tool-free turn used to be
678        // deferred to a future run; now it injects here and starts the next turn.
679        // The stop hook already ran on the way in, so `should_stop_after_turn`
680        // stays authoritative.
681        match next_injected(&mut cfg) {
682            Some(m) => {
683                if sink
684                    .send(AgentEvent::MessageStart { message: m.clone() })
685                    .is_err()
686                {
687                    let _ = sink.send(AgentEvent::AgentEnd);
688                    return finish(StopReason::Aborted, cfg.steering, cfg.follow_ups);
689                }
690                record_session(&mut cfg, &m)?;
691                ctx.push(m.clone());
692                if sink.send(AgentEvent::MessageEnd { message: m }).is_err() {
693                    let _ = sink.send(AgentEvent::AgentEnd);
694                    return finish(StopReason::Aborted, cfg.steering, cfg.follow_ups);
695                }
696                continue 'outer;
697            }
698            None => {
699                let _ = sink.send(AgentEvent::AgentEnd);
700                return finish(StopReason::Stop, cfg.steering, cfg.follow_ups);
701            }
702        }
703    }
704}
705
706fn assistant(
707    content: &[ContentBlock],
708    stop_reason: StopReason,
709    usage: Option<Usage>,
710    model: &str,
711) -> AgentMessage {
712    AgentMessage::Assistant {
713        content: content.to_vec(),
714        stop_reason,
715        usage,
716        // Provenance only: the wire converter ignores `model`; the session keeps it.
717        model: (!model.is_empty()).then(|| model.to_string()),
718    }
719}
720
721/// Synthesizes an error ToolResult for every unexecuted tool call so ctx
722/// never keeps a ToolCall without its ToolResult. Event sends are
723/// best-effort (the sink may already be dead). Pushes are persisted to the
724/// session like every other produced message.
725/// One tool call after preflight: either ready to run, or already failed
726/// (blocked by a hook, unknown tool, dead sink).
727struct Planned {
728    id: String,
729    name: String,
730    /// `Some` = ready to execute.
731    run: Option<PlannedRun>,
732    /// `Some` = the result to record without executing.
733    fail: Option<ToolOutput>,
734    /// May run concurrently with its group peers.
735    parallel_safe: bool,
736}
737
738impl Planned {
739    fn fail(id: String, name: String, output: String) -> Self {
740        Planned {
741            id,
742            name,
743            run: None,
744            fail: Some(ToolOutput {
745                output,
746                is_error: true,
747                diff: None,
748                path: None,
749            }),
750            parallel_safe: false,
751        }
752    }
753}
754
755/// A prepared execution: the resolved tool plus its hook-transformed input.
756struct PlannedRun {
757    tool: Tool,
758    arguments: serde_json::Value,
759}
760
761fn synthesize_unexecuted(
762    cfg: &mut LoopConfig<'_>,
763    sink: &tokio::sync::mpsc::UnboundedSender<AgentEvent>,
764    ctx: &mut Vec<AgentMessage>,
765    calls: impl Iterator<Item = (String, String, serde_json::Value)>,
766) -> Result<(), LoopError> {
767    for (rid, rname, _) in calls {
768        let out = ToolOutput {
769            output: "aborted before execution".to_string(),
770            is_error: true,
771            diff: None,
772            path: None,
773        };
774        let _ = sink.send(AgentEvent::ToolExecutionEnd {
775            call_id: rid.clone(),
776            name: rname.clone(),
777            output: out.output.clone(),
778            is_error: out.is_error,
779            diff: out.diff.clone(),
780            path: out.path.clone(),
781            duration_ms: None,
782        });
783        let result = AgentMessage::ToolResult {
784            tool_call_id: rid,
785            name: rname,
786            output: out.output,
787            is_error: out.is_error,
788        };
789        record_session(cfg, &result)?;
790        ctx.push(result);
791    }
792    Ok(())
793}
794
795/// Persist a just-produced message to the configured session, if any. Runs at
796/// every `ctx.push` so a crash mid-run loses only the in-flight message
797/// instead of the whole turn.
798fn record_session(cfg: &mut LoopConfig<'_>, msg: &AgentMessage) -> Result<(), LoopError> {
799    let Some(session) = cfg.session.as_deref_mut() else {
800        return Ok(());
801    };
802    session
803        .append(SessionEntry::Message {
804            id: uuid::Uuid::new_v4().to_string(),
805            parent_id: None,
806            message: msg.clone(),
807        })
808        .map_err(LoopError::Session)
809}
810
811/// Provider-reported input tokens of the most recent assistant message: the
812/// size of the last request, used to decide whether to compact.
813fn last_input_tokens(ctx: &[AgentMessage]) -> Option<u64> {
814    ctx.iter().rev().find_map(|m| match m {
815        AgentMessage::Assistant { usage: Some(u), .. } => Some(u.input_tokens),
816        _ => None,
817    })
818}
819
820fn append_text(content: &mut Vec<ContentBlock>, delta: &str) {
821    match content.last_mut() {
822        Some(ContentBlock::Text { text }) => text.push_str(delta),
823        _ => content.push(ContentBlock::Text {
824            text: delta.to_string(),
825        }),
826    }
827}
828
829fn append_thinking(content: &mut Vec<ContentBlock>, delta: &str) {
830    match content.last_mut() {
831        Some(ContentBlock::Thinking { text }) => text.push_str(delta),
832        _ => content.push(ContentBlock::Thinking {
833            text: delta.to_string(),
834        }),
835    }
836}
837
838/// Replacement semantics for a complete Thinking block: any thinking
839/// accumulated from deltas is dropped and the block holds just `text`.
840fn replace_thinking(content: &mut Vec<ContentBlock>, text: &str) {
841    content.retain(|b| !matches!(b, ContentBlock::Thinking { .. }));
842    content.push(ContentBlock::Thinking {
843        text: text.to_string(),
844    });
845}