Skip to content

Feature/union response schemas - #597

Merged
flsh86 merged 12 commits into
masterfrom
feature/union-response-schemas
Aug 25, 2026
Merged

Feature/union response schemas#597
flsh86 merged 12 commits into
masterfrom
feature/union-response-schemas

Conversation

@flsh86

@flsh86 flsh86 commented Aug 18, 2026

Copy link
Copy Markdown
Contributor

Union-type response schemas: structured intent classification

Stacked PR: targets feature/derive-agent-tools (or rebase onto master once that PR merges).

Adds discriminated-union response schemas, so a classifier agent returns one of several typed intents and the caller dispatches with an exhaustive match (unhandled intents caught by the compiler):

val classifier = OpenAIAgent
  .synchronous(openai, "gpt-4o-mini")
  .responseSchema(UnionResponseSchema.derive[Refund | Complaint | GeneralQuery]("Classify the user's intent"))
  .build

classifier.run(msg)(backend).finalAnswer.map {
  case r: Refund       => refunds.run(...)
  case c: Complaint    => support.run(...)
  case _: GeneralQuery => faq.run(...)
}

Design

Two layers, one engine:
- ResponseSchema.oneOf[U](Variant[A], Variant[B], ...) (shared, Scala 2.13 + 3, macro-free) — explicit variants for sealed traits; Variant.named customizes the model-facing label.
- UnionResponseSchema.derive[A | B | C] (Scala 3 macro) — flattens the union (dealiasing, nested unions), summons per-member Schema/Encoder/Decoder/ClassTag givens, delegates to oneOf. Non-union types, duplicate members, and missing givens are compile errors.

Wire shape (uniform across providers — OpenAI strict mode forbids anyOf at the schema root): a root object with a single required result property holding an anyOf of the variants, each carrying a required kind: {"type": "string", "enum": ["<name>"]} discriminator as the first property. Model output: {"result": {"kind": "Refund", "orderId": "o-1"}}.

The discriminator-first ordering is load-bearing, discovered in live validation: structured-output grammars (OpenAI strict, Claude) constrain generation to schema property order, and with kind last, models leading with the discriminator were grammar-locked into the empty variant.

Details

- Construction-time validation: non-object variants, reserved kind property, duplicate names/classes, conflicting $defs — all fail at the oneOf/derive call site.
- Nested case classes hoist into a root $defs (no dangling refs); decode failures surface through the existing typed-run AgentFailure channel; unknown kind lists the valid kinds.
- Additive, non-breaking; no new dependencies. Scala 2.13 path is fully tested (shared spec runs on both rows).
- 24 shared + 7 macro unit tests; docs in docs/other/json-schemas.md and docs/agents/tools.md (mdoc-compiled).
- Live-validated on Claude (haiku-4-5), OpenAI strict (gpt-4o-mini), and Gemini (3.5 flash lite): 3-intent classifier with a nested case-class variant, an Option field variant, an empty variant, and a Variant.named custom label — all classified and decoded correctly on all three providers.
- No examples/ entry yet (examples must compile against released artifacts; post-release follow-up).

@flsh86
flsh86 force-pushed the feature/union-response-schemas branch from 7a21b47 to b474f59 Compare August 25, 2026 06:04
Base automatically changed from feature/derive-agent-tools to master August 25, 2026 06:19
flsh86 and others added 9 commits August 25, 2026 09:38
…xplicit variants

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…ypes

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Step 4 verification (strict-mode coverage of response-format schemas):
SchemaSupport.normalizeForStrict is applied on the ResponseFormat.JsonSchema
encoder path, gated on strict = Some(true):
- openai/src/main/scala/sttp/ai/openai/json/OpenAIManualCodecs.scala:132-134
  (chatResponseFormatEncoder, ChatRequestBody.ResponseFormat.JsonSchema)
- openai/src/main/scala/sttp/ai/openai/json/OpenAIManualCodecs.scala:153-155
  (responsesRequestFormatEncoder, ResponsesRequestBody.Format.JsonSchema)
