Skip to content

SMOODEV-1472: Structured output (JSON-schema constrained responses) in the LLM client#6

Merged
brentrager merged 1 commit into
mainfrom
SMOODEV-1472-structured-output
Jun 14, 2026
Merged

SMOODEV-1472: Structured output (JSON-schema constrained responses) in the LLM client#6
brentrager merged 1 commit into
mainfrom
SMOODEV-1472-structured-output

Conversation

@brentrager

Copy link
Copy Markdown
Contributor

What

Implements structured output in the smooth-operator engine's LLM client — the keystone Phase 5 parity gap (SMOODEV-1472). The general-agent brain must emit a typed JSON object every turn; this gives the client a way to request a schema-constrained JSON response and parse it back in a typed way.

API added

  • ResponseFormat (public enum, re-exported from the crate root):
    pub enum ResponseFormat {
        JsonSchema { name: String, schema: serde_json::Value, strict: bool },
    }
    // ctor (defaults strict = true):
    ResponseFormat::json_schema(name: impl Into<String>, schema: serde_json::Value) -> Self
  • LlmClient:
    • chat_structured(&self, messages: &[&Message], format: &ResponseFormat) -> Result<LlmResponse>
    • chat_with_format(&self, messages: &[&Message], tools: &[ToolSchema], format: Option<&ResponseFormat>) -> Result<LlmResponse> (core; chat now delegates with None)
  • LlmResponse parse helpers:
    • structured_json(&self) -> Result<serde_json::Value>
    • deserialize_json<T: DeserializeOwned>(&self) -> Result<T>
    • Both surface a clear error (with a content snippet) on empty/non-JSON content — never a silent empty/null value.
  • LlmProvider trait gains chat_structured; MockLlmClient records the requested ResponseFormat on RecordedCall.response_format.

OpenAI vs Anthropic handling

  • OpenAI-compatible (ApiFormat::OpenAiCompat, the LiteLLM gateway path): serialized on /chat/completions as
    response_format: { type: "json_schema", json_schema: { name, schema, strict } }.
  • Anthropic-native (ApiFormat::Anthropic, /v1/messages): Anthropic has no response_format, so structured output is done via a forced single tool call — a synthetic tool whose input_schema IS the requested schema, forced with tool_choice: { type: "tool", name }. The forced tool's input is surfaced back as the JSON content string, so both paths return the same shape. Schema names are sanitized to valid Anthropic tool names.

Tests (TDD, via MockLlmClient)

  • OpenAI request carries the response_format json_schema object (and is omitted when absent).
  • Anthropic forced-tool request serialization (tool_choice + tool input_schema).
  • chat_structured records the requested format; plain chat records None.
  • JSON response parses via structured_json() and deserializes into a typed T.
  • Non-JSON / empty response surfaces a clear error (asserts it does not silently return empty).
  • Scripted error propagation; trait-object usage; sanitize_tool_name edge cases.

All 353 crate tests pass; cargo fmt --check, cargo clippy --all-targets -D warnings, release build, and cargo publish --dry-run --locked are green. Changeset added (minor). No new deps (Cargo.lock unchanged).

Follow-ups (deferred, noted in code + changeset)

  • Streaming structured output (chat_stream sends response_format: None today).
  • Agent-level wiring (Agent::run schema-constrained final response) — the agent tool loop is large; the client + provider + mock surface is landed here so it's unblocking.

🤖 Generated with Claude Code

…s) to LLM client

The general-agent brain must emit a typed JSON object every turn; this lands
the keystone capability on the Rust engine's LLM client.

API:
- ResponseFormat::JsonSchema { name, schema, strict } + json_schema() ctor
- LlmClient::chat_structured / chat_with_format; chat delegates with None
- LlmResponse::structured_json() / deserialize_json::<T>() — clear error on
  empty/non-JSON content, never a silent empty value
- LlmProvider::chat_structured trait method; MockLlmClient records the
  requested format on RecordedCall.response_format

Provider handling:
- OpenAI-compat: response_format { type: json_schema, json_schema {...} } on
  /chat/completions (LiteLLM gateway shape)
- Anthropic-native: forced single tool call whose input_schema IS the schema,
  forced via tool_choice; tool input surfaced back as the JSON content

Streaming structured output and agent-level wiring deferred as follow-ups.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
@brentrager brentrager merged commit 0d46117 into main Jun 14, 2026
1 check passed
@changeset-bot

changeset-bot Bot commented Jun 14, 2026

Copy link
Copy Markdown

🦋 Changeset detected

Latest commit: 1b6c25e

The changes in this PR will be included in the next version bump.

Not sure what this means? Click here to learn what changesets are.

Click here if you're a maintainer who wants to add another changeset to this PR

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant