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