diff --git a/packages/agent-core-v2/src/agent/background/agent-task.ts b/packages/agent-core-v2/src/agent/background/agent-task.ts index 6c308386b..327e37aff 100644 --- a/packages/agent-core-v2/src/agent/background/agent-task.ts +++ b/packages/agent-core-v2/src/agent/background/agent-task.ts @@ -17,7 +17,7 @@ export type SubagentHandle = { readonly completion: Promise; }; -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, + private readonly subagentHost: SubagentDetachHost, private readonly abortController: AbortController, ) { this.agentId = handle.agentId; diff --git a/packages/agent-core-v2/src/session/subagentHost/agentTool.ts b/packages/agent-core-v2/src/session/subagentHost/agentTool.ts index cff7ac029..9661183bb 100644 --- a/packages/agent-core-v2/src/session/subagentHost/agentTool.ts +++ b/packages/agent-core-v2/src/session/subagentHost/agentTool.ts @@ -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. diff --git a/packages/agent-core-v2/src/session/subagentHost/defaultSessionSubagentHost.ts b/packages/agent-core-v2/src/session/subagentHost/defaultSessionSubagentHost.ts deleted file mode 100644 index 229142a66..000000000 --- a/packages/agent-core-v2/src/session/subagentHost/defaultSessionSubagentHost.ts +++ /dev/null @@ -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(); - - 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 { - 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 { - 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 { - 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 { - 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 { - 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(tasks: readonly QueuedSubagentTask[]): Promise>> { - 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 { - 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 { - 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 { - 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 { - 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 { - if (signal.aborted) { - return Promise.reject(signal.reason ?? userCancellationReason()); - } - return new Promise((_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(''); -} diff --git a/packages/agent-core-v2/src/session/subagentHost/index.ts b/packages/agent-core-v2/src/session/subagentHost/index.ts index 2fa673ea0..ca9a738f7 100644 --- a/packages/agent-core-v2/src/session/subagentHost/index.ts +++ b/packages/agent-core-v2/src/session/subagentHost/index.ts @@ -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'; diff --git a/packages/agent-core-v2/src/session/subagentHost/subagentHost.ts b/packages/agent-core-v2/src/session/subagentHost/subagentHost.ts index d8a01667f..f77d4dc95 100644 --- a/packages/agent-core-v2/src/session/subagentHost/subagentHost.ts +++ b/packages/agent-core-v2/src/session/subagentHost/subagentHost.ts @@ -36,9 +36,11 @@ export type SubagentHandle = { }>; }; -export interface SessionSubagentHost { +export interface ISessionSubagentHost { + readonly _serviceBrand: undefined; getSwarmItem(agentId: string): string | undefined; startBtw(): Promise; + generateAgentsMd(): Promise; spawn(options: SpawnSubagentOptions): Promise; resume(agentId: string, options: RunSubagentOptions): Promise; retry(agentId: string, options: RunSubagentOptions): Promise; @@ -52,21 +54,5 @@ export interface SessionSubagentHost { } export type QueuedSubagentRunResult = SubagentResult; -export type { QueuedSubagentTask }; - -export interface ISessionSubagentHost { - readonly _serviceBrand: undefined; - getSwarmItem(agentId: string): string | undefined; - startBtw(): Promise; - generateAgentsMd(): Promise; - spawn(options: SpawnSubagentOptions): Promise; - resume(agentId: string, options: RunSubagentOptions): Promise; - getProfileName(agentId: string): Promise; - markActiveChildDetached(agentId: string): void; - runQueued(tasks: readonly QueuedSubagentTask[]): Promise>>; - cancelAll(reason?: unknown): void; - suspended(event: SubagentSuspendedEvent): void; -} - export const ISessionSubagentHost = createDecorator('sessionSubagentHost'); diff --git a/packages/agent-core-v2/src/session/subagentHost/subagentHostService.ts b/packages/agent-core-v2/src/session/subagentHost/subagentHostService.ts index 8d4e185b5..27e598a72 100644 --- a/packages/agent-core-v2/src/session/subagentHost/subagentHostService.ts +++ b/packages/agent-core-v2/src/session/subagentHost/subagentHostService.ts @@ -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(); + 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 { - return this.host.startBtw(); + async startBtw(): Promise { + 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 { - 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 { - return this.host.spawn(options); + async spawn(options: SpawnSubagentOptions): Promise { + 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 { - return this.host.resume(agentId, options); + async resume(agentId: string, options: RunSubagentOptions): Promise { + 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 { - return this.host.getProfileName(agentId); + async retry(agentId: string, options: RunSubagentOptions): Promise { + 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 { + 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(tasks: readonly QueuedSubagentTask[]): Promise>> { + 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( - tasks: readonly QueuedSubagentTask[], - ): Promise>> { - const subagentHost = this.host; - if (subagentHost === undefined) { - throw new Error('Subagent host is not configured.'); - } - return subagentHost.runQueued(tasks); + private async ensureParent(): Promise { + 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 { + 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 { + 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 { + 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 { + if (signal.aborted) { + return Promise.reject(signal.reason ?? userCancellationReason()); + } + return new Promise((_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( diff --git a/packages/agent-core-v2/test/background/ids.test.ts b/packages/agent-core-v2/test/background/ids.test.ts index 7eb83fc2a..9cb4f0999 100644 --- a/packages/agent-core-v2/test/background/ids.test.ts +++ b/packages/agent-core-v2/test/background/ids.test.ts @@ -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(), diff --git a/packages/agent-core-v2/test/background/manager.test.ts b/packages/agent-core-v2/test/background/manager.test.ts index a862f08ac..c7a9a8690 100644 --- a/packages/agent-core-v2/test/background/manager.test.ts +++ b/packages/agent-core-v2/test/background/manager.test.ts @@ -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; + readonly subagentHost?: Pick; readonly abortController?: AbortController; readonly timeoutMs?: number; } = {}, diff --git a/packages/agent-core-v2/test/background/rpc-events.test.ts b/packages/agent-core-v2/test/background/rpc-events.test.ts index 484bc7cce..dc6c99cc1 100644 --- a/packages/agent-core-v2/test/background/rpc-events.test.ts +++ b/packages/agent-core-v2/test/background/rpc-events.test.ts @@ -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; + readonly subagentHost?: Pick; readonly abortController?: AbortController; readonly timeoutMs?: number; } = {}, diff --git a/packages/agent-core-v2/test/harness/agent.ts b/packages/agent-core-v2/test/harness/agent.ts index 94df4d9cd..6f14c1373 100644 --- a/packages/agent-core-v2/test/harness/agent.ts +++ b/packages/agent-core-v2/test/harness/agent.ts @@ -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 { + return this.host.startBtw(); + } + + generateAgentsMd(): Promise { + return this.host.generateAgentsMd(); + } + + spawn( + options: Parameters[0], + ): ReturnType { + return this.host.spawn(options); + } + + resume( + agentId: string, + options: Parameters[1], + ): ReturnType { + return this.host.resume(agentId, options); + } + + retry( + agentId: string, + options: Parameters[1], + ): ReturnType { + return this.host.retry(agentId, options); + } + + getProfileName(agentId: string): Promise { + return this.host.getProfileName(agentId); + } + + markActiveChildDetached(agentId: string): void { + this.host.markActiveChildDetached(agentId); + } + + runQueued( + tasks: readonly QueuedSubagentTask[], + ): Promise>> { + return this.host.runQueued(tasks); + } + + cancelAll(reason?: unknown): void { + this.host.cancelAll(reason); + } + + suspended(event: Parameters[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 => { 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, diff --git a/packages/agent-core-v2/test/subagentHost/agent-tool.test.ts b/packages/agent-core-v2/test/subagentHost/agent-tool.test.ts index 89fa11922..d28ff4f49 100644 --- a/packages/agent-core-v2/test/subagentHost/agent-tool.test.ts +++ b/packages/agent-core-v2/test/subagentHost/agent-tool.test.ts @@ -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 { - const host: SessionSubagentHost = { + overrides: Partial = {}, +): 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(), diff --git a/packages/agent-core-v2/test/subagentHost/defaultSessionSubagentHost.test.ts b/packages/agent-core-v2/test/subagentHost/subagentHostService.default.test.ts similarity index 88% rename from packages/agent-core-v2/test/subagentHost/defaultSessionSubagentHost.test.ts rename to packages/agent-core-v2/test/subagentHost/subagentHostService.default.test.ts index 73e905103..349e5b0bc 100644 --- a/packages/agent-core-v2/test/subagentHost/defaultSessionSubagentHost.test.ts +++ b/packages/agent-core-v2/test/subagentHost/subagentHostService.default.test.ts @@ -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 }; } -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, + ): 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', diff --git a/packages/agent-core-v2/test/subagentHost/subagentHostService.test.ts b/packages/agent-core-v2/test/subagentHost/subagentHostService.test.ts index 333ddde76..6cddd2de4 100644 --- a/packages/agent-core-v2/test/subagentHost/subagentHostService.test.ts +++ b/packages/agent-core-v2/test/subagentHost/subagentHostService.test.ts @@ -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); diff --git a/packages/agent-core-v2/test/swarm/swarm.test.ts b/packages/agent-core-v2/test/swarm/swarm.test.ts index 071bd342a..997ee41c5 100644 --- a/packages/agent-core-v2/test/swarm/swarm.test.ts +++ b/packages/agent-core-v2/test/swarm/swarm.test.ts @@ -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() { diff --git a/packages/agent-core-v2/test/tool/tool.test.ts b/packages/agent-core-v2/test/tool/tool.test.ts index 5f990b54c..359bde0a5 100644 --- a/packages/agent-core-v2/test/tool/tool.test.ts +++ b/packages/agent-core-v2/test/tool/tool.test.ts @@ -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'] }); diff --git a/packages/agent-core-v2/test/turn/turn.test.ts b/packages/agent-core-v2/test/turn/turn.test.ts index 4b0d7d990..587d50dae 100644 --- a/packages/agent-core-v2/test/turn/turn.test.ts +++ b/packages/agent-core-v2/test/turn/turn.test.ts @@ -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>( +function mockSubagentHost>( 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 {