diff --git a/packages/agent-core-v2/docs/di-scope-domains.puml b/packages/agent-core-v2/docs/di-scope-domains.puml index 8724a3df3..18c141a22 100644 --- a/packages/agent-core-v2/docs/di-scope-domains.puml +++ b/packages/agent-core-v2/docs/di-scope-domains.puml @@ -57,6 +57,7 @@ package "Session scope (per session)" #EAFAF1 { rectangle "workspaceCommand\nSession\n ISessionWorkspaceCommandService" as workspaceCommand #D5F5E3 rectangle "sessionLog\nSession binding\n ILogService" as sessionLog #D5F5E3 rectangle "sessionSkillCatalog\nSession\n ISessionSkillCatalog\n ISkillCatalogSink\n Workspace/PluginSkillSource" as sessionSkillCatalog #D5F5E3 + rectangle "externalHooks\nSession\n ISessionExternalHooksService" as sessionExternalHooks #D5F5E3 rectangle "sessionFs\nSession\n ISessionFsService" as sessionFs #D5F5E3 rectangle "approval\nSession\n IApprovalService" as approval #D5F5E3 rectangle "question\nSession\n IQuestionService" as question #D5F5E3 @@ -298,6 +299,10 @@ externalHooks --> config #34495E externalHooks --> bootstrap #34495E externalHooks --> plugin #34495E externalHooks --> contextMemory #34495E +sessionExternalHooks --> session_lifecycle #34495E +sessionExternalHooks --> config #34495E +sessionExternalHooks --> plugin #34495E +sessionExternalHooks --> session_context #34495E todo --> agent_lifecycle #34495E usage --> wire #34495E rpc --> prompt #34495E @@ -368,7 +373,8 @@ externalHooks ..> turn #16A085 : prompt/end hooks externalHooks ..> loop #16A085 : stop hook externalHooks ..> fullCompaction #16A085 : compaction hooks externalHooks ..> task #16A085 : notification hook -agent_lifecycle ..> externalHooks #16A085 : SubagentStart/Stop (mirrorAgentRun) +sessionExternalHooks ..> session_lifecycle #16A085 : SessionStart/End hooks +externalHooks ..> agent_lifecycle #16A085 : SubagentStart/Stop (observes run hooks) toolState ..> wire #16A085 : tools.update_store usage ..> wire #16A085 : usage.record turn ..> wire #16A085 : turn.launch diff --git a/packages/agent-core-v2/docs/di-scope-domains.svg b/packages/agent-core-v2/docs/di-scope-domains.svg index 12d7edce1..307c10bc0 100644 --- a/packages/agent-core-v2/docs/di-scope-domains.svg +++ b/packages/agent-core-v2/docs/di-scope-domains.svg @@ -1 +1 @@ -App scope (process-wide)Session scope (per session)Agent scope (per agent)bootstrapAppIBootstrapServicelog_base · App bindingILogServicetelemetryAppITelemetryServiceeventAppIEventServicestorageAppIFileSystemStorageServiceIAppendLogStoreIAtomicDocumentStoreIAtomicTomlDocumentStorefilestoreAppIFileStoregatewayAppIRestGatewayIWSGatewaysessionLifecycleAppISessionLifecycleServicesessionExportAppISessionExportServicesessionIndexAppISessionIndexhostFsAppIHostFileSystemworkspaceRegistryAppIWorkspaceRegistryhostFolderBrowserAppIHostFolderBrowserhostEnvironmentAppIHostEnvironmenthostProcessAppIHostProcessServiceauthAppIOAuthServiceIAuthSummaryServiceIWebSearchProviderServicewebAppIWebFetchServiceeditAppIFileEditServiceproviderAppIProviderServiceflagAppIFlagServiceIFlagRegistryconfigAppIConfigRegistryIConfigServicepluginAppIPluginServicechatProviderAppIChatProviderFactorymodelAppIModelServicemodelCatalogAppIModelCatalogServiceskillCatalogAppISkillDiscoveryISkillSourceBuiltin/UserSkillSourcesessionContextSessionISessionContext (seed)sessionMetadataSessionISessionMetadatasessionActivitySessionISessionActivityagentLifecycleSessionIAgentLifecycleServiceinteractionSessionIInteractionServiceworkspaceContextSessionIWorkspaceContextworkspaceCommandSessionISessionWorkspaceCommandServicesessionLogSession bindingILogServicesessionSkillCatalogSessionISessionSkillCatalogISkillCatalogSinkWorkspace/PluginSkillSourcesessionFsSessionISessionFsServiceapprovalSessionIApprovalServicequestionSessionIQuestionServiceprocessSessionIProcessRunnerIProcessterminalAppIHostTerminalServicesessionTerminalSessionISessionTerminalServicemodelProviderSessionIModelProvider (seed)todoSessionISessionTodoServicewireRecordAgentIAgentWireRecordService (event hub)wireAgentIAgentWireServiceblobStoreAgentIAgentBlobStoreServicecontextMemoryAgentIAgentContextMemoryServicecontextProjectorAgentIAgentContextProjectorServicecontextInjectorAgentIAgentContextInjectorServicecontextSizeAgentIAgentContextSizeServicesystemReminderAgentIAgentSystemReminderServiceprofileAgentIAgentProfileServicepromptAgentIAgentPromptServiceturnAgentIAgentTurnServiceloopAgentIAgentLoopServicellmRequesterAgentIAgentLLMRequesterServicetoolRegistryAgentIAgentToolRegistryServicetoolExecutorAgentIAgentToolExecutorServicetoolStateAgentIAgentToolStatetoolDedupeAgentIAgentToolDedupeServicepermissionGateAgentIAgentPermissionGatepermissionModeAgentIAgentPermissionModeServicepermissionPolicyAgentIAgentPermissionPolicyServicepermissionRulesAgentIAgentPermissionRulesServiceplanAgentIAgentPlanServicegoalAgentIAgentGoalServiceskillAgentIAgentSkillServicequestionToolsAgentIAgentQuestionToolsServiceuserToolAgentIAgentUserToolServicetaskAgentIAgentTaskServicecronAgentIAgentCronServiceswarmAgentIAgentSwarmServicemcpAgentIAgentMcpServicefullCompactionAgentIAgentFullCompactionServicemicroCompactionAgentIAgentMicroCompactionServiceexternalHooksAgentIAgentExternalHooksServiceusageAgentIAgentUsageServicerpcAgentIAgentRPCServicefileToolsAgentRead/Write/Grep/Glob toolseditAgentEditToolshellToolsAgentIAgentShellToolsServicescopeContextAgentIAgentScopeContext (seed)environmentrecordeventSinkonDidChangeConfigurationcontext_size.measuredconfig.update / tools.set_active_toolsagent.status.updated / warningturn.launchhooks.onSplicedtools.update_storepermission.set_modepermission.rules.add / record_approval_resultgoal.create/update/clearturn lifecycle hooksstep/usage hooksskill.activatetools.register_/unregister_user_toolhooks.onSplicedcron.add / delete / cursorswarm_mode.enter/exithooks.onErrormicro_compaction.applyusage.recordcontext.splicetodo.set (main agent)full_compaction.begin/cancel/completehook.result / goal.updatedhooks.onEndedtask.started/terminatedonWillExecuteTool / onDidExecuteToolapproval hooksprompt/end hooksstop hookcompaction hooksnotification hookSubagentStart/Stop (mirrorAgentRun)hooks.onResumeEndedplan_mode.enter/cancel/exitScope = node color      App (process-wide)      Session (per session)      Agent (per agent)Edgessolid: DI injection (ctor @IX)dashed: event-driven (subscribe/emit)direction: consumer ---> providerNotesGenerated from `node scripts/dep-graph.mjs` output;`_base` / seed / options deps are omitted. \ No newline at end of file +App scope (process-wide)Session scope (per session)Agent scope (per agent)bootstrapAppIBootstrapServicelog_base · App bindingILogServicetelemetryAppITelemetryServiceeventAppIEventServicestorageAppIFileSystemStorageServiceIAppendLogStoreIAtomicDocumentStoreIAtomicTomlDocumentStorefilestoreAppIFileStoregatewayAppIRestGatewayIWSGatewaysessionLifecycleAppISessionLifecycleServicesessionExportAppISessionExportServicesessionIndexAppISessionIndexhostFsAppIHostFileSystemworkspaceRegistryAppIWorkspaceRegistryhostFolderBrowserAppIHostFolderBrowserhostEnvironmentAppIHostEnvironmenthostProcessAppIHostProcessServiceauthAppIOAuthServiceIAuthSummaryServiceIWebSearchProviderServicewebAppIWebFetchServiceeditAppIFileEditServiceproviderAppIProviderServiceflagAppIFlagServiceIFlagRegistryconfigAppIConfigRegistryIConfigServicepluginAppIPluginServicechatProviderAppIChatProviderFactorymodelAppIModelServicemodelCatalogAppIModelCatalogServiceskillCatalogAppISkillDiscoveryISkillSourceBuiltin/UserSkillSourcesessionContextSessionISessionContext (seed)sessionMetadataSessionISessionMetadatasessionActivitySessionISessionActivityagentLifecycleSessionIAgentLifecycleServiceinteractionSessionIInteractionServiceworkspaceContextSessionIWorkspaceContextworkspaceCommandSessionISessionWorkspaceCommandServicesessionLogSession bindingILogServicesessionSkillCatalogSessionISessionSkillCatalogISkillCatalogSinkWorkspace/PluginSkillSourceexternalHooksSessionISessionExternalHooksServicesessionFsSessionISessionFsServiceapprovalSessionIApprovalServicequestionSessionIQuestionServiceprocessSessionIProcessRunnerIProcessterminalAppIHostTerminalServicesessionTerminalSessionISessionTerminalServicemodelProviderSessionIModelProvider (seed)todoSessionISessionTodoServicewireRecordAgentIAgentWireRecordService (event hub)wireAgentIAgentWireServiceblobStoreAgentIAgentBlobStoreServicecontextMemoryAgentIAgentContextMemoryServicecontextProjectorAgentIAgentContextProjectorServicecontextInjectorAgentIAgentContextInjectorServicecontextSizeAgentIAgentContextSizeServicesystemReminderAgentIAgentSystemReminderServiceprofileAgentIAgentProfileServicepromptAgentIAgentPromptServiceturnAgentIAgentTurnServiceloopAgentIAgentLoopServicellmRequesterAgentIAgentLLMRequesterServicetoolRegistryAgentIAgentToolRegistryServicetoolExecutorAgentIAgentToolExecutorServicetoolStateAgentIAgentToolStatetoolDedupeAgentIAgentToolDedupeServicepermissionGateAgentIAgentPermissionGatepermissionModeAgentIAgentPermissionModeServicepermissionPolicyAgentIAgentPermissionPolicyServicepermissionRulesAgentIAgentPermissionRulesServiceplanAgentIAgentPlanServicegoalAgentIAgentGoalServiceskillAgentIAgentSkillServicequestionToolsAgentIAgentQuestionToolsServiceuserToolAgentIAgentUserToolServicetaskAgentIAgentTaskServicecronAgentIAgentCronServiceswarmAgentIAgentSwarmServicemcpAgentIAgentMcpServicefullCompactionAgentIAgentFullCompactionServicefullCompactionHooksAgentIAgentFullCompactionHooksServicemicroCompactionAgentIAgentMicroCompactionServiceexternalHooksAgentIAgentExternalHooksServiceusageAgentIAgentUsageServicerpcAgentIAgentRPCServicefileToolsAgentRead/Write/Grep/Glob toolseditAgentEditToolshellToolsAgentIAgentShellToolsServicescopeContextAgentIAgentScopeContext (seed)environmentrecordeventSinkonDidChangeConfigurationcontext_size.measuredconfig.update / tools.set_active_toolsagent.status.updated / warningturn.launchhooks.onSplicedtools.update_storepermission.set_modepermission.rules.add / record_approval_resultgoal.create/update/clearturn lifecycle hooksstep/usage hooksskill.activatetools.register_/unregister_user_toolhooks.onSplicedcron.add / delete / cursorswarm_mode.enter/exithooks.onErrormicro_compaction.applySessionStart/End hooksusage.recordcontext.splicetodo.set (main agent)full_compaction.begin/cancel/completehook.result / goal.updatedhooks.onEndedtask.started/terminatedonWillExecuteTool / onDidExecuteToolapproval hooksprompt/end hooksstop hookcompaction hooksnotification hookSubagentStart/Stop (mirrorAgentRun)hooks.onResumeEndedplan_mode.enter/cancel/exitScope = node color      App (process-wide)      Session (per session)      Agent (per agent)Edgessolid: DI injection (ctor @IX)dashed: event-driven (subscribe/emit)direction: consumer ---> providerNotesGenerated from `node scripts/dep-graph.mjs` output;`_base` / seed / options deps are omitted. \ No newline at end of file diff --git a/packages/agent-core-v2/scripts/check-domain-layers.mjs b/packages/agent-core-v2/scripts/check-domain-layers.mjs index b0686eb22..fa1ff8807 100644 --- a/packages/agent-core-v2/scripts/check-domain-layers.mjs +++ b/packages/agent-core-v2/scripts/check-domain-layers.mjs @@ -165,13 +165,13 @@ const DOMAIN_LAYER = new Map([ ['agentTask', 5], ['mcp', 5], ['cron', 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. ['btw', 5], // L6 — coordination ['agentLifecycle', 6], ['sessionLifecycle', 6], + ['externalHooks', 6], ['sessionExport', 6], ['interaction', 6], ['sessionMetadata', 6], diff --git a/packages/agent-core-v2/src/agent/contextInjector/contextInjectorService.ts b/packages/agent-core-v2/src/agent/contextInjector/contextInjectorService.ts index d853e1064..46c0d3054 100644 --- a/packages/agent-core-v2/src/agent/contextInjector/contextInjectorService.ts +++ b/packages/agent-core-v2/src/agent/contextInjector/contextInjectorService.ts @@ -5,7 +5,6 @@ import { LifecycleScope, registerScopedService } from '#/_base/di/scope'; import { IAgentContextMemoryService } from '#/agent/contextMemory'; import { IAgentLoopService } from '#/agent/loop'; import { IAgentSystemReminderService } from '#/agent/systemReminder'; -import { IAgentTurnService } from '#/agent/turn'; import { IEventBus } from '#/app/event/eventBus'; import type { ContextMessage } from '#/agent/contextMemory'; import { @@ -29,7 +28,6 @@ export class AgentContextInjectorService extends Disposable implements IAgentCon constructor( @IAgentContextMemoryService private readonly context: IAgentContextMemoryService, - @IAgentTurnService turnService: IAgentTurnService, @IAgentLoopService loopService: IAgentLoopService, @IAgentSystemReminderService private readonly reminders: IAgentSystemReminderService, @IEventBus private readonly eventBus: IEventBus, diff --git a/packages/agent-core-v2/src/agent/externalHooks/externalHooks.ts b/packages/agent-core-v2/src/agent/externalHooks/externalHooks.ts index 3eec4f23b..dcda7ee4c 100644 --- a/packages/agent-core-v2/src/agent/externalHooks/externalHooks.ts +++ b/packages/agent-core-v2/src/agent/externalHooks/externalHooks.ts @@ -1,9 +1,9 @@ /** - * `externalHooks` domain (L5) — contract for configured external hook + * `externalHooks` domain (L6) — contract for configured external hook * commands. * * The service is intentionally observer-shaped: business domains expose their - * own minimal hook contexts, and the L5 implementation listens to those hooks + * own minimal hook contexts, and the L6 implementation listens to those hooks * to invoke configured external commands. */ @@ -22,31 +22,8 @@ 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 4107613af..68e5130a9 100644 --- a/packages/agent-core-v2/src/agent/externalHooks/externalHooksService.ts +++ b/packages/agent-core-v2/src/agent/externalHooks/externalHooksService.ts @@ -1,16 +1,15 @@ /** - * `externalHooks` domain (L5) — Agent-scope adapter for external + * `externalHooks` domain (L6) — Agent-scope adapter for external * hook commands. * - * Listens to hook slots owned by the agent behavior/lifecycle domains - * (`toolExecutor`, `permissionGate`, `prompt`, `turn`, `loop`, `fullCompaction`, and - * `task`) and translates those minimal contexts into the configured external - * HookEngine events. Appends UserPromptSubmit hook results and Stop hook - * continuation prompts through `contextMemory`. 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. + * Listens to hook slots and agent events owned by the agent behavior/lifecycle + * domains (`toolExecutor`, `permissionGate`, `prompt`, `turn`, `loop`, + * `fullCompaction`, `task`, and the `agentLifecycle` run hooks) and translates + * those minimal contexts into the configured external HookEngine events — + * including `SubagentStart` / `SubagentStop`, which it now observes via the + * `IAgentRunHooksService` slots rather than being invoked directly by the + * `agentLifecycle` wrapper. Appends UserPromptSubmit hook results and Stop hook + * continuation prompts through `contextMemory`. */ import { IInstantiationService } from '#/_base/di/instantiation'; @@ -48,13 +47,16 @@ import { IBootstrapService } from '#/app/bootstrap/bootstrap'; import { IConfigService } from '#/app/config/config'; import { IPluginService } from '#/app/plugin/plugin'; import { toKimiErrorPayload } from '#/errors'; +import { + IAgentRunHooksService, + type AgentTaskStartHookContext, + type AgentTaskStopHookContext, +} from '#/session/agentLifecycle/runHooks'; import { HOOKS_SECTION, type HookDefConfig } from './configSection'; import { HookEngine } from './engine'; import { IAgentExternalHooksService, - type AgentTaskStartHookContext, - type AgentTaskStopHookContext, type ExternalHooksServiceOptions, } from './externalHooks'; import { @@ -156,6 +158,9 @@ export class AgentExternalHooksService extends Disposable implements IAgentExter this.instantiation.invokeFunction((accessor) => accessor.get(IAgentTaskService)), ); + this.registerAgentTaskHooks( + this.instantiation.invokeFunction((accessor) => accessor.get(IAgentRunHooksService)), + ); } private registerToolHooks(toolExecutor: IAgentToolExecutorService): void { @@ -258,6 +263,21 @@ export class AgentExternalHooksService extends Disposable implements IAgentExter ); } + private registerAgentTaskHooks(runHooks: IAgentRunHooksService): void { + this._register( + runHooks.hooks.onWillStartAgentTask.register('externalHooks', async (ctx, next) => { + await this.runSubagentStart(ctx); + await next(); + }), + ); + this._register( + runHooks.hooks.onDidStopAgentTask.register('externalHooks', (ctx, next) => { + this.notifySubagentStop(ctx); + return next(); + }), + ); + } + private async loadDynamicHooks(): Promise { await this.config.ready; const configured = this.config.get(HOOKS_SECTION) as readonly HookDefConfig[] | undefined; @@ -433,7 +453,7 @@ export class AgentExternalHooksService extends Disposable implements IAgentExter ); } - async runAgentTaskStart(ctx: AgentTaskStartHookContext): Promise { + private async runSubagentStart(ctx: AgentTaskStartHookContext): Promise { ctx.signal.throwIfAborted(); const engine = await this.readyEngine(); await engine?.trigger('SubagentStart', { @@ -447,7 +467,7 @@ export class AgentExternalHooksService extends Disposable implements IAgentExter ctx.signal.throwIfAborted(); } - notifyAgentTaskStop(ctx: AgentTaskStopHookContext): void { + private notifySubagentStop(ctx: AgentTaskStopHookContext): void { this.fireAndForget( 'SubagentStop', { diff --git a/packages/agent-core-v2/src/agent/turn/turn.ts b/packages/agent-core-v2/src/agent/turn/turn.ts index 293d90741..adad5298c 100644 --- a/packages/agent-core-v2/src/agent/turn/turn.ts +++ b/packages/agent-core-v2/src/agent/turn/turn.ts @@ -1,6 +1,5 @@ import { createDecorator } from "#/_base/di/instantiation"; import type { TurnResult } from '#/agent/loop'; -import type { Hooks } from '#/hooks'; export type { TurnResult } from '#/agent/loop'; diff --git a/packages/agent-core-v2/src/agent/turn/turnService.ts b/packages/agent-core-v2/src/agent/turn/turnService.ts index 65cd31787..2d417d521 100644 --- a/packages/agent-core-v2/src/agent/turn/turnService.ts +++ b/packages/agent-core-v2/src/agent/turn/turnService.ts @@ -20,7 +20,6 @@ import type { TurnEndedEvent, TurnStartedEvent } from '@moonshot-ai/protocol'; import { InstantiationType } from '#/_base/di/extensions'; import { LifecycleScope, registerScopedService } from '#/_base/di/scope'; import { ErrorCodes, KimiError, toKimiErrorPayload } from '#/errors'; -import { OrderedHookSlot } from '#/hooks'; import { IAgentLoopService } from '#/agent/loop'; import { IEventBus } from '#/app/event/eventBus'; import { IAgentTelemetryContextService } from '#/app/telemetry/agentTelemetryContext'; diff --git a/packages/agent-core-v2/src/app/sessionLifecycle/sessionLifecycle.ts b/packages/agent-core-v2/src/app/sessionLifecycle/sessionLifecycle.ts index 81a4e8cd5..9f095599a 100644 --- a/packages/agent-core-v2/src/app/sessionLifecycle/sessionLifecycle.ts +++ b/packages/agent-core-v2/src/app/sessionLifecycle/sessionLifecycle.ts @@ -5,8 +5,9 @@ * `ForkSessionOptions`, and the `ISessionLifecycleService` used to create * sessions (`create`), look up the live ones (`get` / `list`), close them * (`close`), archive them (`archive`), and fork them (`fork`). Announces - * lifecycle transitions through `onDidCreateSession` / `onDidCloseSession` / - * `onDidArchiveSession` / `onDidForkSession`. App-scoped — a single + * lifecycle transitions through ordered hook slots plus + * `onDidCreateSession` / `onDidCloseSession` / `onDidArchiveSession` / + * `onDidForkSession`. App-scoped — a single * process-wide instance owns the live session scope tree. Persisted * sessions (open or closed) are the `sessionIndex` read model; per-session * behaviour lives in the Session-scoped domains. @@ -15,6 +16,7 @@ import { createDecorator, type ServiceIdentifier } from '#/_base/di/instantiation'; import type { ISessionScopeHandle } from '#/_base/di/scope'; import type { Event } from '#/_base/event'; +import type { Hooks } from '#/hooks'; export interface CreateSessionOptions { readonly sessionId: string; @@ -33,12 +35,28 @@ export interface ForkSessionOptions { export interface SessionCreatedEvent { readonly sessionId: string; readonly handle: ISessionScopeHandle; + readonly source: SessionCreateSource; } export interface SessionClosedEvent { readonly sessionId: string; } +export type SessionCreateSource = 'startup' | 'resume' | 'fork'; + +export type SessionCloseReason = 'exit'; + +export interface SessionWillCloseEvent { + readonly sessionId: string; + readonly handle: ISessionScopeHandle; + readonly reason: SessionCloseReason; +} + +export type SessionLifecycleHooks = { + readonly onDidCreateSession: SessionCreatedEvent; + readonly onWillCloseSession: SessionWillCloseEvent; +}; + export interface SessionArchivedEvent { readonly sessionId: string; } @@ -56,6 +74,7 @@ export interface ISessionLifecycleService { readonly onDidCloseSession: Event; readonly onDidArchiveSession: Event; readonly onDidForkSession: Event; + readonly hooks: Hooks; create(opts: CreateSessionOptions): Promise; get(sessionId: string): ISessionScopeHandle | undefined; list(): readonly ISessionScopeHandle[]; diff --git a/packages/agent-core-v2/src/app/sessionLifecycle/sessionLifecycleService.ts b/packages/agent-core-v2/src/app/sessionLifecycle/sessionLifecycleService.ts index be15bcaae..d8a1bfb41 100644 --- a/packages/agent-core-v2/src/app/sessionLifecycle/sessionLifecycleService.ts +++ b/packages/agent-core-v2/src/app/sessionLifecycle/sessionLifecycleService.ts @@ -3,8 +3,9 @@ * * Owns the process-wide registry of open Session child scopes, creating them * through the DI scope tree and seeding each with its identity and storage - * addressing, and tearing them down on close/archive — archiving flags the - * session's `sessionMetadata`, removes its `agentLifecycle` agents, and + * addressing, running lifecycle hook slots, and tearing them down on + * close/archive — archiving flags the session's `sessionMetadata`, removes + * its `agentLifecycle` agents, and * broadcasts through `event`. Materializes the session's initial metadata on * creation by resolving `sessionMetadata`. Bound at App scope. Persisted * sessions are the `sessionIndex` read model. @@ -35,9 +36,11 @@ import { ISessionIndex } from '#/app/sessionIndex/sessionIndex'; import { IAtomicDocumentStore } from '#/persistence/interface/atomicDocumentStore'; import { IAppendLogStore } from '#/persistence/interface/appendLogStore'; import { IWorkspaceRegistry } from '#/app/workspaceRegistry/workspaceRegistry'; +import { ISessionExternalHooksService } from '#/session/externalHooks/externalHooks'; import { ISessionContext, sessionContextSeed } from '#/session/sessionContext/sessionContext'; import { ISessionMetadata, type SessionMeta } from '#/session/sessionMetadata/sessionMetadata'; import { ISessionSkillCatalog } from '#/session/sessionSkillCatalog/skillCatalog'; +import { createHooks } from '#/hooks'; import { AGENT_WIRE_PROTOCOL_VERSION, IAgentWireRecordService, @@ -54,6 +57,8 @@ import { type SessionClosedEvent, type SessionCreatedEvent, type SessionForkedEvent, + type SessionLifecycleHooks, + type SessionWillCloseEvent, ISessionLifecycleService, } from './sessionLifecycle'; @@ -68,6 +73,10 @@ export class SessionLifecycleService extends Disposable implements ISessionLifec readonly onDidArchiveSession: Event = this._onDidArchiveSession.event; private readonly _onDidForkSession = this._register(new Emitter()); readonly onDidForkSession: Event = this._onDidForkSession.event; + readonly hooks = createHooks([ + 'onDidCreateSession', + 'onWillCloseSession', + ]); /** In-flight `resume` promises, keyed by session id — de-dupes concurrent * cold loads so a hot read path (e.g. snapshot retry) cannot materialize * the same session twice and leak a handle. */ @@ -87,6 +96,12 @@ export class SessionLifecycleService extends Disposable implements ISessionLifec } async create(opts: CreateSessionOptions): Promise { + const handle = await this.materializeSession(opts); + await this.announceCreated({ sessionId: opts.sessionId, handle, source: 'startup' }); + return handle; + } + + private async materializeSession(opts: CreateSessionOptions): Promise { const workspaceId = encodeWorkDirKey(opts.workDir); const sessionScope = this.bootstrap.sessionScope(workspaceId, opts.sessionId); const sessionDir = this.bootstrap.sessionDir(workspaceId, opts.sessionId); @@ -122,10 +137,15 @@ export class SessionLifecycleService extends Disposable implements ISessionLifec await handle.accessor.get(ISessionMetadata).ready; void handle.accessor.get(ISessionSkillCatalog).ready; await handle.accessor.get(IAgentLifecycleService).ensureMcpReady(); - this._onDidCreateSession.fire({ sessionId: opts.sessionId, handle }); + handle.accessor.get(ISessionExternalHooksService); return handle; } + private async announceCreated(event: SessionCreatedEvent): Promise { + await this.hooks.onDidCreateSession.run(event); + this._onDidCreateSession.fire(event); + } + get(sessionId: string): ISessionScopeHandle | undefined { return this.sessions.get(sessionId); } @@ -151,7 +171,7 @@ export class SessionLifecycleService extends Disposable implements ISessionLifec const workspace = await this.workspaceRegistry.get(summary.workspaceId); if (workspace === undefined) return undefined; - const handle = await this.create({ sessionId, workDir: workspace.root }); + const handle = await this.materializeSession({ sessionId, workDir: workspace.root }); const agents = handle.accessor.get(IAgentLifecycleService); if (agents.getHandle(MAIN_AGENT_ID) === undefined) { const main = await ensureMainAgent(handle); @@ -165,6 +185,7 @@ export class SessionLifecycleService extends Disposable implements ISessionLifec .accessor.get(IAgentWireService) .replay(...(mainWireRecord.getRecords() as readonly PersistedRecord[])); } + await this.announceCreated({ sessionId, handle, source: 'resume' }); return handle; } @@ -175,6 +196,7 @@ export class SessionLifecycleService extends Disposable implements ISessionLifec async close(sessionId: string): Promise { const handle = this.sessions.get(sessionId); if (handle === undefined) return; + await this.announceWillClose({ sessionId, handle, reason: 'exit' }); this.sessions.delete(sessionId); handle.dispose(); this._onDidCloseSession.fire({ sessionId }); @@ -193,11 +215,16 @@ export class SessionLifecycleService extends Disposable implements ISessionLifec type: 'event.session.archived', payload: { sessionId }, }); + await this.announceWillClose({ sessionId, handle, reason: 'exit' }); this.sessions.delete(sessionId); handle.dispose(); this._onDidArchiveSession.fire({ sessionId }); } + private async announceWillClose(event: SessionWillCloseEvent): Promise { + await this.hooks.onWillCloseSession.run(event); + } + async fork(opts: ForkSessionOptions): Promise { const sourceId = opts.sourceSessionId; @@ -245,7 +272,7 @@ export class SessionLifecycleService extends Disposable implements ISessionLifec } // 6. Materialize the target session scope (fresh metadata + storage). - const target = await this.create({ sessionId: targetId, workDir: workspace.root }); + const target = await this.materializeSession({ sessionId: targetId, workDir: workspace.root }); const targetCtx = target.accessor.get(ISessionContext); const targetMeta = target.accessor.get(ISessionMetadata); @@ -299,6 +326,7 @@ export class SessionLifecycleService extends Disposable implements ISessionLifec sessionId: targetId, handle: target, }); + await this.announceCreated({ sessionId: targetId, handle: target, source: 'fork' }); return target; } diff --git a/packages/agent-core-v2/src/index.ts b/packages/agent-core-v2/src/index.ts index ddba9050f..834ee7b35 100644 --- a/packages/agent-core-v2/src/index.ts +++ b/packages/agent-core-v2/src/index.ts @@ -58,6 +58,7 @@ export * from '#/session/cron'; export * from '#/session/agentLifecycle'; export * from '#/app/sessionLifecycle'; +export * from '#/session/externalHooks'; export * from '#/app/sessionExport'; export * from '#/app/sessionLegacy'; export * from '#/session/interaction'; diff --git a/packages/agent-core-v2/src/session/agentLifecycle/index.ts b/packages/agent-core-v2/src/session/agentLifecycle/index.ts index 747d6c7f1..3ea14f92e 100644 --- a/packages/agent-core-v2/src/session/agentLifecycle/index.ts +++ b/packages/agent-core-v2/src/session/agentLifecycle/index.ts @@ -17,8 +17,8 @@ export * from './tools/subagent-task'; export { AGENT_RUN_PROMPT_ORIGIN } from './runAgentTurn'; export * from './mainAgent'; export * from './mirrorAgentRun'; -// Deliberately last: `tools/agent` reaches `sessionSwarmService` through -// `mirrorAgentRun` → `externalHooks` → `permissionPolicy` → `agent/swarm`, -// and `sessionSwarmService` imports this barrel back. The `./agentLifecycle` -// contract (service decorator) must be evaluated before that cycle re-enters. +export * from './runHooks'; +// Deliberately last: `tools/agent` transitively reaches `sessionSwarmService`, +// which imports this barrel back. The `./agentLifecycle` contract (service +// decorator) must be evaluated before that cycle re-enters. import './tools/agent'; diff --git a/packages/agent-core-v2/src/session/agentLifecycle/mirrorAgentRun.ts b/packages/agent-core-v2/src/session/agentLifecycle/mirrorAgentRun.ts index 8571bb035..f5e352d21 100644 --- a/packages/agent-core-v2/src/session/agentLifecycle/mirrorAgentRun.ts +++ b/packages/agent-core-v2/src/session/agentLifecycle/mirrorAgentRun.ts @@ -8,9 +8,10 @@ * 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. + * External hooks (`SubagentStart` / `SubagentStop`) fire by observation, like + * every other external hook: this wrapper announces "a run is about to start" + * / "...has stopped" through the `IAgentRunHooksService` slots, and the + * Agent-scope `externalHooks` adapter registers there to translate them. * * Wire shape note: the signals are still named `subagent.spawned / started / * completed / failed` and telemetry still tracks `subagent_created` so existing @@ -23,7 +24,7 @@ import { userCancellationReason } from '#/_base/utils/abort'; import { isProviderRateLimitError } from '#/app/llmProtocol/errors'; import { type TokenUsage } from '#/app/llmProtocol/usage'; import { ITelemetryService } from '#/app/telemetry/telemetry'; -import { IAgentExternalHooksService } from '#/agent/externalHooks'; +import { IAgentRunHooksService } from './runHooks'; import type { SubagentCompletedEvent, SubagentFailedEvent, @@ -44,8 +45,6 @@ declare module '#/app/event/eventBus' { } } -const HOOK_TEXT_PREVIEW_LENGTH = 500; - export interface AgentRunSpawnedMeta { readonly profileName: string; readonly parentToolCallId?: string; @@ -59,16 +58,17 @@ 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. + * Prompt text submitted to the target. When present the requester-side + * `onWillStartAgentTask` hook slot runs (which the external-hooks adapter + * translates into the `SubagentStart` external hook); omit for retry turns, + * which skip the slot. */ 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). */ + /** The requester's cancellation signal (passed through to the start hook slot). */ readonly signal: AbortSignal; - /** Called to abort the underlying run when the start hook blocks it. */ + /** Called to abort the underlying run when the start hook slot aborts/rejects it. */ readonly cancel?: (reason?: unknown) => void; } @@ -111,13 +111,13 @@ export async function mirrorAgentRun( options: MirrorAgentRunOptions, ): Promise<{ summary: string; usage?: TokenUsage }> { const eventBus = requester.accessor.get(IEventBus); - const externalHooks = requester.accessor.get(IAgentExternalHooksService); + const runHooks = requester.accessor.get(IAgentRunHooksService); eventBus?.publish({ type: 'subagent.started', subagentId: run.agentId }); if (options.prompt !== undefined) { try { - await externalHooks?.runAgentTaskStart({ + await runHooks?.hooks.onWillStartAgentTask.run({ agentName: options.profileName, - prompt: options.prompt.slice(0, HOOK_TEXT_PREVIEW_LENGTH), + prompt: options.prompt, signal: options.signal, }); } catch (error) { @@ -140,10 +140,12 @@ export async function mirrorAgentRun( resultSummary: result.summary, usage: result.usage, }); - externalHooks?.notifyAgentTaskStop({ - agentName: options.profileName, - response: result.summary.slice(0, HOOK_TEXT_PREVIEW_LENGTH), - }); + void runHooks?.hooks + .onDidStopAgentTask.run({ + agentName: options.profileName, + response: result.summary, + }) + .catch(() => {}); return result; } catch (error) { if (!isAbortError(error) && !shouldSuppressFailure(options, error)) { diff --git a/packages/agent-core-v2/src/session/agentLifecycle/runHooks.ts b/packages/agent-core-v2/src/session/agentLifecycle/runHooks.ts new file mode 100644 index 000000000..9c5f8f168 --- /dev/null +++ b/packages/agent-core-v2/src/session/agentLifecycle/runHooks.ts @@ -0,0 +1,67 @@ +/** + * `agentLifecycle` domain (L6) — hook-slot host for the requester-side agent + * run wrapper (`mirrorAgentRun`). + * + * When one agent drives another (the `Agent` tool, the swarm scheduler), + * `mirrorAgentRun` announces "an agent run I am hosting is about to start" and + * "...has stopped" through these ordered slots. Observers — most notably the + * Agent-scope `externalHooks` adapter, which translates them into the + * `SubagentStart` / `SubagentStop` external hook commands — register here + * instead of `mirrorAgentRun` calling them directly. Bound at Agent scope so + * each requester agent gets its own slot (and only its own observers see its + * launches), matching the other Agent-scope hook hosts (`toolExecutor`, + * `prompt`, ...). + * + * The slots carry the raw run facts (`prompt` / `response`); observers apply + * their own truncation. `onWillStartAgentTask` is awaited by `mirrorAgentRun` + * before the run proceeds, preserving start-before-stop ordering; + * `onDidStopAgentTask` is driven fire-and-forget. + */ + +import { InstantiationType } from '#/_base/di/extensions'; +import { createDecorator, type ServiceIdentifier } from '#/_base/di/instantiation'; +import { LifecycleScope, registerScopedService } from '#/_base/di/scope'; +import { createHooks, type Hooks } from '#/hooks'; + +/** Facts announced when an agent run this agent is hosting is about to start. */ +export interface AgentTaskStartHookContext { + readonly agentName: string; + readonly prompt: string; + readonly signal: AbortSignal; +} + +/** Facts announced when an agent run this agent is hosting has stopped. */ +export interface AgentTaskStopHookContext { + readonly agentName: string; + readonly response: string; +} + +export type AgentTaskHooks = { + readonly onWillStartAgentTask: AgentTaskStartHookContext; + readonly onDidStopAgentTask: AgentTaskStopHookContext; +}; + +export interface IAgentRunHooksService { + readonly _serviceBrand: undefined; + readonly hooks: Hooks; +} + +export const IAgentRunHooksService: ServiceIdentifier = + createDecorator('agentRunHooksService'); + +export class AgentRunHooksService implements IAgentRunHooksService { + declare readonly _serviceBrand: undefined; + + readonly hooks = createHooks([ + 'onWillStartAgentTask', + 'onDidStopAgentTask', + ]); +} + +registerScopedService( + LifecycleScope.Agent, + IAgentRunHooksService, + AgentRunHooksService, + InstantiationType.Delayed, + 'agentLifecycle', +); diff --git a/packages/agent-core-v2/src/session/externalHooks/externalHooks.ts b/packages/agent-core-v2/src/session/externalHooks/externalHooks.ts new file mode 100644 index 000000000..7f70ebe07 --- /dev/null +++ b/packages/agent-core-v2/src/session/externalHooks/externalHooks.ts @@ -0,0 +1,17 @@ +/** + * `externalHooks` domain (L6) — Session-scope external hook observer contract. + * + * Exposes an empty Session-scope service whose implementation registers + * session lifecycle callbacks from its constructor. The lifecycle owner invokes + * its own hook slots; callers never trigger session external hooks through + * this contract. + */ + +import { createDecorator, type ServiceIdentifier } from '#/_base/di/instantiation'; + +export interface ISessionExternalHooksService { + readonly _serviceBrand: undefined; +} + +export const ISessionExternalHooksService: ServiceIdentifier = + createDecorator('sessionExternalHooksService'); diff --git a/packages/agent-core-v2/src/session/externalHooks/externalHooksService.ts b/packages/agent-core-v2/src/session/externalHooks/externalHooksService.ts new file mode 100644 index 000000000..1491a8415 --- /dev/null +++ b/packages/agent-core-v2/src/session/externalHooks/externalHooksService.ts @@ -0,0 +1,116 @@ +/** + * `externalHooks` domain (L6) — Session-scope adapter for external hook commands. + * + * Registers with `sessionLifecycle` hook slots to run `SessionStart` and + * `SessionEnd` external commands for the current `sessionContext`, loading + * configured hooks through `config` and plugin-contributed hooks through + * `plugin`. Bound at Session scope. + */ + +import { InstantiationType } from '#/_base/di/extensions'; +import { Disposable } from '#/_base/di/lifecycle'; +import { LifecycleScope, registerScopedService } from '#/_base/di/scope'; +import { IConfigService } from '#/app/config/config'; +import { IPluginService } from '#/app/plugin/plugin'; +import { + ISessionLifecycleService, + type SessionCloseReason, + type SessionCreateSource, +} from '#/app/sessionLifecycle/sessionLifecycle'; +import { HOOKS_SECTION, type HookDefConfig } from '#/agent/externalHooks/configSection'; +import { HookEngine } from '#/agent/externalHooks/engine'; +import { ISessionContext } from '#/session/sessionContext/sessionContext'; + +import { ISessionExternalHooksService } from './externalHooks'; + +type SessionStartHookSource = Exclude; + +export class SessionExternalHooksService + extends Disposable + implements ISessionExternalHooksService +{ + declare readonly _serviceBrand: undefined; + + constructor( + @ISessionContext private readonly context: ISessionContext, + @ISessionLifecycleService lifecycle: ISessionLifecycleService, + @IConfigService config: IConfigService, + @IPluginService plugins: IPluginService, + ) { + super(); + let dynamicEngine = new HookEngine([], { + cwd: this.context.cwd, + sessionId: this.context.sessionId, + }); + const loadDynamicHooks = async (): Promise => { + await config.ready; + const configured = config.get(HOOKS_SECTION) as readonly HookDefConfig[] | undefined; + const pluginHooks = await plugins.enabledHooks(); + dynamicEngine = new HookEngine([...(configured ?? []), ...pluginHooks], { + cwd: this.context.cwd, + sessionId: this.context.sessionId, + }); + }; + const loadDynamicHooksSafe = async (): Promise => { + try { + await loadDynamicHooks(); + } catch {} + }; + const hooksReady = loadDynamicHooksSafe(); + const readyEngine = async (): Promise => { + await hooksReady; + return dynamicEngine; + }; + const triggerSessionStart = async (source: SessionStartHookSource): Promise => { + const engine = await readyEngine(); + await engine.trigger('SessionStart', { + matcherValue: source, + inputData: { + sessionId: this.context.sessionId, + cwd: this.context.cwd, + source, + }, + }); + }; + const triggerSessionEnd = async (reason: SessionCloseReason): Promise => { + const engine = await readyEngine(); + await engine.trigger('SessionEnd', { + matcherValue: reason, + inputData: { + sessionId: this.context.sessionId, + cwd: this.context.cwd, + reason, + }, + }); + }; + this._register( + lifecycle.hooks.onDidCreateSession.register('externalHooks', async (event, next) => { + if (event.sessionId === this.context.sessionId && event.source !== 'fork') { + await triggerSessionStart(event.source); + } + await next(); + }), + ); + this._register( + lifecycle.hooks.onWillCloseSession.register('externalHooks', async (event, next) => { + if (event.sessionId === this.context.sessionId) { + await triggerSessionEnd(event.reason); + } + await next(); + }), + ); + this._register( + plugins.onDidReload(() => { + void loadDynamicHooksSafe(); + }), + ); + } +} + +registerScopedService( + LifecycleScope.Session, + ISessionExternalHooksService, + SessionExternalHooksService, + InstantiationType.Eager, + 'externalHooks', +); diff --git a/packages/agent-core-v2/src/session/externalHooks/index.ts b/packages/agent-core-v2/src/session/externalHooks/index.ts new file mode 100644 index 000000000..69e6f4db2 --- /dev/null +++ b/packages/agent-core-v2/src/session/externalHooks/index.ts @@ -0,0 +1,9 @@ +/** + * `externalHooks` domain barrel — re-exports the Session-scope external hooks + * contract (`externalHooks`) and its scoped service (`externalHooksService`). + * Importing this barrel registers the `ISessionExternalHooksService` binding + * into the scope registry. + */ + +export * from './externalHooks'; +export * from './externalHooksService'; diff --git a/packages/agent-core-v2/test/externalHooks/integration.test.ts b/packages/agent-core-v2/test/externalHooks/integration.test.ts index 9bec226bc..cc71da99c 100644 --- a/packages/agent-core-v2/test/externalHooks/integration.test.ts +++ b/packages/agent-core-v2/test/externalHooks/integration.test.ts @@ -1,7 +1,12 @@ +import { existsSync, mkdtempSync, readFileSync } from 'node:fs'; +import { tmpdir } from 'node:os'; +import { join } from 'node:path'; + import { describe, expect, it } from 'vitest'; import { SyncDescriptor } from '#/_base/di/descriptors'; import { DisposableStore } from '#/_base/di/lifecycle'; +import type { ISessionScopeHandle } from '#/_base/di/scope'; import { createServices, type TestInstantiationService, @@ -22,6 +27,10 @@ import { AgentExternalHooksService, IAgentExternalHooksService, } from '#/agent/externalHooks'; +import { + AgentRunHooksService, + IAgentRunHooksService, +} from '#/session/agentLifecycle/runHooks'; import { HookEngine } from '#/agent/externalHooks/engine'; import { HookDefSchema, @@ -40,7 +49,16 @@ import { IConfigService } from '#/app/config/config'; import { IEventBus } from '#/app/event/eventBus'; import { EventBusService } from '#/app/event/eventBusService'; import { IPluginService } from '#/app/plugin/plugin'; +import { + ISessionLifecycleService, + type SessionLifecycleHooks, +} from '#/app/sessionLifecycle/sessionLifecycle'; import { createHooks } from '#/hooks'; +import { ISessionContext } from '#/session/sessionContext/sessionContext'; +import { + ISessionExternalHooksService, + SessionExternalHooksService, +} from '#/session/externalHooks'; import { IAgentWireService, WireService } from '#/wire'; import { stubBootstrap } from '../bootstrap/stubs'; @@ -111,6 +129,60 @@ async function flushMicrotasks(): Promise { await Promise.resolve(); } +function hookLogPath(): string { + return join(mkdtempSync(join(tmpdir(), 'session-external-hooks-')), 'events.jsonl'); +} + +function appendHookLogCommand(path: string): string { + return stdinScript([ + 'const fs = require("node:fs");', + 'fs.appendFileSync(', + ` ${JSON.stringify(path)},`, + ' JSON.stringify({', + ' event: parsed.hook_event_name,', + ' source: parsed.source,', + ' reason: parsed.reason,', + ' sessionId: parsed.session_id,', + ' cwd: parsed.cwd,', + ' }) + "\\n",', + ');', + ].join('\n')); +} + +function readHookLog(path: string): Array> { + if (!existsSync(path)) return []; + return readFileSync(path, 'utf8') + .trim() + .split('\n') + .filter((line) => line.length > 0) + .map((line) => JSON.parse(line) as Record); +} + +function stubSessionLifecycle(): ISessionLifecycleService { + return { + _serviceBrand: undefined, + hooks: createHooks([ + 'onDidCreateSession', + 'onWillCloseSession', + ]), + onDidCreateSession: Event.None as ISessionLifecycleService['onDidCreateSession'], + onDidCloseSession: Event.None as ISessionLifecycleService['onDidCloseSession'], + onDidArchiveSession: Event.None as ISessionLifecycleService['onDidArchiveSession'], + onDidForkSession: Event.None as ISessionLifecycleService['onDidForkSession'], + create: async () => { + throw new Error('not implemented'); + }, + get: () => undefined, + list: () => [], + resume: async () => undefined, + close: async () => {}, + archive: async () => {}, + fork: async () => { + throw new Error('not implemented'); + }, + }; +} + describe('HookEngine integration', () => { it('blocks a dangerous Bash command and allows a safe one via a PreToolUse script hook', async () => { const engine = new HookEngine([ @@ -201,6 +273,7 @@ describe('HookEngine integration', () => { ); }, }); + ix.set(IAgentRunHooksService, new SyncDescriptor(AgentRunHooksService)); ix.set( IAgentExternalHooksService, new SyncDescriptor(AgentExternalHooksService, [{ hookEngine }]), @@ -303,6 +376,7 @@ describe('HookEngine integration', () => { reg.definePartialInstance(IAgentTaskService, {}); }, }); + ix.set(IAgentRunHooksService, new SyncDescriptor(AgentRunHooksService)); ix.set( IAgentExternalHooksService, new SyncDescriptor(AgentExternalHooksService, [{ hookEngine }]), @@ -354,6 +428,117 @@ describe('HookEngine integration', () => { } }); + it('observes the agent-run hook slots to fire SubagentStart and SubagentStop', async () => { + const disposables = new DisposableStore(); + let ix: TestInstantiationService | undefined; + try { + const fired: Array<{ + event: string; + matcherValue?: unknown; + inputData?: unknown; + }> = []; + const triggered: Array<{ + event: string; + matcherValue?: unknown; + inputData?: unknown; + signal?: unknown; + }> = []; + const hookEngine = { + trigger: async ( + event: string, + args: { matcherValue?: unknown; inputData?: unknown; signal?: unknown }, + ) => { + triggered.push({ + event, + matcherValue: args.matcherValue, + inputData: args.inputData, + signal: args.signal, + }); + return []; + }, + triggerBlock: async () => undefined, + fireAndForgetTrigger: async ( + event: string, + args: { matcherValue?: unknown; inputData?: unknown }, + ) => { + fired.push({ + event, + matcherValue: args.matcherValue, + inputData: args.inputData, + }); + return []; + }, + }; + + ix = createServices(disposables, { + strict: true, + additionalServices: (reg) => { + reg.defineInstance(IBootstrapService, stubBootstrap()); + reg.definePartialInstance(IConfigService, {}); + reg.definePartialInstance(IPluginService, { + onDidReload: Event.None as IPluginService['onDidReload'], + }); + reg.defineInstance(IAgentContextMemoryService, stubContextMemory()); + reg.defineInstance(IAgentLoopService, stubLoopWithHooks()); + reg.define(IEventBus, EventBusService); + reg.definePartialInstance(IAgentPromptService, { + hooks: createHooks(['onWillSubmitPrompt']), + }); + reg.defineInstance(IAgentTurnService, stubTurnWithHooks()); + reg.defineInstance(IAgentToolExecutorService, stubToolExecutor()); + reg.definePartialInstance(IAgentPermissionGate, {}); + reg.definePartialInstance(IAgentFullCompactionService, { + hooks: createHooks(['onWillCompact']), + }); + reg.definePartialInstance(IAgentTaskService, {}); + }, + }); + ix.set(IAgentRunHooksService, new SyncDescriptor(AgentRunHooksService)); + ix.set( + IAgentExternalHooksService, + new SyncDescriptor(AgentExternalHooksService, [{ hookEngine }]), + ); + + // Construct the observer first so it registers on the run-hook slots, + // then drive the slots the way `mirrorAgentRun` does. + ix.get(IAgentExternalHooksService); + const runHooks = ix.get(IAgentRunHooksService); + + await runHooks.hooks.onWillStartAgentTask.run({ + agentName: 'coder', + prompt: 'Fix the bug', + signal: new AbortController().signal, + }); + await runHooks.hooks.onDidStopAgentTask.run({ + agentName: 'coder', + response: 'Bug fixed', + }); + + expect(triggered).toEqual([ + { + event: 'SubagentStart', + matcherValue: 'coder', + inputData: { agentName: 'coder', prompt: 'Fix the bug' }, + signal: expect.any(AbortSignal), + }, + ]); + + // SubagentStop is fire-and-forget; flush until it lands. + await flushMicrotasks(); + await flushMicrotasks(); + expect(fired).toEqual([ + { + event: 'SubagentStop', + matcherValue: 'coder', + inputData: { agentName: 'coder', response: 'Bug fixed' }, + }, + ]); + } finally { + ix?.dispose(); + disposables.dispose(); + } + }); + it('waits for dynamic hooks to load before running the first blocking hook', async () => { const disposables = new DisposableStore(); let ix: TestInstantiationService | undefined; @@ -405,6 +590,7 @@ describe('HookEngine integration', () => { ); }, }); + ix.set(IAgentRunHooksService, new SyncDescriptor(AgentRunHooksService)); ix.set( IAgentExternalHooksService, new SyncDescriptor(AgentExternalHooksService, [{}]), @@ -619,6 +805,103 @@ describe('HookEngine integration', () => { expect(unmatched).toHaveLength(0); }); + it('runs session external hooks from lifecycle callbacks', async () => { + const disposables = new DisposableStore(); + let ix: TestInstantiationService | undefined; + try { + const lifecycle = stubSessionLifecycle(); + const path = hookLogPath(); + const command = appendHookLogCommand(path); + const cwd = mkdtempSync(join(tmpdir(), 'session-external-hooks-cwd-')); + const handle = {} as ISessionScopeHandle; + + ix = createServices(disposables, { + strict: true, + additionalServices: (reg) => { + reg.defineInstance(ISessionContext, { + _serviceBrand: undefined, + sessionId: 'session-1', + workspaceId: 'workspace-1', + sessionDir: '/tmp/session-1', + metaScope: 'sessions/workspace-1/session-1', + cwd, + scope: (subKey?: string) => + subKey === undefined || subKey === '' + ? 'sessions/workspace-1/session-1' + : `sessions/workspace-1/session-1/${subKey}`, + }); + reg.defineInstance(ISessionLifecycleService, lifecycle); + reg.definePartialInstance(IConfigService, { + ready: Promise.resolve(), + get: (domain: string): T => + (domain === HOOKS_SECTION + ? [ + { event: 'SessionStart' as const, command, timeout: 5 }, + { event: 'SessionEnd' as const, command, timeout: 5 }, + ] + : undefined) as T, + }); + reg.definePartialInstance(IPluginService, { + enabledHooks: async () => [], + onDidReload: Event.None as IPluginService['onDidReload'], + }); + }, + }); + ix.set(ISessionExternalHooksService, new SyncDescriptor(SessionExternalHooksService)); + ix.get(ISessionExternalHooksService); + + await lifecycle.hooks.onDidCreateSession.run({ + sessionId: 'session-1', + handle, + source: 'startup', + }); + await lifecycle.hooks.onDidCreateSession.run({ + sessionId: 'session-1', + handle, + source: 'resume', + }); + await lifecycle.hooks.onDidCreateSession.run({ + sessionId: 'session-1', + handle, + source: 'fork', + }); + await lifecycle.hooks.onDidCreateSession.run({ + sessionId: 'other-session', + handle, + source: 'startup', + }); + await lifecycle.hooks.onWillCloseSession.run({ + sessionId: 'session-1', + handle, + reason: 'exit', + }); + + expect(readHookLog(path)).toEqual([ + { + event: 'SessionStart', + source: 'startup', + sessionId: 'session-1', + cwd, + }, + { + event: 'SessionStart', + source: 'resume', + sessionId: 'session-1', + cwd, + }, + { + event: 'SessionEnd', + reason: 'exit', + sessionId: 'session-1', + cwd, + }, + ]); + } finally { + ix?.dispose(); + disposables.dispose(); + } + }); + it('fires a SubagentStart hook with the agent_name payload field', async () => { const engine = new HookEngine([ { diff --git a/packages/agent-core-v2/test/harness/agent.ts b/packages/agent-core-v2/test/harness/agent.ts index 016287de8..129946a7e 100644 --- a/packages/agent-core-v2/test/harness/agent.ts +++ b/packages/agent-core-v2/test/harness/agent.ts @@ -60,6 +60,7 @@ import { AGENT_WIRE_PROTOCOL_VERSION, AgentTaskService, AgentExternalHooksService, + AgentRunHooksService, FileStorageService, InMemoryStorageService, AgentFullCompactionService, @@ -77,6 +78,7 @@ import { IAgentContextProjectorService, IAgentContextSizeService, IAgentExternalHooksService, + IAgentRunHooksService, IAgentFullCompactionService, IAgentLLMRequesterService, ILogService, @@ -532,10 +534,13 @@ export function questionServices(service: ISessionQuestionService): TestAgentSer export function externalHookServices( hookEngine: Pick | undefined, ): TestAgentServiceOverride { - return agentService( - IAgentExternalHooksService, - new SyncDescriptor(AgentExternalHooksService, [hookEngine === undefined ? {} : { hookEngine }]), - ); + return [ + agentService(IAgentRunHooksService, new SyncDescriptor(AgentRunHooksService)), + agentService( + IAgentExternalHooksService, + new SyncDescriptor(AgentExternalHooksService, [hookEngine === undefined ? {} : { hookEngine }]), + ), + ]; } export function microCompactionServices(options: { diff --git a/packages/agent-core-v2/test/sessionExport/sessionExport.test.ts b/packages/agent-core-v2/test/sessionExport/sessionExport.test.ts index 64c076438..7b49e3822 100644 --- a/packages/agent-core-v2/test/sessionExport/sessionExport.test.ts +++ b/packages/agent-core-v2/test/sessionExport/sessionExport.test.ts @@ -21,9 +21,13 @@ import { SessionExportService, } from '#/app/sessionExport/sessionExportService'; import { ISessionIndex, type SessionSummary } from '#/app/sessionIndex/sessionIndex'; -import { ISessionLifecycleService } from '#/app/sessionLifecycle/sessionLifecycle'; +import { + ISessionLifecycleService, + type SessionLifecycleHooks, +} from '#/app/sessionLifecycle/sessionLifecycle'; import { IWorkspaceRegistry } from '#/app/workspaceRegistry/workspaceRegistry'; import { KimiError } from '#/errors'; +import { createHooks } from '#/hooks'; import { IAgentLifecycleService } from '#/session/agentLifecycle/agentLifecycle'; import { ISessionMetadata, type SessionMeta } from '#/session/sessionMetadata/sessionMetadata'; @@ -306,6 +310,10 @@ function registerSessionExportServices( onDidCloseSession: noopEvent, onDidArchiveSession: noopEvent, onDidForkSession: noopEvent, + hooks: createHooks([ + 'onDidCreateSession', + 'onWillCloseSession', + ]), create: async () => { throw new Error('create should not be called by session export'); }, diff --git a/packages/agent-core-v2/test/sessionLifecycle/sessionLifecycle.test.ts b/packages/agent-core-v2/test/sessionLifecycle/sessionLifecycle.test.ts index ac30a7971..2a2b52a53 100644 --- a/packages/agent-core-v2/test/sessionLifecycle/sessionLifecycle.test.ts +++ b/packages/agent-core-v2/test/sessionLifecycle/sessionLifecycle.test.ts @@ -1,6 +1,7 @@ import { afterEach, beforeEach, describe, expect, it } from 'vitest'; import { InstantiationType } from '#/_base/di/extensions'; +import { Disposable } from '#/_base/di/lifecycle'; import { type IAgentScopeHandle, LifecycleScope, @@ -14,6 +15,7 @@ import { IEventService } from '#/app/event/event'; import { IAgentLifecycleService } from '#/session/agentLifecycle/agentLifecycle'; import { ISessionLifecycleService } from '#/app/sessionLifecycle/sessionLifecycle'; import { SessionLifecycleService } from '#/app/sessionLifecycle/sessionLifecycleService'; +import { ISessionExternalHooksService } from '#/session/externalHooks/externalHooks'; import { ISessionMetadata } from '#/session/sessionMetadata/sessionMetadata'; import { ISessionSkillCatalog } from '#/session/sessionSkillCatalog/skillCatalog'; import { ISessionIndex } from '#/app/sessionIndex/sessionIndex'; @@ -161,10 +163,40 @@ function tick(): Promise { return new Promise((resolve) => setTimeout(resolve, 0)); } +class NoopSessionExternalHooksService implements ISessionExternalHooksService { + declare readonly _serviceBrand: undefined; +} + +let recordedSessionHookEvents: string[] = []; + +class RecordingSessionExternalHooksService + extends Disposable + implements ISessionExternalHooksService +{ + declare readonly _serviceBrand: undefined; + + constructor(@ISessionLifecycleService lifecycle: ISessionLifecycleService) { + super(); + this._register( + lifecycle.hooks.onDidCreateSession.register('test', async (event, next) => { + recordedSessionHookEvents.push(`create:${event.source}:${event.sessionId}`); + await next(); + }), + ); + this._register( + lifecycle.hooks.onWillCloseSession.register('test', async (event, next) => { + recordedSessionHookEvents.push(`close:${event.reason}:${event.sessionId}`); + await next(); + }), + ); + } +} + describe('SessionLifecycleService', () => { let host: ScopedTestHost | undefined; beforeEach(() => { + recordedSessionHookEvents = []; _clearScopedRegistryForTests(); registerScopedService( LifecycleScope.App, @@ -173,6 +205,13 @@ describe('SessionLifecycleService', () => { InstantiationType.Delayed, 'sessionLifecycle', ); + registerScopedService( + LifecycleScope.Session, + ISessionExternalHooksService, + NoopSessionExternalHooksService, + InstantiationType.Eager, + 'externalHooks', + ); }); afterEach(() => { @@ -267,7 +306,23 @@ describe('SessionLifecycleService', () => { captured = e; }); const h = await svc.create({ sessionId: 's1', workDir: '/tmp/proj' }); - expect(captured).toMatchObject({ sessionId: 's1', handle: h }); + expect(captured).toMatchObject({ sessionId: 's1', handle: h, source: 'startup' }); + }); + + it('runs constructor-registered session lifecycle hooks before returning create and close', async () => { + registerScopedService( + LifecycleScope.Session, + ISessionExternalHooksService, + RecordingSessionExternalHooksService, + InstantiationType.Eager, + 'externalHooks', + ); + const svc = build(); + + await svc.create({ sessionId: 's1', workDir: '/tmp/proj' }); + await svc.close('s1'); + + expect(recordedSessionHookEvents).toEqual(['create:startup:s1', 'close:exit:s1']); }); it('waits for MCP initialization before create returns', async () => {