diff --git a/.changeset/v2-subagent-binding-ask-once.md b/.changeset/v2-subagent-binding-ask-once.md new file mode 100644 index 0000000000..3aece8d209 --- /dev/null +++ b/.changeset/v2-subagent-binding-ask-once.md @@ -0,0 +1,5 @@ +--- +"@moonshot-ai/kimi-code": minor +--- + +Extend the experimental workspace subagent model bindings: the Agent tool now interactively asks for a model the first time an unbound subagent type or binding slot is spawned — or when a stored binding references a model alias that no longer exists — and records the answer, including a "keep inheriting" choice, in `.kimi-code/local.toml`. Set `KIMI_CODE_EXPERIMENTAL_SUBAGENT_MODEL_SELECTION` and spawn a subagent type that has no `[subagent.]` binding to try it. diff --git a/.changeset/v2-subagent-binding-resume-parity.md b/.changeset/v2-subagent-binding-resume-parity.md new file mode 100644 index 0000000000..3ba951bdaa --- /dev/null +++ b/.changeset/v2-subagent-binding-resume-parity.md @@ -0,0 +1,5 @@ +--- +"@moonshot-ai/kimi-code": patch +--- + +Fix the experimental subagent model bindings: a resumed subagent now fails fast with a clear error when its bound model alias no longer resolves, and binding warnings cover aliases that exist but cannot be resolved, including a notice when `inherit = true` is set alongside an ignored model or thinking effort. diff --git a/.changeset/v2-subagent-binding-single-ask.md b/.changeset/v2-subagent-binding-single-ask.md new file mode 100644 index 0000000000..e4d7961c0e --- /dev/null +++ b/.changeset/v2-subagent-binding-single-ask.md @@ -0,0 +1,5 @@ +--- +"@moonshot-ai/kimi-code": patch +--- + +Fix the experimental subagent model bindings: one spawn now asks at most once — when a binding slot was explicitly requested and its ask was dismissed, the resolver no longer escalates into a second, type-level question; a configured type binding still applies silently. diff --git a/.changeset/v2-subagent-binding-sticky-swarm.md b/.changeset/v2-subagent-binding-sticky-swarm.md new file mode 100644 index 0000000000..921d97742e --- /dev/null +++ b/.changeset/v2-subagent-binding-sticky-swarm.md @@ -0,0 +1,5 @@ +--- +"@moonshot-ai/kimi-code": minor +--- + +Extend the experimental workspace subagent model bindings: resumed subagents now keep the model they were spawned with, and agent swarms accept a batch-wide binding slot. Set `KIMI_CODE_EXPERIMENTAL_SUBAGENT_MODEL_SELECTION` and configure `[subagent.]` / `[subagent-slot.]` in `.kimi-code/local.toml` to try it. diff --git a/.changeset/v2-subagent-model-bindings.md b/.changeset/v2-subagent-model-bindings.md new file mode 100644 index 0000000000..f4e5309284 --- /dev/null +++ b/.changeset/v2-subagent-model-bindings.md @@ -0,0 +1,5 @@ +--- +"@moonshot-ai/kimi-code": minor +--- + +Add experimental workspace subagent model bindings, letting a subagent run on a different model and thinking effort than the main session. Set `KIMI_CODE_EXPERIMENTAL_SUBAGENT_MODEL_SELECTION` and add a `[subagent.]` binding (for example `model = "your-provider/your-model"`) in `.kimi-code/local.toml` to try it. diff --git a/packages/agent-core-v2/scripts/check-domain-layers.mjs b/packages/agent-core-v2/scripts/check-domain-layers.mjs index 08e28ea223..19ff1562a2 100644 --- a/packages/agent-core-v2/scripts/check-domain-layers.mjs +++ b/packages/agent-core-v2/scripts/check-domain-layers.mjs @@ -426,6 +426,10 @@ const ALLOWED_EXCEPTIONS = new Set([ // config defaults reaches the `subagent` section (L6) for the subagent // timeout — same cross-scope config-fill shape as `swarm>subagent`. 'agentTask>subagent', + // `subagent` (L6) ask-once binding creation questions the user through the + // L7 question broker on the Agent tool spawn path (swarm never asks) — same + // shape as the `permissionGate>approval` boundary-broker exception above. + 'subagent>question', 'cron>agentLifecycle', 'cron>sessionContext', 'todo>agentLifecycle', diff --git a/packages/agent-core-v2/src/agent/swarm/tools/agent-swarm.ts b/packages/agent-core-v2/src/agent/swarm/tools/agent-swarm.ts index c358dcdc13..95cadd3a67 100644 --- a/packages/agent-core-v2/src/agent/swarm/tools/agent-swarm.ts +++ b/packages/agent-core-v2/src/agent/swarm/tools/agent-swarm.ts @@ -20,6 +20,7 @@ import { import { registerTool } from '#/agent/toolRegistry/toolContribution'; import { toInputJsonSchema } from '#/tool/input-schema'; import { IConfigService } from '#/app/config/config'; +import { IFlagService } from '#/app/flag/flag'; import { ISessionSwarmService, type SessionSwarmTask } from '#/session/swarm/sessionSwarm'; import { ISessionAgentProfileCatalog } from '#/session/sessionAgentProfileCatalog/sessionAgentProfileCatalog'; import { IAgentProfileService } from '#/agent/profile/profile'; @@ -30,6 +31,7 @@ import { import { IAgentScopeContext } from '#/agent/scopeContext/scopeContext'; import { IAgentSwarmService } from '#/agent/swarm/swarm'; import { resolveSubagentTimeoutMs } from '#/session/subagent/configSection'; +import { SUBAGENT_MODEL_SELECTION_FLAG_ID } from '#/session/subagent/flag'; import AGENT_SWARM_DESCRIPTION from './agent-swarm.md?raw'; const DEFAULT_SUBAGENT_TYPE = 'coder'; @@ -66,6 +68,14 @@ export const AgentSwarmToolInputSchema = z .describe( `Values used to fill ${PROMPT_TEMPLATE_PLACEHOLDER}. Each item launches one new subagent.`, ), + binding_slot: z + .string() + .trim() + .min(1) + .optional() + .describe( + 'Named binding slot pre-configured by the user for this workspace (.kimi-code/local.toml under [subagent-slot.]), applied to every subagent spawned from items. Set ONLY when the task or preset explicitly names a slot — a slot selects a user-configured model/effort. Never invent slot names, and never use this to choose a model yourself.', + ), resume_agent_ids: z .record(z.string().trim().min(1), z.string().trim().min(1)) .optional() @@ -106,8 +116,8 @@ interface SwarmRunResult { export class AgentSwarmTool implements BuiltinTool { readonly name = 'AgentSwarm' as const; readonly description = AGENT_SWARM_DESCRIPTION; - readonly parameters: Record = toInputJsonSchema(AgentSwarmToolInputSchema); + private readonly fullParameters: Record = toInputJsonSchema(AgentSwarmToolInputSchema); private readonly callerAgentId: string; constructor( @@ -117,10 +127,20 @@ export class AgentSwarmTool implements BuiltinTool { @IConfigService private readonly config: IConfigService, @ISessionAgentProfileCatalog private readonly catalog: ISessionAgentProfileCatalog, @IAgentProfileService private readonly profile: IAgentProfileService, + @IFlagService private readonly flags: IFlagService, ) { this.callerAgentId = scopeContext.agentId; } + get parameters(): Record { + if (this.flags.enabled(SUBAGENT_MODEL_SELECTION_FLAG_ID)) { + return this.fullParameters; + } + const properties = { ...(this.fullParameters['properties'] as Record) }; + delete properties['binding_slot']; + return { ...this.fullParameters, properties }; + } + resolveExecution(args: AgentSwarmToolInput): ToolExecution { const agentCount = (args.items?.length ?? 0) + Object.keys(args.resume_agent_ids ?? {}).length; return { @@ -195,6 +215,7 @@ export class AgentSwarmTool implements BuiltinTool { return { ...common, kind: 'spawn' as const, + bindingSlot: normalizeOptionalString(args.binding_slot), }; }); const results = await this.swarmService.run({ diff --git a/packages/agent-core-v2/src/app/projectLocalConfig/projectLocalConfig.ts b/packages/agent-core-v2/src/app/projectLocalConfig/projectLocalConfig.ts index 81b5868bc0..e3feafacf0 100644 --- a/packages/agent-core-v2/src/app/projectLocalConfig/projectLocalConfig.ts +++ b/packages/agent-core-v2/src/app/projectLocalConfig/projectLocalConfig.ts @@ -6,8 +6,13 @@ * path: it discovers the project root (the nearest `.git` ancestor) from a * working directory and reads/writes the project-local TOML there — it never * touches the workspace catalog or a `workspaceId`. Session domains consume - * the resolved directory list and never parse or write the TOML document - * themselves; the local filesystem backend supplies the implementation. + * the resolved directory list and the per-subagent model/effort bindings + * (`[subagent.]` / `[subagent-slot.]`) and never parse or write + * the TOML document themselves; the local filesystem backend supplies the + * implementation. A binding with `inherit: true` is an explicit user choice + * to keep parent inheritance — recorded so the spawn path does not re-ask on + * every spawn. Binding writes preserve unrelated TOML content; a read + * returning `undefined` means the entry was never configured. */ import { createDecorator, type ServiceIdentifier } from '#/_base/di/instantiation'; @@ -18,6 +23,12 @@ export interface ProjectAdditionalDirsLoadResult { readonly additionalDirs: readonly string[]; } +export interface SubagentBinding { + readonly model?: string; + readonly thinkingEffort?: string; + readonly inherit?: boolean; +} + export interface IProjectLocalConfigService { readonly _serviceBrand: undefined; @@ -27,6 +38,18 @@ export interface IProjectLocalConfigService { workDir: string, inputPath: string, ): Promise; + readSubagentBinding(workDir: string, agentType: string): Promise; + writeSubagentBinding( + workDir: string, + agentType: string, + binding: SubagentBinding | undefined, + ): Promise<{ readonly configPath: string }>; + readSubagentSlotBinding(workDir: string, slot: string): Promise; + writeSubagentSlotBinding( + workDir: string, + slot: string, + binding: SubagentBinding | undefined, + ): Promise<{ readonly configPath: string }>; } export const IProjectLocalConfigService: ServiceIdentifier = diff --git a/packages/agent-core-v2/src/index.ts b/packages/agent-core-v2/src/index.ts index 03a153a979..5ddf8a910e 100644 --- a/packages/agent-core-v2/src/index.ts +++ b/packages/agent-core-v2/src/index.ts @@ -220,6 +220,7 @@ export * from '#/agent/toolDedupe/toolDedupe'; export * from '#/agent/toolDedupe/toolDedupeService'; import '#/agent/toolSelect/flag'; import '#/agent/faultInjection/flag'; +import '#/session/subagent/flag'; import '#/agent/toolSelect/tools/select-tools'; export * from '#/agent/toolSelect/dynamicTools'; export * from '#/agent/toolSelect/toolSelect'; diff --git a/packages/agent-core-v2/src/persistence/backends/node-fs/projectLocalConfigService.ts b/packages/agent-core-v2/src/persistence/backends/node-fs/projectLocalConfigService.ts index db5506b881..7ba2298369 100644 --- a/packages/agent-core-v2/src/persistence/backends/node-fs/projectLocalConfigService.ts +++ b/packages/agent-core-v2/src/persistence/backends/node-fs/projectLocalConfigService.ts @@ -2,9 +2,12 @@ * `FileProjectLocalConfigService` — node-fs backend for `IProjectLocalConfigService`. * * Discovers project roots, parses and writes project-local - * `.kimi-code/local.toml`, resolves additional directories with - * v1-compatible OS-home expansion through `bootstrap`, and accesses the local - * filesystem through `hostFs`. Works purely by path (project-root discovery + * `.kimi-code/local.toml` (the `[workspace]` additional-dir list plus the + * `[subagent.]` / `[subagent-slot.]` model/effort binding + * tables), resolves additional directories with v1-compatible OS-home + * expansion through `bootstrap`, and accesses the local filesystem through + * `hostFs`. Binding writes preserve unrelated TOML content and drop a + * section table that goes empty. Works purely by path (project-root discovery * via the nearest `.git` ancestor); it never touches the workspace catalog or * a `workspaceId`. Bound at App scope. */ @@ -19,21 +22,32 @@ import { IBootstrapService } from '#/app/bootstrap/bootstrap'; import { IProjectLocalConfigService, type ProjectAdditionalDirsLoadResult, + type SubagentBinding, } from '#/app/projectLocalConfig/projectLocalConfig'; import { ErrorCodes, Error2, unwrapErrorCause } from '#/errors'; import { IHostFileSystem } from '#/os/interface/hostFileSystem'; import { StorageError, StorageErrors, toStorageIoError } from '#/persistence/interface/storage'; +const SubagentBindingTomlSchema = z.object({ + model: z.string().optional(), + thinking_effort: z.string().optional(), + inherit: z.boolean().optional(), +}); + const ProjectLocalTomlSchema = z.object({ workspace: z .object({ additional_dir: z.array(z.string()), }) .optional(), + subagent: z.record(z.string(), SubagentBindingTomlSchema).optional(), + 'subagent-slot': z.record(z.string(), SubagentBindingTomlSchema).optional(), }); type ProjectLocalToml = z.infer; +type BindingSection = 'subagent' | 'subagent-slot'; + interface ProjectLocalTomlFile { readonly raw: Record; readonly parsed: ProjectLocalToml; @@ -97,6 +111,30 @@ export class FileProjectLocalConfigService implements IProjectLocalConfigService return { projectRoot, configPath, additionalDirs: [...fileExistingDirs, additionalDir] }; } + readSubagentBinding(workDir: string, agentType: string): Promise { + return this.readBindingEntry(workDir, 'subagent', agentType); + } + + writeSubagentBinding( + workDir: string, + agentType: string, + binding: SubagentBinding | undefined, + ): Promise<{ readonly configPath: string }> { + return this.writeBindingEntry(workDir, 'subagent', agentType, binding); + } + + readSubagentSlotBinding(workDir: string, slot: string): Promise { + return this.readBindingEntry(workDir, 'subagent-slot', slot); + } + + writeSubagentSlotBinding( + workDir: string, + slot: string, + binding: SubagentBinding | undefined, + ): Promise<{ readonly configPath: string }> { + return this.writeBindingEntry(workDir, 'subagent-slot', slot, binding); + } + private getProjectLocalConfigPath(projectRoot: string): string { return join(projectRoot, '.kimi-code', 'local.toml'); } @@ -150,6 +188,59 @@ export class FileProjectLocalConfigService implements IProjectLocalConfigService return { raw: cloneRecord(raw), parsed: parseProjectLocalToml(raw) }; } + private async readBindingEntry( + workDir: string, + section: BindingSection, + name: string, + ): Promise { + const projectRoot = await this.findProjectRoot(workDir); + const configPath = this.getProjectLocalConfigPath(projectRoot); + const file = await this.readProjectLocalToml(configPath); + const entry = file?.parsed[section]?.[name]; + if (entry === undefined) return undefined; + return { + model: entry.model, + thinkingEffort: entry.thinking_effort, + inherit: entry.inherit, + }; + } + + private async writeBindingEntry( + workDir: string, + section: BindingSection, + name: string, + binding: SubagentBinding | undefined, + ): Promise<{ readonly configPath: string }> { + const projectRoot = await this.findProjectRoot(workDir); + const configPath = this.getProjectLocalConfigPath(projectRoot); + const file = (await this.readProjectLocalToml(configPath)) ?? { raw: {}, parsed: {} }; + + const record = cloneRecord(file.raw[section]); + if (binding === undefined) { + delete record[name]; + } else { + const entry: Record = {}; + if (binding.model !== undefined) entry['model'] = binding.model; + if (binding.thinkingEffort !== undefined) entry['thinking_effort'] = binding.thinkingEffort; + if (binding.inherit !== undefined) entry['inherit'] = binding.inherit; + record[name] = entry; + } + if (Object.keys(record).length === 0) { + delete file.raw[section]; + } else { + file.raw[section] = record; + } + + try { + await this.fs.mkdir(dirname(configPath), { recursive: true }); + await this.fs.writeText(configPath, `${stringifyToml(file.raw)}\n`); + } catch (error: unknown) { + throw toStorageIoError(error, { path: configPath, op: 'write' }); + } + + return { configPath }; + } + private async resolveAdditionalDirsInternal( baseDir: string, additionalDirs: readonly string[], diff --git a/packages/agent-core-v2/src/session/subagent/bindingAsk.ts b/packages/agent-core-v2/src/session/subagent/bindingAsk.ts new file mode 100644 index 0000000000..2c30b3db4e --- /dev/null +++ b/packages/agent-core-v2/src/session/subagent/bindingAsk.ts @@ -0,0 +1,155 @@ +/** + * `subagent` domain (L6) — interactive ask-once binding creation. + * + * The factory behind the `ask` callback of `bindingResolution`, used only by + * the `Agent` tool spawn path (the swarm path never asks). The first spawn + * of an unbound subagent type or named slot — or a stored binding that + * references a model alias absent from or unresolvable in the catalog — + * asks the user once through `ISessionQuestionService` (routed to the asking + * agent's surfaces, never `main`) and persists the answer to + * `.kimi-code/local.toml` (`[subagent.]` / `[subagent-slot.]`), + * including an explicit "keep inheriting" choice so the question never + * repeats for that type or slot. A model whose catalog entry declares + * `support_efforts` is followed up by a thinking-effort question. Every + * failure mode — dismissal, a non-interactive client + * (`CoreErrors.codes.NOT_IMPLEMENTED`), or any other question error — + * resolves to `undefined` so the spawn silently inherits and is never + * blocked; only abort errors propagate. Model options are projected from + * `IModelCatalog.listModels`; an empty catalog skips the ask entirely. + */ + +import { isAbortError } from '#/_base/utils/abort'; +import { + type IProjectLocalConfigService, + type SubagentBinding, +} from '#/app/projectLocalConfig/projectLocalConfig'; +import { type IModelCatalog } from '#/kosong/model/catalog'; +import { + type ISessionQuestionService, + type QuestionAnswers, + type QuestionOption, + type QuestionResponse, + type QuestionResult, +} from '#/session/question/question'; + +import { type AskSubagentSpawnBindingCallback } from './bindingResolution'; + +const INHERIT_LABEL = 'Keep inheriting from the main agent'; + +let bindingAskSeq = 0; + +export interface SubagentBindingAskerDeps { + readonly question: ISessionQuestionService; + readonly projectLocalConfig: IProjectLocalConfigService; + readonly modelCatalog: IModelCatalog; + readonly workDir: string; + readonly agentId: string; + readonly signal?: AbortSignal; +} + +export function createSubagentBindingAsker( + deps: SubagentBindingAskerDeps, +): AskSubagentSpawnBindingCallback { + return async (profileName, context) => { + const models = await deps.modelCatalog.listModels(); + if (models.length === 0) return undefined; + + const missingModel = context?.missingModel; + const slot = context?.slot; + const subject = slot === undefined ? `Subagent type "${profileName}"` : `Binding slot "${slot}"`; + const modelQuestion = + missingModel === undefined + ? `${subject} has no model binding in this workspace. Bind a model for it?` + : `${subject} is bound to model "${missingModel}", but that alias no longer exists in your models config or cannot be resolved. Bind a model for it?`; + const chosen = await askOne(deps, { + question: modelQuestion, + options: [ + { + label: INHERIT_LABEL, + description: 'Recorded as the choice for this workspace; you will not be asked again', + }, + ...models.map((model) => ({ label: model.model })), + ], + }); + if (chosen === undefined) return undefined; + + const persist = async (binding: SubagentBinding): Promise => { + if (slot === undefined) { + await deps.projectLocalConfig.writeSubagentBinding(deps.workDir, profileName, binding); + } else { + await deps.projectLocalConfig.writeSubagentSlotBinding(deps.workDir, slot, binding); + } + }; + if (chosen === INHERIT_LABEL) { + const binding: SubagentBinding = { inherit: true }; + await persist(binding); + return binding; + } + + const model = chosen; + let thinkingEffort: string | undefined; + const supportEfforts = models.find((item) => item.model === model)?.support_efforts ?? []; + if (supportEfforts.length > 0) { + const effortQuestion = `Thinking effort for ${subject} on ${model}?`; + const effort = await askOne(deps, { + question: effortQuestion, + options: [ + { label: INHERIT_LABEL, description: 'Inherit the main agent thinking effort' }, + ...supportEfforts.map((value) => ({ label: value })), + ], + }); + if (effort !== undefined && effort !== INHERIT_LABEL) thinkingEffort = effort; + } + + const binding: SubagentBinding = { model, thinkingEffort }; + await persist(binding); + return binding; + }; +} + +interface BindingQuestion { + readonly question: string; + readonly options: readonly QuestionOption[]; +} + +async function askOne( + deps: SubagentBindingAskerDeps, + item: BindingQuestion, +): Promise { + let result: QuestionResult; + try { + result = await deps.question.request( + { + id: `subagent-binding:${deps.agentId}:${String(++bindingAskSeq)}`, + questions: [{ question: item.question, header: 'Subagent', options: item.options }], + }, + { agentId: deps.agentId, signal: deps.signal }, + ); + } catch (error) { + if (isAbortError(error)) throw error; + return undefined; + } + const signal = deps.signal; + if (signal?.aborted === true) signal.throwIfAborted(); + return answerFor(result, item.question); +} + +function answerFor(result: QuestionResult, question: string): string | undefined { + const answers = normalizeAnswers(result); + if (answers === undefined) return undefined; + const value = answers[question]; + return typeof value === 'string' && value.length > 0 ? value : undefined; +} + +function normalizeAnswers(result: QuestionResult): QuestionAnswers | undefined { + if (result === null) return undefined; + if (isQuestionResponse(result)) return result.answers; + return result; +} + +function isQuestionResponse(result: Exclude): result is QuestionResponse { + if (typeof result !== 'object') return false; + if (!Object.hasOwn(result, 'answers')) return false; + const answers = (result as { readonly answers?: unknown }).answers; + return typeof answers === 'object' && answers !== null && !Array.isArray(answers); +} diff --git a/packages/agent-core-v2/src/session/subagent/bindingResolution.ts b/packages/agent-core-v2/src/session/subagent/bindingResolution.ts new file mode 100644 index 0000000000..2a07e57d07 --- /dev/null +++ b/packages/agent-core-v2/src/session/subagent/bindingResolution.ts @@ -0,0 +1,199 @@ +/** + * `subagent` domain (L6) — spawn-time workspace model binding resolution. + * + * Pure-function module (no scoped state; services arrive as parameters) that + * resolves the effective model/thinking overrides for one subagent spawn + * from the `.kimi-code/local.toml` binding tables: a named slot + * (`[subagent-slot.]`) wins over the type binding + * (`[subagent.]`), and both fall back to inheriting the caller model + * (an empty resolution). Gated by the `subagent-model-selection` + * experimental flag — while disabled the resolution is always empty, so the + * spawn points behave exactly as they did before bindings existed. + * `inherit: true` entries terminate the resolution without fallback — with a + * warning when the same entry also carries `model` or `thinking_effort`, + * which are ignored; a stale model alias (one `modelCatalog.get` rejects, + * whether missing or otherwise unresolvable) warns and falls through to the + * next-lower level. Storage failures are not swallowed — they propagate to + * the caller. + * + * Callers may supply an optional interactive `ask` callback — only the + * `Agent` tool spawn path does; the swarm path never asks. When present, a + * missing slot/type binding or a stale stored alias asks the user once and + * adopts the (already persisted) answer as terminal; a dismissed ask falls + * through exactly like a missing entry, keeping the stale-alias warning on + * the repair case. One spawn asks at most once: when a slot was explicitly + * requested, its (dismissed) ask never escalates into a second, type-level + * question — the type binding still applies when configured, it just stays + * silent. + */ + +import { IFlagService } from '#/app/flag/flag'; +import { + type IProjectLocalConfigService, + type SubagentBinding, +} from '#/app/projectLocalConfig/projectLocalConfig'; +import { type IModelCatalog } from '#/kosong/model/catalog'; + +import { SUBAGENT_MODEL_SELECTION_FLAG_ID } from './flag'; + +export interface SubagentSpawnBindingResolution { + readonly model?: string; + readonly thinking?: string; + readonly warning?: string; +} + +export interface AskSubagentBindingContext { + readonly missingModel?: string; + readonly slot?: string; +} + +export type AskSubagentSpawnBindingCallback = ( + profileName: string, + context?: AskSubagentBindingContext, +) => Promise; + +export interface ResolveSubagentSpawnBindingDeps { + readonly flags: IFlagService; + readonly projectLocalConfig: IProjectLocalConfigService; + readonly modelCatalog: IModelCatalog; + readonly ask?: AskSubagentSpawnBindingCallback; +} + +export interface ResolveSubagentSpawnBindingInput { + readonly workDir: string; + readonly profileName: string; + readonly bindingSlot?: string; +} + +export async function resolveSubagentSpawnBinding( + deps: ResolveSubagentSpawnBindingDeps, + input: ResolveSubagentSpawnBindingInput, +): Promise { + if (!deps.flags.enabled(SUBAGENT_MODEL_SELECTION_FLAG_ID)) return {}; + + const warnings: string[] = []; + const slot = input.bindingSlot?.trim(); + const explicitSlot = slot !== undefined && slot.length > 0; + if (explicitSlot) { + const slotEntry = await deps.projectLocalConfig.readSubagentSlotBinding( + input.workDir, + slot, + ); + if (slotEntry !== undefined) { + const resolved = resolveBindingEntry( + deps, + slotEntry, + `subagent-slot.${slot}`, + 'the type binding', + ); + if (resolved.terminal) return withWarnings(resolved.resolution, warnings); + const asked = await askOnce(deps, input.profileName, { + slot, + missingModel: resolved.missingModel, + }); + if (asked !== undefined) return withWarnings(asked, warnings); + warnings.push(resolved.warning); + } else { + const asked = await askOnce(deps, input.profileName, { slot }); + if (asked !== undefined) return withWarnings(asked, warnings); + } + } + + const typeEntry = await deps.projectLocalConfig.readSubagentBinding( + input.workDir, + input.profileName, + ); + const mayAsk = !explicitSlot; + if (typeEntry !== undefined) { + const resolved = resolveBindingEntry( + deps, + typeEntry, + `subagent.${input.profileName}`, + 'the caller model', + ); + if (resolved.terminal) return withWarnings(resolved.resolution, warnings); + const asked = mayAsk + ? await askOnce(deps, input.profileName, { + missingModel: resolved.missingModel, + }) + : undefined; + if (asked !== undefined) return withWarnings(asked, warnings); + warnings.push(resolved.warning); + } else { + const asked = mayAsk ? await askOnce(deps, input.profileName, undefined) : undefined; + if (asked !== undefined) return withWarnings(asked, warnings); + } + + return withWarnings({}, warnings); +} + +async function askOnce( + deps: ResolveSubagentSpawnBindingDeps, + profileName: string, + context: AskSubagentBindingContext | undefined, +): Promise { + if (deps.ask === undefined) return undefined; + const binding = await deps.ask(profileName, context); + if (binding === undefined) return undefined; + return adoptAskedBinding(binding); +} + +function adoptAskedBinding(binding: SubagentBinding): SubagentSpawnBindingResolution { + if (binding.inherit === true) return {}; + return { model: binding.model, thinking: binding.thinkingEffort }; +} + +type BindingEntryResolution = + | { readonly terminal: true; readonly resolution: SubagentSpawnBindingResolution } + | { + readonly terminal: false; + readonly warning: string; + readonly missingModel: string; + }; + +function resolveBindingEntry( + deps: ResolveSubagentSpawnBindingDeps, + entry: SubagentBinding, + sectionLabel: string, + fallbackLabel: string, +): BindingEntryResolution { + if (entry.inherit === true) { + const ignored: string[] = []; + if (entry.model !== undefined) ignored.push('model'); + if (entry.thinkingEffort !== undefined) ignored.push('thinking_effort'); + if (ignored.length === 0) return { terminal: true, resolution: {} }; + return { + terminal: true, + resolution: { + warning: `Subagent binding [${sectionLabel}] sets inherit=true; ignoring ${ignored.join(' and ')} set on the same entry.`, + }, + }; + } + if (entry.model === undefined) { + return { terminal: true, resolution: { thinking: entry.thinkingEffort } }; + } + try { + deps.modelCatalog.get(entry.model); + } catch { + return { + terminal: false, + warning: `Subagent binding [${sectionLabel}] references model alias "${entry.model}" which is not configured or cannot be resolved; falling back to ${fallbackLabel}.`, + missingModel: entry.model, + }; + } + return { + terminal: true, + resolution: { model: entry.model, thinking: entry.thinkingEffort }, + }; +} + +function withWarnings( + resolution: SubagentSpawnBindingResolution, + warnings: readonly string[], +): SubagentSpawnBindingResolution { + if (warnings.length === 0) return resolution; + const warning = [...warnings, resolution.warning] + .filter((line): line is string => line !== undefined) + .join(' '); + return { ...resolution, warning }; +} diff --git a/packages/agent-core-v2/src/session/subagent/flag.ts b/packages/agent-core-v2/src/session/subagent/flag.ts new file mode 100644 index 0000000000..a9f0601e73 --- /dev/null +++ b/packages/agent-core-v2/src/session/subagent/flag.ts @@ -0,0 +1,30 @@ +/** + * `subagent` domain (L6) — registers the `subagent-model-selection` + * experimental flag into `flag`. + * + * Gates per-workspace subagent model bindings: `[subagent.]` and + * `[subagent-slot.]` entries in `.kimi-code/local.toml` override the + * inherit-parent-model behavior at spawn time, and the `Agent` tool grows a + * `binding_slot` parameter addressing a named slot. Off by default; enable + * via `KIMI_CODE_EXPERIMENTAL_SUBAGENT_MODEL_SELECTION`, the master + * `KIMI_CODE_EXPERIMENTAL_FLAG`, or the `[experimental]` config section. + * Imported for its side effect (registers the definition) from the package + * barrel. + */ + +import { type FlagDefinitionInput, registerFlagDefinition } from '#/app/flag/flagRegistry'; + +export const SUBAGENT_MODEL_SELECTION_FLAG_ID = 'subagent-model-selection'; +export const SUBAGENT_MODEL_SELECTION_FLAG_ENV = 'KIMI_CODE_EXPERIMENTAL_SUBAGENT_MODEL_SELECTION'; + +export const subagentModelSelectionFlag: FlagDefinitionInput = { + id: SUBAGENT_MODEL_SELECTION_FLAG_ID, + title: 'Subagent model selection (workspace bindings)', + description: + 'Resolve per-workspace subagent model bindings ([subagent.] and [subagent-slot.] in .kimi-code/local.toml) at spawn time instead of always inheriting the caller model. The Agent tool gains a binding_slot parameter that addresses a named slot; slots and type bindings fall back to parent inheritance when missing or stale.', + env: SUBAGENT_MODEL_SELECTION_FLAG_ENV, + default: false, + surface: 'core', +}; + +registerFlagDefinition(subagentModelSelectionFlag); diff --git a/packages/agent-core-v2/src/session/subagent/mirrorAgentRun.ts b/packages/agent-core-v2/src/session/subagent/mirrorAgentRun.ts index 4f17219079..6d53aaa545 100644 --- a/packages/agent-core-v2/src/session/subagent/mirrorAgentRun.ts +++ b/packages/agent-core-v2/src/session/subagent/mirrorAgentRun.ts @@ -44,6 +44,8 @@ export interface SubagentSpawnedEvent { readonly description?: string; readonly swarmIndex?: number; readonly runInBackground: boolean; + readonly modelAlias?: string; + readonly thinkingEffort?: string; } export interface SubagentStartedEvent { @@ -81,6 +83,8 @@ export interface AgentRunSpawnedMeta { readonly description?: string; readonly swarmIndex?: number; readonly runInBackground?: boolean; + readonly modelAlias?: string; + readonly thinkingEffort?: string; } export interface MirrorAgentRunOptions { @@ -107,6 +111,8 @@ export function emitAgentRunSpawned( description: meta.description, swarmIndex: meta.swarmIndex, runInBackground: meta.runInBackground ?? false, + modelAlias: meta.modelAlias, + thinkingEffort: meta.thinkingEffort, }); requester.accessor.get(ITelemetryService)?.track2('subagent_created', { subagent_name: meta.profileName, diff --git a/packages/agent-core-v2/src/session/subagent/tools/agent.ts b/packages/agent-core-v2/src/session/subagent/tools/agent.ts index e98c270ad5..ef01b018c0 100644 --- a/packages/agent-core-v2/src/session/subagent/tools/agent.ts +++ b/packages/agent-core-v2/src/session/subagent/tools/agent.ts @@ -2,8 +2,12 @@ * `subagent` domain (L6) — the `Agent` collaboration tool. * * The LLM-facing wrapper over the `subagent` domain: translates the tool args - * into a Profile + Model binding, creates (or resumes) an agent through - * `IAgentLifecycleService`, drives one turn via `ISessionSubagentService.run`, + * into a Profile + Model binding — resolving the workspace model bindings + * (`bindingResolution`) behind the `subagent-model-selection` experimental + * flag, asking the user once to create a missing or stale binding + * (`bindingAsk`) — creates (or resumes) an agent through + * `IAgentLifecycleService` (resume keeps the child's own binding and fails + * fast on a stale alias), drives one turn via `ISessionSubagentService.run`, * and mirrors the run onto the calling agent's record stream * (`mirrorAgentRun`). The tool also owns the JSON schema + description, * approval rule, background-task registration (so the LLM can see the run @@ -56,12 +60,19 @@ import { } from '#/app/agentProfileCatalog/profile-shared'; import { ILogService } from '#/_base/log/log'; import { IConfigService } from '#/app/config/config'; +import { IFlagService } from '#/app/flag/flag'; +import { IProjectLocalConfigService } from '#/app/projectLocalConfig/projectLocalConfig'; +import { IModelCatalog } from '#/kosong/model/catalog'; import { IAgentLifecycleService } from '#/session/agentLifecycle/agentLifecycle'; import { isSubagentMeta, subagentLabels, subagentParentAgentId } from '#/session/agentLifecycle/subagentMetadata'; import { ISessionProcessRunner } from '#/session/process/processRunner'; +import { ISessionQuestionService } from '#/session/question/question'; import { ISessionMetadata } from '#/session/sessionMetadata/sessionMetadata'; import { ISessionWorkspaceContext } from '#/session/workspaceContext/workspaceContext'; +import { createSubagentBindingAsker } from '../bindingAsk'; +import { resolveSubagentSpawnBinding } from '../bindingResolution'; +import { SUBAGENT_MODEL_SELECTION_FLAG_ID } from '../flag'; import { emitAgentRunSpawned, mirrorAgentRun } from '../mirrorAgentRun'; import { ISessionSubagentService } from '../subagent'; import { @@ -110,6 +121,12 @@ export const AgentToolInputSchema = z.preprocess( .describe( 'Optional agent ID to resume instead of creating a new instance. When set, do not also pass subagent_type — the resumed agent keeps its own type, and supplying both is rejected.', ), + binding_slot: z + .string() + .optional() + .describe( + 'Named binding slot pre-configured by the user for this workspace (.kimi-code/local.toml under [subagent-slot.]). Set ONLY when the task or preset explicitly names a slot — a slot selects a user-configured model/effort. Never invent slot names, and never use this to choose a model yourself.', + ), run_in_background: z .boolean() .optional() @@ -143,11 +160,14 @@ const RESUME_WITH_TYPE_UNAVAILABLE = const USER_INTERRUPTED_SUBAGENT_MESSAGE = 'The subagent was stopped before it finished by user.'; const SUBAGENT_STOPPED_MESSAGE = 'The subagent was stopped before it finished.'; +const SUBAGENT_MODEL_UNAVAILABLE_MESSAGE = + 'The configured subagent model alias is not resolvable. Check the bindings in .kimi-code/local.toml and your models config.'; export class AgentTool implements BuiltinTool { readonly name: string = 'Agent'; - readonly parameters: Record = toInputJsonSchema(AgentToolInputSchema); + + private readonly fullParameters: Record = toInputJsonSchema(AgentToolInputSchema); private readonly callerAgentId: string; private readonly canRunInBackground: () => boolean; @@ -167,6 +187,10 @@ export class AgentTool implements BuiltinTool { @ILogService private readonly log: ILogService, @IAgentPermissionModeService private readonly permissionMode: IAgentPermissionModeService, @IConfigService private readonly config: IConfigService, + @IFlagService private readonly flags: IFlagService, + @IProjectLocalConfigService private readonly projectLocalConfig: IProjectLocalConfigService, + @IModelCatalog private readonly modelCatalog: IModelCatalog, + @ISessionQuestionService private readonly question: ISessionQuestionService, ) { this.callerAgentId = scopeContext.agentId; this.canRunInBackground = () => @@ -175,6 +199,15 @@ export class AgentTool implements BuiltinTool { this.toolPolicy.isToolActive('TaskStop'); } + get parameters(): Record { + if (this.flags.enabled(SUBAGENT_MODEL_SELECTION_FLAG_ID)) { + return this.fullParameters; + } + const properties = { ...(this.fullParameters['properties'] as Record) }; + delete properties['binding_slot']; + return { ...this.fullParameters, properties }; + } + get description(): string { const backgroundDescription = this.canRunInBackground() ? AGENT_BACKGROUND_DESCRIPTION @@ -250,6 +283,8 @@ export class AgentTool implements BuiltinTool { let agentId: string; let profileName: string; let promptText = args.prompt; + let spawnedModelAlias: string | undefined; + let spawnedThinkingEffort: string | undefined; if (isResume) { const target = this.lifecycle.get(resumeAgentId); if (target === undefined) { @@ -258,8 +293,12 @@ export class AgentTool implements BuiltinTool { await this.ensureOwnedIdleSubagent(resumeAgentId, target); this.realignChildModel(target); agentId = target.id; - profileName = - target.accessor.get(IAgentProfileService).data().profileName ?? RESUMED_LABEL; + const childData = target.accessor.get(IAgentProfileService).data(); + profileName = childData.profileName ?? RESUMED_LABEL; + if (this.flags.enabled(SUBAGENT_MODEL_SELECTION_FLAG_ID)) { + spawnedModelAlias = childData.modelAlias; + spawnedThinkingEffort = childData.thinkingLevel; + } } else { const requestedProfileName = args.subagent_type?.length ? args.subagent_type @@ -274,14 +313,43 @@ export class AgentTool implements BuiltinTool { if (profile === undefined) { throw new Error(`Unknown agent type: "${requestedProfileName}"`); } - if (own.modelAlias === undefined) { + const bindingResolution = await resolveSubagentSpawnBinding( + { + flags: this.flags, + projectLocalConfig: this.projectLocalConfig, + modelCatalog: this.modelCatalog, + ask: createSubagentBindingAsker({ + question: this.question, + projectLocalConfig: this.projectLocalConfig, + modelCatalog: this.modelCatalog, + workDir: this.workspace.workDir, + agentId: this.callerAgentId, + signal: controller.signal, + }), + }, + { + workDir: this.workspace.workDir, + profileName: profile.name, + bindingSlot: args.binding_slot, + }, + ); + if (bindingResolution.warning !== undefined) { + this.log.warn('subagent binding resolution', { warning: bindingResolution.warning }); + } + const model = bindingResolution.model ?? own.modelAlias; + if (model === undefined) { throw new Error('Caller agent has no model bound'); } + const thinking = bindingResolution.thinking ?? own.thinkingLevel; + if (this.flags.enabled(SUBAGENT_MODEL_SELECTION_FLAG_ID)) { + spawnedModelAlias = model; + spawnedThinkingEffort = thinking; + } const created = await this.lifecycle.create({ binding: { profile: profile.name, - model: own.modelAlias, - thinking: own.thinkingLevel, + model, + thinking, cwd: own.cwd, }, labels: subagentLabels(this.callerAgentId), @@ -305,6 +373,8 @@ export class AgentTool implements BuiltinTool { parentToolCallId: toolCallId, description: args.description, runInBackground, + modelAlias: spawnedModelAlias, + thinkingEffort: spawnedThinkingEffort, }); const run = await this.subagents.run( @@ -344,6 +414,17 @@ export class AgentTool implements BuiltinTool { } private realignChildModel(target: IAgentScopeHandle): void { + if (this.flags.enabled(SUBAGENT_MODEL_SELECTION_FLAG_ID)) { + const childModelAlias = target.accessor.get(IAgentProfileService).data().modelAlias; + if (childModelAlias !== undefined) { + try { + this.modelCatalog.get(childModelAlias); + } catch { + throw new Error(SUBAGENT_MODEL_UNAVAILABLE_MESSAGE); + } + } + return; + } const modelAlias = this.profile.data().modelAlias; if (modelAlias === undefined) { throw new Error('Caller agent has no model bound'); diff --git a/packages/agent-core-v2/src/session/swarm/agentRunBatch.ts b/packages/agent-core-v2/src/session/swarm/agentRunBatch.ts index 2488c37356..3f09c69c03 100644 --- a/packages/agent-core-v2/src/session/swarm/agentRunBatch.ts +++ b/packages/agent-core-v2/src/session/swarm/agentRunBatch.ts @@ -31,6 +31,7 @@ export interface AgentRunAttemptOptions { export interface AgentSpawnAttemptOptions extends AgentRunAttemptOptions { readonly profileName: string; readonly swarmItem?: string; + readonly bindingSlot?: string; } export type AgentRunAttemptHandle = { @@ -301,6 +302,7 @@ export class AgentRunBatch { const spawnOptions: AgentSpawnAttemptOptions = { profileName: task.profileName, swarmItem: task.swarmItem, + bindingSlot: task.bindingSlot, ...runOptions, }; handle = await this.launcher.spawn(spawnOptions); diff --git a/packages/agent-core-v2/src/session/swarm/sessionSwarm.ts b/packages/agent-core-v2/src/session/swarm/sessionSwarm.ts index 7915237aac..dd1efa2787 100644 --- a/packages/agent-core-v2/src/session/swarm/sessionSwarm.ts +++ b/packages/agent-core-v2/src/session/swarm/sessionSwarm.ts @@ -28,6 +28,7 @@ type SessionSwarmTaskBase = { export type SessionSwarmSpawnTask = SessionSwarmTaskBase & { readonly kind: 'spawn'; readonly resumeAgentId?: undefined; + readonly bindingSlot?: string; }; export type SessionSwarmResumeTask = SessionSwarmTaskBase & { diff --git a/packages/agent-core-v2/src/session/swarm/sessionSwarmService.ts b/packages/agent-core-v2/src/session/swarm/sessionSwarmService.ts index 889c3fe17f..9636741955 100644 --- a/packages/agent-core-v2/src/session/swarm/sessionSwarmService.ts +++ b/packages/agent-core-v2/src/session/swarm/sessionSwarmService.ts @@ -5,8 +5,12 @@ * `AgentRunBatchLauncher` on top of the `agentLifecycle` primitives * (`create({ binding })`, `run`), drives the internal `AgentRunBatch` * scheduler, and tracks one `AbortController` per caller so `cancel` can abort - * every in-flight run. The caller ↔ child association is this domain's own - * business data: requester-side display facts (`subagent.spawned` wire signals + * every in-flight run. Spawn attempts resolve the workspace model bindings + * (`subagent/bindingResolution`) behind the `subagent-model-selection` + * experimental flag before falling back to the caller model; resume attempts + * keep the child's own binding and fail fast on a stale alias. The caller ↔ + * child association is this domain's own business data: requester-side display + * facts (`subagent.spawned` wire signals * carrying the swarm's tool-call context, `subagent.suspended` when a task is * requeued after a provider rate limit) are emitted here / via the * `agentLifecycle` wrapper helper `mirrorAgentRun`; the lifecycle registry @@ -24,8 +28,11 @@ import { IAgentPermissionModeService } from '#/agent/permissionMode/permissionMo import { IAgentLoopService } from '#/agent/loop/loop'; import { IAgentUserToolService } from '#/agent/userTool/userTool'; import { IEventBus } from '#/app/event/eventBus'; +import { IFlagService } from '#/app/flag/flag'; +import { IProjectLocalConfigService } from '#/app/projectLocalConfig/projectLocalConfig'; import { ISessionAgentProfileCatalog } from '#/session/sessionAgentProfileCatalog/sessionAgentProfileCatalog'; import { applyProfilePromptPrefix } from '#/app/agentProfileCatalog/promptPrefix'; +import { IModelCatalog } from '#/kosong/model/catalog'; import { IAgentLifecycleService } from '#/session/agentLifecycle/agentLifecycle'; import { isSubagentMeta, @@ -33,6 +40,8 @@ import { subagentParentAgentId, subagentSwarmItem, } from '#/session/agentLifecycle/subagentMetadata'; +import { resolveSubagentSpawnBinding } from '#/session/subagent/bindingResolution'; +import { SUBAGENT_MODEL_SELECTION_FLAG_ID } from '#/session/subagent/flag'; import { emitAgentRunSpawned, mirrorAgentRun } from '#/session/subagent/mirrorAgentRun'; import { ISessionSubagentService } from '#/session/subagent/subagent'; import { ISessionContext } from '#/session/sessionContext/sessionContext'; @@ -68,6 +77,8 @@ declare module '#/app/event/eventBus' { } const RESUMED_PROFILE_FALLBACK = 'subagent'; +const SUBAGENT_MODEL_UNAVAILABLE_MESSAGE = + 'The configured subagent model alias is not resolvable. Check the bindings in .kimi-code/local.toml and your models config.'; export class SessionSwarmService implements ISessionSwarmService { declare readonly _serviceBrand: undefined; @@ -82,6 +93,9 @@ export class SessionSwarmService implements ISessionSwarmService { @ISessionMetadata private readonly metadata: ISessionMetadata, @ISessionProcessRunner private readonly processRunner: ISessionProcessRunner, @ILogService private readonly log: ILogService, + @IFlagService private readonly flags: IFlagService, + @IProjectLocalConfigService private readonly projectLocalConfig: IProjectLocalConfigService, + @IModelCatalog private readonly modelCatalog: IModelCatalog, ) {} async getSwarmItem(args: { @@ -141,14 +155,32 @@ export class SessionSwarmService implements ISessionSwarmService { throw new Error(`Unknown agent type: "${options.profileName}"`); } const callerData = caller.accessor.get(IAgentProfileService).data(); - if (callerData.modelAlias === undefined) { + const bindingResolution = await resolveSubagentSpawnBinding( + { + flags: this.flags, + projectLocalConfig: this.projectLocalConfig, + modelCatalog: this.modelCatalog, + }, + { + workDir: this.sessionContext.cwd, + profileName: profile.name, + bindingSlot: options.bindingSlot, + }, + ); + if (bindingResolution.warning !== undefined) { + this.log.warn('subagent binding resolution', { warning: bindingResolution.warning }); + } + const model = bindingResolution.model ?? callerData.modelAlias; + if (model === undefined) { throw new Error('Caller agent has no model bound'); } + const thinking = bindingResolution.thinking ?? callerData.thinkingLevel; + const selectionEnabled = this.flags.enabled(SUBAGENT_MODEL_SELECTION_FLAG_ID); const child = await this.lifecycle.create({ binding: { profile: profile.name, - model: callerData.modelAlias, - thinking: callerData.thinkingLevel, + model, + thinking, cwd: callerData.cwd, }, labels: subagentLabels(callerAgentId, { swarmItem: options.swarmItem }), @@ -166,6 +198,8 @@ export class SessionSwarmService implements ISessionSwarmService { description: options.description, swarmIndex: options.swarmIndex, runInBackground: options.runInBackground, + modelAlias: selectionEnabled ? model : undefined, + thinkingEffort: selectionEnabled ? thinking : undefined, }); const promptText = await applyProfilePromptPrefix(profile, options.prompt, { cwd: this.sessionContext.cwd, @@ -190,9 +224,10 @@ export class SessionSwarmService implements ISessionSwarmService { const child = this.requireHandle(agentId, 'Agent instance'); this.requireIdleSubagent(agentId, child); this.realignChildModel(caller, child); - const profileName = - child.accessor.get(IAgentProfileService).data().profileName ?? RESUMED_PROFILE_FALLBACK; + const childData = child.accessor.get(IAgentProfileService).data(); + const profileName = childData.profileName ?? RESUMED_PROFILE_FALLBACK; if (!retryTurn) { + const selectionEnabled = this.flags.enabled(SUBAGENT_MODEL_SELECTION_FLAG_ID); emitAgentRunSpawned(caller, agentId, { profileName, parentToolCallId: options.parentToolCallId, @@ -200,6 +235,8 @@ export class SessionSwarmService implements ISessionSwarmService { description: options.description, swarmIndex: options.swarmIndex, runInBackground: options.runInBackground, + modelAlias: selectionEnabled ? childData.modelAlias : undefined, + thinkingEffort: selectionEnabled ? childData.thinkingLevel : undefined, }); } const request = retryTurn @@ -239,6 +276,17 @@ export class SessionSwarmService implements ISessionSwarmService { } private realignChildModel(caller: IAgentScopeHandle, child: IAgentScopeHandle): void { + if (this.flags.enabled(SUBAGENT_MODEL_SELECTION_FLAG_ID)) { + const childModelAlias = child.accessor.get(IAgentProfileService).data().modelAlias; + if (childModelAlias !== undefined) { + try { + this.modelCatalog.get(childModelAlias); + } catch { + throw new Error(SUBAGENT_MODEL_UNAVAILABLE_MESSAGE); + } + } + return; + } const modelAlias = caller.accessor.get(IAgentProfileService).data().modelAlias; if (modelAlias === undefined) { throw new Error('Caller agent has no model bound'); 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 3fe4f6b895..5526ab9388 100644 --- a/packages/agent-core-v2/test/agent/loop/loop.test.ts +++ b/packages/agent-core-v2/test/agent/loop/loop.test.ts @@ -12,15 +12,18 @@ import type { ExecutableTool } from '#/tool/toolContract'; import { IAgentToolRegistryService } from '#/agent/toolRegistry/toolRegistry'; import { IAgentUsageService } from '#/agent/usage/usage'; import { IEventBus } from '#/app/event/eventBus'; +import { IFlagService } from '#/app/flag/flag'; import { userCancellationReason } from '#/_base/utils/abort'; import { agentService, + appService, createTestAgent, permissionModeServices, type TestAgentContext, type TestAgentOptions, } from '../../harness'; +import { stubFlag } from '../../app/flag/stubs'; import { recordingTelemetry, type TelemetryRecord } from '../../app/telemetry/stubs'; type GenerateFn = NonNullable; @@ -89,6 +92,8 @@ describe('Agent loop', () => { }); it('fails the turn after a filtered step completes', async () => { + await ctx.dispose(); + ctx = createTestAgent(appService(IFlagService, stubFlag(false))); ctx.mockNextProviderResponse({ parts: [{ type: 'text', text: 'blocked' }], finishReason: 'filtered', diff --git a/packages/agent-core-v2/test/agent/swarm/swarm.test.ts b/packages/agent-core-v2/test/agent/swarm/swarm.test.ts index 1363a112e8..736bed8935 100644 --- a/packages/agent-core-v2/test/agent/swarm/swarm.test.ts +++ b/packages/agent-core-v2/test/agent/swarm/swarm.test.ts @@ -38,6 +38,7 @@ import { type DomainEvent, IEventBus } from '#/app/event/eventBus'; import { EventBusService } from '#/app/event/eventBusService'; import { stubContextMemory } from '../contextMemory/stubs'; +import { stubFlag } from '../../app/flag/stubs'; import { executeTool } from '../../tools/fixtures/execute-tool'; import { registerTestAgentWire, restoreTestAgentWire, testWireScope } from '../../wire/stubs'; import { stubLoopWithHooks } from '../loop/stubs'; @@ -315,7 +316,7 @@ describe('AgentSwarmTool', () => { ]), }); const swarmMode = mockSwarmMode(); - const tool = new AgentSwarmTool(host.swarmService, makeAgentScopeContext({ agentId: host.callerAgentId, agentScope: '' }), swarmMode, stubConfig(), stubSwarmCatalog(), stubCallerProfile()); + const tool = new AgentSwarmTool(host.swarmService, makeAgentScopeContext({ agentId: host.callerAgentId, agentScope: '' }), swarmMode, stubConfig(), stubSwarmCatalog(), stubCallerProfile(), stubFlag()); const input = { description: 'Review files', prompt_template: 'Review {{item}}', @@ -412,7 +413,7 @@ describe('AgentSwarmTool', () => { it('does not expose permission rule argument matching', () => { const host = mockSwarmHost(); - const tool = new AgentSwarmTool(host.swarmService, makeAgentScopeContext({ agentId: host.callerAgentId, agentScope: '' }), mockSwarmMode(), stubConfig(), stubSwarmCatalog(), stubCallerProfile()); + const tool = new AgentSwarmTool(host.swarmService, makeAgentScopeContext({ agentId: host.callerAgentId, agentScope: '' }), mockSwarmMode(), stubConfig(), stubSwarmCatalog(), stubCallerProfile(), stubFlag()); const execution = tool.resolveExecution({ description: 'Review files', prompt_template: 'Review {{item}}', @@ -427,7 +428,7 @@ describe('AgentSwarmTool', () => { it('description states the enforced input requirements', () => { const host = mockSwarmHost(); - const tool = new AgentSwarmTool(host.swarmService, makeAgentScopeContext({ agentId: host.callerAgentId, agentScope: '' }), mockSwarmMode(), stubConfig(), stubSwarmCatalog(), stubCallerProfile()); + const tool = new AgentSwarmTool(host.swarmService, makeAgentScopeContext({ agentId: host.callerAgentId, agentScope: '' }), mockSwarmMode(), stubConfig(), stubSwarmCatalog(), stubCallerProfile(), stubFlag()); expect(tool.description).toContain('at least 2'); expect(tool.description).toContain('{{item}}'); expect(tool.description.toLowerCase()).toContain('distinct'); @@ -448,6 +449,7 @@ describe('AgentSwarmTool', () => { stubConfig(), stubSwarmCatalog(caller), stubCallerProfile({ profileName: 'deleted-profile', subagents: ['explore'] }), + stubFlag(), ); const result = await executeTool( @@ -511,7 +513,7 @@ describe('AgentSwarmTool', () => { for (const testCase of cases) { const host = mockSwarmHost(); - const tool = new AgentSwarmTool(host.swarmService, makeAgentScopeContext({ agentId: host.callerAgentId, agentScope: '' }), mockSwarmMode(), stubConfig(), stubSwarmCatalog(), stubCallerProfile()); + const tool = new AgentSwarmTool(host.swarmService, makeAgentScopeContext({ agentId: host.callerAgentId, agentScope: '' }), mockSwarmMode(), stubConfig(), stubSwarmCatalog(), stubCallerProfile(), stubFlag()); const result = await executeTool(tool, context(testCase.input)); @@ -544,7 +546,7 @@ describe('AgentSwarmTool', () => { async ({ agentId }: { readonly agentId: string }) => persistedItems[agentId], ); const host = mockSwarmHost({ run, getSwarmItem }); - const tool = new AgentSwarmTool(host.swarmService, makeAgentScopeContext({ agentId: host.callerAgentId, agentScope: '' }), mockSwarmMode(), stubConfig(), stubSwarmCatalog(), stubCallerProfile()); + const tool = new AgentSwarmTool(host.swarmService, makeAgentScopeContext({ agentId: host.callerAgentId, agentScope: '' }), mockSwarmMode(), stubConfig(), stubSwarmCatalog(), stubCallerProfile(), stubFlag()); const input = { description: 'Finish review', subagent_type: 'explore', @@ -664,7 +666,7 @@ describe('AgentSwarmTool', () => { ); const getSwarmItem = vi.fn(async () => 'src/old-a.ts'); const host = mockSwarmHost({ run, getSwarmItem }); - const tool = new AgentSwarmTool(host.swarmService, makeAgentScopeContext({ agentId: host.callerAgentId, agentScope: '' }), mockSwarmMode(), stubConfig(), stubSwarmCatalog(), stubCallerProfile()); + const tool = new AgentSwarmTool(host.swarmService, makeAgentScopeContext({ agentId: host.callerAgentId, agentScope: '' }), mockSwarmMode(), stubConfig(), stubSwarmCatalog(), stubCallerProfile(), stubFlag()); const input = { description: 'Resume review', resume_agent_ids: { @@ -727,7 +729,7 @@ describe('AgentSwarmTool', () => { }, ]), }); - const tool = new AgentSwarmTool(host.swarmService, makeAgentScopeContext({ agentId: host.callerAgentId, agentScope: '' }), mockSwarmMode(), stubConfig(), stubSwarmCatalog(), stubCallerProfile()); + const tool = new AgentSwarmTool(host.swarmService, makeAgentScopeContext({ agentId: host.callerAgentId, agentScope: '' }), mockSwarmMode(), stubConfig(), stubSwarmCatalog(), stubCallerProfile(), stubFlag()); const result = await executeTool( tool, @@ -753,7 +755,7 @@ describe('AgentSwarmTool', () => { it('passes the configured subagent timeout to swarm tasks', async () => { const host = mockSwarmHost(); - const tool = new AgentSwarmTool(host.swarmService, makeAgentScopeContext({ agentId: host.callerAgentId, agentScope: '' }), mockSwarmMode(), stubConfig(5_000), stubSwarmCatalog(), stubCallerProfile()); + const tool = new AgentSwarmTool(host.swarmService, makeAgentScopeContext({ agentId: host.callerAgentId, agentScope: '' }), mockSwarmMode(), stubConfig(5_000), stubSwarmCatalog(), stubCallerProfile(), stubFlag()); await executeTool( tool, @@ -789,7 +791,7 @@ describe('AgentSwarmTool', () => { }, ]), }); - const tool = new AgentSwarmTool(host.swarmService, makeAgentScopeContext({ agentId: host.callerAgentId, agentScope: '' }), mockSwarmMode(), stubConfig(), stubSwarmCatalog(), stubCallerProfile()); + const tool = new AgentSwarmTool(host.swarmService, makeAgentScopeContext({ agentId: host.callerAgentId, agentScope: '' }), mockSwarmMode(), stubConfig(), stubSwarmCatalog(), stubCallerProfile(), stubFlag()); const result = await executeTool( tool, @@ -836,7 +838,7 @@ describe('AgentSwarmTool', () => { }, ]), }); - const tool = new AgentSwarmTool(host.swarmService, makeAgentScopeContext({ agentId: host.callerAgentId, agentScope: '' }), mockSwarmMode(), stubConfig(), stubSwarmCatalog(), stubCallerProfile()); + const tool = new AgentSwarmTool(host.swarmService, makeAgentScopeContext({ agentId: host.callerAgentId, agentScope: '' }), mockSwarmMode(), stubConfig(), stubSwarmCatalog(), stubCallerProfile(), stubFlag()); const result = await executeTool( tool, @@ -860,4 +862,32 @@ describe('AgentSwarmTool', () => { ); expect(result.isError).toBeUndefined(); }); + + it('hides binding_slot from the advertised schema unless the binding flag is enabled', () => { + const host = mockSwarmHost(); + const scopeContext = makeAgentScopeContext({ agentId: host.callerAgentId, agentScope: '' }); + const off = new AgentSwarmTool( + host.swarmService, + scopeContext, + mockSwarmMode(), + stubConfig(), + stubSwarmCatalog(), + stubCallerProfile(), + stubFlag(false), + ); + const on = new AgentSwarmTool( + host.swarmService, + scopeContext, + mockSwarmMode(), + stubConfig(), + stubSwarmCatalog(), + stubCallerProfile(), + stubFlag(true), + ); + + const offKeys = Object.keys(off.parameters['properties'] as Record); + const onKeys = Object.keys(on.parameters['properties'] as Record); + expect(offKeys).not.toContain('binding_slot'); + expect(onKeys).toContain('binding_slot'); + }); }); diff --git a/packages/agent-core-v2/test/app/projectLocalConfig/stubs.ts b/packages/agent-core-v2/test/app/projectLocalConfig/stubs.ts new file mode 100644 index 0000000000..3014b5a9a8 --- /dev/null +++ b/packages/agent-core-v2/test/app/projectLocalConfig/stubs.ts @@ -0,0 +1,48 @@ +/** + * `projectLocalConfig` test stubs — in-memory `IProjectLocalConfigService`. + * + * Lives under `test/` (not `src/`). Import from a relative path. + */ + +import { + IProjectLocalConfigService, + type SubagentBinding, +} from '#/app/projectLocalConfig/projectLocalConfig'; + +export interface StubProjectLocalConfigOptions { + readonly bindings?: Readonly>; + readonly slotBindings?: Readonly>; +} + +export function stubProjectLocalConfig( + options: StubProjectLocalConfigOptions = {}, +): IProjectLocalConfigService { + const bindings = new Map(Object.entries(options.bindings ?? {})); + const slotBindings = new Map(Object.entries(options.slotBindings ?? {})); + return { + _serviceBrand: undefined, + readAdditionalDirs: (workDir: string) => + Promise.resolve({ + projectRoot: workDir, + configPath: `${workDir}/.kimi-code/local.toml`, + additionalDirs: [], + }), + resolveAdditionalDirs: (_baseDir: string, dirs: readonly string[]) => + Promise.resolve([...dirs]), + appendAdditionalDir: () => Promise.reject(new Error('not implemented')), + readSubagentBinding: (_workDir: string, agentType: string) => + Promise.resolve(bindings.get(agentType)), + writeSubagentBinding: (_workDir: string, agentType: string, binding) => { + if (binding === undefined) bindings.delete(agentType); + else bindings.set(agentType, binding); + return Promise.resolve({ configPath: '/stub/.kimi-code/local.toml' }); + }, + readSubagentSlotBinding: (_workDir: string, slot: string) => + Promise.resolve(slotBindings.get(slot)), + writeSubagentSlotBinding: (_workDir: string, slot: string, binding) => { + if (binding === undefined) slotBindings.delete(slot); + else slotBindings.set(slot, binding); + return Promise.resolve({ configPath: '/stub/.kimi-code/local.toml' }); + }, + }; +} diff --git a/packages/agent-core-v2/test/app/sessionLifecycle/sessionLifecycle.test.ts b/packages/agent-core-v2/test/app/sessionLifecycle/sessionLifecycle.test.ts index c5b769d679..fd7da2a45c 100644 --- a/packages/agent-core-v2/test/app/sessionLifecycle/sessionLifecycle.test.ts +++ b/packages/agent-core-v2/test/app/sessionLifecycle/sessionLifecycle.test.ts @@ -199,6 +199,10 @@ function projectLocalConfigStub( resolveAdditionalDirs: (baseDir: string, dirs: readonly string[]) => Promise.resolve(dirs.map((d) => (isAbsolute(d) ? resolve(d) : resolve(baseDir, d)))), appendAdditionalDir: () => Promise.reject(new Error('not implemented')), + readSubagentBinding: () => Promise.resolve(undefined), + writeSubagentBinding: () => Promise.reject(new Error('not implemented')), + readSubagentSlotBinding: () => Promise.resolve(undefined), + writeSubagentSlotBinding: () => Promise.reject(new Error('not implemented')), }; } diff --git a/packages/agent-core-v2/test/persistence/backends/node-fs/projectLocalConfig.test.ts b/packages/agent-core-v2/test/persistence/backends/node-fs/projectLocalConfig.test.ts new file mode 100644 index 0000000000..7753a124a0 --- /dev/null +++ b/packages/agent-core-v2/test/persistence/backends/node-fs/projectLocalConfig.test.ts @@ -0,0 +1,156 @@ +import { mkdir, mkdtemp, readFile, rm, writeFile } from 'node:fs/promises'; +import { tmpdir } from 'node:os'; + +import { join } from 'pathe'; +import { afterEach, beforeEach, describe, expect, it } from 'vitest'; + +import { DisposableStore } from '#/_base/di/lifecycle'; +import { createServices, type TestInstantiationService } from '#/_base/di/test'; +import { IBootstrapService } from '#/app/bootstrap/bootstrap'; +import { IProjectLocalConfigService } from '#/app/projectLocalConfig/projectLocalConfig'; +import { ErrorCodes } from '#/errors'; +import { HostFileSystem } from '#/os/backends/node-local/hostFsService'; +import { IHostFileSystem } from '#/os/interface/hostFileSystem'; +import { FileProjectLocalConfigService } from '#/persistence/backends/node-fs/projectLocalConfigService'; + +describe('FileProjectLocalConfigService — subagent bindings', () => { + let disposables: DisposableStore; + let ix: TestInstantiationService; + let root: string; + + beforeEach(async () => { + disposables = new DisposableStore(); + root = await mkdtemp(join(tmpdir(), 'ws-local-binding-')); + await mkdir(join(root, '.git'), { recursive: true }); + await mkdir(join(root, 'packages', 'app'), { recursive: true }); + ix = createServices(disposables, { + additionalServices: (reg) => { + reg.defineInstance(IBootstrapService, { + _serviceBrand: undefined, + homeDir: '/home/test', + osHomeDir: '/users/test', + } as IBootstrapService); + reg.defineInstance(IHostFileSystem, new HostFileSystem()); + reg.define(IProjectLocalConfigService, FileProjectLocalConfigService); + }, + }); + }); + + afterEach(async () => { + disposables.dispose(); + await rm(root, { recursive: true, force: true }); + }); + + function svc(): IProjectLocalConfigService { + return ix.get(IProjectLocalConfigService); + } + + function configPath(): string { + return join(root, '.kimi-code', 'local.toml'); + } + + it('returns undefined when no binding is configured', async () => { + await expect(svc().readSubagentBinding(join(root, 'packages', 'app'), 'coder')).resolves.toBeUndefined(); + await expect(svc().readSubagentSlotBinding(root, 'debater_a')).resolves.toBeUndefined(); + }); + + it('writes and reads back a model/effort binding through the project root', async () => { + const workDir = join(root, 'packages', 'app'); + + const written = await svc().writeSubagentBinding(workDir, 'coder', { + model: 'kimi-code/kimi-for-coding', + thinkingEffort: 'high', + }); + + expect(written.configPath).toBe(configPath()); + await expect(svc().readSubagentBinding(workDir, 'coder')).resolves.toEqual({ + model: 'kimi-code/kimi-for-coding', + thinkingEffort: 'high', + inherit: undefined, + }); + await expect(svc().readSubagentBinding(workDir, 'explore')).resolves.toBeUndefined(); + const text = await readFile(configPath(), 'utf-8'); + expect(text).toContain('[subagent.coder]'); + expect(text).toContain('model = "kimi-code/kimi-for-coding"'); + expect(text).toContain('thinking_effort = "high"'); + }); + + it('records an explicit inherit choice', async () => { + await svc().writeSubagentBinding(root, 'explore', { inherit: true }); + + await expect(svc().readSubagentBinding(root, 'explore')).resolves.toEqual({ + model: undefined, + thinkingEffort: undefined, + inherit: true, + }); + }); + + it('preserves unrelated local.toml content and other type bindings', async () => { + await mkdir(join(root, '.kimi-code'), { recursive: true }); + await writeFile( + configPath(), + '[workspace]\nadditional_dir = ["shared"]\n\n[custom]\nfoo = "bar"\n', + 'utf-8', + ); + + await svc().writeSubagentBinding(root, 'explore', { model: 'example/gamma-model' }); + await svc().writeSubagentBinding(root, 'coder', { model: 'example/alpha-model' }); + + const text = await readFile(configPath(), 'utf-8'); + expect(text).toContain('[workspace]'); + expect(text).toContain('"shared"'); + expect(text).toContain('[custom]'); + expect(text).toContain('foo = "bar"'); + expect(text).toContain('[subagent.explore]'); + expect(text).toContain('[subagent.coder]'); + await expect(svc().readSubagentBinding(root, 'explore')).resolves.toMatchObject({ + model: 'example/gamma-model', + }); + await expect(svc().readSubagentBinding(root, 'coder')).resolves.toMatchObject({ + model: 'example/alpha-model', + }); + }); + + it('clears a binding and drops the emptied section table', async () => { + await svc().writeSubagentBinding(root, 'coder', { model: 'example/alpha-model' }); + + await svc().writeSubagentBinding(root, 'coder', undefined); + + await expect(svc().readSubagentBinding(root, 'coder')).resolves.toBeUndefined(); + const text = await readFile(configPath(), 'utf-8'); + expect(text).not.toContain('subagent'); + }); + + it('writes and reads back a named slot binding independently of type bindings', async () => { + const written = await svc().writeSubagentSlotBinding(root, 'debater_a', { + model: 'example/beta-model', + thinkingEffort: 'high', + }); + + expect(written.configPath).toBe(configPath()); + await expect(svc().readSubagentSlotBinding(root, 'debater_a')).resolves.toEqual({ + model: 'example/beta-model', + thinkingEffort: 'high', + inherit: undefined, + }); + // Slot storage is independent from the type-binding table. + await expect(svc().readSubagentBinding(root, 'debater_a')).resolves.toBeUndefined(); + const text = await readFile(configPath(), 'utf-8'); + expect(text).toContain('[subagent-slot.debater_a]'); + expect(text).toContain('model = "example/beta-model"'); + + await svc().writeSubagentSlotBinding(root, 'debater_a', undefined); + + await expect(svc().readSubagentSlotBinding(root, 'debater_a')).resolves.toBeUndefined(); + expect(await readFile(configPath(), 'utf-8')).not.toContain('subagent-slot'); + }); + + it('rejects wrongly-typed binding entries with CONFIG_INVALID', async () => { + await mkdir(join(root, '.kimi-code'), { recursive: true }); + await writeFile(configPath(), '[subagent.coder]\nmodel = 5\n', 'utf-8'); + + await expect(svc().readSubagentBinding(root, 'coder')).rejects.toMatchObject({ + code: ErrorCodes.CONFIG_INVALID, + }); + }); +}); diff --git a/packages/agent-core-v2/test/session/question/stubs.ts b/packages/agent-core-v2/test/session/question/stubs.ts new file mode 100644 index 0000000000..196e22245f --- /dev/null +++ b/packages/agent-core-v2/test/session/question/stubs.ts @@ -0,0 +1,38 @@ +/** + * `question` test stubs — a scriptable `ISessionQuestionService`. + * + * Lives under `test/` (not `src/`). Import from a relative path. + */ + +import { vi } from 'vitest'; + +import { + type ISessionQuestionService, + type QuestionRequest, + type QuestionResult, +} from '#/session/question/question'; + +export interface StubQuestionServiceOptions { + /** Reject every request with this error (e.g. a NOT_IMPLEMENTED Error2). */ + readonly error?: unknown; + /** Per-request answer; defaults to a dismiss (null). */ + readonly respond?: (req: QuestionRequest) => QuestionResult | Promise; +} + +export interface StubQuestionService extends ISessionQuestionService { + readonly request: ReturnType>; +} + +export function stubQuestionService(options: StubQuestionServiceOptions = {}): StubQuestionService { + return { + _serviceBrand: undefined, + request: vi.fn(async (req) => { + if (options.error !== undefined) throw options.error; + return options.respond?.(req) ?? null; + }), + enqueue: (req) => ({ ...req, id: req.id ?? 'stub-question' }), + answer: () => {}, + dismiss: () => {}, + listPending: () => [], + }; +} diff --git a/packages/agent-core-v2/test/session/subagent/bindingAsk.test.ts b/packages/agent-core-v2/test/session/subagent/bindingAsk.test.ts new file mode 100644 index 0000000000..0b34cab6de --- /dev/null +++ b/packages/agent-core-v2/test/session/subagent/bindingAsk.test.ts @@ -0,0 +1,328 @@ +import { describe, expect, it, vi } from 'vitest'; + +import { abortError } from '#/_base/utils/abort'; +import { CoreErrors } from '#/_base/errors/codes'; +import { Error2 } from '#/_base/errors/errors'; +import { type IModelCatalog, type ModelCatalogItem } from '#/kosong/model/catalog'; +import { type QuestionRequest, type QuestionResult } from '#/session/question/question'; +import { createSubagentBindingAsker } from '#/session/subagent/bindingAsk'; + +import { stubProjectLocalConfig } from '../../app/projectLocalConfig/stubs'; +import { stubQuestionService } from '../question/stubs'; + +const WORK_DIR = '/repo/work'; +const INHERIT_LABEL = 'Keep inheriting from the main agent'; + +function modelItem(model: string, supportEfforts?: string[]): ModelCatalogItem { + return { + provider: 'mock', + model, + max_context_size: 8192, + support_efforts: supportEfforts, + }; +} + +function stubCatalog(items: readonly ModelCatalogItem[]): IModelCatalog { + return { + _serviceBrand: undefined, + listModels: vi.fn(async () => items), + } as unknown as IModelCatalog; +} + +function answer(req: QuestionRequest, label: string): QuestionResult { + return { answers: { [req.questions[0]?.question ?? '']: label } }; +} + +describe('createSubagentBindingAsker', () => { + it('offers inherit first, then every catalog model, under the Subagent header', async () => { + const question = stubQuestionService({ respond: (req) => answer(req, 'kimi-k2') }); + const config = stubProjectLocalConfig(); + const asker = createSubagentBindingAsker({ + question, + projectLocalConfig: config, + modelCatalog: stubCatalog([modelItem('kimi-k2'), modelItem('gpt-x')]), + workDir: WORK_DIR, + agentId: 'agent-caller', + }); + + await asker('coder'); + + expect(question.request).toHaveBeenCalledOnce(); + const item = question.request.mock.calls[0]?.[0].questions[0]; + expect(item?.header).toBe('Subagent'); + expect(item?.question).toBe( + 'Subagent type "coder" has no model binding in this workspace. Bind a model for it?', + ); + expect(item?.options).toEqual([ + { + label: INHERIT_LABEL, + description: 'Recorded as the choice for this workspace; you will not be asked again', + }, + { label: 'kimi-k2' }, + { label: 'gpt-x' }, + ]); + }); + + it('persists the chosen model under the type section and returns the binding', async () => { + const question = stubQuestionService({ respond: (req) => answer(req, 'kimi-k2') }); + const config = stubProjectLocalConfig(); + const writeType = vi.spyOn(config, 'writeSubagentBinding'); + const asker = createSubagentBindingAsker({ + question, + projectLocalConfig: config, + modelCatalog: stubCatalog([modelItem('kimi-k2')]), + workDir: WORK_DIR, + agentId: 'agent-caller', + }); + + await expect(asker('coder')).resolves.toEqual({ + model: 'kimi-k2', + thinkingEffort: undefined, + }); + expect(writeType).toHaveBeenCalledWith(WORK_DIR, 'coder', { + model: 'kimi-k2', + thinkingEffort: undefined, + }); + }); + + it('persists under the slot section and names the slot when the ask carries a slot context', async () => { + const question = stubQuestionService({ respond: (req) => answer(req, 'kimi-k2') }); + const config = stubProjectLocalConfig(); + const writeType = vi.spyOn(config, 'writeSubagentBinding'); + const writeSlot = vi.spyOn(config, 'writeSubagentSlotBinding'); + const asker = createSubagentBindingAsker({ + question, + projectLocalConfig: config, + modelCatalog: stubCatalog([modelItem('kimi-k2')]), + workDir: WORK_DIR, + agentId: 'agent-caller', + }); + + await asker('coder', { slot: 'fast' }); + + expect(question.request.mock.calls[0]?.[0].questions[0]?.question).toBe( + 'Binding slot "fast" has no model binding in this workspace. Bind a model for it?', + ); + expect(writeSlot).toHaveBeenCalledWith(WORK_DIR, 'fast', { + model: 'kimi-k2', + thinkingEffort: undefined, + }); + expect(writeType).not.toHaveBeenCalled(); + }); + + it('explains the missing model in the repair question', async () => { + const question = stubQuestionService({ respond: (req) => answer(req, INHERIT_LABEL) }); + const asker = createSubagentBindingAsker({ + question, + projectLocalConfig: stubProjectLocalConfig(), + modelCatalog: stubCatalog([modelItem('kimi-k2')]), + workDir: WORK_DIR, + agentId: 'agent-caller', + }); + + await asker('coder', { missingModel: 'gone/model' }); + + expect(question.request.mock.calls[0]?.[0].questions[0]?.question).toBe( + 'Subagent type "coder" is bound to model "gone/model", but that alias no longer exists in your models config or cannot be resolved. Bind a model for it?', + ); + }); + + it('persists inherit:true when the user keeps inheriting and skips the effort question', async () => { + const question = stubQuestionService({ respond: (req) => answer(req, INHERIT_LABEL) }); + const config = stubProjectLocalConfig(); + const writeType = vi.spyOn(config, 'writeSubagentBinding'); + const asker = createSubagentBindingAsker({ + question, + projectLocalConfig: config, + modelCatalog: stubCatalog([modelItem('kimi-k2', ['low', 'high'])]), + workDir: WORK_DIR, + agentId: 'agent-caller', + }); + + await expect(asker('coder')).resolves.toEqual({ inherit: true }); + expect(writeType).toHaveBeenCalledWith(WORK_DIR, 'coder', { inherit: true }); + expect(question.request).toHaveBeenCalledOnce(); + }); + + it('asks the thinking effort when the chosen model supports efforts', async () => { + const question = stubQuestionService({ + respond: (req) => + req.questions[0]?.question.startsWith('Thinking effort') === true + ? answer(req, 'high') + : answer(req, 'kimi-k2'), + }); + const config = stubProjectLocalConfig(); + const writeType = vi.spyOn(config, 'writeSubagentBinding'); + const asker = createSubagentBindingAsker({ + question, + projectLocalConfig: config, + modelCatalog: stubCatalog([modelItem('kimi-k2', ['low', 'high'])]), + workDir: WORK_DIR, + agentId: 'agent-caller', + }); + + await expect(asker('coder')).resolves.toEqual({ model: 'kimi-k2', thinkingEffort: 'high' }); + expect(question.request).toHaveBeenCalledTimes(2); + const effortItem = question.request.mock.calls[1]?.[0].questions[0]; + expect(effortItem?.question).toBe('Thinking effort for Subagent type "coder" on kimi-k2?'); + expect(effortItem?.options).toEqual([ + { label: INHERIT_LABEL, description: 'Inherit the main agent thinking effort' }, + { label: 'low' }, + { label: 'high' }, + ]); + expect(writeType).toHaveBeenCalledWith(WORK_DIR, 'coder', { + model: 'kimi-k2', + thinkingEffort: 'high', + }); + }); + + it('leaves the effort unset when the effort question keeps inheriting', async () => { + const question = stubQuestionService({ + respond: (req) => + req.questions[0]?.question.startsWith('Thinking effort') === true + ? answer(req, INHERIT_LABEL) + : answer(req, 'kimi-k2'), + }); + const config = stubProjectLocalConfig(); + const writeType = vi.spyOn(config, 'writeSubagentBinding'); + const asker = createSubagentBindingAsker({ + question, + projectLocalConfig: config, + modelCatalog: stubCatalog([modelItem('kimi-k2', ['low', 'high'])]), + workDir: WORK_DIR, + agentId: 'agent-caller', + }); + + await expect(asker('coder')).resolves.toEqual({ + model: 'kimi-k2', + thinkingEffort: undefined, + }); + expect(writeType).toHaveBeenCalledWith(WORK_DIR, 'coder', { + model: 'kimi-k2', + thinkingEffort: undefined, + }); + }); + + it('returns undefined without persisting when the client does not support questions', async () => { + const question = stubQuestionService({ + error: new Error2(CoreErrors.codes.NOT_IMPLEMENTED, 'questions are not supported'), + }); + const config = stubProjectLocalConfig(); + const writeType = vi.spyOn(config, 'writeSubagentBinding'); + const asker = createSubagentBindingAsker({ + question, + projectLocalConfig: config, + modelCatalog: stubCatalog([modelItem('kimi-k2')]), + workDir: WORK_DIR, + agentId: 'agent-caller', + }); + + await expect(asker('coder')).resolves.toBeUndefined(); + expect(writeType).not.toHaveBeenCalled(); + }); + + it('returns undefined without persisting when the user dismisses the question', async () => { + const question = stubQuestionService(); + const config = stubProjectLocalConfig(); + const writeType = vi.spyOn(config, 'writeSubagentBinding'); + const asker = createSubagentBindingAsker({ + question, + projectLocalConfig: config, + modelCatalog: stubCatalog([modelItem('kimi-k2')]), + workDir: WORK_DIR, + agentId: 'agent-caller', + }); + + await expect(asker('coder')).resolves.toBeUndefined(); + expect(question.request).toHaveBeenCalledOnce(); + expect(writeType).not.toHaveBeenCalled(); + }); + + it('returns undefined without asking when the catalog lists no models', async () => { + const question = stubQuestionService(); + const asker = createSubagentBindingAsker({ + question, + projectLocalConfig: stubProjectLocalConfig(), + modelCatalog: stubCatalog([]), + workDir: WORK_DIR, + agentId: 'agent-caller', + }); + + await expect(asker('coder')).resolves.toBeUndefined(); + expect(question.request).not.toHaveBeenCalled(); + }); + + it('routes the question to the asking agent under the spawn signal', async () => { + const signal = new AbortController().signal; + const question = stubQuestionService({ respond: (req) => answer(req, INHERIT_LABEL) }); + const asker = createSubagentBindingAsker({ + question, + projectLocalConfig: stubProjectLocalConfig(), + modelCatalog: stubCatalog([modelItem('kimi-k2')]), + workDir: WORK_DIR, + agentId: 'agent-caller', + signal, + }); + + await asker('coder'); + + expect(question.request).toHaveBeenCalledWith(expect.anything(), { + agentId: 'agent-caller', + signal, + }); + }); + + it('propagates abort errors without persisting', async () => { + const question = stubQuestionService({ error: abortError() }); + const config = stubProjectLocalConfig(); + const writeType = vi.spyOn(config, 'writeSubagentBinding'); + const asker = createSubagentBindingAsker({ + question, + projectLocalConfig: config, + modelCatalog: stubCatalog([modelItem('kimi-k2')]), + workDir: WORK_DIR, + agentId: 'agent-caller', + }); + + await expect(asker('coder')).rejects.toThrow('Aborted'); + expect(writeType).not.toHaveBeenCalled(); + }); + + it('throws when the spawn signal is already aborted instead of inheriting', async () => { + const controller = new AbortController(); + controller.abort(abortError()); + const config = stubProjectLocalConfig(); + const writeType = vi.spyOn(config, 'writeSubagentBinding'); + const asker = createSubagentBindingAsker({ + question: stubQuestionService(), + projectLocalConfig: config, + modelCatalog: stubCatalog([modelItem('kimi-k2')]), + workDir: WORK_DIR, + agentId: 'agent-caller', + signal: controller.signal, + }); + + await expect(asker('coder')).rejects.toThrow('Aborted'); + expect(writeType).not.toHaveBeenCalled(); + }); + + it('assigns distinct request ids to consecutive asks', async () => { + const question = stubQuestionService({ respond: (req) => answer(req, 'kimi-k2') }); + const asker = createSubagentBindingAsker({ + question, + projectLocalConfig: stubProjectLocalConfig(), + modelCatalog: stubCatalog([modelItem('kimi-k2')]), + workDir: WORK_DIR, + agentId: 'agent-caller', + }); + + await asker('coder'); + await asker('explorer'); + + const firstId = question.request.mock.calls[0]?.[0].id; + const secondId = question.request.mock.calls[1]?.[0].id; + expect(firstId).toMatch(/^subagent-binding:agent-caller:\d+$/); + expect(secondId).toMatch(/^subagent-binding:agent-caller:\d+$/); + expect(firstId).not.toBe(secondId); + }); +}); diff --git a/packages/agent-core-v2/test/session/subagent/bindingResolution.test.ts b/packages/agent-core-v2/test/session/subagent/bindingResolution.test.ts new file mode 100644 index 0000000000..f318e10beb --- /dev/null +++ b/packages/agent-core-v2/test/session/subagent/bindingResolution.test.ts @@ -0,0 +1,469 @@ +import { describe, expect, it, vi } from 'vitest'; + +import { IProjectLocalConfigService, type SubagentBinding } from '#/app/projectLocalConfig/projectLocalConfig'; +import { type IModelCatalog, type Model } from '#/kosong/model/catalog'; +import { + type AskSubagentSpawnBindingCallback, + resolveSubagentSpawnBinding, +} from '#/session/subagent/bindingResolution'; + +import { stubFlag } from '../../app/flag/stubs'; + +const WORK_DIR = '/repo/work'; + +interface BindingTables { + readonly bindings?: Readonly>; + readonly slotBindings?: Readonly>; +} + +function makeDeps(options: { + readonly flagEnabled?: boolean; + readonly tables?: BindingTables; + readonly validAliases?: readonly string[]; + readonly ask?: AskSubagentSpawnBindingCallback; +} = {}) { + const bindings = new Map(Object.entries(options.tables?.bindings ?? {})); + const slotBindings = new Map(Object.entries(options.tables?.slotBindings ?? {})); + const projectLocalConfig = { + _serviceBrand: undefined, + readSubagentBinding: vi.fn(async (_workDir: string, agentType: string) => + bindings.get(agentType), + ), + readSubagentSlotBinding: vi.fn(async (_workDir: string, slot: string) => + slotBindings.get(slot), + ), + }; + const validAliases = new Set(options.validAliases ?? []); + const modelCatalog = { + _serviceBrand: undefined, + get: vi.fn((alias: string): Model => { + if (!validAliases.has(alias)) throw new Error(`model.not_configured: ${alias}`); + return {} as Model; + }), + }; + return { + deps: { + flags: stubFlag(options.flagEnabled ?? true), + projectLocalConfig: projectLocalConfig as unknown as IProjectLocalConfigService, + modelCatalog: modelCatalog as unknown as IModelCatalog, + ask: options.ask, + }, + projectLocalConfig, + modelCatalog, + }; +} + +describe('resolveSubagentSpawnBinding', () => { + it('returns an empty resolution when the experimental flag is disabled', async () => { + const { deps, projectLocalConfig } = makeDeps({ + flagEnabled: false, + tables: { bindings: { coder: { model: 'sub/model' } } }, + validAliases: ['sub/model'], + }); + + await expect( + resolveSubagentSpawnBinding(deps, { + workDir: WORK_DIR, + profileName: 'coder', + bindingSlot: 'fast', + }), + ).resolves.toEqual({}); + expect(projectLocalConfig.readSubagentBinding).not.toHaveBeenCalled(); + expect(projectLocalConfig.readSubagentSlotBinding).not.toHaveBeenCalled(); + }); + + it('prefers the slot binding over the type binding', async () => { + const { deps } = makeDeps({ + tables: { + bindings: { coder: { model: 'type/model', thinkingEffort: 'low' } }, + slotBindings: { fast: { model: 'slot/model', thinkingEffort: 'high' } }, + }, + validAliases: ['type/model', 'slot/model'], + }); + + await expect( + resolveSubagentSpawnBinding(deps, { + workDir: WORK_DIR, + profileName: 'coder', + bindingSlot: 'fast', + }), + ).resolves.toEqual({ model: 'slot/model', thinking: 'high' }); + }); + + it('uses the type binding when no slot is requested', async () => { + const { deps } = makeDeps({ + tables: { bindings: { coder: { model: 'type/model', thinkingEffort: 'high' } } }, + validAliases: ['type/model'], + }); + + await expect( + resolveSubagentSpawnBinding(deps, { workDir: WORK_DIR, profileName: 'coder' }), + ).resolves.toEqual({ model: 'type/model', thinking: 'high' }); + }); + + it('falls back silently to the type binding when the slot entry is missing', async () => { + const { deps } = makeDeps({ + tables: { bindings: { coder: { model: 'type/model' } } }, + validAliases: ['type/model'], + }); + + await expect( + resolveSubagentSpawnBinding(deps, { + workDir: WORK_DIR, + profileName: 'coder', + bindingSlot: 'never-configured', + }), + ).resolves.toEqual({ model: 'type/model' }); + }); + + it('treats inherit as an explicit choice and never falls back', async () => { + const { deps, projectLocalConfig } = makeDeps({ + tables: { + bindings: { coder: { model: 'type/model' } }, + slotBindings: { fast: { inherit: true } }, + }, + validAliases: ['type/model'], + }); + + const resolution = await resolveSubagentSpawnBinding(deps, { + workDir: WORK_DIR, + profileName: 'coder', + bindingSlot: 'fast', + }); + + expect(resolution).toEqual({}); + expect(resolution.warning).toBeUndefined(); + expect(projectLocalConfig.readSubagentBinding).not.toHaveBeenCalled(); + }); + + it('warns and keeps inheriting when an inherit slot entry also sets model or thinking_effort', async () => { + const { deps, projectLocalConfig, modelCatalog } = makeDeps({ + tables: { + bindings: { coder: { model: 'type/model' } }, + slotBindings: { fast: { inherit: true, model: 'slot/model', thinkingEffort: 'high' } }, + }, + validAliases: ['type/model', 'slot/model'], + }); + + const resolution = await resolveSubagentSpawnBinding(deps, { + workDir: WORK_DIR, + profileName: 'coder', + bindingSlot: 'fast', + }); + + expect(resolution).toEqual({ + warning: expect.stringContaining('inherit=true'), + }); + expect(resolution.warning).toContain('subagent-slot.fast'); + expect(resolution.warning).toContain('model and thinking_effort'); + expect(modelCatalog.get).not.toHaveBeenCalled(); + expect(projectLocalConfig.readSubagentBinding).not.toHaveBeenCalled(); + }); + + it('warns about the ignored model when an inherit type entry also sets model', async () => { + const { deps, modelCatalog } = makeDeps({ + tables: { bindings: { coder: { inherit: true, model: 'gone/model' } } }, + validAliases: [], + }); + + const resolution = await resolveSubagentSpawnBinding(deps, { + workDir: WORK_DIR, + profileName: 'coder', + }); + + expect(resolution.warning).toContain('subagent.coder'); + expect(resolution.warning).toContain('inherit=true'); + expect(resolution.warning).toContain('model'); + expect(resolution.warning).not.toContain('thinking_effort'); + expect(modelCatalog.get).not.toHaveBeenCalled(); + }); + + it('warns and falls back to the type binding when the slot alias is stale', async () => { + const { deps } = makeDeps({ + tables: { + bindings: { coder: { model: 'type/model' } }, + slotBindings: { fast: { model: 'gone/model' } }, + }, + validAliases: ['type/model'], + }); + + const resolution = await resolveSubagentSpawnBinding(deps, { + workDir: WORK_DIR, + profileName: 'coder', + bindingSlot: 'fast', + }); + + expect(resolution.model).toBe('type/model'); + expect(resolution.warning).toContain('subagent-slot.fast'); + expect(resolution.warning).toContain('gone/model'); + expect(resolution.warning).toContain('not configured or cannot be resolved'); + }); + + it('warns and inherits the caller model when the type alias is stale', async () => { + const { deps } = makeDeps({ + tables: { bindings: { coder: { model: 'gone/model' } } }, + validAliases: [], + }); + + const resolution = await resolveSubagentSpawnBinding(deps, { + workDir: WORK_DIR, + profileName: 'coder', + }); + + expect(resolution.model).toBeUndefined(); + expect(resolution.warning).toContain('subagent.coder'); + expect(resolution.warning).toContain('gone/model'); + }); + + it('passes a thinking-only entry through without consulting the catalog', async () => { + const { deps, modelCatalog } = makeDeps({ + tables: { bindings: { coder: { thinkingEffort: 'high' } } }, + validAliases: [], + }); + + await expect( + resolveSubagentSpawnBinding(deps, { workDir: WORK_DIR, profileName: 'coder' }), + ).resolves.toEqual({ thinking: 'high' }); + expect(modelCatalog.get).not.toHaveBeenCalled(); + }); + + it('keeps the slot warning when the fallback chain ends in inherit', async () => { + const { deps } = makeDeps({ + tables: { slotBindings: { fast: { model: 'gone/model' } } }, + validAliases: [], + }); + + const resolution = await resolveSubagentSpawnBinding(deps, { + workDir: WORK_DIR, + profileName: 'coder', + bindingSlot: 'fast', + }); + + expect(resolution.model).toBeUndefined(); + expect(resolution.warning).toContain('subagent-slot.fast'); + }); + + it('never asks and inherits when nothing is bound and no ask callback is supplied', async () => { + const { deps, projectLocalConfig } = makeDeps({ validAliases: [] }); + + await expect( + resolveSubagentSpawnBinding(deps, { + workDir: WORK_DIR, + profileName: 'coder', + bindingSlot: 'fast', + }), + ).resolves.toEqual({}); + expect(projectLocalConfig.readSubagentSlotBinding).toHaveBeenCalledOnce(); + expect(projectLocalConfig.readSubagentBinding).toHaveBeenCalledOnce(); + }); + + describe('interactive ask-once', () => { + it('asks once for a missing type binding and adopts the answer without re-validation', async () => { + const ask = vi.fn(async () => ({ + model: 'asked/model', + thinkingEffort: 'high', + })); + const { deps, modelCatalog } = makeDeps({ ask, validAliases: [] }); + + await expect( + resolveSubagentSpawnBinding(deps, { workDir: WORK_DIR, profileName: 'coder' }), + ).resolves.toEqual({ model: 'asked/model', thinking: 'high' }); + expect(ask).toHaveBeenCalledWith('coder', undefined); + expect(modelCatalog.get).not.toHaveBeenCalled(); + }); + + it('adopts an inherit answer for a slot as terminal without reading the type binding', async () => { + const ask = vi.fn(async () => ({ inherit: true })); + const { deps, projectLocalConfig } = makeDeps({ + ask, + tables: { bindings: { coder: { model: 'type/model' } } }, + validAliases: ['type/model'], + }); + + await expect( + resolveSubagentSpawnBinding(deps, { + workDir: WORK_DIR, + profileName: 'coder', + bindingSlot: 'fast', + }), + ).resolves.toEqual({}); + expect(ask).toHaveBeenCalledWith('coder', { slot: 'fast' }); + expect(projectLocalConfig.readSubagentBinding).not.toHaveBeenCalled(); + }); + + it('asks for a missing slot with the slot context and adopts the answer', async () => { + const ask = vi.fn(async () => ({ model: 'slot/model' })); + const { deps } = makeDeps({ ask, validAliases: [] }); + + await expect( + resolveSubagentSpawnBinding(deps, { + workDir: WORK_DIR, + profileName: 'coder', + bindingSlot: 'fast', + }), + ).resolves.toEqual({ model: 'slot/model' }); + expect(ask).toHaveBeenCalledWith('coder', { slot: 'fast' }); + }); + + it('falls back to the type binding when the slot ask is dismissed', async () => { + const ask = vi.fn(async () => undefined); + const { deps } = makeDeps({ + ask, + tables: { bindings: { coder: { model: 'type/model' } } }, + validAliases: ['type/model'], + }); + + await expect( + resolveSubagentSpawnBinding(deps, { + workDir: WORK_DIR, + profileName: 'coder', + bindingSlot: 'fast', + }), + ).resolves.toEqual({ model: 'type/model' }); + expect(ask).toHaveBeenCalledOnce(); + expect(ask).toHaveBeenCalledWith('coder', { slot: 'fast' }); + }); + + it('never asks twice in one spawn: a dismissed slot ask suppresses the type ask', async () => { + const ask = vi.fn(async () => undefined); + const { deps } = makeDeps({ ask, validAliases: [] }); + + const resolution = await resolveSubagentSpawnBinding(deps, { + workDir: WORK_DIR, + profileName: 'coder', + bindingSlot: 'fast', + }); + + expect(resolution).toEqual({}); + expect(ask).toHaveBeenCalledOnce(); + expect(ask).toHaveBeenCalledWith('coder', { slot: 'fast' }); + }); + + it('keeps the stale-slot warning and skips the type ask after a dismissed repair ask', async () => { + const ask = vi.fn(async () => undefined); + const { deps } = makeDeps({ + ask, + tables: { + bindings: { coder: { model: 'gone/type' } }, + slotBindings: { fast: { model: 'gone/slot' } }, + }, + validAliases: [], + }); + + const resolution = await resolveSubagentSpawnBinding(deps, { + workDir: WORK_DIR, + profileName: 'coder', + bindingSlot: 'fast', + }); + + expect(resolution.model).toBeUndefined(); + expect(resolution.warning).toContain('subagent-slot.fast'); + expect(resolution.warning).toContain('gone/slot'); + expect(resolution.warning).toContain('subagent.coder'); + expect(resolution.warning).toContain('gone/type'); + expect(ask).toHaveBeenCalledOnce(); + expect(ask).toHaveBeenCalledWith('coder', { slot: 'fast', missingModel: 'gone/slot' }); + }); + + it('inherits silently when the type ask is dismissed', async () => { + const ask = vi.fn(async () => undefined); + const { deps } = makeDeps({ ask, validAliases: [] }); + + const resolution = await resolveSubagentSpawnBinding(deps, { + workDir: WORK_DIR, + profileName: 'coder', + }); + + expect(resolution).toEqual({}); + expect(ask).toHaveBeenCalledWith('coder', undefined); + }); + + it('asks to repair a stale type binding with the missing model context', async () => { + const ask = vi.fn(async () => ({ model: 'fixed/model' })); + const { deps } = makeDeps({ + ask, + tables: { bindings: { coder: { model: 'gone/model' } } }, + validAliases: [], + }); + + const resolution = await resolveSubagentSpawnBinding(deps, { + workDir: WORK_DIR, + profileName: 'coder', + }); + + expect(resolution).toEqual({ model: 'fixed/model' }); + expect(ask).toHaveBeenCalledWith('coder', { missingModel: 'gone/model' }); + }); + + it('keeps the warning when the stale type repair is dismissed', async () => { + const ask = vi.fn(async () => undefined); + const { deps } = makeDeps({ + ask, + tables: { bindings: { coder: { model: 'gone/model' } } }, + validAliases: [], + }); + + const resolution = await resolveSubagentSpawnBinding(deps, { + workDir: WORK_DIR, + profileName: 'coder', + }); + + expect(resolution.model).toBeUndefined(); + expect(resolution.warning).toContain('subagent.coder'); + expect(resolution.warning).toContain('gone/model'); + }); + + it('asks to repair a stale slot binding with slot and missing model context', async () => { + const ask = vi.fn(async () => ({ model: 'fixed/model' })); + const { deps } = makeDeps({ + ask, + tables: { + bindings: { coder: { model: 'type/model' } }, + slotBindings: { fast: { model: 'gone/model' } }, + }, + validAliases: ['type/model'], + }); + + const resolution = await resolveSubagentSpawnBinding(deps, { + workDir: WORK_DIR, + profileName: 'coder', + bindingSlot: 'fast', + }); + + expect(resolution).toEqual({ model: 'fixed/model' }); + expect(ask).toHaveBeenCalledWith('coder', { slot: 'fast', missingModel: 'gone/model' }); + }); + + it('falls back to the type binding when the stale slot repair is dismissed', async () => { + const ask = vi.fn(async () => undefined); + const { deps } = makeDeps({ + ask, + tables: { + bindings: { coder: { model: 'type/model' } }, + slotBindings: { fast: { model: 'gone/model' } }, + }, + validAliases: ['type/model'], + }); + + const resolution = await resolveSubagentSpawnBinding(deps, { + workDir: WORK_DIR, + profileName: 'coder', + bindingSlot: 'fast', + }); + + expect(resolution.model).toBe('type/model'); + expect(resolution.warning).toContain('subagent-slot.fast'); + expect(resolution.warning).toContain('gone/model'); + }); + + it('does not ask when the flag is disabled even with an ask callback supplied', async () => { + const ask = vi.fn(async () => ({ model: 'asked/model' })); + const { deps } = makeDeps({ flagEnabled: false, ask, validAliases: [] }); + + await expect( + resolveSubagentSpawnBinding(deps, { workDir: WORK_DIR, profileName: 'coder' }), + ).resolves.toEqual({}); + expect(ask).not.toHaveBeenCalled(); + }); + }); +}); diff --git a/packages/agent-core-v2/test/session/swarm/sessionSwarm.test.ts b/packages/agent-core-v2/test/session/swarm/sessionSwarm.test.ts index 6114257988..2801fb6fbd 100644 --- a/packages/agent-core-v2/test/session/swarm/sessionSwarm.test.ts +++ b/packages/agent-core-v2/test/session/swarm/sessionSwarm.test.ts @@ -13,6 +13,10 @@ import { IAgentProfileService, type ProfileData } from '#/agent/profile/profile' import { IAgentLoopService } from '#/agent/loop/loop'; import { IAgentUserToolService } from '#/agent/userTool/userTool'; import { IEventBus, type DomainEvent } from '#/app/event/eventBus'; +import { IFlagService } from '#/app/flag/flag'; +import { IProjectLocalConfigService } from '#/app/projectLocalConfig/projectLocalConfig'; +import { IModelCatalog, type Model } from '#/kosong/model/catalog'; +import { type SubagentSpawnedEvent } from '#/session/subagent/mirrorAgentRun'; import { ISessionAgentProfileCatalog } from '#/session/sessionAgentProfileCatalog/sessionAgentProfileCatalog'; import { APIProviderRateLimitError } from '#/kosong/contract/errors'; import { ITelemetryService, noopTelemetryService } from '#/app/telemetry/telemetry'; @@ -49,6 +53,8 @@ import { ISessionSwarmService, type SessionSwarmTask } from '#/session/swarm/ses import { SessionSwarmService } from '#/session/swarm/sessionSwarmService'; import { stubLog } from '../../_base/log/stubs'; +import { stubFlag } from '../../app/flag/stubs'; +import { stubProjectLocalConfig } from '../../app/projectLocalConfig/stubs'; describe('resolveSwarmMaxConcurrency', () => { it('returns undefined when the variable is unset', () => { @@ -903,6 +909,9 @@ describe('SessionSwarmService metadata compatibility', () => { }, }); ix.stub(ILogService, stubLog()); + ix.stub(IFlagService, stubFlag(false)); + ix.stub(IProjectLocalConfigService, stubProjectLocalConfig()); + ix.stub(IModelCatalog, modelCatalogStub(['kimi-test'])); ix.set(ISessionSwarmService, new SyncDescriptor(SessionSwarmService)); }); @@ -1004,6 +1013,171 @@ describe('SessionSwarmService metadata compatibility', () => { ); }); + it('keeps inheriting the caller model when the binding flag is disabled', async () => { + ix.stub( + IProjectLocalConfigService, + stubProjectLocalConfig({ + bindings: { coder: { model: 'sub/model', thinkingEffort: 'high' } }, + }), + ); + const published: DomainEvent[] = []; + (eventBus.publish as ReturnType).mockImplementation((event: DomainEvent) => { + published.push(event); + }); + const service = ix.get(ISessionSwarmService); + + await service.run({ callerAgentId: 'main', tasks: [spawnSessionTask('src/a.ts')] }); + + expect(createAgent).toHaveBeenCalledWith( + expect.objectContaining({ + binding: { + profile: 'coder', + model: 'kimi-test', + thinking: 'medium', + cwd: '/repo', + }, + }), + ); + const spawned = published.find( + (event): event is SubagentSpawnedEvent => event.type === 'subagent.spawned', + ); + expect(spawned).toBeDefined(); + expect(spawned?.modelAlias).toBeUndefined(); + expect(spawned?.thinkingEffort).toBeUndefined(); + }); + + it('applies the workspace type binding to swarm spawns when the flag is enabled', async () => { + ix.stub(IFlagService, stubFlag(true)); + ix.stub( + IProjectLocalConfigService, + stubProjectLocalConfig({ + bindings: { coder: { model: 'sub/model', thinkingEffort: 'high' } }, + }), + ); + ix.stub(IModelCatalog, modelCatalogStub(['kimi-test', 'sub/model'])); + const published: DomainEvent[] = []; + (eventBus.publish as ReturnType).mockImplementation((event: DomainEvent) => { + published.push(event); + }); + const service = ix.get(ISessionSwarmService); + + await service.run({ callerAgentId: 'main', tasks: [spawnSessionTask('src/a.ts')] }); + + expect(createAgent).toHaveBeenCalledWith( + expect.objectContaining({ + binding: { + profile: 'coder', + model: 'sub/model', + thinking: 'high', + cwd: '/repo', + }, + }), + ); + const spawned = published.find( + (event): event is SubagentSpawnedEvent => event.type === 'subagent.spawned', + ); + expect(spawned).toMatchObject({ + modelAlias: 'sub/model', + thinkingEffort: 'high', + }); + }); + + it('falls back to the caller model when the bound alias is not configured', async () => { + ix.stub(IFlagService, stubFlag(true)); + ix.stub( + IProjectLocalConfigService, + stubProjectLocalConfig({ + bindings: { coder: { model: 'gone/model', thinkingEffort: 'high' } }, + }), + ); + const service = ix.get(ISessionSwarmService); + + await service.run({ callerAgentId: 'main', tasks: [spawnSessionTask('src/a.ts')] }); + + expect(createAgent).toHaveBeenCalledWith( + expect.objectContaining({ + binding: { + profile: 'coder', + model: 'kimi-test', + thinking: 'medium', + cwd: '/repo', + }, + }), + ); + }); + + it('applies the named binding slot to every item spawn when the flag is enabled', async () => { + ix.stub(IFlagService, stubFlag(true)); + ix.stub( + IProjectLocalConfigService, + stubProjectLocalConfig({ + bindings: { coder: { model: 'sub/model', thinkingEffort: 'high' } }, + slotBindings: { fast: { model: 'slot/model', thinkingEffort: 'low' } }, + }), + ); + ix.stub(IModelCatalog, modelCatalogStub(['kimi-test', 'sub/model', 'slot/model'])); + const published: DomainEvent[] = []; + (eventBus.publish as ReturnType).mockImplementation((event: DomainEvent) => { + published.push(event); + }); + const service = ix.get(ISessionSwarmService); + + await service.run({ + callerAgentId: 'main', + tasks: [spawnSessionTask('src/a.ts', 'fast'), spawnSessionTask('src/b.ts', 'fast')], + }); + + expect(createAgent).toHaveBeenCalledTimes(2); + createAgent.mock.calls.forEach((call) => { + expect(call[0]).toEqual( + expect.objectContaining({ + binding: { + profile: 'coder', + model: 'slot/model', + thinking: 'low', + cwd: '/repo', + }, + }), + ); + }); + const spawnedEvents = published.filter( + (event): event is SubagentSpawnedEvent => event.type === 'subagent.spawned', + ); + expect(spawnedEvents).toHaveLength(2); + spawnedEvents.forEach((spawned) => { + expect(spawned).toMatchObject({ + modelAlias: 'slot/model', + thinkingEffort: 'low', + }); + }); + }); + + it('ignores the binding slot when the binding flag is disabled', async () => { + ix.stub( + IProjectLocalConfigService, + stubProjectLocalConfig({ + slotBindings: { fast: { model: 'slot/model', thinkingEffort: 'low' } }, + }), + ); + const service = ix.get(ISessionSwarmService); + + await service.run({ + callerAgentId: 'main', + tasks: [spawnSessionTask('src/a.ts', 'fast')], + }); + + expect(createAgent).toHaveBeenCalledWith( + expect.objectContaining({ + binding: { + profile: 'coder', + model: 'kimi-test', + thinking: 'medium', + cwd: '/repo', + }, + }), + ); + }); + it('inherits parent user tools on spawned children', async () => { const parentUserTools = userToolServiceStub(); const childUserTools = userToolServiceStub(); @@ -1088,6 +1262,121 @@ describe('SessionSwarmService metadata compatibility', () => { ); }); + it('keeps the bound model on resumed children and reports it on the spawned event when the flag is enabled', async () => { + ix.stub(IFlagService, stubFlag(true)); + ix.stub(IModelCatalog, modelCatalogStub(['kimi-test', 'sub/model'])); + agents['agent-existing'] = { + labels: { parentAgentId: 'main' }, + }; + const child = agentHandle('agent-existing', lifecycle, eventBus, { + profileName: 'explore', + modelAlias: 'sub/model', + thinkingLevel: 'high', + }); + handles.set('agent-existing', child); + const published: DomainEvent[] = []; + (eventBus.publish as ReturnType).mockImplementation((event: DomainEvent) => { + published.push(event); + }); + const service = ix.get(ISessionSwarmService); + + await expect( + service.run({ + callerAgentId: 'main', + tasks: [resumeSessionTask('agent-existing')], + }), + ).resolves.toMatchObject([{ status: 'completed', agentId: 'agent-existing' }]); + + expect(child.accessor.get(IAgentProfileService).data().modelAlias).toBe('sub/model'); + const spawned = published.find( + (event): event is SubagentSpawnedEvent => event.type === 'subagent.spawned', + ); + expect(spawned).toMatchObject({ + modelAlias: 'sub/model', + thinkingEffort: 'high', + }); + }); + + it('fails a sticky resume when the resumed child model alias no longer resolves', async () => { + ix.stub(IFlagService, stubFlag(true)); + agents['agent-existing'] = { + labels: { parentAgentId: 'main' }, + }; + const child = agentHandle('agent-existing', lifecycle, eventBus, { + profileName: 'explore', + modelAlias: 'gone/model', + thinkingLevel: 'high', + }); + handles.set('agent-existing', child); + const service = ix.get(ISessionSwarmService); + + await expect( + service.run({ + callerAgentId: 'main', + tasks: [resumeSessionTask('agent-existing')], + }), + ).resolves.toMatchObject([ + { + status: 'failed', + state: 'not_started', + error: + 'The configured subagent model alias is not resolvable. Check the bindings in .kimi-code/local.toml and your models config.', + }, + ]); + expect(runAgent).not.toHaveBeenCalled(); + expect(child.accessor.get(IAgentProfileService).data().modelAlias).toBe('gone/model'); + }); + + it('keeps the bound model on resumed children when the alias still resolves and the flag is enabled', async () => { + ix.stub(IFlagService, stubFlag(true)); + ix.stub(IModelCatalog, modelCatalogStub(['kimi-test', 'sub/model'])); + agents['agent-existing'] = { + labels: { parentAgentId: 'main' }, + }; + const child = agentHandle('agent-existing', lifecycle, eventBus, { + profileName: 'explore', + modelAlias: 'sub/model', + thinkingLevel: 'high', + }); + handles.set('agent-existing', child); + const service = ix.get(ISessionSwarmService); + + await expect( + service.run({ + callerAgentId: 'main', + tasks: [resumeSessionTask('agent-existing')], + }), + ).resolves.toMatchObject([{ status: 'completed', agentId: 'agent-existing' }]); + + expect(child.accessor.get(IAgentProfileService).data().modelAlias).toBe('sub/model'); + expect(runAgent).toHaveBeenCalledWith( + 'agent-existing', + { kind: 'prompt', prompt: 'Continue' }, + expect.objectContaining({ signal: expect.any(AbortSignal) }), + ); + }); + + it('does not validate the resumed child model alias when the flag is disabled', async () => { + agents['agent-existing'] = { + labels: { parentAgentId: 'main' }, + }; + const child = agentHandle('agent-existing', lifecycle, eventBus, { + profileName: 'explore', + modelAlias: 'gone/model', + }); + handles.set('agent-existing', child); + const service = ix.get(ISessionSwarmService); + + await expect( + service.run({ + callerAgentId: 'main', + tasks: [resumeSessionTask('agent-existing')], + }), + ).resolves.toMatchObject([{ status: 'completed', agentId: 'agent-existing' }]); + + expect(child.accessor.get(IAgentProfileService).data().modelAlias).toBe('kimi-test'); + }); + it('does not emit spawned again when a rate-limited child retries', async () => { vi.useFakeTimers(); try { @@ -1187,7 +1476,7 @@ describe('SessionSwarmService metadata compatibility', () => { }); }); -function spawnSessionTask(swarmItem?: string): SessionSwarmTask { +function spawnSessionTask(swarmItem?: string, bindingSlot?: string): SessionSwarmTask { return { kind: 'spawn', data: {}, @@ -1198,6 +1487,7 @@ function spawnSessionTask(swarmItem?: string): SessionSwarmTask { swarmIndex: 1, swarmItem, runInBackground: false, + bindingSlot, }; } @@ -1340,6 +1630,17 @@ function eventBusStub(): IEventBus { }; } +function modelCatalogStub(validAliases: readonly string[]): IModelCatalog { + const valid = new Set(validAliases); + return { + _serviceBrand: undefined, + get: (alias: string): Model => { + if (!valid.has(alias)) throw new Error(`model.not_configured: ${alias}`); + return {} as Model; + }, + } as unknown as IModelCatalog; +} + type MockAgentRunAttemptOutcome = | AgentRunResult | { diff --git a/packages/agent-core-v2/test/tool/tool.test.ts b/packages/agent-core-v2/test/tool/tool.test.ts index f12bec39d3..9cc5ec387a 100644 --- a/packages/agent-core-v2/test/tool/tool.test.ts +++ b/packages/agent-core-v2/test/tool/tool.test.ts @@ -34,7 +34,14 @@ import { } from '#/session/subagent/tools/agent'; import { DEFAULT_SUBAGENT_TIMEOUT_MS } from '#/session/subagent/configSection'; import { runAgentTurn } from '#/session/subagent/runAgentTurn'; -import { emitAgentRunSpawned, mirrorAgentRun } from '#/session/subagent/mirrorAgentRun'; +import { + emitAgentRunSpawned, + mirrorAgentRun, + type SubagentSpawnedEvent, +} from '#/session/subagent/mirrorAgentRun'; +import { IFlagService } from '#/app/flag/flag'; +import { IProjectLocalConfigService } from '#/app/projectLocalConfig/projectLocalConfig'; +import { IModelCatalog, type Model } from '#/kosong/model/catalog'; import { IAgentLifecycleService } from '#/session/agentLifecycle/agentLifecycle'; import { type AgentRunHandle, @@ -47,6 +54,7 @@ import { IEventBus, type DomainEvent } from '#/app/event/eventBus'; import type { AgentProfile } from '#/app/agentProfileCatalog/agentProfileCatalog'; import { ITelemetryService, noopTelemetryService } from '#/app/telemetry/telemetry'; import { ISessionCronService } from '#/session/cron/sessionCronService'; +import { ISessionQuestionService } from '#/session/question/question'; import { ISessionMetadata, type AgentMeta } from '#/session/sessionMetadata/sessionMetadata'; import { ISessionAgentProfileCatalog } from '#/session/sessionAgentProfileCatalog/sessionAgentProfileCatalog'; import type { @@ -57,7 +65,11 @@ import type { import type { IProcess, ISessionProcessRunner } from '#/session/process/processRunner'; import { IWireService } from '#/wire/wire'; import { createFakeProcessRunner } from '../tools/fixtures/fake-exec'; +import { stubFlag } from '../app/flag/stubs'; +import { stubProjectLocalConfig } from '../app/projectLocalConfig/stubs'; +import { stubQuestionService } from '../session/question/stubs'; import { + appService, configServices, createCommandRunner, createTestAgent, @@ -365,6 +377,20 @@ function subagentMeta(parentAgentId = 'main'): AgentMeta { }; } +function modelCatalogStub(validAliases: readonly string[]): IModelCatalog { + const valid = new Set(validAliases); + return { + _serviceBrand: undefined, + get: (alias: string): Model => { + if (!valid.has(alias)) throw new Error(`model.not_configured: ${alias}`); + return {} as Model; + }, + // The harness drops the assembled-Model cache behind the service's back + // during context setup (see AgentTestContext.configureRuntimeModel). + notifyConfigChanged: () => {}, + } as unknown as IModelCatalog; +} + describe('AgentToolInputSchema', () => { it('accepts the snake_case background parameter', () => { const parsed = AgentToolInputSchema.parse({ @@ -639,6 +665,12 @@ describe('Agent tool execution contract', () => { sessionService(IAgentLifecycleService, lifecycle), sessionService(ISessionSubagentService, lifecycle), sessionService(ISessionCronService, cronStub), + // With the experimental flag enabled (e.g. via the dev env's + // KIMI_CODE_EXPERIMENTAL_SUBAGENT_MODEL_SELECTION), an unbound spawn + // asks for a binding: default to a dismissing question service so the + // spawn falls back to inheritance instead of parking on the harness + // RPC. Tests that exercise the ask override this via `extra`. + sessionService(ISessionQuestionService, stubQuestionService()), ...extra, ); lifecycle.addHandle('main', 'agent'); @@ -675,6 +707,24 @@ describe('Agent tool execution contract', () => { }; } + it('hides binding_slot from the advertised schema unless the binding flag is enabled', async () => { + const offContext = createAgentToolContext( + createAgentLifecycleStub(), + appService(IFlagService, stubFlag(false)), + ); + const offSchema = agentTool(offContext).parameters as { properties: Record }; + await offContext.dispose(); + + const onContext = createAgentToolContext( + createAgentLifecycleStub(), + appService(IFlagService, stubFlag(true)), + ); + const onSchema = agentTool(onContext).parameters as { properties: Record }; + + expect(Object.keys(offSchema.properties)).not.toContain('binding_slot'); + expect(Object.keys(onSchema.properties)).toContain('binding_slot'); + }); + it('rejects a subagent type outside the caller allowlist', async () => { const lifecycle = createAgentLifecycleStub(); const context = createAgentToolContext( @@ -1094,6 +1144,7 @@ describe('Agent tool execution contract', () => { ISessionMetadata, sessionMetadataStub({ 'agent-existing': subagentMeta() }), ), + appService(IFlagService, stubFlag(false)), ); lifecycle.addHandle( 'agent-existing', @@ -1607,6 +1658,494 @@ describe('Agent tool execution contract', () => { expect(result).toMatchObject({ isError: true }); expect(result.output).toContain('subagent error: Agent timed out after 1 second.'); }); + + describe('subagent model bindings', () => { + it('keeps inheriting the caller model and emits no model fields when the flag is disabled', async () => { + const events: DomainEvent[] = []; + const eventBus = { + _serviceBrand: undefined, + publish: vi.fn((event: DomainEvent) => { + events.push(event); + }), + subscribe: vi.fn(() => noopDisposable()), + } as IEventBus; + const lifecycle = createAgentLifecycleStub({ + createAgentIds: ['agent-child'], + handleServices: new Map([['main', new Map([[IEventBus, eventBus]])]]), + }); + const context = createAgentToolContext( + lifecycle, + appService(IFlagService, stubFlag(false)), + appService( + IProjectLocalConfigService, + stubProjectLocalConfig({ + bindings: { explore: { model: 'mock-model', thinkingEffort: 'high' } }, + }), + ), + ); + const own = context.get(IAgentProfileService).data(); + + await executeAgentTool(context, { + prompt: 'Investigate', + description: 'Find cause', + subagent_type: 'explore', + }); + + expect(lifecycle.create).toHaveBeenCalledWith( + expect.objectContaining({ + binding: expect.objectContaining({ + profile: 'explore', + model: own.modelAlias, + thinking: own.thinkingLevel, + }), + }), + ); + const spawned = events.find( + (event): event is SubagentSpawnedEvent => event.type === 'subagent.spawned', + ); + expect(spawned).toBeDefined(); + expect(spawned?.modelAlias).toBeUndefined(); + expect(spawned?.thinkingEffort).toBeUndefined(); + }); + + it('applies the type binding and reports the effective model on the spawned event when enabled', async () => { + const events: DomainEvent[] = []; + const eventBus = { + _serviceBrand: undefined, + publish: vi.fn((event: DomainEvent) => { + events.push(event); + }), + subscribe: vi.fn(() => noopDisposable()), + } as IEventBus; + const lifecycle = createAgentLifecycleStub({ + createAgentIds: ['agent-child'], + handleServices: new Map([['main', new Map([[IEventBus, eventBus]])]]), + }); + const context = createAgentToolContext( + lifecycle, + appService(IFlagService, stubFlag(true)), + appService( + IProjectLocalConfigService, + stubProjectLocalConfig({ + bindings: { explore: { model: 'mock-model', thinkingEffort: 'high' } }, + }), + ), + ); + context.get(IAgentProfileService).update({ modelAlias: 'parent-model' }); + + await executeAgentTool(context, { + prompt: 'Investigate', + description: 'Find cause', + subagent_type: 'explore', + }); + + expect(lifecycle.create).toHaveBeenCalledWith( + expect.objectContaining({ + binding: expect.objectContaining({ + profile: 'explore', + model: 'mock-model', + thinking: 'high', + }), + }), + ); + const spawned = events.find( + (event): event is SubagentSpawnedEvent => event.type === 'subagent.spawned', + ); + expect(spawned).toMatchObject({ + modelAlias: 'mock-model', + thinkingEffort: 'high', + }); + }); + + it('prefers the named binding slot over the type binding when enabled', async () => { + const events: DomainEvent[] = []; + const eventBus = { + _serviceBrand: undefined, + publish: vi.fn((event: DomainEvent) => { + events.push(event); + }), + subscribe: vi.fn(() => noopDisposable()), + } as IEventBus; + const lifecycle = createAgentLifecycleStub({ + createAgentIds: ['agent-child'], + handleServices: new Map([['main', new Map([[IEventBus, eventBus]])]]), + }); + const context = createAgentToolContext( + lifecycle, + appService(IFlagService, stubFlag(true)), + appService( + IProjectLocalConfigService, + stubProjectLocalConfig({ + bindings: { explore: { model: 'mock-model', thinkingEffort: 'high' } }, + slotBindings: { fast: { model: 'mock-model', thinkingEffort: 'low' } }, + }), + ), + ); + + await executeAgentTool(context, { + prompt: 'Investigate', + description: 'Find cause', + subagent_type: 'explore', + binding_slot: 'fast', + }); + + expect(lifecycle.create).toHaveBeenCalledWith( + expect.objectContaining({ + binding: expect.objectContaining({ + profile: 'explore', + model: 'mock-model', + thinking: 'low', + }), + }), + ); + const spawned = events.find( + (event): event is SubagentSpawnedEvent => event.type === 'subagent.spawned', + ); + expect(spawned).toMatchObject({ + modelAlias: 'mock-model', + thinkingEffort: 'low', + }); + }); + + it('asks once when the type has no binding and applies and persists the answer when enabled', async () => { + const events: DomainEvent[] = []; + const eventBus = { + _serviceBrand: undefined, + publish: vi.fn((event: DomainEvent) => { + events.push(event); + }), + subscribe: vi.fn(() => noopDisposable()), + } as IEventBus; + const lifecycle = createAgentLifecycleStub({ + createAgentIds: ['agent-child'], + handleServices: new Map([['main', new Map([[IEventBus, eventBus]])]]), + }); + const localConfig = stubProjectLocalConfig(); + const writeType = vi.spyOn(localConfig, 'writeSubagentBinding'); + const question = stubQuestionService({ + respond: (req) => ({ answers: { [req.questions[0]?.question ?? '']: 'mock-model' } }), + }); + const context = createAgentToolContext( + lifecycle, + appService(IFlagService, stubFlag(true)), + appService(IProjectLocalConfigService, localConfig), + sessionService(ISessionQuestionService, question), + ); + + await executeAgentTool(context, { + prompt: 'Investigate', + description: 'Find cause', + subagent_type: 'explore', + }); + + expect(question.request).toHaveBeenCalledOnce(); + expect(lifecycle.create).toHaveBeenCalledWith( + expect.objectContaining({ + binding: expect.objectContaining({ + profile: 'explore', + model: 'mock-model', + }), + }), + ); + expect(writeType).toHaveBeenCalledWith(expect.any(String), 'explore', { + model: 'mock-model', + thinkingEffort: undefined, + }); + const spawned = events.find( + (event): event is SubagentSpawnedEvent => event.type === 'subagent.spawned', + ); + expect(spawned).toMatchObject({ + modelAlias: 'mock-model', + }); + }); + + it('inherits the caller model without persisting when the binding ask is dismissed and enabled', async () => { + const events: DomainEvent[] = []; + const eventBus = { + _serviceBrand: undefined, + publish: vi.fn((event: DomainEvent) => { + events.push(event); + }), + subscribe: vi.fn(() => noopDisposable()), + } as IEventBus; + const lifecycle = createAgentLifecycleStub({ + createAgentIds: ['agent-child'], + handleServices: new Map([['main', new Map([[IEventBus, eventBus]])]]), + }); + const localConfig = stubProjectLocalConfig(); + const writeType = vi.spyOn(localConfig, 'writeSubagentBinding'); + const question = stubQuestionService(); + const context = createAgentToolContext( + lifecycle, + appService(IFlagService, stubFlag(true)), + appService(IProjectLocalConfigService, localConfig), + sessionService(ISessionQuestionService, question), + ); + const own = context.get(IAgentProfileService).data(); + + await executeAgentTool(context, { + prompt: 'Investigate', + description: 'Find cause', + subagent_type: 'explore', + }); + + expect(question.request).toHaveBeenCalledOnce(); + expect(lifecycle.create).toHaveBeenCalledWith( + expect.objectContaining({ + binding: expect.objectContaining({ + profile: 'explore', + model: own.modelAlias, + thinking: own.thinkingLevel, + }), + }), + ); + expect(writeType).not.toHaveBeenCalled(); + const spawned = events.find( + (event): event is SubagentSpawnedEvent => event.type === 'subagent.spawned', + ); + expect(spawned).toMatchObject({ + modelAlias: own.modelAlias, + }); + }); + + it('keeps the bound model on a directly resumed subagent and reports it on the spawned event when enabled', async () => { + const events: DomainEvent[] = []; + const eventBus = { + _serviceBrand: undefined, + publish: vi.fn((event: DomainEvent) => { + events.push(event); + }), + subscribe: vi.fn(() => noopDisposable()), + } as IEventBus; + const targetProfile = { + _serviceBrand: undefined, + data: () => ({ profileName: 'explore', modelAlias: 'sub/model', thinkingLevel: 'high' }), + update: vi.fn(), + isToolActive: () => false, + } as unknown as IAgentProfileService; + const lifecycle = createAgentLifecycleStub({ + runCompletion: async () => ({ summary: 'resumed result' }), + handleServices: new Map([['main', new Map([[IEventBus, eventBus]])]]), + }); + const context = createAgentToolContext( + lifecycle, + sessionService( + ISessionMetadata, + sessionMetadataStub({ 'agent-existing': subagentMeta() }), + ), + appService(IFlagService, stubFlag(true)), + appService(IModelCatalog, modelCatalogStub(['mock-model', 'sub/model'])), + appService( + IProjectLocalConfigService, + stubProjectLocalConfig({ + bindings: { explore: { model: 'sub/model', thinkingEffort: 'high' } }, + }), + ), + ); + lifecycle.addHandle( + 'agent-existing', + 'explore', + new Map([[IAgentProfileService, targetProfile]]), + ); + + await executeAgentTool(context, { + prompt: 'Continue', + description: 'Continue work', + resume: 'agent-existing', + }); + + expect(targetProfile.update).not.toHaveBeenCalled(); + const spawned = events.find( + (event): event is SubagentSpawnedEvent => event.type === 'subagent.spawned', + ); + expect(spawned).toMatchObject({ + modelAlias: 'sub/model', + thinkingEffort: 'high', + }); + }); + + it('fails a sticky resume when the bound model alias no longer resolves and the flag is enabled', async () => { + const eventBus = { + _serviceBrand: undefined, + publish: vi.fn(), + subscribe: vi.fn(() => noopDisposable()), + } as IEventBus; + const targetProfile = { + _serviceBrand: undefined, + data: () => ({ profileName: 'explore', modelAlias: 'gone/model', thinkingLevel: 'high' }), + update: vi.fn(), + isToolActive: () => false, + } as unknown as IAgentProfileService; + const lifecycle = createAgentLifecycleStub({ + runCompletion: async () => ({ summary: 'resumed result' }), + handleServices: new Map([['main', new Map([[IEventBus, eventBus]])]]), + }); + const context = createAgentToolContext( + lifecycle, + sessionService( + ISessionMetadata, + sessionMetadataStub({ 'agent-existing': subagentMeta() }), + ), + appService(IFlagService, stubFlag(true)), + appService(IModelCatalog, modelCatalogStub(['mock-model'])), + ); + lifecycle.addHandle( + 'agent-existing', + 'explore', + new Map([[IAgentProfileService, targetProfile]]), + ); + + const result = await executeAgentTool(context, { + prompt: 'Continue', + description: 'Continue work', + resume: 'agent-existing', + }); + + expect(result.isError).toBe(true); + expect(result.output).toContain( + 'The configured subagent model alias is not resolvable. Check the bindings in .kimi-code/local.toml and your models config.', + ); + expect(targetProfile.update).not.toHaveBeenCalled(); + expect(lifecycle.run).not.toHaveBeenCalled(); + }); + + it('resumes a sticky subagent without realigning when the bound alias still resolves and the flag is enabled', async () => { + const eventBus = { + _serviceBrand: undefined, + publish: vi.fn(), + subscribe: vi.fn(() => noopDisposable()), + } as IEventBus; + const targetProfile = { + _serviceBrand: undefined, + data: () => ({ profileName: 'explore', modelAlias: 'sub/model', thinkingLevel: 'high' }), + update: vi.fn(), + isToolActive: () => false, + } as unknown as IAgentProfileService; + const lifecycle = createAgentLifecycleStub({ + runCompletion: async () => ({ summary: 'resumed result' }), + handleServices: new Map([['main', new Map([[IEventBus, eventBus]])]]), + }); + const context = createAgentToolContext( + lifecycle, + sessionService( + ISessionMetadata, + sessionMetadataStub({ 'agent-existing': subagentMeta() }), + ), + appService(IFlagService, stubFlag(true)), + appService(IModelCatalog, modelCatalogStub(['mock-model', 'sub/model'])), + ); + lifecycle.addHandle( + 'agent-existing', + 'explore', + new Map([[IAgentProfileService, targetProfile]]), + ); + + const result = await executeAgentTool(context, { + prompt: 'Continue', + description: 'Continue work', + resume: 'agent-existing', + }); + + expect(result.isError).not.toBe(true); + expect(result.output).toContain('resumed result'); + expect(targetProfile.update).not.toHaveBeenCalled(); + }); + + it('does not validate the resumed subagent model alias when the flag is disabled', async () => { + const eventBus = { + _serviceBrand: undefined, + publish: vi.fn(), + subscribe: vi.fn(() => noopDisposable()), + } as IEventBus; + const targetProfile = { + _serviceBrand: undefined, + data: () => ({ profileName: 'explore', modelAlias: 'gone/model', thinkingLevel: 'high' }), + update: vi.fn(), + isToolActive: () => false, + } as unknown as IAgentProfileService; + const lifecycle = createAgentLifecycleStub({ + runCompletion: async () => ({ summary: 'resumed result' }), + handleServices: new Map([['main', new Map([[IEventBus, eventBus]])]]), + }); + const context = createAgentToolContext( + lifecycle, + sessionService( + ISessionMetadata, + sessionMetadataStub({ 'agent-existing': subagentMeta() }), + ), + appService(IFlagService, stubFlag(false)), + appService(IModelCatalog, modelCatalogStub(['mock-model'])), + ); + lifecycle.addHandle( + 'agent-existing', + 'explore', + new Map([[IAgentProfileService, targetProfile]]), + ); + + const result = await executeAgentTool(context, { + prompt: 'Continue', + description: 'Continue work', + resume: 'agent-existing', + }); + + expect(result.isError).not.toBe(true); + expect(targetProfile.update).toHaveBeenCalledWith({ modelAlias: 'mock-model' }); + }); + + it('realigns a directly resumed subagent to the caller model and emits no model fields when disabled', async () => { + const events: DomainEvent[] = []; + const eventBus = { + _serviceBrand: undefined, + publish: vi.fn((event: DomainEvent) => { + events.push(event); + }), + subscribe: vi.fn(() => noopDisposable()), + } as IEventBus; + const targetProfile = { + _serviceBrand: undefined, + data: () => ({ profileName: 'explore', modelAlias: 'stale-model', thinkingLevel: 'high' }), + update: vi.fn(), + isToolActive: () => false, + } as unknown as IAgentProfileService; + const lifecycle = createAgentLifecycleStub({ + runCompletion: async () => ({ summary: 'resumed result' }), + handleServices: new Map([['main', new Map([[IEventBus, eventBus]])]]), + }); + const context = createAgentToolContext( + lifecycle, + sessionService( + ISessionMetadata, + sessionMetadataStub({ 'agent-existing': subagentMeta() }), + ), + appService(IFlagService, stubFlag(false)), + appService( + IProjectLocalConfigService, + stubProjectLocalConfig({ + bindings: { explore: { model: 'sub/model', thinkingEffort: 'high' } }, + }), + ), + ); + lifecycle.addHandle( + 'agent-existing', + 'explore', + new Map([[IAgentProfileService, targetProfile]]), + ); + + await executeAgentTool(context, { + prompt: 'Continue', + description: 'Continue work', + resume: 'agent-existing', + }); + + expect(targetProfile.update).toHaveBeenCalledWith({ modelAlias: 'mock-model' }); + const spawned = events.find( + (event): event is SubagentSpawnedEvent => event.type === 'subagent.spawned', + ); + expect(spawned).toBeDefined(); + expect(spawned?.modelAlias).toBeUndefined(); + expect(spawned?.thinkingEffort).toBeUndefined(); + }); + }); }); describe('AgentSwarmToolInputSchema', () => { @@ -1656,6 +2195,15 @@ describe('AgentSwarmToolInputSchema', () => { ).toBe(true); }); + it('accepts an optional binding_slot and rejects blank values', () => { + expect( + AgentSwarmToolInputSchema.safeParse({ ...spawnInput, binding_slot: 'fast' }).success, + ).toBe(true); + expect( + AgentSwarmToolInputSchema.safeParse({ ...spawnInput, binding_slot: ' ' }).success, + ).toBe(false); + }); + it('exposes subagent_type and resume_agent_ids parameters', () => { const properties = agentSwarmSchemaProperties<{ description?: string }>(); @@ -1782,6 +2330,48 @@ describe('AgentSwarm tool execution contract', () => { expect(result.isError).toBeUndefined(); }); + it('forwards the binding slot to item-spawned tasks but not resumed tasks', async () => { + const runSwarm = vi.fn( + async ( + args: SessionSwarmRunArgs, + ): Promise[]> => { + return args.tasks.map((task, index) => ({ + task, + agentId: `agent-${String(index + 1)}`, + status: 'completed' as const, + result: `result ${String(index + 1)}`, + })); + }, + ); + const swarmService: ISessionSwarmService = { + _serviceBrand: undefined, + getSwarmItem: async () => undefined, + run: runSwarm as ISessionSwarmService['run'], + cancel: () => {}, + }; + ctx = createTestAgent(swarmServices(swarmService)); + + await executeTool(agentSwarmTool(ctx), { + turnId: 0, + toolCallId: 'call_swarm', + args: { + description: 'Review files', + prompt_template: 'Review {{item}}', + items: ['src/a.ts', 'src/b.ts'], + resume_agent_ids: { 'agent-old-1': 'Continue previous review' }, + binding_slot: 'fast', + }, + signal, + }); + + expect(runSwarm).toHaveBeenCalledOnce(); + const tasks = runSwarm.mock.calls[0]![0].tasks; + expect(tasks).toHaveLength(3); + expect( + tasks.map((task) => (task.kind === 'spawn' ? task.bindingSlot : task.kind)), + ).toEqual(['resume', 'fast', 'fast']); + }); + it('resumes mapped agents before spawning item subagents', async () => { const persistedItems: Record = { 'agent-old-1': 'src/old-a.ts', @@ -2318,6 +2908,9 @@ describe('Agent tools', () => { sessionService(IAgentLifecycleService, lifecycle), sessionService(ISessionSubagentService, lifecycle), sessionService(ISessionCronService, cronStub), + // See createAgentToolContext: an unbound spawn asks for a binding + // when the experimental flag is enabled; dismiss by default. + sessionService(ISessionQuestionService, stubQuestionService()), ); lifecycle.addHandle('main', 'agent'); }); @@ -2509,7 +3102,7 @@ describe('Agent tools', () => { }; beforeEach(async () => { - ctx = createTestAgent(); + ctx = createTestAgent(appService(IFlagService, stubFlag(false))); await ctx.rpc.setPermission({ mode: 'auto' }); await ctx.rpc.registerTool({ name: 'Lookup', diff --git a/packages/kap-server/src/protocol/events-zod.ts b/packages/kap-server/src/protocol/events-zod.ts index 79de1337ca..896741a509 100644 --- a/packages/kap-server/src/protocol/events-zod.ts +++ b/packages/kap-server/src/protocol/events-zod.ts @@ -780,6 +780,8 @@ export const subagentSpawnedEventSchema = z.object({ description: z.string().optional(), swarmIndex: z.number().optional(), runInBackground: z.boolean(), + modelAlias: z.string().optional(), + thinkingEffort: z.string().optional(), }) satisfies z.ZodType; export const subagentStartedEventSchema = z.object({