refactor(agent-core-v2): observe subagent and session hooks via slots

Run SubagentStart/SubagentStop and SessionStart/SessionEnd external hooks by observing hook slots instead of being invoked directly, matching the other external-hook observers.

- Add Agent-scoped IAgentRunHooksService hosting onWillStartAgentTask / onDidStopAgentTask; mirrorAgentRun runs those slots rather than calling IAgentExternalHooksService directly.
- AgentExternalHooksService registers on those slots to emit SubagentStart/SubagentStop; drop runAgentTaskStart/notifyAgentTaskStop from its public interface.
- Add Session-scoped ISessionExternalHooksService. ISessionLifecycleService gains onDidCreateSession / onWillCloseSession slots announcing source/reason for create, resume, fork, close, archive.
- Move externalHooks from L5 to L6 in check-domain-layers.mjs, update the DI dependency map, and drop now-dead Hooks/OrderedHookSlot/@IAgentTurnService imports.
This commit is contained in:
_Kerman 2026-07-07 15:07:20 +08:00
parent e2cecf6551
commit 226ced32d6
21 changed files with 690 additions and 81 deletions

View file

@ -57,6 +57,7 @@ package "Session scope (per session)" #EAFAF1 {
rectangle "<b>workspaceCommand</b>\n<size:9><i>Session</i></size>\n ISessionWorkspaceCommandService" as workspaceCommand #D5F5E3
rectangle "<b>sessionLog</b>\n<size:9><i>Session binding</i></size>\n ILogService" as sessionLog #D5F5E3
rectangle "<b>sessionSkillCatalog</b>\n<size:9><i>Session</i></size>\n ISessionSkillCatalog\n ISkillCatalogSink\n Workspace/PluginSkillSource" as sessionSkillCatalog #D5F5E3
rectangle "<b>externalHooks</b>\n<size:9><i>Session</i></size>\n ISessionExternalHooksService" as sessionExternalHooks #D5F5E3
rectangle "<b>sessionFs</b>\n<size:9><i>Session</i></size>\n ISessionFsService" as sessionFs #D5F5E3
rectangle "<b>approval</b>\n<size:9><i>Session</i></size>\n IApprovalService" as approval #D5F5E3
rectangle "<b>question</b>\n<size:9><i>Session</i></size>\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

File diff suppressed because one or more lines are too long

Before

Width:  |  Height:  |  Size: 260 KiB

After

Width:  |  Height:  |  Size: 264 KiB

Before After
Before After

View file

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

View file

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

View file

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

View file

@ -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<void> {
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<void> {
private async runSubagentStart(ctx: AgentTaskStartHookContext): Promise<void> {
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',
{

View file

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

View file

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

View file

@ -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<SessionClosedEvent>;
readonly onDidArchiveSession: Event<SessionArchivedEvent>;
readonly onDidForkSession: Event<SessionForkedEvent>;
readonly hooks: Hooks<SessionLifecycleHooks>;
create(opts: CreateSessionOptions): Promise<ISessionScopeHandle>;
get(sessionId: string): ISessionScopeHandle | undefined;
list(): readonly ISessionScopeHandle[];

View file

@ -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<SessionArchivedEvent> = this._onDidArchiveSession.event;
private readonly _onDidForkSession = this._register(new Emitter<SessionForkedEvent>());
readonly onDidForkSession: Event<SessionForkedEvent> = this._onDidForkSession.event;
readonly hooks = createHooks<SessionLifecycleHooks, keyof SessionLifecycleHooks>([
'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<ISessionScopeHandle> {
const handle = await this.materializeSession(opts);
await this.announceCreated({ sessionId: opts.sessionId, handle, source: 'startup' });
return handle;
}
private async materializeSession(opts: CreateSessionOptions): Promise<ISessionScopeHandle> {
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<void> {
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<void> {
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<void> {
await this.hooks.onWillCloseSession.run(event);
}
async fork(opts: ForkSessionOptions): Promise<ISessionScopeHandle> {
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;
}

View file

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

View file

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

View file

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

View file

@ -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<AgentTaskHooks>;
}
export const IAgentRunHooksService: ServiceIdentifier<IAgentRunHooksService> =
createDecorator<IAgentRunHooksService>('agentRunHooksService');
export class AgentRunHooksService implements IAgentRunHooksService {
declare readonly _serviceBrand: undefined;
readonly hooks = createHooks<AgentTaskHooks, keyof AgentTaskHooks>([
'onWillStartAgentTask',
'onDidStopAgentTask',
]);
}
registerScopedService(
LifecycleScope.Agent,
IAgentRunHooksService,
AgentRunHooksService,
InstantiationType.Delayed,
'agentLifecycle',
);

View file

@ -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<ISessionExternalHooksService> =
createDecorator<ISessionExternalHooksService>('sessionExternalHooksService');

View file

@ -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<SessionCreateSource, 'fork'>;
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<void> => {
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<void> => {
try {
await loadDynamicHooks();
} catch {}
};
const hooksReady = loadDynamicHooksSafe();
const readyEngine = async (): Promise<HookEngine> => {
await hooksReady;
return dynamicEngine;
};
const triggerSessionStart = async (source: SessionStartHookSource): Promise<void> => {
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<void> => {
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',
);

View file

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

View file

@ -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<void> {
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<Record<string, unknown>> {
if (!existsSync(path)) return [];
return readFileSync(path, 'utf8')
.trim()
.split('\n')
.filter((line) => line.length > 0)
.map((line) => JSON.parse(line) as Record<string, unknown>);
}
function stubSessionLifecycle(): ISessionLifecycleService {
return {
_serviceBrand: undefined,
hooks: createHooks<SessionLifecycleHooks, keyof SessionLifecycleHooks>([
'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: <T = unknown>(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([
{

View file

@ -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<HookEngine, 'trigger' | 'triggerBlock' | 'fireAndForgetTrigger'> | 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: {

View file

@ -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<SessionLifecycleHooks, keyof SessionLifecycleHooks>([
'onDidCreateSession',
'onWillCloseSession',
]),
create: async () => {
throw new Error('create should not be called by session export');
},

View file

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