Skip to main content

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