Skip to content

fix(moonshot): re-normalize composed properties and infer MFJS object/scalar types - #5464

Closed
yeongjunyoo wants to merge 2 commits into
lidge-jun:devfrom
yeongjunyoo:fix/moonshot-recursive-schema-termination
Closed

yeongjunyoo wants to merge 2 commits into
lidge-jun:devfrom
yeongjunyoo:fix/moonshot-recursive-schema-termination

Conversation

@yeongjunyoo

@yeongjunyoo yeongjunyoo commented Sep 21, 2026

Copy link
Copy Markdown
Contributor

Summary

Fixes Moonshot/Kimi 400 schema validation failures ( ools.function.parameters is not a valid moonshot flavored json schema, details: <At path '...': detected infinite recursion without termination condition>) when complex recursive or composed tool parameters (such as MCP tools with �llOf and sibling $refs) are passed through the openai-chat adapter.

Root Causes

  1. Composed sibling refs: When composeProperties merged a referenced target and a sibling that narrowed one of its properties (e.g., adding a const assertion beside a target property that carried a $ref), the composed property retained the $ref alongside the sibling keyword without being re-normalized. This produced an un-normalized sibling-$ref node that violated Moonshot's strict Draft-07 validator.
  2. Missing ype: "object" on composition schemas: Moonshot's schema validator (walle) inspects recursive �nyOf unions to verify that a termination condition (base case) exists. If an object schema candidate in $defs uses �llOf without declaring ype: "object" at that node, walle fails to recognize it as an object termination condition and rejects the entire union as infinite recursion.
  3. Missing ype on scalar const and enum: Moonshot Flavored JSON Schema (MFJS) requires properties declaring const or enum to explicitly declare their scalar ype (e.g., "type": "string"), otherwise rejecting or failing to validate them.

Changes

  • In
    ormalizeMoonshotSchemaNode, target re-normalization specifically to composed properties that still retain a $ref alongside sibling keywords, avoiding unnecessary whole-node re-traversals that exhaust node/depth budgets.
  • Stamp ype: "object" when properties or �dditionalProperties are present, or when �llOf defines object properties/variants, while preserving non-object (e.g. string/numeric) �llOf compositions without an erroneously stamped object type.
  • Infer scalar ype (string,
    umber, �oolean) for un-typed const and enum keywords.
  • Add regression test coverage in ests/providers/moonshot-tool-schema.test.ts including boolean enum inference and string �llOf type preservation.

