From 95a656ca6183b56d6e66182de32633c8538b5260 Mon Sep 17 00:00:00 2001 From: 7Sageer Date: Fri, 31 Jul 2026 13:09:56 +0800 Subject: [PATCH] fix(agent-core): strip the no-op subagent model parameter while the secondary-model experiment is off (#2449) The Agent/AgentSwarm tool schemas always advertised a \`model\` choice parameter, so the secondary-model concept entered the prompt even with the experiment disabled. Gate the advertised JSON schema on the flag in both engines: off (the default) drops the parameter, on keeps it, and spawn-time resolution already falls back to the caller's model either way. Also scrub ambient KIMI_CODE_EXPERIMENTAL_* env vars in both packages' vitest setup so flag-dependent tool schemas in llm.tools_snapshot stay deterministic regardless of the developer shell. --- .../agent/tools/agent-swarm/agentSwarmTool.ts | 17 ++++- .../src/agent/tools/agent/agentTool.ts | 16 ++++- .../src/session/subagent/configSection.ts | 31 ++++++++- .../test/agent/loop/loop.test.ts | 4 +- packages/agent-core-v2/test/setup.ts | 13 ++++ packages/agent-core-v2/test/tool/tool.test.ts | 64 +++++++++++++++++-- packages/agent-core-v2/vitest.config.ts | 1 + packages/agent-core/src/agent/tool/index.ts | 2 + .../src/session/subagent-binding.ts | 28 ++++++++ .../builtin/collaboration/agent-swarm.ts | 13 +++- .../src/tools/builtin/collaboration/agent.ts | 14 +++- packages/agent-core/test/setup.ts | 13 ++++ packages/agent-core/test/tools/agent.test.ts | 15 ++++- .../test/tools/builtin-current.test.ts | 23 +++++++ packages/agent-core/vitest.config.ts | 1 + 15 files changed, 241 insertions(+), 14 deletions(-) create mode 100644 packages/agent-core-v2/test/setup.ts create mode 100644 packages/agent-core/test/setup.ts diff --git a/packages/agent-core-v2/src/agent/tools/agent-swarm/agentSwarmTool.ts b/packages/agent-core-v2/src/agent/tools/agent-swarm/agentSwarmTool.ts index c69c08ef4..9f4ee7cbd 100644 --- a/packages/agent-core-v2/src/agent/tools/agent-swarm/agentSwarmTool.ts +++ b/packages/agent-core-v2/src/agent/tools/agent-swarm/agentSwarmTool.ts @@ -45,7 +45,9 @@ import { buildSubagentModelDescriptions, resolveSubagentBinding, resolveSubagentTimeoutMs, + stripSubagentModelParameter, } from '#/session/subagent/configSection'; +import { SECONDARY_MODEL_FLAG_ID } from '#/session/subagent/flag'; import { AgentSwarmToolInputSchema, IAgentSwarmTool, @@ -57,6 +59,9 @@ import AGENT_SWARM_DESCRIPTION from './agent-swarm.md?raw'; const DEFAULT_SUBAGENT_TYPE = 'coder'; +const AGENT_SWARM_PARAMETERS = toInputJsonSchema(AgentSwarmToolInputSchema); +const AGENT_SWARM_PARAMETERS_NO_MODEL = stripSubagentModelParameter(AGENT_SWARM_PARAMETERS); + interface AgentSwarmSpawnSpec { readonly kind: 'spawn'; readonly index: number; @@ -86,7 +91,17 @@ interface SwarmRunResult { export class AgentSwarmTool implements IAgentSwarmTool { declare readonly _serviceBrand: undefined; readonly name = 'AgentSwarm' as const; - readonly parameters: Record = toInputJsonSchema(AgentSwarmToolInputSchema); + + /** + * The `model` choice only exists while the `secondary-model` experiment is + * on; off, the advertised schema drops it so the concept never enters the + * prompt. Read live per request (same as `description`). + */ + get parameters(): Record { + return this.flags.enabled(SECONDARY_MODEL_FLAG_ID) + ? AGENT_SWARM_PARAMETERS + : AGENT_SWARM_PARAMETERS_NO_MODEL; + } private readonly callerAgentId: string; diff --git a/packages/agent-core-v2/src/agent/tools/agent/agentTool.ts b/packages/agent-core-v2/src/agent/tools/agent/agentTool.ts index ff249b2f5..b840749db 100644 --- a/packages/agent-core-v2/src/agent/tools/agent/agentTool.ts +++ b/packages/agent-core-v2/src/agent/tools/agent/agentTool.ts @@ -84,6 +84,7 @@ import { formatSubagentTimeoutDescription, resolveSubagentBinding, resolveSubagentTimeoutMs, + stripSubagentModelParameter, wrapSubagentModelError, } from '#/session/subagent/configSection'; import { SECONDARY_MODEL_FLAG_ID } from '#/session/subagent/flag'; @@ -104,10 +105,23 @@ import AGENT_BACKGROUND_DISABLED_DESCRIPTION from './agent-background-disabled.m import AGENT_BACKGROUND_DESCRIPTION from './agent-background-enabled.md?raw'; import AGENT_DESCRIPTION_BASE from './agent.md?raw'; +const SUBAGENT_TOOL_PARAMETERS = toInputJsonSchema(SubagentToolInputSchema); +const SUBAGENT_TOOL_PARAMETERS_NO_MODEL = stripSubagentModelParameter(SUBAGENT_TOOL_PARAMETERS); + export class SubagentTool implements ISubagentTool { declare readonly _serviceBrand: undefined; readonly name: string = 'Agent'; - readonly parameters: Record = toInputJsonSchema(SubagentToolInputSchema); + + /** + * The `model` choice only exists while the `secondary-model` experiment is + * on; off, the advertised schema drops it so the concept never enters the + * prompt. Read live per request (same as `description`). + */ + get parameters(): Record { + return this.flags.enabled(SECONDARY_MODEL_FLAG_ID) + ? SUBAGENT_TOOL_PARAMETERS + : SUBAGENT_TOOL_PARAMETERS_NO_MODEL; + } private readonly callerAgentId: string; private readonly canRunInBackground: () => boolean; diff --git a/packages/agent-core-v2/src/session/subagent/configSection.ts b/packages/agent-core-v2/src/session/subagent/configSection.ts index a850f5d22..43b1e9e78 100644 --- a/packages/agent-core-v2/src/session/subagent/configSection.ts +++ b/packages/agent-core-v2/src/session/subagent/configSection.ts @@ -25,7 +25,9 @@ * rather than inheriting the caller's level. Both tools resolve spawn * bindings through `resolveSubagentBinding`, advertise the pair via * `buildSubagentModelDescriptions`, and wrap spawn failures with - * `wrapSubagentModelError`. Self-registered at module load via + * `wrapSubagentModelError`; while the experiment is off they also strip the + * no-op `model` parameter from their advertised schemas via + * `stripSubagentModelParameter`. Self-registered at module load via * `registerConfigSection`, so the `config` domain never imports this * domain's types. */ @@ -34,6 +36,7 @@ import { z } from 'zod'; import { Error2, ErrorCodes, isError2 } from '#/errors'; import type { AgentModelPreference } from '#/app/agentProfileCatalog/agentProfileCatalog'; +import { isPlainObject } from '#/app/config/toml'; import type { IFlagService } from '#/app/flag/flag'; import { SECONDARY_MODEL_ENV, @@ -143,6 +146,32 @@ export function buildSubagentModelDescriptions( ].join('\n'); } +/** + * Strip the `model` property from a subagent collaboration tool's advertised + * JSON schema. While the `secondary-model` experiment is off the parameter is + * a silent no-op, so the schema the model sees (and the args validator + * compiled from the same advertised schema) drops it entirely — the + * secondary-model concept never enters the prompt, and a stray `model` + * argument is rejected instead of silently inheriting the caller's model. + * Returns the input unchanged when there is no `model` property; otherwise a + * shallow copy — the input is never mutated, so callers can keep both + * variants as shared constants. + */ +export function stripSubagentModelParameter( + parameters: Record, +): Record { + const properties = parameters['properties']; + if (!isPlainObject(properties) || !('model' in properties)) return parameters; + const nextProperties = { ...properties }; + delete nextProperties['model']; + const next: Record = { ...parameters, properties: nextProperties }; + const required = parameters['required']; + if (Array.isArray(required) && required.includes('model')) { + next['required'] = required.filter((entry) => entry !== 'model'); + } + return next; +} + export function wrapSubagentModelError( error: unknown, boundModel: string, diff --git a/packages/agent-core-v2/test/agent/loop/loop.test.ts b/packages/agent-core-v2/test/agent/loop/loop.test.ts index 783a8b49c..9d0242ec1 100644 --- a/packages/agent-core-v2/test/agent/loop/loop.test.ts +++ b/packages/agent-core-v2/test/agent/loop/loop.test.ts @@ -104,8 +104,8 @@ describe('Agent loop', () => { [emit] turn.step.started { "turnId": 0, "step": 1, "stepId": "" } [emit] agent.activity.updated { "lifecycle": "ready", "turn": { "turnId": 0, "origin": { "kind": "user" }, "phase": "running", "step": 1, "ending": false, "pendingApprovals": [], "activeToolCalls": [], "since": "