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