mirror of
https://github.com/MoonshotAI/kimi-code.git
synced 2026-08-18 21:25:40 +00:00
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:
parent
6633fcdd04
commit
7487bdf367
46 changed files with 481 additions and 757 deletions
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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');
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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';
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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) => {
|
||||
|
|
|
|||
|
|
@ -9,7 +9,6 @@ import type {
|
|||
|
||||
export interface PermissionGateOptions {
|
||||
readonly agentId?: string;
|
||||
readonly agentType?: 'main' | 'sub';
|
||||
}
|
||||
|
||||
export interface IAgentPermissionGate {
|
||||
|
|
|
|||
|
|
@ -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';
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -6,4 +6,3 @@
|
|||
|
||||
export * from './swarm';
|
||||
export * from './swarmService';
|
||||
export * from './subagentBatch';
|
||||
|
|
|
|||
|
|
@ -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),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
|
|
|
|||
|
|
@ -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';
|
||||
|
|
|
|||
|
|
@ -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>;
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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> {
|
||||
|
|
|
|||
|
|
@ -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, {
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
|
||||
|
|
|
|||
9
packages/agent-core-v2/src/session/swarm/index.ts
Normal file
9
packages/agent-core-v2/src/session/swarm/index.ts
Normal 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';
|
||||
62
packages/agent-core-v2/src/session/swarm/sessionSwarm.ts
Normal file
62
packages/agent-core-v2/src/session/swarm/sessionSwarm.ts
Normal 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');
|
||||
|
|
@ -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',
|
||||
);
|
||||
|
|
@ -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();
|
||||
}
|
||||
|
|
@ -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',
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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) => {
|
||||
|
|
|
|||
|
|
@ -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]));
|
||||
|
|
|
|||
|
|
@ -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',
|
||||
|
|
|
|||
|
|
@ -21,7 +21,6 @@ function agentTask(
|
|||
return new AgentBackgroundTask(
|
||||
{ agentId: 'agent-child', profileName: 'coder', completion },
|
||||
description,
|
||||
{ markActiveChildDetached: vi.fn() },
|
||||
new AbortController(),
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -47,7 +47,6 @@ function agentTask(
|
|||
return new AgentBackgroundTask(
|
||||
{ agentId: 'agent-child', profileName: 'coder', completion },
|
||||
description,
|
||||
{ markActiveChildDetached: vi.fn() },
|
||||
new AbortController(),
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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(),
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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) {
|
||||
|
|
|
|||
|
|
@ -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) {
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
});
|
||||
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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(),
|
||||
|
|
|
|||
|
|
@ -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: () => {},
|
||||
};
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -4,7 +4,6 @@ export {
|
|||
agentServices,
|
||||
backgroundServices,
|
||||
configServices,
|
||||
createCommandKaos,
|
||||
createCommandRunner,
|
||||
createTestAgent,
|
||||
appService,
|
||||
|
|
@ -16,7 +15,6 @@ export {
|
|||
goalServices,
|
||||
homeDirServices,
|
||||
InMemoryWireRecordPersistence,
|
||||
kaosServices,
|
||||
llmGenerateServices,
|
||||
logServices,
|
||||
mcpServices,
|
||||
|
|
|
|||
|
|
@ -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.";
|
||||
|
||||
|
|
|
|||
|
|
@ -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(),
|
||||
|
|
|
|||
|
|
@ -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',
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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(),
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue