Skip to main content

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