Skip to content

fix(ai): Gemini schema normalize only cuts single-def $ref loops — mutually recursive $defs through required properties reach Gemini uncut #1585

Closed

Description

@dezren39

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: child becomes {}.

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"] }, root properties.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:

  • A without required: ["b"] → output keeps both refs. Any fix must preserve this case (and the items/minItems analogue).

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.normalize cuts (replaces with {}) at least one $ref in any cycle whose steps never pass through an optional property or a potentially empty items — including cycles spanning two or more $defs/definitions entries.
  • Cycles that DO pass through an optional property or a potentially empty items at 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. alongside test/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

  1. added
    kind/bugSomething isn't working (prefixed form of the classic bug label)
    task/small-fixQueue packet kind: bounded fix with reproduction and targeted tests
    source/creatorFiled by the new-issue-creator job
    priority/normalOrdinary priority (the default)
    queue/queuedHas a queued task in the Supabase agent queue
    app/aiPackage: ai (pkgs/ or apps/ in this repo)
    effort/sSmall: expected under 2 hours of work
    on Nov 4, 2026
  2. dezren39 commented on Nov 4, 2026

    @dezren39
    Author

    🤖 Agent note · filed

    • Agent: new-issue-creator-opencode (new-issue-creator cron)
    • Session / ID: new-issue-creator-opencode run 2026-11-03 19:35 CST
    • Found via: filed this run
    • Task: GeminiJsonSchema.normalize loop-cutting gap — mutually recursive $defs through required properties reach Gemini uncut; verified by laptop probe against origin/main ae5d592
    • Expectation: effort/s — done when multi-def cycles with no optional/empty-items step are cut, escapable cycles are preserved, and unit tests cover both
    • Links: evidence in the issue body; probe on laptop ~/git/fleet/probe-2026-11-03-gemini-mutual
  3. added
    work/assignedAn agent holds this item via the queue (claimed task)
    queue/claimedA queue worker has claimed the task for this item
    and removed
    queue/queuedHas a queued task in the Supabase agent queue
    on Nov 4, 2026
  4. dezren39 commented on Nov 4, 2026

    @dezren39
    Author

    🤖 Agent note · picked-up

  5. dezren39 commented on Nov 4, 2026

    @dezren39
    Author

    PR #1643 opened — task 9004 (lane opencode-a), parked on CI/Codex: normalize now breaks required-only $ref cycles spanning multiple $defs/definitions entries (hard-edge def graph + cutLoops per 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.

  6. added
    queue/parkedQueue task is parked (waiting on CI, time, or an event)
    queue/claimedA queue worker has claimed the task for this item
    and removed
    queue/claimedA queue worker has claimed the task for this item
    queue/parkedQueue task is parked (waiting on CI, time, or an event)
    on Nov 4, 2026
  7. added a commit that references this issue on Nov 4, 2026
  8. removed
    queue/claimedA queue worker has claimed the task for this item
    work/assignedAn agent holds this item via the queue (claimed task)
    on Nov 4, 2026
  9. dezren39 commented on Nov 4, 2026

    @dezren39
    Author

    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

  10. dezren39 commented on Nov 4, 2026

    @dezren39
    Author

    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

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

Metadata

Metadata

Assignees

No one assigned

    Labels

    app/aiPackage: ai (pkgs/ or apps/ in this repo)area/typescriptTypeScript code areaeffort/sSmall: expected under 2 hours of workkind/bugSomething isn't working (prefixed form of the classic bug label)priority/normalOrdinary priority (the default)source/creatorFiled by the new-issue-creator jobtask/small-fixQueue packet kind: bounded fix with reproduction and targeted tests

    Type

    No type

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions