mirror of
https://github.com/MoonshotAI/kimi-code.git
synced 2026-07-09 17:29:12 +00:00
refactor: merge session subagent host service
This commit is contained in:
parent
ddb3a7609f
commit
2627c9067d
16 changed files with 765 additions and 692 deletions
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -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('');
|
||||
}
|
||||
|
|
@ -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';
|
||||
|
|
|
|||
|
|
@ -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');
|
||||
|
|
|
|||
|
|
@ -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(
|
||||
|
|
|
|||
|
|
@ -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(),
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
} = {},
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
} = {},
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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(),
|
||||
|
|
|
|||
|
|
@ -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',
|
||||
|
|
@ -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);
|
||||
|
||||
|
|
|
|||
|
|
@ -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() {
|
||||
|
|
|
|||
|
|
@ -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'] });
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue