Skip to content

Commit 69c6b29

Browse files
committed
fix(structured): resolve generate_object across models via reasoning-channel + multi-candidate extraction; bump 4.2.4
Reasoning models (GLM/zhipu, DeepSeek-R1, kimi) routinely emit the final object in the `reasoning` channel with `content` empty and no tool call. extract_raw_output only read the tool call / text content, so those models yielded an empty string and generate_object failed with "no structured output" even though a valid object was produced (production asset-diagnose "未返回结构化输出"). The 4.2.3 request-side forcing (tool_choice / response_format) helps models that honor it but does NOT fix extraction. - extract_raw_candidates: mine tool-call args -> text -> reasoning channel. - resolve_structured + extract_all_json_values + find_all_balanced: try every balanced JSON candidate from each source against the schema, keep the one that validates (reasoning traces often contain draft objects before the final answer). - truncate_utf8: fix latent panic in repair prompts slicing &str[..2000] across a CJK boundary. - Complements the 4.2.3 request-side directive work; request + extraction both covered now. - 6 new structured tests + updated team test; full structured suite green (91 passed). Bump all SDK version files to 4.2.4 (version-alignment gate green).
1 parent b09b428 commit 69c6b29

12 files changed

Lines changed: 379 additions & 103 deletions

File tree

Cargo.lock

Lines changed: 1 addition & 1 deletion
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

core/Cargo.toml

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
[package]
22
name = "a3s-code-core"
3-
version = "4.2.3"
3+
version = "4.2.4"
44
edition = "2021"
55
authors = ["A3S Lab Team"]
66
license = "MIT"

core/src/llm/structured.rs

Lines changed: 210 additions & 70 deletions
Original file line numberDiff line numberDiff line change
@@ -130,55 +130,53 @@ pub async fn generate_blocking(
130130

131131
accumulate_usage(&mut total_usage, &resp.usage);
132132

133-
let raw_text = extract_raw_output(&resp.message, mode);
134-
let parsed = extract_json_value(&raw_text);
133+
// Mine the object from every place a model might have parked it (tool call,
134+
// text content, AND the reasoning channel), trying each balanced JSON
135+
// candidate against the schema. Reasoning models routinely leave `content`
136+
// empty and emit the object inside `reasoning`, so without the reasoning
137+
// fallback generate_object failed with "no structured output" across models.
138+
let candidates = extract_raw_candidates(&resp.message, mode);
139+
let resolution = resolve_structured(&candidates, &req.schema);
140+
141+
if let Some((value, raw)) = resolution.valid {
142+
return Ok(StructuredResult {
143+
object: value,
144+
raw_text: Some(raw),
145+
usage: total_usage,
146+
repair_rounds,
147+
mode_used: mode,
148+
});
149+
}
135150

136-
match parsed {
137-
Ok(value) => match validate_against_schema(&value, &req.schema) {
138-
Ok(()) => {
139-
return Ok(StructuredResult {
140-
object: value,
141-
raw_text: Some(raw_text),
142-
usage: total_usage,
143-
repair_rounds,
144-
mode_used: mode,
145-
});
146-
}
147-
Err(errors) if repair_rounds < req.max_repair_attempts => {
148-
repair_rounds += 1;
149-
let repair_msg = build_repair_message(&raw_text, &errors);
150-
append_repair_context(
151-
&mut messages,
152-
&resp.message,
153-
&repair_msg,
154-
mode,
155-
&raw_text,
156-
);
157-
}
158-
Err(errors) => {
159-
bail!(
160-
"Structured output failed schema validation after {} repair attempts. Errors: {}",
161-
repair_rounds,
162-
errors.join("; ")
163-
);
164-
}
165-
},
166-
Err(parse_err) if repair_rounds < req.max_repair_attempts => {
167-
repair_rounds += 1;
168-
let repair_msg = format!(
169-
"Your previous output could not be parsed as JSON:\n\n{}\n\nError: {}\n\nPlease return ONLY a valid JSON object matching the schema.",
170-
raw_text, parse_err
171-
);
172-
append_repair_context(&mut messages, &resp.message, &repair_msg, mode, &raw_text);
173-
}
174-
Err(parse_err) => {
175-
bail!(
176-
"Structured output failed JSON parsing after {} repair attempts: {}",
151+
if repair_rounds >= req.max_repair_attempts {
152+
return Err(match resolution.invalid {
153+
Some((_, errors)) => anyhow::anyhow!(
154+
"Structured output failed schema validation after {} repair attempts. Errors: {}",
177155
repair_rounds,
178-
parse_err
179-
);
180-
}
156+
errors.join("; ")
157+
),
158+
None => anyhow::anyhow!(
159+
"Structured output parsing failed after {} repair attempts: no JSON object found in tool call, text content, or reasoning channel",
160+
repair_rounds
161+
),
162+
});
181163
}
164+
165+
repair_rounds += 1;
166+
let (repair_msg, raw_for_ctx) = match resolution.invalid {
167+
Some((raw, errors)) => (build_repair_message(&raw, &errors), raw),
168+
None => {
169+
let raw = resolution.raw_seen.unwrap_or_default();
170+
(build_parse_failure_repair(&raw), raw)
171+
}
172+
};
173+
append_repair_context(
174+
&mut messages,
175+
&resp.message,
176+
&repair_msg,
177+
mode,
178+
&raw_for_ctx,
179+
);
182180
}
183181
}
184182

