refactor!(agent-core-v2): flatten agent hierarchy, lift swarm to session

- Drop parentAgentId/type from agent-lifecycle; forkedFrom is provenance only, not used by business logic
- Rename IAgentLifecycleService.fork() to clone(); add list({ prefix }) for prefix-based queries
- Lift swarm batch scheduling to Session scope (new SessionSwarmService); SubagentBatch becomes its internal scheduler, no longer exported
- AgentSwarmService keeps mode UI + tool registration, delegates batch runs to SessionSwarmService
- Move ownership/cancellation out of lifecycle: drop activeChildrenByParent/cancelAllChildren/markChildDetached/SubagentDetachHandle; callers own cancellation via abort signals
- cron/permissionGate: derive subagent behavior from agentId !== 'main' instead of type
- protocol: SubagentSpawnedEvent.parentAgentId -> callerAgentId (wire break)
- Remove stale IKaos imports left after kaos was dropped; use IExecContext
This commit is contained in:
haozhe.yang 2026-07-02 00:59:51 +08:00
parent 6633fcdd04
commit 7487bdf367
46 changed files with 481 additions and 757 deletions

View file

@ -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<Record<string, AgentToolSubagentProf
export interface AgentToolOptions {
readonly lifecycle: IAgentLifecycleService;
readonly parentAgentId: string;
readonly callerAgentId: string;
readonly metadata?: ISessionMetadata;
readonly background: IAgentBackgroundService;
readonly profile: IAgentProfileService;
@ -143,7 +142,7 @@ export class AgentTool implements BuiltinTool<AgentToolInput> {
readonly parameters: Record<string, unknown> = 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<AgentToolInput> {
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<AgentToolInput> {
resume: resumeChildAgent,
retry: retryChildAgent,
getProfileName: getChildProfileName,
markDetached: markChildDetached,
}
);
}
@ -209,7 +207,7 @@ export class AgentTool implements BuiltinTool<AgentToolInput> {
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<AgentToolInput> {
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<AgentToolInput> {
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);

View file

@ -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,

View file

@ -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<SubagentHandle>;
resume(args: ResumeChildAgentArgs): Promise<SubagentHandle>;
retry(args: RetryChildAgentArgs): Promise<SubagentHandle>;
getProfileName(args: GetChildProfileNameArgs): Promise<string | undefined>;
markDetached(args: MarkChildDetachedArgs): void;
};
type ActiveChild = {
readonly controller: AbortController;
runInBackground: boolean;
};
const activeChildrenByParent = new Map<string, Map<string, ActiveChild>>();
function childrenOf(parentAgentId: string): Map<string, ActiveChild> {
let children = activeChildrenByParent.get(parentAgentId);
if (children === undefined) {
children = new Map();
activeChildrenByParent.set(parentAgentId, children);
}
return children;
}
export async function spawnChildAgent(args: SpawnChildAgentArgs): Promise<SubagentHandle> {
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<SubagentHandle> {
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<SubagentHandle> {
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<Subage
export async function getChildProfileName(
args: GetChildProfileNameArgs,
): Promise<string | undefined> {
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<IAgentScopeHandle> {
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<IAgentScopeHandle> {
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<void> {
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');

View file

@ -17,10 +17,6 @@ export type SubagentHandle = {
readonly completion: Promise<SubagentCompletion>;
};
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<SubagentDetachHandle, 'markActiveChildDetached'>,
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,

View file

@ -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';

View file

@ -41,10 +41,6 @@ export interface CronTask {
/** Everything the caller supplies; `id` and `createdAt` are generated by the service. */
export type CronTaskInit = Omit<CronTask, 'id' | 'createdAt'>;
export interface CronOptions {
readonly isSubagent?: boolean;
}
export interface CronLoadOptions {
readonly replace?: boolean;
}

View file

@ -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<CronConfig>(CRON_SECTION) ?? DEFAULT_CRON_CONFIG;
this._register(
this.config.onDidChangeConfiguration((e) => {

View file

@ -9,7 +9,6 @@ import type {
export interface PermissionGateOptions {
readonly agentId?: string;
readonly agentType?: 'main' | 'sub';
}
export interface IAgentPermissionGate {

View file

@ -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';
}
}

View file

@ -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;
}

View file

@ -6,4 +6,3 @@
export * from './swarm';
export * from './swarmService';
export * from './subagentBatch';

View file

@ -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),
),
);
}

View file

@ -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<AgentSwarmToolInput> {
@ -154,9 +151,8 @@ export class AgentSwarmTool implements BuiltinTool<AgentSwarmToolInput> {
toolCallId: string,
): Promise<string> {
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<AgentSwarmSpec> => {
const specs = createAgentSwarmSpecs(args, (id) => swarmItems.get(id));
const tasks: SessionSwarmTask<AgentSwarmSpec>[] = specs.map((spec) => {
const descriptionName = spec.kind === 'resume' ? 'resume' : profileName;
const common = {
data: spec,
@ -173,21 +169,19 @@ export class AgentSwarmTool implements BuiltinTool<AgentSwarmToolInput> {
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<SubagentResult<AgentSwarmSpec>>;
});
for (const result of results) {
if (result.agentId !== undefined && result.task.swarmItem !== undefined) {
swarmItems.set(result.agentId, result.task.swarmItem);

View file

@ -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();

View file

@ -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';

View file

@ -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<string>;
create(opts: CreateAgentOptions): Promise<IAgentScopeHandle>;
createMain(): Promise<IAgentScopeHandle>;
/** Create a child agent that inherits the parent's profile and context history. */
fork(parentAgentId: string): Promise<IAgentScopeHandle>;
/** Clone an agent: copy its profile and context history into a new agent. */
clone(sourceAgentId: string): Promise<IAgentScopeHandle>;
getHandle(agentId: string): IAgentScopeHandle | undefined;
list(): readonly IAgentScopeHandle[];
list(filter?: AgentListFilter): readonly IAgentScopeHandle[];
remove(agentId: string): Promise<void>;
}

View file

@ -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<IAgentScopeHandle> {
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<IAgentScopeHandle> {
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<void> {

View file

@ -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<string> {
const child = await this.lifecycle.fork('main');
const child = await this.lifecycle.clone('main');
child.accessor
.get(IAgentSystemReminderService)
?.appendSystemReminder(SIDE_QUESTION_SYSTEM_REMINDER, {

View file

@ -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;
}

View file

@ -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';

View file

@ -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<T> = {
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<T = unknown> = SessionSwarmTaskBase<T> & {
readonly kind: 'spawn';
readonly resumeAgentId?: undefined;
};
export type SessionSwarmResumeTask<T = unknown> = SessionSwarmTaskBase<T> & {
readonly kind: 'resume';
readonly resumeAgentId: string;
};
export type SessionSwarmTask<T = unknown> = SessionSwarmSpawnTask<T> | SessionSwarmResumeTask<T>;
export interface SessionSwarmRunArgs<T = unknown> {
readonly callerAgentId: string;
readonly tasks: readonly SessionSwarmTask<T>[];
}
export interface SessionSwarmRunResult<T = unknown> {
readonly task: SessionSwarmTask<T>;
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<T>(args: SessionSwarmRunArgs<T>): Promise<readonly SessionSwarmRunResult<T>[]>;
cancel(args: { readonly callerAgentId: string }): void;
}
export const ISessionSwarmService: ServiceIdentifier<ISessionSwarmService> =
createDecorator<ISessionSwarmService>('sessionSwarmService');

View file

@ -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<string, AbortController>();
constructor(
@IAgentLifecycleService private readonly lifecycle: IAgentLifecycleService,
) {}
run<T>(args: SessionSwarmRunArgs<T>): Promise<readonly SessionSwarmRunResult<T>[]> {
const { callerAgentId, tasks } = args;
const controller = new AbortController();
this.inFlight.set(callerAgentId, controller);
const unlinks: Array<() => void> = [];
const linkedTasks: SessionSwarmTask<T>[] = 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',
);

View file

@ -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<T> = {
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<T = unknown> = SessionSwarmTask<T>;
export type SpawnQueuedSubagentTask<T = unknown> = BaseQueuedSubagentTask<T> & {
readonly kind: 'spawn';
readonly resumeAgentId?: undefined;
};
export type SubagentResult<T = unknown> = SessionSwarmRunResult<T>;
export type ResumeQueuedSubagentTask<T = unknown> = BaseQueuedSubagentTask<T> & {
readonly kind: 'resume';
readonly resumeAgentId: string;
};
export type QueuedSubagentTask<T = unknown> =
| SpawnQueuedSubagentTask<T>
| ResumeQueuedSubagentTask<T>;
export type SubagentResult<T = unknown> = {
readonly task: QueuedSubagentTask<T>;
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<T = unknown> = SubagentResult<T>;
export type QueuedSubagentRunResult<T = unknown> = SessionSwarmRunResult<T>;
export type SubagentSuspendedEvent = {
readonly task: QueuedSubagentTask;
@ -698,34 +658,4 @@ export function resolveSwarmMaxConcurrency(
return value;
}
export interface RunQueuedArgs<T> {
readonly lifecycle: IAgentLifecycleService;
readonly parentAgentId: string;
readonly metadata?: ISessionMetadata;
readonly tasks: readonly QueuedSubagentTask<T>[];
}
export function runChildAgentQueued<T>({
lifecycle,
parentAgentId,
metadata,
tasks,
}: RunQueuedArgs<T>): Promise<Array<SubagentResult<T>>> {
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();
}

View file

@ -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',
});
});

View file

@ -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) => {

View file

@ -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]));

View file

@ -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<string, IScopeHandle> | (() => 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<string, IScopeHandle>
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<ReturnType<typeof deferred<{ reason: string }>>> = [];
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<void> => 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<void>();
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',

View file

@ -21,7 +21,6 @@ function agentTask(
return new AgentBackgroundTask(
{ agentId: 'agent-child', profileName: 'coder', completion },
description,
{ markActiveChildDetached: vi.fn() },
new AbortController(),
);
}

View file

@ -47,7 +47,6 @@ function agentTask(
return new AgentBackgroundTask(
{ agentId: 'agent-child', profileName: 'coder', completion },
description,
{ markActiveChildDetached: vi.fn() },
new AbortController(),
);
}

View file

@ -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(),
);
}

View file

@ -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<SubagentDetachHandle, 'markActiveChildDetached'>;
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) {

View file

@ -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<SubagentDetachHandle, 'markActiveChildDetached'>;
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) {

View file

@ -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();

View file

@ -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);
});

View file

@ -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);
});

View file

@ -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);
});

View file

@ -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);
});

View file

@ -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(),

View file

@ -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<T>(
}
}
type KaosOverride =
| IKaos
| Kaos
| { readonly cwd?: string; readonly envLayers?: readonly Record<string, string>[] };
type ExecContextOverride = {
readonly cwd?: string;
readonly envLayers?: readonly Record<string, string>[];
};
/**
* 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<string, string>[] };
readonly execContext?: ExecContextOverride;
readonly agentFs?: ISessionAgentFileSystem | Partial<ISessionAgentFileSystem>;
readonly processRunner?: ISessionProcessRunner | Partial<ISessionProcessRunner>;
}
/**
* 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>,
): 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<Kaos['execWithEnv']>,
});
}
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: () => {},
};
}

View file

@ -4,7 +4,6 @@ export {
agentServices,
backgroundServices,
configServices,
createCommandKaos,
createCommandRunner,
createTestAgent,
appService,
@ -16,7 +15,6 @@ export {
goalServices,
homeDirServices,
InMemoryWireRecordPersistence,
kaosServices,
llmGenerateServices,
logServices,
mcpServices,

View file

@ -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.";

View file

@ -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(),

View file

@ -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<Input>(
}
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 <T>({
tasks,
}: {
tasks: readonly QueuedSubagentTask<T>[];
}): Promise<Array<QueuedSubagentRunResult<T>>> =>
tasks.map((task, index) => ({
tasks: readonly SessionSwarmTask<T>[];
}): Promise<Array<SessionSwarmRunResult<T>>> => {
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 <T>({
tasks,
}: {
tasks: readonly QueuedSubagentTask<T>[];
}): Promise<Array<QueuedSubagentRunResult<T>>> =>
tasks.map((task) => ({
tasks: readonly SessionSwarmTask<T>[];
}): Promise<Array<SessionSwarmRunResult<T>>> => {
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',

View file

@ -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);

View file

@ -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,

View file

@ -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(),