mirror of
https://github.com/MoonshotAI/kimi-code.git
synced 2026-08-25 08:34:39 +00:00
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:
parent
e2cecf6551
commit
226ced32d6
21 changed files with 690 additions and 81 deletions
|
|
@ -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 |
|
|
@ -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],
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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 =
|
||||
|
|
|
|||
|
|
@ -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',
|
||||
{
|
||||
|
|
|
|||
|
|
@ -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';
|
||||
|
||||
|
|
|
|||
|
|
@ -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';
|
||||
|
|
|
|||
|
|
@ -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[];
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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';
|
||||
|
|
|
|||
|
|
@ -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';
|
||||
|
|
|
|||
|
|
@ -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)) {
|
||||
|
|
|
|||
|
|
@ -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',
|
||||
);
|
||||
|
|
@ -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');
|
||||
|
|
@ -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',
|
||||
);
|
||||
|
|
@ -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';
|
||||
|
|
@ -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([
|
||||
{
|
||||
|
|
|
|||
|
|
@ -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: {
|
||||
|
|
|
|||
|
|
@ -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');
|
||||
},
|
||||
|
|
|
|||
|
|
@ -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 () => {
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue