Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
28 changes: 28 additions & 0 deletions src/harness/structured/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -184,6 +184,34 @@ impl StructuredExtractor {

fn extract_provider_schema(&self, response: &ModelResponse) -> Result<StructuredOutput> {
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}",
Expand Down
49 changes: 49 additions & 0 deletions src/harness/structured/test.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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}");
}