Skip to main content

laser_sdk/agent/
assembler.rs

1use laser_wire::agent::{AgentEnvelope, AgentKind, TokenUsage};
2use serde::{Serialize, Serializer};
3
4// Synthetic finish reasons, reader-local: never published. The log keeps the
5// raw truth, the live view diverges from it in this one direction only.
6/// Finish reason synthesized when the stream's deadline passed with no chunk.
7pub const FINISH_REASON_ABANDONED: &str = "abandoned";
8/// Finish reason synthesized when a `sequence` was skipped (the in-order log
9/// proves the producer never published it).
10pub const FINISH_REASON_GAP: &str = "gap";
11
12/// One reassembled stream occurrence, in order. Serializes as a record tagged
13/// by `kind` (`body`, `finished`, `failed`) with the variant's fields beside it.
14#[derive(Debug, Clone, PartialEq, Serialize)]
15#[serde(tag = "kind", rename_all = "snake_case")]
16pub enum StreamEvent {
17    /// The next body bytes of the stream.
18    Body {
19        sequence: u64,
20        #[serde(serialize_with = "as_bytes")]
21        payload: Vec<u8>,
22    },
23    /// The stream ended. `synthetic` marks reader-local endings (gap,
24    /// abandonment) that never reach the log.
25    Finished {
26        finish_reason: Option<String>,
27        usage: Option<TokenUsage>,
28        synthetic: bool,
29    },
30    /// The stream ended on a `kind = error` terminal. `body` is the encoded
31    /// `AgentErrorBody`.
32    Failed {
33        #[serde(serialize_with = "as_bytes")]
34        body: Vec<u8>,
35    },
36}
37
38/// Per-channel reassembly state machine, pure and clock-free (the consumer
39/// wires the deadline timer and calls [`abandon`](Self::abandon)).
40#[derive(Debug, Default)]
41pub struct ChunkAssembler {
42    next_sequence: u64,
43    finished: bool,
44    duplicates_dropped: u64,
45    late_dropped: u64,
46}
47
48// Reassembly is mechanical: chunks apply in `sequence` order from 0, each once.
49// A duplicate drops and counts. A gap ends the stream with a synthetic `gap`
50// terminal. Everything after a terminal drops and counts, first terminal wins.
51// A `kind = error` carrying the channel is the failure terminal.
52impl ChunkAssembler {
53    /// A fresh assembler for one channel.
54    pub fn new() -> Self {
55        Self::default()
56    }
57
58    /// Apply one envelope of this channel. Returns the events it produces
59    /// (zero, one, or body-plus-terminal for a non-empty terminal chunk).
60    pub fn feed(&mut self, envelope: &AgentEnvelope) -> Vec<StreamEvent> {
61        match envelope.kind {
62            AgentKind::Chunk => self.feed_chunk(envelope),
63            AgentKind::Error => {
64                if self.finished {
65                    self.late_dropped += 1;
66                    return Vec::new();
67                }
68                self.finished = true;
69                vec![StreamEvent::Failed {
70                    body: envelope.body.clone(),
71                }]
72            }
73            AgentKind::Command | AgentKind::Response | AgentKind::Event | AgentKind::Status => {
74                Vec::new()
75            }
76        }
77    }
78
79    /// Synthesize the reader-local abandonment terminal (the deadline passed
80    /// with no chunk). `None` when the stream already ended.
81    pub fn abandon(&mut self) -> Option<StreamEvent> {
82        if self.finished {
83            return None;
84        }
85        self.finished = true;
86        Some(StreamEvent::Finished {
87            finish_reason: Some(FINISH_REASON_ABANDONED.to_owned()),
88            usage: None,
89            synthetic: true,
90        })
91    }
92
93    /// Whether a terminal (real or synthetic) has been seen.
94    pub fn is_finished(&self) -> bool {
95        self.finished
96    }
97
98    /// Redelivered chunks dropped (consumer at-least-once).
99    pub fn duplicates_dropped(&self) -> u64 {
100        self.duplicates_dropped
101    }
102
103    /// Chunks and terminals dropped after the stream ended.
104    pub fn late_dropped(&self) -> u64 {
105        self.late_dropped
106    }
107
108    fn feed_chunk(&mut self, envelope: &AgentEnvelope) -> Vec<StreamEvent> {
109        let Some(sequence) = envelope.sequence else {
110            // validate() rejects this upstream, and a malformed chunk never
111            // advances the stream.
112            return Vec::new();
113        };
114        if self.finished {
115            self.late_dropped += 1;
116            return Vec::new();
117        }
118        if sequence < self.next_sequence {
119            self.duplicates_dropped += 1;
120            return Vec::new();
121        }
122        if sequence > self.next_sequence {
123            self.finished = true;
124            return vec![StreamEvent::Finished {
125                finish_reason: Some(FINISH_REASON_GAP.to_owned()),
126                usage: None,
127                synthetic: true,
128            }];
129        }
130        self.next_sequence += 1;
131        let mut events = Vec::new();
132        if !envelope.body.is_empty() {
133            events.push(StreamEvent::Body {
134                sequence,
135                payload: envelope.body.clone(),
136            });
137        }
138        if envelope.last {
139            self.finished = true;
140            events.push(StreamEvent::Finished {
141                finish_reason: envelope.finish_reason.clone(),
142                usage: envelope.usage,
143                synthetic: false,
144            });
145        }
146        events
147    }
148}
149
150// Bytes as a byte string, not a sequence of numbers, so binary formats and
151// language bindings carry them as bytes.
152fn as_bytes<S: Serializer>(value: &[u8], serializer: S) -> Result<S::Ok, S::Error> {
153    serializer.serialize_bytes(value)
154}
155
156#[cfg(test)]
157mod tests {
158    use super::*;
159    use laser_wire::agent::{
160        AgentErrorBody, AgentErrorCode, ChannelId, ConversationId, CorrelationId, OPERATION_CHAT,
161        RecordId,
162    };
163    use laser_wire::framing::encode_named;
164
165    #[test]
166    fn given_an_ordered_stream_when_fed_then_should_emit_bodies_and_the_terminal() {
167        let mut assembler = ChunkAssembler::new();
168        assert_eq!(
169            assembler.feed(&chunk(0, b"he")),
170            vec![StreamEvent::Body {
171                sequence: 0,
172                payload: b"he".to_vec()
173            }]
174        );
175        assert_eq!(
176            assembler.feed(&chunk(1, b"llo")),
177            vec![StreamEvent::Body {
178                sequence: 1,
179                payload: b"llo".to_vec()
180            }]
181        );
182        assert_eq!(
183            assembler.feed(&terminal(2)),
184            vec![StreamEvent::Finished {
185                finish_reason: Some("stop".to_owned()),
186                usage: None,
187                synthetic: false
188            }]
189        );
190        assert!(assembler.is_finished());
191        assert_eq!(assembler.duplicates_dropped(), 0);
192        assert_eq!(assembler.late_dropped(), 0);
193    }
194
195    #[test]
196    fn given_a_redelivered_chunk_when_fed_then_should_drop_and_count_it() {
197        let mut assembler = ChunkAssembler::new();
198        assembler.feed(&chunk(0, b"a"));
199        assembler.feed(&chunk(1, b"b"));
200        assert!(assembler.feed(&chunk(0, b"a")).is_empty());
201        assert!(assembler.feed(&chunk(1, b"b")).is_empty());
202        assert_eq!(assembler.duplicates_dropped(), 2);
203        assert!(!assembler.is_finished());
204        // The stream continues exactly where it was.
205        assert_eq!(
206            assembler.feed(&chunk(2, b"c")),
207            vec![StreamEvent::Body {
208                sequence: 2,
209                payload: b"c".to_vec()
210            }]
211        );
212    }
213
214    #[test]
215    fn given_a_sequence_gap_when_fed_then_should_synthesize_the_gap_terminal() {
216        let mut assembler = ChunkAssembler::new();
217        assembler.feed(&chunk(0, b"a"));
218        let events = assembler.feed(&chunk(2, b"c"));
219        assert_eq!(
220            events,
221            vec![StreamEvent::Finished {
222                finish_reason: Some(FINISH_REASON_GAP.to_owned()),
223                usage: None,
224                synthetic: true
225            }]
226        );
227        assert!(assembler.is_finished());
228        // Whatever arrives after the synthetic ending is late.
229        assert!(assembler.feed(&chunk(1, b"b")).is_empty());
230        assert_eq!(assembler.late_dropped(), 1);
231    }
232
233    #[test]
234    fn given_chunks_after_the_terminal_when_fed_then_first_terminal_should_win() {
235        let mut assembler = ChunkAssembler::new();
236        assembler.feed(&chunk(0, b"a"));
237        assembler.feed(&terminal(1));
238        // A late body and a second terminal both drop.
239        assert!(assembler.feed(&chunk(2, b"x")).is_empty());
240        assert!(assembler.feed(&terminal(3)).is_empty());
241        assert_eq!(assembler.late_dropped(), 2);
242    }
243
244    #[test]
245    fn given_a_non_empty_terminal_chunk_when_fed_then_should_emit_body_then_finished() {
246        let mut assembler = ChunkAssembler::new();
247        let mut closing = chunk(0, b"tail");
248        closing.last = true;
249        closing.finish_reason = Some("stop".to_owned());
250        let events = assembler.feed(&closing);
251        assert_eq!(events.len(), 2);
252        assert!(matches!(events[0], StreamEvent::Body { sequence: 0, .. }));
253        assert!(matches!(
254            events[1],
255            StreamEvent::Finished {
256                synthetic: false,
257                ..
258            }
259        ));
260    }
261
262    #[test]
263    fn given_an_error_terminal_when_fed_then_should_fail_the_stream() {
264        let error_body = encode_named(&AgentErrorBody {
265            code: AgentErrorCode::ToolFailure,
266            message: Some("boom".to_owned()),
267            retryable: false,
268            detail: None,
269        })
270        .expect("error body encodes");
271        let mut error = AgentEnvelope::error(
272            RecordId::from_u128(9),
273            ConversationId::from_u128(1),
274            "source-agent".parse().expect("valid agent id"),
275            CorrelationId::from_u128(3),
276            error_body.clone(),
277        );
278        error.channel = Some(ChannelId::from_u128(4));
279        error.sequence = Some(1);
280
281        let mut assembler = ChunkAssembler::new();
282        assembler.feed(&chunk(0, b"a"));
283        assert_eq!(
284            assembler.feed(&error),
285            vec![StreamEvent::Failed { body: error_body }]
286        );
287        assert!(assembler.is_finished());
288        // A second failure after the first is late.
289        assert!(assembler.feed(&error.clone()).is_empty());
290        assert_eq!(assembler.late_dropped(), 1);
291    }
292
293    #[test]
294    fn given_an_idle_stream_when_abandoned_then_should_synthesize_once() {
295        let mut assembler = ChunkAssembler::new();
296        assembler.feed(&chunk(0, b"a"));
297        let ending = assembler.abandon().expect("first abandonment synthesizes");
298        assert_eq!(
299            ending,
300            StreamEvent::Finished {
301                finish_reason: Some(FINISH_REASON_ABANDONED.to_owned()),
302                usage: None,
303                synthetic: true
304            }
305        );
306        assert!(assembler.abandon().is_none());
307        // Late chunks after local abandonment drop while the log keeps them.
308        assert!(assembler.feed(&chunk(1, b"b")).is_empty());
309        assert_eq!(assembler.late_dropped(), 1);
310    }
311
312    #[test]
313    fn given_stream_events_when_serialized_then_should_tag_each_with_its_kind() {
314        let body = serde_json::to_value(StreamEvent::Body {
315            sequence: 2,
316            payload: b"a".to_vec(),
317        })
318        .expect("serializes");
319        assert_eq!(
320            body,
321            serde_json::json!({"kind": "body", "sequence": 2, "payload": [97]})
322        );
323        let finished = serde_json::to_value(StreamEvent::Finished {
324            finish_reason: Some("stop".to_owned()),
325            usage: None,
326            synthetic: false,
327        })
328        .expect("serializes");
329        assert_eq!(
330            finished,
331            serde_json::json!({"kind": "finished", "finish_reason": "stop", "usage": null, "synthetic": false})
332        );
333        let failed =
334            serde_json::to_value(StreamEvent::Failed { body: vec![1] }).expect("serializes");
335        assert_eq!(failed, serde_json::json!({"kind": "failed", "body": [1]}));
336    }
337
338    fn chunk(sequence: u64, body: &[u8]) -> AgentEnvelope {
339        let envelope = AgentEnvelope::chunk(
340            ConversationId::from_u128(1),
341            "source-agent".parse().expect("valid agent id"),
342            CorrelationId::from_u128(3),
343            ChannelId::from_u128(4),
344            sequence,
345            body.to_vec(),
346        );
347        if sequence == 0 {
348            envelope.with_operation(OPERATION_CHAT)
349        } else {
350            envelope
351        }
352    }
353
354    fn terminal(sequence: u64) -> AgentEnvelope {
355        let mut envelope = chunk(sequence, b"");
356        envelope.last = true;
357        envelope.finish_reason = Some("stop".to_owned());
358        envelope
359    }
360}