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, ¬ice)?;
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}