1use 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
14#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
20#[serde(rename_all = "snake_case")]
21pub enum StructuredMode {
22 Auto,
24 Strict,
26 Json,
28 Tool,
31 Prompt,
33}
34
35#[derive(Debug, Clone)]
37pub struct StructuredRequest {
38 pub prompt: String,
39 pub system: Option<String>,
40 pub schema: Value,
41 pub schema_name: String,
42 pub schema_description: Option<String>,
43 pub mode: StructuredMode,
44 pub max_repair_attempts: u8,
45}
46
47#[derive(Debug, Clone, Serialize)]
49pub struct StructuredResult {
50 pub object: Value,
51 pub raw_text: Option<String>,
52 pub usage: TokenUsage,
53 pub repair_rounds: u8,
54 pub mode_used: StructuredMode,
55}
56
57pub type PartialObjectCallback = Box<dyn Fn(&Value) + Send>;
59
60pub async fn generate_blocking(
69 client: &dyn LlmClient,
70 req: &StructuredRequest,
71) -> Result<StructuredResult> {
72 let mode = req.mode;
73 let mut messages = build_initial_messages(req, mode);
74 let system = build_system_prompt(req, mode);
75 let tools = build_tools(req, mode);
76
77 let mut total_usage = TokenUsage::default();
78 let mut repair_rounds: u8 = 0;
79
80 loop {
81 let resp = client
82 .complete(&messages, Some(&system), &tools)
83 .await
84 .context("LLM call failed during structured generation")?;
85
86 accumulate_usage(&mut total_usage, &resp.usage);
87
88 let candidates = extract_raw_candidates(&resp.message, mode);
94 let resolution = resolve_structured(&candidates, &req.schema);
95
96 if let Some((value, raw)) = resolution.valid {
97 return Ok(StructuredResult {
98 object: value,
99 raw_text: Some(raw),
100 usage: total_usage,
101 repair_rounds,
102 mode_used: mode,
103 });
104 }
105
106 if repair_rounds >= req.max_repair_attempts {
107 return Err(match resolution.invalid {
108 Some((_, errors)) => anyhow::anyhow!(
109 "Structured output failed schema validation after {} repair attempts. Errors: {}",
110 repair_rounds,
111 errors.join("; ")
112 ),
113 None => anyhow::anyhow!(
114 "Structured output parsing failed after {} repair attempts: no JSON object found in tool call, text content, or reasoning channel",
115 repair_rounds
116 ),
117 });
118 }
119
120 repair_rounds += 1;
121 let (repair_msg, raw_for_ctx) = match resolution.invalid {
122 Some((raw, errors)) => (build_repair_message(&raw, &errors), raw),
123 None => {
124 let raw = resolution.raw_seen.unwrap_or_default();
125 (build_parse_failure_repair(&raw), raw)
126 }
127 };
128 append_repair_context(
129 &mut messages,
130 &resp.message,
131 &repair_msg,
132 mode,
133 &raw_for_ctx,
134 );
135 }
136}
137
138pub async fn generate_streaming(
150 client: &dyn LlmClient,
151 req: &StructuredRequest,
152 on_partial: PartialObjectCallback,
153) -> Result<StructuredResult> {
154 let mode = req.mode;
155 let messages = build_initial_messages(req, mode);
156 let system = build_system_prompt(req, mode);
157 let tools = build_tools(req, mode);
158
159 let cancel_token = CancellationToken::new();
160 let mut rx = client
161 .complete_streaming(&messages, Some(&system), &tools, cancel_token)
162 .await
163 .context("LLM streaming call failed during structured generation")?;
164
165 let mut json_buffer = String::new();
166 let mut last_valid_partial: Option<Value> = None;
167 let mut final_response: Option<super::LlmResponse> = None;
168 let mut last_parse_len: usize = 0;
169 const PARSE_THRESHOLD: usize = 8;
171
172 while let Some(event) = rx.recv().await {
173 match event {
174 StreamEvent::ToolUseInputDelta(delta) if mode == StructuredMode::Tool => {
175 if final_response.is_some() {
176 continue;
177 }
178 json_buffer.push_str(&delta);
179 if json_buffer.len() - last_parse_len >= PARSE_THRESHOLD {
180 if let Some(partial) = try_parse_partial_json(&json_buffer) {
181 if last_valid_partial.as_ref() != Some(&partial) {
182 on_partial(&partial);
183 last_valid_partial = Some(partial);
184 }
185 }
186 last_parse_len = json_buffer.len();
187 }
188 }
189 StreamEvent::TextDelta(delta) if mode != StructuredMode::Tool => {
190 if final_response.is_some() {
191 continue;
192 }
193 json_buffer.push_str(&delta);
194 if json_buffer.len() - last_parse_len >= PARSE_THRESHOLD {
195 if let Some(json_start) = find_json_start(&json_buffer) {
196 let candidate = &json_buffer[json_start..];
197 if let Some(partial) = try_parse_partial_json(candidate) {
198 if last_valid_partial.as_ref() != Some(&partial) {
199 on_partial(&partial);
200 last_valid_partial = Some(partial);
201 }
202 }
203 }
204 last_parse_len = json_buffer.len();
205 }
206 }
207 StreamEvent::Done(resp) => {
208 final_response = Some(resp);
209 }
210 _ => {}
211 }
212 }
213
214 let resp = final_response.context("Stream ended without Done event")?;
215 let candidates = extract_raw_candidates(&resp.message, mode);
218 let resolution = resolve_structured(&candidates, &req.schema);
219 let (value, raw_text) = match resolution.valid {
220 Some(vr) => vr,
221 None => {
222 return Err(match resolution.invalid {
223 Some((_, errors)) => anyhow::anyhow!(
224 "Streamed structured output failed schema validation: {}",
225 errors.join("; ")
226 ),
227 None => anyhow::anyhow!(
228 "Streamed output produced no parseable JSON object (checked tool call, text content, and reasoning channel)"
229 ),
230 });
231 }
232 };
233
234 on_partial(&value);
236
237 Ok(StructuredResult {
238 object: value,
239 raw_text: Some(raw_text),
240 usage: resp.usage,
241 repair_rounds: 0,
242 mode_used: mode,
243 })
244}
245
246pub fn extract_json_value(text: &str) -> Result<Value> {
254 let trimmed = text.trim();
255
256 if let Ok(v) = serde_json::from_str::<Value>(trimmed) {
258 if v.is_object() || v.is_array() {
259 return Ok(v);
260 }
261 }
262
263 if let Some(inner) = strip_code_fence(trimmed) {
265 if let Ok(v) = serde_json::from_str::<Value>(inner.trim()) {
266 if v.is_object() || v.is_array() {
267 return Ok(v);
268 }
269 }
270 }
271
272 if let Some(candidate) = find_balanced_json_object(trimmed) {
274 if let Ok(v) = serde_json::from_str::<Value>(candidate) {
275 return Ok(v);
276 }
277 }
278
279 if let Some(candidate) = find_balanced_json_array(trimmed) {
281 if let Ok(v) = serde_json::from_str::<Value>(candidate) {
282 return Ok(v);
283 }
284 }
285
286 bail!("No valid JSON object found in LLM output")
287}
288
289fn strip_code_fence(text: &str) -> Option<&str> {
291 let start_patterns = ["```json\n", "```json\r\n", "```\n", "```\r\n"];
292 for pat in &start_patterns {
293 if let Some(rest) = text.strip_prefix(pat) {
294 if let Some(end) = rest.rfind("```") {
296 return Some(&rest[..end]);
297 }
298 }
299 }
300 if let Some(inner) = text.strip_prefix("```json") {
302 if let Some(end) = inner.rfind("```") {
303 return Some(inner[..end].trim());
304 }
305 }
306 if let Some(inner) = text.strip_prefix("```") {
307 if let Some(end) = inner.rfind("```") {
308 return Some(inner[..end].trim());
309 }
310 }
311 None
312}
313
314fn find_balanced_json_object(text: &str) -> Option<&str> {
316 find_balanced(text, '{', '}')
317}
318
319fn find_balanced_json_array(text: &str) -> Option<&str> {
321 find_balanced(text, '[', ']')
322}
323
324fn find_balanced(text: &str, open: char, close: char) -> Option<&str> {
325 find_balanced_range(text, open, close).map(|(start, end)| &text[start..end])
326}
327
328fn find_balanced_range(text: &str, open: char, close: char) -> Option<(usize, usize)> {
330 let bytes = text.as_bytes();
331 let open_byte = open as u8;
332 let close_byte = close as u8;
333
334 let mut in_string = false;
336 let mut escape_next = false;
337 let mut start = None;
338
339 for (i, &b) in bytes.iter().enumerate() {
340 if escape_next {
341 escape_next = false;
342 continue;
343 }
344 match b {
345 b'\\' if in_string => escape_next = true,
346 b'"' => in_string = !in_string,
347 _ if in_string => {}
348 _ if b == open_byte => {
349 start = Some(i);
350 break;
351 }
352 _ => {}
353 }
354 }
355
356 let start = start?;
357 let mut depth = 0i32;
358 in_string = false;
359 escape_next = false;
360
361 for (i, &b) in bytes[start..].iter().enumerate() {
362 if escape_next {
363 escape_next = false;
364 continue;
365 }
366 match b {
367 b'\\' if in_string => escape_next = true,
368 b'"' => in_string = !in_string,
369 _ if in_string => {}
370 _ if b == open_byte => depth += 1,
371 _ if b == close_byte => {
372 depth -= 1;
373 if depth == 0 {
374 return Some((start, start + i + 1));
375 }
376 }
377 _ => {}
378 }
379 }
380 None
381}
382
383fn find_all_balanced(text: &str, open: char, close: char) -> Vec<String> {
389 let mut out = Vec::new();
390 let mut base = 0usize;
391 while base < text.len() {
392 match find_balanced_range(&text[base..], open, close) {
393 Some((start, end)) => {
394 out.push(text[base + start..base + end].to_string());
395 base += end;
396 }
397 None => break,
398 }
399 }
400 out
401}
402
403fn find_json_start(text: &str) -> Option<usize> {
406 let (search_text, offset) = if let Some(rest) = text.strip_prefix("```json") {
408 (rest, 7)
409 } else if let Some(rest) = text.strip_prefix("```") {
410 (rest, 3)
411 } else {
412 (text, 0)
413 };
414
415 let mut in_string = false;
416 let mut escape_next = false;
417 for (i, &b) in search_text.as_bytes().iter().enumerate() {
418 if escape_next {
419 escape_next = false;
420 continue;
421 }
422 match b {
423 b'\\' if in_string => {
424 escape_next = true;
425 }
426 b'"' => {
427 in_string = !in_string;
428 }
429 b'{' | b'[' if !in_string => {
430 return Some(offset + i);
431 }
432 _ => {}
433 }
434 }
435 None
436}
437
438fn try_parse_partial_json(text: &str) -> Option<Value> {
449 let trimmed = text.trim();
450 if trimmed.is_empty() {
451 return None;
452 }
453
454 if let Ok(v) = serde_json::from_str::<Value>(trimmed) {
456 if v.is_object() || v.is_array() {
457 return Some(v);
458 }
459 }
460
461 let mut closers = Vec::new();
463 let mut in_string = false;
464 let mut escape_next = false;
465 let mut last_significant: Option<u8> = None;
467
468 for &b in trimmed.as_bytes() {
469 if escape_next {
470 escape_next = false;
471 continue;
472 }
473 match b {
474 b'\\' if in_string => {
475 escape_next = true;
476 }
477 b'"' => {
478 in_string = !in_string;
479 if !in_string {
480 last_significant = Some(b'"');
481 }
482 }
483 _ if in_string => {}
484 b'{' => {
485 closers.push(b'}');
486 last_significant = Some(b'{');
487 }
488 b'[' => {
489 closers.push(b']');
490 last_significant = Some(b'[');
491 }
492 b'}' | b']' => {
493 closers.pop();
494 last_significant = Some(b);
495 }
496 b':' | b',' => {
497 last_significant = Some(b);
498 }
499 b if !b.is_ascii_whitespace() => {
500 last_significant = Some(b);
501 }
502 _ => {}
503 }
504 }
505
506 if closers.is_empty() {
507 return None; }
509
510 let mut repaired = String::with_capacity(trimmed.len() + closers.len() + 6);
512 repaired.push_str(trimmed);
513
514 if in_string {
515 repaired.push('"');
516 last_significant = Some(b'"');
517 }
518
519 if let Some(last) = last_significant {
521 if last == b':' {
522 repaired.push_str("null");
524 } else if last == b',' {
525 if let Some(pos) = repaired.rfind(',') {
527 repaired.truncate(pos);
528 }
529 }
530 }
531
532 for &closer in closers.iter().rev() {
534 repaired.push(closer as char);
535 }
536
537 serde_json::from_str::<Value>(&repaired)
538 .ok()
539 .filter(|v| v.is_object() || v.is_array())
540}
541
542fn validate_against_schema(value: &Value, schema: &Value) -> Result<(), Vec<String>> {
549 let errors = basic_schema_validate(value, schema, "");
553 if errors.is_empty() {
554 Ok(())
555 } else {
556 Err(errors)
557 }
558}
559
560fn basic_schema_validate(value: &Value, schema: &Value, path: &str) -> Vec<String> {
562 let mut errors = Vec::new();
563
564 if schema.get("$ref").is_some() {
566 return errors;
567 }
568
569 if let Some(any_of) = schema
571 .get("anyOf")
572 .or_else(|| schema.get("oneOf"))
573 .and_then(|v| v.as_array())
574 {
575 let matched = any_of
576 .iter()
577 .any(|sub| basic_schema_validate(value, sub, path).is_empty());
578 if !matched {
579 errors.push(format!(
580 "{}: value does not match any variant in anyOf/oneOf",
581 path_or_root(path),
582 ));
583 }
584 return errors;
585 }
586
587 if let Some(enum_values) = schema.get("enum").and_then(|v| v.as_array()) {
589 if !enum_values.contains(value) {
590 errors.push(format!(
591 "{}: value {:?} not in enum {:?}",
592 path_or_root(path),
593 value,
594 enum_values
595 ));
596 }
597 return errors;
598 }
599
600 if let Some(const_val) = schema.get("const") {
602 if value != const_val {
603 errors.push(format!(
604 "{}: expected const {:?}, got {:?}",
605 path_or_root(path),
606 const_val,
607 value
608 ));
609 }
610 return errors;
611 }
612
613 if let Some(type_val) = schema.get("type") {
615 let type_ok = if let Some(type_str) = type_val.as_str() {
616 check_type(value, type_str)
617 } else if let Some(type_arr) = type_val.as_array() {
618 type_arr
619 .iter()
620 .filter_map(|t| t.as_str())
621 .any(|t| check_type(value, t))
622 } else {
623 true
624 };
625 if !type_ok {
626 errors.push(format!(
627 "{}: expected type {:?}, got {:?}",
628 path_or_root(path),
629 type_val,
630 value_type_name(value)
631 ));
632 return errors;
633 }
634 }
635
636 if let Some(obj) = value.as_object() {
638 if let Some(properties) = schema.get("properties").and_then(|v| v.as_object()) {
639 for (key, prop_schema) in properties {
640 if let Some(child_value) = obj.get(key) {
641 let child_path = if path.is_empty() {
642 format!(".{}", key)
643 } else {
644 format!("{}.{}", path, key)
645 };
646 errors.extend(basic_schema_validate(child_value, prop_schema, &child_path));
647 }
648 }
649 }
650
651 if let Some(required) = schema.get("required").and_then(|v| v.as_array()) {
652 for req_field in required {
653 if let Some(field_name) = req_field.as_str() {
654 if !obj.contains_key(field_name) {
655 errors.push(format!(
656 "{}: missing required field '{}'",
657 path_or_root(path),
658 field_name
659 ));
660 }
661 }
662 }
663 }
664
665 if schema.get("additionalProperties") == Some(&Value::Bool(false)) {
667 if let Some(properties) = schema.get("properties").and_then(|v| v.as_object()) {
668 for key in obj.keys() {
669 if !properties.contains_key(key) {
670 errors.push(format!(
671 "{}: unexpected additional property '{}'",
672 path_or_root(path),
673 key
674 ));
675 }
676 }
677 }
678 }
679 }
680
681 if let Some(arr) = value.as_array() {
683 if let Some(items_schema) = schema.get("items") {
684 for (i, item) in arr.iter().enumerate() {
685 let child_path = format!("{}[{}]", path, i);
686 errors.extend(basic_schema_validate(item, items_schema, &child_path));
687 }
688 }
689 if let Some(min) = schema.get("minItems").and_then(|v| v.as_u64()) {
690 if (arr.len() as u64) < min {
691 errors.push(format!(
692 "{}: array has {} items, minimum is {}",
693 path_or_root(path),
694 arr.len(),
695 min
696 ));
697 }
698 }
699 if let Some(max) = schema.get("maxItems").and_then(|v| v.as_u64()) {
700 if (arr.len() as u64) > max {
701 errors.push(format!(
702 "{}: array has {} items, maximum is {}",
703 path_or_root(path),
704 arr.len(),
705 max
706 ));
707 }
708 }
709 }
710
711 if let Some(s) = value.as_str() {
713 if let Some(min_len) = schema.get("minLength").and_then(|v| v.as_u64()) {
714 if (s.chars().count() as u64) < min_len {
715 errors.push(format!(
716 "{}: string length {} < minLength {}",
717 path_or_root(path),
718 s.chars().count(),
719 min_len
720 ));
721 }
722 }
723 if let Some(max_len) = schema.get("maxLength").and_then(|v| v.as_u64()) {
724 if (s.chars().count() as u64) > max_len {
725 errors.push(format!(
726 "{}: string length {} > maxLength {}",
727 path_or_root(path),
728 s.chars().count(),
729 max_len
730 ));
731 }
732 }
733 if let Some(pattern) = schema.get("pattern").and_then(|v| v.as_str()) {
734 if let Ok(re) = regex::Regex::new(pattern) {
735 if !re.is_match(s) {
736 errors.push(format!(
737 "{}: string does not match pattern '{}'",
738 path_or_root(path),
739 pattern
740 ));
741 }
742 }
743 }
744 }
745
746 if let Some(n) = value.as_f64() {
748 if let Some(min) = schema.get("minimum").and_then(|v| v.as_f64()) {
749 if n < min {
750 errors.push(format!(
751 "{}: value {} < minimum {}",
752 path_or_root(path),
753 n,
754 min
755 ));
756 }
757 }
758 if let Some(max) = schema.get("maximum").and_then(|v| v.as_f64()) {
759 if n > max {
760 errors.push(format!(
761 "{}: value {} > maximum {}",
762 path_or_root(path),
763 n,
764 max
765 ));
766 }
767 }
768 if let Some(exc_min) = schema.get("exclusiveMinimum").and_then(|v| v.as_f64()) {
769 if n <= exc_min {
770 errors.push(format!(
771 "{}: value {} <= exclusiveMinimum {}",
772 path_or_root(path),
773 n,
774 exc_min
775 ));
776 }
777 }
778 if let Some(exc_max) = schema.get("exclusiveMaximum").and_then(|v| v.as_f64()) {
779 if n >= exc_max {
780 errors.push(format!(
781 "{}: value {} >= exclusiveMaximum {}",
782 path_or_root(path),
783 n,
784 exc_max
785 ));
786 }
787 }
788 }
789
790 errors
791}
792
793fn check_type(value: &Value, type_str: &str) -> bool {
794 match type_str {
795 "object" => value.is_object(),
796 "array" => value.is_array(),
797 "string" => value.is_string(),
798 "number" => value.is_number(),
799 "integer" => {
800 value.is_i64()
801 || value.is_u64()
802 || value
803 .as_f64()
804 .map(|f| f.fract() == 0.0 && f.is_finite())
805 .unwrap_or(false)
806 }
807 "boolean" => value.is_boolean(),
808 "null" => value.is_null(),
809 _ => true,
810 }
811}
812
813fn path_or_root(path: &str) -> &str {
814 if path.is_empty() {
815 "$"
816 } else {
817 path
818 }
819}
820
821fn value_type_name(value: &Value) -> &'static str {
822 match value {
823 Value::Null => "null",
824 Value::Bool(_) => "boolean",
825 Value::Number(_) => "number",
826 Value::String(_) => "string",
827 Value::Array(_) => "array",
828 Value::Object(_) => "object",
829 }
830}
831
832fn build_initial_messages(req: &StructuredRequest, mode: StructuredMode) -> Vec<Message> {
837 match mode {
838 StructuredMode::Tool => {
839 vec![Message::user(&req.prompt)]
842 }
843 StructuredMode::Prompt => {
844 let augmented = format!(
846 "{}\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```",
847 req.prompt,
848 serde_json::to_string_pretty(&req.schema).unwrap_or_default()
849 );
850 vec![Message::user(&augmented)]
851 }
852 _ => {
853 vec![Message::user(&req.prompt)]
856 }
857 }
858}
859
860fn build_system_prompt(req: &StructuredRequest, mode: StructuredMode) -> String {
861 let base = req.system.as_deref().unwrap_or("");
862
863 match mode {
864 StructuredMode::Tool => {
865 format!(
866 "{}{}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.",
867 base,
868 if base.is_empty() { "" } else { "\n\n" },
869 req.schema_name
870 )
871 }
872 StructuredMode::Prompt => {
873 format!(
874 "{}{}You are a structured data extraction assistant. Always respond with valid JSON only, no markdown fences, no explanation text.",
875 base,
876 if base.is_empty() { "" } else { "\n\n" },
877 )
878 }
879 _ => base.to_string(),
880 }
881}
882
883fn build_tools(req: &StructuredRequest, mode: StructuredMode) -> Vec<ToolDefinition> {
884 match mode {
885 StructuredMode::Tool => {
886 vec![ToolDefinition {
887 name: format!("emit_{}", req.schema_name),
888 description: req
889 .schema_description
890 .clone()
891 .unwrap_or_else(|| format!("Emit a structured {} object", req.schema_name)),
892 parameters: req.schema.clone(),
893 }]
894 }
895 _ => vec![],
896 }
897}
898
899struct StructuredResolution {
901 valid: Option<(Value, String)>,
903 invalid: Option<(String, Vec<String>)>,
906 raw_seen: Option<String>,
908}
909
910fn push_candidate(out: &mut Vec<String>, s: String) {
912 let trimmed = s.trim();
913 if !trimmed.is_empty() && !out.iter().any(|c| c == trimmed) {
914 out.push(trimmed.to_string());
915 }
916}
917
918fn extract_raw_candidates(message: &super::Message, mode: StructuredMode) -> Vec<String> {
927 let mut out: Vec<String> = Vec::new();
928 if mode == StructuredMode::Tool {
929 if let Some(call) = message.tool_calls().first() {
930 push_candidate(
931 &mut out,
932 serde_json::to_string(&call.args).unwrap_or_default(),
933 );
934 }
935 }
936 push_candidate(&mut out, message.text());
937 if let Some(reasoning) = message.reasoning_content.as_deref() {
938 push_candidate(&mut out, reasoning.to_string());
939 }
940 out
941}
942
943fn extract_all_json_values(text: &str) -> Vec<Value> {
946 let trimmed = text.trim();
947 let mut values: Vec<Value> = Vec::new();
948 let consider = |candidate: &str, values: &mut Vec<Value>| {
949 if let Ok(v) = serde_json::from_str::<Value>(candidate.trim()) {
950 if (v.is_object() || v.is_array()) && !values.contains(&v) {
951 values.push(v);
952 }
953 }
954 };
955 consider(trimmed, &mut values);
956 if let Some(inner) = strip_code_fence(trimmed) {
957 consider(inner, &mut values);
958 }
959 for candidate in find_all_balanced(trimmed, '{', '}') {
960 consider(&candidate, &mut values);
961 }
962 for candidate in find_all_balanced(trimmed, '[', ']') {
963 consider(&candidate, &mut values);
964 }
965 values
966}
967
968fn resolve_structured(candidates: &[String], schema: &Value) -> StructuredResolution {
971 let mut invalid: Option<(String, Vec<String>)> = None;
972 let mut raw_seen: Option<String> = None;
973 for raw in candidates {
974 if raw_seen.is_none() && !raw.trim().is_empty() {
975 raw_seen = Some(raw.clone());
976 }
977 for value in extract_all_json_values(raw) {
978 match validate_against_schema(&value, schema) {
979 Ok(()) => {
980 return StructuredResolution {
981 valid: Some((value, raw.clone())),
982 invalid,
983 raw_seen,
984 };
985 }
986 Err(errors) => {
987 if invalid.is_none() {
988 invalid = Some((raw.clone(), errors));
989 }
990 }
991 }
992 }
993 }
994 StructuredResolution {
995 valid: None,
996 invalid,
997 raw_seen,
998 }
999}
1000
1001fn truncate_utf8(s: &str, max: usize) -> &str {
1004 if s.len() <= max {
1005 return s;
1006 }
1007 let mut end = max;
1008 while end > 0 && !s.is_char_boundary(end) {
1009 end -= 1;
1010 }
1011 &s[..end]
1012}
1013
1014fn build_parse_failure_repair(raw_text: &str) -> String {
1016 if raw_text.trim().is_empty() {
1017 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();
1018 }
1019 format!(
1020 "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.",
1021 truncate_utf8(raw_text, 2000)
1022 )
1023}
1024
1025fn build_repair_message(raw_text: &str, errors: &[String]) -> String {
1026 let truncated_raw = if raw_text.len() > 2000 {
1028 format!(
1029 "{}...[truncated, {} bytes total]",
1030 truncate_utf8(raw_text, 2000),
1031 raw_text.len()
1032 )
1033 } else {
1034 raw_text.to_string()
1035 };
1036 format!(
1037 "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.",
1038 truncated_raw,
1039 errors.iter().map(|e| format!("- {}", e)).collect::<Vec<_>>().join("\n")
1040 )
1041}
1042
1043fn accumulate_usage(total: &mut TokenUsage, delta: &TokenUsage) {
1044 total.prompt_tokens += delta.prompt_tokens;
1045 total.completion_tokens += delta.completion_tokens;
1046 total.total_tokens += delta.total_tokens;
1047}
1048
1049fn append_repair_context(
1056 messages: &mut Vec<Message>,
1057 assistant_msg: &Message,
1058 repair_text: &str,
1059 mode: StructuredMode,
1060 _raw_text: &str,
1061) {
1062 if mode == StructuredMode::Tool {
1063 messages.push(assistant_msg.clone());
1065 let tool_use_id = assistant_msg
1067 .tool_calls()
1068 .first()
1069 .map(|tc| tc.id.clone())
1070 .unwrap_or_else(|| "unknown".to_string());
1071 messages.push(Message::tool_result(&tool_use_id, repair_text, true));
1073 } else {
1074 messages.push(assistant_msg.clone());
1076 messages.push(Message::user(repair_text));
1077 }
1078}
1079
1080#[cfg(test)]
1085#[path = "structured_tests.rs"]
1086mod structured_tests;