diff --git a/packages/agent-core-v2/scripts/check-domain-layers.mjs b/packages/agent-core-v2/scripts/check-domain-layers.mjs index 9eb02936a..83a1389e6 100644 --- a/packages/agent-core-v2/scripts/check-domain-layers.mjs +++ b/packages/agent-core-v2/scripts/check-domain-layers.mjs @@ -35,18 +35,24 @@ const TEST_ROOT = join(PKG_ROOT, 'test'); const DOMAIN_LAYER = new Map([ // L0 — base infrastructure ['_base', 0], + // `_base/execEnv` (pure execution-env helpers such as + // `probeHostEnvironmentFromNode`, `decodeTextWithErrors`, + // `globPatternToRegex`, `BufferedReadable`) sits under `_base/*`, so the + // `_base` L0 entry already covers it — no separate entry needed. // `errors` is a top-level facade (src/errors.ts) that aggregates every // domain's error codes; any domain may import it, so it sits at L0. ['errors', 0], - // `kaos` is the execution-environment substrate (cwd/env/osEnv/backend); - // it wraps the `@moonshot-ai/kaos` package and depends on no business - // domain, so it sits at L0 where any domain may import it. - ['kaos', 0], // L1 — abstraction bridges & low-level capabilities ['log', 1], ['sessionLog', 1], ['telemetry', 1], ['bootstrap', 1], + // `hostEnvironment` is the App-scope OS/shell/path/home probe snapshot; + // low-level substrate that any Session/Agent domain may read synchronously. + ['hostEnvironment', 1], + // `execContext` is the Session-scope seeded immutable value (`cwd`, + // `envLayers`); same layer as the other low-level bridges. + ['execContext', 1], ['hostFs', 1], ['workspaceContext', 1], ['chatProvider', 1], @@ -94,6 +100,7 @@ const DOMAIN_LAYER = new Map([ ['plan', 4], ['goal', 4], ['swarm', 4], + ['scopeContext', 4], ['usage', 4], ['tooldedup', 4], ['contextMemory', 4], @@ -119,7 +126,7 @@ const DOMAIN_LAYER = new Map([ ['background', 5], ['mcp', 5], ['cron', 5], - ['subagentHost', 5], + ['agentTool', 5], // L6 — coordination ['agent-lifecycle', 6], ['session-lifecycle', 6], @@ -205,6 +212,10 @@ function domainFromRel(rel, { exemptRootFile }) { */ const ALLOWED_EXCEPTIONS = new Set([ 'bootstrap>globalSkillCatalog', + // path-access (base tool policy) needs the `IHostEnvironment` type to stay + // host-aware (path class, home dir). Structural type dependency only — + // path-access does not construct or resolve the service. + '_base>hostEnvironment', 'permissionGate>approval', 'userTool>interaction', 'skill>turn', @@ -237,7 +248,10 @@ const ALLOWED_EXCEPTIONS = new Set([ 'shellTools>background', 'skill>contextMemory', 'skill>prompt', - 'swarm>subagentHost', + 'swarm>agentTool', + 'swarm>session-metadata', + 'agentTool>agent-lifecycle', + 'agentTool>session-metadata', 'toolExecutor>loop', 'userTool>profile', 'wireRecord>contextMemory', diff --git a/packages/agent-core-v2/src/session/subagentHost/agent-background-disabled.md b/packages/agent-core-v2/src/agent/agentTool/agent-background-disabled.md similarity index 100% rename from packages/agent-core-v2/src/session/subagentHost/agent-background-disabled.md rename to packages/agent-core-v2/src/agent/agentTool/agent-background-disabled.md diff --git a/packages/agent-core-v2/src/session/subagentHost/agent-background-enabled.md b/packages/agent-core-v2/src/agent/agentTool/agent-background-enabled.md similarity index 100% rename from packages/agent-core-v2/src/session/subagentHost/agent-background-enabled.md rename to packages/agent-core-v2/src/agent/agentTool/agent-background-enabled.md diff --git a/packages/agent-core-v2/src/session/subagentHost/agent.md b/packages/agent-core-v2/src/agent/agentTool/agent.md similarity index 100% rename from packages/agent-core-v2/src/session/subagentHost/agent.md rename to packages/agent-core-v2/src/agent/agentTool/agent.md diff --git a/packages/agent-core-v2/src/session/subagentHost/agentTool.ts b/packages/agent-core-v2/src/agent/agentTool/agentTool.ts similarity index 77% rename from packages/agent-core-v2/src/session/subagentHost/agentTool.ts rename to packages/agent-core-v2/src/agent/agentTool/agentTool.ts index cff7ac029..9c4634cb6 100644 --- a/packages/agent-core-v2/src/session/subagentHost/agentTool.ts +++ b/packages/agent-core-v2/src/agent/agentTool/agentTool.ts @@ -1,15 +1,10 @@ /** - * AgentTool — collaboration tool for spawning task subagents. + * `agentTool` domain (L5) — `Agent` collaboration tool. * - * Unlike the built-in tools (Read/Write/Edit/Bash/Grep/Glob), this is a - * "collaboration tool". It uses `ISessionSubagentHost` (injected via the constructor - * rather than through the runtime) to create in-process subagent loop instances. - * - * Foreground and background subagents both run through the background service. - * Foreground calls wait for the task to finish unless it is detached through - * the background-task RPC. - * - * `ToolResult.content` is textual; the structured output exposed by + * Spawns a task subagent (an ordinary Agent scope) through the `runChildAgent` + * helpers and tracks its completion through the `background` service. Foreground + * calls wait for the task to finish unless it is detached through the + * background-task RPC. `ToolResult.content` is textual; the structured * `AgentToolOutputSchema` is only used for drift-guard and is not consumed at * runtime. */ @@ -27,16 +22,31 @@ import type { ExecutableToolResult, ToolExecution, } from '#/agent/tool'; +import { isUserCancellation } from '#/_base/utils/abort'; +import { + AgentBackgroundTask, + type IAgentBackgroundService, + type RegisterBackgroundTaskOptions, +} from '#/agent/background'; +import type { IAgentProfileService } from '#/agent/profile'; +import type { IAgentLifecycleService } from '#/session/agent-lifecycle'; +import type { ISessionMetadata } from '#/session/session-metadata'; +import { toInputJsonSchema } from '#/_base/tools/support/input-schema'; +import { matchesGlobRuleSubject } from '#/_base/tools/support/rule-match'; +import { + getChildProfileName, + markChildDetached, + resumeChildAgent, + retryChildAgent, + spawnChildAgent, + type AgentToolRunOverride, +} from './runChildAgent'; import { DEFAULT_SUBAGENT_TIMEOUT_DESCRIPTION, DEFAULT_SUBAGENT_TIMEOUT_MS, type SubagentHandle, - type ISessionSubagentHost, -} from './subagentHost'; -import { isUserCancellation } from '#/_base/utils/abort'; -import { AgentBackgroundTask, type IAgentBackgroundService, type RegisterBackgroundTaskOptions } from '#/agent/background'; -import { toInputJsonSchema } from '#/_base/tools/support/input-schema'; -import { matchesGlobRuleSubject } from '#/_base/tools/support/rule-match'; +} from './types'; +import { DEFAULT_AGENT_SUBAGENT_PROFILES } from './profiles'; import AGENT_BACKGROUND_DISABLED_DESCRIPTION from './agent-background-disabled.md?raw'; import AGENT_BACKGROUND_DESCRIPTION from './agent-background-enabled.md?raw'; import AGENT_DESCRIPTION_BASE from './agent.md?raw'; @@ -114,33 +124,64 @@ export interface AgentToolSubagentProfile { export type AgentToolSubagentMap = Readonly>; +export interface AgentToolOptions { + readonly lifecycle: IAgentLifecycleService; + readonly parentAgentId: string; + readonly metadata?: ISessionMetadata; + readonly background: IAgentBackgroundService; + readonly profile: IAgentProfileService; + readonly cwd: string; + readonly processRunner: ISessionProcessRunner; + readonly log?: ILogger; + readonly runOverride?: AgentToolRunOverride; +} + // ── AgentTool class ────────────────────────────────────────────────── export class AgentTool implements BuiltinTool { readonly name: string = 'Agent'; readonly parameters: Record = toInputJsonSchema(AgentToolInputSchema); - constructor( - private readonly subagentHost: ISessionSubagentHost, - private readonly background: IAgentBackgroundService, - subagents?: AgentToolSubagentMap | undefined, - options?: { - log?: ILogger; - canRunInBackground?: (() => boolean) | undefined; - gitContext?: { cwd: string; runner: ISessionProcessRunner }; - }, - ) { - this.canRunInBackground = options?.canRunInBackground ?? (() => true); - this.gitContext = options?.gitContext; - const log = options?.log; - this.typeLines = buildSubagentDescriptions(subagents); - this.log = log; + private readonly lifecycle: IAgentLifecycleService; + private readonly parentAgentId: string; + private readonly metadata?: ISessionMetadata; + private readonly background: IAgentBackgroundService; + private readonly log?: ILogger; + private readonly runOverride?: AgentToolRunOverride; + private readonly typeLines: string; + private readonly gitContext: { cwd: string; runner: ISessionProcessRunner }; + + constructor(options: AgentToolOptions) { + this.lifecycle = options.lifecycle; + this.parentAgentId = options.parentAgentId; + this.metadata = options.metadata; + this.background = options.background; + this.log = options.log; + this.runOverride = options.runOverride; + this.gitContext = { cwd: options.cwd, runner: options.processRunner }; + this.typeLines = buildSubagentDescriptions(DEFAULT_AGENT_SUBAGENT_PROFILES); + this.canRunInBackground = () => { + return ( + options.profile.isToolActive('TaskList') && + options.profile.isToolActive('TaskOutput') && + options.profile.isToolActive('TaskStop') + ); + }; } - private readonly log?: ILogger; private readonly canRunInBackground: () => boolean; - private readonly typeLines: string; - private readonly gitContext?: { cwd: string; runner: ISessionProcessRunner }; + + private get run(): AgentToolRunOverride { + return ( + this.runOverride ?? { + spawn: spawnChildAgent, + resume: resumeChildAgent, + retry: retryChildAgent, + getProfileName: getChildProfileName, + markDetached: markChildDetached, + } + ); + } get description(): string { const backgroundDescription = this.canRunInBackground() @@ -165,7 +206,13 @@ export class AgentTool implements BuiltinTool { let profileName = requestedProfileName ?? 'coder'; if (resumeAgentId !== undefined && resumeAgentId.length > 0) { - profileName = (await this.subagentHost.getProfileName(resumeAgentId)) ?? 'subagent'; + profileName = + (await this.run.getProfileName({ + lifecycle: this.lifecycle, + parentAgentId: this.parentAgentId, + metadata: this.metadata, + agentId: resumeAgentId, + })) ?? 'subagent'; } const prefix = args.run_in_background === true ? 'Launching background' : 'Launching'; return { @@ -185,10 +232,7 @@ export class AgentTool implements BuiltinTool { private async execution( args: AgentToolInput, - { - toolCallId, - signal, - }: ExecutableToolContext, + { toolCallId, signal }: ExecutableToolContext, ): Promise { try { signal.throwIfAborted(); @@ -238,8 +282,17 @@ export class AgentTool implements BuiltinTool { try { handle = operation === 'resume' - ? await this.subagentHost.resume(resumeAgentId!, runOptions) - : await this.subagentHost.spawn({ + ? await this.run.resume({ + lifecycle: this.lifecycle, + parentAgentId: this.parentAgentId, + metadata: this.metadata, + agentId: resumeAgentId!, + ...runOptions, + }) + : await this.run.spawn({ + lifecycle: this.lifecycle, + parentAgentId: this.parentAgentId, + metadata: this.metadata, profileName: requestedProfileName ?? 'coder', ...runOptions, }); @@ -264,7 +317,15 @@ export class AgentTool implements BuiltinTool { signal: runInBackground ? undefined : signal, }; taskId = this.background.registerTask( - new AgentBackgroundTask(handle, args.description, this.subagentHost, controller), + new AgentBackgroundTask( + handle, + args.description, + { + markActiveChildDetached: (agentId) => + this.run.markDetached({ parentAgentId: this.parentAgentId, agentId }), + }, + controller, + ), registerOptions, ); signal.removeEventListener('abort', abortBeforeRegister); @@ -286,24 +347,14 @@ export class AgentTool implements BuiltinTool { if (runInBackground) { return { - output: formatBackgroundAgentResult( - taskId, - handle, - args.description, - allowBackground, - ), + output: formatBackgroundAgentResult(taskId, handle, args.description, allowBackground), }; } const release = await this.background.waitForForegroundRelease(taskId); if (release === 'detached') { return { - output: formatBackgroundAgentResult( - taskId, - handle, - args.description, - allowBackground, - ), + output: formatBackgroundAgentResult(taskId, handle, args.description, allowBackground), }; } return await this.formatForegroundResult(taskId, handle); @@ -313,13 +364,9 @@ export class AgentTool implements BuiltinTool { } private async withGitContext(profileName: string, prompt: string): Promise { - if (profileName !== 'explore' || this.gitContext === undefined) return prompt; + if (profileName !== 'explore') return prompt; try { - const context = await collectGitContext( - this.gitContext.runner, - this.gitContext.cwd, - this.log, - ); + const context = await collectGitContext(this.gitContext.runner, this.gitContext.cwd, this.log); return context.length > 0 ? `${context}\n\n${prompt}` : prompt; } catch { return prompt; @@ -333,10 +380,7 @@ export class AgentTool implements BuiltinTool { const info = this.background.getTask(taskId); if (info?.status === 'completed') { return { - output: formatForegroundAgentSuccess( - handle, - await this.background.readOutput(taskId), - ), + output: formatForegroundAgentSuccess(handle, await this.background.readOutput(taskId)), }; } const timedOut = info?.status === 'timed_out'; @@ -416,8 +460,7 @@ function launchErrorMessage(error: unknown, signal: AbortSignal): string { return error instanceof Error ? error.message : String(error); } -function buildSubagentDescriptions(subagents: AgentToolSubagentMap | undefined): string { - if (subagents === undefined) return ''; +function buildSubagentDescriptions(subagents: AgentToolSubagentMap): string { return Object.entries(subagents) .map(([name, subagent]) => { const details = [subagent.description, subagent.whenToUse].filter( diff --git a/packages/agent-core-v2/src/agent/agentTool/agentToolService.ts b/packages/agent-core-v2/src/agent/agentTool/agentToolService.ts new file mode 100644 index 000000000..dae20a419 --- /dev/null +++ b/packages/agent-core-v2/src/agent/agentTool/agentToolService.ts @@ -0,0 +1,72 @@ +/** + * `agentTool` domain (L5) — registers the `Agent` collaboration tool for an agent. + * + * Registers the `Agent` tool into the `toolRegistry` so the agent can spawn task + * subagents, bound to this agent as the parent (`parentAgentId` from the agent + * `scopeContext`). The optional first static `runner` argument is a test seam + * (`AgentToolRunOverride`) that lets tests substitute the `runChildAgent` + * helpers; the scoped registry supplies none. Bound at Agent scope; reads its + * identity through `scopeContext`, creates child agents through + * `agent-lifecycle`, reads the parent check through `session-metadata`, gates + * background execution through the agent `profile`, and gathers git context + * through `execContext` (cwd) + `process` (runner). + */ + +import { Disposable } from '#/_base/di'; +import { InstantiationType } from '#/_base/di/extensions'; +import { LifecycleScope, registerScopedService } from '#/_base/di/scope'; +import { IAgentBackgroundService } from '#/agent/background'; +import { IAgentProfileService } from '#/agent/profile'; +import { IAgentScopeContext } from '#/agent/scopeContext'; +import { IAgentToolRegistryService } from '#/agent/toolRegistry'; +import { IExecContext } from '#/session/execContext'; +import { ILogService } from '#/app/log'; +import { IAgentLifecycleService } from '#/session/agent-lifecycle'; +import { ISessionProcessRunner } from '#/session/process'; +import { ISessionMetadata } from '#/session/session-metadata'; + +import { AgentTool } from './agentTool'; +import { IAgentToolService } from './agentToolServiceToken'; +import type { AgentToolRunOverride } from './runChildAgent'; + +export class AgentToolService extends Disposable implements IAgentToolService { + declare readonly _serviceBrand: undefined; + + constructor( + runner: AgentToolRunOverride | undefined, + @IAgentScopeContext ctx: IAgentScopeContext, + @IAgentLifecycleService lifecycle: IAgentLifecycleService, + @ISessionMetadata metadata: ISessionMetadata, + @IAgentToolRegistryService toolRegistry: IAgentToolRegistryService, + @IAgentBackgroundService background: IAgentBackgroundService, + @IAgentProfileService profile: IAgentProfileService, + @IExecContext execCtx: IExecContext, + @ISessionProcessRunner processRunner: ISessionProcessRunner, + @ILogService log?: ILogService, + ) { + super(); + this._register( + toolRegistry.register( + new AgentTool({ + lifecycle, + parentAgentId: ctx.agentId, + metadata, + background, + profile, + cwd: execCtx.cwd, + processRunner, + log, + runOverride: runner, + }), + ), + ); + } +} + +registerScopedService( + LifecycleScope.Agent, + IAgentToolService, + AgentToolService, + InstantiationType.Delayed, + 'agentTool', +); diff --git a/packages/agent-core-v2/src/agent/agentTool/agentToolServiceToken.ts b/packages/agent-core-v2/src/agent/agentTool/agentToolServiceToken.ts new file mode 100644 index 000000000..129bc648c --- /dev/null +++ b/packages/agent-core-v2/src/agent/agentTool/agentToolServiceToken.ts @@ -0,0 +1,19 @@ +/** + * `agentTool` domain (L5) — `IAgentToolService` token. + * + * Exposes only the service identifier for the Agent-scoped `Agent` tool + * registrar so consumers (for example `runChildAgent`, which force-instantiates + * it for child agents, and `rpc`, which force-instantiates it for the main + * agent) can resolve the binding without pulling in the registrar's import + * graph. Kept separate from the implementation to avoid an import cycle through + * `agentTool` → `runChildAgent`. Bound at Agent scope. + */ + +import { createDecorator, type ServiceIdentifier } from '#/_base/di/instantiation'; + +export interface IAgentToolService { + readonly _serviceBrand: undefined; +} + +export const IAgentToolService: ServiceIdentifier = + createDecorator('agentToolService'); diff --git a/packages/agent-core-v2/src/agent/agentTool/index.ts b/packages/agent-core-v2/src/agent/agentTool/index.ts new file mode 100644 index 000000000..97d636480 --- /dev/null +++ b/packages/agent-core-v2/src/agent/agentTool/index.ts @@ -0,0 +1,25 @@ +/** + * `agentTool` domain barrel — re-exports the child-agent run contract and + * helpers (`runChildAgent`), the `Agent` collaboration tool, the default + * profiles, and the Agent-scoped registrar (`agentToolService`) plus its token. + * Importing this barrel registers the `IAgentToolService` binding into the scope + * registry. + */ + +export * from './types'; +export * from './runChildAgent'; +export * from './agentToolServiceToken'; +export * from './profiles'; +export * from './agentToolService'; +export { + AgentTool, + AgentToolInputSchema, + AgentToolOutputSchema, +} from './agentTool'; +export type { + AgentToolInput, + AgentToolOptions, + AgentToolOutput, + AgentToolSubagentMap, + AgentToolSubagentProfile, +} from './agentTool'; diff --git a/packages/agent-core-v2/src/session/subagentHost/profiles.ts b/packages/agent-core-v2/src/agent/agentTool/profiles.ts similarity index 100% rename from packages/agent-core-v2/src/session/subagentHost/profiles.ts rename to packages/agent-core-v2/src/agent/agentTool/profiles.ts diff --git a/packages/agent-core-v2/src/agent/agentTool/runChildAgent.ts b/packages/agent-core-v2/src/agent/agentTool/runChildAgent.ts new file mode 100644 index 000000000..29a91455f --- /dev/null +++ b/packages/agent-core-v2/src/agent/agentTool/runChildAgent.ts @@ -0,0 +1,520 @@ +/** + * `agentTool` domain (L5) — runs a child agent (an ordinary Agent scope) to completion. + * + * Stateless helper module (plain functions, not a class, not a DI service). + * Each function takes the parent `agent-lifecycle`, `parentAgentId`, and optional + * `session-metadata` explicitly, creates or resumes a child agent, mirrors the + * way the main agent runs a turn (`prompt` → await the turn result → collect the + * summary + usage), and emits `subagent.*` facts on the parent's event sink. + * Active-child tracking lives in a module-level map keyed by parent agent id so + * `cancelAllChildren` / `markChildDetached` can reach every run. Owns no scoped + * state itself — all durable state lives in the child agent scope. Bound to no + * scope; borrows `event`, `externalHooks`, `telemetry`, `profile`, `prompt`, + * `contextMemory`, `usage`, and `agentTool` through the parent/child accessors. + */ + +import { + APIProviderRateLimitError, + isProviderRateLimitError, + type TokenUsage, +} from '@moonshot-ai/kosong'; + +import { linkAbortSignal, userCancellationReason } from '#/_base/utils/abort'; +import { IAgentLifecycleService } from '#/session/agent-lifecycle'; +import type { IScopeHandle } from '#/_base/di/scope'; +import { + IAgentContextMemoryService, + type ContextMessage, + type PromptOrigin, +} from '#/agent/contextMemory'; +import { ErrorCodes, toKimiErrorPayload, type KimiErrorPayload } from '#/errors'; +import { IAgentEventSinkService } from '#/agent/eventSink'; +import { IAgentExternalHooksService } from '#/agent/externalHooks'; +import { isAbortError } from '#/agent/loop/errors'; +import { IAgentProfileService } from '#/agent/profile'; +import { ISessionMetadata } from '#/session/session-metadata'; +import { ITelemetryService } from '#/app/telemetry'; +import { IAgentPromptService } from '#/agent/prompt'; +import { IAgentUsageService } from '#/agent/usage'; +import type { Turn } from '#/agent/turn'; + +import { IAgentToolService } from './agentToolServiceToken'; +import { DEFAULT_AGENT_SUBAGENT_PROFILES, EXPLORE_ROLE_ADDITIONAL } from './profiles'; +import { + DEFAULT_SUBAGENT_TIMEOUT_MS, + DEFAULT_SUBAGENT_TIMEOUT_DESCRIPTION, + type RunSubagentOptions, + type SpawnSubagentOptions, + type SubagentHandle, +} from './types'; +import SUMMARY_CONTINUATION_PROMPT from './summary-continuation.md?raw'; + +const SUBAGENT_PROMPT_ORIGIN: PromptOrigin = { kind: 'system_trigger', name: 'subagent' }; +const SUMMARY_MIN_LENGTH = 200; +const SUMMARY_CONTINUATION_ATTEMPTS = 1; +const HOOK_TEXT_PREVIEW_LENGTH = 500; + +export type RunContext = { + readonly lifecycle: IAgentLifecycleService; + readonly parentAgentId: string; + readonly metadata?: ISessionMetadata; +}; + +export type SpawnChildAgentArgs = RunContext & SpawnSubagentOptions; +export type ResumeChildAgentArgs = RunContext & { readonly agentId: string } & RunSubagentOptions; +export type RetryChildAgentArgs = RunContext & { readonly agentId: string } & RunSubagentOptions; +export type GetChildProfileNameArgs = RunContext & { readonly agentId: string }; +export type MarkChildDetachedArgs = { readonly parentAgentId: string; readonly agentId: string }; + +export type AgentToolRunOverride = { + spawn(args: SpawnChildAgentArgs): Promise; + resume(args: ResumeChildAgentArgs): Promise; + retry(args: RetryChildAgentArgs): Promise; + getProfileName(args: GetChildProfileNameArgs): Promise; + markDetached(args: MarkChildDetachedArgs): void; +}; + +type ActiveChild = { + readonly controller: AbortController; + runInBackground: boolean; +}; + +const activeChildrenByParent = new Map>(); + +function childrenOf(parentAgentId: string): Map { + let children = activeChildrenByParent.get(parentAgentId); + if (children === undefined) { + children = new Map(); + activeChildrenByParent.set(parentAgentId, children); + } + return children; +} + +export async function spawnChildAgent(args: SpawnChildAgentArgs): Promise { + const { lifecycle, parentAgentId, metadata: _metadata, ...options } = args; + options.signal.throwIfAborted(); + const parent = await ensureParent(lifecycle, parentAgentId); + const child = await lifecycle.create({ + parentAgentId, + cwd: parent.accessor.get(IAgentProfileService).data().cwd, + type: 'sub', + swarmItem: options.swarmItem, + }); + configureChild(parent, child, options.profileName); + ensureAgentTool(child); + emitSpawned(parent, parentAgentId, child.id, options.profileName, options); + const completion = runWithActiveChild( + parentAgentId, + child, + options, + parent, + options.profileName, + (turnRef, controller) => runPromptTurn(child, parent, options, options.profileName, turnRef, controller), + ); + return { agentId: child.id, profileName: options.profileName, resumed: false, completion }; +} + +export async function resumeChildAgent(args: ResumeChildAgentArgs): Promise { + const { lifecycle, parentAgentId, metadata, agentId, ...options } = args; + options.signal.throwIfAborted(); + const parent = await ensureParent(lifecycle, parentAgentId); + const child = await requireChild(lifecycle, parentAgentId, metadata, agentId); + const profileName = child.accessor.get(IAgentProfileService).data().profileName ?? 'subagent'; + emitSpawned(parent, parentAgentId, child.id, profileName, options); + const completion = runWithActiveChild( + parentAgentId, + child, + options, + parent, + profileName, + (turnRef, controller) => runPromptTurn(child, parent, options, profileName, turnRef, controller), + ); + return { agentId, profileName, resumed: true, completion }; +} + +export async function retryChildAgent(args: RetryChildAgentArgs): Promise { + const { lifecycle, parentAgentId, metadata, agentId, ...options } = args; + options.signal.throwIfAborted(); + const parent = await ensureParent(lifecycle, parentAgentId); + const child = await requireChild(lifecycle, parentAgentId, metadata, agentId); + const profileName = child.accessor.get(IAgentProfileService).data().profileName ?? 'subagent'; + emitSpawned(parent, parentAgentId, child.id, profileName, options); + const completion = runWithActiveChild( + parentAgentId, + child, + options, + parent, + profileName, + (turnRef, controller) => runRetryTurn(child, parent, options, profileName, turnRef, controller), + ); + return { agentId, profileName, resumed: true, completion }; +} + +export async function getChildProfileName( + args: GetChildProfileNameArgs, +): Promise { + const { lifecycle, parentAgentId, metadata, agentId } = args; + if (metadata !== undefined) { + const meta = (await metadata.read()).agents?.[agentId]; + if (meta?.type !== 'sub' || meta.parentAgentId !== parentAgentId) return undefined; + } + const child = lifecycle.getHandle(agentId); + if (child === undefined) return undefined; + return child.accessor.get(IAgentProfileService).data().profileName; +} + +export function markChildDetached({ parentAgentId, agentId }: MarkChildDetachedArgs): void { + const child = activeChildrenByParent.get(parentAgentId)?.get(agentId); + if (child !== undefined) child.runInBackground = true; +} + +export function cancelAllChildren( + parentAgentId: string, + reason: unknown = userCancellationReason(), +): void { + const children = activeChildrenByParent.get(parentAgentId); + if (children === undefined) return; + for (const [, child] of children) { + if (child.runInBackground) continue; + child.controller.abort(reason); + } +} + +async function ensureParent( + lifecycle: IAgentLifecycleService, + parentAgentId: string, +): Promise { + const existing = lifecycle.getHandle(parentAgentId); + if (existing !== undefined) return existing; + throw new Error(`Parent agent "${parentAgentId}" does not exist`); +} + +async function requireChild( + lifecycle: IAgentLifecycleService, + parentAgentId: string, + metadata: ISessionMetadata | undefined, + agentId: string, +): Promise { + if (metadata !== undefined) { + const meta = (await metadata.read()).agents?.[agentId]; + if (meta === undefined) throw new Error(`Agent instance "${agentId}" does not exist`); + if (meta.type !== 'sub') throw new Error(`Agent instance "${agentId}" is not a subagent`); + if (meta.parentAgentId !== parentAgentId) { + throw new Error(`Agent instance "${agentId}" does not belong to this parent agent`); + } + } + const child = lifecycle.getHandle(agentId); + if (child === undefined) throw new Error(`Agent instance "${agentId}" does not exist`); + if (activeChildrenByParent.get(parentAgentId)?.has(agentId) === true) { + throw new Error(`Agent instance "${agentId}" is already running`); + } + ensureAgentTool(child); + return child; +} + +function ensureAgentTool(child: IScopeHandle): void { + // Force-instantiate the child agent's `Agent` tool registrar so its `Agent` + // tool is registered before the child's first turn builds its tool list. + child.accessor.get(IAgentToolService); +} + +function configureChild(parent: IScopeHandle, child: IScopeHandle, profileName: string): void { + const parentProfile = parent.accessor.get(IAgentProfileService); + const childProfile = child.accessor.get(IAgentProfileService); + const parentData = parentProfile.data(); + const profile = DEFAULT_AGENT_SUBAGENT_PROFILES[profileName]; + const activeToolNames = + profileName === 'coder' + ? (parentData.activeToolNames ?? profile?.tools) + : profile?.tools; + childProfile.update({ + cwd: parentData.cwd, + modelAlias: parentData.modelAlias, + thinkingLevel: parentData.thinkingLevel, + profileName, + systemPrompt: + profileName === 'explore' + ? `${parentData.systemPrompt}\n\n${EXPLORE_ROLE_ADDITIONAL}` + : parentData.systemPrompt, + activeToolNames, + }); +} + +function emitSpawned( + parent: IScopeHandle, + parentAgentId: string, + subagentId: string, + profileName: string, + options: RunSubagentOptions, +): void { + parent.accessor.get(IAgentEventSinkService)?.emit({ + type: 'subagent.spawned', + subagentId, + subagentName: profileName, + parentToolCallId: options.parentToolCallId, + parentToolCallUuid: options.parentToolCallUuid, + parentAgentId, + description: options.description, + swarmIndex: options.swarmIndex, + runInBackground: options.runInBackground, + }); + parent.accessor.get(ITelemetryService)?.track('subagent_created', { + subagent_name: profileName, + run_in_background: options.runInBackground, + }); +} + +function emitStarted(parent: IScopeHandle, subagentId: string): void { + parent.accessor.get(IAgentEventSinkService)?.emit({ type: 'subagent.started', subagentId }); +} + +function emitCompleted( + parent: IScopeHandle, + subagentId: string, + resultSummary: string, + usage?: TokenUsage, +): void { + parent.accessor.get(IAgentEventSinkService)?.emit({ + type: 'subagent.completed', + subagentId, + resultSummary, + usage, + }); +} + +function emitFailed( + parent: IScopeHandle, + subagentId: string, + error: unknown, + options: RunSubagentOptions, +): void { + if (isAbortError(error)) return; + if (shouldSuppressQueuedAttemptFailureEvent(options, error)) return; + parent.accessor.get(IAgentEventSinkService)?.emit({ + type: 'subagent.failed', + subagentId, + error: errorMessage(error), + }); +} + + +async function triggerSubagentStart( + parent: IScopeHandle, + profileName: string, + prompt: string, + signal: AbortSignal, +): Promise { + await parent.accessor.get(IAgentExternalHooksService)?.triggerSubagentStart( + { + agentName: profileName, + prompt: prompt.slice(0, HOOK_TEXT_PREVIEW_LENGTH), + }, + signal, + ); +} + +function triggerSubagentStop(parent: IScopeHandle, profileName: string, result: string): void { + parent.accessor.get(IAgentExternalHooksService)?.triggerSubagentStop({ + agentName: profileName, + response: result.slice(0, HOOK_TEXT_PREVIEW_LENGTH), + }); +} + +function observeFirstRequest(turn: Turn, options: RunSubagentOptions): void { + if (options.onReady === undefined) return; + void turn.ready.then(() => options.onReady?.()).catch(() => {}); +} + +async function runWithActiveChild( + parentAgentId: string, + child: IScopeHandle, + options: RunSubagentOptions, + parent: IScopeHandle, + profileName: string, + run: ( + turn: { current?: Turn }, + controller: AbortController, + ) => Promise<{ result: string; usage?: TokenUsage }>, +): Promise<{ result: string; usage?: TokenUsage }> { + const controller = new AbortController(); + childrenOf(parentAgentId).set(child.id, { controller, runInBackground: options.runInBackground }); + const unlink = linkAbortSignal(options.signal, controller); + const turnRef: { current?: Turn } = {}; + emitStarted(parent, child.id); + try { + const result = await run(turnRef, controller); + emitCompleted(parent, child.id, result.result, result.usage); + triggerSubagentStop(parent, profileName, result.result); + return result; + } catch (error) { + emitFailed(parent, child.id, error, options); + throw error; + } finally { + unlink(); + if (controller.signal.aborted) { + turnRef.current?.abortController.abort(controller.signal.reason); + } + childrenOf(parentAgentId).delete(child.id); + } +} + +async function runPromptTurn( + child: IScopeHandle, + parent: IScopeHandle, + options: RunSubagentOptions, + profileName: string, + turnRef: { current?: Turn }, + controller: AbortController, +): Promise<{ result: string; usage?: TokenUsage }> { + options.signal.throwIfAborted(); + await triggerSubagentStart(parent, profileName, options.prompt, options.signal); + options.signal.throwIfAborted(); + + const turn = child.accessor.get(IAgentPromptService).prompt({ + role: 'user', + content: [{ type: 'text', text: options.prompt }], + toolCalls: [], + origin: SUBAGENT_PROMPT_ORIGIN, + }); + if (turn === undefined) { + throw new Error('Subagent turn could not be started'); + } + turnRef.current = turn; + observeFirstRequest(turn, options); + const result = await awaitTurn(turn, controller); + classifyTurnResult(result); + const summary = await completeSummary(child, controller, turnRef); + const usage = child.accessor.get(IAgentUsageService)?.status().total; + return { result: summary, usage }; +} + +async function runRetryTurn( + child: IScopeHandle, + parent: IScopeHandle, + options: RunSubagentOptions, + profileName: string, + turnRef: { current?: Turn }, + controller: AbortController, +): Promise<{ result: string; usage?: TokenUsage }> { + options.signal.throwIfAborted(); + await triggerSubagentStart(parent, profileName, options.prompt, options.signal); + options.signal.throwIfAborted(); + + const turn = child.accessor.get(IAgentPromptService).retry('agent-host'); + if (turn === undefined) { + throw new Error(`Agent instance "${child.id}" could not start a retry turn`); + } + turnRef.current = turn; + observeFirstRequest(turn, options); + const result = await awaitTurn(turn, controller); + classifyTurnResult(result); + const summary = await completeSummary(child, controller, turnRef); + const usage = child.accessor.get(IAgentUsageService)?.status().total; + return { result: summary, usage }; +} + +async function awaitTurn( + turn: Turn, + controller: AbortController, +): Promise<{ reason: string; error?: unknown }> { + const onAbort = (): void => { + turn.abortController.abort(controller.signal.reason); + }; + controller.signal.addEventListener('abort', onAbort, { once: true }); + try { + return await Promise.race([turn.result, abortPromise(controller.signal)]); + } finally { + controller.signal.removeEventListener('abort', onAbort); + } +} + +async function completeSummary( + child: IScopeHandle, + controller: AbortController, + turnRef: { current?: Turn }, +): Promise { + let summary = latestAssistantText(child.accessor.get(IAgentContextMemoryService).get()); + if (summary.trim().length >= SUMMARY_MIN_LENGTH) return summary; + + for (let attempt = 0; attempt < SUMMARY_CONTINUATION_ATTEMPTS; attempt++) { + const turn = child.accessor.get(IAgentPromptService).prompt({ + role: 'user', + content: [{ type: 'text', text: SUMMARY_CONTINUATION_PROMPT }], + toolCalls: [], + origin: SUBAGENT_PROMPT_ORIGIN, + }); + if (turn === undefined) break; + turnRef.current = turn; + const result = await awaitTurn(turn, controller); + if (result.reason !== 'completed') break; + const continued = latestAssistantText(child.accessor.get(IAgentContextMemoryService).get()); + if (continued.trim().length > 0) summary = continued; + if (summary.trim().length >= SUMMARY_MIN_LENGTH) break; + } + return summary; +} + +function classifyTurnResult(result: { reason: string; error?: unknown }): void { + if (result.reason === 'filtered') { + throw new Error('Subagent turn blocked by provider safety policy'); + } + if (result.reason === 'failed') { + const error = result.error; + if (isProviderRateLimitError(error)) throw error; + const payload = toKimiErrorPayload(error); + if (payload.code === ErrorCodes.PROVIDER_RATE_LIMIT) { + throw providerRateLimitErrorFromPayload(payload); + } + throw error instanceof Error ? error : new Error(String(error ?? 'Subagent turn failed')); + } + if (result.reason === 'cancelled') { + throw userCancellationReason(); + } +} + +function shouldSuppressQueuedAttemptFailureEvent( + options: RunSubagentOptions, + error: unknown, +): boolean { + if (options.suppressRateLimitFailureEvent !== true) return false; + if (isProviderRateLimitError(error)) return true; + return isAbortError(error) || options.signal.aborted; +} + +function providerRateLimitErrorFromPayload(error: KimiErrorPayload): APIProviderRateLimitError { + const requestId = + typeof error.details?.['requestId'] === 'string' ? error.details['requestId'] : null; + return new APIProviderRateLimitError(error.message, requestId); +} + +function abortPromise(signal: AbortSignal): Promise { + 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/summary-continuation.md b/packages/agent-core-v2/src/agent/agentTool/summary-continuation.md similarity index 100% rename from packages/agent-core-v2/src/session/subagentHost/summary-continuation.md rename to packages/agent-core-v2/src/agent/agentTool/summary-continuation.md diff --git a/packages/agent-core-v2/src/agent/agentTool/types.ts b/packages/agent-core-v2/src/agent/agentTool/types.ts new file mode 100644 index 000000000..d7294295d --- /dev/null +++ b/packages/agent-core-v2/src/agent/agentTool/types.ts @@ -0,0 +1,40 @@ +/** + * `agentTool` domain (L5) — child-agent run contract types. + * + * Leaf module holding the option/handle types shared by the `runChildAgent` + * helpers and the `subagentBatch` scheduler. Owns no scoped state and imports + * no business domain, so it sits below both modules and breaks their import + * cycle. + */ + +import type { TokenUsage } from '@moonshot-ai/kosong'; + +export const DEFAULT_SUBAGENT_TIMEOUT_MS = 30 * 60 * 1000; +export const DEFAULT_SUBAGENT_TIMEOUT_DESCRIPTION = '30 minutes'; + +export interface RunSubagentOptions { + readonly parentToolCallId: string; + readonly parentToolCallUuid?: string; + readonly prompt: string; + readonly description: string; + readonly swarmIndex?: number; + readonly runInBackground: boolean; + readonly signal: AbortSignal; + readonly onReady?: () => void; + readonly suppressRateLimitFailureEvent?: boolean; +} + +export interface SpawnSubagentOptions extends RunSubagentOptions { + readonly profileName: string; + readonly swarmItem?: string; +} + +export type SubagentHandle = { + readonly agentId: string; + readonly profileName: string; + readonly resumed: boolean; + readonly completion: Promise<{ + readonly result: string; + readonly usage?: TokenUsage; + }>; +}; 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..9f5c69022 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 { +export interface SubagentDetachHandle { markActiveChildDetached(agentId: string): void; } @@ -46,7 +46,7 @@ export class AgentBackgroundTask implements BackgroundTask { constructor( private readonly handle: SubagentHandle, readonly description: string, - private readonly subagentHost: Pick, + private readonly detachHandle: Pick, private readonly abortController: AbortController, ) { this.agentId = handle.agentId; @@ -79,7 +79,7 @@ export class AgentBackgroundTask implements BackgroundTask { } onDetach(): void { - this.subagentHost.markActiveChildDetached(this.agentId); + this.detachHandle.markActiveChildDetached(this.agentId); } toInfo(base: BackgroundTaskInfoBase): AgentBackgroundTaskInfo { diff --git a/packages/agent-core-v2/src/agent/background/background.ts b/packages/agent-core-v2/src/agent/background/background.ts index c47663c67..87dbc3633 100644 --- a/packages/agent-core-v2/src/agent/background/background.ts +++ b/packages/agent-core-v2/src/agent/background/background.ts @@ -6,7 +6,7 @@ import type { } from './task'; export { AgentBackgroundTask } from './agent-task'; -export type { AgentBackgroundTaskInfo } from './agent-task'; +export type { AgentBackgroundTaskInfo, SubagentDetachHandle } from './agent-task'; export { ProcessBackgroundTask } from './process-task'; export type { ProcessBackgroundTaskInfo } from './process-task'; export { QuestionBackgroundTask } from './question-task'; diff --git a/packages/agent-core-v2/src/agent/rpc/rpcService.ts b/packages/agent-core-v2/src/agent/rpc/rpcService.ts index 003d75a96..66573f492 100644 --- a/packages/agent-core-v2/src/agent/rpc/rpcService.ts +++ b/packages/agent-core-v2/src/agent/rpc/rpcService.ts @@ -21,11 +21,18 @@ import { IAgentQuestionToolsService } from '#/agent/questionTools'; import { ISessionMetadata, type SessionMetaPatch } from '#/session/session-metadata'; import { BashTool, IAgentShellToolsService } from '#/agent/shellTools'; import { IAgentSkillService } from '#/agent/skill'; -import { IKaos } from '#/app/kaos'; +import { IHostEnvironment } from '#/app/hostEnvironment'; +import { IExecContext } from '#/session/execContext'; import { ISessionProcessRunner } from '#/session/process'; -import { ISessionSubagentHost } from '#/session/subagentHost'; +import { IAgentToolService } from '#/agent/agentTool'; +import { + DenyAllPermissionPolicyService, + IAgentPermissionPolicyService, +} from '#/agent/permissionPolicy'; +import { IAgentSystemReminderService } from '#/agent/systemReminder'; import { IAgentSwarmService } from '#/agent/swarm'; import { ITelemetryService } from '#/app/telemetry'; +import { IAgentLifecycleService } from '#/session/agent-lifecycle'; import { IAgentToolRegistryService } from '#/agent/toolRegistry'; import type { ToolUpdate } from '#/agent/tool'; import { IAgentTurnService } from '#/agent/turn'; @@ -67,6 +74,23 @@ import { const SHELL_FOREGROUND_TIMEOUT_S = 2 * 60; +const TOOL_CALL_DISABLED_MESSAGE = + 'Tool calls are disabled for side questions. Answer with text only.'; +const SIDE_QUESTION_SYSTEM_REMINDER = ` +This is a side-channel conversation with the user. You should answer user questions directly based on what you already know. + +IMPORTANT: +- You are a separate, lightweight instance. +- The main agent continues independently; do not reference being interrupted. +- Do not call any tools. All tool calls are disabled and will be rejected. + Even though tool definitions are visible in this request, they exist only + for technical reasons (prompt cache). You must not use them. +- Respond only with text based on what you already know from the conversation + and this side-channel conversation. +- Follow-up turns may happen in this side-channel conversation. +- If you do not know the answer, say so directly. +`; + export class AgentRPCService implements IAgentRPCService { declare readonly _serviceBrand: undefined; private readonly shellCommandControllers = new Map(); @@ -85,12 +109,14 @@ export class AgentRPCService implements IAgentRPCService { @IAgentFileToolsService private readonly fileTools: IAgentFileToolsService, @IAgentShellToolsService private readonly shellTools: IAgentShellToolsService, @ISessionProcessRunner private readonly processRunner: ISessionProcessRunner, - @IKaos private readonly kaos: IKaos, + @IHostEnvironment private readonly env: IHostEnvironment, + @IExecContext private readonly ctx: IExecContext, @IAgentBackgroundService private readonly background: IAgentBackgroundService, @IAgentContextMemoryService private readonly context: IAgentContextMemoryService, @IAgentContextSizeService private readonly contextSize: IAgentContextSizeService, @IAgentSkillService private readonly skills: IAgentSkillService, - @ISessionSubagentHost private readonly subagentHost: ISessionSubagentHost, + @IAgentLifecycleService private readonly lifecycle: IAgentLifecycleService, + @IAgentToolService private readonly agentTool: IAgentToolService, @IAgentUsageService private readonly usage: IAgentUsageService, @ITelemetryService private readonly telemetry: ITelemetryService, @IAgentGoalService private readonly goal: IAgentGoalService, @@ -113,7 +139,7 @@ export class AgentRPCService implements IAgentRPCService { private ensureBashTool() { const existing = this.toolRegistry.resolve('Bash'); if (existing !== undefined) return existing; - const bash = new BashTool(this.processRunner, this.kaos, this.background); + const bash = new BashTool(this.processRunner, this.env, this.ctx, this.background); this.toolRegistry.register(bash); return bash; } @@ -347,8 +373,18 @@ export class AgentRPCService implements IAgentRPCService { await this.metadata.update(patch satisfies SessionMetaPatch); } - startBtw(_payload: EmptyPayload): Promise { - return this.subagentHost.startBtw(); + async startBtw(_payload: EmptyPayload): Promise { + const child = await this.lifecycle.fork('main'); + child.accessor + .get(IAgentSystemReminderService) + ?.appendSystemReminder(SIDE_QUESTION_SYSTEM_REMINDER.trim(), { + kind: 'system_trigger', + name: 'btw', + }); + child.accessor + .get(IAgentPermissionPolicyService) + ?.registerPolicy(new DenyAllPermissionPolicyService(TOOL_CALL_DISABLED_MESSAGE)); + return child.id; } createGoal(payload: CreateGoalPayload) { diff --git a/packages/agent-core-v2/src/agent/scopeContext/index.ts b/packages/agent-core-v2/src/agent/scopeContext/index.ts new file mode 100644 index 000000000..baaa5ebe9 --- /dev/null +++ b/packages/agent-core-v2/src/agent/scopeContext/index.ts @@ -0,0 +1,6 @@ +/** + * `scopeContext` domain barrel — re-exports the agent-scope identity token + * (`scopeContext`). + */ + +export * from './scopeContext'; diff --git a/packages/agent-core-v2/src/agent/scopeContext/scopeContext.ts b/packages/agent-core-v2/src/agent/scopeContext/scopeContext.ts new file mode 100644 index 000000000..329c5d58e --- /dev/null +++ b/packages/agent-core-v2/src/agent/scopeContext/scopeContext.ts @@ -0,0 +1,19 @@ +/** + * `agent` domain (L2) — agent-scope identity token. + * + * Exposes `IAgentScopeContext`, the identity of the current agent scope (its + * `agentId`). Seeded into every agent scope at creation by `agent-lifecycle` + * so Agent-scoped consumers can refer to themselves (for example as the + * parent of a subagent) without threading the id through every call site. + * Bound at Agent scope via a per-agent seed, not the scoped registry. + */ + +import { createDecorator, type ServiceIdentifier } from '#/_base/di/instantiation'; + +export interface IAgentScopeContext { + readonly _serviceBrand: undefined; + readonly agentId: string; +} + +export const IAgentScopeContext: ServiceIdentifier = + createDecorator('agentScopeContext'); diff --git a/packages/agent-core-v2/src/session/subagentHost/subagent-batch.ts b/packages/agent-core-v2/src/agent/swarm/subagentBatch.ts similarity index 92% rename from packages/agent-core-v2/src/session/subagentHost/subagent-batch.ts rename to packages/agent-core-v2/src/agent/swarm/subagentBatch.ts index 52b6c1390..21e28bfde 100644 --- a/packages/agent-core-v2/src/session/subagentHost/subagent-batch.ts +++ b/packages/agent-core-v2/src/agent/swarm/subagentBatch.ts @@ -1,3 +1,12 @@ +/** + * `swarm` domain (L4) — concurrency / rate-limit scheduler for subagent runs. + * + * Owns the burst-then-throttle launch ramp and the provider-rate-limit recovery + * loop shared by the `AgentSwarm` tool; drives each attempt through a + * `SubagentBatchLauncher` (backed by the `agentTool` run helpers) and surfaces + * requeues via `suspended`. Pure scheduling logic — owns no scoped state. + */ + import { isProviderRateLimitError, type TokenUsage } from '@moonshot-ai/kosong'; import * as retry from 'retry'; @@ -5,7 +14,15 @@ import type { RunSubagentOptions, SpawnSubagentOptions, SubagentHandle, -} from './subagentHost'; +} from '#/agent/agentTool'; +import { + resumeChildAgent, + retryChildAgent, + spawnChildAgent, +} from '#/agent/agentTool'; +import type { IAgentLifecycleService } from '#/session/agent-lifecycle'; +import type { ISessionMetadata } from '#/session/session-metadata'; +import { IAgentEventSinkService } from '#/agent/eventSink'; import { isUserCancellation } from '#/_base/utils/abort'; /* @@ -77,6 +94,8 @@ export type SubagentResult = { readonly error?: string; }; +export type QueuedSubagentRunResult = SubagentResult; + export type SubagentSuspendedEvent = { readonly task: QueuedSubagentTask; readonly agentId: string; @@ -678,3 +697,35 @@ export function resolveSwarmMaxConcurrency( } return value; } + +export interface RunQueuedArgs { + readonly lifecycle: IAgentLifecycleService; + readonly parentAgentId: string; + readonly metadata?: ISessionMetadata; + readonly tasks: readonly QueuedSubagentTask[]; +} + +export function runChildAgentQueued({ + lifecycle, + parentAgentId, + metadata, + tasks, +}: RunQueuedArgs): Promise>> { + const launcher: SubagentBatchLauncher = { + spawn: (options) => spawnChildAgent({ lifecycle, parentAgentId, metadata, ...options }), + resume: (agentId, options) => + resumeChildAgent({ lifecycle, parentAgentId, metadata, agentId, ...options }), + retry: (agentId, options) => + retryChildAgent({ lifecycle, parentAgentId, metadata, agentId, ...options }), + suspended: (event) => { + const parent = lifecycle.getHandle(parentAgentId); + parent?.accessor.get(IAgentEventSinkService)?.emit({ + type: 'subagent.suspended', + subagentId: event.agentId, + reason: event.reason, + }); + }, + }; + const maxConcurrency = resolveSwarmMaxConcurrency(); + return new SubagentBatch(launcher, tasks, { maxConcurrency }).run(); +} diff --git a/packages/agent-core-v2/src/agent/swarm/swarmService.ts b/packages/agent-core-v2/src/agent/swarm/swarmService.ts index 71a05ee77..50698bb27 100644 --- a/packages/agent-core-v2/src/agent/swarm/swarmService.ts +++ b/packages/agent-core-v2/src/agent/swarm/swarmService.ts @@ -1,17 +1,26 @@ -import { - Disposable, -} from "#/_base/di"; +/** + * `swarm` domain (L4) — `IAgentSwarmService` implementation. + * + * Tracks swarm-mode enter/exit (mirroring it into `wireRecord` and + * `systemReminder`), auto-exits on turn end, and registers the `AgentSwarm` + * tool bound to this agent as the parent. Bound at Agent scope; spawns child + * agents through `agent-lifecycle`, reads its identity through `scopeContext`, + * and registers the tool through `toolRegistry`. + */ + +import { Disposable } from '#/_base/di'; import { InstantiationType } from '#/_base/di/extensions'; import { LifecycleScope, registerScopedService } from '#/_base/di/scope'; import { IAgentEventSinkService } from '#/agent/eventSink'; -import { ISessionSubagentHost } from '#/session/subagentHost'; +import { IAgentScopeContext } from '#/agent/scopeContext'; import { IAgentSystemReminderService } from '#/agent/systemReminder'; import { IAgentToolRegistryService } from '#/agent/toolRegistry'; import { IAgentTurnService } from '#/agent/turn'; import { IAgentWireRecordService } from '#/agent/wireRecord'; +import { IAgentLifecycleService } from '#/session/agent-lifecycle'; import SWARM_MODE_ENTER_REMINDER from './enter-reminder.md?raw'; import SWARM_MODE_EXIT_REMINDER from './exit-reminder.md?raw'; -import { AgentSwarmTool } from '#/agent/swarm/tools/agent-swarm'; +import { AgentSwarmTool, type AgentSwarmToolHost } from '#/agent/swarm/tools/agent-swarm'; import { IAgentSwarmService, type SwarmModeTrigger, @@ -32,12 +41,14 @@ export class AgentSwarmService extends Disposable implements IAgentSwarmService private _active: SwarmModeTrigger | null = null; constructor( + runQueued: AgentSwarmToolHost['runQueued'] | undefined, @IAgentWireRecordService private readonly wireRecord: IAgentWireRecordService, @IAgentEventSinkService private readonly events: IAgentEventSinkService, @IAgentSystemReminderService private readonly reminders: IAgentSystemReminderService, @IAgentTurnService turnService: IAgentTurnService, @IAgentToolRegistryService toolRegistry: IAgentToolRegistryService, - @ISessionSubagentHost subagentHost: ISessionSubagentHost, + @IAgentLifecycleService lifecycle: IAgentLifecycleService, + @IAgentScopeContext ctx: IAgentScopeContext, ) { super(); this._register( @@ -59,7 +70,11 @@ export class AgentSwarmService extends Disposable implements IAgentSwarmService return done; }), ); - this._register(toolRegistry.register(new AgentSwarmTool(subagentHost, this))); + this._register( + toolRegistry.register( + new AgentSwarmTool({ lifecycle, parentAgentId: ctx.agentId, runQueued }, this), + ), + ); } enter(trigger: SwarmModeTrigger): void { diff --git a/packages/agent-core-v2/src/agent/swarm/tools/agent-swarm.ts b/packages/agent-core-v2/src/agent/swarm/tools/agent-swarm.ts index 9991a847d..f8ad07f04 100644 --- a/packages/agent-core-v2/src/agent/swarm/tools/agent-swarm.ts +++ b/packages/agent-core-v2/src/agent/swarm/tools/agent-swarm.ts @@ -1,20 +1,32 @@ +/** + * `swarm` domain (L4) — `AgentSwarm` collaboration tool. + * + * Launches a batch of child agents (an ordinary Agent scope each) through the + * `agentTool` queued run helper and renders the per-subagent XML result. Keeps + * a module-level map of spawned agent id → swarm item so a later + * `resume_agent_ids` call can relabel resumed subagents. Pure tool — owns no + * scoped state. + */ + import { z } from 'zod'; import type { BuiltinTool } from '#/agent/tool'; import { DEFAULT_SUBAGENT_TIMEOUT_MS, - type QueuedSubagentRunResult, - type QueuedSubagentTask, -} from '#/session/subagentHost'; +} from '#/agent/agentTool'; import { ToolAccesses } from '#/agent/tool'; import type { ExecutableToolContext, ExecutableToolResult, ToolExecution } from '#/agent/tool'; +import type { IAgentLifecycleService } from '#/session/agent-lifecycle'; import { toInputJsonSchema } from '#/_base/tools/support/input-schema'; +import { runChildAgentQueued, type QueuedSubagentTask, type SubagentResult } from '../subagentBatch'; import AGENT_SWARM_DESCRIPTION from './agent-swarm.md?raw'; const DEFAULT_SUBAGENT_TYPE = 'coder'; const PROMPT_TEMPLATE_PLACEHOLDER = '{{item}}'; const MAX_AGENT_SWARM_SUBAGENTS = 128; +const swarmItems = new Map(); + export const AgentSwarmToolInputSchema = z .object({ description: z @@ -82,24 +94,24 @@ interface SwarmRunResult { readonly error?: string; } -interface AgentSwarmSubagentHost { - getSwarmItem(agentId: string): string | undefined; - runQueued( - tasks: readonly QueuedSubagentTask[], - ): Promise>>; -} - interface AgentSwarmMode { enter(trigger: 'tool'): void; } +export interface AgentSwarmToolHost { + readonly lifecycle: IAgentLifecycleService; + readonly parentAgentId: string; + readonly runQueued?: typeof runChildAgentQueued; + readonly getSwarmItem?: (agentId: string) => string | undefined; +} + export class AgentSwarmTool implements BuiltinTool { readonly name = 'AgentSwarm' as const; readonly description = AGENT_SWARM_DESCRIPTION; readonly parameters: Record = toInputJsonSchema(AgentSwarmToolInputSchema); constructor( - private readonly subagentHost: AgentSwarmSubagentHost, + private readonly host: AgentSwarmToolHost, private readonly swarmMode: AgentSwarmMode, ) {} @@ -142,7 +154,8 @@ export class AgentSwarmTool implements BuiltinTool { toolCallId: string, ): Promise { const profileName = normalizeOptionalString(args.subagent_type) ?? DEFAULT_SUBAGENT_TYPE; - const specs = createAgentSwarmSpecs(args, (agentId) => this.subagentHost.getSwarmItem(agentId)); + const getSwarmItem = this.host.getSwarmItem ?? ((id: string) => swarmItems.get(id)); + const specs = createAgentSwarmSpecs(args, getSwarmItem); const tasks = specs.map((spec): QueuedSubagentTask => { const descriptionName = spec.kind === 'resume' ? 'resume' : profileName; const common = { @@ -169,8 +182,20 @@ export class AgentSwarmTool implements BuiltinTool { kind: 'spawn', }; }); - const results = await this.subagentHost.runQueued(tasks); - return renderSwarmResults(results.map(({ task, ...result }) => ({ spec: task.data, ...result }))); + const runQueued = this.host.runQueued ?? runChildAgentQueued; + const results = (await runQueued({ + lifecycle: this.host.lifecycle, + parentAgentId: this.host.parentAgentId, + tasks, + })) as Array>; + for (const result of results) { + if (result.agentId !== undefined && result.task.swarmItem !== undefined) { + swarmItems.set(result.agentId, result.task.swarmItem); + } + } + return renderSwarmResults( + results.map(({ task, ...result }) => ({ spec: task.data as AgentSwarmSpec, ...result })), + ); } } diff --git a/packages/agent-core-v2/src/index.ts b/packages/agent-core-v2/src/index.ts index 2d6d96895..b27edeb4f 100644 --- a/packages/agent-core-v2/src/index.ts +++ b/packages/agent-core-v2/src/index.ts @@ -59,7 +59,8 @@ export * from '#/app/gateway'; export * from '#/session/workspaceContext'; export * from '#/app/workspaceRegistry'; export * from '#/app/hostFolderBrowser'; -export * from '#/app/kaos'; +export * from '#/app/hostEnvironment'; +export * from '#/session/execContext'; export * from '#/session/agentFs'; export * from '#/session/process'; export * from '#/session/terminal'; @@ -92,7 +93,8 @@ export * from '#/agent/promptLegacy'; export * from '#/app/messageLegacy'; export * from '#/agent/replayBuilder'; export * from '#/agent/rpc'; -export * from '#/session/subagentHost'; +export * from '#/agent/scopeContext'; +export * from '#/agent/agentTool'; export * from '#/agent/todoList'; export * from '#/agent/tool'; export * from '#/agent/toolExecutor'; diff --git a/packages/agent-core-v2/src/session/agent-lifecycle/agentLifecycle.ts b/packages/agent-core-v2/src/session/agent-lifecycle/agentLifecycle.ts index fe4072713..7eb283851 100644 --- a/packages/agent-core-v2/src/session/agent-lifecycle/agentLifecycle.ts +++ b/packages/agent-core-v2/src/session/agent-lifecycle/agentLifecycle.ts @@ -3,8 +3,8 @@ * * Defines the public contract of agent lifecycle: the `CreateAgentOptions` and * the `IAgentLifecycleService` used to create agents (`create` / `createMain`), - * look them up (`getHandle` / `list`), and remove them. Session-scoped — one - * instance per session. + * fork an existing agent (`fork`), look them up (`getHandle` / `list`), and + * remove them. Session-scoped — one instance per session. */ import { createDecorator, type ServiceIdentifier } from '#/_base/di/instantiation'; @@ -27,6 +27,8 @@ export interface IAgentLifecycleService { readonly onDidDispose: Event; create(opts: CreateAgentOptions): Promise; createMain(): Promise; + /** Create a child agent that inherits the parent's profile and context history. */ + fork(parentAgentId: string): Promise; getHandle(agentId: string): IScopeHandle | undefined; list(): readonly IScopeHandle[]; remove(agentId: string): Promise; diff --git a/packages/agent-core-v2/src/session/agent-lifecycle/agentLifecycleService.ts b/packages/agent-core-v2/src/session/agent-lifecycle/agentLifecycleService.ts index 688351a83..796ff6c55 100644 --- a/packages/agent-core-v2/src/session/agent-lifecycle/agentLifecycleService.ts +++ b/packages/agent-core-v2/src/session/agent-lifecycle/agentLifecycleService.ts @@ -1,9 +1,9 @@ /** * `agent-lifecycle` domain (L6) — `IAgentLifecycleService` implementation. * - * Creates and tracks the session's agents as child scopes. Bound at Session - * scope. Removing an agent disposes its scope; surviving agents are disposed - * with the session. + * Creates and tracks the session's agents as child scopes. Seeds each agent's + * identity through `agent` scopeContext, wires per-agent wire records and MCP, + * and registers the agent in the session registry. Bound at Session scope. */ import { join } from 'pathe'; @@ -29,6 +29,9 @@ import { IPluginService } from '#/app/plugin'; import { ISessionContext } from '#/session/session-context'; import { ISessionMetadata } from '#/session/session-metadata'; import { ISessionWorkspaceContext } from '#/session/workspaceContext'; +import { IAgentScopeContext } from '#/agent/scopeContext'; +import { IAgentProfileService } from '#/agent/profile'; +import { IAgentContextMemoryService } from '#/agent/contextMemory'; import { IAgentWireRecordService, AgentWireRecordService } from '#/agent/wireRecord'; import { IAgentReplayBuilderService, @@ -80,6 +83,7 @@ export class AgentLifecycleService extends Disposable implements IAgentLifecycle agentId, { extra: [ + [IAgentScopeContext, { _serviceBrand: undefined, agentId } satisfies IAgentScopeContext], [IAgentWireRecordService, new SyncDescriptor(AgentWireRecordService, [{ homedir: agentHomedir }])], [IAgentMcpService, new SyncDescriptor(AgentMcpService, [{ manager: this.getMcpManager() }])], // These two carry a leading static `options` param; the scoped @@ -119,6 +123,28 @@ export class AgentLifecycleService extends Disposable implements IAgentLifecycle return handle; } + async fork(parentAgentId: string): Promise { + const parent = + this.handles.get(parentAgentId) ?? + (parentAgentId === 'main' ? await this.createMain() : undefined); + if (parent === undefined) throw new Error(`Parent agent "${parentAgentId}" does not exist`); + const child = await this.create({ parentAgentId: parent.id, type: 'sub' }); + + const parentData = parent.accessor.get(IAgentProfileService).data(); + child.accessor.get(IAgentProfileService).update({ + modelAlias: parentData.modelAlias, + thinkingLevel: parentData.thinkingLevel, + systemPrompt: parentData.systemPrompt, + activeToolNames: parentData.activeToolNames, + }); + + const parentMessages = parent.accessor.get(IAgentContextMemoryService)?.get(); + if (parentMessages !== undefined && parentMessages.length > 0) { + child.accessor.get(IAgentContextMemoryService)?.splice(0, 0, parentMessages); + } + return child; + } + /** * One shared `McpConnectionManager` per session (built lazily, cached). All * agents in the session share it, matching v1's session-scoped MCP and 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 deleted file mode 100644 index 2fa673ea0..000000000 --- a/packages/agent-core-v2/src/session/subagentHost/index.ts +++ /dev/null @@ -1,10 +0,0 @@ -/** - * `subagentHost` domain barrel - re-exports the subagentHost service contract and implementation. - */ - -export * from './subagentHost'; -export * from './subagentHostService'; -export * from './defaultSessionSubagentHost'; -export * from './profiles'; -export { AgentTool, AgentToolInputSchema, AgentToolOutputSchema } from './agentTool'; -export type { AgentToolInput, AgentToolOutput } from './agentTool'; diff --git a/packages/agent-core-v2/src/session/subagentHost/subagentHost.ts b/packages/agent-core-v2/src/session/subagentHost/subagentHost.ts deleted file mode 100644 index d8a01667f..000000000 --- a/packages/agent-core-v2/src/session/subagentHost/subagentHost.ts +++ /dev/null @@ -1,72 +0,0 @@ -import { createDecorator } from "#/_base/di"; -import type { TokenUsage } from '@moonshot-ai/kosong'; -import type { - QueuedSubagentTask, - SubagentResult, - SubagentSuspendedEvent, -} from './subagent-batch'; - -export const DEFAULT_SUBAGENT_TIMEOUT_MS = 30 * 60 * 1000; -export const DEFAULT_SUBAGENT_TIMEOUT_DESCRIPTION = '30 minutes'; - -export interface RunSubagentOptions { - readonly parentToolCallId: string; - readonly parentToolCallUuid?: string; - readonly prompt: string; - readonly description: string; - readonly swarmIndex?: number; - readonly runInBackground: boolean; - readonly signal: AbortSignal; - readonly onReady?: () => void; - readonly suppressRateLimitFailureEvent?: boolean; -} - -export interface SpawnSubagentOptions extends RunSubagentOptions { - readonly profileName: string; - readonly swarmItem?: string; -} - -export type SubagentHandle = { - readonly agentId: string; - readonly profileName: string; - readonly resumed: boolean; - readonly completion: Promise<{ - readonly result: string; - readonly usage?: TokenUsage; - }>; -}; - -export interface SessionSubagentHost { - getSwarmItem(agentId: string): string | undefined; - startBtw(): Promise; - spawn(options: SpawnSubagentOptions): Promise; - resume(agentId: string, options: RunSubagentOptions): Promise; - retry(agentId: string, options: RunSubagentOptions): Promise; - getProfileName(agentId: string): Promise; - markActiveChildDetached(agentId: string): void; - runQueued(tasks: readonly QueuedSubagentTask[]): Promise>>; - /** Abort every foreground active child (and its descendants) with the given reason. */ - cancelAll(reason?: unknown): void; - /** Surface a queued subagent being requeued after a provider rate limit. */ - suspended(event: SubagentSuspendedEvent): void; -} - -export type QueuedSubagentRunResult = 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 deleted file mode 100644 index 8d4e185b5..000000000 --- a/packages/agent-core-v2/src/session/subagentHost/subagentHostService.ts +++ /dev/null @@ -1,123 +0,0 @@ -import type { - QueuedSubagentRunResult, - QueuedSubagentTask, - SessionSubagentHost, - SpawnSubagentOptions, - RunSubagentOptions, - SubagentHandle, -} from './subagentHost'; -import type { SubagentSuspendedEvent } from './subagent-batch'; -import { - ISessionSubagentHost, -} from './subagentHost'; -import { Disposable } from '#/_base/di'; -import { InstantiationType } from '#/_base/di/extensions'; -import { LifecycleScope, registerScopedService } from '#/_base/di/scope'; -import { IAgentLifecycleService } from '#/session/agent-lifecycle'; -import { IAgentBackgroundService } from '#/agent/background'; -import { ILogService } from '#/app/log'; -import { IAgentProfileService } from '#/agent/profile'; -import { IKaos } from '#/app/kaos'; -import { ISessionProcessRunner } from '#/session/process'; -import { ISessionMetadata } from '#/session/session-metadata'; -import { IAgentToolRegistryService } from '#/agent/toolRegistry'; -import { AgentTool } from './agentTool'; -import { DefaultSessionSubagentHost } from './defaultSessionSubagentHost'; -import { DEFAULT_AGENT_SUBAGENT_PROFILES } from './profiles'; - -export class SessionSubagentHostService extends Disposable implements ISessionSubagentHost { - declare readonly _serviceBrand: undefined; - - private readonly host: SessionSubagentHost; - - constructor( - subagentHost: SessionSubagentHost | undefined, - @IAgentLifecycleService agents: IAgentLifecycleService, - @ISessionMetadata metadata: ISessionMetadata, - @IAgentToolRegistryService toolRegistry: IAgentToolRegistryService, - @IAgentBackgroundService background: IAgentBackgroundService, - @IAgentProfileService profile: IAgentProfileService, - @IKaos kaos: IKaos, - @ISessionProcessRunner runner: ISessionProcessRunner, - @ILogService log?: ILogService, - ) { - super(); - this.host = subagentHost ?? new DefaultSessionSubagentHost(agents, 'main', metadata); - - this._register( - toolRegistry.register( - new AgentTool(this, background, DEFAULT_AGENT_SUBAGENT_PROFILES, { - log, - gitContext: { cwd: kaos.cwd, runner }, - canRunInBackground: () => { - return profile.isToolActive('TaskList') && - profile.isToolActive('TaskOutput') && - profile.isToolActive('TaskStop'); - }, - }), - ), - ); - } - - getSwarmItem(agentId: string): string | undefined { - return this.host?.getSwarmItem(agentId); - } - - startBtw(): Promise { - return this.host.startBtw(); - } - - async generateAgentsMd(): Promise { - const handle = await this.host.spawn({ - profileName: 'coder', - parentToolCallId: 'generate-agents-md', - prompt: 'Initialize AGENTS.md for this workspace.', - description: 'Initialize AGENTS.md', - runInBackground: false, - signal: new AbortController().signal, - }); - await handle.completion; - } - - spawn(options: SpawnSubagentOptions): Promise { - return this.host.spawn(options); - } - - resume(agentId: string, options: RunSubagentOptions): Promise { - return this.host.resume(agentId, options); - } - - getProfileName(agentId: string): Promise { - return this.host.getProfileName(agentId); - } - - markActiveChildDetached(agentId: string): void { - this.host.markActiveChildDetached(agentId); - } - - cancelAll(reason?: unknown): void { - this.host.cancelAll(reason); - } - - suspended(event: SubagentSuspendedEvent): void { - this.host.suspended(event); - } - - runQueued( - tasks: readonly QueuedSubagentTask[], - ): Promise>> { - const subagentHost = this.host; - if (subagentHost === undefined) { - throw new Error('Subagent host is not configured.'); - } - return subagentHost.runQueued(tasks); - } -} - -registerScopedService( - LifecycleScope.Session, - ISessionSubagentHost, - SessionSubagentHostService, - InstantiationType.Delayed, - 'subagentHost', -); diff --git a/packages/agent-core-v2/test/subagentHost/agent-tool.test.ts b/packages/agent-core-v2/test/agentTool/agentTool.test.ts similarity index 85% rename from packages/agent-core-v2/test/subagentHost/agent-tool.test.ts rename to packages/agent-core-v2/test/agentTool/agentTool.test.ts index 89fa11922..8b5904d4f 100644 --- a/packages/agent-core-v2/test/subagentHost/agent-tool.test.ts +++ b/packages/agent-core-v2/test/agentTool/agentTool.test.ts @@ -8,20 +8,19 @@ import { AgentTool, AgentToolInputSchema, DEFAULT_SUBAGENT_TIMEOUT_MS, - type ISessionSubagentHost, - type SessionSubagentHost, -} from '#/session/subagentHost'; -import type { AgentToolSubagentMap } from '#/session/subagentHost/agentTool'; + type AgentToolRunOverride, +} from '#/agent/agentTool'; import { ToolAccesses } from '#/agent/tool'; import { IAgentToolRegistryService } from '#/agent/toolRegistry'; import { executeTool } from '../tools/fixtures/execute-tool'; import { + agentToolServices, createTestAgent, - subagentHostServices, type TestAgentContext, } from '../harness'; const signal = new AbortController().signal; +const PARENT_AGENT_ID = 'main'; interface CapturedLogEntry { readonly level: 'error' | 'warn' | 'info' | 'debug'; @@ -48,6 +47,35 @@ function createLogCapture(): { return { logger, entries }; } +function fakeProfile(isToolActive: (name: string) => boolean = () => true) { + return { isToolActive: vi.fn(isToolActive) } as unknown as IAgentProfileService; +} + +function fakeLifecycle() { + return {} as never; +} + +function fakeKaos() { + return { cwd: '/repo' } as never; +} + +function fakeProcessRunner() { + return {} as never; +} + +function createRunOverride( + overrides: Partial = {}, +): AgentToolRunOverride { + const run: AgentToolRunOverride = { + spawn: vi.fn(), + resume: vi.fn(), + retry: vi.fn(), + getProfileName: vi.fn().mockResolvedValue(undefined), + markDetached: vi.fn(), + }; + return Object.assign(run, overrides); +} + describe('AgentTool direct contract', () => { let contexts: TestAgentContext[]; @@ -63,21 +91,19 @@ describe('AgentTool direct contract', () => { }); function makeTool({ - host = createSubagentHost(), + run = createRunOverride(), maxRunningTasks, - subagents, - canRunInBackground, + isToolActive, log, }: { - readonly host?: SessionSubagentHost; + readonly run?: AgentToolRunOverride; readonly maxRunningTasks?: number; - readonly subagents?: AgentToolSubagentMap; - readonly canRunInBackground?: () => boolean; + readonly isToolActive?: (name: string) => boolean; readonly log?: ILogger; } = {}): { readonly ctx: TestAgentContext; readonly background: IAgentBackgroundService; - readonly host: SessionSubagentHost; + readonly run: AgentToolRunOverride; readonly tool: AgentTool; } { const ctx = @@ -91,10 +117,16 @@ describe('AgentTool direct contract', () => { return { ctx, background, - host, - tool: new AgentTool(host as unknown as ISessionSubagentHost, background, subagents, { - canRunInBackground, + run, + tool: new AgentTool({ + lifecycle: fakeLifecycle(), + parentAgentId: PARENT_AGENT_ID, + background, + profile: fakeProfile(isToolActive), + cwd: '/repo', + processRunner: fakeProcessRunner(), log, + runOverride: run, }), }; } @@ -149,26 +181,13 @@ describe('AgentTool direct contract', () => { expect(tool.description).not.toContain('no time limit'); }); - it('renders configured subagent types and their tool sets', () => { - const { tool } = makeTool({ - subagents: { - explore: { - description: 'Read-only exploration.', - whenToUse: 'Use for searches.', - tools: ['Read', 'Grep', 'Glob'], - }, - coder: { - description: 'General coding.', - tools: ['Read', 'Write', 'Edit', 'Bash'], - }, - }, - }); + it('renders the default agent types and their tool sets', () => { + const { tool } = makeTool(); expect(tool.description).toContain('Available agent types'); - expect(tool.description).toContain('- explore: Read-only exploration. Use for searches.'); - expect(tool.description).toContain('Tools: Read, Grep, Glob'); - expect(tool.description).toContain('- coder: General coding.'); - expect(tool.description).toContain('Tools: Read, Write, Edit, Bash'); + expect(tool.description).toContain('- explore:'); + expect(tool.description).toContain('- coder:'); + expect(tool.description).toContain('Tools:'); }); it('mentions resume preference and result visibility in the description', () => { @@ -215,10 +234,10 @@ describe('AgentTool direct contract', () => { }); it('uses the resumed agent profile in the activity description', async () => { - const host = createSubagentHost({ + const run = createRunOverride({ getProfileName: vi.fn().mockResolvedValue('explore'), }); - const { tool } = makeTool({ host }); + const { tool } = makeTool({ run }); const execution = await tool.resolveExecution({ prompt: 'Continue', description: 'Continue work', @@ -227,11 +246,13 @@ describe('AgentTool direct contract', () => { if (execution.isError === true) throw new Error('expected runnable execution'); expect(execution.description).toBe('Launching explore agent: Continue work'); - expect(host.getProfileName).toHaveBeenCalledWith('agent-existing'); + expect(run.getProfileName).toHaveBeenCalledWith( + expect.objectContaining({ agentId: 'agent-existing' }), + ); }); it('falls back to coder for an empty subagent type', async () => { - const host = createSubagentHost({ + const run = createRunOverride({ spawn: vi.fn().mockResolvedValue({ agentId: 'agent-child', profileName: 'coder', @@ -239,7 +260,7 @@ describe('AgentTool direct contract', () => { completion: Promise.resolve({ result: 'child result' }), }), }); - const { tool } = makeTool({ host }); + const { tool } = makeTool({ run }); await executeTool( tool, @@ -250,7 +271,7 @@ describe('AgentTool direct contract', () => { }), ); - expect(host.spawn).toHaveBeenCalledWith( + expect(run.spawn).toHaveBeenCalledWith( expect.objectContaining({ parentToolCallId: 'call_agent', profileName: 'coder', @@ -259,7 +280,7 @@ describe('AgentTool direct contract', () => { }); it('resumes a foreground subagent when resume is provided', async () => { - const host = createSubagentHost({ + const run = createRunOverride({ spawn: vi.fn(), resume: vi.fn().mockResolvedValue({ agentId: 'agent-existing', @@ -268,7 +289,7 @@ describe('AgentTool direct contract', () => { completion: Promise.resolve({ result: 'resumed result' }), }), }); - const { tool } = makeTool({ host }); + const { tool } = makeTool({ run }); const result = await executeTool( tool, @@ -279,10 +300,10 @@ describe('AgentTool direct contract', () => { }), ); - expect(host.spawn).not.toHaveBeenCalled(); - expect(host.resume).toHaveBeenCalledWith( - 'agent-existing', + expect(run.spawn).not.toHaveBeenCalled(); + expect(run.resume).toHaveBeenCalledWith( expect.objectContaining({ + agentId: 'agent-existing', parentToolCallId: 'call_agent', prompt: 'Continue', description: 'Continue work', @@ -296,7 +317,7 @@ describe('AgentTool direct contract', () => { }); it('does not consume a background task slot when validation fails before launch', async () => { - const host = createSubagentHost({ + const run = createRunOverride({ spawn: vi.fn().mockResolvedValue({ agentId: 'agent-child', profileName: 'coder', @@ -305,7 +326,7 @@ describe('AgentTool direct contract', () => { }), resume: vi.fn(), }); - const { tool } = makeTool({ host, maxRunningTasks: 1 }); + const { tool } = makeTool({ run, maxRunningTasks: 1 }); const invalid = await executeTool( tool, @@ -331,8 +352,8 @@ describe('AgentTool direct contract', () => { output: 'Cannot set subagent_type when resuming an existing agent. Resume by agent id only.', }); expect(valid.output).toContain('status: running'); - expect(host.resume).not.toHaveBeenCalled(); - expect(host.spawn).toHaveBeenCalledTimes(1); + expect(run.resume).not.toHaveBeenCalled(); + expect(run.spawn).toHaveBeenCalledTimes(1); }); it('can detach a foreground subagent through the background manager', async () => { @@ -340,8 +361,8 @@ describe('AgentTool direct contract', () => { const completion = new Promise<{ result: string }>((resolve) => { resolveCompletion = resolve; }); - const host = createSubagentHost({ - markActiveChildDetached: vi.fn(), + const run = createRunOverride({ + markDetached: vi.fn(), spawn: vi.fn().mockResolvedValue({ agentId: 'agent-child', profileName: 'coder', @@ -349,7 +370,7 @@ describe('AgentTool direct contract', () => { completion, }), }); - const { background, tool } = makeTool({ host }); + const { background, tool } = makeTool({ run }); const running = executeTool( tool, @@ -372,7 +393,9 @@ describe('AgentTool direct contract', () => { background.detach(task.taskId); const result = await running; - expect(host.markActiveChildDetached).toHaveBeenCalledWith('agent-child'); + expect(run.markDetached).toHaveBeenCalledWith( + expect.objectContaining({ agentId: 'agent-child' }), + ); expect(result.output).toContain(`task_id: ${task.taskId}`); expect(result.output).toContain('agent_id: agent-child'); expect(result.output).toContain('automatic_notification: true'); @@ -389,7 +412,7 @@ describe('AgentTool direct contract', () => { const completion = new Promise<{ result: string }>((resolve) => { resolveCompletion = resolve; }); - const host = createSubagentHost({ + const run = createRunOverride({ spawn: vi.fn().mockResolvedValue({ agentId: 'agent-child', profileName: 'coder', @@ -398,8 +421,8 @@ describe('AgentTool direct contract', () => { }), }); const { background, tool } = makeTool({ - host, - canRunInBackground: () => false, + run, + isToolActive: () => false, }); const running = executeTool( @@ -430,7 +453,7 @@ describe('AgentTool direct contract', () => { }); it('guides the AI with a non-blocking query hint and a resume hint on background launch', async () => { - const host = createSubagentHost({ + const run = createRunOverride({ spawn: vi.fn().mockResolvedValue({ agentId: 'agent-child', profileName: 'coder', @@ -438,7 +461,7 @@ describe('AgentTool direct contract', () => { completion: new Promise(() => {}), }), }); - const { tool } = makeTool({ host }); + const { tool } = makeTool({ run }); const result = await executeTool( tool, @@ -461,7 +484,7 @@ describe('AgentTool direct contract', () => { }); it('returns an error when background registration hits the task limit', async () => { - const host = createSubagentHost({ + const run = createRunOverride({ spawn: vi .fn() .mockResolvedValueOnce({ @@ -477,7 +500,7 @@ describe('AgentTool direct contract', () => { completion: new Promise(() => {}), }), }); - const { tool } = makeTool({ host, maxRunningTasks: 1 }); + const { tool } = makeTool({ run, maxRunningTasks: 1 }); const existing = await executeTool( tool, @@ -501,11 +524,11 @@ describe('AgentTool direct contract', () => { isError: true, output: 'Too many background tasks are already running.', }); - expect(host.spawn).toHaveBeenCalledTimes(2); + expect(run.spawn).toHaveBeenCalledTimes(2); }); it('rejects one of two concurrent background subagents when the task limit is reached', async () => { - const host = createSubagentHost({ + const run = createRunOverride({ spawn: vi .fn() .mockResolvedValueOnce({ @@ -521,7 +544,7 @@ describe('AgentTool direct contract', () => { completion: Promise.resolve({ result: 'second result' }), }), }); - const { tool } = makeTool({ host, maxRunningTasks: 1 }); + const { tool } = makeTool({ run, maxRunningTasks: 1 }); const first = executeTool( tool, @@ -542,7 +565,7 @@ describe('AgentTool direct contract', () => { const results = await Promise.all([first, second]); - expect(host.spawn).toHaveBeenCalledTimes(2); + expect(run.spawn).toHaveBeenCalledTimes(2); expect(results).toContainEqual( expect.objectContaining({ output: expect.stringContaining('status: running') }), ); @@ -557,10 +580,10 @@ describe('AgentTool direct contract', () => { it('returns tool errors when spawning fails', async () => { const error = new Error('missing subagent'); const { logger, entries } = createLogCapture(); - const host = createSubagentHost({ + const run = createRunOverride({ spawn: vi.fn().mockRejectedValue(error), }); - const { tool } = makeTool({ host, log: logger }); + const { tool } = makeTool({ run, log: logger }); const result = await executeTool( tool, @@ -589,7 +612,7 @@ describe('AgentTool direct contract', () => { it('logs background registration failures', async () => { const error = new Error('background unavailable'); const { logger, entries } = createLogCapture(); - const host = createSubagentHost({ + const run = createRunOverride({ spawn: vi.fn().mockResolvedValue({ agentId: 'agent-child', profileName: 'coder', @@ -597,7 +620,7 @@ describe('AgentTool direct contract', () => { completion: new Promise(() => {}), }), }); - const { background, tool } = makeTool({ host, log: logger }); + const { background, tool } = makeTool({ run, log: logger }); vi.spyOn(background, 'registerTask').mockImplementation(() => { throw error; }); @@ -631,7 +654,7 @@ describe('AgentTool direct contract', () => { it('reports a deliberate user interruption when a foreground subagent is cancelled by the user', async () => { const controller = new AbortController(); - const host = createSubagentHost({ + const run = createRunOverride({ spawn: vi.fn((options) => Promise.resolve({ agentId: 'agent-child', @@ -647,7 +670,7 @@ describe('AgentTool direct contract', () => { }), ), }); - const { tool } = makeTool({ host }); + const { tool } = makeTool({ run }); const resultPromise = executeTool(tool, { turnId: '0', @@ -669,7 +692,7 @@ describe('AgentTool direct contract', () => { it('returns the spawned agent id when a foreground subagent times out', async () => { vi.useFakeTimers({ toFake: ['setTimeout', 'clearTimeout'] }); - const host = createSubagentHost({ + const run = createRunOverride({ spawn: vi.fn().mockResolvedValue({ agentId: 'agent-timeout', profileName: 'coder', @@ -677,7 +700,7 @@ describe('AgentTool direct contract', () => { completion: new Promise<{ result: string }>(() => {}), }), }); - const { tool } = makeTool({ host }); + const { tool } = makeTool({ run }); const resultPromise = executeTool( tool, @@ -700,13 +723,13 @@ describe('AgentTool direct contract', () => { }); describe('Agent tool service runtime', () => { - describe('with a default subagent host', () => { + describe('with a default run override', () => { let ctx: TestAgentContext; let profile: IAgentProfileService; beforeEach(() => { - const subagentHost = createSubagentHost(); - ctx = createTestAgent(subagentHostServices(subagentHost)); + const run = createRunOverride(); + ctx = createTestAgent(agentToolServices(run)); profile = ctx.get(IAgentProfileService); profile.update({ activeToolNames: ['Agent'] }); }); @@ -719,7 +742,7 @@ describe('Agent tool service runtime', () => { } }); - it('exposes Agent when a subagent host is available', () => { + it('exposes Agent when a run override is available', () => { expect(ctx.toolsData()).toContainEqual( expect.objectContaining({ name: 'Agent', @@ -737,14 +760,14 @@ describe('Agent tool service runtime', () => { }); }); - describe('with a resolving subagent host', () => { + describe('with a resolving run override', () => { let ctx: TestAgentContext; - let subagentHost: SessionSubagentHost; + let run: AgentToolRunOverride; let profile: IAgentProfileService; let tools: IAgentToolRegistryService; beforeEach(() => { - subagentHost = createSubagentHost({ + run = createRunOverride({ spawn: vi.fn().mockResolvedValue({ agentId: 'agent-child', profileName: 'coder', @@ -752,7 +775,7 @@ describe('Agent tool service runtime', () => { completion: Promise.resolve({ result: 'child summary' }), }), }); - ctx = createTestAgent(subagentHostServices(subagentHost)); + ctx = createTestAgent(agentToolServices(run)); profile = ctx.get(IAgentProfileService); tools = ctx.get(IAgentToolRegistryService); profile.update({ activeToolNames: ['Agent'] }); @@ -790,7 +813,7 @@ describe('Agent tool service runtime', () => { 'child summary', ].join('\n'), }); - expect(subagentHost.spawn).toHaveBeenCalledWith( + expect(run.spawn).toHaveBeenCalledWith( expect.objectContaining({ profileName: 'coder', parentToolCallId: 'call_agent', @@ -843,7 +866,7 @@ describe('Agent tool service runtime', () => { expect(result.output).toContain( 'resume_hint: To continue or recover this same subagent later, call Agent(resume="agent-child", prompt="...").', ); - expect(subagentHost.spawn).toHaveBeenLastCalledWith( + expect(run.spawn).toHaveBeenLastCalledWith( expect.objectContaining({ profileName: 'coder', parentToolCallId: 'call_agent', @@ -855,15 +878,15 @@ describe('Agent tool service runtime', () => { }); }); - describe('with a non-resuming subagent host', () => { + describe('with a non-resuming run override', () => { let ctx: TestAgentContext; - let subagentHost: SessionSubagentHost; + let run: AgentToolRunOverride; let profile: IAgentProfileService; let tools: IAgentToolRegistryService; beforeEach(() => { - subagentHost = createSubagentHost(); - ctx = createTestAgent(subagentHostServices(subagentHost)); + run = createRunOverride(); + ctx = createTestAgent(agentToolServices(run)); profile = ctx.get(IAgentProfileService); tools = ctx.get(IAgentToolRegistryService); profile.update({ activeToolNames: ['Agent'] }); @@ -896,25 +919,7 @@ describe('Agent tool service runtime', () => { isError: true, output: 'Cannot set subagent_type when resuming an existing agent. Resume by agent id only.', }); - expect(subagentHost.resume).not.toHaveBeenCalled(); + expect(run.resume).not.toHaveBeenCalled(); }); }); }); - -function createSubagentHost( - overrides: Partial = {}, -): SessionSubagentHost { - const host: SessionSubagentHost = { - getSwarmItem: vi.fn(), - startBtw: vi.fn().mockResolvedValue('btw-url'), - spawn: vi.fn(), - resume: vi.fn(), - retry: vi.fn(), - getProfileName: vi.fn().mockResolvedValue(undefined), - markActiveChildDetached: vi.fn(), - runQueued: vi.fn().mockResolvedValue([]), - cancelAll: vi.fn(), - suspended: vi.fn(), - }; - return Object.assign(host, overrides); -} diff --git a/packages/agent-core-v2/test/agentTool/agentToolService.test.ts b/packages/agent-core-v2/test/agentTool/agentToolService.test.ts new file mode 100644 index 000000000..78425c8a8 --- /dev/null +++ b/packages/agent-core-v2/test/agentTool/agentToolService.test.ts @@ -0,0 +1,48 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; + +import { SyncDescriptor } from '#/_base/di/descriptors'; +import { DisposableStore } from '#/_base/di/lifecycle'; +import { TestInstantiationService } from '#/_base/di/test'; +import { IAgentBackgroundService } from '#/agent/background'; +import { IAgentLifecycleService } from '#/session/agent-lifecycle'; +import { ILogService } from '#/app/log'; +import { IAgentProfileService } from '#/agent/profile'; +import { IAgentScopeContext } from '#/agent/scopeContext'; +import { AgentToolService, IAgentToolService } from '#/agent/agentTool'; +import { IAgentToolRegistryService } from '#/agent/toolRegistry'; +import { IExecContext, createExecContext } from '#/session/execContext'; +import { ISessionMetadata } from '#/session/session-metadata'; +import { ISessionProcessRunner } from '#/session/process'; + +describe('AgentToolService DI wiring', () => { + let disposables: DisposableStore; + let ix: TestInstantiationService; + + beforeEach(() => { + disposables = new DisposableStore(); + ix = disposables.add(new TestInstantiationService()); + }); + + afterEach(() => disposables.dispose()); + + it('registers the Agent tool bound to the current agent', () => { + const register = vi.fn(() => ({ dispose: () => {} })); + ix.stub(IAgentScopeContext, { _serviceBrand: undefined, agentId: 'main' }); + ix.stub(IAgentLifecycleService, {}); + ix.stub(ISessionMetadata, { + read: vi.fn().mockResolvedValue({ agents: {} }), + }); + ix.stub(IAgentToolRegistryService, { register }); + ix.stub(IAgentBackgroundService, {}); + ix.stub(IAgentProfileService, { isToolActive: vi.fn().mockReturnValue(false) }); + ix.stub(IExecContext, createExecContext('/repo')); + ix.stub(ISessionProcessRunner, { exec: vi.fn() }); + ix.stub(ILogService, { warn: vi.fn(), info: vi.fn(), debug: vi.fn(), error: vi.fn() }); + ix.set(IAgentToolService, new SyncDescriptor(AgentToolService, [undefined])); + + const service = ix.get(IAgentToolService); + + expect(service).toBeInstanceOf(AgentToolService); + expect(register).toHaveBeenCalledTimes(1); + }); +}); diff --git a/packages/agent-core-v2/test/subagentHost/defaultSessionSubagentHost.test.ts b/packages/agent-core-v2/test/agentTool/runChildAgent.test.ts similarity index 72% rename from packages/agent-core-v2/test/subagentHost/defaultSessionSubagentHost.test.ts rename to packages/agent-core-v2/test/agentTool/runChildAgent.test.ts index 73e905103..661efbd7c 100644 --- a/packages/agent-core-v2/test/subagentHost/defaultSessionSubagentHost.test.ts +++ b/packages/agent-core-v2/test/agentTool/runChildAgent.test.ts @@ -7,18 +7,23 @@ import type { IScopeHandle } from '#/_base/di/scope'; import { IAgentContextMemoryService } from '#/agent/contextMemory'; import { IAgentEventSinkService } from '#/agent/eventSink'; import { IAgentExternalHooksService } from '#/agent/externalHooks'; -import { - DenyAllPermissionPolicyService, - IAgentPermissionPolicyService, -} from '#/agent/permissionPolicy'; import { IAgentProfileService } from '#/agent/profile'; import { IAgentPromptService } from '#/agent/prompt'; import { IAgentSystemReminderService } from '#/agent/systemReminder'; +import { IAgentPermissionPolicyService } from '#/agent/permissionPolicy'; import { ITelemetryService } from '#/app/telemetry'; import { IAgentUsageService } from '#/agent/usage'; -import { DefaultSessionSubagentHost } from '#/session/subagentHost/defaultSessionSubagentHost'; +import { + cancelAllChildren, + markChildDetached, + resumeChildAgent, + retryChildAgent, + spawnChildAgent, +} from '#/agent/agentTool'; +import { runChildAgentQueued } from '#/agent/swarm'; const CHILD_SUMMARY = 'child summary '.repeat(20); +const PARENT_AGENT_ID = 'main'; interface FakeScopeOptions { readonly result?: Promise<{ reason: string; error?: unknown }>; @@ -145,7 +150,7 @@ function fakeScope(id: string, options: FakeScopeOptions = {}): IScopeHandle { function makeAgents(parent: IScopeHandle, children: Record | (() => IScopeHandle)) { return { getHandle: vi.fn((id: string) => { - if (id === 'main') return parent; + if (id === PARENT_AGENT_ID) return parent; if (typeof children === 'function') return undefined; return children[id]; }), @@ -157,15 +162,16 @@ function makeAgents(parent: IScopeHandle, children: Record }; } -describe('DefaultSessionSubagentHost', () => { +describe('runChildAgent', () => { it('aborts a running subagent when the caller signal aborts', async () => { - const parent = fakeScope('main'); + const parent = fakeScope(PARENT_AGENT_ID); const child = fakeScope('child', { result: new Promise(() => {}) }); const agents = makeAgents(parent, { child }); - const host = new DefaultSessionSubagentHost(agents as unknown as IAgentLifecycleService, 'main'); const controller = new AbortController(); - const handle = await host.spawn({ + const handle = await spawnChildAgent({ + lifecycle: agents as unknown as IAgentLifecycleService, + parentAgentId: PARENT_AGENT_ID, profileName: 'coder', parentToolCallId: 'call_agent', prompt: 'Run long task', @@ -180,12 +186,13 @@ describe('DefaultSessionSubagentHost', () => { it('emits subagent spawned and started events', async () => { const events: unknown[] = []; - const parent = fakeScope('main', { events }); + const parent = fakeScope(PARENT_AGENT_ID, { events }); const child = fakeScope('child'); const agents = makeAgents(parent, { child }); - const host = new DefaultSessionSubagentHost(agents as unknown as IAgentLifecycleService, 'main'); - const handle = await host.spawn({ + const handle = await spawnChildAgent({ + lifecycle: agents as unknown as IAgentLifecycleService, + parentAgentId: PARENT_AGENT_ID, profileName: 'explore', parentToolCallId: 'call_agent', prompt: 'Explore the repo', @@ -208,12 +215,13 @@ describe('DefaultSessionSubagentHost', () => { }); it('asks for a continuation when the first summary is too short', async () => { - const parent = fakeScope('main', { initialText: 'short' }); + const parent = fakeScope(PARENT_AGENT_ID, { initialText: 'short' }); const child = fakeScope('child', { initialText: 'short' }); const agents = makeAgents(parent, { child }); - const host = new DefaultSessionSubagentHost(agents as unknown as IAgentLifecycleService, 'main'); - const handle = await host.spawn({ + const handle = await spawnChildAgent({ + lifecycle: agents as unknown as IAgentLifecycleService, + parentAgentId: PARENT_AGENT_ID, profileName: 'coder', parentToolCallId: 'call_agent', prompt: 'Implement', @@ -227,13 +235,14 @@ describe('DefaultSessionSubagentHost', () => { expect(completion.result).toBe('x'.repeat(220)); }); - it('persists and exposes swarmItem for spawned subagents', async () => { - const parent = fakeScope('main'); + it('persists the swarmItem when spawning a subagent', async () => { + const parent = fakeScope(PARENT_AGENT_ID); const child = fakeScope('child'); const agents = makeAgents(parent, { child }); - const host = new DefaultSessionSubagentHost(agents as unknown as IAgentLifecycleService, 'main'); - await host.spawn({ + await spawnChildAgent({ + lifecycle: agents as unknown as IAgentLifecycleService, + parentAgentId: PARENT_AGENT_ID, profileName: 'coder', parentToolCallId: 'call_agent', prompt: 'Swarm task', @@ -243,9 +252,8 @@ describe('DefaultSessionSubagentHost', () => { signal: new AbortController().signal, }); - expect(host.getSwarmItem('child')).toBe('item-1'); expect(agents.create).toHaveBeenCalledWith({ - parentAgentId: 'main', + parentAgentId: PARENT_AGENT_ID, cwd: '/repo', type: 'sub', swarmItem: 'item-1', @@ -253,9 +261,10 @@ describe('DefaultSessionSubagentHost', () => { }); it('rejects resuming a subagent owned by another parent', async () => { + const parent = fakeScope(PARENT_AGENT_ID); const child = fakeScope('child'); const agents = { - getHandle: vi.fn((id: string) => (id === 'child' ? child : undefined)), + getHandle: vi.fn((id: string) => (id === 'child' ? child : id === PARENT_AGENT_ID ? parent : undefined)), createMain: vi.fn(), create: vi.fn(), }; @@ -266,14 +275,13 @@ describe('DefaultSessionSubagentHost', () => { }, }), }; - const host = new DefaultSessionSubagentHost( - agents as unknown as IAgentLifecycleService, - 'main', - metadata as never, - ); await expect( - host.resume('child', { + resumeChildAgent({ + lifecycle: agents as unknown as IAgentLifecycleService, + parentAgentId: PARENT_AGENT_ID, + metadata: metadata as never, + agentId: 'child', parentToolCallId: 'call_agent', prompt: 'Continue', description: 'Continue', @@ -285,7 +293,7 @@ describe('DefaultSessionSubagentHost', () => { it('emits subagent.failed when the child turn fails', async () => { const events: unknown[] = []; - const parent = fakeScope('main', { + const parent = fakeScope(PARENT_AGENT_ID, { result: Promise.resolve({ reason: 'failed', error: new Error('boom') }), events, }); @@ -293,9 +301,10 @@ describe('DefaultSessionSubagentHost', () => { result: Promise.resolve({ reason: 'failed', error: new Error('boom') }), }); const agents = makeAgents(parent, { child }); - const host = new DefaultSessionSubagentHost(agents as unknown as IAgentLifecycleService, 'main'); - const handle = await host.spawn({ + const handle = await spawnChildAgent({ + lifecycle: agents as unknown as IAgentLifecycleService, + parentAgentId: PARENT_AGENT_ID, profileName: 'coder', parentToolCallId: 'call_agent', prompt: 'Do work', @@ -314,13 +323,14 @@ describe('DefaultSessionSubagentHost', () => { it('treats timeout aborts as subagent failures, not user cancellations', async () => { const events: unknown[] = []; - const parent = fakeScope('main', { result: new Promise(() => {}), events }); + const parent = fakeScope(PARENT_AGENT_ID, { result: new Promise(() => {}), events }); const child = fakeScope('child', { result: new Promise(() => {}) }); const agents = makeAgents(parent, { child }); - const host = new DefaultSessionSubagentHost(agents as unknown as IAgentLifecycleService, 'main'); const controller = new AbortController(); - const handle = await host.spawn({ + const handle = await spawnChildAgent({ + lifecycle: agents as unknown as IAgentLifecycleService, + parentAgentId: PARENT_AGENT_ID, profileName: 'coder', parentToolCallId: 'call_agent', prompt: 'Run long task', @@ -340,16 +350,18 @@ describe('DefaultSessionSubagentHost', () => { it('resumes an existing child agent and returns completion summary', async () => { const events: unknown[] = []; - const parent = fakeScope('main', { events }); + const parent = fakeScope(PARENT_AGENT_ID, { events }); const child = fakeScope('child'); const agents = { - getHandle: vi.fn((id: string) => (id === 'child' ? child : id === 'main' ? parent : undefined)), + getHandle: vi.fn((id: string) => (id === 'child' ? child : id === PARENT_AGENT_ID ? parent : undefined)), createMain: vi.fn(), create: vi.fn(), }; - const host = new DefaultSessionSubagentHost(agents as unknown as IAgentLifecycleService, 'main'); - const handle = await host.resume('child', { + const handle = await resumeChildAgent({ + lifecycle: agents as unknown as IAgentLifecycleService, + parentAgentId: PARENT_AGENT_ID, + agentId: 'child', parentToolCallId: 'call_agent', prompt: 'Continue', description: 'Continue', @@ -369,65 +381,26 @@ describe('DefaultSessionSubagentHost', () => { ]); }); - it('builds a side-question child that projects history, denies tools, and adds the reminder', async () => { - const parentMessages = [ - { role: 'user', content: [{ type: 'text', text: 'earlier question' }], toolCalls: [] }, - { role: 'assistant', content: [{ type: 'text', text: 'earlier answer' }], toolCalls: [] }, - ]; - const parent = fakeScope('main', { parentMessages }); - const child = fakeScope('btw-child'); - const agents = makeAgents(parent, { 'btw-child': child }); - const host = new DefaultSessionSubagentHost(agents as unknown as IAgentLifecycleService, 'main'); - - await expect(host.startBtw()).resolves.toBe('btw-child'); - expect(agents.create).toHaveBeenCalledWith({ parentAgentId: 'main', type: 'sub' }); - - // Loop tools copied from the parent for prompt-cache parity. - const childProfile = child.accessor.get(IAgentProfileService); - expect(childProfile.update).toHaveBeenCalledWith( - expect.objectContaining({ - modelAlias: 'parent-model', - thinkingLevel: 'medium', - systemPrompt: 'parent prompt', - activeToolNames: ['Read', 'Write'], - }), - ); - - // Parent history projected into the child. - const childContext = child.accessor.get(IAgentContextMemoryService); - expect(childContext.splice).toHaveBeenCalledWith(0, 0, parentMessages); - - // Side-question reminder appended. - const childReminder = child.accessor.get(IAgentSystemReminderService); - expect(childReminder.appendSystemReminder).toHaveBeenCalledWith( - expect.stringContaining('side-channel conversation'), - { kind: 'system_trigger', name: 'btw' }, - ); - - // Every tool call denied. - const childPermission = child.accessor.get(IAgentPermissionPolicyService); - expect(childPermission.registerPolicy).toHaveBeenCalledTimes(1); - const policy = mockOf(childPermission.registerPolicy).mock.calls[0]?.[0]; - expect(policy).toBeInstanceOf(DenyAllPermissionPolicyService); - }); - it('runs queued subagent tasks to completion', async () => { - const parent = fakeScope('main'); + const parent = fakeScope(PARENT_AGENT_ID); const child = fakeScope('child'); const agents = makeAgents(parent, { child }); - const host = new DefaultSessionSubagentHost(agents as unknown as IAgentLifecycleService, 'main'); - const results = await host.runQueued([ - { - kind: 'spawn', - data: {}, - profileName: 'coder', - parentToolCallId: 'call_agent', - prompt: 'Queued task', - description: 'Queued task', - runInBackground: false, - }, - ]); + const results = await runChildAgentQueued({ + lifecycle: agents as unknown as IAgentLifecycleService, + parentAgentId: PARENT_AGENT_ID, + tasks: [ + { + kind: 'spawn', + data: {}, + profileName: 'coder', + parentToolCallId: 'call_agent', + prompt: 'Queued task', + description: 'Queued task', + runInBackground: false, + }, + ], + }); expect(results).toEqual([ expect.objectContaining({ status: 'completed', agentId: 'child', result: CHILD_SUMMARY }), @@ -438,14 +411,13 @@ describe('DefaultSessionSubagentHost', () => { const previous = process.env['KIMI_CODE_AGENT_SWARM_MAX_CONCURRENCY']; process.env['KIMI_CODE_AGENT_SWARM_MAX_CONCURRENCY'] = '2'; try { - const parent = fakeScope('main'); + const parent = fakeScope(PARENT_AGENT_ID); const pending: Array>> = []; const agents = makeAgents(parent, () => { const d = deferred<{ reason: string }>(); pending.push(d); return fakeScope(`child-${pending.length}`, { result: d.promise }); }); - const host = new DefaultSessionSubagentHost(agents as unknown as IAgentLifecycleService, 'main'); const tasks = Array.from({ length: 4 }, (_, index) => ({ kind: 'spawn' as const, @@ -456,7 +428,11 @@ describe('DefaultSessionSubagentHost', () => { description: `task ${index}`, runInBackground: false, })); - const run = host.runQueued(tasks); + const run = runChildAgentQueued({ + lifecycle: agents as unknown as IAgentLifecycleService, + parentAgentId: PARENT_AGENT_ID, + tasks, + }); void run.catch(() => {}); const flush = (): Promise => new Promise((r) => setTimeout(r, 0)); @@ -484,13 +460,14 @@ describe('DefaultSessionSubagentHost', () => { } }); - it('marks an active child as detached', async () => { - const parent = fakeScope('main', { result: new Promise(() => {}) }); + it('marks an active child as detached so cancelAllChildren skips it', async () => { + const parent = fakeScope(PARENT_AGENT_ID, { result: new Promise(() => {}) }); const child = fakeScope('child', { result: new Promise(() => {}) }); const agents = makeAgents(parent, { child }); - const host = new DefaultSessionSubagentHost(agents as unknown as IAgentLifecycleService, 'main'); - await host.spawn({ + const handle = await spawnChildAgent({ + lifecycle: agents as unknown as IAgentLifecycleService, + parentAgentId: PARENT_AGENT_ID, profileName: 'coder', parentToolCallId: 'call_agent', prompt: 'Run detached task', @@ -498,18 +475,27 @@ describe('DefaultSessionSubagentHost', () => { runInBackground: false, signal: new AbortController().signal, }); + void handle.completion.catch(() => {}); + await Promise.resolve(); + await Promise.resolve(); - host.markActiveChildDetached('child'); - expect((host as unknown as { activeChildren: Map }).activeChildren.get('child')?.runInBackground).toBe(true); + const prompt = child.accessor.get(IAgentPromptService); + const turn = mockOf(prompt.prompt).mock.results[0]?.value as { abortController: AbortController }; + + markChildDetached({ parentAgentId: PARENT_AGENT_ID, agentId: 'child' }); + cancelAllChildren(PARENT_AGENT_ID, 'user-stop'); + + expect(turn.abortController.signal.aborted).toBe(false); }); it('spawns a child agent and returns its completion summary', async () => { - const parent = fakeScope('main'); + const parent = fakeScope(PARENT_AGENT_ID); const child = fakeScope('child'); const agents = makeAgents(parent, { child }); - const host = new DefaultSessionSubagentHost(agents as unknown as IAgentLifecycleService, 'main'); - const handle = await host.spawn({ + const handle = await spawnChildAgent({ + lifecycle: agents as unknown as IAgentLifecycleService, + parentAgentId: PARENT_AGENT_ID, profileName: 'explore', parentToolCallId: 'call_agent', prompt: 'Explore the repo', @@ -523,7 +509,7 @@ describe('DefaultSessionSubagentHost', () => { usage: { input: 1, output: 2, cache_read: 0, cache_write: 0 }, }); expect(agents.create).toHaveBeenCalledWith({ - parentAgentId: 'main', + parentAgentId: PARENT_AGENT_ID, cwd: '/repo', type: 'sub', swarmItem: undefined, @@ -531,12 +517,13 @@ describe('DefaultSessionSubagentHost', () => { }); it('fires SubagentStart and SubagentStop external hooks around the turn', async () => { - const parent = fakeScope('main'); + const parent = fakeScope(PARENT_AGENT_ID); const child = fakeScope('child'); const agents = makeAgents(parent, { child }); - const host = new DefaultSessionSubagentHost(agents as unknown as IAgentLifecycleService, 'main'); - const handle = await host.spawn({ + const handle = await spawnChildAgent({ + lifecycle: agents as unknown as IAgentLifecycleService, + parentAgentId: PARENT_AGENT_ID, profileName: 'explore', parentToolCallId: 'call_agent', prompt: 'Explore the repo', @@ -558,12 +545,13 @@ describe('DefaultSessionSubagentHost', () => { }); it('tracks subagent_created telemetry on spawn', async () => { - const parent = fakeScope('main'); + const parent = fakeScope(PARENT_AGENT_ID); const child = fakeScope('child'); const agents = makeAgents(parent, { child }); - const host = new DefaultSessionSubagentHost(agents as unknown as IAgentLifecycleService, 'main'); - const handle = await host.spawn({ + const handle = await spawnChildAgent({ + lifecycle: agents as unknown as IAgentLifecycleService, + parentAgentId: PARENT_AGENT_ID, profileName: 'coder', parentToolCallId: 'call_agent', prompt: 'Do work', @@ -582,13 +570,14 @@ describe('DefaultSessionSubagentHost', () => { it('fires onReady on the first turn activity rather than synchronously at launch', async () => { const ready = deferred(); - const parent = fakeScope('main'); + const parent = fakeScope(PARENT_AGENT_ID); const child = fakeScope('child', { ready: ready.promise }); const agents = makeAgents(parent, { child }); - const host = new DefaultSessionSubagentHost(agents as unknown as IAgentLifecycleService, 'main'); const onReady = vi.fn(); - const handle = await host.spawn({ + const handle = await spawnChildAgent({ + lifecycle: agents as unknown as IAgentLifecycleService, + parentAgentId: PARENT_AGENT_ID, profileName: 'coder', parentToolCallId: 'call_agent', prompt: 'Do work', @@ -606,16 +595,18 @@ describe('DefaultSessionSubagentHost', () => { }); it('retries the existing turn in place instead of re-prompting', async () => { - const parent = fakeScope('main'); + const parent = fakeScope(PARENT_AGENT_ID); const child = fakeScope('child'); const agents = { - getHandle: vi.fn((id: string) => (id === 'child' ? child : id === 'main' ? parent : undefined)), + getHandle: vi.fn((id: string) => (id === 'child' ? child : id === PARENT_AGENT_ID ? parent : undefined)), createMain: vi.fn(), create: vi.fn(), }; - const host = new DefaultSessionSubagentHost(agents as unknown as IAgentLifecycleService, 'main'); - const handle = await host.retry('child', { + const handle = await retryChildAgent({ + lifecycle: agents as unknown as IAgentLifecycleService, + parentAgentId: PARENT_AGENT_ID, + agentId: 'child', parentToolCallId: 'call_agent', prompt: 'ignored on retry', description: 'Retry', @@ -630,12 +621,13 @@ describe('DefaultSessionSubagentHost', () => { }); it('classifies a filtered turn as a provider safety policy block', async () => { - const parent = fakeScope('main'); + const parent = fakeScope(PARENT_AGENT_ID); const child = fakeScope('child', { result: Promise.resolve({ reason: 'filtered' }) }); const agents = makeAgents(parent, { child }); - const host = new DefaultSessionSubagentHost(agents as unknown as IAgentLifecycleService, 'main'); - const handle = await host.spawn({ + const handle = await spawnChildAgent({ + lifecycle: agents as unknown as IAgentLifecycleService, + parentAgentId: PARENT_AGENT_ID, profileName: 'coder', parentToolCallId: 'call_agent', prompt: 'Do work', @@ -648,7 +640,7 @@ describe('DefaultSessionSubagentHost', () => { }); it('rethrows a provider rate limit as an APIProviderRateLimitError', async () => { - const parent = fakeScope('main'); + const parent = fakeScope(PARENT_AGENT_ID); const child = fakeScope('child', { result: Promise.resolve({ reason: 'failed', @@ -656,9 +648,10 @@ describe('DefaultSessionSubagentHost', () => { }), }); const agents = makeAgents(parent, { child }); - const host = new DefaultSessionSubagentHost(agents as unknown as IAgentLifecycleService, 'main'); - const handle = await host.spawn({ + const handle = await spawnChildAgent({ + lifecycle: agents as unknown as IAgentLifecycleService, + parentAgentId: PARENT_AGENT_ID, profileName: 'coder', parentToolCallId: 'call_agent', prompt: 'Do work', @@ -670,42 +663,14 @@ describe('DefaultSessionSubagentHost', () => { await expect(handle.completion).rejects.toSatisfy((error) => error instanceof APIProviderRateLimitError); }); - it('emits subagent.suspended when a queued attempt is requeued', () => { - const events: unknown[] = []; - const parent = fakeScope('main', { events }); - const agents = makeAgents(parent, {}); - const host = new DefaultSessionSubagentHost(agents as unknown as IAgentLifecycleService, 'main'); - - host.suspended({ - task: { - kind: 'spawn', - data: {}, - profileName: 'coder', - parentToolCallId: 'call_agent', - prompt: 'task', - description: 'task', - runInBackground: false, - }, - agentId: 'child-1', - reason: 'Provider rate limit; subagent requeued for retry.', - }); - - expect(events).toEqual([ - expect.objectContaining({ - type: 'subagent.suspended', - subagentId: 'child-1', - reason: 'Provider rate limit; subagent requeued for retry.', - }), - ]); - }); - it('cancels every foreground active child with the provided reason', async () => { - const parent = fakeScope('main'); + const parent = fakeScope(PARENT_AGENT_ID); const child = fakeScope('child', { result: new Promise(() => {}) }); const agents = makeAgents(parent, { child }); - const host = new DefaultSessionSubagentHost(agents as unknown as IAgentLifecycleService, 'main'); - const handle = await host.spawn({ + const handle = await spawnChildAgent({ + lifecycle: agents as unknown as IAgentLifecycleService, + parentAgentId: PARENT_AGENT_ID, profileName: 'coder', parentToolCallId: 'call_agent', prompt: 'Run long task', @@ -720,19 +685,20 @@ describe('DefaultSessionSubagentHost', () => { const prompt = child.accessor.get(IAgentPromptService); const turn = mockOf(prompt.prompt).mock.results[0]?.value as { abortController: AbortController }; - host.cancelAll('user-stop'); + cancelAllChildren(PARENT_AGENT_ID, 'user-stop'); expect(turn.abortController.signal.aborted).toBe(true); expect(turn.abortController.signal.reason).toBe('user-stop'); }); it('composes the explore system prompt from the parent prompt plus the explore role', async () => { - const parent = fakeScope('main'); + const parent = fakeScope(PARENT_AGENT_ID); const child = fakeScope('child'); const agents = makeAgents(parent, { child }); - const host = new DefaultSessionSubagentHost(agents as unknown as IAgentLifecycleService, 'main'); - const handle = await host.spawn({ + const handle = await spawnChildAgent({ + lifecycle: agents as unknown as IAgentLifecycleService, + parentAgentId: PARENT_AGENT_ID, profileName: 'explore', parentToolCallId: 'call_agent', prompt: 'Explore the repo', diff --git a/packages/agent-core-v2/test/background/ids.test.ts b/packages/agent-core-v2/test/background/ids.test.ts index 7eb83fc2a..40482e2de 100644 --- a/packages/agent-core-v2/test/background/ids.test.ts +++ b/packages/agent-core-v2/test/background/ids.test.ts @@ -9,7 +9,8 @@ import { IAgentBackgroundService, ProcessBackgroundTask, } from '#/agent/background'; -import type { SessionSubagentHost, SubagentHandle } from '#/session/subagentHost'; +import type { SubagentDetachHandle } from '#/agent/background'; +import type { SubagentHandle } from '#/agent/agentTool'; import { createTestAgent, type TestAgentContext } from '../harness'; import { createBackgroundTaskPersistence } from './stubs'; @@ -36,7 +37,7 @@ function agentTask( handle, description, { markActiveChildDetached: vi.fn() } as unknown as Pick< - SessionSubagentHost, + SubagentDetachHandle, 'markActiveChildDetached' >, new AbortController(), diff --git a/packages/agent-core-v2/test/background/manager.test.ts b/packages/agent-core-v2/test/background/manager.test.ts index a862f08ac..e85ae9a92 100644 --- a/packages/agent-core-v2/test/background/manager.test.ts +++ b/packages/agent-core-v2/test/background/manager.test.ts @@ -17,7 +17,8 @@ import { ProcessBackgroundTask, type BackgroundTaskInfo, } from '#/agent/background'; -import type { SessionSubagentHost, SubagentHandle } from '#/session/subagentHost'; +import type { SubagentDetachHandle } from '#/agent/background'; +import type { SubagentHandle } from '#/agent/agentTool'; import { isUserCancellation, userCancellationReason } from '#/_base/utils/abort'; import { configServices, @@ -79,7 +80,7 @@ function agentTask( options: { readonly agentId?: string; readonly subagentType?: string; - readonly subagentHost?: Pick; + 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..1065211d9 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,8 @@ import { IAgentContextMemoryService } from '#/agent/contextMemory'; import { IAgentEventSinkService } from '#/agent/eventSink'; import type { HookEngine } from '#/agent/externalHooks/engine'; import { IAgentPromptService } from '#/agent/prompt'; -import type { SessionSubagentHost, SubagentHandle } from '#/session/subagentHost'; +import type { SubagentDetachHandle } from '#/agent/background'; +import type { SubagentHandle } from '#/agent/agentTool'; import { configServices, createTestAgent, @@ -82,7 +83,7 @@ function agentTask( options: { readonly agentId?: string; readonly subagentType?: string; - readonly subagentHost?: Pick; + readonly subagentHost?: Pick; readonly abortController?: AbortController; readonly timeoutMs?: number; } = {}, diff --git a/packages/agent-core-v2/test/gateway/gateway.test.ts b/packages/agent-core-v2/test/gateway/gateway.test.ts index 470542703..7f57f6c45 100644 --- a/packages/agent-core-v2/test/gateway/gateway.test.ts +++ b/packages/agent-core-v2/test/gateway/gateway.test.ts @@ -74,6 +74,7 @@ describe('RestGateway', () => { onDidDispose: () => ({ dispose: () => {} }), create: () => Promise.resolve(agentHandle), createMain: () => Promise.resolve(agentHandle), + fork: () => Promise.resolve(agentHandle), getHandle: (id) => (id === 'main' ? agentHandle : undefined), list: () => [agentHandle], remove: () => Promise.resolve(), diff --git a/packages/agent-core-v2/test/harness/agent.ts b/packages/agent-core-v2/test/harness/agent.ts index 94df4d9cd..de8edb800 100644 --- a/packages/agent-core-v2/test/harness/agent.ts +++ b/packages/agent-core-v2/test/harness/agent.ts @@ -3,7 +3,6 @@ import { isAbsolute, relative, resolve } from 'node:path'; import { Readable, type Writable } from 'node:stream'; import { createControlledPromise } from '@antfu/utils'; -import { type Environment, type Kaos, type KaosProcess } from '@moonshot-ai/kaos'; import { isToolCall, isToolCallPart, @@ -27,6 +26,7 @@ import { AgentBackgroundService, AgentExternalHooksService, FileStorageService, + InMemoryStorageService, AgentFullCompactionService, IAgentRPCService, IAppendLogStore, @@ -45,7 +45,7 @@ import { IAgentExternalHooksService, IAgentFileToolsService, IAgentFullCompactionService, - IKaos, + IHostEnvironment, IAgentLLMRequesterService, ILogService, IAgentMcpService, @@ -53,13 +53,18 @@ import { IAgentPermissionGate, IAgentPermissionModeService, IAgentPermissionRulesService, + ISessionAgentFileSystem, ISessionContext, IAgentShellToolsService, + ISessionProcessRunner, IStorageService, - ISessionSubagentHost, + IAgentScopeContext, IAgentSwarmService, + AgentSwarmService, ITelemetryService, ISessionTerminalBackend, + IAgentToolService, + AgentToolService, IAgentToolRegistryService, IAgentToolStoreService, IAgentUserToolService, @@ -84,7 +89,11 @@ import { type Scope, type ScopeSeed, type ServiceIdentifier, + type AgentToolRunOverride, } from '#/index'; +import { IExecContext, createExecContext, execContextSeed } from '#/session/execContext'; +import type { IProcess } from '#/session/process'; +import type { AgentSwarmToolHost } from '#/agent/swarm/tools/agent-swarm'; import { Event } from '#/_base/event'; import { toDisposable } from '#/_base/di'; import type { PromisifyMethods } from '#/_base/utils/types'; @@ -131,7 +140,6 @@ import { ISessionSkillCatalog } from '#/session/sessionSkillCatalog/skillCatalog import { AgentSkillService } from '#/agent/skill/skillService'; import { ModelSkillTool } from '#/agent/skill/tools/modelSkill'; import type { SkillCatalog } from '#/app/globalSkillCatalog/types'; -import { SessionSubagentHostService, type SessionSubagentHost } from '#/session/subagentHost'; import type { ExecutableToolOutput as ToolOutput, ToolResult } from '#/agent/tool'; import type { PersistedWireRecord, @@ -140,7 +148,7 @@ import type { WireRecordRestoreResult, } from '#/agent/wireRecord'; import type { PathAccessOperation } from '#/session/workspaceContext'; -import { createFakeKaos } from '../tools/fixtures/fake-kaos'; +import { createFakeAgentFs, createFakeHostEnvironment, createFakeProcessRunner } from '../tools/fixtures/fake-exec'; import { createScriptedGenerate } from './scripted-generate'; import { @@ -151,13 +159,8 @@ import { } from './snapshots'; import { recordAgentEvents, type RecordedEventEntry } from '../snapshot/events'; -const TEST_OS_ENV: Environment = { - osKind: 'Linux', - osArch: 'x86_64', - osVersion: 'test', - shellName: 'bash', - shellPath: '/bin/bash', -}; +const TEST_HOST_ENVIRONMENT: IHostEnvironment = createFakeHostEnvironment(); +const TEST_HOME_DIR: string = TEST_HOST_ENVIRONMENT.homeDir; const MOCK_PROVIDER = { type: 'kimi', @@ -398,11 +401,113 @@ function defineServiceValue( } } -export function kaosServices(kaos: Kaos): TestAgentServiceOverride { - return sessionServices((reg) => { - reg.defineInstance(IKaos, createIKaos(kaos)); - reg.defineDescriptor(ISessionWorkspaceContext, new SyncDescriptor(SessionWorkspaceContextService)); - }); +/** + * Session-scope override for the execution-environment atoms + * (`IHostEnvironment` / `IExecContext` / `ISessionAgentFileSystem` / + * `ISessionProcessRunner`). Replaces the v1 `kaosServices(kaos)` helper — + * tests now pass just the atoms they care about. + */ +export interface ExecEnvOverride { + readonly hostEnvironment?: IHostEnvironment | Partial; + readonly execContext?: IExecContext | { readonly cwd?: string; readonly envLayers?: readonly Record[] }; + readonly agentFs?: ISessionAgentFileSystem | Partial; + readonly processRunner?: ISessionProcessRunner | Partial; +} + +/** + * Register a fake execution-environment atom set for a test session. Any + * unspecified atom keeps the harness default (fake host env, `/workspace` + * exec ctx, throwing fs/runner). + */ +export function execEnvServices(override: ExecEnvOverride = {}): TestAgentServiceOverride { + return [ + override.hostEnvironment !== undefined + ? appService(IHostEnvironment, resolveHostEnvironmentOverride(override.hostEnvironment)) + : appServices(() => {}), + sessionServices((reg) => { + if (override.execContext !== undefined) { + const ctx = resolveExecContextOverride(override.execContext); + for (const [id, value] of execContextSeed(ctx)) { + reg.defineInstance(id as ServiceIdentifier, value); + } + } + if (override.agentFs !== undefined) { + reg.defineInstance(ISessionAgentFileSystem, resolveAgentFsOverride(override.agentFs)); + } + if (override.processRunner !== undefined) { + reg.defineInstance(ISessionProcessRunner, resolveProcessRunnerOverride(override.processRunner)); + } + reg.defineDescriptor(ISessionWorkspaceContext, new SyncDescriptor(SessionWorkspaceContextService)); + }), + ]; +} + +function resolveHostEnvironmentOverride( + input: IHostEnvironment | Partial, +): IHostEnvironment { + // A full `IHostEnvironment` is an object literal (constructed via + // `createFakeHostEnvironment` or the real service). Anything that is not a + // plain-object literal (no prototype tricks) is treated as a full instance + // and passed through. Plain object literals with partial fields fall + // through to the fake factory. Since both `createFakeHostEnvironment` and + // real services produce plain-shape objects, we discriminate on the + // presence of `osKind` — a full env always has it. + if (typeof (input as IHostEnvironment).osKind === 'string') { + return input as IHostEnvironment; + } + return createFakeHostEnvironment(input as Partial); +} + +function resolveExecContextOverride( + input: IExecContext | { readonly cwd?: string; readonly envLayers?: readonly Record[] }, +): IExecContext { + if ('withCwd' in input && 'withEnv' in input) return input as IExecContext; + const partial = input as { readonly cwd?: string; readonly envLayers?: readonly Record[] }; + return createExecContext(partial.cwd ?? '/workspace', partial.envLayers ?? []); +} + +function resolveAgentFsOverride( + input: ISessionAgentFileSystem | Partial, +): ISessionAgentFileSystem { + // A full impl (class instance or `Proxy` over one) exposes every core + // method. A partial override typically covers only a few of them. If every + // core method is a function, pass the input through unchanged; otherwise + // treat it as a partial override and spread it over the fake defaults. + if (isFullAgentFs(input)) return input as ISessionAgentFileSystem; + return createFakeAgentFs(input as Partial); +} + +function isFullAgentFs(input: unknown): boolean { + if (typeof input !== 'object' || input === null) return false; + const keys: readonly (keyof ISessionAgentFileSystem)[] = [ + 'readText', + 'writeText', + 'readBytes', + 'readLines', + 'writeBytes', + 'stat', + 'readdir', + 'glob', + 'mkdir', + 'withCwd', + ]; + return keys.every((k) => typeof (input as Record)[k] === 'function'); +} + +function resolveProcessRunnerOverride( + input: ISessionProcessRunner | Partial, +): ISessionProcessRunner { + // `ISessionProcessRunner` has only one method (`exec`), so a full impl is + // any object with `exec` as a function. Both `SessionProcessRunner` + // instances and `createFakeProcessRunner()` results satisfy this. + if ( + typeof input === 'object' && + input !== null && + typeof (input as ISessionProcessRunner).exec === 'function' + ) { + return input as ISessionProcessRunner; + } + return createFakeProcessRunner(input as Partial); } export function homeDirServices(homeDir: string | undefined): TestAgentServiceOverride { @@ -541,8 +646,17 @@ function createSessionSkillCatalog(catalog: SkillCatalog): ISessionSkillCatalog }; } -export function subagentHostServices(host: SessionSubagentHost): TestAgentServiceOverride { - return agentService(ISessionSubagentHost, new SyncDescriptor(SessionSubagentHostService, [host])); +export function agentToolServices(runOverride: AgentToolRunOverride): TestAgentServiceOverride { + return agentService( + IAgentToolService, + new SyncDescriptor(AgentToolService, [runOverride]), + ); +} + +export function swarmServices( + runQueued: AgentSwarmToolHost['runQueued'], +): TestAgentServiceOverride { + return agentService(IAgentSwarmService, new SyncDescriptor(AgentSwarmService, [runQueued])); } export function goalServices(options: GoalServiceOptions): TestAgentServiceOverride { @@ -553,24 +667,26 @@ export function replayServices(options: ReplayBuilderServiceOptions = {}): TestA return agentService(IAgentReplayBuilderService, new SyncDescriptor(AgentReplayBuilderService, [options])); } -export function createCommandKaos(stdout: string): Kaos { - function createProcess(): KaosProcess { +/** + * Build a fake `ISessionProcessRunner` whose `exec` returns a scripted + * `IProcess` emitting `stdout` on stdout and exiting with `exitCode`. + * Replaces the v1 `createCommandKaos(stdout)` helper. + */ +export function createCommandRunner(stdout: string, exitCode = 0): ISessionProcessRunner { + function createProcess(): IProcess { return { stdin: { write: vi.fn(), end: vi.fn() } as unknown as Writable, stdout: Readable.from([stdout]), stderr: Readable.from(['']), pid: 42, - exitCode: 0, - wait: vi.fn().mockResolvedValue(0), - kill: vi.fn().mockResolvedValue(undefined), - dispose: vi.fn().mockResolvedValue(undefined), + exitCode, + wait: vi.fn().mockResolvedValue(exitCode) as IProcess['wait'], + kill: vi.fn().mockResolvedValue(undefined) as IProcess['kill'], + dispose: vi.fn().mockResolvedValue(undefined) as IProcess['dispose'], }; } - - return createFakeKaos({ - execWithEnv: vi.fn().mockImplementation(async () => createProcess()), - mkdir: vi.fn().mockResolvedValue(undefined), - writeText: vi.fn(async (_path: string, content: string) => content.length), + return createFakeProcessRunner({ + exec: vi.fn().mockImplementation(async () => createProcess()), }); } @@ -837,7 +953,6 @@ export class AgentTestContext { this.emitter.on('error', () => {}); this.kimiConfig = applyTestAgentOptionsToConfig(emptyConfig(), options); - const kaos = createFakeKaos(); const sessionId = 'test-session'; const agentId = 'main'; const persistence = options.persistence ?? new InMemoryWireRecordPersistence(); @@ -847,11 +962,28 @@ export class AgentTestContext { for (const [id, value] of bootstrapSeed({ homeDir: '/tmp/kimi-code-agent-app-v2-test', cwd: this.cwd, - osHomeDir: kaos.gethome(), + osHomeDir: TEST_HOME_DIR, env: process.env, })) { reg.defineInstance(id, value); } + // Fake `IHostEnvironment` — a real `HostEnvironmentService` would kick + // off an async probe (spawn `sh --version` on Windows, `os.homedir()`, + // etc.) at App-scope construction; the harness stubs it with a + // deterministic Linux/bash snapshot instead. Tests can override with + // `execEnvServices({ hostEnvironment: … })`. + reg.defineInstance(IHostEnvironment, TEST_HOST_ENVIRONMENT); + // In-memory Storage-layer backend. The `InMemoryStorageService` is no + // longer auto-registered, so the harness seeds it here to keep a + // workable default for storage-backed services. Tests that need durable + // (file) storage override this via `homeDirServices(dir)` — overrides + // win over this base seed (see `collectScopeSeed`). + const memoryStorage = (): SyncDescriptor => + new SyncDescriptor(InMemoryStorageService, [], true); + reg.defineDescriptor(IStorageService, memoryStorage()); + reg.defineDescriptor(IAppendLogStorage, memoryStorage()); + reg.defineDescriptor(IAtomicDocumentStorage, memoryStorage()); + reg.defineDescriptor(IBlobStorage, memoryStorage()); reg.defineInstance(IConfigService, configService(() => this.kimiConfig)); reg.defineInstance( IAppendLogStore, @@ -891,7 +1023,14 @@ export class AgentTestContext { reg.defineInstance(ISessionInteractionService, this.createInteractionService()); reg.defineInstance(ISessionApprovalService, this.createApprovalService()); reg.defineInstance(ISessionQuestionService, this.createQuestionService()); - reg.defineInstance(IKaos, createIKaos(kaos)); + // Seed the session `IExecContext` (was `IKaos` in the old harness). + for (const [id, value] of execContextSeed(createExecContext(this.cwd))) { + reg.defineInstance(id as ServiceIdentifier, value); + } + // Note: `ISessionAgentFileSystem` and `ISessionProcessRunner` are + // auto-registered by their service files (backed by `IExecContext` + // and Node fs/spawn). Tests that need a fake override them via + // `execEnvServices({ agentFs: … })` / `execEnvServices({ processRunner: … })`. reg.defineInstance(ISessionTerminalBackend, createTerminalBackend()); reg.defineDescriptor(ISessionWorkspaceContext, new SyncDescriptor(SessionWorkspaceContextService)); reg.defineDescriptor(ISessionModelResolver, new SyncDescriptor(ConfigBackedModelResolver, [{}])); @@ -937,9 +1076,10 @@ export class AgentTestContext { reg.defineDescriptor(IAgentGoalService, new SyncDescriptor(AgentGoalService, [{}])); reg.defineDescriptor(IAgentSkillService, new SyncDescriptor(AgentSkillService)); reg.defineDescriptor(IAgentUserToolService, new SyncDescriptor(AgentUserToolService)); + reg.defineInstance(IAgentScopeContext, { _serviceBrand: undefined, agentId }); reg.defineDescriptor( - ISessionSubagentHost, - new SyncDescriptor(SessionSubagentHostService, [unavailableSubagentHost()]), + IAgentToolService, + new SyncDescriptor(AgentToolService, [unavailableAgentToolRun()]), ); }, ], this.serviceOverrides, 'agent'), @@ -1368,7 +1508,7 @@ export class AgentTestContext { const resumed = createTestAgent( { autoConfigure: false }, ...this.serviceOverrides, - kaosServices(createResumeNoSideEffectKaos(profile.data().cwd)), + createResumeNoSideEffectExecEnv(profile.data().cwd), configServices(() => configSnapshot), llmGenerateServices(failOnResumeGenerate), wireRecordPersistenceServices(new InMemoryWireRecordPersistence( @@ -1644,28 +1784,6 @@ export class AgentTestContext { } } -function createIKaos(kaos: Kaos): IKaos { - return { - _serviceBrand: undefined, - get name() { - return kaos.name; - }, - get cwd() { - return kaos.getcwd(); - }, - get osEnv() { - return kaos.osEnv; - }, - backend: kaos, - pathClass: () => kaos.pathClass(), - normpath: (path) => kaos.normpath(path), - gethome: () => kaos.gethome(), - getcwd: () => kaos.getcwd(), - withCwd: (cwd) => createIKaos(kaos.withCwd(cwd)), - withEnv: (env) => createIKaos(kaos.withEnv(env)), - }; -} - function createWorkspaceContextStub( initialWorkDir: string, initialAdditionalDirs: readonly string[], @@ -1773,21 +1891,16 @@ function createTerminalBackend(): ISessionTerminalBackend { }; } -function unavailableSubagentHost(): SessionSubagentHost { +function unavailableAgentToolRun(): AgentToolRunOverride { const fail = async (): Promise => { - throw new Error('Subagent host is not configured in this test.'); + throw new Error('Agent tool run is not configured in this test.'); }; return { - getSwarmItem: () => undefined, - startBtw: fail, spawn: fail, resume: fail, retry: fail, getProfileName: async () => undefined, - markActiveChildDetached: () => {}, - runQueued: async () => [], - cancelAll: () => {}, - suspended: () => {}, + markDetached: () => {}, }; } @@ -1795,36 +1908,12 @@ const failOnResumeGenerate: GenerateFn = async () => { throw new Error('Resume replay unexpectedly called the LLM'); }; -function createResumeNoSideEffectKaos(initialCwd: string): Kaos { - const fail = (method: string): never => { - throw new Error(`Resume replay unexpectedly called kaos.${method}`); - }; - - let cwd = initialCwd; - return { - name: 'resume-no-side-effects', - osEnv: TEST_OS_ENV, - pathClass: () => 'posix', - normpath: (p: string) => p, - gethome: () => '/home/test', - getcwd: () => cwd, - withCwd: (next: string) => createResumeNoSideEffectKaos(next), - withEnv: () => createResumeNoSideEffectKaos(cwd), - chdir: async (next: string) => { - cwd = next; - }, - stat: () => fail('stat'), - iterdir: () => fail('iterdir'), - glob: () => fail('glob'), - readBytes: () => fail('readBytes'), - readText: () => fail('readText'), - readLines: () => fail('readLines'), - writeBytes: () => fail('writeBytes'), - writeText: () => fail('writeText'), - mkdir: () => fail('mkdir'), - exec: () => fail('exec'), - execWithEnv: () => fail('execWithEnv'), - }; +function createResumeNoSideEffectExecEnv(initialCwd: string): TestAgentServiceOverride { + return execEnvServices({ + execContext: { cwd: initialCwd }, + // Any fs/process interaction during a resume replay is a bug — surface it + // loudly instead of silently no-oping. + }); } function resumeStateSnapshot(ctx: AgentTestContext): ResumeStateSnapshot { diff --git a/packages/agent-core-v2/test/harness/index.ts b/packages/agent-core-v2/test/harness/index.ts index 1804605e0..772d36128 100644 --- a/packages/agent-core-v2/test/harness/index.ts +++ b/packages/agent-core-v2/test/harness/index.ts @@ -4,17 +4,17 @@ export { agentServices, backgroundServices, configServices, - createCommandKaos, + createCommandRunner, createTestAgent, appService, appServices, cronServices, + execEnvServices, externalHookServices, fullCompactionServices, goalServices, homeDirServices, InMemoryWireRecordPersistence, - kaosServices, llmGenerateServices, logServices, mcpServices, @@ -28,7 +28,8 @@ export { sessionService, sessionServices, skillServices, - subagentHostServices, + agentToolServices, + swarmServices, telemetryServices, testAgent, wireRecordPersistenceServices, diff --git a/packages/agent-core-v2/test/session-activity/sessionActivity.test.ts b/packages/agent-core-v2/test/session-activity/sessionActivity.test.ts index f6cd82506..90d50ffe8 100644 --- a/packages/agent-core-v2/test/session-activity/sessionActivity.test.ts +++ b/packages/agent-core-v2/test/session-activity/sessionActivity.test.ts @@ -58,6 +58,7 @@ function lifecycle(handles: readonly IScopeHandle[]): IAgentLifecycleService { onDidDispose: () => ({ dispose: () => {} }), create: () => Promise.resolve(handles[0]!), createMain: () => Promise.resolve(handles[0]!), + fork: () => Promise.resolve(handles[0]!), getHandle: () => undefined, list: () => handles, remove: () => Promise.resolve(), diff --git a/packages/agent-core-v2/test/subagentHost/subagentHostService.test.ts b/packages/agent-core-v2/test/subagentHost/subagentHostService.test.ts deleted file mode 100644 index 333ddde76..000000000 --- a/packages/agent-core-v2/test/subagentHost/subagentHostService.test.ts +++ /dev/null @@ -1,79 +0,0 @@ -import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; - -import { SyncDescriptor } from '#/_base/di/descriptors'; -import { DisposableStore } from '#/_base/di/lifecycle'; -import { TestInstantiationService } from '#/_base/di/test'; -import { IAgentBackgroundService } from '#/agent/background'; -import { IAgentLifecycleService } from '#/session/agent-lifecycle'; -import { ILogService } from '#/app/log'; -import { IAgentProfileService } from '#/agent/profile'; -import { IAgentToolRegistryService } from '#/agent/toolRegistry'; -import type { IScopeHandle } from '#/_base/di/scope'; -import { IKaos } from '#/app/kaos'; -import { ISessionMetadata } from '#/session/session-metadata'; -import { ISessionProcessRunner } from '#/session/process'; -import { ISessionSubagentHost, SessionSubagentHostService } from '#/session/subagentHost'; - -function fakeProfileScope(id: string): IScopeHandle { - const profile = { - data: vi.fn(() => ({ - cwd: '/repo', - modelAlias: 'parent-model', - thinkingLevel: 'medium', - systemPrompt: 'parent prompt', - activeToolNames: ['Read', 'Write'], - })), - update: vi.fn(), - }; - return { - id, - accessor: { - get: vi.fn((token: unknown) => (token === IAgentProfileService ? profile : undefined)), - }, - } as unknown as IScopeHandle; -} - -describe('SessionSubagentHostService DI wiring', () => { - let disposables: DisposableStore; - let ix: TestInstantiationService; - - beforeEach(() => { - disposables = new DisposableStore(); - ix = disposables.add(new TestInstantiationService()); - }); - - afterEach(() => disposables.dispose()); - - it('constructs a DefaultSessionSubagentHost when no host override is provided', async () => { - const register = vi.fn(() => ({ dispose: () => {} })); - const parent = fakeProfileScope('main'); - const child = fakeProfileScope('child'); - ix.stub(IAgentLifecycleService, { - getHandle: vi.fn().mockReturnValue(undefined), - createMain: vi.fn().mockResolvedValue(parent), - create: vi.fn().mockResolvedValue(child), - }); - ix.stub(ISessionMetadata, { - ready: Promise.resolve(), - read: vi.fn().mockResolvedValue({ agents: {} }), - onDidChange: () => ({ dispose: () => {} }), - update: vi.fn(), - setTitle: vi.fn(), - setArchived: vi.fn(), - registerAgent: vi.fn(), - }); - ix.stub(IAgentToolRegistryService, { register }); - ix.stub(IAgentBackgroundService, {}); - ix.stub(IAgentProfileService, { isToolActive: vi.fn().mockReturnValue(false) }); - ix.stub(IKaos, { cwd: '/repo' }); - ix.stub(ISessionProcessRunner, { exec: vi.fn() }); - ix.stub(ILogService, { warn: vi.fn(), info: vi.fn(), debug: vi.fn(), error: vi.fn() }); - ix.set(ISessionSubagentHost, new SyncDescriptor(SessionSubagentHostService, [undefined])); - - const service = ix.get(ISessionSubagentHost); - - expect(service).toBeInstanceOf(SessionSubagentHostService); - expect(register).toHaveBeenCalledTimes(1); - await expect(service.startBtw()).resolves.toBe('child'); - }); -}); diff --git a/packages/agent-core-v2/test/swarm/swarm.test.ts b/packages/agent-core-v2/test/swarm/swarm.test.ts index 071bd342a..11403059c 100644 --- a/packages/agent-core-v2/test/swarm/swarm.test.ts +++ b/packages/agent-core-v2/test/swarm/swarm.test.ts @@ -7,10 +7,13 @@ import { IAgentContextMemoryService } from '#/agent/contextMemory'; import { IAgentEventSinkService } from '#/agent/eventSink'; import { DEFAULT_SUBAGENT_TIMEOUT_MS, - ISessionSubagentHost, +} from '#/agent/agentTool'; +import { type QueuedSubagentRunResult, type QueuedSubagentTask, -} from '#/session/subagentHost'; +} from '#/agent/swarm'; +import { IAgentLifecycleService } from '#/session/agent-lifecycle'; +import { IAgentScopeContext } from '#/agent/scopeContext'; import { IAgentSystemReminderService } from '#/agent/systemReminder'; import { AgentSystemReminderService } from '#/agent/systemReminder/systemReminderService'; import { IAgentSwarmService } from '#/agent/swarm'; @@ -34,7 +37,7 @@ function context( return { turnId: '0', toolCallId, args, signal }; } -function mockSubagentHost({ +function mockSwarmHost({ getSwarmItem = () => undefined, runQueued = vi.fn().mockResolvedValue([]), }: { @@ -43,10 +46,11 @@ function mockSubagentHost({ readonly runQueued?: (...args: any[]) => any; } = {}) { return { + lifecycle: {} as never, + parentAgentId: 'main', getSwarmItem: vi.fn(getSwarmItem), runQueued, - // eslint-disable-next-line @typescript-eslint/no-explicit-any - } as any; + }; } function mockSwarmMode() { @@ -65,7 +69,8 @@ describe('AgentSwarmService', () => { ix.stub(IAgentEventSinkService, { emit: () => {}, on: () => toDisposable(() => {}) }); ix.stub(IAgentTurnService, stubTurnWithHooks()); ix.set(IAgentToolRegistryService, new SyncDescriptor(AgentToolRegistryService)); - ix.stub(ISessionSubagentHost, {}); + ix.stub(IAgentLifecycleService, {}); + ix.stub(IAgentScopeContext, { _serviceBrand: undefined, agentId: 'main' }); ix.set(IAgentSystemReminderService, new SyncDescriptor(AgentSystemReminderService)); ix.set(IAgentSwarmService, new SyncDescriptor(AgentSwarmService)); }); @@ -83,7 +88,7 @@ describe('AgentSwarmService', () => { describe('AgentSwarmTool', () => { it('applies one subagent_type across templated subagents', async () => { - const host = mockSubagentHost({ + const host = mockSwarmHost({ runQueued: vi.fn().mockResolvedValue([ { task: { @@ -161,7 +166,7 @@ describe('AgentSwarmTool', () => { expect(swarmMode.enter).toHaveBeenCalledWith('tool'); expect(host.runQueued).toHaveBeenCalledTimes(1); - expect(host.runQueued).toHaveBeenCalledWith([ + expect(host.runQueued).toHaveBeenCalledWith(expect.objectContaining({ tasks: [ { kind: 'spawn', data: { @@ -198,7 +203,7 @@ describe('AgentSwarmTool', () => { signal, timeout: DEFAULT_SUBAGENT_TIMEOUT_MS, }, - ]); + ] })); expect(result.output).toBe( [ '', @@ -212,7 +217,7 @@ describe('AgentSwarmTool', () => { }); it('does not expose permission rule argument matching', () => { - const tool = new AgentSwarmTool(mockSubagentHost(), mockSwarmMode()); + const tool = new AgentSwarmTool(mockSwarmHost(), mockSwarmMode()); const execution = tool.resolveExecution({ description: 'Review files', prompt_template: 'Review {{item}}', @@ -270,7 +275,7 @@ describe('AgentSwarmTool', () => { ]; for (const testCase of cases) { - const host = mockSubagentHost(); + const host = mockSwarmHost(); const tool = new AgentSwarmTool(host, mockSwarmMode()); const result = await executeTool(tool, context(testCase.input)); @@ -283,9 +288,11 @@ describe('AgentSwarmTool', () => { it('resumes mapped agents before spawning item subagents', async () => { const runQueued = vi.fn( - async ( - tasks: readonly QueuedSubagentTask[], - ): Promise>> => + async ({ + tasks, + }: { + tasks: readonly QueuedSubagentTask[]; + }): Promise>> => tasks.map((task, index) => ({ task, agentId: task.kind === 'resume' ? task.resumeAgentId : `agent-new-${String(index + 1)}`, @@ -293,7 +300,7 @@ describe('AgentSwarmTool', () => { result: `result ${String(index + 1)}`, })), ); - const host = mockSubagentHost({ + const host = mockSwarmHost({ getSwarmItem: (agentId) => ({ 'agent-old-1': 'src/old-a.ts', 'agent-old-2': 'src/old-b.ts' })[agentId], runQueued, @@ -320,7 +327,7 @@ describe('AgentSwarmTool', () => { const result = await executeTool(tool, context(input)); - expect(host.runQueued).toHaveBeenCalledWith([ + expect(host.runQueued).toHaveBeenCalledWith(expect.objectContaining({ tasks: [ { kind: 'resume', data: { @@ -379,7 +386,7 @@ describe('AgentSwarmTool', () => { signal, timeout: DEFAULT_SUBAGENT_TIMEOUT_MS, }, - ]); + ] })); expect(result.output).toBe( [ '', @@ -395,9 +402,11 @@ describe('AgentSwarmTool', () => { it('allows a single resumed subagent without item subagents', async () => { const runQueued = vi.fn( - async ( - tasks: readonly QueuedSubagentTask[], - ): Promise>> => + async ({ + tasks, + }: { + tasks: readonly QueuedSubagentTask[]; + }): Promise>> => tasks.map((task) => ({ task, agentId: task.kind === 'resume' ? task.resumeAgentId : 'agent-new', @@ -405,7 +414,7 @@ describe('AgentSwarmTool', () => { result: 'resumed result', })), ); - const host = mockSubagentHost({ + const host = mockSwarmHost({ getSwarmItem: (agentId) => (agentId === 'agent-old-1' ? 'src/old-a.ts' : undefined), runQueued, }); @@ -419,7 +428,7 @@ describe('AgentSwarmTool', () => { const result = await executeTool(tool, context(input)); - expect(host.runQueued).toHaveBeenCalledWith([ + expect(host.runQueued).toHaveBeenCalledWith(expect.objectContaining({ tasks: [ { kind: 'resume', data: { @@ -440,7 +449,7 @@ describe('AgentSwarmTool', () => { signal, timeout: DEFAULT_SUBAGENT_TIMEOUT_MS, }, - ]); + ] })); expect(result.output).toBe( [ '', @@ -452,8 +461,8 @@ describe('AgentSwarmTool', () => { }); it('reports failed subagents inside the XML result without failing the tool', async () => { - const host = mockSubagentHost({ - runQueued: vi.fn().mockImplementation(async (tasks) => [ + const host = mockSwarmHost({ + runQueued: vi.fn().mockImplementation(async ({ tasks }) => [ { task: tasks[0], agentId: 'agent-coder-1', @@ -493,8 +502,8 @@ describe('AgentSwarmTool', () => { }); it('omits resume hint when incomplete subagents have no agent ids', async () => { - const host = mockSubagentHost({ - runQueued: vi.fn().mockImplementation(async (tasks) => [ + const host = mockSwarmHost({ + runQueued: vi.fn().mockImplementation(async ({ tasks }) => [ { task: tasks[0], status: 'failed', @@ -530,8 +539,8 @@ describe('AgentSwarmTool', () => { }); it('reports partial aborted subagents inside the XML result', async () => { - const host = mockSubagentHost({ - runQueued: vi.fn().mockImplementation(async (tasks) => [ + const host = mockSwarmHost({ + runQueued: vi.fn().mockImplementation(async ({ tasks }) => [ { task: tasks[0], agentId: 'agent-coder-1', diff --git a/packages/agent-core-v2/test/tool/tool.test.ts b/packages/agent-core-v2/test/tool/tool.test.ts index 5f990b54c..df0a73e66 100644 --- a/packages/agent-core-v2/test/tool/tool.test.ts +++ b/packages/agent-core-v2/test/tool/tool.test.ts @@ -1,21 +1,21 @@ import { Readable, type Writable } from 'node:stream'; -import type { Kaos, KaosProcess } from '@moonshot-ai/kaos'; import type { ToolCall } from '@moonshot-ai/kosong'; import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; import { IAgentContextMemoryService } from '#/agent/contextMemory'; import { HookEngine } from '#/agent/externalHooks/engine'; import { IAgentProfileService } from '#/agent/profile'; -import type { SessionSubagentHost } from '#/session/subagentHost'; +import type { AgentToolRunOverride } from '#/agent/agentTool'; import { IAgentToolRegistryService } from '#/agent/toolRegistry'; -import { createFakeKaos } from '../tools/fixtures/fake-kaos'; +import type { IProcess, ISessionProcessRunner } from '#/session/process'; +import { createFakeProcessRunner } from '../tools/fixtures/fake-exec'; import { - createCommandKaos, + agentToolServices, + createCommandRunner, createTestAgent, + execEnvServices, externalHookServices, - kaosServices, - subagentHostServices, type TestAgentContext, } from '../harness'; import { executeTool } from '../tools/fixtures/execute-tool'; @@ -37,13 +37,11 @@ describe('Agent tools', () => { }); describe('PreToolUse blocking', () => { - let execWithEnv: NonNullable; + let exec: ReturnType; let triggered: Array<[string, string, number]>; beforeEach(() => { - execWithEnv = vi - .fn>() - .mockRejectedValue(new Error('Bash should not execute')); + exec = vi.fn().mockRejectedValue(new Error('Bash should not execute')); triggered = []; const hookEngine = new HookEngine( [ @@ -65,7 +63,7 @@ describe('Agent tools', () => { }, ); ctx = createTestAgent( - kaosServices(createFakeKaos({ execWithEnv })), + execEnvServices({ processRunner: createFakeProcessRunner({ exec: exec as unknown as ISessionProcessRunner['exec'] }) }), externalHookServices(hookEngine), ); context = ctx.get(IAgentContextMemoryService); @@ -80,7 +78,7 @@ describe('Agent tools', () => { await ctx.untilTurnEnd(); - expect(execWithEnv).not.toHaveBeenCalled(); + expect(exec).not.toHaveBeenCalled(); expect(triggered).toEqual([ ['PreToolUse', 'Bash', 1], ['PostToolUseFailure', 'Bash', 1], @@ -125,7 +123,7 @@ describe('Agent tools', () => { }, ); ctx = createTestAgent( - kaosServices(createCommandKaos('hook-output')), + execEnvServices({ processRunner: createCommandRunner('hook-output') }), externalHookServices(hookEngine), ); profile = ctx.get(IAgentProfileService); @@ -175,7 +173,7 @@ describe('Agent tools', () => { }, ); ctx = createTestAgent( - kaosServices(createFailingCommandKaos('hook-output')), + execEnvServices({ processRunner: createFailingCommandRunner('hook-output') }), externalHookServices(hookEngine), ); profile = ctx.get(IAgentProfileService); @@ -198,7 +196,7 @@ describe('Agent tools', () => { describe('Bash tool call start event', () => { beforeEach(async () => { - ctx = createTestAgent(kaosServices(createCommandKaos('ok'))); + ctx = createTestAgent(execEnvServices({ processRunner: createCommandRunner('ok') })); profile = ctx.get(IAgentProfileService); profile.update({ activeToolNames: ['Bash'] }); await ctx.rpc.setPermission({ mode: 'yolo' }); @@ -220,14 +218,14 @@ describe('Agent tools', () => { }); describe('foreground Agent tool recovery', () => { - let subagentHost: SessionSubagentHost; + let runOverride: AgentToolRunOverride; beforeEach(() => { const completion = Promise.reject( new Error('Subagent turn failed before completing its final summary: reason=max_tokens.'), ); void completion.catch(() => undefined); - subagentHost = { + runOverride = { spawn: vi.fn().mockResolvedValue({ agentId: 'agent-child', profileName: 'coder', @@ -235,8 +233,11 @@ describe('Agent tools', () => { completion, }), resume: vi.fn(), - } as unknown as SessionSubagentHost; - ctx = createTestAgent(subagentHostServices(subagentHost)); + retry: vi.fn(), + getProfileName: vi.fn().mockResolvedValue(undefined), + markDetached: vi.fn(), + }; + ctx = createTestAgent(agentToolServices(runOverride)); profile = ctx.get(IAgentProfileService); profile.update({ activeToolNames: ['Agent'] }); }); @@ -250,7 +251,7 @@ describe('Agent tools', () => { await ctx.rpc.prompt({ input: [{ type: 'text', text: 'Delegate and recover' }] }); await ctx.untilTurnEnd(); - expect(subagentHost.spawn).toHaveBeenCalledWith( + expect(runOverride.spawn).toHaveBeenCalledWith( expect.objectContaining({ profileName: 'coder', parentToolCallId: 'call_agent', @@ -529,24 +530,21 @@ function bashCall(): ToolCall { }; } -function createFailingCommandKaos(stdout: string): ReturnType { - function createProcess(): KaosProcess { +function createFailingCommandRunner(stdout: string): ISessionProcessRunner { + function createProcess(): IProcess { return { stdin: { write: vi.fn(), end: vi.fn() } as unknown as Writable, stdout: Readable.from([stdout]), stderr: Readable.from(['']), pid: 42, exitCode: 2, - wait: vi.fn().mockResolvedValue(2), - kill: vi.fn().mockResolvedValue(undefined), - dispose: vi.fn().mockResolvedValue(undefined), + wait: vi.fn().mockResolvedValue(2) as IProcess['wait'], + kill: vi.fn().mockResolvedValue(undefined) as IProcess['kill'], + dispose: vi.fn().mockResolvedValue(undefined) as IProcess['dispose'], }; } - - return createFakeKaos({ - execWithEnv: vi.fn().mockImplementation(async () => createProcess()), - mkdir: vi.fn().mockResolvedValue(undefined), - writeText: vi.fn(async (_path: string, content: string) => content.length), + return createFakeProcessRunner({ + exec: vi.fn().mockImplementation(async () => createProcess()), }); } diff --git a/packages/agent-core-v2/test/turn/turn.test.ts b/packages/agent-core-v2/test/turn/turn.test.ts index 4b0d7d990..ebbc967b2 100644 --- a/packages/agent-core-v2/test/turn/turn.test.ts +++ b/packages/agent-core-v2/test/turn/turn.test.ts @@ -4,7 +4,6 @@ import { Readable } from 'node:stream'; import { join } from 'pathe'; import { setTimeout as delay } from 'node:timers/promises'; -import type { Kaos, KaosProcess } from '@moonshot-ai/kaos'; import { APIConnectionError, APIEmptyResponseError, @@ -22,7 +21,7 @@ import type { ContextMessage } from '#/agent/contextMemory'; import { IOAuthService } from '#/app/auth'; import { ErrorCodes, KimiError } from '#/errors'; import { HookEngine } from '#/agent/externalHooks/engine'; -import { IKaos } from '#/app/kaos'; +import { IHostEnvironment } from '#/app/hostEnvironment'; import type { ILogger as Logger, LogPayload } from '#/app/log'; import { IAgentMcpService } from '#/agent/mcp'; import { McpConnectionManager } from '#/agent/mcp/connection-manager'; @@ -32,21 +31,21 @@ import { IAgentProfileService } from '#/agent/profile'; import { IAgentSwarmService } from '#/agent/swarm'; import { IAgentTurnService } from '#/agent/turn'; import { IAgentToolRegistryService } from '#/agent/toolRegistry'; +import type { IProcess, ISessionProcessRunner } from '#/session/process'; import type { QueuedSubagentRunResult, QueuedSubagentTask, - SessionSubagentHost, -} from '#/session/subagentHost'; +} from '#/agent/swarm'; import { recordingTelemetry, type TelemetryRecord } from '../telemetry/stubs'; -import { createFakeKaos } from '../tools/fixtures/fake-kaos'; +import { createFakeAgentFs, createFakeProcessRunner } from '../tools/fixtures/fake-exec'; import { configServices, appServices, - createCommandKaos, - kaosServices, + createCommandRunner, + execEnvServices, logServices, mcpServices, - subagentHostServices, + swarmServices, testAgent, type TestAgentOptions, type TestAgentServiceOverride, @@ -89,8 +88,8 @@ describe('Agent turn flow', () => { .mockImplementation((signal?: AbortSignal) => signal === undefined ? initialLoad : abortable(initialLoad, signal), ); - const { kaos, execWithEnv } = createExecKaos('mcp-ready'); - const ctx = testAgent(mcpServices({ manager: mcp }), kaosServices(kaos)); + const { runner, exec: execWithEnv } = createExecRunner('mcp-ready'); + const ctx = testAgent(mcpServices({ manager: mcp }), execEnvServices({ processRunner: runner })); ctx.get(IAgentMcpService); ctx.configure({ tools: ['Bash'] }); await ctx.rpc.setPermission({ mode: 'yolo' }); @@ -121,8 +120,8 @@ describe('Agent turn flow', () => { .mockImplementation((signal?: AbortSignal) => signal === undefined ? initialLoad : abortable(initialLoad, signal), ); - const { kaos, execWithEnv } = createExecKaos('should-not-run'); - const ctx = testAgent(mcpServices({ manager: mcp }), kaosServices(kaos)); + const { runner, exec: execWithEnv } = createExecRunner('should-not-run'); + const ctx = testAgent(mcpServices({ manager: mcp }), execEnvServices({ processRunner: runner })); ctx.get(IAgentMcpService); ctx.configure({ tools: ['Bash'] }); await ctx.rpc.setPermission({ mode: 'yolo' }); @@ -168,7 +167,7 @@ describe('Agent turn flow', () => { it('tracks duplicate tool-call detection telemetry', async () => { const records: TelemetryRecord[] = []; - const ctx = testAgent(kaosServices(createCommandKaos('dup')), { + const ctx = testAgent(execEnvServices({ processRunner: createCommandRunner('dup') }), { telemetry: recordingTelemetry(records), }); ctx.configure({ tools: ['Bash'] }); @@ -207,7 +206,7 @@ describe('Agent turn flow', () => { it('tracks cross-step duplicate tool-call detection telemetry', async () => { const records: TelemetryRecord[] = []; - const ctx = testAgent(kaosServices(createCommandKaos('dup')), { + const ctx = testAgent(execEnvServices({ processRunner: createCommandRunner('dup') }), { telemetry: recordingTelemetry(records), }); ctx.configure({ tools: ['Bash'] }); @@ -270,7 +269,7 @@ describe('Agent turn flow', () => { }, }, ); - const ctx = testAgent(kaosServices(createCommandKaos('dup')), { hookEngine }); + const ctx = testAgent(execEnvServices({ processRunner: createCommandRunner('dup') }), { hookEngine }); ctx.configure({ tools: ['Bash'] }); await ctx.rpc.setPermission({ mode: 'yolo' }); @@ -447,7 +446,7 @@ describe('Agent turn flow', () => { it('enters silent swarm mode when the agent calls AgentSwarm', async () => { const runQueued = vi.fn(async ( - tasks: readonly QueuedSubagentTask[], + { tasks }: { tasks: readonly QueuedSubagentTask[] }, ): Promise>> => { return tasks.map((task, index) => ({ task, @@ -456,10 +455,7 @@ describe('Agent turn flow', () => { result: `result ${String(index + 1)}`, })); }); - const subagentHost = mockSubagentHost({ - runQueued: runQueued as unknown as SessionSubagentHost['runQueued'], - }); - const ctx = testAgent(subagentHostServices(subagentHost)); + const ctx = testAgent(swarmServices(runQueued as never)); ctx.configure({ tools: ['AgentSwarm'] }); await ctx.rpc.setPermission({ mode: 'yolo' }); @@ -951,7 +947,7 @@ describe('Agent turn flow', () => { timeout: 5, }, ]); - const ctx = testAgent(kaosServices(createFakeKaos({ execWithEnv })), { + const ctx = testAgent(execEnvServices({ processRunner: createFakeProcessRunner({ exec: execWithEnv }) }), { hookEngine, }); const authorize = vi.spyOn(ctx.get(IAgentPermissionGate), 'authorize'); @@ -1357,7 +1353,7 @@ describe('Agent turn flow', () => { }); it('honors configured maxStepsPerTurn in agent turns', async () => { - const ctx = testAgent(kaosServices(createCommandKaos('loop-output')), { + const ctx = testAgent(execEnvServices({ processRunner: createCommandRunner('loop-output') }), { initialConfig: { providers: {}, loopControl: { maxStepsPerTurn: 1 }, @@ -1706,7 +1702,7 @@ describe('Agent turn flow', () => { throw new APIStatusError(401, 'Unauthorized', 'req-upload-401'); }), } as unknown as ChatProvider; - const ctx = testAgent(oauthOptions.services, kaosServices(createVideoKaos()), { + const ctx = testAgent(oauthOptions.services, execEnvServices({ agentFs: createVideoAgentFs() }), { initialConfig: oauthOptions.initialConfig, autoConfigure: false, }); @@ -1727,7 +1723,7 @@ describe('Agent turn flow', () => { }); const registration = registerMediaTools(ctx.get(IAgentToolRegistryService), { fs: ctx.get(ISessionAgentFileSystem), - kaos: ctx.get(IKaos), + env: ctx.get(IHostEnvironment), workspace: { workspaceDir: '/workspace', additionalDirs: [] }, capabilities: mediaCapabilities(), videoUploader, @@ -1756,7 +1752,7 @@ describe('Agent turn flow', () => { it('cancels an active turn', async () => { const records: TelemetryRecord[] = []; - const ctx = testAgent(kaosServices(createCommandKaos('should-not-run')), { + const ctx = testAgent(execEnvServices({ processRunner: createCommandRunner('should-not-run') }), { telemetry: recordingTelemetry(records), }); ctx.configure({ tools: ['Bash'] }); @@ -1804,7 +1800,7 @@ describe('Agent turn flow', () => { name: 'Bash', arguments: '{"command":"printf approved","timeout":60}', }; - const ctx = testAgent(kaosServices(createCommandKaos('approved'))); + const ctx = testAgent(execEnvServices({ processRunner: createCommandRunner('approved') })); ctx.configure({ tools: ['Bash'] }); ctx.mockNextResponse({ type: 'text', text: 'I will ask first.' }, bashCall); @@ -1878,7 +1874,7 @@ describe('Agent turn flow', () => { }); it('rejects a non-steer prompt while a turn is active', async () => { - const ctx = testAgent(kaosServices(createCommandKaos('should-not-run'))); + const ctx = testAgent(execEnvServices({ processRunner: createCommandRunner('should-not-run') })); ctx.configure({ tools: ['Bash'] }); ctx.mockNextResponse({ type: 'text', text: 'I will wait for approval.' }, bashCall()); @@ -1995,13 +1991,6 @@ function agentSwarmCall(): ToolCall { }; } -function mockSubagentHost>( - host: T, -): T & SessionSubagentHost { - return { spawn: vi.fn(), resume: vi.fn(), runQueued: vi.fn(), ...host } as unknown as T & - SessionSubagentHost; -} - interface ApiErrorTelemetryCase { readonly name: string; readonly createError: () => Error; @@ -2039,27 +2028,31 @@ const DEFAULT_MEDIA_STAT = { stCtime: 0, }; -function createExecKaos(output: string): { - readonly kaos: Kaos; - readonly execWithEnv: NonNullable; +function createExecRunner(output: string): { + readonly runner: ISessionProcessRunner; + readonly exec: ReturnType; } { - const execWithEnv = vi.fn>(async () => ({ - stdin: { write: vi.fn(), end: vi.fn() } as unknown as KaosProcess['stdin'], + const exec = vi.fn(async (): Promise => ({ + stdin: { write: vi.fn(), end: vi.fn() } as unknown as IProcess['stdin'], stdout: Readable.from([output]), stderr: Readable.from(['']), pid: 42, exitCode: 0, - wait: vi.fn().mockResolvedValue(0), - kill: vi.fn().mockResolvedValue(undefined), - dispose: vi.fn().mockResolvedValue(undefined), + wait: vi.fn().mockResolvedValue(0) as IProcess['wait'], + kill: vi.fn().mockResolvedValue(undefined) as IProcess['kill'], + dispose: vi.fn().mockResolvedValue(undefined) as IProcess['dispose'], })); - return { kaos: createFakeKaos({ execWithEnv }), execWithEnv }; + return { runner: createFakeProcessRunner({ exec }), exec }; } -function createVideoKaos(): Kaos { - return createFakeKaos({ - stat: vi.fn().mockResolvedValue(DEFAULT_MEDIA_STAT), - readBytes: vi.fn().mockResolvedValue(MP4_HEADER), +function createVideoAgentFs(): ISessionAgentFileSystem { + return createFakeAgentFs({ + stat: vi.fn(async () => ({ + isFile: true, + isDirectory: false, + size: MP4_HEADER.length, + })), + readBytes: vi.fn(async () => MP4_HEADER), }); }