Skip to main content

rig_core/providers/internal/
chunk_lifecycle.rs

1//! Shared lifecycle derivation for boundary-less constant-key wires.
2//!
3//! Wires with no reasoning boundary of their own (ollama's `thinking`,
4//! cohere's `thinking` content, gemini REST's `thought` parts, gemini
5//! Interactions' thought summaries) used to hand-roll the same algorithm
6//! per adapter: track `reasoning_open`, emit
7//! the delta under the per-stream constant minted key, and synthesize a
8//! silent `ReasoningEnd` before any other content class. Every review round
9//! found one adapter that missed a piece of it ("close the open reasoning
10//! block before any other part class" took two rounds across six adapters;
11//! "emit a chunk's parts in canonical order" took another).
12//!
13//! Here the adapter *declares* what one wire chunk carried — a
14//! [`ChunkParts`] — and [`MintedReasoningLifecycle::emit_chunk`] derives the
15//! canonical event sequence: reasoning first, a wire-signed close when the
16//! chunk carried one, the synthesized boundary end when other content
17//! interleaves, then text, then tool events. "Forgot the boundary" and
18//! "wrong intra-chunk order" are not expressible through this interface
19//! (langchain's declarative-chunk + core-side merge factoring;
20//! semantic-kernel converged on the same shape independently). The driver's
21//! debug-mode sequence laws (`sequence_law`) still watch the emitted stream,
22//! so an adapter bypassing this helper fails its own tests.
23//!
24//! `pub` (not `pub(crate)`) for the same reason as [`adapter`](super::adapter)
25//! and [`tool_call_bridge`](super::tool_call_bridge): companion provider
26//! crates implementing [`WireAdapter`](super::adapter::WireAdapter) over a
27//! boundary-less wire (rig-gemini-grpc) must inherit this derivation rather
28//! than hand-roll it; it is not part of rig-core's stable public API.
29//!
30//! Wires that announce their own boundaries (anthropic `content_block_stop`,
31//! OpenAI Responses `output_item.done`) do not use this — their lifecycle is
32//! the wire's, not a derivation. The chat-completions compat family keeps its
33//! `CompatibleStreamProfile` system (in the crate-private
34//! `openai_chat_completions_compatible` module, hence named rather than
35//! linked): that IS the shared derivation for its ~15 gateway providers, with
36//! wire quirks (slot eviction, encrypted reasoning details, tool-call
37//! decorations) this declarative shape does not model.
38
39use crate::streaming::{RawStreamingChoice, StreamPartId};
40
41use super::adapter::AdapterOutput;
42
43/// What one wire chunk (or one wire part, for parts-array wires) carried,
44/// declared by the adapter with no lifecycle events of its own.
45#[derive(Default)]
46pub struct ChunkParts<R> {
47    /// Reasoning content accumulating under the wire's constant minted key.
48    pub reasoning: Option<String>,
49    /// A wire-carried signature closing the reasoning block (gemini's
50    /// `thoughtSignature`) — the one authoritative close these wires spell.
51    pub reasoning_signature: Option<String>,
52    /// Visible text content.
53    pub text: Option<String>,
54    /// Tool-call events in wire order — whole calls, fragments, or input
55    /// ends, prebuilt by the adapter (keys and ids are wire policy, not
56    /// lifecycle). Emitted after the boundary close, in the canonical slot.
57    pub tool_events: Vec<RawStreamingChoice<R>>,
58}
59
60impl<R> ChunkParts<R> {
61    /// Whether the chunk carries content that interleaves — and therefore
62    /// closes — an open reasoning block.
63    fn has_boundary_content(&self) -> bool {
64        self.text.as_ref().is_some_and(|text| !text.is_empty()) || !self.tool_events.is_empty()
65    }
66}
67
68/// The lifecycle state for one stream's constant-key reasoning block.
69///
70/// Owns the open/close bookkeeping the adapters used to hand-roll; an
71/// adapter never touches a `reasoning_open` flag or emits a lifecycle event
72/// directly.
73pub struct MintedReasoningLifecycle {
74    key: StreamPartId,
75    open: bool,
76}
77
78impl MintedReasoningLifecycle {
79    /// A lifecycle for the given per-stream constant minted key.
80    pub fn new(key: StreamPartId) -> Self {
81        Self { key, open: false }
82    }
83
84    /// Emit one declared chunk as the canonical event sequence.
85    ///
86    /// Order and boundary are derived, not stated per adapter:
87    /// 1. reasoning delta (opens the block);
88    /// 2. a wire-carried signature closes the block authoritatively;
89    /// 3. other content in the chunk closes a still-open block with a
90    ///    synthesized silent end (`wire_sent: false` — the wire never spelled
91    ///    the boundary, so downstream must not observe a fabricated event);
92    /// 4. text, then tool events.
93    pub fn emit_chunk<R>(&mut self, parts: ChunkParts<R>, out: &mut AdapterOutput<R>) {
94        if let Some(reasoning) = parts
95            .reasoning
96            .as_ref()
97            .filter(|reasoning| !reasoning.is_empty())
98        {
99            self.open = true;
100            out.push(Ok(RawStreamingChoice::ReasoningDelta {
101                id: self.key.clone(),
102                provider_id: None,
103                reasoning: reasoning.clone(),
104            }));
105        }
106
107        if let Some(signature) = parts.reasoning_signature.clone() {
108            // The wire's own authoritative close: signs the accumulated
109            // deltas, the already-finished block that holds the
110            // chain-of-thought, or a signature-only part when nothing
111            // streamed — the shared accumulator owns the per-case behavior.
112            self.open = false;
113            out.push(Ok(RawStreamingChoice::ReasoningEnd {
114                id: self.key.clone(),
115                reasoning: None,
116                signature: Some(signature),
117                wire_sent: false,
118            }));
119        }
120
121        if parts.has_boundary_content() && self.open {
122            // Interleaving output ends an open reasoning block — the
123            // boundary these wires never announce, synthesized once here
124            // instead of once per adapter.
125            self.open = false;
126            out.push(Ok(RawStreamingChoice::ReasoningEnd {
127                id: self.key.clone(),
128                reasoning: None,
129                signature: None,
130                wire_sent: false,
131            }));
132        }
133
134        if let Some(text) = parts.text.filter(|text| !text.is_empty()) {
135            out.push(Ok(RawStreamingChoice::Message(text)));
136        }
137
138        for event in parts.tool_events {
139            out.push(Ok(event));
140        }
141    }
142}
143
144#[cfg(test)]
145mod tests {
146    use super::*;
147    use crate::streaming::MintKind;
148
149    fn lifecycle() -> MintedReasoningLifecycle {
150        MintedReasoningLifecycle::new(StreamPartId::minted(MintKind::Reasoning, 0))
151    }
152
153    fn emitted(batches: Vec<ChunkParts<()>>) -> Vec<&'static str> {
154        let mut lifecycle = lifecycle();
155        let mut out = AdapterOutput::<()>::new();
156        for parts in batches {
157            lifecycle.emit_chunk(parts, &mut out);
158        }
159        out.iter()
160            .map(|item| match item {
161                Ok(RawStreamingChoice::ReasoningDelta { .. }) => "reasoning-delta",
162                Ok(RawStreamingChoice::ReasoningEnd {
163                    signature: Some(_), ..
164                }) => "signed-end",
165                Ok(RawStreamingChoice::ReasoningEnd { .. }) => "bare-end",
166                Ok(RawStreamingChoice::Message(_)) => "text",
167                Ok(RawStreamingChoice::ToolCall(_) | RawStreamingChoice::ToolCallDelta { .. }) => {
168                    "tool"
169                }
170                _ => "other",
171            })
172            .collect()
173    }
174
175    fn tool_event() -> RawStreamingChoice<()> {
176        RawStreamingChoice::ToolCall(crate::streaming::RawStreamingToolCall::new(
177            StreamPartId::minted(MintKind::Tool, 0),
178            "probe".to_owned(),
179            serde_json::json!({}),
180        ))
181    }
182
183    /// A chunk carrying every class emits canonical order, with the boundary
184    /// end derived between reasoning and the interleaving content.
185    #[test]
186    fn a_full_chunk_emits_canonical_order_with_the_boundary_end() {
187        let order = emitted(vec![ChunkParts {
188            reasoning: Some("thinking".to_owned()),
189            reasoning_signature: None,
190            text: Some("visible".to_owned()),
191            tool_events: vec![tool_event()],
192        }]);
193        assert_eq!(order, vec!["reasoning-delta", "bare-end", "text", "tool"]);
194    }
195
196    /// A class change across chunks closes the open block exactly once.
197    #[test]
198    fn interleaving_content_closes_the_open_block_once() {
199        let order = emitted(vec![
200            ChunkParts {
201                reasoning: Some("thinking".to_owned()),
202                ..ChunkParts::default()
203            },
204            ChunkParts {
205                text: Some("visible".to_owned()),
206                ..ChunkParts::default()
207            },
208            ChunkParts {
209                text: Some("more".to_owned()),
210                ..ChunkParts::default()
211            },
212        ]);
213        assert_eq!(order, vec!["reasoning-delta", "bare-end", "text", "text"]);
214    }
215
216    /// A wire-carried signature closes the block authoritatively; interleaved
217    /// content after it needs no synthesized end.
218    #[test]
219    fn a_signature_closes_the_block_before_text() {
220        let order = emitted(vec![ChunkParts {
221            reasoning: Some("thinking".to_owned()),
222            reasoning_signature: Some("sig".to_owned()),
223            text: Some("visible".to_owned()),
224            tool_events: Vec::new(),
225        }]);
226        assert_eq!(order, vec!["reasoning-delta", "signed-end", "text"]);
227    }
228
229    /// A signature with nothing streamed still emits its close (the
230    /// signature-only stream — replay-required provider state).
231    #[test]
232    fn a_signature_only_chunk_emits_its_close() {
233        let order = emitted(vec![ChunkParts {
234            reasoning_signature: Some("sig".to_owned()),
235            ..ChunkParts::default()
236        }]);
237        assert_eq!(order, vec!["signed-end"]);
238    }
239
240    /// An empty chunk (and empty-string content) emits nothing.
241    #[test]
242    fn an_empty_chunk_emits_nothing() {
243        let order = emitted(vec![ChunkParts {
244            reasoning: Some(String::new()),
245            reasoning_signature: None,
246            text: Some(String::new()),
247            tool_events: Vec::new(),
248        }]);
249        assert!(order.is_empty());
250    }
251
252    /// Reasoning after a boundary opens a NEW block, closed again by the
253    /// next interleaving content — the reasoning→tool→reasoning shape.
254    #[test]
255    fn reasoning_reopens_after_a_boundary() {
256        let order = emitted(vec![
257            ChunkParts {
258                reasoning: Some("before".to_owned()),
259                ..ChunkParts::default()
260            },
261            ChunkParts {
262                tool_events: vec![tool_event()],
263                ..ChunkParts::default()
264            },
265            ChunkParts {
266                reasoning: Some("after".to_owned()),
267                ..ChunkParts::default()
268            },
269            ChunkParts {
270                text: Some("done".to_owned()),
271                ..ChunkParts::default()
272            },
273        ]);
274        assert_eq!(
275            order,
276            vec![
277                "reasoning-delta",
278                "bare-end",
279                "tool",
280                "reasoning-delta",
281                "bare-end",
282                "text"
283            ]
284        );
285    }
286}