From 69df7998104b10cdc88c3534a97caea8dee19f8a Mon Sep 17 00:00:00 2001 From: _Kerman Date: Wed, 8 Jul 2026 15:11:15 +0800 Subject: [PATCH] fix(agent-core-v2): preserve swarm metadata compatibility --- .../agent-core-v2/docs/di-scope-domains.puml | 8 + .../agent-core-v2/docs/di-scope-domains.svg | 2 +- .../src/agent/swarm/tools/agent-swarm.ts | 29 +- .../sessionLifecycleService.ts | 8 +- .../agentLifecycle/subagentMetadata.ts | 55 +++ .../src/session/agentLifecycle/tools/agent.ts | 2 + .../sessionMetadata/sessionMetadata.ts | 4 + .../src/session/swarm/sessionSwarm.ts | 4 + .../src/session/swarm/sessionSwarmService.ts | 36 +- packages/agent-core-v2/test/harness/agent.ts | 16 +- .../test/swarm/sessionSwarm.test.ts | 352 +++++++++++++++++- .../agent-core-v2/test/swarm/swarm.test.ts | 81 ++-- packages/agent-core-v2/test/turn/turn.test.ts | 4 +- 13 files changed, 534 insertions(+), 67 deletions(-) create mode 100644 packages/agent-core-v2/src/session/agentLifecycle/subagentMetadata.ts 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 @@ -App scope (process-wide)Session scope (per session)Agent scope (per agent)bootstrapAppIBootstrapServicelog_base · App bindingILogServicetelemetryAppITelemetryServiceeventAppIEventServicestorageAppIFileSystemStorageServiceIAppendLogStoreIAtomicDocumentStoreIAtomicTomlDocumentStorefilestoreAppIFileStoregatewayAppIRestGatewayIWSGatewaysessionLifecycleAppISessionLifecycleServicesessionExportAppISessionExportServicesessionIndexAppISessionIndexhostFsAppIHostFileSystemworkspaceRegistryAppIWorkspaceRegistryworkspaceLocalConfigAppIWorkspaceLocalConfigServicehostFolderBrowserAppIHostFolderBrowserhostEnvironmentAppIHostEnvironmenthostProcessAppIHostProcessServiceauthAppIOAuthServiceIAuthSummaryServiceIWebSearchProviderServicewebAppIWebFetchServiceeditAppIFileEditServiceproviderAppIProviderServiceplatformAppIPlatformServiceflagAppIFlagServiceIFlagRegistryconfigAppIConfigRegistryIConfigServicepluginAppIPluginServicechatProviderAppIChatProviderFactorymodelAppIModelServicemodelCatalogAppIModelCatalogServiceskillCatalogAppISkillDiscoveryISkillSourceBuiltin/UserSkillSourcesessionContextSessionISessionContext (seed)sessionMetadataSessionISessionMetadatasessionActivitySessionISessionActivityagentLifecycleSessionIAgentLifecycleServiceinteractionSessionIInteractionServiceworkspaceContextSessionIWorkspaceContextworkspaceCommandSessionISessionWorkspaceCommandServicesessionLogSession bindingILogServicesessionSkillCatalogSessionISessionSkillCatalogISkillCatalogSinkWorkspace/PluginSkillSourceexternalHooksSessionISessionExternalHooksServicesessionFsSessionISessionFsServiceapprovalSessionIApprovalServicequestionSessionIQuestionServiceprocessSessionIProcessRunnerIProcessterminalAppIHostTerminalServicesessionTerminalSessionISessionTerminalServicemodelProviderSessionIModelProvider (seed)todoSessionISessionTodoServicewireRecordAgentIAgentWireRecordService (event hub)wireAgentIAgentWireServiceblobStoreAgentIAgentBlobStoreServicecontextMemoryAgentIAgentContextMemoryServicecontextProjectorAgentIAgentContextProjectorServicecontextInjectorAgentIAgentContextInjectorServicecontextSizeAgentIAgentContextSizeServicesystemReminderAgentIAgentSystemReminderServiceprofileAgentIAgentProfileServicepromptAgentIAgentPromptServicepromptLegacyAgentIAgentPromptLegacyServiceturnAgentIAgentTurnServiceloopAgentIAgentLoopServicellmRequesterAgentIAgentLLMRequesterServicetoolRegistryAgentIAgentToolRegistryServicetoolExecutorAgentIAgentToolExecutorServicetoolDedupeAgentIAgentToolDedupeServicepermissionGateAgentIAgentPermissionGatepermissionModeAgentIAgentPermissionModeServicepermissionPolicyAgentIAgentPermissionPolicyServicepermissionRulesAgentIAgentPermissionRulesServiceplanAgentIAgentPlanServicegoalAgentIAgentGoalServiceskillAgentIAgentSkillServicequestionToolsAgentIAgentQuestionToolsServiceuserToolAgentIAgentUserToolServicetaskAgentIAgentTaskServicecronAgentIAgentCronServiceswarmAgentIAgentSwarmServicemcpAgentIAgentMcpServicefullCompactionAgentIAgentFullCompactionServicemicroCompactionAgentIAgentMicroCompactionServiceexternalHooksAgentIAgentExternalHooksServiceusageAgentIAgentUsageServicerpcAgentIAgentRPCServicefileToolsAgentRead/Write/Grep/Glob toolseditAgentEditToolshellToolsAgentIAgentShellToolsServicescopeContextAgentIAgentScopeContext (seed)environmenteventSinkonDidChangeConfigurationcontext_size.measuredconfig.update / tools.set_active_toolsagent.status.updated / warningturn.launchhooks.onSplicedpermission.set_modepermission.rules.add / record_approval_resultplan_mode.enter/cancel/exitgoal.create/update/clearturn lifecycle hooksstep/usage hooksskill.activatetools.register_/unregister_user_toolhooks.onSplicedcron.add / delete / cursorswarm_mode.enter/exithooks.onErrormicro_compaction.applySessionStart/End hooksSubagentStart/Stop (observes run hooks)usage.recordcontext.splicetodo.set (main agent)full_compaction.begin/cancel/completehook.result / goal.updatedhooks.onEndedtask.started/terminatedonWillExecuteTool / onDidExecuteToolapproval hooksprompt/end hooksstop hookcompaction hooksnotification hookhooks.onResumeEndedScope = node color      App (process-wide)      Session (per session)      Agent (per agent)Edgessolid: DI injection (ctor @IX)dashed: event-driven (subscribe/emit)direction: consumer ---> providerNotesGenerated from `node scripts/dep-graph.mjs` output;`_base` / seed / options deps are omitted. \ No newline at end of file +App scope (process-wide)Session scope (per session)Agent scope (per agent)bootstrapAppIBootstrapServicelog_base · App bindingILogServicetelemetryAppITelemetryServiceeventAppIEventServicestorageAppIFileSystemStorageServiceIAppendLogStoreIAtomicDocumentStoreIAtomicTomlDocumentStorefilestoreAppIFileStoregatewayAppIRestGatewayIWSGatewaysessionLifecycleAppISessionLifecycleServicesessionExportAppISessionExportServicesessionIndexAppISessionIndexhostFsAppIHostFileSystemworkspaceRegistryAppIWorkspaceRegistryworkspaceLocalConfigAppIWorkspaceLocalConfigServicehostFolderBrowserAppIHostFolderBrowserhostEnvironmentAppIHostEnvironmenthostProcessAppIHostProcessServiceauthAppIOAuthServiceIAuthSummaryServiceIWebSearchProviderServicewebAppIWebFetchServiceeditAppIFileEditServiceproviderAppIProviderServiceplatformAppIPlatformServiceflagAppIFlagServiceIFlagRegistryconfigAppIConfigRegistryIConfigServicepluginAppIPluginServicechatProviderAppIChatProviderFactorymodelAppIModelServicemodelCatalogAppIModelCatalogServiceagentProfileCatalogAppIAgentProfileCatalogServiceskillCatalogAppISkillDiscoveryISkillSourceBuiltin/UserSkillSourcesessionContextSessionISessionContext (seed)sessionMetadataSessionISessionMetadatasessionActivitySessionISessionActivityagentLifecycleSessionIAgentLifecycleServicesessionSwarmSessionISessionSwarmServiceinteractionSessionIInteractionServiceworkspaceContextSessionIWorkspaceContextworkspaceCommandSessionISessionWorkspaceCommandServicesessionLogSession bindingILogServicesessionSkillCatalogSessionISessionSkillCatalogISkillCatalogSinkWorkspace/PluginSkillSourceexternalHooksSessionISessionExternalHooksServicesessionFsSessionISessionFsServiceapprovalSessionIApprovalServicequestionSessionIQuestionServiceprocessSessionIProcessRunnerIProcessterminalAppIHostTerminalServicesessionTerminalSessionISessionTerminalServicemodelProviderSessionIModelProvider (seed)todoSessionISessionTodoServicewireRecordAgentIAgentWireRecordService (event hub)wireAgentIAgentWireServiceblobStoreAgentIAgentBlobStoreServicecontextMemoryAgentIAgentContextMemoryServicecontextProjectorAgentIAgentContextProjectorServicecontextInjectorAgentIAgentContextInjectorServicecontextSizeAgentIAgentContextSizeServicesystemReminderAgentIAgentSystemReminderServiceprofileAgentIAgentProfileServicepromptAgentIAgentPromptServicepromptLegacyAgentIAgentPromptLegacyServiceturnAgentIAgentTurnServiceloopAgentIAgentLoopServicellmRequesterAgentIAgentLLMRequesterServicetoolRegistryAgentIAgentToolRegistryServicetoolExecutorAgentIAgentToolExecutorServicetoolDedupeAgentIAgentToolDedupeServicepermissionGateAgentIAgentPermissionGatepermissionModeAgentIAgentPermissionModeServicepermissionPolicyAgentIAgentPermissionPolicyServicepermissionRulesAgentIAgentPermissionRulesServiceplanAgentIAgentPlanServicegoalAgentIAgentGoalServiceskillAgentIAgentSkillServicequestionToolsAgentIAgentQuestionToolsServiceuserToolAgentIAgentUserToolServicetaskAgentIAgentTaskServicecronAgentIAgentCronServiceswarmAgentIAgentSwarmServicemcpAgentIAgentMcpServicefullCompactionAgentIAgentFullCompactionServicemicroCompactionAgentIAgentMicroCompactionServiceexternalHooksAgentIAgentExternalHooksServiceusageAgentIAgentUsageServicerpcAgentIAgentRPCServicefileToolsAgentRead/Write/Grep/Glob toolseditAgentEditToolshellToolsAgentIAgentShellToolsServicescopeContextAgentIAgentScopeContext (seed)environmentrecordeventSinkonDidChangeConfigurationcontext_size.measuredconfig.update / tools.set_active_toolsagent.status.updated / warningturn.launchhooks.onSplicedpermission.set_modepermission.rules.add / record_approval_resultgoal.create/update/clearturn lifecycle hooksstep/usage hooksskill.activatetools.register_/unregister_user_toolhooks.onSplicedcron.add / delete / cursorswarm_mode.enter/exithooks.onErrormicro_compaction.applySessionStart/End hooksSubagentStart/Stop (observes run hooks)usage.recordcontext.splicetodo.set (main agent)full_compaction.begin/cancel/completehook.result / goal.updatedhooks.onEndedtask.started/terminatedonWillExecuteTool / onDidExecuteToolapproval hooksprompt/end hooksstop hookcompaction hooksnotification hookhooks.onResumeEndedplan_mode.enter/cancel/exitScope = node color      App (process-wide)      Session (per session)      Agent (per agent)Edgessolid: DI injection (ctor @IX)dashed: event-driven (subscribe/emit)direction: consumer ---> providerNotesGenerated from `node scripts/dep-graph.mjs` output;`_base` / seed / options deps are omitted. 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" } } ] }