fix(kosong,agent-core-v2): hoist type into anyOf/oneOf branches - #2662
fix(kosong,agent-core-v2): hoist type into anyOf/oneOf branches#2662vinlee19 wants to merge 1 commit into
Conversation
🦋 Changeset detectedLatest commit: 8a7d567 The changes in this PR will be included in the next version bump. This PR includes changesets to release 1 package
Not sure what this means? Click here to learn what changesets are. Click here if you're a maintainer who wants to add another changeset to this PR |
commit: |
There was a problem hiding this comment.
💡 Codex Review
Here are some automated review suggestions for this pull request.
Reviewed commit: f529a7d27e
ℹ️ About Codex in GitHub
Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you
- Open a pull request for review
- Mark a draft as ready
- Comment "@codex review".
If Codex has suggestions, it will comment; otherwise it will react with 👍.
Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".
645457c to
e943bc0
Compare
e943bc0 to
3e5de81
Compare
993e9d4 to
57892b2
Compare
|
@sailist PTAL when you get a chance — you know this normalizer best, so I'd value a sanity check on the root-flattening trade-off (a root union can't be expressed on the wire, so it widens into one object schema and leaves the exactly-one-of intent to the MCP server). Happy to rework it if you'd prefer a different shape. |
|
Thanks for the thorough fix — the normalizer gap is real, and the wire-log replay validation is solid. One consequence of the If you are open to it, there is a more complete version of this fix: while flattening, synthesize the lost variant information back into the schema description (e.g. enumerate each branch's required/optional fields as text — "Variant 1: filename + content; Variant 2: filename + source_url; Variant 3: source_file_id"), so the model keeps full guidance even when the original description says nothing about the variants. Happy to help sketch the details if that sounds worthwhile. |
57892b2 to
f4a8924
Compare
|
Thanks — you were right that the cost is real, and worse than I had assumed: in the Notion tool the open first branch forces Implemented your suggestion. Flattening now restates the variant structure in the description — the valid field combinations, plus the names and descriptions of fields that had to be dropped: It appends after whatever the tool author wrote, says "exactly one" for |
eefdeef to
96b161a
Compare
Moonshot's tool validator requires tools.function.parameters to carry type: "object" at the root and rejects type declared next to anyOf/oneOf, so a root-level union cannot be expressed at all. The whole tools[] array is validated per request, so one MCP server publishing such a root — the official Notion MCP server started doing so — made every prompt fail with a non-retryable 400. MCP inputSchema is forwarded verbatim and normalizeKimiToolSchema treated the root as a container and skipped combinator-bearing nodes, so nothing repaired the shape; the $ref sibling-key merge can synthesize it too. normalizeKimiToolSchema now runs two passes between $ref dereferencing and type completion. At the root, a union flattens into one typed object schema without narrowing what the schema accepts: conflicting branch property schemas merge into a nested anyOf of the distinct variants, a property any branch leaves unconstrained is omitted rather than pinned, and required keeps only fields every branch requires that survive in properties. Union members are classified conservatively — one that may match an arbitrary object, including an opaque combinator this cannot reason about, drops the branch-derived constraints rather than letting the remaining members narrow the root. Because that widening loses the variant structure the model needs, flattening restates it in the description: the valid field combinations, an unrestricted variant where one exists, and the names and descriptions of fields that had to leave properties. Nested unions instead move the parent type into the branches, intersecting declared types, judging enum/const members individually as values, splitting mixed-type enums per value type, and dropping dead or non-schema branches, relaxing to the parent constraint when a union empties. The MCP server still performs the real validation. Fixes MoonshotAI#2661
96b161a to
8a7d567
Compare
Related Issue
Resolve #2661 — bug: 400 "not a valid moonshot flavored json schema" when an MCP tool schema declares
typenext toanyOfProblem
The tool validator enforces two rules on
tools.function.parameters— no node may declaretypebesideanyOf/oneOf, and the root must carrytype: "object". Both were hit against the live endpoint:Together they leave a root-level union unrepresentable on the wire: moving the root
typeinto the branches satisfies the first rule and then trips the second.The whole
tools[]array is validated per request, so one MCP tool publishing a (perfectly valid) root-level union makes every prompt fail immediately with a non-retryable error — the CLI becomes unusable in any workspace that loads that server. This is happening in the wild: the official Notion MCP server started publishing such a root fornotion-create-attachmentaround 2026-08-04 (captured wire logs show a plain object root through 08-03 and the union root from 08-05).Kimi Code forwards MCP
inputSchemaverbatim, andnormalizeKimiToolSchematreated the root as a container and skipped every combinator-bearing node, so nothing could repair the shape.derefJsonSchema's$refsibling-key merge can also synthesize it from a root-level$ref, so the exposure is not vendor-specific. Full timeline and analysis in the linked issue.What changed
normalizeKimiToolSchemagains two passes between$refdereferencing and type completion, in both copies of the normalizer (packages/kosong/src/providers/kimi-schema.tsand the agent-core-v2 dialect copy, kept logic-identical):Root —
flattenRootUnion. A root union flattens into a single typed object root without narrowing what the schema accepts (the wire schema may only widen — the MCP server performs the final validation): the root's own properties stay authoritative; a property defined differently across branches becomes a nestedanyOfof the distinct variants (deduplicated by canonical JSON); a property is omitted rather than pinned whenever any branch leaves it unconstrained — open additional properties, apatternPropertiesbag that may cover the key (no regex evaluation), or a typeless schema that type completion would pin to a fabricated type (bareenum/constschemas keep their value-implied type); a branch that accepts any object (true,{}, a baretype: "object") drops the branch-derived constraints entirely;requiredkeeps the root's own list plus fields required by every branch, minus anything no longer inproperties(arequirednaming an absent property is itself rejected by the validator). The guarantee covers this step: the root's own property schemas keep whatever the long-standing type-completion pass does to them, union or not.Restating what widening loses. A flattened root only shows a bag of merged properties, so flattening appends the variant structure to the tool description — the valid field combinations, plus the names and descriptions of fields that had to be dropped from
properties. Without it the model is left guessing among merged fields with only the MCP server's rejection as a guard, which turns schema precision into failed tool-call round-trips. For the Notion tool this reads:Nested —
hoistCombinatorTypes(nestedanyOfwithout a siblingtypeis accepted by the validator): the parenttypemoves into the union branches and is dropped from the parent, preserving the constraint on every branch — typed branches narrow to the type intersection (foldinginteger ⊂ number); enum/const members are judged individually as values, filtering unsatisfiable ones (enum: [1, 1.5]under anintegerparent becomesenum: [1]) and splitting a mixed-type enum into one typed variant per value type;truebranches become the bare parent constraint; dead branches, explicitfalsebranches, and any other non-schema member are dropped, and a union left with no live branch is removed so the node relaxes to its parent constraint (neitheranyOf: []nor a boolean member is legal).Tests (written first, observed failing): unit coverage for both passes in
packages/kosong/test/providers/kimi-schema.test.tsandpackages/agent-core-v2/test/kosong/provider/kimi-schema.test.ts(the v2 copy previously had no dedicated coverage), plus an AJV differential suite asserting that every object the original schema accepts is still accepted after flattening.Verification: replayed all 102 tool schemas captured from a failing real session against the strict rules above — conforming typed object roots, no
typebeside a combinator, no empty or boolean union members, norequirednaming an absent property, no mixed-typeenum— 0 violations. The two new passes act on exactly 1 of the 102 (the rest carry neither a root union nor atypebeside a combinator, so they reach the type-completion pass untouched); full kosong suite,typecheckon both packages,lint:imports, and oxlint all pass. Verified live against the Moonshot endpoint with the affected MCP server connected: prompts complete normally instead of returning 400.Checklist
gen-changesetsskill, or this PR needs no changeset. (changeset added: patch for@moonshot-ai/kimi-code)gen-docsskill, or this PR needs no doc update. (bug fix, no doc change needed)