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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions .changeset/v2-subagent-binding-ask-once.md
Original file line number Diff line number Diff line change
@@ -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.<type>]` binding to try it.
5 changes: 5 additions & 0 deletions .changeset/v2-subagent-binding-resume-parity.md
Original file line number Diff line number Diff line change
@@ -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.
5 changes: 5 additions & 0 deletions .changeset/v2-subagent-binding-single-ask.md
Original file line number Diff line number Diff line change
@@ -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.
5 changes: 5 additions & 0 deletions .changeset/v2-subagent-binding-sticky-swarm.md
Original file line number Diff line number Diff line change
@@ -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.<type>]` / `[subagent-slot.<name>]` in `.kimi-code/local.toml` to try it.
5 changes: 5 additions & 0 deletions .changeset/v2-subagent-model-bindings.md
Original file line number Diff line number Diff line change
@@ -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.<type>]` binding (for example `model = "your-provider/your-model"`) in `.kimi-code/local.toml` to try it.
4 changes: 4 additions & 0 deletions packages/agent-core-v2/scripts/check-domain-layers.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -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',
Expand Down
23 changes: 22 additions & 1 deletion packages/agent-core-v2/src/agent/swarm/tools/agent-swarm.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand All @@ -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';
Expand Down Expand Up @@ -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.<name>]), 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()
Expand Down Expand Up @@ -106,8 +116,8 @@ interface SwarmRunResult {
export class AgentSwarmTool implements BuiltinTool<AgentSwarmToolInput> {
readonly name = 'AgentSwarm' as const;
readonly description = AGENT_SWARM_DESCRIPTION;
readonly parameters: Record<string, unknown> = toInputJsonSchema(AgentSwarmToolInputSchema);

private readonly fullParameters: Record<string, unknown> = toInputJsonSchema(AgentSwarmToolInputSchema);
private readonly callerAgentId: string;

constructor(
Expand All @@ -117,10 +127,20 @@ export class AgentSwarmTool implements BuiltinTool<AgentSwarmToolInput> {
@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<string, unknown> {
if (this.flags.enabled(SUBAGENT_MODEL_SELECTION_FLAG_ID)) {
return this.fullParameters;
}
const properties = { ...(this.fullParameters['properties'] as Record<string, unknown>) };
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 {
Expand Down Expand Up @@ -195,6 +215,7 @@ export class AgentSwarmTool implements BuiltinTool<AgentSwarmToolInput> {
return {
...common,
kind: 'spawn' as const,
bindingSlot: normalizeOptionalString(args.binding_slot),
};
});
const results = await this.swarmService.run({
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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.<type>]` / `[subagent-slot.<name>]`) 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';
Expand All @@ -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;

Expand All @@ -27,6 +38,18 @@ export interface IProjectLocalConfigService {
workDir: string,
inputPath: string,
): Promise<ProjectAdditionalDirsLoadResult>;
readSubagentBinding(workDir: string, agentType: string): Promise<SubagentBinding | undefined>;
writeSubagentBinding(
workDir: string,
agentType: string,
binding: SubagentBinding | undefined,
): Promise<{ readonly configPath: string }>;
readSubagentSlotBinding(workDir: string, slot: string): Promise<SubagentBinding | undefined>;
writeSubagentSlotBinding(
workDir: string,
slot: string,
binding: SubagentBinding | undefined,
): Promise<{ readonly configPath: string }>;
}

export const IProjectLocalConfigService: ServiceIdentifier<IProjectLocalConfigService> =
Expand Down
1 change: 1 addition & 0 deletions packages/agent-core-v2/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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.<type>]` / `[subagent-slot.<name>]` 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.
*/
Expand All @@ -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<typeof ProjectLocalTomlSchema>;

type BindingSection = 'subagent' | 'subagent-slot';

interface ProjectLocalTomlFile {
readonly raw: Record<string, unknown>;
readonly parsed: ProjectLocalToml;
Expand Down Expand Up @@ -97,6 +111,30 @@ export class FileProjectLocalConfigService implements IProjectLocalConfigService
return { projectRoot, configPath, additionalDirs: [...fileExistingDirs, additionalDir] };
}

readSubagentBinding(workDir: string, agentType: string): Promise<SubagentBinding | undefined> {
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<SubagentBinding | undefined> {
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');
}
Expand Down Expand Up @@ -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<SubagentBinding | undefined> {
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<string, unknown> = {};
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[],
Expand Down
Loading