1use std::pin::Pin;
32
33use agent_sdk_foundation::llm::{
34 ChatOutcome, ChatRequest, ChatResponse, ContentBlock, Message, ResponseFormat, Tool,
35 ToolChoice, Usage,
36};
37use agent_sdk_foundation::types::ToolTier;
38use futures::{Stream, StreamExt};
39
40use crate::provider::{LlmProvider, StructuredOutputSupport};
41use crate::streaming::{StreamAccumulator, StreamDelta, StreamErrorKind};
42
43const RESPOND_TOOL_NAME: &str = "respond";
45
46#[derive(Debug, Clone, Copy)]
48pub struct StructuredConfig {
49 pub max_retries: u32,
55}
56
57impl Default for StructuredConfig {
58 fn default() -> Self {
59 Self { max_retries: 2 }
60 }
61}
62
63#[derive(Debug, Clone)]
65pub struct StructuredOutput {
66 pub value: serde_json::Value,
68 pub response: ChatResponse,
71 pub retries: u32,
74}
75
76#[derive(Debug, thiserror::Error)]
81pub enum StructuredOutputError {
82 #[error("structured output requested without a response_format on the request")]
85 MissingResponseFormat,
86
87 #[error("invalid output JSON schema: {0}")]
89 InvalidSchema(String),
90
91 #[error("model produced no structured output to validate")]
94 NoStructuredOutput,
95
96 #[error("provider returned a non-success outcome: {0}")]
99 ProviderOutcome(String),
100
101 #[error(
105 "structured output failed schema validation after {attempts} attempt(s); last errors: {errors}"
106 )]
107 RetriesExhausted {
108 attempts: u32,
110 errors: String,
112 last_value: Option<serde_json::Value>,
114 },
115
116 #[error(transparent)]
118 Transport(#[from] anyhow::Error),
119}
120
121pub async fn run_structured(
134 provider: &dyn LlmProvider,
135 mut request: ChatRequest,
136 config: StructuredConfig,
137) -> Result<StructuredOutput, StructuredOutputError> {
138 let response_format = request
139 .response_format
140 .clone()
141 .ok_or(StructuredOutputError::MissingResponseFormat)?;
142
143 let validator = jsonschema::validator_for(&response_format.schema)
145 .map_err(|e| StructuredOutputError::InvalidSchema(e.to_string()))?;
146
147 let support = provider.structured_output_support();
148 if matches!(support, StructuredOutputSupport::ToolForcing) {
149 apply_tool_forcing(&mut request, &response_format);
150 }
151
152 let max_attempts = config.max_retries.saturating_add(1);
153 let mut last_value: Option<serde_json::Value> = None;
154 let mut last_errors = String::new();
155
156 for attempt in 0..max_attempts {
157 let attempt_request = if attempt + 1 == max_attempts {
160 std::mem::replace(&mut request, ChatRequest::new(String::new(), Vec::new()))
161 } else {
162 request.clone()
163 };
164 let outcome = provider.chat(attempt_request).await?;
165 let response = match outcome {
166 ChatOutcome::Success(response) => response,
167 ChatOutcome::RateLimited(_) => {
168 return Err(StructuredOutputError::ProviderOutcome(
169 "rate limited".to_owned(),
170 ));
171 }
172 ChatOutcome::InvalidRequest(msg) => {
173 return Err(StructuredOutputError::ProviderOutcome(format!(
174 "invalid request: {msg}"
175 )));
176 }
177 ChatOutcome::ServerError(msg) => {
178 return Err(StructuredOutputError::ProviderOutcome(format!(
179 "server error: {msg}"
180 )));
181 }
182 _ => {
185 return Err(StructuredOutputError::ProviderOutcome(
186 "unrecognized provider outcome".to_owned(),
187 ));
188 }
189 };
190
191 let candidate = extract_candidate(&response, support);
192 let Some(value) = candidate else {
193 if attempt + 1 >= max_attempts {
196 return Err(StructuredOutputError::NoStructuredOutput);
197 }
198 append_correction(
199 &mut request,
200 &response,
201 support,
202 "Your previous reply did not contain a structured answer. \
203 Respond with a single JSON value that satisfies the requested schema.",
204 );
205 "missing structured output".clone_into(&mut last_errors);
206 continue;
207 };
208
209 let errors = collect_schema_errors(&validator, &value);
210
211 if errors.is_empty() {
212 return Ok(StructuredOutput {
213 value,
214 response,
215 retries: attempt,
216 });
217 }
218
219 last_errors = errors.join("; ");
220 last_value = Some(value);
221
222 if attempt + 1 < max_attempts {
223 let correction = format!(
224 "Your previous JSON output did not satisfy the schema. \
225 Fix these validation errors and resend the full JSON value: {last_errors}"
226 );
227 append_correction(&mut request, &response, support, &correction);
228 }
229 }
230
231 Err(StructuredOutputError::RetriesExhausted {
232 attempts: max_attempts,
233 errors: last_errors,
234 last_value,
235 })
236}
237
238#[derive(Debug, Clone)]
240pub enum StructuredStreamUpdate {
241 Partial(serde_json::Value),
247 Final(StructuredOutput),
250}
251
252pub type StructuredStream<'a> =
254 Pin<Box<dyn Stream<Item = Result<StructuredStreamUpdate, StructuredOutputError>> + Send + 'a>>;
255
256pub fn run_structured_stream(
274 provider: &dyn LlmProvider,
275 request: ChatRequest,
276 config: StructuredConfig,
277) -> StructuredStream<'_> {
278 Box::pin(async_stream::stream! {
279 let mut request = request;
280 let Some(response_format) = request.response_format.clone() else {
281 yield Err(StructuredOutputError::MissingResponseFormat);
282 return;
283 };
284 let validator = match jsonschema::validator_for(&response_format.schema) {
285 Ok(validator) => validator,
286 Err(e) => {
287 yield Err(StructuredOutputError::InvalidSchema(e.to_string()));
288 return;
289 }
290 };
291
292 let support = provider.structured_output_support();
293 if matches!(support, StructuredOutputSupport::ToolForcing) {
294 apply_tool_forcing(&mut request, &response_format);
295 }
296
297 let max_attempts = config.max_retries.saturating_add(1);
298 let model = provider.model().to_owned();
299 let mut last_value: Option<serde_json::Value> = None;
300 let mut last_errors = String::new();
301
302 for attempt in 0..max_attempts {
303 let response = if attempt == 0 {
306 let mut attempt_stream =
307 Box::pin(stream_first_attempt(provider, request.clone(), support, model.clone()));
308 let mut completed: Option<ChatResponse> = None;
309 while let Some(item) = attempt_stream.next().await {
310 match item {
311 StreamAttemptItem::Partial(value) => {
312 yield Ok(StructuredStreamUpdate::Partial(value));
313 }
314 StreamAttemptItem::Complete(response) => completed = Some(response),
315 StreamAttemptItem::Failed(error) => {
316 yield Err(error);
317 return;
318 }
319 }
320 }
321 match completed {
324 Some(response) => response,
325 None => return,
326 }
327 } else {
328 match provider.chat(request.clone()).await {
329 Ok(ChatOutcome::Success(response)) => response,
330 Ok(other) => {
331 yield Err(non_success_outcome_error(&other));
332 return;
333 }
334 Err(e) => {
335 yield Err(StructuredOutputError::Transport(e));
336 return;
337 }
338 }
339 };
340
341 let Some(value) = extract_candidate(&response, support) else {
342 if attempt + 1 >= max_attempts {
343 yield Err(StructuredOutputError::NoStructuredOutput);
344 return;
345 }
346 append_correction(
347 &mut request,
348 &response,
349 support,
350 "Your previous reply did not contain a structured answer. \
351 Respond with a single JSON value that satisfies the requested schema.",
352 );
353 "missing structured output".clone_into(&mut last_errors);
354 continue;
355 };
356
357 let errors = collect_schema_errors(&validator, &value);
358
359 if errors.is_empty() {
360 yield Ok(StructuredStreamUpdate::Final(StructuredOutput {
361 value,
362 response,
363 retries: attempt,
364 }));
365 return;
366 }
367
368 last_errors = errors.join("; ");
369 last_value = Some(value);
370
371 if attempt + 1 < max_attempts {
372 let correction = format!(
373 "Your previous JSON output did not satisfy the schema. \
374 Fix these validation errors and resend the full JSON value: {last_errors}"
375 );
376 append_correction(&mut request, &response, support, &correction);
377 }
378 }
379
380 yield Err(StructuredOutputError::RetriesExhausted {
381 attempts: max_attempts,
382 errors: last_errors,
383 last_value,
384 });
385 })
386}
387
388enum StreamAttemptItem {
391 Partial(serde_json::Value),
392 Complete(ChatResponse),
393 Failed(StructuredOutputError),
394}
395
396fn stream_first_attempt(
401 provider: &dyn LlmProvider,
402 request: ChatRequest,
403 support: StructuredOutputSupport,
404 model: String,
405) -> impl Stream<Item = StreamAttemptItem> + Send + '_ {
406 async_stream::stream! {
407 let mut accumulator = StreamAccumulator::new();
408 let mut partial_buf = String::new();
409 let mut respond_tool_ids: std::collections::HashSet<String> =
410 std::collections::HashSet::new();
411 let mut last_partial: Option<serde_json::Value> = None;
412 let mut stream_error: Option<(String, StreamErrorKind)> = None;
413
414 let mut stream = provider.chat_stream(request);
415 while let Some(item) = stream.next().await {
416 let delta = match item {
417 Ok(delta) => delta,
418 Err(e) => {
419 yield StreamAttemptItem::Failed(StructuredOutputError::Transport(e));
420 return;
421 }
422 };
423
424 accumulate_partial_buffer(&delta, support, &mut partial_buf, &mut respond_tool_ids);
425 if let StreamDelta::Error { message, kind } = &delta {
426 stream_error = Some((message.clone(), *kind));
427 }
428 accumulator.apply(&delta);
429
430 if let Some(value) = partial_from_buffer(&partial_buf)
431 && last_partial.as_ref() != Some(&value)
432 {
433 last_partial = Some(value.clone());
434 yield StreamAttemptItem::Partial(value);
435 }
436 }
437
438 if let Some((message, kind)) = stream_error {
439 yield StreamAttemptItem::Failed(stream_error_to_outcome(&message, kind));
440 return;
441 }
442
443 yield StreamAttemptItem::Complete(build_streamed_response(accumulator, model));
444 }
445}
446
447fn accumulate_partial_buffer(
452 delta: &StreamDelta,
453 support: StructuredOutputSupport,
454 buffer: &mut String,
455 respond_tool_ids: &mut std::collections::HashSet<String>,
456) {
457 match (support, delta) {
458 (StructuredOutputSupport::Native, StreamDelta::TextDelta { delta, .. }) => {
459 buffer.push_str(delta);
460 }
461 (StructuredOutputSupport::ToolForcing, StreamDelta::ToolUseStart { id, name, .. })
462 if name == RESPOND_TOOL_NAME =>
463 {
464 respond_tool_ids.insert(id.clone());
465 }
466 (StructuredOutputSupport::ToolForcing, StreamDelta::ToolInputDelta { id, delta, .. })
467 if respond_tool_ids.contains(id) =>
468 {
469 buffer.push_str(delta);
470 }
471 _ => {}
472 }
473}
474
475fn stream_error_to_outcome(message: &str, kind: StreamErrorKind) -> StructuredOutputError {
477 let label = match kind {
478 StreamErrorKind::RateLimited => "rate limited".to_owned(),
479 StreamErrorKind::InvalidRequest => format!("invalid request: {message}"),
480 _ => format!("server error: {message}"),
481 };
482 StructuredOutputError::ProviderOutcome(label)
483}
484
485fn non_success_outcome_error(outcome: &ChatOutcome) -> StructuredOutputError {
487 let label = match outcome {
488 ChatOutcome::RateLimited(_) => "rate limited".to_owned(),
489 ChatOutcome::InvalidRequest(msg) => format!("invalid request: {msg}"),
490 ChatOutcome::ServerError(msg) => format!("server error: {msg}"),
491 _ => "unrecognized provider outcome".to_owned(),
492 };
493 StructuredOutputError::ProviderOutcome(label)
494}
495
496fn build_streamed_response(mut accumulator: StreamAccumulator, model: String) -> ChatResponse {
498 let usage = accumulator.take_usage().unwrap_or(Usage {
499 input_tokens: 0,
500 output_tokens: 0,
501 cached_input_tokens: 0,
502 cache_creation_input_tokens: 0,
503 });
504 let stop_reason = accumulator.take_stop_reason();
505 ChatResponse {
506 id: String::new(),
507 content: accumulator.into_content_blocks(),
508 model,
509 stop_reason,
510 usage,
511 }
512}
513
514fn partial_from_buffer(buffer: &str) -> Option<serde_json::Value> {
520 let trimmed = buffer.trim_start();
521 let body = trimmed
523 .strip_prefix("```")
524 .and_then(|rest| rest.split_once('\n').map(|(_, body)| body))
525 .unwrap_or(trimmed)
526 .trim();
527 if body.is_empty() {
528 return None;
529 }
530 let repaired = repair_partial_json(body);
531 serde_json::from_str::<serde_json::Value>(&repaired)
532 .ok()
533 .filter(|value| value.is_object() || value.is_array())
534}
535
536fn repair_partial_json(buffer: &str) -> String {
543 let mut in_string = false;
544 let mut escape = false;
545 let mut stack: Vec<char> = Vec::new();
546
547 for ch in buffer.chars() {
548 if in_string {
549 if escape {
550 escape = false;
551 } else if ch == '\\' {
552 escape = true;
553 } else if ch == '"' {
554 in_string = false;
555 }
556 continue;
557 }
558 match ch {
559 '"' => in_string = true,
560 '{' => stack.push('}'),
561 '[' => stack.push(']'),
562 '}' | ']' => {
563 stack.pop();
564 }
565 _ => {}
566 }
567 }
568
569 let mut out = buffer.to_owned();
570 if escape {
571 out.pop();
572 }
573 if in_string {
574 out.push('"');
575 }
576 out.truncate(out.trim_end().len());
577 if out.ends_with(',') {
578 out.pop();
579 out.truncate(out.trim_end().len());
580 } else if out.ends_with(':') {
581 out.push_str(" null");
582 }
583 for closer in stack.iter().rev() {
584 out.push(*closer);
585 }
586 out
587}
588
589fn collect_schema_errors(
592 validator: &jsonschema::Validator,
593 value: &serde_json::Value,
594) -> Vec<String> {
595 validator
596 .iter_errors(value)
597 .map(|error| format!("at `{}`: {error}", error.instance_path()))
598 .collect()
599}
600
601fn apply_tool_forcing(request: &mut ChatRequest, response_format: &ResponseFormat) {
603 let respond_tool = Tool {
604 name: RESPOND_TOOL_NAME.to_owned(),
605 description: format!(
606 "Return the final answer as structured data named `{}`. \
607 You MUST call this tool exactly once with arguments matching the schema.",
608 response_format.name
609 ),
610 input_schema: response_format.schema.clone(),
611 display_name: "Structured response".to_owned(),
612 tier: ToolTier::Observe,
613 };
614
615 match request.tools {
616 Some(ref mut tools) => {
617 tools.retain(|t| t.name != RESPOND_TOOL_NAME);
618 tools.push(respond_tool);
619 }
620 None => request.tools = Some(vec![respond_tool]),
621 }
622 request.tool_choice = Some(ToolChoice::Tool(RESPOND_TOOL_NAME.to_owned()));
623 }
634
635fn extract_candidate(
638 response: &ChatResponse,
639 support: StructuredOutputSupport,
640) -> Option<serde_json::Value> {
641 match support {
642 StructuredOutputSupport::ToolForcing => {
643 response.content.iter().find_map(|block| match block {
644 ContentBlock::ToolUse { name, input, .. } if name == RESPOND_TOOL_NAME => {
645 Some(input.clone())
646 }
647 _ => None,
648 })
649 }
650 StructuredOutputSupport::Native => {
651 let text = response.first_text()?;
652 parse_json_text(text)
653 }
654 }
655}
656
657fn parse_json_text(text: &str) -> Option<serde_json::Value> {
663 let trimmed = text.trim();
664 let unfenced = strip_code_fence(trimmed);
665 serde_json::from_str(unfenced).ok()
666}
667
668fn strip_code_fence(text: &str) -> &str {
670 let Some(rest) = text.strip_prefix("```") else {
671 return text;
672 };
673 let rest = rest.split_once('\n').map_or(rest, |(_, body)| body);
675 rest.strip_suffix("```")
676 .map_or(text, |inner| inner.trim_end_matches('`').trim())
677}
678
679fn append_correction(
690 request: &mut ChatRequest,
691 previous: &ChatResponse,
692 support: StructuredOutputSupport,
693 correction: &str,
694) {
695 request
696 .messages
697 .push(Message::assistant_with_content(previous.content.clone()));
698
699 let respond_tool_use_id = if matches!(support, StructuredOutputSupport::ToolForcing) {
700 previous.content.iter().find_map(|block| match block {
701 ContentBlock::ToolUse { id, name, .. } if name == RESPOND_TOOL_NAME => Some(id.clone()),
702 _ => None,
703 })
704 } else {
705 None
706 };
707
708 match respond_tool_use_id {
709 Some(tool_use_id) => {
710 request
711 .messages
712 .push(Message::tool_result(tool_use_id, correction, true));
713 }
714 None => request.messages.push(Message::user(correction)),
715 }
716}
717
718#[cfg(test)]
719mod tests {
720 use super::*;
721
722 use std::sync::Mutex;
723 use std::sync::atomic::{AtomicUsize, Ordering};
724
725 use agent_sdk_foundation::llm::{StopReason, Usage};
726 use anyhow::Result;
727 use async_trait::async_trait;
728
729 use crate::streaming::StreamBox;
730
731 struct ScriptedProvider {
736 provider_name: &'static str,
737 model: String,
738 support: StructuredOutputSupport,
739 outcomes: Mutex<std::collections::VecDeque<ChatOutcome>>,
740 seen_requests: Mutex<Vec<ChatRequest>>,
741 calls: AtomicUsize,
742 }
743
744 impl ScriptedProvider {
745 fn new(
746 provider_name: &'static str,
747 support: StructuredOutputSupport,
748 outcomes: Vec<ChatOutcome>,
749 ) -> Self {
750 Self {
751 provider_name,
752 model: "scripted-model".to_owned(),
753 support,
754 outcomes: Mutex::new(outcomes.into()),
755 seen_requests: Mutex::new(Vec::new()),
756 calls: AtomicUsize::new(0),
757 }
758 }
759
760 fn call_count(&self) -> usize {
761 self.calls.load(Ordering::SeqCst)
762 }
763 }
764
765 #[async_trait]
766 impl LlmProvider for ScriptedProvider {
767 async fn chat(&self, request: ChatRequest) -> Result<ChatOutcome> {
768 self.calls.fetch_add(1, Ordering::SeqCst);
769 self.seen_requests
770 .lock()
771 .expect("seen_requests lock")
772 .push(request);
773 let outcome = self
774 .outcomes
775 .lock()
776 .expect("outcomes lock")
777 .pop_front()
778 .expect("ScriptedProvider: ran out of scripted outcomes");
779 Ok(outcome)
780 }
781
782 fn chat_stream(&self, _request: ChatRequest) -> StreamBox<'_> {
783 Box::pin(async_stream::stream! {
784 yield Err(anyhow::anyhow!("streaming not used in structured tests"));
785 })
786 }
787
788 fn model(&self) -> &str {
789 &self.model
790 }
791
792 fn provider(&self) -> &'static str {
793 self.provider_name
794 }
795
796 fn structured_output_support(&self) -> StructuredOutputSupport {
797 self.support
798 }
799 }
800
801 fn person_schema() -> serde_json::Value {
802 serde_json::json!({
803 "type": "object",
804 "properties": {
805 "name": { "type": "string" },
806 "age": { "type": "integer", "minimum": 0 }
807 },
808 "required": ["name", "age"],
809 "additionalProperties": false
810 })
811 }
812
813 fn request_with_format() -> ChatRequest {
814 ChatRequest {
815 system: String::new(),
816 messages: vec![Message::user("Describe a person.")],
817 tools: None,
818 max_tokens: 256,
819 max_tokens_explicit: true,
820 session_id: None,
821 cached_content: None,
822 thinking: None,
823 tool_choice: None,
824 response_format: Some(ResponseFormat::new("person", person_schema())),
825 cache: None,
826 }
827 }
828
829 fn success(content: Vec<ContentBlock>) -> ChatOutcome {
830 ChatOutcome::Success(ChatResponse {
831 id: "resp".to_owned(),
832 content,
833 model: "scripted-model".to_owned(),
834 stop_reason: Some(StopReason::EndTurn),
835 usage: Usage {
836 input_tokens: 1,
837 output_tokens: 1,
838 cached_input_tokens: 0,
839 cache_creation_input_tokens: 0,
840 },
841 })
842 }
843
844 fn text_block(text: &str) -> Vec<ContentBlock> {
845 vec![ContentBlock::Text {
846 text: text.to_owned(),
847 }]
848 }
849
850 fn respond_tool_block(input: serde_json::Value) -> Vec<ContentBlock> {
851 vec![ContentBlock::ToolUse {
852 id: "call_1".to_owned(),
853 name: RESPOND_TOOL_NAME.to_owned(),
854 input,
855 thought_signature: None,
856 }]
857 }
858
859 #[tokio::test]
862 async fn native_happy_path_validates_json_text() -> Result<()> {
863 let provider = ScriptedProvider::new(
864 "openai",
865 StructuredOutputSupport::Native,
866 vec![success(text_block(r#"{"name": "Ada", "age": 36}"#))],
867 );
868
869 let out = run_structured(
870 &provider,
871 request_with_format(),
872 StructuredConfig::default(),
873 )
874 .await?;
875
876 assert_eq!(out.value["name"], "Ada");
877 assert_eq!(out.value["age"], 36);
878 assert_eq!(out.retries, 0);
879 assert_eq!(provider.call_count(), 1);
880 Ok(())
881 }
882
883 #[tokio::test]
884 async fn native_happy_path_strips_markdown_fence() -> Result<()> {
885 let provider = ScriptedProvider::new(
886 "gemini",
887 StructuredOutputSupport::Native,
888 vec![success(text_block(
889 "```json\n{\"name\": \"Grace\", \"age\": 45}\n```",
890 ))],
891 );
892
893 let out = run_structured(
894 &provider,
895 request_with_format(),
896 StructuredConfig::default(),
897 )
898 .await?;
899
900 assert_eq!(out.value["name"], "Grace");
901 Ok(())
902 }
903
904 #[tokio::test]
907 async fn tool_forcing_happy_path_reads_tool_input() -> Result<()> {
908 let provider = ScriptedProvider::new(
909 "anthropic",
910 StructuredOutputSupport::ToolForcing,
911 vec![success(respond_tool_block(
912 serde_json::json!({"name": "Linus", "age": 54}),
913 ))],
914 );
915
916 let out = run_structured(
917 &provider,
918 request_with_format(),
919 StructuredConfig::default(),
920 )
921 .await?;
922
923 assert_eq!(out.value["name"], "Linus");
924 assert_eq!(out.retries, 0);
925
926 let (has_respond_tool, forces_respond) = {
928 let seen = provider.seen_requests.lock().expect("seen lock");
929 let tools = seen[0].tools.as_ref().expect("tools injected");
930 (
931 tools.iter().any(|t| t.name == RESPOND_TOOL_NAME),
932 matches!(
933 seen[0].tool_choice,
934 Some(ToolChoice::Tool(ref n)) if n == RESPOND_TOOL_NAME
935 ),
936 )
937 };
938 assert!(has_respond_tool);
939 assert!(forces_respond);
940 Ok(())
941 }
942
943 #[tokio::test]
946 async fn mismatch_then_retry_succeeds() -> Result<()> {
947 let provider = ScriptedProvider::new(
948 "openai",
949 StructuredOutputSupport::Native,
950 vec![
951 success(text_block(r#"{"name": "Ada", "age": "old"}"#)),
953 success(text_block(r#"{"name": "Ada", "age": 36}"#)),
955 ],
956 );
957
958 let out = run_structured(
959 &provider,
960 request_with_format(),
961 StructuredConfig { max_retries: 2 },
962 )
963 .await?;
964
965 assert_eq!(out.value["age"], 36);
966 assert_eq!(out.retries, 1);
967 assert_eq!(provider.call_count(), 2);
968
969 let grew = {
972 let seen = provider.seen_requests.lock().expect("seen lock");
973 seen[1].messages.len() > seen[0].messages.len()
974 };
975 assert!(grew);
976 Ok(())
977 }
978
979 #[tokio::test]
980 async fn tool_forcing_retry_appends_tool_result_for_forced_tool_use() -> Result<()> {
981 use agent_sdk_foundation::llm::Content;
982
983 let provider = ScriptedProvider::new(
984 "anthropic",
985 StructuredOutputSupport::ToolForcing,
986 vec![
987 success(respond_tool_block(serde_json::json!({"name": "x"}))),
989 success(respond_tool_block(
991 serde_json::json!({"name": "x", "age": 1}),
992 )),
993 ],
994 );
995
996 let out = run_structured(
997 &provider,
998 request_with_format(),
999 StructuredConfig { max_retries: 1 },
1000 )
1001 .await?;
1002 assert_eq!(out.retries, 1);
1003
1004 let seen = provider.seen_requests.lock().expect("seen lock");
1008 let retry = &seen[1];
1009
1010 let assistant_tool_use_id = retry
1011 .messages
1012 .iter()
1013 .find_map(|m| match &m.content {
1014 Content::Blocks(blocks) => blocks.iter().find_map(|b| match b {
1015 ContentBlock::ToolUse { id, name, .. } if name == RESPOND_TOOL_NAME => {
1016 Some(id.clone())
1017 }
1018 _ => None,
1019 }),
1020 Content::Text(_) => None,
1021 })
1022 .expect("assistant respond tool_use present in retry");
1023
1024 let has_matching_result = retry.messages.iter().any(|m| match &m.content {
1025 Content::Blocks(blocks) => blocks.iter().any(|b| {
1026 matches!(
1027 b,
1028 ContentBlock::ToolResult { tool_use_id, .. }
1029 if *tool_use_id == assistant_tool_use_id
1030 )
1031 }),
1032 Content::Text(_) => false,
1033 });
1034 drop(seen);
1035 assert!(
1036 has_matching_result,
1037 "retry must carry a tool_result for the forced tool_use id"
1038 );
1039 Ok(())
1040 }
1041
1042 #[tokio::test]
1045 async fn retry_exhaustion_yields_typed_error() -> Result<()> {
1046 let provider = ScriptedProvider::new(
1047 "anthropic",
1048 StructuredOutputSupport::ToolForcing,
1049 vec![
1050 success(respond_tool_block(serde_json::json!({"name": "x"}))),
1051 success(respond_tool_block(serde_json::json!({"name": "y"}))),
1052 success(respond_tool_block(serde_json::json!({"name": "z"}))),
1053 ],
1054 );
1055
1056 let err = run_structured(
1057 &provider,
1058 request_with_format(),
1059 StructuredConfig { max_retries: 2 },
1060 )
1061 .await
1062 .expect_err("schema never satisfied");
1063
1064 match err {
1065 StructuredOutputError::RetriesExhausted {
1066 attempts,
1067 last_value,
1068 ..
1069 } => {
1070 assert_eq!(attempts, 3, "1 initial + 2 retries");
1071 assert_eq!(
1072 last_value.as_ref().and_then(|v| v["name"].as_str()),
1073 Some("z")
1074 );
1075 }
1076 other => panic!("expected RetriesExhausted, got {other:?}"),
1077 }
1078 assert_eq!(provider.call_count(), 3);
1080 Ok(())
1081 }
1082
1083 #[tokio::test]
1084 async fn zero_retries_fails_after_single_attempt() -> Result<()> {
1085 let provider = ScriptedProvider::new(
1086 "openai",
1087 StructuredOutputSupport::Native,
1088 vec![success(text_block(r#"{"name": "Ada"}"#))],
1089 );
1090
1091 let err = run_structured(
1092 &provider,
1093 request_with_format(),
1094 StructuredConfig { max_retries: 0 },
1095 )
1096 .await
1097 .expect_err("missing required `age`");
1098
1099 assert!(matches!(
1100 err,
1101 StructuredOutputError::RetriesExhausted { attempts: 1, .. }
1102 ));
1103 assert_eq!(provider.call_count(), 1);
1104 Ok(())
1105 }
1106
1107 #[tokio::test]
1110 async fn missing_response_format_is_typed_error() {
1111 let provider = ScriptedProvider::new(
1112 "openai",
1113 StructuredOutputSupport::Native,
1114 vec![success(text_block("{}"))],
1115 );
1116 let mut req = request_with_format();
1117 req.response_format = None;
1118
1119 let err = run_structured(&provider, req, StructuredConfig::default())
1120 .await
1121 .expect_err("no response format");
1122 assert!(matches!(err, StructuredOutputError::MissingResponseFormat));
1123 }
1124
1125 #[tokio::test]
1126 async fn invalid_schema_is_typed_error() {
1127 let provider = ScriptedProvider::new(
1128 "openai",
1129 StructuredOutputSupport::Native,
1130 vec![success(text_block("{}"))],
1131 );
1132 let mut req = request_with_format();
1133 req.response_format = Some(ResponseFormat::new("bad", serde_json::json!({"type": 123})));
1135
1136 let err = run_structured(&provider, req, StructuredConfig::default())
1137 .await
1138 .expect_err("invalid schema");
1139 assert!(matches!(err, StructuredOutputError::InvalidSchema(_)));
1140 }
1141
1142 #[tokio::test]
1143 async fn provider_rate_limit_surfaces_as_typed_error() {
1144 let provider = ScriptedProvider::new(
1145 "openai",
1146 StructuredOutputSupport::Native,
1147 vec![ChatOutcome::RateLimited(None)],
1148 );
1149
1150 let err = run_structured(
1151 &provider,
1152 request_with_format(),
1153 StructuredConfig::default(),
1154 )
1155 .await
1156 .expect_err("rate limited");
1157 assert!(matches!(err, StructuredOutputError::ProviderOutcome(_)));
1158 }
1159
1160 #[tokio::test]
1161 async fn no_structured_output_on_final_attempt_errors() {
1162 let provider = ScriptedProvider::new(
1164 "openai",
1165 StructuredOutputSupport::Native,
1166 vec![
1167 success(text_block("I cannot do that.")),
1168 success(text_block("Still prose, sorry.")),
1169 ],
1170 );
1171
1172 let err = run_structured(
1173 &provider,
1174 request_with_format(),
1175 StructuredConfig { max_retries: 1 },
1176 )
1177 .await
1178 .expect_err("never produced JSON");
1179 assert!(matches!(err, StructuredOutputError::NoStructuredOutput));
1180 assert_eq!(provider.call_count(), 2);
1181 }
1182
1183 struct StreamingProvider {
1189 provider_name: &'static str,
1190 model: String,
1191 support: StructuredOutputSupport,
1192 deltas: Mutex<Vec<StreamDelta>>,
1193 }
1194
1195 impl StreamingProvider {
1196 fn new(
1197 provider_name: &'static str,
1198 support: StructuredOutputSupport,
1199 deltas: Vec<StreamDelta>,
1200 ) -> Self {
1201 Self {
1202 provider_name,
1203 model: "scripted-model".to_owned(),
1204 support,
1205 deltas: Mutex::new(deltas),
1206 }
1207 }
1208 }
1209
1210 #[async_trait]
1211 impl LlmProvider for StreamingProvider {
1212 async fn chat(&self, _request: ChatRequest) -> Result<ChatOutcome> {
1213 Ok(ChatOutcome::ServerError("chat() not used".to_owned()))
1214 }
1215
1216 fn chat_stream(&self, _request: ChatRequest) -> StreamBox<'_> {
1217 let deltas = self.deltas.lock().map(|d| d.clone()).unwrap_or_default();
1218 Box::pin(async_stream::stream! {
1219 for delta in deltas {
1220 yield Ok(delta);
1221 }
1222 })
1223 }
1224
1225 fn model(&self) -> &str {
1226 &self.model
1227 }
1228
1229 fn provider(&self) -> &'static str {
1230 self.provider_name
1231 }
1232
1233 fn structured_output_support(&self) -> StructuredOutputSupport {
1234 self.support
1235 }
1236 }
1237
1238 async fn drive_stream(
1239 mut stream: StructuredStream<'_>,
1240 ) -> Result<(Vec<serde_json::Value>, Option<StructuredOutput>)> {
1241 let mut partials = Vec::new();
1242 let mut final_out = None;
1243 while let Some(update) = stream.next().await {
1244 match update? {
1245 StructuredStreamUpdate::Partial(value) => partials.push(value),
1246 StructuredStreamUpdate::Final(out) => final_out = Some(out),
1247 }
1248 }
1249 Ok((partials, final_out))
1250 }
1251
1252 #[tokio::test]
1253 async fn streaming_native_emits_partials_then_validated_final() -> Result<()> {
1254 let provider = StreamingProvider::new(
1255 "openai",
1256 StructuredOutputSupport::Native,
1257 vec![
1258 StreamDelta::TextDelta {
1259 delta: r#"{"name": "Ada""#.to_owned(),
1260 block_index: 0,
1261 },
1262 StreamDelta::TextDelta {
1263 delta: r#", "age": 36}"#.to_owned(),
1264 block_index: 0,
1265 },
1266 StreamDelta::Done {
1267 stop_reason: Some(StopReason::EndTurn),
1268 },
1269 ],
1270 );
1271
1272 let stream = run_structured_stream(
1273 &provider,
1274 request_with_format(),
1275 StructuredConfig::default(),
1276 );
1277 let (partials, final_out) = drive_stream(stream).await?;
1278
1279 assert!(!partials.is_empty(), "expected at least one partial");
1280 assert_eq!(partials[0]["name"], "Ada");
1282 let final_out = final_out.expect("a validated final value");
1283 assert_eq!(final_out.value["name"], "Ada");
1284 assert_eq!(final_out.value["age"], 36);
1285 assert_eq!(final_out.retries, 0);
1286 Ok(())
1287 }
1288
1289 #[tokio::test]
1290 async fn streaming_tool_forcing_reads_partial_tool_input() -> Result<()> {
1291 let provider = StreamingProvider::new(
1292 "anthropic",
1293 StructuredOutputSupport::ToolForcing,
1294 vec![
1295 StreamDelta::ToolUseStart {
1296 id: "call_1".to_owned(),
1297 name: RESPOND_TOOL_NAME.to_owned(),
1298 block_index: 0,
1299 thought_signature: None,
1300 },
1301 StreamDelta::ToolInputDelta {
1302 id: "call_1".to_owned(),
1303 delta: r#"{"name": "Linus""#.to_owned(),
1304 block_index: 0,
1305 },
1306 StreamDelta::ToolInputDelta {
1307 id: "call_1".to_owned(),
1308 delta: r#", "age": 54}"#.to_owned(),
1309 block_index: 0,
1310 },
1311 StreamDelta::Done {
1312 stop_reason: Some(StopReason::ToolUse),
1313 },
1314 ],
1315 );
1316
1317 let stream = run_structured_stream(
1318 &provider,
1319 request_with_format(),
1320 StructuredConfig::default(),
1321 );
1322 let (partials, final_out) = drive_stream(stream).await?;
1323
1324 assert_eq!(partials[0]["name"], "Linus");
1325 let final_out = final_out.expect("a validated final value");
1326 assert_eq!(final_out.value["age"], 54);
1327 Ok(())
1328 }
1329
1330 #[tokio::test]
1331 async fn streaming_missing_response_format_errors() {
1332 let provider =
1333 StreamingProvider::new("openai", StructuredOutputSupport::Native, Vec::new());
1334 let mut req = request_with_format();
1335 req.response_format = None;
1336
1337 let mut stream = run_structured_stream(&provider, req, StructuredConfig::default());
1338 let first = stream.next().await.expect("one item");
1339 assert!(matches!(
1340 first,
1341 Err(StructuredOutputError::MissingResponseFormat)
1342 ));
1343 }
1344
1345 #[test]
1346 fn partial_from_buffer_repairs_incomplete_json() {
1347 assert_eq!(
1348 partial_from_buffer(r#"{"name": "Ada""#).map(|v| v["name"].clone()),
1349 Some(serde_json::json!("Ada"))
1350 );
1351 assert_eq!(
1352 partial_from_buffer(r#"{"a": 1,"#),
1353 Some(serde_json::json!({"a": 1}))
1354 );
1355 assert_eq!(
1356 partial_from_buffer(r#"{"a":"#),
1357 Some(serde_json::json!({"a": null}))
1358 );
1359 assert!(partial_from_buffer("").is_none());
1360 assert!(partial_from_buffer("not json").is_none());
1361 }
1362
1363 #[test]
1364 fn apply_tool_forcing_forces_the_respond_tool() {
1365 let mut request = request_with_format();
1374 request.thinking = Some(agent_sdk_foundation::llm::ThinkingConfig::new(10_000));
1375 let response_format = request
1376 .response_format
1377 .clone()
1378 .expect("request_with_format sets a response format");
1379
1380 apply_tool_forcing(&mut request, &response_format);
1381
1382 assert!(matches!(
1383 request.tool_choice,
1384 Some(ToolChoice::Tool(ref name)) if name == RESPOND_TOOL_NAME
1385 ));
1386 assert!(
1387 request
1388 .tools
1389 .as_ref()
1390 .is_some_and(|tools| tools.iter().any(|t| t.name == RESPOND_TOOL_NAME)),
1391 "the respond tool must be present"
1392 );
1393 assert!(request.thinking.is_some());
1396 }
1397}