Skip to main content

clark_agent/
run.rs

1//! The canonical agent loop.
2//!
3//! One free function each for run/start and continue — no god-class.
4//!
5//! Shape:
6//!
7//! ```text
8//! agent_start
9//!  └ loop:                          ← outer (follow-up) loop
10//!     turn_start
11//!     [pending steering messages]   ← injected before LLM call
12//!     stream assistant response     ← StreamFn → AssistantMessage
13//!     execute tool batch (if any)   ← parallel/sequential dispatch
14//!     turn_end
15//!     ↻ until no more tool calls AND no steering ready
16//!     check follow-up               ← post-stop injection
17//!  agent_end
18//! ```
19//!
20//! Termination is unanimous-tool-vote: a batch ends the run only when
21//! every finalized tool result sets `terminate = true`. One tool wanting
22//! to stop does not stop the batch.
23
24use futures::stream::StreamExt;
25use std::time::{SystemTime, UNIX_EPOCH};
26use tokio_util::sync::CancellationToken;
27
28use crate::config::LoopConfig;
29use crate::error::{LoopError, StreamError};
30use crate::event::AgentEvent;
31use crate::exec::{execute_tool_batch, ExecutedBatch};
32use crate::plugin::TransformContext;
33use crate::stream::{ReasoningEffort, StreamErrorKind, StreamEvent, StreamRequest, ToolSchema};
34use crate::types::{
35    AgentContext, AgentMessage, AssistantContent, StopReason, ToolResultContent, Usage,
36};
37
38const EMPTY_STREAM_RETRY_INITIAL_DELAY: std::time::Duration = std::time::Duration::from_millis(250);
39const ZERO_OUTPUT_TRANSPORT_RETRY_INITIAL_DELAY: std::time::Duration =
40    std::time::Duration::from_millis(500);
41const PROVIDER_RETRY_MAX_DELAY: std::time::Duration = std::time::Duration::from_secs(30);
42const ZERO_OUTPUT_TRANSPORT_RECOVERY_CONTEXT: &str = "\
43[runtime context — transport recovery, not user instruction]\n\
44The previous provider attempt produced no actionable output: no visible assistant text and no usable tool call reached the runtime. \
45It may have produced private-only reasoning or an unusable burst of partial tool calls. \
46Do not continue with private reasoning only. Re-read the latest observation and immediately choose exactly one next structured tool call; \
47if the answer is ready, use the final response tool.";
48
49/// Outcome label for a completed run.
50///
51/// A hard error becomes [`LoopError`]; caller cancellation is the only
52/// external lifetime boundary.
53#[derive(Debug, Clone, Copy, PartialEq, Eq)]
54pub enum LoopOutcome {
55    /// Model emitted a final assistant turn with no tool calls and no
56    /// pending steering. The natural happy path.
57    Done,
58}
59
60impl LoopOutcome {
61    /// Whether this outcome implies a clean, non-partial final answer.
62    pub fn is_complete(self) -> bool {
63        true
64    }
65
66    /// Short stable label suitable for logs and tool-result prefixes.
67    pub fn label(self) -> &'static str {
68        "done"
69    }
70}
71
72/// Result of a completed run: emitted messages plus a typed outcome label.
73///
74/// Returned by [`run`] and [`run_continue`]. `messages` is the slice of
75/// messages produced **during this run** (not the full transcript).
76/// `outcome` records the natural close without requiring message inspection.
77#[derive(Debug, Clone)]
78pub struct RunResult {
79    pub messages: Vec<AgentMessage>,
80    pub outcome: LoopOutcome,
81}
82
83/// Run the loop with one or more starting prompts.
84///
85/// The prompts are appended to the context's existing message list, then
86/// the loop runs until natural stop (no more tool calls, no follow-up).
87/// Returns the messages produced **during this run** plus a typed outcome
88/// label — not the full transcript. Callers that want the full transcript
89/// should fold prior messages into their own state, or read from the
90/// event sink.
91pub async fn run(
92    prompts: Vec<AgentMessage>,
93    context: AgentContext,
94    config: &LoopConfig,
95    signal: CancellationToken,
96) -> Result<RunResult, LoopError> {
97    let mut current = context;
98    let mut new_messages = prompts.clone();
99
100    current.messages.extend(prompts.iter().cloned());
101
102    emit(config, AgentEvent::AgentStart).await;
103    if let Some(identity) = current.identity.clone() {
104        emit(config, AgentEvent::RunIdentified { identity }).await;
105    }
106    emit(config, AgentEvent::TurnStart).await;
107    for prompt in &prompts {
108        emit(
109            config,
110            AgentEvent::MessageStart {
111                message: prompt.clone(),
112            },
113        )
114        .await;
115        emit(
116            config,
117            AgentEvent::MessageEnd {
118                message: prompt.clone(),
119            },
120        )
121        .await;
122    }
123
124    let outcome = inner_run(&mut current, &mut new_messages, config, &signal).await?;
125
126    Ok(RunResult {
127        messages: new_messages,
128        outcome,
129    })
130}
131
132/// Continue an existing context without adding a new prompt.
133///
134/// Used when the trailing message is already a `User` (e.g., steering
135/// queued externally) or `ToolResult` (e.g., an out-of-band tool result
136/// was injected). Errors if the trailing message is `Assistant` — the
137/// model would not respond to its own message.
138pub async fn run_continue(
139    context: AgentContext,
140    config: &LoopConfig,
141    signal: CancellationToken,
142) -> Result<RunResult, LoopError> {
143    let last = context
144        .messages
145        .last()
146        .ok_or_else(|| LoopError::InvalidContinuation("no messages in context".into()))?;
147    if matches!(last, AgentMessage::Assistant { .. }) {
148        return Err(LoopError::InvalidContinuation(
149            "trailing message is assistant".into(),
150        ));
151    }
152
153    let mut current = context;
154    let mut new_messages = Vec::new();
155
156    emit(config, AgentEvent::AgentStart).await;
157    if let Some(identity) = current.identity.clone() {
158        emit(config, AgentEvent::RunIdentified { identity }).await;
159    }
160    emit(config, AgentEvent::TurnStart).await;
161
162    let outcome = inner_run(&mut current, &mut new_messages, config, &signal).await?;
163
164    Ok(RunResult {
165        messages: new_messages,
166        outcome,
167    })
168}
169
170// ─── Internals ─────────────────────────────────────────────────────
171
172async fn emit(config: &LoopConfig, event: AgentEvent) {
173    config.event_sink.emit(event.clone()).await;
174    for observer in &config.plugins.event_observer {
175        observer.on_event(&event).await;
176    }
177}
178
179async fn inner_run(
180    current: &mut AgentContext,
181    new_messages: &mut Vec<AgentMessage>,
182    config: &LoopConfig,
183    signal: &CancellationToken,
184) -> Result<LoopOutcome, LoopError> {
185    let mut first_turn = true;
186    let mut iterations: usize = 0;
187
188    // Steering messages may already be queued (caller produced them
189    // before calling `run`).
190    let mut pending = collect_steering(config).await;
191
192    'outer: loop {
193        let mut has_more_tool_calls = true;
194        // Did the most recent tool batch vote terminate? Reset per
195        // outer iteration so a follow-up-driven re-entry starts clean.
196        //
197        // When the batch produces a unanimous terminator (every
198        // finalized result votes `terminate = true`), the run is over —
199        // `SteeringSource` and `FollowUpSource` plugins must NOT
200        // re-prompt the model with another LLM call. Without this
201        // guard a steering source whose firing condition lined up
202        // with the same turn (e.g. `graceful_turn_limit` reaching
203        // its soft limit on the same turn the model delivered)
204        // would inject a wrap-up message and the loop would burn
205        // another turn after a clean delivery — observed in production,
206        // where a model drifted into hallucinated content on the
207        // wrap-up re-entry after the prior batch had already produced
208        // the correct terminal delivery.
209        let mut last_batch_terminated = false;
210
211        while has_more_tool_calls || !pending.is_empty() {
212            if signal.is_cancelled() {
213                return Err(LoopError::Aborted);
214            }
215            iterations += 1;
216
217            if !first_turn {
218                emit(config, AgentEvent::TurnStart).await;
219            } else {
220                first_turn = false;
221            }
222
223            // Inject any pending steering messages before the next LLM call.
224            if !pending.is_empty() {
225                for msg in pending.drain(..) {
226                    emit(
227                        config,
228                        AgentEvent::MessageStart {
229                            message: msg.clone(),
230                        },
231                    )
232                    .await;
233                    emit(
234                        config,
235                        AgentEvent::MessageEnd {
236                            message: msg.clone(),
237                        },
238                    )
239                    .await;
240                    current.messages.push(msg.clone());
241                    new_messages.push(msg);
242                }
243            }
244
245            // Stream one assistant response, applying the configured
246            // max-tokens recovery ladder if a turn comes back truncated,
247            // and the context-overflow recovery hook if the request is
248            // rejected for exceeding the model's window. `iteration` is
249            // 0-indexed and counts LLM calls within this run — `iterations`
250            // was already incremented above for cap-checking, so the
251            // 0-indexed turn number is `iterations - 1`.
252            let (assistant, turn_allowlist) =
253                stream_with_overflow_recovery(current, config, signal, iterations - 1).await?;
254            // The assistant message must land in *both* the live conversation
255            // (so the next turn's request body includes it — providers reject
256            // tool messages that don't follow a matching assistant tool_call)
257            // and the run's emitted-messages tail.
258            current.messages.push(assistant.clone());
259            new_messages.push(assistant.clone());
260
261            // Extract tool calls.
262            let tool_calls: Vec<_> = match &assistant {
263                AgentMessage::Assistant { content, .. } => {
264                    content.tool_calls().into_iter().cloned().collect()
265                }
266                _ => Vec::new(),
267            };
268
269            let mut tool_result_messages = Vec::new();
270            has_more_tool_calls = false;
271
272            if tool_calls.is_empty() {
273                if let Some(tool_name) = config.plain_text_terminal_fallback_tool.as_deref() {
274                    let eager = config.plain_text_terminal_fallback_eager;
275                    let terminal_tool_names = config.protocol.terminal_tool_names();
276                    let narrowed_to_terminators = is_terminal_only_allowlist(
277                        turn_allowlist.as_ref(),
278                        tool_name,
279                        &terminal_tool_names,
280                    );
281                    let nudge_mode = config.plain_text_terminal_fallback_eager_nudge
282                        && eager
283                        && !narrowed_to_terminators;
284                    if nudge_mode {
285                        // Catalog still contains real work tools (e.g. `plan`)
286                        // but the model emitted prose. Inject an explicit
287                        // protocol-recovery system message and force the
288                        // inner loop to re-stream rather than laundering
289                        // the prose into a synthetic `message_result`.
290                        // Push directly into `current.messages` (mirrors the
291                        // synthesize path) rather than `pending`, which is
292                        // overwritten by `collect_steering` at end-of-iter.
293                        // Set `has_more_tool_calls = true` to satisfy the
294                        // inner while-loop's continuation predicate.
295                        //
296                        // The recovery prose comes from the active
297                        // `ProtocolPolicy` (which may name the product's
298                        // delivery / ask tools); the core falls back to a
299                        // generic, vocabulary-free nudge.
300                        let available_tool_names: Vec<&str> =
301                            config.tools.iter().map(|t| t.name()).collect();
302                        let nudge_text = config
303                            .protocol
304                            .plain_text_recovery_prompt(crate::protocol::PlainTextRecoveryContext {
305                                messages: &current.messages,
306                                iteration: iterations - 1,
307                                available_tool_names: &available_tool_names,
308                                terminal_fallback_tool: Some(tool_name),
309                            })
310                            .unwrap_or_else(|| {
311                                crate::protocol::DEFAULT_PLAIN_TEXT_RECOVERY_PROMPT.to_string()
312                            });
313                        let nudge = AgentMessage::System {
314                            content: nudge_text,
315                            timestamp: Some(now_ms()),
316                        };
317                        current.messages.push(nudge.clone());
318                        new_messages.push(nudge);
319                        has_more_tool_calls = true;
320                    } else if let Some(result_msg) = synthesize_plain_text_terminal_result(
321                        &assistant,
322                        turn_allowlist.as_ref(),
323                        tool_name,
324                        eager,
325                        &terminal_tool_names,
326                    ) {
327                        last_batch_terminated = true;
328                        current.messages.push(result_msg.clone());
329                        new_messages.push(result_msg.clone());
330                        tool_result_messages.push(result_msg);
331                    }
332                }
333            } else {
334                let ExecutedBatch {
335                    messages,
336                    terminate,
337                } = execute_tool_batch(
338                    &assistant,
339                    tool_calls,
340                    current,
341                    config,
342                    signal,
343                    turn_allowlist.as_ref(),
344                )
345                .await?;
346
347                tool_result_messages = messages;
348                has_more_tool_calls = !terminate;
349                last_batch_terminated = terminate;
350
351                for result_msg in &tool_result_messages {
352                    current.messages.push(result_msg.clone());
353                    new_messages.push(result_msg.clone());
354                }
355            }
356
357            emit(
358                config,
359                AgentEvent::TurnEnd {
360                    message: assistant,
361                    tool_results: tool_result_messages,
362                },
363            )
364            .await;
365
366            // Drain any new steering messages that arrived during the
367            // turn — except when the batch just emitted a unanimous
368            // terminator. A clean terminator vote is the model's
369            // "we're done" signal; further steering would re-prompt
370            // past the delivery and let the model drift.
371            pending = if last_batch_terminated {
372                Vec::new()
373            } else {
374                collect_steering(config).await
375            };
376        }
377
378        // The model produced no tool calls and no steering is queued. Give
379        // follow-up sources one last chance to inject another turn.
380        // Skip the follow-up source pass when the last batch
381        // terminated for the same reason steering is skipped above:
382        // a clean terminator vote means the run is done; follow-up
383        // sources exist to nudge the model toward a terminator when
384        // it failed to emit one, not to overrule one it already cast.
385        let follow_up = if last_batch_terminated {
386            Vec::new()
387        } else {
388            collect_follow_up(config).await
389        };
390        if !follow_up.is_empty() {
391            pending = follow_up;
392            continue 'outer;
393        }
394
395        break;
396    }
397
398    emit(
399        config,
400        AgentEvent::AgentEnd {
401            messages: new_messages.clone(),
402        },
403    )
404    .await;
405
406    Ok(LoopOutcome::Done)
407}
408
409async fn collect_steering(config: &LoopConfig) -> Vec<AgentMessage> {
410    let mut out = Vec::new();
411    for source in &config.plugins.steering {
412        out.extend(source.next_steering_messages().await);
413    }
414    out
415}
416
417async fn collect_follow_up(config: &LoopConfig) -> Vec<AgentMessage> {
418    let mut out = Vec::new();
419    for source in &config.plugins.follow_up {
420        out.extend(source.next_follow_up_messages().await);
421    }
422    out
423}
424
425fn synthesize_plain_text_terminal_result(
426    assistant: &AgentMessage,
427    turn_allowlist: Option<&std::collections::HashSet<String>>,
428    tool_name: &str,
429    eager: bool,
430    terminal_tool_names: &std::collections::HashSet<String>,
431) -> Option<AgentMessage> {
432    // The default contract is "only convert plain text once the runtime
433    // has narrowed the catalog to terminators" — preserves strict
434    // delivery shape for everyone else. When `eager` is set the gate is
435    // lifted: the host has signalled this provider can never honor
436    // forced tool choice, so prose IS the failure mode and the nudge
437    // cycle that normally narrows the allowlist would just burn turns.
438    if !eager && !is_terminal_only_allowlist(turn_allowlist, tool_name, terminal_tool_names) {
439        return None;
440    }
441    let text = plain_assistant_text(assistant)?;
442    Some(AgentMessage::ToolResult {
443        tool_call_id: format!("plain_text_terminal_fallback_{}", now_ms()),
444        tool_name: tool_name.to_string(),
445        content: ToolResultContent::text(text),
446        is_error: false,
447        narration: Some(
448            "Converted plain assistant text into terminal delivery for an auto-tool-choice provider."
449                .to_string(),
450        ),
451        details: None,
452        timestamp: Some(now_ms()),
453    })
454}
455
456fn plain_assistant_text(assistant: &AgentMessage) -> Option<String> {
457    let AgentMessage::Assistant { content, .. } = assistant else {
458        return None;
459    };
460    let text = crate::strip_thinking_tags(&content.plain_text())
461        .trim()
462        .to_string();
463    (!text.is_empty()).then_some(text)
464}
465
466/// Whether a turn's allowlist has narrowed to "terminal only" — it
467/// contains the configured fallback terminal tool and nothing but
468/// terminal/delivery tools. The set of *other* names that count as
469/// terminal comes from the active [`crate::protocol::ProtocolPolicy`]
470/// ([`crate::protocol::ProtocolPolicy::terminal_tool_names`]); the core
471/// hardcodes no product tool names. With the default policy (empty extra
472/// set) an allowlist is terminal-only exactly when it contains only the
473/// fallback tool itself.
474fn is_terminal_only_allowlist(
475    turn_allowlist: Option<&std::collections::HashSet<String>>,
476    terminal_tool: &str,
477    terminal_tool_names: &std::collections::HashSet<String>,
478) -> bool {
479    let Some(allowlist) = turn_allowlist else {
480        return false;
481    };
482    !allowlist.is_empty()
483        && allowlist.contains(terminal_tool)
484        && allowlist
485            .iter()
486            .all(|tool| tool == terminal_tool || terminal_tool_names.contains(tool))
487}
488
489// ─── Stream one assistant response ─────────────────────────────────
490
491/// Wrap [`stream_assistant_response`] with the configured max-output-
492/// tokens recovery ladder. When recovery is disabled (the default), this
493/// reduces to a single call. When enabled, a `StopReason::MaxTokens`
494/// turn is discarded and the next attempt re-streams with a larger
495/// cap until the ladder runs out or the model produces a non-truncated
496/// turn.
497///
498/// Discarded turns *do* fire `MessageStart`/`MessageEnd` from the
499/// inner streamer — listeners that care must correlate via the
500/// `OutputTokensEscalation` event that this wrapper emits before each
501/// retry. Persistence layers should treat the message that immediately
502/// precedes an `OutputTokensEscalation` as overridden by the next
503/// `MessageEnd`.
504/// Wraps [`stream_with_max_tokens_recovery`] with context-overflow
505/// recovery: when the provider rejects the request for exceeding its
506/// window ([`StreamError::ContextOverflow`]) and an overflow-recovery
507/// hook is installed, shrink `current.messages` in place (persisting it
508/// so later turns don't re-expand), emit the diff event, and retry the
509/// same LLM call. With no hook, the overflow propagates unchanged. A
510/// recovery that fails to shrink also stops the loop rather than spinning.
511async fn stream_with_overflow_recovery(
512    current: &mut AgentContext,
513    config: &LoopConfig,
514    signal: &CancellationToken,
515    iteration: usize,
516) -> Result<(AgentMessage, Option<std::collections::HashSet<String>>), LoopError> {
517    loop {
518        match stream_with_max_tokens_recovery(current, config, signal, iteration).await {
519            Err(LoopError::Stream(StreamError::ContextOverflow(message))) => {
520                let Some(recovery) = config.overflow_recovery.clone() else {
521                    return Err(LoopError::Stream(StreamError::ContextOverflow(message)));
522                };
523                if signal.is_cancelled() {
524                    return Err(LoopError::Stream(StreamError::ContextOverflow(message)));
525                }
526
527                // Compute the observables before taking the history, so the
528                // borrow doesn't collide with the `mem::take` below.
529                let usage = last_provider_usage(&current.messages);
530                let cx = TransformContext {
531                    signal,
532                    model_id: config.model_id.as_deref().unwrap_or(""),
533                    iteration,
534                    last_provider_usage: usage.as_ref(),
535                    estimator: &*config.token_estimator,
536                };
537                let before = std::mem::take(&mut current.messages);
538                let before_size = cx.estimator.estimate_messages(&before);
539                let after = recovery.recover(before.clone(), &cx).await;
540
541                // No-progress guard: a recovery that didn't actually SHRINK the
542                // history (measured in estimated tokens, not message count —
543                // compaction can trade many messages for a summary + tail
544                // without reducing the count) would just overflow again.
545                // Surface the overflow instead of retrying forever.
546                if cx.estimator.estimate_messages(&after) >= before_size {
547                    current.messages = before;
548                    return Err(LoopError::Stream(StreamError::ContextOverflow(message)));
549                }
550                emit(
551                    config,
552                    AgentEvent::ContextTransformApplied {
553                        iteration,
554                        plugin: recovery.name(),
555                        before,
556                        after: after.clone(),
557                    },
558                )
559                .await;
560                current.messages = after;
561                // Retry the same LLM call against the shrunk history.
562            }
563            other => return other,
564        }
565    }
566}
567
568async fn stream_with_max_tokens_recovery(
569    context: &AgentContext,
570    config: &LoopConfig,
571    signal: &CancellationToken,
572    iteration: usize,
573) -> Result<(AgentMessage, Option<std::collections::HashSet<String>>), LoopError> {
574    let mut current_cap = config.max_output_tokens;
575    let mut max_tokens_attempt: u32 = 0;
576    let mut empty_stream_attempts: u32 = 0;
577    let mut zero_output_transport_attempts: u32 = 0;
578    let mut transient_stream_attempts: u32 = 0;
579    let mut zero_output_recovery_context: Option<AgentContext> = None;
580    let mut reasoning = config.reasoning;
581
582    loop {
583        let attempt_context = zero_output_recovery_context.as_ref().unwrap_or(context);
584        let (assistant, allowlist) = match stream_assistant_response(
585            attempt_context,
586            config,
587            signal,
588            iteration,
589            current_cap,
590            reasoning,
591        )
592        .await
593        {
594            Ok(pair) => pair,
595            Err(LoopError::Stream(StreamError::Empty)) => {
596                empty_stream_attempts = empty_stream_attempts.saturating_add(1);
597                let delay =
598                    provider_retry_delay(EMPTY_STREAM_RETRY_INITIAL_DELAY, empty_stream_attempts);
599                tokio::select! {
600                    _ = signal.cancelled() => return Err(LoopError::Aborted),
601                    _ = tokio::time::sleep(delay) => {}
602                }
603                continue;
604            }
605            Err(LoopError::Stream(StreamError::ZeroOutputTransport(_))) => {
606                zero_output_transport_attempts = zero_output_transport_attempts.saturating_add(1);
607                zero_output_recovery_context =
608                    Some(context_with_zero_output_transport_recovery(context));
609                reasoning = zero_output_transport_retry_reasoning(config.reasoning);
610                let delay = provider_retry_delay(
611                    ZERO_OUTPUT_TRANSPORT_RETRY_INITIAL_DELAY,
612                    zero_output_transport_attempts,
613                );
614                tokio::select! {
615                    _ = signal.cancelled() => return Err(LoopError::Aborted),
616                    _ = tokio::time::sleep(delay) => {}
617                }
618                continue;
619            }
620            Err(LoopError::Stream(
621                StreamError::Transient(_) | StreamError::ProviderRateLimited(_),
622            )) => {
623                transient_stream_attempts = transient_stream_attempts.saturating_add(1);
624                let delay = provider_retry_delay(
625                    ZERO_OUTPUT_TRANSPORT_RETRY_INITIAL_DELAY,
626                    transient_stream_attempts,
627                );
628                tokio::select! {
629                    _ = signal.cancelled() => return Err(LoopError::Aborted),
630                    _ = tokio::time::sleep(delay) => {}
631                }
632                continue;
633            }
634            Err(err) => return Err(err),
635        };
636
637        let stop_reason = match &assistant {
638            AgentMessage::Assistant { stop_reason, .. } => *stop_reason,
639            _ => StopReason::Other,
640        };
641        if stop_reason == StopReason::Aborted {
642            return Err(LoopError::Aborted);
643        }
644        if stop_reason == StopReason::Error {
645            transient_stream_attempts = transient_stream_attempts.saturating_add(1);
646            let delay = provider_retry_delay(
647                ZERO_OUTPUT_TRANSPORT_RETRY_INITIAL_DELAY,
648                transient_stream_attempts,
649            );
650            tokio::select! {
651                _ = signal.cancelled() => return Err(LoopError::Aborted),
652                _ = tokio::time::sleep(delay) => {}
653            }
654            continue;
655        }
656        if stop_reason != StopReason::MaxTokens {
657            return Ok((assistant, allowlist));
658        }
659        // No starting cap means there's no number to scale from. Refuse
660        // recovery rather than guess — the deployment hadn't pinned a
661        // cap, so the truncation came from a provider-side limit we
662        // don't know how to raise.
663        let Some(prev_cap) = current_cap else {
664            return Ok((assistant, allowlist));
665        };
666        let new_cap = prev_cap.saturating_mul(2);
667        if new_cap <= prev_cap {
668            return Ok((assistant, allowlist));
669        }
670
671        max_tokens_attempt = max_tokens_attempt.saturating_add(1);
672        emit(
673            config,
674            AgentEvent::OutputTokensEscalation {
675                attempt: max_tokens_attempt,
676                prev_cap,
677                new_cap,
678            },
679        )
680        .await;
681        current_cap = Some(new_cap);
682        // Discard the truncated `assistant` by simply not pushing it
683        // into the caller's transcript. The MessageStart/MessageEnd
684        // events for it already fired from the inner streamer; the
685        // OutputTokensEscalation event above is the listener's signal
686        // to roll the previous pair back from any projection.
687    }
688}
689
690fn provider_retry_delay(base: std::time::Duration, attempt: u32) -> std::time::Duration {
691    base.saturating_mul(attempt.min(64))
692        .min(PROVIDER_RETRY_MAX_DELAY)
693}
694
695async fn stream_assistant_response(
696    context: &AgentContext,
697    config: &LoopConfig,
698    signal: &CancellationToken,
699    iteration: usize,
700    max_output_tokens: Option<u32>,
701    reasoning: ReasoningEffort,
702) -> Result<(AgentMessage, Option<std::collections::HashSet<String>>), LoopError> {
703    // Apply context transforms in registration order. The
704    // `TransformContext` carries the cancellation signal plus a few
705    // cheap observables (model id, iteration, last-turn provider
706    // usage, token estimator) so each transform can decide locally
707    // without the loop widening the trait per-knob.
708    let last_provider_usage = last_provider_usage(&context.messages);
709    let cx = TransformContext {
710        signal,
711        model_id: config.model_id.as_deref().unwrap_or(""),
712        iteration,
713        last_provider_usage: last_provider_usage.as_ref(),
714        estimator: &*config.token_estimator,
715    };
716    let mut messages = context.messages.clone();
717    // Each transform's diff is observable so post-mortems can attribute
718    // a specific compaction (shrinker, microcompactor, history-repair,
719    // …) to the missing slice the model went on to misuse. Cloning is
720    // cheap relative to the actual transform work, and the eval-side
721    // observer is the one consumer that wants this much detail; other
722    // sinks ignore the variant.
723    for transform in &config.plugins.context_transform {
724        // Cheap pre-check: plugins that can locally decide they have
725        // nothing to do (no browser snapshots, history under budget, …)
726        // skip the clone + diff-event entirely. Default impl returns
727        // `true`, so plugins that haven't opted in still run on every
728        // round.
729        if !transform.should_run(&messages, &cx) {
730            continue;
731        }
732        let before = messages.clone();
733        messages = transform.transform(messages, &cx).await;
734        emit(
735            config,
736            AgentEvent::ContextTransformApplied {
737                iteration,
738                plugin: transform.name(),
739                before,
740                after: messages.clone(),
741            },
742        )
743        .await;
744    }
745
746    // Consult any registered ToolGate plugins for a per-turn allowlist.
747    // Each plugin returns `Some(set)` to narrow the advertised tools for
748    // exactly this LLM call. Multiple plugins compose by intersection;
749    // `None` plugins do not constrain. See `ToolGate` docs for rationale.
750    let allowlist = collect_tool_allowlist_with_events(config, iteration, &messages).await;
751
752    let tools = build_tool_schemas(config, allowlist.as_ref());
753    // Final snapshot of what the loop is about to send, captured after
754    // every transform/gate. Observers (eval per-turn dump, debugger,
755    // replay) take this as the source of truth for "what did the
756    // model see this turn?".
757    emit(
758        config,
759        AgentEvent::ProviderRequestPrepared {
760            iteration,
761            model_id: config.model_id.clone(),
762            system_prompt: context.system_prompt.clone(),
763            messages: messages.clone(),
764            tools: tools.clone(),
765            temperature: config.temperature,
766            max_output_tokens,
767        },
768    )
769    .await;
770    let request = StreamRequest {
771        system_prompt: context.system_prompt.clone(),
772        messages,
773        tools,
774        temperature: config.temperature,
775        max_output_tokens,
776        reasoning,
777        provider_extras: config
778            .provider_extras
779            .clone()
780            .unwrap_or(serde_json::Value::Null),
781        // `tool_choice: "required"` on every turn. The LLM-in-charge
782        // contract is "context → LLM → tool call → append result →
783        // repeat" — the model's job is to pick a tool, not emit
784        // narration. This assumes the catalog includes a terminal
785        // text-delivery tool, so required-on-every-turn doesn't trap the
786        // model: when the work is done it calls that delivery tool to
787        // return the answer. If the model loops on verification instead,
788        // the bug is in the catalog or prompt — not in the requirement.
789        force_tool_call: true,
790    };
791
792    let mut stream = config.stream.stream(request, signal.clone()).await;
793
794    let mut last_partial: Option<AgentMessage> = None;
795
796    while let Some(event) = stream.next().await {
797        match event {
798            StreamEvent::Start { partial } => {
799                emit(
800                    config,
801                    AgentEvent::MessageStart {
802                        message: partial.clone(),
803                    },
804                )
805                .await;
806                last_partial = Some(partial);
807            }
808            StreamEvent::Chunk(chunk) => {
809                if let Some(ref partial) = last_partial {
810                    emit(
811                        config,
812                        AgentEvent::MessageUpdate {
813                            partial: partial.clone(),
814                            chunk,
815                        },
816                    )
817                    .await;
818                }
819            }
820            StreamEvent::Done { message } => {
821                emit(
822                    config,
823                    AgentEvent::MessageEnd {
824                        message: message.clone(),
825                    },
826                )
827                .await;
828                return Ok((message, allowlist));
829            }
830            StreamEvent::Error {
831                partial,
832                kind,
833                message,
834            } => {
835                let stop_reason = match kind {
836                    StreamErrorKind::Aborted => StopReason::Aborted,
837                    _ => StopReason::Error,
838                };
839                let error_message = AgentMessage::Assistant {
840                    content: match &partial {
841                        AgentMessage::Assistant { content, .. } => content.clone(),
842                        _ => AssistantContent { blocks: Vec::new() },
843                    },
844                    stop_reason,
845                    error_message: Some(message.clone()),
846                    timestamp: Some(now_ms()),
847                    usage: None,
848                };
849                emit(
850                    config,
851                    AgentEvent::MessageEnd {
852                        message: error_message.clone(),
853                    },
854                )
855                .await;
856                return Err(loop_error_from_stream_kind(kind, message));
857            }
858        }
859    }
860
861    // Stream ended without `Done` or `Error`. Synthesize an empty
862    // assistant message so the loop can recover.
863    let empty = AgentMessage::Assistant {
864        content: AssistantContent { blocks: Vec::new() },
865        stop_reason: StopReason::Error,
866        error_message: Some("stream ended without terminal event".into()),
867        timestamp: Some(now_ms()),
868        usage: None,
869    };
870    emit(
871        config,
872        AgentEvent::MessageEnd {
873            message: empty.clone(),
874        },
875    )
876    .await;
877    Err(LoopError::Stream(StreamError::Empty))
878}
879
880fn context_with_zero_output_transport_recovery(context: &AgentContext) -> AgentContext {
881    let mut recovered = context.clone();
882    recovered.messages.push(AgentMessage::System {
883        content: ZERO_OUTPUT_TRANSPORT_RECOVERY_CONTEXT.to_string(),
884        timestamp: Some(now_ms()),
885    });
886    recovered
887}
888
889fn zero_output_transport_retry_reasoning(reasoning: ReasoningEffort) -> ReasoningEffort {
890    match reasoning {
891        ReasoningEffort::Medium | ReasoningEffort::High | ReasoningEffort::XHigh => {
892            ReasoningEffort::Minimal
893        }
894        ReasoningEffort::None | ReasoningEffort::Minimal | ReasoningEffort::Low => reasoning,
895    }
896}
897
898fn loop_error_from_stream_kind(kind: StreamErrorKind, message: String) -> LoopError {
899    // StreamFn implementations own transport retries. Once an error
900    // reaches the loop, it is the terminal outcome of that provider
901    // attempt and must not be reclassified as a successful assistant
902    // turn.
903    match kind {
904        StreamErrorKind::Transient => LoopError::Stream(StreamError::Transient(message)),
905        StreamErrorKind::ProviderRateLimited => {
906            LoopError::Stream(StreamError::ProviderRateLimited(message))
907        }
908        StreamErrorKind::ZeroOutputTransport => {
909            LoopError::Stream(StreamError::ZeroOutputTransport(message))
910        }
911        StreamErrorKind::Fatal => LoopError::Stream(StreamError::Fatal(message)),
912        StreamErrorKind::InconsistentToolHistory => {
913            LoopError::Stream(StreamError::InconsistentToolHistory(message))
914        }
915        StreamErrorKind::Empty => LoopError::Stream(StreamError::Empty),
916        StreamErrorKind::Aborted => LoopError::Aborted,
917        StreamErrorKind::ContextOverflow => {
918            LoopError::Stream(StreamError::ContextOverflow(message))
919        }
920    }
921}
922
923fn now_ms() -> u64 {
924    SystemTime::now()
925        .duration_since(UNIX_EPOCH)
926        .map(|d| d.as_millis() as u64)
927        .unwrap_or(0)
928}
929
930/// Walk back through `messages` and return the most recent provider
931/// usage block reported on an assistant turn, if any. `None` on the
932/// very first turn or when the active provider doesn't surface usage.
933fn last_provider_usage(messages: &[AgentMessage]) -> Option<Usage> {
934    messages.iter().rev().find_map(|message| match message {
935        AgentMessage::Assistant {
936            usage: Some(usage), ..
937        } => Some(usage.clone()),
938        _ => None,
939    })
940}
941
942fn build_tool_schemas(
943    config: &LoopConfig,
944    allowlist: Option<&std::collections::HashSet<String>>,
945) -> Vec<ToolSchema> {
946    config
947        .tools
948        .iter()
949        .filter(|tool| match allowlist {
950            Some(set) => set.contains(tool.name()),
951            None => true,
952        })
953        .map(|tool| ToolSchema {
954            name: tool.name().to_string(),
955            description: tool.description().to_string(),
956            parameters: tool.parameters_schema(),
957        })
958        .collect()
959}
960
961/// Poll every registered `ToolGate` plugin and intersect their
962/// allowlists. Returns `None` when no plugin returned an allowlist
963/// (the common case — no narrowing). Returns `Some(set)` when at
964/// least one plugin is gating; multiple gates compose by intersection
965/// unless their non-empty allowlists conflict to an empty set, in which
966/// case the highest-priority gate wins and a typed conflict event is
967/// emitted.
968/// Resolve the per-turn tool allowlist by composing every registered
969/// `ToolGate` plugin (intersection) and emit one
970/// [`AgentEvent::ToolGateApplied`] per gate so observers can attribute
971/// the final allowlist to specific plugins.
972async fn collect_tool_allowlist_with_events(
973    config: &LoopConfig,
974    iteration: usize,
975    messages: &[AgentMessage],
976) -> Option<std::collections::HashSet<String>> {
977    if config.plugins.tool_gate.is_empty() {
978        return None;
979    }
980    let conversation_id = config.conversation_id.as_deref();
981    let available_tool_names: Vec<&str> = config.tools.iter().map(|t| t.name()).collect();
982    let mut decisions: Vec<GateAllowDecision> = Vec::new();
983    for gate in &config.plugins.tool_gate {
984        let ctx = crate::plugin::ToolGateContext {
985            iteration,
986            messages,
987            conversation_id,
988            available_tool_names: &available_tool_names,
989        };
990        let decision = gate.next_turn_tool_allowlist(ctx).await;
991        emit(
992            config,
993            AgentEvent::ToolGateApplied {
994                iteration,
995                plugin: gate.name(),
996                allow: decision.as_ref().map(|set| {
997                    let mut sorted: Vec<String> = set.iter().cloned().collect();
998                    sorted.sort();
999                    sorted
1000                }),
1001            },
1002        )
1003        .await;
1004        if let Some(set) = decision {
1005            let suppresses_advisory =
1006                gate.suppresses_advisory_gates(crate::plugin::ToolGateContext {
1007                    iteration,
1008                    messages,
1009                    conversation_id,
1010                    available_tool_names: &available_tool_names,
1011                });
1012            decisions.push(GateAllowDecision {
1013                plugin: gate.name(),
1014                priority: gate.conflict_priority(),
1015                class: gate.tool_gate_class(),
1016                suppresses_advisory,
1017                allow: set,
1018            });
1019        }
1020    }
1021    let suppression_priority = decisions
1022        .iter()
1023        .filter(|decision| decision.suppresses_advisory)
1024        .map(|decision| decision.priority)
1025        .max();
1026    let active_decisions = decisions
1027        .iter()
1028        .filter(|decision| {
1029            !matches!(
1030                suppression_priority,
1031                Some(priority)
1032                    if decision.class == crate::plugin::ToolGateClass::Advisory
1033                        && decision.priority < priority
1034            )
1035        })
1036        .collect::<Vec<_>>();
1037    let mut combined: Option<std::collections::HashSet<String>> = None;
1038    for decision in &active_decisions {
1039        combined = Some(match combined {
1040            Some(prev) => prev.intersection(&decision.allow).cloned().collect(),
1041            None => decision.allow.clone(),
1042        });
1043    }
1044    if combined.as_ref().is_some_and(|allow| allow.is_empty()) {
1045        let non_empty_decisions = active_decisions
1046            .iter()
1047            .filter(|decision| !decision.allow.is_empty())
1048            .map(|decision| (decision.plugin, decision.priority, decision.allow.clone()))
1049            .collect::<Vec<_>>();
1050        let resolved = resolve_empty_tool_gate_intersection(&non_empty_decisions);
1051        let (chosen_plugin, allow, reason) = match resolved {
1052            Some((plugin, allow, reason)) => (Some(plugin.to_string()), allow, reason),
1053            None => (
1054                None,
1055                std::collections::HashSet::new(),
1056                "all gating plugins returned empty allowlists".to_string(),
1057            ),
1058        };
1059        let sorted_allow = sorted_tool_names(&allow);
1060        emit(
1061            config,
1062            AgentEvent::ToolGateConflictResolved {
1063                iteration,
1064                plugins: active_decisions
1065                    .iter()
1066                    .map(|decision| decision.plugin.to_string())
1067                    .collect(),
1068                chosen_plugin,
1069                allow: sorted_allow,
1070                reason,
1071            },
1072        )
1073        .await;
1074        return if allow.is_empty() { None } else { Some(allow) };
1075    }
1076    combined
1077}
1078
1079struct GateAllowDecision {
1080    plugin: &'static str,
1081    priority: i32,
1082    class: crate::plugin::ToolGateClass,
1083    suppresses_advisory: bool,
1084    allow: std::collections::HashSet<String>,
1085}
1086
1087fn resolve_empty_tool_gate_intersection(
1088    decisions: &[(&'static str, i32, std::collections::HashSet<String>)],
1089) -> Option<(&'static str, std::collections::HashSet<String>, String)> {
1090    decisions
1091        .iter()
1092        .max_by(|(left_plugin, left_priority, left), (right_plugin, right_priority, right)| {
1093            left_priority
1094                .cmp(right_priority)
1095                .then_with(|| right.len().cmp(&left.len()))
1096                .then_with(|| right_plugin.cmp(left_plugin))
1097        })
1098        .map(|(plugin, priority, allow)| {
1099            (
1100                *plugin,
1101                allow.clone(),
1102                format!(
1103                    "empty intersection repaired by highest-priority owner `{plugin}` (priority {priority})"
1104                ),
1105            )
1106        })
1107}
1108
1109fn sorted_tool_names(set: &std::collections::HashSet<String>) -> Vec<String> {
1110    let mut sorted: Vec<String> = set.iter().cloned().collect();
1111    sorted.sort();
1112    sorted
1113}
1114
1115#[cfg(test)]
1116mod tests {
1117    use super::*;
1118    use crate::config::AgentBuilder;
1119    use crate::plugin::{
1120        FollowUpSource, Plugin, PluginCapabilities, ToolGate, ToolGateClass, ToolGateContext,
1121    };
1122    use crate::stream::{ReasoningEffort, StreamFn};
1123    use crate::types::{AssistantBlock, UserContent};
1124    use futures::stream::{self, BoxStream};
1125    use std::sync::{
1126        atomic::{AtomicUsize, Ordering},
1127        Arc, Mutex,
1128    };
1129
1130    #[test]
1131    fn inconsistent_tool_history_stream_kind_stays_typed_at_loop_boundary() {
1132        let error = loop_error_from_stream_kind(
1133            StreamErrorKind::InconsistentToolHistory,
1134            "interleaved tool result batch".into(),
1135        );
1136
1137        assert!(matches!(
1138            error,
1139            LoopError::Stream(StreamError::InconsistentToolHistory(message))
1140                if message == "interleaved tool result batch"
1141        ));
1142    }
1143
1144    fn empty_assistant_message() -> AgentMessage {
1145        AgentMessage::Assistant {
1146            content: AssistantContent { blocks: Vec::new() },
1147            stop_reason: StopReason::Other,
1148            error_message: None,
1149            timestamp: None,
1150            usage: None,
1151        }
1152    }
1153
1154    fn text_assistant_message(text: impl Into<String>) -> AgentMessage {
1155        AgentMessage::Assistant {
1156            content: AssistantContent::text(text),
1157            stop_reason: StopReason::EndTurn,
1158            error_message: None,
1159            timestamp: None,
1160            usage: None,
1161        }
1162    }
1163
1164    fn tool_call_assistant_message(name: impl Into<String>, id: impl Into<String>) -> AgentMessage {
1165        AgentMessage::Assistant {
1166            content: AssistantContent::with_tool_calls(
1167                None,
1168                vec![crate::tool::ToolCall {
1169                    id: id.into(),
1170                    name: name.into(),
1171                    arguments: serde_json::json!({}),
1172                }],
1173            ),
1174            stop_reason: StopReason::ToolUse,
1175            error_message: None,
1176            timestamp: None,
1177            usage: None,
1178        }
1179    }
1180
1181    struct EmptyThenTextStream {
1182        calls: AtomicUsize,
1183        failures_before_success: usize,
1184    }
1185
1186    impl EmptyThenTextStream {
1187        fn new(failures_before_success: usize) -> Self {
1188            Self {
1189                calls: AtomicUsize::new(0),
1190                failures_before_success,
1191            }
1192        }
1193    }
1194
1195    #[derive(Default)]
1196    struct ZeroOutputThenTextStream {
1197        calls: AtomicUsize,
1198        requests: Mutex<Vec<StreamRequest>>,
1199    }
1200
1201    impl ZeroOutputThenTextStream {
1202        fn requests(&self) -> Vec<StreamRequest> {
1203            self.requests.lock().unwrap().clone()
1204        }
1205    }
1206
1207    #[derive(Default)]
1208    struct RepeatedTextStream {
1209        calls: AtomicUsize,
1210    }
1211
1212    struct PlainTextThenTerminatorStream {
1213        calls: AtomicUsize,
1214        plain_text_turns: usize,
1215    }
1216
1217    struct TerminalOnlyGate;
1218    struct TerminalWithStatusGate;
1219
1220    /// A product protocol policy that declares several delivery/status
1221    /// tools (beyond the configured fallback tool) as terminal, so an
1222    /// allowlist narrowed to `{message_info, message_result}` still
1223    /// classifies as terminal-only. The core ships none of these names;
1224    /// they live behind the policy.
1225    struct TestTerminalPolicy;
1226    impl crate::protocol::ProtocolPolicy for TestTerminalPolicy {
1227        fn terminal_tool_names(&self) -> std::collections::HashSet<String> {
1228            [
1229                "message_info",
1230                "message_ask",
1231                "message_result",
1232                "terminator",
1233            ]
1234            .iter()
1235            .map(|s| s.to_string())
1236            .collect()
1237        }
1238    }
1239    struct StaticAllowGate {
1240        name: &'static str,
1241        tools: &'static [&'static str],
1242        priority: i32,
1243        class: ToolGateClass,
1244        suppresses_advisory: bool,
1245    }
1246
1247    impl Plugin for TerminalOnlyGate {
1248        fn name(&self) -> &'static str {
1249            "terminal_only_gate"
1250        }
1251
1252        fn capabilities(&self) -> PluginCapabilities {
1253            PluginCapabilities::tool_gate()
1254        }
1255    }
1256
1257    #[async_trait::async_trait]
1258    impl ToolGate for TerminalOnlyGate {
1259        async fn next_turn_tool_allowlist(
1260            &self,
1261            _ctx: ToolGateContext<'_>,
1262        ) -> Option<std::collections::HashSet<String>> {
1263            Some(["message_result".to_string()].into_iter().collect())
1264        }
1265    }
1266
1267    impl Plugin for TerminalWithStatusGate {
1268        fn name(&self) -> &'static str {
1269            "terminal_with_status_gate"
1270        }
1271
1272        fn capabilities(&self) -> PluginCapabilities {
1273            PluginCapabilities::tool_gate()
1274        }
1275    }
1276
1277    #[async_trait::async_trait]
1278    impl ToolGate for TerminalWithStatusGate {
1279        async fn next_turn_tool_allowlist(
1280            &self,
1281            _ctx: ToolGateContext<'_>,
1282        ) -> Option<std::collections::HashSet<String>> {
1283            Some(
1284                ["message_info".to_string(), "message_result".to_string()]
1285                    .into_iter()
1286                    .collect(),
1287            )
1288        }
1289    }
1290
1291    impl Plugin for StaticAllowGate {
1292        fn name(&self) -> &'static str {
1293            self.name
1294        }
1295
1296        fn capabilities(&self) -> PluginCapabilities {
1297            PluginCapabilities::tool_gate()
1298        }
1299    }
1300
1301    #[async_trait::async_trait]
1302    impl ToolGate for StaticAllowGate {
1303        fn conflict_priority(&self) -> i32 {
1304            self.priority
1305        }
1306
1307        fn tool_gate_class(&self) -> ToolGateClass {
1308            self.class
1309        }
1310
1311        fn suppresses_advisory_gates(&self, _ctx: ToolGateContext<'_>) -> bool {
1312            self.suppresses_advisory
1313        }
1314
1315        async fn next_turn_tool_allowlist(
1316            &self,
1317            _ctx: ToolGateContext<'_>,
1318        ) -> Option<std::collections::HashSet<String>> {
1319            Some(self.tools.iter().map(|name| (*name).to_string()).collect())
1320        }
1321    }
1322
1323    impl PlainTextThenTerminatorStream {
1324        fn new(plain_text_turns: usize) -> Self {
1325            Self {
1326                calls: AtomicUsize::new(0),
1327                plain_text_turns,
1328            }
1329        }
1330    }
1331
1332    #[async_trait::async_trait]
1333    impl StreamFn for EmptyThenTextStream {
1334        async fn stream(
1335            &self,
1336            _request: StreamRequest,
1337            _signal: CancellationToken,
1338        ) -> BoxStream<'static, StreamEvent> {
1339            let call = self.calls.fetch_add(1, Ordering::SeqCst);
1340            let partial = empty_assistant_message();
1341            if call < self.failures_before_success {
1342                return Box::pin(stream::iter(vec![
1343                    StreamEvent::Start {
1344                        partial: partial.clone(),
1345                    },
1346                    StreamEvent::Error {
1347                        partial,
1348                        kind: StreamErrorKind::Empty,
1349                        message: "empty provider response".to_string(),
1350                    },
1351                ]));
1352            }
1353            Box::pin(stream::iter(vec![
1354                StreamEvent::Start { partial },
1355                StreamEvent::Done {
1356                    message: text_assistant_message("recovered"),
1357                },
1358            ]))
1359        }
1360    }
1361
1362    #[async_trait::async_trait]
1363    impl StreamFn for RepeatedTextStream {
1364        async fn stream(
1365            &self,
1366            _request: StreamRequest,
1367            _signal: CancellationToken,
1368        ) -> BoxStream<'static, StreamEvent> {
1369            let call = self.calls.fetch_add(1, Ordering::SeqCst);
1370            let partial = empty_assistant_message();
1371            Box::pin(stream::iter(vec![
1372                StreamEvent::Start { partial },
1373                StreamEvent::Done {
1374                    message: text_assistant_message(format!("plain stop {call}")),
1375                },
1376            ]))
1377        }
1378    }
1379
1380    #[async_trait::async_trait]
1381    impl StreamFn for PlainTextThenTerminatorStream {
1382        async fn stream(
1383            &self,
1384            _request: StreamRequest,
1385            _signal: CancellationToken,
1386        ) -> BoxStream<'static, StreamEvent> {
1387            let call = self.calls.fetch_add(1, Ordering::SeqCst);
1388            let partial = empty_assistant_message();
1389            let message = if call < self.plain_text_turns {
1390                text_assistant_message(format!("plain stop {call}"))
1391            } else if call == self.plain_text_turns {
1392                tool_call_assistant_message("terminator", "tc-terminator")
1393            } else {
1394                panic!("unexpected stream call after terminal turn: {call}")
1395            };
1396            Box::pin(stream::iter(vec![
1397                StreamEvent::Start { partial },
1398                StreamEvent::Done { message },
1399            ]))
1400        }
1401    }
1402
1403    #[async_trait::async_trait]
1404    impl StreamFn for ZeroOutputThenTextStream {
1405        async fn stream(
1406            &self,
1407            request: StreamRequest,
1408            _signal: CancellationToken,
1409        ) -> BoxStream<'static, StreamEvent> {
1410            self.requests.lock().unwrap().push(request);
1411            let call = self.calls.fetch_add(1, Ordering::SeqCst);
1412            let partial = empty_assistant_message();
1413            if call == 0 {
1414                return Box::pin(stream::iter(vec![
1415                    StreamEvent::Start {
1416                        partial: partial.clone(),
1417                    },
1418                    StreamEvent::Error {
1419                        partial,
1420                        kind: StreamErrorKind::ZeroOutputTransport,
1421                        message: "response body decode failed before output".to_string(),
1422                    },
1423                ]));
1424            }
1425            Box::pin(stream::iter(vec![
1426                StreamEvent::Start { partial },
1427                StreamEvent::Done {
1428                    message: text_assistant_message("recovered from transport"),
1429                },
1430            ]))
1431        }
1432    }
1433
1434    /// Overflows on the first call, then (once the history is shrunk)
1435    /// returns text. Records each request so a test can assert what the
1436    /// retried call actually sent.
1437    struct OverflowThenTextStream {
1438        calls: AtomicUsize,
1439        requests: Mutex<Vec<StreamRequest>>,
1440        overflows_before_success: usize,
1441    }
1442
1443    impl OverflowThenTextStream {
1444        fn new(overflows_before_success: usize) -> Self {
1445            Self {
1446                calls: AtomicUsize::new(0),
1447                requests: Mutex::new(Vec::new()),
1448                overflows_before_success,
1449            }
1450        }
1451    }
1452
1453    impl Default for OverflowThenTextStream {
1454        fn default() -> Self {
1455            Self::new(1)
1456        }
1457    }
1458
1459    #[async_trait::async_trait]
1460    impl StreamFn for OverflowThenTextStream {
1461        async fn stream(
1462            &self,
1463            request: StreamRequest,
1464            _signal: CancellationToken,
1465        ) -> BoxStream<'static, StreamEvent> {
1466            self.requests.lock().unwrap().push(request);
1467            let call = self.calls.fetch_add(1, Ordering::SeqCst);
1468            let partial = empty_assistant_message();
1469            if call < self.overflows_before_success {
1470                return Box::pin(stream::iter(vec![
1471                    StreamEvent::Start {
1472                        partial: partial.clone(),
1473                    },
1474                    StreamEvent::Error {
1475                        partial,
1476                        kind: StreamErrorKind::ContextOverflow,
1477                        message: "maximum context length exceeded".to_string(),
1478                    },
1479                ]));
1480            }
1481            Box::pin(stream::iter(vec![
1482                StreamEvent::Start { partial },
1483                StreamEvent::Done {
1484                    message: text_assistant_message("recovered after shrink"),
1485                },
1486            ]))
1487        }
1488    }
1489
1490    /// Recovery that drops the oldest message — enough to prove the loop
1491    /// persists each material shrink and can retry repeatedly.
1492    struct KeepLastRecovery {
1493        calls: Arc<AtomicUsize>,
1494    }
1495
1496    #[async_trait::async_trait]
1497    impl crate::plugin::ContextOverflowRecovery for KeepLastRecovery {
1498        async fn recover(
1499            &self,
1500            mut messages: Vec<AgentMessage>,
1501            _cx: &TransformContext<'_>,
1502        ) -> Vec<AgentMessage> {
1503            self.calls.fetch_add(1, Ordering::SeqCst);
1504            if !messages.is_empty() {
1505                messages.remove(0);
1506            }
1507            messages
1508        }
1509        fn name(&self) -> &'static str {
1510            "keep_last_recovery"
1511        }
1512    }
1513
1514    #[tokio::test]
1515    async fn context_overflow_is_recovered_by_shrinking_and_retrying() {
1516        let stream = Arc::new(OverflowThenTextStream::default());
1517        let recovery_calls = Arc::new(AtomicUsize::new(0));
1518        let config = AgentBuilder::new()
1519            .stream(stream.clone())
1520            .model_id("test-model")
1521            .overflow_recovery(KeepLastRecovery {
1522                calls: recovery_calls.clone(),
1523            })
1524            .build()
1525            .expect("config builds");
1526        let mut context = AgentContext::new("system").with_messages(vec![
1527            AgentMessage::User {
1528                content: UserContent::Text("first".to_string()),
1529                timestamp: None,
1530            },
1531            AgentMessage::User {
1532                content: UserContent::Text("keep me".to_string()),
1533                timestamp: None,
1534            },
1535        ]);
1536
1537        let (assistant, _allowlist) =
1538            stream_with_overflow_recovery(&mut context, &config, &CancellationToken::new(), 0)
1539                .await
1540                .expect("overflow recovery should retry");
1541
1542        let AgentMessage::Assistant { content, .. } = assistant else {
1543            panic!("expected assistant response");
1544        };
1545        assert_eq!(content.plain_text(), "recovered after shrink");
1546        assert_eq!(stream.calls.load(Ordering::SeqCst), 2, "one retry");
1547        assert_eq!(recovery_calls.load(Ordering::SeqCst), 1);
1548        // The shrink is persisted into the live transcript…
1549        assert_eq!(context.messages.len(), 1);
1550        // …and the retried request sent only the shrunk history.
1551        let retried = &stream.requests.lock().unwrap()[1];
1552        assert_eq!(retried.messages.len(), 1);
1553    }
1554
1555    #[tokio::test]
1556    async fn context_overflow_recovery_outlives_the_legacy_two_attempt_cap() {
1557        let stream = Arc::new(OverflowThenTextStream::new(3));
1558        let recovery_calls = Arc::new(AtomicUsize::new(0));
1559        let config = AgentBuilder::new()
1560            .stream(stream.clone())
1561            .model_id("test-model")
1562            .overflow_recovery(KeepLastRecovery {
1563                calls: recovery_calls.clone(),
1564            })
1565            .build()
1566            .expect("config builds");
1567        let mut context = AgentContext::new("system").with_messages(
1568            ["first", "second", "third", "keep me"]
1569                .into_iter()
1570                .map(|text| AgentMessage::User {
1571                    content: UserContent::Text(text.to_string()),
1572                    timestamp: None,
1573                })
1574                .collect(),
1575        );
1576
1577        let (assistant, _allowlist) =
1578            stream_with_overflow_recovery(&mut context, &config, &CancellationToken::new(), 0)
1579                .await
1580                .expect("fourth stream attempt should recover");
1581
1582        let AgentMessage::Assistant { content, .. } = assistant else {
1583            panic!("expected assistant response");
1584        };
1585        assert_eq!(content.plain_text(), "recovered after shrink");
1586        assert_eq!(stream.calls.load(Ordering::SeqCst), 4);
1587        assert_eq!(recovery_calls.load(Ordering::SeqCst), 3);
1588        assert_eq!(context.messages.len(), 1);
1589    }
1590
1591    #[tokio::test]
1592    async fn context_overflow_without_recovery_propagates() {
1593        let stream = Arc::new(OverflowThenTextStream::default());
1594        let config = AgentBuilder::new()
1595            .stream(stream.clone())
1596            .model_id("test-model")
1597            .build()
1598            .expect("config builds");
1599        let mut context = AgentContext::new("system").with_messages(vec![AgentMessage::User {
1600            content: UserContent::Text("hi".to_string()),
1601            timestamp: None,
1602        }]);
1603
1604        let result =
1605            stream_with_overflow_recovery(&mut context, &config, &CancellationToken::new(), 0)
1606                .await;
1607        assert!(matches!(
1608            result,
1609            Err(LoopError::Stream(StreamError::ContextOverflow(_)))
1610        ));
1611        assert_eq!(stream.calls.load(Ordering::SeqCst), 1, "no retry");
1612    }
1613
1614    #[test]
1615    fn done_is_complete() {
1616        assert!(LoopOutcome::Done.is_complete());
1617    }
1618
1619    #[tokio::test(start_paused = true)]
1620    async fn empty_stream_recovery_outlives_the_legacy_three_attempt_cap() {
1621        let stream = Arc::new(EmptyThenTextStream::new(4));
1622        let config = AgentBuilder::new()
1623            .stream(stream.clone())
1624            .model_id("test-model")
1625            .build()
1626            .expect("config builds");
1627        let context = AgentContext::new("system").with_messages(vec![AgentMessage::User {
1628            content: UserContent::Text("continue".to_string()),
1629            timestamp: None,
1630        }]);
1631
1632        let (assistant, _allowlist) =
1633            stream_with_max_tokens_recovery(&context, &config, &CancellationToken::new(), 0)
1634                .await
1635                .expect("fifth stream attempt should recover");
1636
1637        let AgentMessage::Assistant { content, .. } = assistant else {
1638            panic!("expected assistant response");
1639        };
1640        assert_eq!(content.plain_text(), "recovered");
1641        assert_eq!(stream.calls.load(Ordering::SeqCst), 5);
1642    }
1643
1644    #[tokio::test]
1645    async fn zero_output_transport_error_is_retried_before_returning() {
1646        let stream = Arc::new(ZeroOutputThenTextStream::default());
1647        let config = AgentBuilder::new()
1648            .stream(stream.clone())
1649            .model_id("test-model")
1650            .reasoning(ReasoningEffort::High)
1651            .build()
1652            .expect("config builds");
1653        let context = AgentContext::new("system").with_messages(vec![AgentMessage::User {
1654            content: UserContent::Text("continue".to_string()),
1655            timestamp: None,
1656        }]);
1657
1658        let (assistant, _allowlist) =
1659            stream_with_max_tokens_recovery(&context, &config, &CancellationToken::new(), 0)
1660                .await
1661                .expect("second zero-output transport attempt should recover");
1662
1663        let AgentMessage::Assistant { content, .. } = assistant else {
1664            panic!("expected assistant response");
1665        };
1666        assert_eq!(content.plain_text(), "recovered from transport");
1667        assert_eq!(stream.calls.load(Ordering::SeqCst), 2);
1668
1669        let requests = stream.requests();
1670        assert_eq!(requests.len(), 2);
1671        assert_eq!(requests[0].reasoning, ReasoningEffort::High);
1672        assert_eq!(
1673            requests[1].reasoning,
1674            ReasoningEffort::Minimal,
1675            "zero-output replay should lower high reasoning so reasoning-heavy private-only spins can produce a tool call"
1676        );
1677        assert!(
1678            requests[1].messages.iter().any(|message| matches!(
1679                message,
1680                AgentMessage::System { content, .. }
1681                    if content.contains("transport recovery")
1682                        && content.contains("no visible assistant text")
1683                        && content.contains("no usable tool call")
1684                        && content.contains("unusable burst of partial tool calls")
1685                        && content.contains("exactly one next structured tool call")
1686                        && content.contains("next structured tool call")
1687            )),
1688            "zero-output replay must carry explicit recovery context"
1689        );
1690    }
1691
1692    /// `StreamFn` that emits one assistant turn with a single
1693    /// `terminator` tool call, then panics on subsequent invocations
1694    /// — the test asserts the loop never re-enters the LLM.
1695    struct TerminatorOnlyStream {
1696        calls: AtomicUsize,
1697    }
1698
1699    impl Default for TerminatorOnlyStream {
1700        fn default() -> Self {
1701            Self {
1702                calls: AtomicUsize::new(0),
1703            }
1704        }
1705    }
1706
1707    #[async_trait::async_trait]
1708    impl StreamFn for TerminatorOnlyStream {
1709        async fn stream(
1710            &self,
1711            _request: StreamRequest,
1712            _signal: CancellationToken,
1713        ) -> BoxStream<'static, StreamEvent> {
1714            let call = self.calls.fetch_add(1, Ordering::SeqCst);
1715            assert_eq!(
1716                call, 0,
1717                "terminate-on-turn-1 test must NOT re-enter the LLM after a successful terminator"
1718            );
1719            let partial = empty_assistant_message();
1720            let assistant = AgentMessage::Assistant {
1721                content: AssistantContent {
1722                    blocks: vec![AssistantBlock::ToolCall(crate::tool::ToolCall {
1723                        id: "tc-terminator-1".into(),
1724                        name: "terminator".into(),
1725                        arguments: serde_json::json!({}),
1726                    })],
1727                },
1728                stop_reason: StopReason::ToolUse,
1729                error_message: None,
1730                timestamp: None,
1731                usage: None,
1732            };
1733            Box::pin(stream::iter(vec![
1734                StreamEvent::Start { partial },
1735                StreamEvent::Done { message: assistant },
1736            ]))
1737        }
1738    }
1739
1740    /// Tool that always votes `terminate=true`. Mirrors the contract a
1741    /// downstream terminal/delivery tool upholds.
1742    struct TerminatorTool;
1743
1744    #[async_trait::async_trait]
1745    impl crate::tool::AgentTool for TerminatorTool {
1746        fn name(&self) -> &str {
1747            "terminator"
1748        }
1749
1750        fn description(&self) -> &str {
1751            "test terminator"
1752        }
1753
1754        fn parameters_schema(&self) -> serde_json::Value {
1755            serde_json::json!({"type": "object"})
1756        }
1757
1758        async fn execute(
1759            &self,
1760            _call_id: &str,
1761            _args: serde_json::Value,
1762            _signal: CancellationToken,
1763            _update: tokio::sync::mpsc::UnboundedSender<crate::tool::ToolResult>,
1764        ) -> Result<crate::tool::ToolResult, crate::error::ToolError> {
1765            Ok(crate::tool::ToolResult {
1766                content: vec![crate::types::ToolResultBlock::Text(
1767                    crate::types::TextContent {
1768                        text: "delivered".into(),
1769                    },
1770                )],
1771                is_error: false,
1772                details: serde_json::Value::Null,
1773                terminate: true,
1774                narration: None,
1775            })
1776        }
1777    }
1778
1779    /// `SteeringSource` that always returns one wrap-up message. Used
1780    /// to prove the loop does NOT poll steering after a terminator
1781    /// vote (otherwise this would re-enter the LLM and trip the
1782    /// `assert_eq!(call, 0)` in `TerminatorOnlyStream`).
1783    struct AlwaysSteer {
1784        polls: Arc<AtomicUsize>,
1785    }
1786
1787    impl Plugin for AlwaysSteer {
1788        fn name(&self) -> &'static str {
1789            "always_steer"
1790        }
1791
1792        fn capabilities(&self) -> PluginCapabilities {
1793            PluginCapabilities {
1794                steering: true,
1795                ..PluginCapabilities::default()
1796            }
1797        }
1798    }
1799
1800    #[async_trait::async_trait]
1801    impl crate::plugin::SteeringSource for AlwaysSteer {
1802        async fn next_steering_messages(&self) -> Vec<AgentMessage> {
1803            self.polls.fetch_add(1, Ordering::SeqCst);
1804            vec![AgentMessage::System {
1805                content: "wrap up now".into(),
1806                timestamp: None,
1807            }]
1808        }
1809    }
1810
1811    #[tokio::test]
1812    async fn terminator_vote_skips_post_batch_steering_collection() {
1813        // Regression: a `SteeringSource` whose firing condition lines
1814        // up with the same turn the model delivers (e.g.
1815        // `graceful_turn_limit` reaching its soft limit on the delivery
1816        // turn) used to re-enter the loop and prompt the model for
1817        // ANOTHER turn after a clean terminator. The model's drift on
1818        // that extra turn corrupted the user-visible answer in
1819        // production. With the fix, a unanimous terminator vote is a
1820        // hard exit — steering sources are not polled once the run has
1821        // decided it's done.
1822        let stream = Arc::new(TerminatorOnlyStream::default());
1823        let polls = Arc::new(AtomicUsize::new(0));
1824        let mut tool_registry = crate::tool::ToolRegistry::new();
1825        tool_registry = tool_registry.with(Arc::new(TerminatorTool));
1826        let config = AgentBuilder::new()
1827            .stream(stream.clone())
1828            .model_id("test-model")
1829            .tools(tool_registry)
1830            .steering(AlwaysSteer {
1831                polls: polls.clone(),
1832            })
1833            .build()
1834            .expect("config builds");
1835        let context = AgentContext::new("system");
1836        let prompts = vec![AgentMessage::User {
1837            content: UserContent::Text("deliver".to_string()),
1838            timestamp: None,
1839        }];
1840
1841        let result = run(prompts, context, &config, CancellationToken::new())
1842            .await
1843            .expect("run completes after one terminator turn");
1844
1845        // Exactly one LLM call — the terminator turn.
1846        assert_eq!(stream.calls.load(Ordering::SeqCst), 1);
1847        // Outcome is a clean natural completion.
1848        assert_eq!(result.outcome, LoopOutcome::Done);
1849        // Steering source is consulted exactly once — the pre-loop
1850        // priming poll at the top of `inner_run`. After the terminator
1851        // batch, `collect_steering` MUST NOT fire again.
1852        assert_eq!(
1853            polls.load(Ordering::SeqCst),
1854            1,
1855            "steering source polled more than once — terminator vote did not gate post-batch re-entry"
1856        );
1857    }
1858
1859    /// `FollowUpSource` that always emits one nudge. Counts polls so
1860    /// the test can prove `collect_follow_up` is NOT invoked after a
1861    /// terminator batch.
1862    struct AlwaysFollowUp {
1863        polls: Arc<AtomicUsize>,
1864    }
1865
1866    impl Plugin for AlwaysFollowUp {
1867        fn name(&self) -> &'static str {
1868            "always_follow_up"
1869        }
1870
1871        fn capabilities(&self) -> PluginCapabilities {
1872            PluginCapabilities::follow_up()
1873        }
1874    }
1875
1876    #[async_trait::async_trait]
1877    impl FollowUpSource for AlwaysFollowUp {
1878        async fn next_follow_up_messages(&self) -> Vec<AgentMessage> {
1879            self.polls.fetch_add(1, Ordering::SeqCst);
1880            vec![AgentMessage::System {
1881                content: "deliver something".into(),
1882                timestamp: None,
1883            }]
1884        }
1885    }
1886
1887    #[tokio::test]
1888    async fn terminator_vote_skips_post_batch_follow_up_collection() {
1889        // Mirror of the steering test for the follow-up source path.
1890        // `FollowUpSource` exists to nudge the model toward a
1891        // terminator when it failed to emit one — not to overrule a
1892        // terminator the model already cast. After a clean delivery,
1893        // follow-up must be silent.
1894        let stream = Arc::new(TerminatorOnlyStream::default());
1895        let polls = Arc::new(AtomicUsize::new(0));
1896        let mut tool_registry = crate::tool::ToolRegistry::new();
1897        tool_registry = tool_registry.with(Arc::new(TerminatorTool));
1898        let config = AgentBuilder::new()
1899            .stream(stream.clone())
1900            .model_id("test-model")
1901            .tools(tool_registry)
1902            .follow_up(AlwaysFollowUp {
1903                polls: polls.clone(),
1904            })
1905            .build()
1906            .expect("config builds");
1907        let context = AgentContext::new("system");
1908        let prompts = vec![AgentMessage::User {
1909            content: UserContent::Text("deliver".to_string()),
1910            timestamp: None,
1911        }];
1912
1913        let result = run(prompts, context, &config, CancellationToken::new())
1914            .await
1915            .expect("run completes after one terminator turn");
1916
1917        assert_eq!(stream.calls.load(Ordering::SeqCst), 1);
1918        assert_eq!(result.outcome, LoopOutcome::Done);
1919        assert_eq!(
1920            polls.load(Ordering::SeqCst),
1921            0,
1922            "follow-up source polled after a terminator vote — terminator did not gate post-batch re-entry"
1923        );
1924    }
1925
1926    #[tokio::test]
1927    async fn empty_tool_gate_intersection_prefers_delivery_repair_owner() {
1928        let (sink, mut rx) = crate::event::ChannelSink::new();
1929        let config = AgentBuilder::new()
1930            .stream(Arc::new(RepeatedTextStream::default()))
1931            .event_sink(Arc::new(sink))
1932            .tool_gate_arc(Arc::new(StaticAllowGate {
1933                name: "delivery_repair_gate",
1934                tools: &["browser_interact"],
1935                priority: 100,
1936                class: ToolGateClass::Required,
1937                suppresses_advisory: false,
1938            }))
1939            .tool_gate_arc(Arc::new(StaticAllowGate {
1940                name: "terminal_message_guard",
1941                tools: &["message_result"],
1942                priority: 10,
1943                class: ToolGateClass::Required,
1944                suppresses_advisory: false,
1945            }))
1946            .build()
1947            .expect("config builds");
1948
1949        let allow = collect_tool_allowlist_with_events(&config, 3, &[])
1950            .await
1951            .expect("conflict repair should keep a non-empty allowlist");
1952
1953        assert_eq!(
1954            allow,
1955            ["browser_interact".to_string()].into_iter().collect()
1956        );
1957
1958        let mut saw_conflict = false;
1959        while let Ok(event) = rx.try_recv() {
1960            if let AgentEvent::ToolGateConflictResolved {
1961                chosen_plugin,
1962                allow,
1963                ..
1964            } = event
1965            {
1966                saw_conflict = true;
1967                assert_eq!(chosen_plugin.as_deref(), Some("delivery_repair_gate"));
1968                assert_eq!(allow, vec!["browser_interact".to_string()]);
1969            }
1970        }
1971        assert!(saw_conflict, "tool-gate deadlock should be diagnosable");
1972    }
1973
1974    #[tokio::test]
1975    async fn repair_owner_suppresses_advisory_gate_before_plan_only_intersection() {
1976        let config = AgentBuilder::new()
1977            .stream(Arc::new(RepeatedTextStream::default()))
1978            .tool_gate_arc(Arc::new(StaticAllowGate {
1979                name: "delivery_repair_gate",
1980                tools: &["plan", "file_write"],
1981                priority: 100,
1982                class: ToolGateClass::Required,
1983                suppresses_advisory: true,
1984            }))
1985            .tool_gate_arc(Arc::new(StaticAllowGate {
1986                name: "wrap_up_gate",
1987                tools: &["plan", "message_result", "message_ask"],
1988                priority: 0,
1989                class: ToolGateClass::Advisory,
1990                suppresses_advisory: false,
1991            }))
1992            .build()
1993            .expect("config builds");
1994
1995        let allow = collect_tool_allowlist_with_events(&config, 3, &[])
1996            .await
1997            .expect("repair owner should keep its own allowlist");
1998
1999        assert_eq!(
2000            allow,
2001            ["plan".to_string(), "file_write".to_string()]
2002                .into_iter()
2003                .collect()
2004        );
2005    }
2006
2007    #[tokio::test]
2008    async fn terminal_only_plain_text_fallback_synthesizes_terminal_result() {
2009        let stream = Arc::new(RepeatedTextStream::default());
2010        let mut tool_registry = crate::tool::ToolRegistry::new();
2011        tool_registry = tool_registry.with(Arc::new(TerminalNamedTool("message_result")));
2012        let config = AgentBuilder::new()
2013            .stream(stream.clone())
2014            .model_id("auto-tool-provider")
2015            .tools(tool_registry)
2016            .tool_gate_arc(Arc::new(TerminalOnlyGate))
2017            .plain_text_terminal_fallback_tool("message_result")
2018            .build()
2019            .expect("config builds");
2020        let context = AgentContext::new("system");
2021        let prompts = vec![AgentMessage::User {
2022            content: UserContent::Text("answer directly".to_string()),
2023            timestamp: None,
2024        }];
2025
2026        let result = run(prompts, context, &config, CancellationToken::new())
2027            .await
2028            .expect("plain text should be converted on terminal-only turn");
2029
2030        assert_eq!(stream.calls.load(Ordering::SeqCst), 1);
2031        assert_eq!(result.outcome, LoopOutcome::Done);
2032        assert!(result.messages.iter().any(|message| matches!(
2033            message,
2034            AgentMessage::ToolResult {
2035                tool_name,
2036                content,
2037                is_error: false,
2038                ..
2039            } if tool_name == "message_result"
2040                && content.plain_text() == "plain stop 0"
2041        )));
2042    }
2043
2044    #[tokio::test]
2045    async fn eager_plain_text_fallback_fires_without_terminal_only_allowlist() {
2046        // Providers in the "auto-when-forced" class can never be
2047        // wire-forced into a tool call, so prose IS their failure mode.
2048        // The eager flag lifts the "allowlist must already be narrowed"
2049        // precondition so the fallback fires on the FIRST plain-text
2050        // stop instead of after a narrowing gate has burned 2-3 nudge
2051        // turns.
2052        //
2053        // No `tool_gate_arc` is installed in this test, so the catalog
2054        // stays at the full registry — exactly the situation where the
2055        // non-eager path would refuse to convert.
2056        let stream = Arc::new(RepeatedTextStream::default());
2057        let mut tool_registry = crate::tool::ToolRegistry::new();
2058        tool_registry = tool_registry.with(Arc::new(TerminalNamedTool("message_result")));
2059        let config = AgentBuilder::new()
2060            .stream(stream.clone())
2061            .model_id("auto-tool-provider-eager")
2062            .tools(tool_registry)
2063            .plain_text_terminal_fallback_tool("message_result")
2064            .plain_text_terminal_fallback_eager(true)
2065            .build()
2066            .expect("config builds");
2067        let context = AgentContext::new("system");
2068        let prompts = vec![AgentMessage::User {
2069            content: UserContent::Text("answer directly".to_string()),
2070            timestamp: None,
2071        }];
2072
2073        let result = run(prompts, context, &config, CancellationToken::new())
2074            .await
2075            .expect("eager fallback should convert plain text on first stop");
2076
2077        assert_eq!(stream.calls.load(Ordering::SeqCst), 1);
2078        assert_eq!(result.outcome, LoopOutcome::Done);
2079        assert!(result.messages.iter().any(|message| matches!(
2080            message,
2081            AgentMessage::ToolResult {
2082                tool_name,
2083                content,
2084                is_error: false,
2085                ..
2086            } if tool_name == "message_result"
2087                && content.plain_text() == "plain stop 0"
2088        )));
2089    }
2090
2091    #[tokio::test]
2092    async fn eager_nudge_mode_has_no_implicit_retry_ceiling() {
2093        // With `plain_text_terminal_fallback_eager_nudge(true)` the eager
2094        // path nudges the model with a protocol-recovery system message
2095        // on every consecutive plain-text stop. Four failures exceed the
2096        // deleted two-nudge cap; the fifth turn supplies the real terminal
2097        // tool call rather than relying on synthesized completion.
2098        let stream = Arc::new(PlainTextThenTerminatorStream::new(4));
2099        let mut tool_registry = crate::tool::ToolRegistry::new();
2100        tool_registry = tool_registry
2101            .with(Arc::new(TerminalNamedTool("message_result")))
2102            .with(Arc::new(TerminatorTool));
2103        let config = AgentBuilder::new()
2104            .stream(stream.clone())
2105            .model_id("auto-tool-provider-eager-nudge")
2106            .tools(tool_registry)
2107            .plain_text_terminal_fallback_tool("message_result")
2108            .plain_text_terminal_fallback_eager(true)
2109            .plain_text_terminal_fallback_eager_nudge(true)
2110            .build()
2111            .expect("config builds");
2112        let context = AgentContext::new("system");
2113        let prompts = vec![AgentMessage::User {
2114            content: UserContent::Text("answer directly".to_string()),
2115            timestamp: None,
2116        }];
2117
2118        let result = run(prompts, context, &config, CancellationToken::new())
2119            .await
2120            .expect("nudge mode should wait for a real terminal tool call");
2121
2122        assert_eq!(stream.calls.load(Ordering::SeqCst), 5);
2123        assert_eq!(result.outcome, LoopOutcome::Done);
2124
2125        let nudge_count = result
2126            .messages
2127            .iter()
2128            .filter(|m| matches!(m, AgentMessage::System { content, .. } if content == crate::protocol::DEFAULT_PLAIN_TEXT_RECOVERY_PROMPT))
2129            .count();
2130        assert_eq!(
2131            nudge_count, 4,
2132            "expected one protocol-recovery message per plain-text turn, got {nudge_count}",
2133        );
2134    }
2135
2136    #[tokio::test]
2137    async fn non_eager_plain_text_fallback_still_requires_narrowed_allowlist() {
2138        // Default behaviour preserved: when eager is NOT set and the
2139        // turn allowlist is the full catalog, plain text is NOT
2140        // converted. It remains the run's natural assistant completion.
2141        let stream = Arc::new(RepeatedTextStream::default());
2142        let mut tool_registry = crate::tool::ToolRegistry::new();
2143        tool_registry = tool_registry.with(Arc::new(TerminalNamedTool("message_result")));
2144        let config = AgentBuilder::new()
2145            .stream(stream.clone())
2146            .model_id("non-eager-provider")
2147            .tools(tool_registry)
2148            .plain_text_terminal_fallback_tool("message_result")
2149            // Eager NOT set → defaults to false.
2150            .build()
2151            .expect("config builds");
2152        let context = AgentContext::new("system");
2153        let prompts = vec![AgentMessage::User {
2154            content: UserContent::Text("answer directly".to_string()),
2155            timestamp: None,
2156        }];
2157
2158        let result = run(prompts, context, &config, CancellationToken::new())
2159            .await
2160            .expect("plain assistant completion should remain valid");
2161
2162        assert_eq!(stream.calls.load(Ordering::SeqCst), 1);
2163        assert!(!result.messages.iter().any(|message| matches!(
2164            message,
2165            AgentMessage::ToolResult { tool_name, .. } if tool_name == "message_result"
2166        )));
2167    }
2168
2169    #[tokio::test]
2170    async fn terminal_plain_text_fallback_allows_status_delivery_gate() {
2171        let stream = Arc::new(RepeatedTextStream::default());
2172        let mut tool_registry = crate::tool::ToolRegistry::new();
2173        tool_registry = tool_registry.with(Arc::new(TerminalNamedTool("message_result")));
2174        let config = AgentBuilder::new()
2175            .stream(stream.clone())
2176            .model_id("auto-tool-provider")
2177            .tools(tool_registry)
2178            .protocol_policy(Arc::new(TestTerminalPolicy))
2179            .tool_gate_arc(Arc::new(TerminalWithStatusGate))
2180            .plain_text_terminal_fallback_tool("message_result")
2181            .build()
2182            .expect("config builds");
2183        let context = AgentContext::new("system");
2184        let prompts = vec![AgentMessage::User {
2185            content: UserContent::Text("answer directly".to_string()),
2186            timestamp: None,
2187        }];
2188
2189        let result = run(prompts, context, &config, CancellationToken::new())
2190            .await
2191            .expect(
2192                "plain text should be converted when only status and terminal tools are allowed",
2193            );
2194
2195        assert_eq!(stream.calls.load(Ordering::SeqCst), 1);
2196        assert_eq!(result.outcome, LoopOutcome::Done);
2197        assert!(result.messages.iter().any(|message| matches!(
2198            message,
2199            AgentMessage::ToolResult {
2200                tool_name,
2201                content,
2202                is_error: false,
2203                ..
2204            } if tool_name == "message_result"
2205                && content.plain_text() == "plain stop 0"
2206        )));
2207    }
2208
2209    struct TerminalNamedTool(&'static str);
2210
2211    #[async_trait::async_trait]
2212    impl crate::tool::AgentTool for TerminalNamedTool {
2213        fn name(&self) -> &str {
2214            self.0
2215        }
2216
2217        fn description(&self) -> &str {
2218            "test terminal tool"
2219        }
2220
2221        fn parameters_schema(&self) -> serde_json::Value {
2222            serde_json::json!({"type": "object"})
2223        }
2224
2225        async fn execute(
2226            &self,
2227            _call_id: &str,
2228            _args: serde_json::Value,
2229            _signal: CancellationToken,
2230            _update: tokio::sync::mpsc::UnboundedSender<crate::tool::ToolResult>,
2231        ) -> Result<crate::tool::ToolResult, crate::error::ToolError> {
2232            Ok(crate::tool::ToolResult {
2233                content: vec![crate::types::ToolResultBlock::Text(
2234                    crate::types::TextContent {
2235                        text: "not used".into(),
2236                    },
2237                )],
2238                is_error: false,
2239                details: serde_json::Value::Null,
2240                terminate: true,
2241                narration: None,
2242            })
2243        }
2244    }
2245}