Merge branch 'kimi-code-v2' of https://github.com/MoonshotAI/kimi-code into kimi-code-v2

This commit is contained in:
_Kerman 2026-07-03 18:15:12 +08:00
commit 87faa7897b
92 changed files with 1322 additions and 1109 deletions

View file

@ -102,7 +102,6 @@ package "Agent scope (per agent)" #FDF5E6 {
rectangle "<b>rpc</b>\n<size:9><i>Agent</i></size>\n IAgentRPCService" as rpc #FDEBD0
rectangle "<b>fileTools</b>\n<size:9><i>Agent</i></size>\n IAgentFileToolsService" as fileTools #FDEBD0
rectangle "<b>shellTools</b>\n<size:9><i>Agent</i></size>\n IAgentShellToolsService" as shellTools #FDEBD0
rectangle "<b>agentTool</b>\n<size:9><i>Agent</i></size>\n IAgentToolService" as agentTool #FDEBD0
rectangle "<b>scopeContext</b>\n<size:9><i>Agent</i></size>\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

View file

@ -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',

View file

@ -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<AgentToolWillRunSubagentContext>(),
onDidRunSubagent: new OrderedHookSlot<AgentToolDidRunSubagentContext>(),
};
}
registerScopedService(
LifecycleScope.Agent,
IAgentToolService,
AgentToolService,
InstantiationType.Eager,
'agentTool',
);

View file

@ -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<IAgentToolService> =
createDecorator<IAgentToolService>('agentToolService');

View file

@ -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';

View file

@ -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

View file

@ -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';

View file

@ -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<void>;
/** Fire-and-forget `SubagentStop` external hook counterpart. */
notifyAgentTaskStop(ctx: AgentTaskStopHookContext): void;
}
export const IAgentExternalHooksService =

View file

