fix: stop merging anyOf and oneOf into a single widened union - #4303
fix: stop merging anyOf and oneOf into a single widened union#4303abhay-codes07 wants to merge 1 commit into
Conversation
When a schema node carries both anyOf and oneOf, strict conversion
folded the oneOf variants into the existing anyOf list. Those are two
constraints that must BOTH hold, so the fold turns a conjunction into a
union and the converted schema accepts values the original rejects:
{"anyOf": [string, integer], "oneOf": [string, boolean]}
accepts "hello" only, since 5 fails the oneOf and true fails the anyOf.
After merging, the schema is anyOf [string, integer, string, boolean]
and both 5 and true validate. For a function tool or output type this
widens the declared contract: the provider constrains generation to the
converted schema, so the model can produce arguments or outputs the
original schema forbids and nothing downstream notices.
The prior expectation in test_oneof_merged_with_existing_anyof made the
problem concrete: its input schema accepts no value at all, a string
fails its oneOf and an integer fails its anyOf, yet the merged form
accepted strings, integers and booleans alike.
The combination now fails conversion the way other inexpressible
schemas do. Callers that can degrade, such as MCP tool conversion, fall
back to serving the original schema as non-strict, which preserves both
constraints; function tools and output types get an actionable error.
The documented oneOf-to-anyOf conversion for discriminated unions is
unchanged when no competing anyOf exists, and an empty anyOf list is
still replaced, since it constrains nothing.
|
Thanks for identifying the semantic widening here. The core observation is correct, but we do not want to merge another isolated condition into the current The proposed exception for We are going to address this at the strict-conversion contract level: accept only known meaning-preserving normalizations, reject ambiguous or unsupported compositions before mutation, and validate the converted result against the supported Structured Outputs subset while preserving MCP's non-strict fallback. I am going to close this PR as a standalone fix, but we will reuse its mixed-union regression case in that work. Thank you for the careful investigation. |
Summary
When a schema node carries both
anyOfandoneOf, strict conversion folds theoneOfvariants into the existinganyOflist. Those are two constraints that must BOTH hold, so the fold turns a conjunction into a union, and the converted schema accepts values the original rejects.Checked against a JSON Schema validator:
{"anyOf": [{"type": "string"}, {"type": "integer"}], "oneOf": [{"type": "string"}, {"type": "boolean"}]}"hello"5oneOf)trueanyOf)3.14For a function tool or output type this widens the declared contract: the provider constrains generation to the converted schema, so the model can produce arguments or outputs the original schema forbids, and nothing downstream notices.
The prior expectation in
test_oneof_merged_with_existing_anyofmakes the problem concrete: its input schema accepts no value at all (a string fails itsoneOf, an integer fails itsanyOf), yet the merged form accepted strings, integers and booleans alike.Fix
The combination now fails conversion the way other inexpressible schemas do, with an actionable message. Callers that can degrade take their existing fallback: an MCP tool with such a schema is served non-strict with both constraints intact:
There is no meaning-preserving strict form to emit instead: computing the actual conjunction of two schema lists is not generally possible, and any flattening picks one constraint over the other.
Unchanged behaviour, pinned by tests that pass both before and after:
oneOfalone still converts toanyOf, the documented discriminated-union pathoneOfnext to an emptyanyOflist still converts, since an empty list constrains nothingoneOfconversions are untouchedTest plan
test_anyof_and_oneof_on_the_same_node_are_rejected_not_mergedfails onmainand passes here.test_oneof_merged_with_existing_anyofasserted the widened merge and is replaced bytest_oneof_alongside_anyof_is_rejected_rather_than_merged, with the unsatisfiable-input observation recorded in the test.make format/make lintmake mypymainmake pyrightuv run pytest tests/test_strict_schema.py tests/test_strict_schema_oneof.pymake testsmainat the same commitThis is independent of #4277: it changes only the
oneOfblock, which that PR does not modify, and the defect exists onmaintoday. If both land, whichever goes second rebases trivially and I will handle that.Issue number
None reported; found while auditing the strict conversion for other silent semantic changes.
Checks
.agents/skills/code-change-verification/scripts/run.sh/reviewbefore submitting this PRThe verification script shells out to
make; I ran the underlying steps individually, with the results above.