Skip to main content

agent_sdk_providers/
structured.rs

1//! Schema-validated structured output.
2//!
3//! This module implements a provider-agnostic runner that constrains a model's
4//! final answer to a JSON Schema, validates the output, and bounded-re-prompts
5//! on mismatch before failing with a typed error.
6//!
7//! # How it works
8//!
9//! 1. The caller supplies a [`ChatRequest`] whose
10//!    [`response_format`](agent_sdk_foundation::llm::ChatRequest::response_format) is
11//!    set, plus a [`StructuredConfig`] bounding the retries.
12//! 2. The runner inspects the provider's
13//!    [`structured_output_support`](crate::LlmProvider::structured_output_support):
14//!    - [`Native`](crate::StructuredOutputSupport::Native) — the provider
15//!      already mapped `response_format` onto its wire request (`OpenAI` JSON
16//!      mode, Gemini `responseSchema`). The structured value is parsed from the
17//!      assistant's text output.
18//!    - [`ToolForcing`](crate::StructuredOutputSupport::ToolForcing) — the
19//!      runner injects a single forced "respond" tool whose `input_schema` is
20//!      the output schema (the Anthropic fallback) and reads the structured
21//!      value from that tool call's input.
22//! 3. The candidate value is validated against the schema with `jsonschema`.
23//!    On success it is returned. On failure the runner appends the model's
24//!    output plus a corrective user message describing the validation errors
25//!    and retries, up to [`StructuredConfig::max_retries`] times. Exhausting
26//!    the budget yields [`StructuredOutputError::RetriesExhausted`].
27//!
28//! This mirrors the Claude SDK's `output_format` +
29//! `error_max_structured_output_retries` behaviour.
30
31use 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
43/// The forced tool name used for the tool-forcing fallback (Anthropic).
44const RESPOND_TOOL_NAME: &str = "respond";
45
46/// Bounds for the structured-output re-prompt loop.
47#[derive(Debug, Clone, Copy)]
48pub struct StructuredConfig {
49    /// Maximum number of *re-prompts* after the first attempt. A value of `2`
50    /// means up to three model calls total (1 initial + 2 retries) before the
51    /// runner gives up with [`StructuredOutputError::RetriesExhausted`].
52    ///
53    /// Mirrors the Claude SDK `error_max_structured_output_retries`.
54    pub max_retries: u32,
55}
56
57impl Default for StructuredConfig {
58    fn default() -> Self {
59        Self { max_retries: 2 }
60    }
61}
62
63/// A successfully validated structured output and the response that produced it.
64#[derive(Debug, Clone)]
65pub struct StructuredOutput {
66    /// The validated JSON value, guaranteed to satisfy the requested schema.
67    pub value: serde_json::Value,
68    /// The full provider response that produced [`value`](Self::value), so
69    /// callers can still read usage, stop reason, and any leading text.
70    pub response: ChatResponse,
71    /// Number of re-prompts performed before the value validated (0 when the
72    /// first attempt already satisfied the schema).
73    pub retries: u32,
74}
75
76/// Errors from the structured-output runner.
77///
78/// These are *typed* terminal outcomes — the runner never panics on a model
79/// that fails to produce schema-valid output.
80#[derive(Debug, thiserror::Error)]
81pub enum StructuredOutputError {
82    /// The request did not carry a
83    /// [`response_format`](agent_sdk_foundation::llm::ChatRequest::response_format).
84    #[error("structured output requested without a response_format on the request")]
85    MissingResponseFormat,
86
87    /// The schema supplied in the response format is not a valid JSON Schema.
88    #[error("invalid output JSON schema: {0}")]
89    InvalidSchema(String),
90
91    /// The model produced no extractable structured value (no JSON text /
92    /// no forced tool call).
93    #[error("model produced no structured output to validate")]
94    NoStructuredOutput,
95
96    /// The provider returned a non-success outcome (rate limit, server error,
97    /// invalid request).
98    #[error("provider returned a non-success outcome: {0}")]
99    ProviderOutcome(String),
100
101    /// The re-prompt budget was exhausted and the latest output still failed
102    /// schema validation. Carries the final validation errors and the last
103    /// candidate value for diagnostics.
104    #[error(
105        "structured output failed schema validation after {attempts} attempt(s); last errors: {errors}"
106    )]
107    RetriesExhausted {
108        /// Total number of model calls made (initial + retries).
109        attempts: u32,
110        /// Human-readable concatenation of the final validation errors.
111        errors: String,
112        /// The last candidate value the model produced, if any.
113        last_value: Option<serde_json::Value>,
114    },
115
116    /// A transport-level error bubbled up from the provider.
117    #[error(transparent)]
118    Transport(#[from] anyhow::Error),
119}
120
121/// Run a bounded, schema-validated structured-output exchange against `provider`.
122///
123/// The `request`'s
124/// [`response_format`](agent_sdk_foundation::llm::ChatRequest::response_format) must
125/// be set; on success the returned [`StructuredOutput::value`] is guaranteed to
126/// satisfy that schema.
127///
128/// # Errors
129///
130/// Returns a [`StructuredOutputError`] when the request is missing a response
131/// format, the schema is invalid, the provider errors, or the model fails to
132/// produce schema-valid output within [`StructuredConfig::max_retries`].
133pub 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    // Compile the validator once; reuse it across every retry.
144    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        // Clone only when a retry may still follow; on the final attempt move the
158        // request in (no deep clone of the message history + attachments).
159        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            // `ChatOutcome` is `#[non_exhaustive]`; an unrecognized outcome is
183            // surfaced as a provider failure rather than silently retried.
184            _ => {
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            // No structured value at all. On the final attempt this is a hard
194            // failure; otherwise re-prompt asking for the structured answer.
195            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/// An incremental update emitted by [`run_structured_stream`].
239#[derive(Debug, Clone)]
240pub enum StructuredStreamUpdate {
241    /// A best-effort, not-yet-validated object parsed from the partial
242    /// response as it streams in. Successive `Partial`s grow toward the final
243    /// value; a consumer can render them as a live preview. Because the
244    /// underlying JSON is incomplete, a partial may contain truncated string
245    /// values and is never schema-validated.
246    Partial(serde_json::Value),
247    /// The final, schema-validated structured output. Exactly one `Final` is
248    /// emitted on success and it is always the last item in the stream.
249    Final(StructuredOutput),
250}
251
252/// A stream of [`StructuredStreamUpdate`]s produced by [`run_structured_stream`].
253pub type StructuredStream<'a> =
254    Pin<Box<dyn Stream<Item = Result<StructuredStreamUpdate, StructuredOutputError>> + Send + 'a>>;
255
256/// Streaming counterpart to [`run_structured`].
257///
258/// Drives the same bounded, schema-validated exchange, but streams the first
259/// attempt so callers see the structured object build incrementally
260/// ([`StructuredStreamUpdate::Partial`]) before the validated
261/// [`StructuredStreamUpdate::Final`] arrives. If the first (streamed) attempt
262/// fails schema validation, the bounded re-prompt loop continues
263/// non-streaming, reusing the exact retry machinery of [`run_structured`].
264///
265/// This is additive: existing [`run_structured`] callers are unaffected.
266///
267/// # Errors
268///
269/// The stream yields a single [`StructuredOutputError`] (and then ends) when
270/// the request lacks a response format, the schema is invalid, the provider
271/// errors, or the model never produces schema-valid output within
272/// [`StructuredConfig::max_retries`].
273pub 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            // The first attempt streams (emitting partials); retries reuse the
304            // non-streaming path so the re-prompt machinery is shared verbatim.
305            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                // `stream_first_attempt` always terminates with `Complete` or
322                // `Failed`; the `None` arm is an unreachable safety net.
323                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
388/// An item produced by [`stream_first_attempt`]: an incremental partial, the
389/// terminal accumulated response, or a typed failure.
390enum StreamAttemptItem {
391    Partial(serde_json::Value),
392    Complete(ChatResponse),
393    Failed(StructuredOutputError),
394}
395
396/// Stream the first structured-output attempt, emitting de-duplicated partial
397/// objects as the response builds and finishing with the accumulated response
398/// (or a typed failure). Factored out of [`run_structured_stream`] so the
399/// orchestration loop stays small.
400fn 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
447/// Append the relevant part of a streamed delta to the running partial buffer
448/// so [`partial_from_buffer`] can re-parse it. For native providers this is the
449/// model's text output; for tool-forcing it is the forced `respond` tool's
450/// input JSON.
451fn 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
475/// Map a recorded streaming error into the structured-output error surface.
476fn 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
485/// Map a non-success [`ChatOutcome`] from a retry attempt into a typed error.
486fn 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
496/// Materialize a [`ChatResponse`] from a fully-consumed stream accumulator.
497fn 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
514/// Best-effort parse of a partial JSON object/array from an in-flight buffer.
515///
516/// Returns the repaired value only when the buffer (after closing any open
517/// containers) parses to an object or array; otherwise `None` so the caller
518/// simply waits for more data.
519fn partial_from_buffer(buffer: &str) -> Option<serde_json::Value> {
520    let trimmed = buffer.trim_start();
521    // Tolerate a streamed leading ```json fence.
522    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
536/// Close any open strings/containers in a partial JSON fragment so it parses.
537///
538/// Truncated string *values* are kept (closed with `"`); a dangling separator
539/// (`,`) is dropped and a dangling key (`"k":`) is completed with `null`. The
540/// result is not guaranteed to parse (e.g. a half-typed key), in which case the
541/// caller discards it.
542fn 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
589/// Collect human-readable schema-validation errors for a candidate value
590/// (empty when it satisfies the schema).
591fn 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
601/// Inject the forced "respond" tool for providers without native JSON mode.
602fn 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    // Forcing a specific tool is incompatible with extended thinking on
624    // Anthropic-family models (the API 400s when `thinking` is active alongside
625    // a `tool_choice` that names a tool). Clearing `request.thinking` here is
626    // *not* sufficient on its own: the Claude providers fall back to their
627    // provider-configured thinking default when the request field is `None`
628    // (see `resolve_thinking_config`), which would resurrect thinking on the
629    // wire. The authoritative guard lives at the wire boundary — the Claude
630    // request builders drop thinking whenever `tool_choice` names a tool (see
631    // `provider::thinking_for_forced_tool`) — so we do not touch
632    // `request.thinking` here and instead rely on that single source of truth.
633}
634
635/// Pull the candidate structured value out of a response according to how the
636/// provider satisfied the request.
637fn 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
657/// Parse a JSON value from model text output.
658///
659/// Native JSON mode returns a bare JSON document, but models occasionally wrap
660/// it in a fenced code block, so this strips a leading/trailing markdown fence
661/// before parsing.
662fn 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
668/// Strip a surrounding ```` ```json ... ``` ```` (or plain ```` ``` ````) fence.
669fn strip_code_fence(text: &str) -> &str {
670    let Some(rest) = text.strip_prefix("```") else {
671        return text;
672    };
673    // Drop an optional language tag on the opening fence line.
674    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
679/// Append the assistant's previous output plus a corrective user message so the
680/// next attempt sees the validation feedback.
681///
682/// For the tool-forcing path (Anthropic), the assistant turn carries a forced
683/// `respond` `ContentBlock::ToolUse`. The Anthropic Messages API rejects any
684/// conversation where a `tool_use` is not immediately followed by a matching
685/// `tool_result` in the next user message, so the correction is delivered as a
686/// `ToolResult` for that tool-use id (carrying the validation errors) rather than
687/// as plain user text — otherwise the very first re-prompt 400s. When no forced
688/// tool call is present (or for native providers) the correction is plain text.
689fn 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    /// A scripted provider: replays a fixed queue of [`ChatOutcome`]s and
732    /// reports a configurable [`StructuredOutputSupport`]. It also records every
733    /// request it receives so tests can assert on the re-prompt history and on
734    /// the tool-forcing injection.
735    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    // ── Happy path: native (OpenAI / Gemini) ──────────────────────────
860
861    #[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    // ── Happy path: tool-forcing fallback (Anthropic) ─────────────────
905
906    #[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        // The runner must have injected the forced respond tool.
927        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    // ── Mismatch → retry → success ────────────────────────────────────
944
945    #[tokio::test]
946    async fn mismatch_then_retry_succeeds() -> Result<()> {
947        let provider = ScriptedProvider::new(
948            "openai",
949            StructuredOutputSupport::Native,
950            vec![
951                // First attempt: `age` is a string, violating the schema.
952                success(text_block(r#"{"name": "Ada", "age": "old"}"#)),
953                // Retry: corrected.
954                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        // The corrective re-prompt must have appended the prior answer + a
970        // user correction message.
971        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                // First respond: invalid (missing required `age`).
988                success(respond_tool_block(serde_json::json!({"name": "x"}))),
989                // Retry: valid.
990                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        // The retry request must be a valid Anthropic conversation: the appended
1005        // assistant `respond` tool_use must be answered by a user tool_result with
1006        // a matching tool_use_id — not a bare user text message (which 400s).
1007        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    // ── Retry exhaustion → typed error ────────────────────────────────
1043
1044    #[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        // initial + 2 retries == 3 calls.
1079        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    // ── Error surfaces ────────────────────────────────────────────────
1108
1109    #[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        // `type` must be a string/array, not a number — an invalid schema.
1134        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        // Native provider returns non-JSON prose every time.
1163        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    // ── Streaming structured output ───────────────────────────────────
1184
1185    /// A provider that serves a fixed list of streaming deltas from
1186    /// `chat_stream`. The non-streaming `chat` is only exercised on retries
1187    /// (none of the streaming tests below need it).
1188    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        // The first partial sees only the name before the age streamed in.
1281        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        // `apply_tool_forcing` injects the `respond` tool and forces it via
1366        // `tool_choice`. It intentionally leaves `request.thinking` untouched:
1367        // clearing it here would be resurrected by `resolve_thinking_config`'s
1368        // fallback to the provider-configured default, so the actual
1369        // thinking-incompatible-with-forced-tool guard lives at the Claude wire
1370        // boundary (see `provider::thinking_for_forced_tool` and the
1371        // `forced_tool_drops_configured_thinking_on_the_wire` regression in the
1372        // Anthropic provider). This test locks in the forcing contract.
1373        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        // Thinking is deliberately not mutated here; the wire-boundary guard
1394        // drops it. See the module-level comment in `apply_tool_forcing`.
1395        assert!(request.thinking.is_some());
1396    }
1397}