-
Notifications
You must be signed in to change notification settings - Fork 24
Stop stalled narration in streamed model calls #217
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Changes from all commits
e23f4cb
cbc857b
981e9fb
ad9762f
c0ea844
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -61,10 +61,12 @@ | |
| //! `as_str()` gives a stable telemetry label. | ||
|
|
||
| mod classified; | ||
| mod stream_text; | ||
|
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Add the declared stream_text module This module declaration requires [RULE] missing-module · |
||
| mod successful_repeat; | ||
| mod types; | ||
|
|
||
| pub use classified::{ClassifiedFailure, ClassifiedFailureTracker}; | ||
| pub use stream_text::StreamTextStallDetector; | ||
|
senamakel marked this conversation as resolved.
|
||
| pub use successful_repeat::{DEFAULT_REPEAT_CALL_THRESHOLD, DEFAULT_REPEAT_OUTPUT_THRESHOLD}; | ||
| use types::LadderState; | ||
| pub use types::{ | ||
|
|
||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,104 @@ | ||
| //! Detect a streamed response that keeps starting sentences the same way. | ||
| //! | ||
| //! The provider can stream an open-ended sequence of process narration without | ||
| //! ever completing a model call. Tool-call repeat guards run only after that | ||
| //! call, so they cannot stop this shape of stall. | ||
|
|
||
| use std::collections::VecDeque; | ||
|
|
||
| const MIN_CHARS: usize = 600; | ||
| const WINDOW: usize = 10; | ||
| const MATCHES_TO_STALL: usize = 8; | ||
|
|
||
| /// Per-model-call detector for a long run of similarly opened sentences. | ||
| /// Feed visible text fragments in stream order, regardless of chunk boundaries. | ||
| #[derive(Default)] | ||
| pub struct StreamTextStallDetector { | ||
|
senamakel marked this conversation as resolved.
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Wire the detector into streaming model calls This pull request only defines the detector; no streaming model-call path invokes [RULE] unwired-detector · |
||
| sentence: String, | ||
| recent_starts: VecDeque<String>, | ||
| total_chars: usize, | ||
| stalled: bool, | ||
| } | ||
|
|
||
| impl StreamTextStallDetector { | ||
| /// Observe the next visible text fragment. Returns `true` once a strong | ||
| /// sentence-start recurrence is present; callers should stop that stream. | ||
| pub fn observe(&mut self, fragment: &str) -> bool { | ||
| if self.stalled { | ||
| return true; | ||
| } | ||
| for ch in fragment.chars() { | ||
| self.total_chars += 1; | ||
| if matches!(ch, '.' | '!' | '?' | '\n') { | ||
| self.finish_sentence(); | ||
| self.stalled |= self.has_stalled_window(); | ||
| if self.stalled { | ||
| return true; | ||
| } | ||
| } else if self.sentence.len() < 1024 { | ||
| self.sentence.push(ch); | ||
| } | ||
| } | ||
| self.stalled |= self.has_stalled_window(); | ||
| self.stalled | ||
| } | ||
|
|
||
| fn has_stalled_window(&self) -> bool { | ||
| if self.total_chars < MIN_CHARS || self.recent_starts.len() < WINDOW { | ||
|
senamakel marked this conversation as resolved.
|
||
| return false; | ||
| } | ||
| self.recent_starts | ||
| .iter() | ||
| .filter(|start| is_process_start(start)) | ||
| .any(|candidate| { | ||
| self.recent_starts | ||
| .iter() | ||
| .filter(|start| *start == candidate) | ||
| .count() | ||
| >= MATCHES_TO_STALL | ||
| }) | ||
| } | ||
|
|
||
| /// A structured tool call is progress; any later visible text starts a | ||
| /// fresh window instead of inheriting narration before that call. | ||
| pub fn reset(&mut self) { | ||
| self.sentence.clear(); | ||
| self.recent_starts.clear(); | ||
| self.total_chars = 0; | ||
| self.stalled = false; | ||
| } | ||
|
|
||
| fn finish_sentence(&mut self) { | ||
| let words: Vec<String> = self | ||
| .sentence | ||
| .split_whitespace() | ||
| .map(|word| { | ||
| word.chars() | ||
| .filter(|ch| ch.is_alphanumeric()) | ||
| .collect::<String>() | ||
| .to_lowercase() | ||
| }) | ||
| .filter(|word| !word.is_empty()) | ||
| .take(2) | ||
| .collect(); | ||
| if words.len() == 2 { | ||
| self.recent_starts.push_back(words.join(" ")); | ||
| if self.recent_starts.len() > WINDOW { | ||
| self.recent_starts.pop_front(); | ||
| } | ||
| } | ||
| self.sentence.clear(); | ||
| } | ||
| } | ||
|
|
||
| /// A repeated subject in an explanation ("Jev is ...") may be intentional. | ||
| /// The failure shape is repeated self-narration of actions never taken. | ||
| fn is_process_start(start: &str) -> bool { | ||
| matches!( | ||
| start, | ||
| "let me" | "i will" | "i need" | "i should" | "ill now" | ||
| ) | ||
| } | ||
|
|
||
| #[cfg(test)] | ||
| mod test; | ||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,118 @@ | ||
| use super::*; | ||
|
|
||
| #[test] | ||
| fn catches_repeated_process_narration_across_chunks() { | ||
| let mut detector = StreamTextStallDetector::default(); | ||
|
senamakel marked this conversation as resolved.
|
||
| let text = (0..12) | ||
| .map(|i| format!("Let me check the source number {i} before I answer the user. ")) | ||
| .collect::<String>(); | ||
| let mut stalled = false; | ||
| for chunk in text.as_bytes().chunks(7) { | ||
| stalled |= detector.observe(std::str::from_utf8(chunk).unwrap()); | ||
| } | ||
| assert!(stalled); | ||
| } | ||
|
|
||
| #[test] | ||
| fn varied_prose_and_short_repetitions_continue() { | ||
|
senamakel marked this conversation as resolved.
|
||
| let mut detector = StreamTextStallDetector::default(); | ||
| for sentence in [ | ||
| "Jev accepts typed questions about the supplied state.", | ||
| "TypeSafe describes Choice, Score and Noul answers.", | ||
| "A desktop host observes the screen before asking Jev.", | ||
| "The host executes a bounded action and verifies its result.", | ||
| ] { | ||
| assert!(!detector.observe(sentence)); | ||
| } | ||
| let mut detector = StreamTextStallDetector::default(); | ||
| for i in 0..7 { | ||
| assert!(!detector.observe(&format!( | ||
|
senamakel marked this conversation as resolved.
|
||
| "Let me check one more source number {i} before answering. " | ||
| ))); | ||
| } | ||
| let mut detector = StreamTextStallDetector::default(); | ||
| for i in 0..12 { | ||
| assert!(!detector.observe(&format!( | ||
| "Jev is a typed decision model used in example number {i}. " | ||
| ))); | ||
| } | ||
| } | ||
|
|
||
| #[test] | ||
| fn tool_progress_resets_the_window() { | ||
| let mut detector = StreamTextStallDetector::default(); | ||
| for i in 0..7 { | ||
| detector.observe(&format!( | ||
| "Let me inspect the page number {i} before answering. " | ||
| )); | ||
| } | ||
| detector.reset(); | ||
| for i in 0..7 { | ||
| assert!(!detector.observe(&format!( | ||
| "Let me inspect the result number {i} before answering. " | ||
| ))); | ||
| } | ||
| } | ||
|
|
||
| #[test] | ||
| fn useful_facts_between_planning_sentences_break_the_streak() { | ||
| let mut detector = StreamTextStallDetector::default(); | ||
| for i in 0..12 { | ||
| assert!(!detector.observe(&format!( | ||
| "Let me check source {i} before answering the user. Jev returns a typed Choice for state {i}. " | ||
| ))); | ||
| } | ||
| } | ||
|
|
||
| #[test] | ||
| fn a_large_fragment_cannot_evict_an_earlier_stall() { | ||
| let repeated = (0..10) | ||
| .map(|i| { | ||
| format!( | ||
| "Let me check source {i} carefully before answering the user's request with the correct details. " | ||
| ) | ||
| }) | ||
| .collect::<String>(); | ||
| let useful = "Jev returns typed choices. TypeSafe hosts its API. The caller verifies actions."; | ||
| let mut at_boundary = StreamTextStallDetector::default(); | ||
| let mut coalesced = StreamTextStallDetector::default(); | ||
| assert!(at_boundary.observe(&repeated)); | ||
| assert!(coalesced.observe(&format!("{repeated}{useful}"))); | ||
| } | ||
|
|
||
| #[test] | ||
| fn short_narration_stalls_but_short_facts_break_the_window() { | ||
| let mut repeated = StreamTextStallDetector::default(); | ||
| assert!(!repeated.observe(&format!("{}.", "x".repeat(MIN_CHARS)))); | ||
| for _ in 0..9 { | ||
| assert!(!repeated.observe("Let me check. ")); | ||
| } | ||
| assert!(repeated.observe("Let me check.")); | ||
|
|
||
| let mut interleaved = StreamTextStallDetector::default(); | ||
| for _ in 0..12 { | ||
| assert!( | ||
| !interleaved | ||
| .observe("Let me inspect this long source before answering. The answer is 42. ") | ||
| ); | ||
| } | ||
| } | ||
|
|
||
| #[test] | ||
| fn markdown_list_markers_do_not_hide_narration() { | ||
| let mut detector = StreamTextStallDetector::default(); | ||
| let list = (0..12) | ||
| .map(|i| format!("- Let me inspect source {i} before answering the user.\n")) | ||
| .collect::<String>(); | ||
| assert!(detector.observe(&list)); | ||
| } | ||
|
|
||
| #[test] | ||
| fn ordinary_capability_sentences_do_not_count_as_process_narration() { | ||
| let mut detector = StreamTextStallDetector::default(); | ||
| for i in 0..12 { | ||
| assert!(!detector.observe(&format!( | ||
| "I can explain the typed decision result in useful detail number {i}. " | ||
| ))); | ||
| } | ||
| } | ||
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Wire the detector into streaming model calls
The new detector is only re-exported here; this change does not connect it to
invoke_streamingor the streaming loop. Consequently, callers cannot receive the promised stall detection, and the earlier high-severity finding remains unresolved. Integrate the detector into each streaming model call before exposing the public export.[RULE] dead-api ·