diff --git a/packages/agent-core-v2/src/agent/agentTool/agentTool.ts b/packages/agent-core-v2/src/agent/agentTool/agentTool.ts index 9c4634cb6..c574a0329 100644 --- a/packages/agent-core-v2/src/agent/agentTool/agentTool.ts +++ b/packages/agent-core-v2/src/agent/agentTool/agentTool.ts @@ -35,7 +35,6 @@ import { toInputJsonSchema } from '#/_base/tools/support/input-schema'; import { matchesGlobRuleSubject } from '#/_base/tools/support/rule-match'; import { getChildProfileName, - markChildDetached, resumeChildAgent, retryChildAgent, spawnChildAgent, @@ -126,7 +125,7 @@ export type AgentToolSubagentMap = Readonly { readonly parameters: Record = toInputJsonSchema(AgentToolInputSchema); private readonly lifecycle: IAgentLifecycleService; - private readonly parentAgentId: string; + private readonly callerAgentId: string; private readonly metadata?: ISessionMetadata; private readonly background: IAgentBackgroundService; private readonly log?: ILogger; @@ -153,7 +152,7 @@ export class AgentTool implements BuiltinTool { constructor(options: AgentToolOptions) { this.lifecycle = options.lifecycle; - this.parentAgentId = options.parentAgentId; + this.callerAgentId = options.callerAgentId; this.metadata = options.metadata; this.background = options.background; this.log = options.log; @@ -178,7 +177,6 @@ export class AgentTool implements BuiltinTool { resume: resumeChildAgent, retry: retryChildAgent, getProfileName: getChildProfileName, - markDetached: markChildDetached, } ); } @@ -209,7 +207,7 @@ export class AgentTool implements BuiltinTool { profileName = (await this.run.getProfileName({ lifecycle: this.lifecycle, - parentAgentId: this.parentAgentId, + callerAgentId: this.callerAgentId, metadata: this.metadata, agentId: resumeAgentId, })) ?? 'subagent'; @@ -284,14 +282,14 @@ export class AgentTool implements BuiltinTool { operation === 'resume' ? await this.run.resume({ lifecycle: this.lifecycle, - parentAgentId: this.parentAgentId, + callerAgentId: this.callerAgentId, metadata: this.metadata, agentId: resumeAgentId!, ...runOptions, }) : await this.run.spawn({ lifecycle: this.lifecycle, - parentAgentId: this.parentAgentId, + callerAgentId: this.callerAgentId, metadata: this.metadata, profileName: requestedProfileName ?? 'coder', ...runOptions, @@ -317,15 +315,7 @@ export class AgentTool implements BuiltinTool { signal: runInBackground ? undefined : signal, }; taskId = this.background.registerTask( - new AgentBackgroundTask( - handle, - args.description, - { - markActiveChildDetached: (agentId) => - this.run.markDetached({ parentAgentId: this.parentAgentId, agentId }), - }, - controller, - ), + new AgentBackgroundTask(handle, args.description, controller), registerOptions, ); signal.removeEventListener('abort', abortBeforeRegister); diff --git a/packages/agent-core-v2/src/agent/agentTool/agentToolService.ts b/packages/agent-core-v2/src/agent/agentTool/agentToolService.ts index 87e9b1d4b..602bfc4ac 100644 --- a/packages/agent-core-v2/src/agent/agentTool/agentToolService.ts +++ b/packages/agent-core-v2/src/agent/agentTool/agentToolService.ts @@ -2,7 +2,7 @@ * `agentTool` domain (L5) — registers the `Agent` collaboration tool for an agent. * * Registers the `Agent` tool into the `toolRegistry` so the agent can spawn task - * subagents, bound to this agent as the parent (`parentAgentId` from the agent + * subagents, bound to this agent as the caller (`callerAgentId` from the agent * `scopeContext`). The optional first static `runner` argument is a test seam * (`AgentToolRunOverride`) that lets tests substitute the `runChildAgent` * helpers; the scoped registry supplies none. Bound at Agent scope; reads its @@ -19,7 +19,7 @@ import { IAgentBackgroundService } from '#/agent/background'; import { IAgentProfileService } from '#/agent/profile'; import { IAgentScopeContext } from '#/agent/scopeContext'; import { IAgentToolRegistryService } from '#/agent/toolRegistry'; -import { IKaos } from '#/app/kaos'; +import { IExecContext } from '#/session/execContext'; import { ILogService } from '#/app/log'; import { IAgentLifecycleService } from '#/session/agent-lifecycle'; import { ISessionProcessRunner } from '#/session/process'; @@ -40,7 +40,7 @@ export class AgentToolService extends Disposable implements IAgentToolService { @IAgentToolRegistryService toolRegistry: IAgentToolRegistryService, @IAgentBackgroundService background: IAgentBackgroundService, @IAgentProfileService profile: IAgentProfileService, - @IKaos kaos: IKaos, + @IExecContext execContext: IExecContext, @ISessionProcessRunner processRunner: ISessionProcessRunner, @ILogService log?: ILogService, ) { @@ -49,11 +49,11 @@ export class AgentToolService extends Disposable implements IAgentToolService { toolRegistry.register( new AgentTool({ lifecycle, - parentAgentId: ctx.agentId, + callerAgentId: ctx.agentId, metadata, background, profile, - cwd: kaos.cwd, + cwd: execContext.cwd, processRunner, log, runOverride: runner, diff --git a/packages/agent-core-v2/src/agent/agentTool/runChildAgent.ts b/packages/agent-core-v2/src/agent/agentTool/runChildAgent.ts index 4df2b41ba..60eb078a4 100644 --- a/packages/agent-core-v2/src/agent/agentTool/runChildAgent.ts +++ b/packages/agent-core-v2/src/agent/agentTool/runChildAgent.ts @@ -1,16 +1,16 @@ /** - * `agentTool` domain (L5) — runs a child agent (an ordinary Agent scope) to completion. + * `agentTool` domain (L5) — runs a sub agent (an ordinary Agent scope) to completion. * * Stateless helper module (plain functions, not a class, not a DI service). - * Each function takes the parent `agent-lifecycle`, `parentAgentId`, and optional - * `session-metadata` explicitly, creates or resumes a child agent, mirrors the + * Each function takes the `agent-lifecycle`, the `callerAgentId`, and optional + * `session-metadata` explicitly, creates or resumes a sub agent, mirrors the * way the main agent runs a turn (`prompt` → await the turn result → collect the - * summary + usage), and emits `subagent.*` facts on the parent's event sink. - * Active-child tracking lives in a module-level map keyed by parent agent id so - * `cancelAllChildren` / `markChildDetached` can reach every run. Owns no scoped - * state itself — all durable state lives in the child agent scope. Bound to no - * scope; borrows `event`, `externalHooks`, `telemetry`, `profile`, `prompt`, - * `contextMemory`, `usage`, and `agentTool` through the parent/child accessors. + * summary + usage), and emits `subagent.*` facts on the caller's event sink. + * Owns no scoped state itself — all durable state lives in the sub agent scope, + * and cancellation is the caller's responsibility (via the abort signal it + * passes in). Bound to no scope; borrows `event`, `externalHooks`, `telemetry`, + * `profile`, `prompt`, `contextMemory`, `usage`, and `agentTool` through the + * caller/child accessors. */ import { @@ -56,7 +56,7 @@ const HOOK_TEXT_PREVIEW_LENGTH = 500; export type RunContext = { readonly lifecycle: IAgentLifecycleService; - readonly parentAgentId: string; + readonly callerAgentId: string; readonly metadata?: ISessionMetadata; }; @@ -64,88 +64,66 @@ export type SpawnChildAgentArgs = RunContext & SpawnSubagentOptions; export type ResumeChildAgentArgs = RunContext & { readonly agentId: string } & RunSubagentOptions; export type RetryChildAgentArgs = RunContext & { readonly agentId: string } & RunSubagentOptions; export type GetChildProfileNameArgs = RunContext & { readonly agentId: string }; -export type MarkChildDetachedArgs = { readonly parentAgentId: string; readonly agentId: string }; export type AgentToolRunOverride = { spawn(args: SpawnChildAgentArgs): Promise; resume(args: ResumeChildAgentArgs): Promise; retry(args: RetryChildAgentArgs): Promise; getProfileName(args: GetChildProfileNameArgs): Promise; - markDetached(args: MarkChildDetachedArgs): void; }; -type ActiveChild = { - readonly controller: AbortController; - runInBackground: boolean; -}; - -const activeChildrenByParent = new Map>(); - -function childrenOf(parentAgentId: string): Map { - let children = activeChildrenByParent.get(parentAgentId); - if (children === undefined) { - children = new Map(); - activeChildrenByParent.set(parentAgentId, children); - } - return children; -} - export async function spawnChildAgent(args: SpawnChildAgentArgs): Promise { - const { lifecycle, parentAgentId, metadata: _metadata, ...options } = args; + const { lifecycle, callerAgentId, metadata: _metadata, ...options } = args; options.signal.throwIfAborted(); - const parent = await ensureParent(lifecycle, parentAgentId); + const caller = await requireAgent(lifecycle, callerAgentId); const child = await lifecycle.create({ - parentAgentId, - cwd: parent.accessor.get(IAgentProfileService).data().cwd, - type: 'sub', + forkedFrom: callerAgentId, + cwd: caller.accessor.get(IAgentProfileService).data().cwd, swarmItem: options.swarmItem, }); - configureChild(parent, child, options.profileName); + configureChild(caller, child, options.profileName); ensureAgentTool(child); - emitSpawned(parent, parentAgentId, child.id, options.profileName, options); + emitSpawned(caller, callerAgentId, child.id, options.profileName, options); const completion = runWithActiveChild( - parentAgentId, child, options, - parent, + caller, options.profileName, - (turnRef, controller) => runPromptTurn(child, parent, options, options.profileName, turnRef, controller), + (turnRef, controller) => runPromptTurn(child, caller, options, options.profileName, turnRef, controller), ); return { agentId: child.id, profileName: options.profileName, resumed: false, completion }; } export async function resumeChildAgent(args: ResumeChildAgentArgs): Promise { - const { lifecycle, parentAgentId, metadata, agentId, ...options } = args; + const { lifecycle, callerAgentId, metadata: _metadata, agentId, ...options } = args; options.signal.throwIfAborted(); - const parent = await ensureParent(lifecycle, parentAgentId); - const child = await requireChild(lifecycle, parentAgentId, metadata, agentId); + const caller = await requireAgent(lifecycle, callerAgentId); + const child = await requireAgent(lifecycle, agentId); const profileName = child.accessor.get(IAgentProfileService).data().profileName ?? 'subagent'; - emitSpawned(parent, parentAgentId, child.id, profileName, options); + emitSpawned(caller, callerAgentId, child.id, profileName, options); const completion = runWithActiveChild( - parentAgentId, child, options, - parent, + caller, profileName, - (turnRef, controller) => runPromptTurn(child, parent, options, profileName, turnRef, controller), + (turnRef, controller) => runPromptTurn(child, caller, options, profileName, turnRef, controller), ); return { agentId, profileName, resumed: true, completion }; } export async function retryChildAgent(args: RetryChildAgentArgs): Promise { - const { lifecycle, parentAgentId, metadata, agentId, ...options } = args; + const { lifecycle, callerAgentId, metadata: _metadata, agentId, ...options } = args; options.signal.throwIfAborted(); - const parent = await ensureParent(lifecycle, parentAgentId); - const child = await requireChild(lifecycle, parentAgentId, metadata, agentId); + const caller = await requireAgent(lifecycle, callerAgentId); + const child = await requireAgent(lifecycle, agentId); const profileName = child.accessor.get(IAgentProfileService).data().profileName ?? 'subagent'; - emitSpawned(parent, parentAgentId, child.id, profileName, options); + emitSpawned(caller, callerAgentId, child.id, profileName, options); const completion = runWithActiveChild( - parentAgentId, child, options, - parent, + caller, profileName, - (turnRef, controller) => runRetryTurn(child, parent, options, profileName, turnRef, controller), + (turnRef, controller) => runRetryTurn(child, caller, options, profileName, turnRef, controller), ); return { agentId, profileName, resumed: true, completion }; } @@ -153,63 +131,20 @@ export async function retryChildAgent(args: RetryChildAgentArgs): Promise { - const { lifecycle, parentAgentId, metadata, agentId } = args; - if (metadata !== undefined) { - const meta = (await metadata.read()).agents?.[agentId]; - if (meta?.type !== 'sub' || meta.parentAgentId !== parentAgentId) return undefined; - } + const { lifecycle, agentId } = args; const child = lifecycle.getHandle(agentId); if (child === undefined) return undefined; return child.accessor.get(IAgentProfileService).data().profileName; } -export function markChildDetached({ parentAgentId, agentId }: MarkChildDetachedArgs): void { - const child = activeChildrenByParent.get(parentAgentId)?.get(agentId); - if (child !== undefined) child.runInBackground = true; -} - -export function cancelAllChildren( - parentAgentId: string, - reason: unknown = userCancellationReason(), -): void { - const children = activeChildrenByParent.get(parentAgentId); - if (children === undefined) return; - for (const [, child] of children) { - if (child.runInBackground) continue; - child.controller.abort(reason); - } -} - -async function ensureParent( +async function requireAgent( lifecycle: IAgentLifecycleService, - parentAgentId: string, -): Promise { - const existing = lifecycle.getHandle(parentAgentId); - if (existing !== undefined) return existing; - throw new Error(`Parent agent "${parentAgentId}" does not exist`); -} - -async function requireChild( - lifecycle: IAgentLifecycleService, - parentAgentId: string, - metadata: ISessionMetadata | undefined, agentId: string, ): Promise { - if (metadata !== undefined) { - const meta = (await metadata.read()).agents?.[agentId]; - if (meta === undefined) throw new Error(`Agent instance "${agentId}" does not exist`); - if (meta.type !== 'sub') throw new Error(`Agent instance "${agentId}" is not a subagent`); - if (meta.parentAgentId !== parentAgentId) { - throw new Error(`Agent instance "${agentId}" does not belong to this parent agent`); - } - } - const child = lifecycle.getHandle(agentId); - if (child === undefined) throw new Error(`Agent instance "${agentId}" does not exist`); - if (activeChildrenByParent.get(parentAgentId)?.has(agentId) === true) { - throw new Error(`Agent instance "${agentId}" is already running`); - } - ensureAgentTool(child); - return child; + const handle = lifecycle.getHandle(agentId); + if (handle === undefined) throw new Error(`Agent instance "${agentId}" does not exist`); + ensureAgentTool(handle); + return handle; } function ensureAgentTool(child: IAgentScopeHandle): void { @@ -218,63 +153,63 @@ function ensureAgentTool(child: IAgentScopeHandle): void { child.accessor.get(IAgentToolService); } -function configureChild(parent: IAgentScopeHandle, child: IAgentScopeHandle, profileName: string): void { - const parentProfile = parent.accessor.get(IAgentProfileService); +function configureChild(source: IAgentScopeHandle, child: IAgentScopeHandle, profileName: string): void { + const sourceProfile = source.accessor.get(IAgentProfileService); const childProfile = child.accessor.get(IAgentProfileService); - const parentData = parentProfile.data(); + const sourceData = sourceProfile.data(); const profile = DEFAULT_AGENT_SUBAGENT_PROFILES[profileName]; const activeToolNames = profileName === 'coder' - ? (parentData.activeToolNames ?? profile?.tools) + ? (sourceData.activeToolNames ?? profile?.tools) : profile?.tools; childProfile.update({ - cwd: parentData.cwd, - modelAlias: parentData.modelAlias, - thinkingLevel: parentData.thinkingLevel, + cwd: sourceData.cwd, + modelAlias: sourceData.modelAlias, + thinkingLevel: sourceData.thinkingLevel, profileName, systemPrompt: profileName === 'explore' - ? `${parentData.systemPrompt}\n\n${EXPLORE_ROLE_ADDITIONAL}` - : parentData.systemPrompt, + ? `${sourceData.systemPrompt}\n\n${EXPLORE_ROLE_ADDITIONAL}` + : sourceData.systemPrompt, activeToolNames, }); } function emitSpawned( - parent: IAgentScopeHandle, - parentAgentId: string, + caller: IAgentScopeHandle, + callerAgentId: string, subagentId: string, profileName: string, options: RunSubagentOptions, ): void { - parent.accessor.get(IAgentRecordService)?.signal({ + caller.accessor.get(IAgentRecordService)?.signal({ type: 'subagent.spawned', subagentId, subagentName: profileName, parentToolCallId: options.parentToolCallId, parentToolCallUuid: options.parentToolCallUuid, - parentAgentId, + callerAgentId, description: options.description, swarmIndex: options.swarmIndex, runInBackground: options.runInBackground, }); - parent.accessor.get(ITelemetryService)?.track('subagent_created', { + caller.accessor.get(ITelemetryService)?.track('subagent_created', { subagent_name: profileName, run_in_background: options.runInBackground, }); } -function emitStarted(parent: IAgentScopeHandle, subagentId: string): void { - parent.accessor.get(IAgentRecordService)?.signal({ type: 'subagent.started', subagentId }); +function emitStarted(caller: IAgentScopeHandle, subagentId: string): void { + caller.accessor.get(IAgentRecordService)?.signal({ type: 'subagent.started', subagentId }); } function emitCompleted( - parent: IAgentScopeHandle, + caller: IAgentScopeHandle, subagentId: string, resultSummary: string, usage?: TokenUsage, ): void { - parent.accessor.get(IAgentRecordService)?.signal({ + caller.accessor.get(IAgentRecordService)?.signal({ type: 'subagent.completed', subagentId, resultSummary, @@ -283,14 +218,14 @@ function emitCompleted( } function emitFailed( - parent: IAgentScopeHandle, + caller: IAgentScopeHandle, subagentId: string, error: unknown, options: RunSubagentOptions, ): void { if (isAbortError(error)) return; if (shouldSuppressQueuedAttemptFailureEvent(options, error)) return; - parent.accessor.get(IAgentRecordService)?.signal({ + caller.accessor.get(IAgentRecordService)?.signal({ type: 'subagent.failed', subagentId, error: errorMessage(error), @@ -299,12 +234,12 @@ function emitFailed( async function triggerSubagentStart( - parent: IAgentScopeHandle, + caller: IAgentScopeHandle, profileName: string, prompt: string, signal: AbortSignal, ): Promise { - await parent.accessor.get(IAgentExternalHooksService)?.triggerSubagentStart( + await caller.accessor.get(IAgentExternalHooksService)?.triggerSubagentStart( { agentName: profileName, prompt: prompt.slice(0, HOOK_TEXT_PREVIEW_LENGTH), @@ -313,8 +248,8 @@ async function triggerSubagentStart( ); } -function triggerSubagentStop(parent: IAgentScopeHandle, profileName: string, result: string): void { - parent.accessor.get(IAgentExternalHooksService)?.triggerSubagentStop({ +function triggerSubagentStop(caller: IAgentScopeHandle, profileName: string, result: string): void { + caller.accessor.get(IAgentExternalHooksService)?.triggerSubagentStop({ agentName: profileName, response: result.slice(0, HOOK_TEXT_PREVIEW_LENGTH), }); @@ -326,10 +261,9 @@ function observeFirstRequest(turn: Turn, options: RunSubagentOptions): void { } async function runWithActiveChild( - parentAgentId: string, child: IAgentScopeHandle, options: RunSubagentOptions, - parent: IAgentScopeHandle, + caller: IAgentScopeHandle, profileName: string, run: ( turn: { current?: Turn }, @@ -337,37 +271,35 @@ async function runWithActiveChild( ) => Promise<{ result: string; usage?: TokenUsage }>, ): Promise<{ result: string; usage?: TokenUsage }> { const controller = new AbortController(); - childrenOf(parentAgentId).set(child.id, { controller, runInBackground: options.runInBackground }); const unlink = linkAbortSignal(options.signal, controller); const turnRef: { current?: Turn } = {}; - emitStarted(parent, child.id); + emitStarted(caller, child.id); try { const result = await run(turnRef, controller); - emitCompleted(parent, child.id, result.result, result.usage); - triggerSubagentStop(parent, profileName, result.result); + emitCompleted(caller, child.id, result.result, result.usage); + triggerSubagentStop(caller, profileName, result.result); return result; } catch (error) { - emitFailed(parent, child.id, error, options); + emitFailed(caller, child.id, error, options); throw error; } finally { unlink(); if (controller.signal.aborted) { turnRef.current?.abortController.abort(controller.signal.reason); } - childrenOf(parentAgentId).delete(child.id); } } async function runPromptTurn( child: IAgentScopeHandle, - parent: IAgentScopeHandle, + caller: IAgentScopeHandle, options: RunSubagentOptions, profileName: string, turnRef: { current?: Turn }, controller: AbortController, ): Promise<{ result: string; usage?: TokenUsage }> { options.signal.throwIfAborted(); - await triggerSubagentStart(parent, profileName, options.prompt, options.signal); + await triggerSubagentStart(caller, profileName, options.prompt, options.signal); options.signal.throwIfAborted(); const turn = child.accessor.get(IAgentPromptService).prompt({ @@ -390,14 +322,14 @@ async function runPromptTurn( async function runRetryTurn( child: IAgentScopeHandle, - parent: IAgentScopeHandle, + caller: IAgentScopeHandle, options: RunSubagentOptions, profileName: string, turnRef: { current?: Turn }, controller: AbortController, ): Promise<{ result: string; usage?: TokenUsage }> { options.signal.throwIfAborted(); - await triggerSubagentStart(parent, profileName, options.prompt, options.signal); + await triggerSubagentStart(caller, profileName, options.prompt, options.signal); options.signal.throwIfAborted(); const turn = child.accessor.get(IAgentPromptService).retry('agent-host'); diff --git a/packages/agent-core-v2/src/agent/background/agent-task.ts b/packages/agent-core-v2/src/agent/background/agent-task.ts index 9f5c69022..3e902b93a 100644 --- a/packages/agent-core-v2/src/agent/background/agent-task.ts +++ b/packages/agent-core-v2/src/agent/background/agent-task.ts @@ -17,10 +17,6 @@ export type SubagentHandle = { readonly completion: Promise; }; -export interface SubagentDetachHandle { - markActiveChildDetached(agentId: string): void; -} - export interface AgentBackgroundTaskInfo extends BackgroundTaskInfoBase { readonly kind: 'agent'; /** Subagent identifier accepted by Agent(resume=...). */ @@ -46,7 +42,6 @@ export class AgentBackgroundTask implements BackgroundTask { constructor( private readonly handle: SubagentHandle, readonly description: string, - private readonly detachHandle: Pick, private readonly abortController: AbortController, ) { this.agentId = handle.agentId; @@ -78,10 +73,6 @@ export class AgentBackgroundTask implements BackgroundTask { } } - onDetach(): void { - this.detachHandle.markActiveChildDetached(this.agentId); - } - toInfo(base: BackgroundTaskInfoBase): AgentBackgroundTaskInfo { return { ...base, diff --git a/packages/agent-core-v2/src/agent/background/background.ts b/packages/agent-core-v2/src/agent/background/background.ts index 87dbc3633..c47663c67 100644 --- a/packages/agent-core-v2/src/agent/background/background.ts +++ b/packages/agent-core-v2/src/agent/background/background.ts @@ -6,7 +6,7 @@ import type { } from './task'; export { AgentBackgroundTask } from './agent-task'; -export type { AgentBackgroundTaskInfo, SubagentDetachHandle } from './agent-task'; +export type { AgentBackgroundTaskInfo } from './agent-task'; export { ProcessBackgroundTask } from './process-task'; export type { ProcessBackgroundTaskInfo } from './process-task'; export { QuestionBackgroundTask } from './question-task'; diff --git a/packages/agent-core-v2/src/agent/cron/cron.ts b/packages/agent-core-v2/src/agent/cron/cron.ts index f516f9674..12e2a493a 100644 --- a/packages/agent-core-v2/src/agent/cron/cron.ts +++ b/packages/agent-core-v2/src/agent/cron/cron.ts @@ -41,10 +41,6 @@ export interface CronTask { /** Everything the caller supplies; `id` and `createdAt` are generated by the service. */ export type CronTaskInit = Omit; -export interface CronOptions { - readonly isSubagent?: boolean; -} - export interface CronLoadOptions { readonly replace?: boolean; } diff --git a/packages/agent-core-v2/src/agent/cron/cronService.ts b/packages/agent-core-v2/src/agent/cron/cronService.ts index 3f3b73483..b6e2869a9 100644 --- a/packages/agent-core-v2/src/agent/cron/cronService.ts +++ b/packages/agent-core-v2/src/agent/cron/cronService.ts @@ -24,6 +24,7 @@ import { ITelemetryService } from '#/app/telemetry'; import type { ContextMessage } from '#/agent/contextMemory'; import { IAgentPromptService } from '#/agent/prompt'; import { IAgentRecordService } from '#/agent/record'; +import { IAgentScopeContext } from '#/agent/scopeContext'; import { IAgentToolRegistryService } from '#/agent/toolRegistry'; import type { Turn } from '#/agent/turn'; import { IAgentTurnService } from '#/agent/turn'; @@ -36,7 +37,6 @@ import { import { IAgentCronService, type CronLoadOptions, - type CronOptions, type CronTask, type CronTaskInit, } from './cron'; @@ -142,7 +142,7 @@ export class AgentCronService extends Disposable implements IAgentCronService { private sigusr1Handler: NodeJS.SignalsListener | null = null; constructor( - options: CronOptions = {}, + @IAgentScopeContext private readonly ctx: IAgentScopeContext, @IAgentPromptService private readonly prompt: IAgentPromptService, @IAgentRecordService private readonly record: IAgentRecordService, @IAgentTurnService private readonly turnService: IAgentTurnService, @@ -152,7 +152,7 @@ export class AgentCronService extends Disposable implements IAgentCronService { @IAtomicDocumentStore private readonly atomicDocs: IAtomicDocumentStore, ) { super(); - this.enabled = options.isSubagent !== true; + this.enabled = this.ctx.agentId === 'main'; this.cronConfig = this.config.get(CRON_SECTION) ?? DEFAULT_CRON_CONFIG; this._register( this.config.onDidChangeConfiguration((e) => { diff --git a/packages/agent-core-v2/src/agent/permissionGate/permissionGate.ts b/packages/agent-core-v2/src/agent/permissionGate/permissionGate.ts index 60210a2d8..db4e8fc34 100644 --- a/packages/agent-core-v2/src/agent/permissionGate/permissionGate.ts +++ b/packages/agent-core-v2/src/agent/permissionGate/permissionGate.ts @@ -9,7 +9,6 @@ import type { export interface PermissionGateOptions { readonly agentId?: string; - readonly agentType?: 'main' | 'sub'; } export interface IAgentPermissionGate { diff --git a/packages/agent-core-v2/src/agent/permissionGate/permissionGateService.ts b/packages/agent-core-v2/src/agent/permissionGate/permissionGateService.ts index 07bcc3e01..94813f2ff 100644 --- a/packages/agent-core-v2/src/agent/permissionGate/permissionGateService.ts +++ b/packages/agent-core-v2/src/agent/permissionGate/permissionGateService.ts @@ -266,7 +266,7 @@ export class AgentPermissionGate extends Disposable implements IAgentPermissionG } private isSubagent(): boolean { - return this.options.agentType === 'sub'; + return this.options.agentId !== undefined && this.options.agentId !== 'main'; } } diff --git a/packages/agent-core-v2/src/agent/rpc/rpcService.ts b/packages/agent-core-v2/src/agent/rpc/rpcService.ts index 108839a1b..e803dfc27 100644 --- a/packages/agent-core-v2/src/agent/rpc/rpcService.ts +++ b/packages/agent-core-v2/src/agent/rpc/rpcService.ts @@ -14,7 +14,7 @@ import { userCancellationReason } from '#/_base/utils/abort'; import { IAgentPermissionGate } from '#/agent/permissionGate'; import { IAgentPermissionModeService } from '#/agent/permissionMode/permissionMode'; import { IAgentPlanService } from '#/agent/plan'; -import { IKaos } from '#/app/kaos'; +import { IExecContext } from '#/session/execContext'; import { expandCommandArguments, IPluginService } from '#/app/plugin'; import { IAgentProfileService } from '#/agent/profile'; import { IAgentPromptService } from '#/agent/prompt'; @@ -85,7 +85,7 @@ export class AgentRPCService implements IAgentRPCService { @IAgentFileToolsService private readonly fileTools: IAgentFileToolsService, @IAgentShellToolsService private readonly shellTools: IAgentShellToolsService, @ISessionProcessRunner private readonly processRunner: ISessionProcessRunner, - @IKaos private readonly kaos: IKaos, + @IExecContext private readonly execContext: IExecContext, @IAgentBackgroundService private readonly background: IAgentBackgroundService, @IAgentContextMemoryService private readonly context: IAgentContextMemoryService, @IAgentContextSizeService private readonly contextSize: IAgentContextSizeService, @@ -113,7 +113,7 @@ export class AgentRPCService implements IAgentRPCService { private ensureBashTool() { const existing = this.toolRegistry.resolve('Bash'); if (existing !== undefined) return existing; - const bash = new BashTool(this.processRunner, this.kaos, this.background); + const bash = new BashTool(this.processRunner, this.execContext, this.background); this.toolRegistry.register(bash); return bash; } diff --git a/packages/agent-core-v2/src/agent/swarm/index.ts b/packages/agent-core-v2/src/agent/swarm/index.ts index e5ecb2dbd..831d46c42 100644 --- a/packages/agent-core-v2/src/agent/swarm/index.ts +++ b/packages/agent-core-v2/src/agent/swarm/index.ts @@ -6,4 +6,3 @@ export * from './swarm'; export * from './swarmService'; -export * from './subagentBatch'; diff --git a/packages/agent-core-v2/src/agent/swarm/swarmService.ts b/packages/agent-core-v2/src/agent/swarm/swarmService.ts index 58c48c3ad..a5633b4a7 100644 --- a/packages/agent-core-v2/src/agent/swarm/swarmService.ts +++ b/packages/agent-core-v2/src/agent/swarm/swarmService.ts @@ -3,9 +3,9 @@ * * Tracks swarm-mode enter/exit (mirroring it into `wireRecord` and * `systemReminder`), auto-exits on turn end, and registers the `AgentSwarm` - * tool bound to this agent as the parent. Bound at Agent scope; spawns child - * agents through `agent-lifecycle`, reads its identity through `scopeContext`, - * and registers the tool through `toolRegistry`. + * tool bound to this agent as the caller. Bound at Agent scope; reads its + * identity through `scopeContext`, registers the tool through `toolRegistry`, + * and delegates batch runs to the Session-scoped `sessionSwarm` service. */ import { Disposable } from '#/_base/di'; @@ -16,10 +16,10 @@ import { IAgentScopeContext } from '#/agent/scopeContext'; import { IAgentSystemReminderService } from '#/agent/systemReminder'; import { IAgentToolRegistryService } from '#/agent/toolRegistry'; import { IAgentTurnService } from '#/agent/turn'; -import { IAgentLifecycleService } from '#/session/agent-lifecycle'; +import { ISessionSwarmService } from '#/session/swarm'; import SWARM_MODE_ENTER_REMINDER from './enter-reminder.md?raw'; import SWARM_MODE_EXIT_REMINDER from './exit-reminder.md?raw'; -import { AgentSwarmTool, type AgentSwarmToolHost } from '#/agent/swarm/tools/agent-swarm'; +import { AgentSwarmTool } from '#/agent/swarm/tools/agent-swarm'; import { IAgentSwarmService, type SwarmModeTrigger, @@ -40,13 +40,12 @@ export class AgentSwarmService extends Disposable implements IAgentSwarmService private _active: SwarmModeTrigger | null = null; constructor( - runQueued: AgentSwarmToolHost['runQueued'] | undefined, @IAgentRecordService private readonly record: IAgentRecordService, @IAgentSystemReminderService private readonly reminders: IAgentSystemReminderService, @IAgentTurnService turnService: IAgentTurnService, @IAgentToolRegistryService toolRegistry: IAgentToolRegistryService, - @IAgentLifecycleService lifecycle: IAgentLifecycleService, @IAgentScopeContext ctx: IAgentScopeContext, + @ISessionSwarmService swarmService: ISessionSwarmService, ) { super(); this._register( @@ -74,7 +73,7 @@ export class AgentSwarmService extends Disposable implements IAgentSwarmService ); this._register( toolRegistry.register( - new AgentSwarmTool({ lifecycle, parentAgentId: ctx.agentId, runQueued }, this), + new AgentSwarmTool({ swarmService, callerAgentId: ctx.agentId }, this), ), ); } 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 f8ad07f04..bd5f80194 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 @@ -16,9 +16,8 @@ import { } from '#/agent/agentTool'; import { ToolAccesses } from '#/agent/tool'; import type { ExecutableToolContext, ExecutableToolResult, ToolExecution } from '#/agent/tool'; -import type { IAgentLifecycleService } from '#/session/agent-lifecycle'; import { toInputJsonSchema } from '#/_base/tools/support/input-schema'; -import { runChildAgentQueued, type QueuedSubagentTask, type SubagentResult } from '../subagentBatch'; +import type { ISessionSwarmService, SessionSwarmTask } from '#/session/swarm'; import AGENT_SWARM_DESCRIPTION from './agent-swarm.md?raw'; const DEFAULT_SUBAGENT_TYPE = 'coder'; @@ -99,10 +98,8 @@ interface AgentSwarmMode { } export interface AgentSwarmToolHost { - readonly lifecycle: IAgentLifecycleService; - readonly parentAgentId: string; - readonly runQueued?: typeof runChildAgentQueued; - readonly getSwarmItem?: (agentId: string) => string | undefined; + readonly swarmService: ISessionSwarmService; + readonly callerAgentId: string; } export class AgentSwarmTool implements BuiltinTool { @@ -154,9 +151,8 @@ export class AgentSwarmTool implements BuiltinTool { toolCallId: string, ): Promise { const profileName = normalizeOptionalString(args.subagent_type) ?? DEFAULT_SUBAGENT_TYPE; - const getSwarmItem = this.host.getSwarmItem ?? ((id: string) => swarmItems.get(id)); - const specs = createAgentSwarmSpecs(args, getSwarmItem); - const tasks = specs.map((spec): QueuedSubagentTask => { + const specs = createAgentSwarmSpecs(args, (id) => swarmItems.get(id)); + const tasks: SessionSwarmTask[] = specs.map((spec) => { const descriptionName = spec.kind === 'resume' ? 'resume' : profileName; const common = { data: spec, @@ -173,21 +169,19 @@ export class AgentSwarmTool implements BuiltinTool { if (spec.kind === 'resume') { return { ...common, - kind: 'resume', + kind: 'resume' as const, resumeAgentId: spec.agentId, }; } return { ...common, - kind: 'spawn', + kind: 'spawn' as const, }; }); - const runQueued = this.host.runQueued ?? runChildAgentQueued; - const results = (await runQueued({ - lifecycle: this.host.lifecycle, - parentAgentId: this.host.parentAgentId, + const results = await this.host.swarmService.run({ + callerAgentId: this.host.callerAgentId, tasks, - })) as Array>; + }); for (const result of results) { if (result.agentId !== undefined && result.task.swarmItem !== undefined) { swarmItems.set(result.agentId, result.task.swarmItem); diff --git a/packages/agent-core-v2/src/app/session-lifecycle/sessionLifecycleService.ts b/packages/agent-core-v2/src/app/session-lifecycle/sessionLifecycleService.ts index 848fc3931..e52e8bf32 100644 --- a/packages/agent-core-v2/src/app/session-lifecycle/sessionLifecycleService.ts +++ b/packages/agent-core-v2/src/app/session-lifecycle/sessionLifecycleService.ts @@ -260,10 +260,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, - type: sourceAgent.type, - parentAgentId: sourceAgent.parentAgentId, + forkedFrom: sourceAgent.forkedFrom ?? legacy.parentAgentId, swarmItem: sourceAgent.swarmItem, }); await agentHandle.accessor.get(IAgentWireRecordService).restore(); diff --git a/packages/agent-core-v2/src/index.ts b/packages/agent-core-v2/src/index.ts index effede2db..0c71e1ddd 100644 --- a/packages/agent-core-v2/src/index.ts +++ b/packages/agent-core-v2/src/index.ts @@ -58,7 +58,6 @@ export * from '#/app/gateway'; export * from '#/session/workspaceContext'; export * from '#/app/workspaceRegistry'; export * from '#/app/hostFolderBrowser'; -export * from '#/app/kaos'; export * from '#/session/agentFs'; export * from '#/session/process'; export * from '#/session/terminal'; @@ -94,6 +93,7 @@ export * from '#/agent/rpc'; export * from '#/agent/scopeContext'; export * from '#/agent/agentTool'; export * from '#/session/btw'; +export * from '#/session/swarm'; export * from '#/agent/todoList'; export * from '#/agent/tool'; export * from '#/agent/toolExecutor'; diff --git a/packages/agent-core-v2/src/session/agent-lifecycle/agentLifecycle.ts b/packages/agent-core-v2/src/session/agent-lifecycle/agentLifecycle.ts index 744233a98..7ee19a23c 100644 --- a/packages/agent-core-v2/src/session/agent-lifecycle/agentLifecycle.ts +++ b/packages/agent-core-v2/src/session/agent-lifecycle/agentLifecycle.ts @@ -3,7 +3,7 @@ * * Defines the public contract of agent lifecycle: the `CreateAgentOptions` and * the `IAgentLifecycleService` used to create agents (`create` / `createMain`), - * fork an existing agent (`fork`), look them up (`getHandle` / `list`), and + * clone an existing agent (`clone`), look them up (`getHandle` / `list`), and * remove them. Session-scoped — one instance per session. */ @@ -13,12 +13,16 @@ import type { Event } from '#/_base/event'; export interface CreateAgentOptions { readonly agentId?: string; - readonly parentAgentId?: string; + /** Agent this one is cloned / derived from (provenance only; not used by business logic). */ + readonly forkedFrom?: string; readonly cwd?: string; - readonly type?: 'main' | 'sub'; readonly swarmItem?: string; } +export interface AgentListFilter { + readonly prefix?: string; +} + export interface IAgentLifecycleService { readonly _serviceBrand: undefined; /** Fires after an agent is created and registered, with its scope handle. */ @@ -27,10 +31,10 @@ export interface IAgentLifecycleService { readonly onDidDispose: Event; create(opts: CreateAgentOptions): Promise; createMain(): Promise; - /** Create a child agent that inherits the parent's profile and context history. */ - fork(parentAgentId: string): Promise; + /** Clone an agent: copy its profile and context history into a new agent. */ + clone(sourceAgentId: string): Promise; getHandle(agentId: string): IAgentScopeHandle | undefined; - list(): readonly IAgentScopeHandle[]; + list(filter?: AgentListFilter): readonly IAgentScopeHandle[]; remove(agentId: string): Promise; } diff --git a/packages/agent-core-v2/src/session/agent-lifecycle/agentLifecycleService.ts b/packages/agent-core-v2/src/session/agent-lifecycle/agentLifecycleService.ts index 1d8742cc7..e2e9eb50d 100644 --- a/packages/agent-core-v2/src/session/agent-lifecycle/agentLifecycleService.ts +++ b/packages/agent-core-v2/src/session/agent-lifecycle/agentLifecycleService.ts @@ -42,7 +42,7 @@ import { AgentExternalHooksService, } from '#/agent/externalHooks'; -import { type CreateAgentOptions, IAgentLifecycleService } from './agentLifecycle'; +import { type AgentListFilter, type CreateAgentOptions, IAgentLifecycleService } from './agentLifecycle'; let nextAgentId = 0; @@ -101,8 +101,7 @@ export class AgentLifecycleService extends Disposable implements IAgentLifecycle // enumerate every agent and relocate its wire log. await this.sessionMetadata.registerAgent(agentId, { homedir: agentHomedir, - type: opts.type ?? (opts.parentAgentId === undefined ? 'main' : 'sub'), - parentAgentId: opts.parentAgentId, + forkedFrom: opts.forkedFrom, swarmItem: opts.swarmItem, }); this.onDidCreateEmitter.fire(handle); @@ -123,24 +122,24 @@ export class AgentLifecycleService extends Disposable implements IAgentLifecycle return handle; } - async fork(parentAgentId: string): Promise { - const parent = - this.handles.get(parentAgentId) ?? - (parentAgentId === 'main' ? await this.createMain() : undefined); - if (parent === undefined) throw new Error(`Parent agent "${parentAgentId}" does not exist`); - const child = await this.create({ parentAgentId: parent.id, type: 'sub' }); + async clone(sourceAgentId: string): Promise { + const source = + this.handles.get(sourceAgentId) ?? + (sourceAgentId === 'main' ? await this.createMain() : undefined); + if (source === undefined) throw new Error(`Source agent "${sourceAgentId}" does not exist`); + const child = await this.create({ forkedFrom: source.id }); - const parentData = parent.accessor.get(IAgentProfileService).data(); + const sourceData = source.accessor.get(IAgentProfileService).data(); child.accessor.get(IAgentProfileService).update({ - modelAlias: parentData.modelAlias, - thinkingLevel: parentData.thinkingLevel, - systemPrompt: parentData.systemPrompt, - activeToolNames: parentData.activeToolNames, + modelAlias: sourceData.modelAlias, + thinkingLevel: sourceData.thinkingLevel, + systemPrompt: sourceData.systemPrompt, + activeToolNames: sourceData.activeToolNames, }); - const parentMessages = parent.accessor.get(IAgentContextMemoryService)?.get(); - if (parentMessages !== undefined && parentMessages.length > 0) { - child.accessor.get(IAgentContextMemoryService)?.splice(0, 0, parentMessages); + const sourceMessages = source.accessor.get(IAgentContextMemoryService)?.get(); + if (sourceMessages !== undefined && sourceMessages.length > 0) { + child.accessor.get(IAgentContextMemoryService)?.splice(0, 0, sourceMessages); } return child; } @@ -177,8 +176,11 @@ export class AgentLifecycleService extends Disposable implements IAgentLifecycle return this.handles.get(agentId); } - list(): readonly IAgentScopeHandle[] { - return [...this.handles.values()]; + list(filter?: AgentListFilter): readonly IAgentScopeHandle[] { + const all = [...this.handles.values()]; + const prefix = filter?.prefix; + if (prefix === undefined) return all; + return all.filter((handle) => handle.id.startsWith(prefix)); } remove(agentId: string): Promise { diff --git a/packages/agent-core-v2/src/session/btw/btwService.ts b/packages/agent-core-v2/src/session/btw/btwService.ts index 0f1757666..9710f1a1b 100644 --- a/packages/agent-core-v2/src/session/btw/btwService.ts +++ b/packages/agent-core-v2/src/session/btw/btwService.ts @@ -1,10 +1,10 @@ /** * `btw` domain — `ISessionBtwService` implementation. * - * Forks the main agent into a side-question child: inherits profile/context via - * `IAgentLifecycleService.fork`, then disables tool calls (deny-all permission + * Clones the main agent into a side-question child: inherits profile/context via + * `IAgentLifecycleService.clone`, then disables tool calls (deny-all permission * policy) and appends the side-channel system reminder. Bound at Session scope — - * `fork('main')` is a session-level operation, so the service injects the + * `clone('main')` is a session-level operation, so the service injects the * session's `IAgentLifecycleService` directly rather than resolving it through * the main agent's accessor. */ @@ -28,7 +28,7 @@ export class SessionBtwService implements ISessionBtwService { ) {} async start(): Promise { - const child = await this.lifecycle.fork('main'); + const child = await this.lifecycle.clone('main'); child.accessor .get(IAgentSystemReminderService) ?.appendSystemReminder(SIDE_QUESTION_SYSTEM_REMINDER, { diff --git a/packages/agent-core-v2/src/session/session-metadata/sessionMetadata.ts b/packages/agent-core-v2/src/session/session-metadata/sessionMetadata.ts index 92c8abe46..00ee914c1 100644 --- a/packages/agent-core-v2/src/session/session-metadata/sessionMetadata.ts +++ b/packages/agent-core-v2/src/session/session-metadata/sessionMetadata.ts @@ -15,8 +15,8 @@ import { createDecorator, type ServiceIdentifier } from '#/_base/di/instantiatio export interface AgentMeta { /** Per-agent directory used as the wire-record `homedir` (persistence key). */ readonly homedir: string; - readonly type?: 'main' | 'sub'; - readonly parentAgentId?: string; + /** Agent this one was cloned / derived from (provenance only; not used by business logic). */ + readonly forkedFrom?: string; readonly swarmItem?: string; } diff --git a/packages/agent-core-v2/src/session/swarm/index.ts b/packages/agent-core-v2/src/session/swarm/index.ts new file mode 100644 index 000000000..b28bf0067 --- /dev/null +++ b/packages/agent-core-v2/src/session/swarm/index.ts @@ -0,0 +1,9 @@ +/** + * `sessionSwarm` domain barrel — re-exports the sessionSwarm contract + * (`sessionSwarm`) and its scoped service (`sessionSwarmService`). Importing + * this barrel registers the `ISessionSwarmService` binding into the scope + * registry. The internal `subagentBatch` scheduler is not exported. + */ + +export * from './sessionSwarm'; +export * from './sessionSwarmService'; diff --git a/packages/agent-core-v2/src/session/swarm/sessionSwarm.ts b/packages/agent-core-v2/src/session/swarm/sessionSwarm.ts new file mode 100644 index 000000000..1cd19b5e9 --- /dev/null +++ b/packages/agent-core-v2/src/session/swarm/sessionSwarm.ts @@ -0,0 +1,62 @@ +/** + * `sessionSwarm` domain (L4) — batch scheduler for swarm subagent runs. + * + * Defines `ISessionSwarmService`, the Session-scoped service that runs a batch + * of subagents on behalf of a caller agent. Owns the in-flight batch state so + * cancellation can reach every run; the actual concurrency / rate-limit logic + * lives in the internal `subagentBatch` module. Bound at Session scope. + */ + +import type { TokenUsage } from '@moonshot-ai/kosong'; + +import { createDecorator, type ServiceIdentifier } from '#/_base/di/instantiation'; + +type SessionSwarmTaskBase = { + readonly data: T; + readonly profileName: string; + readonly parentToolCallId: string; + readonly parentToolCallUuid?: string; + readonly prompt: string; + readonly description: string; + readonly swarmIndex?: number; + readonly swarmItem?: string; + readonly runInBackground: boolean; + readonly timeout?: number; + readonly signal?: AbortSignal; +}; + +export type SessionSwarmSpawnTask = SessionSwarmTaskBase & { + readonly kind: 'spawn'; + readonly resumeAgentId?: undefined; +}; + +export type SessionSwarmResumeTask = SessionSwarmTaskBase & { + readonly kind: 'resume'; + readonly resumeAgentId: string; +}; + +export type SessionSwarmTask = SessionSwarmSpawnTask | SessionSwarmResumeTask; + +export interface SessionSwarmRunArgs { + readonly callerAgentId: string; + readonly tasks: readonly SessionSwarmTask[]; +} + +export interface SessionSwarmRunResult { + readonly task: SessionSwarmTask; + readonly agentId?: string; + readonly status: 'completed' | 'failed' | 'aborted'; + readonly state?: 'started' | 'not_started'; + readonly result?: string; + readonly usage?: TokenUsage; + readonly error?: string; +} + +export interface ISessionSwarmService { + readonly _serviceBrand: undefined; + run(args: SessionSwarmRunArgs): Promise[]>; + cancel(args: { readonly callerAgentId: string }): void; +} + +export const ISessionSwarmService: ServiceIdentifier = + createDecorator('sessionSwarmService'); diff --git a/packages/agent-core-v2/src/session/swarm/sessionSwarmService.ts b/packages/agent-core-v2/src/session/swarm/sessionSwarmService.ts new file mode 100644 index 000000000..4a2d77400 --- /dev/null +++ b/packages/agent-core-v2/src/session/swarm/sessionSwarmService.ts @@ -0,0 +1,85 @@ +/** + * `sessionSwarm` domain (L4) — `ISessionSwarmService` implementation. + * + * Runs a batch of subagents on behalf of a caller agent: builds a + * `SubagentBatchLauncher` (backed by the `agentTool` run helpers), drives the + * internal `SubagentBatch` scheduler, and tracks one `AbortController` per + * caller so `cancel` can abort every in-flight run. `subagent.suspended` facts + * are emitted on the caller agent's event sink. Bound at Session scope. + */ + +import { InstantiationType } from '#/_base/di/extensions'; +import { LifecycleScope, registerScopedService } from '#/_base/di/scope'; +import { linkAbortSignal } from '#/_base/utils/abort'; +import { + resumeChildAgent, + retryChildAgent, + spawnChildAgent, +} from '#/agent/agentTool'; +import { IAgentRecordService } from '#/agent/record'; +import { IAgentLifecycleService } from '#/session/agent-lifecycle'; + +import { + ISessionSwarmService, + type SessionSwarmRunArgs, + type SessionSwarmRunResult, + type SessionSwarmTask, +} from './sessionSwarm'; +import { + resolveSwarmMaxConcurrency, + SubagentBatch, + type SubagentBatchLauncher, +} from './subagentBatch'; + +export class SessionSwarmService implements ISessionSwarmService { + declare readonly _serviceBrand: undefined; + + private readonly inFlight = new Map(); + + constructor( + @IAgentLifecycleService private readonly lifecycle: IAgentLifecycleService, + ) {} + + run(args: SessionSwarmRunArgs): Promise[]> { + const { callerAgentId, tasks } = args; + const controller = new AbortController(); + this.inFlight.set(callerAgentId, controller); + const unlinks: Array<() => void> = []; + const linkedTasks: SessionSwarmTask[] = tasks.map((task) => { + if (task.signal !== undefined) unlinks.push(linkAbortSignal(task.signal, controller)); + return { ...task, signal: controller.signal }; + }); + const lifecycle = this.lifecycle; + const launcher: SubagentBatchLauncher = { + spawn: (options) => spawnChildAgent({ lifecycle, callerAgentId, ...options }), + resume: (agentId, options) => resumeChildAgent({ lifecycle, callerAgentId, agentId, ...options }), + retry: (agentId, options) => retryChildAgent({ lifecycle, callerAgentId, agentId, ...options }), + suspended: (event) => { + lifecycle.getHandle(callerAgentId)?.accessor.get(IAgentRecordService)?.signal({ + type: 'subagent.suspended', + subagentId: event.agentId, + reason: event.reason, + }); + }, + }; + const maxConcurrency = resolveSwarmMaxConcurrency(); + const promise = new SubagentBatch(launcher, linkedTasks, { maxConcurrency }).run(); + void promise.finally(() => { + for (const unlink of unlinks) unlink(); + if (this.inFlight.get(callerAgentId) === controller) this.inFlight.delete(callerAgentId); + }); + return promise; + } + + cancel({ callerAgentId }: { readonly callerAgentId: string }): void { + this.inFlight.get(callerAgentId)?.abort(); + } +} + +registerScopedService( + LifecycleScope.Session, + ISessionSwarmService, + SessionSwarmService, + InstantiationType.Delayed, + 'sessionSwarm', +); diff --git a/packages/agent-core-v2/src/agent/swarm/subagentBatch.ts b/packages/agent-core-v2/src/session/swarm/subagentBatch.ts similarity index 88% rename from packages/agent-core-v2/src/agent/swarm/subagentBatch.ts rename to packages/agent-core-v2/src/session/swarm/subagentBatch.ts index fbc439208..62c1d8dd1 100644 --- a/packages/agent-core-v2/src/agent/swarm/subagentBatch.ts +++ b/packages/agent-core-v2/src/session/swarm/subagentBatch.ts @@ -1,10 +1,11 @@ /** - * `swarm` domain (L4) — concurrency / rate-limit scheduler for subagent runs. + * `sessionSwarm` domain (L4) — internal concurrency / rate-limit scheduler. * * Owns the burst-then-throttle launch ramp and the provider-rate-limit recovery - * loop shared by the `AgentSwarm` tool; drives each attempt through a - * `SubagentBatchLauncher` (backed by the `agentTool` run helpers) and surfaces - * requeues via `suspended`. Pure scheduling logic — owns no scoped state. + * loop used by `SessionSwarmService`; drives each attempt through a + * `SubagentBatchLauncher` and surfaces requeues via `suspended`. Pure scheduling + * logic — owns no scoped state. Not part of the public surface: only + * `SessionSwarmService` imports it. */ import { isProviderRateLimitError, type TokenUsage } from '@moonshot-ai/kosong'; @@ -15,15 +16,8 @@ import type { SpawnSubagentOptions, SubagentHandle, } from '#/agent/agentTool'; -import { - resumeChildAgent, - retryChildAgent, - spawnChildAgent, -} from '#/agent/agentTool'; -import type { IAgentLifecycleService } from '#/session/agent-lifecycle'; -import type { ISessionMetadata } from '#/session/session-metadata'; -import { IAgentRecordService } from '#/agent/record'; import { isUserCancellation } from '#/_base/utils/abort'; +import type { SessionSwarmRunResult, SessionSwarmTask } from './sessionSwarm'; /* Subagent batch scheduling contract: @@ -56,45 +50,11 @@ const RATE_LIMIT_SUSPENDED_REASON = 'Provider rate limit; subagent requeued for const AGENT_SWARM_MAX_CONCURRENCY_ENV = 'KIMI_CODE_AGENT_SWARM_MAX_CONCURRENCY'; -type BaseQueuedSubagentTask = { - readonly data: T; - readonly profileName: string; - readonly parentToolCallId: string; - readonly parentToolCallUuid?: string; - readonly prompt: string; - readonly description: string; - readonly swarmIndex?: number; - readonly swarmItem?: string; - readonly runInBackground: boolean; - readonly timeout?: number; - readonly signal?: AbortSignal; -}; +export type QueuedSubagentTask = SessionSwarmTask; -export type SpawnQueuedSubagentTask = BaseQueuedSubagentTask & { - readonly kind: 'spawn'; - readonly resumeAgentId?: undefined; -}; +export type SubagentResult = SessionSwarmRunResult; -export type ResumeQueuedSubagentTask = BaseQueuedSubagentTask & { - readonly kind: 'resume'; - readonly resumeAgentId: string; -}; - -export type QueuedSubagentTask = - | SpawnQueuedSubagentTask - | ResumeQueuedSubagentTask; - -export type SubagentResult = { - readonly task: QueuedSubagentTask; - readonly agentId?: string; - readonly status: 'completed' | 'failed' | 'aborted'; - readonly state?: 'started' | 'not_started'; - readonly result?: string; - readonly usage?: TokenUsage; - readonly error?: string; -}; - -export type QueuedSubagentRunResult = SubagentResult; +export type QueuedSubagentRunResult = SessionSwarmRunResult; export type SubagentSuspendedEvent = { readonly task: QueuedSubagentTask; @@ -698,34 +658,4 @@ export function resolveSwarmMaxConcurrency( return value; } -export interface RunQueuedArgs { - readonly lifecycle: IAgentLifecycleService; - readonly parentAgentId: string; - readonly metadata?: ISessionMetadata; - readonly tasks: readonly QueuedSubagentTask[]; -} -export function runChildAgentQueued({ - lifecycle, - parentAgentId, - metadata, - tasks, -}: RunQueuedArgs): Promise>> { - const launcher: SubagentBatchLauncher = { - spawn: (options) => spawnChildAgent({ lifecycle, parentAgentId, metadata, ...options }), - resume: (agentId, options) => - resumeChildAgent({ lifecycle, parentAgentId, metadata, agentId, ...options }), - retry: (agentId, options) => - retryChildAgent({ lifecycle, parentAgentId, metadata, agentId, ...options }), - suspended: (event) => { - const parent = lifecycle.getHandle(parentAgentId); - parent?.accessor.get(IAgentRecordService)?.signal({ - type: 'subagent.suspended', - subagentId: event.agentId, - reason: event.reason, - }); - }, - }; - const maxConcurrency = resolveSwarmMaxConcurrency(); - return new SubagentBatch(launcher, tasks, { maxConcurrency }).run(); -} diff --git a/packages/agent-core-v2/test/agent-lifecycle/agentLifecycle.test.ts b/packages/agent-core-v2/test/agent-lifecycle/agentLifecycle.test.ts index b21308d92..0e99b5497 100644 --- a/packages/agent-core-v2/test/agent-lifecycle/agentLifecycle.test.ts +++ b/packages/agent-core-v2/test/agent-lifecycle/agentLifecycle.test.ts @@ -131,16 +131,14 @@ describe('AgentLifecycleService', () => { const child = await svc.create({ agentId: 'child', - parentAgentId: 'main', - type: 'sub', + forkedFrom: 'main', swarmItem: 'swarm-item-1', }); expect(child.id).toBe('child'); expect(registerAgent).toHaveBeenCalledWith('child', { homedir: '/tmp/kimi-agent-lifecycle-test/agents/child', - type: 'sub', - parentAgentId: 'main', + forkedFrom: 'main', swarmItem: 'swarm-item-1', }); }); diff --git a/packages/agent-core-v2/test/agentTool/agentTool.test.ts b/packages/agent-core-v2/test/agentTool/agentTool.test.ts index 8b5904d4f..69a44b2cd 100644 --- a/packages/agent-core-v2/test/agentTool/agentTool.test.ts +++ b/packages/agent-core-v2/test/agentTool/agentTool.test.ts @@ -71,7 +71,6 @@ function createRunOverride( resume: vi.fn(), retry: vi.fn(), getProfileName: vi.fn().mockResolvedValue(undefined), - markDetached: vi.fn(), }; return Object.assign(run, overrides); } @@ -120,7 +119,7 @@ describe('AgentTool direct contract', () => { run, tool: new AgentTool({ lifecycle: fakeLifecycle(), - parentAgentId: PARENT_AGENT_ID, + callerAgentId: PARENT_AGENT_ID, background, profile: fakeProfile(isToolActive), cwd: '/repo', @@ -356,57 +355,6 @@ describe('AgentTool direct contract', () => { expect(run.spawn).toHaveBeenCalledTimes(1); }); - it('can detach a foreground subagent through the background manager', async () => { - let resolveCompletion: (value: { result: string }) => void = () => {}; - const completion = new Promise<{ result: string }>((resolve) => { - resolveCompletion = resolve; - }); - const run = createRunOverride({ - markDetached: vi.fn(), - spawn: vi.fn().mockResolvedValue({ - agentId: 'agent-child', - profileName: 'coder', - resumed: false, - completion, - }), - }); - const { background, tool } = makeTool({ run }); - - const running = executeTool( - tool, - context({ - prompt: 'Investigate', - description: 'Find cause', - }), - ); - await vi.waitFor(() => { - expect(background.list(false)).toHaveLength(1); - }); - const task = background.list(false)[0]!; - - expect(task).toMatchObject({ - kind: 'agent', - detached: false, - agentId: 'agent-child', - }); - - background.detach(task.taskId); - const result = await running; - - expect(run.markDetached).toHaveBeenCalledWith( - expect.objectContaining({ agentId: 'agent-child' }), - ); - expect(result.output).toContain(`task_id: ${task.taskId}`); - expect(result.output).toContain('agent_id: agent-child'); - expect(result.output).toContain('automatic_notification: true'); - - resolveCompletion({ result: 'finished later' }); - await expect(background.wait(task.taskId)).resolves.toMatchObject({ - status: 'completed', - detached: true, - }); - }); - it('does not recommend disabled task tools when a foreground subagent is detached', async () => { let resolveCompletion: (value: { result: string }) => void = () => {}; const completion = new Promise<{ result: string }>((resolve) => { diff --git a/packages/agent-core-v2/test/agentTool/agentToolService.test.ts b/packages/agent-core-v2/test/agentTool/agentToolService.test.ts index 140d289f6..b7a150b6b 100644 --- a/packages/agent-core-v2/test/agentTool/agentToolService.test.ts +++ b/packages/agent-core-v2/test/agentTool/agentToolService.test.ts @@ -5,7 +5,7 @@ import { DisposableStore } from '#/_base/di/lifecycle'; import { TestInstantiationService } from '#/_base/di/test'; import { IAgentBackgroundService } from '#/agent/background'; import { IAgentLifecycleService } from '#/session/agent-lifecycle'; -import { IKaos } from '#/app/kaos'; +import { IExecContext } from '#/session/execContext'; import { ILogService } from '#/app/log'; import { IAgentProfileService } from '#/agent/profile'; import { IAgentScopeContext } from '#/agent/scopeContext'; @@ -35,7 +35,7 @@ describe('AgentToolService DI wiring', () => { ix.stub(IAgentToolRegistryService, { register }); ix.stub(IAgentBackgroundService, {}); ix.stub(IAgentProfileService, { isToolActive: vi.fn().mockReturnValue(false) }); - ix.stub(IKaos, { cwd: '/repo' }); + ix.stub(IExecContext, { cwd: '/repo' }); ix.stub(ISessionProcessRunner, { exec: vi.fn() }); ix.stub(ILogService, { warn: vi.fn(), info: vi.fn(), debug: vi.fn(), error: vi.fn() }); ix.set(IAgentToolService, new SyncDescriptor(AgentToolService, [undefined])); diff --git a/packages/agent-core-v2/test/agentTool/runChildAgent.test.ts b/packages/agent-core-v2/test/agentTool/runChildAgent.test.ts index 661efbd7c..88ff10695 100644 --- a/packages/agent-core-v2/test/agentTool/runChildAgent.test.ts +++ b/packages/agent-core-v2/test/agentTool/runChildAgent.test.ts @@ -13,17 +13,10 @@ import { IAgentSystemReminderService } from '#/agent/systemReminder'; import { IAgentPermissionPolicyService } from '#/agent/permissionPolicy'; import { ITelemetryService } from '#/app/telemetry'; import { IAgentUsageService } from '#/agent/usage'; -import { - cancelAllChildren, - markChildDetached, - resumeChildAgent, - retryChildAgent, - spawnChildAgent, -} from '#/agent/agentTool'; -import { runChildAgentQueued } from '#/agent/swarm'; +import { resumeChildAgent, retryChildAgent, spawnChildAgent } from '#/agent/agentTool'; const CHILD_SUMMARY = 'child summary '.repeat(20); -const PARENT_AGENT_ID = 'main'; +const CALLER_AGENT_ID = 'main'; interface FakeScopeOptions { readonly result?: Promise<{ reason: string; error?: unknown }>; @@ -150,7 +143,7 @@ function fakeScope(id: string, options: FakeScopeOptions = {}): IScopeHandle { function makeAgents(parent: IScopeHandle, children: Record | (() => IScopeHandle)) { return { getHandle: vi.fn((id: string) => { - if (id === PARENT_AGENT_ID) return parent; + if (id === CALLER_AGENT_ID) return parent; if (typeof children === 'function') return undefined; return children[id]; }), @@ -164,14 +157,14 @@ function makeAgents(parent: IScopeHandle, children: Record describe('runChildAgent', () => { it('aborts a running subagent when the caller signal aborts', async () => { - const parent = fakeScope(PARENT_AGENT_ID); + const parent = fakeScope(CALLER_AGENT_ID); const child = fakeScope('child', { result: new Promise(() => {}) }); const agents = makeAgents(parent, { child }); const controller = new AbortController(); const handle = await spawnChildAgent({ lifecycle: agents as unknown as IAgentLifecycleService, - parentAgentId: PARENT_AGENT_ID, + callerAgentId: CALLER_AGENT_ID, profileName: 'coder', parentToolCallId: 'call_agent', prompt: 'Run long task', @@ -186,13 +179,13 @@ describe('runChildAgent', () => { it('emits subagent spawned and started events', async () => { const events: unknown[] = []; - const parent = fakeScope(PARENT_AGENT_ID, { events }); + const parent = fakeScope(CALLER_AGENT_ID, { events }); const child = fakeScope('child'); const agents = makeAgents(parent, { child }); const handle = await spawnChildAgent({ lifecycle: agents as unknown as IAgentLifecycleService, - parentAgentId: PARENT_AGENT_ID, + callerAgentId: CALLER_AGENT_ID, profileName: 'explore', parentToolCallId: 'call_agent', prompt: 'Explore the repo', @@ -215,13 +208,13 @@ describe('runChildAgent', () => { }); it('asks for a continuation when the first summary is too short', async () => { - const parent = fakeScope(PARENT_AGENT_ID, { initialText: 'short' }); + const parent = fakeScope(CALLER_AGENT_ID, { initialText: 'short' }); const child = fakeScope('child', { initialText: 'short' }); const agents = makeAgents(parent, { child }); const handle = await spawnChildAgent({ lifecycle: agents as unknown as IAgentLifecycleService, - parentAgentId: PARENT_AGENT_ID, + callerAgentId: CALLER_AGENT_ID, profileName: 'coder', parentToolCallId: 'call_agent', prompt: 'Implement', @@ -236,13 +229,13 @@ describe('runChildAgent', () => { }); it('persists the swarmItem when spawning a subagent', async () => { - const parent = fakeScope(PARENT_AGENT_ID); + const parent = fakeScope(CALLER_AGENT_ID); const child = fakeScope('child'); const agents = makeAgents(parent, { child }); await spawnChildAgent({ lifecycle: agents as unknown as IAgentLifecycleService, - parentAgentId: PARENT_AGENT_ID, + callerAgentId: CALLER_AGENT_ID, profileName: 'coder', parentToolCallId: 'call_agent', prompt: 'Swarm task', @@ -253,47 +246,15 @@ describe('runChildAgent', () => { }); expect(agents.create).toHaveBeenCalledWith({ - parentAgentId: PARENT_AGENT_ID, + forkedFrom: CALLER_AGENT_ID, cwd: '/repo', - type: 'sub', swarmItem: 'item-1', }); }); - it('rejects resuming a subagent owned by another parent', async () => { - const parent = fakeScope(PARENT_AGENT_ID); - const child = fakeScope('child'); - const agents = { - getHandle: vi.fn((id: string) => (id === 'child' ? child : id === PARENT_AGENT_ID ? parent : undefined)), - createMain: vi.fn(), - create: vi.fn(), - }; - const metadata = { - read: vi.fn().mockResolvedValue({ - agents: { - child: { homedir: '/repo/agents/child', type: 'sub', parentAgentId: 'other-parent' }, - }, - }), - }; - - await expect( - resumeChildAgent({ - lifecycle: agents as unknown as IAgentLifecycleService, - parentAgentId: PARENT_AGENT_ID, - metadata: metadata as never, - agentId: 'child', - parentToolCallId: 'call_agent', - prompt: 'Continue', - description: 'Continue', - runInBackground: false, - signal: new AbortController().signal, - }), - ).rejects.toThrow('does not belong'); - }); - it('emits subagent.failed when the child turn fails', async () => { const events: unknown[] = []; - const parent = fakeScope(PARENT_AGENT_ID, { + const parent = fakeScope(CALLER_AGENT_ID, { result: Promise.resolve({ reason: 'failed', error: new Error('boom') }), events, }); @@ -304,7 +265,7 @@ describe('runChildAgent', () => { const handle = await spawnChildAgent({ lifecycle: agents as unknown as IAgentLifecycleService, - parentAgentId: PARENT_AGENT_ID, + callerAgentId: CALLER_AGENT_ID, profileName: 'coder', parentToolCallId: 'call_agent', prompt: 'Do work', @@ -323,14 +284,14 @@ describe('runChildAgent', () => { it('treats timeout aborts as subagent failures, not user cancellations', async () => { const events: unknown[] = []; - const parent = fakeScope(PARENT_AGENT_ID, { result: new Promise(() => {}), events }); + const parent = fakeScope(CALLER_AGENT_ID, { result: new Promise(() => {}), events }); const child = fakeScope('child', { result: new Promise(() => {}) }); const agents = makeAgents(parent, { child }); const controller = new AbortController(); const handle = await spawnChildAgent({ lifecycle: agents as unknown as IAgentLifecycleService, - parentAgentId: PARENT_AGENT_ID, + callerAgentId: CALLER_AGENT_ID, profileName: 'coder', parentToolCallId: 'call_agent', prompt: 'Run long task', @@ -350,17 +311,17 @@ describe('runChildAgent', () => { it('resumes an existing child agent and returns completion summary', async () => { const events: unknown[] = []; - const parent = fakeScope(PARENT_AGENT_ID, { events }); + const parent = fakeScope(CALLER_AGENT_ID, { events }); const child = fakeScope('child'); const agents = { - getHandle: vi.fn((id: string) => (id === 'child' ? child : id === PARENT_AGENT_ID ? parent : undefined)), + getHandle: vi.fn((id: string) => (id === 'child' ? child : id === CALLER_AGENT_ID ? parent : undefined)), createMain: vi.fn(), create: vi.fn(), }; const handle = await resumeChildAgent({ lifecycle: agents as unknown as IAgentLifecycleService, - parentAgentId: PARENT_AGENT_ID, + callerAgentId: CALLER_AGENT_ID, agentId: 'child', parentToolCallId: 'call_agent', prompt: 'Continue', @@ -381,121 +342,14 @@ describe('runChildAgent', () => { ]); }); - it('runs queued subagent tasks to completion', async () => { - const parent = fakeScope(PARENT_AGENT_ID); - const child = fakeScope('child'); - const agents = makeAgents(parent, { child }); - - const results = await runChildAgentQueued({ - lifecycle: agents as unknown as IAgentLifecycleService, - parentAgentId: PARENT_AGENT_ID, - tasks: [ - { - kind: 'spawn', - data: {}, - profileName: 'coder', - parentToolCallId: 'call_agent', - prompt: 'Queued task', - description: 'Queued task', - runInBackground: false, - }, - ], - }); - - expect(results).toEqual([ - expect.objectContaining({ status: 'completed', agentId: 'child', result: CHILD_SUMMARY }), - ]); - }); - - it('passes the swarm maxConcurrency env cap through to the batch', async () => { - const previous = process.env['KIMI_CODE_AGENT_SWARM_MAX_CONCURRENCY']; - process.env['KIMI_CODE_AGENT_SWARM_MAX_CONCURRENCY'] = '2'; - try { - const parent = fakeScope(PARENT_AGENT_ID); - const pending: Array>> = []; - const agents = makeAgents(parent, () => { - const d = deferred<{ reason: string }>(); - pending.push(d); - return fakeScope(`child-${pending.length}`, { result: d.promise }); - }); - - const tasks = Array.from({ length: 4 }, (_, index) => ({ - kind: 'spawn' as const, - data: {}, - profileName: 'coder', - parentToolCallId: 'call_agent', - prompt: `task ${index}`, - description: `task ${index}`, - runInBackground: false, - })); - const run = runChildAgentQueued({ - lifecycle: agents as unknown as IAgentLifecycleService, - parentAgentId: PARENT_AGENT_ID, - tasks, - }); - void run.catch(() => {}); - const flush = (): Promise => new Promise((r) => setTimeout(r, 0)); - - // The cap stops the launch burst at 2 even though 4 tasks are queued. - await flush(); - expect(agents.create).toHaveBeenCalledTimes(2); - - // Completing one task frees a slot so the next queued task launches. - pending[0]?.resolve({ reason: 'completed' }); - await flush(); - expect(agents.create).toHaveBeenCalledTimes(3); - - // Completing another task launches the final queued task. - pending[1]?.resolve({ reason: 'completed' }); - await flush(); - expect(agents.create).toHaveBeenCalledTimes(4); - - // Drain the remaining attempts so the batch settles. - pending[2]?.resolve({ reason: 'completed' }); - pending[3]?.resolve({ reason: 'completed' }); - await run; - } finally { - if (previous === undefined) delete process.env['KIMI_CODE_AGENT_SWARM_MAX_CONCURRENCY']; - else process.env['KIMI_CODE_AGENT_SWARM_MAX_CONCURRENCY'] = previous; - } - }); - - it('marks an active child as detached so cancelAllChildren skips it', async () => { - const parent = fakeScope(PARENT_AGENT_ID, { result: new Promise(() => {}) }); - const child = fakeScope('child', { result: new Promise(() => {}) }); - const agents = makeAgents(parent, { child }); - - const handle = await spawnChildAgent({ - lifecycle: agents as unknown as IAgentLifecycleService, - parentAgentId: PARENT_AGENT_ID, - profileName: 'coder', - parentToolCallId: 'call_agent', - prompt: 'Run detached task', - description: 'Detached task', - runInBackground: false, - signal: new AbortController().signal, - }); - void handle.completion.catch(() => {}); - await Promise.resolve(); - await Promise.resolve(); - - const prompt = child.accessor.get(IAgentPromptService); - const turn = mockOf(prompt.prompt).mock.results[0]?.value as { abortController: AbortController }; - - markChildDetached({ parentAgentId: PARENT_AGENT_ID, agentId: 'child' }); - cancelAllChildren(PARENT_AGENT_ID, 'user-stop'); - - expect(turn.abortController.signal.aborted).toBe(false); - }); - it('spawns a child agent and returns its completion summary', async () => { - const parent = fakeScope(PARENT_AGENT_ID); + const parent = fakeScope(CALLER_AGENT_ID); const child = fakeScope('child'); const agents = makeAgents(parent, { child }); const handle = await spawnChildAgent({ lifecycle: agents as unknown as IAgentLifecycleService, - parentAgentId: PARENT_AGENT_ID, + callerAgentId: CALLER_AGENT_ID, profileName: 'explore', parentToolCallId: 'call_agent', prompt: 'Explore the repo', @@ -509,21 +363,20 @@ describe('runChildAgent', () => { usage: { input: 1, output: 2, cache_read: 0, cache_write: 0 }, }); expect(agents.create).toHaveBeenCalledWith({ - parentAgentId: PARENT_AGENT_ID, + forkedFrom: CALLER_AGENT_ID, cwd: '/repo', - type: 'sub', swarmItem: undefined, }); }); it('fires SubagentStart and SubagentStop external hooks around the turn', async () => { - const parent = fakeScope(PARENT_AGENT_ID); + const parent = fakeScope(CALLER_AGENT_ID); const child = fakeScope('child'); const agents = makeAgents(parent, { child }); const handle = await spawnChildAgent({ lifecycle: agents as unknown as IAgentLifecycleService, - parentAgentId: PARENT_AGENT_ID, + callerAgentId: CALLER_AGENT_ID, profileName: 'explore', parentToolCallId: 'call_agent', prompt: 'Explore the repo', @@ -545,13 +398,13 @@ describe('runChildAgent', () => { }); it('tracks subagent_created telemetry on spawn', async () => { - const parent = fakeScope(PARENT_AGENT_ID); + const parent = fakeScope(CALLER_AGENT_ID); const child = fakeScope('child'); const agents = makeAgents(parent, { child }); const handle = await spawnChildAgent({ lifecycle: agents as unknown as IAgentLifecycleService, - parentAgentId: PARENT_AGENT_ID, + callerAgentId: CALLER_AGENT_ID, profileName: 'coder', parentToolCallId: 'call_agent', prompt: 'Do work', @@ -570,14 +423,14 @@ describe('runChildAgent', () => { it('fires onReady on the first turn activity rather than synchronously at launch', async () => { const ready = deferred(); - const parent = fakeScope(PARENT_AGENT_ID); + const parent = fakeScope(CALLER_AGENT_ID); const child = fakeScope('child', { ready: ready.promise }); const agents = makeAgents(parent, { child }); const onReady = vi.fn(); const handle = await spawnChildAgent({ lifecycle: agents as unknown as IAgentLifecycleService, - parentAgentId: PARENT_AGENT_ID, + callerAgentId: CALLER_AGENT_ID, profileName: 'coder', parentToolCallId: 'call_agent', prompt: 'Do work', @@ -595,17 +448,17 @@ describe('runChildAgent', () => { }); it('retries the existing turn in place instead of re-prompting', async () => { - const parent = fakeScope(PARENT_AGENT_ID); + const parent = fakeScope(CALLER_AGENT_ID); const child = fakeScope('child'); const agents = { - getHandle: vi.fn((id: string) => (id === 'child' ? child : id === PARENT_AGENT_ID ? parent : undefined)), + getHandle: vi.fn((id: string) => (id === 'child' ? child : id === CALLER_AGENT_ID ? parent : undefined)), createMain: vi.fn(), create: vi.fn(), }; const handle = await retryChildAgent({ lifecycle: agents as unknown as IAgentLifecycleService, - parentAgentId: PARENT_AGENT_ID, + callerAgentId: CALLER_AGENT_ID, agentId: 'child', parentToolCallId: 'call_agent', prompt: 'ignored on retry', @@ -621,13 +474,13 @@ describe('runChildAgent', () => { }); it('classifies a filtered turn as a provider safety policy block', async () => { - const parent = fakeScope(PARENT_AGENT_ID); + const parent = fakeScope(CALLER_AGENT_ID); const child = fakeScope('child', { result: Promise.resolve({ reason: 'filtered' }) }); const agents = makeAgents(parent, { child }); const handle = await spawnChildAgent({ lifecycle: agents as unknown as IAgentLifecycleService, - parentAgentId: PARENT_AGENT_ID, + callerAgentId: CALLER_AGENT_ID, profileName: 'coder', parentToolCallId: 'call_agent', prompt: 'Do work', @@ -640,7 +493,7 @@ describe('runChildAgent', () => { }); it('rethrows a provider rate limit as an APIProviderRateLimitError', async () => { - const parent = fakeScope(PARENT_AGENT_ID); + const parent = fakeScope(CALLER_AGENT_ID); const child = fakeScope('child', { result: Promise.resolve({ reason: 'failed', @@ -651,7 +504,7 @@ describe('runChildAgent', () => { const handle = await spawnChildAgent({ lifecycle: agents as unknown as IAgentLifecycleService, - parentAgentId: PARENT_AGENT_ID, + callerAgentId: CALLER_AGENT_ID, profileName: 'coder', parentToolCallId: 'call_agent', prompt: 'Do work', @@ -663,42 +516,14 @@ describe('runChildAgent', () => { await expect(handle.completion).rejects.toSatisfy((error) => error instanceof APIProviderRateLimitError); }); - it('cancels every foreground active child with the provided reason', async () => { - const parent = fakeScope(PARENT_AGENT_ID); - const child = fakeScope('child', { result: new Promise(() => {}) }); - const agents = makeAgents(parent, { child }); - - const handle = await spawnChildAgent({ - lifecycle: agents as unknown as IAgentLifecycleService, - parentAgentId: PARENT_AGENT_ID, - profileName: 'coder', - parentToolCallId: 'call_agent', - prompt: 'Run long task', - description: 'Long task', - runInBackground: false, - signal: new AbortController().signal, - }); - void handle.completion.catch(() => {}); - // Let the SubagentStart hook resolve so the turn is launched. - await Promise.resolve(); - await Promise.resolve(); - - const prompt = child.accessor.get(IAgentPromptService); - const turn = mockOf(prompt.prompt).mock.results[0]?.value as { abortController: AbortController }; - cancelAllChildren(PARENT_AGENT_ID, 'user-stop'); - - expect(turn.abortController.signal.aborted).toBe(true); - expect(turn.abortController.signal.reason).toBe('user-stop'); - }); - it('composes the explore system prompt from the parent prompt plus the explore role', async () => { - const parent = fakeScope(PARENT_AGENT_ID); + const parent = fakeScope(CALLER_AGENT_ID); const child = fakeScope('child'); const agents = makeAgents(parent, { child }); const handle = await spawnChildAgent({ lifecycle: agents as unknown as IAgentLifecycleService, - parentAgentId: PARENT_AGENT_ID, + callerAgentId: CALLER_AGENT_ID, profileName: 'explore', parentToolCallId: 'call_agent', prompt: 'Explore the repo', diff --git a/packages/agent-core-v2/test/background/agent-timeout.test.ts b/packages/agent-core-v2/test/background/agent-timeout.test.ts index 2c9ee0116..4be897c64 100644 --- a/packages/agent-core-v2/test/background/agent-timeout.test.ts +++ b/packages/agent-core-v2/test/background/agent-timeout.test.ts @@ -21,7 +21,6 @@ function agentTask( return new AgentBackgroundTask( { agentId: 'agent-child', profileName: 'coder', completion }, description, - { markActiveChildDetached: vi.fn() }, new AbortController(), ); } diff --git a/packages/agent-core-v2/test/background/bg-idle-notification-repro.test.ts b/packages/agent-core-v2/test/background/bg-idle-notification-repro.test.ts index 660aa3d4a..0d06c99eb 100644 --- a/packages/agent-core-v2/test/background/bg-idle-notification-repro.test.ts +++ b/packages/agent-core-v2/test/background/bg-idle-notification-repro.test.ts @@ -47,7 +47,6 @@ function agentTask( return new AgentBackgroundTask( { agentId: 'agent-child', profileName: 'coder', completion }, description, - { markActiveChildDetached: vi.fn() }, new AbortController(), ); } diff --git a/packages/agent-core-v2/test/background/ids.test.ts b/packages/agent-core-v2/test/background/ids.test.ts index ae3c2f775..b5dce11ee 100644 --- a/packages/agent-core-v2/test/background/ids.test.ts +++ b/packages/agent-core-v2/test/background/ids.test.ts @@ -9,7 +9,6 @@ import { IAgentBackgroundService, ProcessBackgroundTask, } from '#/agent/background'; -import type { SubagentDetachHandle } from '#/agent/background'; import type { SubagentHandle } from '#/agent/agentTool'; import { createTestAgent, type TestAgentContext } from '../harness'; import { createBackgroundTaskPersistence } from './stubs'; @@ -36,10 +35,6 @@ function agentTask( return new AgentBackgroundTask( handle, description, - { markActiveChildDetached: vi.fn() } as unknown as Pick< - SubagentDetachHandle, - 'markActiveChildDetached' - >, new AbortController(), ); } diff --git a/packages/agent-core-v2/test/background/manager.test.ts b/packages/agent-core-v2/test/background/manager.test.ts index b9f47afa4..348142005 100644 --- a/packages/agent-core-v2/test/background/manager.test.ts +++ b/packages/agent-core-v2/test/background/manager.test.ts @@ -17,7 +17,6 @@ import { ProcessBackgroundTask, type BackgroundTaskInfo, } from '#/agent/background'; -import type { SubagentDetachHandle } from '#/agent/background'; import type { SubagentHandle } from '#/agent/agentTool'; import { isUserCancellation, userCancellationReason } from '#/_base/utils/abort'; import { @@ -80,7 +79,6 @@ function agentTask( options: { readonly agentId?: string; readonly subagentType?: string; - readonly subagentHost?: Pick; readonly abortController?: AbortController; readonly timeoutMs?: number; } = {}, @@ -94,7 +92,6 @@ function agentTask( const task = new AgentBackgroundTask( handle, description, - options.subagentHost ?? { markActiveChildDetached: vi.fn() }, options.abortController ?? new AbortController(), ); if (options.timeoutMs !== undefined) { diff --git a/packages/agent-core-v2/test/background/rpc-events.test.ts b/packages/agent-core-v2/test/background/rpc-events.test.ts index 64ef207b9..539ec8f74 100644 --- a/packages/agent-core-v2/test/background/rpc-events.test.ts +++ b/packages/agent-core-v2/test/background/rpc-events.test.ts @@ -21,7 +21,6 @@ import { IAgentContextMemoryService } from '#/agent/contextMemory'; import { IAgentEventSinkService } from '#/agent/eventSink'; import type { HookEngine } from '#/agent/externalHooks/engine'; import { IAgentPromptService } from '#/agent/prompt'; -import type { SubagentDetachHandle } from '#/agent/background'; import type { SubagentHandle } from '#/agent/agentTool'; import { ISessionMetadata } from '#/session/session-metadata'; import { @@ -84,7 +83,6 @@ function agentTask( options: { readonly agentId?: string; readonly subagentType?: string; - readonly subagentHost?: Pick; readonly abortController?: AbortController; readonly timeoutMs?: number; } = {}, @@ -98,7 +96,6 @@ function agentTask( const task = new AgentBackgroundTask( handle, description, - options.subagentHost ?? { markActiveChildDetached: vi.fn() }, options.abortController ?? new AbortController(), ); if (options.timeoutMs !== undefined) { diff --git a/packages/agent-core-v2/test/cron/cron.e2e.test.ts b/packages/agent-core-v2/test/cron/cron.e2e.test.ts index f64a505ff..72ae29ab6 100644 --- a/packages/agent-core-v2/test/cron/cron.e2e.test.ts +++ b/packages/agent-core-v2/test/cron/cron.e2e.test.ts @@ -57,7 +57,7 @@ describe('Cron — session E2E (P1.9)', () => { vi.stubEnv('KIMI_CRON_NO_JITTER', '1'); vi.stubEnv('KIMI_CRON_POLL_INTERVAL_MS', '0'); harness = createClocks(); - ctx = createTestAgent(cronServices({})); + ctx = createTestAgent(cronServices({ _serviceBrand: undefined, agentId: 'main' })); cron = ctx.get(IAgentCronService); prompt = ctx.get(IAgentPromptService); cron.start(); diff --git a/packages/agent-core-v2/test/cron/manager.test.ts b/packages/agent-core-v2/test/cron/manager.test.ts index e472844a0..be20c8c6c 100644 --- a/packages/agent-core-v2/test/cron/manager.test.ts +++ b/packages/agent-core-v2/test/cron/manager.test.ts @@ -107,7 +107,7 @@ describe('AgentCronService', () => { describe('construction', () => { beforeEach(() => { - ctx = createTestAgent(cronServices({})); + ctx = createTestAgent(cronServices({ _serviceBrand: undefined, agentId: 'main' })); cron = ctx.get(IAgentCronService); }); @@ -140,7 +140,7 @@ describe('AgentCronService', () => { beforeEach(() => { harness = createClocks(); - ctx = createTestAgent(cronServices({})); + ctx = createTestAgent(cronServices({ _serviceBrand: undefined, agentId: 'main' })); cron = ctx.get(IAgentCronService); prompt = ctx.get(IAgentPromptService); telemetry = ctx.get(ITelemetryService); @@ -212,7 +212,7 @@ describe('AgentCronService', () => { beforeEach(() => { harness = createClocks(); - ctx = createTestAgent(cronServices({})); + ctx = createTestAgent(cronServices({ _serviceBrand: undefined, agentId: 'main' })); cron = ctx.get(IAgentCronService); prompt = ctx.get(IAgentPromptService); telemetry = ctx.get(ITelemetryService); @@ -257,7 +257,7 @@ describe('AgentCronService', () => { beforeEach(() => { harness = createClocks(); - ctx = createTestAgent(cronServices({})); + ctx = createTestAgent(cronServices({ _serviceBrand: undefined, agentId: 'main' })); cron = ctx.get(IAgentCronService); }); @@ -312,7 +312,7 @@ describe('AgentCronService', () => { beforeEach(() => { vi.stubEnv('KIMI_CRON_NO_STALE', '1'); harness = createClocks(); - ctx = createTestAgent(cronServices({})); + ctx = createTestAgent(cronServices({ _serviceBrand: undefined, agentId: 'main' })); cron = ctx.get(IAgentCronService); }); @@ -332,7 +332,7 @@ describe('AgentCronService', () => { beforeEach(() => { vi.spyOn(Date, 'now').mockReturnValue(Number.NaN); ctx = createTestAgent( - cronServices({}), + cronServices({ _serviceBrand: undefined, agentId: 'main' }), ); cron = ctx.get(IAgentCronService); }); @@ -356,7 +356,7 @@ describe('AgentCronService', () => { beforeEach(() => { harness = createClocks(); - ctx = createTestAgent(cronServices({})); + ctx = createTestAgent(cronServices({ _serviceBrand: undefined, agentId: 'main' })); cron = ctx.get(IAgentCronService); prompt = ctx.get(IAgentPromptService); telemetry = ctx.get(ITelemetryService); @@ -424,7 +424,7 @@ describe('AgentCronService', () => { beforeEach(() => { harness = createClocks(); - ctx = createTestAgent(cronServices({})); + ctx = createTestAgent(cronServices({ _serviceBrand: undefined, agentId: 'main' })); cron = ctx.get(IAgentCronService); prompt = ctx.get(IAgentPromptService); telemetry = ctx.get(ITelemetryService); @@ -451,7 +451,7 @@ describe('AgentCronService', () => { beforeEach(() => { harness = createClocks(); - ctx = createTestAgent(cronServices({})); + ctx = createTestAgent(cronServices({ _serviceBrand: undefined, agentId: 'main' })); cron = ctx.get(IAgentCronService); prompt = ctx.get(IAgentPromptService); telemetry = ctx.get(ITelemetryService); @@ -494,7 +494,7 @@ describe('AgentCronService', () => { beforeEach(() => { harness = createClocks(); - ctx = createTestAgent(cronServices({})); + ctx = createTestAgent(cronServices({ _serviceBrand: undefined, agentId: 'main' })); cron = ctx.get(IAgentCronService); prompt = ctx.get(IAgentPromptService); steerCalls = createSteerSpy(prompt); @@ -518,7 +518,7 @@ describe('AgentCronService', () => { let telemetryRecords: TelemetryRecord[]; beforeEach(() => { - ctx = createTestAgent(cronServices({})); + ctx = createTestAgent(cronServices({ _serviceBrand: undefined, agentId: 'main' })); cron = ctx.get(IAgentCronService); prompt = ctx.get(IAgentPromptService); telemetry = ctx.get(ITelemetryService); @@ -573,7 +573,7 @@ describe('AgentCronService', () => { beforeEach(() => { harness = createClocks(); - ctx = createTestAgent(cronServices({})); + ctx = createTestAgent(cronServices({ _serviceBrand: undefined, agentId: 'main' })); cron = ctx.get(IAgentCronService); prompt = ctx.get(IAgentPromptService); }); diff --git a/packages/agent-core-v2/test/cron/manual-tick.test.ts b/packages/agent-core-v2/test/cron/manual-tick.test.ts index 483e7c744..d4dbb1552 100644 --- a/packages/agent-core-v2/test/cron/manual-tick.test.ts +++ b/packages/agent-core-v2/test/cron/manual-tick.test.ts @@ -60,7 +60,7 @@ describe('AgentCronService — P1.8 manual tick + SIGUSR1', () => { beforeEach(() => { vi.stubEnv('KIMI_CRON_MANUAL_TICK', '1'); harness = createClocks(); - ctx = createTestAgent(cronServices({})); + ctx = createTestAgent(cronServices({ _serviceBrand: undefined, agentId: 'main' })); cron = ctx.get(IAgentCronService); prompt = ctx.get(IAgentPromptService); }); @@ -100,7 +100,7 @@ describe('AgentCronService — P1.8 manual tick + SIGUSR1', () => { vi.useFakeTimers(); vi.stubEnv('KIMI_CRON_POLL_INTERVAL_MS', '50'); harness = createClocks(); - ctx = createTestAgent(cronServices({})); + ctx = createTestAgent(cronServices({ _serviceBrand: undefined, agentId: 'main' })); cron = ctx.get(IAgentCronService); prompt = ctx.get(IAgentPromptService); }); @@ -135,7 +135,7 @@ describe('AgentCronService — P1.8 manual tick + SIGUSR1', () => { beforeEach(() => { vi.stubEnv('KIMI_CRON_MANUAL_TICK', '1'); listenerCountBeforeCreate = process.listenerCount('SIGUSR1'); - ctx = createTestAgent(cronServices({})); + ctx = createTestAgent(cronServices({ _serviceBrand: undefined, agentId: 'main' })); cron = ctx.get(IAgentCronService); }); @@ -208,7 +208,7 @@ describe('AgentCronService — P1.8 manual tick + SIGUSR1', () => { beforeEach(() => { vi.stubEnv('KIMI_CRON_MANUAL_TICK', '1'); vi.stubEnv('KIMI_CRON_DEBUG', '1'); - ctx = createTestAgent(cronServices({})); + ctx = createTestAgent(cronServices({ _serviceBrand: undefined, agentId: 'main' })); cron = ctx.get(IAgentCronService); }); @@ -244,7 +244,7 @@ describe('AgentCronService — P1.8 manual tick + SIGUSR1', () => { let cron: IAgentCronService; beforeEach(() => { - ctx = createTestAgent(cronServices({})); + ctx = createTestAgent(cronServices({ _serviceBrand: undefined, agentId: 'main' })); cron = ctx.get(IAgentCronService); }); diff --git a/packages/agent-core-v2/test/cron/resume.test.ts b/packages/agent-core-v2/test/cron/resume.test.ts index d1d3ae863..b344e7f66 100644 --- a/packages/agent-core-v2/test/cron/resume.test.ts +++ b/packages/agent-core-v2/test/cron/resume.test.ts @@ -137,7 +137,7 @@ describe('AgentCronService — persistence and resume', () => { harness.install(); ctx = createCronAgent( sessionDir, - cronServices({}), + cronServices({ _serviceBrand: undefined, agentId: 'main' }), ); cron = ctx.get(IAgentCronService); }); @@ -181,13 +181,13 @@ describe('AgentCronService — persistence and resume', () => { clockA.install(); ctx = createCronAgent( sessionDir, - cronServices({}), + cronServices({ _serviceBrand: undefined, agentId: 'main' }), ); cron = ctx.get(IAgentCronService); clockB.install(); resumedCtx = createCronAgent( sessionDir, - cronServices({}), + cronServices({ _serviceBrand: undefined, agentId: 'main' }), ); resumedCron = resumedCtx.get(IAgentCronService); }); @@ -229,13 +229,13 @@ describe('AgentCronService — persistence and resume', () => { clockA.install(); ctx = createCronAgent( sessionDir, - cronServices({}), + cronServices({ _serviceBrand: undefined, agentId: 'main' }), ); cron = ctx.get(IAgentCronService); clockB.install(); resumedCtx = createCronAgent( sessionDir, - cronServices({}), + cronServices({ _serviceBrand: undefined, agentId: 'main' }), ); resumedCron = resumedCtx.get(IAgentCronService); resumedPrompt = resumedCtx.get(IAgentPromptService); @@ -270,13 +270,13 @@ describe('AgentCronService — persistence and resume', () => { clockA.install(); ctx = createCronAgent( sessionDir, - cronServices({}), + cronServices({ _serviceBrand: undefined, agentId: 'main' }), ); cron = ctx.get(IAgentCronService); clockB.install(); resumedCtx = createCronAgent( sessionDir, - cronServices({}), + cronServices({ _serviceBrand: undefined, agentId: 'main' }), ); resumedCron = resumedCtx.get(IAgentCronService); resumedPrompt = resumedCtx.get(IAgentPromptService); @@ -319,14 +319,14 @@ describe('AgentCronService — persistence and resume', () => { clockA.install(); ctx = createCronAgent( sessionDir, - cronServices({}), + cronServices({ _serviceBrand: undefined, agentId: 'main' }), ); cron = ctx.get(IAgentCronService); prompt = ctx.get(IAgentPromptService); clockB.install(); resumedCtx = createCronAgent( sessionDir, - cronServices({}), + cronServices({ _serviceBrand: undefined, agentId: 'main' }), ); resumedCron = resumedCtx.get(IAgentCronService); resumedPrompt = resumedCtx.get(IAgentPromptService); @@ -372,13 +372,13 @@ describe('AgentCronService — persistence and resume', () => { clockA.install(); ctx = createCronAgent( sessionDir, - cronServices({}), + cronServices({ _serviceBrand: undefined, agentId: 'main' }), ); cron = ctx.get(IAgentCronService); clockB.install(); resumedCtx = createCronAgent( sessionDir, - cronServices({}), + cronServices({ _serviceBrand: undefined, agentId: 'main' }), ); resumedCron = resumedCtx.get(IAgentCronService); resumedPrompt = resumedCtx.get(IAgentPromptService); @@ -414,7 +414,7 @@ describe('AgentCronService — persistence and resume', () => { const harness = createClocks(); harness.install(); ctx = createTestAgent( - cronServices({}), + cronServices({ _serviceBrand: undefined, agentId: 'main' }), ); cron = ctx.get(IAgentCronService); }); diff --git a/packages/agent-core-v2/test/cron/subagent-skip.test.ts b/packages/agent-core-v2/test/cron/subagent-skip.test.ts index 5f86a1c7d..890986ac4 100644 --- a/packages/agent-core-v2/test/cron/subagent-skip.test.ts +++ b/packages/agent-core-v2/test/cron/subagent-skip.test.ts @@ -43,7 +43,7 @@ describe('Agent + Cron — subagent suppression', () => { beforeEach(() => { listenerCountBeforeCreate = process.listenerCount('SIGUSR1'); - ctx = createTestAgent(cronServices({ isSubagent: true })); + ctx = createTestAgent(cronServices({ _serviceBrand: undefined, agentId: 'sub-1' })); cron = ctx.get(IAgentCronService); profile = ctx.get(IAgentProfileService); }); diff --git a/packages/agent-core-v2/test/gateway/gateway.test.ts b/packages/agent-core-v2/test/gateway/gateway.test.ts index c2ebdac50..0efb7f4fb 100644 --- a/packages/agent-core-v2/test/gateway/gateway.test.ts +++ b/packages/agent-core-v2/test/gateway/gateway.test.ts @@ -74,7 +74,7 @@ describe('RestGateway', () => { onDidDispose: () => ({ dispose: () => {} }), create: () => Promise.resolve(agentHandle), createMain: () => Promise.resolve(agentHandle), - fork: () => Promise.resolve(agentHandle), + clone: () => Promise.resolve(agentHandle), getHandle: (id) => (id === 'main' ? agentHandle : undefined), list: () => [agentHandle], remove: () => Promise.resolve(), diff --git a/packages/agent-core-v2/test/harness/agent.ts b/packages/agent-core-v2/test/harness/agent.ts index 680ea9f68..1f1d42ca4 100644 --- a/packages/agent-core-v2/test/harness/agent.ts +++ b/packages/agent-core-v2/test/harness/agent.ts @@ -3,7 +3,6 @@ import { isAbsolute, relative, resolve } from 'node:path'; import { Readable, type Writable } from 'node:stream'; import { createControlledPromise } from '@antfu/utils'; -import type { Kaos } from '@moonshot-ai/kaos'; import { isToolCall, isToolCallPart, @@ -47,7 +46,6 @@ import { IAgentExternalHooksService, IAgentFileToolsService, IAgentFullCompactionService, - IKaos, IAgentLLMRequesterService, ILogService, IAgentMcpService, @@ -94,7 +92,8 @@ import { type AgentToolRunOverride, } from '#/index'; import type { IProcess } from '#/session/process'; -import type { AgentSwarmToolHost } from '#/agent/swarm/tools/agent-swarm'; +import { IExecContext, createExecContext } from '#/session/execContext'; +import { ISessionSwarmService } from '#/session/swarm'; import { Event } from '#/_base/event'; import { toDisposable } from '#/_base/di'; import type { PromisifyMethods } from '#/_base/utils/types'; @@ -151,7 +150,6 @@ import type { import { IAgentRecordService } from '#/agent/record'; import type { PathAccessOperation } from '#/session/workspaceContext'; import { createFakeAgentFs, createFakeProcessRunner } from '../tools/fixtures/fake-exec'; -import { createFakeKaos } from '../tools/fixtures/fake-kaos'; import { createScriptedGenerate } from './scripted-generate'; import { @@ -402,31 +400,32 @@ function defineServiceValue( } } -type KaosOverride = - | IKaos - | Kaos - | { readonly cwd?: string; readonly envLayers?: readonly Record[] }; +type ExecContextOverride = { + readonly cwd?: string; + readonly envLayers?: readonly Record[]; +}; /** * Session-scope override for the execution environment and derived atoms. */ export interface ExecEnvOverride { - readonly kaos?: KaosOverride; - readonly execContext?: { readonly cwd?: string; readonly envLayers?: readonly Record[] }; + readonly execContext?: ExecContextOverride; readonly agentFs?: ISessionAgentFileSystem | Partial; readonly processRunner?: ISessionProcessRunner | Partial; } /** * Register a fake execution-environment set for a test session. Any - * unspecified atom keeps the harness default fake `IKaos` and the real + * unspecified atom keeps the harness default `IExecContext` and the real * services backed by it. */ export function execEnvServices(override: ExecEnvOverride = {}): TestAgentServiceOverride { return sessionServices((reg) => { - const kaosOverride = override.kaos ?? override.execContext; - if (kaosOverride !== undefined) { - reg.defineInstance(IKaos, resolveKaosOverride(kaosOverride)); + if (override.execContext !== undefined) { + reg.defineInstance( + IExecContext, + createExecContext(override.execContext.cwd ?? '/workspace', override.execContext.envLayers), + ); } if (override.agentFs !== undefined) { reg.defineInstance(ISessionAgentFileSystem, resolveAgentFsOverride(override.agentFs)); @@ -438,46 +437,6 @@ export function execEnvServices(override: ExecEnvOverride = {}): TestAgentServic }); } -export function kaosServices(input: IKaos | Kaos): TestAgentServiceOverride { - return sessionService(IKaos, resolveKaosOverride(input)); -} - -function resolveKaosOverride(input: KaosOverride): IKaos { - if (isIKaos(input)) return input; - if (isKaosBackend(input)) return wrapKaos(input); - return wrapKaos(createFakeKaos(undefined, input.envLayers).withCwd(input.cwd ?? '/workspace')); -} - -function isIKaos(input: KaosOverride): input is IKaos { - return typeof (input as IKaos).backend === 'object' && typeof (input as IKaos).getcwd === 'function'; -} - -function isKaosBackend(input: KaosOverride): input is Kaos { - return typeof (input as Kaos).execWithEnv === 'function' && typeof (input as Kaos).getcwd === 'function'; -} - -function wrapKaos(backend: Kaos): IKaos { - return { - _serviceBrand: undefined, - get name() { - return backend.name; - }, - get cwd() { - return backend.getcwd(); - }, - get osEnv() { - return backend.osEnv; - }, - backend, - pathClass: () => backend.pathClass(), - normpath: (path) => backend.normpath(path), - gethome: () => backend.gethome(), - getcwd: () => backend.getcwd(), - withCwd: (cwd) => wrapKaos(backend.withCwd(cwd)), - withEnv: (env) => wrapKaos(backend.withEnv(env)), - }; -} - function resolveAgentFsOverride( input: ISessionAgentFileSystem | Partial, ): ISessionAgentFileSystem { @@ -666,9 +625,12 @@ export function agentToolServices(runOverride: AgentToolRunOverride): TestAgentS } export function swarmServices( - runQueued: AgentSwarmToolHost['runQueued'], + swarmService: ISessionSwarmService, ): TestAgentServiceOverride { - return agentService(IAgentSwarmService, new SyncDescriptor(AgentSwarmService, [runQueued])); + return [ + sessionService(ISessionSwarmService, swarmService), + agentService(IAgentSwarmService, new SyncDescriptor(AgentSwarmService)), + ]; } export function goalServices(options: GoalServiceOptions): TestAgentServiceOverride { @@ -682,7 +644,6 @@ export function replayServices(options: ReplayBuilderServiceOptions = {}): TestA /** * Build a fake `ISessionProcessRunner` whose `exec` returns a scripted * `IProcess` emitting `stdout` on stdout and exiting with `exitCode`. - * Replaces the v1 `createCommandKaos(stdout)` helper. */ export function createCommandRunner(stdout: string, exitCode = 0): ISessionProcessRunner { function createProcess(): IProcess { @@ -702,13 +663,6 @@ export function createCommandRunner(stdout: string, exitCode = 0): ISessionProce }); } -export function createCommandKaos(stdout: string, exitCode = 0): Kaos { - const runner = createCommandRunner(stdout, exitCode); - return createFakeKaos({ - execWithEnv: async (args, env) => runner.exec(args, { env }) as unknown as ReturnType, - }); -} - export function testAgent(...inputs: readonly TestAgentInput[]): AgentTestContext { return createTestAgent(...inputs); } @@ -1036,9 +990,9 @@ export class AgentTestContext { reg.defineInstance(ISessionInteractionService, this.createInteractionService()); reg.defineInstance(ISessionApprovalService, this.createApprovalService()); reg.defineInstance(ISessionQuestionService, this.createQuestionService()); - reg.defineInstance(IKaos, wrapKaos(createFakeKaos().withCwd(this.cwd))); + reg.defineInstance(IExecContext, createExecContext(this.cwd)); // Note: `ISessionAgentFileSystem` and `ISessionProcessRunner` are - // auto-registered by their service files and backed by `IKaos`. + // auto-registered by their service files and backed by `IExecContext`. // Tests that need a fake override them via `execEnvServices`. reg.defineInstance(ISessionTerminalBackend, createTerminalBackend()); reg.defineDescriptor(ISessionWorkspaceContext, new SyncDescriptor(SessionWorkspaceContextService)); @@ -1075,7 +1029,6 @@ export class AgentTestContext { IAgentPermissionGate, new SyncDescriptor(AgentPermissionGate, [{ agentId, - agentType: 'main', } satisfies PermissionGateOptions]), ); reg.defineDescriptor(IAgentCronService, new SyncDescriptor(AgentCronService, [{}])); @@ -1916,7 +1869,6 @@ function unavailableAgentToolRun(): AgentToolRunOverride { resume: fail, retry: fail, getProfileName: async () => undefined, - markDetached: () => {}, }; } diff --git a/packages/agent-core-v2/test/harness/index.ts b/packages/agent-core-v2/test/harness/index.ts index 8977ad19f..772d36128 100644 --- a/packages/agent-core-v2/test/harness/index.ts +++ b/packages/agent-core-v2/test/harness/index.ts @@ -4,7 +4,6 @@ export { agentServices, backgroundServices, configServices, - createCommandKaos, createCommandRunner, createTestAgent, appService, @@ -16,7 +15,6 @@ export { goalServices, homeDirServices, InMemoryWireRecordPersistence, - kaosServices, llmGenerateServices, logServices, mcpServices, diff --git a/packages/agent-core-v2/test/permission/permissionGate.test.ts b/packages/agent-core-v2/test/permission/permissionGate.test.ts index a224675a6..7aa3a4fca 100644 --- a/packages/agent-core-v2/test/permission/permissionGate.test.ts +++ b/packages/agent-core-v2/test/permission/permissionGate.test.ts @@ -199,7 +199,7 @@ describe('AgentPermissionGate', () => { it('adds subagent retry guidance to policy deny messages', async () => { policyResult = { policyName: 'p', result: { kind: 'deny', message: 'nope' } }; - const svc = make({ agentType: 'sub' }); + const svc = make({ agentId: 'sub-1' }); const retryGuidance = "Try a different approach — don't retry the same call, don't attempt to bypass the restriction."; diff --git a/packages/agent-core-v2/test/session-activity/sessionActivity.test.ts b/packages/agent-core-v2/test/session-activity/sessionActivity.test.ts index 41116b5af..66c191744 100644 --- a/packages/agent-core-v2/test/session-activity/sessionActivity.test.ts +++ b/packages/agent-core-v2/test/session-activity/sessionActivity.test.ts @@ -58,7 +58,7 @@ function lifecycle(handles: readonly IAgentScopeHandle[]): IAgentLifecycleServic onDidDispose: () => ({ dispose: () => {} }), create: () => Promise.resolve(handles[0]!), createMain: () => Promise.resolve(handles[0]!), - fork: () => Promise.resolve(handles[0]!), + clone: () => Promise.resolve(handles[0]!), getHandle: () => undefined, list: () => handles, remove: () => Promise.resolve(), diff --git a/packages/agent-core-v2/test/swarm/swarm.test.ts b/packages/agent-core-v2/test/swarm/swarm.test.ts index 11403059c..66ce46fd8 100644 --- a/packages/agent-core-v2/test/swarm/swarm.test.ts +++ b/packages/agent-core-v2/test/swarm/swarm.test.ts @@ -8,11 +8,12 @@ import { IAgentEventSinkService } from '#/agent/eventSink'; import { DEFAULT_SUBAGENT_TIMEOUT_MS, } from '#/agent/agentTool'; -import { - type QueuedSubagentRunResult, - type QueuedSubagentTask, -} from '#/agent/swarm'; import { IAgentLifecycleService } from '#/session/agent-lifecycle'; +import { ISessionSwarmService } from '#/session/swarm'; +import type { + SessionSwarmRunResult, + SessionSwarmTask, +} from '#/session/swarm'; import { IAgentScopeContext } from '#/agent/scopeContext'; import { IAgentSystemReminderService } from '#/agent/systemReminder'; import { AgentSystemReminderService } from '#/agent/systemReminder/systemReminderService'; @@ -38,18 +39,14 @@ function context( } function mockSwarmHost({ - getSwarmItem = () => undefined, - runQueued = vi.fn().mockResolvedValue([]), + run = vi.fn().mockResolvedValue([]), }: { - readonly getSwarmItem?: (agentId: string) => string | undefined; // eslint-disable-next-line @typescript-eslint/no-explicit-any - readonly runQueued?: (...args: any[]) => any; + readonly run?: (...args: any[]) => any; } = {}) { return { - lifecycle: {} as never, - parentAgentId: 'main', - getSwarmItem: vi.fn(getSwarmItem), - runQueued, + swarmService: { _serviceBrand: undefined, run, cancel: vi.fn() }, + callerAgentId: 'main', }; } @@ -70,6 +67,7 @@ describe('AgentSwarmService', () => { ix.stub(IAgentTurnService, stubTurnWithHooks()); ix.set(IAgentToolRegistryService, new SyncDescriptor(AgentToolRegistryService)); ix.stub(IAgentLifecycleService, {}); + ix.stub(ISessionSwarmService, { run: async () => [], cancel: () => {} }); ix.stub(IAgentScopeContext, { _serviceBrand: undefined, agentId: 'main' }); ix.set(IAgentSystemReminderService, new SyncDescriptor(AgentSystemReminderService)); ix.set(IAgentSwarmService, new SyncDescriptor(AgentSwarmService)); @@ -89,7 +87,7 @@ describe('AgentSwarmService', () => { describe('AgentSwarmTool', () => { it('applies one subagent_type across templated subagents', async () => { const host = mockSwarmHost({ - runQueued: vi.fn().mockResolvedValue([ + run: vi.fn().mockResolvedValue([ { task: { kind: 'spawn', @@ -165,8 +163,8 @@ describe('AgentSwarmTool', () => { const result = await executeTool(tool, context(input)); expect(swarmMode.enter).toHaveBeenCalledWith('tool'); - expect(host.runQueued).toHaveBeenCalledTimes(1); - expect(host.runQueued).toHaveBeenCalledWith(expect.objectContaining({ tasks: [ + expect(host.swarmService.run).toHaveBeenCalledTimes(1); + expect(host.swarmService.run).toHaveBeenCalledWith(expect.objectContaining({ tasks: [ { kind: 'spawn', data: { @@ -282,30 +280,43 @@ describe('AgentSwarmTool', () => { expect(result.output).toBe(testCase.output); expect(result.isError).toBe(true); - expect(host.runQueued).not.toHaveBeenCalled(); + expect(host.swarmService.run).not.toHaveBeenCalled(); } }); it('resumes mapped agents before spawning item subagents', async () => { - const runQueued = vi.fn( + let runCallCount = 0; + const run = vi.fn( async ({ tasks, }: { - tasks: readonly QueuedSubagentTask[]; - }): Promise>> => - tasks.map((task, index) => ({ + tasks: readonly SessionSwarmTask[]; + }): Promise>> => { + runCallCount++; + return tasks.map((task, index) => ({ task, - agentId: task.kind === 'resume' ? task.resumeAgentId : `agent-new-${String(index + 1)}`, + agentId: + task.kind === 'resume' + ? task.resumeAgentId + : runCallCount === 1 + ? `agent-old-${String(index + 1)}` + : `agent-new-${String(index + 1)}`, status: 'completed' as const, result: `result ${String(index + 1)}`, - })), + })); + }, ); - const host = mockSwarmHost({ - getSwarmItem: (agentId) => - ({ 'agent-old-1': 'src/old-a.ts', 'agent-old-2': 'src/old-b.ts' })[agentId], - runQueued, - }); + const host = mockSwarmHost({ run }); const tool = new AgentSwarmTool(host, 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 input = { description: 'Finish review', subagent_type: 'explore', @@ -327,7 +338,7 @@ describe('AgentSwarmTool', () => { const result = await executeTool(tool, context(input)); - expect(host.runQueued).toHaveBeenCalledWith(expect.objectContaining({ tasks: [ + expect(host.swarmService.run).toHaveBeenCalledWith(expect.objectContaining({ tasks: [ { kind: 'resume', data: { @@ -401,24 +412,38 @@ describe('AgentSwarmTool', () => { }); it('allows a single resumed subagent without item subagents', async () => { - const runQueued = vi.fn( + let runCallCount = 0; + const run = vi.fn( async ({ tasks, }: { - tasks: readonly QueuedSubagentTask[]; - }): Promise>> => - tasks.map((task) => ({ + tasks: readonly SessionSwarmTask[]; + }): Promise>> => { + runCallCount++; + return tasks.map((task, index) => ({ task, - agentId: task.kind === 'resume' ? task.resumeAgentId : 'agent-new', + agentId: + task.kind === 'resume' + ? task.resumeAgentId + : runCallCount === 1 + ? `agent-old-${String(index + 1)}` + : 'agent-new', status: 'completed' as const, result: 'resumed result', - })), + })); + }, ); - const host = mockSwarmHost({ - getSwarmItem: (agentId) => (agentId === 'agent-old-1' ? 'src/old-a.ts' : undefined), - runQueued, - }); + const host = mockSwarmHost({ run }); const tool = new AgentSwarmTool(host, 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: { @@ -428,7 +453,7 @@ describe('AgentSwarmTool', () => { const result = await executeTool(tool, context(input)); - expect(host.runQueued).toHaveBeenCalledWith(expect.objectContaining({ tasks: [ + expect(host.swarmService.run).toHaveBeenCalledWith(expect.objectContaining({ tasks: [ { kind: 'resume', data: { @@ -462,7 +487,7 @@ describe('AgentSwarmTool', () => { it('reports failed subagents inside the XML result without failing the tool', async () => { const host = mockSwarmHost({ - runQueued: vi.fn().mockImplementation(async ({ tasks }) => [ + run: vi.fn().mockImplementation(async ({ tasks }) => [ { task: tasks[0], agentId: 'agent-coder-1', @@ -503,7 +528,7 @@ describe('AgentSwarmTool', () => { it('omits resume hint when incomplete subagents have no agent ids', async () => { const host = mockSwarmHost({ - runQueued: vi.fn().mockImplementation(async ({ tasks }) => [ + run: vi.fn().mockImplementation(async ({ tasks }) => [ { task: tasks[0], status: 'failed', @@ -540,7 +565,7 @@ describe('AgentSwarmTool', () => { it('reports partial aborted subagents inside the XML result', async () => { const host = mockSwarmHost({ - runQueued: vi.fn().mockImplementation(async ({ tasks }) => [ + run: vi.fn().mockImplementation(async ({ tasks }) => [ { task: tasks[0], agentId: 'agent-coder-1', diff --git a/packages/agent-core-v2/test/tool/tool.test.ts b/packages/agent-core-v2/test/tool/tool.test.ts index df0a73e66..8ed4f449a 100644 --- a/packages/agent-core-v2/test/tool/tool.test.ts +++ b/packages/agent-core-v2/test/tool/tool.test.ts @@ -235,7 +235,6 @@ describe('Agent tools', () => { resume: vi.fn(), retry: vi.fn(), getProfileName: vi.fn().mockResolvedValue(undefined), - markDetached: vi.fn(), }; ctx = createTestAgent(agentToolServices(runOverride)); profile = ctx.get(IAgentProfileService); diff --git a/packages/agent-core-v2/test/turn/turn.test.ts b/packages/agent-core-v2/test/turn/turn.test.ts index 01ed27854..6bb5a1ff3 100644 --- a/packages/agent-core-v2/test/turn/turn.test.ts +++ b/packages/agent-core-v2/test/turn/turn.test.ts @@ -18,7 +18,7 @@ import { describe, expect, it, vi } from 'vitest'; import { abortError, abortable } from '#/_base/utils/abort'; import { ISessionAgentFileSystem } from '#/session/agentFs'; import type { ContextMessage } from '#/agent/contextMemory'; -import { IKaos } from '#/app/kaos'; +import { IHostEnvironment } from '#/app/hostEnvironment'; import { IOAuthService } from '#/app/auth'; import { ErrorCodes, KimiError } from '#/errors'; import { HookEngine } from '#/agent/externalHooks/engine'; @@ -33,9 +33,9 @@ import { IAgentTurnService } from '#/agent/turn'; import { IAgentToolRegistryService } from '#/agent/toolRegistry'; import type { IProcess, ISessionProcessRunner } from '#/session/process'; import type { - QueuedSubagentRunResult, - QueuedSubagentTask, -} from '#/agent/swarm'; + SessionSwarmRunResult as QueuedSubagentRunResult, + SessionSwarmTask as QueuedSubagentTask, +} from '#/session/swarm'; import { recordingTelemetry, type TelemetryRecord } from '../telemetry/stubs'; import { createFakeAgentFs, createFakeProcessRunner } from '../tools/fixtures/fake-exec'; import { @@ -1723,7 +1723,7 @@ describe('Agent turn flow', () => { }); const registration = registerMediaTools(ctx.get(IAgentToolRegistryService), { fs: ctx.get(ISessionAgentFileSystem), - kaos: ctx.get(IKaos), + env: ctx.get(IHostEnvironment), workspace: { workspaceDir: '/workspace', additionalDirs: [] }, capabilities: mediaCapabilities(), videoUploader, diff --git a/packages/protocol/src/events.ts b/packages/protocol/src/events.ts index 0547023d2..896a9a397 100644 --- a/packages/protocol/src/events.ts +++ b/packages/protocol/src/events.ts @@ -596,7 +596,7 @@ export interface SubagentSpawnedEvent { readonly subagentName: string; readonly parentToolCallId: string; readonly parentToolCallUuid?: string; - readonly parentAgentId?: string; + readonly callerAgentId?: string; readonly description?: string; readonly swarmIndex?: number; readonly runInBackground: boolean; @@ -1287,7 +1287,7 @@ export const subagentSpawnedEventSchema = z.object({ subagentName: z.string(), parentToolCallId: z.string(), parentToolCallUuid: z.string().optional(), - parentAgentId: z.string().optional(), + callerAgentId: z.string().optional(), description: z.string().optional(), swarmIndex: z.number().optional(), runInBackground: z.boolean(),