Repository navigation
Description
fix(ai): Gemini schema normalize only cuts single-def $ref loops — mutually recursive $defs through required properties reach Gemini uncut
Summary
GeminiJsonSchema.normalize (packages/ai/src/protocols/utils/gemini-json-schema.ts) is supposed to make tool schemas safe for Gemini's parametersJsonSchema: per the module's own comment (lines 45–46), "Gemini accepts a recursive $ref only when the loop passes through an optional property or potentially empty array items", and cutLoops replaces other self-references with an unconstrained schema {}.
The cutting only ever tracks one target at a time: normalize (lines 63–76) runs one pass per $defs/definitions entry with that entry's own target (#/$defs/<name>), plus one pass for the root target #. cutLoops walks the literal schema tree and never resolves a $ref into another def's body — so a cycle that spans two or more defs is invisible to every pass. A mutually recursive pair whose steps all pass through required properties (or items with minItems > 0) is sent to Gemini exactly as written, in precisely the shape the module exists to prevent.
Evidence (verified on origin/main ae5d592, laptop probe ~/git/fleet/probe-2026-11-03-gemini-mutual, GeminiJsonSchema.normalize run under bun)
Control — direct self-loop through a required property is cut, as intended:
- Input
$defs.node.properties.child = { $ref: "#/$defs/node" },required: ["child"] - Output:
childbecomes{}.
Bug — mutual recursion, every step required, survives intact:
- Input:
$defs.A = { properties: { b: { $ref: "#/$defs/B" } }, required: ["b"] },$defs.B = { properties: { a: { $ref: "#/$defs/A" } }, required: ["a"] }, rootproperties.root = { $ref: "#/$defs/A" } - Output is byte-for-byte the same cycle: A still requires a B, B still requires an A. No step in the cycle passes through an optional property or a potentially empty
items, so by the module's stated rule Gemini does not accept this shape.
Control — the same mutual pair with the A→B step optional is (correctly) kept, because the loop can terminate:
Awithoutrequired: ["b"]→ output keeps both refs. Any fix must preserve this case (and theitems/minItemsanalogue).
The same gap applies to cycles of length ≥ 2 through definitions (same code path), and to mixed cycles where the inter-def steps are items with minItems > 0.
Why it matters
This projection runs on every Gemini-family tool schema (ToolSchemaProjection.gemini, selected in packages/ai/src/protocols/utils/tool-schema.ts:49 and applied to all tools before a request is built). Tools with recursive data models (trees, nested comments, ASTs, org charts) commonly split the recursion across two named defs; when every link is required, the uncut cycle reaches Gemini unaltered — the class of schema the normalizer was written to keep off the wire — instead of degrading to the unconstrained-{} fallback the single-def case gets.
Acceptance criteria
GeminiJsonSchema.normalizecuts (replaces with{}) at least one$refin any cycle whose steps never pass through an optional property or a potentially emptyitems— including cycles spanning two or more$defs/definitionsentries.- Cycles that DO pass through an optional property or a potentially empty
itemsat any step are preserved unchanged (existing controls above keep passing). - Existing single-def and root-
#loop behavior is unchanged. - New unit tests in
packages/ai(e.g. alongsidetest/tool-schema-projection.test.ts) cover: mutual A↔B required (cut), mutual A↔B with one optional step (kept), and a 3-def required cycle (cut).
Activity
dezren39 commentedon Nov 4, 2026
🤖 Agent note · filed
dezren39 commentedon Nov 4, 2026
🤖 Agent note · picked-up
$refcycles in Gemini schema normalization.queue/9004-1585-gemini-ref-cycles}dezren39 commentedon Nov 4, 2026
PR #1643 opened — task 9004 (lane opencode-a), parked on CI/Codex:
normalizenow breaks required-only$refcycles spanning multiple$defs/definitionsentries (hard-edge def graph +cutLoopsper cycle edge); safe cycles and single-def/root behavior unchanged. New tests cover mutual/3-def/items/definitions cases — 18/18 with the projection suite, tsgo clean. Resume: disposition Codex, verify CI, merge, complete task 9004, clean labels here.Merge pull request #1643 from developing-today-forks/queue/9004-1585-…
dezren39 commentedon Nov 4, 2026
PR #1643 merged (merge commit a65782db1a). Task 9004 completed. Re-verified at merge time at the PR tree: gemini-json-schema tests pass and the full packages/ai suite is green (0 fail, 1934 tests); mergeable clean. No Codex review arrived despite the request (repo-wide pattern; zero findings to disposition), so the merge proceeded under the standing merge directive.
agent-note:v1 — repo-queue-worker-shift-opencode lane A, task 9004 done
dezren39 commentedon Nov 4, 2026
Correction: the merge commit for PR #1643 on main is 6a6e2fb (verified in main's log this shift); the a65782db1a SHA quoted in the completion note was a transient inconsistent API response at merge time. The merge itself is confirmed on main.
agent-note:v1 — repo-queue-worker-shift-opencode lane A