Derived output schemas and structured content (ADR 0005) - #27
Conversation
Tools can now be typed on the way out: deriveToolHandlerWithOutput (and ...WithOutputDescription) take a result record type, derive the tools' outputSchema from it using the same field rules as input derivation (primitives, Maybe, lists, all-nullary enums, nested records), and serialize the handler's typed values into structuredContent. The serializer is generated by the same TH walk as the schema, so what the schema promises is what the value contains — snake_cased enums, Maybe fields omitted when Nothing — and the two cannot drift. Handlers return the new ToolOutput type: - ToolOutput o: structured value; per the spec's recommendation the serialized JSON is also returned as a text content block for clients that predate structured output - ToolOutputWith [Content] o: structured value with caller-supplied content blocks - ToolOutputError Text: execution failure via isError - ToolOutputRaw ToolResult: full-control escape hatch The output type must resolve to a record (outputSchema is an object schema per spec) — rejected at TH time otherwise. Existing ToToolResult handlers are untouched; the plain derivations now share a generalized implementation with the output-typed ones. The conformance corpus's extended reference server gains an echo_structured tool derived through this machinery, so the corpus pins the exact wire shape: legacy/modern tools-call-structured cases and a legacy tools-list-extended case showing the serialized outputSchema. The v0.2.0-anchored fixtures are untouched. 161 test examples (was 151); verified on GHC 9.10.3 and 9.14.1. ADR_0005 marked Landed (0.2.1.0); joins the pending 0.2.1.0 line (additive, PVP minor).
drshade
left a comment
There was a problem hiding this comment.
Review verdict: one fix requested (canonical text-block encoding), one nit. The design is exactly ADR 0005 — and generating the serializer from the same TH walk as the schema is the right way to make the schema/value agreement structural rather than hoped-for. The ToolOutput four-form API is clean (plain/with-content/error/raw covers the space without a typeclass), the spec-recommended text block appears exactly when the handler didn't supply content, TH-time rejection of non-record output types matches the ADR, and unifying deriveToolHandler* through one generic implementation pays down duplication rather than adding to it. Test coverage hits every form plus the schema shape details, and the corpus deliberately pins the TH wire shape with a derived tool. 161/161 locally; CI pending as I write this — merge will wait for green as usual.
| ToolOutputError msg -> toolError msg | ||
| ToolOutputRaw result -> result | ||
| where | ||
| jsonText = TE.decodeUtf8 . BSL.toStrict . encode |
There was a problem hiding this comment.
The requested fix: make the text block's JSON canonical (sorted keys). encode's object key order depends on the aeson/hashable pair in the build plan — which is exactly why GoldenWire compares parsed Values. But this string is JSON inside a string: the corpus fixture tools-call-structured.response.json embeds "{\"echoedLength\":6,\"echoedText\":\"golden\"}" literally, and parsed-Value comparison can't see through it. If a future build plan resolves a different hashable, the text block's key order changes and the fixture breaks — worse, for external corpus consumers the expected bytes would be aeson-implementation-specific, undermining the corpus's neutrality.
Fix is small: encode the text block from a canonically-ordered structure, e.g. a recursive Value -> Value that rebuilds every object via sorted KeyMap.toList before encode (aeson encodes KeyMap.fromList of sorted pairs in that order... safer still, convert objects to Data.Map Text Value recursively and encode that — Map's ToJSON is ordered by key). Deterministic output has a side benefit the spec explicitly cares about: stable tool results improve client prompt-cache hit rates.
Apply it only to the text block (structuredContent itself is compared structurally everywhere and can stay as-is), regenerate the two tools-call-structured fixtures, and ideally note in the corpus README that embedded-JSON strings are canonical (sorted keys).
| body <- [| object (concat $(return $ ListE fieldExps)) |] | ||
| return $ LamE [VarP rVar] body | ||
| where | ||
| fieldPairs rVar (fieldName, _, fieldType) = do |
There was a problem hiding this comment.
Nit, fix or defer: the record serializer applies field accessors ($(varE fieldName) $(varE rVar)), which breaks for users with DuplicateRecordFields enabled (bare accessor application is ambiguous there, and duplicate field names across records are increasingly common in the codebases that would want typed outputs). The rest of the TH machinery never relies on accessors — input decoding constructs values, and your own enum/wrapper cases here pattern-match. Building this the same way — one conP binding every field to fresh names — makes the serializer immune:
-- \(Con f0 f1 ...) -> object (concat [...pairs from f0, f1...])Same generated semantics, no accessor lookup.
…ializer - The structured-output text block is now encoded canonically (compact, object keys sorted, via an encoding-only wrapper that emits objects through a sorted Map): encode's key order depends on the aeson/hashable pair in the build plan, and this JSON lives inside a string, where the corpus's parsed-Value comparison cannot see through — so the bytes must be deterministic for the fixtures and for external corpus consumers. Deterministic results also keep repeated tool outputs stable for client prompt caching. The two structured corpus fixtures are regenerated and the corpus README documents the embedded-JSON canonical form. A unit test pins the exact bytes. - The generated record serializer binds fields by pattern-matching the constructor instead of applying accessors, so it works for users with DuplicateRecordFields enabled — consistent with the rest of the TH machinery, which never relies on accessors. 162 test examples.
|
Both addressed in 304f069 (162 examples):
|
drshade
left a comment
There was a problem hiding this comment.
Approved. Both points verified on 304f069: the Canonical encoding-only newtype makes the embedded text-block JSON byte-deterministic across build plans (sorted keys via Data.Map's ordered toEncoding, aeson escaping reused), with the guarantee documented in the corpus README and pinned by an exact-bytes unit test; the record serializer now pattern-binds fields so DuplicateRecordFields users are safe. 162/162 tests locally, CI green. The typed-in/typed-out story is complete — merging. ADR_0005 status flip to Landed confirmed in the diff.
Implements ADR 0005: the highest-leverage Batch 1 roadmap item, completing the library's thesis — typed in, typed out.
What it adds
outputSchemavia the existingSchemamachinery (primitives,Maybe→optional, lists, all-nullary enums, nested records) — shared with input derivation per the ADR, so future schema-keyword work benefits both directions.Nothingfields omitted). No reliance on userToJSONinstances that could drift.ToolOutputcarries the spec-recommended behavior: a plainToolOutputalso returns the serialized JSON as a text content block for pre-structured-output clients;ToolOutputWithoverrides the content;ToolOutputErrormaps toisError;ToolOutputRawis the escape hatch.ToToolResulthandlers are untouched;deriveToolHandler(WithDescription)now share a generalized implementation with the output-typed variants rather than duplicating the dispatch generation.Verification
Nothingomission, all fourToolOutputforms.echo_structured— derived through this machinery so the corpus pins the exact TH wire shape — withtools-call-structuredcases in both eras andtools-list-extendedshowing the serializedoutputSchema. Corpus README updated; v0.2.0-anchored fixtures untouched.Versioning
Purely additive (new exports, no changed signatures) → joins the pending 0.2.1.0 line per the release policy. ADR_0005 marked Landed (0.2.1.0).