Skip to main content

a3s_code_core/llm/
structured.rs

1//! Structured object generation from LLM output.
2//!
3//! Provides reliable JSON object generation with schema validation, automatic
4//! repair, and streaming partial object support. Works across all providers by
5//! selecting the best available mode (strict JSON schema, json_mode, tool-call,
6//! or prompt-only).
7
8use super::{LlmClient, Message, StreamEvent, TokenUsage, ToolDefinition};
9use anyhow::{bail, Context, Result};
10use serde::{Deserialize, Serialize};
11use serde_json::Value;
12use tokio_util::sync::CancellationToken;
13
14mod partial_json;
15use partial_json::parse_partial_json;
16#[cfg(test)]
17use partial_json::try_parse_partial_json;
18
19// ---------------------------------------------------------------------------
20// Public types
21// ---------------------------------------------------------------------------
22
23/// Mode selection for structured output generation.
24#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
25#[serde(rename_all = "snake_case")]
26pub enum StructuredMode {
27    /// Auto-select best mode based on provider capabilities.
28    Auto,
29    /// OpenAI native strict JSON schema (response_format.type = json_schema).
30    Strict,
31    /// OpenAI json_object mode (guarantees valid JSON, not schema-conformant).
32    Json,
33    /// Use tool-calling: inject a synthetic tool whose parameters IS the schema.
34    /// Works on all providers that support tool use (Anthropic, OpenAI, etc).
35    Tool,
36    /// Prompt-only: append schema instructions to the prompt. Least reliable.
37    Prompt,
38}
39
40/// Request specification for structured object generation.
41#[derive(Debug, Clone)]
42pub struct StructuredRequest {
43    pub prompt: String,
44    pub system: Option<String>,
45    pub schema: Value,
46    pub schema_name: String,
47    pub schema_description: Option<String>,
48    pub mode: StructuredMode,
49    pub max_repair_attempts: u8,
50}
51
52/// Result of a successful structured generation.
53#[derive(Debug, Clone, Serialize)]
54pub struct StructuredResult {
55    pub object: Value,
56    pub raw_text: Option<String>,
57    pub usage: TokenUsage,
58    pub repair_rounds: u8,
59    pub mode_used: StructuredMode,
60}
61
62/// Provider-native structured-output capability.
63///
64/// Each [`LlmClient`] reports this so the structured engine can request the
65/// strongest enforcement the provider actually supports. Defaults to
66/// [`NativeStructuredSupport::None`] for clients that don't override it.
67#[derive(Debug, Clone, Copy, PartialEq, Eq)]
68pub enum NativeStructuredSupport {
69    /// No native enforcement — rely on prompt instructions + lenient extraction.
70    None,
71    /// Can force a specific tool call (Anthropic `tool_choice`, OpenAI function
72    /// `tool_choice`). Guarantees the model emits the structured tool call
73    /// instead of free-form prose.
74    ForcedTool,
75    /// Supports OpenAI-style `response_format` (`json_object` and
76    /// `json_schema` + `strict`) in addition to forced tool calls.
77    JsonSchema,
78}
79
80/// A native `response_format` request for OpenAI-compatible providers.
81#[derive(Debug, Clone, PartialEq)]
82pub enum ResponseFormat {
83    /// `{"type":"json_object"}` — guarantees syntactically valid JSON, but not
84    /// schema conformance.
85    JsonObject,
86    /// `{"type":"json_schema","json_schema":{name,schema,strict:true}}` —
87    /// parser-enforced schema conformance.
88    JsonSchema { name: String, schema: Value },
89}
90
91/// Instruction telling a provider how to enforce structured output for a call.
92///
93/// Carries the union of intents; each provider honors what it supports and
94/// ignores the rest (e.g. Anthropic has no `response_format`, so it only acts
95/// on `force_tool`). The default (`force_tool: None, response_format: None`)
96/// reproduces an ordinary completion, which is why the trait's default
97/// `complete_structured` impl is behavior-preserving.
98#[derive(Debug, Clone, Default, PartialEq)]
99pub struct StructuredDirective {
100    /// Force the model to call exactly this tool (provider `tool_choice`).
101    pub force_tool: Option<String>,
102    /// Request a provider-native `response_format` (OpenAI-compatible only).
103    pub response_format: Option<ResponseFormat>,
104}
105
106/// Callback for streaming partial object snapshots.
107pub type PartialObjectCallback = Box<dyn Fn(&Value) + Send>;
108
109/// Provider-facing schema envelope.
110///
111/// Function/tool parameters are most reliable when the top-level schema is an
112/// object. Inspired by Vercel AI SDK's `Output.array` / `Output.choice`
113/// wrappers, A3S sends top-level arrays and scalar schemas inside a small object
114/// envelope, then unwraps the validated value before returning it to callers.
115#[derive(Debug, Clone, Copy, PartialEq, Eq)]
116enum SchemaEnvelope {
117    Direct,
118    Elements,
119    Value,
120}
121
122impl SchemaEnvelope {
123    fn for_schema(schema: &Value) -> Self {
124        if schema_is_object_like(schema) {
125            Self::Direct
126        } else if schema.get("type").and_then(Value::as_str) == Some("array") {
127            Self::Elements
128        } else {
129            Self::Value
130        }
131    }
132
133    fn response_schema(self, schema: &Value) -> Value {
134        match self {
135            Self::Direct => schema.clone(),
136            Self::Elements => serde_json::json!({
137                "type": "object",
138                "required": ["elements"],
139                "additionalProperties": false,
140                "properties": {
141                    "elements": schema
142                }
143            }),
144            Self::Value => serde_json::json!({
145                "type": "object",
146                "required": ["value"],
147                "additionalProperties": false,
148                "properties": {
149                    "value": schema
150                }
151            }),
152        }
153    }
154
155    fn unwrap_final(self, value: &Value) -> Option<Value> {
156        match self {
157            Self::Direct => Some(value.clone()),
158            Self::Elements => value.get("elements").cloned(),
159            Self::Value => value.get("value").cloned(),
160        }
161    }
162
163    fn project_partial(self, value: &Value, repaired: bool) -> Option<Value> {
164        match self {
165            Self::Direct => Some(value.clone()),
166            Self::Elements => {
167                let mut elements = value.get("elements")?.as_array()?.clone();
168                // A repaired parse may include a synthetic last element that was
169                // closed only so the partial JSON can parse. Match Vercel's
170                // array streaming behavior: publish only completed elements.
171                if repaired && !elements.is_empty() {
172                    elements.pop();
173                }
174                Some(Value::Array(elements))
175            }
176            Self::Value => value.get("value").cloned(),
177        }
178    }
179
180    fn instruction(self) -> &'static str {
181        match self {
182            Self::Direct => "",
183            Self::Elements => {
184                "The provider-facing response schema wraps the requested array in an `elements` field. Follow that schema exactly; callers receive the unwrapped array."
185            }
186            Self::Value => {
187                "The provider-facing response schema wraps the requested scalar/enum value in a `value` field. Follow that schema exactly; callers receive the unwrapped value."
188            }
189        }
190    }
191}
192
193fn schema_is_object_like(schema: &Value) -> bool {
194    schema.get("type").and_then(Value::as_str) == Some("object")
195        || schema.get("properties").is_some()
196        || schema.get("required").is_some()
197        || schema.get("additionalProperties").is_some()
198}
199
200// ---------------------------------------------------------------------------
201// Core generation: blocking (non-streaming)
202// ---------------------------------------------------------------------------
203
204/// Generate a structured JSON object using the given LLM client.
205///
206/// Selects the best mode based on `req.mode`, calls the LLM, validates against
207/// the schema, and retries with repair prompts if validation fails.
208pub async fn generate_blocking(
209    client: &dyn LlmClient,
210    req: &StructuredRequest,
211) -> Result<StructuredResult> {
212    let mode = resolve_mode(req.mode, client.native_structured_support());
213    let envelope = SchemaEnvelope::for_schema(&req.schema);
214    let mut messages = build_initial_messages(req, mode);
215    let system = build_system_prompt(req, mode);
216    let tools = build_tools(req, mode);
217    let directive = build_directive(req, mode);
218
219    let mut total_usage = TokenUsage::default();
220    let mut repair_rounds: u8 = 0;
221
222    loop {
223        let resp = client
224            .complete_structured(&messages, Some(&system), &tools, &directive)
225            .await
226            .context("LLM call failed during structured generation")?;
227
228        accumulate_usage(&mut total_usage, &resp.usage);
229
230        // Mine the object from every place a model might have parked it (tool call,
231        // text content, AND the reasoning channel), trying each balanced JSON
232        // candidate against the schema. Reasoning models routinely leave `content`
233        // empty and emit the object inside `reasoning`, so without the reasoning
234        // fallback generate_object failed with "no structured output" across models.
235        let candidates = extract_raw_candidates(&resp.message, mode);
236        let resolution = resolve_structured(&candidates, &req.schema, envelope);
237
238        if let Some((value, raw)) = resolution.valid {
239            return Ok(StructuredResult {
240                object: value,
241                raw_text: Some(raw),
242                usage: total_usage,
243                repair_rounds,
244                mode_used: mode,
245            });
246        }
247
248        if repair_rounds >= req.max_repair_attempts {
249            return Err(match resolution.invalid {
250                Some((_, errors)) => anyhow::anyhow!(
251                    "Structured output failed schema validation after {} repair attempts. Errors: {}",
252                    repair_rounds,
253                    errors.join("; ")
254                ),
255                None => anyhow::anyhow!(
256                    "Structured output parsing failed after {} repair attempts: no JSON object found in tool call, text content, or reasoning channel",
257                    repair_rounds
258                ),
259            });
260        }
261
262        repair_rounds += 1;
263        let (repair_msg, raw_for_ctx) = match resolution.invalid {
264            Some((raw, errors)) => (build_repair_message(&raw, &errors), raw),
265            None => {
266                let raw = resolution.raw_seen.unwrap_or_default();
267                (build_parse_failure_repair(&raw), raw)
268            }
269        };
270        append_repair_context(
271            &mut messages,
272            &resp.message,
273            &repair_msg,
274            mode,
275            &raw_for_ctx,
276        );
277    }
278}
279
280// ---------------------------------------------------------------------------
281// Core generation: streaming
282// ---------------------------------------------------------------------------
283
284/// Generate a structured JSON object with streaming partial updates.
285///
286/// Calls `on_partial` with progressively more complete partial objects as tokens
287/// arrive. Returns the final validated object.
288///
289/// A streamed first attempt may be followed by bounded non-streaming repair
290/// calls when `max_repair_attempts` is non-zero. Repair calls publish only the
291/// final corrected object, avoiding a second misleading partial stream.
292pub async fn generate_streaming(
293    client: &dyn LlmClient,
294    req: &StructuredRequest,
295    on_partial: PartialObjectCallback,
296) -> Result<StructuredResult> {
297    let mode = resolve_mode(req.mode, client.native_structured_support());
298    let envelope = SchemaEnvelope::for_schema(&req.schema);
299    let mut messages = build_initial_messages(req, mode);
300    let system = build_system_prompt(req, mode);
301    let tools = build_tools(req, mode);
302    let directive = build_directive(req, mode);
303
304    let cancel_token = CancellationToken::new();
305    let mut rx = client
306        .complete_streaming_structured(&messages, Some(&system), &tools, &directive, cancel_token)
307        .await
308        .context("LLM streaming call failed during structured generation")?;
309
310    let mut json_buffer = String::new();
311    let mut last_valid_partial: Option<Value> = None;
312    let mut final_response: Option<super::LlmResponse> = None;
313    let mut last_parse_len: usize = 0;
314    // Minimum bytes of new data before attempting a partial parse (reduces CPU)
315    const PARSE_THRESHOLD: usize = 8;
316
317    while let Some(event) = rx.recv().await {
318        match event {
319            StreamEvent::ToolUseInputDelta { delta, .. } if mode == StructuredMode::Tool => {
320                if final_response.is_some() {
321                    continue;
322                }
323                json_buffer.push_str(&delta);
324                if json_buffer.len() - last_parse_len >= PARSE_THRESHOLD {
325                    if let Some(partial) = parse_partial_json(&json_buffer) {
326                        if let Some(projected) =
327                            envelope.project_partial(&partial.value, partial.repaired)
328                        {
329                            if last_valid_partial.as_ref() != Some(&projected) {
330                                on_partial(&projected);
331                                last_valid_partial = Some(projected);
332                            }
333                        }
334                    }
335                    last_parse_len = json_buffer.len();
336                }
337            }
338            StreamEvent::TextDelta(delta) if mode != StructuredMode::Tool => {
339                if final_response.is_some() {
340                    continue;
341                }
342                json_buffer.push_str(&delta);
343                if json_buffer.len() - last_parse_len >= PARSE_THRESHOLD {
344                    if let Some(json_start) = find_json_start(&json_buffer) {
345                        let candidate = &json_buffer[json_start..];
346                        if let Some(partial) = parse_partial_json(candidate) {
347                            if let Some(projected) =
348                                envelope.project_partial(&partial.value, partial.repaired)
349                            {
350                                if last_valid_partial.as_ref() != Some(&projected) {
351                                    on_partial(&projected);
352                                    last_valid_partial = Some(projected);
353                                }
354                            }
355                        }
356                    }
357                    last_parse_len = json_buffer.len();
358                }
359            }
360            StreamEvent::Done(resp) => {
361                final_response = Some(resp);
362            }
363            _ => {}
364        }
365    }
366
367    let mut resp = final_response.context("Stream ended without Done event")?;
368    let mut total_usage = TokenUsage::default();
369    accumulate_usage(&mut total_usage, &resp.usage);
370    let mut repair_rounds = 0u8;
371    // Same multi-source resolution as the blocking path: the final message may carry
372    // the object in the tool call, the text content, or the reasoning channel.
373    let mut resolution = resolve_structured(
374        &extract_raw_candidates(&resp.message, mode),
375        &req.schema,
376        envelope,
377    );
378    let (value, raw_text) = loop {
379        if let Some(valid) = resolution.valid.take() {
380            break valid;
381        }
382
383        if repair_rounds >= req.max_repair_attempts {
384            return Err(match resolution.invalid {
385                Some((_, errors)) => anyhow::anyhow!(
386                    "Streamed structured output failed schema validation after {} repair attempts: {}",
387                    repair_rounds,
388                    errors.join("; ")
389                ),
390                None => anyhow::anyhow!(
391                    "Streamed output produced no parseable JSON object after {} repair attempts (checked tool call, text content, and reasoning channel)",
392                    repair_rounds
393                ),
394            });
395        }
396
397        repair_rounds += 1;
398        let (repair_message, raw_for_context) = match resolution.invalid.take() {
399            Some((raw, errors)) => (build_repair_message(&raw, &errors), raw),
400            None => {
401                let raw = resolution.raw_seen.take().unwrap_or_default();
402                (build_parse_failure_repair(&raw), raw)
403            }
404        };
405        append_repair_context(
406            &mut messages,
407            &resp.message,
408            &repair_message,
409            mode,
410            &raw_for_context,
411        );
412        resp = client
413            .complete_structured(&messages, Some(&system), &tools, &directive)
414            .await
415            .context("LLM call failed while repairing streamed structured output")?;
416        accumulate_usage(&mut total_usage, &resp.usage);
417        resolution = resolve_structured(
418            &extract_raw_candidates(&resp.message, mode),
419            &req.schema,
420            envelope,
421        );
422    };
423
424    // Emit final complete object
425    on_partial(&value);
426
427    Ok(StructuredResult {
428        object: value,
429        raw_text: Some(raw_text),
430        usage: total_usage,
431        repair_rounds,
432        mode_used: mode,
433    })
434}
435
436// ---------------------------------------------------------------------------
437// JSON extraction and parsing
438// ---------------------------------------------------------------------------
439
440/// Extract a JSON value from potentially dirty LLM output.
441///
442/// Handles: raw JSON, markdown code fences, leading/trailing prose.
443pub fn extract_json_value(text: &str) -> Result<Value> {
444    let trimmed = text.trim();
445
446    // 1. Direct parse
447    if let Ok(v) = serde_json::from_str::<Value>(trimmed) {
448        if v.is_object() || v.is_array() {
449            return Ok(v);
450        }
451    }
452
453    // 2. Strip markdown code fence
454    if let Some(inner) = strip_code_fence(trimmed) {
455        if let Ok(v) = serde_json::from_str::<Value>(inner.trim()) {
456            if v.is_object() || v.is_array() {
457                return Ok(v);
458            }
459        }
460    }
461
462    // 3. Find balanced JSON substring (first { to matching })
463    if let Some(candidate) = find_balanced_json_object(trimmed) {
464        if let Ok(v) = serde_json::from_str::<Value>(candidate) {
465            return Ok(v);
466        }
467    }
468
469    // 4. Try array
470    if let Some(candidate) = find_balanced_json_array(trimmed) {
471        if let Ok(v) = serde_json::from_str::<Value>(candidate) {
472            return Ok(v);
473        }
474    }
475
476    bail!("No valid JSON object found in LLM output")
477}
478
479/// Strip ```json ... ``` or ``` ... ``` fences.
480fn strip_code_fence(text: &str) -> Option<&str> {
481    let start_patterns = ["```json\n", "```json\r\n", "```\n", "```\r\n"];
482    for pat in &start_patterns {
483        if let Some(rest) = text.strip_prefix(pat) {
484            // Find closing fence
485            if let Some(end) = rest.rfind("```") {
486                return Some(&rest[..end]);
487            }
488        }
489    }
490    // Also handle inline: ```json{...}```
491    if let Some(inner) = text.strip_prefix("```json") {
492        if let Some(end) = inner.rfind("```") {
493            return Some(inner[..end].trim());
494        }
495    }
496    if let Some(inner) = text.strip_prefix("```") {
497        if let Some(end) = inner.rfind("```") {
498            return Some(inner[..end].trim());
499        }
500    }
501    None
502}
503
504/// Find the first balanced `{...}` substring using bracket counting.
505fn find_balanced_json_object(text: &str) -> Option<&str> {
506    find_balanced(text, '{', '}')
507}
508
509/// Find the first balanced `[...]` substring.
510fn find_balanced_json_array(text: &str) -> Option<&str> {
511    find_balanced(text, '[', ']')
512}
513
514fn find_balanced(text: &str, open: char, close: char) -> Option<&str> {
515    find_balanced_range(text, open, close).map(|(start, end)| &text[start..end])
516}
517
518/// Byte range `[start, end)` of the first balanced `open..close` substring (quote-aware).
519fn find_balanced_range(text: &str, open: char, close: char) -> Option<(usize, usize)> {
520    let bytes = text.as_bytes();
521    let open_byte = open as u8;
522    let close_byte = close as u8;
523
524    // Find the first unquoted occurrence of `open`
525    let mut in_string = false;
526    let mut escape_next = false;
527    let mut start = None;
528
529    for (i, &b) in bytes.iter().enumerate() {
530        if escape_next {
531            escape_next = false;
532            continue;
533        }
534        match b {
535            b'\\' if in_string => escape_next = true,
536            b'"' => in_string = !in_string,
537            _ if in_string => {}
538            _ if b == open_byte => {
539                start = Some(i);
540                break;
541            }
542            _ => {}
543        }
544    }
545
546    let start = start?;
547    let mut depth = 0i32;
548    in_string = false;
549    escape_next = false;
550
551    for (i, &b) in bytes[start..].iter().enumerate() {
552        if escape_next {
553            escape_next = false;
554            continue;
555        }
556        match b {
557            b'\\' if in_string => escape_next = true,
558            b'"' => in_string = !in_string,
559            _ if in_string => {}
560            _ if b == open_byte => depth += 1,
561            _ if b == close_byte => {
562                depth -= 1;
563                if depth == 0 {
564                    return Some((start, start + i + 1));
565                }
566            }
567            _ => {}
568        }
569    }
570    None
571}
572
573/// Every top-level balanced `open..close` substring, in document order.
574///
575/// Reasoning traces often contain several objects (worked examples, partial drafts)
576/// before the final answer, so callers validate each against the schema and keep the
577/// one that fits rather than blindly trusting the first `{...}`.
578fn find_all_balanced(text: &str, open: char, close: char) -> Vec<String> {
579    let mut out = Vec::new();
580    let mut base = 0usize;
581    while base < text.len() {
582        match find_balanced_range(&text[base..], open, close) {
583            Some((start, end)) => {
584                out.push(text[base + start..base + end].to_string());
585                base += end;
586            }
587            None => break,
588        }
589    }
590    out
591}
592
593/// Find the byte offset where JSON content starts in a text stream.
594/// Skips leading prose/whitespace to find `{` or `[` that isn't inside a string.
595fn find_json_start(text: &str) -> Option<usize> {
596    // Skip past code fence markers if present
597    let (search_text, offset) = if let Some(rest) = text.strip_prefix("```json") {
598        (rest, 7)
599    } else if let Some(rest) = text.strip_prefix("```") {
600        (rest, 3)
601    } else {
602        (text, 0)
603    };
604
605    let mut in_string = false;
606    let mut escape_next = false;
607    for (i, &b) in search_text.as_bytes().iter().enumerate() {
608        if escape_next {
609            escape_next = false;
610            continue;
611        }
612        match b {
613            b'\\' if in_string => {
614                escape_next = true;
615            }
616            b'"' => {
617                in_string = !in_string;
618            }
619            b'{' | b'[' if !in_string => {
620                return Some(offset + i);
621            }
622            _ => {}
623        }
624    }
625    None
626}
627
628// ---------------------------------------------------------------------------
629// Schema validation
630// ---------------------------------------------------------------------------
631
632/// Validate a JSON value against a JSON Schema.
633/// Returns Ok(()) on success, or a list of human-readable error strings.
634fn validate_against_schema(value: &Value, schema: &Value) -> Result<(), Vec<String>> {
635    // Structured-output schemas are host/model input, so compilation is kept
636    // entirely in-memory: the dependency is built without HTTP/file resolvers.
637    // Local `$ref` / `$defs`, composition keywords, conditional schemas, and
638    // exact `oneOf` semantics are handled by the standards-compliant validator.
639    let validator = jsonschema::draft202012::options()
640        .build(schema)
641        .map_err(|error| vec![format!("invalid JSON Schema: {error}")])?;
642    let errors = validator
643        .iter_errors(value)
644        .map(|error| {
645            let path = error.instance_path().to_string();
646            if path.is_empty() {
647                format!("$: {error}")
648            } else {
649                format!("{path}: {error}")
650            }
651        })
652        .collect::<Vec<_>>();
653    if errors.is_empty() {
654        Ok(())
655    } else {
656        Err(errors)
657    }
658}
659
660// ---------------------------------------------------------------------------
661// Message/prompt construction helpers
662// ---------------------------------------------------------------------------
663
664/// Resolve the requested mode against the provider's native capability.
665///
666/// Prefer native enforcement only when the client explicitly reports support.
667/// Unknown OpenAI-compatible endpoints can hang when sent `tool_choice` or
668/// `response_format`, so unsupported requests degrade to prompt+schema parsing
669/// instead of optimistic native parameters.
670fn resolve_mode(requested: StructuredMode, support: NativeStructuredSupport) -> StructuredMode {
671    match (requested, support) {
672        (StructuredMode::Prompt, _) => StructuredMode::Prompt,
673        (StructuredMode::Strict, NativeStructuredSupport::JsonSchema) => StructuredMode::Strict,
674        (StructuredMode::Json, NativeStructuredSupport::JsonSchema) => StructuredMode::Json,
675        (StructuredMode::Auto | StructuredMode::Tool, NativeStructuredSupport::JsonSchema) => {
676            StructuredMode::Tool
677        }
678        (
679            StructuredMode::Auto
680            | StructuredMode::Tool
681            | StructuredMode::Strict
682            | StructuredMode::Json,
683            NativeStructuredSupport::ForcedTool,
684        ) => StructuredMode::Tool,
685        (
686            StructuredMode::Auto
687            | StructuredMode::Tool
688            | StructuredMode::Strict
689            | StructuredMode::Json,
690            NativeStructuredSupport::None,
691        ) => StructuredMode::Prompt,
692    }
693}
694
695/// Build the provider directive for an already-resolved mode.
696fn build_directive(req: &StructuredRequest, mode: StructuredMode) -> StructuredDirective {
697    match mode {
698        StructuredMode::Tool => StructuredDirective {
699            force_tool: Some(format!("emit_{}", req.schema_name)),
700            response_format: None,
701        },
702        StructuredMode::Strict => StructuredDirective {
703            force_tool: None,
704            response_format: Some(ResponseFormat::JsonSchema {
705                name: req.schema_name.clone(),
706                schema: SchemaEnvelope::for_schema(&req.schema).response_schema(&req.schema),
707            }),
708        },
709        StructuredMode::Json => StructuredDirective {
710            force_tool: None,
711            response_format: Some(ResponseFormat::JsonObject),
712        },
713        StructuredMode::Auto | StructuredMode::Prompt => StructuredDirective::default(),
714    }
715}
716
717fn build_initial_messages(req: &StructuredRequest, mode: StructuredMode) -> Vec<Message> {
718    let envelope = SchemaEnvelope::for_schema(&req.schema);
719    let response_schema = envelope.response_schema(&req.schema);
720    let envelope_instruction = envelope.instruction();
721    match mode {
722        StructuredMode::Tool => {
723            // For tool mode, the prompt is the user message; the LLM will respond
724            // with a tool call whose input is the structured object.
725            vec![Message::user(&req.prompt)]
726        }
727        StructuredMode::Prompt | StructuredMode::Json => {
728            // Prompt mode and json_object mode both need the schema in the prompt:
729            // json_object only guarantees *syntactic* validity, so the model still
730            // has to be told the shape it should produce.
731            let augmented = format!(
732                "{}\n\n{}{}\n\nYou MUST respond with ONLY a valid JSON object (no markdown, no explanation) that conforms to this JSON Schema:\n\n```json\n{}\n```",
733                req.prompt,
734                envelope_instruction,
735                if envelope_instruction.is_empty() { "" } else { "\n" },
736                serde_json::to_string_pretty(&response_schema).unwrap_or_default()
737            );
738            vec![Message::user(&augmented)]
739        }
740        _ => {
741            // Strict mode: the schema constraint is enforced by the provider via
742            // response_format.json_schema, so the user message is just the prompt.
743            vec![Message::user(&req.prompt)]
744        }
745    }
746}
747
748fn build_system_prompt(req: &StructuredRequest, mode: StructuredMode) -> String {
749    let base = req.system.as_deref().unwrap_or("");
750    let envelope_instruction = SchemaEnvelope::for_schema(&req.schema).instruction();
751
752    match mode {
753        StructuredMode::Tool => {
754            format!(
755                "{}{}You MUST respond by calling the `emit_{}` tool exactly once with a valid argument matching the schema. Do not output any text outside the tool call.{}{}",
756                base,
757                if base.is_empty() { "" } else { "\n\n" },
758                req.schema_name,
759                if envelope_instruction.is_empty() { "" } else { "\n\n" },
760                envelope_instruction
761            )
762        }
763        StructuredMode::Prompt | StructuredMode::Json => {
764            format!(
765                "{}{}You are a structured data extraction assistant. Always respond with valid JSON only, no markdown fences, no explanation text.{}{}",
766                base,
767                if base.is_empty() { "" } else { "\n\n" },
768                if envelope_instruction.is_empty() { "" } else { "\n\n" },
769                envelope_instruction,
770            )
771        }
772        _ => base.to_string(),
773    }
774}
775
776fn build_tools(req: &StructuredRequest, mode: StructuredMode) -> Vec<ToolDefinition> {
777    match mode {
778        StructuredMode::Tool => {
779            vec![ToolDefinition {
780                name: format!("emit_{}", req.schema_name),
781                description: req
782                    .schema_description
783                    .clone()
784                    .unwrap_or_else(|| format!("Emit a structured {} object", req.schema_name)),
785                parameters: SchemaEnvelope::for_schema(&req.schema).response_schema(&req.schema),
786            }]
787        }
788        _ => vec![],
789    }
790}
791
792/// Outcome of mining a response for the structured object across all candidate sources.
793struct StructuredResolution {
794    /// A schema-valid object plus the raw source string it came from.
795    valid: Option<(Value, String)>,
796    /// First parseable-but-schema-invalid object source + its validation errors,
797    /// used to build a targeted repair prompt.
798    invalid: Option<(String, Vec<String>)>,
799    /// First non-empty raw candidate, shown verbatim in a parse-failure repair prompt.
800    raw_seen: Option<String>,
801}
802
803/// Append `s` to `out` if it is non-empty and not already present (trimmed, deduped).
804fn push_candidate(out: &mut Vec<String>, s: String) {
805    let trimmed = s.trim();
806    if !trimmed.is_empty() && !out.iter().any(|c| c == trimmed) {
807        out.push(trimmed.to_string());
808    }
809}
810
811/// Ordered raw strings to mine for the structured object, most authoritative first:
812/// tool-call arguments, then text content, then the reasoning channel.
813///
814/// The reasoning fallback is the crux of the cross-model fix: reasoning models
815/// (GLM/zhipu, DeepSeek-R1, kimi…) frequently emit the final object inside
816/// `reasoning` with `content` empty and no tool call. Earlier extraction only looked
817/// at the tool call / text, so those models yielded an empty string and the whole
818/// generate_object failed even though a perfectly good object was produced.
819fn extract_raw_candidates(message: &super::Message, mode: StructuredMode) -> Vec<String> {
820    let mut out: Vec<String> = Vec::new();
821    if mode == StructuredMode::Tool {
822        if let Some(call) = message.tool_calls().first() {
823            push_candidate(
824                &mut out,
825                serde_json::to_string(&call.args).unwrap_or_default(),
826            );
827        }
828    }
829    push_candidate(&mut out, message.text());
830    if let Some(reasoning) = message.reasoning_content.as_deref() {
831        push_candidate(&mut out, reasoning.to_string());
832    }
833    out
834}
835
836/// Every JSON object/array value mineable from possibly-dirty text, in document order
837/// (direct parse, code fences, then all balanced `{...}` / `[...]`). Deduped.
838#[cfg(test)]
839fn extract_all_json_values(text: &str) -> Vec<Value> {
840    extract_json_candidates(text, false)
841}
842
843/// Every JSON value mineable from possibly-dirty text for schema-aware structured
844/// resolution. When `include_direct_scalars` is true, direct raw/fenced scalar JSON
845/// is retained so top-level scalar schemas can recover non-enveloped model output.
846fn extract_json_candidates(text: &str, include_direct_scalars: bool) -> Vec<Value> {
847    let trimmed = text.trim();
848    let mut values: Vec<Value> = Vec::new();
849    let consider = |candidate: &str, values: &mut Vec<Value>, allow_scalar: bool| {
850        if let Ok(v) = serde_json::from_str::<Value>(candidate.trim()) {
851            if (v.is_object() || v.is_array() || allow_scalar) && !values.contains(&v) {
852                values.push(v);
853            }
854        }
855    };
856    consider(trimmed, &mut values, include_direct_scalars);
857    if let Some(inner) = strip_code_fence(trimmed) {
858        consider(inner, &mut values, include_direct_scalars);
859    }
860    for candidate in find_all_balanced(trimmed, '{', '}') {
861        consider(&candidate, &mut values, false);
862    }
863    for candidate in find_all_balanced(trimmed, '[', ']') {
864        consider(&candidate, &mut values, false);
865    }
866    values
867}
868
869/// Try every raw candidate × every JSON value it yields against the schema; return the
870/// first schema-valid value, else the best parseable-but-invalid value (for repair).
871fn resolve_structured(
872    candidates: &[String],
873    schema: &Value,
874    envelope: SchemaEnvelope,
875) -> StructuredResolution {
876    let mut invalid: Option<(String, Vec<String>)> = None;
877    let mut raw_seen: Option<String> = None;
878    let response_schema = envelope.response_schema(schema);
879    for raw in candidates {
880        if raw_seen.is_none() && !raw.trim().is_empty() {
881            raw_seen = Some(raw.clone());
882        }
883        for value in extract_json_candidates(raw, envelope == SchemaEnvelope::Value) {
884            match validate_against_schema(&value, schema) {
885                Ok(()) => {
886                    return StructuredResolution {
887                        valid: Some((value, raw.clone())),
888                        invalid,
889                        raw_seen,
890                    };
891                }
892                Err(errors) => {
893                    if invalid.is_none() {
894                        invalid = Some((raw.clone(), errors));
895                    }
896                }
897            }
898
899            if envelope != SchemaEnvelope::Direct {
900                match validate_against_schema(&value, &response_schema) {
901                    Ok(()) => {
902                        if let Some(unwrapped) = envelope.unwrap_final(&value) {
903                            match validate_against_schema(&unwrapped, schema) {
904                                Ok(()) => {
905                                    return StructuredResolution {
906                                        valid: Some((unwrapped, raw.clone())),
907                                        invalid,
908                                        raw_seen,
909                                    };
910                                }
911                                Err(errors) => {
912                                    if invalid.is_none() {
913                                        invalid = Some((raw.clone(), errors));
914                                    }
915                                }
916                            }
917                        } else if invalid.is_none() {
918                            invalid = Some((
919                                raw.clone(),
920                                vec!["$: response envelope was missing the expected value field"
921                                    .to_string()],
922                            ));
923                        }
924                    }
925                    Err(errors) => {
926                        if invalid.is_none() {
927                            invalid = Some((raw.clone(), errors));
928                        }
929                    }
930                }
931            }
932        }
933    }
934    StructuredResolution {
935        valid: None,
936        invalid,
937        raw_seen,
938    }
939}
940
941/// Extract the first JSON value from possibly dirty model text that validates
942/// against `schema`.
943///
944/// This is the local fast path for callers that already asked an agent to
945/// produce structured output. It accepts direct JSON, fenced JSON, and a
946/// balanced JSON value embedded in prose, while preserving the same schema
947/// and envelope semantics used by [`generate_blocking`]. Callers can fall back
948/// to an LLM repair pass only when this returns `None`.
949pub(crate) fn parse_validated_output(text: &str, schema: &Value) -> Option<Value> {
950    resolve_structured(
951        &[text.to_string()],
952        schema,
953        SchemaEnvelope::for_schema(schema),
954    )
955    .valid
956    .map(|(value, _)| value)
957}
958
959/// UTF-8-safe truncation to at most `max` bytes (never splits a multibyte char —
960/// repair prompts echo arbitrary model output, including CJK).
961fn truncate_utf8(s: &str, max: usize) -> &str {
962    if s.len() <= max {
963        return s;
964    }
965    let mut end = max;
966    while end > 0 && !s.is_char_boundary(end) {
967        end -= 1;
968    }
969    &s[..end]
970}
971
972/// Repair prompt for when nothing parseable was produced at all.
973fn build_parse_failure_repair(raw_text: &str) -> String {
974    if raw_text.trim().is_empty() {
975        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();
976    }
977    format!(
978        "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.",
979        truncate_utf8(raw_text, 2000)
980    )
981}
982
983fn build_repair_message(raw_text: &str, errors: &[String]) -> String {
984    // Truncate raw output in repair message to avoid blowing context
985    let truncated_raw = if raw_text.len() > 2000 {
986        format!(
987            "{}...[truncated, {} bytes total]",
988            truncate_utf8(raw_text, 2000),
989            raw_text.len()
990        )
991    } else {
992        raw_text.to_string()
993    };
994    format!(
995        "Your previous output failed schema validation:\n\n{}\n\nValidation errors:\n{}\n\nPlease return ONLY a corrected JSON object that fixes these errors. No explanation, no markdown.",
996        truncated_raw,
997        errors.iter().map(|e| format!("- {}", e)).collect::<Vec<_>>().join("\n")
998    )
999}
1000
1001fn accumulate_usage(total: &mut TokenUsage, delta: &TokenUsage) {
1002    total.prompt_tokens += delta.prompt_tokens;
1003    total.completion_tokens += delta.completion_tokens;
1004    total.total_tokens += delta.total_tokens;
1005}
1006
1007/// Append repair context to the message history, respecting conversation structure.
1008///
1009/// In tool mode, the LLM returned a tool_use block. The correct follow-up is:
1010///   assistant (tool_use) → user (tool_result with error) → assistant (retry)
1011/// In text modes, it's simply:
1012///   assistant (text) → user (repair request) → assistant (retry)
1013fn append_repair_context(
1014    messages: &mut Vec<Message>,
1015    assistant_msg: &Message,
1016    repair_text: &str,
1017    mode: StructuredMode,
1018    _raw_text: &str,
1019) {
1020    if mode == StructuredMode::Tool {
1021        // Push the original assistant message (with tool_use block intact)
1022        messages.push(assistant_msg.clone());
1023        // Find the tool_use ID to construct a proper tool_result
1024        let tool_use_id = assistant_msg
1025            .tool_calls()
1026            .first()
1027            .map(|tc| tc.id.clone())
1028            .unwrap_or_else(|| "unknown".to_string());
1029        // Return the error as a tool_result so the conversation stays valid
1030        messages.push(Message::tool_result(&tool_use_id, repair_text, true));
1031    } else {
1032        // Text modes: push assistant text then user repair request
1033        messages.push(assistant_msg.clone());
1034        messages.push(Message::user(repair_text));
1035    }
1036}
1037
1038// ---------------------------------------------------------------------------
1039// Tests
1040// ---------------------------------------------------------------------------
1041
1042#[cfg(test)]
1043#[path = "structured_tests.rs"]
1044mod structured_tests;