Skip to main content

rig_agent/agent/run/
mod.rs

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