Skip to content

fix(kosong,agent-core-v2): hoist type into anyOf/oneOf branches - #2662

Open
vinlee19 wants to merge 1 commit into
MoonshotAI:mainfrom
vinlee19:fix/kimi-schema-anyof-type-sibling
Open

fix(kosong,agent-core-v2): hoist type into anyOf/oneOf branches#2662
vinlee19 wants to merge 1 commit into
MoonshotAI:mainfrom
vinlee19:fix/kimi-schema-anyof-type-sibling

Conversation

@vinlee19

@vinlee19 vinlee19 commented Aug 5, 2026

Copy link
Copy Markdown
Contributor

Related Issue

Resolve #2661bug: 400 "not a valid moonshot flavored json schema" when an MCP tool schema declares type next to anyOf

Problem

The tool validator enforces two rules on tools.function.parameters — no node may declare type beside anyOf/oneOf, and the root must carry type: "object". Both were hit against the live endpoint:

Error: [provider.api_error] 400 tools.function.parameters is not a valid moonshot flavored json schema,
details: <At path 'root': when using anyOf, type should be defined in anyOf items instead of the parent schema>
Error: [provider.api_error] 400 tools.function.parameters.type is required and must be "object"

Together they leave a root-level union unrepresentable on the wire: moving the root type into 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 for notion-create-attachment around 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 inputSchema verbatim, and normalizeKimiToolSchema treated the root as a container and skipped every combinator-bearing node, so nothing could repair the shape. derefJsonSchema's $ref sibling-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

normalizeKimiToolSchema gains two passes between $ref dereferencing and type completion, in both copies of the normalizer (packages/kosong/src/providers/kimi-schema.ts and 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 nested anyOf of the distinct variants (deduplicated by canonical JSON); a property is omitted rather than pinned whenever any branch leaves it unconstrained — open additional properties, a patternProperties bag that may cover the key (no regex evaluation), or a typeless schema that type completion would pin to a fabricated type (bare enum/const schemas keep their value-implied type); a branch that accepts any object (true, {}, a bare type: "object") drops the branch-derived constraints entirely; required keeps the root's own list plus fields required by every branch, minus anything no longer in properties (a required naming 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:

    Valid argument variants (at least one must match): (1) required: filename, content;
    optional: content_type. (2) required: filename, source_url; optional: content_type.
    (3) required: source_file_id.
    
    Fields without their own schema entry:
    - source_url — A direct, publicly reachable HTTPS URL from which Notion can download …
    - source_file_id — The ID of a file upload this exact integration already created …
    
  • Nested — hoistCombinatorTypes (nested anyOf without a sibling type is accepted by the validator): the parent type moves into the union branches and is dropped from the parent, preserving the constraint on every branch — typed branches narrow to the type intersection (folding integer ⊂ number); enum/const members are judged individually as values, filtering unsatisfiable ones (enum: [1, 1.5] under an integer parent becomes enum: [1]) and splitting a mixed-type enum into one typed variant per value type; true branches become the bare parent constraint; dead branches, explicit false branches, 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 (neither anyOf: [] nor a boolean member is legal).

Tests (written first, observed failing): unit coverage for both passes in packages/kosong/test/providers/kimi-schema.test.ts and packages/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 type beside a combinator, no empty or boolean union members, no required naming an absent property, no mixed-type enum — 0 violations. The two new passes act on exactly 1 of the 102 (the rest carry neither a root union nor a type beside a combinator, so they reach the type-completion pass untouched); full kosong suite, typecheck on 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

  • I have read the CONTRIBUTING document.
  • I have linked a related issue, or explained the problem above.
  • I have added tests that prove my feature works.
  • Ran gen-changesets skill, or this PR needs no changeset. (changeset added: patch for @moonshot-ai/kimi-code)
  • Ran gen-docs skill, or this PR needs no doc update. (bug fix, no doc change needed)

@changeset-bot

changeset-bot Bot commented Aug 5, 2026

Copy link
Copy Markdown

🦋 Changeset detected

Latest commit: 8a7d567

The changes in this PR will be included in the next version bump.

This PR includes changesets to release 1 package
Name Type
@moonshot-ai/kimi-code Patch

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

@pkg-pr-new

pkg-pr-new Bot commented Aug 5, 2026

Copy link
Copy Markdown
pnpm dlx https://pkg.pr.new/@moonshot-ai/kimi-code@8a7d567
npx https://pkg.pr.new/@moonshot-ai/kimi-code@8a7d567

commit: 8a7d567

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

💡 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".

Comment thread packages/agent-core-v2/src/kosong/provider/providers/kimi/kimi-schema.ts Outdated
Comment thread packages/kosong/src/providers/kimi-schema.ts Outdated
@vinlee19

vinlee19 commented Aug 6, 2026

Copy link
Copy Markdown
Contributor Author

@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.

@sailist

sailist commented Aug 6, 2026

Copy link
Copy Markdown
Collaborator

Thanks for the thorough fix — the normalizer gap is real, and the wire-log replay validation is solid.

One consequence of the flattenRootUnion approach is worth spelling out explicitly: the wire schema is widened, so the "exactly one of" constraint survives only in whatever prose the tool author happened to put in the root description. Branch-level descriptions and the variant structure itself are dropped, and nothing synthesizes a replacement. For tools with a thin or vague description, the model is left guessing among the merged properties, and the only remaining guard is the MCP server rejecting invalid combinations — which turns schema precision into extra failed tool-call round-trips. That is an acceptable trade-off to unbrick the CLI, but it is a real cost.

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.

@vinlee19
vinlee19 force-pushed the fix/kimi-schema-anyof-type-sibling branch from 57892b2 to f4a8924 Compare August 6, 2026 14:41
@vinlee19

vinlee19 commented Aug 6, 2026

Copy link
Copy Markdown
Contributor Author

Thanks — you were right that the cost is real, and worse than I had assumed: in the Notion tool the open first branch forces source_url and source_file_id out of properties entirely (constraining them would narrow the open branch), so the model could not see two of the three variants' key fields at all.

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:

Provide exactly one of content for inline UTF-8 text, source_url for a direct HTTPS download, …

Valid argument variants (at least one must match): (1) required: filename, content; optional: content_type. (2) required: filename, source_url; optional: content_type. (3) required: source_file_id.

Fields without their own schema entry:
- source_url — A direct, publicly reachable HTTPS URL from which Notion can download the file within one minute. …
- source_file_id — The ID of a file upload this exact integration already created, …

It appends after whatever the tool author wrote, says "exactly one" for oneOf and "at least one" for anyOf, and is skipped when there is nothing to disambiguate (a single branch, or a branch that accepts any object). Tests cover the wording, the dropped-field list, and the skip cases; squashed into the same commit.

@vinlee19
vinlee19 force-pushed the fix/kimi-schema-anyof-type-sibling branch 2 times, most recently from eefdeef to 96b161a Compare August 6, 2026 16:20
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
@vinlee19
vinlee19 force-pushed the fix/kimi-schema-anyof-type-sibling branch from 96b161a to 8a7d567 Compare August 7, 2026 02:10
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

bug: 400 "not a valid moonshot flavored json schema" when an MCP tool schema declares type next to anyOf

2 participants