refactor: merge session subagent host service

This commit is contained in:
_Kerman 2026-07-01 18:57:49 +08:00
parent ddb3a7609f
commit 2627c9067d
16 changed files with 765 additions and 692 deletions

View file

@ -17,7 +17,7 @@ export type SubagentHandle = {
readonly completion: Promise<SubagentCompletion>;
};
export interface SessionSubagentHost {
interface SubagentDetachHost {
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 subagentHost: SubagentDetachHost,
private readonly abortController: AbortController,
) {
this.agentId = handle.agentId;

View file

@ -2,7 +2,7 @@
* AgentTool collaboration tool for spawning task subagents.
*
* Unlike the built-in tools (Read/Write/Edit/Bash/Grep/Glob), this is a
* "collaboration tool". It uses `ISessionSubagentHost` (injected via the constructor
* "collaboration tool". It uses the session subagent host (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.

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

@ -4,7 +4,7 @@
export * from './subagentHost';
export * from './subagentHostService';
export * from './defaultSessionSubagentHost';
export * from './profiles';
export { AgentTool, AgentToolInputSchema, AgentToolOutputSchema } from './agentTool';
export type { AgentToolInput, AgentToolOutput } from './agentTool';
export type { QueuedSubagentTask } from './subagent-batch';

View file

@ -36,9 +36,11 @@ export type SubagentHandle = {
}>;
};
export interface SessionSubagentHost {
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>;
retry(agentId: string, options: RunSubagentOptions): Promise<SubagentHandle>;
@ -52,21 +54,5 @@ export interface SessionSubagentHost {
}
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,39 +1,94 @@
import type {
QueuedSubagentRunResult,
QueuedSubagentTask,
SessionSubagentHost,
SpawnSubagentOptions,
RunSubagentOptions,
SubagentHandle,
} from './subagentHost';
import type { SubagentSuspendedEvent } from './subagent-batch';
import {
ISessionSubagentHost,
} from './subagentHost';
APIProviderRateLimitError,
isProviderRateLimitError,
type TokenUsage,
} from '@moonshot-ai/kosong';
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 {
LifecycleScope,
registerScopedService,
type IScopeHandle,
} from '#/_base/di/scope';
import { linkAbortSignal, userCancellationReason } from '#/_base/utils/abort';
import { IKaos } from '#/app/kaos';
import { ILogService } from '#/app/log';
import { ITelemetryService } from '#/app/telemetry';
import { IAgentBackgroundService } from '#/agent/background';
import {
IAgentContextMemoryService,
type ContextMessage,
type PromptOrigin,
} from '#/agent/contextMemory';
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 { IAgentPromptService } from '#/agent/prompt';
import { IAgentSystemReminderService } from '#/agent/systemReminder';
import { IAgentToolRegistryService } from '#/agent/toolRegistry';
import type { Turn } from '#/agent/turn';
import { IAgentUsageService } from '#/agent/usage';
import { ErrorCodes, toKimiErrorPayload, type KimiErrorPayload } from '#/errors';
import { IAgentLifecycleService } from '#/session/agent-lifecycle';
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';
import { DEFAULT_AGENT_SUBAGENT_PROFILES, EXPLORE_ROLE_ADDITIONAL } from './profiles';
import {
resolveSwarmMaxConcurrency,
SubagentBatch,
type QueuedSubagentTask,
type SubagentResult,
type SubagentSuspendedEvent,
} from './subagent-batch';
import SUMMARY_CONTINUATION_PROMPT from './summary-continuation.md?raw';
import {
ISessionSubagentHost,
type RunSubagentOptions,
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 SessionSubagentHostService extends Disposable implements ISessionSubagentHost {
declare readonly _serviceBrand: undefined;
private readonly host: SessionSubagentHost;
private readonly activeChildren = new Map<
string,
{ readonly controller: AbortController; runInBackground: boolean }
>();
private readonly swarmItems = new Map<string, string>();
private readonly ownerAgentId = 'main';
constructor(
subagentHost: SessionSubagentHost | undefined,
@IAgentLifecycleService agents: IAgentLifecycleService,
@ISessionMetadata metadata: ISessionMetadata,
@IAgentLifecycleService private readonly agents: IAgentLifecycleService,
@ISessionMetadata private readonly metadata: ISessionMetadata | undefined,
@IAgentToolRegistryService toolRegistry: IAgentToolRegistryService,
@IAgentBackgroundService background: IAgentBackgroundService,
@IAgentProfileService profile: IAgentProfileService,
@ -42,8 +97,6 @@ export class SessionSubagentHostService extends Disposable implements ISessionSu
@ILogService log?: ILogService,
) {
super();
this.host = subagentHost ?? new DefaultSessionSubagentHost(agents, 'main', metadata);
this._register(
toolRegistry.register(
new AgentTool(this, background, DEFAULT_AGENT_SUBAGENT_PROFILES, {
@ -60,15 +113,51 @@ export class SessionSubagentHostService extends Disposable implements ISessionSu
}
getSwarmItem(agentId: string): string | undefined {
return this.host?.getSwarmItem(agentId);
return this.swarmItems.get(agentId);
}
startBtw(): Promise<string> {
return this.host.startBtw();
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 generateAgentsMd(): Promise<void> {
const handle = await this.host.spawn({
const handle = await this.spawn({
profileName: 'coder',
parentToolCallId: 'generate-agents-md',
prompt: 'Initialize AGENTS.md for this workspace.',
@ -79,39 +168,439 @@ export class SessionSubagentHostService extends Disposable implements ISessionSu
await handle.completion;
}
spawn(options: SpawnSubagentOptions): Promise<SubagentHandle> {
return this.host.spawn(options);
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 };
}
resume(agentId: string, options: RunSubagentOptions): Promise<SubagentHandle> {
return this.host.resume(agentId, options);
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 };
}
getProfileName(agentId: string): Promise<string | undefined> {
return this.host.getProfileName(agentId);
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 {
this.host.markActiveChildDetached(agentId);
const child = this.activeChildren.get(agentId);
if (child !== undefined) child.runInBackground = true;
}
cancelAll(reason?: unknown): void {
this.host.cancelAll(reason);
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 {
this.host.suspended(event);
const parent = this.agents.getHandle(this.ownerAgentId);
parent?.accessor.get(IAgentEventSinkService)?.emit({
type: 'subagent.suspended',
subagentId: event.agentId,
reason: event.reason,
});
}
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);
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('');
}
registerScopedService(

View file

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

View file

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

View file

@ -21,7 +21,7 @@ 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 { ISessionSubagentHost, SubagentHandle } from '#/session/subagentHost';
import {
configServices,
createTestAgent,
@ -82,7 +82,7 @@ function agentTask(
options: {
readonly agentId?: string;
readonly subagentType?: string;
readonly subagentHost?: Pick<SessionSubagentHost, 'markActiveChildDetached'>;
readonly subagentHost?: Pick<ISessionSubagentHost, 'markActiveChildDetached'>;
readonly abortController?: AbortController;
readonly timeoutMs?: number;
} = {},

View file

@ -57,6 +57,7 @@ import {
IAgentShellToolsService,
IStorageService,
ISessionSubagentHost,
ISessionProcessRunner,
IAgentSwarmService,
ITelemetryService,
ISessionTerminalBackend,
@ -80,13 +81,15 @@ import {
bootstrapSeed,
createAppScope,
resolveBootstrapOptions,
type QueuedSubagentRunResult,
type QueuedSubagentTask,
type IDisposable,
type Scope,
type ScopeSeed,
type ServiceIdentifier,
} from '#/index';
import { Event } from '#/_base/event';
import { toDisposable } from '#/_base/di';
import { Disposable, toDisposable } from '#/_base/di';
import type { PromisifyMethods } from '#/_base/utils/types';
import type { ApprovalResponse } from '#/session/approval';
import type { BackgroundTaskInfo } from '#/agent/background';
@ -131,7 +134,11 @@ 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 {
AgentTool,
DEFAULT_AGENT_SUBAGENT_PROFILES,
SessionSubagentHostService,
} from '#/session/subagentHost';
import type { ExecutableToolOutput as ToolOutput, ToolResult } from '#/agent/tool';
import type {
PersistedWireRecord,
@ -541,8 +548,91 @@ function createSessionSkillCatalog(catalog: SkillCatalog): ISessionSkillCatalog
};
}
export function subagentHostServices(host: SessionSubagentHost): TestAgentServiceOverride {
return agentService(ISessionSubagentHost, new SyncDescriptor(SessionSubagentHostService, [host]));
class TestSessionSubagentHostService extends Disposable implements ISessionSubagentHost {
declare readonly _serviceBrand: undefined;
constructor(
private readonly host: ISessionSubagentHost,
@IAgentToolRegistryService toolRegistry: IAgentToolRegistryService,
@IAgentBackgroundService background: IAgentBackgroundService,
@IAgentProfileService profile: IAgentProfileService,
@IKaos kaos: IKaos,
@ISessionProcessRunner runner: ISessionProcessRunner,
@ILogService log?: ILogService,
) {
super();
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();
}
generateAgentsMd(): Promise<void> {
return this.host.generateAgentsMd();
}
spawn(
options: Parameters<ISessionSubagentHost['spawn']>[0],
): ReturnType<ISessionSubagentHost['spawn']> {
return this.host.spawn(options);
}
resume(
agentId: string,
options: Parameters<ISessionSubagentHost['resume']>[1],
): ReturnType<ISessionSubagentHost['resume']> {
return this.host.resume(agentId, options);
}
retry(
agentId: string,
options: Parameters<ISessionSubagentHost['retry']>[1],
): ReturnType<ISessionSubagentHost['retry']> {
return this.host.retry(agentId, options);
}
getProfileName(agentId: string): Promise<string | undefined> {
return this.host.getProfileName(agentId);
}
markActiveChildDetached(agentId: string): void {
this.host.markActiveChildDetached(agentId);
}
runQueued<T>(
tasks: readonly QueuedSubagentTask<T>[],
): Promise<Array<QueuedSubagentRunResult<T>>> {
return this.host.runQueued(tasks);
}
cancelAll(reason?: unknown): void {
this.host.cancelAll(reason);
}
suspended(event: Parameters<ISessionSubagentHost['suspended']>[0]): void {
this.host.suspended(event);
}
}
export function subagentHostServices(host: ISessionSubagentHost): TestAgentServiceOverride {
return agentService(ISessionSubagentHost, new SyncDescriptor(TestSessionSubagentHostService, [host]));
}
export function goalServices(options: GoalServiceOptions): TestAgentServiceOverride {
@ -939,7 +1029,7 @@ export class AgentTestContext {
reg.defineDescriptor(IAgentUserToolService, new SyncDescriptor(AgentUserToolService));
reg.defineDescriptor(
ISessionSubagentHost,
new SyncDescriptor(SessionSubagentHostService, [unavailableSubagentHost()]),
new SyncDescriptor(TestSessionSubagentHostService, [unavailableSubagentHost()]),
);
},
], this.serviceOverrides, 'agent'),
@ -1773,13 +1863,15 @@ function createTerminalBackend(): ISessionTerminalBackend {
};
}
function unavailableSubagentHost(): SessionSubagentHost {
function unavailableSubagentHost(): ISessionSubagentHost {
const fail = async (): Promise<never> => {
throw new Error('Subagent host is not configured in this test.');
};
return {
_serviceBrand: undefined,
getSwarmItem: () => undefined,
startBtw: fail,
generateAgentsMd: fail,
spawn: fail,
resume: fail,
retry: fail,

View file

@ -9,7 +9,6 @@ import {
AgentToolInputSchema,
DEFAULT_SUBAGENT_TIMEOUT_MS,
type ISessionSubagentHost,
type SessionSubagentHost,
} from '#/session/subagentHost';
import type { AgentToolSubagentMap } from '#/session/subagentHost/agentTool';
import { ToolAccesses } from '#/agent/tool';
@ -69,7 +68,7 @@ describe('AgentTool direct contract', () => {
canRunInBackground,
log,
}: {
readonly host?: SessionSubagentHost;
readonly host?: ISessionSubagentHost;
readonly maxRunningTasks?: number;
readonly subagents?: AgentToolSubagentMap;
readonly canRunInBackground?: () => boolean;
@ -77,7 +76,7 @@ describe('AgentTool direct contract', () => {
} = {}): {
readonly ctx: TestAgentContext;
readonly background: IAgentBackgroundService;
readonly host: SessionSubagentHost;
readonly host: ISessionSubagentHost;
readonly tool: AgentTool;
} {
const ctx =
@ -739,7 +738,7 @@ describe('Agent tool service runtime', () => {
describe('with a resolving subagent host', () => {
let ctx: TestAgentContext;
let subagentHost: SessionSubagentHost;
let subagentHost: ISessionSubagentHost;
let profile: IAgentProfileService;
let tools: IAgentToolRegistryService;
@ -857,7 +856,7 @@ describe('Agent tool service runtime', () => {
describe('with a non-resuming subagent host', () => {
let ctx: TestAgentContext;
let subagentHost: SessionSubagentHost;
let subagentHost: ISessionSubagentHost;
let profile: IAgentProfileService;
let tools: IAgentToolRegistryService;
@ -902,11 +901,13 @@ describe('Agent tool service runtime', () => {
});
function createSubagentHost(
overrides: Partial<SessionSubagentHost> = {},
): SessionSubagentHost {
const host: SessionSubagentHost = {
overrides: Partial<ISessionSubagentHost> = {},
): ISessionSubagentHost {
const host: ISessionSubagentHost = {
_serviceBrand: undefined,
getSwarmItem: vi.fn(),
startBtw: vi.fn().mockResolvedValue('btw-url'),
generateAgentsMd: vi.fn().mockResolvedValue(undefined),
spawn: vi.fn(),
resume: vi.fn(),
retry: vi.fn(),

View file

@ -1,9 +1,14 @@
import { describe, expect, it, vi } from 'vitest';
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
import { APIProviderRateLimitError } from '@moonshot-ai/kosong';
import { IAgentLifecycleService } from '#/session/agent-lifecycle';
import { SyncDescriptor } from '#/_base/di/descriptors';
import { DisposableStore } from '#/_base/di/lifecycle';
import { TestInstantiationService } from '#/_base/di/test';
import type { IScopeHandle } from '#/_base/di/scope';
import { IKaos } from '#/app/kaos';
import { ITelemetryService } from '#/app/telemetry';
import { IAgentBackgroundService } from '#/agent/background';
import { IAgentContextMemoryService } from '#/agent/contextMemory';
import { IAgentEventSinkService } from '#/agent/eventSink';
import { IAgentExternalHooksService } from '#/agent/externalHooks';
@ -14,9 +19,12 @@ import {
import { IAgentProfileService } from '#/agent/profile';
import { IAgentPromptService } from '#/agent/prompt';
import { IAgentSystemReminderService } from '#/agent/systemReminder';
import { ITelemetryService } from '#/app/telemetry';
import { IAgentToolRegistryService } from '#/agent/toolRegistry';
import { IAgentUsageService } from '#/agent/usage';
import { DefaultSessionSubagentHost } from '#/session/subagentHost/defaultSessionSubagentHost';
import { IAgentLifecycleService } from '#/session/agent-lifecycle';
import { ISessionProcessRunner } from '#/session/process';
import { ISessionMetadata } from '#/session/session-metadata';
import { ISessionSubagentHost, SessionSubagentHostService } from '#/session/subagentHost';
const CHILD_SUMMARY = 'child summary '.repeat(20);
@ -157,12 +165,39 @@ function makeAgents(parent: IScopeHandle, children: Record<string, IScopeHandle>
};
}
describe('DefaultSessionSubagentHost', () => {
describe('SessionSubagentHostService built-in host behavior', () => {
let disposables: DisposableStore;
let ix: TestInstantiationService;
beforeEach(() => {
disposables = new DisposableStore();
ix = disposables.add(new TestInstantiationService());
});
afterEach(() => {
disposables.dispose();
});
function createHost(
agents: IAgentLifecycleService,
metadata?: Partial<ISessionMetadata>,
): SessionSubagentHostService {
ix.stub(IAgentLifecycleService, agents);
if (metadata !== undefined) ix.stub(ISessionMetadata, metadata);
ix.stub(IAgentToolRegistryService, { register: vi.fn(() => ({ dispose: () => {} })) });
ix.stub(IAgentBackgroundService, {});
ix.stub(IAgentProfileService, { isToolActive: vi.fn().mockReturnValue(false) });
ix.stub(IKaos, { cwd: '/repo' });
ix.stub(ISessionProcessRunner, { exec: vi.fn() });
ix.set(ISessionSubagentHost, new SyncDescriptor(SessionSubagentHostService));
return ix.get(ISessionSubagentHost) as SessionSubagentHostService;
}
it('aborts a running subagent when the caller signal aborts', async () => {
const parent = fakeScope('main');
const child = fakeScope('child', { result: new Promise(() => {}) });
const agents = makeAgents(parent, { child });
const host = new DefaultSessionSubagentHost(agents as unknown as IAgentLifecycleService, 'main');
const host = createHost(agents as unknown as IAgentLifecycleService);
const controller = new AbortController();
const handle = await host.spawn({
@ -183,7 +218,7 @@ describe('DefaultSessionSubagentHost', () => {
const parent = fakeScope('main', { events });
const child = fakeScope('child');
const agents = makeAgents(parent, { child });
const host = new DefaultSessionSubagentHost(agents as unknown as IAgentLifecycleService, 'main');
const host = createHost(agents as unknown as IAgentLifecycleService);
const handle = await host.spawn({
profileName: 'explore',
@ -211,7 +246,7 @@ describe('DefaultSessionSubagentHost', () => {
const parent = fakeScope('main', { initialText: 'short' });
const child = fakeScope('child', { initialText: 'short' });
const agents = makeAgents(parent, { child });
const host = new DefaultSessionSubagentHost(agents as unknown as IAgentLifecycleService, 'main');
const host = createHost(agents as unknown as IAgentLifecycleService);
const handle = await host.spawn({
profileName: 'coder',
@ -231,7 +266,7 @@ describe('DefaultSessionSubagentHost', () => {
const parent = fakeScope('main');
const child = fakeScope('child');
const agents = makeAgents(parent, { child });
const host = new DefaultSessionSubagentHost(agents as unknown as IAgentLifecycleService, 'main');
const host = createHost(agents as unknown as IAgentLifecycleService);
await host.spawn({
profileName: 'coder',
@ -266,11 +301,7 @@ describe('DefaultSessionSubagentHost', () => {
},
}),
};
const host = new DefaultSessionSubagentHost(
agents as unknown as IAgentLifecycleService,
'main',
metadata as never,
);
const host = createHost(agents as unknown as IAgentLifecycleService, metadata as never);
await expect(
host.resume('child', {
@ -293,7 +324,7 @@ 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 host = createHost(agents as unknown as IAgentLifecycleService);
const handle = await host.spawn({
profileName: 'coder',
@ -317,7 +348,7 @@ describe('DefaultSessionSubagentHost', () => {
const parent = fakeScope('main', { 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 host = createHost(agents as unknown as IAgentLifecycleService);
const controller = new AbortController();
const handle = await host.spawn({
@ -347,7 +378,7 @@ describe('DefaultSessionSubagentHost', () => {
createMain: vi.fn(),
create: vi.fn(),
};
const host = new DefaultSessionSubagentHost(agents as unknown as IAgentLifecycleService, 'main');
const host = createHost(agents as unknown as IAgentLifecycleService);
const handle = await host.resume('child', {
parentToolCallId: 'call_agent',
@ -377,7 +408,7 @@ describe('DefaultSessionSubagentHost', () => {
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');
const host = createHost(agents as unknown as IAgentLifecycleService);
await expect(host.startBtw()).resolves.toBe('btw-child');
expect(agents.create).toHaveBeenCalledWith({ parentAgentId: 'main', type: 'sub' });
@ -415,7 +446,7 @@ describe('DefaultSessionSubagentHost', () => {
const parent = fakeScope('main');
const child = fakeScope('child');
const agents = makeAgents(parent, { child });
const host = new DefaultSessionSubagentHost(agents as unknown as IAgentLifecycleService, 'main');
const host = createHost(agents as unknown as IAgentLifecycleService);
const results = await host.runQueued([
{
@ -445,7 +476,7 @@ describe('DefaultSessionSubagentHost', () => {
pending.push(d);
return fakeScope(`child-${pending.length}`, { result: d.promise });
});
const host = new DefaultSessionSubagentHost(agents as unknown as IAgentLifecycleService, 'main');
const host = createHost(agents as unknown as IAgentLifecycleService);
const tasks = Array.from({ length: 4 }, (_, index) => ({
kind: 'spawn' as const,
@ -488,7 +519,7 @@ describe('DefaultSessionSubagentHost', () => {
const parent = fakeScope('main', { 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');
const host = createHost(agents as unknown as IAgentLifecycleService);
await host.spawn({
profileName: 'coder',
@ -507,7 +538,7 @@ describe('DefaultSessionSubagentHost', () => {
const parent = fakeScope('main');
const child = fakeScope('child');
const agents = makeAgents(parent, { child });
const host = new DefaultSessionSubagentHost(agents as unknown as IAgentLifecycleService, 'main');
const host = createHost(agents as unknown as IAgentLifecycleService);
const handle = await host.spawn({
profileName: 'explore',
@ -534,7 +565,7 @@ describe('DefaultSessionSubagentHost', () => {
const parent = fakeScope('main');
const child = fakeScope('child');
const agents = makeAgents(parent, { child });
const host = new DefaultSessionSubagentHost(agents as unknown as IAgentLifecycleService, 'main');
const host = createHost(agents as unknown as IAgentLifecycleService);
const handle = await host.spawn({
profileName: 'explore',
@ -561,7 +592,7 @@ describe('DefaultSessionSubagentHost', () => {
const parent = fakeScope('main');
const child = fakeScope('child');
const agents = makeAgents(parent, { child });
const host = new DefaultSessionSubagentHost(agents as unknown as IAgentLifecycleService, 'main');
const host = createHost(agents as unknown as IAgentLifecycleService);
const handle = await host.spawn({
profileName: 'coder',
@ -585,7 +616,7 @@ describe('DefaultSessionSubagentHost', () => {
const parent = fakeScope('main');
const child = fakeScope('child', { ready: ready.promise });
const agents = makeAgents(parent, { child });
const host = new DefaultSessionSubagentHost(agents as unknown as IAgentLifecycleService, 'main');
const host = createHost(agents as unknown as IAgentLifecycleService);
const onReady = vi.fn();
const handle = await host.spawn({
@ -613,7 +644,7 @@ describe('DefaultSessionSubagentHost', () => {
createMain: vi.fn(),
create: vi.fn(),
};
const host = new DefaultSessionSubagentHost(agents as unknown as IAgentLifecycleService, 'main');
const host = createHost(agents as unknown as IAgentLifecycleService);
const handle = await host.retry('child', {
parentToolCallId: 'call_agent',
@ -633,7 +664,7 @@ describe('DefaultSessionSubagentHost', () => {
const parent = fakeScope('main');
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 host = createHost(agents as unknown as IAgentLifecycleService);
const handle = await host.spawn({
profileName: 'coder',
@ -656,7 +687,7 @@ describe('DefaultSessionSubagentHost', () => {
}),
});
const agents = makeAgents(parent, { child });
const host = new DefaultSessionSubagentHost(agents as unknown as IAgentLifecycleService, 'main');
const host = createHost(agents as unknown as IAgentLifecycleService);
const handle = await host.spawn({
profileName: 'coder',
@ -674,7 +705,7 @@ describe('DefaultSessionSubagentHost', () => {
const events: unknown[] = [];
const parent = fakeScope('main', { events });
const agents = makeAgents(parent, {});
const host = new DefaultSessionSubagentHost(agents as unknown as IAgentLifecycleService, 'main');
const host = createHost(agents as unknown as IAgentLifecycleService);
host.suspended({
task: {
@ -703,7 +734,7 @@ describe('DefaultSessionSubagentHost', () => {
const parent = fakeScope('main');
const child = fakeScope('child', { result: new Promise(() => {}) });
const agents = makeAgents(parent, { child });
const host = new DefaultSessionSubagentHost(agents as unknown as IAgentLifecycleService, 'main');
const host = createHost(agents as unknown as IAgentLifecycleService);
const handle = await host.spawn({
profileName: 'coder',
@ -730,7 +761,7 @@ describe('DefaultSessionSubagentHost', () => {
const parent = fakeScope('main');
const child = fakeScope('child');
const agents = makeAgents(parent, { child });
const host = new DefaultSessionSubagentHost(agents as unknown as IAgentLifecycleService, 'main');
const host = createHost(agents as unknown as IAgentLifecycleService);
const handle = await host.spawn({
profileName: 'explore',

View file

@ -44,7 +44,7 @@ describe('SessionSubagentHostService DI wiring', () => {
afterEach(() => disposables.dispose());
it('constructs a DefaultSessionSubagentHost when no host override is provided', async () => {
it('uses the built-in host behavior when no host override is provided', async () => {
const register = vi.fn(() => ({ dispose: () => {} }));
const parent = fakeProfileScope('main');
const child = fakeProfileScope('child');
@ -56,7 +56,7 @@ describe('SessionSubagentHostService DI wiring', () => {
ix.stub(ISessionMetadata, {
ready: Promise.resolve(),
read: vi.fn().mockResolvedValue({ agents: {} }),
onDidChange: () => ({ dispose: () => {} }),
onDidChangeMetadata: () => ({ dispose: () => {} }),
update: vi.fn(),
setTitle: vi.fn(),
setArchived: vi.fn(),
@ -68,7 +68,7 @@ describe('SessionSubagentHostService DI wiring', () => {
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]));
ix.set(ISessionSubagentHost, new SyncDescriptor(SessionSubagentHostService));
const service = ix.get(ISessionSubagentHost);

View file

@ -41,12 +41,21 @@ function mockSubagentHost({
readonly getSwarmItem?: (agentId: string) => string | undefined;
// eslint-disable-next-line @typescript-eslint/no-explicit-any
readonly runQueued?: (...args: any[]) => any;
} = {}) {
} = {}): ISessionSubagentHost {
return {
_serviceBrand: undefined,
getSwarmItem: vi.fn(getSwarmItem),
startBtw: vi.fn(),
generateAgentsMd: vi.fn(),
spawn: vi.fn(),
resume: vi.fn(),
retry: vi.fn(),
getProfileName: vi.fn(),
markActiveChildDetached: vi.fn(),
runQueued,
// eslint-disable-next-line @typescript-eslint/no-explicit-any
} as any;
cancelAll: vi.fn(),
suspended: vi.fn(),
};
}
function mockSwarmMode() {

View file

@ -7,7 +7,7 @@ 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 { ISessionSubagentHost } from '#/session/subagentHost';
import { IAgentToolRegistryService } from '#/agent/toolRegistry';
import { createFakeKaos } from '../tools/fixtures/fake-kaos';
import {
@ -220,7 +220,7 @@ describe('Agent tools', () => {
});
describe('foreground Agent tool recovery', () => {
let subagentHost: SessionSubagentHost;
let subagentHost: ISessionSubagentHost;
beforeEach(() => {
const completion = Promise.reject(
@ -228,6 +228,10 @@ describe('Agent tools', () => {
);
void completion.catch(() => undefined);
subagentHost = {
_serviceBrand: undefined,
getSwarmItem: vi.fn(),
startBtw: vi.fn(),
generateAgentsMd: vi.fn(),
spawn: vi.fn().mockResolvedValue({
agentId: 'agent-child',
profileName: 'coder',
@ -235,7 +239,13 @@ describe('Agent tools', () => {
completion,
}),
resume: vi.fn(),
} as unknown as SessionSubagentHost;
retry: vi.fn(),
getProfileName: vi.fn(),
markActiveChildDetached: vi.fn(),
runQueued: vi.fn(),
cancelAll: vi.fn(),
suspended: vi.fn(),
};
ctx = createTestAgent(subagentHostServices(subagentHost));
profile = ctx.get(IAgentProfileService);
profile.update({ activeToolNames: ['Agent'] });

View file

@ -35,7 +35,7 @@ import { IAgentToolRegistryService } from '#/agent/toolRegistry';
import type {
QueuedSubagentRunResult,
QueuedSubagentTask,
SessionSubagentHost,
ISessionSubagentHost,
} from '#/session/subagentHost';
import { recordingTelemetry, type TelemetryRecord } from '../telemetry/stubs';
import { createFakeKaos } from '../tools/fixtures/fake-kaos';
@ -457,7 +457,7 @@ describe('Agent turn flow', () => {
}));
});
const subagentHost = mockSubagentHost({
runQueued: runQueued as unknown as SessionSubagentHost['runQueued'],
runQueued: runQueued as unknown as ISessionSubagentHost['runQueued'],
});
const ctx = testAgent(subagentHostServices(subagentHost));
ctx.configure({ tools: ['AgentSwarm'] });
@ -1995,11 +1995,24 @@ function agentSwarmCall(): ToolCall {
};
}
function mockSubagentHost<T extends Partial<SessionSubagentHost>>(
function mockSubagentHost<T extends Partial<ISessionSubagentHost>>(
host: T,
): T & SessionSubagentHost {
return { spawn: vi.fn(), resume: vi.fn(), runQueued: vi.fn(), ...host } as unknown as T &
SessionSubagentHost;
): T & ISessionSubagentHost {
return {
_serviceBrand: undefined,
getSwarmItem: vi.fn(),
startBtw: vi.fn(),
generateAgentsMd: vi.fn(),
spawn: vi.fn(),
resume: vi.fn(),
retry: vi.fn(),
getProfileName: vi.fn(),
markActiveChildDetached: vi.fn(),
runQueued: vi.fn(),
cancelAll: vi.fn(),
suspended: vi.fn(),
...host,
} as T & ISessionSubagentHost;
}
interface ApiErrorTelemetryCase {