Structured output — answers you can parse
Free-text replies are fine for chat, useless for code. Structured output makes the model answer with JSON matching a schema you define, so you deserialize straight into a Rust type. Source: src/structured.rs.
The trait — describe your type once
Section titled “The trait — describe your type once”use loopctl::structured::StructuredOutput;
#[derive(serde::Deserialize)]struct Classification { label: String, confidence: f64,}
impl StructuredOutput for Classification { fn name() -> &'static str { "classification" } fn schema() -> serde_json::Value { json!({ "type": "object", "properties": { "label": { "type": "string" }, "confidence": { "type": "number" } }, "required": ["label", "confidence"], "additionalProperties": false }) } // from_value has a default: serde deserialization — override for // post-processing (defaults, trimming, cross-field validation)}name() must match ^[a-zA-Z0-9_-]+$ (convention — it becomes the schema’s name on the wire).
One-shot helper — request_structured
Section titled “One-shot helper — request_structured”For standalone calls (outside the agent loop):
let result: Classification = loopctl::structured::request_structured( &*client, vec![Message::user("Classify: 'The package arrived broken'")], Some("You classify support messages.".into()),).await?;Under the hood: set a ResponseFormat from your type → one non-streaming request → extract the JSON (extract_structured: a tool-call payload if present, otherwise the text parsed leniently — the outermost balanced {...} or [...] found inside prose or markdown fences) → from_value. No retry on parse failure — a malformed answer surfaces as StructuredError::Deserialize; retrying with clearer instructions is your call.
Inside the agent loop — set_request_options
Section titled “Inside the agent loop — set_request_options”agent.set_request_options( RequestOptions::new().with_response_format(ResponseFormat::from_type::<Classification>()));Now every model request carries the format. What happens per provider:
| Provider | How the format is enforced |
|---|---|
| OpenAI | native json_schema response format (strict) |
| Anthropic | synthesized as a single forced tool — the answer arrives as a tool-call payload |
| Gemini | responseMimeType: application/json + responseJsonSchema |
Universal rule: a response format suppresses tools for that request — the format occupies the same slot. A turn under a format is a Q&A turn, not a tool-using turn. (The engine’s context estimate still reserves the tool-schema overhead for such turns — conservative, never under.)
The three ToolConstraint modes — for tool-calling turns
Section titled “The three ToolConstraint modes — for tool-calling turns”Separate knob, applies when tools are advertised:
RequestOptions::new().with_tool_constraint(ToolConstraint::Strict)| Mode | Effect |
|---|---|
None (default) |
schemas as-is; malformed calls answered with errors, not prevented |
Strict |
schemas tightened (every object closed via additionalProperties: false, every property required) + native strict flags where they exist — malformed calls largely prevented at the source |
Grammar(provider) [feature: grammar] |
sampler-level constraint for local servers — see grammar |
The tightening transform is loopctl’s own (tighten_json_schema) — it recurses through nested objects, arrays, allOf/anyOf/oneOf, and $defs, and is idempotent. It exists because OpenAI’s strict mode requires closed, fully-required schemas — without it, strict requests bounce with a 400.
The tightening transform, exactly
Section titled “The tightening transform, exactly”The precise algorithm, for when you need to predict what your schema becomes:
tighten(schema): 1. recurse FIRST into: every value of "properties", "items", every member of allOf/anyOf/oneOf, every definition in $defs AND definitions 2. act only if this node has "type": "object" (explicit) — a {"type":"string"} node is left structurally untouched 3. insert "additionalProperties": false 4. "required" = the union of whatever was already listed (original order, unknown entries survive) PLUS every property key not yet listed, appended in map order. An object with no "properties" gets "required": [] — present but empty.Two non-goals are as deliberate as the actions: $ref references are not followed (there’s no schema registry to resolve against), and if/then/else conditionals are left alone. A schema relying on either will tighten only its explicit object nodes.
The lenient extractor, step by step
Section titled “The lenient extractor, step by step”The fallback that rescues JSON from prose — a small state machine over the reply’s bytes:
scan(text), tracking: start position, brace depth, bracket depth, in-string flag, escaped flag '{' or '[' with no candidate yet → start a candidate here inside a "…" string → only escapes and the closing quote matter (braces in strings never count) '{' / '[' during a candidate → depth++ '}' / ']' matching the open kind → depth-- ; both depths back to zero → try parsing text[start..=here]; the outermost candidate that PARSES wins '}' / ']' NOT matching (e.g. '{oops]') → abandon this candidate entirely, reset depths, keep scanning (a later well-formed value can still win)The “outermost candidate that parses” rule is what makes it robust: markdown fences, a prose prefix, an array of objects — the first complete, parseable value found is the answer. And a balanced-but-invalid candidate ({"a": }) is abandoned rather than fatal, so garbage before the real payload doesn’t sink the extraction.
Rejections, not silent downgrades
Section titled “Rejections, not silent downgrades”Three cases fail loudly before any bytes hit the network:
- A client that doesn’t support options at all (default
ApiClientmethods):ApiError::confignaming the unsupported field. strict: trueresponse formats on Anthropic/Gemini (no strict switch exists there):ApiError::config_validation.Grammarconstraints on Anthropic/Gemini: same.
ResponseFormat::new(name, schema) (dynamic schemas, strict by default) exists for when the shape isn’t known at compile time.
Gotchas
Section titled “Gotchas”- No parse-failure retry in
request_structured— the model will occasionally wrap JSON in prose on weak models; the lenient extractor handles the common cases, but plan a retry loop for flaky models. - Schema quality is on you. The framework doesn’t validate hand-written schemas (except when serving MCP tools). Test them.
with_model("")is ignored (empty/whitespace model overrides never apply).- Anthropic’s forced-tool answers look like tool calls on the wire —
extract_structuredprefers a tool-call payload precisely for this reason; don’t bypass it. - Per-request sampling knobs (temperature, top-p, stop sequences, max tokens) are deliberately not part of
RequestOptions— set what the client exposes (e.g.max_tokenson the Anthropic/Bedrock builders).
Related pages
Section titled “Related pages”- Grammar — the local-model mode.
- API client — where options are honored or rejected.