Not blocked.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
- ResponseSchema.oneOf: kind discriminator now includes "type": "string"
  alongside "enum", and the assembled root carries a single "$schema" key
  matching ResponseSchema.derived's output; scaladoc notes encoder dispatch
  is first-match by declaration order on runtime class.
- UnionResponseSchema.derive: renderType handles OrType so union-typed fail
  messages no longer render as <none>; silence the unused-foldLeft-result
  warning in the duplicate-member check.
- Tests: assert kind's "type": "string" and the root "$schema" key; add
  decode-failure cases for missing result/kind; add an openai SchemaSupportSpec
  case pinning additionalProperties:false on a union variant while the root
  keeps its own required list.
- Docs: note Scala 2.13 instances use deriveCodec/Schema.derived where the
  union examples show Scala 3 derives syntax.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Structured-output grammars (OpenAI strict mode, Claude) constrain generation
to the schema's property order. With kind last, a model leading with the
discriminator was locked out of every variant except the empty one -
verified live: all non-empty variants classified as GeneralQuery. With kind
first, all three providers classify correctly.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
A definition literally named 'properties' or 'type' was mistaken for a
schema keyword of the $defs container, corrupting it. Also pins that the
kind discriminator stays the first variant property through normalization.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
- strip the synthetic kind discriminator before variant decoders run, so
  unknown-field-rejecting decoders work
- compile-time error for distinct union members sharing a simple name
  (previously compiled, then failed at runtime with no escape hatch)
- targeted error for reference-rooted (recursive) variant schemas instead
  of the misleading 'must be object schemas' message
- reject encoders that emit their own kind field instead of clobbering it
- version-consistent Variant default names for local classes (2.13's
  Strict$1 suffix stripped)
- extract renderType into MacroSupport (shared by AgentTools and
  UnionResponseSchema) and the tapir rendering convention into
  ResponseSchema.renderTapirSchema

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Scala Native's getSimpleName returns just the numeric counter for
method-local classes (Local$1 -> "1"), so ResponseSchemaOneOfSpec's
local-class fixtures got kind "1" on the coreNative3 CI row and the
strip-synthetic-kind test failed with an unknown-kind error. getName is
identical on JVM and Native; the last non-numeric $-segment is the
declared class name (verified on Scala Native 0.5.12 via scala-cli).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
@flsh86
flsh86 force-pushed the feature/union-response-schemas branch from 110a6a7 to cd51922 Compare August 25, 2026 07:40
@flsh86
flsh86 marked this pull request as ready for review August 25, 2026 07:54
.maxIterations(10) // Max reasoning steps
.systemPrompt("Custom prompt") // Optional instructions
.tools(tool1, tool2) // Your tools
.deriveResponseSchema[T] // Optional typed result (see runAs[T] below)

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

generated-docs should not be modified in PRs, only during releases

Comment thread docs/agents/tools.md Outdated

val classifier = OpenAIAgent
.synchronous(openai, "gpt-4o-mini")
.responseSchema(UnionResponseSchema.derive[Refund | Complaint | GeneralQuery]("Classify the user's intent"))

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

why can't the same .deriveResponseSchema[Refund | Complaint | GeneralQuery] be used here, as in the first example above? Why do we need a specialised UnionResponseSchema in the first place?

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

deriveResponseSchema[T] requires given sttp.tapir.Schema[T] and io.circe.Codec[T]. Neither tapir nor circe can derive these for union types — unions have no Mirror - so that call simply doesn't compile for a union (with an unhelpful "no given instance" error).

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Ok ... so then maybe we could have:

.responseSchema(ResponseSchema.derive[T]) // normal case
.responseSchema(ResponseSchema.deriveUnion[T | U]) // union case

this would be more regular. Plus we need clear docs on when to use .responseSchema vs .deriveResponseSchema and how these compare

final case class Complaint(topic: String) derives Codec.AsObject, Schema
final case class GeneralQuery() derives Codec.AsObject, Schema

val intentSchema: ResponseSchema[Refund | Complaint | GeneralQuery] =

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

is ResponseSchema introduced earlier? Doesn't seem so - it might need a section on its own

