Skip to main content

rig_core/agent/run/
streamed.rs

1//! Streamed-turn assembly for [`AgentRun`](super::AgentRun).
2//!
3//! A streamed model turn arrives as incremental [`StreamedAssistantContent`]
4//! items. [`StreamedTurnAssembler`] is the sans-IO accumulator that turns that
5//! item stream into the same canonical complete turn the non-streaming path
6//! feeds the machine — while telling the driver what to forward to its
7//! consumer and surfacing invalid tool calls the moment they appear, so a
8//! driver can stop paying for a doomed provider stream early.
9//!
10//! The protocol, paired with the streamed entry points on
11//! [`AgentRun`](super::AgentRun):
12//!
13//! 1. On [`AgentRunStep::CallModel`](super::AgentRunStep::CallModel), open a
14//!    provider stream and create one assembler per turn with the tool names
15//!    advertised for that turn.
16//! 2. Feed every stream item to [`StreamedTurnAssembler::ingest`] and act on
17//!    the returned [`StreamedTurnEvent`]s: forward items to the consumer, and
18//!    on [`StreamedTurnEvent::InvalidToolCall`] consult
19//!    [`AgentRun::resolve_streamed_invalid_tool_call`](super::AgentRun::resolve_streamed_invalid_tool_call) —
20//!    [`StreamedResolution::Repaired`] continues the same stream via
21//!    [`StreamedTurnAssembler::resolve_pending_invalid`];
22//!    [`StreamedResolution::TurnAbandoned`] means drain the provider stream
23//!    for usage and re-enter
24//!    [`AgentRun::next_step`](super::AgentRun::next_step).
25//! 3. When the provider stream ends, call [`StreamedTurnAssembler::finish`]
26//!    and feed the result to
27//!    [`AgentRun::streamed_turn`](super::AgentRun::streamed_turn); the run
28//!    then proceeds exactly like a non-streamed one
29//!    ([`CallTools`](super::AgentRunStep::CallTools) /
30//!    [`Done`](super::AgentRunStep::Done)).
31//!
32//! [`crate::streaming::StreamingPrompt::stream_prompt`] drives this protocol
33//! internally; hand-driven runs can use it to stream any
34//! [`AgentRun`](super::AgentRun).
35
36use std::collections::{BTreeSet, HashMap};
37
38use serde::{Deserialize, Serialize};
39
40use crate::{
41    OneOrMany,
42    agent::prompt_request::{TOOL_NOT_EXECUTED_DUE_TO_INVALID_PEER, tool_result_message},
43    completion::{CompletionError, GetTokenUsage, Message, Usage},
44    json_utils,
45    message::{AssistantContent, Reasoning, ToolCall, ToolFunction, ToolResult},
46    streaming::{StreamedAssistantContent, ToolCallDeltaContent},
47};
48
49/// Merge an incoming reasoning block into the accumulated reasoning,
50/// extending an existing block when provider-assigned IDs match.
51pub(crate) fn merge_reasoning_blocks(
52    accumulated_reasoning: &mut Vec<Reasoning>,
53    incoming: &Reasoning,
54) {
55    let ids_match = |existing: &Reasoning| {
56        matches!(
57            (&existing.id, &incoming.id),
58            (Some(existing_id), Some(incoming_id)) if existing_id == incoming_id
59        )
60    };
61
62    if let Some(existing) = accumulated_reasoning
63        .iter_mut()
64        .rev()
65        .find(|existing| ids_match(existing))
66    {
67        existing.content.extend(incoming.content.clone());
68    } else {
69        accumulated_reasoning.push(incoming.clone());
70    }
71}
72
73/// Assemble assistant content in canonical replay order: reasoning blocks,
74/// then text, then trailing items (tool calls, images).
75pub(crate) fn ordered_streaming_assistant_content(
76    reasoning_items: impl IntoIterator<Item = Reasoning>,
77    text_items: impl IntoIterator<Item = AssistantContent>,
78    trailing_items: impl IntoIterator<Item = AssistantContent>,
79) -> Option<OneOrMany<AssistantContent>> {
80    let mut content_items = reasoning_items
81        .into_iter()
82        .map(AssistantContent::Reasoning)
83        .collect::<Vec<_>>();
84    content_items.extend(text_items);
85    content_items.extend(trailing_items);
86
87    OneOrMany::from_iter_optional(content_items)
88}
89
90pub(crate) fn assistant_text_items_from_choice(
91    choice: &OneOrMany<AssistantContent>,
92) -> Vec<AssistantContent> {
93    choice
94        .iter()
95        .filter_map(|content| match content {
96            AssistantContent::Text(text) => (!text.text.is_empty()
97                || text.additional_params.is_some())
98            .then(|| AssistantContent::Text(text.clone())),
99            _ => None,
100        })
101        .collect()
102}
103
104/// One invalid tool call surfaced mid-stream, awaiting a resolution from
105/// [`AgentRun::resolve_streamed_invalid_tool_call`](super::AgentRun::resolve_streamed_invalid_tool_call).
106#[derive(Debug, Clone, Serialize, Deserialize)]
107#[non_exhaustive]
108pub struct StreamedInvalidToolCall {
109    /// The rejected tool call. For a name delta this is a diagnostic call
110    /// assembled from the streamed name and any buffered argument deltas.
111    pub tool_call: ToolCall,
112    /// Rig-generated identifier correlating this call's stream items.
113    pub internal_call_id: String,
114    /// Raw argument payload for diagnostics, when available.
115    pub args: Option<String>,
116    /// Executable Rig tools advertised to the provider for this turn.
117    pub executable_tool_names: BTreeSet<String>,
118    /// Tools allowed by the active tool choice for this turn.
119    pub allowed_tool_names: BTreeSet<String>,
120}
121
122/// Snapshot of a streamed turn at the moment an invalid tool call appeared.
123/// Used by the machine to build diagnostics and rollback messages from
124/// exactly what the model has produced so far.
125#[derive(Debug, Clone, Serialize, Deserialize)]
126#[non_exhaustive]
127pub struct PartialStreamedTurn {
128    /// Provider-assigned assistant message ID, when already known.
129    pub message_id: Option<String>,
130    /// Aggregated assistant text, when any text was streamed this turn.
131    pub text: Option<String>,
132    /// Accumulated reasoning, with any pending unsigned delta text assembled
133    /// into a block.
134    pub reasoning: Vec<Reasoning>,
135    /// Tool calls already validated (or repaired) this turn.
136    pub pending_tool_calls: Vec<ToolCall>,
137}
138
139impl PartialStreamedTurn {
140    /// The assistant message representing this partial turn, in canonical
141    /// order, including `current_tool_call` when provided. `None` when the
142    /// turn has produced no representable content.
143    pub(crate) fn assistant_message(&self, current_tool_call: Option<ToolCall>) -> Option<Message> {
144        let text_items = match &self.text {
145            Some(text) if !text.is_empty() => vec![AssistantContent::text(text.clone())],
146            _ => Vec::new(),
147        };
148        let mut tool_items = self
149            .pending_tool_calls
150            .iter()
151            .cloned()
152            .map(AssistantContent::ToolCall)
153            .collect::<Vec<_>>();
154        if let Some(tool_call) = current_tool_call {
155            tool_items.push(AssistantContent::ToolCall(tool_call));
156        }
157
158        let content = ordered_streaming_assistant_content(
159            self.reasoning.iter().cloned(),
160            text_items,
161            tool_items,
162        )?;
163        Some(Message::Assistant {
164            id: self.message_id.clone(),
165            content,
166        })
167    }
168
169    /// Rollback messages for a retried or skipped streamed turn: the partial
170    /// assistant turn plus a user message carrying `feedback` for the invalid
171    /// call and a synthetic "not executed" result for each validated peer.
172    pub(crate) fn rollback_messages(
173        &self,
174        invalid_tool_call: ToolCall,
175        feedback: String,
176    ) -> Option<(Message, Message)> {
177        let assistant_message = self.assistant_message(Some(invalid_tool_call.clone()))?;
178
179        let mut retry_results = self
180            .pending_tool_calls
181            .iter()
182            .map(|tool_call| {
183                tool_result_message(
184                    tool_call.id.clone(),
185                    tool_call.call_id.clone(),
186                    TOOL_NOT_EXECUTED_DUE_TO_INVALID_PEER.to_string(),
187                )
188            })
189            .collect::<Vec<_>>();
190        retry_results.push(tool_result_message(
191            invalid_tool_call.id,
192            invalid_tool_call.call_id,
193            feedback,
194        ));
195
196        let user_message = Message::User {
197            content: OneOrMany::from_iter_optional(retry_results)?,
198        };
199
200        Some((assistant_message, user_message))
201    }
202}
203
204/// The assembled streamed turn, fed to
205/// [`AgentRun::streamed_turn`](super::AgentRun::streamed_turn).
206#[derive(Debug, Clone, Serialize, Deserialize)]
207#[non_exhaustive]
208pub struct StreamedTurn {
209    /// Provider-assigned assistant message ID, when available.
210    pub message_id: Option<String>,
211    /// The assistant content to record in history: canonical
212    /// (reasoning → text → tool calls) when the turn produced reasoning or
213    /// tool calls, otherwise the provider's aggregated choice as-is.
214    pub choice: OneOrMany<AssistantContent>,
215    /// Executable Rig tools advertised to the provider for this turn.
216    pub executable_tool_names: BTreeSet<String>,
217    /// Tools allowed by the active tool choice for this turn.
218    pub allowed_tool_names: BTreeSet<String>,
219    /// `(tool_call_id, internal_call_id)` pairs for this turn's tool calls,
220    /// in emission order. Carried into the run state so a resumed process
221    /// keeps the IDs consumers already saw in tool-call deltas.
222    #[serde(default)]
223    pub internal_call_ids: Vec<(String, String)>,
224}
225
226/// What the machine decided about a mid-stream invalid tool call.
227///
228/// Deliberately exhaustive: a driver must handle every resolution, so adding
229/// a variant is a breaking change by design.
230#[derive(Debug)]
231pub enum StreamedResolution {
232    /// The tool name was repaired. Apply it via
233    /// [`StreamedTurnAssembler::resolve_pending_invalid`] and keep consuming
234    /// the provider stream.
235    Repaired {
236        /// The validated replacement tool name.
237        tool_name: String,
238    },
239    /// The turn was rolled back (retry) or the call skipped; corrective
240    /// messages are already in the history. Drain the provider stream for
241    /// usage, record the completion call, then call
242    /// [`AgentRun::next_step`](super::AgentRun::next_step).
243    TurnAbandoned {
244        /// For a skipped call, the synthetic tool result to surface to the
245        /// consumer stream.
246        skipped_tool_result: Option<ToolResult>,
247    },
248}
249
250/// What a driver must do with one ingested stream item.
251///
252/// Deliberately exhaustive: a driver must handle every event, so adding a
253/// variant is a breaking change by design.
254#[derive(Debug, Clone)]
255pub enum StreamedTurnEvent {
256    /// Forward the ingested item to the consumer as-is (text, reasoning, or
257    /// reasoning deltas, after accumulation).
258    EmitIngested,
259    /// Forward this tool-call delta. Argument deltas buffered while the tool
260    /// name awaited validation are replayed through this event.
261    EmitToolCallDelta {
262        /// Provider-supplied tool call ID.
263        id: String,
264        /// Rig-generated identifier correlating this call's stream items.
265        internal_call_id: String,
266        /// The (possibly repaired) name or argument delta.
267        content: ToolCallDeltaContent,
268    },
269    /// The model emitted an unknown or disallowed tool call. Resolve it via
270    /// [`AgentRun::resolve_streamed_invalid_tool_call`](super::AgentRun::resolve_streamed_invalid_tool_call),
271    /// then apply the outcome with
272    /// [`StreamedTurnAssembler::resolve_pending_invalid`].
273    InvalidToolCall(Box<StreamedInvalidToolCall>),
274    /// The provider reported the end of this completion call. Record it (see
275    /// [`AgentRun::record_streamed_completion_call`](super::AgentRun::record_streamed_completion_call));
276    /// when `emit_final` is set, the turn streamed text and the driver should
277    /// run its stream-finish hook and forward the final item.
278    Completed {
279        /// Provider-reported usage for this call. Zero-valued usage means the
280        /// provider reported no usage metrics.
281        usage: Usage,
282        /// Whether the ingested final item should be forwarded to the
283        /// consumer (set when the turn streamed text).
284        emit_final: bool,
285    },
286}
287
288#[derive(Default)]
289struct ToolCallDeltaState {
290    name_validated: bool,
291    buffered_arguments: Vec<String>,
292}
293
294enum PendingInvalid {
295    /// A complete tool call with a disallowed name.
296    FullCall {
297        tool_call: Box<ToolCall>,
298        internal_call_id: String,
299    },
300    /// A streamed tool-name delta with a disallowed name.
301    NameDelta {
302        id: String,
303        internal_call_id: String,
304    },
305}
306
307/// Sans-IO accumulator that assembles one streamed model turn. See the
308/// [module docs](self) for the driving protocol.
309pub struct StreamedTurnAssembler {
310    executable_tool_names: BTreeSet<String>,
311    allowed_tool_names: BTreeSet<String>,
312    text: String,
313    saw_text: bool,
314    accumulated_reasoning: Vec<Reasoning>,
315    pending_reasoning_delta_text: String,
316    pending_reasoning_delta_id: Option<String>,
317    pending_tool_calls: Vec<(ToolCall, String)>,
318    delta_states: HashMap<(String, String), ToolCallDeltaState>,
319    pending_invalid: Option<PendingInvalid>,
320}
321
322impl StreamedTurnAssembler {
323    /// Create an assembler for one streamed turn with the tool names
324    /// advertised to the provider for that turn.
325    pub fn new(
326        executable_tool_names: BTreeSet<String>,
327        allowed_tool_names: BTreeSet<String>,
328    ) -> Self {
329        Self {
330            executable_tool_names,
331            allowed_tool_names,
332            text: String::new(),
333            saw_text: false,
334            accumulated_reasoning: Vec::new(),
335            pending_reasoning_delta_text: String::new(),
336            pending_reasoning_delta_id: None,
337            pending_tool_calls: Vec::new(),
338            delta_states: HashMap::new(),
339            pending_invalid: None,
340        }
341    }
342
343    /// Aggregated assistant text streamed so far this turn (empty until the
344    /// first text delta).
345    pub fn aggregated_text(&self) -> &str {
346        &self.text
347    }
348
349    /// Ingest one provider stream item and return what the driver must do.
350    ///
351    /// # Errors
352    /// Returns an error when the provider stream is inconsistent (argument
353    /// deltas finishing without a validated tool name) or when an invalid
354    /// tool call is still awaiting resolution.
355    pub fn ingest<R>(
356        &mut self,
357        item: &StreamedAssistantContent<R>,
358    ) -> Result<Vec<StreamedTurnEvent>, CompletionError>
359    where
360        R: Clone + Unpin + GetTokenUsage,
361    {
362        if self.pending_invalid.is_some() {
363            return Err(CompletionError::ResponseError(
364                "streamed turn ingested while an invalid tool call awaits resolution".to_string(),
365            ));
366        }
367
368        match item {
369            StreamedAssistantContent::Text(text) => {
370                if !self.saw_text {
371                    self.text.clear();
372                    self.saw_text = true;
373                }
374                self.text.push_str(&text.text);
375                Ok(vec![StreamedTurnEvent::EmitIngested])
376            }
377            StreamedAssistantContent::Reasoning(reasoning) => {
378                merge_reasoning_blocks(&mut self.accumulated_reasoning, reasoning);
379                Ok(vec![StreamedTurnEvent::EmitIngested])
380            }
381            StreamedAssistantContent::ReasoningDelta { reasoning, id } => {
382                // Deltas lack signatures/encrypted content that full blocks
383                // carry; mixing them into accumulated reasoning causes
384                // providers like Anthropic to reject with "signature required",
385                // so they are kept aside until the turn ends.
386                self.pending_reasoning_delta_text.push_str(reasoning);
387                if self.pending_reasoning_delta_id.is_none() {
388                    self.pending_reasoning_delta_id = id.clone();
389                }
390                Ok(vec![StreamedTurnEvent::EmitIngested])
391            }
392            StreamedAssistantContent::ToolCall {
393                tool_call,
394                internal_call_id,
395            } => {
396                if !self.allowed_tool_names.contains(&tool_call.function.name) {
397                    let invalid = StreamedInvalidToolCall {
398                        tool_call: tool_call.clone(),
399                        internal_call_id: internal_call_id.clone(),
400                        args: Some(json_utils::value_to_json_string(
401                            &tool_call.function.arguments,
402                        )),
403                        executable_tool_names: self.executable_tool_names.clone(),
404                        allowed_tool_names: self.allowed_tool_names.clone(),
405                    };
406                    self.pending_invalid = Some(PendingInvalid::FullCall {
407                        tool_call: Box::new(tool_call.clone()),
408                        internal_call_id: internal_call_id.clone(),
409                    });
410                    return Ok(vec![StreamedTurnEvent::InvalidToolCall(Box::new(invalid))]);
411                }
412
413                self.pending_tool_calls
414                    .push((tool_call.clone(), internal_call_id.clone()));
415                Ok(Vec::new())
416            }
417            StreamedAssistantContent::ToolCallDelta {
418                id,
419                internal_call_id,
420                content,
421            } => {
422                let key = (id.clone(), internal_call_id.clone());
423                match content {
424                    ToolCallDeltaContent::Name(name) => {
425                        if !self.allowed_tool_names.contains(name) {
426                            let buffered_args = self
427                                .delta_states
428                                .get(&key)
429                                .map(|state| state.buffered_arguments.join(""))
430                                .unwrap_or_default();
431                            let invalid = StreamedInvalidToolCall {
432                                tool_call: self.name_delta_diagnostic_tool_call(
433                                    id,
434                                    name,
435                                    &buffered_args,
436                                ),
437                                internal_call_id: internal_call_id.clone(),
438                                args: Some(buffered_args),
439                                executable_tool_names: self.executable_tool_names.clone(),
440                                allowed_tool_names: self.allowed_tool_names.clone(),
441                            };
442                            self.pending_invalid = Some(PendingInvalid::NameDelta {
443                                id: id.clone(),
444                                internal_call_id: internal_call_id.clone(),
445                            });
446                            return Ok(vec![StreamedTurnEvent::InvalidToolCall(Box::new(invalid))]);
447                        }
448
449                        Ok(self.validate_delta_name(&key, name.clone()))
450                    }
451                    ToolCallDeltaContent::Delta(arguments) => {
452                        let state = self.delta_states.entry(key.clone()).or_default();
453                        if state.name_validated {
454                            Ok(vec![StreamedTurnEvent::EmitToolCallDelta {
455                                id: id.clone(),
456                                internal_call_id: internal_call_id.clone(),
457                                content: ToolCallDeltaContent::Delta(arguments.clone()),
458                            }])
459                        } else {
460                            state.buffered_arguments.push(arguments.clone());
461                            Ok(Vec::new())
462                        }
463                    }
464                }
465            }
466            StreamedAssistantContent::Final(final_response) => {
467                if let Some(err) = self.pending_delta_error() {
468                    return Err(err);
469                }
470
471                let usage = final_response.token_usage();
472                let emit_final = self.saw_text;
473                self.saw_text = false;
474                Ok(vec![StreamedTurnEvent::Completed { usage, emit_final }])
475            }
476            StreamedAssistantContent::Unknown(_) => {
477                // Unmodeled provider item (e.g. a hosted-tool result): forward it
478                // to the consumer but do not fold it into the accumulated
479                // assistant message — there is no `AssistantContent::Unknown`, and
480                // it must not perturb text/tool-call/reasoning accumulation.
481                Ok(vec![StreamedTurnEvent::EmitIngested])
482            }
483        }
484    }
485
486    /// Apply the machine's resolution for the invalid tool call surfaced by
487    /// the last [`StreamedTurnEvent::InvalidToolCall`]. For a repaired name
488    /// this returns the deltas to forward (the repaired name plus any
489    /// buffered argument deltas).
490    pub fn resolve_pending_invalid(
491        &mut self,
492        resolution: &StreamedResolution,
493    ) -> Vec<StreamedTurnEvent> {
494        let Some(pending) = self.pending_invalid.take() else {
495            return Vec::new();
496        };
497
498        match (resolution, pending) {
499            (
500                StreamedResolution::Repaired { tool_name },
501                PendingInvalid::FullCall {
502                    mut tool_call,
503                    internal_call_id,
504                },
505            ) => {
506                tool_call.function.name = tool_name.clone();
507                self.pending_tool_calls.push((*tool_call, internal_call_id));
508                Vec::new()
509            }
510            (
511                StreamedResolution::Repaired { tool_name },
512                PendingInvalid::NameDelta {
513                    id,
514                    internal_call_id,
515                },
516            ) => {
517                let key = (id, internal_call_id);
518                self.validate_delta_name(&key, tool_name.clone())
519            }
520            (
521                StreamedResolution::TurnAbandoned { .. },
522                PendingInvalid::NameDelta {
523                    id,
524                    internal_call_id,
525                },
526            ) => {
527                // The abandoned call's buffered state must not trip the
528                // pending-delta consistency check while usage is drained.
529                self.delta_states.remove(&(id, internal_call_id));
530                Vec::new()
531            }
532            (StreamedResolution::TurnAbandoned { .. }, PendingInvalid::FullCall { .. }) => {
533                Vec::new()
534            }
535        }
536    }
537
538    /// Error when argument deltas were buffered for a tool call whose name
539    /// never validated — a provider-stream consistency violation.
540    pub fn pending_delta_error(&self) -> Option<CompletionError> {
541        self.delta_states
542            .iter()
543            .find(|(_, state)| !state.name_validated && !state.buffered_arguments.is_empty())
544            .map(|((id, internal_call_id), state)| {
545                CompletionError::ResponseError(format!(
546                    "streamed tool call arguments received before a validated tool name for id `{id}` and internal_call_id `{internal_call_id}` ({} buffered argument delta(s))",
547                    state.buffered_arguments.len()
548                ))
549            })
550    }
551
552    /// Snapshot of the turn so far, for diagnostics and rollback messages.
553    pub fn partial_turn(&self, message_id: Option<String>) -> PartialStreamedTurn {
554        let mut reasoning = self.accumulated_reasoning.clone();
555        if reasoning.is_empty() && !self.pending_reasoning_delta_text.is_empty() {
556            let mut assembled = Reasoning::new(&self.pending_reasoning_delta_text);
557            if let Some(id) = self.pending_reasoning_delta_id.clone() {
558                assembled = assembled.with_id(id);
559            }
560            reasoning.push(assembled);
561        }
562
563        PartialStreamedTurn {
564            message_id,
565            text: self.saw_text.then(|| self.text.clone()),
566            reasoning,
567            pending_tool_calls: self
568                .pending_tool_calls
569                .iter()
570                .map(|(tool_call, _)| tool_call.clone())
571                .collect(),
572        }
573    }
574
575    /// Assemble the completed turn. `final_choice` is the provider's
576    /// aggregated choice for the turn
577    /// ([`crate::streaming::StreamingCompletionResponse::choice`]).
578    pub fn finish(
579        mut self,
580        message_id: Option<String>,
581        final_choice: &OneOrMany<AssistantContent>,
582    ) -> StreamedTurn {
583        let internal_call_ids: Vec<(String, String)> = self
584            .pending_tool_calls
585            .iter()
586            .map(|(tool_call, internal_call_id)| (tool_call.id.clone(), internal_call_id.clone()))
587            .collect();
588        // Providers like Gemini emit thinking as incremental deltas without
589        // signatures; assemble them into a single block so reasoning survives
590        // into the next turn's chat history.
591        if self.accumulated_reasoning.is_empty() && !self.pending_reasoning_delta_text.is_empty() {
592            let mut assembled = Reasoning::new(&self.pending_reasoning_delta_text);
593            if let Some(id) = self.pending_reasoning_delta_id.take() {
594                assembled = assembled.with_id(id);
595            }
596            self.accumulated_reasoning.push(assembled);
597        }
598
599        // Canonical replay order when the turn produced reasoning or tool
600        // calls; otherwise the provider's aggregated choice is recorded as-is.
601        let choice =
602            if !self.pending_tool_calls.is_empty() || !self.accumulated_reasoning.is_empty() {
603                let text_items = assistant_text_items_from_choice(final_choice);
604                let tool_items = self
605                    .pending_tool_calls
606                    .iter()
607                    .map(|(tool_call, _)| AssistantContent::ToolCall(tool_call.clone()))
608                    .collect::<Vec<_>>();
609                ordered_streaming_assistant_content(
610                    self.accumulated_reasoning.drain(..),
611                    text_items,
612                    tool_items,
613                )
614                .unwrap_or_else(|| final_choice.clone())
615            } else {
616                final_choice.clone()
617            };
618
619        StreamedTurn {
620            message_id,
621            choice,
622            executable_tool_names: self.executable_tool_names,
623            allowed_tool_names: self.allowed_tool_names,
624            internal_call_ids,
625        }
626    }
627
628    fn name_delta_diagnostic_tool_call(
629        &self,
630        id: &str,
631        name: &str,
632        buffered_args: &str,
633    ) -> ToolCall {
634        let diagnostic_args = if buffered_args.trim().is_empty() {
635            serde_json::Value::Null
636        } else {
637            serde_json::from_str(buffered_args).unwrap_or(serde_json::Value::Null)
638        };
639        ToolCall::new(
640            id.to_string(),
641            ToolFunction::new(name.to_string(), diagnostic_args),
642        )
643    }
644
645    fn validate_delta_name(
646        &mut self,
647        key: &(String, String),
648        name: String,
649    ) -> Vec<StreamedTurnEvent> {
650        let state = self.delta_states.entry(key.clone()).or_default();
651        state.name_validated = true;
652        let buffered_arguments = std::mem::take(&mut state.buffered_arguments);
653
654        let mut events = vec![StreamedTurnEvent::EmitToolCallDelta {
655            id: key.0.clone(),
656            internal_call_id: key.1.clone(),
657            content: ToolCallDeltaContent::Name(name),
658        }];
659        events.extend(buffered_arguments.into_iter().map(|arguments| {
660            StreamedTurnEvent::EmitToolCallDelta {
661                id: key.0.clone(),
662                internal_call_id: key.1.clone(),
663                content: ToolCallDeltaContent::Delta(arguments),
664            }
665        }));
666        events
667    }
668}
669
670#[cfg(test)]
671mod tests {
672    use super::*;
673    use crate::agent::hook::InvalidToolCallHookAction;
674    use crate::agent::run::{AgentRun, AgentRunStep};
675    use crate::completion::PromptError;
676    use crate::message::{Text, ToolResultContent, UserContent};
677    use crate::test_utils::MockResponse;
678    use serde_json::json;
679
680    fn tool_names(names: &[&str]) -> BTreeSet<String> {
681        names.iter().map(|name| (*name).to_string()).collect()
682    }
683
684    fn assembler() -> StreamedTurnAssembler {
685        StreamedTurnAssembler::new(tool_names(&["add"]), tool_names(&["add"]))
686    }
687
688    fn text_item(text: &str) -> StreamedAssistantContent<MockResponse> {
689        StreamedAssistantContent::Text(Text::new(text.to_string()))
690    }
691
692    fn tool_call(id: &str, name: &str) -> ToolCall {
693        ToolCall::new(
694            id.to_string(),
695            ToolFunction::new(name.to_string(), json!({"x": 1})),
696        )
697    }
698
699    fn tool_call_item(id: &str, name: &str) -> StreamedAssistantContent<MockResponse> {
700        StreamedAssistantContent::ToolCall {
701            tool_call: tool_call(id, name),
702            internal_call_id: format!("internal_{id}"),
703        }
704    }
705
706    fn final_item() -> StreamedAssistantContent<MockResponse> {
707        StreamedAssistantContent::Final(MockResponse::with_usage(Usage::new()))
708    }
709
710    fn name_delta(id: &str, name: &str) -> StreamedAssistantContent<MockResponse> {
711        StreamedAssistantContent::ToolCallDelta {
712            id: id.to_string(),
713            internal_call_id: format!("internal_{id}"),
714            content: ToolCallDeltaContent::Name(name.to_string()),
715        }
716    }
717
718    fn args_delta(id: &str, arguments: &str) -> StreamedAssistantContent<MockResponse> {
719        StreamedAssistantContent::ToolCallDelta {
720            id: id.to_string(),
721            internal_call_id: format!("internal_{id}"),
722            content: ToolCallDeltaContent::Delta(arguments.to_string()),
723        }
724    }
725
726    fn expect_invalid(events: Vec<StreamedTurnEvent>) -> StreamedInvalidToolCall {
727        match events.into_iter().next() {
728            Some(StreamedTurnEvent::InvalidToolCall(invalid)) => *invalid,
729            other => panic!("expected InvalidToolCall, got {other:?}"),
730        }
731    }
732
733    #[test]
734    fn text_accumulates_and_emits() {
735        let mut asm = assembler();
736        let events = asm
737            .ingest(&text_item("hel"))
738            .expect("ingest should succeed");
739        assert!(matches!(
740            events.as_slice(),
741            [StreamedTurnEvent::EmitIngested]
742        ));
743        asm.ingest(&text_item("lo")).expect("ingest should succeed");
744        assert_eq!(asm.aggregated_text(), "hello");
745    }
746
747    #[test]
748    fn unknown_item_emits_to_consumer_without_touching_accumulation() {
749        let mut asm = assembler();
750        asm.ingest(&text_item("answer"))
751            .expect("ingest text should succeed");
752
753        let events = asm
754            .ingest(&StreamedAssistantContent::<MockResponse>::Unknown(
755                json!({ "type": "web_search_call", "id": "ws_1" }),
756            ))
757            .expect("ingest unknown should succeed");
758
759        // The unmodeled item is forwarded to the consumer ...
760        assert!(matches!(
761            events.as_slice(),
762            [StreamedTurnEvent::EmitIngested]
763        ));
764        // ... but perturbs no accumulation state used to build the assistant message.
765        assert_eq!(asm.aggregated_text(), "answer");
766    }
767
768    #[test]
769    fn argument_deltas_buffer_until_name_validates() {
770        let mut asm = assembler();
771
772        let events = asm
773            .ingest(&args_delta("tc_1", "{\"x\""))
774            .expect("ingest should succeed");
775        assert!(events.is_empty(), "arguments must buffer before the name");
776
777        let events = asm
778            .ingest(&name_delta("tc_1", "add"))
779            .expect("ingest should succeed");
780        let contents: Vec<_> = events
781            .iter()
782            .map(|event| match event {
783                StreamedTurnEvent::EmitToolCallDelta { content, .. } => content.clone(),
784                other => panic!("expected EmitToolCallDelta, got {other:?}"),
785            })
786            .collect();
787        assert_eq!(
788            contents,
789            vec![
790                ToolCallDeltaContent::Name("add".to_string()),
791                ToolCallDeltaContent::Delta("{\"x\"".to_string()),
792            ]
793        );
794
795        // Subsequent argument deltas now pass straight through.
796        let events = asm
797            .ingest(&args_delta("tc_1", ":1}"))
798            .expect("ingest should succeed");
799        assert_eq!(events.len(), 1);
800    }
801
802    #[test]
803    fn buffered_arguments_without_validated_name_error_at_final() {
804        let mut asm = assembler();
805        asm.ingest(&args_delta("tc_1", "{\"x\":1}"))
806            .expect("ingest should succeed");
807
808        assert!(asm.pending_delta_error().is_some());
809        assert!(asm.ingest(&final_item()).is_err());
810    }
811
812    #[test]
813    fn finish_orders_reasoning_text_then_tool_calls() {
814        let mut asm = assembler();
815        asm.ingest(&StreamedAssistantContent::<MockResponse>::ReasoningDelta {
816            id: Some("rs_1".to_string()),
817            reasoning: "think".to_string(),
818        })
819        .expect("ingest should succeed");
820        asm.ingest(&tool_call_item("tc_1", "add"))
821            .expect("ingest should succeed");
822
823        // Provider aggregation order differs deliberately.
824        let final_choice = OneOrMany::many(vec![
825            AssistantContent::text("answer"),
826            AssistantContent::ToolCall(tool_call("tc_1", "add")),
827        ])
828        .expect("two items");
829
830        let turn = asm.finish(Some("msg_1".to_string()), &final_choice);
831        let kinds: Vec<&'static str> = turn
832            .choice
833            .iter()
834            .map(|item| match item {
835                AssistantContent::Reasoning(_) => "reasoning",
836                AssistantContent::Text(_) => "text",
837                AssistantContent::ToolCall(_) => "tool_call",
838                _ => "other",
839            })
840            .collect();
841        assert_eq!(kinds, vec!["reasoning", "text", "tool_call"]);
842    }
843
844    #[test]
845    fn finish_passes_raw_choice_through_for_plain_text_turns() {
846        let mut asm = assembler();
847        asm.ingest(&text_item("hi")).expect("ingest should succeed");
848
849        let final_choice = OneOrMany::one(AssistantContent::text("hi"));
850        let turn = asm.finish(None, &final_choice);
851        assert_eq!(
852            serde_json::to_value(&turn.choice).expect("serialize"),
853            serde_json::to_value(&final_choice).expect("serialize"),
854        );
855    }
856
857    #[test]
858    fn streamed_run_completes_a_tool_roundtrip() {
859        let mut run = AgentRun::new("add things").max_turns(2);
860
861        // Turn 1: the model streams one tool call.
862        let AgentRunStep::CallModel { .. } = run.next_step().expect("next_step") else {
863            panic!("expected CallModel");
864        };
865        let mut asm = assembler();
866        assert!(
867            asm.ingest(&tool_call_item("tc_1", "add"))
868                .expect("ingest should succeed")
869                .is_empty()
870        );
871        let usage = Usage {
872            input_tokens: 5,
873            output_tokens: 7,
874            total_tokens: 12,
875            ..Usage::new()
876        };
877        run.record_streamed_completion_call(usage)
878            .expect("record should succeed");
879        let final_choice = OneOrMany::one(AssistantContent::ToolCall(tool_call("tc_1", "add")));
880        run.streamed_turn(asm.finish(Some("msg_1".to_string()), &final_choice))
881            .expect("streamed_turn should succeed");
882
883        let AgentRunStep::CallTools { calls } = run.next_step().expect("next_step") else {
884            panic!("expected CallTools");
885        };
886        assert_eq!(calls.len(), 1);
887        assert_eq!(calls[0].internal_call_id.as_deref(), Some("internal_tc_1"));
888        run.tool_results(vec![UserContent::tool_result(
889            "tc_1".to_string(),
890            ToolResultContent::from_tool_output("2".to_string()),
891        )])
892        .expect("tool_results should succeed");
893
894        // Turn 2: plain text finishes the run.
895        let AgentRunStep::CallModel { .. } = run.next_step().expect("next_step") else {
896            panic!("expected CallModel");
897        };
898        let asm = assembler();
899        run.record_streamed_completion_call(Usage::new())
900            .expect("record should succeed");
901        let final_choice = OneOrMany::one(AssistantContent::text("done"));
902        run.streamed_turn(asm.finish(None, &final_choice))
903            .expect("streamed_turn should succeed");
904
905        let AgentRunStep::Done(response) = run.next_step().expect("next_step") else {
906            panic!("expected Done");
907        };
908        assert_eq!(response.output, "done");
909        assert_eq!(response.usage, usage);
910        assert_eq!(response.completion_calls.len(), 2);
911        assert_eq!(response.completion_calls[0].usage, usage);
912        assert_eq!(response.completion_calls[1].usage, Usage::new());
913        // prompt, assistant tool call, tool result, final assistant text
914        assert_eq!(
915            response
916                .messages
917                .expect("messages should be recorded")
918                .len(),
919            4
920        );
921    }
922
923    #[test]
924    fn streamed_invalid_tool_call_retry_rolls_back_with_partial_turn() {
925        let mut run = AgentRun::new("use the tool")
926            .max_turns(2)
927            .max_invalid_tool_call_retries(1);
928        run.next_step().expect("next_step");
929
930        let mut asm = assembler();
931        asm.ingest(&text_item("thinking ")).expect("ingest");
932        let invalid = expect_invalid(
933            asm.ingest(&tool_call_item("tc_1", "default_api"))
934                .expect("ingest should succeed"),
935        );
936        let partial = asm.partial_turn(Some("msg_1".to_string()));
937        assert_eq!(partial.text.as_deref(), Some("thinking "));
938
939        let context = run.streamed_invalid_tool_call_context(&partial, &invalid);
940        assert!(context.is_streaming);
941        assert_eq!(context.tool_name, "default_api");
942        assert_eq!(context.internal_call_id.as_deref(), Some("internal_tc_1"));
943
944        let resolution = run
945            .resolve_streamed_invalid_tool_call(
946                &partial,
947                &invalid,
948                InvalidToolCallHookAction::retry("use add instead"),
949            )
950            .expect("retry should be accepted");
951        assert!(matches!(
952            resolution,
953            StreamedResolution::TurnAbandoned {
954                skipped_tool_result: None
955            }
956        ));
957        asm.resolve_pending_invalid(&resolution);
958
959        // Usage from the drained stream is recorded after the rollback.
960        run.record_streamed_completion_call(Usage::new())
961            .expect("record after rollback should succeed");
962
963        // The rollback appended the partial assistant turn and feedback.
964        assert_eq!(run.messages().len(), 3);
965        let AgentRunStep::CallModel { turn, .. } = run.next_step().expect("next_step") else {
966            panic!("expected CallModel retry");
967        };
968        assert_eq!(turn, 2);
969    }
970
971    #[test]
972    fn streamed_invalid_tool_call_retry_cannot_emit_call_past_total_budget() {
973        let mut run = AgentRun::new("use the tool")
974            .max_turns(1)
975            .max_invalid_tool_call_retries(1);
976        run.next_step().expect("initial model call");
977
978        let mut asm = assembler();
979        let invalid = expect_invalid(
980            asm.ingest(&tool_call_item("tc_1", "default_api"))
981                .expect("ingest should succeed"),
982        );
983        let partial = asm.partial_turn(Some("msg_1".to_string()));
984        let resolution = run
985            .resolve_streamed_invalid_tool_call(
986                &partial,
987                &invalid,
988                InvalidToolCallHookAction::retry("use add instead"),
989            )
990            .expect("retry resolution should be accepted");
991        assert!(matches!(
992            resolution,
993            StreamedResolution::TurnAbandoned {
994                skipped_tool_result: None
995            }
996        ));
997        run.record_streamed_completion_call(Usage::new())
998            .expect("completion call should be recorded");
999        assert_eq!(run.completion_calls().len(), 1);
1000
1001        let err = run
1002            .next_step()
1003            .expect_err("retry must not emit a second model call");
1004        assert!(matches!(
1005            err,
1006            PromptError::MaxTurnsError { max_turns: 1, .. }
1007        ));
1008        assert_eq!(run.turn(), 1);
1009    }
1010
1011    #[test]
1012    fn streamed_invalid_tool_call_skip_returns_synthetic_result() {
1013        let mut run = AgentRun::new("use the tool").max_turns(2);
1014        run.next_step().expect("next_step");
1015
1016        let mut asm = assembler();
1017        let invalid = expect_invalid(
1018            asm.ingest(&tool_call_item("tc_1", "default_api"))
1019                .expect("ingest should succeed"),
1020        );
1021        let partial = asm.partial_turn(None);
1022
1023        let resolution = run
1024            .resolve_streamed_invalid_tool_call(
1025                &partial,
1026                &invalid,
1027                InvalidToolCallHookAction::skip("not available"),
1028            )
1029            .expect("skip should be accepted");
1030        let StreamedResolution::TurnAbandoned {
1031            skipped_tool_result: Some(tool_result),
1032        } = &resolution
1033        else {
1034            panic!("expected skipped tool result");
1035        };
1036        assert_eq!(tool_result.id, "tc_1");
1037    }
1038
1039    #[test]
1040    fn streamed_invalid_name_delta_repair_replays_buffered_arguments() {
1041        let mut run = AgentRun::new("use the tool").max_turns(2);
1042        run.next_step().expect("next_step");
1043
1044        let mut asm = assembler();
1045        asm.ingest(&args_delta("tc_1", "{\"x\":1}"))
1046            .expect("ingest should succeed");
1047        let invalid = expect_invalid(
1048            asm.ingest(&name_delta("tc_1", "default_api"))
1049                .expect("ingest should succeed"),
1050        );
1051        assert_eq!(invalid.args.as_deref(), Some("{\"x\":1}"));
1052
1053        let partial = asm.partial_turn(None);
1054        let resolution = run
1055            .resolve_streamed_invalid_tool_call(
1056                &partial,
1057                &invalid,
1058                InvalidToolCallHookAction::repair("add"),
1059            )
1060            .expect("repair should be accepted");
1061        assert!(matches!(
1062            resolution,
1063            StreamedResolution::Repaired { ref tool_name } if tool_name == "add"
1064        ));
1065
1066        let events = asm.resolve_pending_invalid(&resolution);
1067        let contents: Vec<_> = events
1068            .iter()
1069            .map(|event| match event {
1070                StreamedTurnEvent::EmitToolCallDelta { content, .. } => content.clone(),
1071                other => panic!("expected EmitToolCallDelta, got {other:?}"),
1072            })
1073            .collect();
1074        assert_eq!(
1075            contents,
1076            vec![
1077                ToolCallDeltaContent::Name("add".to_string()),
1078                ToolCallDeltaContent::Delta("{\"x\":1}".to_string()),
1079            ]
1080        );
1081    }
1082
1083    #[test]
1084    fn streamed_turn_rejects_unknown_tool_calls_fail_fast() {
1085        let mut run = AgentRun::new("use the tool");
1086        run.next_step().expect("next_step");
1087
1088        let turn = StreamedTurn {
1089            message_id: None,
1090            choice: OneOrMany::one(AssistantContent::ToolCall(tool_call("tc_1", "unknown"))),
1091            executable_tool_names: tool_names(&["add"]),
1092            allowed_tool_names: tool_names(&["add"]),
1093            internal_call_ids: Vec::new(),
1094        };
1095        let err = run
1096            .streamed_turn(turn)
1097            .expect_err("unknown tool should fail fast");
1098        assert!(matches!(
1099            err,
1100            PromptError::UnknownToolCall { tool_name, .. } if tool_name == "unknown"
1101        ));
1102    }
1103
1104    #[test]
1105    fn streamed_completion_call_record_requires_a_model_call() {
1106        // A fresh run has emitted no CallModel: recording must be rejected
1107        // even though the machine is in its initial PreparingRequest state.
1108        let mut run = AgentRun::new("hello");
1109        let err = run
1110            .record_streamed_completion_call(Usage::new())
1111            .expect_err("recording before any model call must be rejected");
1112        assert!(matches!(err, PromptError::PromptCancelled { .. }));
1113
1114        // The run stays drivable.
1115        run.next_step().expect("next_step should still succeed");
1116        run.record_streamed_completion_call(Usage::new())
1117            .expect("recording during a pending model call succeeds");
1118    }
1119
1120    #[test]
1121    fn duplicate_tool_call_ids_keep_distinct_internal_ids_through_the_run() {
1122        let mut run = AgentRun::new("do both").max_turns(2);
1123        run.next_step().expect("next_step");
1124
1125        let mut asm = assembler();
1126        asm.ingest(&StreamedAssistantContent::<MockResponse>::ToolCall {
1127            tool_call: tool_call("tc_1", "add"),
1128            internal_call_id: "internal_a".to_string(),
1129        })
1130        .expect("ingest should succeed");
1131        asm.ingest(&StreamedAssistantContent::<MockResponse>::ToolCall {
1132            tool_call: tool_call("tc_1", "add"),
1133            internal_call_id: "internal_b".to_string(),
1134        })
1135        .expect("ingest should succeed");
1136        run.record_streamed_completion_call(Usage::new())
1137            .expect("record should succeed");
1138
1139        let final_choice = OneOrMany::many(vec![
1140            AssistantContent::ToolCall(tool_call("tc_1", "add")),
1141            AssistantContent::ToolCall(tool_call("tc_1", "add")),
1142        ])
1143        .expect("two items");
1144        run.streamed_turn(asm.finish(None, &final_choice))
1145            .expect("streamed_turn should succeed");
1146
1147        // The internal IDs survive in the run state itself: a serde round
1148        // trip must keep both calls distinguishable.
1149        let serialized = serde_json::to_string(&run).expect("serialize");
1150        let mut restored: AgentRun = serde_json::from_str(&serialized).expect("deserialize");
1151        let AgentRunStep::CallTools { calls } = restored.next_step().expect("next_step") else {
1152            panic!("expected CallTools");
1153        };
1154        assert_eq!(calls.len(), 2);
1155        assert_eq!(calls[0].internal_call_id.as_deref(), Some("internal_a"));
1156        assert_eq!(calls[1].internal_call_id.as_deref(), Some("internal_b"));
1157    }
1158
1159    #[test]
1160    fn streamed_turn_records_the_completion_call_when_the_driver_did_not() {
1161        let mut run = AgentRun::new("hello");
1162        run.next_step().expect("next_step");
1163
1164        let asm = assembler();
1165        let final_choice = OneOrMany::one(AssistantContent::text("done"));
1166        run.streamed_turn(asm.finish(None, &final_choice))
1167            .expect("streamed_turn should succeed");
1168
1169        // Exactly one CompletionCall per model call, even without an explicit
1170        // record; usage is simply unreported.
1171        assert_eq!(run.completion_calls().len(), 1);
1172        assert_eq!(run.completion_calls()[0].usage, Usage::new());
1173    }
1174
1175    #[test]
1176    fn streamed_completion_call_is_recorded_once_per_turn() {
1177        let mut run = AgentRun::new("hello");
1178        run.next_step().expect("next_step");
1179
1180        run.record_streamed_completion_call(Usage::new())
1181            .expect("first record succeeds");
1182        let err = run
1183            .record_streamed_completion_call(Usage::new())
1184            .expect_err("second record for the same turn must be rejected");
1185        assert!(matches!(err, PromptError::PromptCancelled { .. }));
1186        assert_eq!(run.completion_calls().len(), 1);
1187    }
1188
1189    #[test]
1190    fn streamed_run_serde_round_trips_while_tools_pend() {
1191        let mut run = AgentRun::new("add things").max_turns(2);
1192        run.next_step().expect("next_step");
1193
1194        let mut asm = assembler();
1195        asm.ingest(&tool_call_item("tc_1", "add"))
1196            .expect("ingest should succeed");
1197        run.record_streamed_completion_call(Usage::new())
1198            .expect("record should succeed");
1199        let final_choice = OneOrMany::one(AssistantContent::ToolCall(tool_call("tc_1", "add")));
1200        run.streamed_turn(asm.finish(None, &final_choice))
1201            .expect("streamed_turn should succeed");
1202        run.next_step().expect("CallTools step");
1203
1204        let serialized = serde_json::to_string(&run).expect("serialize mid-run");
1205        let mut restored: AgentRun =
1206            serde_json::from_str(&serialized).expect("deserialize mid-run");
1207        restored
1208            .tool_results(vec![UserContent::tool_result(
1209                "tc_1".to_string(),
1210                ToolResultContent::from_tool_output("2".to_string()),
1211            )])
1212            .expect("tool_results should succeed");
1213        assert!(matches!(
1214            restored.next_step().expect("next turn"),
1215            AgentRunStep::CallModel { turn: 2, .. }
1216        ));
1217    }
1218}