feat(go): send constrained output via ResponseJsonSchema, with LegacyResponseSchema opt-out - #6020
feat(go): send constrained output via ResponseJsonSchema, with LegacyResponseSchema opt-out#6020cabljac wants to merge 10 commits into
Conversation
… inference
Schema inference collapsed self-referential Go types to a lossy
{type:object, additionalProperties:true} schema via DoNotReference plus an
in-progress cycle breaker. Reflect with references enabled instead and inline
only the acyclic definitions (InlineAcyclicDefs), so acyclic types stay fully
inlined while genuinely recursive types keep $ref/$defs and round-trip as
proper recursive JSON Schema.
Schema inference now emits $ref/$defs for recursive types, which the legacy genai.Schema converter (also used for tool input schemas) would follow into infinite recursion. Track the $ref names on the active resolution path and collapse a re-entered reference to a generic object schema.
…nseSchema opt-out Constrained output now sends the raw JSON schema via GenerateContentConfig.ResponseJsonSchema, which supports recursion through $ref/$defs, instead of converting to the limited ResponseSchema. Add a LegacyResponseSchema flag on the GoogleAI and VertexAI plugins to restore the previous ResponseSchema path, mirroring the JS plugin's legacyResponseSchema option.
Demonstrates constrained output with a self-referential Go type (Employee org chart), which is only expressible because the Gemini plugin now sends the schema via ResponseJsonSchema with $ref/$defs.
… output Adds live subtests that exercise the new ResponseJsonSchema default path with a self-referential output type (an org chart), asserting the recursion round-trips instead of collapsing to an "any" schema. - GoogleAI: default path + a LegacyResponseSchema fallback sanity check. - VertexAI: default path — Vertex historically lags GoogleAI on schema fields, so this surfaces any responseJsonSchema rejection rather than the default flip failing silently in production. Env-gated like the existing live tests (skip without GEMINI_API_KEY / GOOGLE_CLOUD_PROJECT), so no CI cost.
…rtex Refines the live coverage added in the previous commit after running it against both backends: - VertexAI recursive constrained output: pinned to temperature 0, thinking off, a small output cap and a bounded prompt — confirmed PASS (the ResponseJsonSchema $ref/$defs path round-trips a self-referential type). - GoogleAI: dropped the recursive subtest. gemini-2.5-flash deterministically degenerates into a repetition loop on this self-referential schema and truncates at MAX_TOKENS — reproducible against the raw genai client with no genkit involved, so a model-level quirk, not a plugin fault. The default ResponseJsonSchema path is already covered for GoogleAI by the flat "constrained generation" test; recursion is covered by the Vertex suite. - GoogleAI: kept a flat LegacyResponseSchema fallback test (confirmed PASS). Env-gated like the other live tests, so no CI cost.
Ports the ResponseJsonSchema work onto the typed-config refactor that landed underneath it (#5849, #5862, #5869, #5874). - googlegenai: newModel/generate/toGeminiRequest keep main's typed *genai.GenerateContentConfig shape and gain a legacyResponseSchema argument. It reaches them through main's per-instance `catalog`, which already threads from the plugin struct to every path that builds an action; LegacyResponseSchema itself stays a public field on GoogleAI and VertexAI, mirroring the JS plugin's option. - base: main's new SchemaMapFor and ConvertToExact infer schemas as maps and walk them in place, so both go through InferJSONSchemaMap rather than the now-referenced InferJSONSchema. - status: Error.JSONSchema is spliced into the documents that embed an Error, where a "#/$defs/..." reference does not resolve, so it returns the new inlined form. - base: read a definition name by cutting the "#/$defs/" prefix instead of taking the last "/" segment. An instantiated generic is named after its type argument's import path, so the old reading left a dangling $ref for Operation[...] and AgentInit[...] once references were on.
There was a problem hiding this comment.
Code Review
This pull request transitions the Google AI and Vertex AI plugins to use the newer ResponseJsonSchema field by default for constrained outputs, enabling support for recursive and self-referential Go types via $ref and $defs. It introduces helper functions to inline acyclic definitions while preserving recursive cycles, adds a LegacyResponseSchema configuration option for backward compatibility, and includes robust tests and a recursive structured output sample. The review feedback highlights critical opportunities to improve robustness in go/plugins/googlegenai/schema.go by replacing unsafe type assertions on the items and properties schema fields with safe type assertions to prevent potential runtime panics.
| } | ||
| if v, ok := genkitSchema["items"]; ok { | ||
| items, err := toGeminiSchema(originalSchema, v.(map[string]any)) | ||
| items, err := toGeminiSchemaRec(originalSchema, v.(map[string]any), visited) |
There was a problem hiding this comment.
The type assertion v.(map[string]any) is unsafe and will panic if the items field in the schema is not a JSON object (for example, if it is a boolean or an array of schemas, which are valid in some JSON Schema drafts). Using a safe type assertion prevents potential runtime panics.
| items, err := toGeminiSchemaRec(originalSchema, v.(map[string]any), visited) | |
| m, ok := v.(map[string]any) | |
| if !ok { | |
| return nil, fmt.Errorf("items field is not a map") | |
| } | |
| items, err := toGeminiSchemaRec(originalSchema, m, visited) |
There was a problem hiding this comment.
Applied in 7fc1a27, returning an error rather than panicking. Worth noting for anyone reading this thread: these assertions are pre-existing on main, not introduced here, and the same commit covers three more with the same problem that this review missed, the description/format/title string assertions at schema.go:145-153.
| if val, ok := genkitSchema["properties"]; ok { | ||
| props := map[string]*genai.Schema{} | ||
| for k, v := range val.(map[string]any) { | ||
| p, err := toGeminiSchema(originalSchema, v.(map[string]any)) | ||
| p, err := toGeminiSchemaRec(originalSchema, v.(map[string]any), visited) | ||
| if err != nil { | ||
| return nil, err | ||
| } |
There was a problem hiding this comment.
The type assertions val.(map[string]any) and v.(map[string]any) are unsafe and will panic if the properties field or any of its property definitions are not JSON objects. Performing safe type assertions prevents runtime panics and improves robustness when handling malformed or unexpected schemas.
if val, ok := genkitSchema["properties"]; ok {
propertiesMap, ok := val.(map[string]any)
if !ok {
return nil, fmt.Errorf("properties field is not a map")
}
props := map[string]*genai.Schema{}
for k, v := range propertiesMap {
m, ok := v.(map[string]any)
if !ok {
return nil, fmt.Errorf("property %q is not a map", k)
}
p, err := toGeminiSchemaRec(originalSchema, m, visited)
if err != nil {
return nil, err
}There was a problem hiding this comment.
Covered by 7fc1a27, along with the items assertion above and the per-property value.
…n-object subschemas
toGeminiSchema asserted map[string]any on "items", "properties" and each
property value, and string on "description", "format" and "title". A boolean
schema ("items": true, {"x": false}) and a draft-07 items tuple are both valid
JSON Schema, and callers supply schemas directly through output schemas,
dotprompt files and tool inputs, so these reachable shapes took the process
down rather than failing the request.
Pre-existing on main; surfaced here because this change moves the recursion
through those same call sites.
…hemas Two regressions from enabling references in schema inference: requestInputSchema splices the config schema under the request's config slot, so a recursive config's "$defs" ended up nested while its "$ref" resolved against the document root. Every request to such an action failed validation with "Object has no key". Definitions are now hoisted to the root, at any depth, since the schema arrives already wrapped for null tolerance. stripRequired and tolerateNulls walked properties, items and additionalProperties but not "$defs", so a recursive config's fields stayed required and null-intolerant, breaking the partial-config contract. inlineRefs replaced a "$ref" node wholesale with its definition body, dropping sibling keywords. A struct-typed field with a description or title tag lost them, silently, in every inferred schema. Local keywords now win over the inlined body, per JSON Schema 2020-12. Adds regression coverage for the request path and for annotation preservation.
Migrates the Go Gemini plugin from
ResponseSchema, a limited OpenAPI 3.0 subset, toResponseJsonSchema, which takes raw JSON Schema with$ref/$defs. Recursive Go output types currently collapse to a lossy{type: object, additionalProperties: true}; they now round-trip properly.Fixes #5478.
Changes
go/internal/base:InferJSONSchemareflects with references enabled. NewInlineAcyclicDefsinlines non-recursive definitions, leaving only genuinely recursive types as$ref/$defs.go/plugins/googlegenai: path-scoped cycle guard intoGeminiSchema, shared with tool input schemas.go/plugins/googlegenai: constrained output usesResponseJsonSchemaby default, with aLegacyResponseSchemaflag restoring the old path, mirroring JS.go/samples/recursive-structured: sample.Refs by default with opt-in inlining means plugins whose APIs can't take
$refcallInlineAcyclicDefsthemselves. Inlining isn't cheaply reversible, so it belongs at the edge.Worth a closer look
Enum output also moved to
ResponseJsonSchema. The gate isConstrained && ResponseMIMEType != "", which catchestext/x.enum; JS gates onjsonModeso enum stays onresponseSchemathere. Untested against the live API. Happy to narrow the gate to match JS.Recursive schemas now reach plugins that can't express them. The root is a bare
$ref, and other consumers don't handle that:MapToStructinto Anthropic'sToolInputSchemaParamdrops it,EnforceStrictno-ops, MCP'sserver.goindexespropertiesdirectly so recursive tool inputs advertise none. Only googlegenai inlines. Flagging rather than burying it.LegacyResponseSchemarestores the wire field, not the old semantics. Validation stays strict while the model gets the laxer schema, and it doesn't cover tools, which still usetoGeminiSchema. Migrating tools toParametersJsonSchemafelt like a separate change.Incidental fixes
Enabling references surfaced latent issues in code that gained callers while this was in flight:
requestInputSchemasplices the config schema under the request's config slot, so a recursive config's$defsnested while its$refresolved against the root, failing every request to that action. Now hoisted.stripRequired/tolerateNullsdidn't descend into$defseither.inlineRefsreplaced a$refnode wholesale, so a struct-typed field lost itsdescription/title. Local keywords now win, per 2020-12.refNamemisparsed generics whose names embed an import path;status.Error.JSONSchema()returned a$refresolving against the wrong root.Separately,
toGeminiSchemapanicked on valid JSON Schema it can't express ("items": true, boolean property schemas, draft-07 tuples). Pre-existing on main, reachable from any caller-supplied schema, now returns errors.Testing
Unit coverage on the inlining, cycle guard, generic names, annotation preservation, malformed subschemas, both response branches, and a request-path regression test for recursive configs.
go test ./...green, vet and gofmt clean. No live call (needsGEMINI_API_KEY); enum on the new path untested either way.Predates the typed-config migration (#5849, #5862, #5869, #5874) and was brought up to date with a merge, so read the diff against
mainrather than the commits.