@@ -260,16 +258,24 @@ pub async fn generate_streaming(
260258
}
261259

262260
let resp = final_response.context("Stream ended without Done event")?;
263-
let raw_text = extract_raw_output(&resp.message, mode);
264-
let value =
265-
extract_json_value(&raw_text).context("Failed to parse final streamed output as JSON")?;
266-
267-
validate_against_schema(&value, &req.schema).map_err(|errors| {
268-
anyhow::anyhow!(
269-
"Streamed structured output failed schema validation: {}",
270-
errors.join("; ")
271-
)
272-
})?;
261+
// Same multi-source resolution as the blocking path: the final message may carry
262+
// the object in the tool call, the text content, or the reasoning channel.
263+
let candidates = extract_raw_candidates(&resp.message, mode);
264+
let resolution = resolve_structured(&candidates, &req.schema);
265+
let (value, raw_text) = match resolution.valid {
266+
Some(vr) => vr,
267+
None => {
268+
return Err(match resolution.invalid {
269+
Some((_, errors)) => anyhow::anyhow!(
270+
"Streamed structured output failed schema validation: {}",
271+
errors.join("; ")
272+
),
273+
None => anyhow::anyhow!(
274+
"Streamed output produced no parseable JSON object (checked tool call, text content, and reasoning channel)"
275+
),
276+
});
277+
}
278+
};
273279

274280
// Emit final complete object
275281
on_partial(&value);
@@ -362,6 +368,11 @@ fn find_balanced_json_array(text: &str) -> Option<&str> {
362368
}
363369

