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}