Skip to content

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.


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).

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 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 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.

Three cases fail loudly before any bytes hit the network:

  • A client that doesn’t support options at all (default ApiClient methods): ApiError::config naming the unsupported field.
  • strict: true response formats on Anthropic/Gemini (no strict switch exists there): ApiError::config_validation.
  • Grammar constraints on Anthropic/Gemini: same.

ResponseFormat::new(name, schema) (dynamic schemas, strict by default) exists for when the shape isn’t known at compile time.


  1. 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.
  2. Schema quality is on you. The framework doesn’t validate hand-written schemas (except when serving MCP tools). Test them.
  3. with_model("") is ignored (empty/whitespace model overrides never apply).
  4. Anthropic’s forced-tool answers look like tool calls on the wire — extract_structured prefers a tool-call payload precisely for this reason; don’t bypass it.
  5. 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_tokens on the Anthropic/Bedrock builders).

  • Grammar — the local-model mode.
  • API client — where options are honored or rejected.