364370
fn find_balanced(text: &str, open: char, close: char) -> Option<&str> {
371+
find_balanced_range(text, open, close).map(|(start, end)| &text[start..end])
372+
}
373+
374+
/// Byte range `[start, end)` of the first balanced `open..close` substring (quote-aware).
375+
fn find_balanced_range(text: &str, open: char, close: char) -> Option<(usize, usize)> {
365376
let bytes = text.as_bytes();
366377
let open_byte = open as u8;
367378
let close_byte = close as u8;
@@ -406,7 +417,7 @@ fn find_balanced(text: &str, open: char, close: char) -> Option<&str> {
406417
_ if b == close_byte => {
407418
depth -= 1;
408419
if depth == 0 {
409-
return Some(&text[start..start + i + 1]);
420+
return Some((start, start + i + 1));
410421
}
411422
}
412423
_ => {}
@@ -415,6 +426,26 @@ fn find_balanced(text: &str, open: char, close: char) -> Option<&str> {
415426
None
416427
}
417428

429+
/// Every top-level balanced `open..close` substring, in document order.
430+
///
431+
/// Reasoning traces often contain several objects (worked examples, partial drafts)
432+
/// before the final answer, so callers validate each against the schema and keep the
433+
/// one that fits rather than blindly trusting the first `{...}`.
434+
fn find_all_balanced(text: &str, open: char, close: char) -> Vec<String> {
435+
let mut out = Vec::new();
436+
let mut base = 0usize;
437+
while base < text.len() {
438+
match find_balanced_range(&text[base..], open, close) {
439+
Some((start, end)) => {
440+
out.push(text[base + start..base + end].to_string());
441+
base += end;
442+
}
443+
None => break,
444+
}
445+
}
446+
out
447+
}
448+
418449
/// Find the byte offset where JSON content starts in a text stream.
419450
/// Skips leading prose/whitespace to find `{` or `[` that isn't inside a string.
420451
fn find_json_start(text: &str) -> Option<usize> {
@@ -957,29 +988,138 @@ fn build_tools(req: &StructuredRequest, mode: StructuredMode) -> Vec<ToolDefinit
957988
}
958989
}
959990

960-
/// Extract the raw JSON string from the LLM response based on mode.
961-
fn extract_raw_output(message: &super::Message, mode: StructuredMode) -> String {
962-
match mode {
963-
StructuredMode::Tool => {
964-
// Look for tool call input
965-
let calls = message.tool_calls();
966-
if let Some(call) = calls.first() {
967-
serde_json::to_string(&call.args).unwrap_or_default()
968-
} else {
969-
// Fallback: maybe the model responded with text anyway
970-
message.text()
991+
/// Outcome of mining a response for the structured object across all candidate sources.
992+
struct StructuredResolution {
993+
/// A schema-valid object plus the raw source string it came from.
994+
valid: Option<(Value, String)>,
995+
/// First parseable-but-schema-invalid object source + its validation errors,
996+
/// used to build a targeted repair prompt.
997+
invalid: Option<(String, Vec<String>)>,
998+
/// First non-empty raw candidate, shown verbatim in a parse-failure repair prompt.
999+
raw_seen: Option<String>,
1000+
}
1001+
1002+
/// Append `s` to `out` if it is non-empty and not already present (trimmed, deduped).
1003+
fn push_candidate(out: &mut Vec<String>, s: String) {
1004+
let trimmed = s.trim();
1005+
if !trimmed.is_empty() && !out.iter().any(|c| c == trimmed) {
1006+
out.push(trimmed.to_string());
1007+
}
1008+
}
1009+
1010+
/// Ordered raw strings to mine for the structured object, most authoritative first:
1011+
/// tool-call arguments, then text content, then the reasoning channel.
1012+
///
1013+
/// The reasoning fallback is the crux of the cross-model fix: reasoning models
1014+
/// (GLM/zhipu, DeepSeek-R1, kimi…) frequently emit the final object inside
1015+
/// `reasoning` with `content` empty and no tool call. Earlier extraction only looked
1016+
/// at the tool call / text, so those models yielded an empty string and the whole
1017+
/// generate_object failed even though a perfectly good object was produced.
1018+
fn extract_raw_candidates(message: &super::Message, mode: StructuredMode) -> Vec<String> {
1019+
let mut out: Vec<String> = Vec::new();
1020+
if mode == StructuredMode::Tool {
1021+
if let Some(call) = message.tool_calls().first() {
1022+
push_candidate(
1023+
&mut out,
1024+
serde_json::to_string(&call.args).unwrap_or_default(),
1025+
);
1026+
}
1027+
}
1028+
push_candidate(&mut out, message.text());
1029+
if let Some(reasoning) = message.reasoning_content.as_deref() {
1030+
push_candidate(&mut out, reasoning.to_string());
1031+
}
1032+
out
1033+
}
1034+
1035+
/// Every JSON object/array value mineable from possibly-dirty text, in document order
1036+
/// (direct parse, code fences, then all balanced `{...}` / `[...]`). Deduped.
1037+
fn extract_all_json_values(text: &str) -> Vec<Value> {
1038+
let trimmed = text.trim();
1039+
let mut values: Vec<Value> = Vec::new();
1040+
let consider = |candidate: &str, values: &mut Vec<Value>| {
1041+
if let Ok(v) = serde_json::from_str::<Value>(candidate.trim()) {
1042+
if (v.is_object() || v.is_array()) && !values.contains(&v) {
1043+
values.push(v);
1044+
}
1045+
}
1046+
};
1047+
consider(trimmed, &mut values);
1048+
if let Some(inner) = strip_code_fence(trimmed) {
1049+
consider(inner, &mut values);
1050+
}
1051+
for candidate in find_all_balanced(trimmed, '{', '}') {
1052+
consider(&candidate, &mut values);
1053+
}
1054+
for candidate in find_all_balanced(trimmed, '[', ']') {
1055+
consider(&candidate, &mut values);
1056+
}
1057+
values
1058+
}
1059+
1060+
/// Try every raw candidate × every JSON value it yields against the schema; return the
1061+
/// first schema-valid object, else the best parseable-but-invalid object (for repair).
1062+
fn resolve_structured(candidates: &[String], schema: &Value) -> StructuredResolution {
1063+
let mut invalid: Option<(String, Vec<String>)> = None;
1064+
let mut raw_seen: Option<String> = None;
1065+
for raw in candidates {
1066+
if raw_seen.is_none() && !raw.trim().is_empty() {
1067+
raw_seen = Some(raw.clone());
1068+
}
1069+
for value in extract_all_json_values(raw) {
1070+
match validate_against_schema(&value, schema) {
1071+
Ok(()) => {
1072+
return StructuredResolution {
1073+
valid: Some((value, raw.clone())),
1074+
invalid,
1075+
raw_seen,
1076+
};
1077+
}
1078+
Err(errors) => {
1079+
if invalid.is_none() {
1080+
invalid = Some((raw.clone(), errors));
1081+
}
1082+
}
9711083
}
9721084
}
973-
_ => message.text(),
9741085
}
1086+
StructuredResolution {
1087+
valid: None,
1088+
invalid,
1089+
raw_seen,
1090+
}
1091+
}
1092+
1093+
/// UTF-8-safe truncation to at most `max` bytes (never splits a multibyte char —
1094+
/// repair prompts echo arbitrary model output, including CJK).
1095+
fn truncate_utf8(s: &str, max: usize) -> &str {
1096+
if s.len() <= max {
1097+
return s;
1098+
}
1099+
let mut end = max;
1100+
while end > 0 && !s.is_char_boundary(end) {
1101+
end -= 1;
1102+
}
1103+
&s[..end]
1104+
}
1105+
1106+
/// Repair prompt for when nothing parseable was produced at all.
1107+
fn build_parse_failure_repair(raw_text: &str) -> String {
1108+
if raw_text.trim().is_empty() {
1109+
return "Your previous response contained no JSON. Respond with ONLY a single valid JSON object that matches the schema — no prose, no markdown, no analysis, and put the object in your reply content (not in a thinking/reasoning aside).".to_string();
1110+
}
1111+
format!(
1112+
"Your previous output could not be parsed as a JSON object:\n\n{}\n\nReturn ONLY a single valid JSON object matching the schema — no prose, no markdown.",
1113+
truncate_utf8(raw_text, 2000)
1114+
)
9751115
}
9761116

9771117
fn build_repair_message(raw_text: &str, errors: &[String]) -> String {
9781118
// Truncate raw output in repair message to avoid blowing context
9791119
let truncated_raw = if raw_text.len() > 2000 {
9801120
format!(
9811121
"{}...[truncated, {} bytes total]",
982-
&raw_text[..2000],
1122+
truncate_utf8(raw_text, 2000),
9831123
raw_text.len()
9841124
)
9851125
} else {

0 commit comments

Comments
 (0)