From e1f8637dce9cb6f4af09c050f702af7ef28d6b75 Mon Sep 17 00:00:00 2001 From: Steven Enamakel Date: Sat, 8 Aug 2026 17:43:00 +0300 Subject: [PATCH] fix(structured): report an output-limit stop instead of a JSON parse error A reasoning model can spend its entire output budget thinking and return no content at all. The provider says so plainly through `finish_reason: "length"`, but `extract_provider_schema` parsed the empty string regardless and surfaced schema 'x': response text is not valid JSON: expected value at line 1 column 1 which points at a malformed response that was never sent. The reader goes looking for bad JSON; the actual fix is to raise `max_tokens` or cap reasoning. Observed with `moonshotai/kimi-k3` on a 49k-token prompt: `finish_reason` was `length`, `completion_tokens` was the full 8000, reasoning was 17k characters, and content was empty. Empty content is now reported as what it is, naming the remedy when the stop reason confirms truncation, and reporting the stop reason otherwise. Genuinely malformed JSON still reports a parse error. Co-authored-by: Medulla --- src/harness/structured/mod.rs | 28 +++++++++++++++++++ src/harness/structured/test.rs | 49 ++++++++++++++++++++++++++++++++++ 2 files changed, 77 insertions(+) diff --git a/src/harness/structured/mod.rs b/src/harness/structured/mod.rs index 8eec80c..525cb30 100644 --- a/src/harness/structured/mod.rs +++ b/src/harness/structured/mod.rs @@ -184,6 +184,34 @@ impl StructuredExtractor { fn extract_provider_schema(&self, response: &ModelResponse) -> Result { let raw = response.text(); + + // A reasoning model can spend its entire output budget thinking and + // return no content at all. The provider says so plainly through + // `finish_reason: "length"`, but a naive parse of the empty string + // reports "expected value at line 1 column 1" — which sends the reader + // hunting for a malformed response that was never sent, and hides the + // one-line fix of raising `max_tokens` or capping reasoning. + if raw.trim().is_empty() { + let truncated = response + .finish_reason + .as_deref() + .is_some_and(|reason| reason == "length"); + return Err(TinyAgentsError::StructuredOutput(if truncated { + format!( + "schema '{}': the model returned no content because it hit its output limit \ + (finish_reason = \"length\"). Reasoning models can consume the whole budget \ + before emitting an answer: raise `max_tokens`, or cap/disable reasoning.", + self.schema_name + ) + } else { + format!( + "schema '{}': the model returned no content (finish_reason = {:?})", + self.schema_name, + response.finish_reason.as_deref().unwrap_or("unknown") + ) + })); + } + let value: Value = serde_json::from_str(&raw).map_err(|e| { TinyAgentsError::StructuredOutput(format!( "schema '{}': response text is not valid JSON: {e}", diff --git a/src/harness/structured/test.rs b/src/harness/structured/test.rs index cf0b0c6..0145d2b 100644 --- a/src/harness/structured/test.rs +++ b/src/harness/structured/test.rs @@ -111,3 +111,52 @@ fn structured_output_parse_deserialises() { let parsed: Answer = output.parse().unwrap(); assert_eq!(parsed.value, "hello"); } + +// -- truncation diagnostics -- + +/// A response whose content is `text`, stopped for `finish_reason`. +fn stopped(text: &str, finish_reason: &str) -> ModelResponse { + ModelResponse::assistant(text).with_finish_reason(finish_reason) +} + +#[test] +fn empty_content_stopped_for_length_reports_the_output_limit() { + // A reasoning model can spend its whole output budget thinking and return + // no content. Parsing the empty string reports "expected value at line 1 + // column 1", which sends the reader hunting for a malformed response that + // was never sent, and hides the one-line fix. + let extractor = + StructuredExtractor::new(StructuredStrategy::ProviderSchema, "review", json!({})); + let err = extractor + .extract(&stopped("", "length")) + .expect_err("empty content is not extractable"); + + let message = err.to_string(); + assert!(message.contains("output limit"), "{message}"); + assert!(message.contains("max_tokens"), "{message}"); + assert!( + !message.contains("line 1 column 1"), + "the misleading parse error must not survive: {message}" + ); +} + +#[test] +fn empty_content_stopped_normally_reports_what_is_known() { + let extractor = + StructuredExtractor::new(StructuredStrategy::ProviderSchema, "review", json!({})); + let err = extractor + .extract(&stopped(" ", "stop")) + .expect_err("empty content is not extractable"); + assert!(err.to_string().contains("no content"), "{err}"); +} + +#[test] +fn genuinely_malformed_json_still_reports_a_parse_error() { + // The new branch must not swallow the case it was not written for. + let extractor = + StructuredExtractor::new(StructuredStrategy::ProviderSchema, "review", json!({})); + let err = extractor + .extract(&stopped("not json at all", "stop")) + .expect_err("malformed content is not extractable"); + assert!(err.to_string().contains("not valid JSON"), "{err}"); +}