diff --git a/packages/agent-core-v2/docs/di-scope-domains.puml b/packages/agent-core-v2/docs/di-scope-domains.puml
index f2cda5cab..54dcede08 100644
--- a/packages/agent-core-v2/docs/di-scope-domains.puml
+++ b/packages/agent-core-v2/docs/di-scope-domains.puml
@@ -102,7 +102,6 @@ package "Agent scope (per agent)" #FDF5E6 {
rectangle "rpc\nAgent\n IAgentRPCService" as rpc #FDEBD0
rectangle "fileTools\nAgent\n IAgentFileToolsService" as fileTools #FDEBD0
rectangle "shellTools\nAgent\n IAgentShellToolsService" as shellTools #FDEBD0
- rectangle "agentTool\nAgent\n IAgentToolService" as agentTool #FDEBD0
rectangle "scopeContext\nAgent\n IAgentScopeContext (seed)" as scopeContext #FDEBD0
}
@@ -367,7 +366,7 @@ externalHooks ..> turn #16A085 : prompt/end hooks
externalHooks ..> loop #16A085 : stop hook
externalHooks ..> fullCompaction #16A085 : compaction hooks
externalHooks ..> task #16A085 : notification hook
-externalHooks ..> agentTool #16A085 : subagent hooks
+agent_lifecycle ..> externalHooks #16A085 : SubagentStart/Stop (mirrorAgentRun)
toolStore ..> wireRecord #16A085 : tools.update_store
usage ..> wireRecord #16A085 : usage.record
contextSize ..> contextMemory #16A085 : hooks.onSpliced
diff --git a/packages/agent-core-v2/scripts/check-domain-layers.mjs b/packages/agent-core-v2/scripts/check-domain-layers.mjs
index 71cf65838..8e74f31fa 100644
--- a/packages/agent-core-v2/scripts/check-domain-layers.mjs
+++ b/packages/agent-core-v2/scripts/check-domain-layers.mjs
@@ -147,7 +147,6 @@ const DOMAIN_LAYER = new Map([
['microCompaction', 4],
['loop', 4],
['media', 4],
- ['shellTools', 4],
['llmRequester', 4],
['profile', 4],
['prompt', 4],
@@ -158,8 +157,6 @@ const DOMAIN_LAYER = new Map([
['agentTask', 5],
['mcp', 5],
['cron', 5],
- ['cronPersistence', 5],
- ['agentTool', 5],
['externalHooks', 5],
// `btw` forks a single side-question sub-agent via `agentLifecycle`,
// parallel to how the `Agent` tool spawns child agents. Agent-scope, L5.
@@ -302,7 +299,6 @@ const ALLOWED_EXCEPTIONS = new Set([
'replayBuilder>agentTask',
'replayBuilder>rpc',
'replayBuilder>sessionMetadata',
- 'shellTools>agentTask',
'skill>contextMemory',
'skill>prompt',
'swarm>sessionMetadata',
diff --git a/packages/agent-core-v2/src/agent/agentTool/agentToolService.ts b/packages/agent-core-v2/src/agent/agentTool/agentToolService.ts
deleted file mode 100644
index 7c391f48b..000000000
--- a/packages/agent-core-v2/src/agent/agentTool/agentToolService.ts
+++ /dev/null
@@ -1,35 +0,0 @@
-/**
- * `agentTool` domain (L5) — `IAgentToolService` implementation.
- *
- * Owns the Agent-scoped hook slots for child-agent start/stop lifecycle events.
- * The actual `Agent` tool is registered by `agentLifecycle` as a builtin tool;
- * this service remains as the observer boundary consumed by `externalHooks`.
- * Bound at Agent scope.
- */
-
-import { Disposable } from '#/_base/di';
-import { InstantiationType } from '#/_base/di/extensions';
-import { LifecycleScope, registerScopedService } from '#/_base/di/scope';
-import { OrderedHookSlot } from '#/hooks';
-
-import {
- IAgentToolService,
- type AgentToolDidRunSubagentContext,
- type AgentToolWillRunSubagentContext,
-} from './agentToolServiceToken';
-
-export class AgentToolService extends Disposable implements IAgentToolService {
- declare readonly _serviceBrand: undefined;
- readonly hooks: IAgentToolService['hooks'] = {
- onWillRunSubagent: new OrderedHookSlot(),
- onDidRunSubagent: new OrderedHookSlot(),
- };
-}
-
-registerScopedService(
- LifecycleScope.Agent,
- IAgentToolService,
- AgentToolService,
- InstantiationType.Eager,
- 'agentTool',
-);
diff --git a/packages/agent-core-v2/src/agent/agentTool/agentToolServiceToken.ts b/packages/agent-core-v2/src/agent/agentTool/agentToolServiceToken.ts
deleted file mode 100644
index 3d5679d26..000000000
--- a/packages/agent-core-v2/src/agent/agentTool/agentToolServiceToken.ts
+++ /dev/null
@@ -1,32 +0,0 @@
-/**
- * `agentTool` domain (L5) — hook service for the `Agent` collaboration tool.
- *
- * Exposes the Agent-scoped subagent lifecycle hooks that the `Agent` tool and
- * swarm scheduler emit, and that observer services such as `externalHooks`
- * consume. Bound at Agent scope.
- */
-
-import { createDecorator, type ServiceIdentifier } from '#/_base/di/instantiation';
-import type { Hooks } from '#/hooks';
-
-export interface AgentToolWillRunSubagentContext {
- readonly agentName: string;
- readonly prompt: string;
- readonly signal: AbortSignal;
-}
-
-export interface AgentToolDidRunSubagentContext {
- readonly agentName: string;
- readonly response: string;
-}
-
-export interface IAgentToolService {
- readonly _serviceBrand: undefined;
- readonly hooks: Hooks<{
- onWillRunSubagent: AgentToolWillRunSubagentContext;
- onDidRunSubagent: AgentToolDidRunSubagentContext;
- }>;
-}
-
-export const IAgentToolService: ServiceIdentifier =
- createDecorator('agentToolService');
diff --git a/packages/agent-core-v2/src/agent/agentTool/index.ts b/packages/agent-core-v2/src/agent/agentTool/index.ts
deleted file mode 100644
index 5ff51b9be..000000000
--- a/packages/agent-core-v2/src/agent/agentTool/index.ts
+++ /dev/null
@@ -1,9 +0,0 @@
-/**
- * `agentTool` domain barrel — re-exports the subagent lifecycle hook service
- * contract (`agentToolServiceToken`) and its scoped service
- * (`agentToolService`). Importing this barrel registers the
- * `IAgentToolService` binding into the scope registry.
- */
-
-export * from './agentToolServiceToken';
-export * from './agentToolService';
diff --git a/packages/agent-core-v2/src/agent/contextMemory/notification-xml.ts b/packages/agent-core-v2/src/agent/contextMemory/notification-xml.ts
index 05e217d8c..e4ef3275e 100644
--- a/packages/agent-core-v2/src/agent/contextMemory/notification-xml.ts
+++ b/packages/agent-core-v2/src/agent/contextMemory/notification-xml.ts
@@ -14,7 +14,7 @@
* consumers that detect chat-history injections.
*
* `agent_id` is emitted only for task notifications whose
- * source task is an agent subagent — surfacing it structurally lets the
+ * source task is an agent run — surfacing it structurally lets the
* LLM identify the correct id to pass to `Agent(resume=...)` without
* having to grep the body or the original spawn-success ToolResult.
* It is intentionally a separate attribute from `source_id`: the two
diff --git a/packages/agent-core-v2/src/agent/cron/index.ts b/packages/agent-core-v2/src/agent/cron/index.ts
deleted file mode 100644
index 43696308a..000000000
--- a/packages/agent-core-v2/src/agent/cron/index.ts
+++ /dev/null
@@ -1,17 +0,0 @@
-/**
- * `cron` domain barrel — re-exports cron utilities (expression parser, jitter,
- * format, clock, config) and registers the three cron tools (`CronCreate` /
- * `CronList` / `CronDelete`) via side-effect imports. The cron task record
- * type lives in `app/cronPersistence`; the scheduling engine lives in `session/cron`.
- */
-
-import './configSection';
-import './tools/cron-create';
-import './tools/cron-delete';
-import './tools/cron-list';
-
-export * from './cron-expr';
-export * from './format';
-export * from './jitter';
-export * from './clock';
-export { CRON_SECTION, type CronConfig, DEFAULT_CRON_CONFIG } from './configSection';
diff --git a/packages/agent-core-v2/src/agent/externalHooks/externalHooks.ts b/packages/agent-core-v2/src/agent/externalHooks/externalHooks.ts
index 7e56a5820..8ad87a374 100644
--- a/packages/agent-core-v2/src/agent/externalHooks/externalHooks.ts
+++ b/packages/agent-core-v2/src/agent/externalHooks/externalHooks.ts
@@ -22,8 +22,30 @@ export interface ExternalHooksServiceOptions {
| undefined;
}
+export interface AgentTaskStartHookContext {
+ readonly agentName: string;
+ readonly prompt: string;
+ readonly signal: AbortSignal;
+}
+
+export interface AgentTaskStopHookContext {
+ readonly agentName: string;
+ readonly response: string;
+}
+
export interface IAgentExternalHooksService {
readonly _serviceBrand: undefined;
+ /**
+ * Run the blocking `SubagentStart` external hook for an agent task this
+ * agent is launching (via the `Agent` tool / swarm). Called directly by the
+ * `agentLifecycle` tool wrapper (`mirrorAgentRun`) — the wrapper is a thin
+ * layer over the lifecycle with no hook service of its own, so this is the
+ * one context invoked explicitly rather than observed. Throws when a
+ * configured hook blocks.
+ */
+ runAgentTaskStart(ctx: AgentTaskStartHookContext): Promise;
+ /** Fire-and-forget `SubagentStop` external hook counterpart. */
+ notifyAgentTaskStop(ctx: AgentTaskStopHookContext): void;
}
export const IAgentExternalHooksService =
diff --git a/packages/agent-core-v2/src/agent/externalHooks/externalHooksService.ts b/packages/agent-core-v2/src/agent/externalHooks/externalHooksService.ts
index edf8298e9..94330758e 100644
--- a/packages/agent-core-v2/src/agent/externalHooks/externalHooksService.ts
+++ b/packages/agent-core-v2/src/agent/externalHooks/externalHooksService.ts
@@ -3,9 +3,12 @@
* hook commands.
*
* Listens to hook slots owned by the agent behavior/lifecycle domains
- * (`toolExecutor`, `permissionGate`, `turn`, `loop`, `fullCompaction`,
- * `task`, and `agentTool`) and translates those minimal contexts into
- * the configured external HookEngine events.
+ * (`toolExecutor`, `permissionGate`, `turn`, `loop`, `fullCompaction`, and
+ * `task`) and translates those minimal contexts into the configured external
+ * HookEngine events. The `SubagentStart` / `SubagentStop` pair is the one
+ * exception: the `agentLifecycle` tool wrapper has no hook service of its own,
+ * so `mirrorAgentRun` invokes `runAgentTaskStart` / `notifyAgentTaskStop` on
+ * this service directly.
*/
import { Disposable, IInstantiationService } from '#/_base/di';
@@ -13,11 +16,6 @@ import { InstantiationType } from '#/_base/di/extensions';
import { LifecycleScope, registerScopedService } from '#/_base/di/scope';
import { isUserCancellation } from '#/_base/utils/abort';
import { isPlainRecord } from '#/_base/utils/canonical-args';
-import type {
- AgentToolDidRunSubagentContext,
- AgentToolWillRunSubagentContext,
-} from '#/agent/agentTool';
-import { IAgentToolService } from '#/agent/agentTool';
import { IAgentTaskService, type AgentTaskNotificationContext } from '#/agent/task';
import {
IAgentFullCompactionService,
@@ -50,6 +48,8 @@ import { HOOKS_SECTION, type HookDefConfig } from './configSection';
import { HookEngine } from './engine';
import {
IAgentExternalHooksService,
+ type AgentTaskStartHookContext,
+ type AgentTaskStopHookContext,
type ExternalHooksServiceOptions,
} from './externalHooks';
import {
@@ -128,9 +128,6 @@ export class AgentExternalHooksService extends Disposable implements IAgentExter
this.instantiation.invokeFunction((accessor) => accessor.get(IAgentTaskService)),
);
- this.registerAgentToolHooks(
- this.instantiation.invokeFunction((accessor) => accessor.get(IAgentToolService)),
- );
}
private registerToolHooks(toolExecutor: IAgentToolExecutorService): void {
@@ -237,21 +234,6 @@ export class AgentExternalHooksService extends Disposable implements IAgentExter
);
}
- private registerAgentToolHooks(agentTool: IAgentToolService): void {
- this._register(
- agentTool.hooks.onWillRunSubagent.register('externalHooks', async (ctx, next) => {
- await this.runSubagentStart(ctx);
- await next();
- }),
- );
- this._register(
- agentTool.hooks.onDidRunSubagent.register('externalHooks', async (ctx, next) => {
- this.notifySubagentStop(ctx);
- await next();
- }),
- );
- }
-
private async loadDynamicHooks(): Promise {
await this.config.ready;
const configured = this.config.get(HOOKS_SECTION) as readonly HookDefConfig[] | undefined;
@@ -390,7 +372,7 @@ export class AgentExternalHooksService extends Disposable implements IAgentExter
);
}
- private async runSubagentStart(ctx: AgentToolWillRunSubagentContext): Promise {
+ async runAgentTaskStart(ctx: AgentTaskStartHookContext): Promise {
ctx.signal.throwIfAborted();
await this.engine()?.trigger('SubagentStart', {
matcherValue: ctx.agentName,
@@ -403,7 +385,7 @@ export class AgentExternalHooksService extends Disposable implements IAgentExter
ctx.signal.throwIfAborted();
}
- private notifySubagentStop(ctx: AgentToolDidRunSubagentContext): void {
+ notifyAgentTaskStop(ctx: AgentTaskStopHookContext): void {
void this.engine()?.fireAndForgetTrigger('SubagentStop', {
matcherValue: ctx.agentName,
inputData: {
diff --git a/packages/agent-core-v2/src/agent/permissionGate/permissionGateService.ts b/packages/agent-core-v2/src/agent/permissionGate/permissionGateService.ts
index bde248f68..db71a8964 100644
--- a/packages/agent-core-v2/src/agent/permissionGate/permissionGateService.ts
+++ b/packages/agent-core-v2/src/agent/permissionGate/permissionGateService.ts
@@ -258,20 +258,25 @@ export class AgentPermissionGate extends Disposable implements IAgentPermissionG
result.decision === 'cancelled'
? `Tool "${toolName}" was not run because the approval request was cancelled.`
: `Tool "${toolName}" was not run because the user rejected the approval request.`;
- if (this.isSubagent()) {
+ if (this.usesWorkerRejectionGuidance()) {
return `${prefix}${suffix} Try a different approach — don't retry the same call, don't attempt to bypass the restriction.`;
}
return `${prefix}${suffix}`;
}
private formatDenyMessage(message: string): string {
- if (this.isSubagent()) {
+ if (this.usesWorkerRejectionGuidance()) {
return `${message} Try a different approach — don't retry the same call, don't attempt to bypass the restriction.`;
}
return message;
}
- private isSubagent(): boolean {
+ /**
+ * Rejection messages for agents driven by another agent (no user in the
+ * loop) carry extra "don't retry / don't bypass" guidance. Heuristic: any
+ * agent other than `main` is treated as worker-driven.
+ */
+ private usesWorkerRejectionGuidance(): boolean {
return this.options.agentId !== undefined && this.options.agentId !== 'main';
}
}
diff --git a/packages/agent-core-v2/src/agent/profile/profile.ts b/packages/agent-core-v2/src/agent/profile/profile.ts
index 002af87ee..84f7db6b7 100644
--- a/packages/agent-core-v2/src/agent/profile/profile.ts
+++ b/packages/agent-core-v2/src/agent/profile/profile.ts
@@ -1,3 +1,4 @@
+import type { AgentProfile, AgentProfileContext } from '#/app/agentProfileCatalog';
import type { ModelCapability, ThinkingEffort } from '#/app/llmProtocol';
import type { Model } from '#/app/model';
@@ -28,27 +29,26 @@ export type AgentConfigUpdateData = Partial<{
systemPrompt: string;
}>;
-export interface SystemPromptContext {
- readonly cwd?: string;
- /** 2-level tree listing of the working directory, for LLM orientation. */
- readonly cwdListing?: string;
- /** Concatenated AGENTS.md instruction hierarchy (user-level + project-level). */
- readonly agentsMd?: string;
- /** Rendered listings of additional workspace directories. */
- readonly additionalDirsInfo?: string;
+/**
+ * Runtime context supplied to a profile's system-prompt renderer. Extends the
+ * catalog's {@link AgentProfileContext} (host OS/shell, cwd, AGENTS.md, skills,
+ * …) with the AGENTS.md size warning produced by `prepareSystemPromptContext`.
+ */
+export interface SystemPromptContext extends AgentProfileContext {
/**
* Present when the combined AGENTS.md content exceeds the recommended soft
* budget. Surfaced through `getSessionWarnings` instead of truncating.
*/
readonly agentsMdWarning?: string;
- readonly [key: string]: unknown;
}
-export interface ResolvedAgentProfile {
- readonly name: string;
- readonly tools: readonly string[];
- systemPrompt(context: SystemPromptContext): string;
-}
+/**
+ * Resolved profile consumed by {@link IAgentProfileService.useProfile} /
+ * {@link IAgentProfileService.applyProfile}. Alias of the catalog's
+ * {@link AgentProfile} — a profile is self-contained (full system prompt +
+ * tools), so the per-agent binding and the profile catalog share one type.
+ */
+export type ResolvedAgentProfile = AgentProfile;
export interface ProfileData extends AgentConfigData {
readonly activeToolNames?: readonly string[];
@@ -92,11 +92,39 @@ export interface ProfileSetModelResult {
readonly providerName?: string | undefined;
}
+/**
+ * Atomic binding input: a named Profile plus a Model id/alias. Binding the two
+ * (with optional run config) is what makes an Agent runnable — `Profile +
+ * Model ⇒ Agent`. `profile` defaults to the catalog's default profile when the
+ * caller only supplies a model (see {@link IAgentProfileService.setModel}).
+ */
+export interface BindAgentInput {
+ /** Profile name from `IAgentProfileCatalogService` (e.g. 'agent', 'explore'). */
+ readonly profile: string;
+ /** Model id or routing alias resolved through `IModelResolver`. */
+ readonly model: string;
+ readonly thinking?: string;
+ readonly cwd?: string;
+}
+
export interface IAgentProfileService {
readonly _serviceBrand: undefined;
configure(options: ProfileServiceOptions): void;
update(changed: ProfileUpdateData): void;
- setModel(model: string): ProfileSetModelResult;
+ /**
+ * Atomically bind a Profile + Model (plus optional run config) to this agent,
+ * rendering the profile's system prompt and activating its tool set. This is
+ * the production entry point that turns an agent scope into a runnable Agent.
+ * Throws `PROFILE_NOT_FOUND` / `MODEL_NOT_CONFIGURED` on unknown inputs.
+ */
+ bind(input: BindAgentInput): Promise;
+ /**
+ * Bind (or switch) the active Model. When no Profile is bound yet, the
+ * catalog's default profile is bound first (rendering its system prompt and
+ * tool set), so a fresh agent becomes runnable on its first `setModel`.
+ * Subsequent calls swap the model while keeping the existing profile.
+ */
+ setModel(model: string): Promise;
setThinking(level: string): void;
getModel(): string;
useProfile(profile: ResolvedAgentProfile, context: SystemPromptContext): void;
@@ -135,6 +163,8 @@ export interface IAgentProfileService {
getModelCapabilities(): ModelCapability;
getMaxOutputSize(): number | undefined;
hasModel(): boolean;
+ /** True when both a Profile and a Model are bound — i.e. the agent can run a turn. */
+ isRunnable(): boolean;
hasProvider(): boolean;
getSystemPrompt(): string;
getActiveToolNames(): readonly string[] | undefined;
diff --git a/packages/agent-core-v2/src/agent/profile/profileService.ts b/packages/agent-core-v2/src/agent/profile/profileService.ts
index ec577d27d..d16339291 100644
--- a/packages/agent-core-v2/src/agent/profile/profileService.ts
+++ b/packages/agent-core-v2/src/agent/profile/profileService.ts
@@ -15,6 +15,10 @@ import {
type ModelCapability,
type ThinkingEffort,
} from '#/app/llmProtocol';
+import {
+ DEFAULT_AGENT_PROFILE_NAME,
+ IAgentProfileCatalogService,
+} from '#/app/agentProfileCatalog';
import { IModelResolver, type KimiModelOverrides, type Model } from '#/app/model';
import picomatch from 'picomatch';
@@ -28,6 +32,7 @@ import { ISessionAgentFileSystem } from '#/session/agentFs';
import { IExecContext } from '#/session/execContext';
import { isMcpToolName } from '#/agent/tool';
import { ISessionWorkspaceContext } from '#/session/workspaceContext';
+import { ISessionSkillCatalog } from '#/session/sessionSkillCatalog';
import type { ResolvedAgentProfile, SystemPromptContext } from '#/agent/profile';
import { IAgentRecordService, type AgentRecord } from '#/agent/record';
@@ -36,6 +41,7 @@ import type { ToolSource } from '#/agent/tool';
import { prepareSystemPromptContext } from './context';
import type {
ApplyProfileOptions,
+ BindAgentInput,
ProfileData,
ProfileModelContext,
ProfileServiceOptions,
@@ -79,6 +85,8 @@ export class AgentProfileService implements IAgentProfileService {
@IExecContext private readonly execCtx: IExecContext,
@IBootstrapService private readonly bootstrap: IBootstrapService,
@ISessionWorkspaceContext private readonly workspace: ISessionWorkspaceContext,
+ @IAgentProfileCatalogService private readonly catalog: IAgentProfileCatalogService,
+ @ISessionSkillCatalog private readonly skillCatalog: ISessionSkillCatalog,
) {
this.configure({});
record.define('config.update', {
@@ -116,9 +124,44 @@ export class AgentProfileService implements IAgentProfileService {
}
}
- setModel(alias: string): ProfileSetModelResult {
+ async bind(input: BindAgentInput): Promise {
+ const profile = this.catalog.get(input.profile);
+ if (profile === undefined) {
+ throw new Error(`Unknown agent profile: "${input.profile}"`);
+ }
+ // Resolve eagerly so an unknown model id fails the bind here rather than on
+ // the first turn.
+ this.modelFactory.resolve(input.model);
+
+ const context = await this.buildSystemPromptContext(input.cwd);
+ const systemPrompt = profile.systemPrompt(context);
+ const { agentsMdWarning } = context;
+ this.agentsMdWarning = agentsMdWarning;
+
+ this.update({
+ cwd: input.cwd,
+ profileName: profile.name,
+ modelAlias: input.model,
+ thinkingLevel: input.thinking,
+ systemPrompt,
+ activeToolNames: profile.tools,
+ });
+
+ if (agentsMdWarning !== undefined) {
+ this.record.signal({
+ type: 'warning',
+ message: agentsMdWarning,
+ code: 'agents-md-oversized',
+ });
+ }
+ }
+
+ async setModel(alias: string): Promise {
const model = this.modelFactory.resolve(alias);
- if (this.modelAlias !== alias) {
+ if (this.profileName === undefined) {
+ await this.bind({ profile: DEFAULT_AGENT_PROFILE_NAME, model: alias });
+ this.telemetry.track('model_switch', { model: alias });
+ } else if (this.modelAlias !== alias) {
this.update({ modelAlias: alias });
this.telemetry.track('model_switch', { model: alias });
}
@@ -240,6 +283,10 @@ export class AgentProfileService implements IAgentProfileService {
return this.modelAlias !== undefined;
}
+ isRunnable(): boolean {
+ return this.profileName !== undefined && this.hasModel();
+ }
+
hasProvider(): boolean {
return this.tryResolveRawModel() !== undefined;
}
@@ -353,6 +400,35 @@ export class AgentProfileService implements IAgentProfileService {
}
}
+ private async buildSystemPromptContext(cwd?: string): Promise {
+ const effectiveCwd = cwd ?? this.execCtx.cwd;
+ const base = await prepareSystemPromptContext(
+ { fs: this.fs, homeDir: this.env.homeDir },
+ effectiveCwd,
+ this.bootstrap.homeDir,
+ { additionalDirs: this.workspace.additionalDirs },
+ );
+ const skills = await this.resolveSkillListing();
+ return {
+ ...base,
+ cwd: effectiveCwd,
+ osKind: this.env.osKind,
+ shellName: this.env.shellName,
+ shellPath: this.env.shellPath,
+ now: new Date().toISOString(),
+ skills,
+ };
+ }
+
+ private async resolveSkillListing(): Promise {
+ try {
+ await this.skillCatalog.ready;
+ return this.skillCatalog.catalog.getModelSkillListing();
+ } catch {
+ return '';
+ }
+ }
+
private readConfiguredCwd(): string | undefined {
const cwd = this.optionsValue.cwd;
return typeof cwd === 'function' ? cwd() : cwd;
diff --git a/packages/agent-core-v2/src/agent/replayBuilder/types.ts b/packages/agent-core-v2/src/agent/replayBuilder/types.ts
index 12e5d7ed5..5a3a8a726 100644
--- a/packages/agent-core-v2/src/agent/replayBuilder/types.ts
+++ b/packages/agent-core-v2/src/agent/replayBuilder/types.ts
@@ -11,6 +11,11 @@ import type { SessionSummary } from '#/agent/rpc/core-api';
import type { UsageStatus } from '@moonshot-ai/protocol';
import type { SessionMeta } from '#/session/sessionMetadata';
+/**
+ * Wire projection of the agent's role in the resume DTO: `'main'` when
+ * `agentId === 'main'`, `'sub'` otherwise. Wire values kept for node-sdk
+ * compatibility; not a business concept.
+ */
type AgentType = 'main' | 'sub';
export type AgentReplayRecordPayload =
diff --git a/packages/agent-core-v2/src/agent/shellTools/index.ts b/packages/agent-core-v2/src/agent/shellTools/index.ts
deleted file mode 100644
index ff4474c16..000000000
--- a/packages/agent-core-v2/src/agent/shellTools/index.ts
+++ /dev/null
@@ -1,10 +0,0 @@
-/**
- * `shellTools` domain barrel — re-exports the built-in Bash tool, its process
- * task adapter, and the shared output `ToolResultBuilder`. The Bash tool self-registers via
- * `registerTool(BashTool)` at module load, so importing this barrel is what
- * wires it into every Agent-scope tool registry.
- */
-
-export * from '#/agent/shellTools/tools/bash';
-export * from '#/agent/shellTools/tools/process-task';
-export * from '#/agent/shellTools/tools/result-builder';
diff --git a/packages/agent-core-v2/src/agent/shellTools/tools/result-builder.ts b/packages/agent-core-v2/src/agent/shellTools/tools/result-builder.ts
deleted file mode 100644
index b4e55ba20..000000000
--- a/packages/agent-core-v2/src/agent/shellTools/tools/result-builder.ts
+++ /dev/null
@@ -1,5 +0,0 @@
-export {
- ToolResultBuilder,
- type ExecutableToolResultBuilderResult,
- type ToolResultBuilderOptions,
-} from '#/agent/tool/result-builder';
diff --git a/packages/agent-core-v2/src/app/agentProfileCatalog/agentProfileCatalog.ts b/packages/agent-core-v2/src/app/agentProfileCatalog/agentProfileCatalog.ts
index 8a5337178..f0f494056 100644
--- a/packages/agent-core-v2/src/app/agentProfileCatalog/agentProfileCatalog.ts
+++ b/packages/agent-core-v2/src/app/agentProfileCatalog/agentProfileCatalog.ts
@@ -1,19 +1,25 @@
/**
- * `agentProfileCatalog` domain (L3) — App-scope registry of named agent profiles
- * that a parent Agent can invoke a child Agent under.
+ * `agentProfileCatalog` domain (L3) — App-scope registry of named agent
+ * profiles.
*
- * A profile is "how an Agent runs": which tools are active, what system prompt
- * overlay is applied on top of the caller's prompt, an optional per-invocation
- * prompt prefix (e.g. explore's git-context block), and an optional summary
- * distillation policy (min chars + continuation prompt) applied when a caller
- * awaits the child's turn output.
+ * A profile is "how an Agent runs": the full system prompt it renders for a
+ * given context, the tool set it may use, plus optional per-invocation and
+ * summary-distillation behavior for child agents. A profile is model-agnostic:
+ * the same profile can be bound to any Model. Together with a bound Model, a
+ * profile uniquely determines an Agent's behavior (`Profile + Model ⇒ Agent`).
+ *
+ * Every profile is self-contained: `systemPrompt(context)` returns the complete
+ * prompt (base + role overlay are merged at definition time, not at spawn
+ * time). The builtin {@link DEFAULT_AGENT_PROFILE_NAME} (`agent`) is the default
+ * profile used when an Agent is bound to a Model without naming a profile.
*
* Profiles are contributed at module load via `registerAgentProfile(...)`, the
* same "import = register" pattern used by `registerTool` and
* `registerConfigSection`. `AgentProfileCatalogService` consumes the accumulated
- * contributions on construction and exposes `get(name)` / `list()` to callers
- * (currently the `Agent` tool). Contributions are keyed by `name`; a
- * later-registered profile with the same name overrides an earlier one.
+ * contributions on construction and exposes `get(name)` / `getDefault()` /
+ * `list()` to callers (the `Agent` tool, the swarm scheduler, and the per-agent
+ * profile binding). Contributions are keyed by `name`; a later-registered
+ * profile with the same name overrides an earlier one.
*/
import { createDecorator, type ServiceIdentifier } from '#/_base/di/instantiation';
@@ -21,6 +27,9 @@ import { createDecorator, type ServiceIdentifier } from '#/_base/di/instantiatio
import type { ILogger } from '#/app/log';
import type { ISessionProcessRunner } from '#/session/process';
+/** Name of the builtin default profile (the top-level interactive agent). */
+export const DEFAULT_AGENT_PROFILE_NAME = 'agent';
+
export interface AgentProfilePromptPrefixContext {
readonly cwd: string;
readonly runner: ISessionProcessRunner;
@@ -38,24 +47,45 @@ export interface AgentProfileSummaryPolicy {
readonly retries: number;
}
-export interface AgentProfileDefinition {
+/**
+ * Runtime context supplied to a profile's system-prompt renderer. Captures
+ * everything determined at render time (working dir, AGENTS.md, host OS/shell,
+ * skills, …). Assembled by the `profile` domain and passed into
+ * {@link AgentProfile.systemPrompt}.
+ */
+export interface AgentProfileContext {
+ readonly cwd?: string;
+ /** 2-level tree listing of the working directory, for LLM orientation. */
+ readonly cwdListing?: string;
+ /** Concatenated AGENTS.md instruction hierarchy (user-level + project-level). */
+ readonly agentsMd?: string;
+ /** Rendered listings of additional workspace directories. */
+ readonly additionalDirsInfo?: string;
+ /** Host OS family (`macOS` / `Linux` / `Windows` / raw platform). */
+ readonly osKind?: string;
+ readonly shellName?: string;
+ readonly shellPath?: string;
+ /** ISO timestamp captured at render time. */
+ readonly now?: string;
+ /** Rendered model-facing listing of available skills. */
+ readonly skills?: string;
+ readonly [key: string]: unknown;
+}
+
+export interface AgentProfile {
/** Stable identifier; must be unique across contributions. */
readonly name: string;
/** Short human-readable label; surfaced to the caller (LLM) as "Available agent types". */
readonly description?: string;
/** When-to-use hint appended to `description` in the caller's tool spec. */
readonly whenToUse?: string;
+ /** Tool names (and MCP glob patterns) the agent may use under this profile. */
+ readonly tools: readonly string[];
/**
- * Text appended to the parent's system prompt when a child agent is spawned
- * under this profile. Undefined = use parent's system prompt verbatim.
+ * Render the complete system prompt for this profile given the runtime
+ * context. Self-contained — includes the base prompt and any role overlay.
*/
- readonly systemPromptOverlay?: string;
- /**
- * Tool names the child agent may use. Undefined = inherit the parent's
- * active tool set (`coder` behaves this way for the special case where the
- * parent is also a `coder`).
- */
- readonly activeToolNames?: readonly string[];
+ systemPrompt(context: AgentProfileContext): string;
/**
* Optional per-invocation prompt prefix produced from the caller's context
* (e.g. `explore`'s `` block). Prepended to the caller-supplied
@@ -73,9 +103,15 @@ export interface AgentProfileDefinition {
export interface IAgentProfileCatalogService {
readonly _serviceBrand: undefined;
/** Return the profile with the given name, or `undefined` when unknown. */
- get(name: string): AgentProfileDefinition | undefined;
+ get(name: string): AgentProfile | undefined;
+ /**
+ * Return the builtin default profile ({@link DEFAULT_AGENT_PROFILE_NAME}).
+ * Throws when no default profile is registered (a programming-time invariant
+ * violation, not a request failure).
+ */
+ getDefault(): AgentProfile;
/** Enumerate every registered profile. Stable order (insertion order). */
- list(): readonly AgentProfileDefinition[];
+ list(): readonly AgentProfile[];
}
export const IAgentProfileCatalogService: ServiceIdentifier =
diff --git a/packages/agent-core-v2/src/app/agentProfileCatalog/agentProfileCatalogService.ts b/packages/agent-core-v2/src/app/agentProfileCatalog/agentProfileCatalogService.ts
index 8801cd6fe..8654762ee 100644
--- a/packages/agent-core-v2/src/app/agentProfileCatalog/agentProfileCatalogService.ts
+++ b/packages/agent-core-v2/src/app/agentProfileCatalog/agentProfileCatalogService.ts
@@ -4,21 +4,24 @@
* Snapshots the module-level contributions on construction. Register-after-
* construction is not supported: like `IAgentToolRegistryService`, the
* expectation is that contributions accumulate at import time before the
- * container resolves the service.
+ * container resolves the service. `getDefault()` throws a plain `Error` when
+ * the builtin default profile is missing — that is a programming-time
+ * invariant violation, not a request failure, so it does not warrant a wire
+ * error code.
*/
import { InstantiationType } from '#/_base/di/extensions';
import { LifecycleScope, registerScopedService } from '#/_base/di/scope';
-import type { AgentProfileDefinition } from './agentProfileCatalog';
-import { IAgentProfileCatalogService } from './agentProfileCatalog';
+import type { AgentProfile } from './agentProfileCatalog';
+import { DEFAULT_AGENT_PROFILE_NAME, IAgentProfileCatalogService } from './agentProfileCatalog';
import { getAgentProfileContributions } from './contribution';
export class AgentProfileCatalogService implements IAgentProfileCatalogService {
declare readonly _serviceBrand: undefined;
- private readonly byName: Map;
- private readonly ordered: readonly AgentProfileDefinition[];
+ private readonly byName: Map;
+ private readonly ordered: readonly AgentProfile[];
constructor() {
const contributions = getAgentProfileContributions();
@@ -26,11 +29,21 @@ export class AgentProfileCatalogService implements IAgentProfileCatalogService {
this.byName = new Map(this.ordered.map((def) => [def.name, def]));
}
- get(name: string): AgentProfileDefinition | undefined {
+ get(name: string): AgentProfile | undefined {
return this.byName.get(name);
}
- list(): readonly AgentProfileDefinition[] {
+ getDefault(): AgentProfile {
+ const profile = this.byName.get(DEFAULT_AGENT_PROFILE_NAME);
+ if (profile === undefined) {
+ throw new Error(
+ `Default agent profile "${DEFAULT_AGENT_PROFILE_NAME}" is not registered`,
+ );
+ }
+ return profile;
+ }
+
+ list(): readonly AgentProfile[] {
return this.ordered;
}
}
diff --git a/packages/agent-core-v2/src/app/agentProfileCatalog/contribution.ts b/packages/agent-core-v2/src/app/agentProfileCatalog/contribution.ts
index 6541856f0..5e894a3b5 100644
--- a/packages/agent-core-v2/src/app/agentProfileCatalog/contribution.ts
+++ b/packages/agent-core-v2/src/app/agentProfileCatalog/contribution.ts
@@ -9,11 +9,11 @@
* can override built-ins by re-registering.
*/
-import type { AgentProfileDefinition } from './agentProfileCatalog';
+import type { AgentProfile } from './agentProfileCatalog';
-const _profileContributions: AgentProfileDefinition[] = [];
+const _profileContributions: AgentProfile[] = [];
-export function registerAgentProfile(definition: AgentProfileDefinition): void {
+export function registerAgentProfile(definition: AgentProfile): void {
const existingIndex = _profileContributions.findIndex((d) => d.name === definition.name);
if (existingIndex >= 0) {
_profileContributions.splice(existingIndex, 1);
@@ -21,7 +21,7 @@ export function registerAgentProfile(definition: AgentProfileDefinition): void {
_profileContributions.push(definition);
}
-export function getAgentProfileContributions(): readonly AgentProfileDefinition[] {
+export function getAgentProfileContributions(): readonly AgentProfile[] {
return _profileContributions;
}
diff --git a/packages/agent-core-v2/src/app/agentProfileCatalog/index.ts b/packages/agent-core-v2/src/app/agentProfileCatalog/index.ts
index d3eff9b1c..0d5a6f59e 100644
--- a/packages/agent-core-v2/src/app/agentProfileCatalog/index.ts
+++ b/packages/agent-core-v2/src/app/agentProfileCatalog/index.ts
@@ -9,6 +9,7 @@
export * from './agentProfileCatalog';
export * from './agentProfileCatalogService';
export * from './profile-shared';
+export * from './promptPrefix';
export {
registerAgentProfile,
getAgentProfileContributions,
diff --git a/packages/agent-core-v2/src/app/agentProfileCatalog/promptPrefix.ts b/packages/agent-core-v2/src/app/agentProfileCatalog/promptPrefix.ts
new file mode 100644
index 000000000..abf91e3ef
--- /dev/null
+++ b/packages/agent-core-v2/src/app/agentProfileCatalog/promptPrefix.ts
@@ -0,0 +1,28 @@
+/**
+ * `agentProfileCatalog` domain (L3) — profile prompt-prefix helper.
+ *
+ * Applies a profile's optional per-invocation `promptPrefix` (e.g. `explore`'s
+ * `` block) to a caller-supplied prompt. Best-effort: a thrown
+ * error or empty prefix leaves the prompt unchanged. Shared by every launcher
+ * that instantiates an agent from a profile (the `Agent` tool, the swarm
+ * scheduler).
+ */
+
+import type {
+ AgentProfile,
+ AgentProfilePromptPrefixContext,
+} from './agentProfileCatalog';
+
+export async function applyProfilePromptPrefix(
+ profile: AgentProfile,
+ prompt: string,
+ ctx: AgentProfilePromptPrefixContext,
+): Promise {
+ if (profile.promptPrefix === undefined) return prompt;
+ try {
+ const prefix = await profile.promptPrefix(ctx);
+ return prefix.length > 0 ? `${prefix}\n\n${prompt}` : prompt;
+ } catch {
+ return prompt;
+ }
+}
diff --git a/packages/agent-core-v2/src/agent/cron/clock.ts b/packages/agent-core-v2/src/app/cron/clock.ts
similarity index 100%
rename from packages/agent-core-v2/src/agent/cron/clock.ts
rename to packages/agent-core-v2/src/app/cron/clock.ts
diff --git a/packages/agent-core-v2/src/agent/cron/configSection.ts b/packages/agent-core-v2/src/app/cron/configSection.ts
similarity index 100%
rename from packages/agent-core-v2/src/agent/cron/configSection.ts
rename to packages/agent-core-v2/src/app/cron/configSection.ts
diff --git a/packages/agent-core-v2/src/agent/cron/cron-expr.ts b/packages/agent-core-v2/src/app/cron/cron-expr.ts
similarity index 100%
rename from packages/agent-core-v2/src/agent/cron/cron-expr.ts
rename to packages/agent-core-v2/src/app/cron/cron-expr.ts
diff --git a/packages/agent-core-v2/src/app/cronPersistence/cronTask.ts b/packages/agent-core-v2/src/app/cron/cronTask.ts
similarity index 100%
rename from packages/agent-core-v2/src/app/cronPersistence/cronTask.ts
rename to packages/agent-core-v2/src/app/cron/cronTask.ts
diff --git a/packages/agent-core-v2/src/app/cronPersistence/cronTaskPersistence.ts b/packages/agent-core-v2/src/app/cron/cronTaskPersistence.ts
similarity index 100%
rename from packages/agent-core-v2/src/app/cronPersistence/cronTaskPersistence.ts
rename to packages/agent-core-v2/src/app/cron/cronTaskPersistence.ts
diff --git a/packages/agent-core-v2/src/app/cronPersistence/cronTaskPersistenceService.ts b/packages/agent-core-v2/src/app/cron/cronTaskPersistenceService.ts
similarity index 100%
rename from packages/agent-core-v2/src/app/cronPersistence/cronTaskPersistenceService.ts
rename to packages/agent-core-v2/src/app/cron/cronTaskPersistenceService.ts
diff --git a/packages/agent-core-v2/src/agent/cron/format.ts b/packages/agent-core-v2/src/app/cron/format.ts
similarity index 100%
rename from packages/agent-core-v2/src/agent/cron/format.ts
rename to packages/agent-core-v2/src/app/cron/format.ts
diff --git a/packages/agent-core-v2/src/app/cron/index.ts b/packages/agent-core-v2/src/app/cron/index.ts
new file mode 100644
index 000000000..8b501df0d
--- /dev/null
+++ b/packages/agent-core-v2/src/app/cron/index.ts
@@ -0,0 +1,19 @@
+/**
+ * `cron` domain barrel — re-exports the cron task record (`cronTask`), the
+ * `ICronTaskPersistence` contract and its App-scoped implementation
+ * (`cronTaskPersistenceService`), the dependency-free cron algorithm library
+ * (`cron-expr`, `jitter`, `clock`, `format`), and registers the `cron` config
+ * section into `config`. Importing this barrel registers the
+ * `ICronTaskPersistence` binding and the cron config section.
+ */
+
+import './configSection';
+
+export * from './cronTask';
+export * from './cronTaskPersistence';
+export * from './cronTaskPersistenceService';
+export * from './cron-expr';
+export * from './format';
+export * from './jitter';
+export * from './clock';
+export * from './configSection';
diff --git a/packages/agent-core-v2/src/agent/cron/jitter.ts b/packages/agent-core-v2/src/app/cron/jitter.ts
similarity index 100%
rename from packages/agent-core-v2/src/agent/cron/jitter.ts
rename to packages/agent-core-v2/src/app/cron/jitter.ts
diff --git a/packages/agent-core-v2/src/app/cronPersistence/index.ts b/packages/agent-core-v2/src/app/cronPersistence/index.ts
deleted file mode 100644
index b197e4f99..000000000
--- a/packages/agent-core-v2/src/app/cronPersistence/index.ts
+++ /dev/null
@@ -1,8 +0,0 @@
-/**
- * `cron` domain barrel — re-exports the cron task data record, the
- * `ICronTaskPersistence` contract, and registers the App-scoped persistence service.
- */
-
-export * from './cronTask';
-export * from './cronTaskPersistence';
-export * from './cronTaskPersistenceService';
diff --git a/packages/agent-core-v2/src/app/messageLegacy/messageLegacyService.ts b/packages/agent-core-v2/src/app/messageLegacy/messageLegacyService.ts
index e7888bea6..52f6ffa6f 100644
--- a/packages/agent-core-v2/src/app/messageLegacy/messageLegacyService.ts
+++ b/packages/agent-core-v2/src/app/messageLegacy/messageLegacyService.ts
@@ -15,7 +15,7 @@ import type { Message, PageResponse } from '@moonshot-ai/protocol';
import { InstantiationType } from '#/_base/di/extensions';
import { type IAgentScopeHandle, LifecycleScope, registerScopedService } from '#/_base/di/scope';
-import { IAgentLifecycleService } from '#/session/agentLifecycle';
+import { ensureMainAgent } from '#/session/agentLifecycle';
import {
IAgentContextMemoryService,
toProtocolMessage,
@@ -28,7 +28,6 @@ import { ISessionLifecycleService } from '#/app/sessionLifecycle';
import { IMessageLegacyService, type MessageListQuery } from './messageLegacy';
-const MAIN_AGENT_ID = 'main';
const DEFAULT_PAGE_SIZE = 50;
const MAX_PAGE_SIZE = 100;
@@ -128,13 +127,10 @@ export class MessageLegacyService implements IMessageLegacyService {
private async resolveMainAgent(sessionId: string): Promise {
const session = await this.lifecycle.resume(sessionId);
if (session === undefined) return undefined;
- const agents = session.accessor.get(IAgentLifecycleService);
- const existing = agents.getHandle(MAIN_AGENT_ID);
- if (existing !== undefined) return existing;
// Live session whose main agent has not been materialized yet: create it
// fresh. No restore here — the session was already live, so any persisted
// wire is already reflected in memory; re-restoring would re-apply splices.
- return agents.createMain();
+ return ensureMainAgent(session);
}
}
diff --git a/packages/agent-core-v2/src/app/sessionLegacy/sessionLegacyService.ts b/packages/agent-core-v2/src/app/sessionLegacy/sessionLegacyService.ts
index e29ac812e..1a09e7c77 100644
--- a/packages/agent-core-v2/src/app/sessionLegacy/sessionLegacyService.ts
+++ b/packages/agent-core-v2/src/app/sessionLegacy/sessionLegacyService.ts
@@ -11,7 +11,7 @@
import { InstantiationType } from '#/_base/di/extensions';
import { type IAgentScopeHandle, LifecycleScope, registerScopedService } from '#/_base/di/scope';
-import { IAgentLifecycleService } from '#/session/agentLifecycle';
+import { ensureMainAgent } from '#/session/agentLifecycle';
import { IAgentContextMemoryService, toProtocolMessage, type ContextMessage } from '#/agent/contextMemory';
import { IAgentContextSizeService } from '#/agent/contextSize';
import { ErrorCodes, isKimiError, KimiError } from '#/errors';
@@ -47,8 +47,6 @@ import {
type SessionWireFields,
} from './sessionLegacy';
-const MAIN_AGENT_ID = 'main';
-
/**
* v1 `child_session_kind` marker (`packages/agent-core/.../sessionService.ts`).
* A fork is only listed as a "child" when its metadata carries both
@@ -263,17 +261,15 @@ export class SessionLegacyService implements ISessionLegacyService {
/**
* Resolve the session's main agent, creating it on demand (mirrors v1's
- * `resumeSession` + the server-v2 `ensureMainAgent` helper).
+ * `resumeSession`; delegates to the `agentLifecycle` domain's
+ * `ensureMainAgent` bootstrap helper).
*/
private async resolveMainAgent(sessionId: string): Promise {
const session = this.lifecycle.get(sessionId);
if (session === undefined) {
throw new KimiError(ErrorCodes.SESSION_NOT_FOUND, `session ${sessionId} does not exist`);
}
- const agents = session.accessor.get(IAgentLifecycleService);
- const existing = agents.getHandle(MAIN_AGENT_ID);
- if (existing !== undefined) return existing;
- return agents.createMain();
+ return ensureMainAgent(session);
}
private async assembleStatus(sessionId: string, agent: IAgentScopeHandle): Promise {
diff --git a/packages/agent-core-v2/src/app/sessionLifecycle/sessionLifecycleService.ts b/packages/agent-core-v2/src/app/sessionLifecycle/sessionLifecycleService.ts
index ed3f37b8b..5d1e8700f 100644
--- a/packages/agent-core-v2/src/app/sessionLifecycle/sessionLifecycleService.ts
+++ b/packages/agent-core-v2/src/app/sessionLifecycle/sessionLifecycleService.ts
@@ -23,7 +23,7 @@ import {
import { Disposable } from '#/_base/di/lifecycle';
import { Emitter, type Event } from '#/_base/event';
import { encodeWorkDirKey } from '#/_base/utils/workdir-slug';
-import { IAgentLifecycleService } from '#/session/agentLifecycle';
+import { IAgentLifecycleService, ensureMainAgent, MAIN_AGENT_ID } from '#/session/agentLifecycle';
import { IBootstrapService } from '#/app/bootstrap';
import { IEventService } from '#/app/event';
import { IAgentContextMemoryService } from '#/agent/contextMemory';
@@ -153,8 +153,8 @@ export class SessionLifecycleService extends Disposable implements ISessionLifec
const handle = await this.create({ sessionId, workDir: workspace.root });
const agents = handle.accessor.get(IAgentLifecycleService);
- if (agents.getHandle('main') === undefined) {
- const main = await agents.createMain();
+ if (agents.getHandle(MAIN_AGENT_ID) === undefined) {
+ const main = await ensureMainAgent(handle);
// Resolve context memory BEFORE restoring so its `context.splice` resumer
// is registered; otherwise the wire replay applies splices into a void and
// the restored transcript never lands in context memory.
@@ -280,7 +280,9 @@ export class SessionLifecycleService extends Disposable implements ISessionLifec
const agentHandle = await target.accessor.get(IAgentLifecycleService).create({
agentId,
forkedFrom: sourceAgent.forkedFrom ?? legacy.parentAgentId,
- swarmItem: sourceAgent.swarmItem,
+ labels:
+ sourceAgent.labels ??
+ (sourceAgent.swarmItem !== undefined ? { swarmItem: sourceAgent.swarmItem } : undefined),
});
await agentHandle.accessor.get(IAgentWireRecordService).restore();
}
diff --git a/packages/agent-core-v2/src/index.ts b/packages/agent-core-v2/src/index.ts
index 6a26b641a..52859b4d7 100644
--- a/packages/agent-core-v2/src/index.ts
+++ b/packages/agent-core-v2/src/index.ts
@@ -44,9 +44,8 @@ export * from '#/agent/usage';
export * from '#/agent/toolDedupe';
export * from '#/agent/task';
-export * from '#/app/cronPersistence';
+export * from '#/app/cron';
export * from '#/session/cron';
-import '#/agent/cron';
export * from '#/session/agentLifecycle';
export * from '#/app/sessionLifecycle';
@@ -115,4 +114,3 @@ export type { ToolContribution, ToolContributionOptions } from '#/agent/toolRegi
export * from '#/agent/toolState';
export * from '#/agent/userTool';
export * from '#/agent/wireRecord';
-export * from '#/agent/shellTools';
diff --git a/packages/agent-core-v2/src/agent/shellTools/tools/bash.md b/packages/agent-core-v2/src/os/backends/node-local/tools/bash.md
similarity index 100%
rename from packages/agent-core-v2/src/agent/shellTools/tools/bash.md
rename to packages/agent-core-v2/src/os/backends/node-local/tools/bash.md
diff --git a/packages/agent-core-v2/src/agent/shellTools/tools/bash.ts b/packages/agent-core-v2/src/os/backends/node-local/tools/bash.ts
similarity index 99%
rename from packages/agent-core-v2/src/agent/shellTools/tools/bash.ts
rename to packages/agent-core-v2/src/os/backends/node-local/tools/bash.ts
index a992bda04..dfac1a404 100644
--- a/packages/agent-core-v2/src/agent/shellTools/tools/bash.ts
+++ b/packages/agent-core-v2/src/os/backends/node-local/tools/bash.ts
@@ -39,13 +39,13 @@ import { ISessionProcessRunner } from '#/session/process';
import type { IProcess } from '#/session/process';
import { IAgentProfileService } from '#/agent/profile';
import type { BuiltinTool, ExecutableToolResult, ToolExecution, ToolUpdate } from '#/agent/tool';
+import { ToolResultBuilder } from '#/agent/tool/result-builder';
import { registerTool } from '#/agent/toolRegistry';
import { toInputJsonSchema } from '#/_base/tools/support/input-schema';
import { literalRulePattern, matchesGlobRuleSubject } from '#/_base/tools/support/rule-match';
import { renderPrompt } from '#/_base/utils/render-prompt';
import bashDescriptionTemplate from './bash.md?raw';
import { ProcessTask } from './process-task';
-import { ToolResultBuilder } from './result-builder';
const MS_PER_SECOND = 1000;
const DEFAULT_TIMEOUT_S = 60;
diff --git a/packages/agent-core-v2/src/os/backends/node-local/tools/index.ts b/packages/agent-core-v2/src/os/backends/node-local/tools/index.ts
index c655c0a22..0465d22fd 100644
--- a/packages/agent-core-v2/src/os/backends/node-local/tools/index.ts
+++ b/packages/agent-core-v2/src/os/backends/node-local/tools/index.ts
@@ -1,3 +1,4 @@
+export * from './bash';
export * from './edit';
export * from './glob';
export * from './grep';
diff --git a/packages/agent-core-v2/src/agent/shellTools/tools/process-task.ts b/packages/agent-core-v2/src/os/backends/node-local/tools/process-task.ts
similarity index 100%
rename from packages/agent-core-v2/src/agent/shellTools/tools/process-task.ts
rename to packages/agent-core-v2/src/os/backends/node-local/tools/process-task.ts
diff --git a/packages/agent-core-v2/src/session/agentFs/gitContext.ts b/packages/agent-core-v2/src/session/agentFs/gitContext.ts
index 7cc41e41c..042934767 100644
--- a/packages/agent-core-v2/src/session/agentFs/gitContext.ts
+++ b/packages/agent-core-v2/src/session/agentFs/gitContext.ts
@@ -1,13 +1,13 @@
/**
- * Git context collection for explore subagents.
+ * Git context collection for explore agents.
*
* `collectGitContext` produces a `` block that is prepended to a
- * fresh explore subagent's prompt so it can orient itself in the repository
+ * fresh explore agent's prompt so it can orient itself in the repository
* before searching. Every git probe is best-effort: probes fail in perfectly
* normal states (no `origin` remote, no commits yet, detached HEAD, older
* Git), so a failed probe is logged and its section omitted rather than
* dropping the whole block. The block is omitted entirely only when nothing
- * useful was collected. The one explicit state surfaced to the subagent is
+ * useful was collected. The one explicit state surfaced to the agent is
* `reason="not-a-repo"`, so it doesn't waste turns probing git history in a
* non-repo directory. Remote URLs are sanitized so internal infrastructure
* is not surfaced to the model.
diff --git a/packages/agent-core-v2/src/session/agentLifecycle/agentLifecycle.ts b/packages/agent-core-v2/src/session/agentLifecycle/agentLifecycle.ts
index 8a6337119..87eca1b3e 100644
--- a/packages/agent-core-v2/src/session/agentLifecycle/agentLifecycle.ts
+++ b/packages/agent-core-v2/src/session/agentLifecycle/agentLifecycle.ts
@@ -1,107 +1,128 @@
/**
- * `agentLifecycle` domain (L6) — creates and tracks agents within a session.
+ * `agentLifecycle` domain (L6) — flat registry of the session's agents.
*
- * Defines the public contract of agent lifecycle: the `CreateAgentOptions` and
- * the `IAgentLifecycleService` used to create agents (`create` / `createMain`),
- * clone an existing agent (`clone`), look them up (`getHandle` / `list`), and
- * remove them. Session-scoped — one instance per session.
+ * Defines the public contract of agent lifecycle: `create` (from zero, Profile
+ * + Model), `fork` (inherit binding + context history), `run` (drive one
+ * prompt/retry turn on an agent and await its distilled summary), plus lookup
+ * (`getHandle` / `list`) and removal. Session-scoped — one instance per
+ * session.
+ *
+ * Invariants:
+ * - The registry is flat: agents have no nesting. There is no parent/child or
+ * caller/callee relationship here; when a business domain needs such a
+ * relationship (e.g. the `Agent` tool's display events), that domain
+ * maintains it itself.
+ * - The main agent is an ordinary agent whose only distinction is
+ * `agentId === 'main'`. Business operations (create / fork / run / lookup)
+ * treat it uniformly; the only main-specific surface is the
+ * `onDidCreateMain` event, fired via `notifyMainCreated` by the main
+ * bootstrapper so main-only capabilities subscribe without filtering every
+ * `onDidCreate`.
+ * - `forkedFrom` is provenance only (a recorded value); business logic must
+ * not branch on it.
*/
import { createDecorator, type ServiceIdentifier } from '#/_base/di/instantiation';
import type { IAgentScopeHandle } from '#/_base/di/scope';
import type { Event } from '#/_base/event';
import type { TokenUsage } from '#/app/llmProtocol';
+import type { AgentProfileSummaryPolicy } from '#/app/agentProfileCatalog';
+import type { BindAgentInput } from '#/agent/profile';
+import type { Turn } from '#/agent/turn';
export interface CreateAgentOptions {
readonly agentId?: string;
- /** Agent this one is cloned / derived from (provenance only; not used by business logic). */
+ /**
+ * Profile + Model to bind at creation so the agent is born runnable
+ * (`Profile + Model ⇒ Agent`). May be omitted by exactly two callers:
+ * session resume/fork (the binding is restored from the wire log) and the
+ * edge-bootstrapped main agent (the edge binds a model right after). Every
+ * other creation path must pass a full binding.
+ */
+ readonly binding?: BindAgentInput;
+ /** Agent this one is derived from (provenance only; not used by business logic). */
readonly forkedFrom?: string;
- readonly cwd?: string;
- readonly swarmItem?: string;
+ /**
+ * Business-defined recorded values (e.g. the swarm's `swarmItem`). Persisted
+ * verbatim into the session's agent registry; never interpreted here.
+ */
+ readonly labels?: Readonly>;
}
-export interface SpawnAgentOptions {
+export interface ForkAgentOptions {
readonly agentId?: string;
- /** Override the child's cwd. Defaults to the parent's cwd. */
- readonly cwd?: string;
- readonly swarmItem?: string;
+ /**
+ * Overrides merged over the source agent's binding (e.g. a title generator
+ * forking `main` onto a cheaper model).
+ */
+ readonly binding?: Partial;
+}
+
+export type AgentRunRequest =
+ | { readonly kind: 'prompt'; readonly prompt: string }
+ | { readonly kind: 'retry'; readonly trigger?: string };
+
+export interface RunAgentOptions {
+ /** Cancellation signal. Aborting it cancels the agent's turn. */
+ readonly signal: AbortSignal;
+ /**
+ * Summary distillation policy. Defaults to the `summaryPolicy` of the
+ * profile the target agent is bound to; pass explicitly to override.
+ */
+ readonly summaryPolicy?: AgentProfileSummaryPolicy;
+ /** Fires once the turn's first request is committed (used by swarm to fan out). */
+ readonly onReady?: () => void;
+}
+
+export interface AgentRunHandle {
+ readonly agentId: string;
+ readonly turn: Turn;
+ readonly completion: Promise<{ readonly summary: string; readonly usage?: TokenUsage }>;
}
export interface AgentListFilter {
readonly prefix?: string;
}
-// ── Subagent orchestration types ────────────────────────────────────
-
-export interface SubagentRecordMetadata {
- readonly parentToolCallId?: string;
- readonly description?: string;
- readonly runInBackground?: boolean;
- readonly swarmIndex?: number;
-}
-
-export interface RunSubagentOptions {
- readonly callerAgentId: string;
- readonly profileName: string;
- readonly prompt: string;
- readonly signal: AbortSignal;
- readonly metadata?: SubagentRecordMetadata;
- readonly suppressRateLimitFailureEvent?: boolean;
- readonly onReady?: () => void;
-}
-
-export interface ResumeSubagentOptions {
- readonly callerAgentId: string;
- readonly agentId: string;
- readonly prompt: string;
- readonly signal: AbortSignal;
- readonly metadata?: SubagentRecordMetadata;
-}
-
-export interface SubagentRunHandle {
- readonly agentId: string;
- readonly profileName: string;
- readonly completion: Promise<{ readonly result: string; readonly usage?: TokenUsage }>;
-}
-
export interface IAgentLifecycleService {
readonly _serviceBrand: undefined;
/** Fires after an agent is created and registered, with its scope handle. */
readonly onDidCreate: Event;
+ /**
+ * Fires once after the main agent is created and its main-only wirings are
+ * attached, with its scope handle. Use this instead of `onDidCreate` when a
+ * capability belongs exclusively to the main agent, so subscribers do not
+ * need to filter every agent creation by `id === 'main'`.
+ */
+ readonly onDidCreateMain: Event;
/** Fires after an agent is removed, with its agent id. */
readonly onDidDispose: Event;
- create(opts: CreateAgentOptions): Promise;
- createMain(): Promise;
- /** Clone an agent: copy its profile and context history into a new agent. */
- clone(sourceAgentId: string): Promise;
+ /** Create an agent from zero (empty context). */
+ create(opts?: CreateAgentOptions): Promise;
/**
- * Create a child agent from a parent, copying the parent's profile fields
- * (`cwd` / `modelAlias` / `thinkingLevel` / `systemPrompt` / `activeToolNames`)
- * and recording `forkedFrom = parentAgentId`. Does **not** copy the parent's
- * context memory — the child starts with an empty context. Throws when the
- * parent does not exist.
- *
- * Applying a named profile (system-prompt overlay, tool overrides, prompt
- * prefix, summary policy) is a caller concern: use `applyProfileToAgent(...)`
- * from `session/agentLifecycle` after `spawn` returns.
+ * Fire {@link onDidCreateMain} for the given handle. Called exactly once by
+ * the main-agent bootstrapper (`ensureMainAgent`) after main-only wirings
+ * are attached, so main-only capabilities can subscribe without filtering
+ * every {@link onDidCreate}. No other caller should invoke it.
*/
- spawn(parentAgentId: string, opts?: SpawnAgentOptions): Promise;
+ notifyMainCreated(handle: IAgentScopeHandle): void;
+ /**
+ * Fork an agent: copy its profile binding and context history into a new
+ * agent, recording `forkedFrom = sourceAgentId`. Throws when the source does
+ * not exist.
+ */
+ fork(sourceAgentId: string, opts?: ForkAgentOptions): Promise;
+ /**
+ * Submit one prompt (or retry) turn to an existing agent and return a handle
+ * whose `completion` resolves with the distilled summary and token usage.
+ * Emits nothing on anyone else's record stream — a caller that wants to
+ * surface this run (the `Agent` tool, the swarm) mirrors it itself. Throws
+ * when the agent does not exist or a turn cannot be started (busy / no head).
+ */
+ run(agentId: string, request: AgentRunRequest, opts: RunAgentOptions): AgentRunHandle;
getHandle(agentId: string): IAgentScopeHandle | undefined;
list(filter?: AgentListFilter): readonly IAgentScopeHandle[];
remove(agentId: string): Promise;
- /**
- * Spawn a new child agent under a named profile, observe its turn, and
- * return a handle to the running completion. Composes `spawn` →
- * `applyProfileToAgent` → prompt-prefix → record signaling → telemetry →
- * `observeChildAgentTurn`.
- */
- runSubagent(opts: RunSubagentOptions): Promise;
- /**
- * Resume an existing child agent with a new prompt, observe its turn, and
- * return a handle to the running completion. Validates the target agent
- * exists, resolves its profile, and delegates to `observeChildAgentTurn`.
- */
- resumeSubagent(opts: ResumeSubagentOptions): Promise;
}
export const IAgentLifecycleService: ServiceIdentifier =
diff --git a/packages/agent-core-v2/src/session/agentLifecycle/agentLifecycleService.ts b/packages/agent-core-v2/src/session/agentLifecycle/agentLifecycleService.ts
index 9e52240d9..abedb7f15 100644
--- a/packages/agent-core-v2/src/session/agentLifecycle/agentLifecycleService.ts
+++ b/packages/agent-core-v2/src/session/agentLifecycle/agentLifecycleService.ts
@@ -1,10 +1,16 @@
/**
* `agentLifecycle` domain (L6) — `IAgentLifecycleService` implementation.
*
- * Creates and tracks the session's agents as child scopes. Seeds each agent's
- * identity through `agent` scopeContext, wires per-agent wire records, blob
- * store, and MCP, and registers the agent in the session registry. Bound at
- * Session scope.
+ * Creates and tracks the session's agents as child scopes in a flat registry.
+ * Seeds each agent's identity through `agent` scopeContext, wires per-agent
+ * wire records, blob store, and MCP, and registers the agent in the session
+ * registry. Bound at Session scope.
+ *
+ * No agent id is special here: the main agent is created by its bootstrappers
+ * as `create({ agentId: 'main' })` (see `mainAgent.ts`), and `fork` requires
+ * its source to exist. Caller-facing orchestration (record mirroring, hooks,
+ * telemetry, prompt prefixes) lives with the callers — see this domain's
+ * wrapper helpers (`tools/agent.ts`, `mirrorAgentRun`).
*/
import { InstantiationType } from '#/_base/di/extensions';
@@ -19,10 +25,9 @@ import {
registerScopedService,
} from '#/_base/di/scope';
import { IBootstrapService } from '#/app/bootstrap';
-import { IPluginSessionStartInjectorService } from '#/agent/contextInjector';
import { ILogService } from '#/app/log';
-import { IAgentProfileCatalogService, type AgentProfileDefinition } from '#/app/agentProfileCatalog';
-import { ITelemetryService } from '#/app/telemetry';
+import { IAgentProfileCatalogService } from '#/app/agentProfileCatalog';
+import type { AgentProfileSummaryPolicy } from '#/app/agentProfileCatalog';
import { AgentMcpService, IAgentMcpService } from '#/agent/mcp';
import { McpConnectionManager } from '#/agent/mcp/connection-manager';
import { resolveSessionMcpConfig } from '#/agent/mcp/session-config';
@@ -33,7 +38,6 @@ import { ISessionWorkspaceContext } from '#/session/workspaceContext';
import { IAgentScopeContext } from '#/agent/scopeContext';
import { IAgentProfileService } from '#/agent/profile';
import { IAgentContextMemoryService } from '#/agent/contextMemory';
-import { IAgentRecordService } from '#/agent/record';
import { IAgentBuiltinToolsRegistrar } from '#/agent/toolRegistry';
import { IAgentWireRecordService, AgentWireRecordService } from '#/agent/wireRecord';
import { IAgentBlobService, AgentBlobServiceImpl } from '#/agent/blob';
@@ -44,16 +48,14 @@ import {
import {
type AgentListFilter,
+ type AgentRunHandle,
+ type AgentRunRequest,
type CreateAgentOptions,
+ type ForkAgentOptions,
IAgentLifecycleService,
- type ResumeSubagentOptions,
- type RunSubagentOptions,
- type SpawnAgentOptions,
- type SubagentRunHandle,
+ type RunAgentOptions,
} from './agentLifecycle';
-import { applyProfileToAgent } from './applyProfileToAgent';
-import { observeChildAgentTurn } from './observeChildAgentTurn';
-import { ISessionProcessRunner } from '#/session/process';
+import { runAgentTurn } from './runAgentTurn';
let nextAgentId = 0;
@@ -61,12 +63,16 @@ export class AgentLifecycleService extends Disposable implements IAgentLifecycle
declare readonly _serviceBrand: undefined;
private readonly handles = new Map();
private readonly onDidCreateEmitter = this._register(new Emitter());
+ private readonly onDidCreateMainEmitter = this._register(new Emitter());
private readonly onDidDisposeEmitter = this._register(new Emitter());
private mcpManager: McpConnectionManager | undefined;
get onDidCreate() {
return this.onDidCreateEmitter.event;
}
+ get onDidCreateMain() {
+ return this.onDidCreateMainEmitter.event;
+ }
get onDidDispose() {
return this.onDidDisposeEmitter.event;
}
@@ -80,13 +86,11 @@ export class AgentLifecycleService extends Disposable implements IAgentLifecycle
@IPluginService private readonly plugins: IPluginService,
@ILogService private readonly log: ILogService,
@IAgentProfileCatalogService private readonly catalog: IAgentProfileCatalogService,
- @ITelemetryService private readonly telemetry: ITelemetryService,
- @ISessionProcessRunner private readonly processRunner: ISessionProcessRunner,
) {
super();
}
- async create(opts: CreateAgentOptions): Promise {
+ async create(opts: CreateAgentOptions = {}): Promise {
const agentId = opts.agentId ?? `agent-${nextAgentId++}`;
// Per-agent homedir → the wire-record persistence key (`hashKey(homedir)`).
// Bootstrap computes it under the session dir, mirroring v1's
@@ -134,7 +138,7 @@ export class AgentLifecycleService extends Disposable implements IAgentLifecycle
await this.sessionMetadata.registerAgent(agentId, {
homedir: agentHomedir,
forkedFrom: opts.forkedFrom,
- swarmItem: opts.swarmItem,
+ labels: opts.labels,
});
this.onDidCreateEmitter.fire(handle);
// Force-instantiate the Eager builtin-tools registrar: its constructor
@@ -154,32 +158,40 @@ export class AgentLifecycleService extends Disposable implements IAgentLifecycle
// first turn — otherwise plugin/session MCP servers would connect but their
// tools would never register until something explicitly requests the service.
handle.accessor.get(IAgentMcpService);
+ if (opts.binding !== undefined) {
+ await handle.accessor.get(IAgentProfileService).bind(opts.binding);
+ }
return handle;
}
- async createMain(): Promise {
- const handle = await this.create({ agentId: 'main' });
- // Force-instantiate the plugin session-start injector so it registers its
- // turn-cadence injection before the first turn. Main-agent only, matching
- // v1's `pluginSessionStarts: type === 'main' ? ... : undefined`.
- handle.accessor.get(IPluginSessionStartInjectorService);
- return handle;
+ notifyMainCreated(handle: IAgentScopeHandle): void {
+ this.onDidCreateMainEmitter.fire(handle);
}
- async clone(sourceAgentId: string): Promise {
- const source =
- this.handles.get(sourceAgentId) ??
- (sourceAgentId === 'main' ? await this.createMain() : undefined);
+ async fork(sourceAgentId: string, opts?: ForkAgentOptions): Promise {
+ const source = this.handles.get(sourceAgentId);
if (source === undefined) throw new Error(`Source agent "${sourceAgentId}" does not exist`);
- const child = await this.create({ forkedFrom: source.id });
+ const child = await this.create({ agentId: opts?.agentId, forkedFrom: source.id });
const sourceData = source.accessor.get(IAgentProfileService).data();
- child.accessor.get(IAgentProfileService).update({
- modelAlias: sourceData.modelAlias,
- thinkingLevel: sourceData.thinkingLevel,
- systemPrompt: sourceData.systemPrompt,
- activeToolNames: sourceData.activeToolNames,
- });
+ const childProfile = child.accessor.get(IAgentProfileService);
+ const override = opts?.binding;
+ const model = override?.model ?? sourceData.modelAlias;
+ if (model !== undefined) {
+ await childProfile.bind({
+ profile: override?.profile ?? sourceData.profileName ?? 'agent',
+ model,
+ thinking: override?.thinking ?? sourceData.thinkingLevel,
+ cwd: override?.cwd ?? sourceData.cwd,
+ });
+ } else {
+ childProfile.update({
+ profileName: override?.profile ?? sourceData.profileName,
+ thinkingLevel: override?.thinking ?? sourceData.thinkingLevel,
+ systemPrompt: sourceData.systemPrompt,
+ activeToolNames: sourceData.activeToolNames,
+ });
+ }
const sourceMessages = source.accessor.get(IAgentContextMemoryService)?.get();
if (sourceMessages !== undefined && sourceMessages.length > 0) {
@@ -188,30 +200,26 @@ export class AgentLifecycleService extends Disposable implements IAgentLifecycle
return child;
}
- async spawn(parentAgentId: string, opts?: SpawnAgentOptions): Promise {
- const parent = this.handles.get(parentAgentId);
- if (parent === undefined) throw new Error(`Parent agent "${parentAgentId}" does not exist`);
- const parentData = parent.accessor.get(IAgentProfileService).data();
- const child = await this.create({
- agentId: opts?.agentId,
- forkedFrom: parentAgentId,
- cwd: opts?.cwd ?? parentData.cwd,
- swarmItem: opts?.swarmItem,
+ run(agentId: string, request: AgentRunRequest, opts: RunAgentOptions): AgentRunHandle {
+ const handle = this.handles.get(agentId);
+ if (handle === undefined) throw new Error(`Agent "${agentId}" does not exist`);
+ return runAgentTurn(handle, request, {
+ summaryPolicy: opts.summaryPolicy ?? this.summaryPolicyFor(handle),
+ signal: opts.signal,
+ onReady: opts.onReady,
});
- child.accessor.get(IAgentProfileService).update({
- cwd: opts?.cwd ?? parentData.cwd,
- modelAlias: parentData.modelAlias,
- thinkingLevel: parentData.thinkingLevel,
- systemPrompt: parentData.systemPrompt,
- activeToolNames: parentData.activeToolNames,
- });
- return child;
+ }
+
+ private summaryPolicyFor(handle: IAgentScopeHandle): AgentProfileSummaryPolicy | undefined {
+ const profileName = handle.accessor.get(IAgentProfileService).data().profileName;
+ if (profileName === undefined) return undefined;
+ return this.catalog.get(profileName)?.summaryPolicy;
}
/**
* One shared `McpConnectionManager` per session (built lazily, cached). All
* agents in the session share it, matching v1's session-scoped MCP and
- * avoiding a reconnect storm per subagent. Connects the session-config
+ * avoiding a reconnect storm per agent. Connects the session-config
* servers merged with enabled plugin MCP servers (fire-and-forget; the
* manager's `initialLoad` gates tool use via `waitForInitialLoad`).
*/
@@ -236,119 +244,6 @@ export class AgentLifecycleService extends Disposable implements IAgentLifecycle
await manager.connectAll(servers);
}
- // ── Subagent orchestration ─────────────────────────────────────────
-
- async runSubagent(opts: RunSubagentOptions): Promise {
- const { callerAgentId, profileName, signal } = opts;
- const caller = this.handles.get(callerAgentId);
- if (caller === undefined) throw new Error(`Caller agent "${callerAgentId}" does not exist`);
-
- const profile = this.catalog.get(profileName);
- if (profile === undefined) throw new Error(`Unknown agent type: "${profileName}"`);
-
- const child = await this.spawn(callerAgentId);
- applyProfileToAgent(child, profile);
-
- const promptText = await this.withProfilePrefix(profile, opts.prompt);
- this.emitSubagentSpawned(caller, child.id, profileName, opts.metadata);
- this.telemetry?.track('subagent_created', {
- subagent_name: profileName,
- run_in_background: opts.metadata?.runInBackground ?? false,
- });
-
- const observed = observeChildAgentTurn(
- caller,
- child,
- { kind: 'prompt', prompt: promptText },
- {
- profileName,
- summaryPolicy: profile.summaryPolicy,
- suppressRateLimitFailureEvent: opts.suppressRateLimitFailureEvent,
- signal,
- onReady: opts.onReady,
- },
- );
- if (observed === undefined) throw new Error('Subagent turn could not be started');
-
- return {
- agentId: child.id,
- profileName,
- completion: observed.completion.then((r) => ({ result: r.summary, usage: r.usage })),
- };
- }
-
- async resumeSubagent(opts: ResumeSubagentOptions): Promise {
- const { callerAgentId, agentId, signal } = opts;
- const caller = this.handles.get(callerAgentId);
- if (caller === undefined) throw new Error(`Caller agent "${callerAgentId}" does not exist`);
-
- const child = this.handles.get(agentId);
- if (child === undefined) throw new Error(`Agent instance "${agentId}" does not exist`);
-
- const profileName =
- child.accessor.get(IAgentProfileService).data().profileName ?? 'subagent';
- const profile = this.catalog.get(profileName);
-
- this.emitSubagentSpawned(caller, agentId, profileName, opts.metadata);
- this.telemetry?.track('subagent_created', {
- subagent_name: profileName,
- run_in_background: opts.metadata?.runInBackground ?? false,
- });
-
- const observed = observeChildAgentTurn(
- caller,
- child,
- { kind: 'prompt', prompt: opts.prompt },
- {
- profileName,
- summaryPolicy: profile?.summaryPolicy,
- signal,
- },
- );
- if (observed === undefined) throw new Error('Subagent turn could not be started');
-
- return {
- agentId,
- profileName,
- completion: observed.completion.then((r) => ({ result: r.summary, usage: r.usage })),
- };
- }
-
- private emitSubagentSpawned(
- caller: IAgentScopeHandle,
- subagentId: string,
- profileName: string,
- metadata?: RunSubagentOptions['metadata'],
- ): void {
- caller.accessor.get(IAgentRecordService)?.signal({
- type: 'subagent.spawned',
- subagentId,
- subagentName: profileName,
- parentToolCallId: metadata?.parentToolCallId ?? '',
- callerAgentId: caller.id,
- description: metadata?.description,
- swarmIndex: metadata?.swarmIndex,
- runInBackground: metadata?.runInBackground ?? false,
- });
- }
-
- private async withProfilePrefix(
- profile: AgentProfileDefinition,
- prompt: string,
- ): Promise {
- if (profile.promptPrefix === undefined) return prompt;
- try {
- const prefix = await profile.promptPrefix({
- cwd: this.workspace.workDir,
- runner: this.processRunner,
- log: this.log,
- });
- return prefix.length > 0 ? `${prefix}\n\n${prompt}` : prompt;
- } catch {
- return prompt;
- }
- }
-
getHandle(agentId: string): IAgentScopeHandle | undefined {
return this.handles.get(agentId);
}
diff --git a/packages/agent-core-v2/src/session/agentLifecycle/mainAgent.ts b/packages/agent-core-v2/src/session/agentLifecycle/mainAgent.ts
new file mode 100644
index 000000000..c654df4de
--- /dev/null
+++ b/packages/agent-core-v2/src/session/agentLifecycle/mainAgent.ts
@@ -0,0 +1,48 @@
+/**
+ * `agentLifecycle` domain (L6) — main-agent bootstrap helper.
+ *
+ * The main agent is an ordinary agent whose only distinction is
+ * `agentId === 'main'`; `IAgentLifecycleService` itself knows nothing about
+ * it. What *is* main-specific is session bootstrap business: the plugin
+ * session-start injector registers its turn-cadence injection on the main
+ * agent only (matching v1's `pluginSessionStarts: type === 'main' ? … :
+ * undefined`). `ensureMainAgent` concentrates that business in one place so
+ * every bootstrapper (session resume, legacy session/message services, the
+ * server edge) creates the main agent the same way.
+ *
+ * Not a Service: a pure composition helper over the session handle.
+ */
+
+import type { ISessionScopeHandle, IAgentScopeHandle } from '#/_base/di/scope';
+import { IPluginSessionStartInjectorService } from '#/agent/contextInjector';
+import type { BindAgentInput } from '#/agent/profile';
+
+import { IAgentLifecycleService } from './agentLifecycle';
+
+export const MAIN_AGENT_ID = 'main';
+
+export interface EnsureMainAgentOptions {
+ /** Profile + Model to bind at creation. Omit for an edge-bound main agent. */
+ readonly binding?: BindAgentInput;
+}
+
+/**
+ * Return the session's main agent, creating it (with its session-start
+ * bootstrap wiring) when it does not exist yet.
+ */
+export async function ensureMainAgent(
+ session: ISessionScopeHandle,
+ opts?: EnsureMainAgentOptions,
+): Promise {
+ const agents = session.accessor.get(IAgentLifecycleService);
+ const existing = agents.getHandle(MAIN_AGENT_ID);
+ if (existing !== undefined) return existing;
+ const main = await agents.create({ agentId: MAIN_AGENT_ID, binding: opts?.binding });
+ // Force-instantiate the plugin session-start injector so it registers its
+ // turn-cadence injection before the first turn. Main-agent-only business.
+ main.accessor.get(IPluginSessionStartInjectorService);
+ // Notify main-only capabilities (e.g. the cron tool registrar) that the main
+ // agent is ready, so they bind to it without filtering every `onDidCreate`.
+ agents.notifyMainCreated(main);
+ return main;
+}
diff --git a/packages/agent-core-v2/src/session/agentLifecycle/mirrorAgentRun.ts b/packages/agent-core-v2/src/session/agentLifecycle/mirrorAgentRun.ts
new file mode 100644
index 000000000..3a93c7703
--- /dev/null
+++ b/packages/agent-core-v2/src/session/agentLifecycle/mirrorAgentRun.ts
@@ -0,0 +1,152 @@
+/**
+ * `agentLifecycle` domain (L6) — caller-side mirroring of an agent run.
+ *
+ * When one agent drives another through `IAgentLifecycleService.run` (the
+ * `Agent` tool, the swarm scheduler), the *requesting* agent surfaces that run
+ * on its own record stream so the UI can nest the child transcript under the
+ * launching tool call, external hooks fire, and telemetry is tracked. That
+ * requester ↔ target association is business data of this wrapper layer — the
+ * lifecycle registry itself stays flat and knows nothing about it.
+ *
+ * External hooks (`SubagentStart` / `SubagentStop`) are invoked directly on
+ * the requester's `IAgentExternalHooksService` — there is no intermediary
+ * hook service; the tool wrapper is just a thin layer over the lifecycle.
+ *
+ * Wire shape note: the record signals are still named `subagent.spawned /
+ * started / completed / failed` and telemetry still tracks `subagent_created`
+ * so existing session recordings and dashboards stay valid. Rename lives on a
+ * separate wire-cleanup PR.
+ */
+
+import type { IAgentScopeHandle } from '#/_base/di/scope';
+import { userCancellationReason } from '#/_base/utils/abort';
+import { isProviderRateLimitError, type TokenUsage } from '#/app/llmProtocol';
+import { ITelemetryService } from '#/app/telemetry';
+import { IAgentExternalHooksService } from '#/agent/externalHooks';
+import { IAgentRecordService } from '#/agent/record';
+import { isAbortError } from '#/agent/loop/errors';
+
+import type { AgentRunHandle } from './agentLifecycle';
+
+const HOOK_TEXT_PREVIEW_LENGTH = 500;
+
+export interface AgentRunSpawnedMeta {
+ readonly profileName: string;
+ readonly parentToolCallId?: string;
+ readonly parentToolCallUuid?: string;
+ readonly description?: string;
+ readonly swarmIndex?: number;
+ readonly runInBackground?: boolean;
+}
+
+export interface MirrorAgentRunOptions {
+ /** Profile the target runs under; only used for hooks / record labels. */
+ readonly profileName: string;
+ /**
+ * Prompt text submitted to the target. When present the `SubagentStart`
+ * external hook fires (and may block the run); omit for retry turns, which
+ * skip the hook.
+ */
+ readonly prompt?: string;
+ /** Skip the requester-side `subagent.failed` record for provider-rate-limit / aborted failures. */
+ readonly suppressRateLimitFailureEvent?: boolean;
+ /** The requester's cancellation signal (passed through to the start hook). */
+ readonly signal: AbortSignal;
+ /** Called to abort the underlying run when the start hook blocks it. */
+ readonly cancel?: (reason?: unknown) => void;
+}
+
+/**
+ * Emit the requester-side "an agent run was launched" record + telemetry.
+ * Called once per launch (spawn or resume), before or right after the run is
+ * submitted, because it carries tool-call provenance (`parentToolCallId`,
+ * `swarmIndex`, `runInBackground`) only the requester knows.
+ */
+export function emitAgentRunSpawned(
+ requester: IAgentScopeHandle,
+ targetAgentId: string,
+ meta: AgentRunSpawnedMeta,
+): void {
+ requester.accessor.get(IAgentRecordService)?.signal({
+ type: 'subagent.spawned',
+ subagentId: targetAgentId,
+ subagentName: meta.profileName,
+ parentToolCallId: meta.parentToolCallId ?? '',
+ parentToolCallUuid: meta.parentToolCallUuid,
+ callerAgentId: requester.id,
+ description: meta.description,
+ swarmIndex: meta.swarmIndex,
+ runInBackground: meta.runInBackground ?? false,
+ });
+ requester.accessor.get(ITelemetryService)?.track('subagent_created', {
+ subagent_name: meta.profileName,
+ run_in_background: meta.runInBackground ?? false,
+ });
+}
+
+/**
+ * Mirror a running agent turn onto the requester's record stream + external
+ * hooks and await its completion. Returns the distilled summary/usage;
+ * rethrows the run's failure after emitting the requester-side failure record.
+ */
+export async function mirrorAgentRun(
+ requester: IAgentScopeHandle,
+ run: AgentRunHandle,
+ options: MirrorAgentRunOptions,
+): Promise<{ summary: string; usage?: TokenUsage }> {
+ const record = requester.accessor.get(IAgentRecordService);
+ const externalHooks = requester.accessor.get(IAgentExternalHooksService);
+ record?.signal({ type: 'subagent.started', subagentId: run.agentId });
+ if (options.prompt !== undefined) {
+ try {
+ await externalHooks?.runAgentTaskStart({
+ agentName: options.profileName,
+ prompt: options.prompt.slice(0, HOOK_TEXT_PREVIEW_LENGTH),
+ signal: options.signal,
+ });
+ } catch (error) {
+ options.cancel?.(error);
+ void run.completion.catch(() => {});
+ throw error;
+ }
+ if (options.signal.aborted) {
+ const reason: unknown = options.signal.reason ?? userCancellationReason();
+ options.cancel?.(reason);
+ void run.completion.catch(() => {});
+ throw reason;
+ }
+ }
+ try {
+ const result = await run.completion;
+ record?.signal({
+ type: 'subagent.completed',
+ subagentId: run.agentId,
+ resultSummary: result.summary,
+ usage: result.usage,
+ });
+ externalHooks?.notifyAgentTaskStop({
+ agentName: options.profileName,
+ response: result.summary.slice(0, HOOK_TEXT_PREVIEW_LENGTH),
+ });
+ return result;
+ } catch (error) {
+ if (!isAbortError(error) && !shouldSuppressFailure(options, error)) {
+ record?.signal({
+ type: 'subagent.failed',
+ subagentId: run.agentId,
+ error: errorMessage(error),
+ });
+ }
+ throw error;
+ }
+}
+
+function shouldSuppressFailure(options: MirrorAgentRunOptions, error: unknown): boolean {
+ if (options.suppressRateLimitFailureEvent !== true) return false;
+ if (isProviderRateLimitError(error)) return true;
+ return isAbortError(error) || options.signal.aborted;
+}
+
+function errorMessage(error: unknown): string {
+ return error instanceof Error ? error.message : String(error);
+}
diff --git a/packages/agent-core-v2/src/session/agentLifecycle/observeChildAgentTurn.ts b/packages/agent-core-v2/src/session/agentLifecycle/observeChildAgentTurn.ts
deleted file mode 100644
index deda1b23e..000000000
--- a/packages/agent-core-v2/src/session/agentLifecycle/observeChildAgentTurn.ts
+++ /dev/null
@@ -1,291 +0,0 @@
-/**
- * `agentLifecycle` domain (L6) — helper that runs one prompt (or retry) turn on
- * a child agent, mirrors the child's turn lifecycle onto the caller's record
- * stream + subagent hooks, and distills a summary the caller can hand back to
- * its own tool result.
- *
- * Not a Service: `observeChildAgentTurn` is a pure function that borrows
- * `IAgentPromptService`, `IAgentContextMemoryService`, `IAgentUsageService`
- * from the child scope and `IAgentRecordService`, `IAgentToolService` from the
- * caller scope. It replaces the free-function `spawnChildAgent` /
- * `resumeChildAgent` orchestration under the old `agentTool` domain: the fork
- * step is now `IAgentLifecycleService.spawn`, profile application is
- * `applyProfileToAgent`, and the caller-side spawn record (`subagent.spawned`)
- * is emitted by the caller directly because it carries tool-call provenance
- * (`parentToolCallId`, `swarmIndex`, `runInBackground`) the observer does not
- * know about.
- *
- * The lifecycle is imperative — the caller (`Agent` tool, `sessionSwarmService`)
- * awaits the returned `completion` promise. Turn hooks are not used because
- * there is exactly one observer (the caller who spawned the child); a hook
- * indirection would only obscure the flow.
- */
-
-import {
- APIProviderRateLimitError,
- isProviderRateLimitError,
- type TokenUsage,
-} from '#/app/llmProtocol';
-
-import { linkAbortSignal, userCancellationReason } from '#/_base/utils/abort';
-import type { IAgentScopeHandle } from '#/_base/di/scope';
-import {
- IAgentContextMemoryService,
- type ContextMessage,
- type PromptOrigin,
-} from '#/agent/contextMemory';
-import { ErrorCodes, toKimiErrorPayload, type KimiErrorPayload } from '#/errors';
-import { IAgentRecordService } from '#/agent/record';
-import { IAgentToolService } from '#/agent/agentTool';
-import { isAbortError } from '#/agent/loop/errors';
-import { IAgentPromptService } from '#/agent/prompt';
-import { IAgentUsageService } from '#/agent/usage';
-import type { Turn } from '#/agent/turn';
-import type { AgentProfileSummaryPolicy } from '#/app/agentProfileCatalog';
-
-/**
- * Legacy `PromptOrigin` tag emitted by the `Agent` tool and swarm scheduler
- * when they submit a prompt to a child agent. Wire shape kept unchanged
- * (`kind: 'system_trigger', name: 'subagent'`) so existing session recordings
- * replay against v2 without a protocol schema bump. Rename lives on a separate
- * wire-cleanup PR.
- */
-export const CHILD_AGENT_PROMPT_ORIGIN: PromptOrigin = {
- kind: 'system_trigger',
- name: 'subagent',
-};
-
-const HOOK_TEXT_PREVIEW_LENGTH = 500;
-
-export type ChildAgentTurnRequest =
- | { readonly kind: 'prompt'; readonly prompt: string }
- | { readonly kind: 'retry'; readonly trigger?: string };
-
-export interface ObserveChildAgentTurnOptions {
- /** Profile the child was configured under; only used for external hooks / record labels. */
- readonly profileName: string;
- /** When set, drives a continuation-prompt loop when the child's summary is too short. */
- readonly summaryPolicy?: AgentProfileSummaryPolicy;
- /** Skip the caller-side `subagent.failed` record for provider-rate-limit / aborted failures. */
- readonly suppressRateLimitFailureEvent?: boolean;
- /** Caller's cancellation signal. Aborting it cancels the child's turn. */
- readonly signal: AbortSignal;
- /** Fires once the child's first request is committed (used by swarm to fan out). */
- readonly onReady?: () => void;
-}
-
-export interface ObservedChildAgentTurn {
- readonly turn: Turn;
- readonly completion: Promise<{ readonly summary: string; readonly usage?: TokenUsage }>;
-}
-
-/**
- * Submit a prompt (or a retry) to `child`, wire the caller's record/hook
- * projections and the summary-distillation policy, and return the running
- * `Turn` plus a promise of the distilled summary/usage.
- *
- * Returns `undefined` when the underlying `IAgentPromptService.prompt/retry`
- * refuses to launch a turn (busy / no head).
- */
-export function observeChildAgentTurn(
- caller: IAgentScopeHandle,
- child: IAgentScopeHandle,
- request: ChildAgentTurnRequest,
- options: ObserveChildAgentTurnOptions,
-): ObservedChildAgentTurn | undefined {
- options.signal.throwIfAborted();
- const promptService = child.accessor.get(IAgentPromptService);
- const turn =
- request.kind === 'prompt'
- ? promptService.prompt({
- role: 'user',
- content: [{ type: 'text', text: request.prompt }],
- toolCalls: [],
- origin: CHILD_AGENT_PROMPT_ORIGIN,
- })
- : promptService.retry(request.trigger ?? 'agent-host');
- if (turn === undefined) return undefined;
-
- if (options.onReady !== undefined) {
- void turn.ready.then(() => options.onReady?.()).catch(() => {});
- }
-
- const completion = runObservation(caller, child, turn, request, options);
- return { turn, completion };
-}
-
-async function runObservation(
- caller: IAgentScopeHandle,
- child: IAgentScopeHandle,
- turn: Turn,
- request: ChildAgentTurnRequest,
- options: ObserveChildAgentTurnOptions,
-): Promise<{ summary: string; usage?: TokenUsage }> {
- const controller = new AbortController();
- const unlink = linkAbortSignal(options.signal, controller);
- const record = caller.accessor.get(IAgentRecordService);
- const agentTool = caller.accessor.get(IAgentToolService);
- let turnRef: Turn = turn;
- record?.signal({ type: 'subagent.started', subagentId: child.id });
- if (request.kind === 'prompt') {
- try {
- await agentTool.hooks.onWillRunSubagent.run({
- agentName: options.profileName,
- prompt: request.prompt.slice(0, HOOK_TEXT_PREVIEW_LENGTH),
- signal: options.signal,
- });
- } catch (error) {
- unlink();
- throw error;
- }
- if (options.signal.aborted) {
- unlink();
- throw options.signal.reason ?? userCancellationReason();
- }
- }
- try {
- const result = await awaitTurn(turnRef, controller);
- classifyTurnResult(result);
- const summary = await distillSummary(child, controller, options.summaryPolicy, (t) => {
- turnRef = t;
- });
- const usage = child.accessor.get(IAgentUsageService)?.status().total;
- record?.signal({
- type: 'subagent.completed',
- subagentId: child.id,
- resultSummary: summary,
- usage,
- });
- void agentTool.hooks.onDidRunSubagent.run({
- agentName: options.profileName,
- response: summary.slice(0, HOOK_TEXT_PREVIEW_LENGTH),
- }).catch(() => undefined);
- return { summary, usage };
- } catch (error) {
- if (!isAbortError(error) && !shouldSuppressFailure(options, error)) {
- record?.signal({
- type: 'subagent.failed',
- subagentId: child.id,
- error: errorMessage(error),
- });
- }
- throw error;
- } finally {
- unlink();
- if (controller.signal.aborted) {
- turnRef.abortController.abort(controller.signal.reason);
- }
- }
-}
-
-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 distillSummary(
- child: IAgentScopeHandle,
- controller: AbortController,
- policy: AgentProfileSummaryPolicy | undefined,
- setTurn: (turn: Turn) => void,
-): Promise {
- const memory = child.accessor.get(IAgentContextMemoryService);
- let summary = latestAssistantText(memory.get());
- if (policy === undefined) return summary;
- if (summary.trim().length >= policy.minChars) return summary;
-
- const promptService = child.accessor.get(IAgentPromptService);
- for (let attempt = 0; attempt < policy.retries; attempt++) {
- const turn = promptService.prompt({
- role: 'user',
- content: [{ type: 'text', text: policy.continuationPrompt }],
- toolCalls: [],
- origin: CHILD_AGENT_PROMPT_ORIGIN,
- });
- if (turn === undefined) break;
- setTurn(turn);
- const result = await awaitTurn(turn, controller);
- if (result.reason !== 'completed') break;
- const continued = latestAssistantText(memory.get());
- if (continued.trim().length > 0) summary = continued;
- if (summary.trim().length >= policy.minChars) 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 shouldSuppressFailure(
- options: ObserveChildAgentTurnOptions,
- error: unknown,
-): boolean {
- if (options.suppressRateLimitFailureEvent !== true) return false;
- if (isProviderRateLimitError(error)) return true;
- return isAbortError(error) || options.signal.aborted;
-}
-
-function providerRateLimitErrorFromPayload(error: KimiErrorPayload): APIProviderRateLimitError {
- const requestId =
- typeof error.details?.['requestId'] === 'string' ? error.details['requestId'] : null;
- return new APIProviderRateLimitError(error.message, requestId);
-}
-
-function abortPromise(signal: AbortSignal): Promise {
- if (signal.aborted) {
- return Promise.reject(signal.reason ?? userCancellationReason());
- }
- return new Promise((_resolve, reject) => {
- signal.addEventListener(
- 'abort',
- () => reject(signal.reason ?? userCancellationReason()),
- { once: true },
- );
- });
-}
-
-function errorMessage(error: unknown): string {
- return error instanceof Error ? error.message : String(error);
-}
-
-function latestAssistantText(messages: readonly ContextMessage[]): string {
- for (let i = messages.length - 1; i >= 0; i--) {
- const message = messages[i]!;
- if (message.role !== 'assistant') continue;
- return contentText(message.content);
- }
- return '';
-}
-
-function contentText(content: ContextMessage['content']): string {
- if (typeof content === 'string') return content;
- return content
- .filter((part): part is Extract<(typeof content)[number], { type: 'text' }> => part.type === 'text')
- .map((part) => part.text)
- .join('');
-}
diff --git a/packages/agent-core-v2/src/session/agentLifecycle/runAgentTurn.ts b/packages/agent-core-v2/src/session/agentLifecycle/runAgentTurn.ts
new file mode 100644
index 000000000..981c355b9
--- /dev/null
+++ b/packages/agent-core-v2/src/session/agentLifecycle/runAgentTurn.ts
@@ -0,0 +1,225 @@
+/**
+ * `agentLifecycle` domain (L6) — helper that runs one prompt (or retry) turn on
+ * an agent and distills a summary from its context once the turn ends.
+ *
+ * Not a Service: `runAgentTurn` is a pure function that borrows
+ * `IAgentPromptService`, `IAgentContextMemoryService`, `IAgentUsageService`
+ * from the target agent's scope. It has no notion of a caller: it emits no
+ * record signals, runs no hooks, and tracks no telemetry. Callers that want to
+ * surface the run on their own record stream (the `Agent` tool, the swarm
+ * scheduler) compose this with `mirrorAgentRun` from the `agentTool` domain.
+ *
+ * The lifecycle is imperative — the caller awaits the returned `completion`
+ * promise. Turn hooks are not used because there is exactly one observer (the
+ * caller who requested the run); a hook indirection would only obscure the
+ * flow.
+ */
+
+import {
+ APIProviderRateLimitError,
+ isProviderRateLimitError,
+ type TokenUsage,
+} from '#/app/llmProtocol';
+
+import { linkAbortSignal, userCancellationReason } from '#/_base/utils/abort';
+import type { IAgentScopeHandle } from '#/_base/di/scope';
+import {
+ IAgentContextMemoryService,
+ type ContextMessage,
+ type PromptOrigin,
+} from '#/agent/contextMemory';
+import { ErrorCodes, toKimiErrorPayload, type KimiErrorPayload } from '#/errors';
+import { IAgentPromptService } from '#/agent/prompt';
+import { IAgentUsageService } from '#/agent/usage';
+import type { Turn } from '#/agent/turn';
+import type { AgentProfileSummaryPolicy } from '#/app/agentProfileCatalog';
+
+import type { AgentRunHandle, AgentRunRequest } from './agentLifecycle';
+
+/**
+ * Legacy `PromptOrigin` tag emitted when one agent submits a prompt to another
+ * (the `Agent` tool, swarm scheduler, …). Wire shape kept unchanged
+ * (`kind: 'system_trigger', name: 'subagent'`) so existing session recordings
+ * replay against v2 without a protocol schema bump. Rename lives on a separate
+ * wire-cleanup PR.
+ */
+export const AGENT_RUN_PROMPT_ORIGIN: PromptOrigin = {
+ kind: 'system_trigger',
+ name: 'subagent',
+};
+
+export interface RunAgentTurnOptions {
+ /** When set, drives a continuation-prompt loop when the agent's summary is too short. */
+ readonly summaryPolicy?: AgentProfileSummaryPolicy;
+ /** Cancellation signal. Aborting it cancels the agent's turn. */
+ readonly signal: AbortSignal;
+ /** Fires once the turn's first request is committed (used by swarm to fan out). */
+ readonly onReady?: () => void;
+}
+
+/**
+ * Submit a prompt (or a retry) to `target` and return the running `Turn` plus
+ * a promise of the distilled summary/usage. Throws when the underlying
+ * `IAgentPromptService.prompt/retry` refuses to launch a turn (busy / no head).
+ */
+export function runAgentTurn(
+ target: IAgentScopeHandle,
+ request: AgentRunRequest,
+ options: RunAgentTurnOptions,
+): AgentRunHandle {
+ options.signal.throwIfAborted();
+ const promptService = target.accessor.get(IAgentPromptService);
+ const turn =
+ request.kind === 'prompt'
+ ? promptService.prompt({
+ role: 'user',
+ content: [{ type: 'text', text: request.prompt }],
+ toolCalls: [],
+ origin: AGENT_RUN_PROMPT_ORIGIN,
+ })
+ : promptService.retry(request.trigger ?? 'agent-host');
+ if (turn === undefined) throw new Error('Agent turn could not be started');
+
+ if (options.onReady !== undefined) {
+ void turn.ready.then(() => options.onReady?.()).catch(() => {});
+ }
+
+ const completion = awaitRun(target, turn, options);
+ return { agentId: target.id, turn, completion };
+}
+
+async function awaitRun(
+ target: IAgentScopeHandle,
+ turn: Turn,
+ options: RunAgentTurnOptions,
+): Promise<{ summary: string; usage?: TokenUsage }> {
+ const controller = new AbortController();
+ const unlink = linkAbortSignal(options.signal, controller);
+ let turnRef: Turn = turn;
+ try {
+ const result = await awaitTurn(turnRef, controller);
+ classifyTurnResult(result);
+ const summary = await distillSummary(target, controller, options.summaryPolicy, (t) => {
+ turnRef = t;
+ });
+ const usage = target.accessor.get(IAgentUsageService)?.status().total;
+ return { summary, usage };
+ } finally {
+ unlink();
+ if (controller.signal.aborted) {
+ turnRef.abortController.abort(controller.signal.reason);
+ }
+ }
+}
+
+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 distillSummary(
+ target: IAgentScopeHandle,
+ controller: AbortController,
+ policy: AgentProfileSummaryPolicy | undefined,
+ setTurn: (turn: Turn) => void,
+): Promise {
+ const memory = target.accessor.get(IAgentContextMemoryService);
+ let summary = latestAssistantText(memory.get());
+ if (policy === undefined) return summary;
+ if (summary.trim().length >= policy.minChars) return summary;
+
+ const promptService = target.accessor.get(IAgentPromptService);
+ for (let attempt = 0; attempt < policy.retries; attempt++) {
+ const turn = promptService.prompt({
+ role: 'user',
+ content: [{ type: 'text', text: policy.continuationPrompt }],
+ toolCalls: [],
+ origin: AGENT_RUN_PROMPT_ORIGIN,
+ });
+ if (turn === undefined) break;
+ setTurn(turn);
+ const result = await awaitTurn(turn, controller);
+ if (result.reason !== 'completed') break;
+ const continued = latestAssistantText(memory.get());
+ if (continued.trim().length > 0) summary = continued;
+ if (summary.trim().length >= policy.minChars) break;
+ }
+ return summary;
+}
+
+function classifyTurnResult(result: { reason: string; error?: unknown }): void {
+ if (result.reason === 'filtered') {
+ throw new Error('Agent 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 toRunError(error);
+ }
+ if (result.reason === 'cancelled') {
+ throw userCancellationReason();
+ }
+}
+
+function toRunError(error: unknown): Error {
+ if (error instanceof Error) return error;
+ if (error === undefined || error === null) return new Error('Agent turn failed');
+ return new Error(stringifyRunError(error));
+}
+
+function stringifyRunError(value: unknown): string {
+ if (typeof value === 'string') return value;
+ return String(value);
+}
+
+function providerRateLimitErrorFromPayload(error: KimiErrorPayload): APIProviderRateLimitError {
+ const requestId =
+ typeof error.details?.['requestId'] === 'string' ? error.details['requestId'] : null;
+ return new APIProviderRateLimitError(error.message, requestId);
+}
+
+function abortPromise(signal: AbortSignal): Promise {
+ if (signal.aborted) {
+ return Promise.reject(signal.reason ?? userCancellationReason());
+ }
+ return new Promise((_resolve, reject) => {
+ signal.addEventListener(
+ 'abort',
+ () => {
+ reject(signal.reason ?? userCancellationReason());
+ },
+ { once: true },
+ );
+ });
+}
+
+function latestAssistantText(messages: readonly ContextMessage[]): string {
+ for (let i = messages.length - 1; i >= 0; i--) {
+ const message = messages[i]!;
+ if (message.role !== 'assistant') continue;
+ return contentText(message.content);
+ }
+ return '';
+}
+
+function contentText(content: ContextMessage['content']): string {
+ if (typeof content === 'string') return content;
+ return content
+ .filter((part): part is Extract<(typeof content)[number], { type: 'text' }> => part.type === 'text')
+ .map((part) => part.text)
+ .join('');
+}
diff --git a/packages/agent-core-v2/src/session/agentLifecycle/tools/agent.ts b/packages/agent-core-v2/src/session/agentLifecycle/tools/agent.ts
index 20291cb65..17bb2fb6b 100644
--- a/packages/agent-core-v2/src/session/agentLifecycle/tools/agent.ts
+++ b/packages/agent-core-v2/src/session/agentLifecycle/tools/agent.ts
@@ -1,12 +1,13 @@
/**
* `agentLifecycle` domain (L6) — the `Agent` collaboration tool.
*
- * Thin adapter over `IAgentLifecycleService.runSubagent` /
- * `resumeSubagent`. The tool owns only the LLM-facing surface: JSON
- * schema + tool description, approval rule, task registration
- * (so the LLM can see the child under TaskList/TaskOutput/TaskStop when
- * `run_in_background=true` or after detach), and terminal text
- * formatting.
+ * The LLM-facing wrapper over `IAgentLifecycleService`: translates the tool
+ * args into a Profile + Model binding, creates (or resumes) an agent, drives
+ * one turn via `run`, and mirrors the run onto the calling agent's record
+ * stream (`mirrorAgentRun`). The tool also owns the JSON schema + description,
+ * approval rule, background-task registration (so the LLM can see the run
+ * under TaskList/TaskOutput/TaskStop when `run_in_background=true` or after
+ * detach), and terminal text formatting.
*
* Registered via the module-level `registerTool(AgentTool)` at the bottom of
* this file — the same "import = register" pattern used by every builtin tool.
@@ -33,12 +34,16 @@ import type {
import { ToolAccesses } from '#/agent/tool';
import { registerTool } from '#/agent/toolRegistry';
import {
+ applyProfilePromptPrefix,
IAgentProfileCatalogService,
- type AgentProfileDefinition,
+ type AgentProfile,
} from '#/app/agentProfileCatalog';
import { ILogService } from '#/app/log';
+import { ISessionProcessRunner } from '#/session/process';
+import { ISessionWorkspaceContext } from '#/session/workspaceContext';
import { IAgentLifecycleService } from '../agentLifecycle';
+import { emitAgentRunSpawned, mirrorAgentRun } from '../mirrorAgentRun';
import { SubagentTask, type SubagentHandle } from './subagent-task';
import AGENT_BACKGROUND_DISABLED_DESCRIPTION from './agent-background-disabled.md?raw';
@@ -135,6 +140,8 @@ export class AgentTool implements BuiltinTool {
@IAgentScopeContext scopeContext: IAgentScopeContext,
@IAgentTaskService private readonly tasks: IAgentTaskService,
@IAgentProfileService private readonly profile: IAgentProfileService,
+ @ISessionWorkspaceContext private readonly workspace: ISessionWorkspaceContext,
+ @ISessionProcessRunner private readonly processRunner: ISessionProcessRunner,
@ILogService private readonly log: ILogService,
) {
this.callerAgentId = scopeContext.agentId;
@@ -188,9 +195,99 @@ export class AgentTool implements BuiltinTool {
}
private resumeProfileName(agentId: string): string | undefined {
- const child = this.lifecycle.getHandle(agentId);
- if (child === undefined) return undefined;
- return child.accessor.get(IAgentProfileService).data().profileName;
+ const target = this.lifecycle.getHandle(agentId);
+ if (target === undefined) return undefined;
+ return target.accessor.get(IAgentProfileService).data().profileName;
+ }
+
+ /**
+ * Launch (or resume) the target agent and start its turn: create from an
+ * explicit Profile + Model binding inherited from this agent's own config,
+ * submit the prompt via `lifecycle.run`, and mirror the run onto this
+ * agent's record stream. Returns a handle whose completion carries the
+ * distilled result text.
+ */
+ private async launch(
+ args: AgentToolInput,
+ toolCallId: string,
+ controller: AbortController,
+ ): Promise {
+ const requester = this.lifecycle.getHandle(this.callerAgentId);
+ if (requester === undefined) {
+ throw new Error(`Caller agent "${this.callerAgentId}" does not exist`);
+ }
+
+ const resumeAgentId = args.resume?.trim();
+ const isResume = resumeAgentId !== undefined && resumeAgentId.length > 0;
+
+ let agentId: string;
+ let profileName: string;
+ let promptText = args.prompt;
+ if (isResume) {
+ const target = this.lifecycle.getHandle(resumeAgentId);
+ if (target === undefined) {
+ throw new Error(`Agent instance "${resumeAgentId}" does not exist`);
+ }
+ agentId = target.id;
+ profileName =
+ target.accessor.get(IAgentProfileService).data().profileName ?? RESUMED_LABEL;
+ } else {
+ const requestedProfileName = args.subagent_type?.length
+ ? args.subagent_type
+ : DEFAULT_PROFILE_NAME;
+ const profile = this.catalog.get(requestedProfileName);
+ if (profile === undefined) {
+ throw new Error(`Unknown agent type: "${requestedProfileName}"`);
+ }
+ const own = this.profile.data();
+ if (own.modelAlias === undefined) {
+ throw new Error('Caller agent has no model bound');
+ }
+ // Explicit inheritance: the new agent runs the requested profile on this
+ // agent's own model / thinking level / cwd (Profile + Model ⇒ Agent).
+ const created = await this.lifecycle.create({
+ binding: {
+ profile: profile.name,
+ model: own.modelAlias,
+ thinking: own.thinkingLevel,
+ cwd: own.cwd,
+ },
+ });
+ agentId = created.id;
+ profileName = profile.name;
+ promptText = await applyProfilePromptPrefix(profile, args.prompt, {
+ cwd: this.workspace.workDir,
+ runner: this.processRunner,
+ log: this.log,
+ });
+ }
+
+ const runInBackground = args.run_in_background === true;
+ emitAgentRunSpawned(requester, agentId, {
+ profileName,
+ parentToolCallId: toolCallId,
+ description: args.description,
+ runInBackground,
+ });
+
+ const run = this.lifecycle.run(
+ agentId,
+ { kind: 'prompt', prompt: promptText },
+ { signal: controller.signal },
+ );
+ const mirrored = mirrorAgentRun(requester, run, {
+ profileName,
+ prompt: promptText,
+ signal: controller.signal,
+ cancel: (reason) => {
+ controller.abort(reason);
+ },
+ });
+ return {
+ agentId,
+ profileName,
+ completion: mirrored.then((r) => ({ result: r.summary, usage: r.usage })),
+ };
}
private async execution(
@@ -213,13 +310,6 @@ export class AgentTool implements BuiltinTool {
return { output: BACKGROUND_AGENT_UNAVAILABLE, isError: true };
}
- const metadata = {
- parentToolCallId: toolCallId,
- description: args.description,
- runInBackground,
- };
-
- // Delegate spawn-or-resume + turn observation to the lifecycle service.
const controller = new AbortController();
const abortBeforeRegister = (): void => {
controller.abort(signal.reason);
@@ -228,36 +318,16 @@ export class AgentTool implements BuiltinTool {
signal.addEventListener('abort', abortBeforeRegister, { once: true });
}
- let handle;
+ let handle: SubagentHandle;
try {
- handle = isResume
- ? await this.lifecycle.resumeSubagent({
- callerAgentId: this.callerAgentId,
- agentId: resumeAgentId!,
- prompt: args.prompt,
- signal: controller.signal,
- metadata,
- })
- : await this.lifecycle.runSubagent({
- callerAgentId: this.callerAgentId,
- profileName: requestedProfileName ?? DEFAULT_PROFILE_NAME,
- prompt: args.prompt,
- signal: controller.signal,
- metadata,
- });
+ handle = await this.launch(args, toolCallId, controller);
} catch (error) {
signal.removeEventListener('abort', abortBeforeRegister);
throw error;
}
- // Wrap the lifecycle handle in a background task so the LLM can interact
+ // Wrap the run handle in a background task so the LLM can interact
// with it via TaskList / TaskOutput / TaskStop.
- const subagentHandle: SubagentHandle = {
- agentId: handle.agentId,
- profileName: handle.profileName,
- completion: handle.completion,
- };
-
let taskId: string;
try {
const registerOptions: RegisterAgentTaskOptions = {
@@ -266,18 +336,18 @@ export class AgentTool implements BuiltinTool {
signal: runInBackground ? undefined : signal,
};
taskId = this.tasks.registerTask(
- new SubagentTask(subagentHandle, args.description, controller),
+ new SubagentTask(handle, args.description, controller),
registerOptions,
);
signal.removeEventListener('abort', abortBeforeRegister);
} catch (error) {
controller.abort();
- void subagentHandle.completion.catch(() => {});
+ void handle.completion.catch(() => {});
signal.removeEventListener('abort', abortBeforeRegister);
- this.log?.warn('subagent task registration failed', {
+ this.log?.warn('background agent task registration failed', {
toolCallId,
- agentId: subagentHandle.agentId,
- subagentType: subagentHandle.profileName,
+ agentId: handle.agentId,
+ subagentType: handle.profileName,
error,
});
return {
@@ -288,17 +358,17 @@ export class AgentTool implements BuiltinTool {
if (runInBackground) {
return {
- output: formatBackgroundAgentResult(taskId, subagentHandle, args.description, allowBackground),
+ output: formatBackgroundAgentResult(taskId, handle, args.description, allowBackground),
};
}
const release = await this.tasks.waitForForegroundRelease(taskId);
if (release === 'detached') {
return {
- output: formatBackgroundAgentResult(taskId, subagentHandle, args.description, allowBackground),
+ output: formatBackgroundAgentResult(taskId, handle, args.description, allowBackground),
};
}
- return await this.formatForegroundResult(taskId, subagentHandle);
+ return await this.formatForegroundResult(taskId, handle);
} catch (error) {
return { output: `subagent error: ${launchErrorMessage(error, signal)}`, isError: true };
}
@@ -334,7 +404,7 @@ registerTool(AgentTool);
// ── formatting helpers ───────────────────────────────────────────────
function buildProfileDescriptions(
- profiles: readonly AgentProfileDefinition[],
+ profiles: readonly AgentProfile[],
): string {
return profiles
.map((profile) => {
@@ -342,10 +412,10 @@ function buildProfileDescriptions(
(part): part is string => part !== undefined && part.length > 0,
);
const header = details.length === 0 ? `- ${profile.name}` : `- ${profile.name}: ${details.join(' ')}`;
- if (profile.activeToolNames === undefined || profile.activeToolNames.length === 0) {
+ if (profile.tools.length === 0) {
return header;
}
- return `${header}\n Tools: ${profile.activeToolNames.join(', ')}`;
+ return `${header}\n Tools: ${profile.tools.join(', ')}`;
})
.join('\n');
}
diff --git a/packages/agent-core-v2/src/session/agentLifecycle/tools/subagent-task.ts b/packages/agent-core-v2/src/session/agentLifecycle/tools/subagent-task.ts
index 6db2fb79a..42c3950fb 100644
--- a/packages/agent-core-v2/src/session/agentLifecycle/tools/subagent-task.ts
+++ b/packages/agent-core-v2/src/session/agentLifecycle/tools/subagent-task.ts
@@ -11,6 +11,7 @@ type SubagentCompletion = {
readonly usage?: TokenUsage;
};
+/** Handle to an agent run launched by the `Agent` tool (or swarm). */
export type SubagentHandle = {
readonly agentId: string;
readonly profileName: string;
@@ -19,9 +20,9 @@ export type SubagentHandle = {
export interface SubagentTaskInfo extends AgentTaskInfoBase {
readonly kind: 'agent';
- /** Subagent identifier accepted by Agent(resume=...). */
+ /** Agent identifier accepted by Agent(resume=...). */
readonly agentId?: string;
- /** Subagent profile name. */
+ /** Profile name of the agent. Wire DTO field name kept for compatibility. */
readonly subagentType?: string;
}
@@ -40,8 +41,8 @@ function errorMessage(err: unknown): string {
}
/**
- * Create a `taskService.run()`-compatible executor that waits for a
- * subagent completion promise. Resolves with the subagent result on
+ * Create a `taskService.run()`-compatible executor that waits for an
+ * agent-run completion promise. Resolves with the agent's result on
* success, throws on abort or failure.
*/
export function createSubagentExecutor(
diff --git a/packages/agent-core-v2/src/session/btw/btwService.ts b/packages/agent-core-v2/src/session/btw/btwService.ts
index 5cb9a4f28..df991d473 100644
--- a/packages/agent-core-v2/src/session/btw/btwService.ts
+++ b/packages/agent-core-v2/src/session/btw/btwService.ts
@@ -1,12 +1,13 @@
/**
* `btw` domain — `ISessionBtwService` implementation.
*
- * Clones the main agent into a side-question child: inherits profile/context via
- * `IAgentLifecycleService.clone`, then disables tool calls (deny-all permission
+ * Forks the main agent into a side-question child: inherits profile/context via
+ * `IAgentLifecycleService.fork`, then disables tool calls (deny-all permission
* policy) and appends the side-channel system reminder. Bound at Session scope —
- * `clone('main')` is a session-level operation, so the service injects the
+ * `fork('main')` is a session-level operation, so the service injects the
* session's `IAgentLifecycleService` directly rather than resolving it through
- * the main agent's accessor.
+ * the main agent's accessor. The main agent is guaranteed to exist by session
+ * bootstrap (`ensureMainAgent`); forking a missing source throws.
*/
import { InstantiationType } from '#/_base/di/extensions';
@@ -28,7 +29,7 @@ export class SessionBtwService implements ISessionBtwService {
) {}
async start(): Promise {
- const child = await this.lifecycle.clone('main');
+ const child = await this.lifecycle.fork('main');
child.accessor
.get(IAgentSystemReminderService)
?.appendSystemReminder(SIDE_QUESTION_SYSTEM_REMINDER, {
diff --git a/packages/agent-core-v2/src/session/cron/sessionCronService.ts b/packages/agent-core-v2/src/session/cron/sessionCronService.ts
index cbacbbefa..110f754ca 100644
--- a/packages/agent-core-v2/src/session/cron/sessionCronService.ts
+++ b/packages/agent-core-v2/src/session/cron/sessionCronService.ts
@@ -12,7 +12,7 @@ import type { ContentPart } from '#/app/llmProtocol';
import { createDecorator } from '#/_base/di';
import type { Turn } from '#/agent/turn';
-import type { CronTask, CronTaskInit } from '#/app/cronPersistence';
+import type { CronTask, CronTaskInit } from '#/app/cron';
export interface CronLoadOptions {
readonly replace?: boolean;
diff --git a/packages/agent-core-v2/src/session/cron/sessionCronServiceImpl.ts b/packages/agent-core-v2/src/session/cron/sessionCronServiceImpl.ts
index ff4d7b64b..1ab865678 100644
--- a/packages/agent-core-v2/src/session/cron/sessionCronServiceImpl.ts
+++ b/packages/agent-core-v2/src/session/cron/sessionCronServiceImpl.ts
@@ -6,7 +6,10 @@
* (tick / coalesce / jitter / cursor), persists mutations through the
* App-scoped `ICronTaskPersistence`, mirrors mutations onto `wireRecord` for
* replay via the main agent's `IAgentRecordService` (cross-scope borrow),
- * and steers the main agent through `IAgentPromptService` when a task fires.
+ * steers the main agent through `IAgentPromptService` when a task fires, and
+ * registers the cron tools (`CronCreate` / `CronList` / `CronDelete`) into the
+ * main agent's `IAgentToolRegistryService` once `IAgentLifecycleService`
+ * signals `onDidCreateMain`. Bound at Session scope.
* Bound at Session scope.
*/
@@ -17,37 +20,41 @@ import type { CronJobOrigin, CronMissedOrigin } from '@moonshot-ai/protocol';
import { Disposable, toDisposable } from '#/_base/di';
import { InstantiationType } from '#/_base/di/extensions';
+import { IInstantiationService } from '#/_base/di/instantiation';
import { type IAgentScopeHandle, LifecycleScope, registerScopedService } from '#/_base/di/scope';
import { IntervalTimer } from '#/_base/utils';
import { IConfigService } from '#/app/config';
import { ITelemetryService } from '#/app/telemetry';
-import { ICronTaskPersistence, type CronTask, type CronTaskInit } from '#/app/cronPersistence';
+import {
+ computeNextCronRun,
+ type ClockSources,
+ type CronConfig,
+ type CronTask,
+ type CronTaskInit,
+ CRON_SECTION,
+ DEFAULT_CRON_CONFIG,
+ ICronTaskPersistence,
+ jitteredNextCronRunMs,
+ oneShotJitteredNextCronRunMs,
+ parseCronExpression,
+ type ParsedCronExpression,
+ renderCronFireXml,
+ resolveClockSources,
+ SYSTEM_CLOCKS,
+} from '#/app/cron';
import { ISessionContext } from '#/session/sessionContext';
import { IAgentLifecycleService } from '#/session/agentLifecycle';
import type { ContextMessage } from '#/agent/contextMemory';
import { IAgentPromptService } from '#/agent/prompt';
import { IAgentRecordService } from '#/agent/record';
+import { IAgentToolRegistryService } from '#/agent/toolRegistry';
import type { Turn } from '#/agent/turn';
import { IAgentTurnService } from '#/agent/turn';
-import {
- type CronConfig,
- CRON_SECTION,
- DEFAULT_CRON_CONFIG,
-} from '#/agent/cron/configSection';
-import {
- computeNextCronRun,
- parseCronExpression,
- type ParsedCronExpression,
-} from '#/agent/cron/cron-expr';
-import { renderCronFireXml } from '#/agent/cron/format';
-import { jitteredNextCronRunMs, oneShotJitteredNextCronRunMs } from '#/agent/cron/jitter';
-import {
- resolveClockSources,
- SYSTEM_CLOCKS,
- type ClockSources,
-} from '#/agent/cron/clock';
+import { CronCreateTool } from './tools/cron-create';
+import { CronListTool } from './tools/cron-list';
+import { CronDeleteTool } from './tools/cron-delete';
import { ISessionCronService, type CronLoadOptions } from './sessionCronService';
@@ -116,15 +123,14 @@ export class SessionCronServiceImpl extends Disposable implements ISessionCronSe
resolveClockSources(this.cronConfig.clock, this.cronConfig.debug) ?? SYSTEM_CLOCKS;
this._register(
- this.agentLifecycle.onDidCreate((handle) => {
- if (handle.id !== 'main') return;
- this.wireMainAgent(handle);
+ this.agentLifecycle.onDidCreateMain((handle) => {
+ this.bindMainAgent(handle);
}),
);
const existingMain = this.agentLifecycle.getHandle('main');
if (existingMain) {
- this.wireMainAgent(existingMain);
+ this.bindMainAgent(existingMain);
}
this._register(
@@ -134,7 +140,7 @@ export class SessionCronServiceImpl extends Disposable implements ISessionCronSe
);
}
- private wireMainAgent(handle: IAgentScopeHandle): void {
+ private bindMainAgent(handle: IAgentScopeHandle): void {
const record = handle.accessor.get(IAgentRecordService);
record.define('cron.add', {
@@ -158,9 +164,24 @@ export class SessionCronServiceImpl extends Disposable implements ISessionCronSe
await next();
});
+ this.registerCronTools(handle);
+
void this.loadFromStore().then(() => this.start());
}
+ private registerCronTools(handle: IAgentScopeHandle): void {
+ const instantiation = handle.accessor.get(IInstantiationService);
+ const registry = handle.accessor.get(IAgentToolRegistryService);
+ const tools = [
+ instantiation.createInstance(CronCreateTool, this.cronConfig.disabled),
+ instantiation.createInstance(CronListTool),
+ instantiation.createInstance(CronDeleteTool),
+ ];
+ for (const tool of tools) {
+ this._register(registry.register(tool, { source: 'builtin' }));
+ }
+ }
+
now(): number {
return this.clocks.wallNow();
}
diff --git a/packages/agent-core-v2/src/agent/cron/tools/cron-create.md b/packages/agent-core-v2/src/session/cron/tools/cron-create.md
similarity index 100%
rename from packages/agent-core-v2/src/agent/cron/tools/cron-create.md
rename to packages/agent-core-v2/src/session/cron/tools/cron-create.md
diff --git a/packages/agent-core-v2/src/agent/cron/tools/cron-create.ts b/packages/agent-core-v2/src/session/cron/tools/cron-create.ts
similarity index 96%
rename from packages/agent-core-v2/src/agent/cron/tools/cron-create.ts
rename to packages/agent-core-v2/src/session/cron/tools/cron-create.ts
index 5ddd1196e..cad632072 100644
--- a/packages/agent-core-v2/src/agent/cron/tools/cron-create.ts
+++ b/packages/agent-core-v2/src/session/cron/tools/cron-create.ts
@@ -26,28 +26,19 @@
import { z } from 'zod';
import type { ExecutableTool as BuiltinTool, ToolExecution } from '#/agent/tool';
-import { registerTool } from '#/agent/toolRegistry';
import { toInputJsonSchema } from '#/_base/tools/support/input-schema';
import { literalRulePattern } from '#/_base/tools/support/rule-match';
-import { IConfigService } from '#/app/config';
import { ISessionCronService } from '#/session/cron';
-import {
- CRON_SECTION,
- DEFAULT_CRON_CONFIG,
- type CronConfig,
-} from '#/agent/cron/configSection';
import {
computeNextCronRun,
cronToHuman,
+ formatLocalIsoWithOffset,
hasFireWithinYears,
- parseCronExpression,
- type ParsedCronExpression,
-} from '#/agent/cron/cron-expr';
-import { formatLocalIsoWithOffset } from '#/agent/cron/format';
-import {
jitteredNextCronRunMs,
oneShotJitteredNextCronRunMs,
-} from '#/agent/cron/jitter';
+ parseCronExpression,
+ type ParsedCronExpression,
+} from '#/app/cron';
import CRON_CREATE_DESCRIPTION from './cron-create.md?raw';
// ── Constants ────────────────────────────────────────────────────────
@@ -322,13 +313,6 @@ export class CronCreateTool implements BuiltinTool {
}
}
-registerTool(CronCreateTool, {
- staticArgs: (accessor) => [
- accessor.get(IConfigService).get(CRON_SECTION)?.disabled
- ?? DEFAULT_CRON_CONFIG.disabled,
- ],
-});
-
function formatOutput(o: CronCreateOutput): string {
const lines = [
`id: ${o.id}`,
diff --git a/packages/agent-core-v2/src/agent/cron/tools/cron-delete.md b/packages/agent-core-v2/src/session/cron/tools/cron-delete.md
similarity index 100%
rename from packages/agent-core-v2/src/agent/cron/tools/cron-delete.md
rename to packages/agent-core-v2/src/session/cron/tools/cron-delete.md
diff --git a/packages/agent-core-v2/src/agent/cron/tools/cron-delete.ts b/packages/agent-core-v2/src/session/cron/tools/cron-delete.ts
similarity index 98%
rename from packages/agent-core-v2/src/agent/cron/tools/cron-delete.ts
rename to packages/agent-core-v2/src/session/cron/tools/cron-delete.ts
index 5f3edf834..fac9c417b 100644
--- a/packages/agent-core-v2/src/agent/cron/tools/cron-delete.ts
+++ b/packages/agent-core-v2/src/session/cron/tools/cron-delete.ts
@@ -38,7 +38,6 @@
import { z } from 'zod';
import type { ExecutableTool as BuiltinTool, ToolExecution } from '#/agent/tool';
-import { registerTool } from '#/agent/toolRegistry';
import { toInputJsonSchema } from '#/_base/tools/support/input-schema';
import { ISessionCronService } from '#/session/cron';
import CRON_DELETE_DESCRIPTION from './cron-delete.md?raw';
@@ -116,5 +115,3 @@ export class CronDeleteTool implements BuiltinTool {
};
}
}
-
-registerTool(CronDeleteTool);
diff --git a/packages/agent-core-v2/src/agent/cron/tools/cron-list.md b/packages/agent-core-v2/src/session/cron/tools/cron-list.md
similarity index 100%
rename from packages/agent-core-v2/src/agent/cron/tools/cron-list.md
rename to packages/agent-core-v2/src/session/cron/tools/cron-list.md
diff --git a/packages/agent-core-v2/src/agent/cron/tools/cron-list.ts b/packages/agent-core-v2/src/session/cron/tools/cron-list.ts
similarity index 96%
rename from packages/agent-core-v2/src/agent/cron/tools/cron-list.ts
rename to packages/agent-core-v2/src/session/cron/tools/cron-list.ts
index feff7019e..03ee1cd70 100644
--- a/packages/agent-core-v2/src/agent/cron/tools/cron-list.ts
+++ b/packages/agent-core-v2/src/session/cron/tools/cron-list.ts
@@ -43,15 +43,14 @@
import { z } from 'zod';
import type { ExecutableTool as BuiltinTool, ToolExecution } from '#/agent/tool';
-import { registerTool } from '#/agent/toolRegistry';
import { toInputJsonSchema } from '#/_base/tools/support/input-schema';
import { ISessionCronService } from '#/session/cron';
-import type { CronTask } from '#/app/cronPersistence';
import {
cronToHuman,
+ formatLocalIsoWithOffset,
parseCronExpression,
-} from '#/agent/cron/cron-expr';
-import { formatLocalIsoWithOffset } from '#/agent/cron/format';
+ type CronTask,
+} from '#/app/cron';
import CRON_LIST_DESCRIPTION from './cron-list.md?raw';
// ── Input schema ─────────────────────────────────────────────────────
@@ -170,5 +169,3 @@ export class CronListTool implements BuiltinTool {
].join('\n');
}
}
-
-registerTool(CronListTool);
diff --git a/packages/agent-core-v2/src/session/sessionMetadata/sessionMetadata.ts b/packages/agent-core-v2/src/session/sessionMetadata/sessionMetadata.ts
index d8d7d4ba2..0a1a41d75 100644
--- a/packages/agent-core-v2/src/session/sessionMetadata/sessionMetadata.ts
+++ b/packages/agent-core-v2/src/session/sessionMetadata/sessionMetadata.ts
@@ -15,8 +15,14 @@ import { createDecorator, type ServiceIdentifier } from '#/_base/di/instantiatio
export interface AgentMeta {
/** Per-agent directory used as the wire-record `homedir` (persistence key). */
readonly homedir: string;
- /** Agent this one was cloned / derived from (provenance only; not used by business logic). */
+ /** Agent this one was forked / derived from (provenance only; not used by business logic). */
readonly forkedFrom?: string;
+ /**
+ * Business-defined recorded values (e.g. the swarm's `swarmItem`), persisted
+ * verbatim. Never interpreted by the lifecycle.
+ */
+ readonly labels?: Readonly>;
+ /** @deprecated Legacy on-disk field predating `labels`; read-compat only. */
readonly swarmItem?: string;
}
diff --git a/packages/agent-core-v2/src/session/swarm/subagentBatch.ts b/packages/agent-core-v2/src/session/swarm/agentRunBatch.ts
similarity index 91%
rename from packages/agent-core-v2/src/session/swarm/subagentBatch.ts
rename to packages/agent-core-v2/src/session/swarm/agentRunBatch.ts
index 9b39a8628..36a607022 100644
--- a/packages/agent-core-v2/src/session/swarm/subagentBatch.ts
+++ b/packages/agent-core-v2/src/session/swarm/agentRunBatch.ts
@@ -3,7 +3,7 @@
*
* Owns the burst-then-throttle launch ramp and the provider-rate-limit recovery
* loop used by `SessionSwarmService`; drives each attempt through a
- * `SubagentBatchLauncher` and surfaces requeues via `suspended`. Pure scheduling
+ * `AgentRunBatchLauncher` and surfaces requeues via `suspended`. Pure scheduling
* logic — owns no scoped state. Not part of the public surface: only
* `SessionSwarmService` imports it.
*/
@@ -16,13 +16,13 @@ import type { SessionSwarmRunResult, SessionSwarmTask } from './sessionSwarm';
// ── Launcher contract ────────────────────────────────────────────────
//
-// The scheduler drives child-agent attempts through a small launcher
+// The scheduler drives agent-run attempts through a small launcher
// interface. Consumers (currently only `SessionSwarmService`) implement it
-// on top of `IAgentLifecycleService.spawn({ profile })` +
-// `observeChildAgentTurn`; the option shapes are defined here so the
-// scheduler has a stable contract regardless of how launches are wired.
+// on top of `IAgentLifecycleService.create({ binding })` + `run` +
+// `mirrorAgentRun`; the option shapes are defined here so the scheduler has
+// a stable contract regardless of how launches are wired.
-export interface RunSubagentOptions {
+export interface AgentRunAttemptOptions {
readonly parentToolCallId: string;
readonly parentToolCallUuid?: string;
readonly prompt: string;
@@ -34,12 +34,12 @@ export interface RunSubagentOptions {
readonly suppressRateLimitFailureEvent?: boolean;
}
-export interface SpawnSubagentOptions extends RunSubagentOptions {
+export interface AgentSpawnAttemptOptions extends AgentRunAttemptOptions {
readonly profileName: string;
readonly swarmItem?: string;
}
-export type SubagentHandle = {
+export type AgentRunAttemptHandle = {
readonly agentId: string;
readonly profileName: string;
readonly completion: Promise<{
@@ -49,7 +49,7 @@ export type SubagentHandle = {
};
/*
-Subagent batch scheduling contract:
+Agent-run batch scheduling contract:
Normal phase:
- Return results in input order; empty input returns an empty list.
- Start up to 5 tasks immediately, then 1 more every 700 ms while queued work remains. By default active tasks do not cap this ramp; when KIMI_CODE_AGENT_SWARM_MAX_CONCURRENCY is set to a positive integer, the ramp additionally stops while active tasks reach that cap, and resumes as tasks complete.
@@ -79,23 +79,23 @@ const RATE_LIMIT_SUSPENDED_REASON = 'Provider rate limit; subagent requeued for
const AGENT_SWARM_MAX_CONCURRENCY_ENV = 'KIMI_CODE_AGENT_SWARM_MAX_CONCURRENCY';
-export type QueuedSubagentTask = SessionSwarmTask;
+export type QueuedAgentRunTask = SessionSwarmTask;
-export type SubagentResult = SessionSwarmRunResult;
+export type AgentRunResult = SessionSwarmRunResult;
-export type QueuedSubagentRunResult = SessionSwarmRunResult;
+export type QueuedAgentRunResult = SessionSwarmRunResult;
-export type SubagentSuspendedEvent = {
- readonly task: QueuedSubagentTask;
+export type AgentRunSuspendedEvent = {
+ readonly task: QueuedAgentRunTask;
readonly agentId: string;
readonly reason: string;
};
-export type SubagentBatchLauncher = {
- spawn(options: SpawnSubagentOptions): Promise;
- resume(agentId: string, options: RunSubagentOptions): Promise;
- retry(agentId: string, options: RunSubagentOptions): Promise;
- suspended?(event: SubagentSuspendedEvent): void;
+export type AgentRunBatchLauncher = {
+ spawn(options: AgentSpawnAttemptOptions): Promise;
+ resume(agentId: string, options: AgentRunAttemptOptions): Promise;
+ retry(agentId: string, options: AgentRunAttemptOptions): Promise;
+ suspended?(event: AgentRunSuspendedEvent): void;
};
type RateLimitedOutcome = {
@@ -104,11 +104,11 @@ type RateLimitedOutcome = {
readonly error: string;
};
-type AttemptOutcome = SubagentResult | RateLimitedOutcome;
+type AttemptOutcome = AgentRunResult | RateLimitedOutcome;
type TaskState = {
readonly index: number;
- readonly task: QueuedSubagentTask;
+ readonly task: QueuedAgentRunTask;
agentId?: string;
retryAgentId?: string;
retryCount: number;
@@ -124,19 +124,19 @@ type ActiveAttempt = {
timedOut: boolean;
};
-export type SubagentBatchOptions = {
+export type AgentRunBatchOptions = {
/**
- * Optional cap on how many subagents may run concurrently during the normal
+ * Optional cap on how many agent runs may execute concurrently during the normal
* phase. `undefined` means no cap (legacy ramp behavior). The rate-limit
* phase is governed by its own capacity logic and is not affected.
*/
readonly maxConcurrency?: number;
};
-export class SubagentBatch {
+export class AgentRunBatch {
private readonly states: Array>;
private readonly pending: Array>;
- private readonly results: Array | undefined>;
+ private readonly results: Array | undefined>;
private readonly active = new Set>();
private readonly controller = new AbortController();
private readonly batchSignal: AbortSignal | undefined;
@@ -145,7 +145,7 @@ export class SubagentBatch {
private normalLaunchCount = 0;
private normalLaunchTimer: ReturnType | undefined;
private rateLimitLaunchTimer: ReturnType | undefined;
- private resolve: ((results: Array>) => void) | undefined;
+ private resolve: ((results: Array>) => void) | undefined;
private reject: ((error: unknown) => void) | undefined;
private finished = false;
private started = false;
@@ -159,9 +159,9 @@ export class SubagentBatch {
private nextRateLimitLaunchAt = 0;
constructor(
- private readonly launcher: SubagentBatchLauncher,
- tasks: readonly QueuedSubagentTask[],
- options: SubagentBatchOptions = {},
+ private readonly launcher: AgentRunBatchLauncher,
+ tasks: readonly QueuedAgentRunTask[],
+ options: AgentRunBatchOptions = {},
) {
this.maxConcurrency = options.maxConcurrency;
this.states = tasks.map((task, index) => ({
@@ -184,9 +184,9 @@ export class SubagentBatch {
};
}
- run(): Promise>> {
+ run(): Promise>> {
if (this.started) {
- throw new Error('SubagentBatch.run() can only be called once.');
+ throw new Error('AgentRunBatch.run() can only be called once.');
}
this.started = true;
@@ -307,7 +307,7 @@ export class SubagentBatch {
private async runAttempt(attempt: ActiveAttempt): Promise> {
const task = attempt.state.task;
- const runOptions: RunSubagentOptions = {
+ const runOptions: AgentRunAttemptOptions = {
parentToolCallId: task.parentToolCallId,
parentToolCallUuid: task.parentToolCallUuid,
prompt: task.prompt,
@@ -321,7 +321,7 @@ export class SubagentBatch {
suppressRateLimitFailureEvent: true,
};
- let handle: SubagentHandle;
+ let handle: AgentRunAttemptHandle;
try {
attempt.controller.signal.throwIfAborted();
if (attempt.state.retryAgentId !== undefined) {
@@ -329,7 +329,7 @@ export class SubagentBatch {
} else if (task.kind === 'resume') {
handle = await this.launcher.resume(task.resumeAgentId, runOptions);
} else {
- const spawnOptions: SpawnSubagentOptions = {
+ const spawnOptions: AgentSpawnAttemptOptions = {
profileName: task.profileName,
swarmItem: task.swarmItem,
...runOptions,
@@ -363,7 +363,7 @@ export class SubagentBatch {
}
}
- private failedAttemptOutcome(attempt: ActiveAttempt, error: unknown): SubagentResult {
+ private failedAttemptOutcome(attempt: ActiveAttempt, error: unknown): AgentRunResult {
const status =
attempt.controller.signal.aborted && isUserCancellation(attempt.controller.signal.reason)
? 'aborted'
@@ -588,7 +588,7 @@ export class SubagentBatch {
);
}
- private finish(results: Array>): void {
+ private finish(results: Array>): void {
if (this.finished) return;
this.finished = true;
this.cleanup();
@@ -622,7 +622,7 @@ export class SubagentBatch {
this.rateLimitLaunchTimer = undefined;
}
- private linkAttemptSignals(attempt: ActiveAttempt, task: QueuedSubagentTask): () => void {
+ private linkAttemptSignals(attempt: ActiveAttempt, task: QueuedAgentRunTask): () => void {
const abortFromBatch = () => {
attempt.controller.abort(this.controller.signal.reason);
};
@@ -656,7 +656,7 @@ export class SubagentBatch {
private attemptErrorMessage(
attempt: ActiveAttempt,
error: unknown,
- status: SubagentResult['status'],
+ status: AgentRunResult['status'],
): string {
if (attempt.timedOut && attempt.state.task.timeout !== undefined) {
return 'Subagent timed out.';
diff --git a/packages/agent-core-v2/src/session/swarm/index.ts b/packages/agent-core-v2/src/session/swarm/index.ts
index b28bf0067..52ee0a1f9 100644
--- a/packages/agent-core-v2/src/session/swarm/index.ts
+++ b/packages/agent-core-v2/src/session/swarm/index.ts
@@ -2,7 +2,7 @@
* `sessionSwarm` domain barrel — re-exports the sessionSwarm contract
* (`sessionSwarm`) and its scoped service (`sessionSwarmService`). Importing
* this barrel registers the `ISessionSwarmService` binding into the scope
- * registry. The internal `subagentBatch` scheduler is not exported.
+ * registry. The internal `agentRunBatch` scheduler is not exported.
*/
export * from './sessionSwarm';
diff --git a/packages/agent-core-v2/src/session/swarm/sessionSwarm.ts b/packages/agent-core-v2/src/session/swarm/sessionSwarm.ts
index 9c2b5e78b..743cadf42 100644
--- a/packages/agent-core-v2/src/session/swarm/sessionSwarm.ts
+++ b/packages/agent-core-v2/src/session/swarm/sessionSwarm.ts
@@ -1,10 +1,10 @@
/**
- * `sessionSwarm` domain (L4) — batch scheduler for swarm subagent runs.
+ * `sessionSwarm` domain (L4) — batch scheduler for swarm agent runs.
*
* Defines `ISessionSwarmService`, the Session-scoped service that runs a batch
- * of subagents on behalf of a caller agent. Owns the in-flight batch state so
+ * of agents on behalf of a caller agent. Owns the in-flight batch state so
* cancellation can reach every run; the actual concurrency / rate-limit logic
- * lives in the internal `subagentBatch` module. Bound at Session scope.
+ * lives in the internal `agentRunBatch` module. Bound at Session scope.
*/
import type { TokenUsage } from '#/app/llmProtocol';
diff --git a/packages/agent-core-v2/src/session/swarm/sessionSwarmService.ts b/packages/agent-core-v2/src/session/swarm/sessionSwarmService.ts
index 8bae46900..d904744bf 100644
--- a/packages/agent-core-v2/src/session/swarm/sessionSwarmService.ts
+++ b/packages/agent-core-v2/src/session/swarm/sessionSwarmService.ts
@@ -1,16 +1,16 @@
/**
* `sessionSwarm` domain (L4) — `ISessionSwarmService` implementation.
*
- * Runs a batch of subagents on behalf of a caller agent: builds a
- * `SubagentBatchLauncher` on top of the `agentLifecycle` primitives
- * (`spawn({ profile })`, `observeChildAgentTurn`), drives the
- * internal `SubagentBatch` scheduler, and tracks one `AbortController` per
- * caller so `cancel` can abort every in-flight run. `subagent.spawned` facts
- * carrying the swarm's tool-call context, and `subagent.suspended` facts
- * emitted when a task is requeued after a provider rate limit, are recorded
- * on the caller agent's event sink; the child's own turn lifecycle
- * (`subagent.started/completed/failed`) is mirrored inside
- * `observeChildAgentTurn`. Bound at Session scope.
+ * Runs a batch of agents on behalf of a caller agent: builds an
+ * `AgentRunBatchLauncher` on top of the `agentLifecycle` primitives
+ * (`create({ binding })`, `run`), drives the internal `AgentRunBatch`
+ * scheduler, and tracks one `AbortController` per caller so `cancel` can abort
+ * every in-flight run. The caller ↔ child association is this domain's own
+ * business data: requester-side display facts (`subagent.spawned` wire signals
+ * carrying the swarm's tool-call context, `subagent.suspended` when a task is
+ * requeued after a provider rate limit) are emitted here / via the
+ * `agentLifecycle` wrapper helper `mirrorAgentRun`; the lifecycle registry
+ * itself stays flat. Bound at Session scope.
*/
import type { TokenUsage } from '#/app/llmProtocol';
@@ -21,11 +21,14 @@ import { linkAbortSignal } from '#/_base/utils/abort';
import type { IAgentScopeHandle } from '#/_base/di/scope';
import { IAgentProfileService } from '#/agent/profile';
import { IAgentRecordService } from '#/agent/record';
-import { ITelemetryService } from '#/app/telemetry';
-import { IAgentProfileCatalogService } from '#/app/agentProfileCatalog';
import {
+ applyProfilePromptPrefix,
+ IAgentProfileCatalogService,
+} from '#/app/agentProfileCatalog';
+import {
+ emitAgentRunSpawned,
IAgentLifecycleService,
- observeChildAgentTurn,
+ mirrorAgentRun,
} from '#/session/agentLifecycle';
import { IExecContext } from '#/session/execContext';
import { ISessionProcessRunner } from '#/session/process';
@@ -39,12 +42,18 @@ import {
} from './sessionSwarm';
import {
resolveSwarmMaxConcurrency,
- SubagentBatch,
- type RunSubagentOptions,
- type SpawnSubagentOptions,
- type SubagentBatchLauncher,
- type SubagentHandle,
-} from './subagentBatch';
+ AgentRunBatch,
+ type AgentRunAttemptOptions,
+ type AgentSpawnAttemptOptions,
+ type AgentRunBatchLauncher,
+ type AgentRunAttemptHandle,
+} from './agentRunBatch';
+
+/**
+ * Requester-facing label for a resumed agent whose profile binding is unknown.
+ * Kept as the legacy wire display value.
+ */
+const RESUMED_PROFILE_FALLBACK = 'subagent';
export class SessionSwarmService implements ISessionSwarmService {
declare readonly _serviceBrand: undefined;
@@ -68,7 +77,7 @@ export class SessionSwarmService implements ISessionSwarmService {
if (task.signal !== undefined) unlinks.push(linkAbortSignal(task.signal, controller));
return { ...task, signal: controller.signal };
});
- const launcher: SubagentBatchLauncher = {
+ const launcher: AgentRunBatchLauncher = {
spawn: (options) => this.spawnAttempt(callerAgentId, options),
resume: (agentId, options) => this.resumeAttempt(callerAgentId, agentId, options, false),
retry: (agentId, options) => this.resumeAttempt(callerAgentId, agentId, options, true),
@@ -82,7 +91,7 @@ export class SessionSwarmService implements ISessionSwarmService {
},
};
const maxConcurrency = resolveSwarmMaxConcurrency();
- const promise = new SubagentBatch(launcher, linkedTasks, { maxConcurrency }).run();
+ const promise = new AgentRunBatch(launcher, linkedTasks, { maxConcurrency }).run();
void promise.finally(() => {
for (const unlink of unlinks) unlink();
if (this.inFlight.get(callerAgentId) === controller) this.inFlight.delete(callerAgentId);
@@ -96,114 +105,95 @@ export class SessionSwarmService implements ISessionSwarmService {
private async spawnAttempt(
callerAgentId: string,
- options: SpawnSubagentOptions,
- ): Promise {
+ options: AgentSpawnAttemptOptions,
+ ): Promise {
options.signal.throwIfAborted();
const caller = this.requireHandle(callerAgentId, 'Caller agent');
const profile = this.catalog.get(options.profileName);
if (profile === undefined) {
throw new Error(`Unknown agent type: "${options.profileName}"`);
}
- const child = await this.lifecycle.spawn(callerAgentId, {
- swarmItem: options.swarmItem,
- profile: profile.name,
- });
- this.emitSpawned(caller, child.id, options.profileName, options);
- const promptText = profile.promptPrefix !== undefined
- ? await this.withProfilePrefix(profile.promptPrefix, options.prompt)
- : options.prompt;
- const observed = observeChildAgentTurn(
- caller,
- child,
- { kind: 'prompt', prompt: promptText },
- {
- profileName: options.profileName,
- summaryPolicy: profile.summaryPolicy,
- suppressRateLimitFailureEvent: options.suppressRateLimitFailureEvent,
- signal: options.signal,
- onReady: options.onReady,
+ const callerData = caller.accessor.get(IAgentProfileService).data();
+ if (callerData.modelAlias === undefined) {
+ throw new Error('Caller agent has no model bound');
+ }
+ // Explicit inheritance: the child runs the requested profile on the
+ // caller's own model / thinking level / cwd (Profile + Model ⇒ Agent).
+ const child = await this.lifecycle.create({
+ binding: {
+ profile: profile.name,
+ model: callerData.modelAlias,
+ thinking: callerData.thinkingLevel,
+ cwd: callerData.cwd,
},
- );
- if (observed === undefined) throw new Error('Subagent turn could not be started');
- return {
- agentId: child.id,
+ labels: options.swarmItem === undefined ? undefined : { swarmItem: options.swarmItem },
+ });
+ emitAgentRunSpawned(caller, child.id, {
profileName: options.profileName,
- completion: observed.completion.then((r) => ({ result: r.summary, usage: r.usage })),
- };
+ parentToolCallId: options.parentToolCallId,
+ parentToolCallUuid: options.parentToolCallUuid,
+ description: options.description,
+ swarmIndex: options.swarmIndex,
+ runInBackground: options.runInBackground,
+ });
+ const promptText = await applyProfilePromptPrefix(profile, options.prompt, {
+ cwd: this.execContext.cwd,
+ runner: this.processRunner,
+ log: this.log,
+ });
+ return this.observe(caller, child.id, options.profileName, {
+ kind: 'prompt',
+ prompt: promptText,
+ }, options);
}
private async resumeAttempt(
callerAgentId: string,
agentId: string,
- options: RunSubagentOptions,
+ options: AgentRunAttemptOptions,
retryTurn: boolean,
- ): Promise {
+ ): Promise {
options.signal.throwIfAborted();
const caller = this.requireHandle(callerAgentId, 'Caller agent');
const child = this.requireHandle(agentId, 'Agent instance');
const profileName =
- child.accessor.get(IAgentProfileService).data().profileName ?? 'subagent';
- const profile = this.catalog.get(profileName);
- this.emitSpawned(caller, agentId, profileName, options);
- const request = retryTurn
- ? ({ kind: 'retry' } as const)
- : ({ kind: 'prompt', prompt: options.prompt } as const);
- const observed = observeChildAgentTurn(caller, child, request, {
+ child.accessor.get(IAgentProfileService).data().profileName ?? RESUMED_PROFILE_FALLBACK;
+ emitAgentRunSpawned(caller, agentId, {
profileName,
- summaryPolicy: profile?.summaryPolicy,
- suppressRateLimitFailureEvent: options.suppressRateLimitFailureEvent,
- signal: options.signal,
- onReady: options.onReady,
- });
- if (observed === undefined) throw new Error('Subagent turn could not be started');
- return {
- agentId,
- profileName,
- completion: observed.completion.then((r) => ({ result: r.summary, usage: r.usage })),
- };
- }
-
- private emitSpawned(
- caller: IAgentScopeHandle,
- subagentId: string,
- profileName: string,
- options: RunSubagentOptions,
- ): void {
- caller.accessor.get(IAgentRecordService)?.signal({
- type: 'subagent.spawned',
- subagentId,
- subagentName: profileName,
parentToolCallId: options.parentToolCallId,
parentToolCallUuid: options.parentToolCallUuid,
- callerAgentId: caller.id,
description: options.description,
swarmIndex: options.swarmIndex,
runInBackground: options.runInBackground,
});
- caller.accessor.get(ITelemetryService)?.track('subagent_created', {
- subagent_name: profileName,
- run_in_background: options.runInBackground,
- });
+ const request = retryTurn
+ ? ({ kind: 'retry' } as const)
+ : ({ kind: 'prompt', prompt: options.prompt } as const);
+ return this.observe(caller, child.id, profileName, request, options);
}
- private async withProfilePrefix(
- promptPrefix: (ctx: {
- cwd: string;
- runner: ISessionProcessRunner;
- log?: ILogService;
- }) => Promise,
- prompt: string,
- ): Promise {
- try {
- const prefix = await promptPrefix({
- cwd: this.execContext.cwd,
- runner: this.processRunner,
- log: this.log,
- });
- return prefix.length > 0 ? `${prefix}\n\n${prompt}` : prompt;
- } catch {
- return prompt;
- }
+ private observe(
+ caller: IAgentScopeHandle,
+ agentId: string,
+ profileName: string,
+ request: { kind: 'prompt'; prompt: string } | { kind: 'retry' },
+ options: AgentRunAttemptOptions,
+ ): AgentRunAttemptHandle {
+ const run = this.lifecycle.run(agentId, request, {
+ signal: options.signal,
+ onReady: options.onReady,
+ });
+ const mirrored = mirrorAgentRun(caller, run, {
+ profileName,
+ prompt: request.kind === 'prompt' ? request.prompt : undefined,
+ suppressRateLimitFailureEvent: options.suppressRateLimitFailureEvent,
+ signal: options.signal,
+ });
+ return {
+ agentId,
+ profileName,
+ completion: mirrored.then((r) => ({ result: r.summary, usage: r.usage })),
+ };
}
private requireHandle(agentId: string, label: string): IAgentScopeHandle {
@@ -214,7 +204,7 @@ export class SessionSwarmService implements ISessionSwarmService {
}
// Kept as a type-anchor so future maintenance imports the usage shape from here.
-export type _SubagentUsage = TokenUsage;
+export type _AgentRunUsage = TokenUsage;
registerScopedService(
LifecycleScope.Session,
diff --git a/packages/agent-core-v2/test/agentLifecycle/agentLifecycle.test.ts b/packages/agent-core-v2/test/agentLifecycle/agentLifecycle.test.ts
index 6b0264cb7..4851c1541 100644
--- a/packages/agent-core-v2/test/agentLifecycle/agentLifecycle.test.ts
+++ b/packages/agent-core-v2/test/agentLifecycle/agentLifecycle.test.ts
@@ -6,14 +6,14 @@ import { TestInstantiationService } from '#/_base/di/test';
import { IAgentLifecycleService } from '#/session/agentLifecycle/agentLifecycle';
import { AgentLifecycleService } from '#/session/agentLifecycle/agentLifecycleService';
import { IBootstrapService } from '#/app/bootstrap';
+import { IConfigService } from '#/app/config';
import { IPluginSessionStartInjectorService } from '#/agent/contextInjector';
-import { IAgentEventSinkService } from '#/agent/eventSink';
import { ILogService } from '#/app/log';
import { IPluginService } from '#/app/plugin';
import { ISessionContext } from '#/session/sessionContext/sessionContext';
import { ISessionMetadata } from '#/session/sessionMetadata/sessionMetadata';
import { IAgentToolExecutorService } from '#/agent/toolExecutor';
-import { IAgentToolRegistryService } from '#/agent/toolRegistry';
+import { IAgentToolRegistryService, _clearToolContributionsForTests } from '#/agent/toolRegistry';
import { ISessionWorkspaceContext } from '#/session/workspaceContext';
const noopLog = {
@@ -52,6 +52,10 @@ describe('AgentLifecycleService', () => {
let registerAgent: ReturnType;
beforeEach(() => {
+ // The unit under test force-instantiates the builtin-tools registrar per
+ // created agent; clear module-level tool contributions so no real tool
+ // (with its own service dependencies) is constructed in this unit test.
+ _clearToolContributionsForTests();
disposables = new DisposableStore();
ix = disposables.add(new TestInstantiationService());
registerAgent = vi.fn(() => Promise.resolve());
@@ -76,6 +80,10 @@ describe('AgentLifecycleService', () => {
_serviceBrand: undefined,
homeDir: '/tmp/kimi-agentLifecycle-home',
cwd: '/tmp/kimi-agentLifecycle-home',
+ agentHomedir: (_ws: string, _session: string, agentId: string) =>
+ `/tmp/kimi-agentLifecycle-test/agents/${agentId}`,
+ agentScope: (_ws: string, _session: string, agentId: string) =>
+ `test/agents/${agentId}`,
} as unknown as IBootstrapService);
ix.stub(ISessionWorkspaceContext, {
_serviceBrand: undefined,
@@ -83,6 +91,10 @@ describe('AgentLifecycleService', () => {
additionalDirs: [],
} as unknown as ISessionWorkspaceContext);
ix.stub(IPluginService, pluginServiceStub);
+ ix.stub(IConfigService, {
+ ready: Promise.resolve(),
+ get: (() => undefined) as IConfigService['get'],
+ } as unknown as IConfigService);
ix.stub(ILogService, noopLog);
ix.stub(IPluginSessionStartInjectorService, {
_serviceBrand: undefined,
@@ -93,11 +105,6 @@ describe('AgentLifecycleService', () => {
resolve: () => undefined,
list: () => [],
} as unknown as IAgentToolRegistryService);
- ix.stub(IAgentEventSinkService, {
- _serviceBrand: undefined,
- emit: () => {},
- on: () => ({ dispose: () => {} }),
- } as unknown as IAgentEventSinkService);
ix.stub(IAgentToolExecutorService, {
_serviceBrand: undefined,
hooks: {
@@ -111,7 +118,7 @@ describe('AgentLifecycleService', () => {
it('create / getHandle / list / remove', async () => {
const svc = ix.get(IAgentLifecycleService);
- const main = await svc.createMain();
+ const main = await svc.create({ agentId: 'main' });
expect(main.id).toBe('main');
expect(svc.getHandle('main')).toBe(main);
expect(svc.list()).toEqual([main]);
@@ -126,23 +133,35 @@ describe('AgentLifecycleService', () => {
expect(a.id).not.toBe(b.id);
});
- it('persists subagent metadata when creating a child agent', async () => {
+ it('persists provenance and labels when creating an agent', async () => {
const svc = ix.get(IAgentLifecycleService);
const child = await svc.create({
agentId: 'child',
forkedFrom: 'main',
- swarmItem: 'swarm-item-1',
+ labels: { swarmItem: 'swarm-item-1' },
});
expect(child.id).toBe('child');
expect(registerAgent).toHaveBeenCalledWith('child', {
homedir: '/tmp/kimi-agentLifecycle-test/agents/child',
forkedFrom: 'main',
- swarmItem: 'swarm-item-1',
+ labels: { swarmItem: 'swarm-item-1' },
});
});
+ it('fork throws when the source agent does not exist', async () => {
+ const svc = ix.get(IAgentLifecycleService);
+ await expect(svc.fork('missing')).rejects.toThrow('Source agent "missing" does not exist');
+ });
+
+ it('run throws when the agent does not exist', () => {
+ const svc = ix.get(IAgentLifecycleService);
+ expect(() =>
+ svc.run('missing', { kind: 'prompt', prompt: 'hi' }, { signal: new AbortController().signal }),
+ ).toThrow('Agent "missing" does not exist');
+ });
+
it('fires onDidCreate on create and onDidDispose on remove', async () => {
const svc = ix.get(IAgentLifecycleService);
const created: string[] = [];
diff --git a/packages/agent-core-v2/test/cron/agent-integration.test.ts b/packages/agent-core-v2/test/cron/agent-integration.test.ts
index a578bd1cf..497cf57bf 100644
--- a/packages/agent-core-v2/test/cron/agent-integration.test.ts
+++ b/packages/agent-core-v2/test/cron/agent-integration.test.ts
@@ -8,7 +8,7 @@ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
import {
CronCreateTool,
type CronCreateInput,
-} from '#/agent/cron/tools/cron-create';
+} from '#/session/cron/tools/cron-create';
import { ISessionCronService } from '#/session/cron';
import { IAgentProfileService } from '#/agent/profile';
import { IAgentToolRegistryService } from '#/agent/toolRegistry';
diff --git a/packages/agent-core-v2/test/cron/clock.test.ts b/packages/agent-core-v2/test/cron/clock.test.ts
index 9356ff83f..9b54500ae 100644
--- a/packages/agent-core-v2/test/cron/clock.test.ts
+++ b/packages/agent-core-v2/test/cron/clock.test.ts
@@ -4,7 +4,7 @@ import { join } from 'node:path';
import { describe, expect, it } from 'vitest';
-import { resolveClockSources, SYSTEM_CLOCKS } from '#/agent/cron/clock';
+import { resolveClockSources, SYSTEM_CLOCKS } from '#/app/cron/clock';
describe('cron clock sources', () => {
describe('SYSTEM_CLOCKS', () => {
diff --git a/packages/agent-core-v2/test/cron/cron-expr.test.ts b/packages/agent-core-v2/test/cron/cron-expr.test.ts
index 8749ab71c..ad87944b3 100644
--- a/packages/agent-core-v2/test/cron/cron-expr.test.ts
+++ b/packages/agent-core-v2/test/cron/cron-expr.test.ts
@@ -5,7 +5,7 @@ import {
cronToHuman,
hasFireWithinYears,
parseCronExpression,
-} from '#/agent/cron/cron-expr';
+} from '#/app/cron/cron-expr';
function localDate(
year: number,
diff --git a/packages/agent-core-v2/test/cron/cron.e2e.test.ts b/packages/agent-core-v2/test/cron/cron.e2e.test.ts
index f9d44881b..28833d3f2 100644
--- a/packages/agent-core-v2/test/cron/cron.e2e.test.ts
+++ b/packages/agent-core-v2/test/cron/cron.e2e.test.ts
@@ -7,9 +7,9 @@
*/
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
-import { CronCreateTool } from '#/agent/cron/tools/cron-create';
-import { CronDeleteTool } from '#/agent/cron/tools/cron-delete';
-import { CronListTool } from '#/agent/cron/tools/cron-list';
+import { CronCreateTool } from '#/session/cron/tools/cron-create';
+import { CronDeleteTool } from '#/session/cron/tools/cron-delete';
+import { CronListTool } from '#/session/cron/tools/cron-list';
import type { ExecutableToolOutput } from '#/agent/tool';
import type { ContextMessage } from '#/agent/contextMemory';
import { ISessionCronService } from '#/session/cron';
diff --git a/packages/agent-core-v2/test/cron/jitter.test.ts b/packages/agent-core-v2/test/cron/jitter.test.ts
index 1f05b0b47..9be2dcf06 100644
--- a/packages/agent-core-v2/test/cron/jitter.test.ts
+++ b/packages/agent-core-v2/test/cron/jitter.test.ts
@@ -1,11 +1,11 @@
import { describe, expect, it } from 'vitest';
-import { parseCronExpression } from '#/agent/cron/cron-expr';
+import { parseCronExpression } from '#/app/cron/cron-expr';
import {
DEFAULT_CRON_JITTER_CONFIG,
jitteredNextCronRunMs,
oneShotJitteredNextCronRunMs,
-} from '#/agent/cron/jitter';
+} from '#/app/cron/jitter';
function localDate(
year: number,
diff --git a/packages/agent-core-v2/test/cron/manager.test.ts b/packages/agent-core-v2/test/cron/manager.test.ts
index 664305cf8..65b46f210 100644
--- a/packages/agent-core-v2/test/cron/manager.test.ts
+++ b/packages/agent-core-v2/test/cron/manager.test.ts
@@ -7,7 +7,7 @@ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
import type { ContentPart } from '#/app/llmProtocol/kosong';
-import type { CronTask } from '#/app/cronPersistence';
+import type { CronTask } from '#/app/cron';
import {
CRON_FIRED,
CRON_MISSED,
diff --git a/packages/agent-core-v2/test/cron/persist.test.ts b/packages/agent-core-v2/test/cron/persist.test.ts
index 089ac6664..66273a3fb 100644
--- a/packages/agent-core-v2/test/cron/persist.test.ts
+++ b/packages/agent-core-v2/test/cron/persist.test.ts
@@ -1,7 +1,7 @@
import { describe, expect, it } from 'vitest';
-import type { CronTask } from '#/app/cronPersistence';
-import { CRON_ID_REGEX, isValidCronTask } from '#/app/cronPersistence';
+import type { CronTask } from '#/app/cron';
+import { CRON_ID_REGEX, isValidCronTask } from '#/app/cron';
const validTask: CronTask = {
id: '0123abcd',
diff --git a/packages/agent-core-v2/test/cron/resume.test.ts b/packages/agent-core-v2/test/cron/resume.test.ts
index 8bbe09a9f..c6c9d9f9f 100644
--- a/packages/agent-core-v2/test/cron/resume.test.ts
+++ b/packages/agent-core-v2/test/cron/resume.test.ts
@@ -18,7 +18,7 @@ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
import type { ContentPart } from '#/app/llmProtocol/kosong';
import type { ContextMessage, PromptOrigin } from '#/agent/contextMemory';
import { IAgentPromptService } from '#/agent/prompt';
-import type { CronTask } from '#/app/cronPersistence';
+import type { CronTask } from '#/app/cron';
import { ISessionCronService } from '#/session/cron';
import { IBootstrapService } from '#/app/bootstrap';
import { IAtomicDocumentStore } from '#/persistence/interface/atomicDocumentStore';
diff --git a/packages/agent-core-v2/test/cron/source-clock-guard.test.ts b/packages/agent-core-v2/test/cron/source-clock-guard.test.ts
index 891be92f3..ed908f5a2 100644
--- a/packages/agent-core-v2/test/cron/source-clock-guard.test.ts
+++ b/packages/agent-core-v2/test/cron/source-clock-guard.test.ts
@@ -3,13 +3,13 @@ import { readFileSync } from 'node:fs';
import { describe, expect, it } from 'vitest';
const GUARDED_FILES = [
- 'cronService.ts',
- 'jitter.ts',
+ { dir: 'session/cron', file: 'sessionCronServiceImpl.ts' },
+ { dir: 'app/cron', file: 'jitter.ts' },
] as const;
describe('cron source clock guard', () => {
- it.each(GUARDED_FILES)('%s does not call Date.now()', (file) => {
- const source = readFileSync(new URL(`../../src/agent/cron/${file}`, import.meta.url), 'utf8');
+ it.each(GUARDED_FILES)('$file does not call Date.now()', ({ dir, file }) => {
+ const source = readFileSync(new URL(`../../src/${dir}/${file}`, import.meta.url), 'utf8');
expect(stripComments(source)).not.toMatch(/\bDate\.now\s*\(/);
});
});
diff --git a/packages/agent-core-v2/test/cron/tools.test.ts b/packages/agent-core-v2/test/cron/tools.test.ts
index 6fb646f6f..2c0fb52f8 100644
--- a/packages/agent-core-v2/test/cron/tools.test.ts
+++ b/packages/agent-core-v2/test/cron/tools.test.ts
@@ -8,24 +8,24 @@ import type {
RunnableToolExecution,
ToolExecution,
} from '#/agent/tool';
-import type { CronTask, CronTaskInit } from '#/app/cronPersistence';
+import type { CronTask, CronTaskInit } from '#/app/cron';
import type { ISessionCronService } from '#/session/cron';
import {
computeNextCronRun,
parseCronExpression,
-} from '#/agent/cron/cron-expr';
-import { renderCronFireXml } from '#/agent/cron/format';
+} from '#/app/cron/cron-expr';
+import { renderCronFireXml } from '#/app/cron/format';
import {
jitteredNextCronRunMs,
oneShotJitteredNextCronRunMs,
-} from '#/agent/cron/jitter';
+} from '#/app/cron/jitter';
import {
CronCreateTool,
MAX_CRON_JOBS_PER_SESSION,
type CronCreateInput,
-} from '#/agent/cron/tools/cron-create';
-import { CronDeleteTool, type CronDeleteInput } from '#/agent/cron/tools/cron-delete';
-import { CronListTool, type CronListInput } from '#/agent/cron/tools/cron-list';
+} from '#/session/cron/tools/cron-create';
+import { CronDeleteTool, type CronDeleteInput } from '#/session/cron/tools/cron-delete';
+import { CronListTool, type CronListInput } from '#/session/cron/tools/cron-list';
const WALL_ANCHOR = 1_700_000_000_000;
const MS_PER_DAY = 24 * 60 * 60 * 1000;
diff --git a/packages/agent-core-v2/test/externalHooks/integration.test.ts b/packages/agent-core-v2/test/externalHooks/integration.test.ts
index e9c3fcec7..e4c48d255 100644
--- a/packages/agent-core-v2/test/externalHooks/integration.test.ts
+++ b/packages/agent-core-v2/test/externalHooks/integration.test.ts
@@ -7,7 +7,6 @@ import {
type TestInstantiationService,
} from '#/_base/di/test';
import { Event } from '#/_base/event';
-import { IAgentToolService } from '#/agent/agentTool';
import { IAgentTaskService } from '#/agent/task';
import {
AgentExternalHooksService,
@@ -139,9 +138,6 @@ describe('HookEngine integration', () => {
reg.definePartialInstance(IAgentTaskService, {
hooks: createHooks(['onDidNotify']),
});
- reg.definePartialInstance(IAgentToolService, {
- hooks: createHooks(['onWillRunSubagent', 'onDidRunSubagent']),
- });
},
});
ix.set(
diff --git a/packages/agent-core-v2/test/gateway/gateway.test.ts b/packages/agent-core-v2/test/gateway/gateway.test.ts
index 023847258..0187532a2 100644
--- a/packages/agent-core-v2/test/gateway/gateway.test.ts
+++ b/packages/agent-core-v2/test/gateway/gateway.test.ts
@@ -72,15 +72,16 @@ describe('RestGateway', () => {
_serviceBrand: undefined,
onDidCreate: () => ({ dispose: () => {} }),
onDidDispose: () => ({ dispose: () => {} }),
+ onDidCreateMain: () => ({ dispose: () => {} }),
+ notifyMainCreated: () => {},
create: () => Promise.resolve(agentHandle),
- createMain: () => Promise.resolve(agentHandle),
- clone: () => Promise.resolve(agentHandle),
- spawn: () => Promise.resolve(agentHandle),
+ fork: () => Promise.resolve(agentHandle),
+ run: () => {
+ throw new Error('not implemented in test');
+ },
getHandle: (id) => (id === 'main' ? agentHandle : undefined),
list: () => [agentHandle],
remove: () => Promise.resolve(),
- runSubagent: () => Promise.reject(new Error('not implemented in test')),
- resumeSubagent: () => Promise.reject(new Error('not implemented in test')),
};
const sessionHandle: ISessionScopeHandle = {
id: 's1',
diff --git a/packages/agent-core-v2/test/harness/agent.ts b/packages/agent-core-v2/test/harness/agent.ts
index 76fb914b2..544983a27 100644
--- a/packages/agent-core-v2/test/harness/agent.ts
+++ b/packages/agent-core-v2/test/harness/agent.ts
@@ -18,8 +18,8 @@ import { IAgentContextInjectorService } from '#/agent/contextInjector';
import type { ContextMessage } from '#/agent/contextMemory';
import { ISessionCronService } from '#/session/cron/sessionCronService';
import { SessionCronServiceImpl } from '#/session/cron/sessionCronServiceImpl';
-import { ICronTaskPersistence } from '#/app/cronPersistence/cronTaskPersistence';
-import { CronTaskPersistenceService } from '#/app/cronPersistence/cronTaskPersistenceService';
+import { ICronTaskPersistence } from '#/app/cron/cronTaskPersistence';
+import { CronTaskPersistenceService } from '#/app/cron/cronTaskPersistenceService';
import type { HookEngine } from '#/agent/externalHooks/engine';
import type { FullCompactionServiceOptions } from '#/agent/fullCompaction';
import { AgentGoalService, IAgentGoalService, type GoalServiceOptions } from '#/agent/goal';
diff --git a/packages/agent-core-v2/test/sessionActivity/sessionActivity.test.ts b/packages/agent-core-v2/test/sessionActivity/sessionActivity.test.ts
index 72331cd39..fb633b48d 100644
--- a/packages/agent-core-v2/test/sessionActivity/sessionActivity.test.ts
+++ b/packages/agent-core-v2/test/sessionActivity/sessionActivity.test.ts
@@ -56,15 +56,16 @@ function lifecycle(handles: readonly IAgentScopeHandle[]): IAgentLifecycleServic
_serviceBrand: undefined,
onDidCreate: () => ({ dispose: () => {} }),
onDidDispose: () => ({ dispose: () => {} }),
+ onDidCreateMain: () => ({ dispose: () => {} }),
+ notifyMainCreated: () => {},
create: () => Promise.resolve(handles[0]!),
- createMain: () => Promise.resolve(handles[0]!),
- clone: () => Promise.resolve(handles[0]!),
- spawn: () => Promise.resolve(handles[0]!),
+ fork: () => Promise.resolve(handles[0]!),
+ run: () => {
+ throw new Error('not implemented in test');
+ },
getHandle: () => undefined,
list: () => handles,
remove: () => Promise.resolve(),
- runSubagent: () => Promise.reject(new Error('not implemented in test')),
- resumeSubagent: () => Promise.reject(new Error('not implemented in test')),
};
}
diff --git a/packages/agent-core-v2/test/shellTools/bash.test.ts b/packages/agent-core-v2/test/shellTools/bash.test.ts
index d3c4337b0..e9ab7ebba 100644
--- a/packages/agent-core-v2/test/shellTools/bash.test.ts
+++ b/packages/agent-core-v2/test/shellTools/bash.test.ts
@@ -31,12 +31,12 @@ import {
type RegisterAgentTaskOptions,
} from '#/agent/task';
import type { AgentTaskSettlement } from '#/agent/task/types';
-import { ProcessTask } from '#/agent/shellTools/tools/process-task';
+import { ProcessTask } from '#/os/backends/node-local/tools/process-task';
import type { IHostEnvironment } from '#/os/interface/hostEnvironment';
import type { IAgentProfileService } from '#/agent/profile';
import { createExecContext, type IExecContext } from '#/session/execContext';
import type { IProcess, ISessionProcessRunner } from '#/session/process';
-import { type BashInput, BashInputSchema, BashTool } from '#/agent/shellTools/tools/bash';
+import { type BashInput, BashInputSchema, BashTool } from '#/os/backends/node-local/tools/bash';
import type { ExecutableToolContext, ExecutableToolResult, ToolExecution } from '#/agent/tool';
import { createHooks } from '#/hooks';
diff --git a/packages/agent-core-v2/test/shellTools/result-builder.test.ts b/packages/agent-core-v2/test/shellTools/result-builder.test.ts
index 486169be8..be71a0e76 100644
--- a/packages/agent-core-v2/test/shellTools/result-builder.test.ts
+++ b/packages/agent-core-v2/test/shellTools/result-builder.test.ts
@@ -1,6 +1,6 @@
import { describe, expect, it } from 'vitest';
-import { ToolResultBuilder } from '#/agent/shellTools/tools/result-builder';
+import { ToolResultBuilder } from '#/agent/tool/result-builder';
describe('ToolResultBuilder', () => {
it('returns concatenated output and a confirmation message under the limit', () => {
diff --git a/packages/agent-core-v2/test/task/foreground-persistence.test.ts b/packages/agent-core-v2/test/task/foreground-persistence.test.ts
index 0ef41d511..26fbded5e 100644
--- a/packages/agent-core-v2/test/task/foreground-persistence.test.ts
+++ b/packages/agent-core-v2/test/task/foreground-persistence.test.ts
@@ -15,7 +15,7 @@ import type { IProcess } from '#/session/process';
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
import { IAgentTaskService } from '#/agent/task';
-import { ProcessTask } from '#/agent/shellTools/tools/process-task';
+import { ProcessTask } from '#/os/backends/node-local/tools/process-task';
import {
taskServices,
createTestAgent,
diff --git a/packages/agent-core-v2/test/task/ids.test.ts b/packages/agent-core-v2/test/task/ids.test.ts
index 05625cb5b..e9ea2ccff 100644
--- a/packages/agent-core-v2/test/task/ids.test.ts
+++ b/packages/agent-core-v2/test/task/ids.test.ts
@@ -11,7 +11,7 @@ import {
SubagentTask,
type SubagentHandle,
} from '#/session/agentLifecycle/tools/subagent-task';
-import { ProcessTask } from '#/agent/shellTools/tools/process-task';
+import { ProcessTask } from '#/os/backends/node-local/tools/process-task';
import { createTestAgent, type TestAgentContext } from '../harness';
import { createAgentTaskPersistence } from './stubs';
@@ -31,7 +31,6 @@ function agentTask(
const handle: SubagentHandle = {
agentId: 'agent-child',
profileName: 'coder',
- resumed: false,
completion,
};
return new SubagentTask(
diff --git a/packages/agent-core-v2/test/task/manager.test.ts b/packages/agent-core-v2/test/task/manager.test.ts
index 0969c1175..1b090e757 100644
--- a/packages/agent-core-v2/test/task/manager.test.ts
+++ b/packages/agent-core-v2/test/task/manager.test.ts
@@ -19,7 +19,7 @@ import {
SubagentTask,
type SubagentHandle,
} from '#/session/agentLifecycle/tools/subagent-task';
-import { ProcessTask } from '#/agent/shellTools/tools/process-task';
+import { ProcessTask } from '#/os/backends/node-local/tools/process-task';
import { isUserCancellation, userCancellationReason } from '#/_base/utils/abort';
import {
configServices,
@@ -88,7 +88,6 @@ function agentTask(
const handle: SubagentHandle = {
agentId: options.agentId ?? 'agent-child',
profileName: options.subagentType ?? 'coder',
- resumed: false,
completion,
};
const task = new SubagentTask(
diff --git a/packages/agent-core-v2/test/task/output-access.test.ts b/packages/agent-core-v2/test/task/output-access.test.ts
index efbc29c9a..b36260c4e 100644
--- a/packages/agent-core-v2/test/task/output-access.test.ts
+++ b/packages/agent-core-v2/test/task/output-access.test.ts
@@ -6,7 +6,7 @@ import { join } from 'pathe';
import type { IProcess } from '#/session/process';
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
import { IAgentTaskService } from '#/agent/task';
-import { ProcessTask } from '#/agent/shellTools/tools/process-task';
+import { ProcessTask } from '#/os/backends/node-local/tools/process-task';
import { createAgentTaskPersistence, type TaskServiceTestManager } from './stubs';
import { taskServices, createTestAgent, homeDirServices, type TestAgentContext } from '../harness';
diff --git a/packages/agent-core-v2/test/task/rpc-events.test.ts b/packages/agent-core-v2/test/task/rpc-events.test.ts
index bd18b7cea..dfcb50c1a 100644
--- a/packages/agent-core-v2/test/task/rpc-events.test.ts
+++ b/packages/agent-core-v2/test/task/rpc-events.test.ts
@@ -19,7 +19,7 @@ import {
SubagentTask,
type SubagentHandle,
} from '#/session/agentLifecycle/tools/subagent-task';
-import { ProcessTask } from '#/agent/shellTools/tools/process-task';
+import { ProcessTask } from '#/os/backends/node-local/tools/process-task';
import { IAgentContextMemoryService } from '#/agent/contextMemory';
import { IAgentEventSinkService } from '#/agent/eventSink';
import type { HookEngine } from '#/agent/externalHooks/engine';
@@ -92,7 +92,6 @@ function agentTask(
const handle: SubagentHandle = {
agentId: options.agentId ?? 'agent-child',
profileName: options.subagentType ?? 'coder',
- resumed: false,
completion,
};
const task = new SubagentTask(
diff --git a/packages/server-v2/src/transport/mainAgent.ts b/packages/server-v2/src/transport/mainAgent.ts
index df4233e1b..ab0323681 100644
--- a/packages/server-v2/src/transport/mainAgent.ts
+++ b/packages/server-v2/src/transport/mainAgent.ts
@@ -5,23 +5,16 @@
* `main` materializes it here. Both the `/api/v1` routes and the `/api/v2`
* dispatcher resolve the main agent through {@link ensureMainAgent} so a
* missing main agent is created instead of reported as `agent.not_found`.
+ *
+ * The main agent is created unbound (no Profile / Model). It becomes runnable
+ * when the edge binds a Model — via the `profile:setModel` action, a legacy
+ * prompt's `body.model` override, or a resumed wire log — at which point the
+ * default profile is applied automatically. There is intentionally no default
+ * model baked in here: a runnable agent only exists once a model is chosen.
+ *
+ * Both symbols are re-exports of the core `agentLifecycle` domain's bootstrap
+ * helper, so main-agent bootstrap business (plugin session-start injection)
+ * lives in exactly one place.
*/
-import {
- IAgentLifecycleService,
- type IAgentScopeHandle,
- type ISessionScopeHandle,
-} from '@moonshot-ai/agent-core-v2';
-
-export const MAIN_AGENT_ID = 'main';
-
-/**
- * Return the session's main agent, creating it on demand when it does not
- * exist yet.
- */
-export async function ensureMainAgent(session: ISessionScopeHandle): Promise {
- const agents = session.accessor.get(IAgentLifecycleService);
- const existing = agents.getHandle(MAIN_AGENT_ID);
- if (existing !== undefined) return existing;
- return agents.createMain();
-}
+export { ensureMainAgent, MAIN_AGENT_ID } from '@moonshot-ai/agent-core-v2';
diff --git a/packages/server-v2/test/messages.test.ts b/packages/server-v2/test/messages.test.ts
index 39c55b7b4..3739b7304 100644
--- a/packages/server-v2/test/messages.test.ts
+++ b/packages/server-v2/test/messages.test.ts
@@ -110,7 +110,7 @@ describe('server-v2 /api/v1/sessions/{sid}/messages', () => {
if (session === undefined) throw new Error(`session ${sessionId} not found`);
let agent = session.accessor.get(IAgentLifecycleService).getHandle('main');
if (agent === undefined) {
- agent = await session.accessor.get(IAgentLifecycleService).createMain();
+ agent = await session.accessor.get(IAgentLifecycleService).create({ agentId: 'main' });
}
if (messages.length > 0) {
agent.accessor.get(IAgentContextMemoryService).splice(0, 0, messages);
@@ -283,7 +283,7 @@ describe('server-v2 /api/v1/sessions/{sid}/messages', () => {
const id = await createSession();
const session = server!.core.accessor.get(ISessionLifecycleService).get(id);
if (session === undefined) throw new Error(`session ${id} not found`);
- const agent = await session.accessor.get(IAgentLifecycleService).createMain();
+ const agent = await session.accessor.get(IAgentLifecycleService).create({ agentId: 'main' });
const ctx = agent.accessor.get(IAgentContextMemoryService);
// Three messages, then a compaction that folds the prefix into a summary.
ctx.splice(0, 0, [
diff --git a/packages/server-v2/test/prompts.test.ts b/packages/server-v2/test/prompts.test.ts
index 3a31d08dc..b0fcbb368 100644
--- a/packages/server-v2/test/prompts.test.ts
+++ b/packages/server-v2/test/prompts.test.ts
@@ -86,7 +86,7 @@ describe('server-v2 /api/v1 prompts', () => {
async function createMainAgent(sessionId: string): Promise {
const session = server!.core.accessor.get(ISessionLifecycleService).get(sessionId);
if (session === undefined) throw new Error(`session ${sessionId} not found`);
- await session.accessor.get(IAgentLifecycleService).createMain();
+ await session.accessor.get(IAgentLifecycleService).create({ agentId: 'main' });
}
it('submits a prompt and lists it as active', async () => {
diff --git a/packages/server-v2/test/rpc.test.ts b/packages/server-v2/test/rpc.test.ts
index 59a3176eb..05cffec7d 100644
--- a/packages/server-v2/test/rpc.test.ts
+++ b/packages/server-v2/test/rpc.test.ts
@@ -109,7 +109,7 @@ describe('server-v2 /api/v2 RPC', () => {
async function createMainAgent(sessionId: string): Promise {
const session = server!.core.accessor.get(ISessionLifecycleService).get(sessionId);
if (session === undefined) throw new Error(`session ${sessionId} not found`);
- await session.accessor.get(IAgentLifecycleService).createMain();
+ await session.accessor.get(IAgentLifecycleService).create({ agentId: 'main' });
}
// --- Core scope -----------------------------------------------------------
diff --git a/packages/server-v2/test/skills.test.ts b/packages/server-v2/test/skills.test.ts
index 739c58d15..442353a14 100644
--- a/packages/server-v2/test/skills.test.ts
+++ b/packages/server-v2/test/skills.test.ts
@@ -104,7 +104,7 @@ describe('server-v2 /api/v1 skills', () => {
const session = server!.core.accessor.get(ISessionLifecycleService).get(sessionId);
if (session === undefined) throw new Error(`session ${sessionId} not found`);
const agents = session.accessor.get(IAgentLifecycleService);
- if (agents.getHandle('main') === undefined) await agents.createMain();
+ if (agents.getHandle('main') === undefined) await agents.create({ agentId: 'main' });
}
describe('GET /api/v1/sessions/{sid}/skills', () => {
diff --git a/packages/server-v2/test/snapshot.test.ts b/packages/server-v2/test/snapshot.test.ts
index 2b1bf9bf9..b79555a7a 100644
--- a/packages/server-v2/test/snapshot.test.ts
+++ b/packages/server-v2/test/snapshot.test.ts
@@ -56,7 +56,7 @@ describe('server-v2 GET /api/v1/sessions/:id/snapshot', () => {
async function ensureMainAgent(sessionId: string): Promise {
const session = server!.core.accessor.get(ISessionLifecycleService).get(sessionId);
const agents = session!.accessor.get(IAgentLifecycleService);
- if (agents.getHandle('main') === undefined) await agents.createMain();
+ if (agents.getHandle('main') === undefined) await agents.create({ agentId: 'main' });
}
function emit(sessionId: string, event: AgentEvent): void {
@@ -172,7 +172,7 @@ describe('server-v2 GET /api/v1/sessions/:id/snapshot', () => {
// snapshot projects message timestamps from the normalized numeric base.
const resumed = await server!.core.accessor.get(ISessionLifecycleService).resume(sid);
if (resumed === undefined) throw new Error(`session ${sid} failed to resume`);
- const main = await resumed.accessor.get(IAgentLifecycleService).createMain();
+ const main = await resumed.accessor.get(IAgentLifecycleService).create({ agentId: 'main' });
main.accessor.get(IAgentContextMemoryService).splice(0, 0, [
{ role: 'user', content: [{ type: 'text', text: 'hello' }], toolCalls: [] },
{ role: 'assistant', content: [{ type: 'text', text: 'hi' }], toolCalls: [] },
diff --git a/packages/server-v2/test/tasks.test.ts b/packages/server-v2/test/tasks.test.ts
index ececd3078..37b6a735c 100644
--- a/packages/server-v2/test/tasks.test.ts
+++ b/packages/server-v2/test/tasks.test.ts
@@ -110,7 +110,7 @@ describe('server-v2 /api/v1/sessions/{sid}/tasks', () => {
if (session === undefined) throw new Error(`session ${sessionId} not found`);
const agent =
session.accessor.get(IAgentLifecycleService).getHandle('main') ??
- (await session.accessor.get(IAgentLifecycleService).createMain());
+ (await session.accessor.get(IAgentLifecycleService).create({ agentId: 'main' }));
return agent.accessor.get(IAgentTaskService);
}
diff --git a/packages/server-v2/test/tools.test.ts b/packages/server-v2/test/tools.test.ts
index 993f55432..a7a52ef47 100644
--- a/packages/server-v2/test/tools.test.ts
+++ b/packages/server-v2/test/tools.test.ts
@@ -115,7 +115,7 @@ describe('server-v2 /api/v1 tools + mcp', () => {
const session = server!.core.accessor.get(ISessionLifecycleService).get(sessionId);
if (session === undefined) throw new Error(`session ${sessionId} not found`);
let agent = session.accessor.get(IAgentLifecycleService).getHandle('main');
- agent ??= await session.accessor.get(IAgentLifecycleService).createMain();
+ agent ??= await session.accessor.get(IAgentLifecycleService).create({ agentId: 'main' });
return agent;
}
diff --git a/packages/server-v2/test/wsV1Resync.test.ts b/packages/server-v2/test/wsV1Resync.test.ts
index d15b6f4dd..f180b3efb 100644
--- a/packages/server-v2/test/wsV1Resync.test.ts
+++ b/packages/server-v2/test/wsV1Resync.test.ts
@@ -131,7 +131,7 @@ describe('server-v2 /api/v1/ws resync', () => {
expect(session).toBeDefined();
const agents = session!.accessor.get(IAgentLifecycleService);
if (agents.getHandle('main') === undefined) {
- await agents.createMain();
+ await agents.create({ agentId: 'main' });
}
}