mirror of
https://github.com/MoonshotAI/kimi-code.git
synced 2026-07-10 01:39:25 +00:00
fix(agent-core-v2): preserve swarm metadata compatibility
This commit is contained in:
parent
19f90dff27
commit
69df799810
13 changed files with 534 additions and 67 deletions
|
|
@ -46,6 +46,7 @@ package "App scope (process-wide)" #EAF3FB {
|
|||
rectangle "<b>chatProvider</b>\n<size:9><i>App</i></size>\n IChatProviderFactory" as chatProvider #D6EAF8
|
||||
rectangle "<b>model</b>\n<size:9><i>App</i></size>\n IModelService" as model #D6EAF8
|
||||
rectangle "<b>modelCatalog</b>\n<size:9><i>App</i></size>\n IModelCatalogService" as modelCatalog #D6EAF8
|
||||
rectangle "<b>agentProfileCatalog</b>\n<size:9><i>App</i></size>\n IAgentProfileCatalogService" as agentProfileCatalog #D6EAF8
|
||||
rectangle "<b>skillCatalog</b>\n<size:9><i>App</i></size>\n ISkillDiscovery\n ISkillSource\n Builtin/UserSkillSource" as skillCatalog #D6EAF8
|
||||
}
|
||||
|
||||
|
|
@ -54,6 +55,7 @@ package "Session scope (per session)" #EAFAF1 {
|
|||
rectangle "<b>sessionMetadata</b>\n<size:9><i>Session</i></size>\n ISessionMetadata" as session_metadata #D5F5E3
|
||||
rectangle "<b>sessionActivity</b>\n<size:9><i>Session</i></size>\n ISessionActivity" as session_activity #D5F5E3
|
||||
rectangle "<b>agentLifecycle</b>\n<size:9><i>Session</i></size>\n IAgentLifecycleService" as agent_lifecycle #D5F5E3
|
||||
rectangle "<b>sessionSwarm</b>\n<size:9><i>Session</i></size>\n ISessionSwarmService" as sessionSwarm #D5F5E3
|
||||
rectangle "<b>interaction</b>\n<size:9><i>Session</i></size>\n IInteractionService" as interaction #D5F5E3
|
||||
rectangle "<b>workspaceContext</b>\n<size:9><i>Session</i></size>\n IWorkspaceContext" as workspaceContext #D5F5E3
|
||||
rectangle "<b>workspaceCommand</b>\n<size:9><i>Session</i></size>\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
|
||||
|
|
|
|||
File diff suppressed because one or more lines are too long
|
Before Width: | Height: | Size: 270 KiB After Width: | Height: | Size: 273 KiB |
|
|
@ -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<string, string>();
|
||||
|
||||
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<AgentSwarmToolInput> {
|
|||
toolCallId: string,
|
||||
): Promise<string> {
|
||||
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<AgentSwarmSpec>[] = specs.map((spec) => {
|
||||
const descriptionName = spec.kind === 'resume' ? 'resume' : profileName;
|
||||
const common = {
|
||||
|
|
@ -178,11 +178,6 @@ export class AgentSwarmTool implements BuiltinTool<AgentSwarmToolInput> {
|
|||
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<AgentSwarmToolInput> {
|
|||
|
||||
registerTool(AgentSwarmTool);
|
||||
|
||||
function createAgentSwarmSpecs(
|
||||
async function createAgentSwarmSpecs(
|
||||
args: AgentSwarmToolInput,
|
||||
getResumeItem: (agentId: string) => string | undefined,
|
||||
): AgentSwarmSpec[] {
|
||||
getResumeItem: (agentId: string) => Promise<string | undefined>,
|
||||
): Promise<AgentSwarmSpec[]> {
|
||||
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,
|
||||
});
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
|
|
|
|||
|
|
@ -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<Record<string, string>> {
|
||||
const labels: Record<string, string> = { parentAgentId };
|
||||
if (options.swarmItem !== undefined) {
|
||||
labels['swarmItem'] = options.swarmItem;
|
||||
}
|
||||
return labels;
|
||||
}
|
||||
|
||||
export function labelsFromAgentMeta(
|
||||
meta: AgentMeta,
|
||||
): Readonly<Record<string, string>> | undefined {
|
||||
const labels: Record<string, string> = { ...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);
|
||||
}
|
||||
|
|
@ -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<AgentToolInput> {
|
|||
cwd: own.cwd,
|
||||
},
|
||||
permissionMode: this.permissionMode.mode,
|
||||
labels: subagentLabels(this.callerAgentId),
|
||||
});
|
||||
agentId = created.id;
|
||||
profileName = profile.name;
|
||||
|
|
|
|||
|
|
@ -24,6 +24,10 @@ export interface AgentMeta {
|
|||
* verbatim. Never interpreted by the lifecycle.
|
||||
*/
|
||||
readonly labels?: Readonly<Record<string, string>>;
|
||||
/** @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;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -55,6 +55,10 @@ export interface SessionSwarmRunResult<T = unknown> {
|
|||
export interface ISessionSwarmService {
|
||||
readonly _serviceBrand: undefined;
|
||||
|
||||
getSwarmItem(args: {
|
||||
readonly callerAgentId: string;
|
||||
readonly agentId: string;
|
||||
}): Promise<string | undefined>;
|
||||
run<T>(args: SessionSwarmRunArgs<T>): Promise<readonly SessionSwarmRunResult<T>[]>;
|
||||
cancel(args: { readonly callerAgentId: string }): void;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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<string | undefined> {
|
||||
const meta = await this.agentMeta(args.agentId);
|
||||
if (!isSubagentMeta(meta)) return undefined;
|
||||
if (subagentParentAgentId(meta) !== args.callerAgentId) return undefined;
|
||||
return subagentSwarmItem(meta);
|
||||
}
|
||||
|
||||
run<T>(args: SessionSwarmRunArgs<T>): Promise<readonly SessionSwarmRunResult<T>[]> {
|
||||
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<AgentRunAttemptHandle> {
|
||||
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<void> {
|
||||
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<AgentMeta | undefined> {
|
||||
const meta = await this.metadata.read();
|
||||
return meta.agents?.[agentId];
|
||||
}
|
||||
}
|
||||
|
||||
// Kept as a type-anchor so future maintenance imports the usage shape from here.
|
||||
|
|
|
|||
|
|
@ -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)),
|
||||
];
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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<string, AgentMeta>;
|
||||
let handles: Map<string, IAgentScopeHandle>;
|
||||
let lifecycle: IAgentLifecycleService;
|
||||
let createAgent: ReturnType<typeof vi.fn>;
|
||||
let runAgent: ReturnType<typeof vi.fn>;
|
||||
|
||||
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<typeof vi.fn>;
|
||||
runAgent = lifecycle.run as ReturnType<typeof vi.fn>;
|
||||
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<SessionMetadataChangedEvent>,
|
||||
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<string, IAgentScopeHandle>,
|
||||
eventBus: IEventBus,
|
||||
): IAgentLifecycleService {
|
||||
const hooks = createHooks<AgentTaskHooks, keyof AgentTaskHooks>([
|
||||
'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<ProfileData> = {},
|
||||
): 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'],
|
||||
};
|
||||
}
|
||||
|
|
|
|||
|
|
@ -45,12 +45,15 @@ function context<Input>(
|
|||
|
||||
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<string, unknown>).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 <T>({
|
||||
tasks,
|
||||
}: {
|
||||
tasks: readonly SessionSwarmTask<T>[];
|
||||
}): Promise<Array<SessionSwarmRunResult<T>>> => {
|
||||
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<string, string> = {
|
||||
'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 <T>({
|
||||
tasks,
|
||||
}: {
|
||||
tasks: readonly SessionSwarmTask<T>[];
|
||||
}): Promise<Array<SessionSwarmRunResult<T>>> => {
|
||||
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',
|
||||
|
|
|
|||
|
|
@ -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": "<system-reminder>\\nlegacy swarm enter reminder\\n</system-reminder>" } ], "toolCalls": [], "origin": { "kind": "injection", "variant": "swarm_mode" } } ] }
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue