refactor(agent-core-v2): dissolve subagent domain into agentTool + swarm

- remove SubAgentHost / ISessionSubagentHost; a subagent is now a plain
  agent scope created via IAgentLifecycleService and driven through the
  child agent's own turn/loop services
- add IAgentLifecycleService.fork(parentAgentId); startBtw forks main
- move the Agent collaboration tool into the new agentTool domain
  (stateless runChildAgent helpers, no runner class, no subagent service)
- move SubagentBatch + runChildAgentQueued into the swarm domain
- seed IAgentScopeContext (agentId) per agent so the Agent tool and swarm
  can name themselves as the parent
This commit is contained in:
haozhe.yang 2026-07-01 20:25:11 +08:00
parent f5bebda7d5
commit ea658c26a6
41 changed files with 1618 additions and 1431 deletions

View file

@ -35,18 +35,24 @@ const TEST_ROOT = join(PKG_ROOT, 'test');
const DOMAIN_LAYER = new Map([
// L0 — base infrastructure
['_base', 0],
// `_base/execEnv` (pure execution-env helpers such as
// `probeHostEnvironmentFromNode`, `decodeTextWithErrors`,
// `globPatternToRegex`, `BufferedReadable`) sits under `_base/*`, so the
// `_base` L0 entry already covers it — no separate entry needed.
// `errors` is a top-level facade (src/errors.ts) that aggregates every
// domain's error codes; any domain may import it, so it sits at L0.
['errors', 0],
// `kaos` is the execution-environment substrate (cwd/env/osEnv/backend);
// it wraps the `@moonshot-ai/kaos` package and depends on no business
// domain, so it sits at L0 where any domain may import it.
['kaos', 0],
// L1 — abstraction bridges & low-level capabilities
['log', 1],
['sessionLog', 1],
['telemetry', 1],
['bootstrap', 1],
// `hostEnvironment` is the App-scope OS/shell/path/home probe snapshot;
// low-level substrate that any Session/Agent domain may read synchronously.
['hostEnvironment', 1],
// `execContext` is the Session-scope seeded immutable value (`cwd`,
// `envLayers`); same layer as the other low-level bridges.
['execContext', 1],
['hostFs', 1],
['workspaceContext', 1],
['chatProvider', 1],
@ -94,6 +100,7 @@ const DOMAIN_LAYER = new Map([
['plan', 4],
['goal', 4],
['swarm', 4],
['scopeContext', 4],
['usage', 4],
['tooldedup', 4],
['contextMemory', 4],
@ -119,7 +126,7 @@ const DOMAIN_LAYER = new Map([
['background', 5],
['mcp', 5],
['cron', 5],
['subagentHost', 5],
['agentTool', 5],
// L6 — coordination
['agent-lifecycle', 6],
['session-lifecycle', 6],
@ -205,6 +212,10 @@ function domainFromRel(rel, { exemptRootFile }) {
*/
const ALLOWED_EXCEPTIONS = new Set([
'bootstrap>globalSkillCatalog',
// path-access (base tool policy) needs the `IHostEnvironment` type to stay
// host-aware (path class, home dir). Structural type dependency only —
// path-access does not construct or resolve the service.
'_base>hostEnvironment',
'permissionGate>approval',
'userTool>interaction',
'skill>turn',
@ -237,7 +248,10 @@ const ALLOWED_EXCEPTIONS = new Set([
'shellTools>background',
'skill>contextMemory',
'skill>prompt',
'swarm>subagentHost',
'swarm>agentTool',
'swarm>session-metadata',
'agentTool>agent-lifecycle',
'agentTool>session-metadata',
'toolExecutor>loop',
'userTool>profile',
'wireRecord>contextMemory',

View file

@ -1,15 +1,10 @@
/**
* AgentTool collaboration tool for spawning task subagents.
* `agentTool` domain (L5) `Agent` collaboration tool.
*
* Unlike the built-in tools (Read/Write/Edit/Bash/Grep/Glob), this is a
* "collaboration tool". It uses `ISessionSubagentHost` (injected via the constructor
* rather than through the runtime) to create in-process subagent loop instances.
*
* Foreground and background subagents both run through the background service.
* Foreground calls wait for the task to finish unless it is detached through
* the background-task RPC.
*
* `ToolResult.content` is textual; the structured output exposed by
* Spawns a task subagent (an ordinary Agent scope) through the `runChildAgent`
* helpers and tracks its completion through the `background` service. Foreground
* calls wait for the task to finish unless it is detached through the
* background-task RPC. `ToolResult.content` is textual; the structured
* `AgentToolOutputSchema` is only used for drift-guard and is not consumed at
* runtime.
*/
@ -27,16 +22,31 @@ import type {
ExecutableToolResult,
ToolExecution,
} from '#/agent/tool';
import { isUserCancellation } from '#/_base/utils/abort';
import {
AgentBackgroundTask,
type IAgentBackgroundService,
type RegisterBackgroundTaskOptions,
} from '#/agent/background';
import type { IAgentProfileService } from '#/agent/profile';
import type { IAgentLifecycleService } from '#/session/agent-lifecycle';
import type { ISessionMetadata } from '#/session/session-metadata';
import { toInputJsonSchema } from '#/_base/tools/support/input-schema';
import { matchesGlobRuleSubject } from '#/_base/tools/support/rule-match';
import {
getChildProfileName,
markChildDetached,
resumeChildAgent,
retryChildAgent,
spawnChildAgent,
type AgentToolRunOverride,
} from './runChildAgent';
import {
DEFAULT_SUBAGENT_TIMEOUT_DESCRIPTION,
DEFAULT_SUBAGENT_TIMEOUT_MS,
type SubagentHandle,
type ISessionSubagentHost,
} from './subagentHost';
import { isUserCancellation } from '#/_base/utils/abort';
import { AgentBackgroundTask, type IAgentBackgroundService, type RegisterBackgroundTaskOptions } from '#/agent/background';
import { toInputJsonSchema } from '#/_base/tools/support/input-schema';
import { matchesGlobRuleSubject } from '#/_base/tools/support/rule-match';
} from './types';
import { DEFAULT_AGENT_SUBAGENT_PROFILES } from './profiles';
import AGENT_BACKGROUND_DISABLED_DESCRIPTION from './agent-background-disabled.md?raw';
import AGENT_BACKGROUND_DESCRIPTION from './agent-background-enabled.md?raw';
import AGENT_DESCRIPTION_BASE from './agent.md?raw';
@ -114,33 +124,64 @@ export interface AgentToolSubagentProfile {
export type AgentToolSubagentMap = Readonly<Record<string, AgentToolSubagentProfile>>;
export interface AgentToolOptions {
readonly lifecycle: IAgentLifecycleService;
readonly parentAgentId: string;
readonly metadata?: ISessionMetadata;
readonly background: IAgentBackgroundService;
readonly profile: IAgentProfileService;
readonly cwd: string;
readonly processRunner: ISessionProcessRunner;
readonly log?: ILogger;
readonly runOverride?: AgentToolRunOverride;
}
// ── AgentTool class ──────────────────────────────────────────────────
export class AgentTool implements BuiltinTool<AgentToolInput> {
readonly name: string = 'Agent';
readonly parameters: Record<string, unknown> = toInputJsonSchema(AgentToolInputSchema);
constructor(
private readonly subagentHost: ISessionSubagentHost,
private readonly background: IAgentBackgroundService,
subagents?: AgentToolSubagentMap | undefined,
options?: {
log?: ILogger;
canRunInBackground?: (() => boolean) | undefined;
gitContext?: { cwd: string; runner: ISessionProcessRunner };
},
) {
this.canRunInBackground = options?.canRunInBackground ?? (() => true);
this.gitContext = options?.gitContext;
const log = options?.log;
this.typeLines = buildSubagentDescriptions(subagents);
this.log = log;
private readonly lifecycle: IAgentLifecycleService;
private readonly parentAgentId: string;
private readonly metadata?: ISessionMetadata;
private readonly background: IAgentBackgroundService;
private readonly log?: ILogger;
private readonly runOverride?: AgentToolRunOverride;
private readonly typeLines: string;
private readonly gitContext: { cwd: string; runner: ISessionProcessRunner };
constructor(options: AgentToolOptions) {
this.lifecycle = options.lifecycle;
this.parentAgentId = options.parentAgentId;
this.metadata = options.metadata;
this.background = options.background;
this.log = options.log;
this.runOverride = options.runOverride;
this.gitContext = { cwd: options.cwd, runner: options.processRunner };
this.typeLines = buildSubagentDescriptions(DEFAULT_AGENT_SUBAGENT_PROFILES);
this.canRunInBackground = () => {
return (
options.profile.isToolActive('TaskList') &&
options.profile.isToolActive('TaskOutput') &&
options.profile.isToolActive('TaskStop')
);
};
}
private readonly log?: ILogger;
private readonly canRunInBackground: () => boolean;
private readonly typeLines: string;
private readonly gitContext?: { cwd: string; runner: ISessionProcessRunner };
private get run(): AgentToolRunOverride {
return (
this.runOverride ?? {
spawn: spawnChildAgent,
resume: resumeChildAgent,
retry: retryChildAgent,
getProfileName: getChildProfileName,
markDetached: markChildDetached,
}
);
}
get description(): string {
const backgroundDescription = this.canRunInBackground()
@ -165,7 +206,13 @@ export class AgentTool implements BuiltinTool<AgentToolInput> {
let profileName = requestedProfileName ?? 'coder';
if (resumeAgentId !== undefined && resumeAgentId.length > 0) {
profileName = (await this.subagentHost.getProfileName(resumeAgentId)) ?? 'subagent';
profileName =
(await this.run.getProfileName({
lifecycle: this.lifecycle,
parentAgentId: this.parentAgentId,
metadata: this.metadata,
agentId: resumeAgentId,
})) ?? 'subagent';
}
const prefix = args.run_in_background === true ? 'Launching background' : 'Launching';
return {
@ -185,10 +232,7 @@ export class AgentTool implements BuiltinTool<AgentToolInput> {
private async execution(
args: AgentToolInput,
{
toolCallId,
signal,
}: ExecutableToolContext,
{ toolCallId, signal }: ExecutableToolContext,
): Promise<ExecutableToolResult> {
try {
signal.throwIfAborted();
@ -238,8 +282,17 @@ export class AgentTool implements BuiltinTool<AgentToolInput> {
try {
handle =
operation === 'resume'
? await this.subagentHost.resume(resumeAgentId!, runOptions)
: await this.subagentHost.spawn({
? await this.run.resume({
lifecycle: this.lifecycle,
parentAgentId: this.parentAgentId,
metadata: this.metadata,
agentId: resumeAgentId!,
...runOptions,
})
: await this.run.spawn({
lifecycle: this.lifecycle,
parentAgentId: this.parentAgentId,
metadata: this.metadata,
profileName: requestedProfileName ?? 'coder',
...runOptions,
});
@ -264,7 +317,15 @@ export class AgentTool implements BuiltinTool<AgentToolInput> {
signal: runInBackground ? undefined : signal,
};
taskId = this.background.registerTask(
new AgentBackgroundTask(handle, args.description, this.subagentHost, controller),
new AgentBackgroundTask(
handle,
args.description,
{
markActiveChildDetached: (agentId) =>
this.run.markDetached({ parentAgentId: this.parentAgentId, agentId }),
},
controller,
),
registerOptions,
);
signal.removeEventListener('abort', abortBeforeRegister);
@ -286,24 +347,14 @@ export class AgentTool implements BuiltinTool<AgentToolInput> {
if (runInBackground) {
return {
output: formatBackgroundAgentResult(
taskId,
handle,
args.description,
allowBackground,
),
output: formatBackgroundAgentResult(taskId, handle, args.description, allowBackground),
};
}
const release = await this.background.waitForForegroundRelease(taskId);
if (release === 'detached') {
return {
output: formatBackgroundAgentResult(
taskId,
handle,
args.description,
allowBackground,
),
output: formatBackgroundAgentResult(taskId, handle, args.description, allowBackground),
};
}
return await this.formatForegroundResult(taskId, handle);
@ -313,13 +364,9 @@ export class AgentTool implements BuiltinTool<AgentToolInput> {
}
private async withGitContext(profileName: string, prompt: string): Promise<string> {
if (profileName !== 'explore' || this.gitContext === undefined) return prompt;
if (profileName !== 'explore') return prompt;
try {
const context = await collectGitContext(
this.gitContext.runner,
this.gitContext.cwd,
this.log,
);
const context = await collectGitContext(this.gitContext.runner, this.gitContext.cwd, this.log);
return context.length > 0 ? `${context}\n\n${prompt}` : prompt;
} catch {
return prompt;
@ -333,10 +380,7 @@ export class AgentTool implements BuiltinTool<AgentToolInput> {
const info = this.background.getTask(taskId);
if (info?.status === 'completed') {
return {
output: formatForegroundAgentSuccess(
handle,
await this.background.readOutput(taskId),
),
output: formatForegroundAgentSuccess(handle, await this.background.readOutput(taskId)),
};
}
const timedOut = info?.status === 'timed_out';
@ -416,8 +460,7 @@ function launchErrorMessage(error: unknown, signal: AbortSignal): string {
return error instanceof Error ? error.message : String(error);
}
function buildSubagentDescriptions(subagents: AgentToolSubagentMap | undefined): string {
if (subagents === undefined) return '';
function buildSubagentDescriptions(subagents: AgentToolSubagentMap): string {
return Object.entries(subagents)
.map(([name, subagent]) => {
const details = [subagent.description, subagent.whenToUse].filter(

View file

@ -0,0 +1,72 @@
/**
* `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
* `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
* identity through `scopeContext`, creates child agents through
* `agent-lifecycle`, reads the parent check through `session-metadata`, gates
* background execution through the agent `profile`, and gathers git context
* through `execContext` (cwd) + `process` (runner).
*/
import { Disposable } from '#/_base/di';
import { InstantiationType } from '#/_base/di/extensions';
import { LifecycleScope, registerScopedService } from '#/_base/di/scope';
import { IAgentBackgroundService } from '#/agent/background';
import { IAgentProfileService } from '#/agent/profile';
import { IAgentScopeContext } from '#/agent/scopeContext';
import { IAgentToolRegistryService } from '#/agent/toolRegistry';
import { IExecContext } from '#/session/execContext';
import { ILogService } from '#/app/log';
import { IAgentLifecycleService } from '#/session/agent-lifecycle';
import { ISessionProcessRunner } from '#/session/process';
import { ISessionMetadata } from '#/session/session-metadata';
import { AgentTool } from './agentTool';
import { IAgentToolService } from './agentToolServiceToken';
import type { AgentToolRunOverride } from './runChildAgent';
export class AgentToolService extends Disposable implements IAgentToolService {
declare readonly _serviceBrand: undefined;
constructor(
runner: AgentToolRunOverride | undefined,
@IAgentScopeContext ctx: IAgentScopeContext,
@IAgentLifecycleService lifecycle: IAgentLifecycleService,
@ISessionMetadata metadata: ISessionMetadata,
@IAgentToolRegistryService toolRegistry: IAgentToolRegistryService,
@IAgentBackgroundService background: IAgentBackgroundService,
@IAgentProfileService profile: IAgentProfileService,
@IExecContext execCtx: IExecContext,
@ISessionProcessRunner processRunner: ISessionProcessRunner,
@ILogService log?: ILogService,
) {
super();
this._register(
toolRegistry.register(
new AgentTool({
lifecycle,
parentAgentId: ctx.agentId,
metadata,
background,
profile,
cwd: execCtx.cwd,
processRunner,
log,
runOverride: runner,
}),
),
);
}
}
registerScopedService(
LifecycleScope.Agent,
IAgentToolService,
AgentToolService,
InstantiationType.Delayed,
'agentTool',
);

View file

@ -0,0 +1,19 @@
/**
* `agentTool` domain (L5) `IAgentToolService` token.
*
* Exposes only the service identifier for the Agent-scoped `Agent` tool
* registrar so consumers (for example `runChildAgent`, which force-instantiates
* it for child agents, and `rpc`, which force-instantiates it for the main
* agent) can resolve the binding without pulling in the registrar's import
* graph. Kept separate from the implementation to avoid an import cycle through
* `agentTool` `runChildAgent`. Bound at Agent scope.
*/
import { createDecorator, type ServiceIdentifier } from '#/_base/di/instantiation';
export interface IAgentToolService {
readonly _serviceBrand: undefined;
}
export const IAgentToolService: ServiceIdentifier<IAgentToolService> =
createDecorator<IAgentToolService>('agentToolService');

View file

@ -0,0 +1,25 @@
/**
* `agentTool` domain barrel re-exports the child-agent run contract and
* helpers (`runChildAgent`), the `Agent` collaboration tool, the default
* profiles, and the Agent-scoped registrar (`agentToolService`) plus its token.
* Importing this barrel registers the `IAgentToolService` binding into the scope
* registry.
*/
export * from './types';
export * from './runChildAgent';
export * from './agentToolServiceToken';
export * from './profiles';
export * from './agentToolService';
export {
AgentTool,
AgentToolInputSchema,
AgentToolOutputSchema,
} from './agentTool';
export type {
AgentToolInput,
AgentToolOptions,
AgentToolOutput,
AgentToolSubagentMap,
AgentToolSubagentProfile,
} from './agentTool';

View file

@ -0,0 +1,520 @@
/**
* `agentTool` domain (L5) runs a child 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
* 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.
*/
import {
APIProviderRateLimitError,
isProviderRateLimitError,
type TokenUsage,
} from '@moonshot-ai/kosong';
import { linkAbortSignal, userCancellationReason } from '#/_base/utils/abort';
import { IAgentLifecycleService } from '#/session/agent-lifecycle';
import type { IScopeHandle } from '#/_base/di/scope';
import {
IAgentContextMemoryService,
type ContextMessage,
type PromptOrigin,
} from '#/agent/contextMemory';
import { ErrorCodes, toKimiErrorPayload, type KimiErrorPayload } from '#/errors';
import { IAgentEventSinkService } from '#/agent/eventSink';
import { IAgentExternalHooksService } from '#/agent/externalHooks';
import { isAbortError } from '#/agent/loop/errors';
import { IAgentProfileService } from '#/agent/profile';
import { ISessionMetadata } from '#/session/session-metadata';
import { ITelemetryService } from '#/app/telemetry';
import { IAgentPromptService } from '#/agent/prompt';
import { IAgentUsageService } from '#/agent/usage';
import type { Turn } from '#/agent/turn';
import { IAgentToolService } from './agentToolServiceToken';
import { DEFAULT_AGENT_SUBAGENT_PROFILES, EXPLORE_ROLE_ADDITIONAL } from './profiles';
import {
DEFAULT_SUBAGENT_TIMEOUT_MS,
DEFAULT_SUBAGENT_TIMEOUT_DESCRIPTION,
type RunSubagentOptions,
type SpawnSubagentOptions,
type SubagentHandle,
} from './types';
import SUMMARY_CONTINUATION_PROMPT from './summary-continuation.md?raw';
const SUBAGENT_PROMPT_ORIGIN: PromptOrigin = { kind: 'system_trigger', name: 'subagent' };
const SUMMARY_MIN_LENGTH = 200;
const SUMMARY_CONTINUATION_ATTEMPTS = 1;
const HOOK_TEXT_PREVIEW_LENGTH = 500;
export type RunContext = {
readonly lifecycle: IAgentLifecycleService;
readonly parentAgentId: string;
readonly metadata?: ISessionMetadata;
};
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;
options.signal.throwIfAborted();
const parent = await ensureParent(lifecycle, parentAgentId);
const child = await lifecycle.create({
parentAgentId,
cwd: parent.accessor.get(IAgentProfileService).data().cwd,
type: 'sub',
swarmItem: options.swarmItem,
});
configureChild(parent, child, options.profileName);
ensureAgentTool(child);
emitSpawned(parent, parentAgentId, child.id, options.profileName, options);
const completion = runWithActiveChild(
parentAgentId,
child,
options,
parent,
options.profileName,
(turnRef, controller) => runPromptTurn(child, parent, 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;
options.signal.throwIfAborted();
const parent = await ensureParent(lifecycle, parentAgentId);
const child = await requireChild(lifecycle, parentAgentId, metadata, agentId);
const profileName = child.accessor.get(IAgentProfileService).data().profileName ?? 'subagent';
emitSpawned(parent, parentAgentId, child.id, profileName, options);
const completion = runWithActiveChild(
parentAgentId,
child,
options,
parent,
profileName,
(turnRef, controller) => runPromptTurn(child, parent, 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;
options.signal.throwIfAborted();
const parent = await ensureParent(lifecycle, parentAgentId);
const child = await requireChild(lifecycle, parentAgentId, metadata, agentId);
const profileName = child.accessor.get(IAgentProfileService).data().profileName ?? 'subagent';
emitSpawned(parent, parentAgentId, child.id, profileName, options);
const completion = runWithActiveChild(
parentAgentId,
child,
options,
parent,
profileName,
(turnRef, controller) => runRetryTurn(child, parent, options, profileName, turnRef, controller),
);
return { agentId, profileName, resumed: true, completion };
}
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 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(
lifecycle: IAgentLifecycleService,
parentAgentId: string,
): Promise<IScopeHandle> {
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<IScopeHandle> {
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;
}
function ensureAgentTool(child: IScopeHandle): void {
// Force-instantiate the child agent's `Agent` tool registrar so its `Agent`
// tool is registered before the child's first turn builds its tool list.
child.accessor.get(IAgentToolService);
}
function configureChild(parent: IScopeHandle, child: IScopeHandle, profileName: string): void {
const parentProfile = parent.accessor.get(IAgentProfileService);
const childProfile = child.accessor.get(IAgentProfileService);
const parentData = parentProfile.data();
const profile = DEFAULT_AGENT_SUBAGENT_PROFILES[profileName];
const activeToolNames =
profileName === 'coder'
? (parentData.activeToolNames ?? profile?.tools)
: profile?.tools;
childProfile.update({
cwd: parentData.cwd,
modelAlias: parentData.modelAlias,
thinkingLevel: parentData.thinkingLevel,
profileName,
systemPrompt:
profileName === 'explore'
? `${parentData.systemPrompt}\n\n${EXPLORE_ROLE_ADDITIONAL}`
: parentData.systemPrompt,
activeToolNames,
});
}
function emitSpawned(
parent: IScopeHandle,
parentAgentId: string,
subagentId: string,
profileName: string,
options: RunSubagentOptions,
): void {
parent.accessor.get(IAgentEventSinkService)?.emit({
type: 'subagent.spawned',
subagentId,
subagentName: profileName,
parentToolCallId: options.parentToolCallId,
parentToolCallUuid: options.parentToolCallUuid,
parentAgentId,
description: options.description,
swarmIndex: options.swarmIndex,
runInBackground: options.runInBackground,
});
parent.accessor.get(ITelemetryService)?.track('subagent_created', {
subagent_name: profileName,
run_in_background: options.runInBackground,
});
}
function emitStarted(parent: IScopeHandle, subagentId: string): void {
parent.accessor.get(IAgentEventSinkService)?.emit({ type: 'subagent.started', subagentId });
}
function emitCompleted(
parent: IScopeHandle,
subagentId: string,
resultSummary: string,
usage?: TokenUsage,
): void {
parent.accessor.get(IAgentEventSinkService)?.emit({
type: 'subagent.completed',
subagentId,
resultSummary,
usage,
});
}
function emitFailed(
parent: IScopeHandle,
subagentId: string,
error: unknown,
options: RunSubagentOptions,
): void {
if (isAbortError(error)) return;
if (shouldSuppressQueuedAttemptFailureEvent(options, error)) return;
parent.accessor.get(IAgentEventSinkService)?.emit({
type: 'subagent.failed',
subagentId,
error: errorMessage(error),
});
}
async function triggerSubagentStart(
parent: IScopeHandle,
profileName: string,
prompt: string,
signal: AbortSignal,
): Promise<void> {
await parent.accessor.get(IAgentExternalHooksService)?.triggerSubagentStart(
{
agentName: profileName,
prompt: prompt.slice(0, HOOK_TEXT_PREVIEW_LENGTH),
},
signal,
);
}
function triggerSubagentStop(parent: IScopeHandle, profileName: string, result: string): void {
parent.accessor.get(IAgentExternalHooksService)?.triggerSubagentStop({
agentName: profileName,
response: result.slice(0, HOOK_TEXT_PREVIEW_LENGTH),
});
}
function observeFirstRequest(turn: Turn, options: RunSubagentOptions): void {
if (options.onReady === undefined) return;
void turn.ready.then(() => options.onReady?.()).catch(() => {});
}
async function runWithActiveChild(
parentAgentId: string,
child: IScopeHandle,
options: RunSubagentOptions,
parent: IScopeHandle,
profileName: string,
run: (
turn: { current?: Turn },
controller: AbortController,
) => 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);
try {
const result = await run(turnRef, controller);
emitCompleted(parent, child.id, result.result, result.usage);
triggerSubagentStop(parent, profileName, result.result);
return result;
} catch (error) {
emitFailed(parent, 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: IScopeHandle,
parent: IScopeHandle,
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);
options.signal.throwIfAborted();
const turn = child.accessor.get(IAgentPromptService).prompt({
role: 'user',
content: [{ type: 'text', text: options.prompt }],
toolCalls: [],
origin: SUBAGENT_PROMPT_ORIGIN,
});
if (turn === undefined) {
throw new Error('Subagent turn could not be started');
}
turnRef.current = turn;
observeFirstRequest(turn, options);
const result = await awaitTurn(turn, controller);
classifyTurnResult(result);
const summary = await completeSummary(child, controller, turnRef);
const usage = child.accessor.get(IAgentUsageService)?.status().total;
return { result: summary, usage };
}
async function runRetryTurn(
child: IScopeHandle,
parent: IScopeHandle,
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);
options.signal.throwIfAborted();
const turn = child.accessor.get(IAgentPromptService).retry('agent-host');
if (turn === undefined) {
throw new Error(`Agent instance "${child.id}" could not start a retry turn`);
}
turnRef.current = turn;
observeFirstRequest(turn, options);
const result = await awaitTurn(turn, controller);
classifyTurnResult(result);
const summary = await completeSummary(child, controller, turnRef);
const usage = child.accessor.get(IAgentUsageService)?.status().total;
return { result: summary, usage };
}
async function awaitTurn(
turn: Turn,
controller: AbortController,
): Promise<{ reason: string; error?: unknown }> {
const onAbort = (): void => {
turn.abortController.abort(controller.signal.reason);
};
controller.signal.addEventListener('abort', onAbort, { once: true });
try {
return await Promise.race([turn.result, abortPromise(controller.signal)]);
} finally {
controller.signal.removeEventListener('abort', onAbort);
}
}
async function completeSummary(
child: IScopeHandle,
controller: AbortController,
turnRef: { current?: Turn },
): Promise<string> {
let summary = latestAssistantText(child.accessor.get(IAgentContextMemoryService).get());
if (summary.trim().length >= SUMMARY_MIN_LENGTH) return summary;
for (let attempt = 0; attempt < SUMMARY_CONTINUATION_ATTEMPTS; attempt++) {
const turn = child.accessor.get(IAgentPromptService).prompt({
role: 'user',
content: [{ type: 'text', text: SUMMARY_CONTINUATION_PROMPT }],
toolCalls: [],
origin: SUBAGENT_PROMPT_ORIGIN,
});
if (turn === undefined) break;
turnRef.current = turn;
const result = await awaitTurn(turn, controller);
if (result.reason !== 'completed') break;
const continued = latestAssistantText(child.accessor.get(IAgentContextMemoryService).get());
if (continued.trim().length > 0) summary = continued;
if (summary.trim().length >= SUMMARY_MIN_LENGTH) break;
}
return summary;
}
function classifyTurnResult(result: { reason: string; error?: unknown }): void {
if (result.reason === 'filtered') {
throw new Error('Subagent turn blocked by provider safety policy');
}
if (result.reason === 'failed') {
const error = result.error;
if (isProviderRateLimitError(error)) throw error;
const payload = toKimiErrorPayload(error);
if (payload.code === ErrorCodes.PROVIDER_RATE_LIMIT) {
throw providerRateLimitErrorFromPayload(payload);
}
throw error instanceof Error ? error : new Error(String(error ?? 'Subagent turn failed'));
}
if (result.reason === 'cancelled') {
throw userCancellationReason();
}
}
function shouldSuppressQueuedAttemptFailureEvent(
options: RunSubagentOptions,
error: unknown,
): boolean {
if (options.suppressRateLimitFailureEvent !== true) return false;
if (isProviderRateLimitError(error)) return true;
return isAbortError(error) || options.signal.aborted;
}
function providerRateLimitErrorFromPayload(error: KimiErrorPayload): APIProviderRateLimitError {
const requestId =
typeof error.details?.['requestId'] === 'string' ? error.details['requestId'] : null;
return new APIProviderRateLimitError(error.message, requestId);
}
function abortPromise(signal: AbortSignal): Promise<never> {
if (signal.aborted) {
return Promise.reject(signal.reason ?? userCancellationReason());
}
return new Promise<never>((_resolve, reject) => {
signal.addEventListener('abort', () => reject(signal.reason ?? userCancellationReason()), {
once: true,
});
});
}
function errorMessage(error: unknown): string {
return error instanceof Error ? error.message : String(error);
}
function latestAssistantText(messages: readonly ContextMessage[]): string {
for (let i = messages.length - 1; i >= 0; i--) {
const message = messages[i]!;
if (message.role !== 'assistant') continue;
return contentText(message.content);
}
return '';
}
function contentText(content: ContextMessage['content']): string {
if (typeof content === 'string') return content;
return content
.filter((part): part is Extract<(typeof content)[number], { type: 'text' }> => part.type === 'text')
.map((part) => part.text)
.join('');
}

View file

@ -0,0 +1,40 @@
/**
* `agentTool` domain (L5) child-agent run contract types.
*
* Leaf module holding the option/handle types shared by the `runChildAgent`
* helpers and the `subagentBatch` scheduler. Owns no scoped state and imports
* no business domain, so it sits below both modules and breaks their import
* cycle.
*/
import type { TokenUsage } from '@moonshot-ai/kosong';
export const DEFAULT_SUBAGENT_TIMEOUT_MS = 30 * 60 * 1000;
export const DEFAULT_SUBAGENT_TIMEOUT_DESCRIPTION = '30 minutes';
export interface RunSubagentOptions {
readonly parentToolCallId: string;
readonly parentToolCallUuid?: string;
readonly prompt: string;
readonly description: string;
readonly swarmIndex?: number;
readonly runInBackground: boolean;
readonly signal: AbortSignal;
readonly onReady?: () => void;
readonly suppressRateLimitFailureEvent?: boolean;
}
export interface SpawnSubagentOptions extends RunSubagentOptions {
readonly profileName: string;
readonly swarmItem?: string;
}
export type SubagentHandle = {
readonly agentId: string;
readonly profileName: string;
readonly resumed: boolean;
readonly completion: Promise<{
readonly result: string;
readonly usage?: TokenUsage;
}>;
};

View file

@ -17,7 +17,7 @@ export type SubagentHandle = {
readonly completion: Promise<SubagentCompletion>;
};
export interface SessionSubagentHost {
export interface SubagentDetachHandle {
markActiveChildDetached(agentId: string): void;
}
@ -46,7 +46,7 @@ export class AgentBackgroundTask implements BackgroundTask {
constructor(
private readonly handle: SubagentHandle,
readonly description: string,
private readonly subagentHost: Pick<SessionSubagentHost, 'markActiveChildDetached'>,
private readonly detachHandle: Pick<SubagentDetachHandle, 'markActiveChildDetached'>,
private readonly abortController: AbortController,
) {
this.agentId = handle.agentId;
@ -79,7 +79,7 @@ export class AgentBackgroundTask implements BackgroundTask {
}
onDetach(): void {
this.subagentHost.markActiveChildDetached(this.agentId);
this.detachHandle.markActiveChildDetached(this.agentId);
}
toInfo(base: BackgroundTaskInfoBase): AgentBackgroundTaskInfo {

View file

@ -6,7 +6,7 @@ import type {
} from './task';
export { AgentBackgroundTask } from './agent-task';
export type { AgentBackgroundTaskInfo } from './agent-task';
export type { AgentBackgroundTaskInfo, SubagentDetachHandle } from './agent-task';
export { ProcessBackgroundTask } from './process-task';
export type { ProcessBackgroundTaskInfo } from './process-task';
export { QuestionBackgroundTask } from './question-task';

View file

@ -21,11 +21,18 @@ import { IAgentQuestionToolsService } from '#/agent/questionTools';
import { ISessionMetadata, type SessionMetaPatch } from '#/session/session-metadata';
import { BashTool, IAgentShellToolsService } from '#/agent/shellTools';
import { IAgentSkillService } from '#/agent/skill';
import { IKaos } from '#/app/kaos';
import { IHostEnvironment } from '#/app/hostEnvironment';
import { IExecContext } from '#/session/execContext';
import { ISessionProcessRunner } from '#/session/process';
import { ISessionSubagentHost } from '#/session/subagentHost';
import { IAgentToolService } from '#/agent/agentTool';
import {
DenyAllPermissionPolicyService,
IAgentPermissionPolicyService,
} from '#/agent/permissionPolicy';
import { IAgentSystemReminderService } from '#/agent/systemReminder';
import { IAgentSwarmService } from '#/agent/swarm';
import { ITelemetryService } from '#/app/telemetry';
import { IAgentLifecycleService } from '#/session/agent-lifecycle';
import { IAgentToolRegistryService } from '#/agent/toolRegistry';
import type { ToolUpdate } from '#/agent/tool';
import { IAgentTurnService } from '#/agent/turn';
@ -67,6 +74,23 @@ import {
const SHELL_FOREGROUND_TIMEOUT_S = 2 * 60;
const TOOL_CALL_DISABLED_MESSAGE =
'Tool calls are disabled for side questions. Answer with text only.';
const SIDE_QUESTION_SYSTEM_REMINDER = `
This is a side-channel conversation with the user. You should answer user questions directly based on what you already know.
IMPORTANT:
- You are a separate, lightweight instance.
- The main agent continues independently; do not reference being interrupted.
- Do not call any tools. All tool calls are disabled and will be rejected.
Even though tool definitions are visible in this request, they exist only
for technical reasons (prompt cache). You must not use them.
- Respond only with text based on what you already know from the conversation
and this side-channel conversation.
- Follow-up turns may happen in this side-channel conversation.
- If you do not know the answer, say so directly.
`;
export class AgentRPCService implements IAgentRPCService {
declare readonly _serviceBrand: undefined;
private readonly shellCommandControllers = new Map<string, AbortController>();
@ -85,12 +109,14 @@ 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,
@IHostEnvironment private readonly env: IHostEnvironment,
@IExecContext private readonly ctx: IExecContext,
@IAgentBackgroundService private readonly background: IAgentBackgroundService,
@IAgentContextMemoryService private readonly context: IAgentContextMemoryService,
@IAgentContextSizeService private readonly contextSize: IAgentContextSizeService,
@IAgentSkillService private readonly skills: IAgentSkillService,
@ISessionSubagentHost private readonly subagentHost: ISessionSubagentHost,
@IAgentLifecycleService private readonly lifecycle: IAgentLifecycleService,
@IAgentToolService private readonly agentTool: IAgentToolService,
@IAgentUsageService private readonly usage: IAgentUsageService,
@ITelemetryService private readonly telemetry: ITelemetryService,
@IAgentGoalService private readonly goal: IAgentGoalService,
@ -113,7 +139,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.env, this.ctx, this.background);
this.toolRegistry.register(bash);
return bash;
}
@ -347,8 +373,18 @@ export class AgentRPCService implements IAgentRPCService {
await this.metadata.update(patch satisfies SessionMetaPatch);
}
startBtw(_payload: EmptyPayload): Promise<string> {
return this.subagentHost.startBtw();
async startBtw(_payload: EmptyPayload): Promise<string> {
const child = await this.lifecycle.fork('main');
child.accessor
.get(IAgentSystemReminderService)
?.appendSystemReminder(SIDE_QUESTION_SYSTEM_REMINDER.trim(), {
kind: 'system_trigger',
name: 'btw',
});
child.accessor
.get(IAgentPermissionPolicyService)
?.registerPolicy(new DenyAllPermissionPolicyService(TOOL_CALL_DISABLED_MESSAGE));
return child.id;
}
createGoal(payload: CreateGoalPayload) {

View file

@ -0,0 +1,6 @@
/**
* `scopeContext` domain barrel re-exports the agent-scope identity token
* (`scopeContext`).
*/
export * from './scopeContext';

View file

@ -0,0 +1,19 @@
/**
* `agent` domain (L2) agent-scope identity token.
*
* Exposes `IAgentScopeContext`, the identity of the current agent scope (its
* `agentId`). Seeded into every agent scope at creation by `agent-lifecycle`
* so Agent-scoped consumers can refer to themselves (for example as the
* parent of a subagent) without threading the id through every call site.
* Bound at Agent scope via a per-agent seed, not the scoped registry.
*/
import { createDecorator, type ServiceIdentifier } from '#/_base/di/instantiation';
export interface IAgentScopeContext {
readonly _serviceBrand: undefined;
readonly agentId: string;
}
export const IAgentScopeContext: ServiceIdentifier<IAgentScopeContext> =
createDecorator<IAgentScopeContext>('agentScopeContext');

View file

@ -1,3 +1,12 @@
/**
* `swarm` domain (L4) concurrency / rate-limit scheduler for subagent runs.
*
* 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.
*/
import { isProviderRateLimitError, type TokenUsage } from '@moonshot-ai/kosong';
import * as retry from 'retry';
@ -5,7 +14,15 @@ import type {
RunSubagentOptions,
SpawnSubagentOptions,
SubagentHandle,
} from './subagentHost';
} 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 { IAgentEventSinkService } from '#/agent/eventSink';
import { isUserCancellation } from '#/_base/utils/abort';
/*
@ -77,6 +94,8 @@ export type SubagentResult<T = unknown> = {
readonly error?: string;
};
export type QueuedSubagentRunResult<T = unknown> = SubagentResult<T>;
export type SubagentSuspendedEvent = {
readonly task: QueuedSubagentTask;
readonly agentId: string;
@ -678,3 +697,35 @@ 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(IAgentEventSinkService)?.emit({
type: 'subagent.suspended',
subagentId: event.agentId,
reason: event.reason,
});
},
};
const maxConcurrency = resolveSwarmMaxConcurrency();
return new SubagentBatch(launcher, tasks, { maxConcurrency }).run();
}

View file

@ -1,17 +1,26 @@
import {
Disposable,
} from "#/_base/di";
/**
* `swarm` domain (L4) `IAgentSwarmService` implementation.
*
* 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`.
*/
import { Disposable } from '#/_base/di';
import { InstantiationType } from '#/_base/di/extensions';
import { LifecycleScope, registerScopedService } from '#/_base/di/scope';
import { IAgentEventSinkService } from '#/agent/eventSink';
import { ISessionSubagentHost } from '#/session/subagentHost';
import { IAgentScopeContext } from '#/agent/scopeContext';
import { IAgentSystemReminderService } from '#/agent/systemReminder';
import { IAgentToolRegistryService } from '#/agent/toolRegistry';
import { IAgentTurnService } from '#/agent/turn';
import { IAgentWireRecordService } from '#/agent/wireRecord';
import { IAgentLifecycleService } from '#/session/agent-lifecycle';
import SWARM_MODE_ENTER_REMINDER from './enter-reminder.md?raw';
import SWARM_MODE_EXIT_REMINDER from './exit-reminder.md?raw';
import { AgentSwarmTool } from '#/agent/swarm/tools/agent-swarm';
import { AgentSwarmTool, type AgentSwarmToolHost } from '#/agent/swarm/tools/agent-swarm';
import {
IAgentSwarmService,
type SwarmModeTrigger,
@ -32,12 +41,14 @@ export class AgentSwarmService extends Disposable implements IAgentSwarmService
private _active: SwarmModeTrigger | null = null;
constructor(
runQueued: AgentSwarmToolHost['runQueued'] | undefined,
@IAgentWireRecordService private readonly wireRecord: IAgentWireRecordService,
@IAgentEventSinkService private readonly events: IAgentEventSinkService,
@IAgentSystemReminderService private readonly reminders: IAgentSystemReminderService,
@IAgentTurnService turnService: IAgentTurnService,
@IAgentToolRegistryService toolRegistry: IAgentToolRegistryService,
@ISessionSubagentHost subagentHost: ISessionSubagentHost,
@IAgentLifecycleService lifecycle: IAgentLifecycleService,
@IAgentScopeContext ctx: IAgentScopeContext,
) {
super();
this._register(
@ -59,7 +70,11 @@ export class AgentSwarmService extends Disposable implements IAgentSwarmService
return done;
}),
);
this._register(toolRegistry.register(new AgentSwarmTool(subagentHost, this)));
this._register(
toolRegistry.register(
new AgentSwarmTool({ lifecycle, parentAgentId: ctx.agentId, runQueued }, this),
),
);
}
enter(trigger: SwarmModeTrigger): void {

View file

@ -1,20 +1,32 @@
/**
* `swarm` domain (L4) `AgentSwarm` collaboration tool.
*
* Launches a batch of child agents (an ordinary Agent scope each) through the
* `agentTool` queued run helper and renders the per-subagent XML result. Keeps
* a module-level map of spawned agent id swarm item so a later
* `resume_agent_ids` call can relabel resumed subagents. Pure tool owns no
* scoped state.
*/
import { z } from 'zod';
import type { BuiltinTool } from '#/agent/tool';
import {
DEFAULT_SUBAGENT_TIMEOUT_MS,
type QueuedSubagentRunResult,
type QueuedSubagentTask,
} from '#/session/subagentHost';
} 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 AGENT_SWARM_DESCRIPTION from './agent-swarm.md?raw';
const DEFAULT_SUBAGENT_TYPE = 'coder';
const PROMPT_TEMPLATE_PLACEHOLDER = '{{item}}';
const MAX_AGENT_SWARM_SUBAGENTS = 128;
const swarmItems = new Map<string, string>();
export const AgentSwarmToolInputSchema = z
.object({
description: z
@ -82,24 +94,24 @@ interface SwarmRunResult {
readonly error?: string;
}
interface AgentSwarmSubagentHost {
getSwarmItem(agentId: string): string | undefined;
runQueued<T>(
tasks: readonly QueuedSubagentTask<T>[],
): Promise<Array<QueuedSubagentRunResult<T>>>;
}
interface AgentSwarmMode {
enter(trigger: 'tool'): void;
}
export interface AgentSwarmToolHost {
readonly lifecycle: IAgentLifecycleService;
readonly parentAgentId: string;
readonly runQueued?: typeof runChildAgentQueued;
readonly getSwarmItem?: (agentId: string) => string | undefined;
}
export class AgentSwarmTool implements BuiltinTool<AgentSwarmToolInput> {
readonly name = 'AgentSwarm' as const;
readonly description = AGENT_SWARM_DESCRIPTION;
readonly parameters: Record<string, unknown> = toInputJsonSchema(AgentSwarmToolInputSchema);
constructor(
private readonly subagentHost: AgentSwarmSubagentHost,
private readonly host: AgentSwarmToolHost,
private readonly swarmMode: AgentSwarmMode,
) {}
@ -142,7 +154,8 @@ export class AgentSwarmTool implements BuiltinTool<AgentSwarmToolInput> {
toolCallId: string,
): Promise<string> {
const profileName = normalizeOptionalString(args.subagent_type) ?? DEFAULT_SUBAGENT_TYPE;
const specs = createAgentSwarmSpecs(args, (agentId) => this.subagentHost.getSwarmItem(agentId));
const getSwarmItem = this.host.getSwarmItem ?? ((id: string) => swarmItems.get(id));
const specs = createAgentSwarmSpecs(args, getSwarmItem);
const tasks = specs.map((spec): QueuedSubagentTask<AgentSwarmSpec> => {
const descriptionName = spec.kind === 'resume' ? 'resume' : profileName;
const common = {
@ -169,8 +182,20 @@ export class AgentSwarmTool implements BuiltinTool<AgentSwarmToolInput> {
kind: 'spawn',
};
});
const results = await this.subagentHost.runQueued(tasks);
return renderSwarmResults(results.map(({ task, ...result }) => ({ spec: task.data, ...result })));
const runQueued = this.host.runQueued ?? runChildAgentQueued;
const results = (await runQueued({
lifecycle: this.host.lifecycle,
parentAgentId: this.host.parentAgentId,
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);
}
}
return renderSwarmResults(
results.map(({ task, ...result }) => ({ spec: task.data as AgentSwarmSpec, ...result })),
);
}
}

View file

@ -59,7 +59,8 @@ export * from '#/app/gateway';
export * from '#/session/workspaceContext';
export * from '#/app/workspaceRegistry';
export * from '#/app/hostFolderBrowser';
export * from '#/app/kaos';
export * from '#/app/hostEnvironment';
export * from '#/session/execContext';
export * from '#/session/agentFs';
export * from '#/session/process';
export * from '#/session/terminal';
@ -92,7 +93,8 @@ export * from '#/agent/promptLegacy';
export * from '#/app/messageLegacy';
export * from '#/agent/replayBuilder';
export * from '#/agent/rpc';
export * from '#/session/subagentHost';
export * from '#/agent/scopeContext';
export * from '#/agent/agentTool';
export * from '#/agent/todoList';
export * from '#/agent/tool';
export * from '#/agent/toolExecutor';

View file

@ -3,8 +3,8 @@
*
* Defines the public contract of agent lifecycle: the `CreateAgentOptions` and
* the `IAgentLifecycleService` used to create agents (`create` / `createMain`),
* look them up (`getHandle` / `list`), and remove them. Session-scoped one
* instance per session.
* fork an existing agent (`fork`), look them up (`getHandle` / `list`), and
* remove them. Session-scoped one instance per session.
*/
import { createDecorator, type ServiceIdentifier } from '#/_base/di/instantiation';
@ -27,6 +27,8 @@ export interface IAgentLifecycleService {
readonly onDidDispose: Event<string>;
create(opts: CreateAgentOptions): Promise<IScopeHandle>;
createMain(): Promise<IScopeHandle>;
/** Create a child agent that inherits the parent's profile and context history. */
fork(parentAgentId: string): Promise<IScopeHandle>;
getHandle(agentId: string): IScopeHandle | undefined;
list(): readonly IScopeHandle[];
remove(agentId: string): Promise<void>;

View file

@ -1,9 +1,9 @@
/**
* `agent-lifecycle` domain (L6) `IAgentLifecycleService` implementation.
*
* Creates and tracks the session's agents as child scopes. Bound at Session
* scope. Removing an agent disposes its scope; surviving agents are disposed
* with the session.
* Creates and tracks the session's agents as child scopes. Seeds each agent's
* identity through `agent` scopeContext, wires per-agent wire records and MCP,
* and registers the agent in the session registry. Bound at Session scope.
*/
import { join } from 'pathe';
@ -29,6 +29,9 @@ import { IPluginService } from '#/app/plugin';
import { ISessionContext } from '#/session/session-context';
import { ISessionMetadata } from '#/session/session-metadata';
import { ISessionWorkspaceContext } from '#/session/workspaceContext';
import { IAgentScopeContext } from '#/agent/scopeContext';
import { IAgentProfileService } from '#/agent/profile';
import { IAgentContextMemoryService } from '#/agent/contextMemory';
import { IAgentWireRecordService, AgentWireRecordService } from '#/agent/wireRecord';
import {
IAgentReplayBuilderService,
@ -80,6 +83,7 @@ export class AgentLifecycleService extends Disposable implements IAgentLifecycle
agentId,
{
extra: [
[IAgentScopeContext, { _serviceBrand: undefined, agentId } satisfies IAgentScopeContext],
[IAgentWireRecordService, new SyncDescriptor(AgentWireRecordService, [{ homedir: agentHomedir }])],
[IAgentMcpService, new SyncDescriptor(AgentMcpService, [{ manager: this.getMcpManager() }])],
// These two carry a leading static `options` param; the scoped
@ -119,6 +123,28 @@ export class AgentLifecycleService extends Disposable implements IAgentLifecycle
return handle;
}
async fork(parentAgentId: string): Promise<IScopeHandle> {
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' });
const parentData = parent.accessor.get(IAgentProfileService).data();
child.accessor.get(IAgentProfileService).update({
modelAlias: parentData.modelAlias,
thinkingLevel: parentData.thinkingLevel,
systemPrompt: parentData.systemPrompt,
activeToolNames: parentData.activeToolNames,
});
const parentMessages = parent.accessor.get(IAgentContextMemoryService)?.get();
if (parentMessages !== undefined && parentMessages.length > 0) {
child.accessor.get(IAgentContextMemoryService)?.splice(0, 0, parentMessages);
}
return child;
}
/**
* One shared `McpConnectionManager` per session (built lazily, cached). All
* agents in the session share it, matching v1's session-scoped MCP and

View file

@ -1,558 +0,0 @@
import {
APIProviderRateLimitError,
isProviderRateLimitError,
type TokenUsage,
} from '@moonshot-ai/kosong';
import { linkAbortSignal, userCancellationReason } from '#/_base/utils/abort';
import { IAgentLifecycleService } from '#/session/agent-lifecycle';
import type { IScopeHandle } from '#/_base/di/scope';
import {
IAgentContextMemoryService,
type ContextMessage,
type PromptOrigin,
} from '#/agent/contextMemory';
import { ErrorCodes, toKimiErrorPayload, type KimiErrorPayload } from '#/errors';
import { IAgentEventSinkService } from '#/agent/eventSink';
import { IAgentExternalHooksService } from '#/agent/externalHooks';
import { isAbortError } from '#/agent/loop/errors';
import {
DenyAllPermissionPolicyService,
IAgentPermissionPolicyService,
} from '#/agent/permissionPolicy';
import { IAgentProfileService } from '#/agent/profile';
import { ISessionMetadata } from '#/session/session-metadata';
import { IAgentSystemReminderService } from '#/agent/systemReminder';
import { ITelemetryService } from '#/app/telemetry';
import { IAgentPromptService } from '#/agent/prompt';
import { IAgentUsageService } from '#/agent/usage';
import type { Turn } from '#/agent/turn';
import { DEFAULT_AGENT_SUBAGENT_PROFILES, EXPLORE_ROLE_ADDITIONAL } from './profiles';
import {
resolveSwarmMaxConcurrency,
SubagentBatch,
type SubagentResult,
type SubagentSuspendedEvent,
} from './subagent-batch';
import SUMMARY_CONTINUATION_PROMPT from './summary-continuation.md?raw';
import {
type QueuedSubagentTask,
type RunSubagentOptions,
type SessionSubagentHost,
type SpawnSubagentOptions,
type SubagentHandle,
} from './subagentHost';
const SUBAGENT_PROMPT_ORIGIN: PromptOrigin = { kind: 'system_trigger', name: 'subagent' };
const SUMMARY_MIN_LENGTH = 200;
const SUMMARY_CONTINUATION_ATTEMPTS = 1;
const HOOK_TEXT_PREVIEW_LENGTH = 500;
const TOOL_CALL_DISABLED_MESSAGE =
'Tool calls are disabled for side questions. Answer with text only.';
const SIDE_QUESTION_SYSTEM_REMINDER = `
This is a side-channel conversation with the user. You should answer user questions directly based on what you already know.
IMPORTANT:
- You are a separate, lightweight instance.
- The main agent continues independently; do not reference being interrupted.
- Do not call any tools. All tool calls are disabled and will be rejected.
Even though tool definitions are visible in this request, they exist only
for technical reasons (prompt cache). You must not use them.
- Respond only with text based on what you already know from the conversation
and this side-channel conversation.
- Follow-up turns may happen in this side-channel conversation.
- If you do not know the answer, say so directly.
`;
export class DefaultSessionSubagentHost implements SessionSubagentHost {
private readonly activeChildren = new Map<
string,
{ readonly controller: AbortController; runInBackground: boolean }
>();
private readonly swarmItems = new Map<string, string>();
constructor(
private readonly agents: IAgentLifecycleService,
private readonly ownerAgentId: string,
private readonly metadata?: ISessionMetadata,
) {}
getSwarmItem(agentId: string): string | undefined {
return this.swarmItems.get(agentId);
}
async startBtw(): Promise<string> {
const parent = await this.ensureParent();
const child = await this.agents.create({ parentAgentId: this.ownerAgentId, type: 'sub' });
const parentProfile = parent.accessor.get(IAgentProfileService);
const childProfile = child.accessor.get(IAgentProfileService);
const parentData = parentProfile.data();
// A side-question agent inherits the parent's model, thinking level, and
// system prompt so it answers from the same posture.
childProfile.update({
modelAlias: parentData.modelAlias,
thinkingLevel: parentData.thinkingLevel,
systemPrompt: parentData.systemPrompt,
// Keep the parent's loop tools visible (prompt-cache parity) even though
// every call is denied below.
activeToolNames: parentData.activeToolNames,
});
// Project the parent's history into the child so it can answer from what
// the main agent already knows.
const parentMessages = parent.accessor.get(IAgentContextMemoryService)?.get();
if (parentMessages !== undefined && parentMessages.length > 0) {
child.accessor.get(IAgentContextMemoryService)?.splice(0, 0, parentMessages);
}
child.accessor
.get(IAgentSystemReminderService)
?.appendSystemReminder(SIDE_QUESTION_SYSTEM_REMINDER.trim(), {
kind: 'system_trigger',
name: 'btw',
});
// Disable every tool call: side questions are answered with text only.
child.accessor
.get(IAgentPermissionPolicyService)
?.registerPolicy(new DenyAllPermissionPolicyService(TOOL_CALL_DISABLED_MESSAGE));
return child.id;
}
async spawn(options: SpawnSubagentOptions): Promise<SubagentHandle> {
options.signal.throwIfAborted();
const parent = await this.ensureParent();
const child = await this.agents.create({
parentAgentId: this.ownerAgentId,
cwd: parent.accessor.get(IAgentProfileService).data().cwd,
type: 'sub',
swarmItem: options.swarmItem,
});
if (options.swarmItem !== undefined) this.swarmItems.set(child.id, options.swarmItem);
this.configureChild(parent, child, options.profileName);
this.emitSpawned(parent, child.id, options.profileName, options);
const completion = this.runWithActiveChild(
child,
options,
parent,
options.profileName,
(turnRef, controller) => this.runPromptTurn(child, parent, options, options.profileName, turnRef, controller),
);
return { agentId: child.id, profileName: options.profileName, resumed: false, completion };
}
async resume(agentId: string, options: RunSubagentOptions): Promise<SubagentHandle> {
options.signal.throwIfAborted();
const parent = await this.ensureParent();
const child = await this.requireChild(agentId);
const profileName = child.accessor.get(IAgentProfileService).data().profileName ?? 'subagent';
this.emitSpawned(parent, child.id, profileName, options);
const completion = this.runWithActiveChild(
child,
options,
parent,
profileName,
(turnRef, controller) => this.runPromptTurn(child, parent, options, profileName, turnRef, controller),
);
return { agentId, profileName, resumed: true, completion };
}
async retry(agentId: string, options: RunSubagentOptions): Promise<SubagentHandle> {
options.signal.throwIfAborted();
const parent = await this.ensureParent();
const child = await this.requireChild(agentId);
const profileName = child.accessor.get(IAgentProfileService).data().profileName ?? 'subagent';
this.emitSpawned(parent, child.id, profileName, options);
const completion = this.runWithActiveChild(
child,
options,
parent,
profileName,
(turnRef, controller) => this.runRetryTurn(child, parent, options, profileName, turnRef, controller),
);
return { agentId, profileName, resumed: true, completion };
}
async getProfileName(agentId: string): Promise<string | undefined> {
if (this.metadata !== undefined) {
const meta = (await this.metadata.read()).agents?.[agentId];
if (meta?.type !== 'sub' || meta.parentAgentId !== this.ownerAgentId) return undefined;
}
const child = this.agents.getHandle(agentId);
if (child === undefined) return undefined;
return child.accessor.get(IAgentProfileService).data().profileName;
}
markActiveChildDetached(agentId: string): void {
const child = this.activeChildren.get(agentId);
if (child !== undefined) child.runInBackground = true;
}
async runQueued<T>(tasks: readonly QueuedSubagentTask<T>[]): Promise<Array<SubagentResult<T>>> {
const maxConcurrency = resolveSwarmMaxConcurrency();
return new SubagentBatch(this, tasks, { maxConcurrency }).run();
}
cancelAll(reason: unknown = userCancellationReason()): void {
// v2 tracks every subagent (including descendants spawned by subagents) in
// the single session-scoped host, so aborting the foreground children here
// cancels the whole tree — there is no per-agent host to recurse into.
for (const [, child] of this.activeChildren) {
if (child.runInBackground) continue;
child.controller.abort(reason);
}
}
suspended(event: SubagentSuspendedEvent): void {
const parent = this.agents.getHandle(this.ownerAgentId);
parent?.accessor.get(IAgentEventSinkService)?.emit({
type: 'subagent.suspended',
subagentId: event.agentId,
reason: event.reason,
});
}
private async ensureParent(): Promise<IScopeHandle> {
const existing = this.agents.getHandle(this.ownerAgentId);
if (existing !== undefined) return existing;
if (this.ownerAgentId === 'main') return this.agents.createMain();
throw new Error(`Parent agent "${this.ownerAgentId}" does not exist`);
}
private async requireChild(agentId: string): Promise<IScopeHandle> {
if (this.metadata !== undefined) {
const meta = (await this.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 !== this.ownerAgentId) {
throw new Error(`Agent instance "${agentId}" does not belong to this parent agent`);
}
}
const child = this.agents.getHandle(agentId);
if (child === undefined) throw new Error(`Agent instance "${agentId}" does not exist`);
if (this.activeChildren.has(agentId)) {
throw new Error(`Agent instance "${agentId}" is already running`);
}
return child;
}
private configureChild(parent: IScopeHandle, child: IScopeHandle, profileName: string): void {
const parentProfile = parent.accessor.get(IAgentProfileService);
const childProfile = child.accessor.get(IAgentProfileService);
const parentData = parentProfile.data();
const profile = DEFAULT_AGENT_SUBAGENT_PROFILES[profileName];
const activeToolNames =
profileName === 'coder'
? (parentData.activeToolNames ?? profile?.tools)
: profile?.tools;
childProfile.update({
cwd: parentData.cwd,
modelAlias: parentData.modelAlias,
thinkingLevel: parentData.thinkingLevel,
profileName,
// `explore` extends the parent (agent) prompt with its read-only
// exploration role, mirroring v1's `explore.yaml` (`extends: agent` +
// `roleAdditional`). Full profile resolution via `applyProfile` (AGENTS.md
// context assembly) and v1's `inheritUserTools` are not yet wired in v2,
// so the standard explore tool set is used directly.
systemPrompt:
profileName === 'explore'
? `${parentData.systemPrompt}\n\n${EXPLORE_ROLE_ADDITIONAL}`
: parentData.systemPrompt,
activeToolNames,
});
}
private emitSpawned(
parent: IScopeHandle,
subagentId: string,
profileName: string,
options: RunSubagentOptions,
): void {
parent.accessor.get(IAgentEventSinkService)?.emit({
type: 'subagent.spawned',
subagentId,
subagentName: profileName,
parentToolCallId: options.parentToolCallId,
parentToolCallUuid: options.parentToolCallUuid,
parentAgentId: this.ownerAgentId,
description: options.description,
swarmIndex: options.swarmIndex,
runInBackground: options.runInBackground,
});
parent.accessor.get(ITelemetryService)?.track('subagent_created', {
subagent_name: profileName,
run_in_background: options.runInBackground,
});
}
private emitStarted(parent: IScopeHandle, subagentId: string): void {
parent.accessor.get(IAgentEventSinkService)?.emit({ type: 'subagent.started', subagentId });
}
private emitCompleted(
parent: IScopeHandle,
subagentId: string,
resultSummary: string,
usage?: TokenUsage,
): void {
parent.accessor.get(IAgentEventSinkService)?.emit({
type: 'subagent.completed',
subagentId,
resultSummary,
usage,
});
}
private emitFailed(
parent: IScopeHandle,
subagentId: string,
error: unknown,
options: RunSubagentOptions,
): void {
if (isAbortError(error)) return;
if (shouldSuppressQueuedAttemptFailureEvent(options, error)) return;
parent.accessor.get(IAgentEventSinkService)?.emit({
type: 'subagent.failed',
subagentId,
error: errorMessage(error),
});
}
private async triggerSubagentStart(
parent: IScopeHandle,
profileName: string,
prompt: string,
signal: AbortSignal,
): Promise<void> {
await parent.accessor.get(IAgentExternalHooksService)?.triggerSubagentStart(
{
agentName: profileName,
prompt: prompt.slice(0, HOOK_TEXT_PREVIEW_LENGTH),
},
signal,
);
}
private triggerSubagentStop(parent: IScopeHandle, profileName: string, result: string): void {
parent.accessor.get(IAgentExternalHooksService)?.triggerSubagentStop({
agentName: profileName,
response: result.slice(0, HOOK_TEXT_PREVIEW_LENGTH),
});
}
private observeFirstRequest(turn: Turn, options: RunSubagentOptions): void {
if (options.onReady === undefined) return;
void turn.ready.then(() => options.onReady?.()).catch(() => {});
}
private async runWithActiveChild(
child: IScopeHandle,
options: RunSubagentOptions,
parent: IScopeHandle,
profileName: string,
run: (
turn: { current?: Turn },
controller: AbortController,
) => Promise<{ result: string; usage?: TokenUsage }>,
): Promise<{ result: string; usage?: TokenUsage }> {
const controller = new AbortController();
this.activeChildren.set(child.id, { controller, runInBackground: options.runInBackground });
const unlink = linkAbortSignal(options.signal, controller);
const turnRef: { current?: Turn } = {};
this.emitStarted(parent, child.id);
try {
const result = await run(turnRef, controller);
this.emitCompleted(parent, child.id, result.result, result.usage);
this.triggerSubagentStop(parent, profileName, result.result);
return result;
} catch (error) {
this.emitFailed(parent, child.id, error, options);
throw error;
} finally {
unlink();
if (controller.signal.aborted) {
turnRef.current?.abortController.abort(controller.signal.reason);
}
this.activeChildren.delete(child.id);
}
}
private async runPromptTurn(
child: IScopeHandle,
parent: IScopeHandle,
options: RunSubagentOptions,
profileName: string,
turnRef: { current?: Turn },
controller: AbortController,
): Promise<{ result: string; usage?: TokenUsage }> {
options.signal.throwIfAborted();
await this.triggerSubagentStart(parent, profileName, options.prompt, options.signal);
options.signal.throwIfAborted();
const turn = child.accessor.get(IAgentPromptService).prompt({
role: 'user',
content: [{ type: 'text', text: options.prompt }],
toolCalls: [],
origin: SUBAGENT_PROMPT_ORIGIN,
});
if (turn === undefined) {
throw new Error('Subagent turn could not be started');
}
turnRef.current = turn;
this.observeFirstRequest(turn, options);
const result = await this.awaitTurn(turn, controller);
classifyTurnResult(result);
const summary = await this.completeSummary(child, controller, turnRef);
const usage = child.accessor.get(IAgentUsageService)?.status().total;
return { result: summary, usage };
}
private async runRetryTurn(
child: IScopeHandle,
parent: IScopeHandle,
options: RunSubagentOptions,
profileName: string,
turnRef: { current?: Turn },
controller: AbortController,
): Promise<{ result: string; usage?: TokenUsage }> {
options.signal.throwIfAborted();
await this.triggerSubagentStart(parent, profileName, options.prompt, options.signal);
options.signal.throwIfAborted();
// Retry the existing turn in place (no new user message appended), mirroring
// v1's `child.turn.retry('agent-host')`.
const turn = child.accessor.get(IAgentPromptService).retry('agent-host');
if (turn === undefined) {
throw new Error(`Agent instance "${child.id}" could not start a retry turn`);
}
turnRef.current = turn;
this.observeFirstRequest(turn, options);
const result = await this.awaitTurn(turn, controller);
classifyTurnResult(result);
const summary = await this.completeSummary(child, controller, turnRef);
const usage = child.accessor.get(IAgentUsageService)?.status().total;
return { result: summary, usage };
}
private async awaitTurn(
turn: Turn,
controller: AbortController,
): Promise<{ reason: string; error?: unknown }> {
const onAbort = (): void => {
turn.abortController.abort(controller.signal.reason);
};
controller.signal.addEventListener('abort', onAbort, { once: true });
try {
return await Promise.race([turn.result, abortPromise(controller.signal)]);
} finally {
controller.signal.removeEventListener('abort', onAbort);
}
}
private async completeSummary(
child: IScopeHandle,
controller: AbortController,
turnRef: { current?: Turn },
): Promise<string> {
let summary = latestAssistantText(child.accessor.get(IAgentContextMemoryService).get());
if (summary.trim().length >= SUMMARY_MIN_LENGTH) return summary;
for (let attempt = 0; attempt < SUMMARY_CONTINUATION_ATTEMPTS; attempt++) {
const turn = child.accessor.get(IAgentPromptService).prompt({
role: 'user',
content: [{ type: 'text', text: SUMMARY_CONTINUATION_PROMPT }],
toolCalls: [],
origin: SUBAGENT_PROMPT_ORIGIN,
});
if (turn === undefined) break;
turnRef.current = turn;
const result = await this.awaitTurn(turn, controller);
if (result.reason !== 'completed') break;
const continued = latestAssistantText(child.accessor.get(IAgentContextMemoryService).get());
if (continued.trim().length > 0) summary = continued;
if (summary.trim().length >= SUMMARY_MIN_LENGTH) break;
}
return summary;
}
}
/**
* Map a finished subagent turn to the v1 error taxonomy:
* - `filtered` provider safety policy block
* - provider rate limit `APIProviderRateLimitError` (so the swarm batch
* requeues the attempt via `isProviderRateLimitError`)
* - `cancelled` user cancellation reason
*
* `max_tokens` is intentionally not classified here: v2's turn result collapses
* every non-aborted/non-filtered stop into `completed`, so the subagent host
* cannot observe a max_tokens stop. See the migration notes for this deliberate
* drop.
*/
function classifyTurnResult(result: { reason: string; error?: unknown }): void {
if (result.reason === 'filtered') {
throw new Error('Subagent turn blocked by provider safety policy');
}
if (result.reason === 'failed') {
const error = result.error;
if (isProviderRateLimitError(error)) throw error;
const payload = toKimiErrorPayload(error);
if (payload.code === ErrorCodes.PROVIDER_RATE_LIMIT) {
throw providerRateLimitErrorFromPayload(payload);
}
throw error instanceof Error ? error : new Error(String(error ?? 'Subagent turn failed'));
}
if (result.reason === 'cancelled') {
throw userCancellationReason();
}
}
function shouldSuppressQueuedAttemptFailureEvent(
options: RunSubagentOptions,
error: unknown,
): boolean {
if (options.suppressRateLimitFailureEvent !== true) return false;
if (isProviderRateLimitError(error)) return true;
return isAbortError(error) || options.signal.aborted;
}
function providerRateLimitErrorFromPayload(error: KimiErrorPayload): APIProviderRateLimitError {
const requestId =
typeof error.details?.['requestId'] === 'string' ? error.details['requestId'] : null;
return new APIProviderRateLimitError(error.message, requestId);
}
function abortPromise(signal: AbortSignal): Promise<never> {
if (signal.aborted) {
return Promise.reject(signal.reason ?? userCancellationReason());
}
return new Promise<never>((_resolve, reject) => {
signal.addEventListener('abort', () => reject(signal.reason ?? userCancellationReason()), {
once: true,
});
});
}
function errorMessage(error: unknown): string {
return error instanceof Error ? error.message : String(error);
}
function latestAssistantText(messages: readonly ContextMessage[]): string {
for (let i = messages.length - 1; i >= 0; i--) {
const message = messages[i]!;
if (message.role !== 'assistant') continue;
return contentText(message.content);
}
return '';
}
function contentText(content: ContextMessage['content']): string {
if (typeof content === 'string') return content;
return content
.filter((part): part is Extract<(typeof content)[number], { type: 'text' }> => part.type === 'text')
.map((part) => part.text)
.join('');
}

View file

@ -1,10 +0,0 @@
/**
* `subagentHost` domain barrel - re-exports the subagentHost service contract and implementation.
*/
export * from './subagentHost';
export * from './subagentHostService';
export * from './defaultSessionSubagentHost';
export * from './profiles';
export { AgentTool, AgentToolInputSchema, AgentToolOutputSchema } from './agentTool';
export type { AgentToolInput, AgentToolOutput } from './agentTool';

View file

@ -1,72 +0,0 @@
import { createDecorator } from "#/_base/di";
import type { TokenUsage } from '@moonshot-ai/kosong';
import type {
QueuedSubagentTask,
SubagentResult,
SubagentSuspendedEvent,
} from './subagent-batch';
export const DEFAULT_SUBAGENT_TIMEOUT_MS = 30 * 60 * 1000;
export const DEFAULT_SUBAGENT_TIMEOUT_DESCRIPTION = '30 minutes';
export interface RunSubagentOptions {
readonly parentToolCallId: string;
readonly parentToolCallUuid?: string;
readonly prompt: string;
readonly description: string;
readonly swarmIndex?: number;
readonly runInBackground: boolean;
readonly signal: AbortSignal;
readonly onReady?: () => void;
readonly suppressRateLimitFailureEvent?: boolean;
}
export interface SpawnSubagentOptions extends RunSubagentOptions {
readonly profileName: string;
readonly swarmItem?: string;
}
export type SubagentHandle = {
readonly agentId: string;
readonly profileName: string;
readonly resumed: boolean;
readonly completion: Promise<{
readonly result: string;
readonly usage?: TokenUsage;
}>;
};
export interface SessionSubagentHost {
getSwarmItem(agentId: string): string | undefined;
startBtw(): Promise<string>;
spawn(options: SpawnSubagentOptions): Promise<SubagentHandle>;
resume(agentId: string, options: RunSubagentOptions): Promise<SubagentHandle>;
retry(agentId: string, options: RunSubagentOptions): Promise<SubagentHandle>;
getProfileName(agentId: string): Promise<string | undefined>;
markActiveChildDetached(agentId: string): void;
runQueued<T>(tasks: readonly QueuedSubagentTask<T>[]): Promise<Array<SubagentResult<T>>>;
/** Abort every foreground active child (and its descendants) with the given reason. */
cancelAll(reason?: unknown): void;
/** Surface a queued subagent being requeued after a provider rate limit. */
suspended(event: SubagentSuspendedEvent): void;
}
export type QueuedSubagentRunResult<T = unknown> = SubagentResult<T>;
export type { QueuedSubagentTask };
export interface ISessionSubagentHost {
readonly _serviceBrand: undefined;
getSwarmItem(agentId: string): string | undefined;
startBtw(): Promise<string>;
generateAgentsMd(): Promise<void>;
spawn(options: SpawnSubagentOptions): Promise<SubagentHandle>;
resume(agentId: string, options: RunSubagentOptions): Promise<SubagentHandle>;
getProfileName(agentId: string): Promise<string | undefined>;
markActiveChildDetached(agentId: string): void;
runQueued<T>(tasks: readonly QueuedSubagentTask<T>[]): Promise<Array<SubagentResult<T>>>;
cancelAll(reason?: unknown): void;
suspended(event: SubagentSuspendedEvent): void;
}
export const ISessionSubagentHost = createDecorator<ISessionSubagentHost>('sessionSubagentHost');

View file

@ -1,123 +0,0 @@
import type {
QueuedSubagentRunResult,
QueuedSubagentTask,
SessionSubagentHost,
SpawnSubagentOptions,
RunSubagentOptions,
SubagentHandle,
} from './subagentHost';
import type { SubagentSuspendedEvent } from './subagent-batch';
import {
ISessionSubagentHost,
} from './subagentHost';
import { Disposable } from '#/_base/di';
import { InstantiationType } from '#/_base/di/extensions';
import { LifecycleScope, registerScopedService } from '#/_base/di/scope';
import { IAgentLifecycleService } from '#/session/agent-lifecycle';
import { IAgentBackgroundService } from '#/agent/background';
import { ILogService } from '#/app/log';
import { IAgentProfileService } from '#/agent/profile';
import { IKaos } from '#/app/kaos';
import { ISessionProcessRunner } from '#/session/process';
import { ISessionMetadata } from '#/session/session-metadata';
import { IAgentToolRegistryService } from '#/agent/toolRegistry';
import { AgentTool } from './agentTool';
import { DefaultSessionSubagentHost } from './defaultSessionSubagentHost';
import { DEFAULT_AGENT_SUBAGENT_PROFILES } from './profiles';
export class SessionSubagentHostService extends Disposable implements ISessionSubagentHost {
declare readonly _serviceBrand: undefined;
private readonly host: SessionSubagentHost;
constructor(
subagentHost: SessionSubagentHost | undefined,
@IAgentLifecycleService agents: IAgentLifecycleService,
@ISessionMetadata metadata: ISessionMetadata,
@IAgentToolRegistryService toolRegistry: IAgentToolRegistryService,
@IAgentBackgroundService background: IAgentBackgroundService,
@IAgentProfileService profile: IAgentProfileService,
@IKaos kaos: IKaos,
@ISessionProcessRunner runner: ISessionProcessRunner,
@ILogService log?: ILogService,
) {
super();
this.host = subagentHost ?? new DefaultSessionSubagentHost(agents, 'main', metadata);
this._register(
toolRegistry.register(
new AgentTool(this, background, DEFAULT_AGENT_SUBAGENT_PROFILES, {
log,
gitContext: { cwd: kaos.cwd, runner },
canRunInBackground: () => {
return profile.isToolActive('TaskList') &&
profile.isToolActive('TaskOutput') &&
profile.isToolActive('TaskStop');
},
}),
),
);
}
getSwarmItem(agentId: string): string | undefined {
return this.host?.getSwarmItem(agentId);
}
startBtw(): Promise<string> {
return this.host.startBtw();
}
async generateAgentsMd(): Promise<void> {
const handle = await this.host.spawn({
profileName: 'coder',
parentToolCallId: 'generate-agents-md',
prompt: 'Initialize AGENTS.md for this workspace.',
description: 'Initialize AGENTS.md',
runInBackground: false,
signal: new AbortController().signal,
});
await handle.completion;
}
spawn(options: SpawnSubagentOptions): Promise<SubagentHandle> {
return this.host.spawn(options);
}
resume(agentId: string, options: RunSubagentOptions): Promise<SubagentHandle> {
return this.host.resume(agentId, options);
}
getProfileName(agentId: string): Promise<string | undefined> {
return this.host.getProfileName(agentId);
}
markActiveChildDetached(agentId: string): void {
this.host.markActiveChildDetached(agentId);
}
cancelAll(reason?: unknown): void {
this.host.cancelAll(reason);
}
suspended(event: SubagentSuspendedEvent): void {
this.host.suspended(event);
}
runQueued<T>(
tasks: readonly QueuedSubagentTask<T>[],
): Promise<Array<QueuedSubagentRunResult<T>>> {
const subagentHost = this.host;
if (subagentHost === undefined) {
throw new Error('Subagent host is not configured.');
}
return subagentHost.runQueued(tasks);
}
}
registerScopedService(
LifecycleScope.Session,
ISessionSubagentHost,
SessionSubagentHostService,
InstantiationType.Delayed,
'subagentHost',
);

View file

@ -8,20 +8,19 @@ import {
AgentTool,
AgentToolInputSchema,
DEFAULT_SUBAGENT_TIMEOUT_MS,
type ISessionSubagentHost,
type SessionSubagentHost,
} from '#/session/subagentHost';
import type { AgentToolSubagentMap } from '#/session/subagentHost/agentTool';
type AgentToolRunOverride,
} from '#/agent/agentTool';
import { ToolAccesses } from '#/agent/tool';
import { IAgentToolRegistryService } from '#/agent/toolRegistry';
import { executeTool } from '../tools/fixtures/execute-tool';
import {
agentToolServices,
createTestAgent,
subagentHostServices,
type TestAgentContext,
} from '../harness';
const signal = new AbortController().signal;
const PARENT_AGENT_ID = 'main';
interface CapturedLogEntry {
readonly level: 'error' | 'warn' | 'info' | 'debug';
@ -48,6 +47,35 @@ function createLogCapture(): {
return { logger, entries };
}
function fakeProfile(isToolActive: (name: string) => boolean = () => true) {
return { isToolActive: vi.fn(isToolActive) } as unknown as IAgentProfileService;
}
function fakeLifecycle() {
return {} as never;
}
function fakeKaos() {
return { cwd: '/repo' } as never;
}
function fakeProcessRunner() {
return {} as never;
}
function createRunOverride(
overrides: Partial<AgentToolRunOverride> = {},
): AgentToolRunOverride {
const run: AgentToolRunOverride = {
spawn: vi.fn(),
resume: vi.fn(),
retry: vi.fn(),
getProfileName: vi.fn().mockResolvedValue(undefined),
markDetached: vi.fn(),
};
return Object.assign(run, overrides);
}
describe('AgentTool direct contract', () => {
let contexts: TestAgentContext[];
@ -63,21 +91,19 @@ describe('AgentTool direct contract', () => {
});
function makeTool({
host = createSubagentHost(),
run = createRunOverride(),
maxRunningTasks,
subagents,
canRunInBackground,
isToolActive,
log,
}: {
readonly host?: SessionSubagentHost;
readonly run?: AgentToolRunOverride;
readonly maxRunningTasks?: number;
readonly subagents?: AgentToolSubagentMap;
readonly canRunInBackground?: () => boolean;
readonly isToolActive?: (name: string) => boolean;
readonly log?: ILogger;
} = {}): {
readonly ctx: TestAgentContext;
readonly background: IAgentBackgroundService;
readonly host: SessionSubagentHost;
readonly run: AgentToolRunOverride;
readonly tool: AgentTool;
} {
const ctx =
@ -91,10 +117,16 @@ describe('AgentTool direct contract', () => {
return {
ctx,
background,
host,
tool: new AgentTool(host as unknown as ISessionSubagentHost, background, subagents, {
canRunInBackground,
run,
tool: new AgentTool({
lifecycle: fakeLifecycle(),
parentAgentId: PARENT_AGENT_ID,
background,
profile: fakeProfile(isToolActive),
cwd: '/repo',
processRunner: fakeProcessRunner(),
log,
runOverride: run,
}),
};
}
@ -149,26 +181,13 @@ describe('AgentTool direct contract', () => {
expect(tool.description).not.toContain('no time limit');
});
it('renders configured subagent types and their tool sets', () => {
const { tool } = makeTool({
subagents: {
explore: {
description: 'Read-only exploration.',
whenToUse: 'Use for searches.',
tools: ['Read', 'Grep', 'Glob'],
},
coder: {
description: 'General coding.',
tools: ['Read', 'Write', 'Edit', 'Bash'],
},
},
});
it('renders the default agent types and their tool sets', () => {
const { tool } = makeTool();
expect(tool.description).toContain('Available agent types');
expect(tool.description).toContain('- explore: Read-only exploration. Use for searches.');
expect(tool.description).toContain('Tools: Read, Grep, Glob');
expect(tool.description).toContain('- coder: General coding.');
expect(tool.description).toContain('Tools: Read, Write, Edit, Bash');
expect(tool.description).toContain('- explore:');
expect(tool.description).toContain('- coder:');
expect(tool.description).toContain('Tools:');
});
it('mentions resume preference and result visibility in the description', () => {
@ -215,10 +234,10 @@ describe('AgentTool direct contract', () => {
});
it('uses the resumed agent profile in the activity description', async () => {
const host = createSubagentHost({
const run = createRunOverride({
getProfileName: vi.fn().mockResolvedValue('explore'),
});
const { tool } = makeTool({ host });
const { tool } = makeTool({ run });
const execution = await tool.resolveExecution({
prompt: 'Continue',
description: 'Continue work',
@ -227,11 +246,13 @@ describe('AgentTool direct contract', () => {
if (execution.isError === true) throw new Error('expected runnable execution');
expect(execution.description).toBe('Launching explore agent: Continue work');
expect(host.getProfileName).toHaveBeenCalledWith('agent-existing');
expect(run.getProfileName).toHaveBeenCalledWith(
expect.objectContaining({ agentId: 'agent-existing' }),
);
});
it('falls back to coder for an empty subagent type', async () => {
const host = createSubagentHost({
const run = createRunOverride({
spawn: vi.fn().mockResolvedValue({
agentId: 'agent-child',
profileName: 'coder',
@ -239,7 +260,7 @@ describe('AgentTool direct contract', () => {
completion: Promise.resolve({ result: 'child result' }),
}),
});
const { tool } = makeTool({ host });
const { tool } = makeTool({ run });
await executeTool(
tool,
@ -250,7 +271,7 @@ describe('AgentTool direct contract', () => {
}),
);
expect(host.spawn).toHaveBeenCalledWith(
expect(run.spawn).toHaveBeenCalledWith(
expect.objectContaining({
parentToolCallId: 'call_agent',
profileName: 'coder',
@ -259,7 +280,7 @@ describe('AgentTool direct contract', () => {
});
it('resumes a foreground subagent when resume is provided', async () => {
const host = createSubagentHost({
const run = createRunOverride({
spawn: vi.fn(),
resume: vi.fn().mockResolvedValue({
agentId: 'agent-existing',
@ -268,7 +289,7 @@ describe('AgentTool direct contract', () => {
completion: Promise.resolve({ result: 'resumed result' }),
}),
});
const { tool } = makeTool({ host });
const { tool } = makeTool({ run });
const result = await executeTool(
tool,
@ -279,10 +300,10 @@ describe('AgentTool direct contract', () => {
}),
);
expect(host.spawn).not.toHaveBeenCalled();
expect(host.resume).toHaveBeenCalledWith(
'agent-existing',
expect(run.spawn).not.toHaveBeenCalled();
expect(run.resume).toHaveBeenCalledWith(
expect.objectContaining({
agentId: 'agent-existing',
parentToolCallId: 'call_agent',
prompt: 'Continue',
description: 'Continue work',
@ -296,7 +317,7 @@ describe('AgentTool direct contract', () => {
});
it('does not consume a background task slot when validation fails before launch', async () => {
const host = createSubagentHost({
const run = createRunOverride({
spawn: vi.fn().mockResolvedValue({
agentId: 'agent-child',
profileName: 'coder',
@ -305,7 +326,7 @@ describe('AgentTool direct contract', () => {
}),
resume: vi.fn(),
});
const { tool } = makeTool({ host, maxRunningTasks: 1 });
const { tool } = makeTool({ run, maxRunningTasks: 1 });
const invalid = await executeTool(
tool,
@ -331,8 +352,8 @@ describe('AgentTool direct contract', () => {
output: 'Cannot set subagent_type when resuming an existing agent. Resume by agent id only.',
});
expect(valid.output).toContain('status: running');
expect(host.resume).not.toHaveBeenCalled();
expect(host.spawn).toHaveBeenCalledTimes(1);
expect(run.resume).not.toHaveBeenCalled();
expect(run.spawn).toHaveBeenCalledTimes(1);
});
it('can detach a foreground subagent through the background manager', async () => {
@ -340,8 +361,8 @@ describe('AgentTool direct contract', () => {
const completion = new Promise<{ result: string }>((resolve) => {
resolveCompletion = resolve;
});
const host = createSubagentHost({
markActiveChildDetached: vi.fn(),
const run = createRunOverride({
markDetached: vi.fn(),
spawn: vi.fn().mockResolvedValue({
agentId: 'agent-child',
profileName: 'coder',
@ -349,7 +370,7 @@ describe('AgentTool direct contract', () => {
completion,
}),
});
const { background, tool } = makeTool({ host });
const { background, tool } = makeTool({ run });
const running = executeTool(
tool,
@ -372,7 +393,9 @@ describe('AgentTool direct contract', () => {
background.detach(task.taskId);
const result = await running;
expect(host.markActiveChildDetached).toHaveBeenCalledWith('agent-child');
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');
@ -389,7 +412,7 @@ describe('AgentTool direct contract', () => {
const completion = new Promise<{ result: string }>((resolve) => {
resolveCompletion = resolve;
});
const host = createSubagentHost({
const run = createRunOverride({
spawn: vi.fn().mockResolvedValue({
agentId: 'agent-child',
profileName: 'coder',
@ -398,8 +421,8 @@ describe('AgentTool direct contract', () => {
}),
});
const { background, tool } = makeTool({
host,
canRunInBackground: () => false,
run,
isToolActive: () => false,
});
const running = executeTool(
@ -430,7 +453,7 @@ describe('AgentTool direct contract', () => {
});
it('guides the AI with a non-blocking query hint and a resume hint on background launch', async () => {
const host = createSubagentHost({
const run = createRunOverride({
spawn: vi.fn().mockResolvedValue({
agentId: 'agent-child',
profileName: 'coder',
@ -438,7 +461,7 @@ describe('AgentTool direct contract', () => {
completion: new Promise(() => {}),
}),
});
const { tool } = makeTool({ host });
const { tool } = makeTool({ run });
const result = await executeTool(
tool,
@ -461,7 +484,7 @@ describe('AgentTool direct contract', () => {
});
it('returns an error when background registration hits the task limit', async () => {
const host = createSubagentHost({
const run = createRunOverride({
spawn: vi
.fn()
.mockResolvedValueOnce({
@ -477,7 +500,7 @@ describe('AgentTool direct contract', () => {
completion: new Promise(() => {}),
}),
});
const { tool } = makeTool({ host, maxRunningTasks: 1 });
const { tool } = makeTool({ run, maxRunningTasks: 1 });
const existing = await executeTool(
tool,
@ -501,11 +524,11 @@ describe('AgentTool direct contract', () => {
isError: true,
output: 'Too many background tasks are already running.',
});
expect(host.spawn).toHaveBeenCalledTimes(2);
expect(run.spawn).toHaveBeenCalledTimes(2);
});
it('rejects one of two concurrent background subagents when the task limit is reached', async () => {
const host = createSubagentHost({
const run = createRunOverride({
spawn: vi
.fn()
.mockResolvedValueOnce({
@ -521,7 +544,7 @@ describe('AgentTool direct contract', () => {
completion: Promise.resolve({ result: 'second result' }),
}),
});
const { tool } = makeTool({ host, maxRunningTasks: 1 });
const { tool } = makeTool({ run, maxRunningTasks: 1 });
const first = executeTool(
tool,
@ -542,7 +565,7 @@ describe('AgentTool direct contract', () => {
const results = await Promise.all([first, second]);
expect(host.spawn).toHaveBeenCalledTimes(2);
expect(run.spawn).toHaveBeenCalledTimes(2);
expect(results).toContainEqual(
expect.objectContaining({ output: expect.stringContaining('status: running') }),
);
@ -557,10 +580,10 @@ describe('AgentTool direct contract', () => {
it('returns tool errors when spawning fails', async () => {
const error = new Error('missing subagent');
const { logger, entries } = createLogCapture();
const host = createSubagentHost({
const run = createRunOverride({
spawn: vi.fn().mockRejectedValue(error),
});
const { tool } = makeTool({ host, log: logger });
const { tool } = makeTool({ run, log: logger });
const result = await executeTool(
tool,
@ -589,7 +612,7 @@ describe('AgentTool direct contract', () => {
it('logs background registration failures', async () => {
const error = new Error('background unavailable');
const { logger, entries } = createLogCapture();
const host = createSubagentHost({
const run = createRunOverride({
spawn: vi.fn().mockResolvedValue({
agentId: 'agent-child',
profileName: 'coder',
@ -597,7 +620,7 @@ describe('AgentTool direct contract', () => {
completion: new Promise(() => {}),
}),
});
const { background, tool } = makeTool({ host, log: logger });
const { background, tool } = makeTool({ run, log: logger });
vi.spyOn(background, 'registerTask').mockImplementation(() => {
throw error;
});
@ -631,7 +654,7 @@ describe('AgentTool direct contract', () => {
it('reports a deliberate user interruption when a foreground subagent is cancelled by the user', async () => {
const controller = new AbortController();
const host = createSubagentHost({
const run = createRunOverride({
spawn: vi.fn((options) =>
Promise.resolve({
agentId: 'agent-child',
@ -647,7 +670,7 @@ describe('AgentTool direct contract', () => {
}),
),
});
const { tool } = makeTool({ host });
const { tool } = makeTool({ run });
const resultPromise = executeTool(tool, {
turnId: '0',
@ -669,7 +692,7 @@ describe('AgentTool direct contract', () => {
it('returns the spawned agent id when a foreground subagent times out', async () => {
vi.useFakeTimers({ toFake: ['setTimeout', 'clearTimeout'] });
const host = createSubagentHost({
const run = createRunOverride({
spawn: vi.fn().mockResolvedValue({
agentId: 'agent-timeout',
profileName: 'coder',
@ -677,7 +700,7 @@ describe('AgentTool direct contract', () => {
completion: new Promise<{ result: string }>(() => {}),
}),
});
const { tool } = makeTool({ host });
const { tool } = makeTool({ run });
const resultPromise = executeTool(
tool,
@ -700,13 +723,13 @@ describe('AgentTool direct contract', () => {
});
describe('Agent tool service runtime', () => {
describe('with a default subagent host', () => {
describe('with a default run override', () => {
let ctx: TestAgentContext;
let profile: IAgentProfileService;
beforeEach(() => {
const subagentHost = createSubagentHost();
ctx = createTestAgent(subagentHostServices(subagentHost));
const run = createRunOverride();
ctx = createTestAgent(agentToolServices(run));
profile = ctx.get(IAgentProfileService);
profile.update({ activeToolNames: ['Agent'] });
});
@ -719,7 +742,7 @@ describe('Agent tool service runtime', () => {
}
});
it('exposes Agent when a subagent host is available', () => {
it('exposes Agent when a run override is available', () => {
expect(ctx.toolsData()).toContainEqual(
expect.objectContaining({
name: 'Agent',
@ -737,14 +760,14 @@ describe('Agent tool service runtime', () => {
});
});
describe('with a resolving subagent host', () => {
describe('with a resolving run override', () => {
let ctx: TestAgentContext;
let subagentHost: SessionSubagentHost;
let run: AgentToolRunOverride;
let profile: IAgentProfileService;
let tools: IAgentToolRegistryService;
beforeEach(() => {
subagentHost = createSubagentHost({
run = createRunOverride({
spawn: vi.fn().mockResolvedValue({
agentId: 'agent-child',
profileName: 'coder',
@ -752,7 +775,7 @@ describe('Agent tool service runtime', () => {
completion: Promise.resolve({ result: 'child summary' }),
}),
});
ctx = createTestAgent(subagentHostServices(subagentHost));
ctx = createTestAgent(agentToolServices(run));
profile = ctx.get(IAgentProfileService);
tools = ctx.get(IAgentToolRegistryService);
profile.update({ activeToolNames: ['Agent'] });
@ -790,7 +813,7 @@ describe('Agent tool service runtime', () => {
'child summary',
].join('\n'),
});
expect(subagentHost.spawn).toHaveBeenCalledWith(
expect(run.spawn).toHaveBeenCalledWith(
expect.objectContaining({
profileName: 'coder',
parentToolCallId: 'call_agent',
@ -843,7 +866,7 @@ describe('Agent tool service runtime', () => {
expect(result.output).toContain(
'resume_hint: To continue or recover this same subagent later, call Agent(resume="agent-child", prompt="...").',
);
expect(subagentHost.spawn).toHaveBeenLastCalledWith(
expect(run.spawn).toHaveBeenLastCalledWith(
expect.objectContaining({
profileName: 'coder',
parentToolCallId: 'call_agent',
@ -855,15 +878,15 @@ describe('Agent tool service runtime', () => {
});
});
describe('with a non-resuming subagent host', () => {
describe('with a non-resuming run override', () => {
let ctx: TestAgentContext;
let subagentHost: SessionSubagentHost;
let run: AgentToolRunOverride;
let profile: IAgentProfileService;
let tools: IAgentToolRegistryService;
beforeEach(() => {
subagentHost = createSubagentHost();
ctx = createTestAgent(subagentHostServices(subagentHost));
run = createRunOverride();
ctx = createTestAgent(agentToolServices(run));
profile = ctx.get(IAgentProfileService);
tools = ctx.get(IAgentToolRegistryService);
profile.update({ activeToolNames: ['Agent'] });
@ -896,25 +919,7 @@ describe('Agent tool service runtime', () => {
isError: true,
output: 'Cannot set subagent_type when resuming an existing agent. Resume by agent id only.',
});
expect(subagentHost.resume).not.toHaveBeenCalled();
expect(run.resume).not.toHaveBeenCalled();
});
});
});
function createSubagentHost(
overrides: Partial<SessionSubagentHost> = {},
): SessionSubagentHost {
const host: SessionSubagentHost = {
getSwarmItem: vi.fn(),
startBtw: vi.fn().mockResolvedValue('btw-url'),
spawn: vi.fn(),
resume: vi.fn(),
retry: vi.fn(),
getProfileName: vi.fn().mockResolvedValue(undefined),
markActiveChildDetached: vi.fn(),
runQueued: vi.fn().mockResolvedValue([]),
cancelAll: vi.fn(),
suspended: vi.fn(),
};
return Object.assign(host, overrides);
}

View file

@ -0,0 +1,48 @@
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
import { SyncDescriptor } from '#/_base/di/descriptors';
import { DisposableStore } from '#/_base/di/lifecycle';
import { TestInstantiationService } from '#/_base/di/test';
import { IAgentBackgroundService } from '#/agent/background';
import { IAgentLifecycleService } from '#/session/agent-lifecycle';
import { ILogService } from '#/app/log';
import { IAgentProfileService } from '#/agent/profile';
import { IAgentScopeContext } from '#/agent/scopeContext';
import { AgentToolService, IAgentToolService } from '#/agent/agentTool';
import { IAgentToolRegistryService } from '#/agent/toolRegistry';
import { IExecContext, createExecContext } from '#/session/execContext';
import { ISessionMetadata } from '#/session/session-metadata';
import { ISessionProcessRunner } from '#/session/process';
describe('AgentToolService DI wiring', () => {
let disposables: DisposableStore;
let ix: TestInstantiationService;
beforeEach(() => {
disposables = new DisposableStore();
ix = disposables.add(new TestInstantiationService());
});
afterEach(() => disposables.dispose());
it('registers the Agent tool bound to the current agent', () => {
const register = vi.fn(() => ({ dispose: () => {} }));
ix.stub(IAgentScopeContext, { _serviceBrand: undefined, agentId: 'main' });
ix.stub(IAgentLifecycleService, {});
ix.stub(ISessionMetadata, {
read: vi.fn().mockResolvedValue({ agents: {} }),
});
ix.stub(IAgentToolRegistryService, { register });
ix.stub(IAgentBackgroundService, {});
ix.stub(IAgentProfileService, { isToolActive: vi.fn().mockReturnValue(false) });
ix.stub(IExecContext, createExecContext('/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]));
const service = ix.get(IAgentToolService);
expect(service).toBeInstanceOf(AgentToolService);
expect(register).toHaveBeenCalledTimes(1);
});
});

View file

@ -7,18 +7,23 @@ import type { IScopeHandle } from '#/_base/di/scope';
import { IAgentContextMemoryService } from '#/agent/contextMemory';
import { IAgentEventSinkService } from '#/agent/eventSink';
import { IAgentExternalHooksService } from '#/agent/externalHooks';
import {
DenyAllPermissionPolicyService,
IAgentPermissionPolicyService,
} from '#/agent/permissionPolicy';
import { IAgentProfileService } from '#/agent/profile';
import { IAgentPromptService } from '#/agent/prompt';
import { IAgentSystemReminderService } from '#/agent/systemReminder';
import { IAgentPermissionPolicyService } from '#/agent/permissionPolicy';
import { ITelemetryService } from '#/app/telemetry';
import { IAgentUsageService } from '#/agent/usage';
import { DefaultSessionSubagentHost } from '#/session/subagentHost/defaultSessionSubagentHost';
import {
cancelAllChildren,
markChildDetached,
resumeChildAgent,
retryChildAgent,
spawnChildAgent,
} from '#/agent/agentTool';
import { runChildAgentQueued } from '#/agent/swarm';
const CHILD_SUMMARY = 'child summary '.repeat(20);
const PARENT_AGENT_ID = 'main';
interface FakeScopeOptions {
readonly result?: Promise<{ reason: string; error?: unknown }>;
@ -145,7 +150,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 === 'main') return parent;
if (id === PARENT_AGENT_ID) return parent;
if (typeof children === 'function') return undefined;
return children[id];
}),
@ -157,15 +162,16 @@ function makeAgents(parent: IScopeHandle, children: Record<string, IScopeHandle>
};
}
describe('DefaultSessionSubagentHost', () => {
describe('runChildAgent', () => {
it('aborts a running subagent when the caller signal aborts', async () => {
const parent = fakeScope('main');
const parent = fakeScope(PARENT_AGENT_ID);
const child = fakeScope('child', { result: new Promise(() => {}) });
const agents = makeAgents(parent, { child });
const host = new DefaultSessionSubagentHost(agents as unknown as IAgentLifecycleService, 'main');
const controller = new AbortController();
const handle = await host.spawn({
const handle = await spawnChildAgent({
lifecycle: agents as unknown as IAgentLifecycleService,
parentAgentId: PARENT_AGENT_ID,
profileName: 'coder',
parentToolCallId: 'call_agent',
prompt: 'Run long task',
@ -180,12 +186,13 @@ describe('DefaultSessionSubagentHost', () => {
it('emits subagent spawned and started events', async () => {
const events: unknown[] = [];
const parent = fakeScope('main', { events });
const parent = fakeScope(PARENT_AGENT_ID, { events });
const child = fakeScope('child');
const agents = makeAgents(parent, { child });
const host = new DefaultSessionSubagentHost(agents as unknown as IAgentLifecycleService, 'main');
const handle = await host.spawn({
const handle = await spawnChildAgent({
lifecycle: agents as unknown as IAgentLifecycleService,
parentAgentId: PARENT_AGENT_ID,
profileName: 'explore',
parentToolCallId: 'call_agent',
prompt: 'Explore the repo',
@ -208,12 +215,13 @@ describe('DefaultSessionSubagentHost', () => {
});
it('asks for a continuation when the first summary is too short', async () => {
const parent = fakeScope('main', { initialText: 'short' });
const parent = fakeScope(PARENT_AGENT_ID, { initialText: 'short' });
const child = fakeScope('child', { initialText: 'short' });
const agents = makeAgents(parent, { child });
const host = new DefaultSessionSubagentHost(agents as unknown as IAgentLifecycleService, 'main');
const handle = await host.spawn({
const handle = await spawnChildAgent({
lifecycle: agents as unknown as IAgentLifecycleService,
parentAgentId: PARENT_AGENT_ID,
profileName: 'coder',
parentToolCallId: 'call_agent',
prompt: 'Implement',
@ -227,13 +235,14 @@ describe('DefaultSessionSubagentHost', () => {
expect(completion.result).toBe('x'.repeat(220));
});
it('persists and exposes swarmItem for spawned subagents', async () => {
const parent = fakeScope('main');
it('persists the swarmItem when spawning a subagent', async () => {
const parent = fakeScope(PARENT_AGENT_ID);
const child = fakeScope('child');
const agents = makeAgents(parent, { child });
const host = new DefaultSessionSubagentHost(agents as unknown as IAgentLifecycleService, 'main');
await host.spawn({
await spawnChildAgent({
lifecycle: agents as unknown as IAgentLifecycleService,
parentAgentId: PARENT_AGENT_ID,
profileName: 'coder',
parentToolCallId: 'call_agent',
prompt: 'Swarm task',
@ -243,9 +252,8 @@ describe('DefaultSessionSubagentHost', () => {
signal: new AbortController().signal,
});
expect(host.getSwarmItem('child')).toBe('item-1');
expect(agents.create).toHaveBeenCalledWith({
parentAgentId: 'main',
parentAgentId: PARENT_AGENT_ID,
cwd: '/repo',
type: 'sub',
swarmItem: 'item-1',
@ -253,9 +261,10 @@ describe('DefaultSessionSubagentHost', () => {
});
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 : undefined)),
getHandle: vi.fn((id: string) => (id === 'child' ? child : id === PARENT_AGENT_ID ? parent : undefined)),
createMain: vi.fn(),
create: vi.fn(),
};
@ -266,14 +275,13 @@ describe('DefaultSessionSubagentHost', () => {
},
}),
};
const host = new DefaultSessionSubagentHost(
agents as unknown as IAgentLifecycleService,
'main',
metadata as never,
);
await expect(
host.resume('child', {
resumeChildAgent({
lifecycle: agents as unknown as IAgentLifecycleService,
parentAgentId: PARENT_AGENT_ID,
metadata: metadata as never,
agentId: 'child',
parentToolCallId: 'call_agent',
prompt: 'Continue',
description: 'Continue',
@ -285,7 +293,7 @@ describe('DefaultSessionSubagentHost', () => {
it('emits subagent.failed when the child turn fails', async () => {
const events: unknown[] = [];
const parent = fakeScope('main', {
const parent = fakeScope(PARENT_AGENT_ID, {
result: Promise.resolve({ reason: 'failed', error: new Error('boom') }),
events,
});
@ -293,9 +301,10 @@ describe('DefaultSessionSubagentHost', () => {
result: Promise.resolve({ reason: 'failed', error: new Error('boom') }),
});
const agents = makeAgents(parent, { child });
const host = new DefaultSessionSubagentHost(agents as unknown as IAgentLifecycleService, 'main');
const handle = await host.spawn({
const handle = await spawnChildAgent({
lifecycle: agents as unknown as IAgentLifecycleService,
parentAgentId: PARENT_AGENT_ID,
profileName: 'coder',
parentToolCallId: 'call_agent',
prompt: 'Do work',
@ -314,13 +323,14 @@ describe('DefaultSessionSubagentHost', () => {
it('treats timeout aborts as subagent failures, not user cancellations', async () => {
const events: unknown[] = [];
const parent = fakeScope('main', { result: new Promise(() => {}), events });
const parent = fakeScope(PARENT_AGENT_ID, { result: new Promise(() => {}), events });
const child = fakeScope('child', { result: new Promise(() => {}) });
const agents = makeAgents(parent, { child });
const host = new DefaultSessionSubagentHost(agents as unknown as IAgentLifecycleService, 'main');
const controller = new AbortController();
const handle = await host.spawn({
const handle = await spawnChildAgent({
lifecycle: agents as unknown as IAgentLifecycleService,
parentAgentId: PARENT_AGENT_ID,
profileName: 'coder',
parentToolCallId: 'call_agent',
prompt: 'Run long task',
@ -340,16 +350,18 @@ describe('DefaultSessionSubagentHost', () => {
it('resumes an existing child agent and returns completion summary', async () => {
const events: unknown[] = [];
const parent = fakeScope('main', { events });
const parent = fakeScope(PARENT_AGENT_ID, { events });
const child = fakeScope('child');
const agents = {
getHandle: vi.fn((id: string) => (id === 'child' ? child : id === 'main' ? parent : undefined)),
getHandle: vi.fn((id: string) => (id === 'child' ? child : id === PARENT_AGENT_ID ? parent : undefined)),
createMain: vi.fn(),
create: vi.fn(),
};
const host = new DefaultSessionSubagentHost(agents as unknown as IAgentLifecycleService, 'main');
const handle = await host.resume('child', {
const handle = await resumeChildAgent({
lifecycle: agents as unknown as IAgentLifecycleService,
parentAgentId: PARENT_AGENT_ID,
agentId: 'child',
parentToolCallId: 'call_agent',
prompt: 'Continue',
description: 'Continue',
@ -369,65 +381,26 @@ describe('DefaultSessionSubagentHost', () => {
]);
});
it('builds a side-question child that projects history, denies tools, and adds the reminder', async () => {
const parentMessages = [
{ role: 'user', content: [{ type: 'text', text: 'earlier question' }], toolCalls: [] },
{ role: 'assistant', content: [{ type: 'text', text: 'earlier answer' }], toolCalls: [] },
];
const parent = fakeScope('main', { parentMessages });
const child = fakeScope('btw-child');
const agents = makeAgents(parent, { 'btw-child': child });
const host = new DefaultSessionSubagentHost(agents as unknown as IAgentLifecycleService, 'main');
await expect(host.startBtw()).resolves.toBe('btw-child');
expect(agents.create).toHaveBeenCalledWith({ parentAgentId: 'main', type: 'sub' });
// Loop tools copied from the parent for prompt-cache parity.
const childProfile = child.accessor.get(IAgentProfileService);
expect(childProfile.update).toHaveBeenCalledWith(
expect.objectContaining({
modelAlias: 'parent-model',
thinkingLevel: 'medium',
systemPrompt: 'parent prompt',
activeToolNames: ['Read', 'Write'],
}),
);
// Parent history projected into the child.
const childContext = child.accessor.get(IAgentContextMemoryService);
expect(childContext.splice).toHaveBeenCalledWith(0, 0, parentMessages);
// Side-question reminder appended.
const childReminder = child.accessor.get(IAgentSystemReminderService);
expect(childReminder.appendSystemReminder).toHaveBeenCalledWith(
expect.stringContaining('side-channel conversation'),
{ kind: 'system_trigger', name: 'btw' },
);
// Every tool call denied.
const childPermission = child.accessor.get(IAgentPermissionPolicyService);
expect(childPermission.registerPolicy).toHaveBeenCalledTimes(1);
const policy = mockOf(childPermission.registerPolicy).mock.calls[0]?.[0];
expect(policy).toBeInstanceOf(DenyAllPermissionPolicyService);
});
it('runs queued subagent tasks to completion', async () => {
const parent = fakeScope('main');
const parent = fakeScope(PARENT_AGENT_ID);
const child = fakeScope('child');
const agents = makeAgents(parent, { child });
const host = new DefaultSessionSubagentHost(agents as unknown as IAgentLifecycleService, 'main');
const results = await host.runQueued([
{
kind: 'spawn',
data: {},
profileName: 'coder',
parentToolCallId: 'call_agent',
prompt: 'Queued task',
description: 'Queued task',
runInBackground: false,
},
]);
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 }),
@ -438,14 +411,13 @@ describe('DefaultSessionSubagentHost', () => {
const previous = process.env['KIMI_CODE_AGENT_SWARM_MAX_CONCURRENCY'];
process.env['KIMI_CODE_AGENT_SWARM_MAX_CONCURRENCY'] = '2';
try {
const parent = fakeScope('main');
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 host = new DefaultSessionSubagentHost(agents as unknown as IAgentLifecycleService, 'main');
const tasks = Array.from({ length: 4 }, (_, index) => ({
kind: 'spawn' as const,
@ -456,7 +428,11 @@ describe('DefaultSessionSubagentHost', () => {
description: `task ${index}`,
runInBackground: false,
}));
const run = host.runQueued(tasks);
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));
@ -484,13 +460,14 @@ describe('DefaultSessionSubagentHost', () => {
}
});
it('marks an active child as detached', async () => {
const parent = fakeScope('main', { result: new Promise(() => {}) });
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 host = new DefaultSessionSubagentHost(agents as unknown as IAgentLifecycleService, 'main');
await host.spawn({
const handle = await spawnChildAgent({
lifecycle: agents as unknown as IAgentLifecycleService,
parentAgentId: PARENT_AGENT_ID,
profileName: 'coder',
parentToolCallId: 'call_agent',
prompt: 'Run detached task',
@ -498,18 +475,27 @@ describe('DefaultSessionSubagentHost', () => {
runInBackground: false,
signal: new AbortController().signal,
});
void handle.completion.catch(() => {});
await Promise.resolve();
await Promise.resolve();
host.markActiveChildDetached('child');
expect((host as unknown as { activeChildren: Map<string, { runInBackground: boolean }> }).activeChildren.get('child')?.runInBackground).toBe(true);
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('main');
const parent = fakeScope(PARENT_AGENT_ID);
const child = fakeScope('child');
const agents = makeAgents(parent, { child });
const host = new DefaultSessionSubagentHost(agents as unknown as IAgentLifecycleService, 'main');
const handle = await host.spawn({
const handle = await spawnChildAgent({
lifecycle: agents as unknown as IAgentLifecycleService,
parentAgentId: PARENT_AGENT_ID,
profileName: 'explore',
parentToolCallId: 'call_agent',
prompt: 'Explore the repo',
@ -523,7 +509,7 @@ describe('DefaultSessionSubagentHost', () => {
usage: { input: 1, output: 2, cache_read: 0, cache_write: 0 },
});
expect(agents.create).toHaveBeenCalledWith({
parentAgentId: 'main',
parentAgentId: PARENT_AGENT_ID,
cwd: '/repo',
type: 'sub',
swarmItem: undefined,
@ -531,12 +517,13 @@ describe('DefaultSessionSubagentHost', () => {
});
it('fires SubagentStart and SubagentStop external hooks around the turn', async () => {
const parent = fakeScope('main');
const parent = fakeScope(PARENT_AGENT_ID);
const child = fakeScope('child');
const agents = makeAgents(parent, { child });
const host = new DefaultSessionSubagentHost(agents as unknown as IAgentLifecycleService, 'main');
const handle = await host.spawn({
const handle = await spawnChildAgent({
lifecycle: agents as unknown as IAgentLifecycleService,
parentAgentId: PARENT_AGENT_ID,
profileName: 'explore',
parentToolCallId: 'call_agent',
prompt: 'Explore the repo',
@ -558,12 +545,13 @@ describe('DefaultSessionSubagentHost', () => {
});
it('tracks subagent_created telemetry on spawn', async () => {
const parent = fakeScope('main');
const parent = fakeScope(PARENT_AGENT_ID);
const child = fakeScope('child');
const agents = makeAgents(parent, { child });
const host = new DefaultSessionSubagentHost(agents as unknown as IAgentLifecycleService, 'main');
const handle = await host.spawn({
const handle = await spawnChildAgent({
lifecycle: agents as unknown as IAgentLifecycleService,
parentAgentId: PARENT_AGENT_ID,
profileName: 'coder',
parentToolCallId: 'call_agent',
prompt: 'Do work',
@ -582,13 +570,14 @@ describe('DefaultSessionSubagentHost', () => {
it('fires onReady on the first turn activity rather than synchronously at launch', async () => {
const ready = deferred<void>();
const parent = fakeScope('main');
const parent = fakeScope(PARENT_AGENT_ID);
const child = fakeScope('child', { ready: ready.promise });
const agents = makeAgents(parent, { child });
const host = new DefaultSessionSubagentHost(agents as unknown as IAgentLifecycleService, 'main');
const onReady = vi.fn();
const handle = await host.spawn({
const handle = await spawnChildAgent({
lifecycle: agents as unknown as IAgentLifecycleService,
parentAgentId: PARENT_AGENT_ID,
profileName: 'coder',
parentToolCallId: 'call_agent',
prompt: 'Do work',
@ -606,16 +595,18 @@ describe('DefaultSessionSubagentHost', () => {
});
it('retries the existing turn in place instead of re-prompting', async () => {
const parent = fakeScope('main');
const parent = fakeScope(PARENT_AGENT_ID);
const child = fakeScope('child');
const agents = {
getHandle: vi.fn((id: string) => (id === 'child' ? child : id === 'main' ? parent : undefined)),
getHandle: vi.fn((id: string) => (id === 'child' ? child : id === PARENT_AGENT_ID ? parent : undefined)),
createMain: vi.fn(),
create: vi.fn(),
};
const host = new DefaultSessionSubagentHost(agents as unknown as IAgentLifecycleService, 'main');
const handle = await host.retry('child', {
const handle = await retryChildAgent({
lifecycle: agents as unknown as IAgentLifecycleService,
parentAgentId: PARENT_AGENT_ID,
agentId: 'child',
parentToolCallId: 'call_agent',
prompt: 'ignored on retry',
description: 'Retry',
@ -630,12 +621,13 @@ describe('DefaultSessionSubagentHost', () => {
});
it('classifies a filtered turn as a provider safety policy block', async () => {
const parent = fakeScope('main');
const parent = fakeScope(PARENT_AGENT_ID);
const child = fakeScope('child', { result: Promise.resolve({ reason: 'filtered' }) });
const agents = makeAgents(parent, { child });
const host = new DefaultSessionSubagentHost(agents as unknown as IAgentLifecycleService, 'main');
const handle = await host.spawn({
const handle = await spawnChildAgent({
lifecycle: agents as unknown as IAgentLifecycleService,
parentAgentId: PARENT_AGENT_ID,
profileName: 'coder',
parentToolCallId: 'call_agent',
prompt: 'Do work',
@ -648,7 +640,7 @@ describe('DefaultSessionSubagentHost', () => {
});
it('rethrows a provider rate limit as an APIProviderRateLimitError', async () => {
const parent = fakeScope('main');
const parent = fakeScope(PARENT_AGENT_ID);
const child = fakeScope('child', {
result: Promise.resolve({
reason: 'failed',
@ -656,9 +648,10 @@ describe('DefaultSessionSubagentHost', () => {
}),
});
const agents = makeAgents(parent, { child });
const host = new DefaultSessionSubagentHost(agents as unknown as IAgentLifecycleService, 'main');
const handle = await host.spawn({
const handle = await spawnChildAgent({
lifecycle: agents as unknown as IAgentLifecycleService,
parentAgentId: PARENT_AGENT_ID,
profileName: 'coder',
parentToolCallId: 'call_agent',
prompt: 'Do work',
@ -670,42 +663,14 @@ describe('DefaultSessionSubagentHost', () => {
await expect(handle.completion).rejects.toSatisfy((error) => error instanceof APIProviderRateLimitError);
});
it('emits subagent.suspended when a queued attempt is requeued', () => {
const events: unknown[] = [];
const parent = fakeScope('main', { events });
const agents = makeAgents(parent, {});
const host = new DefaultSessionSubagentHost(agents as unknown as IAgentLifecycleService, 'main');
host.suspended({
task: {
kind: 'spawn',
data: {},
profileName: 'coder',
parentToolCallId: 'call_agent',
prompt: 'task',
description: 'task',
runInBackground: false,
},
agentId: 'child-1',
reason: 'Provider rate limit; subagent requeued for retry.',
});
expect(events).toEqual([
expect.objectContaining({
type: 'subagent.suspended',
subagentId: 'child-1',
reason: 'Provider rate limit; subagent requeued for retry.',
}),
]);
});
it('cancels every foreground active child with the provided reason', async () => {
const parent = fakeScope('main');
const parent = fakeScope(PARENT_AGENT_ID);
const child = fakeScope('child', { result: new Promise(() => {}) });
const agents = makeAgents(parent, { child });
const host = new DefaultSessionSubagentHost(agents as unknown as IAgentLifecycleService, 'main');
const handle = await host.spawn({
const handle = await spawnChildAgent({
lifecycle: agents as unknown as IAgentLifecycleService,
parentAgentId: PARENT_AGENT_ID,
profileName: 'coder',
parentToolCallId: 'call_agent',
prompt: 'Run long task',
@ -720,19 +685,20 @@ describe('DefaultSessionSubagentHost', () => {
const prompt = child.accessor.get(IAgentPromptService);
const turn = mockOf(prompt.prompt).mock.results[0]?.value as { abortController: AbortController };
host.cancelAll('user-stop');
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('main');
const parent = fakeScope(PARENT_AGENT_ID);
const child = fakeScope('child');
const agents = makeAgents(parent, { child });
const host = new DefaultSessionSubagentHost(agents as unknown as IAgentLifecycleService, 'main');
const handle = await host.spawn({
const handle = await spawnChildAgent({
lifecycle: agents as unknown as IAgentLifecycleService,
parentAgentId: PARENT_AGENT_ID,
profileName: 'explore',
parentToolCallId: 'call_agent',
prompt: 'Explore the repo',

View file

@ -9,7 +9,8 @@ import {
IAgentBackgroundService,
ProcessBackgroundTask,
} from '#/agent/background';
import type { SessionSubagentHost, SubagentHandle } from '#/session/subagentHost';
import type { SubagentDetachHandle } from '#/agent/background';
import type { SubagentHandle } from '#/agent/agentTool';
import { createTestAgent, type TestAgentContext } from '../harness';
import { createBackgroundTaskPersistence } from './stubs';
@ -36,7 +37,7 @@ function agentTask(
handle,
description,
{ markActiveChildDetached: vi.fn() } as unknown as Pick<
SessionSubagentHost,
SubagentDetachHandle,
'markActiveChildDetached'
>,
new AbortController(),

View file

@ -17,7 +17,8 @@ import {
ProcessBackgroundTask,
type BackgroundTaskInfo,
} from '#/agent/background';
import type { SessionSubagentHost, SubagentHandle } from '#/session/subagentHost';
import type { SubagentDetachHandle } from '#/agent/background';
import type { SubagentHandle } from '#/agent/agentTool';
import { isUserCancellation, userCancellationReason } from '#/_base/utils/abort';
import {
configServices,
@ -79,7 +80,7 @@ function agentTask(
options: {
readonly agentId?: string;
readonly subagentType?: string;
readonly subagentHost?: Pick<SessionSubagentHost, 'markActiveChildDetached'>;
readonly subagentHost?: Pick<SubagentDetachHandle, 'markActiveChildDetached'>;
readonly abortController?: AbortController;
readonly timeoutMs?: number;
} = {},

View file

@ -21,7 +21,8 @@ import { IAgentContextMemoryService } from '#/agent/contextMemory';
import { IAgentEventSinkService } from '#/agent/eventSink';
import type { HookEngine } from '#/agent/externalHooks/engine';
import { IAgentPromptService } from '#/agent/prompt';
import type { SessionSubagentHost, SubagentHandle } from '#/session/subagentHost';
import type { SubagentDetachHandle } from '#/agent/background';
import type { SubagentHandle } from '#/agent/agentTool';
import {
configServices,
createTestAgent,
@ -82,7 +83,7 @@ function agentTask(
options: {
readonly agentId?: string;
readonly subagentType?: string;
readonly subagentHost?: Pick<SessionSubagentHost, 'markActiveChildDetached'>;
readonly subagentHost?: Pick<SubagentDetachHandle, 'markActiveChildDetached'>;
readonly abortController?: AbortController;
readonly timeoutMs?: number;
} = {},

View file

@ -74,6 +74,7 @@ describe('RestGateway', () => {
onDidDispose: () => ({ dispose: () => {} }),
create: () => Promise.resolve(agentHandle),
createMain: () => Promise.resolve(agentHandle),
fork: () => Promise.resolve(agentHandle),
getHandle: (id) => (id === 'main' ? agentHandle : undefined),
list: () => [agentHandle],
remove: () => Promise.resolve(),

View file

@ -3,7 +3,6 @@ import { isAbsolute, relative, resolve } from 'node:path';
import { Readable, type Writable } from 'node:stream';
import { createControlledPromise } from '@antfu/utils';
import { type Environment, type Kaos, type KaosProcess } from '@moonshot-ai/kaos';
import {
isToolCall,
isToolCallPart,
@ -27,6 +26,7 @@ import {
AgentBackgroundService,
AgentExternalHooksService,
FileStorageService,
InMemoryStorageService,
AgentFullCompactionService,
IAgentRPCService,
IAppendLogStore,
@ -45,7 +45,7 @@ import {
IAgentExternalHooksService,
IAgentFileToolsService,
IAgentFullCompactionService,
IKaos,
IHostEnvironment,
IAgentLLMRequesterService,
ILogService,
IAgentMcpService,
@ -53,13 +53,18 @@ import {
IAgentPermissionGate,
IAgentPermissionModeService,
IAgentPermissionRulesService,
ISessionAgentFileSystem,
ISessionContext,
IAgentShellToolsService,
ISessionProcessRunner,
IStorageService,
ISessionSubagentHost,
IAgentScopeContext,
IAgentSwarmService,
AgentSwarmService,
ITelemetryService,
ISessionTerminalBackend,
IAgentToolService,
AgentToolService,
IAgentToolRegistryService,
IAgentToolStoreService,
IAgentUserToolService,
@ -84,7 +89,11 @@ import {
type Scope,
type ScopeSeed,
type ServiceIdentifier,
type AgentToolRunOverride,
} from '#/index';
import { IExecContext, createExecContext, execContextSeed } from '#/session/execContext';
import type { IProcess } from '#/session/process';
import type { AgentSwarmToolHost } from '#/agent/swarm/tools/agent-swarm';
import { Event } from '#/_base/event';
import { toDisposable } from '#/_base/di';
import type { PromisifyMethods } from '#/_base/utils/types';
@ -131,7 +140,6 @@ import { ISessionSkillCatalog } from '#/session/sessionSkillCatalog/skillCatalog
import { AgentSkillService } from '#/agent/skill/skillService';
import { ModelSkillTool } from '#/agent/skill/tools/modelSkill';
import type { SkillCatalog } from '#/app/globalSkillCatalog/types';
import { SessionSubagentHostService, type SessionSubagentHost } from '#/session/subagentHost';
import type { ExecutableToolOutput as ToolOutput, ToolResult } from '#/agent/tool';
import type {
PersistedWireRecord,
@ -140,7 +148,7 @@ import type {
WireRecordRestoreResult,
} from '#/agent/wireRecord';
import type { PathAccessOperation } from '#/session/workspaceContext';
import { createFakeKaos } from '../tools/fixtures/fake-kaos';
import { createFakeAgentFs, createFakeHostEnvironment, createFakeProcessRunner } from '../tools/fixtures/fake-exec';
import { createScriptedGenerate } from './scripted-generate';
import {
@ -151,13 +159,8 @@ import {
} from './snapshots';
import { recordAgentEvents, type RecordedEventEntry } from '../snapshot/events';
const TEST_OS_ENV: Environment = {
osKind: 'Linux',
osArch: 'x86_64',
osVersion: 'test',
shellName: 'bash',
shellPath: '/bin/bash',
};
const TEST_HOST_ENVIRONMENT: IHostEnvironment = createFakeHostEnvironment();
const TEST_HOME_DIR: string = TEST_HOST_ENVIRONMENT.homeDir;
const MOCK_PROVIDER = {
type: 'kimi',
@ -398,11 +401,113 @@ function defineServiceValue<T>(
}
}
export function kaosServices(kaos: Kaos): TestAgentServiceOverride {
return sessionServices((reg) => {
reg.defineInstance(IKaos, createIKaos(kaos));
reg.defineDescriptor(ISessionWorkspaceContext, new SyncDescriptor(SessionWorkspaceContextService));
});
/**
* Session-scope override for the execution-environment atoms
* (`IHostEnvironment` / `IExecContext` / `ISessionAgentFileSystem` /
* `ISessionProcessRunner`). Replaces the v1 `kaosServices(kaos)` helper
* tests now pass just the atoms they care about.
*/
export interface ExecEnvOverride {
readonly hostEnvironment?: IHostEnvironment | Partial<IHostEnvironment>;
readonly execContext?: IExecContext | { readonly cwd?: string; readonly envLayers?: readonly Record<string, string>[] };
readonly agentFs?: ISessionAgentFileSystem | Partial<ISessionAgentFileSystem>;
readonly processRunner?: ISessionProcessRunner | Partial<ISessionProcessRunner>;
}
/**
* Register a fake execution-environment atom set for a test session. Any
* unspecified atom keeps the harness default (fake host env, `/workspace`
* exec ctx, throwing fs/runner).
*/
export function execEnvServices(override: ExecEnvOverride = {}): TestAgentServiceOverride {
return [
override.hostEnvironment !== undefined
? appService(IHostEnvironment, resolveHostEnvironmentOverride(override.hostEnvironment))
: appServices(() => {}),
sessionServices((reg) => {
if (override.execContext !== undefined) {
const ctx = resolveExecContextOverride(override.execContext);
for (const [id, value] of execContextSeed(ctx)) {
reg.defineInstance(id as ServiceIdentifier<unknown>, value);
}
}
if (override.agentFs !== undefined) {
reg.defineInstance(ISessionAgentFileSystem, resolveAgentFsOverride(override.agentFs));
}
if (override.processRunner !== undefined) {
reg.defineInstance(ISessionProcessRunner, resolveProcessRunnerOverride(override.processRunner));
}
reg.defineDescriptor(ISessionWorkspaceContext, new SyncDescriptor(SessionWorkspaceContextService));
}),
];
}
function resolveHostEnvironmentOverride(
input: IHostEnvironment | Partial<IHostEnvironment>,
): IHostEnvironment {
// A full `IHostEnvironment` is an object literal (constructed via
// `createFakeHostEnvironment` or the real service). Anything that is not a
// plain-object literal (no prototype tricks) is treated as a full instance
// and passed through. Plain object literals with partial fields fall
// through to the fake factory. Since both `createFakeHostEnvironment` and
// real services produce plain-shape objects, we discriminate on the
// presence of `osKind` — a full env always has it.
if (typeof (input as IHostEnvironment).osKind === 'string') {
return input as IHostEnvironment;
}
return createFakeHostEnvironment(input as Partial<IHostEnvironment>);
}
function resolveExecContextOverride(
input: IExecContext | { readonly cwd?: string; readonly envLayers?: readonly Record<string, string>[] },
): IExecContext {
if ('withCwd' in input && 'withEnv' in input) return input as IExecContext;
const partial = input as { readonly cwd?: string; readonly envLayers?: readonly Record<string, string>[] };
return createExecContext(partial.cwd ?? '/workspace', partial.envLayers ?? []);
}
function resolveAgentFsOverride(
input: ISessionAgentFileSystem | Partial<ISessionAgentFileSystem>,
): ISessionAgentFileSystem {
// A full impl (class instance or `Proxy` over one) exposes every core
// method. A partial override typically covers only a few of them. If every
// core method is a function, pass the input through unchanged; otherwise
// treat it as a partial override and spread it over the fake defaults.
if (isFullAgentFs(input)) return input as ISessionAgentFileSystem;
return createFakeAgentFs(input as Partial<ISessionAgentFileSystem>);
}
function isFullAgentFs(input: unknown): boolean {
if (typeof input !== 'object' || input === null) return false;
const keys: readonly (keyof ISessionAgentFileSystem)[] = [
'readText',
'writeText',
'readBytes',
'readLines',
'writeBytes',
'stat',
'readdir',
'glob',
'mkdir',
'withCwd',
];
return keys.every((k) => typeof (input as Record<string, unknown>)[k] === 'function');
}
function resolveProcessRunnerOverride(
input: ISessionProcessRunner | Partial<ISessionProcessRunner>,
): ISessionProcessRunner {
// `ISessionProcessRunner` has only one method (`exec`), so a full impl is
// any object with `exec` as a function. Both `SessionProcessRunner`
// instances and `createFakeProcessRunner()` results satisfy this.
if (
typeof input === 'object' &&
input !== null &&
typeof (input as ISessionProcessRunner).exec === 'function'
) {
return input as ISessionProcessRunner;
}
return createFakeProcessRunner(input as Partial<ISessionProcessRunner>);
}
export function homeDirServices(homeDir: string | undefined): TestAgentServiceOverride {
@ -541,8 +646,17 @@ function createSessionSkillCatalog(catalog: SkillCatalog): ISessionSkillCatalog
};
}
export function subagentHostServices(host: SessionSubagentHost): TestAgentServiceOverride {
return agentService(ISessionSubagentHost, new SyncDescriptor(SessionSubagentHostService, [host]));
export function agentToolServices(runOverride: AgentToolRunOverride): TestAgentServiceOverride {
return agentService(
IAgentToolService,
new SyncDescriptor(AgentToolService, [runOverride]),
);
}
export function swarmServices(
runQueued: AgentSwarmToolHost['runQueued'],
): TestAgentServiceOverride {
return agentService(IAgentSwarmService, new SyncDescriptor(AgentSwarmService, [runQueued]));
}
export function goalServices(options: GoalServiceOptions): TestAgentServiceOverride {
@ -553,24 +667,26 @@ export function replayServices(options: ReplayBuilderServiceOptions = {}): TestA
return agentService(IAgentReplayBuilderService, new SyncDescriptor(AgentReplayBuilderService, [options]));
}
export function createCommandKaos(stdout: string): Kaos {
function createProcess(): KaosProcess {
/**
* 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 {
return {
stdin: { write: vi.fn(), end: vi.fn() } as unknown as Writable,
stdout: Readable.from([stdout]),
stderr: Readable.from(['']),
pid: 42,
exitCode: 0,
wait: vi.fn().mockResolvedValue(0),
kill: vi.fn().mockResolvedValue(undefined),
dispose: vi.fn().mockResolvedValue(undefined),
exitCode,
wait: vi.fn().mockResolvedValue(exitCode) as IProcess['wait'],
kill: vi.fn().mockResolvedValue(undefined) as IProcess['kill'],
dispose: vi.fn().mockResolvedValue(undefined) as IProcess['dispose'],
};
}
return createFakeKaos({
execWithEnv: vi.fn().mockImplementation(async () => createProcess()),
mkdir: vi.fn().mockResolvedValue(undefined),
writeText: vi.fn(async (_path: string, content: string) => content.length),
return createFakeProcessRunner({
exec: vi.fn().mockImplementation(async () => createProcess()),
});
}
@ -837,7 +953,6 @@ export class AgentTestContext {
this.emitter.on('error', () => {});
this.kimiConfig = applyTestAgentOptionsToConfig(emptyConfig(), options);
const kaos = createFakeKaos();
const sessionId = 'test-session';
const agentId = 'main';
const persistence = options.persistence ?? new InMemoryWireRecordPersistence();
@ -847,11 +962,28 @@ export class AgentTestContext {
for (const [id, value] of bootstrapSeed({
homeDir: '/tmp/kimi-code-agent-app-v2-test',
cwd: this.cwd,
osHomeDir: kaos.gethome(),
osHomeDir: TEST_HOME_DIR,
env: process.env,
})) {
reg.defineInstance(id, value);
}
// Fake `IHostEnvironment` — a real `HostEnvironmentService` would kick
// off an async probe (spawn `sh --version` on Windows, `os.homedir()`,
// etc.) at App-scope construction; the harness stubs it with a
// deterministic Linux/bash snapshot instead. Tests can override with
// `execEnvServices({ hostEnvironment: … })`.
reg.defineInstance(IHostEnvironment, TEST_HOST_ENVIRONMENT);
// In-memory Storage-layer backend. The `InMemoryStorageService` is no
// longer auto-registered, so the harness seeds it here to keep a
// workable default for storage-backed services. Tests that need durable
// (file) storage override this via `homeDirServices(dir)` — overrides
// win over this base seed (see `collectScopeSeed`).
const memoryStorage = (): SyncDescriptor<IStorageService> =>
new SyncDescriptor(InMemoryStorageService, [], true);
reg.defineDescriptor(IStorageService, memoryStorage());
reg.defineDescriptor(IAppendLogStorage, memoryStorage());
reg.defineDescriptor(IAtomicDocumentStorage, memoryStorage());
reg.defineDescriptor(IBlobStorage, memoryStorage());
reg.defineInstance(IConfigService, configService(() => this.kimiConfig));
reg.defineInstance(
IAppendLogStore,
@ -891,7 +1023,14 @@ export class AgentTestContext {
reg.defineInstance(ISessionInteractionService, this.createInteractionService());
reg.defineInstance(ISessionApprovalService, this.createApprovalService());
reg.defineInstance(ISessionQuestionService, this.createQuestionService());
reg.defineInstance(IKaos, createIKaos(kaos));
// Seed the session `IExecContext` (was `IKaos` in the old harness).
for (const [id, value] of execContextSeed(createExecContext(this.cwd))) {
reg.defineInstance(id as ServiceIdentifier<unknown>, value);
}
// Note: `ISessionAgentFileSystem` and `ISessionProcessRunner` are
// auto-registered by their service files (backed by `IExecContext`
// and Node fs/spawn). Tests that need a fake override them via
// `execEnvServices({ agentFs: … })` / `execEnvServices({ processRunner: … })`.
reg.defineInstance(ISessionTerminalBackend, createTerminalBackend());
reg.defineDescriptor(ISessionWorkspaceContext, new SyncDescriptor(SessionWorkspaceContextService));
reg.defineDescriptor(ISessionModelResolver, new SyncDescriptor(ConfigBackedModelResolver, [{}]));
@ -937,9 +1076,10 @@ export class AgentTestContext {
reg.defineDescriptor(IAgentGoalService, new SyncDescriptor(AgentGoalService, [{}]));
reg.defineDescriptor(IAgentSkillService, new SyncDescriptor(AgentSkillService));
reg.defineDescriptor(IAgentUserToolService, new SyncDescriptor(AgentUserToolService));
reg.defineInstance(IAgentScopeContext, { _serviceBrand: undefined, agentId });
reg.defineDescriptor(
ISessionSubagentHost,
new SyncDescriptor(SessionSubagentHostService, [unavailableSubagentHost()]),
IAgentToolService,
new SyncDescriptor(AgentToolService, [unavailableAgentToolRun()]),
);
},
], this.serviceOverrides, 'agent'),
@ -1368,7 +1508,7 @@ export class AgentTestContext {
const resumed = createTestAgent(
{ autoConfigure: false },
...this.serviceOverrides,
kaosServices(createResumeNoSideEffectKaos(profile.data().cwd)),
createResumeNoSideEffectExecEnv(profile.data().cwd),
configServices(() => configSnapshot),
llmGenerateServices(failOnResumeGenerate),
wireRecordPersistenceServices(new InMemoryWireRecordPersistence(
@ -1644,28 +1784,6 @@ export class AgentTestContext {
}
}
function createIKaos(kaos: Kaos): IKaos {
return {
_serviceBrand: undefined,
get name() {
return kaos.name;
},
get cwd() {
return kaos.getcwd();
},
get osEnv() {
return kaos.osEnv;
},
backend: kaos,
pathClass: () => kaos.pathClass(),
normpath: (path) => kaos.normpath(path),
gethome: () => kaos.gethome(),
getcwd: () => kaos.getcwd(),
withCwd: (cwd) => createIKaos(kaos.withCwd(cwd)),
withEnv: (env) => createIKaos(kaos.withEnv(env)),
};
}
function createWorkspaceContextStub(
initialWorkDir: string,
initialAdditionalDirs: readonly string[],
@ -1773,21 +1891,16 @@ function createTerminalBackend(): ISessionTerminalBackend {
};
}
function unavailableSubagentHost(): SessionSubagentHost {
function unavailableAgentToolRun(): AgentToolRunOverride {
const fail = async (): Promise<never> => {
throw new Error('Subagent host is not configured in this test.');
throw new Error('Agent tool run is not configured in this test.');
};
return {
getSwarmItem: () => undefined,
startBtw: fail,
spawn: fail,
resume: fail,
retry: fail,
getProfileName: async () => undefined,
markActiveChildDetached: () => {},
runQueued: async () => [],
cancelAll: () => {},
suspended: () => {},
markDetached: () => {},
};
}
@ -1795,36 +1908,12 @@ const failOnResumeGenerate: GenerateFn = async () => {
throw new Error('Resume replay unexpectedly called the LLM');
};
function createResumeNoSideEffectKaos(initialCwd: string): Kaos {
const fail = (method: string): never => {
throw new Error(`Resume replay unexpectedly called kaos.${method}`);
};
let cwd = initialCwd;
return {
name: 'resume-no-side-effects',
osEnv: TEST_OS_ENV,
pathClass: () => 'posix',
normpath: (p: string) => p,
gethome: () => '/home/test',
getcwd: () => cwd,
withCwd: (next: string) => createResumeNoSideEffectKaos(next),
withEnv: () => createResumeNoSideEffectKaos(cwd),
chdir: async (next: string) => {
cwd = next;
},
stat: () => fail('stat'),
iterdir: () => fail('iterdir'),
glob: () => fail('glob'),
readBytes: () => fail('readBytes'),
readText: () => fail('readText'),
readLines: () => fail('readLines'),
writeBytes: () => fail('writeBytes'),
writeText: () => fail('writeText'),
mkdir: () => fail('mkdir'),
exec: () => fail('exec'),
execWithEnv: () => fail('execWithEnv'),
};
function createResumeNoSideEffectExecEnv(initialCwd: string): TestAgentServiceOverride {
return execEnvServices({
execContext: { cwd: initialCwd },
// Any fs/process interaction during a resume replay is a bug — surface it
// loudly instead of silently no-oping.
});
}
function resumeStateSnapshot(ctx: AgentTestContext): ResumeStateSnapshot {

View file

@ -4,17 +4,17 @@ export {
agentServices,
backgroundServices,
configServices,
createCommandKaos,
createCommandRunner,
createTestAgent,
appService,
appServices,
cronServices,
execEnvServices,
externalHookServices,
fullCompactionServices,
goalServices,
homeDirServices,
InMemoryWireRecordPersistence,
kaosServices,
llmGenerateServices,
logServices,
mcpServices,
@ -28,7 +28,8 @@ export {
sessionService,
sessionServices,
skillServices,
subagentHostServices,
agentToolServices,
swarmServices,
telemetryServices,
testAgent,
wireRecordPersistenceServices,

View file

@ -58,6 +58,7 @@ function lifecycle(handles: readonly IScopeHandle[]): IAgentLifecycleService {
onDidDispose: () => ({ dispose: () => {} }),
create: () => Promise.resolve(handles[0]!),
createMain: () => Promise.resolve(handles[0]!),
fork: () => Promise.resolve(handles[0]!),
getHandle: () => undefined,
list: () => handles,
remove: () => Promise.resolve(),

View file

@ -1,79 +0,0 @@
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
import { SyncDescriptor } from '#/_base/di/descriptors';
import { DisposableStore } from '#/_base/di/lifecycle';
import { TestInstantiationService } from '#/_base/di/test';
import { IAgentBackgroundService } from '#/agent/background';
import { IAgentLifecycleService } from '#/session/agent-lifecycle';
import { ILogService } from '#/app/log';
import { IAgentProfileService } from '#/agent/profile';
import { IAgentToolRegistryService } from '#/agent/toolRegistry';
import type { IScopeHandle } from '#/_base/di/scope';
import { IKaos } from '#/app/kaos';
import { ISessionMetadata } from '#/session/session-metadata';
import { ISessionProcessRunner } from '#/session/process';
import { ISessionSubagentHost, SessionSubagentHostService } from '#/session/subagentHost';
function fakeProfileScope(id: string): IScopeHandle {
const profile = {
data: vi.fn(() => ({
cwd: '/repo',
modelAlias: 'parent-model',
thinkingLevel: 'medium',
systemPrompt: 'parent prompt',
activeToolNames: ['Read', 'Write'],
})),
update: vi.fn(),
};
return {
id,
accessor: {
get: vi.fn((token: unknown) => (token === IAgentProfileService ? profile : undefined)),
},
} as unknown as IScopeHandle;
}
describe('SessionSubagentHostService DI wiring', () => {
let disposables: DisposableStore;
let ix: TestInstantiationService;
beforeEach(() => {
disposables = new DisposableStore();
ix = disposables.add(new TestInstantiationService());
});
afterEach(() => disposables.dispose());
it('constructs a DefaultSessionSubagentHost when no host override is provided', async () => {
const register = vi.fn(() => ({ dispose: () => {} }));
const parent = fakeProfileScope('main');
const child = fakeProfileScope('child');
ix.stub(IAgentLifecycleService, {
getHandle: vi.fn().mockReturnValue(undefined),
createMain: vi.fn().mockResolvedValue(parent),
create: vi.fn().mockResolvedValue(child),
});
ix.stub(ISessionMetadata, {
ready: Promise.resolve(),
read: vi.fn().mockResolvedValue({ agents: {} }),
onDidChange: () => ({ dispose: () => {} }),
update: vi.fn(),
setTitle: vi.fn(),
setArchived: vi.fn(),
registerAgent: vi.fn(),
});
ix.stub(IAgentToolRegistryService, { register });
ix.stub(IAgentBackgroundService, {});
ix.stub(IAgentProfileService, { isToolActive: vi.fn().mockReturnValue(false) });
ix.stub(IKaos, { 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(ISessionSubagentHost, new SyncDescriptor(SessionSubagentHostService, [undefined]));
const service = ix.get(ISessionSubagentHost);
expect(service).toBeInstanceOf(SessionSubagentHostService);
expect(register).toHaveBeenCalledTimes(1);
await expect(service.startBtw()).resolves.toBe('child');
});
});

View file

@ -7,10 +7,13 @@ import { IAgentContextMemoryService } from '#/agent/contextMemory';
import { IAgentEventSinkService } from '#/agent/eventSink';
import {
DEFAULT_SUBAGENT_TIMEOUT_MS,
ISessionSubagentHost,
} from '#/agent/agentTool';
import {
type QueuedSubagentRunResult,
type QueuedSubagentTask,
} from '#/session/subagentHost';
} from '#/agent/swarm';
import { IAgentLifecycleService } from '#/session/agent-lifecycle';
import { IAgentScopeContext } from '#/agent/scopeContext';
import { IAgentSystemReminderService } from '#/agent/systemReminder';
import { AgentSystemReminderService } from '#/agent/systemReminder/systemReminderService';
import { IAgentSwarmService } from '#/agent/swarm';
@ -34,7 +37,7 @@ function context<Input>(
return { turnId: '0', toolCallId, args, signal };
}
function mockSubagentHost({
function mockSwarmHost({
getSwarmItem = () => undefined,
runQueued = vi.fn().mockResolvedValue([]),
}: {
@ -43,10 +46,11 @@ function mockSubagentHost({
readonly runQueued?: (...args: any[]) => any;
} = {}) {
return {
lifecycle: {} as never,
parentAgentId: 'main',
getSwarmItem: vi.fn(getSwarmItem),
runQueued,
// eslint-disable-next-line @typescript-eslint/no-explicit-any
} as any;
};
}
function mockSwarmMode() {
@ -65,7 +69,8 @@ describe('AgentSwarmService', () => {
ix.stub(IAgentEventSinkService, { emit: () => {}, on: () => toDisposable(() => {}) });
ix.stub(IAgentTurnService, stubTurnWithHooks());
ix.set(IAgentToolRegistryService, new SyncDescriptor(AgentToolRegistryService));
ix.stub(ISessionSubagentHost, {});
ix.stub(IAgentLifecycleService, {});
ix.stub(IAgentScopeContext, { _serviceBrand: undefined, agentId: 'main' });
ix.set(IAgentSystemReminderService, new SyncDescriptor(AgentSystemReminderService));
ix.set(IAgentSwarmService, new SyncDescriptor(AgentSwarmService));
});
@ -83,7 +88,7 @@ describe('AgentSwarmService', () => {
describe('AgentSwarmTool', () => {
it('applies one subagent_type across templated subagents', async () => {
const host = mockSubagentHost({
const host = mockSwarmHost({
runQueued: vi.fn().mockResolvedValue([
{
task: {
@ -161,7 +166,7 @@ describe('AgentSwarmTool', () => {
expect(swarmMode.enter).toHaveBeenCalledWith('tool');
expect(host.runQueued).toHaveBeenCalledTimes(1);
expect(host.runQueued).toHaveBeenCalledWith([
expect(host.runQueued).toHaveBeenCalledWith(expect.objectContaining({ tasks: [
{
kind: 'spawn',
data: {
@ -198,7 +203,7 @@ describe('AgentSwarmTool', () => {
signal,
timeout: DEFAULT_SUBAGENT_TIMEOUT_MS,
},
]);
] }));
expect(result.output).toBe(
[
'<agent_swarm_result>',
@ -212,7 +217,7 @@ describe('AgentSwarmTool', () => {
});
it('does not expose permission rule argument matching', () => {
const tool = new AgentSwarmTool(mockSubagentHost(), mockSwarmMode());
const tool = new AgentSwarmTool(mockSwarmHost(), mockSwarmMode());
const execution = tool.resolveExecution({
description: 'Review files',
prompt_template: 'Review {{item}}',
@ -270,7 +275,7 @@ describe('AgentSwarmTool', () => {
];
for (const testCase of cases) {
const host = mockSubagentHost();
const host = mockSwarmHost();
const tool = new AgentSwarmTool(host, mockSwarmMode());
const result = await executeTool(tool, context(testCase.input));
@ -283,9 +288,11 @@ describe('AgentSwarmTool', () => {
it('resumes mapped agents before spawning item subagents', async () => {
const runQueued = vi.fn(
async <T>(
tasks: readonly QueuedSubagentTask<T>[],
): Promise<Array<QueuedSubagentRunResult<T>>> =>
async <T>({
tasks,
}: {
tasks: readonly QueuedSubagentTask<T>[];
}): Promise<Array<QueuedSubagentRunResult<T>>> =>
tasks.map((task, index) => ({
task,
agentId: task.kind === 'resume' ? task.resumeAgentId : `agent-new-${String(index + 1)}`,
@ -293,7 +300,7 @@ describe('AgentSwarmTool', () => {
result: `result ${String(index + 1)}`,
})),
);
const host = mockSubagentHost({
const host = mockSwarmHost({
getSwarmItem: (agentId) =>
({ 'agent-old-1': 'src/old-a.ts', 'agent-old-2': 'src/old-b.ts' })[agentId],
runQueued,
@ -320,7 +327,7 @@ describe('AgentSwarmTool', () => {
const result = await executeTool(tool, context(input));
expect(host.runQueued).toHaveBeenCalledWith([
expect(host.runQueued).toHaveBeenCalledWith(expect.objectContaining({ tasks: [
{
kind: 'resume',
data: {
@ -379,7 +386,7 @@ describe('AgentSwarmTool', () => {
signal,
timeout: DEFAULT_SUBAGENT_TIMEOUT_MS,
},
]);
] }));
expect(result.output).toBe(
[
'<agent_swarm_result>',
@ -395,9 +402,11 @@ describe('AgentSwarmTool', () => {
it('allows a single resumed subagent without item subagents', async () => {
const runQueued = vi.fn(
async <T>(
tasks: readonly QueuedSubagentTask<T>[],
): Promise<Array<QueuedSubagentRunResult<T>>> =>
async <T>({
tasks,
}: {
tasks: readonly QueuedSubagentTask<T>[];
}): Promise<Array<QueuedSubagentRunResult<T>>> =>
tasks.map((task) => ({
task,
agentId: task.kind === 'resume' ? task.resumeAgentId : 'agent-new',
@ -405,7 +414,7 @@ describe('AgentSwarmTool', () => {
result: 'resumed result',
})),
);
const host = mockSubagentHost({
const host = mockSwarmHost({
getSwarmItem: (agentId) => (agentId === 'agent-old-1' ? 'src/old-a.ts' : undefined),
runQueued,
});
@ -419,7 +428,7 @@ describe('AgentSwarmTool', () => {
const result = await executeTool(tool, context(input));
expect(host.runQueued).toHaveBeenCalledWith([
expect(host.runQueued).toHaveBeenCalledWith(expect.objectContaining({ tasks: [
{
kind: 'resume',
data: {
@ -440,7 +449,7 @@ describe('AgentSwarmTool', () => {
signal,
timeout: DEFAULT_SUBAGENT_TIMEOUT_MS,
},
]);
] }));
expect(result.output).toBe(
[
'<agent_swarm_result>',
@ -452,8 +461,8 @@ describe('AgentSwarmTool', () => {
});
it('reports failed subagents inside the XML result without failing the tool', async () => {
const host = mockSubagentHost({
runQueued: vi.fn().mockImplementation(async (tasks) => [
const host = mockSwarmHost({
runQueued: vi.fn().mockImplementation(async ({ tasks }) => [
{
task: tasks[0],
agentId: 'agent-coder-1',
@ -493,8 +502,8 @@ describe('AgentSwarmTool', () => {
});
it('omits resume hint when incomplete subagents have no agent ids', async () => {
const host = mockSubagentHost({
runQueued: vi.fn().mockImplementation(async (tasks) => [
const host = mockSwarmHost({
runQueued: vi.fn().mockImplementation(async ({ tasks }) => [
{
task: tasks[0],
status: 'failed',
@ -530,8 +539,8 @@ describe('AgentSwarmTool', () => {
});
it('reports partial aborted subagents inside the XML result', async () => {
const host = mockSubagentHost({
runQueued: vi.fn().mockImplementation(async (tasks) => [
const host = mockSwarmHost({
runQueued: vi.fn().mockImplementation(async ({ tasks }) => [
{
task: tasks[0],
agentId: 'agent-coder-1',

View file

@ -1,21 +1,21 @@
import { Readable, type Writable } from 'node:stream';
import type { Kaos, KaosProcess } from '@moonshot-ai/kaos';
import type { ToolCall } from '@moonshot-ai/kosong';
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
import { IAgentContextMemoryService } from '#/agent/contextMemory';
import { HookEngine } from '#/agent/externalHooks/engine';
import { IAgentProfileService } from '#/agent/profile';
import type { SessionSubagentHost } from '#/session/subagentHost';
import type { AgentToolRunOverride } from '#/agent/agentTool';
import { IAgentToolRegistryService } from '#/agent/toolRegistry';
import { createFakeKaos } from '../tools/fixtures/fake-kaos';
import type { IProcess, ISessionProcessRunner } from '#/session/process';
import { createFakeProcessRunner } from '../tools/fixtures/fake-exec';
import {
createCommandKaos,
agentToolServices,
createCommandRunner,
createTestAgent,
execEnvServices,
externalHookServices,
kaosServices,
subagentHostServices,
type TestAgentContext,
} from '../harness';
import { executeTool } from '../tools/fixtures/execute-tool';
@ -37,13 +37,11 @@ describe('Agent tools', () => {
});
describe('PreToolUse blocking', () => {
let execWithEnv: NonNullable<Kaos['execWithEnv']>;
let exec: ReturnType<typeof vi.fn>;
let triggered: Array<[string, string, number]>;
beforeEach(() => {
execWithEnv = vi
.fn<NonNullable<Kaos['execWithEnv']>>()
.mockRejectedValue(new Error('Bash should not execute'));
exec = vi.fn<ISessionProcessRunner['exec']>().mockRejectedValue(new Error('Bash should not execute'));
triggered = [];
const hookEngine = new HookEngine(
[
@ -65,7 +63,7 @@ describe('Agent tools', () => {
},
);
ctx = createTestAgent(
kaosServices(createFakeKaos({ execWithEnv })),
execEnvServices({ processRunner: createFakeProcessRunner({ exec: exec as unknown as ISessionProcessRunner['exec'] }) }),
externalHookServices(hookEngine),
);
context = ctx.get(IAgentContextMemoryService);
@ -80,7 +78,7 @@ describe('Agent tools', () => {
await ctx.untilTurnEnd();
expect(execWithEnv).not.toHaveBeenCalled();
expect(exec).not.toHaveBeenCalled();
expect(triggered).toEqual([
['PreToolUse', 'Bash', 1],
['PostToolUseFailure', 'Bash', 1],
@ -125,7 +123,7 @@ describe('Agent tools', () => {
},
);
ctx = createTestAgent(
kaosServices(createCommandKaos('hook-output')),
execEnvServices({ processRunner: createCommandRunner('hook-output') }),
externalHookServices(hookEngine),
);
profile = ctx.get(IAgentProfileService);
@ -175,7 +173,7 @@ describe('Agent tools', () => {
},
);
ctx = createTestAgent(
kaosServices(createFailingCommandKaos('hook-output')),
execEnvServices({ processRunner: createFailingCommandRunner('hook-output') }),
externalHookServices(hookEngine),
);
profile = ctx.get(IAgentProfileService);
@ -198,7 +196,7 @@ describe('Agent tools', () => {
describe('Bash tool call start event', () => {
beforeEach(async () => {
ctx = createTestAgent(kaosServices(createCommandKaos('ok')));
ctx = createTestAgent(execEnvServices({ processRunner: createCommandRunner('ok') }));
profile = ctx.get(IAgentProfileService);
profile.update({ activeToolNames: ['Bash'] });
await ctx.rpc.setPermission({ mode: 'yolo' });
@ -220,14 +218,14 @@ describe('Agent tools', () => {
});
describe('foreground Agent tool recovery', () => {
let subagentHost: SessionSubagentHost;
let runOverride: AgentToolRunOverride;
beforeEach(() => {
const completion = Promise.reject(
new Error('Subagent turn failed before completing its final summary: reason=max_tokens.'),
);
void completion.catch(() => undefined);
subagentHost = {
runOverride = {
spawn: vi.fn().mockResolvedValue({
agentId: 'agent-child',
profileName: 'coder',
@ -235,8 +233,11 @@ describe('Agent tools', () => {
completion,
}),
resume: vi.fn(),
} as unknown as SessionSubagentHost;
ctx = createTestAgent(subagentHostServices(subagentHost));
retry: vi.fn(),
getProfileName: vi.fn().mockResolvedValue(undefined),
markDetached: vi.fn(),
};
ctx = createTestAgent(agentToolServices(runOverride));
profile = ctx.get(IAgentProfileService);
profile.update({ activeToolNames: ['Agent'] });
});
@ -250,7 +251,7 @@ describe('Agent tools', () => {
await ctx.rpc.prompt({ input: [{ type: 'text', text: 'Delegate and recover' }] });
await ctx.untilTurnEnd();
expect(subagentHost.spawn).toHaveBeenCalledWith(
expect(runOverride.spawn).toHaveBeenCalledWith(
expect.objectContaining({
profileName: 'coder',
parentToolCallId: 'call_agent',
@ -529,24 +530,21 @@ function bashCall(): ToolCall {
};
}
function createFailingCommandKaos(stdout: string): ReturnType<typeof createFakeKaos> {
function createProcess(): KaosProcess {
function createFailingCommandRunner(stdout: string): ISessionProcessRunner {
function createProcess(): IProcess {
return {
stdin: { write: vi.fn(), end: vi.fn() } as unknown as Writable,
stdout: Readable.from([stdout]),
stderr: Readable.from(['']),
pid: 42,
exitCode: 2,
wait: vi.fn().mockResolvedValue(2),
kill: vi.fn().mockResolvedValue(undefined),
dispose: vi.fn().mockResolvedValue(undefined),
wait: vi.fn().mockResolvedValue(2) as IProcess['wait'],
kill: vi.fn().mockResolvedValue(undefined) as IProcess['kill'],
dispose: vi.fn().mockResolvedValue(undefined) as IProcess['dispose'],
};
}
return createFakeKaos({
execWithEnv: vi.fn().mockImplementation(async () => createProcess()),
mkdir: vi.fn().mockResolvedValue(undefined),
writeText: vi.fn(async (_path: string, content: string) => content.length),
return createFakeProcessRunner({
exec: vi.fn().mockImplementation(async () => createProcess()),
});
}

View file

@ -4,7 +4,6 @@ import { Readable } from 'node:stream';
import { join } from 'pathe';
import { setTimeout as delay } from 'node:timers/promises';
import type { Kaos, KaosProcess } from '@moonshot-ai/kaos';
import {
APIConnectionError,
APIEmptyResponseError,
@ -22,7 +21,7 @@ import type { ContextMessage } from '#/agent/contextMemory';
import { IOAuthService } from '#/app/auth';
import { ErrorCodes, KimiError } from '#/errors';
import { HookEngine } from '#/agent/externalHooks/engine';
import { IKaos } from '#/app/kaos';
import { IHostEnvironment } from '#/app/hostEnvironment';
import type { ILogger as Logger, LogPayload } from '#/app/log';
import { IAgentMcpService } from '#/agent/mcp';
import { McpConnectionManager } from '#/agent/mcp/connection-manager';
@ -32,21 +31,21 @@ import { IAgentProfileService } from '#/agent/profile';
import { IAgentSwarmService } from '#/agent/swarm';
import { IAgentTurnService } from '#/agent/turn';
import { IAgentToolRegistryService } from '#/agent/toolRegistry';
import type { IProcess, ISessionProcessRunner } from '#/session/process';
import type {
QueuedSubagentRunResult,
QueuedSubagentTask,
SessionSubagentHost,
} from '#/session/subagentHost';
} from '#/agent/swarm';
import { recordingTelemetry, type TelemetryRecord } from '../telemetry/stubs';
import { createFakeKaos } from '../tools/fixtures/fake-kaos';
import { createFakeAgentFs, createFakeProcessRunner } from '../tools/fixtures/fake-exec';
import {
configServices,
appServices,
createCommandKaos,
kaosServices,
createCommandRunner,
execEnvServices,
logServices,
mcpServices,
subagentHostServices,
swarmServices,
testAgent,
type TestAgentOptions,
type TestAgentServiceOverride,
@ -89,8 +88,8 @@ describe('Agent turn flow', () => {
.mockImplementation((signal?: AbortSignal) =>
signal === undefined ? initialLoad : abortable(initialLoad, signal),
);
const { kaos, execWithEnv } = createExecKaos('mcp-ready');
const ctx = testAgent(mcpServices({ manager: mcp }), kaosServices(kaos));
const { runner, exec: execWithEnv } = createExecRunner('mcp-ready');
const ctx = testAgent(mcpServices({ manager: mcp }), execEnvServices({ processRunner: runner }));
ctx.get(IAgentMcpService);
ctx.configure({ tools: ['Bash'] });
await ctx.rpc.setPermission({ mode: 'yolo' });
@ -121,8 +120,8 @@ describe('Agent turn flow', () => {
.mockImplementation((signal?: AbortSignal) =>
signal === undefined ? initialLoad : abortable(initialLoad, signal),
);
const { kaos, execWithEnv } = createExecKaos('should-not-run');
const ctx = testAgent(mcpServices({ manager: mcp }), kaosServices(kaos));
const { runner, exec: execWithEnv } = createExecRunner('should-not-run');
const ctx = testAgent(mcpServices({ manager: mcp }), execEnvServices({ processRunner: runner }));
ctx.get(IAgentMcpService);
ctx.configure({ tools: ['Bash'] });
await ctx.rpc.setPermission({ mode: 'yolo' });
@ -168,7 +167,7 @@ describe('Agent turn flow', () => {
it('tracks duplicate tool-call detection telemetry', async () => {
const records: TelemetryRecord[] = [];
const ctx = testAgent(kaosServices(createCommandKaos('dup')), {
const ctx = testAgent(execEnvServices({ processRunner: createCommandRunner('dup') }), {
telemetry: recordingTelemetry(records),
});
ctx.configure({ tools: ['Bash'] });
@ -207,7 +206,7 @@ describe('Agent turn flow', () => {
it('tracks cross-step duplicate tool-call detection telemetry', async () => {
const records: TelemetryRecord[] = [];
const ctx = testAgent(kaosServices(createCommandKaos('dup')), {
const ctx = testAgent(execEnvServices({ processRunner: createCommandRunner('dup') }), {
telemetry: recordingTelemetry(records),
});
ctx.configure({ tools: ['Bash'] });
@ -270,7 +269,7 @@ describe('Agent turn flow', () => {
},
},
);
const ctx = testAgent(kaosServices(createCommandKaos('dup')), { hookEngine });
const ctx = testAgent(execEnvServices({ processRunner: createCommandRunner('dup') }), { hookEngine });
ctx.configure({ tools: ['Bash'] });
await ctx.rpc.setPermission({ mode: 'yolo' });
@ -447,7 +446,7 @@ describe('Agent turn flow', () => {
it('enters silent swarm mode when the agent calls AgentSwarm', async () => {
const runQueued = vi.fn(async <T>(
tasks: readonly QueuedSubagentTask<T>[],
{ tasks }: { tasks: readonly QueuedSubagentTask<T>[] },
): Promise<Array<QueuedSubagentRunResult<T>>> => {
return tasks.map((task, index) => ({
task,
@ -456,10 +455,7 @@ describe('Agent turn flow', () => {
result: `result ${String(index + 1)}`,
}));
});
const subagentHost = mockSubagentHost({
runQueued: runQueued as unknown as SessionSubagentHost['runQueued'],
});
const ctx = testAgent(subagentHostServices(subagentHost));
const ctx = testAgent(swarmServices(runQueued as never));
ctx.configure({ tools: ['AgentSwarm'] });
await ctx.rpc.setPermission({ mode: 'yolo' });
@ -951,7 +947,7 @@ describe('Agent turn flow', () => {
timeout: 5,
},
]);
const ctx = testAgent(kaosServices(createFakeKaos({ execWithEnv })), {
const ctx = testAgent(execEnvServices({ processRunner: createFakeProcessRunner({ exec: execWithEnv }) }), {
hookEngine,
});
const authorize = vi.spyOn(ctx.get(IAgentPermissionGate), 'authorize');
@ -1357,7 +1353,7 @@ describe('Agent turn flow', () => {
});
it('honors configured maxStepsPerTurn in agent turns', async () => {
const ctx = testAgent(kaosServices(createCommandKaos('loop-output')), {
const ctx = testAgent(execEnvServices({ processRunner: createCommandRunner('loop-output') }), {
initialConfig: {
providers: {},
loopControl: { maxStepsPerTurn: 1 },
@ -1706,7 +1702,7 @@ describe('Agent turn flow', () => {
throw new APIStatusError(401, 'Unauthorized', 'req-upload-401');
}),
} as unknown as ChatProvider;
const ctx = testAgent(oauthOptions.services, kaosServices(createVideoKaos()), {
const ctx = testAgent(oauthOptions.services, execEnvServices({ agentFs: createVideoAgentFs() }), {
initialConfig: oauthOptions.initialConfig,
autoConfigure: false,
});
@ -1727,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,
@ -1756,7 +1752,7 @@ describe('Agent turn flow', () => {
it('cancels an active turn', async () => {
const records: TelemetryRecord[] = [];
const ctx = testAgent(kaosServices(createCommandKaos('should-not-run')), {
const ctx = testAgent(execEnvServices({ processRunner: createCommandRunner('should-not-run') }), {
telemetry: recordingTelemetry(records),
});
ctx.configure({ tools: ['Bash'] });
@ -1804,7 +1800,7 @@ describe('Agent turn flow', () => {
name: 'Bash',
arguments: '{"command":"printf approved","timeout":60}',
};
const ctx = testAgent(kaosServices(createCommandKaos('approved')));
const ctx = testAgent(execEnvServices({ processRunner: createCommandRunner('approved') }));
ctx.configure({ tools: ['Bash'] });
ctx.mockNextResponse({ type: 'text', text: 'I will ask first.' }, bashCall);
@ -1878,7 +1874,7 @@ describe('Agent turn flow', () => {
});
it('rejects a non-steer prompt while a turn is active', async () => {
const ctx = testAgent(kaosServices(createCommandKaos('should-not-run')));
const ctx = testAgent(execEnvServices({ processRunner: createCommandRunner('should-not-run') }));
ctx.configure({ tools: ['Bash'] });
ctx.mockNextResponse({ type: 'text', text: 'I will wait for approval.' }, bashCall());
@ -1995,13 +1991,6 @@ function agentSwarmCall(): ToolCall {
};
}
function mockSubagentHost<T extends Partial<SessionSubagentHost>>(
host: T,
): T & SessionSubagentHost {
return { spawn: vi.fn(), resume: vi.fn(), runQueued: vi.fn(), ...host } as unknown as T &
SessionSubagentHost;
}
interface ApiErrorTelemetryCase {
readonly name: string;
readonly createError: () => Error;
@ -2039,27 +2028,31 @@ const DEFAULT_MEDIA_STAT = {
stCtime: 0,
};
function createExecKaos(output: string): {
readonly kaos: Kaos;
readonly execWithEnv: NonNullable<Kaos['execWithEnv']>;
function createExecRunner(output: string): {
readonly runner: ISessionProcessRunner;
readonly exec: ReturnType<typeof vi.fn>;
} {
const execWithEnv = vi.fn<NonNullable<Kaos['execWithEnv']>>(async () => ({
stdin: { write: vi.fn(), end: vi.fn() } as unknown as KaosProcess['stdin'],
const exec = vi.fn(async (): Promise<IProcess> => ({
stdin: { write: vi.fn(), end: vi.fn() } as unknown as IProcess['stdin'],
stdout: Readable.from([output]),
stderr: Readable.from(['']),
pid: 42,
exitCode: 0,
wait: vi.fn().mockResolvedValue(0),
kill: vi.fn().mockResolvedValue(undefined),
dispose: vi.fn().mockResolvedValue(undefined),
wait: vi.fn().mockResolvedValue(0) as IProcess['wait'],
kill: vi.fn().mockResolvedValue(undefined) as IProcess['kill'],
dispose: vi.fn().mockResolvedValue(undefined) as IProcess['dispose'],
}));
return { kaos: createFakeKaos({ execWithEnv }), execWithEnv };
return { runner: createFakeProcessRunner({ exec }), exec };
}
function createVideoKaos(): Kaos {
return createFakeKaos({
stat: vi.fn<Kaos['stat']>().mockResolvedValue(DEFAULT_MEDIA_STAT),
readBytes: vi.fn<Kaos['readBytes']>().mockResolvedValue(MP4_HEADER),
function createVideoAgentFs(): ISessionAgentFileSystem {
return createFakeAgentFs({
stat: vi.fn(async () => ({
isFile: true,
isDirectory: false,
size: MP4_HEADER.length,
})),
readBytes: vi.fn(async () => MP4_HEADER),
});
}