fix(jsonschema): preserve multi-branch anyOf/oneOf unions - #173
Conversation
_simplify_node() previously collapsed all anyOf/oneOf to the first non-null branch, which was correct for simple nullable patterns (T | None) but lossy for genuine unions like float | list[float] | None. Now only collapses when there is exactly one non-null branch. When there are 2+ non-null branches, they are preserved as anyOf with the null branch stripped. Ref: Oaklight/ToolRegistry#269
There was a problem hiding this comment.
Clean, well-scoped fix. The old behavior silently discarding all but the first non-null branch was a real correctness bug for genuine unions — good catch from the ToolRegistry case.
What I checked:
- Logic change in
_simplify_node(): single non-null → collapse (unchanged), multi non-null → preserve asanyOf(new). Correct. - Null-branch stripping +
nullable: truestill works for the multi-branch case. ✅ - Test coverage: 4 test cases cover the key matrix (with/without null × anyOf/oneOf input, plus the motivating
number | array[number] | nullcase). Solid. - Version bump 0.4.0 → 0.4.1 is appropriate for a patch-level bugfix.
- CI all green across 3.10–3.13.
One minor nit inline about oneOf → anyOf keyword preservation, but it's non-blocking given the LLM-tool-schema target audience.
LGTM 👍
| elif len(non_null) > 1: | ||
| # Lossy but safe for LLM tool schemas: keep first non-null variant. | ||
| base = _deep_merge_two(base, non_null[0]) | ||
| base["anyOf"] = non_null |
There was a problem hiding this comment.
Nit: this always emits anyOf even when the input was oneOf. In strict JSON Schema semantics they differ (oneOf = exactly one branch matches, anyOf = at least one). For LLM tool schemas it's a non-issue — providers treat them interchangeably — but if you ever want this simplifier to round-trip general schemas more faithfully, you could preserve the original keyword:
base[kw] = non_null # kw is already bound from the for-loopTotally non-blocking, just flagging for future awareness.
There was a problem hiding this comment.
Good call — fixed in ef8fc4f. Now uses base[keyword] to preserve the original oneOf/anyOf keyword. Updated the test assertion for the oneOf input case too.
There was a problem hiding this comment.
Clean, correct fix. The old behavior silently discarded all but the first non-null branch — a real correctness bug for genuine unions like float | list[float] | None.
What I checked
| Area | Status |
|---|---|
| Core logic change | ✅ Single non-null → collapse (unchanged); multi non-null → preserve as anyOf (new) |
Null-stripping + nullable: true |
✅ Works for both single and multi-branch cases |
| Tests | ✅ 4 cases covering with/without null × anyOf/oneOf, plus the motivating number | array[number] | null pattern |
| Version bump (0.4.0 → 0.4.1) | ✅ Appropriate for patch-level bugfix |
| CI (lint + 3.10–3.13) | ✅ All green |
Minor observation (non-blocking)
oneOf inputs are normalized to anyOf in the output (line 410). For LLM tool schemas this is a reasonable default — most providers handle anyOf better — but it's a semantic shift (oneOf = exclusive, anyOf = inclusive). If strict JSON Schema fidelity matters for other consumers, base[keyword] = non_null would preserve the original keyword. Clementine flagged the same nit; fine as-is for the target audience.
LGTM 👍
…h unions Address review nit from Clementine and Elena: use `base[keyword]` instead of hardcoded `base["anyOf"]` so oneOf inputs retain their keyword.
There was a problem hiding this comment.
Clean fix — the change is small and well-scoped.
What I checked:
_simplify_nodelogic: single non-null branch still collapses via_deep_merge_two; 2+ non-null branches now preserved under the original keyword (base[keyword] = non_null). The null-stripping +nullableflag path is unchanged. Correct.ef8fc4faddresses theoneOf→anyOfnormalization nit —keywordvariable is already in scope from theforloop, so this is the right minimal fix.- Test coverage: four multi-branch scenarios (with/without null,
anyOf/oneOf, heterogeneous types). All assert the original keyword is preserved. Good. - CI green on 3.10–3.13 + lint.
- Version bump 0.4.0 → 0.4.1: appropriate for a bugfix.
No issues found. LGTM.
Summary
Fix
_simplify_node()to preserve genuine multi-branchanyOf/oneOfunions instead of collapsing them to the first non-null branch.What changed
_simplify_node()previously unwrapped allanyOf/oneOfnodes to the first non-null branch. This was correct for simple nullable patterns likeT | Nonebut was lossy for genuine unions likefloat | list[float] | None— it would silently discard all branches except the first.Now the function checks the count of non-null branches:
anyOfVersion bumped from 0.4.0 to 0.4.1. Tests updated to cover multi-branch union behavior.
Ref: Oaklight/ToolRegistry#269