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}"); +}