Adds a Response schemas section to json-schemas.md before the union
section uses the type, and documents why unions need a dedicated
UnionResponseSchema.derive: no given Schema/Codec instances exist for
union types, and the discriminated wire shape couples schema and codec.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
@flsh86
flsh86 force-pushed the feature/union-response-schemas branch from cd51922 to b1eb7c1 Compare August 25, 2026 08:09
@adamw

adamw commented Aug 25, 2026

Copy link
Copy Markdown
Member

Automated review. Core logic verified correct (oneOf assembly, $defs merge, kind-stripping decoder, runtime dispatch, SchemaSupport $defs fix). Findings:

Coverage / docs

  1. docs/other/json-schemas.md:113 — docs say the union schema works on Gemini, but GeminiAgent sends it unmodified and no test covers that path. Verify live on Gemini or soften the claim.
  2. ResponseSchema.scala:46 — the description in the new oneOf(...) / derive[T](description) overloads is only sent by the OpenAI backend; Claude and Gemini drop it silently. The new docs don't mention this.

Hardening
3. SchemaSupport.scala:96 — the strict-mode folder now special-cases properties and $defs/definitions, but other name-to-schema containers (patternProperties, dependentSchemas, dependencies) still hit the generic branch — the same bug class this PR fixes for $defs. A shared helper over a key set would fix all of them.
4. UnionResponseSchema.scala:64 — the macro doesn't check that members are case classes, so derive[Refund | String] compiles and fails at runtime. A compile-time check would match the macro's other errors.
5. ResponseSchema.scala:110 — the kind-must-be-first invariant survives the Json → apispec Schema → Json round trip only because apispec happens to preserve property order. A dependency upgrade could silently demote kind. Assert kind-first where the schema is rendered, or keep the assembled Json end-to-end.

Cleanup
6. ResponseSchema.scala:108 — the encode → JsonObject surgery → decode round trip could stay in typed apispec.Schema (prepend kind to properties/required, clear $defs), removing ~25 lines and the "internal error" decode branch.
7. Variant.scala:32 — compile-time collision checks use typeSymbol.name, runtime labels parse Class.getName; these diverge for operator names, backticked digit names, and generics erasing to one class. The macro's advice to use Variant.named can't work for erasure-colliding generics (fails the duplicate-classes require).
8. ResponseSchema.scala:128 — variant decode failures lose the .result path prefix (the unknown-kind branch keeps it). Fix: df.history ::: result.history.
9. AgentTool.scala:39fromFunctionF still inlines TapirSchemaToJsonSchema(...) instead of calling the new renderTapirSchema helper.
10. ResponseSchema.scala:89 — the $defs merge fold hides its conflict require two closures deep, and JsonObject.fromMap makes $defs order nondeterministic. groupBy + ListMap is simpler and deterministic.

flsh86 and others added 2 commits August 25, 2026 10:39
ResponseSchema.derivedUnion[A | B] now sits next to ResponseSchema.derived,
via a version-specific companion parent trait (empty on Scala 2.13), and
the standalone UnionResponseSchema object is gone. Docs gain explicit
guidance on .responseSchema vs .deriveResponseSchema.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
- oneOf now builds the schema as typed apispec values: ListMap properties
  make the discriminator-first invariant structural instead of depending
  on a Json round trip preserving key order, the $defs merge is
  deterministic and readable, and the internal-error decode branch is gone
- the schema description is embedded in the root document so it reaches
  every provider, not only OpenAI (documented, incl. the derived caveat)
- variant decode failures are re-anchored under the .result path
- the macro rejects non-case-class members and same-generic-erasure
  members at compile time with dedicated errors
- the strict-mode folder treats all name-to-schema containers
  (patternProperties, dependentSchemas, ...) like $defs
- AgentTool.fromFunctionF uses the shared renderTapirSchema helper

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
@flsh86
flsh86 merged commit b663cf6 into master Aug 25, 2026
10 checks passed
@flsh86
flsh86 deleted the feature/union-response-schemas branch August 25, 2026 09:58
@flsh86 flsh86 linked an issue Aug 25, 2026 that may be closed by this pull request
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.

Structured intent classification with union-type response schemas

2 participants