diff --git a/packages/agent-core-v2/docs/di-scope-domains.puml b/packages/agent-core-v2/docs/di-scope-domains.puml
index 42d2e3b1b..0d103484f 100644
--- a/packages/agent-core-v2/docs/di-scope-domains.puml
+++ b/packages/agent-core-v2/docs/di-scope-domains.puml
@@ -46,6 +46,7 @@ package "App scope (process-wide)" #EAF3FB {
rectangle "chatProvider\nApp\n IChatProviderFactory" as chatProvider #D6EAF8
rectangle "model\nApp\n IModelService" as model #D6EAF8
rectangle "modelCatalog\nApp\n IModelCatalogService" as modelCatalog #D6EAF8
+ rectangle "agentProfileCatalog\nApp\n IAgentProfileCatalogService" as agentProfileCatalog #D6EAF8
rectangle "skillCatalog\nApp\n ISkillDiscovery\n ISkillSource\n Builtin/UserSkillSource" as skillCatalog #D6EAF8
}
@@ -54,6 +55,7 @@ package "Session scope (per session)" #EAFAF1 {
rectangle "sessionMetadata\nSession\n ISessionMetadata" as session_metadata #D5F5E3
rectangle "sessionActivity\nSession\n ISessionActivity" as session_activity #D5F5E3
rectangle "agentLifecycle\nSession\n IAgentLifecycleService" as agent_lifecycle #D5F5E3
+ rectangle "sessionSwarm\nSession\n ISessionSwarmService" as sessionSwarm #D5F5E3
rectangle "interaction\nSession\n IInteractionService" as interaction #D5F5E3
rectangle "workspaceContext\nSession\n IWorkspaceContext" as workspaceContext #D5F5E3
rectangle "workspaceCommand\nSession\n ISessionWorkspaceCommandService" as workspaceCommand #D5F5E3
@@ -171,6 +173,12 @@ session_metadata --> log #34495E
agent_lifecycle --> storage #34495E
session_activity --> agent_lifecycle #34495E
session_activity --> interaction #34495E
+sessionSwarm --> agent_lifecycle #34495E
+sessionSwarm --> agentProfileCatalog #34495E
+sessionSwarm --> session_context #34495E
+sessionSwarm --> session_metadata #34495E
+sessionSwarm --> process #34495E
+sessionSwarm --> log #34495E
wireRecord --> blobStore #34495E
wireRecord --> bootstrap #34495E
wireRecord --> storage #34495E
diff --git a/packages/agent-core-v2/docs/di-scope-domains.svg b/packages/agent-core-v2/docs/di-scope-domains.svg
index fb73b0e9e..f5038c661 100644
--- a/packages/agent-core-v2/docs/di-scope-domains.svg
+++ b/packages/agent-core-v2/docs/di-scope-domains.svg
@@ -1 +1 @@
-
\ No newline at end of file
+
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 a2ffd6d5a..5f8515f78 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
@@ -2,10 +2,10 @@
* `swarm` domain (L4) — `AgentSwarm` collaboration tool.
*
* Launches a batch of child agents (an ordinary Agent scope each) through the
- * session swarm coordinator and renders the per-subagent XML result. Keeps a
- * module-level map of spawned agent id → swarm item so a later
- * `resume_agent_ids` call can relabel resumed subagents. Pure tool — owns no
- * scoped state.
+ * session swarm coordinator and renders the per-subagent XML result. Reads
+ * persisted swarm item labels through the Session-scoped coordinator so later
+ * `resume_agent_ids` calls relabel resumed subagents like v1. Pure tool —
+ * owns no scoped state.
*/
import { z } from 'zod';
@@ -24,8 +24,6 @@ const DEFAULT_SUBAGENT_TIMEOUT_MS = 30 * 60 * 1000;
const PROMPT_TEMPLATE_PLACEHOLDER = '{{item}}';
const MAX_AGENT_SWARM_SUBAGENTS = 128;
-const swarmItems = new Map();
-
export const AgentSwarmToolInputSchema = z
.object({
description: z
@@ -39,7 +37,7 @@ export const AgentSwarmToolInputSchema = z
.min(1)
.optional()
.describe(
- 'Subagent type used for every spawned subagent. Defaults to coder when omitted.',
+ 'Subagent type used for every new subagent spawned from items; defaults to coder when omitted. Resumed subagents always keep their original type, so passing subagent_type together with resume_agent_ids is allowed — it only affects the item-based spawns.',
),
prompt_template: z
.string()
@@ -147,7 +145,9 @@ export class AgentSwarmTool implements BuiltinTool {
toolCallId: string,
): Promise {
const profileName = normalizeOptionalString(args.subagent_type) ?? DEFAULT_SUBAGENT_TYPE;
- const specs = createAgentSwarmSpecs(args, (id) => swarmItems.get(id));
+ const specs = await createAgentSwarmSpecs(args, (agentId) =>
+ this.swarmService.getSwarmItem({ callerAgentId: this.callerAgentId, agentId }),
+ );
const tasks: SessionSwarmTask[] = specs.map((spec) => {
const descriptionName = spec.kind === 'resume' ? 'resume' : profileName;
const common = {
@@ -178,11 +178,6 @@ export class AgentSwarmTool implements BuiltinTool {
callerAgentId: this.callerAgentId,
tasks,
});
- for (const result of results) {
- if (result.agentId !== undefined && result.task.swarmItem !== undefined) {
- swarmItems.set(result.agentId, result.task.swarmItem);
- }
- }
return renderSwarmResults(
results.map(({ task, ...result }) => ({ spec: task.data as AgentSwarmSpec, ...result })),
);
@@ -191,10 +186,10 @@ export class AgentSwarmTool implements BuiltinTool {
registerTool(AgentSwarmTool);
-function createAgentSwarmSpecs(
+async function createAgentSwarmSpecs(
args: AgentSwarmToolInput,
- getResumeItem: (agentId: string) => string | undefined,
-): AgentSwarmSpec[] {
+ getResumeItem: (agentId: string) => Promise,
+): Promise {
const resumeEntries = Object.entries(args.resume_agent_ids ?? {}).map(([agentId, prompt]) => ({
agentId: agentId.trim(),
prompt: prompt.trim(),
@@ -226,7 +221,7 @@ function createAgentSwarmSpecs(
kind: 'resume',
index: specs.length + 1,
agentId: entry.agentId,
- item: getResumeItem(entry.agentId),
+ item: await getResumeItem(entry.agentId),
prompt: entry.prompt,
});
}
diff --git a/packages/agent-core-v2/src/app/sessionLifecycle/sessionLifecycleService.ts b/packages/agent-core-v2/src/app/sessionLifecycle/sessionLifecycleService.ts
index e5ec53d18..47293b234 100644
--- a/packages/agent-core-v2/src/app/sessionLifecycle/sessionLifecycleService.ts
+++ b/packages/agent-core-v2/src/app/sessionLifecycle/sessionLifecycleService.ts
@@ -32,6 +32,7 @@ import { IAgentContextMemoryService } from '#/agent/contextMemory/contextMemory'
import { ErrorCodes, KimiError } from '#/errors';
import { IHostEnvironment } from '#/os/interface/hostEnvironment';
import { ISessionActivity } from '#/session/sessionActivity/sessionActivity';
+import { labelsFromAgentMeta } from '#/session/agentLifecycle/subagentMetadata';
import { ISessionIndex } from '#/app/sessionIndex/sessionIndex';
import { IAtomicDocumentStore } from '#/persistence/interface/atomicDocumentStore';
import { IAppendLogStore } from '#/persistence/interface/appendLogStore';
@@ -307,13 +308,10 @@ export class SessionLifecycleService extends Disposable implements ISessionLifec
// log. Creating them registers fresh agent entries with TARGET homedirs.
for (const agentId of agentIds) {
const sourceAgent = sourceAgents[agentId]!;
- const legacy = sourceAgent as { parentAgentId?: string };
const agentHandle = await target.accessor.get(IAgentLifecycleService).create({
agentId,
- forkedFrom: sourceAgent.forkedFrom ?? legacy.parentAgentId,
- labels:
- sourceAgent.labels ??
- (sourceAgent.swarmItem !== undefined ? { swarmItem: sourceAgent.swarmItem } : undefined),
+ forkedFrom: sourceAgent.forkedFrom,
+ labels: labelsFromAgentMeta(sourceAgent),
});
const forkWireRecord = agentHandle.accessor.get(IAgentWireRecordService);
await forkWireRecord.restore();
diff --git a/packages/agent-core-v2/src/session/agentLifecycle/subagentMetadata.ts b/packages/agent-core-v2/src/session/agentLifecycle/subagentMetadata.ts
new file mode 100644
index 000000000..22edc5fc4
--- /dev/null
+++ b/packages/agent-core-v2/src/session/agentLifecycle/subagentMetadata.ts
@@ -0,0 +1,55 @@
+/**
+ * `agentLifecycle` domain (L6) — persisted subagent relationship labels.
+ *
+ * Provides the label helpers used by caller-owned agent-run wrappers (`Agent`
+ * and `AgentSwarm`) to record and read the requester → subagent relationship
+ * without making the flat lifecycle registry interpret parentage itself.
+ */
+
+import type { AgentMeta } from '#/session/sessionMetadata/sessionMetadata';
+
+export function subagentLabels(
+ parentAgentId: string,
+ options: { readonly swarmItem?: string } = {},
+): Readonly> {
+ const labels: Record = { parentAgentId };
+ if (options.swarmItem !== undefined) {
+ labels['swarmItem'] = options.swarmItem;
+ }
+ return labels;
+}
+
+export function labelsFromAgentMeta(
+ meta: AgentMeta,
+): Readonly> | undefined {
+ const labels: Record = { ...meta.labels };
+ const parentAgentId = subagentParentAgentId(meta);
+ if (parentAgentId !== undefined) {
+ labels['parentAgentId'] = parentAgentId;
+ }
+ const swarmItem = subagentSwarmItem(meta);
+ if (swarmItem !== undefined) {
+ labels['swarmItem'] = swarmItem;
+ }
+ return Object.keys(labels).length > 0 ? labels : undefined;
+}
+
+export function isSubagentMeta(meta: AgentMeta | undefined): boolean {
+ if (meta === undefined) return false;
+ if (subagentParentAgentId(meta) !== undefined) return true;
+ return meta.type === 'sub';
+}
+
+export function subagentParentAgentId(meta: AgentMeta | undefined): string | undefined {
+ if (meta === undefined) return undefined;
+ return firstNonEmpty(meta.labels?.['parentAgentId'], meta.parentAgentId ?? undefined);
+}
+
+export function subagentSwarmItem(meta: AgentMeta | undefined): string | undefined {
+ if (meta === undefined) return undefined;
+ return firstNonEmpty(meta.labels?.['swarmItem'], meta.swarmItem);
+}
+
+function firstNonEmpty(...values: readonly (string | undefined)[]): string | undefined {
+ return values.find((value) => value !== undefined && value.length > 0);
+}
diff --git a/packages/agent-core-v2/src/session/agentLifecycle/tools/agent.ts b/packages/agent-core-v2/src/session/agentLifecycle/tools/agent.ts
index 1edbad031..7ced2d0b3 100644
--- a/packages/agent-core-v2/src/session/agentLifecycle/tools/agent.ts
+++ b/packages/agent-core-v2/src/session/agentLifecycle/tools/agent.ts
@@ -42,6 +42,7 @@ import { ISessionWorkspaceContext } from '#/session/workspaceContext/workspaceCo
import { IAgentLifecycleService } from '../agentLifecycle';
import { emitAgentRunSpawned, mirrorAgentRun } from '../mirrorAgentRun';
+import { subagentLabels } from '../subagentMetadata';
import { SubagentTask, type SubagentHandle } from './subagent-task';
import AGENT_BACKGROUND_DISABLED_DESCRIPTION from './agent-background-disabled.md?raw';
@@ -253,6 +254,7 @@ export class AgentTool implements BuiltinTool {
cwd: own.cwd,
},
permissionMode: this.permissionMode.mode,
+ labels: subagentLabels(this.callerAgentId),
});
agentId = created.id;
profileName = profile.name;
diff --git a/packages/agent-core-v2/src/session/sessionMetadata/sessionMetadata.ts b/packages/agent-core-v2/src/session/sessionMetadata/sessionMetadata.ts
index be89f7807..27417b9f4 100644
--- a/packages/agent-core-v2/src/session/sessionMetadata/sessionMetadata.ts
+++ b/packages/agent-core-v2/src/session/sessionMetadata/sessionMetadata.ts
@@ -24,6 +24,10 @@ export interface AgentMeta {
* verbatim. Never interpreted by the lifecycle.
*/
readonly labels?: Readonly>;
+ /** @deprecated Legacy v1 field; read-compat only. */
+ readonly type?: 'main' | 'sub';
+ /** @deprecated Legacy v1 field; read-compat only. */
+ readonly parentAgentId?: string | null;
/** @deprecated Legacy on-disk field predating `labels`; read-compat only. */
readonly swarmItem?: string;
}
diff --git a/packages/agent-core-v2/src/session/swarm/sessionSwarm.ts b/packages/agent-core-v2/src/session/swarm/sessionSwarm.ts
index 2adbc76a9..b092b2cfe 100644
--- a/packages/agent-core-v2/src/session/swarm/sessionSwarm.ts
+++ b/packages/agent-core-v2/src/session/swarm/sessionSwarm.ts
@@ -55,6 +55,10 @@ export interface SessionSwarmRunResult {
export interface ISessionSwarmService {
readonly _serviceBrand: undefined;
+ getSwarmItem(args: {
+ readonly callerAgentId: string;
+ readonly agentId: string;
+ }): Promise;
run(args: SessionSwarmRunArgs): Promise[]>;
cancel(args: { readonly callerAgentId: string }): void;
}
diff --git a/packages/agent-core-v2/src/session/swarm/sessionSwarmService.ts b/packages/agent-core-v2/src/session/swarm/sessionSwarmService.ts
index 70ed3eb4d..2e73f071c 100644
--- a/packages/agent-core-v2/src/session/swarm/sessionSwarmService.ts
+++ b/packages/agent-core-v2/src/session/swarm/sessionSwarmService.ts
@@ -27,7 +27,14 @@ import { IAgentProfileCatalogService } from '#/app/agentProfileCatalog/agentProf
import { applyProfilePromptPrefix } from '#/app/agentProfileCatalog/promptPrefix';
import { IAgentLifecycleService } from '#/session/agentLifecycle/agentLifecycle';
import { emitAgentRunSpawned, mirrorAgentRun } from '#/session/agentLifecycle/mirrorAgentRun';
+import {
+ isSubagentMeta,
+ subagentLabels,
+ subagentParentAgentId,
+ subagentSwarmItem,
+} from '#/session/agentLifecycle/subagentMetadata';
import { ISessionContext } from '#/session/sessionContext/sessionContext';
+import { ISessionMetadata, type AgentMeta } from '#/session/sessionMetadata/sessionMetadata';
import { ISessionProcessRunner } from '#/session/process/processRunner';
import { ILogService } from '#/_base/log/log';
@@ -67,10 +74,21 @@ export class SessionSwarmService implements ISessionSwarmService {
@IAgentLifecycleService private readonly lifecycle: IAgentLifecycleService,
@IAgentProfileCatalogService private readonly catalog: IAgentProfileCatalogService,
@ISessionContext private readonly sessionContext: ISessionContext,
+ @ISessionMetadata private readonly metadata: ISessionMetadata,
@ISessionProcessRunner private readonly processRunner: ISessionProcessRunner,
@ILogService private readonly log: ILogService,
) {}
+ async getSwarmItem(args: {
+ readonly callerAgentId: string;
+ readonly agentId: string;
+ }): Promise {
+ const meta = await this.agentMeta(args.agentId);
+ if (!isSubagentMeta(meta)) return undefined;
+ if (subagentParentAgentId(meta) !== args.callerAgentId) return undefined;
+ return subagentSwarmItem(meta);
+ }
+
run(args: SessionSwarmRunArgs): Promise[]> {
const { callerAgentId, tasks } = args;
const controller = new AbortController();
@@ -131,7 +149,7 @@ export class SessionSwarmService implements ISessionSwarmService {
cwd: callerData.cwd,
},
permissionMode: caller.accessor.get(IAgentPermissionModeService).mode,
- labels: options.swarmItem === undefined ? undefined : { swarmItem: options.swarmItem },
+ labels: subagentLabels(callerAgentId, { swarmItem: options.swarmItem }),
});
emitAgentRunSpawned(caller, child.id, {
profileName: options.profileName,
@@ -159,6 +177,7 @@ export class SessionSwarmService implements ISessionSwarmService {
retryTurn: boolean,
): Promise {
options.signal.throwIfAborted();
+ await this.requireOwnedSubagent(callerAgentId, agentId);
const caller = this.requireHandle(callerAgentId, 'Caller agent');
const child = this.requireHandle(agentId, 'Agent instance');
const profileName =
@@ -206,6 +225,21 @@ export class SessionSwarmService implements ISessionSwarmService {
if (handle === undefined) throw new Error(`${label} "${agentId}" does not exist`);
return handle;
}
+
+ private async requireOwnedSubagent(callerAgentId: string, agentId: string): Promise {
+ const meta = await this.agentMeta(agentId);
+ if (!isSubagentMeta(meta)) {
+ throw new Error(`Agent instance "${agentId}" is not a subagent`);
+ }
+ if (subagentParentAgentId(meta) !== callerAgentId) {
+ throw new Error(`Agent instance "${agentId}" does not belong to this parent agent`);
+ }
+ }
+
+ private async agentMeta(agentId: string): Promise {
+ const meta = await this.metadata.read();
+ return meta.agents?.[agentId];
+ }
}
// Kept as a type-anchor so future maintenance imports the usage shape from here.
diff --git a/packages/agent-core-v2/test/harness/agent.ts b/packages/agent-core-v2/test/harness/agent.ts
index cb5b88b0e..e5e893651 100644
--- a/packages/agent-core-v2/test/harness/agent.ts
+++ b/packages/agent-core-v2/test/harness/agent.ts
@@ -157,6 +157,7 @@ const TEST_HOME_DIR = '/home/test';
const MOCK_PROVIDER = {
type: 'kimi',
apiKey: 'test-key',
+ baseUrl: 'https://api.example.test/v1',
model: 'mock-model',
} as const;
@@ -639,9 +640,20 @@ function createSessionSkillCatalog(catalog: SkillCatalog): ISessionSkillCatalog
};
}
-export function swarmServices(swarmService: ISessionSwarmService): TestAgentServiceOverride {
+export function swarmServices(
+ swarmService: ISessionSwarmService | ISessionSwarmService['run'],
+): TestAgentServiceOverride {
+ const service =
+ typeof swarmService === 'function'
+ ? {
+ _serviceBrand: undefined,
+ getSwarmItem: async () => undefined,
+ run: swarmService,
+ cancel: () => {},
+ } satisfies ISessionSwarmService
+ : swarmService;
return [
- sessionService(ISessionSwarmService, swarmService),
+ sessionService(ISessionSwarmService, service),
agentService(IAgentSwarmService, new SyncDescriptor(AgentSwarmService)),
];
}
diff --git a/packages/agent-core-v2/test/swarm/sessionSwarm.test.ts b/packages/agent-core-v2/test/swarm/sessionSwarm.test.ts
index da6566d7d..ba7a2cd88 100644
--- a/packages/agent-core-v2/test/swarm/sessionSwarm.test.ts
+++ b/packages/agent-core-v2/test/swarm/sessionSwarm.test.ts
@@ -1,5 +1,31 @@
-import { describe, expect, it, vi } from 'vitest';
+import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
+import type { IAgentScopeHandle } from '#/_base/di/scope';
+import { LifecycleScope } from '#/_base/di/scope';
+import { SyncDescriptor } from '#/_base/di/descriptors';
+import { DisposableStore } from '#/_base/di/lifecycle';
+import { TestInstantiationService } from '#/_base/di/test';
+import { Event } from '#/_base/event';
+import { IAgentPermissionModeService } from '#/agent/permissionMode/permissionMode';
+import { IAgentProfileService, type ProfileData } from '#/agent/profile/profile';
+import { IEventBus, type DomainEvent } from '#/app/event/eventBus';
+import { IAgentProfileCatalogService } from '#/app/agentProfileCatalog/agentProfileCatalog';
+import { ITelemetryService, noopTelemetryService } from '#/app/telemetry/telemetry';
+import {
+ IAgentLifecycleService,
+ type AgentTaskHooks,
+ type CreateAgentOptions,
+} from '#/session/agentLifecycle/agentLifecycle';
+import { labelsFromAgentMeta } from '#/session/agentLifecycle/subagentMetadata';
+import { createHooks } from '#/hooks';
+import { ISessionContext, makeSessionContext } from '#/session/sessionContext/sessionContext';
+import {
+ ISessionMetadata,
+ type AgentMeta,
+ type SessionMetadataChangedEvent,
+} from '#/session/sessionMetadata/sessionMetadata';
+import { ISessionProcessRunner } from '#/session/process/processRunner';
+import { ILogService } from '#/_base/log/log';
import {
AgentRunBatch,
resolveSwarmMaxConcurrency,
@@ -7,6 +33,10 @@ import {
type AgentSpawnAttemptOptions,
type QueuedAgentRunTask,
} from '#/session/swarm/agentRunBatch';
+import { ISessionSwarmService, type SessionSwarmTask } from '#/session/swarm/sessionSwarm';
+import { SessionSwarmService } from '#/session/swarm/sessionSwarmService';
+
+import { stubLog } from '../log/stubs';
describe('resolveSwarmMaxConcurrency', () => {
it('returns undefined when the variable is unset', () => {
@@ -94,3 +124,323 @@ describe('AgentRunBatch swarm item forwarding', () => {
expect(spawned[0]?.swarmItem).toBeUndefined();
});
});
+
+describe('SessionSwarmService metadata compatibility', () => {
+ let disposables: DisposableStore;
+ let ix: TestInstantiationService;
+ let agents: Record;
+ let handles: Map;
+ let lifecycle: IAgentLifecycleService;
+ let createAgent: ReturnType;
+ let runAgent: ReturnType;
+
+ beforeEach(() => {
+ disposables = new DisposableStore();
+ ix = disposables.add(new TestInstantiationService());
+ agents = {};
+ handles = new Map();
+ const eventBus = eventBusStub();
+ lifecycle = lifecycleStub(handles, eventBus);
+ createAgent = lifecycle.create as ReturnType;
+ runAgent = lifecycle.run as ReturnType;
+ handles.set('main', agentHandle('main', lifecycle, eventBus));
+
+ ix.stub(IAgentLifecycleService, lifecycle);
+ ix.stub(IAgentProfileCatalogService, {
+ _serviceBrand: undefined,
+ get: (name: string) =>
+ name === 'coder'
+ ? { name: 'coder', tools: [], systemPrompt: () => '' }
+ : undefined,
+ getDefault: () => ({ name: 'agent', tools: [], systemPrompt: () => '' }),
+ list: () => [],
+ });
+ ix.stub(
+ ISessionContext,
+ makeSessionContext({
+ sessionId: 's1',
+ workspaceId: 'w1',
+ sessionDir: '/tmp/kimi/s1',
+ sessionScope: 'sessions/w1/s1',
+ cwd: '/repo',
+ }),
+ );
+ ix.stub(ISessionMetadata, {
+ _serviceBrand: undefined,
+ ready: Promise.resolve(),
+ onDidChangeMetadata: Event.None as Event,
+ read: async () => ({
+ id: 's1',
+ createdAt: 0,
+ updatedAt: 0,
+ archived: false,
+ agents,
+ }),
+ update: async () => {},
+ setTitle: async () => {},
+ setArchived: async () => {},
+ registerAgent: async (agentId, meta) => {
+ agents[agentId] = meta;
+ },
+ });
+ ix.stub(ISessionProcessRunner, {
+ _serviceBrand: undefined,
+ exec: async () => {
+ throw new Error('unexpected process exec');
+ },
+ });
+ ix.stub(ILogService, stubLog());
+ ix.set(ISessionSwarmService, new SyncDescriptor(SessionSwarmService));
+ });
+
+ afterEach(() => {
+ disposables.dispose();
+ });
+
+ it('reads swarm items from caller-owned v2 labels and legacy v1 metadata', async () => {
+ agents['v2-child'] = {
+ homedir: '/tmp/kimi/s1/agents/v2-child',
+ labels: { parentAgentId: 'main', swarmItem: 'src/a.ts' },
+ };
+ agents['legacy-child'] = {
+ homedir: '/tmp/kimi/s1/agents/legacy-child',
+ type: 'sub',
+ parentAgentId: 'main',
+ swarmItem: 'src/legacy.ts',
+ };
+ agents['other-child'] = {
+ homedir: '/tmp/kimi/s1/agents/other-child',
+ labels: { parentAgentId: 'other', swarmItem: 'src/other.ts' },
+ };
+
+ const service = ix.get(ISessionSwarmService);
+
+ await expect(
+ service.getSwarmItem({ callerAgentId: 'main', agentId: 'v2-child' }),
+ ).resolves.toBe('src/a.ts');
+ await expect(
+ service.getSwarmItem({ callerAgentId: 'main', agentId: 'legacy-child' }),
+ ).resolves.toBe('src/legacy.ts');
+ await expect(
+ service.getSwarmItem({ callerAgentId: 'main', agentId: 'other-child' }),
+ ).resolves.toBeUndefined();
+ await expect(
+ service.getSwarmItem({ callerAgentId: 'main', agentId: 'missing' }),
+ ).resolves.toBeUndefined();
+ });
+
+ it('prefers labels over legacy metadata fields when both are present', async () => {
+ agents['mixed-child'] = {
+ homedir: '/tmp/kimi/s1/agents/mixed-child',
+ labels: { parentAgentId: 'main', swarmItem: 'src/labels.ts' },
+ type: 'sub',
+ parentAgentId: 'other',
+ swarmItem: 'src/legacy.ts',
+ };
+
+ const service = ix.get(ISessionSwarmService);
+
+ await expect(
+ service.getSwarmItem({ callerAgentId: 'main', agentId: 'mixed-child' }),
+ ).resolves.toBe('src/labels.ts');
+ await expect(
+ service.getSwarmItem({ callerAgentId: 'other', agentId: 'mixed-child' }),
+ ).resolves.toBeUndefined();
+ });
+
+ it('normalizes legacy subagent metadata into labels for new writes', () => {
+ expect(
+ labelsFromAgentMeta({
+ homedir: '/tmp/kimi/s1/agents/legacy-child',
+ type: 'sub',
+ parentAgentId: 'main',
+ swarmItem: 'src/legacy.ts',
+ }),
+ ).toEqual({ parentAgentId: 'main', swarmItem: 'src/legacy.ts' });
+ expect(
+ labelsFromAgentMeta({
+ homedir: '/tmp/kimi/s1/agents/mixed-child',
+ labels: { parentAgentId: 'main', swarmItem: 'src/labels.ts', custom: 'kept' },
+ type: 'sub',
+ parentAgentId: 'other',
+ swarmItem: 'src/legacy.ts',
+ }),
+ ).toEqual({ parentAgentId: 'main', swarmItem: 'src/labels.ts', custom: 'kept' });
+ });
+
+ it('persists caller ownership and swarm item labels on spawned children', async () => {
+ const service = ix.get(ISessionSwarmService);
+
+ await expect(
+ service.run({
+ callerAgentId: 'main',
+ tasks: [spawnSessionTask('src/a.ts')],
+ }),
+ ).resolves.toMatchObject([
+ {
+ agentId: 'agent-new',
+ status: 'completed',
+ result: 'child summary',
+ },
+ ]);
+
+ expect(createAgent).toHaveBeenCalledWith(
+ expect.objectContaining({
+ binding: {
+ profile: 'coder',
+ model: 'kimi-test',
+ thinking: 'medium',
+ cwd: '/repo',
+ },
+ permissionMode: 'auto',
+ labels: { parentAgentId: 'main', swarmItem: 'src/a.ts' },
+ }),
+ );
+ });
+
+ it('keeps v1 resume ownership errors inside the per-subagent result', async () => {
+ agents['other-child'] = {
+ homedir: '/tmp/kimi/s1/agents/other-child',
+ labels: { parentAgentId: 'other', swarmItem: 'src/other.ts' },
+ };
+ handles.set('other-child', agentHandle('other-child', lifecycle, eventBusStub()));
+ const service = ix.get(ISessionSwarmService);
+
+ await expect(
+ service.run({
+ callerAgentId: 'main',
+ tasks: [resumeSessionTask('other-child')],
+ }),
+ ).resolves.toMatchObject([
+ {
+ status: 'failed',
+ state: 'not_started',
+ error: 'Agent instance "other-child" does not belong to this parent agent',
+ },
+ ]);
+ expect(runAgent).not.toHaveBeenCalled();
+ });
+});
+
+function spawnSessionTask(swarmItem?: string): SessionSwarmTask {
+ return {
+ kind: 'spawn',
+ data: {},
+ profileName: 'coder',
+ parentToolCallId: 'call_swarm',
+ prompt: 'Review the file',
+ description: 'Review #1 (coder)',
+ swarmIndex: 1,
+ swarmItem,
+ runInBackground: false,
+ };
+}
+
+function resumeSessionTask(agentId: string): SessionSwarmTask {
+ return {
+ kind: 'resume',
+ data: {},
+ profileName: 'subagent',
+ parentToolCallId: 'call_swarm',
+ prompt: 'Continue',
+ description: 'Resume #1 (resume)',
+ swarmIndex: 1,
+ runInBackground: false,
+ resumeAgentId: agentId,
+ };
+}
+
+function lifecycleStub(
+ handles: Map,
+ eventBus: IEventBus,
+): IAgentLifecycleService {
+ const hooks = createHooks([
+ 'onWillStartAgentTask',
+ 'onDidStopAgentTask',
+ ]);
+ const lifecycle = {
+ _serviceBrand: undefined,
+ hooks,
+ onDidCreate: Event.None,
+ onDidCreateMain: Event.None,
+ onDidDispose: Event.None,
+ create: vi.fn(async (opts: CreateAgentOptions = {}) => {
+ const id = opts.agentId ?? 'agent-new';
+ const handle = agentHandle(id, lifecycle as IAgentLifecycleService, eventBus, {
+ profileName: opts.binding?.profile ?? 'coder',
+ modelAlias: opts.binding?.model ?? 'kimi-test',
+ thinkingLevel: opts.binding?.thinking ?? 'medium',
+ cwd: opts.binding?.cwd ?? '/repo',
+ });
+ handles.set(id, handle);
+ return handle;
+ }),
+ ensureMcpReady: async () => {},
+ notifyMainCreated: () => {},
+ fork: vi.fn(),
+ run: vi.fn(async (agentId: string) => ({
+ agentId,
+ turn: {} as never,
+ completion: Promise.resolve({ summary: 'child summary' }),
+ })),
+ getHandle: (agentId: string) => handles.get(agentId),
+ list: () => [...handles.values()],
+ remove: async (agentId: string) => {
+ handles.delete(agentId);
+ },
+ };
+ return lifecycle as IAgentLifecycleService;
+}
+
+function agentHandle(
+ id: string,
+ lifecycle: IAgentLifecycleService,
+ eventBus: IEventBus,
+ data: Partial = {},
+): IAgentScopeHandle {
+ const profile = profileService({
+ cwd: '/repo',
+ modelAlias: 'kimi-test',
+ modelCapabilities: {} as never,
+ profileName: 'agent',
+ thinkingLevel: 'medium',
+ systemPrompt: '',
+ ...data,
+ });
+ const permissionMode = {
+ _serviceBrand: undefined,
+ mode: 'auto',
+ setMode: () => {},
+ hooks: createHooks(['onChanged']),
+ } as IAgentPermissionModeService;
+ return {
+ id,
+ kind: LifecycleScope.Agent,
+ accessor: {
+ get: ((serviceId: unknown) => {
+ if (serviceId === IAgentProfileService) return profile;
+ if (serviceId === IAgentPermissionModeService) return permissionMode;
+ if (serviceId === IEventBus) return eventBus;
+ if (serviceId === ITelemetryService) return noopTelemetryService;
+ if (serviceId === IAgentLifecycleService) return lifecycle;
+ return undefined;
+ }) as IAgentScopeHandle['accessor']['get'],
+ },
+ dispose: () => {},
+ };
+}
+
+function profileService(data: ProfileData): IAgentProfileService {
+ return {
+ _serviceBrand: undefined,
+ data: () => data,
+ } as IAgentProfileService;
+}
+
+function eventBusStub(): IEventBus {
+ return {
+ _serviceBrand: undefined,
+ publish: vi.fn((_: DomainEvent) => {}),
+ subscribe: vi.fn(() => ({ dispose: () => {} })) as IEventBus['subscribe'],
+ };
+}
diff --git a/packages/agent-core-v2/test/swarm/swarm.test.ts b/packages/agent-core-v2/test/swarm/swarm.test.ts
index 7d59f901a..2bfb5219a 100644
--- a/packages/agent-core-v2/test/swarm/swarm.test.ts
+++ b/packages/agent-core-v2/test/swarm/swarm.test.ts
@@ -45,12 +45,15 @@ function context(
function mockSwarmHost({
run = vi.fn().mockResolvedValue([]),
+ getSwarmItem = vi.fn().mockResolvedValue(undefined),
}: {
// eslint-disable-next-line @typescript-eslint/no-explicit-any
readonly run?: (...args: any[]) => any;
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
+ readonly getSwarmItem?: (...args: any[]) => any;
} = {}) {
return {
- swarmService: { _serviceBrand: undefined, run, cancel: vi.fn() },
+ swarmService: { _serviceBrand: undefined, getSwarmItem, run, cancel: vi.fn() },
callerAgentId: 'main',
};
}
@@ -78,7 +81,11 @@ describe('AgentSwarmService', () => {
ix.stub(IAgentTurnService, stubTurnWithHooks());
ix.set(IAgentToolRegistryService, new SyncDescriptor(AgentToolRegistryService));
ix.stub(IAgentLifecycleService, {});
- ix.stub(ISessionSwarmService, { run: async () => [], cancel: () => {} });
+ ix.stub(ISessionSwarmService, {
+ getSwarmItem: async () => undefined,
+ run: async () => [],
+ cancel: () => {},
+ });
ix.stub(IAgentScopeContext, makeAgentScopeContext({ agentId: 'main', agentScope: '' }));
ix.set(IAgentSystemReminderService, new SyncDescriptor(AgentSystemReminderService));
ix.set(IAgentSwarmService, new SyncDescriptor(AgentSwarmService));
@@ -200,6 +207,16 @@ describe('AgentSwarmTool', () => {
subagent_type: { type: 'string' },
},
});
+ expect(
+ (
+ tool.parameters['properties'] as Record<
+ string,
+ { readonly description?: string }
+ >
+ )['subagent_type']?.description,
+ ).toBe(
+ 'Subagent type used for every new subagent spawned from items; defaults to coder when omitted. Resumed subagents always keep their original type, so passing subagent_type together with resume_agent_ids is allowed — it only affects the item-based spawns.',
+ );
expect(Object.keys(tool.parameters['properties'] as Record).at(-1)).toBe(
'resume_agent_ids',
);
@@ -340,38 +357,29 @@ describe('AgentSwarmTool', () => {
});
it('resumes mapped agents before spawning item subagents', async () => {
- let runCallCount = 0;
const run = vi.fn(
async ({
tasks,
}: {
tasks: readonly SessionSwarmTask[];
}): Promise>> => {
- runCallCount++;
return tasks.map((task, index) => ({
task,
- agentId:
- task.kind === 'resume'
- ? task.resumeAgentId
- : runCallCount === 1
- ? `agent-old-${String(index + 1)}`
- : `agent-new-${String(index + 1)}`,
+ agentId: task.kind === 'resume' ? task.resumeAgentId : `agent-new-${String(index + 1)}`,
status: 'completed' as const,
result: `result ${String(index + 1)}`,
}));
},
);
- const host = mockSwarmHost({ run });
- const tool = new AgentSwarmTool(host.swarmService, makeAgentScopeContext({ agentId: host.callerAgentId, agentScope: '' }), mockSwarmMode());
- // Seed the module-level swarm item map so resume_agent_ids can recover the original items.
- await executeTool(
- tool,
- context({
- description: 'Seed swarm items',
- prompt_template: 'Review {{item}}',
- items: ['src/old-a.ts', 'src/old-b.ts'],
- }),
+ const persistedItems: Record = {
+ 'agent-old-1': 'src/old-a.ts',
+ 'agent-old-2': 'src/old-b.ts',
+ };
+ const getSwarmItem = vi.fn(
+ async ({ agentId }: { readonly agentId: string }) => persistedItems[agentId],
);
+ const host = mockSwarmHost({ run, getSwarmItem });
+ const tool = new AgentSwarmTool(host.swarmService, makeAgentScopeContext({ agentId: host.callerAgentId, agentScope: '' }), mockSwarmMode());
const input = {
description: 'Finish review',
subagent_type: 'explore',
@@ -393,6 +401,14 @@ describe('AgentSwarmTool', () => {
const result = await executeTool(tool, context(input));
+ expect(getSwarmItem).toHaveBeenCalledWith({
+ callerAgentId: 'main',
+ agentId: 'agent-old-1',
+ });
+ expect(getSwarmItem).toHaveBeenCalledWith({
+ callerAgentId: 'main',
+ agentId: 'agent-old-2',
+ });
expect(host.swarmService.run).toHaveBeenCalledWith(expect.objectContaining({ tasks: [
{
kind: 'resume',
@@ -467,38 +483,23 @@ describe('AgentSwarmTool', () => {
});
it('allows a single resumed subagent without item subagents', async () => {
- let runCallCount = 0;
const run = vi.fn(
async ({
tasks,
}: {
tasks: readonly SessionSwarmTask[];
}): Promise>> => {
- runCallCount++;
return tasks.map((task, index) => ({
task,
- agentId:
- task.kind === 'resume'
- ? task.resumeAgentId
- : runCallCount === 1
- ? `agent-old-${String(index + 1)}`
- : 'agent-new',
+ agentId: task.kind === 'resume' ? task.resumeAgentId : `agent-new-${String(index + 1)}`,
status: 'completed' as const,
result: 'resumed result',
}));
},
);
- const host = mockSwarmHost({ run });
+ 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());
- // Seed the module-level swarm item map so resume_agent_ids can recover the original item.
- await executeTool(
- tool,
- context({
- description: 'Seed swarm items',
- prompt_template: 'Review {{item}}',
- items: ['src/old-a.ts', 'src/old-b.ts'],
- }),
- );
const input = {
description: 'Resume review',
resume_agent_ids: {
@@ -508,6 +509,10 @@ describe('AgentSwarmTool', () => {
const result = await executeTool(tool, context(input));
+ expect(getSwarmItem).toHaveBeenCalledWith({
+ callerAgentId: 'main',
+ agentId: 'agent-old-1',
+ });
expect(host.swarmService.run).toHaveBeenCalledWith(expect.objectContaining({ tasks: [
{
kind: 'resume',
diff --git a/packages/agent-core-v2/test/turn/turn.test.ts b/packages/agent-core-v2/test/turn/turn.test.ts
index a745d7775..445d50ed3 100644
--- a/packages/agent-core-v2/test/turn/turn.test.ts
+++ b/packages/agent-core-v2/test/turn/turn.test.ts
@@ -358,7 +358,7 @@ describe('Agent turn flow', () => {
await ctx.expectResumeMatches();
});
- it('removes a replayed swarm enter reminder when restoring swarm exit', async () => {
+ it('restores swarm exit without synthesizing reminder cleanup records', async () => {
const ctx = testAgent();
const enterReminder: ContextMessage = {
role: 'user',
@@ -384,7 +384,7 @@ describe('Agent turn flow', () => {
]);
expect(ctx.get(IAgentSwarmService).isActive).toBe(false);
- expect(ctx.contextData().history).toEqual([]);
+ expect(ctx.contextData().history).toEqual([enterReminder]);
expect(ctx.newEvents()).toMatchInlineSnapshot(`
[wire] swarm_mode.enter { "trigger": "manual" }
[wire] context.splice { "start": 0, "deleteCount": 0, "messages": [ { "role": "user", "content": [ { "type": "text", "text": "\\nlegacy swarm enter reminder\\n" } ], "toolCalls": [], "origin": { "kind": "injection", "variant": "swarm_mode" } } ] }