diff --git a/crates/llm/src/lib.rs b/crates/llm/src/lib.rs index aec5c9d..8111ffc 100644 --- a/crates/llm/src/lib.rs +++ b/crates/llm/src/lib.rs @@ -631,10 +631,27 @@ fn json_envelope_complete(text: &str) -> bool { } fn extract_json_envelope(text: &str) -> Option<&str> { - let start = text + // Phase B.9 audit residual (2026-05-14): strip the leading + // `` reasoning block before scanning. Qwen-style + // models emit non-empty reasoning when thinking mode is on, and + // the reasoning can contain JSON-looking literals (e.g. + // "the answer should be {\"x\":1}") or unbalanced braces ("I wonder + // about {..."). The naive "find the first '{' or '['" extractor + // would then either return the wrong envelope or pollute the + // brace-stack and return None. We split on the FIRST `` — + // anything before it is reasoning, anything after is the answer + // proper. Falls back to the whole text when no `` is + // present (covers non-reasoning models and the empty-thinking + // case already covered by `extract_json_envelope_skips_qwen_thinking_prefix`). + let scan_region = text + .split_once("") + .map(|(_, rest)| rest) + .unwrap_or(text); + + let start = scan_region .char_indices() .find_map(|(idx, ch)| (ch == '{' || ch == '[').then_some(idx))?; - let mut chars = text[start..].char_indices(); + let mut chars = scan_region[start..].char_indices(); let (_, first) = chars.next()?; let mut stack = vec![match first { @@ -667,7 +684,7 @@ fn extract_json_envelope(text: &str) -> Option<&str> { } if stack.is_empty() { let end = start + offset + ch.len_utf8(); - return Some(&text[start..end]); + return Some(&scan_region[start..end]); } } _ => {} @@ -812,6 +829,45 @@ mod tests { ); } + /// Phase B.9 audit regression (2026-05-14). The original + /// `extract_json_envelope_skips_qwen_thinking_prefix` test only + /// covered an EMPTY `` block. Qwen-style reasoning + /// is typically non-empty and can contain JSON-looking literals + /// (the model thinking out loud about what shape it should emit). + /// The naive "first '{' wins" extractor mis-identified the + /// reasoning's literal as the answer envelope and returned it, + /// skipping the actual answer that followed ``. + /// + /// Post-fix the extractor strips the leading `` + /// block before scanning, so the reasoning's literal cannot + /// poison the result. + #[test] + fn extract_json_envelope_skips_thinking_block_with_json_looking_content() { + let raw = "The answer should look like {\"topic\":\"reasoning-example\",\"intent\":\"capture\"} \ + based on the schema.{\"topic\":\"real-answer\",\"intent\":\"planning\"}"; + assert_eq!( + extract_json_envelope(raw), + Some("{\"topic\":\"real-answer\",\"intent\":\"planning\"}"), + ); + } + + /// Phase B.9 audit regression (2026-05-14). If the reasoning block + /// contains UNBALANCED braces (e.g. the model writes "I wonder + /// about {..." inside ``), the pre-strip extractor + /// would start its stack on that unbalanced `{`, never find a + /// matching `}`, and continue past `` polluting the stack + /// with the real answer's braces — ultimately returning None and + /// losing the answer entirely. Stripping the reasoning block first + /// makes both cases moot. + #[test] + fn extract_json_envelope_survives_unbalanced_braces_in_thinking() { + let raw = "I wonder about {something unfinished here{\"topic\":\"recovery\",\"intent\":\"capture\"}"; + assert_eq!( + extract_json_envelope(raw), + Some("{\"topic\":\"recovery\",\"intent\":\"capture\"}"), + ); + } + #[test] fn prompt_preflight_rejects_oversized_prompt_tokens() { let err = preflight_context_window(7_105, 1_024).unwrap_err();