1use std::collections::VecDeque;
43use std::pin::Pin;
44use std::task::{Context, Poll};
45
46use anyhow::Result;
47use futures_core::Stream;
48
49use super::parser::{ParserOutput, ToolCallParser};
50use crate::domain::agent::{LlmStreamEvent, ToolCall};
51
52type InnerStream = Pin<Box<dyn Stream<Item = Result<LlmStreamEvent>> + Send>>;
53
54pub struct NormalizingStream {
57 inner: InnerStream,
58 parser: Box<dyn ToolCallParser>,
59 queued: VecDeque<LlmStreamEvent>,
63 next_index: usize,
67 terminated: bool,
71 done_forwarded: bool,
84}
85
86impl NormalizingStream {
87 #[must_use]
89 pub fn new(inner: InnerStream, parser: Box<dyn ToolCallParser>) -> Self {
90 Self {
91 inner,
92 parser,
93 queued: VecDeque::new(),
94 next_index: 0,
95 terminated: false,
96 done_forwarded: false,
97 }
98 }
99
100 fn enqueue_parser_output(&mut self, mut out: ParserOutput) {
102 if !out.forward_text.is_empty() {
103 let text = std::mem::take(&mut out.forward_text);
111 let text = text.replace("</think>", "").replace("<think>", "");
112 if !text.is_empty() {
113 self.queued
114 .push_back(LlmStreamEvent::TextDelta { content: text });
115 }
116 }
117 if !out.forward_reasoning.is_empty() {
118 self.queued.push_back(LlmStreamEvent::ReasoningDelta {
119 content: std::mem::take(&mut out.forward_reasoning),
120 });
121 }
122 for ToolCall {
123 id,
124 name,
125 arguments,
126 } in out.tool_calls
127 {
128 let index = self.next_index;
129 self.next_index += 1;
130 self.queued.push_back(LlmStreamEvent::ToolCallDelta {
131 index,
132 id: Some(id),
133 name: Some(name),
134 arguments: Some(arguments.to_string()),
135 });
136 }
137 for err in out.errors {
138 self.queued.push_back(LlmStreamEvent::NormalizationError {
139 kind: err.kind,
140 raw: err.raw,
141 });
142 }
143 }
144
145 fn handle_upstream(&mut self, event: LlmStreamEvent) {
147 match event {
148 LlmStreamEvent::TextDelta { content } => {
149 if self.done_forwarded {
150 tracing::warn!("NormalizingStream: dropping TextDelta received after Done");
151 return;
152 }
153 let out = self.parser.push_text(&content);
154 self.enqueue_parser_output(out);
155 }
156 LlmStreamEvent::ReasoningDelta { content } => {
157 if self.done_forwarded {
158 tracing::warn!(
159 "NormalizingStream: dropping ReasoningDelta received after Done"
160 );
161 return;
162 }
163 let out = self.parser.push_reasoning(&content);
164 self.enqueue_parser_output(out);
165 }
166 LlmStreamEvent::ToolCallDelta {
167 index,
168 id,
169 name,
170 arguments,
171 } => {
172 if self.done_forwarded {
173 tracing::warn!("NormalizingStream: dropping ToolCallDelta received after Done");
174 return;
175 }
176 if index >= self.next_index {
177 self.next_index = index + 1;
178 }
179 self.queued.push_back(LlmStreamEvent::ToolCallDelta {
180 index,
181 id,
182 name,
183 arguments,
184 });
185 }
186 LlmStreamEvent::PromptProgress { .. }
187 | LlmStreamEvent::NormalizationError { .. }
188 | LlmStreamEvent::Usage { .. }
189 | LlmStreamEvent::UpstreamError { .. } => {
190 self.queued.push_back(event);
193 }
194 LlmStreamEvent::Done { finish_reason } => {
195 if self.done_forwarded {
196 tracing::warn!("NormalizingStream: dropping duplicate Done event");
197 return;
198 }
199 let out = self.parser.finish();
200 self.enqueue_parser_output(out);
201 let finish_reason = if finish_reason == "stop" && self.next_index > 0 {
207 "tool_calls".to_owned()
208 } else {
209 finish_reason
210 };
211 self.queued
212 .push_back(LlmStreamEvent::Done { finish_reason });
213 self.done_forwarded = true;
219 }
220 }
221 }
222}
223
224impl Stream for NormalizingStream {
225 type Item = Result<LlmStreamEvent>;
226
227 fn poll_next(mut self: Pin<&mut Self>, cx: &mut Context<'_>) -> Poll<Option<Self::Item>> {
228 loop {
229 if let Some(ev) = self.queued.pop_front() {
230 return Poll::Ready(Some(Ok(ev)));
231 }
232 if self.terminated {
233 return Poll::Ready(None);
234 }
235 match self.inner.as_mut().poll_next(cx) {
236 Poll::Pending => return Poll::Pending,
237 Poll::Ready(Some(Ok(event))) => {
238 self.handle_upstream(event);
239 }
241 Poll::Ready(Some(Err(e))) => {
242 self.terminated = true;
243 return Poll::Ready(Some(Err(e)));
244 }
245 Poll::Ready(None) => {
246 if !self.done_forwarded {
253 let out = self.parser.finish();
254 self.enqueue_parser_output(out);
255 }
256 self.terminated = true;
257 if let Some(ev) = self.queued.pop_front() {
258 return Poll::Ready(Some(Ok(ev)));
259 }
260 return Poll::Ready(None);
261 }
262 }
263 }
264 }
265}
266
267#[cfg(test)]
268mod tests {
269 use super::*;
270 use crate::normalize::{registry::get_parser, tags};
271 use std::task::Poll;
272
273 struct VecStream {
275 items: VecDeque<Result<LlmStreamEvent>>,
276 }
277
278 impl VecStream {
279 fn new(items: Vec<Result<LlmStreamEvent>>) -> Self {
280 Self {
281 items: items.into(),
282 }
283 }
284 }
285
286 impl Stream for VecStream {
287 type Item = Result<LlmStreamEvent>;
288 fn poll_next(mut self: Pin<&mut Self>, _cx: &mut Context<'_>) -> Poll<Option<Self::Item>> {
289 Poll::Ready(self.items.pop_front())
290 }
291 }
292
293 fn drain(mut s: NormalizingStream) -> Vec<LlmStreamEvent> {
294 let waker = std::task::Waker::noop();
297 let mut cx = Context::from_waker(waker);
298 let mut out = Vec::new();
299 loop {
300 match Pin::new(&mut s).poll_next(&mut cx) {
301 Poll::Ready(Some(Ok(ev))) => out.push(ev),
302 Poll::Ready(Some(Err(e))) => panic!("unexpected error: {e}"),
303 Poll::Ready(None) => return out,
304 Poll::Pending => panic!("test stream returned Pending"),
305 }
306 }
307 }
308
309 fn wrap(events: Vec<LlmStreamEvent>, qwen: bool) -> NormalizingStream {
310 let inner: InnerStream = Box::pin(VecStream::new(events.into_iter().map(Ok).collect()));
311 let parser = if qwen {
312 get_parser(&[tags::FORMAT_QWEN_XML.to_owned()])
313 } else {
314 get_parser(&[])
315 };
316 NormalizingStream::new(inner, parser)
317 }
318
319 #[test]
320 fn standard_parser_is_passthrough() {
321 let events = vec![
322 LlmStreamEvent::TextDelta {
323 content: "hello".into(),
324 },
325 LlmStreamEvent::Done {
326 finish_reason: "stop".into(),
327 },
328 ];
329 let out = drain(wrap(events.clone(), false));
330 assert_eq!(out, events);
331 }
332
333 #[test]
334 fn usage_event_passes_through_unchanged() {
335 let events = vec![
336 LlmStreamEvent::TextDelta {
337 content: "hello".into(),
338 },
339 LlmStreamEvent::Usage {
340 prompt_tokens: 10,
341 completion_tokens: 5,
342 total_tokens: 15,
343 cached_tokens: None,
344 },
345 LlmStreamEvent::Done {
346 finish_reason: "stop".into(),
347 },
348 ];
349 let out = drain(wrap(events.clone(), false));
350 assert_eq!(out, events);
351 }
352
353 #[test]
354 fn usage_event_after_done_still_forwarded() {
355 let events = vec![
360 LlmStreamEvent::TextDelta {
361 content: "hello".into(),
362 },
363 LlmStreamEvent::Done {
364 finish_reason: "stop".into(),
365 },
366 LlmStreamEvent::Usage {
367 prompt_tokens: 10,
368 completion_tokens: 5,
369 total_tokens: 15,
370 cached_tokens: None,
371 },
372 ];
373 let out = drain(wrap(events.clone(), false));
374 assert_eq!(
375 out, events,
376 "Usage arriving after Done must still be forwarded, in order"
377 );
378 }
379
380 #[test]
381 fn text_delta_after_done_is_dropped_defensively() {
382 let events = vec![
385 LlmStreamEvent::Done {
386 finish_reason: "stop".into(),
387 },
388 LlmStreamEvent::TextDelta {
389 content: "should be dropped".into(),
390 },
391 LlmStreamEvent::Usage {
392 prompt_tokens: 1,
393 completion_tokens: 1,
394 total_tokens: 2,
395 cached_tokens: None,
396 },
397 ];
398 let out = drain(wrap(events, false));
399 assert_eq!(
400 out,
401 vec![
402 LlmStreamEvent::Done {
403 finish_reason: "stop".into(),
404 },
405 LlmStreamEvent::Usage {
406 prompt_tokens: 1,
407 completion_tokens: 1,
408 total_tokens: 2,
409 cached_tokens: None,
410 },
411 ],
412 "stray TextDelta after Done must be dropped, Usage still forwarded"
413 );
414 }
415
416 #[test]
417 fn qwen_xml_in_text_is_extracted_to_tool_call_delta() {
418 let events = vec![
419 LlmStreamEvent::TextDelta {
420 content: r#"hi <tool_call>{"name":"foo","arguments":{"x":1}}</tool_call> done"#
421 .into(),
422 },
423 LlmStreamEvent::Done {
424 finish_reason: "tool_calls".into(),
425 },
426 ];
427 let out = drain(wrap(events, true));
428 assert_eq!(out.len(), 3);
430 assert!(matches!(
431 &out[0],
432 LlmStreamEvent::TextDelta { content } if content == "hi done"
433 ));
434 match &out[1] {
435 LlmStreamEvent::ToolCallDelta {
436 index,
437 id,
438 name,
439 arguments,
440 } => {
441 assert_eq!(*index, 0);
442 assert_eq!(id.as_deref(), Some("call_qwen_0"));
443 assert_eq!(name.as_deref(), Some("foo"));
444 assert_eq!(arguments.as_deref(), Some(r#"{"x":1}"#));
445 }
446 other => panic!("expected ToolCallDelta, got {other:?}"),
447 }
448 assert!(matches!(out[2], LlmStreamEvent::Done { .. }));
449 }
450
451 #[test]
452 fn qwen_xml_in_reasoning_is_extracted_and_text_clean() {
453 let events = vec![
454 LlmStreamEvent::ReasoningDelta {
455 content: r#"think <tool_call>{"name":"foo","arguments":{}}</tool_call> end"#.into(),
456 },
457 LlmStreamEvent::Done {
458 finish_reason: "tool_calls".into(),
459 },
460 ];
461 let out = drain(wrap(events, true));
462 assert_eq!(out.len(), 3);
463 assert!(matches!(
464 &out[0],
465 LlmStreamEvent::ReasoningDelta { content } if content == "think end"
466 ));
467 assert!(matches!(out[1], LlmStreamEvent::ToolCallDelta { .. }));
468 assert!(matches!(out[2], LlmStreamEvent::Done { .. }));
469 }
470
471 #[test]
472 fn synthesised_index_does_not_collide_with_upstream() {
473 let events = vec![
474 LlmStreamEvent::ToolCallDelta {
475 index: 0,
476 id: Some("native".into()),
477 name: Some("nat".into()),
478 arguments: Some("{}".into()),
479 },
480 LlmStreamEvent::TextDelta {
481 content: r#"<tool_call>{"name":"foo","arguments":{}}</tool_call>"#.into(),
482 },
483 LlmStreamEvent::Done {
484 finish_reason: "stop".into(),
485 },
486 ];
487 let out = drain(wrap(events, true));
488 assert_eq!(out.len(), 3);
490 let LlmStreamEvent::ToolCallDelta { index: idx0, .. } = &out[0] else {
491 panic!()
492 };
493 let LlmStreamEvent::ToolCallDelta { index: idx1, .. } = &out[1] else {
494 panic!()
495 };
496 assert_eq!(*idx0, 0);
497 assert_eq!(*idx1, 1);
498 }
499
500 #[test]
501 fn unclosed_tag_at_done_emits_normalization_error_then_done() {
502 let events = vec![
503 LlmStreamEvent::TextDelta {
504 content: r#"<tool_call>{"name":"foo""#.into(),
505 },
506 LlmStreamEvent::Done {
507 finish_reason: "stop".into(),
508 },
509 ];
510 let out = drain(wrap(events, true));
511 assert!(matches!(out.last(), Some(LlmStreamEvent::Done { .. })));
513 assert!(
514 out.iter()
515 .any(|e| matches!(e, LlmStreamEvent::NormalizationError { .. }))
516 );
517 }
518
519 #[test]
520 fn upstream_ends_without_done_flushes_parser() {
521 let events = vec![LlmStreamEvent::TextDelta {
524 content: "<tool".into(),
525 }];
526 let out = drain(wrap(events, true));
527 assert_eq!(out.len(), 1);
528 assert!(matches!(
529 &out[0],
530 LlmStreamEvent::TextDelta { content } if content == "<tool"
531 ));
532 }
533
534 #[test]
539 fn finish_reason_corrected_to_tool_calls_when_tool_calls_seen() {
540 let events = vec![
541 LlmStreamEvent::ToolCallDelta {
542 index: 0,
543 id: Some("call_0".into()),
544 name: Some("read_file".into()),
545 arguments: Some(r#"{"path":"/tmp/x"}"#.into()),
546 },
547 LlmStreamEvent::Done {
548 finish_reason: "stop".into(), },
550 ];
551 let out = drain(wrap(events, false));
552 assert_eq!(out.len(), 2);
553 match &out[1] {
554 LlmStreamEvent::Done { finish_reason } => {
555 assert_eq!(finish_reason, "tool_calls");
556 }
557 other => panic!("expected Done, got {other:?}"),
558 }
559 }
560
561 #[test]
564 fn finish_reason_stop_unchanged_when_no_tool_calls() {
565 let events = vec![
566 LlmStreamEvent::TextDelta {
567 content: "hello".into(),
568 },
569 LlmStreamEvent::Done {
570 finish_reason: "stop".into(),
571 },
572 ];
573 let out = drain(wrap(events, false));
574 match &out[1] {
575 LlmStreamEvent::Done { finish_reason } => {
576 assert_eq!(finish_reason, "stop");
577 }
578 other => panic!("expected Done, got {other:?}"),
579 }
580 }
581
582 #[test]
585 fn stray_close_think_tag_stripped_from_text() {
586 let events = vec![
587 LlmStreamEvent::TextDelta {
588 content: "</think>\n\n".into(),
589 },
590 LlmStreamEvent::TextDelta {
591 content: "actual answer".into(),
592 },
593 LlmStreamEvent::Done {
594 finish_reason: "stop".into(),
595 },
596 ];
597 let out = drain(wrap(events, false));
598 let texts: Vec<_> = out
601 .iter()
602 .filter_map(|e| {
603 if let LlmStreamEvent::TextDelta { content } = e {
604 Some(content.as_str())
605 } else {
606 None
607 }
608 })
609 .collect();
610 assert!(
611 !texts.iter().any(|t| t.contains("</think>")),
612 "found </think> in output: {texts:?}"
613 );
614 assert!(texts.iter().any(|t| t.contains("actual answer")));
615 }
616
617 #[test]
619 fn stray_open_think_tag_stripped_from_text() {
620 let events = vec![
621 LlmStreamEvent::TextDelta {
622 content: "<think>spurious</think>real text".into(),
623 },
624 LlmStreamEvent::Done {
625 finish_reason: "stop".into(),
626 },
627 ];
628 let out = drain(wrap(events, false));
629 let texts: Vec<_> = out
630 .iter()
631 .filter_map(|e| {
632 if let LlmStreamEvent::TextDelta { content } = e {
633 Some(content.as_str())
634 } else {
635 None
636 }
637 })
638 .collect();
639 assert!(
640 !texts
641 .iter()
642 .any(|t| t.contains("<think>") || t.contains("</think>"))
643 );
644 assert!(texts.iter().any(|t| t.contains("real text")));
645 }
646}