@ -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<void> {
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<void> {
async runAgentTaskStart(ctx: AgentTaskStartHookContext): Promise<void> {
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: {

View file

@ -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';
}
}

View file

@ -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<void>;
/**
* 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<ProfileSetModelResult>;
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;

View file

@ -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<void> {
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<ProfileSetModelResult> {
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<SystemPromptContext> {
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<string> {
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;

View file

@ -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 =

View file

@ -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';

View file

@ -1,5 +0,0 @@
export {
ToolResultBuilder,
type ExecutableToolResultBuilderResult,
type ToolResultBuilderOptions,
} from '#/agent/tool/result-builder';

View file

@ -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 `<git-context>` 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<IAgentProfileCatalogService> =

View file

@ -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<string, AgentProfileDefinition>;
private readonly ordered: readonly AgentProfileDefinition[];
private readonly byName: Map<string, AgentProfile>;
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;
}
}

View file

@ -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;
}

View file

@ -9,6 +9,7 @@
export * from './agentProfileCatalog';
export * from './agentProfileCatalogService';
export * from './profile-shared';
export * from './promptPrefix';
export {
registerAgentProfile,
getAgentProfileContributions,

View file

@ -0,0 +1,28 @@
/**
* `agentProfileCatalog` domain (L3) profile prompt-prefix helper.
*
* Applies a profile's optional per-invocation `promptPrefix` (e.g. `explore`'s
* `<git-context>` 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<string> {
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;
}
}

View file

@ -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';

View file

@ -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';

View file

@ -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<IAgentScopeHandle | undefined> {
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);
}
}

View file

@ -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<IAgentScopeHandle> {
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<SessionStatusResponse> {

View file

@ -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();
}

View file

@ -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';

View file

@ -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;

View file

@ -1,3 +1,4 @@
export * from './bash';
export * from './edit';
export * from './glob';
export * from './grep';

View file

@ -1,13 +1,13 @@
/**
* Git context collection for explore subagents.
* Git context collection for explore agents.
*
* `collectGitContext` produces a `<git-context>` 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.

View file

@ -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<Record<string, string>>;
}
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<BindAgentInput>;
}
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<IAgentScopeHandle>;
/**
* 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<IAgentScopeHandle>;
/** Fires after an agent is removed, with its agent id. */
readonly onDidDispose: Event<string>;
create(opts: CreateAgentOptions): Promise<IAgentScopeHandle>;
createMain(): Promise<IAgentScopeHandle>;
/** Clone an agent: copy its profile and context history into a new agent. */
clone(sourceAgentId: string): Promise<IAgentScopeHandle>;
/** Create an agent from zero (empty context). */
create(opts?: CreateAgentOptions): Promise<IAgentScopeHandle>;
/**
* 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<IAgentScopeHandle>;
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<IAgentScopeHandle>;
/**
* 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<void>;
/**
* 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<SubagentRunHandle>;
/**
* 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<SubagentRunHandle>;
}
export const IAgentLifecycleService: ServiceIdentifier<IAgentLifecycleService> =

View file

@ -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<string, IAgentScopeHandle>();
private readonly onDidCreateEmitter = this._register(new Emitter<IAgentScopeHandle>());
private readonly onDidCreateMainEmitter = this._register(new Emitter<IAgentScopeHandle>());
private readonly onDidDisposeEmitter = this._register(new Emitter<string>());
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<IAgentScopeHandle> {
async create(opts: CreateAgentOptions = {}): Promise<IAgentScopeHandle> {
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<IAgentScopeHandle> {
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<IAgentScopeHandle> {
const source =
this.handles.get(sourceAgentId) ??
(sourceAgentId === 'main' ? await this.createMain() : undefined);
async fork(sourceAgentId: string, opts?: ForkAgentOptions): Promise<IAgentScopeHandle> {
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<IAgentScopeHandle> {
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<SubagentRunHandle> {
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<SubagentRunHandle> {
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<string> {
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);
}

View file

@ -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<IAgentScopeHandle> {
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;
}

View file

@ -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);
}

View file

@ -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<string> {
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<never> {
if (signal.aborted) {
return Promise.reject(signal.reason ?? userCancellationReason());
}
return new Promise<never>((_resolve, reject) => {
signal.addEventListener(
'abort',
() => reject(signal.reason ?? userCancellationReason()),
{ once: true },
);
});
}
function errorMessage(error: unknown): string {
return error instanceof Error ? error.message : String(error);
}
function latestAssistantText(messages: readonly ContextMessage[]): string {
for (let i = messages.length - 1; i >= 0; i--) {
const message = messages[i]!;
if (message.role !== 'assistant') continue;
return contentText(message.content);
}
return '';
}
function contentText(content: ContextMessage['content']): string {
if (typeof content === 'string') return content;
return content
.filter((part): part is Extract<(typeof content)[number], { type: 'text' }> => part.type === 'text')
.map((part) => part.text)
.join('');
}

View file

@ -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<string> {
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<never> {
if (signal.aborted) {
return Promise.reject(signal.reason ?? userCancellationReason());
}
return new Promise<never>((_resolve, reject) => {
signal.addEventListener(
'abort',
() => {
reject(signal.reason ?? userCancellationReason());
},
{ once: true },
);
});
}
function 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('');
}

View file

@ -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<AgentToolInput> {
@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<AgentToolInput> {
}
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<SubagentHandle> {
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<AgentToolInput> {
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<AgentToolInput> {
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<AgentToolInput> {
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<AgentToolInput> {
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');
}

View file

@ -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(

View file

@ -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<string> {
const child = await this.lifecycle.clone('main');
const child = await this.lifecycle.fork('main');
child.accessor
.get(IAgentSystemReminderService)
?.appendSystemReminder(SIDE_QUESTION_SYSTEM_REMINDER, {

View file

@ -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;

View file

@ -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();
}

View file

@ -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<CronCreateInput> {
}
}
registerTool(CronCreateTool, {
staticArgs: (accessor) => [
accessor.get(IConfigService).get<CronConfig>(CRON_SECTION)?.disabled
?? DEFAULT_CRON_CONFIG.disabled,
],
});
function formatOutput(o: CronCreateOutput): string {
const lines = [
`id: ${o.id}`,

View file

@ -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<CronDeleteInput> {
};
}
}
registerTool(CronDeleteTool);

View file

@ -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<CronListInput> {
].join('\n');
}
}
registerTool(CronListTool);

View file

@ -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<Record<string, string>>;
/** @deprecated Legacy on-disk field predating `labels`; read-compat only. */
readonly swarmItem?: string;
}

View file

@ -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<T = unknown> = SessionSwarmTask<T>;
export type QueuedAgentRunTask<T = unknown> = SessionSwarmTask<T>;
export type SubagentResult<T = unknown> = SessionSwarmRunResult<T>;
export type AgentRunResult<T = unknown> = SessionSwarmRunResult<T>;
export type QueuedSubagentRunResult<T = unknown> = SessionSwarmRunResult<T>;
export type QueuedAgentRunResult<T = unknown> = SessionSwarmRunResult<T>;
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<SubagentHandle>;
resume(agentId: string, options: RunSubagentOptions): Promise<SubagentHandle>;
retry(agentId: string, options: RunSubagentOptions): Promise<SubagentHandle>;
suspended?(event: SubagentSuspendedEvent): void;
export type AgentRunBatchLauncher = {
spawn(options: AgentSpawnAttemptOptions): Promise<AgentRunAttemptHandle>;
resume(agentId: string, options: AgentRunAttemptOptions): Promise<AgentRunAttemptHandle>;
retry(agentId: string, options: AgentRunAttemptOptions): Promise<AgentRunAttemptHandle>;
suspended?(event: AgentRunSuspendedEvent): void;
};
type RateLimitedOutcome = {
@ -104,11 +104,11 @@ type RateLimitedOutcome = {
readonly error: string;
};
type AttemptOutcome<T> = SubagentResult<T> | RateLimitedOutcome;
type AttemptOutcome<T> = AgentRunResult<T> | RateLimitedOutcome;
type TaskState<T> = {
readonly index: number;
readonly task: QueuedSubagentTask<T>;
readonly task: QueuedAgentRunTask<T>;
agentId?: string;
retryAgentId?: string;
retryCount: number;
@ -124,19 +124,19 @@ type ActiveAttempt<T> = {
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<T> {
export class AgentRunBatch<T> {
private readonly states: Array<TaskState<T>>;
private readonly pending: Array<TaskState<T>>;
private readonly results: Array<SubagentResult<T> | undefined>;
private readonly results: Array<AgentRunResult<T> | undefined>;
private readonly active = new Set<ActiveAttempt<T>>();
private readonly controller = new AbortController();
private readonly batchSignal: AbortSignal | undefined;
@ -145,7 +145,7 @@ export class SubagentBatch<T> {
private normalLaunchCount = 0;
private normalLaunchTimer: ReturnType<typeof setTimeout> | undefined;
private rateLimitLaunchTimer: ReturnType<typeof setTimeout> | undefined;
private resolve: ((results: Array<SubagentResult<T>>) => void) | undefined;
private resolve: ((results: Array<AgentRunResult<T>>) => void) | undefined;
private reject: ((error: unknown) => void) | undefined;
private finished = false;
private started = false;
@ -159,9 +159,9 @@ export class SubagentBatch<T> {
private nextRateLimitLaunchAt = 0;
constructor(
private readonly launcher: SubagentBatchLauncher,
tasks: readonly QueuedSubagentTask<T>[],
options: SubagentBatchOptions = {},
private readonly launcher: AgentRunBatchLauncher,
tasks: readonly QueuedAgentRunTask<T>[],
options: AgentRunBatchOptions = {},
) {
this.maxConcurrency = options.maxConcurrency;
this.states = tasks.map((task, index) => ({
@ -184,9 +184,9 @@ export class SubagentBatch<T> {
};
}
run(): Promise<Array<SubagentResult<T>>> {
run(): Promise<Array<AgentRunResult<T>>> {
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<T> {
private async runAttempt(attempt: ActiveAttempt<T>): Promise<AttemptOutcome<T>> {
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<T> {
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<T> {
} 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<T> {
}
}
private failedAttemptOutcome(attempt: ActiveAttempt<T>, error: unknown): SubagentResult<T> {
private failedAttemptOutcome(attempt: ActiveAttempt<T>, error: unknown): AgentRunResult<T> {
const status =
attempt.controller.signal.aborted && isUserCancellation(attempt.controller.signal.reason)
? 'aborted'
@ -588,7 +588,7 @@ export class SubagentBatch<T> {
);
}
private finish(results: Array<SubagentResult<T>>): void {
private finish(results: Array<AgentRunResult<T>>): void {
if (this.finished) return;
this.finished = true;
this.cleanup();
@ -622,7 +622,7 @@ export class SubagentBatch<T> {
this.rateLimitLaunchTimer = undefined;
}
private linkAttemptSignals(attempt: ActiveAttempt<T>, task: QueuedSubagentTask<T>): () => void {
private linkAttemptSignals(attempt: ActiveAttempt<T>, task: QueuedAgentRunTask<T>): () => void {
const abortFromBatch = () => {
attempt.controller.abort(this.controller.signal.reason);
};
@ -656,7 +656,7 @@ export class SubagentBatch<T> {
private attemptErrorMessage(
attempt: ActiveAttempt<T>,
error: unknown,
status: SubagentResult<T>['status'],
status: AgentRunResult<T>['status'],
): string {
if (attempt.timedOut && attempt.state.task.timeout !== undefined) {
return 'Subagent timed out.';

View file

@ -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';

View file

@ -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';

View file

@ -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<SubagentHandle> {
options: AgentSpawnAttemptOptions,
): Promise<AgentRunAttemptHandle> {
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<SubagentHandle> {
): Promise<AgentRunAttemptHandle> {
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<string>,
prompt: string,
): Promise<string> {
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,

View file

@ -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<typeof vi.fn>;
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[] = [];

View file

@ -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';

View file

@ -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', () => {

View file

@ -5,7 +5,7 @@ import {
cronToHuman,
hasFireWithinYears,
parseCronExpression,
} from '#/agent/cron/cron-expr';
} from '#/app/cron/cron-expr';
function localDate(
year: number,

View file

@ -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';

View file

@ -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,

View file

@ -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,

View file

@ -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',

View file

@ -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';

View file

@ -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*\(/);
});
});

View file

@ -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;

View file

@ -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(

View file

@ -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',

View file

@ -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';

View file

@ -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')),
};
}

View file

@ -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';

View file

@ -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', () => {

View file

@ -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,

View file

@ -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(

View file

@ -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(

View file

@ -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';

View file

@ -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(

View file

@ -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<IAgentScopeHandle> {
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';

View file

@ -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, [

View file

@ -86,7 +86,7 @@ describe('server-v2 /api/v1 prompts', () => {
async function createMainAgent(sessionId: string): Promise<void> {
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 () => {

View file

@ -109,7 +109,7 @@ describe('server-v2 /api/v2 RPC', () => {
async function createMainAgent(sessionId: string): Promise<void> {
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 -----------------------------------------------------------

View file

@ -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', () => {

View file

@ -56,7 +56,7 @@ describe('server-v2 GET /api/v1/sessions/:id/snapshot', () => {
async function ensureMainAgent(sessionId: string): Promise<void> {
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: [] },

View file

@ -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);
}

View file

@ -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;
}

View file

@ -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' });
}
}