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 =
210 if finish_reason.as_deref() == Some("stop") && self.next_index > 0 {
211 Some("tool_calls".to_owned())
212 } else {
213 finish_reason
214 };
215 self.queued
216 .push_back(LlmStreamEvent::Done { finish_reason });
217 self.done_forwarded = true;
223 }
224 }
225 }
226}
227
228impl Stream for NormalizingStream {
229 type Item = Result<LlmStreamEvent>;
230
231 fn poll_next(mut self: Pin<&mut Self>, cx: &mut Context<'_>) -> Poll<Option<Self::Item>> {
232 loop {
233 if let Some(ev) = self.queued.pop_front() {
234 return Poll::Ready(Some(Ok(ev)));
235 }
236 if self.terminated {
237 return Poll::Ready(None);
238 }
239 match self.inner.as_mut().poll_next(cx) {
240 Poll::Pending => return Poll::Pending,
241 Poll::Ready(Some(Ok(event))) => {
242 self.handle_upstream(event);
243 }
245 Poll::Ready(Some(Err(e))) => {
246 self.terminated = true;
247 return Poll::Ready(Some(Err(e)));
248 }
249 Poll::Ready(None) => {
250 if !self.done_forwarded {
257 let out = self.parser.finish();
258 self.enqueue_parser_output(out);
259 }
260 self.terminated = true;
261 if let Some(ev) = self.queued.pop_front() {
262 return Poll::Ready(Some(Ok(ev)));
263 }
264 return Poll::Ready(None);
265 }
266 }
267 }
268 }
269}
270
271#[cfg(test)]
272mod tests {
273 use super::*;
274 use crate::normalize::registry::{dialect_for_tags, get_parser};
275 use crate::normalize::tags;
276 use std::task::Poll;
277
278 struct VecStream {
280 items: VecDeque<Result<LlmStreamEvent>>,
281 }
282
283 impl VecStream {
284 fn new(items: Vec<Result<LlmStreamEvent>>) -> Self {
285 Self {
286 items: items.into(),
287 }
288 }
289 }
290
291 impl Stream for VecStream {
292 type Item = Result<LlmStreamEvent>;
293 fn poll_next(mut self: Pin<&mut Self>, _cx: &mut Context<'_>) -> Poll<Option<Self::Item>> {
294 Poll::Ready(self.items.pop_front())
295 }
296 }
297
298 fn drain(mut s: NormalizingStream) -> Vec<LlmStreamEvent> {
299 let waker = std::task::Waker::noop();
302 let mut cx = Context::from_waker(waker);
303 let mut out = Vec::new();
304 loop {
305 match Pin::new(&mut s).poll_next(&mut cx) {
306 Poll::Ready(Some(Ok(ev))) => out.push(ev),
307 Poll::Ready(Some(Err(e))) => panic!("unexpected error: {e}"),
308 Poll::Ready(None) => return out,
309 Poll::Pending => panic!("test stream returned Pending"),
310 }
311 }
312 }
313
314 fn wrap(events: Vec<LlmStreamEvent>, qwen: bool) -> NormalizingStream {
315 let inner: InnerStream = Box::pin(VecStream::new(events.into_iter().map(Ok).collect()));
316 let parser = if qwen {
317 get_parser(dialect_for_tags(&[tags::FORMAT_QWEN_XML.to_owned()]).as_ref())
318 } else {
319 get_parser(None)
320 };
321 NormalizingStream::new(inner, parser)
322 }
323
324 #[test]
325 fn standard_parser_is_passthrough() {
326 let events = vec![
327 LlmStreamEvent::TextDelta {
328 content: "hello".into(),
329 },
330 LlmStreamEvent::Done {
331 finish_reason: Some("stop".into()),
332 },
333 ];
334 let out = drain(wrap(events.clone(), false));
335 assert_eq!(out, events);
336 }
337
338 #[test]
339 fn usage_event_passes_through_unchanged() {
340 let events = vec![
341 LlmStreamEvent::TextDelta {
342 content: "hello".into(),
343 },
344 LlmStreamEvent::Usage {
345 prompt_tokens: 10,
346 completion_tokens: 5,
347 total_tokens: 15,
348 cached_tokens: None,
349 },
350 LlmStreamEvent::Done {
351 finish_reason: Some("stop".into()),
352 },
353 ];
354 let out = drain(wrap(events.clone(), false));
355 assert_eq!(out, events);
356 }
357
358 #[test]
359 fn usage_event_after_done_still_forwarded() {
360 let events = vec![
365 LlmStreamEvent::TextDelta {
366 content: "hello".into(),
367 },
368 LlmStreamEvent::Done {
369 finish_reason: Some("stop".into()),
370 },
371 LlmStreamEvent::Usage {
372 prompt_tokens: 10,
373 completion_tokens: 5,
374 total_tokens: 15,
375 cached_tokens: None,
376 },
377 ];
378 let out = drain(wrap(events.clone(), false));
379 assert_eq!(
380 out, events,
381 "Usage arriving after Done must still be forwarded, in order"
382 );
383 }
384
385 #[test]
386 fn text_delta_after_done_is_dropped_defensively() {
387 let events = vec![
390 LlmStreamEvent::Done {
391 finish_reason: Some("stop".into()),
392 },
393 LlmStreamEvent::TextDelta {
394 content: "should be dropped".into(),
395 },
396 LlmStreamEvent::Usage {
397 prompt_tokens: 1,
398 completion_tokens: 1,
399 total_tokens: 2,
400 cached_tokens: None,
401 },
402 ];
403 let out = drain(wrap(events, false));
404 assert_eq!(
405 out,
406 vec![
407 LlmStreamEvent::Done {
408 finish_reason: Some("stop".into()),
409 },
410 LlmStreamEvent::Usage {
411 prompt_tokens: 1,
412 completion_tokens: 1,
413 total_tokens: 2,
414 cached_tokens: None,
415 },
416 ],
417 "stray TextDelta after Done must be dropped, Usage still forwarded"
418 );
419 }
420
421 #[test]
422 fn qwen_xml_in_text_is_extracted_to_tool_call_delta() {
423 let events = vec![
424 LlmStreamEvent::TextDelta {
425 content: r#"hi <tool_call>{"name":"foo","arguments":{"x":1}}</tool_call> done"#
426 .into(),
427 },
428 LlmStreamEvent::Done {
429 finish_reason: Some("tool_calls".into()),
430 },
431 ];
432 let out = drain(wrap(events, true));
433 assert_eq!(out.len(), 3);
435 assert!(matches!(
436 &out[0],
437 LlmStreamEvent::TextDelta { content } if content == "hi done"
438 ));
439 match &out[1] {
440 LlmStreamEvent::ToolCallDelta {
441 index,
442 id,
443 name,
444 arguments,
445 } => {
446 assert_eq!(*index, 0);
447 assert_eq!(id.as_deref(), Some("call_qwen_0"));
448 assert_eq!(name.as_deref(), Some("foo"));
449 assert_eq!(arguments.as_deref(), Some(r#"{"x":1}"#));
450 }
451 other => panic!("expected ToolCallDelta, got {other:?}"),
452 }
453 assert!(matches!(out[2], LlmStreamEvent::Done { .. }));
454 }
455
456 #[test]
457 fn qwen_xml_in_reasoning_is_extracted_and_text_clean() {
458 let events = vec![
459 LlmStreamEvent::ReasoningDelta {
460 content: r#"think <tool_call>{"name":"foo","arguments":{}}</tool_call> end"#.into(),
461 },
462 LlmStreamEvent::Done {
463 finish_reason: Some("tool_calls".into()),
464 },
465 ];
466 let out = drain(wrap(events, true));
467 assert_eq!(out.len(), 3);
468 assert!(matches!(
469 &out[0],
470 LlmStreamEvent::ReasoningDelta { content } if content == "think end"
471 ));
472 assert!(matches!(out[1], LlmStreamEvent::ToolCallDelta { .. }));
473 assert!(matches!(out[2], LlmStreamEvent::Done { .. }));
474 }
475
476 #[test]
477 fn synthesised_index_does_not_collide_with_upstream() {
478 let events = vec![
479 LlmStreamEvent::ToolCallDelta {
480 index: 0,
481 id: Some("native".into()),
482 name: Some("nat".into()),
483 arguments: Some("{}".into()),
484 },
485 LlmStreamEvent::TextDelta {
486 content: r#"<tool_call>{"name":"foo","arguments":{}}</tool_call>"#.into(),
487 },
488 LlmStreamEvent::Done {
489 finish_reason: Some("stop".into()),
490 },
491 ];
492 let out = drain(wrap(events, true));
493 assert_eq!(out.len(), 3);
495 let LlmStreamEvent::ToolCallDelta { index: idx0, .. } = &out[0] else {
496 panic!()
497 };
498 let LlmStreamEvent::ToolCallDelta { index: idx1, .. } = &out[1] else {
499 panic!()
500 };
501 assert_eq!(*idx0, 0);
502 assert_eq!(*idx1, 1);
503 }
504
505 #[test]
506 fn unclosed_tag_at_done_emits_normalization_error_then_done() {
507 let events = vec![
508 LlmStreamEvent::TextDelta {
509 content: r#"<tool_call>{"name":"foo""#.into(),
510 },
511 LlmStreamEvent::Done {
512 finish_reason: Some("stop".into()),
513 },
514 ];
515 let out = drain(wrap(events, true));
516 assert!(matches!(out.last(), Some(LlmStreamEvent::Done { .. })));
518 assert!(
519 out.iter()
520 .any(|e| matches!(e, LlmStreamEvent::NormalizationError { .. }))
521 );
522 }
523
524 #[test]
525 fn upstream_ends_without_done_flushes_parser() {
526 let events = vec![LlmStreamEvent::TextDelta {
529 content: "<tool".into(),
530 }];
531 let out = drain(wrap(events, true));
532 assert_eq!(out.len(), 1);
533 assert!(matches!(
534 &out[0],
535 LlmStreamEvent::TextDelta { content } if content == "<tool"
536 ));
537 }
538
539 #[test]
544 fn finish_reason_corrected_to_tool_calls_when_tool_calls_seen() {
545 let events = vec![
546 LlmStreamEvent::ToolCallDelta {
547 index: 0,
548 id: Some("call_0".into()),
549 name: Some("read_file".into()),
550 arguments: Some(r#"{"path":"/tmp/x"}"#.into()),
551 },
552 LlmStreamEvent::Done {
553 finish_reason: Some("stop".into()), },
555 ];
556 let out = drain(wrap(events, false));
557 assert_eq!(out.len(), 2);
558 match &out[1] {
559 LlmStreamEvent::Done { finish_reason } => {
560 assert_eq!(finish_reason.as_deref(), Some("tool_calls"));
561 }
562 other => panic!("expected Done, got {other:?}"),
563 }
564 }
565
566 #[test]
569 fn finish_reason_stop_unchanged_when_no_tool_calls() {
570 let events = vec![
571 LlmStreamEvent::TextDelta {
572 content: "hello".into(),
573 },
574 LlmStreamEvent::Done {
575 finish_reason: Some("stop".into()),
576 },
577 ];
578 let out = drain(wrap(events, false));
579 match &out[1] {
580 LlmStreamEvent::Done { finish_reason } => {
581 assert_eq!(finish_reason.as_deref(), Some("stop"));
582 }
583 other => panic!("expected Done, got {other:?}"),
584 }
585 }
586
587 #[test]
590 fn stray_close_think_tag_stripped_from_text() {
591 let events = vec![
592 LlmStreamEvent::TextDelta {
593 content: "</think>\n\n".into(),
594 },
595 LlmStreamEvent::TextDelta {
596 content: "actual answer".into(),
597 },
598 LlmStreamEvent::Done {
599 finish_reason: Some("stop".into()),
600 },
601 ];
602 let out = drain(wrap(events, false));
603 let texts: Vec<_> = out
606 .iter()
607 .filter_map(|e| {
608 if let LlmStreamEvent::TextDelta { content } = e {
609 Some(content.as_str())
610 } else {
611 None
612 }
613 })
614 .collect();
615 assert!(
616 !texts.iter().any(|t| t.contains("</think>")),
617 "found </think> in output: {texts:?}"
618 );
619 assert!(texts.iter().any(|t| t.contains("actual answer")));
620 }
621
622 #[test]
624 fn stray_open_think_tag_stripped_from_text() {
625 let events = vec![
626 LlmStreamEvent::TextDelta {
627 content: "<think>spurious</think>real text".into(),
628 },
629 LlmStreamEvent::Done {
630 finish_reason: Some("stop".into()),
631 },
632 ];
633 let out = drain(wrap(events, false));
634 let texts: Vec<_> = out
635 .iter()
636 .filter_map(|e| {
637 if let LlmStreamEvent::TextDelta { content } = e {
638 Some(content.as_str())
639 } else {
640 None
641 }
642 })
643 .collect();
644 assert!(
645 !texts
646 .iter()
647 .any(|t| t.contains("<think>") || t.contains("</think>"))
648 );
649 assert!(texts.iter().any(|t| t.contains("real text")));
650 }
651}