From 21920a9ee4ad7b5bc4e714cc13f84b79270eeb2b Mon Sep 17 00:00:00 2001 From: yorha9e Date: Wed, 22 Jul 2026 02:19:38 +0800 Subject: [PATCH 1/7] feat(agent-core-v2): per-workspace subagent model bindings Resolve [subagent.] / [subagent-slot.] bindings from .kimi-code/local.toml at subagent spawn (slot > type > inherit), add the Agent tool binding_slot parameter, and carry the effective model and thinking effort on subagent.spawned. Gated by the subagent-model-selection experiment; behavior is unchanged while the flag is off. --- .changeset/v2-subagent-model-bindings.md | 5 + .../projectLocalConfig/projectLocalConfig.ts | 27 ++- packages/agent-core-v2/src/index.ts | 1 + .../node-fs/projectLocalConfigService.ts | 97 ++++++++- .../src/session/subagent/bindingResolution.ts | 127 ++++++++++++ .../src/session/subagent/flag.ts | 30 +++ .../src/session/subagent/mirrorAgentRun.ts | 10 + .../src/session/subagent/tools/agent.ts | 49 ++++- .../src/session/swarm/sessionSwarmService.ts | 37 +++- .../test/agent/loop/loop.test.ts | 4 +- .../sessionLifecycle/sessionLifecycle.test.ts | 4 + .../test/app/workspaceLocalConfig/stubs.ts | 48 +++++ .../node-fs/workspaceLocalConfig.test.ts | 156 ++++++++++++++ .../subagent/bindingResolution.test.ts | 196 ++++++++++++++++++ .../test/session/swarm/sessionSwarm.test.ts | 113 ++++++++++ packages/agent-core-v2/test/tool/tool.test.ts | 170 ++++++++++++++- .../kap-server/src/protocol/events-zod.ts | 2 + 17 files changed, 1055 insertions(+), 21 deletions(-) create mode 100644 .changeset/v2-subagent-model-bindings.md create mode 100644 packages/agent-core-v2/src/session/subagent/bindingResolution.ts create mode 100644 packages/agent-core-v2/src/session/subagent/flag.ts create mode 100644 packages/agent-core-v2/test/app/workspaceLocalConfig/stubs.ts create mode 100644 packages/agent-core-v2/test/persistence/backends/node-fs/workspaceLocalConfig.test.ts create mode 100644 packages/agent-core-v2/test/session/subagent/bindingResolution.test.ts 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/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/bindingResolution.ts b/packages/agent-core-v2/src/session/subagent/bindingResolution.ts new file mode 100644 index 0000000000..e7124fffd3 --- /dev/null +++ b/packages/agent-core-v2/src/session/subagent/bindingResolution.ts @@ -0,0 +1,127 @@ +/** + * `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; a stale + * model alias (one `modelCatalog.get` rejects) warns and falls through to + * the next-lower level. Storage failures are not swallowed — they propagate + * to the caller. + */ + +import { IFlagService } from '#/app/flag/flag'; +import { + type IWorkspaceLocalConfigService, + type SubagentBinding, +} from '#/app/workspaceLocalConfig/workspaceLocalConfig'; +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 ResolveSubagentSpawnBindingDeps { + readonly flags: IFlagService; + readonly workspaceLocalConfig: IWorkspaceLocalConfigService; + readonly modelCatalog: IModelCatalog; +} + +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(); + if (slot !== undefined && slot.length > 0) { + const slotEntry = await deps.workspaceLocalConfig.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); + warnings.push(resolved.warning); + } + } + + const typeEntry = await deps.workspaceLocalConfig.readSubagentBinding( + input.workDir, + input.profileName, + ); + if (typeEntry !== undefined) { + const resolved = resolveBindingEntry( + deps, + typeEntry, + `subagent.${input.profileName}`, + 'the caller model', + ); + if (resolved.terminal) return withWarnings(resolved.resolution, warnings); + warnings.push(resolved.warning); + } + + return withWarnings({}, warnings); +} + +type BindingEntryResolution = + | { readonly terminal: true; readonly resolution: SubagentSpawnBindingResolution } + | { readonly terminal: false; readonly warning: string }; + +function resolveBindingEntry( + deps: ResolveSubagentSpawnBindingDeps, + entry: SubagentBinding, + sectionLabel: string, + fallbackLabel: string, +): BindingEntryResolution { + if (entry.inherit === true) { + return { terminal: true, resolution: {} }; + } + 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; falling back to ${fallbackLabel}.`, + }; + } + 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..8cec72a8dd 100644 --- a/packages/agent-core-v2/src/session/subagent/mirrorAgentRun.ts +++ b/packages/agent-core-v2/src/session/subagent/mirrorAgentRun.ts @@ -44,6 +44,10 @@ export interface SubagentSpawnedEvent { readonly description?: string; readonly swarmIndex?: number; readonly runInBackground: boolean; + /** Effective model alias the subagent will run with (after override resolution). */ + readonly modelAlias?: string; + /** Effective thinking effort the subagent will run with (after override resolution). */ + readonly thinkingEffort?: string; } export interface SubagentStartedEvent { @@ -81,6 +85,10 @@ export interface AgentRunSpawnedMeta { readonly description?: string; readonly swarmIndex?: number; readonly runInBackground?: boolean; + /** Effective model alias the subagent will run with (after override resolution). */ + readonly modelAlias?: string; + /** Effective thinking effort the subagent will run with (after override resolution). */ + readonly thinkingEffort?: string; } export interface MirrorAgentRunOptions { @@ -107,6 +115,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..dbd9eb3c94 100644 --- a/packages/agent-core-v2/src/session/subagent/tools/agent.ts +++ b/packages/agent-core-v2/src/session/subagent/tools/agent.ts @@ -2,7 +2,9 @@ * `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 + * into a Profile + Model binding — resolving the workspace model bindings + * (`bindingResolution`) behind the `subagent-model-selection` experimental + * flag — creates (or resumes) an agent through * `IAgentLifecycleService`, 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, @@ -56,12 +58,17 @@ 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 { IWorkspaceLocalConfigService } from '#/app/workspaceLocalConfig/workspaceLocalConfig'; +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 { ISessionMetadata } from '#/session/sessionMetadata/sessionMetadata'; import { ISessionWorkspaceContext } from '#/session/workspaceContext/workspaceContext'; +import { resolveSubagentSpawnBinding } from '../bindingResolution'; +import { SUBAGENT_MODEL_SELECTION_FLAG_ID } from '../flag'; import { emitAgentRunSpawned, mirrorAgentRun } from '../mirrorAgentRun'; import { ISessionSubagentService } from '../subagent'; import { @@ -110,6 +117,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() @@ -167,6 +180,9 @@ 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, + @IWorkspaceLocalConfigService private readonly workspaceLocalConfig: IWorkspaceLocalConfigService, + @IModelCatalog private readonly modelCatalog: IModelCatalog, ) { this.callerAgentId = scopeContext.agentId; this.canRunInBackground = () => @@ -250,6 +266,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) { @@ -274,14 +292,35 @@ 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, + workspaceLocalConfig: this.workspaceLocalConfig, + modelCatalog: this.modelCatalog, + }, + { + 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 +344,8 @@ export class AgentTool implements BuiltinTool { parentToolCallId: toolCallId, description: args.description, runInBackground, + modelAlias: spawnedModelAlias, + thinkingEffort: spawnedThinkingEffort, }); const run = await this.subagents.run( diff --git a/packages/agent-core-v2/src/session/swarm/sessionSwarmService.ts b/packages/agent-core-v2/src/session/swarm/sessionSwarmService.ts index 889c3fe17f..b30a466b0e 100644 --- a/packages/agent-core-v2/src/session/swarm/sessionSwarmService.ts +++ b/packages/agent-core-v2/src/session/swarm/sessionSwarmService.ts @@ -5,7 +5,9 @@ * `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 + * 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. 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 @@ -24,8 +26,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 { IWorkspaceLocalConfigService } from '#/app/workspaceLocalConfig/workspaceLocalConfig'; 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 +38,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'; @@ -82,6 +89,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, + @IWorkspaceLocalConfigService private readonly workspaceLocalConfig: IWorkspaceLocalConfigService, + @IModelCatalog private readonly modelCatalog: IModelCatalog, ) {} async getSwarmItem(args: { @@ -141,14 +151,31 @@ 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, + workspaceLocalConfig: this.workspaceLocalConfig, + modelCatalog: this.modelCatalog, + }, + { + workDir: this.sessionContext.cwd, + profileName: profile.name, + }, + ); + 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 +193,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, 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..b0ebbaabc9 100644 --- a/packages/agent-core-v2/test/agent/loop/loop.test.ts +++ b/packages/agent-core-v2/test/agent/loop/loop.test.ts @@ -104,8 +104,8 @@ describe('Agent loop', () => { [emit] turn.step.started { "turnId": 0, "step": 1, "stepId": "" } [emit] agent.activity.updated { "lifecycle": "ready", "turn": { "turnId": 0, "origin": { "kind": "user" }, "phase": "running", "step": 1, "ending": false, "pendingApprovals": [], "activeToolCalls": [], "since": "