Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
31 changes: 30 additions & 1 deletion src/agent/tool-schema-normalize.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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" },
Expand Down
30 changes: 19 additions & 11 deletions src/agent/tool-schema-normalize.ts
Original file line number Diff line number Diff line change
@@ -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 {
Expand All @@ -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 =
Expand Down Expand Up @@ -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 = {
Expand Down Expand Up @@ -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.
Expand All @@ -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);
Expand All @@ -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,
);
}
25 changes: 25 additions & 0 deletions src/session/live-model-switch.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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];

Expand Down Expand Up @@ -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,
);
});
});
4 changes: 2 additions & 2 deletions src/session/live-model-switch.ts
Original file line number Diff line number Diff line change
@@ -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.
*/

Expand All @@ -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;
}
Expand Down
2 changes: 1 addition & 1 deletion src/subagent/run.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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({
Expand Down
Loading