Verification

  • Rebased onto latest dev at 81413a17; working tree clean.
  • Unit tests: �un test tests/providers/moonshot-tool-schema.test.ts passed (20 passed, 0 failed).
  • Typecheck: �un run typecheck passed cleanly without errors.
  • Structural integrity: �un run structure:check passed.
  • Privacy scan: �un run privacy:scan passed.
  • End-to-end verification: Verified against the real Moonshot API (https://api.kimi.com/coding/v1/chat/completions) using the production Kimi k3 model with the exact complex MCP tool schema that originally triggered the 400 error. The request succeeded with HTTP 200 OK.

Checklist

  • Scope stays focused and avoids unrelated cleanup.
  • Docs or release notes were updated when needed.
  • Security-sensitive changes were reviewed for secrets, auth, and unsafe defaults.

Review readiness checklist

This PR stays in draft until every box below is ticked. Tick all four boxes once the requirements are met:

  • All CI tests are green on my local testing.

  • I pushed my PR to the latest dev commit.

  • I resolved all correct Codex and CodeRabbit findings.

  • My PR is ready for review.

…/scalar types

When composeProperties merges a referenced target and a sibling that narrows
one of its properties (for example, supplying a const beside a $ref), the
composed property retained the $ref alongside the sibling keyword without
being re-normalized. This produced an un-normalized sibling-$ref node that
violated Moonshot's strict Draft-07 schema validator.

Additionally, Moonshot's schema validator (walle) requires object definitions
(especially those composed with allOf or properties and used as base cases in
recursive anyOf unions) to explicitly declare type: "object". Without an
explicit type, walle fails to recognize the schema as a valid termination
condition, rejecting valid recursive tools with:
`detected infinite recursion without termination condition`.

1. Recursively re-normalize the merged node in normalizeMoonshotSchemaNode so
   composed sibling refs are resolved.
2. Infer type: "object" for schemas containing properties or allOf when type is
   omitted.
3. Infer scalar types for un-typed const and enum keywords.
4. Add regression coverage in tests/providers/moonshot-tool-schema.test.ts.
@coderabbitai

coderabbitai Bot commented Sep 21, 2026

Copy link
Copy Markdown
Contributor

Important

Draft PR not reviewed

Draft PRs are not automatically reviewed by default.

  • Trigger a manual review

To automatically review draft PRs, update your CodeRabbit configuration:

reviews:
  auto_review:
    drafts: true

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands.

@github-actions

Copy link
Copy Markdown
Contributor

Deterministic PR hygiene checks passed.

@github-actions github-actions Bot added the bug Something isn't working label Sep 21, 2026
@github-actions

github-actions Bot commented Sep 21, 2026

Copy link
Copy Markdown
Contributor

⏳ DRAFT

  • review readiness checklist open (0/4 boxes ticked).

What to do

  • Tick all four boxes in the PR description once you're done (currently 0/4).

Review readiness checklist

  • ⬜ All CI tests are green on my local testing.
  • ⬜ I pushed my PR to the latest dev commit.
  • ⬜ I resolved all correct Codex and CodeRabbit findings.
  • ⬜ My PR is ready for review.

0/4 boxes ticked.

This PR stays in draft until every box above is ticked.

Hygiene

Deterministic PR hygiene checks passed.

@lidge-jun

Copy link
Copy Markdown
Owner

리뷰 · 우선순위 58 / 80

이 PR은 Kimi에 도구 설명을 보낼 때 거절나던 경우를 고칩니다. Kimi는 도구의 입력 모양을 엄격히 봅니다. 모양이 자기 규칙과 다르면 400을 주고, 그 턴이 실패합니다. 본문에 적힌 거절 문구는 detected infinite recursion without termination condition입니다.

걸린 모양은 두 가지입니다. 하나는 $ref(다른 정의를 가리키는 표시) 옆에 const 같은 조건이 같이 있는 칸입니다. Kimi는 그 둘을 한 칸에 두지 못합니다. 참조를 펼친 뒤 같은 이름 속성을 합치면, 합쳐진 칸에 $ref가 그대로 남았습니다. 다른 하나는 객체인데 type이 비어 있는 정의입니다. 재귀 도구에서 Kimi는 그걸 끝이 있는 객체로 알아보지 못하고, 끝이 없는 재귀로 거절합니다.

고친 곳은 normalizeMoonshotSchemaNode입니다. 참조와 옆 조건을 합친 다음, 합친 결과를 같은 함수로 한 번 더 돌립니다. type이 없으면 propertiesallOf가 있을 때 object를 넣고, constenum에는 글자·숫자·참거짓을 넣습니다. 테스트 두 개가 이 기대를 고정합니다. base는 dev입니다. 아직 드래프트이고, 준비 체크는 4칸 중 2칸입니다.

라인 src/adapters/openai-chat/tool-schema.ts 405-407 - allOf가 있으면 무조건 typeobject로 찍습니다. allOf는 객체 전용 문법이 아닙니다. { allOf: [{ type: "string" }, { minLength: 1 }] }처럼 글자 조건을 겹친 스키마도 이 칸에 들어옵니다. 그러면 "객체이면서 글자"가 됩니다. 원래 통과하던 도구가 다시 400이 나거나, 모델이 넣을 값이 없어집니다. 새 테스트는 안에 properties가 있는 allOf만 보고, 글자 allOf는 보지 않습니다.

라인 src/adapters/openai-chat/tool-schema.ts 413-418 - const: trueboolean이 됩니다. enum: [true, false]는 타입이 그대로 비어 있습니다. 같은 함수가 const의 참거짓은 알아보고 enum의 참거짓은 모릅니다. 정수 const와 정수만 있는 enumnumber로 찍힙니다. 테스트 471줄이 const: 42number로 고정합니다.

라인 src/adapters/openai-chat/tool-schema.ts 383 - 합친 노드 전체를 다시 걷습니다. 이미 센 칸을 또 세고, 깊이도 한 칸 내려갑니다. 한도는 노드 4096개, 깊이 64입니다. 한도를 넘으면 그 자리는 {}가 되어 도구 조건이 사라집니다. 합친 결과가 또 맨 앞이 $ref뿐인 정의(자기 자신을 가리키는 별칭)이면, 이 재진입이 깊이 한도까지 반복된 뒤 {}가 됩니다. 다시 돌려야 하는 곳은 $ref가 아직 남은 합쳐진 속성입니다.

메인테이너의 판단이 필요한 지점

allOfobject를 찍는 범위를 객체로 좁힐지입니다. additionalProperties만 있는 정의는 지금 코드가 type을 넣지 않습니다. Kimi 검사기가 그걸 객체 끝으로 보는지도 같이 보면 됩니다. 정수 const와 정수 enumnumber로 둘지도 정하면 됩니다. 준비 체크의 남은 두 칸(리뷰 반영, 리뷰 준비)은 아직 비어 있습니다.

너의 추천

$ref 옆 조건을 합친 뒤, $ref가 남은 그 칸만 다시 정리하세요. 노드 전체를 다시 걷지 않아도 됩니다. allOf는 안의 조건이 객체일 때만 type: "object"를 찍으세요. 글자나 숫자 allOf에는 찍지 마세요. enum이 전부 참거짓이면 boolean을 찍으세요. 테스트에 글자 allOfobject로 바뀌지 않는 경우를 하나 넣으세요. base는 dev입니다. types.ts/config.ts 분할이 아니고, 같은 Moonshot 스키마를 고치는 다른 열린 PR은 없습니다.

이 댓글은 grok-bot이 작성했습니다

… enums, and composed property re-normalization
@yeongjunyoo
yeongjunyoo marked this pull request as ready for review September 22, 2026 00:41
@yeongjunyoo

Copy link
Copy Markdown
Contributor Author

리뷰 피드백 반영 완료했습니다 (

@github-actions
github-actions Bot marked this pull request as draft September 22, 2026 00:42
@luvs01

luvs01 commented Sep 22, 2026

Copy link
Copy Markdown
Collaborator

Correction: I closed this PR without authorization from its author or a maintainer. That was my mistake. I have reopened it at its unchanged head 0fad7640ea6b812ea29e4d3c3478516bfccfa991. I apologize for the interruption.

#5547 is an integration proposal containing related changes and preserved contributor credit. Technical inclusion in that proposal did not authorize me to close this PR. The previous closure statement in this comment is withdrawn; this PR remains under its original author's control.

@luvs01 luvs01 closed this Sep 22, 2026
@luvs01 luvs01 reopened this Sep 22, 2026
lidge-jun added a commit that referenced this pull request Sep 23, 2026
… fixes (#5619)

* fix(cursor): bound capability reads and buffered tool budgets (#5533)

Carries #5533 (and the closed #5233 it consolidates) onto current dev.

Co-authored-by: Epinephrine <luvs01@hanmail.net>

* fix(moonshot): bound normalized tool-schema expansion (#5547)

Carries #5547, which consolidates #5464 and the request-wide inline budget, onto current dev.

Co-authored-by: yeongjunyoo <47925973+yeongjunyoo@users.noreply.github.com>
Co-authored-by: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com>
Co-authored-by: Epinephrine <luvs01@hanmail.net>

* fix(moonshot): restore rejected inline budgets and charge nested growth once

A rejected sibling-reference expansion now restores the byte, node and expansion allowances it consumed, and outer growth no longer re-charges nested copies, so later independent expansions in the same request keep their allowance. Documents the provider-driven object type inference as a deliberate tradeoff and rewrites ADR-0355 in English.

Co-authored-by: luvs01 <27862058+luvs01@users.noreply.github.com>

* feat(reasoning): consolidate replay, opt-in tag parsing, and summary policy (#5566)

Carries #5566, which consolidates #5449, #5205 and #5491, onto current dev. The provider guide keeps the current bridge replay paragraph and adds the inline-tag and summary paragraphs.

Co-authored-by: Joonsuh Park <trckstr4422@gmail.com>
Co-authored-by: Daniel Sjöstrand <16033062+Danielsjostrand1979@users.noreply.github.com>
Co-authored-by: alexph-dev <alexph-dev@users.noreply.github.com>
Co-authored-by: Yum-wu <1172989563@qq.com>

* fix: bound Fernet slot runs, Kiro error-body read, and skill-path line slice (#5310)

Carries #5310 onto current dev. The follow-up commit makes the Fernet run cap fail closed and moves the Kiro regression out of the capped stream suite.

* docs(reasoning): reconcile inline-tag whitespace contract

Interleaved inline-tag parsing preserves answer whitespace; only Kiro single-block mode drops the whitespace after its leading block.

Co-authored-by: luvs01 <27862058+luvs01@users.noreply.github.com>

* fix(responses): fail closed on Fernet run overflow and keep the Kiro suite under its cap

A slot with more than 64 structurally valid Fernet runs is now treated as unreadable or omitted as a whole, so no unexamined tail reaches the provider as text. The bounded Kiro fallback error-body regression moves byte for byte into a registered sibling file, and the Kiro, Responses and inbound contracts document the new bounds.

Co-authored-by: luvs01 <27862058+luvs01@users.noreply.github.com>

* fix(reasoning): scan inline think tags with a moving cursor

The parser copied, rescanned and reserved the whole remaining response after every block, so one upstream chunk carrying many short blocks cost quadratic work. It now scans each chunk from an offset and charges the translator budget only for retained carry: undecided leading input or a trailing tag fragment.

Co-authored-by: luvs01 <27862058+luvs01@users.noreply.github.com>

* fix(reasoning): keep undecided leading whitespace incremental

Before the format was decided, every content delta rebuilt, trimmed and re-reserved the whole leading prefix, so a stream of one-character whitespace deltas cost quadratic work. Leading whitespace is now kept in segments whose bytes are reserved once and joined only when the format is decided or the stream flushes.

Co-authored-by: luvs01 <27862058+luvs01@users.noreply.github.com>

* fix(meta-muse): consolidate login admission and bounded response handling (#5591)

Carries #5591, which consolidates the closed #5234 and #5432, onto current dev. The provider contract keeps the inline-tag paragraph and adds the Meta Muse admission paragraph.

Co-authored-by: Epinephrine <luvs01@hanmail.net>

* fix(claude-desktop): keep applied state consistent across profile edits (#5590)

Carries #5590, which consolidates the closed #5337, onto current dev.

Co-authored-by: Epinephrine <luvs01@hanmail.net>
Co-authored-by: luvs01 <luvs01@users.noreply.github.com>

* fix(claude-desktop): commit applied markers only over the observed baseline

Both Desktop writers, provider-change auto-apply and client sync, now capture the desired profile and its applied marker before the Desktop write and commit the new marker only if profile presence, content, fingerprint and timestamp are unchanged. A concurrent edit, deletion or newer marker keeps its state and the write reports a skipped marker. The provider-change path no longer saves a whole stale config snapshot.

Co-authored-by: luvs01 <27862058+luvs01@users.noreply.github.com>

* fix(claude-desktop): commit profile edits against the persisted marker

The Desktop profile PUT built its response from an earlier snapshot and saved that whole snapshot, so a marker committed by another writer during the awaited state build could be replaced by an older one. The edit now commits in one persisted-config mutation that keeps the latest marker for unchanged content and answers 409 when the profile itself changed meanwhile. The Meta Muse overflow test now asserts that the bounded-body limit, not a generic failure, produced the error.

Co-authored-by: luvs01 <27862058+luvs01@users.noreply.github.com>

* fix(claude-desktop): report an unreadable config separately from an edit conflict

A missing or invalid config now answers 500 with its reason; only a concurrent profile change or exhausted rebase answers 409.

Co-authored-by: luvs01 <27862058+luvs01@users.noreply.github.com>

* feat(desktop): consolidate consent-based runtime takeover and ownership contracts (#5564)

Carries #5564, which consolidates #5459 and #5457, onto current dev. The review screenshot stays in the pull request description rather than the tree.

Co-authored-by: jun <bitkyc08@gmail.com>
Co-authored-by: sanggyulee <andy53295774@gmail.com>
Co-authored-by: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com>

* fix(desktop): bind takeover stop to the approved runtime and fail closed

Desktop takeover re-resolves ownership immediately before stopping and passes the approved PID, endpoint, config home, CLI version and compatibility token to an opt-in guarded stop. The guard is checked under the ownership mutation lease before any manager or signal stop; the approved PID and endpoint must settle and the service manager must then be proven inactive, otherwise the stop answers approval-changed or manager-still-active and the desktop neither waits for silence nor claims. Unreadable or unparseable stop output is terminal as well. A second unreadable service-state read now blocks takeover, Windows managing-CLI discovery follows PATHEXT with file-only candidates and refuses command-interpreter metacharacters, the claim refusal test uses real sandbox state, and the runtime and desktop contracts record that the claim token is a consistency check rather than consent proof. Plain ocx stop is unchanged.

Co-authored-by: luvs01 <27862058+luvs01@users.noreply.github.com>

* fix(desktop): keep plain stop entry points and format the takeover changes

Desktop exit keeps its plain runtime_stop::run entry while takeover uses run_approved, AttachPlan::Ask no longer carries an unread field, the Rust changes follow rustfmt, the plain CLI stop path keeps its literal outcome return, the stop source oracles follow the reader and outcome union that now include the two guarded refusals, and the runtime contract fits its 600-line budget.

Co-authored-by: luvs01 <27862058+luvs01@users.noreply.github.com>

* fix(desktop): run takeover seam tests without tokio macros and harden manager and shim checks

The two async takeover seam tests now run on the shell runtime already used by the crate instead of tokio test macros, which this crate does not enable. Windows command-shim probes refuse command-interpreter metacharacters in every recorded argument as well as the executable, and the guarded stop re-inspects the service manager identity immediately before the manager command, answering approval-changed without stopping if it moved.

Co-authored-by: luvs01 <27862058+luvs01@users.noreply.github.com>

* fix(responses): keep effort-based reasoning visible after routing

Final-route normalization recomputed hideThinkingSummary without the validated active-effort condition, so routed Chat and Kiro requests with an active effort and an omitted summary still hid raw reasoning. It now uses the same predicate as the parser; explicit "none" and requests without an active effort stay hidden.

Co-authored-by: luvs01 <27862058+luvs01@users.noreply.github.com>

* fix(service): match the running CLI case-insensitively only on Windows

On case-sensitive filesystems a PATH executable that differs only in case is a different file, so it must get its own version probe instead of reporting the running CLI version.

Co-authored-by: luvs01 <27862058+luvs01@users.noreply.github.com>

* fix(meta-muse): require the dashboard session for manual login codes

The manual-code continuation now applies the same dashboard-session admission as the login start, so a management token cannot advance a pending Meta Muse login.

Co-authored-by: luvs01 <27862058+luvs01@users.noreply.github.com>

* fix(reasoning): reserve the joined leading-whitespace copy

Joining retained leading whitespace allocated a second copy outside the translator budget; the join is now reserved first and released once the segments are cleared.

Co-authored-by: luvs01 <27862058+luvs01@users.noreply.github.com>

* fix(service): skip CLI probes for an absent runtime and treat failed systemd units as stopped

Resolve no longer spawns managing-CLI version probes when no runtime is live, since takeover is only offered for a live runtime. A systemd unit reported failed with no main PID is stopped, so a guarded stop that leaves it failed succeeds and a leftover failed unit does not block takeover.

Co-authored-by: luvs01 <27862058+luvs01@users.noreply.github.com>

* fix(service): keep failed systemd units fail-closed and assess takeover only for a live runtime in tests

systemd can report failed before an automatic restart, so failed with no main PID is again treated as unknown rather than stopped. The resolve contract tests that assert ownership and takeover fields now use a live runtime, matching the skip of managing-CLI probes when no runtime is live.

Co-authored-by: luvs01 <27862058+luvs01@users.noreply.github.com>

---------

Co-authored-by: luvs01 <27862058+luvs01@users.noreply.github.com>
Co-authored-by: Epinephrine <luvs01@hanmail.net>
Co-authored-by: yeongjunyoo <47925973+yeongjunyoo@users.noreply.github.com>
Co-authored-by: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com>
Co-authored-by: Joonsuh Park <trckstr4422@gmail.com>
Co-authored-by: Daniel Sjöstrand <16033062+Danielsjostrand1979@users.noreply.github.com>
Co-authored-by: alexph-dev <alexph-dev@users.noreply.github.com>
Co-authored-by: Yum-wu <1172989563@qq.com>
Co-authored-by: luvs01 <luvs01@users.noreply.github.com>
Co-authored-by: sanggyulee <andy53295774@gmail.com>
@lidge-jun

Copy link
Copy Markdown
Owner

Closing as superseded. The changes from this PR (head 0fad7640ea6b, by @yeongjunyoo) were carried with credit into #5547, which was consolidated into #5619. #5619 merged to dev as e964387. The carry was reimplemented as a squash with review repairs, not merged, so this branch's own commit history is not part of dev. I compared this head against current dev and found its behavior present, in some cases in revised form. Re-normalizing composed properties and the MFJS object/scalar type inference are present. #5619 added a request-wide budget repair.

This is on dev only. It is not in the stable v2.63.0 release and will ship in a later release. Thank you for the contribution.

@lidge-jun lidge-jun closed this Sep 23, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

bug Something isn't working

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants