diff --git a/src/agent/tool-schema-normalize.test.ts b/src/agent/tool-schema-normalize.test.ts index 15bc5b55b..b39e003ef 100644 --- a/src/agent/tool-schema-normalize.test.ts +++ b/src/agent/tool-schema-normalize.test.ts @@ -119,7 +119,36 @@ describe("normalizeToolDefinitionsForProvider", () => { ).toBe(false); }); - test("non-kimi providers get identity schemas (recursive present kept)", () => { + // Meta Muse Spark (OpenCode Go / Zen) rejects recursive $ref cycles with + // "Recursive JSON schemas are not currently supported". Same wire rewrite + // as Kimi — model-id gate via isMuseSparkLeafProvider. + test.each([ + { + providerName: "opencode-go", + model: "muse-spark-1.3-contributor", + }, + { + providerName: "opencode-go", + model: "muse-spark-1.2-contributor", + }, + { providerName: "opencode-go", model: "muse-spark-1.3" }, + { providerName: "zen", model: "muse-spark-1.3-contributor-free" }, + ] as const)( + "$providerName + $model rewrites present (muse schema gate)", + (ctx) => { + const out = normalizeToolDefinitionsForProvider(defs, ctx); + const present = defined(out.find((d) => d.name === "present")); + expect(schemaHasRef(present.inputSchema)).toBe(false); + expect(schemaHasDefs(present.inputSchema)).toBe(false); + expect(present.inputSchema).toEqual( + structuredClone( + KIMI_PRESENT_INPUT_SCHEMA, + ) as typeof present.inputSchema, + ); + }, + ); + + test("providers that accept recursive schemas get identity present", () => { for (const ctx of [ { providerName: "anthropic", model: "claude-sonnet-4" }, { providerName: "openai", model: "gpt-5.6" }, diff --git a/src/agent/tool-schema-normalize.ts b/src/agent/tool-schema-normalize.ts index 1306efd99..40a1a89e1 100644 --- a/src/agent/tool-schema-normalize.ts +++ b/src/agent/tool-schema-normalize.ts @@ -1,5 +1,8 @@ import type { ToolDefinition } from "@intx/types/runtime"; -import { isKimiLeafProvider } from "../subagent/provider-family.js"; +import { + isKimiLeafProvider, + isMuseSparkLeafProvider, +} from "../subagent/provider-family.js"; /** Context used to decide whether a provider needs wire-schema rewrites. */ export interface NormalizeToolDefsContext { @@ -9,7 +12,7 @@ export interface NormalizeToolDefsContext { /** * Shared primitives / view guidance for `present`. Used by both the canonical - * `presentDefinition.description` and the kimi wire `view.description` so the + * `presentDefinition.description` and the acyclic wire `view.description` so the * two never drift. */ export const PRESENT_VIEW_PRIMITIVES_GUIDANCE = @@ -114,10 +117,11 @@ const COLUMNS_PROP = { } as const; /** - * Non-recursive `present` parameters for Moonshot/kimi-class backends. - * Those providers reject JSON Schema `$ref` cycles on `tools.function.parameters` - * (recursive ViewNode). This shape inlines depth-capped oneOf primitives so the - * model still sees type/children/text fields — not a bare freeform object. + * Non-recursive `present` parameters for backends that reject JSON Schema `$ref` + * cycles on `tools.function.parameters` (recursive ViewNode). Moonshot/Kimi and + * Meta Muse Spark both fail the turn before inference when the cycle is on the + * wire. This shape inlines depth-capped oneOf primitives so the model still + * sees type/children/text fields — not a bare freeform object. * Runtime still validates full nested trees via `validateView`. */ export const KIMI_PRESENT_INPUT_SCHEMA = { @@ -180,7 +184,7 @@ export const KIMI_PRESENT_INPUT_SCHEMA = { required: ["view"], } as const; -function rewritePresentForKimi(def: ToolDefinition): ToolDefinition { +function rewritePresentAcyclic(def: ToolDefinition): ToolDefinition { return { ...def, // structuredClone so callers cannot mutate the shared const via the tool def. @@ -190,13 +194,17 @@ function rewritePresentForKimi(def: ToolDefinition): ToolDefinition { }; } +function needsAcyclicPresentSchema(ctx: NormalizeToolDefsContext): boolean { + return isKimiLeafProvider(ctx) || isMuseSparkLeafProvider(ctx); +} + /** * Family-gated wire rewrite of tool definitions before they reach the director / - * provider. Today only Moonshot/kimi get a non-recursive `present` schema; + * provider. Moonshot/kimi and Muse Spark get a non-recursive `present` schema; * other providers receive the definitions unchanged (identity). * * Does not alter runtime validation or the canonical `presentDefinition` used - * as the source of truth for non-kimi advertise paths. + * as the source of truth for providers that accept `$ref` cycles. * * Call this at every advertise path that may include `present` (main TUI/exec * sessions). Sub-agent toolsets currently omit `present` (main-session only); @@ -206,10 +214,10 @@ export function normalizeToolDefinitionsForProvider( defs: readonly ToolDefinition[], ctx: NormalizeToolDefsContext, ): ToolDefinition[] { - if (!isKimiLeafProvider(ctx)) { + if (!needsAcyclicPresentSchema(ctx)) { return defs as ToolDefinition[]; } return defs.map((def) => - def.name === "present" ? rewritePresentForKimi(def) : def, + def.name === "present" ? rewritePresentAcyclic(def) : def, ); } diff --git a/src/session/live-model-switch.test.ts b/src/session/live-model-switch.test.ts index d55d7e120..f32d1edc0 100644 --- a/src/session/live-model-switch.test.ts +++ b/src/session/live-model-switch.test.ts @@ -19,6 +19,10 @@ const MODEL_B: LiveModelRef = { model: "claude-opus", }; const MODEL_KIMI: LiveModelRef = { providerName: "moonshot", model: "kimi-k2" }; +const MODEL_MUSE: LiveModelRef = { + providerName: "opencode-go", + model: "muse-spark-1.3-contributor", +}; const canonicalDefs: readonly ToolDefinition[] = [presentDefinition]; @@ -185,4 +189,25 @@ describe("applyLiveModelSwitch", () => { presentDefinition.inputSchema, ); }); + + test("non-muse to muse rewrites advertised present; switching away restores canonical", () => { + const session = createProductionSwitch(); + expect(schemaHasRef(presentSchema(session.advertised()))).toBe(true); + + session.switchTo(MODEL_MUSE); + + expect(session.inference()).toEqual(MODEL_MUSE); + expect(schemaHasRef(presentSchema(session.advertised()))).toBe(false); + expect(presentSchema(session.advertised())).not.toBe( + presentDefinition.inputSchema, + ); + + session.switchTo(MODEL_A); + + expect(session.inference()).toEqual(MODEL_A); + expect(schemaHasRef(presentSchema(session.advertised()))).toBe(true); + expect(presentSchema(session.advertised())).toBe( + presentDefinition.inputSchema, + ); + }); }); diff --git a/src/session/live-model-switch.ts b/src/session/live-model-switch.ts index 3380db834..d5d6bf5c7 100644 --- a/src/session/live-model-switch.ts +++ b/src/session/live-model-switch.ts @@ -1,7 +1,7 @@ /** * One cutover for every model-scoped runtime fact a live session reads. * `/model` (and tests) must go through this rather than refreshing inference - * alone — a missed step is how provider-model grants and kimi wire schemas + * alone — a missed step is how provider-model grants and kimi/muse wire schemas * went stale after a switch. */ @@ -24,7 +24,7 @@ export interface LiveModelSwitchHandles { /** * Re-advertise family-gated tool schemas from canonical definitions. * Must not re-normalize an already-rewritten advertise set — switching - * away from kimi would then keep the non-recursive present schema. + * away from kimi/muse would then keep the non-recursive present schema. */ refreshAdvertisedSchemas: (next: LiveModelRef) => void; } diff --git a/src/subagent/run.ts b/src/subagent/run.ts index c9e2cf6d5..735bdcb24 100644 --- a/src/subagent/run.ts +++ b/src/subagent/run.ts @@ -1189,7 +1189,7 @@ async function runSubAgentInner( // modelFamilyPolicy is resolved above at the skill mount; reused here for // stall timing and wire-schema normalization. - // Family-gate wire schemas the same way main sessions do (kimi present rewrite). + // Family-gate wire schemas the same way main sessions do (kimi/muse present rewrite). // Sub-agent toolsets currently omit `present` (main-session only); normalize is // still applied so a future present on leaf tools cannot reintroduce $ref cycles. const directorDef = defineDirector({