Skip to main content

rig_agent/agent/run/
mod.rs

1//! A sans-IO, steppable, serializable state machine for the agent prompt loop.
2//!
3//! [`AgentRun`] owns every *decision* the agent loop makes — turn counting,
4//! tool-call validation, invalid tool-call recovery, chat-history threading,
5//! usage aggregation and final response construction — without performing any
6//! IO itself. A driver advances the machine by calling [`AgentRun::next_step`]
7//! and acting on the returned [`AgentRunStep`]:
8//!
9//! - [`AgentRunStep::CallModel`]: send a completion request to the model and
10//!   feed the result back via [`AgentRun::model_response`].
11//! - [`AgentRunStep::CallTools`]: execute the listed tool calls (with whatever
12//!   concurrency the driver chooses) and feed the results back via
13//!   [`AgentRun::tool_results`].
14//! - [`AgentRunStep::Done`]: the run is complete.
15//!
16//! Because the machine never awaits anything, it is runtime-agnostic and the
17//! whole run state is `Serialize + Deserialize`: a driver can serialize a run
18//! between steps (for example while tool calls are pending), persist it, and
19//! resume it later in another process. Note that serialized run state embeds
20//! the full conversation accumulated so far — persisting it inherits whatever
21//! sensitivity the conversation content has — and the serialization format
22//! carries no cross-version stability guarantee yet: resume with the same rig
23//! version that suspended the run.
24//!
25//! `AgentRun` deliberately contains no model, tool registry, memory backend, or
26//! hook stack. Hand-driving it is a low-level provider integration: the caller
27//! owns all IO and any lifecycle policy. To execute a configured [`Agent`](crate::agent::Agent)
28//! with its hooks, tools, retrieval, and memory, use
29//! [`Agent::runner`](crate::agent::Agent::runner); constructing an `AgentRun`
30//! directly is not an alternate way to execute an `Agent`.
31//!
32//! [`crate::completion::Prompt::prompt`] and
33//! [`Agent::runner`](crate::agent::Agent::runner) drive this machine internally;
34//! the same machine can be driven by hand for custom provider control flow:
35//!
36//! ```rust,no_run
37//! use rig_agent::agent::run::{AgentRun, AgentRunStep, ModelTurn, ModelTurnOutcome};
38//!
39//! # async fn example() -> Result<(), Box<dyn std::error::Error>> {
40//! let mut run = AgentRun::new("What is 2+2?").max_turns(3);
41//! loop {
42//!     match run.next_step()? {
43//!         AgentRunStep::CallModel { prompt, history, .. } => {
44//!             // Send `prompt` + `history` to a model, then:
45//!             // run.model_response(ModelTurn { ... })?;
46//!             # let _ = (prompt, history);
47//!             # break;
48//!         }
49//!         AgentRunStep::CallTools { calls } => {
50//!             // Execute `calls`, then: run.tool_results(results)?;
51//!             # let _ = calls;
52//!         }
53//!         AgentRunStep::Done(response) => {
54//!             println!("{}", response.output);
55//!             break;
56//!         }
57//!     }
58//! }
59//! # Ok(())
60//! # }
61//! ```
62
63pub mod output_mode;
64pub mod streamed;
65
66pub use output_mode::OutputMode;
67
68use std::collections::{BTreeMap, BTreeSet};
69
70use serde::{Deserialize, Serialize};
71
72use rig_core::{
73    OneOrMany,
74    message::{AssistantContent, ToolCall, ToolChoice, ToolResult, ToolResultContent, UserContent},
75};
76
77use crate::{
78    agent::hook::{InvalidToolCallAction, InvalidToolCallContext, RetryRequest},
79    agent::prompt_request::{
80        CompletionCall, PromptResponse, TOOL_NOT_EXECUTED_DUE_TO_INVALID_PEER,
81        assistant_text_from_choice, build_full_history, build_history_for_request,
82        invalid_tool_retry_user_message, is_empty_assistant_turn, tool_result_message,
83    },
84    completion::{Message, PromptError, Usage},
85    json_utils,
86};
87
88pub use streamed::{
89    PartialStreamedTurn, StreamedInvalidToolCall, StreamedResolution, StreamedTurn,
90    StreamedTurnAssembler, StreamedTurnEvent,
91};
92
93/// Build the canonical "the model called a tool that isn't available" error.
94/// The identical shape is raised from every recovery-rejection path
95/// (`resolve_invalid_tool_call`, `resolve_streamed_invalid_tool_call`) and the
96/// streamed fail-fast in `streamed_turn`; this collapses the copied struct
97/// literal to one place while leaving each caller's control flow untouched.
98fn unknown_tool_call_error(
99    tool_name: String,
100    available_tools: Vec<String>,
101    allowed_tools: Vec<String>,
102    chat_history: Vec<Message>,
103) -> PromptError {
104    PromptError::UnknownToolCall {
105        tool_name,
106        available_tools,
107        allowed_tools,
108        chat_history: Box::new(chat_history),
109    }
110}
111
112/// Default number of times Tool output mode re-prompts the model for valid
113/// structured output before finalizing best-effort (see #1928). Mirrors
114/// pydantic-ai's default output-retry budget of 1.
115pub(crate) const DEFAULT_OUTPUT_RETRIES: usize = 1;
116
117/// What a driver must do next to advance an [`AgentRun`].
118///
119/// Deliberately exhaustive: a driver must handle every step, so adding a
120/// variant is a breaking change by design.
121#[derive(Debug, Clone)]
122pub enum AgentRunStep {
123    /// Send a completion request to the model and feed the result back via
124    /// [`AgentRun::model_response`].
125    CallModel {
126        /// The prompt message for this turn (the latest message in the run).
127        prompt: Message,
128        /// The chat history preceding `prompt`: the caller-provided input
129        /// history followed by messages accumulated by earlier turns.
130        history: Vec<Message>,
131        /// One-based index of this model call within the run.
132        turn: usize,
133    },
134    /// Execute these tool calls and feed the results back via
135    /// [`AgentRun::tool_results`].
136    CallTools {
137        /// The tool calls of the current assistant turn, in emission order.
138        calls: Vec<PendingToolCall>,
139    },
140    /// The run is complete.
141    Done(PromptResponse),
142}
143
144/// One tool call awaiting execution by the driver.
145#[derive(Debug, Clone, Serialize, Deserialize)]
146#[non_exhaustive]
147pub struct PendingToolCall {
148    /// The tool call emitted by the model (with any repaired tool name applied).
149    pub tool_call: ToolCall,
150    /// Pre-resolved result for tool calls suppressed by invalid tool-call
151    /// recovery. When set, the driver must return this content as the tool
152    /// result without executing the tool or invoking tool hooks.
153    pub preresolved_result: Option<UserContent>,
154    /// Rig-generated identifier correlating this call's stream items, when
155    /// the call arrived via a streamed turn. Persisted with the run state so
156    /// a resumed process keeps emitting the IDs consumers already saw in
157    /// tool-call deltas. Drivers generate a fresh ID when absent.
158    #[serde(default)]
159    pub internal_call_id: Option<String>,
160}
161
162/// A completed model turn fed back to [`AgentRun::model_response`].
163#[derive(Debug, Clone, Serialize, Deserialize)]
164#[non_exhaustive]
165pub struct ModelTurn {
166    /// Provider-assigned assistant message ID, when available.
167    pub message_id: Option<String>,
168    /// The assistant content returned by the model.
169    pub choice: OneOrMany<AssistantContent>,
170    /// Token usage reported by the provider for this completion request.
171    pub usage: Usage,
172    /// Executable Rig tools advertised to the provider for this turn.
173    pub executable_tool_names: BTreeSet<String>,
174    /// Tools allowed by the active [`ToolChoice`] for this turn.
175    pub allowed_tool_names: BTreeSet<String>,
176}
177
178impl ModelTurn {
179    /// Create a model turn from response parts and the tool names advertised
180    /// for the turn.
181    pub fn new(
182        message_id: Option<String>,
183        choice: OneOrMany<AssistantContent>,
184        usage: Usage,
185        executable_tool_names: BTreeSet<String>,
186        allowed_tool_names: BTreeSet<String>,
187    ) -> Self {
188        Self {
189            message_id,
190            choice,
191            usage,
192            executable_tool_names,
193            allowed_tool_names,
194        }
195    }
196}
197
198/// Result of feeding a model turn (or an invalid tool-call resolution) into
199/// the machine.
200///
201/// Deliberately exhaustive: a driver must handle every outcome, so adding a
202/// variant is a breaking change by design.
203#[derive(Debug)]
204pub enum ModelTurnOutcome {
205    /// The turn was accepted. Unless `response_hook_suppressed` is set, the
206    /// driver should run its completion-response hook now, then call
207    /// [`AgentRun::next_step`].
208    ///
209    /// `response_hook_suppressed` is set when invalid tool-call recovery
210    /// (repair or skip) modified the turn, matching the agent loop's behavior
211    /// of not invoking `on_completion_response` for recovered turns.
212    Continue {
213        /// Whether the driver should suppress its completion-response hook.
214        response_hook_suppressed: bool,
215    },
216    /// The model emitted a tool call that is unknown or disallowed for this
217    /// turn. The driver must decide how to recover (typically by asking its
218    /// invalid tool-call hook) and answer via
219    /// [`AgentRun::resolve_invalid_tool_call`].
220    NeedsResolution(InvalidToolCallContext),
221    /// The turn was rolled back with corrective feedback appended to the
222    /// history. Call [`AgentRun::next_step`] to obtain the retry
223    /// [`AgentRunStep::CallModel`].
224    TurnRetried,
225}
226
227#[derive(Debug, Clone, Serialize, Deserialize)]
228struct ResolvingState {
229    message_id: Option<String>,
230    /// The unmodified model output, used for diagnostic histories and retry
231    /// messages (repairs are never reflected in those).
232    original_choice: OneOrMany<AssistantContent>,
233    /// Working copy of the assistant content; repairs rename tool calls here.
234    items: Vec<AssistantContent>,
235    /// Index of the next item to validate.
236    next_index: usize,
237    executable_tool_names: BTreeSet<String>,
238    allowed_tool_names: BTreeSet<String>,
239    /// Synthetic tool results for skipped tool calls, keyed by tool call ID.
240    skipped: BTreeMap<String, UserContent>,
241    recovered: bool,
242    any_skipped: bool,
243    has_tool_calls: bool,
244}
245
246#[derive(Debug, Clone, Serialize, Deserialize)]
247struct TurnState {
248    message_id: Option<String>,
249    items: Vec<AssistantContent>,
250    has_tool_calls: bool,
251    skipped: BTreeMap<String, UserContent>,
252    /// `(tool_call_id, internal_call_id)` pairs for streamed turns, in
253    /// emission order; empty for non-streamed turns.
254    #[serde(default)]
255    internal_call_ids: Vec<(String, String)>,
256}
257
258#[derive(Debug, Clone, Serialize, Deserialize)]
259enum RunState {
260    /// Ready to emit [`AgentRunStep::CallModel`].
261    PreparingRequest,
262    /// Waiting for [`AgentRun::model_response`].
263    AwaitingModel,
264    /// Scanning the model turn's tool calls for validity; may be waiting for
265    /// [`AgentRun::resolve_invalid_tool_call`].
266    ResolvingToolCalls(Box<ResolvingState>),
267    /// The turn was accepted; ready to emit [`AgentRunStep::CallTools`] or
268    /// [`AgentRunStep::Done`].
269    AwaitingAdvance(Box<TurnState>),
270    /// Waiting for [`AgentRun::tool_results`] for these pending tool calls.
271    /// Carrying the calls in the state keeps a serialized run self-contained:
272    /// a resumed process re-obtains them from [`AgentRun::next_step`].
273    ExecutingTools(Vec<PendingToolCall>),
274    /// Terminal: the run completed successfully.
275    Done(Box<PromptResponse>),
276    /// Terminal: the run returned an error.
277    Failed,
278}
279
280/// The sans-IO agent loop state machine. See the [module docs](self) for the
281/// driving protocol.
282#[derive(Debug, Clone, Serialize, Deserialize)]
283pub struct AgentRun {
284    max_turns: usize,
285    max_invalid_tool_call_retries: usize,
286    tool_choice: Option<ToolChoice>,
287    /// Name of the synthetic output tool when the agent uses Tool output mode
288    /// (see #1928). A model turn calling this tool finalizes the run with the
289    /// call's arguments as the response, instead of executing it as a tool.
290    #[serde(default)]
291    output_tool_name: Option<String>,
292    /// JSON schema the Tool-mode output must satisfy, used to re-prompt on
293    /// missing required fields before finalizing best-effort (#1928).
294    #[serde(default)]
295    output_schema: Option<serde_json::Value>,
296    /// Budget for re-prompting the model in Tool output mode when it finalizes
297    /// without calling the output tool, or calls it with arguments missing
298    /// required fields. Exhausting it finalizes best-effort.
299    #[serde(default)]
300    max_output_retries: usize,
301    #[serde(default)]
302    output_retries: usize,
303    chat_history: Option<Vec<Message>>,
304    new_messages: Vec<Message>,
305    current_turn: usize,
306    usage: Usage,
307    completion_calls: Vec<CompletionCall>,
308    completion_call_index: usize,
309    invalid_tool_call_retries: usize,
310    /// Set while a streamed turn rollback awaits its completion-call record;
311    /// see [`AgentRun::record_streamed_completion_call`].
312    #[serde(default)]
313    rollback_pending: bool,
314    /// Set once the current streamed model turn's completion call has been
315    /// recorded, rejecting duplicate records; reset when the next
316    /// [`AgentRunStep::CallModel`] is emitted.
317    #[serde(default)]
318    streamed_completion_call_recorded: bool,
319    state: RunState,
320}
321
322impl AgentRun {
323    /// Create a run for one prompt with no input history, a one-model-call
324    /// budget, and no invalid tool-call retries.
325    pub fn new(prompt: impl Into<Message>) -> Self {
326        Self {
327            max_turns: 1,
328            max_invalid_tool_call_retries: 0,
329            tool_choice: None,
330            output_tool_name: None,
331            output_schema: None,
332            max_output_retries: 0,
333            output_retries: 0,
334            chat_history: None,
335            new_messages: vec![prompt.into()],
336            current_turn: 0,
337            usage: Usage::new(),
338            completion_calls: Vec::new(),
339            completion_call_index: 0,
340            invalid_tool_call_retries: 0,
341            rollback_pending: false,
342            streamed_completion_call_recorded: false,
343            state: RunState::PreparingRequest,
344        }
345    }
346
347    /// Set the input chat history preceding the prompt.
348    pub fn with_history(mut self, history: Vec<Message>) -> Self {
349        self.chat_history = Some(history);
350        self
351    }
352
353    /// Set the total model-call budget, including the initial call and every
354    /// retry or continuation. A budget of zero emits no model calls. Exceeding
355    /// the budget makes [`AgentRun::next_step`] return
356    /// [`PromptError::MaxTurnsError`].
357    pub fn max_turns(mut self, max_turns: usize) -> Self {
358        self.max_turns = max_turns;
359        self
360    }
361
362    /// Configure Tool output-mode validation (#1928): the JSON schema the
363    /// output-tool arguments should satisfy, and how many times to re-prompt the
364    /// model — when it finalizes without calling the output tool, or calls it
365    /// with arguments missing required fields — before finalizing best-effort.
366    pub fn with_output_validation(
367        mut self,
368        output_schema: Option<serde_json::Value>,
369        max_output_retries: usize,
370    ) -> Self {
371        self.output_schema = output_schema;
372        self.max_output_retries = max_output_retries;
373        self
374    }
375
376    /// Top-level `required` schema fields absent from the output-tool arguments.
377    /// A lightweight structural check (not full JSON Schema validation): empty
378    /// when there is no schema, no `required` array, or every required field is
379    /// present. Non-object arguments (e.g. `null`) count every required field as
380    /// missing.
381    fn missing_required_output_fields(&self, args: &serde_json::Value) -> Vec<String> {
382        let Some(required) = self
383            .output_schema
384            .as_ref()
385            .and_then(|schema| schema.get("required"))
386            .and_then(|required| required.as_array())
387        else {
388            return Vec::new();
389        };
390        let object = args.as_object();
391        required
392            .iter()
393            .filter_map(|field| field.as_str())
394            .filter(|field| object.is_none_or(|object| !object.contains_key(*field)))
395            .map(str::to_owned)
396            .collect()
397    }
398
399    /// Whether `text` already parses as a JSON object satisfying the output
400    /// schema's required fields — i.e. it is acceptable structured output even
401    /// though the model returned it as plain text instead of an output-tool call.
402    fn text_satisfies_output_schema(&self, text: &str) -> bool {
403        serde_json::from_str::<serde_json::Value>(text.trim())
404            .ok()
405            .is_some_and(|value| self.missing_required_output_fields(&value).is_empty())
406    }
407
408    /// Whether the run may re-prompt for valid Tool-mode output: both the
409    /// output-retry budget and the total model-call budget must remain.
410    /// Otherwise, finalize best-effort rather than surface a max-turns error.
411    fn can_reprompt_for_output(&self) -> bool {
412        self.output_retries < self.max_output_retries && self.current_turn < self.max_turns
413    }
414
415    /// Roll the run back to re-prompt for valid output (#1928). The caller must
416    /// have already appended the assistant turn and the corrective feedback
417    /// message to the history. Consumes one output-retry, then emits the retry
418    /// [`AgentRunStep::CallModel`].
419    fn reprompt_for_output(&mut self) -> Result<AgentRunStep, PromptError> {
420        self.output_retries += 1;
421        self.state = RunState::PreparingRequest;
422        self.next_step()
423    }
424
425    /// Set the retry budget for [`InvalidToolCallAction::Retry`]
426    /// resolutions. Invalid tool-call retries also consume the total model-call
427    /// budget.
428    pub fn max_invalid_tool_call_retries(mut self, retries: usize) -> Self {
429        self.max_invalid_tool_call_retries = retries;
430        self
431    }
432
433    /// Set the tool choice active for this run. Used to reject
434    /// [`InvalidToolCallAction::Skip`] resolutions under
435    /// [`ToolChoice::None`] and reported in invalid tool-call contexts.
436    pub fn with_tool_choice(mut self, tool_choice: ToolChoice) -> Self {
437        self.tool_choice = Some(tool_choice);
438        self
439    }
440
441    /// Set the synthetic output-tool name for Tool output mode (see #1928).
442    /// When a model turn calls this tool, the run finalizes with the call's
443    /// arguments (serialized JSON) as the response.
444    pub fn with_output_tool_name(mut self, name: impl Into<String>) -> Self {
445        self.output_tool_name = Some(name.into());
446        self
447    }
448
449    /// Set (or clear) the output-tool name in place. The driver resolves the
450    /// name from the prepared request inside the run loop, where the agent's
451    /// tool set (and thus the resolved output mode) is known.
452    pub(crate) fn set_output_tool_name(&mut self, name: Option<String>) {
453        // The name is committed once and pinned for the whole run, so the
454        // request the driver builds each turn stays consistent with the
455        // intercept (and a tool set that shifts mid-run cannot flip the mode).
456        if self.output_tool_name.is_none() {
457            self.output_tool_name = name;
458        }
459    }
460
461    /// The synthetic output-tool name committed for this run, if any. The driver
462    /// passes this back when preparing later turns so Tool output mode stays
463    /// pinned even if the per-turn tool set changes (see #1928).
464    pub(crate) fn output_tool_name(&self) -> Option<&str> {
465        self.output_tool_name.as_deref()
466    }
467
468    /// Aggregated token usage across all completed model calls so far.
469    pub fn usage(&self) -> Usage {
470        self.usage
471    }
472
473    /// Number of model calls emitted so far (including retries).
474    pub fn turn(&self) -> usize {
475        self.current_turn
476    }
477
478    /// Details for each completed model call so far.
479    pub fn completion_calls(&self) -> &[CompletionCall] {
480        &self.completion_calls
481    }
482
483    /// Messages accumulated by this run (the prompt plus all assistant turns
484    /// and tool results), excluding the input history.
485    pub fn messages(&self) -> &[Message] {
486        &self.new_messages
487    }
488
489    /// Canonical content for the accepted model turn awaiting advancement.
490    pub(crate) fn accepted_turn_choice(&self) -> Option<OneOrMany<AssistantContent>> {
491        let RunState::AwaitingAdvance(turn) = &self.state else {
492            return None;
493        };
494
495        OneOrMany::from_iter_optional(turn.items.clone())
496    }
497
498    /// Reject the accepted, tool-free model turn and prepare another model call.
499    ///
500    /// [`RetryRequest::Repeat`] discards the rejected assistant response and
501    /// reuses the same prompt and preceding history with fresh request
502    /// preparation. [`RetryRequest::Feedback`] records the rejected response
503    /// followed by corrective user feedback. Canonical empty assistant turns
504    /// are omitted from history, matching normal turn advancement. Both modes
505    /// preserve completion-call and usage accounting, and the next call consumes
506    /// the existing total model-call budget.
507    ///
508    /// Tool-bearing turns cannot be retried through this operation because
509    /// preserving them without matching tool results would create invalid
510    /// provider-visible history. Use tool-call hooks to steer those turns.
511    pub fn retry_model_turn(&mut self, request: RetryRequest) -> Result<(), PromptError> {
512        let turn = match std::mem::replace(&mut self.state, RunState::Failed) {
513            RunState::AwaitingAdvance(turn) => turn,
514            other => {
515                self.state = other;
516                return Err(self.protocol_violation(
517                    "retry_model_turn called without an accepted turn awaiting advancement",
518                ));
519            }
520        };
521
522        if turn.has_tool_calls {
523            return Err(PromptError::prompt_cancelled(
524                self.full_history(),
525                "model-turn retry does not support tool-bearing model turns; use tool-call hooks instead",
526            ));
527        }
528
529        match request {
530            RetryRequest::Repeat => {}
531            RetryRequest::Feedback(feedback) => {
532                let Some(content) = OneOrMany::from_iter_optional(turn.items) else {
533                    return Err(PromptError::prompt_cancelled(
534                        self.full_history(),
535                        "model-turn retry lost the rejected assistant content",
536                    ));
537                };
538                if !is_empty_assistant_turn(&content) {
539                    self.new_messages.push(Message::Assistant {
540                        id: turn.message_id,
541                        content,
542                    });
543                }
544                self.new_messages.push(Message::user(feedback));
545            }
546        }
547
548        self.state = RunState::PreparingRequest;
549        Ok(())
550    }
551
552    /// The full conversation: input history followed by [`Self::messages`].
553    pub fn full_history(&self) -> Vec<Message> {
554        build_full_history(self.chat_history.as_deref(), self.new_messages.clone())
555    }
556
557    /// Whether the run reached [`AgentRunStep::Done`].
558    pub fn is_done(&self) -> bool {
559        matches!(self.state, RunState::Done(_))
560    }
561
562    /// The final response once the run is done, without cloning it.
563    /// [`AgentRun::next_step`] in the done state returns an owned clone
564    /// (including the full accumulated message history); prefer this when
565    /// only inspecting the result.
566    pub fn response(&self) -> Option<&PromptResponse> {
567        match &self.state {
568            RunState::Done(response) => Some(response),
569            _ => None,
570        }
571    }
572
573    /// Build the cancellation error a driver should return when one of its
574    /// hooks terminates the run, carrying the current full history.
575    pub fn cancel_error(&self, reason: impl Into<String>) -> PromptError {
576        PromptError::prompt_cancelled(self.full_history(), reason)
577    }
578
579    /// The invalid tool call currently awaiting
580    /// [`AgentRun::resolve_invalid_tool_call`], if any. Useful to re-derive
581    /// the resolution context after deserializing a suspended run.
582    pub fn pending_invalid_tool_call(&self) -> Option<InvalidToolCallContext> {
583        let RunState::ResolvingToolCalls(resolving) = &self.state else {
584            return None;
585        };
586        let AssistantContent::ToolCall(tool_call) = resolving.items.get(resolving.next_index)?
587        else {
588            return None;
589        };
590        if resolving
591            .allowed_tool_names
592            .contains(&tool_call.function.name)
593        {
594            return None;
595        }
596
597        Some(InvalidToolCallContext {
598            tool_name: tool_call.function.name.clone(),
599            tool_call_id: Some(tool_call.id.clone()),
600            internal_call_id: None,
601            args: Some(json_utils::serialize_json_value(
602                &tool_call.function.arguments,
603            )),
604            available_tools: resolving.executable_tool_names.iter().cloned().collect(),
605            allowed_tools: resolving.allowed_tool_names.iter().cloned().collect(),
606            tool_choice: self.tool_choice.clone(),
607            chat_history: self.diagnostic_history(resolving),
608            is_streaming: false,
609        })
610    }
611
612    /// Advance the machine and return the next action for the driver.
613    ///
614    /// # Errors
615    /// - [`PromptError::MaxTurnsError`] when the total model-call budget is exhausted.
616    /// - [`PromptError::PromptCancelled`] when the machine is driven out of
617    ///   protocol (for example, calling this while a model response is
618    ///   pending).
619    pub fn next_step(&mut self) -> Result<AgentRunStep, PromptError> {
620        match std::mem::replace(&mut self.state, RunState::Failed) {
621            RunState::PreparingRequest => {
622                let Some((prompt_ref, history_for_turn)) = self.new_messages.split_last() else {
623                    return Err(PromptError::prompt_cancelled(
624                        self.full_history(),
625                        "prompt loop lost its pending prompt",
626                    ));
627                };
628                let prompt = prompt_ref.clone();
629
630                if self.current_turn >= self.max_turns {
631                    return Err(PromptError::MaxTurnsError {
632                        max_turns: self.max_turns,
633                        chat_history: self.full_history().into(),
634                        prompt: prompt.into(),
635                    });
636                }
637
638                let history =
639                    build_history_for_request(self.chat_history.as_deref(), history_for_turn);
640                self.current_turn += 1;
641                self.rollback_pending = false;
642                self.streamed_completion_call_recorded = false;
643                self.state = RunState::AwaitingModel;
644                Ok(AgentRunStep::CallModel {
645                    prompt,
646                    history,
647                    turn: self.current_turn,
648                })
649            }
650            RunState::AwaitingAdvance(turn_state) => {
651                let TurnState {
652                    message_id,
653                    items,
654                    has_tool_calls,
655                    skipped,
656                    mut internal_call_ids,
657                } = *turn_state;
658                let Some(choice) = OneOrMany::from_iter_optional(items.clone()) else {
659                    return Err(PromptError::prompt_cancelled(
660                        self.full_history(),
661                        "model turn lost its assistant content",
662                    ));
663                };
664
665                // Tool output mode (#1928): a call to the synthetic output tool
666                // finalizes the run with the call's arguments as the response,
667                // instead of executing it as a tool. First match wins; any
668                // sibling tool calls in the same turn are dropped.
669                if has_tool_calls
670                    && let Some(output_tool_name) = self.output_tool_name.clone()
671                    && let Some(tool_call) = items.iter().find_map(|item| match item {
672                        AssistantContent::ToolCall(tc) if tc.function.name == output_tool_name => {
673                            Some(tc)
674                        }
675                        _ => None,
676                    })
677                {
678                    let output_tool_calls = items
679                        .iter()
680                        .filter(|item| {
681                            matches!(
682                                item,
683                                AssistantContent::ToolCall(tc)
684                                    if tc.function.name == output_tool_name
685                            )
686                        })
687                        .count();
688                    let args = tool_call.function.arguments.clone();
689                    let tool_call_id = tool_call.id.clone();
690                    let output = json_utils::serialize_json_value(&args);
691
692                    // Validate the output against the schema's required fields and
693                    // re-prompt while budget remains, so a model that omits fields
694                    // gets a chance to fix it before we finalize best-effort.
695                    let missing = self.missing_required_output_fields(&args);
696                    if !missing.is_empty() && self.can_reprompt_for_output() {
697                        self.new_messages.push(Message::Assistant {
698                            id: message_id,
699                            content: choice.clone(),
700                        });
701                        let feedback = format!(
702                            "The `{output_tool_name}` arguments were missing required field(s): \
703                             {}. Call `{output_tool_name}` again with every required field.",
704                            missing.join(", ")
705                        );
706                        if let Some(user_message) =
707                            invalid_tool_retry_user_message(&choice, &tool_call_id, feedback)
708                        {
709                            self.new_messages.push(user_message);
710                        }
711                        return self.reprompt_for_output();
712                    }
713
714                    // Finalize. The turn is persisted as the assistant's final
715                    // *text* (keeping any reasoning, dropping every tool call)
716                    // rather than the raw output-tool call. Otherwise the saved
717                    // history would carry an unanswered tool_use, which providers
718                    // reject when the conversation is replayed on a later turn.
719                    let mut final_items: Vec<AssistantContent> = items
720                        .iter()
721                        .filter(|item| !matches!(item, AssistantContent::ToolCall(_)))
722                        .cloned()
723                        .collect();
724                    final_items.push(AssistantContent::text(output.clone()));
725                    let final_content = OneOrMany::from_iter_optional(final_items);
726                    if let Some(content) = final_content.clone() {
727                        self.new_messages.push(Message::Assistant {
728                            id: message_id,
729                            content,
730                        });
731                    }
732
733                    let mut response = PromptResponse::new(output, self.usage)
734                        .with_messages(self.new_messages.clone())
735                        .with_completion_calls(self.completion_calls.clone())
736                        .with_output_tool_calls(output_tool_calls);
737                    if let Some(content) = final_content {
738                        response = response.with_content(content);
739                    }
740                    self.state = RunState::Done(Box::new(response.clone()));
741                    return Ok(AgentRunStep::Done(response));
742                }
743
744                if !is_empty_assistant_turn(&choice) {
745                    self.new_messages.push(Message::Assistant {
746                        id: message_id,
747                        content: choice.clone(),
748                    });
749                }
750
751                if has_tool_calls {
752                    // The model is making progress with real tools, so reset the
753                    // output-retry budget: it is per finalization attempt, not a
754                    // single per-run allowance an early stray turn could burn
755                    // before the model genuinely needs to produce output (#1928).
756                    self.output_retries = 0;
757                    let calls: Vec<PendingToolCall> = items
758                        .iter()
759                        .filter_map(|item| match item {
760                            AssistantContent::ToolCall(tool_call) => {
761                                // Consume pairs positionally so duplicate
762                                // provider IDs within one turn stay
763                                // distinguishable.
764                                let internal_call_id = internal_call_ids
765                                    .iter()
766                                    .position(|(id, _)| *id == tool_call.id)
767                                    .map(|index| internal_call_ids.remove(index).1);
768                                Some(PendingToolCall {
769                                    tool_call: tool_call.clone(),
770                                    preresolved_result: skipped.get(&tool_call.id).cloned(),
771                                    internal_call_id,
772                                })
773                            }
774                            _ => None,
775                        })
776                        .collect();
777                    self.state = RunState::ExecutingTools(calls.clone());
778                    Ok(AgentRunStep::CallTools { calls })
779                } else {
780                    // Tool output mode (#1928): the model produced a final text
781                    // answer without calling the output tool. Re-prompt while
782                    // budget remains so it returns structured output; the
783                    // assistant text was already appended above, so just add the
784                    // corrective feedback. Empty turns finalize best-effort.
785                    //
786                    // But if the text already *is* valid output (parses as JSON
787                    // with every required field), accept it rather than wasting a
788                    // turn — the model answered correctly, just via the wrong
789                    // channel.
790                    if let Some(output_tool_name) = self.output_tool_name.clone()
791                        && !is_empty_assistant_turn(&choice)
792                        && self.can_reprompt_for_output()
793                        && !self.text_satisfies_output_schema(&assistant_text_from_choice(&choice))
794                    {
795                        let feedback = format!(
796                            "Provide your final answer by calling the `{output_tool_name}` tool \
797                             with the structured result as its arguments, not as plain text."
798                        );
799                        self.new_messages.push(Message::user(feedback));
800                        return self.reprompt_for_output();
801                    }
802
803                    let response =
804                        PromptResponse::new(assistant_text_from_choice(&choice), self.usage)
805                            .with_messages(self.new_messages.clone())
806                            .with_completion_calls(self.completion_calls.clone())
807                            .with_content(choice.clone());
808                    self.state = RunState::Done(Box::new(response.clone()));
809                    Ok(AgentRunStep::Done(response))
810                }
811            }
812            RunState::ExecutingTools(calls) => {
813                // Idempotent, like Done: a process resuming a serialized run
814                // re-obtains the pending tool calls from the state itself.
815                let step = AgentRunStep::CallTools {
816                    calls: calls.clone(),
817                };
818                self.state = RunState::ExecutingTools(calls);
819                Ok(step)
820            }
821            RunState::Done(response) => {
822                let step = AgentRunStep::Done((*response).clone());
823                self.state = RunState::Done(response);
824                Ok(step)
825            }
826            state @ (RunState::AwaitingModel | RunState::ResolvingToolCalls(_)) => {
827                let reason = match &state {
828                    RunState::AwaitingModel => {
829                        "next_step called while a model response is pending; feed it via model_response first"
830                    }
831                    _ => {
832                        "next_step called while an invalid tool-call resolution is pending; answer it via resolve_invalid_tool_call first"
833                    }
834                };
835                self.state = state;
836                Err(self.protocol_violation(reason))
837            }
838            RunState::Failed => Err(self.protocol_violation(
839                "next_step called after the run already failed or was misdriven",
840            )),
841        }
842    }
843
844    /// Feed the model's response for the pending [`AgentRunStep::CallModel`].
845    ///
846    /// Records the completion call and aggregates usage, then validates the
847    /// turn's tool calls against the advertised tool names. See
848    /// [`ModelTurnOutcome`] for what the driver must do next.
849    pub fn model_response(&mut self, turn: ModelTurn) -> Result<ModelTurnOutcome, PromptError> {
850        if !matches!(self.state, RunState::AwaitingModel) {
851            return Err(
852                self.protocol_violation("model_response called without a pending CallModel step")
853            );
854        }
855        if self.streamed_completion_call_recorded {
856            return Err(self.protocol_violation(
857                "model_response called after record_streamed_completion_call for the same turn; feed streamed turns via streamed_turn",
858            ));
859        }
860
861        self.record_completion_call(turn.usage);
862
863        let items: Vec<AssistantContent> = turn.choice.iter().cloned().collect();
864        let has_tool_calls = items
865            .iter()
866            .any(|item| matches!(item, AssistantContent::ToolCall(_)));
867
868        self.state = RunState::ResolvingToolCalls(Box::new(ResolvingState {
869            message_id: turn.message_id,
870            original_choice: turn.choice,
871            items,
872            next_index: 0,
873            executable_tool_names: turn.executable_tool_names,
874            allowed_tool_names: turn.allowed_tool_names,
875            skipped: BTreeMap::new(),
876            recovered: false,
877            any_skipped: false,
878            has_tool_calls,
879        }));
880
881        self.advance_resolution()
882    }
883
884    /// Record one provider completion call: assign it the next call index,
885    /// push it, and aggregate its usage into the run total. The single home for
886    /// this accounting arithmetic, shared by the non-streamed and streamed
887    /// ingestion paths. Callers own the once-per-turn `streamed_completion_call_recorded`
888    /// guard/flag; this helper never touches it, so it cannot be mistaken for
889    /// "a completion call happened" and re-introduce a double count.
890    fn record_completion_call(&mut self, usage: Usage) -> CompletionCall {
891        let call = CompletionCall::new(self.completion_call_index, usage);
892        self.completion_call_index += 1;
893        self.completion_calls.push(call);
894        self.usage += usage;
895        call
896    }
897
898    /// Park an accepted model turn in [`RunState::AwaitingAdvance`]. Both the
899    /// non-streamed (`advance_resolution`) and streamed (`streamed_turn`)
900    /// ingestion paths converge here, differing only in the `skipped` map and
901    /// the streamed `internal_call_ids`.
902    fn finalize_turn(
903        &mut self,
904        message_id: Option<String>,
905        items: Vec<AssistantContent>,
906        has_tool_calls: bool,
907        skipped: BTreeMap<String, UserContent>,
908        internal_call_ids: Vec<(String, String)>,
909    ) {
910        self.state = RunState::AwaitingAdvance(Box::new(TurnState {
911            message_id,
912            items,
913            has_tool_calls,
914            skipped,
915            internal_call_ids,
916        }));
917    }
918
919    /// Answer a pending [`ModelTurnOutcome::NeedsResolution`].
920    ///
921    /// Applies the agent loop's recovery semantics:
922    /// - [`InvalidToolCallAction::Fail`] fails the run with
923    ///   [`PromptError::UnknownToolCall`].
924    /// - [`InvalidToolCallAction::Retry`] rolls the turn back with
925    ///   corrective feedback while budget remains, consuming the total
926    ///   model-call budget.
927    /// - [`InvalidToolCallAction::Repair`] renames the tool call; the
928    ///   repaired name is revalidated against the allowed tools.
929    /// - [`InvalidToolCallAction::Stop`] cancels the run with
930    ///   `PromptError::prompt_cancelled` and the supplied reason.
931    /// - [`InvalidToolCallAction::Skip`] records a synthetic tool result
932    ///   and suppresses execution of every tool call in the turn. Rejected
933    ///   under [`ToolChoice::None`].
934    pub fn resolve_invalid_tool_call(
935        &mut self,
936        action: InvalidToolCallAction,
937    ) -> Result<ModelTurnOutcome, PromptError> {
938        // Take the resolving state; rejection paths below restore it so an
939        // out-of-protocol call does not corrupt a drivable run.
940        let mut resolving = match std::mem::replace(&mut self.state, RunState::Failed) {
941            RunState::ResolvingToolCalls(resolving) => resolving,
942            other => {
943                self.state = other;
944                return Err(self.protocol_violation(
945                    "resolve_invalid_tool_call called without a pending invalid tool call",
946                ));
947            }
948        };
949        let tool_call = match resolving.items.get(resolving.next_index) {
950            Some(AssistantContent::ToolCall(tool_call))
951                if !resolving
952                    .allowed_tool_names
953                    .contains(&tool_call.function.name) =>
954            {
955                tool_call.clone()
956            }
957            _ => {
958                self.state = RunState::ResolvingToolCalls(resolving);
959                return Err(self.protocol_violation(
960                    "resolve_invalid_tool_call called without a pending invalid tool call",
961                ));
962            }
963        };
964
965        let diagnostic_history = self.diagnostic_history(&resolving);
966        let executable_tool_names: Vec<String> =
967            resolving.executable_tool_names.iter().cloned().collect();
968        let allowed_tool_names: Vec<String> =
969            resolving.allowed_tool_names.iter().cloned().collect();
970
971        match action {
972            InvalidToolCallAction::Fail => Err(unknown_tool_call_error(
973                tool_call.function.name,
974                executable_tool_names,
975                allowed_tool_names,
976                diagnostic_history,
977            )),
978            InvalidToolCallAction::Retry { feedback } => {
979                if self.invalid_tool_call_retries >= self.max_invalid_tool_call_retries {
980                    return Err(unknown_tool_call_error(
981                        tool_call.function.name,
982                        executable_tool_names,
983                        allowed_tool_names,
984                        diagnostic_history,
985                    ));
986                }
987                self.invalid_tool_call_retries += 1;
988
989                self.new_messages.push(Message::Assistant {
990                    id: resolving.message_id.clone(),
991                    content: resolving.original_choice.clone(),
992                });
993                let Some(user_message) = invalid_tool_retry_user_message(
994                    &resolving.original_choice,
995                    &tool_call.id,
996                    feedback,
997                ) else {
998                    return Err(PromptError::prompt_cancelled(
999                        diagnostic_history,
1000                        "invalid tool call retry produced no retry messages",
1001                    ));
1002                };
1003                self.new_messages.push(user_message);
1004                self.state = RunState::PreparingRequest;
1005                Ok(ModelTurnOutcome::TurnRetried)
1006            }
1007            InvalidToolCallAction::Repair { tool_name } => {
1008                if !allowed_tool_names.contains(&tool_name) {
1009                    return Err(unknown_tool_call_error(
1010                        tool_name,
1011                        executable_tool_names,
1012                        allowed_tool_names,
1013                        diagnostic_history,
1014                    ));
1015                }
1016                if let Some(AssistantContent::ToolCall(tool_call)) =
1017                    resolving.items.get_mut(resolving.next_index)
1018                {
1019                    tool_call.function.name = tool_name;
1020                }
1021                resolving.recovered = true;
1022                self.state = RunState::ResolvingToolCalls(resolving);
1023                self.advance_resolution()
1024            }
1025            InvalidToolCallAction::Stop { reason } => {
1026                self.state = RunState::Failed;
1027                Err(PromptError::prompt_cancelled(diagnostic_history, reason))
1028            }
1029            InvalidToolCallAction::Skip { reason } => {
1030                if matches!(self.tool_choice, Some(ToolChoice::None)) {
1031                    return Err(unknown_tool_call_error(
1032                        tool_call.function.name,
1033                        executable_tool_names,
1034                        allowed_tool_names,
1035                        diagnostic_history,
1036                    ));
1037                }
1038                let user_content = if let Some(call_id) = tool_call.call_id.clone() {
1039                    UserContent::tool_result_with_call_id(
1040                        tool_call.id.clone(),
1041                        call_id,
1042                        OneOrMany::one(reason.into()),
1043                    )
1044                } else {
1045                    UserContent::tool_result(tool_call.id.clone(), OneOrMany::one(reason.into()))
1046                };
1047                resolving.skipped.insert(tool_call.id.clone(), user_content);
1048                resolving.recovered = true;
1049                resolving.any_skipped = true;
1050                resolving.next_index += 1;
1051                self.state = RunState::ResolvingToolCalls(resolving);
1052                self.advance_resolution()
1053            }
1054        }
1055    }
1056
1057    /// Discard the pending invalid tool call without marking the turn as
1058    /// recovered.
1059    ///
1060    /// This is the extractor driver's fallback after every invalid-tool hook
1061    /// has declined to act. Keeping it distinct from [`InvalidToolCallAction::Skip`]
1062    /// preserves the extractor's legacy response semantics: unrelated calls
1063    /// disappear, a sibling output call can still finalize the turn, and
1064    /// response observers still receive the canonical response fields.
1065    pub(crate) fn ignore_invalid_tool_call(&mut self) -> Result<ModelTurnOutcome, PromptError> {
1066        let mut resolving = match std::mem::replace(&mut self.state, RunState::Failed) {
1067            RunState::ResolvingToolCalls(resolving) => resolving,
1068            other => {
1069                self.state = other;
1070                return Err(self.protocol_violation(
1071                    "ignore_invalid_tool_call called without a pending invalid tool call",
1072                ));
1073            }
1074        };
1075
1076        match resolving.items.get(resolving.next_index) {
1077            Some(AssistantContent::ToolCall(tool_call))
1078                if !resolving
1079                    .allowed_tool_names
1080                    .contains(&tool_call.function.name) => {}
1081            _ => {
1082                self.state = RunState::ResolvingToolCalls(resolving);
1083                return Err(self.protocol_violation(
1084                    "ignore_invalid_tool_call called without a pending invalid tool call",
1085                ));
1086            }
1087        }
1088
1089        resolving.items.remove(resolving.next_index);
1090        resolving.has_tool_calls = resolving
1091            .items
1092            .iter()
1093            .any(|item| matches!(item, AssistantContent::ToolCall(_)));
1094        if resolving.items.is_empty() {
1095            resolving.items.push(AssistantContent::text(""));
1096        }
1097        self.state = RunState::ResolvingToolCalls(resolving);
1098        self.advance_resolution()
1099    }
1100
1101    /// Feed the tool results for the pending [`AgentRunStep::CallTools`].
1102    ///
1103    /// Results may be in any order; they are appended as a single user
1104    /// message, matching what providers expect for parallel tool calls. Each
1105    /// result must be a tool result answering one of the pending calls, and
1106    /// every pending call must be answered — exactly what providers require
1107    /// to accept the next request.
1108    pub fn tool_results(&mut self, results: Vec<UserContent>) -> Result<(), PromptError> {
1109        let RunState::ExecutingTools(pending) = &self.state else {
1110            return Err(
1111                self.protocol_violation("tool_results called without a pending CallTools step")
1112            );
1113        };
1114        // Match results against pending calls by tool call ID as a multiset,
1115        // so duplicate provider IDs within one turn stay answerable.
1116        let mut unanswered: Vec<String> = pending
1117            .iter()
1118            .map(|call| call.tool_call.id.clone())
1119            .collect();
1120
1121        if results.is_empty() {
1122            self.state = RunState::Failed;
1123            return Err(PromptError::prompt_cancelled(
1124                self.full_history(),
1125                "tool execution produced no tool results",
1126            ));
1127        }
1128        for result in &results {
1129            let UserContent::ToolResult(tool_result) = result else {
1130                return Err(self.protocol_violation(
1131                    "tool_results received content that is not a tool result",
1132                ));
1133            };
1134            let Some(index) = unanswered.iter().position(|id| *id == tool_result.id) else {
1135                return Err(self.protocol_violation(&format!(
1136                    "tool_results received a result for unknown or already-answered tool call id `{}`",
1137                    tool_result.id
1138                )));
1139            };
1140            unanswered.swap_remove(index);
1141        }
1142        if !unanswered.is_empty() {
1143            return Err(self.protocol_violation(&format!(
1144                "tool_results left pending tool call id(s) unanswered: {unanswered:?}"
1145            )));
1146        }
1147
1148        // `results` is non-empty (checked above), so construction succeeds.
1149        let Some(content) = OneOrMany::from_iter_optional(results) else {
1150            return Err(
1151                self.protocol_violation("internal: tool results vanished during validation")
1152            );
1153        };
1154
1155        self.new_messages.push(Message::User { content });
1156        self.state = RunState::PreparingRequest;
1157        Ok(())
1158    }
1159
1160    /// Scan forward for the next invalid tool call; finish the turn when the
1161    /// scan completes.
1162    fn advance_resolution(&mut self) -> Result<ModelTurnOutcome, PromptError> {
1163        let mut resolving = match std::mem::replace(&mut self.state, RunState::Failed) {
1164            RunState::ResolvingToolCalls(resolving) => resolving,
1165            other => {
1166                self.state = other;
1167                return Err(self.protocol_violation(
1168                    "internal: advance_resolution outside of tool-call resolution",
1169                ));
1170            }
1171        };
1172        while let Some(item) = resolving.items.get(resolving.next_index) {
1173            match item {
1174                AssistantContent::ToolCall(tool_call)
1175                    if !resolving
1176                        .allowed_tool_names
1177                        .contains(&tool_call.function.name) =>
1178                {
1179                    break;
1180                }
1181                _ => resolving.next_index += 1,
1182            }
1183        }
1184
1185        if resolving.next_index < resolving.items.len() {
1186            self.state = RunState::ResolvingToolCalls(resolving);
1187            return match self.pending_invalid_tool_call() {
1188                Some(context) => Ok(ModelTurnOutcome::NeedsResolution(context)),
1189                None => Err(self.protocol_violation(
1190                    "internal: pending invalid tool call could not be derived",
1191                )),
1192            };
1193        }
1194
1195        let ResolvingState {
1196            message_id,
1197            items,
1198            mut skipped,
1199            recovered,
1200            any_skipped,
1201            has_tool_calls,
1202            ..
1203        } = *resolving;
1204
1205        // When any tool call was skipped, none of the turn's tool calls
1206        // execute: peers get a synthetic "not executed" result.
1207        if any_skipped {
1208            for item in &items {
1209                if let AssistantContent::ToolCall(tool_call) = item {
1210                    skipped.entry(tool_call.id.clone()).or_insert_with(|| {
1211                        tool_result_message(
1212                            tool_call.id.clone(),
1213                            tool_call.call_id.clone(),
1214                            TOOL_NOT_EXECUTED_DUE_TO_INVALID_PEER.to_string(),
1215                        )
1216                    });
1217                }
1218            }
1219        }
1220
1221        self.finalize_turn(message_id, items, has_tool_calls, skipped, Vec::new());
1222        Ok(ModelTurnOutcome::Continue {
1223            response_hook_suppressed: recovered,
1224        })
1225    }
1226
1227    // ── Streamed-turn entry points ──────────────────────────────────────
1228    // Paired with [`streamed::StreamedTurnAssembler`]; see that module's
1229    // docs for the full driving protocol.
1230
1231    /// Record one provider completion call for a streamed turn.
1232    ///
1233    /// Streamed turns learn usage from the provider's final stream event —
1234    /// including for turns abandoned by invalid tool-call recovery, where the
1235    /// stream is drained for usage after the rollback — so recording is
1236    /// decoupled from turn ingestion. Valid while a model response is pending
1237    /// or between a turn rollback and the next [`AgentRunStep::CallModel`];
1238    /// aggregates `usage` into the run total. Zero-valued usage means the
1239    /// provider reported no usage metrics.
1240    pub fn record_streamed_completion_call(
1241        &mut self,
1242        usage: Usage,
1243    ) -> Result<CompletionCall, PromptError> {
1244        let recordable = matches!(self.state, RunState::AwaitingModel)
1245            || (matches!(self.state, RunState::PreparingRequest) && self.rollback_pending);
1246        if !recordable {
1247            return Err(self.protocol_violation(
1248                "record_streamed_completion_call called without a pending or rolled-back CallModel step",
1249            ));
1250        }
1251        if self.streamed_completion_call_recorded {
1252            return Err(self.protocol_violation(
1253                "record_streamed_completion_call called twice for the same model turn",
1254            ));
1255        }
1256        self.streamed_completion_call_recorded = true;
1257
1258        Ok(self.record_completion_call(usage))
1259    }
1260
1261    /// The recovery-hook context for an invalid tool call surfaced
1262    /// mid-stream by a [`streamed::StreamedTurnAssembler`].
1263    pub fn streamed_invalid_tool_call_context(
1264        &self,
1265        partial: &PartialStreamedTurn,
1266        invalid: &StreamedInvalidToolCall,
1267    ) -> InvalidToolCallContext {
1268        InvalidToolCallContext {
1269            tool_name: invalid.tool_call.function.name.clone(),
1270            tool_call_id: Some(invalid.tool_call.id.clone()),
1271            internal_call_id: Some(invalid.internal_call_id.clone()),
1272            args: invalid.args.clone(),
1273            available_tools: invalid.executable_tool_names.iter().cloned().collect(),
1274            allowed_tools: invalid.allowed_tool_names.iter().cloned().collect(),
1275            tool_choice: self.tool_choice.clone(),
1276            chat_history: self
1277                .streamed_diagnostic_history(partial, Some(invalid.tool_call.clone())),
1278            is_streaming: true,
1279        }
1280    }
1281
1282    /// Resolve an invalid tool call surfaced mid-stream.
1283    ///
1284    /// Applies the same recovery semantics as
1285    /// [`AgentRun::resolve_invalid_tool_call`], but rollback messages are
1286    /// assembled from the partial streamed turn — exactly what the model has
1287    /// produced so far — and a successful retry or skip abandons the turn
1288    /// (see [`StreamedResolution`]) instead of finishing it.
1289    pub fn resolve_streamed_invalid_tool_call(
1290        &mut self,
1291        partial: &PartialStreamedTurn,
1292        invalid: &StreamedInvalidToolCall,
1293        action: InvalidToolCallAction,
1294    ) -> Result<StreamedResolution, PromptError> {
1295        if !matches!(self.state, RunState::AwaitingModel) {
1296            return Err(self.protocol_violation(
1297                "resolve_streamed_invalid_tool_call called without a pending CallModel step",
1298            ));
1299        }
1300
1301        let diagnostic_history =
1302            self.streamed_diagnostic_history(partial, Some(invalid.tool_call.clone()));
1303        let executable_tool_names: Vec<String> =
1304            invalid.executable_tool_names.iter().cloned().collect();
1305        let allowed_tool_names: Vec<String> = invalid.allowed_tool_names.iter().cloned().collect();
1306
1307        match action {
1308            InvalidToolCallAction::Fail => {
1309                self.state = RunState::Failed;
1310                Err(unknown_tool_call_error(
1311                    invalid.tool_call.function.name.clone(),
1312                    executable_tool_names,
1313                    allowed_tool_names,
1314                    diagnostic_history,
1315                ))
1316            }
1317            InvalidToolCallAction::Retry { feedback } => {
1318                if self.invalid_tool_call_retries >= self.max_invalid_tool_call_retries {
1319                    self.state = RunState::Failed;
1320                    return Err(unknown_tool_call_error(
1321                        invalid.tool_call.function.name.clone(),
1322                        executable_tool_names,
1323                        allowed_tool_names,
1324                        diagnostic_history,
1325                    ));
1326                }
1327                self.invalid_tool_call_retries += 1;
1328
1329                let Some((assistant_message, user_message)) =
1330                    partial.rollback_messages(invalid.tool_call.clone(), feedback)
1331                else {
1332                    self.state = RunState::Failed;
1333                    return Err(PromptError::prompt_cancelled(
1334                        diagnostic_history,
1335                        "invalid tool call retry produced no retry messages",
1336                    ));
1337                };
1338                self.new_messages.push(assistant_message);
1339                self.new_messages.push(user_message);
1340                self.rollback_pending = true;
1341                self.state = RunState::PreparingRequest;
1342                Ok(StreamedResolution::TurnAbandoned {
1343                    skipped_tool_result: None,
1344                })
1345            }
1346            InvalidToolCallAction::Repair { tool_name } => {
1347                if !invalid.allowed_tool_names.contains(&tool_name) {
1348                    self.state = RunState::Failed;
1349                    return Err(unknown_tool_call_error(
1350                        tool_name,
1351                        executable_tool_names,
1352                        allowed_tool_names,
1353                        diagnostic_history,
1354                    ));
1355                }
1356                Ok(StreamedResolution::Repaired { tool_name })
1357            }
1358            InvalidToolCallAction::Stop { reason } => {
1359                self.state = RunState::Failed;
1360                Err(PromptError::prompt_cancelled(diagnostic_history, reason))
1361            }
1362            InvalidToolCallAction::Skip { reason } => {
1363                if matches!(self.tool_choice, Some(ToolChoice::None)) {
1364                    self.state = RunState::Failed;
1365                    return Err(unknown_tool_call_error(
1366                        invalid.tool_call.function.name.clone(),
1367                        executable_tool_names,
1368                        allowed_tool_names,
1369                        diagnostic_history,
1370                    ));
1371                }
1372
1373                // Synthetic skip reason: emit verbatim text, matching the
1374                // non-streamed `resolve_invalid_tool_call` skip path (parity) and
1375                // avoiding re-parsing a rejection message as structured output.
1376                let skipped_tool_result = ToolResult {
1377                    id: invalid.tool_call.id.clone(),
1378                    call_id: invalid.tool_call.call_id.clone(),
1379                    content: OneOrMany::one(ToolResultContent::text(reason.clone())),
1380                };
1381                let Some((assistant_message, user_message)) =
1382                    partial.rollback_messages(invalid.tool_call.clone(), reason)
1383                else {
1384                    self.state = RunState::Failed;
1385                    return Err(PromptError::prompt_cancelled(
1386                        diagnostic_history,
1387                        "invalid tool call skip produced no recovery messages",
1388                    ));
1389                };
1390                self.new_messages.push(assistant_message);
1391                self.new_messages.push(user_message);
1392                self.rollback_pending = true;
1393                self.state = RunState::PreparingRequest;
1394                Ok(StreamedResolution::TurnAbandoned {
1395                    skipped_tool_result: Some(skipped_tool_result),
1396                })
1397            }
1398        }
1399    }
1400
1401    /// Feed the assembled streamed turn for the pending
1402    /// [`AgentRunStep::CallModel`].
1403    ///
1404    /// Remaining tool calls are validated fail-fast — mid-stream resolution
1405    /// already had recovery-hook access — and the turn then advances through
1406    /// [`AgentRun::next_step`] exactly like a non-streamed one.
1407    pub fn streamed_turn(&mut self, turn: StreamedTurn) -> Result<(), PromptError> {
1408        if !matches!(self.state, RunState::AwaitingModel) {
1409            return Err(
1410                self.protocol_violation("streamed_turn called without a pending CallModel step")
1411            );
1412        }
1413
1414        // Guarantee exactly one CompletionCall per model call: drivers that
1415        // never learned usage (no record before the turn completed) still get
1416        // the call recorded, with no reported usage.
1417        if !self.streamed_completion_call_recorded {
1418            // `Usage::new()` is the additive identity for `Usage`'s `AddAssign`,
1419            // so routing the no-usage fallback through `record_completion_call`
1420            // leaves the run total unchanged while unifying the accounting.
1421            self.record_completion_call(Usage::new());
1422            self.streamed_completion_call_recorded = true;
1423        }
1424
1425        let items: Vec<AssistantContent> = turn.choice.iter().cloned().collect();
1426        let has_tool_calls = items
1427            .iter()
1428            .any(|item| matches!(item, AssistantContent::ToolCall(_)));
1429
1430        for item in &items {
1431            let AssistantContent::ToolCall(tool_call) = item else {
1432                continue;
1433            };
1434            if !turn.allowed_tool_names.contains(&tool_call.function.name) {
1435                let mut diagnostic_messages = self.new_messages.clone();
1436                if !is_empty_assistant_turn(&turn.choice) {
1437                    diagnostic_messages.push(Message::Assistant {
1438                        id: turn.message_id.clone(),
1439                        content: turn.choice.clone(),
1440                    });
1441                }
1442                let diagnostic_history =
1443                    build_full_history(self.chat_history.as_deref(), diagnostic_messages);
1444                self.state = RunState::Failed;
1445                return Err(unknown_tool_call_error(
1446                    tool_call.function.name.clone(),
1447                    turn.executable_tool_names.iter().cloned().collect(),
1448                    turn.allowed_tool_names.iter().cloned().collect(),
1449                    diagnostic_history,
1450                ));
1451            }
1452        }
1453
1454        self.finalize_turn(
1455            turn.message_id,
1456            items,
1457            has_tool_calls,
1458            BTreeMap::new(),
1459            turn.internal_call_ids,
1460        );
1461        Ok(())
1462    }
1463
1464    /// Diagnostic history for a streamed turn: the run's messages plus the
1465    /// partial assistant turn under inspection.
1466    fn streamed_diagnostic_history(
1467        &self,
1468        partial: &PartialStreamedTurn,
1469        current_tool_call: Option<ToolCall>,
1470    ) -> Vec<Message> {
1471        let mut messages = self.new_messages.clone();
1472        if let Some(assistant) = partial.assistant_message(current_tool_call) {
1473            messages.push(assistant);
1474        }
1475        build_full_history(self.chat_history.as_deref(), messages)
1476    }
1477
1478    /// History used for invalid tool-call diagnostics: the run's messages plus
1479    /// the unmodified assistant turn under inspection.
1480    fn diagnostic_history(&self, resolving: &ResolvingState) -> Vec<Message> {
1481        let mut diagnostic_messages = self.new_messages.clone();
1482        diagnostic_messages.push(Message::Assistant {
1483            id: resolving.message_id.clone(),
1484            content: resolving.original_choice.clone(),
1485        });
1486        build_full_history(self.chat_history.as_deref(), diagnostic_messages)
1487    }
1488
1489    fn protocol_violation(&self, reason: &str) -> PromptError {
1490        PromptError::prompt_cancelled(
1491            self.full_history(),
1492            format!("agent run driver protocol violation: {reason}"),
1493        )
1494    }
1495}
1496
1497#[cfg(test)]
1498mod tests {
1499    use super::*;
1500    use rig_core::message::{ToolFunction, ToolResultContent};
1501    use serde_json::json;
1502
1503    fn tool_names(names: &[&str]) -> BTreeSet<String> {
1504        names.iter().map(|name| (*name).to_string()).collect()
1505    }
1506
1507    fn usage(input_tokens: u64, output_tokens: u64) -> Usage {
1508        Usage {
1509            input_tokens,
1510            output_tokens,
1511            total_tokens: input_tokens + output_tokens,
1512            ..Usage::new()
1513        }
1514    }
1515
1516    fn text_turn(text: &str) -> ModelTurn {
1517        ModelTurn::new(
1518            None,
1519            OneOrMany::one(AssistantContent::text(text)),
1520            Usage::new(),
1521            tool_names(&["add"]),
1522            tool_names(&["add"]),
1523        )
1524    }
1525
1526    fn tool_call(id: &str, name: &str) -> AssistantContent {
1527        AssistantContent::ToolCall(ToolCall::new(
1528            id.to_string(),
1529            ToolFunction::new(name.to_string(), json!({"x": 1})),
1530        ))
1531    }
1532
1533    fn tool_call_turn(id: &str, name: &str) -> ModelTurn {
1534        ModelTurn::new(
1535            None,
1536            OneOrMany::one(tool_call(id, name)),
1537            Usage::new(),
1538            tool_names(&["add"]),
1539            tool_names(&["add"]),
1540        )
1541    }
1542
1543    fn tool_result(id: &str, output: &str) -> UserContent {
1544        UserContent::tool_result(
1545            id.to_string(),
1546            OneOrMany::one(ToolResultContent::text(output)),
1547        )
1548    }
1549
1550    fn expect_call_model(run: &mut AgentRun) -> (Message, Vec<Message>, usize) {
1551        match run.next_step().expect("next_step should succeed") {
1552            AgentRunStep::CallModel {
1553                prompt,
1554                history,
1555                turn,
1556            } => (prompt, history, turn),
1557            step => panic!("expected CallModel, got {step:?}"),
1558        }
1559    }
1560
1561    fn expect_call_tools(run: &mut AgentRun) -> Vec<PendingToolCall> {
1562        match run.next_step().expect("next_step should succeed") {
1563            AgentRunStep::CallTools { calls } => calls,
1564            step => panic!("expected CallTools, got {step:?}"),
1565        }
1566    }
1567
1568    fn expect_done(run: &mut AgentRun) -> PromptResponse {
1569        match run.next_step().expect("next_step should succeed") {
1570            AgentRunStep::Done(response) => response,
1571            step => panic!("expected Done, got {step:?}"),
1572        }
1573    }
1574
1575    fn expect_continue(outcome: ModelTurnOutcome) -> bool {
1576        match outcome {
1577            ModelTurnOutcome::Continue {
1578                response_hook_suppressed,
1579            } => response_hook_suppressed,
1580            outcome => panic!("expected Continue, got {outcome:?}"),
1581        }
1582    }
1583
1584    fn expect_needs_resolution(outcome: ModelTurnOutcome) -> InvalidToolCallContext {
1585        match outcome {
1586            ModelTurnOutcome::NeedsResolution(context) => context,
1587            outcome => panic!("expected NeedsResolution, got {outcome:?}"),
1588        }
1589    }
1590
1591    #[test]
1592    fn text_only_run_completes_in_one_turn() {
1593        let mut run = AgentRun::new("hello");
1594
1595        let (prompt, history, turn) = expect_call_model(&mut run);
1596        assert_eq!(prompt, Message::user("hello"));
1597        assert!(history.is_empty());
1598        assert_eq!(turn, 1);
1599
1600        let suppressed = expect_continue(
1601            run.model_response(text_turn("hi there"))
1602                .expect("model_response should succeed"),
1603        );
1604        assert!(!suppressed);
1605
1606        let response = expect_done(&mut run);
1607        assert_eq!(response.output, "hi there");
1608        let messages = response.messages.expect("messages should be recorded");
1609        assert_eq!(messages.len(), 2);
1610        assert!(run.is_done());
1611    }
1612
1613    #[test]
1614    fn input_history_prefixes_request_history() {
1615        let mut run = AgentRun::new("question")
1616            .with_history(vec![Message::user("earlier"), Message::assistant("reply")]);
1617
1618        let (_, history, _) = expect_call_model(&mut run);
1619        assert_eq!(
1620            history,
1621            vec![Message::user("earlier"), Message::assistant("reply")]
1622        );
1623
1624        expect_continue(
1625            run.model_response(text_turn("answer"))
1626                .expect("model_response should succeed"),
1627        );
1628        let response = expect_done(&mut run);
1629        // Returned messages exclude the input history.
1630        assert_eq!(
1631            response
1632                .messages
1633                .expect("messages should be recorded")
1634                .len(),
1635            2
1636        );
1637    }
1638
1639    #[test]
1640    fn repeated_model_turn_reuses_prompt_without_recording_rejected_response() {
1641        let first_usage = usage(10, 3);
1642        let second_usage = usage(7, 2);
1643        let mut run = AgentRun::new("question").max_turns(2);
1644
1645        let (first_prompt, first_history, first_turn) = expect_call_model(&mut run);
1646        assert_eq!(first_prompt, Message::user("question"));
1647        assert!(first_history.is_empty());
1648        assert_eq!(first_turn, 1);
1649        expect_continue(
1650            run.model_response(text_turn("rejected").with_usage_for_test(first_usage))
1651                .expect("first response"),
1652        );
1653
1654        run.retry_model_turn(RetryRequest::Repeat)
1655            .expect("repeat should be accepted");
1656        let (second_prompt, second_history, second_turn) = expect_call_model(&mut run);
1657        assert_eq!(second_prompt, Message::user("question"));
1658        assert!(second_history.is_empty());
1659        assert_eq!(second_turn, 2);
1660        assert_eq!(run.messages(), &[Message::user("question")]);
1661
1662        expect_continue(
1663            run.model_response(text_turn("accepted").with_usage_for_test(second_usage))
1664                .expect("second response"),
1665        );
1666        let response = expect_done(&mut run);
1667        assert_eq!(response.output, "accepted");
1668        assert_eq!(response.usage, first_usage + second_usage);
1669        assert_eq!(response.completion_calls.len(), 2);
1670        let messages = response.messages.expect("response history");
1671        assert_eq!(messages.len(), 2);
1672        assert!(!format!("{messages:?}").contains("rejected"));
1673    }
1674
1675    #[test]
1676    fn feedback_retry_records_rejected_response_and_corrective_prompt() {
1677        let mut run = AgentRun::new("question").max_turns(2);
1678
1679        expect_call_model(&mut run);
1680        expect_continue(
1681            run.model_response(text_turn("rejected"))
1682                .expect("first response"),
1683        );
1684        run.retry_model_turn(RetryRequest::Feedback("try another approach".to_string()))
1685            .expect("feedback retry should be accepted");
1686
1687        let (prompt, history, turn) = expect_call_model(&mut run);
1688        assert_eq!(prompt, Message::user("try another approach"));
1689        assert_eq!(turn, 2);
1690        assert_eq!(
1691            history,
1692            vec![Message::user("question"), Message::assistant("rejected")]
1693        );
1694    }
1695
1696    #[test]
1697    fn repeated_model_turn_consumes_existing_max_turns_budget() {
1698        let mut run = AgentRun::new("question");
1699
1700        expect_call_model(&mut run);
1701        expect_continue(
1702            run.model_response(text_turn("rejected"))
1703                .expect("first response"),
1704        );
1705        run.retry_model_turn(RetryRequest::Repeat)
1706            .expect("state transition itself should succeed");
1707
1708        let err = run.next_step().expect_err("second call must exceed budget");
1709        assert!(matches!(
1710            err,
1711            PromptError::MaxTurnsError { max_turns: 1, .. }
1712        ));
1713        assert_eq!(run.completion_calls().len(), 1);
1714    }
1715
1716    #[test]
1717    fn model_turn_retry_rejects_tool_calls_without_advancing_to_execution() {
1718        let mut run = AgentRun::new("add things").max_turns(2);
1719
1720        expect_call_model(&mut run);
1721        expect_continue(
1722            run.model_response(tool_call_turn("call_1", "add"))
1723                .expect("tool response"),
1724        );
1725        let err = run
1726            .retry_model_turn(RetryRequest::Feedback("do not call tools".to_string()))
1727            .expect_err("tool-bearing retries must fail closed");
1728
1729        let PromptError::PromptCancelled {
1730            chat_history,
1731            reason,
1732        } = err
1733        else {
1734            panic!("tool-bearing retry should return PromptCancelled");
1735        };
1736        assert!(reason.contains("tool-bearing model turns"));
1737        assert!(reason.contains("tool-call hooks"));
1738        assert_eq!(chat_history, vec![Message::user("add things")]);
1739        assert!(run.next_step().is_err(), "failed run cannot execute tools");
1740    }
1741
1742    #[test]
1743    fn tool_roundtrip_threads_history_and_usage() {
1744        let mut run = AgentRun::new("add things").max_turns(2);
1745
1746        expect_call_model(&mut run);
1747        expect_continue(
1748            run.model_response(tool_call_turn("call_1", "add").with_usage_for_test(usage(10, 5)))
1749                .expect("model_response should succeed"),
1750        );
1751
1752        let calls = expect_call_tools(&mut run);
1753        assert_eq!(calls.len(), 1);
1754        assert_eq!(calls[0].tool_call.function.name, "add");
1755        assert!(calls[0].preresolved_result.is_none());
1756
1757        run.tool_results(vec![tool_result("call_1", "2")])
1758            .expect("tool_results should succeed");
1759
1760        let (prompt, history, turn) = expect_call_model(&mut run);
1761        assert_eq!(turn, 2);
1762        // The tool-result user message becomes the new prompt; the assistant
1763        // turn is part of the history.
1764        assert!(matches!(prompt, Message::User { .. }));
1765        assert_eq!(history.len(), 2);
1766
1767        expect_continue(
1768            run.model_response(text_turn("the answer is 2").with_usage_for_test(usage(20, 7)))
1769                .expect("model_response should succeed"),
1770        );
1771
1772        let response = expect_done(&mut run);
1773        assert_eq!(response.output, "the answer is 2");
1774        assert_eq!(response.usage, usage(30, 12));
1775        assert_eq!(response.completion_calls.len(), 2);
1776        assert_eq!(response.completion_calls[0].call_index, 0);
1777        assert_eq!(response.completion_calls[0].usage, usage(10, 5));
1778        assert_eq!(response.completion_calls[1].usage, usage(20, 7));
1779        // prompt, assistant tool call, tool result, final assistant text
1780        assert_eq!(
1781            response
1782                .messages
1783                .expect("messages should be recorded")
1784                .len(),
1785            4
1786        );
1787    }
1788
1789    #[test]
1790    fn parallel_tool_calls_surface_in_emission_order() {
1791        let mut run = AgentRun::new("do both").max_turns(2);
1792
1793        expect_call_model(&mut run);
1794        let turn = ModelTurn::new(
1795            None,
1796            OneOrMany::many(vec![tool_call("call_1", "add"), tool_call("call_2", "add")])
1797                .expect("two items"),
1798            Usage::new(),
1799            tool_names(&["add"]),
1800            tool_names(&["add"]),
1801        );
1802        expect_continue(
1803            run.model_response(turn)
1804                .expect("model_response should succeed"),
1805        );
1806
1807        let calls = expect_call_tools(&mut run);
1808        assert_eq!(calls.len(), 2);
1809        assert_eq!(calls[0].tool_call.id, "call_1");
1810        assert_eq!(calls[1].tool_call.id, "call_2");
1811
1812        // Results fed out of order still land in one user message.
1813        run.tool_results(vec![tool_result("call_2", "b"), tool_result("call_1", "a")])
1814            .expect("tool_results should succeed");
1815        let messages = run.messages();
1816        assert!(matches!(
1817            messages.last(),
1818            Some(Message::User { content }) if content.len() == 2
1819        ));
1820    }
1821
1822    #[test]
1823    fn max_turns_zero_rejects_initial_model_call() {
1824        let mut run = AgentRun::new("do not call").max_turns(0);
1825
1826        let err = run
1827            .next_step()
1828            .expect_err("zero budget should emit no call");
1829        assert!(matches!(
1830            err,
1831            PromptError::MaxTurnsError { max_turns: 0, .. }
1832        ));
1833        assert_eq!(run.turn(), 0);
1834    }
1835
1836    #[test]
1837    fn new_implicitly_allows_one_model_call_and_rejects_tool_continuation() {
1838        let mut run = AgentRun::new("add things");
1839
1840        let (_, _, turn) = expect_call_model(&mut run);
1841        assert_eq!(turn, 1);
1842        expect_continue(
1843            run.model_response(tool_call_turn("call_1", "add"))
1844                .expect("model_response should succeed"),
1845        );
1846        expect_call_tools(&mut run);
1847        run.tool_results(vec![tool_result("call_1", "2")])
1848            .expect("tool_results should succeed");
1849
1850        let err = run
1851            .next_step()
1852            .expect_err("second model call should exceed budget");
1853        assert!(matches!(
1854            err,
1855            PromptError::MaxTurnsError { max_turns: 1, .. }
1856        ));
1857        assert_eq!(run.turn(), 1);
1858    }
1859
1860    #[test]
1861    fn max_turns_n_allows_exactly_n_model_calls() {
1862        let mut run = AgentRun::new("loop").max_turns(3);
1863
1864        for (expected_turn, call_id) in [(1, "call_1"), (2, "call_2"), (3, "call_3")] {
1865            let (_, _, turn) = expect_call_model(&mut run);
1866            assert_eq!(turn, expected_turn);
1867            expect_continue(
1868                run.model_response(tool_call_turn(call_id, "add"))
1869                    .expect("model_response should succeed"),
1870            );
1871            expect_call_tools(&mut run);
1872            run.tool_results(vec![tool_result(call_id, "0")])
1873                .expect("tool_results should succeed");
1874        }
1875
1876        let err = run
1877            .next_step()
1878            .expect_err("fourth model call should exceed budget");
1879        assert!(matches!(
1880            err,
1881            PromptError::MaxTurnsError { max_turns: 3, .. }
1882        ));
1883        assert_eq!(run.turn(), 3);
1884    }
1885
1886    #[test]
1887    fn invalid_tool_call_fail_returns_unknown_tool_call() {
1888        let mut run = AgentRun::new("call something");
1889
1890        expect_call_model(&mut run);
1891        let context = expect_needs_resolution(
1892            run.model_response(tool_call_turn("call_1", "unknown"))
1893                .expect("model_response should succeed"),
1894        );
1895        assert_eq!(context.tool_name, "unknown");
1896        assert_eq!(context.available_tools, vec!["add".to_string()]);
1897        assert!(!context.is_streaming);
1898        // Diagnostic history includes the rejected assistant turn.
1899        assert_eq!(context.chat_history.len(), 2);
1900
1901        let err = run
1902            .resolve_invalid_tool_call(InvalidToolCallAction::fail())
1903            .expect_err("fail action should error");
1904        assert!(matches!(
1905            err,
1906            PromptError::UnknownToolCall { tool_name, .. } if tool_name == "unknown"
1907        ));
1908    }
1909
1910    #[test]
1911    fn invalid_tool_call_stop_leaves_run_terminal() {
1912        let mut run = AgentRun::new("call something");
1913
1914        expect_call_model(&mut run);
1915        expect_needs_resolution(
1916            run.model_response(tool_call_turn("call_1", "unknown"))
1917                .expect("model_response should succeed"),
1918        );
1919        let err = run
1920            .resolve_invalid_tool_call(InvalidToolCallAction::stop("operator stop"))
1921            .expect_err("stop should cancel the run");
1922        assert!(matches!(
1923            err,
1924            PromptError::PromptCancelled { reason, .. } if reason == "operator stop"
1925        ));
1926
1927        let err = run
1928            .next_step()
1929            .expect_err("a stopped run must remain terminal");
1930        assert!(matches!(
1931            err,
1932            PromptError::PromptCancelled { reason, .. }
1933                if reason.contains("next_step called after the run already failed")
1934        ));
1935    }
1936
1937    #[test]
1938    fn invalid_tool_call_retry_rolls_back_with_feedback() {
1939        let mut run = AgentRun::new("call something")
1940            .max_turns(2)
1941            .max_invalid_tool_call_retries(1);
1942
1943        expect_call_model(&mut run);
1944        expect_needs_resolution(
1945            run.model_response(tool_call_turn("call_1", "unknown"))
1946                .expect("model_response should succeed"),
1947        );
1948        let outcome = run
1949            .resolve_invalid_tool_call(InvalidToolCallAction::retry("use add instead"))
1950            .expect("retry should be accepted");
1951        assert!(matches!(outcome, ModelTurnOutcome::TurnRetried));
1952
1953        // The rolled-back turn appended the assistant message and feedback.
1954        assert_eq!(run.messages().len(), 3);
1955        let (prompt, _, turn) = expect_call_model(&mut run);
1956        assert_eq!(turn, 2);
1957        assert!(matches!(
1958            prompt,
1959            Message::User { ref content }
1960                if matches!(content.first(), UserContent::ToolResult(_))
1961        ));
1962
1963        // Budget of one: a second retry fails with UnknownToolCall.
1964        expect_needs_resolution(
1965            run.model_response(tool_call_turn("call_2", "unknown"))
1966                .expect("model_response should succeed"),
1967        );
1968        let err = run
1969            .resolve_invalid_tool_call(InvalidToolCallAction::retry("again"))
1970            .expect_err("budget exhausted");
1971        assert!(matches!(err, PromptError::UnknownToolCall { .. }));
1972    }
1973
1974    #[test]
1975    fn invalid_tool_call_retry_cannot_emit_call_past_total_budget() {
1976        let mut run = AgentRun::new("call something")
1977            .max_turns(1)
1978            .max_invalid_tool_call_retries(1);
1979
1980        expect_call_model(&mut run);
1981        expect_needs_resolution(
1982            run.model_response(tool_call_turn("call_1", "unknown"))
1983                .expect("model_response should succeed"),
1984        );
1985        let outcome = run
1986            .resolve_invalid_tool_call(InvalidToolCallAction::retry("use add instead"))
1987            .expect("retry resolution should be accepted");
1988        assert!(matches!(outcome, ModelTurnOutcome::TurnRetried));
1989        assert_eq!(run.completion_calls().len(), 1);
1990
1991        let err = run
1992            .next_step()
1993            .expect_err("retry must not emit a second model call");
1994        assert!(matches!(
1995            err,
1996            PromptError::MaxTurnsError { max_turns: 1, .. }
1997        ));
1998        assert_eq!(run.turn(), 1);
1999    }
2000
2001    #[test]
2002    fn invalid_tool_call_repair_renames_and_suppresses_response_hook() {
2003        let mut run = AgentRun::new("call something").max_turns(2);
2004
2005        expect_call_model(&mut run);
2006        expect_needs_resolution(
2007            run.model_response(tool_call_turn("call_1", "default_api"))
2008                .expect("model_response should succeed"),
2009        );
2010        let suppressed = expect_continue(
2011            run.resolve_invalid_tool_call(InvalidToolCallAction::repair("add"))
2012                .expect("repair should be accepted"),
2013        );
2014        assert!(suppressed);
2015
2016        let calls = expect_call_tools(&mut run);
2017        assert_eq!(calls[0].tool_call.function.name, "add");
2018        assert!(calls[0].preresolved_result.is_none());
2019    }
2020
2021    #[test]
2022    fn invalid_tool_call_repair_to_disallowed_name_fails() {
2023        let mut run = AgentRun::new("call something");
2024
2025        expect_call_model(&mut run);
2026        expect_needs_resolution(
2027            run.model_response(tool_call_turn("call_1", "unknown"))
2028                .expect("model_response should succeed"),
2029        );
2030        let err = run
2031            .resolve_invalid_tool_call(InvalidToolCallAction::repair("also_unknown"))
2032            .expect_err("repair to disallowed name should fail");
2033        assert!(matches!(
2034            err,
2035            PromptError::UnknownToolCall { tool_name, .. } if tool_name == "also_unknown"
2036        ));
2037    }
2038
2039    #[test]
2040    fn invalid_tool_call_skip_suppresses_all_peer_executions() {
2041        let mut run = AgentRun::new("call things").max_turns(2);
2042
2043        expect_call_model(&mut run);
2044        let turn = ModelTurn::new(
2045            None,
2046            OneOrMany::many(vec![
2047                tool_call("call_1", "unknown"),
2048                tool_call("call_2", "add"),
2049            ])
2050            .expect("two items"),
2051            Usage::new(),
2052            tool_names(&["add"]),
2053            tool_names(&["add"]),
2054        );
2055        expect_needs_resolution(
2056            run.model_response(turn)
2057                .expect("model_response should succeed"),
2058        );
2059        let suppressed = expect_continue(
2060            run.resolve_invalid_tool_call(InvalidToolCallAction::skip("not available"))
2061                .expect("skip should be accepted"),
2062        );
2063        assert!(suppressed);
2064
2065        let calls = expect_call_tools(&mut run);
2066        assert_eq!(calls.len(), 2);
2067        // Both the skipped call and its valid peer carry preresolved results.
2068        assert!(calls.iter().all(|call| call.preresolved_result.is_some()));
2069    }
2070
2071    #[test]
2072    fn skip_under_tool_choice_none_fails() {
2073        let mut run = AgentRun::new("call something").with_tool_choice(ToolChoice::None);
2074
2075        expect_call_model(&mut run);
2076        expect_needs_resolution(
2077            run.model_response(ModelTurn::new(
2078                None,
2079                OneOrMany::one(tool_call("call_1", "add")),
2080                Usage::new(),
2081                tool_names(&["add"]),
2082                BTreeSet::new(),
2083            ))
2084            .expect("model_response should succeed"),
2085        );
2086        let err = run
2087            .resolve_invalid_tool_call(InvalidToolCallAction::skip("nope"))
2088            .expect_err("skip under ToolChoice::None should fail");
2089        assert!(matches!(err, PromptError::UnknownToolCall { .. }));
2090    }
2091
2092    #[test]
2093    fn empty_tool_results_cancel_the_run() {
2094        let mut run = AgentRun::new("call something").max_turns(2);
2095
2096        expect_call_model(&mut run);
2097        expect_continue(
2098            run.model_response(tool_call_turn("call_1", "add"))
2099                .expect("model_response should succeed"),
2100        );
2101        expect_call_tools(&mut run);
2102
2103        let err = run
2104            .tool_results(Vec::new())
2105            .expect_err("empty results should cancel");
2106        assert!(matches!(
2107            err,
2108            PromptError::PromptCancelled { reason, .. }
2109                if reason.contains("tool execution produced no tool results")
2110        ));
2111    }
2112
2113    #[test]
2114    fn out_of_protocol_calls_are_rejected_without_corrupting_state() {
2115        let mut run = AgentRun::new("hello");
2116
2117        let err = run
2118            .tool_results(vec![tool_result("call_1", "x")])
2119            .expect_err("no CallTools pending");
2120        assert!(matches!(err, PromptError::PromptCancelled { .. }));
2121
2122        // The run is still drivable after a rejected out-of-protocol call.
2123        expect_call_model(&mut run);
2124        let err = run
2125            .next_step()
2126            .expect_err("model response is pending, next_step must be rejected");
2127        assert!(matches!(err, PromptError::PromptCancelled { .. }));
2128        expect_continue(
2129            run.model_response(text_turn("hi"))
2130                .expect("model_response should still succeed"),
2131        );
2132        assert_eq!(expect_done(&mut run).output, "hi");
2133    }
2134
2135    #[test]
2136    fn model_response_rejected_after_streamed_completion_call_record() {
2137        let mut run = AgentRun::new("hello");
2138        expect_call_model(&mut run);
2139        run.record_streamed_completion_call(Usage::new())
2140            .expect("record should succeed");
2141
2142        let err = run
2143            .model_response(text_turn("hi"))
2144            .expect_err("mixed streamed/non-streamed ingestion must be rejected");
2145        assert!(matches!(err, PromptError::PromptCancelled { .. }));
2146        // No duplicate completion call was appended.
2147        assert_eq!(run.completion_calls().len(), 1);
2148    }
2149
2150    #[test]
2151    fn done_step_is_idempotent() {
2152        let mut run = AgentRun::new("hello");
2153        expect_call_model(&mut run);
2154        expect_continue(
2155            run.model_response(text_turn("hi"))
2156                .expect("model_response should succeed"),
2157        );
2158        assert_eq!(expect_done(&mut run).output, "hi");
2159        assert_eq!(expect_done(&mut run).output, "hi");
2160    }
2161
2162    #[test]
2163    fn serialized_run_alone_carries_pending_tool_calls() {
2164        let mut run = AgentRun::new("add things").max_turns(2);
2165        expect_call_model(&mut run);
2166        expect_continue(
2167            run.model_response(tool_call_turn("call_1", "add"))
2168                .expect("model_response should succeed"),
2169        );
2170        expect_call_tools(&mut run);
2171
2172        // A fresh process receives only the serialized run: the pending tool
2173        // calls must be recoverable from the state itself.
2174        let serialized = serde_json::to_string(&run).expect("mid-run state should serialize");
2175        drop(run);
2176        let mut resumed: AgentRun =
2177            serde_json::from_str(&serialized).expect("mid-run state should deserialize");
2178
2179        let calls = expect_call_tools(&mut resumed);
2180        assert_eq!(calls.len(), 1);
2181        assert_eq!(calls[0].tool_call.function.name, "add");
2182        // Re-emission is idempotent while results are pending.
2183        let calls_again = expect_call_tools(&mut resumed);
2184        assert_eq!(calls_again[0].tool_call.id, calls[0].tool_call.id);
2185
2186        // Answer using only IDs learned from the re-emitted step.
2187        let results = calls
2188            .iter()
2189            .map(|call| tool_result(&call.tool_call.id, "2"))
2190            .collect::<Vec<_>>();
2191        resumed
2192            .tool_results(results)
2193            .expect("tool_results should succeed");
2194        expect_call_model(&mut resumed);
2195        expect_continue(
2196            resumed
2197                .model_response(text_turn("done"))
2198                .expect("model_response should succeed"),
2199        );
2200        assert_eq!(expect_done(&mut resumed).output, "done");
2201    }
2202
2203    #[test]
2204    fn tool_results_validates_against_pending_calls() {
2205        let drive_to_pending_tools = || {
2206            let mut run = AgentRun::new("add things").max_turns(2);
2207            expect_call_model(&mut run);
2208            expect_continue(
2209                run.model_response(tool_call_turn("call_1", "add"))
2210                    .expect("model_response should succeed"),
2211            );
2212            expect_call_tools(&mut run);
2213            run
2214        };
2215
2216        // A result for an unknown call ID is rejected without corrupting the run.
2217        let mut run = drive_to_pending_tools();
2218        let err = run
2219            .tool_results(vec![tool_result("call_unknown", "2")])
2220            .expect_err("unknown tool call id must be rejected");
2221        assert!(matches!(err, PromptError::PromptCancelled { .. }));
2222        run.tool_results(vec![tool_result("call_1", "2")])
2223            .expect("valid results should still be accepted after a rejection");
2224
2225        // Leaving a pending call unanswered is rejected.
2226        let mut run = drive_to_pending_tools();
2227        let err = run
2228            .tool_results(vec![tool_result("call_1", "2"), tool_result("call_1", "3")])
2229            .expect_err("answering one call twice must be rejected");
2230        assert!(matches!(err, PromptError::PromptCancelled { .. }));
2231
2232        // Non-tool-result content is rejected.
2233        let mut run = drive_to_pending_tools();
2234        let err = run
2235            .tool_results(vec![UserContent::text("not a tool result")])
2236            .expect_err("non-tool-result content must be rejected");
2237        assert!(matches!(err, PromptError::PromptCancelled { .. }));
2238    }
2239
2240    #[test]
2241    fn agent_run_deserializes_pre_monoid_suspended_state() {
2242        // Fixture captured from rig before CompletionCall.usage dropped its
2243        // Option encoding, suspended at ExecutingTools with a null-usage
2244        // completion call. It must deserialize and resume.
2245        let fixture = r#"{"max_turns":2,"max_invalid_tool_call_retries":0,"tool_choice":null,"chat_history":null,"new_messages":[{"role":"user","content":[{"type":"text","text":"add things"}]},{"role":"assistant","id":null,"content":[{"id":"call_1","call_id":null,"function":{"name":"add","arguments":{"x":1}},"signature":null,"additional_params":null}]}],"current_turn":1,"usage":{"input_tokens":10,"output_tokens":5,"total_tokens":15,"cached_input_tokens":0,"cache_creation_input_tokens":0,"tool_use_prompt_tokens":0,"reasoning_tokens":0},"completion_calls":[{"call_index":0,"usage":null}],"completion_call_index":1,"invalid_tool_call_retries":0,"rollback_pending":false,"streamed_completion_call_recorded":false,"state":{"ExecutingTools":[{"tool_call":{"id":"call_1","call_id":null,"function":{"name":"add","arguments":{"x":1}},"signature":null,"additional_params":null},"preresolved_result":null,"internal_call_id":null}]}}"#;
2246
2247        let mut restored: AgentRun =
2248            serde_json::from_str(fixture).expect("old-format suspended run should deserialize");
2249        assert_eq!(restored.completion_calls()[0].usage, Usage::new());
2250
2251        let calls = expect_call_tools(&mut restored);
2252        assert_eq!(calls.len(), 1);
2253        restored
2254            .tool_results(vec![tool_result("call_1", "2")])
2255            .expect("tool_results should succeed");
2256        expect_call_model(&mut restored);
2257    }
2258
2259    #[test]
2260    fn serde_round_trip_at_exhausted_budget_preserves_boundary() {
2261        let mut run = AgentRun::new("add things").max_turns(1);
2262        expect_call_model(&mut run);
2263        expect_continue(
2264            run.model_response(tool_call_turn("call_1", "add"))
2265                .expect("model_response should succeed"),
2266        );
2267        expect_call_tools(&mut run);
2268        run.tool_results(vec![tool_result("call_1", "2")])
2269            .expect("tool_results should succeed");
2270
2271        let serialized = serde_json::to_string(&run).expect("exhausted run should serialize");
2272        let mut restored: AgentRun =
2273            serde_json::from_str(&serialized).expect("exhausted run should deserialize");
2274        assert_eq!(restored.completion_calls().len(), 1);
2275        let err = restored
2276            .next_step()
2277            .expect_err("restored run must not emit a second model call");
2278        assert!(matches!(
2279            err,
2280            PromptError::MaxTurnsError { max_turns: 1, .. }
2281        ));
2282        assert_eq!(restored.turn(), 1);
2283    }
2284
2285    #[test]
2286    fn serde_round_trip_mid_run_resumes_identically() {
2287        let drive_to_pending_tools = || {
2288            let mut run = AgentRun::new("add things").max_turns(2);
2289            expect_call_model(&mut run);
2290            expect_continue(
2291                run.model_response(
2292                    tool_call_turn("call_1", "add").with_usage_for_test(usage(10, 5)),
2293                )
2294                .expect("model_response should succeed"),
2295            );
2296            expect_call_tools(&mut run);
2297            run
2298        };
2299
2300        let finish = |mut run: AgentRun| {
2301            run.tool_results(vec![tool_result("call_1", "2")])
2302                .expect("tool_results should succeed");
2303            expect_call_model(&mut run);
2304            expect_continue(
2305                run.model_response(text_turn("done").with_usage_for_test(usage(3, 4)))
2306                    .expect("model_response should succeed"),
2307            );
2308            expect_done(&mut run)
2309        };
2310
2311        let uninterrupted = finish(drive_to_pending_tools());
2312
2313        let suspended = drive_to_pending_tools();
2314        let serialized = serde_json::to_string(&suspended).expect("mid-run state should serialize");
2315        let restored: AgentRun =
2316            serde_json::from_str(&serialized).expect("mid-run state should deserialize");
2317        let resumed = finish(restored);
2318
2319        assert_eq!(resumed.output, uninterrupted.output);
2320        assert_eq!(resumed.usage, uninterrupted.usage);
2321        assert_eq!(resumed.completion_calls, uninterrupted.completion_calls);
2322        // Compare messages by their serialized form: deserializing a message
2323        // normalizes absent `additional_params` to an empty map, which is
2324        // semantically identical and serializes identically.
2325        assert_eq!(
2326            serde_json::to_value(&resumed.messages).expect("messages should serialize"),
2327            serde_json::to_value(&uninterrupted.messages).expect("messages should serialize"),
2328        );
2329    }
2330
2331    #[test]
2332    fn pending_invalid_tool_call_survives_serde_round_trip() {
2333        let mut run = AgentRun::new("call something");
2334        expect_call_model(&mut run);
2335        let context = expect_needs_resolution(
2336            run.model_response(tool_call_turn("call_1", "unknown"))
2337                .expect("model_response should succeed"),
2338        );
2339
2340        let serialized = serde_json::to_string(&run).expect("state should serialize");
2341        let restored: AgentRun =
2342            serde_json::from_str(&serialized).expect("state should deserialize");
2343        let restored_context = restored
2344            .pending_invalid_tool_call()
2345            .expect("pending resolution should survive serialization");
2346        assert_eq!(restored_context.tool_name, context.tool_name);
2347        assert_eq!(
2348            restored_context.chat_history.len(),
2349            context.chat_history.len()
2350        );
2351    }
2352
2353    /// A turn calling `name`, advertising it as an allowed-but-not-executable
2354    /// tool (the shape Tool output mode produces — see #1928).
2355    fn output_tool_turn(id: &str, name: &str) -> ModelTurn {
2356        ModelTurn::new(
2357            None,
2358            OneOrMany::one(tool_call(id, name)),
2359            Usage::new(),
2360            tool_names(&["add"]),
2361            tool_names(&["add", name]),
2362        )
2363    }
2364
2365    fn output_tool_turn_with_args(id: &str, name: &str, arguments: serde_json::Value) -> ModelTurn {
2366        ModelTurn::new(
2367            None,
2368            OneOrMany::one(AssistantContent::ToolCall(ToolCall::new(
2369                id.to_string(),
2370                ToolFunction::new(name.to_string(), arguments),
2371            ))),
2372            Usage::new(),
2373            tool_names(&["add"]),
2374            tool_names(&["add", name]),
2375        )
2376    }
2377
2378    /// Every assistant tool call in `messages` must have a matching user tool
2379    /// result — an unanswered tool_use is rejected by providers on replay.
2380    fn assert_no_orphan_tool_use(messages: &[Message]) {
2381        let mut answered = BTreeSet::new();
2382        for message in messages {
2383            if let Message::User { content } = message {
2384                for item in content.iter() {
2385                    if let UserContent::ToolResult(result) = item {
2386                        answered.insert(result.id.clone());
2387                    }
2388                }
2389            }
2390        }
2391        for message in messages {
2392            if let Message::Assistant { content, .. } = message {
2393                for item in content.iter() {
2394                    if let AssistantContent::ToolCall(call) = item {
2395                        assert!(
2396                            answered.contains(&call.id),
2397                            "assistant tool_call {:?} has no matching tool_result in history",
2398                            call.id
2399                        );
2400                    }
2401                }
2402            }
2403        }
2404    }
2405
2406    #[test]
2407    fn output_tool_call_finalizes_run_with_arguments() {
2408        let mut run = AgentRun::new("summarize").with_output_tool_name("final_result");
2409
2410        expect_call_model(&mut run);
2411        expect_continue(
2412            run.model_response(output_tool_turn("call_1", "final_result"))
2413                .expect("model_response should succeed"),
2414        );
2415
2416        // The output tool is not executed; its arguments become the run output.
2417        let response = expect_done(&mut run);
2418        assert_eq!(response.output, r#"{"x":1}"#);
2419        assert!(run.is_done());
2420
2421        // The finalizing turn is persisted as assistant text, not as the raw
2422        // output-tool call, so the saved history has no dangling tool_use.
2423        let messages = response.messages.expect("messages should be recorded");
2424        assert_no_orphan_tool_use(&messages);
2425        assert!(matches!(
2426            messages.last(),
2427            Some(Message::Assistant { content, .. })
2428                if assistant_text_from_choice(content) == r#"{"x":1}"#
2429        ));
2430    }
2431
2432    #[test]
2433    fn scalar_output_tool_call_is_serialized_as_reparseable_json() {
2434        let mut run = AgentRun::new("summarize").with_output_tool_name("final_result");
2435
2436        expect_call_model(&mut run);
2437        expect_continue(
2438            run.model_response(output_tool_turn_with_args(
2439                "call_1",
2440                "final_result",
2441                json!("complete"),
2442            ))
2443            .expect("model_response should succeed"),
2444        );
2445
2446        let response = expect_done(&mut run);
2447        assert_eq!(
2448            serde_json::from_str::<serde_json::Value>(&response.output)
2449                .expect("scalar output must remain valid JSON"),
2450            json!("complete")
2451        );
2452        assert_eq!(response.output, r#""complete""#);
2453
2454        let messages = response.messages.expect("messages should be recorded");
2455        assert_no_orphan_tool_use(&messages);
2456        assert!(matches!(
2457            messages.last(),
2458            Some(Message::Assistant { content, .. })
2459                if assistant_text_from_choice(content) == r#""complete""#
2460        ));
2461    }
2462
2463    #[test]
2464    fn output_tool_call_wins_over_sibling_real_tool_calls() {
2465        let mut run = AgentRun::new("do it")
2466            .max_turns(2)
2467            .with_output_tool_name("final_result");
2468
2469        expect_call_model(&mut run);
2470        // The model emits a real tool call *and* the output tool in one turn;
2471        // the output-tool intercept wins and the real call is never executed.
2472        let turn = ModelTurn::new(
2473            None,
2474            OneOrMany::many(vec![
2475                tool_call("call_1", "add"),
2476                tool_call("call_2", "final_result"),
2477            ])
2478            .expect("two items"),
2479            Usage::new(),
2480            tool_names(&["add"]),
2481            tool_names(&["add", "final_result"]),
2482        );
2483        expect_continue(
2484            run.model_response(turn)
2485                .expect("model_response should succeed"),
2486        );
2487
2488        let response = expect_done(&mut run);
2489        assert_eq!(response.output, r#"{"x":1}"#);
2490        assert!(run.is_done());
2491
2492        // Both the sibling `add` call and the output-tool call are dropped from
2493        // the persisted assistant message, leaving no unanswered tool_use.
2494        let messages = response.messages.expect("messages should be recorded");
2495        assert_no_orphan_tool_use(&messages);
2496        assert!(
2497            messages.iter().all(|message| match message {
2498                Message::Assistant { content, .. } => !content
2499                    .iter()
2500                    .any(|item| matches!(item, AssistantContent::ToolCall(_))),
2501                _ => true,
2502            }),
2503            "no assistant tool calls should survive in the finalized history"
2504        );
2505    }
2506
2507    #[test]
2508    fn real_tool_calls_still_execute_when_output_tool_unused() {
2509        // With an output tool configured but only real tools called, the run
2510        // proceeds to tool execution as normal (the intercept must not fire).
2511        let mut run = AgentRun::new("add things")
2512            .max_turns(2)
2513            .with_output_tool_name("final_result");
2514
2515        expect_call_model(&mut run);
2516        expect_continue(
2517            run.model_response(tool_call_turn("call_1", "add"))
2518                .expect("model_response should succeed"),
2519        );
2520
2521        let calls = expect_call_tools(&mut run);
2522        assert_eq!(calls.len(), 1);
2523        assert_eq!(calls[0].tool_call.function.name, "add");
2524    }
2525
2526    fn required_field_schema(field: &str) -> serde_json::Value {
2527        json!({
2528            "type": "object",
2529            "required": [field],
2530            "properties": { field: { "type": "string" } },
2531        })
2532    }
2533
2534    #[test]
2535    fn tool_mode_reprompts_when_output_tool_not_called() {
2536        // #1928: in Tool mode the model finalized with plain text instead of
2537        // calling the output tool, so the run re-prompts (within budget).
2538        let mut run = AgentRun::new("summarize")
2539            .max_turns(2)
2540            .with_output_tool_name("final_result")
2541            .with_output_validation(Some(required_field_schema("summary")), 1);
2542
2543        expect_call_model(&mut run);
2544        expect_continue(
2545            run.model_response(text_turn("here is the answer"))
2546                .expect("model_response should succeed"),
2547        );
2548
2549        // Instead of finalizing, the run emits a second CallModel with corrective
2550        // feedback naming the output tool.
2551        let (prompt, _history, turn) = expect_call_model(&mut run);
2552        assert_eq!(turn, 2);
2553        let prompt_json = serde_json::to_string(&prompt).expect("prompt should serialize");
2554        assert!(
2555            prompt_json.contains("final_result"),
2556            "re-prompt feedback should name the output tool: {prompt_json}"
2557        );
2558        assert!(!run.is_done());
2559    }
2560
2561    #[test]
2562    fn tool_mode_reprompts_when_output_args_missing_required_fields() {
2563        // #1928: the output tool was called but its arguments omit a required
2564        // field, so the run re-prompts rather than finalizing invalid output.
2565        let mut run = AgentRun::new("summarize")
2566            .max_turns(2)
2567            .with_output_tool_name("final_result")
2568            // `output_tool_turn` calls with args {"x":1}; require a different key.
2569            .with_output_validation(Some(required_field_schema("summary")), 1);
2570
2571        expect_call_model(&mut run);
2572        expect_continue(
2573            run.model_response(output_tool_turn("call_1", "final_result"))
2574                .expect("model_response should succeed"),
2575        );
2576
2577        let (_prompt, _history, turn) = expect_call_model(&mut run);
2578        assert_eq!(turn, 2);
2579        assert!(!run.is_done());
2580    }
2581
2582    #[test]
2583    fn tool_mode_accepts_valid_json_text_without_reprompting() {
2584        // The model returned valid structured output as plain text instead of an
2585        // output-tool call — accept it rather than wasting a turn re-prompting.
2586        let mut run = AgentRun::new("summarize")
2587            .max_turns(3)
2588            .with_output_tool_name("final_result")
2589            .with_output_validation(Some(required_field_schema("summary")), 1);
2590
2591        expect_call_model(&mut run);
2592        expect_continue(
2593            run.model_response(text_turn(r#"{"summary":"all good"}"#))
2594                .expect("model_response should succeed"),
2595        );
2596
2597        let response = expect_done(&mut run);
2598        assert_eq!(response.output, r#"{"summary":"all good"}"#);
2599        assert!(run.is_done());
2600    }
2601
2602    #[test]
2603    fn tool_mode_finalizes_best_effort_when_model_call_budget_exhausted() {
2604        let mut run = AgentRun::new("summarize")
2605            .max_turns(1)
2606            .with_output_tool_name("final_result")
2607            .with_output_validation(Some(required_field_schema("summary")), 1);
2608
2609        expect_call_model(&mut run);
2610        expect_continue(
2611            run.model_response(text_turn("invalid output"))
2612                .expect("model_response should succeed"),
2613        );
2614
2615        let response = expect_done(&mut run);
2616        assert_eq!(response.output, "invalid output");
2617        assert_eq!(run.turn(), 1);
2618    }
2619
2620    #[test]
2621    fn tool_mode_finalizes_best_effort_when_output_retry_budget_exhausted() {
2622        // With no retry budget, invalid output finalizes best-effort (the caller
2623        // validates) rather than looping — and history stays free of orphan
2624        // tool_use.
2625        let mut run = AgentRun::new("summarize")
2626            .max_turns(3)
2627            .with_output_tool_name("final_result")
2628            .with_output_validation(Some(required_field_schema("summary")), 0);
2629
2630        expect_call_model(&mut run);
2631        expect_continue(
2632            run.model_response(output_tool_turn("call_1", "final_result"))
2633                .expect("model_response should succeed"),
2634        );
2635
2636        let response = expect_done(&mut run);
2637        assert_eq!(response.output, r#"{"x":1}"#);
2638        let messages = response.messages.expect("messages should be recorded");
2639        assert_no_orphan_tool_use(&messages);
2640    }
2641
2642    #[test]
2643    fn set_output_tool_name_is_idempotent_and_only_fills_when_unset() {
2644        // A pre-set name (e.g. via `with_output_tool_name`) is never overwritten,
2645        // keeping a resumed run deterministic.
2646        let mut run = AgentRun::new("x").with_output_tool_name("first");
2647        run.set_output_tool_name(Some("second".to_string()));
2648        run.set_output_tool_name(None);
2649        assert_eq!(run.output_tool_name.as_deref(), Some("first"));
2650
2651        // When unset, the first non-None value fills it.
2652        let mut run = AgentRun::new("x");
2653        run.set_output_tool_name(None);
2654        assert_eq!(run.output_tool_name, None);
2655        run.set_output_tool_name(Some("filled".to_string()));
2656        assert_eq!(run.output_tool_name.as_deref(), Some("filled"));
2657    }
2658
2659    impl ModelTurn {
2660        fn with_usage_for_test(mut self, usage: Usage) -> Self {
2661            self.usage = usage;
2662            self
2663        }
2664    }
2665
2666    /// Durable human-in-the-loop: the run is serialized while tool calls are
2667    /// pending, reconstructed from JSON (as a separate process / request would),
2668    /// and only then does the human decision land — approve one call, deny the
2669    /// other. The resumed-from-bytes run accepts those results and continues to
2670    /// completion, proving approval can happen out-of-process / arbitrarily later.
2671    /// This is the state-machine foundation for `examples/agent_with_durable_approval`.
2672    #[test]
2673    fn durable_human_in_the_loop_approval_survives_serialize_resume() {
2674        let mut run = AgentRun::new("pay two invoices").max_turns(3);
2675        let (_, _, turn) = expect_call_model(&mut run);
2676        assert_eq!(turn, 1);
2677
2678        // Turn 1: the model emits two tool calls.
2679        let two_calls =
2680            OneOrMany::many([tool_call("c1", "add"), tool_call("c2", "add")]).expect("two calls");
2681        let outcome = run
2682            .model_response(ModelTurn::new(
2683                None,
2684                two_calls,
2685                Usage::new(),
2686                tool_names(&["add"]),
2687                tool_names(&["add"]),
2688            ))
2689            .expect("model_response");
2690        expect_continue(outcome);
2691
2692        // CallTools is now pending. Serialize the run (a durable checkpoint) and
2693        // reconstruct it from the bytes — nothing live crosses this boundary.
2694        let checkpoint = serde_json::to_string(&run).expect("serialize suspended run");
2695        let mut resumed: AgentRun = serde_json::from_str(&checkpoint).expect("deserialize run");
2696
2697        // The resumed run re-emits the pending calls purely from its own state.
2698        let calls = expect_call_tools(&mut resumed);
2699        assert_eq!(calls.len(), 2);
2700        assert_eq!(calls[0].tool_call.id, "c1");
2701        assert_eq!(calls[1].tool_call.id, "c2");
2702
2703        // The human decision lands only after the resume: approve c1 (real
2704        // result), deny c2 (the reason becomes the tool result the model sees).
2705        resumed
2706            .tool_results(vec![
2707                tool_result("c1", "approved-result"),
2708                tool_result("c2", "denied by reviewer: second payment not authorized"),
2709            ])
2710            .expect("tool_results on the resumed run");
2711
2712        // Both decisions are recorded in the resumed run's persisted state.
2713        let after = serde_json::to_string(&resumed).expect("serialize resumed run");
2714        assert!(
2715            after.contains("approved-result"),
2716            "the approved call's result must be in the resumed run state"
2717        );
2718        assert!(
2719            after.contains("denied by reviewer: second payment not authorized"),
2720            "the denied call's reason must be in the resumed run state"
2721        );
2722
2723        // Turn 2: the model wraps up; the run completes from the resumed state.
2724        let (_, _, turn2) = expect_call_model(&mut resumed);
2725        assert_eq!(turn2, 2);
2726        expect_continue(
2727            resumed
2728                .model_response(text_turn("done"))
2729                .expect("model_response 2"),
2730        );
2731        let response = expect_done(&mut resumed);
2732        assert_eq!(response.output, "done");
2733    }
2734}