refactor(agent-core-v2): migrate service hooks to IEventBus events

- replace OrderedHookSlot-based hooks (turn onLaunched/onEnded, context onSpliced, permission approval request/resolve, task onDidNotify, fullCompaction onDidCompact, toolRegistry register/unregister) with IEventBus domain events
- add contextMemory named primitives (append/clear/undo/applyCompaction) on wire-protocol 1.4 ops, retaining splice for replay and rare single-deletes
- update externalHooks, goal, swarm, compaction, context-injector, micro-compaction, and task-notification to subscribe to the new events
- drop removed hook slots from the turn and task test stubs
This commit is contained in:
haozhe.yang 2026-07-06 20:17:46 +08:00
parent f54ebe0499
commit f851cc4776
20 changed files with 203 additions and 190 deletions

View file

@ -9,6 +9,7 @@ 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';
import type { ContextMessage } from '#/agent/contextMemory';
import {
IAgentContextInjectorService,
@ -34,6 +35,7 @@ export class AgentContextInjectorService extends Disposable implements IAgentCon
@IAgentTurnService turnService: IAgentTurnService,
@IAgentLoopService loopService: IAgentLoopService,
@IAgentSystemReminderService private readonly reminders: IAgentSystemReminderService,
@IEventBus private readonly eventBus: IEventBus,
) {
super();
this._register(
@ -43,17 +45,13 @@ export class AgentContextInjectorService extends Disposable implements IAgentCon
}),
);
this._register(
turnService.hooks.onLaunched.register('context-injector', (_ctx, next) => {
this.eventBus.subscribe('turn.started', () => {
for (const entry of this.entries) {
entry.turnConsumed = false;
}
return next();
}),
);
context.hooks.onSpliced.register('context-injector', (ctx, next) => {
this.handleSplice(ctx);
return next();
});
this.eventBus.subscribe('context.spliced', (e) => this.handleSplice(e));
}
register(

View file

@ -1,27 +1,47 @@
import { createDecorator } from "#/_base/di";
import type { Hooks } from '#/hooks';
import type { UndoCut } from './contextOps';
import type { ContextMessage } from './types';
export interface ContextCompactionInput {
readonly count: number;
readonly summary: ContextMessage;
readonly tokens?: number;
}
export interface IAgentContextMemoryService {
readonly _serviceBrand: undefined;
get(): readonly ContextMessage[];
/** Append one or more already-folded messages (`context.append_message`). */
append(...messages: readonly ContextMessage[]): void;
/** Drop the entire history (`context.clear`). No-op when already empty. */
clear(): void;
/**
* Remove the trailing `count` real-user prompts and the exchange that follows
* them (`context.undo`). Returns the computed cut so the caller can surface a
* `request.invalid` when fewer than `count` prompts were undoable; the model is
* left untouched in that case.
*/
undo(count: number): UndoCut;
/** Replace the leading `count` messages with a compaction summary (`context.apply_compaction`). */
applyCompaction(input: ContextCompactionInput): void;
/**
* Arbitrary splice (`context.splice`). Retained for replay of protocol 1.5
* sessions and the few internal single-delete mutations with no 1.4 spelling;
* new code should prefer the named primitives above.
*/
splice(
start: number,
deleteCount: number,
messages: readonly ContextMessage[],
tokens?: number,
): void;
readonly hooks: Hooks<{
onSpliced: {
start: number;
deleteCount: number;
messages: ContextMessage[];
tokens?: number;
};
}>;
}
export const IAgentContextMemoryService = createDecorator<IAgentContextMemoryService>('agentContextMemoryService');

View file

@ -2,27 +2,35 @@
* `contextMemory` domain (L4) `IAgentContextMemoryService` implementation.
*
* Owns the per-agent conversation history in the wire `ContextModel`
* (`ContextMessage[]`): reads through `wire.getModel`, writes through
* `wire.dispatch(contextSplice(...))` (splice is the single primitive). The
* `context.splice` record still rides the shared wire log read by `getRecords()`
* and replayed into the Model, so its shape stays declared in `WireRecordMap`;
* blob offload now lives in the `WireService` hook (seeded with
* `contextBlobSelector`) rather than a `record.define(..., { blobs })` facet.
* Message ids are stamped at the dispatch call site so `apply` stays pure.
* `onSpliced` fires from the live `splice` path only replay rebuilds the Model
* silently and never invokes the service method, so the hook is quiet on
* restore. The legacy replay read model (`IAgentRecordService`) is no longer
* mirrored here. Bound at Agent scope.
* (`ContextMessage[]`): reads through `wire.getModel`, writes through the
* wire-protocol 1.4 Ops (`append` / `clear` / `undo` / `applyCompaction`), with
* `splice` retained for protocol 1.5 replay and the rare internal single-delete.
* Every mutation still fires `onSpliced` from the live path only (replay rebuilds
* the Model silently and never invokes these methods), so existing subscribers
* (micro-compaction, context-injector, task-notification) observe the same
* splice-shaped change events regardless of which 1.4 Op was persisted. Message
* ids are stamped at the dispatch call site so `apply` stays pure. Blob offload
* lives in the `WireService` hook seeded with `contextBlobSelector`. Bound at
* Agent scope.
*/
import { Disposable } from '#/_base/di';
import { InstantiationType } from '#/_base/di/extensions';
import { LifecycleScope, registerScopedService } from '#/_base/di/scope';
import { OrderedHookSlot } from '#/hooks';
import { IEventBus } from '#/app/event';
import { IAgentWireService, type IWireService } from '#/wire';
import { IAgentContextMemoryService } from './contextMemory';
import { ContextModel, contextSplice } from './contextOps';
import { IAgentContextMemoryService, type ContextCompactionInput } from './contextMemory';
import {
computeUndoCut,
ContextModel,
contextAppendMessage,
contextApplyCompaction,
contextClear,
contextSplice,
contextUndo,
type UndoCut,
} from './contextOps';
import { ensureMessageId } from './messageId';
import type { ContextMessage } from './types';
@ -37,19 +45,24 @@ declare module '#/agent/wireRecord' {
}
}
declare module '#/app/event/eventBus' {
interface DomainEventMap {
'context.spliced': {
start: number;
deleteCount: number;
messages: readonly ContextMessage[];
tokens?: number;
};
}
}
export class AgentContextMemoryService extends Disposable implements IAgentContextMemoryService {
declare readonly _serviceBrand: undefined;
readonly hooks = {
onSpliced: new OrderedHookSlot<{
start: number;
deleteCount: number;
messages: ContextMessage[];
tokens?: number;
}>(),
};
constructor(@IAgentWireService private readonly wire: IWireService) {
constructor(
@IAgentWireService private readonly wire: IWireService,
@IEventBus private readonly eventBus: IEventBus,
) {
super();
}
@ -57,6 +70,50 @@ export class AgentContextMemoryService extends Disposable implements IAgentConte
return this.wire.getModel(ContextModel) as readonly ContextMessage[];
}
append(...messages: readonly ContextMessage[]): void {
if (messages.length === 0) return;
const stamped = messages.map(ensureMessageId);
const start = this.get().length;
this.wire.dispatch(...stamped.map((message) => contextAppendMessage({ message })));
this.eventBus.publish({ type: 'context.spliced',
start,
deleteCount: 0,
messages: [...stamped],
});
}
clear(): void {
const deleteCount = this.get().length;
if (deleteCount === 0) return;
this.wire.dispatch(contextClear({}));
this.eventBus.publish({ type: 'context.spliced', start: 0, deleteCount, messages: [] });
}
undo(count: number): UndoCut {
const history = this.get();
const cut = computeUndoCut(history, count);
if (cut.cutIndex >= 0 && cut.removedCount >= count) {
this.wire.dispatch(contextUndo({ count }));
this.eventBus.publish({ type: 'context.spliced',
start: cut.cutIndex,
deleteCount: history.length - cut.cutIndex,
messages: [],
});
}
return cut;
}
applyCompaction(input: ContextCompactionInput): void {
const summary = ensureMessageId(input.summary);
this.wire.dispatch(contextApplyCompaction({ count: input.count, summary }));
this.eventBus.publish({ type: 'context.spliced',
start: 0,
deleteCount: input.count,
messages: [summary],
tokens: input.tokens,
});
}
splice(
start: number,
deleteCount: number,
@ -65,7 +122,7 @@ export class AgentContextMemoryService extends Disposable implements IAgentConte
): void {
const stamped = messages.map(ensureMessageId);
this.wire.dispatch(contextSplice({ start, deleteCount, messages: stamped, tokens }));
void this.hooks.onSpliced.run({
this.eventBus.publish({ type: 'context.spliced',
start,
deleteCount,
messages: [...stamped],

View file

@ -22,9 +22,9 @@ import { IAgentTaskService, type AgentTaskNotificationContext } from '#/agent/ta
import { IAgentContextMemoryService, USER_PROMPT_ORIGIN } from '#/agent/contextMemory';
import {
IAgentFullCompactionService,
type FullCompactionDidCompactContext,
type FullCompactionWillCompactContext,
} from '#/agent/fullCompaction';
import type { CompactionResult, CompactionSource } from '#/agent/fullCompaction/types';
import { IAgentLoopService, type TurnAfterStepContext } from '#/agent/loop';
import {
IAgentPermissionGate,
@ -33,7 +33,7 @@ import {
IAgentPromptService,
type PromptSubmitContext,
} from '#/agent/prompt';
import type { HookResultEvent } from '@moonshot-ai/protocol';
import type { HookResultEvent, TurnEndReason } from '@moonshot-ai/protocol';
import { IEventBus } from '#/app/event';
import type {
ExecutableToolResult,
@ -43,7 +43,6 @@ import type {
import { IAgentToolExecutorService } from '#/agent/toolExecutor';
import {
IAgentTurnService,
type TurnEndedContext,
} from '#/agent/turn';
import { IBootstrapService } from '#/app/bootstrap';
import { IConfigService } from '#/app/config';
@ -169,21 +168,21 @@ export class AgentExternalHooksService extends Disposable implements IAgentExter
private registerPermissionHooks(permission: IAgentPermissionGate): void {
this._register(
permission.hooks.onDidRequestApproval.register('externalHooks', async (ctx, next) => {
this.eventBus.subscribe('permission.approval.requested', (e) => {
const { type: _type, ...inputData } = e;
void this.engine()?.fireAndForgetTrigger('PermissionRequest', {
matcherValue: ctx.toolName,
inputData: { ...ctx },
matcherValue: e.toolName,
inputData,
});
await next();
}),
);
this._register(
permission.hooks.onDidResolveApproval.register('externalHooks', async (ctx, next) => {
this.eventBus.subscribe('permission.approval.resolved', (e) => {
const { type: _type, ...inputData } = e;
void this.engine()?.fireAndForgetTrigger('PermissionResult', {
matcherValue: ctx.toolName,
inputData: { ...ctx },
matcherValue: e.toolName,
inputData,
});
await next();
}),
);
}
@ -202,10 +201,7 @@ export class AgentExternalHooksService extends Disposable implements IAgentExter
private registerTurnHooks(turn: IAgentTurnService): void {
this._register(
turn.hooks.onEnded.register('externalHooks', async (ctx, next) => {
this.notifyTurnEnded(ctx);
await next();
}),
this.eventBus.subscribe('turn.ended', (e) => this.notifyTurnEnded(e)),
);
}
@ -244,18 +240,15 @@ export class AgentExternalHooksService extends Disposable implements IAgentExter
}),
);
this._register(
fullCompaction.hooks.onDidCompact.register('externalHooks', async (ctx, next) => {
this.notifyPostCompact(ctx);
await next();
}),
this.eventBus.subscribe('compaction.completed', (e) => this.notifyPostCompact(e)),
);
}
private registerTaskHooks(tasks: IAgentTaskService): void {
this._register(
tasks.hooks.onDidNotify.register('externalHooks', async (ctx, next) => {
this.eventBus.subscribe('task.notified', (e) => {
const { type: _type, ...ctx } = e;
this.notifyTaskNotification(ctx);
await next();
}),
);
}
@ -352,17 +345,18 @@ export class AgentExternalHooksService extends Disposable implements IAgentExter
return false;
}
private notifyTurnEnded(ctx: TurnEndedContext): void {
private notifyTurnEnded(event: {
turnId: number;
reason: TurnEndReason;
error?: unknown;
}): void {
this.stopHookContinuationUsed = false;
if (ctx.result.reason === 'failed' && ctx.result.error !== undefined) {
this.notifyStopFailure(ctx.result.error, ctx.turn.abortController.signal);
if (event.reason === 'failed' && event.error !== undefined) {
this.notifyStopFailure(event.error, new AbortController().signal);
}
if (
ctx.result.reason === 'cancelled' &&
isUserCancellation(ctx.turn.abortController.signal.reason)
) {
if (event.reason === 'cancelled') {
void this.engine()?.fireAndForgetTrigger('Interrupt', {
inputData: { turnId: ctx.turn.id, reason: 'cancelled' },
inputData: { turnId: event.turnId, reason: 'cancelled' },
});
}
}
@ -406,12 +400,12 @@ export class AgentExternalHooksService extends Disposable implements IAgentExter
ctx.signal.throwIfAborted();
}
private notifyPostCompact(ctx: FullCompactionDidCompactContext): void {
private notifyPostCompact(event: { trigger: CompactionSource; result: CompactionResult }): void {
void this.engine()?.fireAndForgetTrigger('PostCompact', {
matcherValue: ctx.trigger,
matcherValue: event.trigger,
inputData: {
trigger: ctx.trigger,
estimatedTokenCount: ctx.estimatedTokenCount,
trigger: event.trigger,
estimatedTokenCount: event.result.tokensAfter,
},
});
}

View file

@ -47,7 +47,7 @@ import type {
import { defineModel, defineOp } from '#/wire';
import type { FullCompactionCompleteData } from './fullCompaction';
import type { CompactionBeginData } from './types';
import type { CompactionBeginData, CompactionSource } from './types';
export type CompactionPhase = 'idle' | 'running' | 'cancelled' | 'completed';
@ -64,7 +64,7 @@ declare module '#/app/event/eventBus' {
'compaction.started': Omit<CompactionStartedEvent, 'type'>;
'compaction.blocked': Omit<CompactionBlockedEvent, 'type'>;
'compaction.cancelled': Omit<CompactionCancelledEvent, 'type'>;
'compaction.completed': Omit<CompactionCompletedEvent, 'type'>;
'compaction.completed': Omit<CompactionCompletedEvent, 'type'> & { readonly trigger: CompactionSource };
}
}

View file

@ -32,7 +32,6 @@ export interface IAgentFullCompactionService {
readonly hooks: Hooks<{
onWillCompact: FullCompactionWillCompactContext;
onDidCompact: FullCompactionDidCompactContext;
}>;
}

View file

@ -102,7 +102,6 @@ export class AgentFullCompactionService extends Disposable implements IAgentFull
declare readonly _serviceBrand: undefined;
readonly hooks: IAgentFullCompactionService['hooks'] = {
onWillCompact: new OrderedHookSlot<FullCompactionWillCompactContext>(),
onDidCompact: new OrderedHookSlot<FullCompactionDidCompactContext>(),
};
private readonly strategy: CompactionStrategy;
@ -138,10 +137,7 @@ export class AgentFullCompactionService extends Disposable implements IAgentFull
new RuntimeCompactionStrategy(() => this.profile.resolveModelContext());
this._register(this.wire.onRestored(() => this.normalizeAfterReplay()));
this._register(
turnService.hooks.onLaunched.register('full-compaction-reset', async (_ctx, next) => {
this.resetForTurn();
await next();
}),
this.eventBus.subscribe('turn.started', () => this.resetForTurn()),
);
this._register(
loopService.hooks.beforeStep.register('full-compaction', async (ctx, next) => {
@ -342,11 +338,7 @@ export class AgentFullCompactionService extends Disposable implements IAgentFull
if (this.compacting !== active) return;
this.lastCompactedTokenCount = finalResult.tokensAfter;
this.markCompleted(completeData(finalResult));
this.eventBus.publish({ type: 'compaction.completed', result: finalResult });
void this.hooks.onDidCompact.run({
trigger: data.source,
estimatedTokenCount: finalResult.tokensAfter,
}).catch(() => undefined);
this.eventBus.publish({ type: 'compaction.completed', result: finalResult, trigger: data.source });
} catch (error) {
if (isAbortError(error)) return;
const blockedByTurn = this.compacting === active && active.blockedByTurn;

View file

@ -42,7 +42,7 @@ import {
type TurnBeforeStepContext,
} from '#/agent/loop';
import { IAgentSystemReminderService } from '#/agent/systemReminder';
import { IAgentTurnService, type Turn, type TurnEndedContext } from '#/agent/turn';
import { IAgentTurnService, type TurnResult } from '#/agent/turn';
import type { TokenUsage } from '#/app/llmProtocol';
import type { TelemetryProperties } from '#/app/telemetry';
import { ITelemetryService } from '#/app/telemetry';
@ -161,10 +161,7 @@ export class AgentGoalService extends Disposable implements IAgentGoalService {
// fork clear handled by wire forkGoal op; reminder intentionally dropped (reversible).
this._register(this.wire.onRestored(() => this.normalizeAfterReplay()));
this._register(
turnService.hooks.onLaunched.register('goal-track-launched-turn', (ctx, next) => {
this.handleTurnLaunched(ctx.turn);
return next();
}),
this.eventBus.subscribe('turn.started', (e) => this.handleTurnLaunched(e.turnId)),
);
this._register(
loopService.hooks.beforeStep.register('goal-count-turn', async (ctx, next) => {
@ -179,9 +176,10 @@ export class AgentGoalService extends Disposable implements IAgentGoalService {
}),
);
this._register(
turnService.hooks.onEnded.register('goal-drive-continuation', async (ctx, next) => {
await next();
await this.handleTurnEnded(ctx);
this.eventBus.subscribe('turn.ended', (e) => {
void this.handleTurnEnded(e.turnId, { reason: e.reason, error: e.error }).catch(
() => undefined,
);
}),
);
}
@ -379,8 +377,7 @@ export class AgentGoalService extends Disposable implements IAgentGoalService {
return this.blockIfBudgetReached(next) ?? this.toSnapshot(next);
}
private handleTurnLaunched(turn: Turn): void {
const turnId = turn.id;
private handleTurnLaunched(turnId: number): void {
if (this.goalState?.status === 'active') this.goalDrivenTurns.add(turnId);
this.goalOutcomeContinuationTurns.delete(turnId);
}
@ -409,23 +406,25 @@ export class AgentGoalService extends Disposable implements IAgentGoalService {
ctx.continue = true;
}
private async handleTurnEnded(ctx: TurnEndedContext): Promise<void> {
const turnId = ctx.turn.id;
private async handleTurnEnded(
turnId: number,
result: { reason: TurnResult['reason']; error?: TurnResult['error'] },
): Promise<void> {
this.goalDrivenTurns.delete(turnId);
this.countedGoalTurns.delete(turnId);
this.goalOutcomeContinuationTurns.delete(turnId);
if (ctx.result.reason === 'blocked') {
if (result.reason === 'blocked') {
await this.markBlocked({ reason: 'Blocked by UserPromptSubmit hook' });
return;
}
if (ctx.result.reason === 'cancelled') {
if (result.reason === 'cancelled') {
await this.pauseOnInterrupt({ reason: 'Paused after interruption' });
return;
}
if (ctx.result.reason === 'failed') {
await this.pauseActiveGoal({ reason: goalFailurePauseReason(ctx.result.error) });
if (result.reason === 'failed') {
await this.pauseActiveGoal({ reason: goalFailurePauseReason(result.error) });
return;
}

View file

@ -28,6 +28,7 @@ import {
} from "#/_base/utils/tokens";
import type { TelemetryProperties } from '#/app/telemetry';
import { IConfigService } from '#/app/config';
import { IEventBus } from '#/app/event';
import { IAgentContextMemoryService } from '#/agent/contextMemory';
import { IAgentContextSizeService } from '#/agent/contextSize';
import { IFlagService } from '#/app/flag';
@ -72,6 +73,7 @@ export class AgentMicroCompactionService
@ITelemetryService private readonly telemetry: ITelemetryService,
@IAgentLoopService loop: IAgentLoopService,
@IConfigService private readonly config: IConfigService,
@IEventBus private readonly eventBus: IEventBus,
) {
super();
this.microConfig = this.readConfig();
@ -92,10 +94,7 @@ export class AgentMicroCompactionService
),
);
this._register(
this.context.hooks.onSpliced.register('micro-compaction', async (ctx, next) => {
this.observeSplice(ctx);
await next();
}),
this.eventBus.subscribe('context.spliced', (e) => this.observeSplice(e)),
);
}

View file

@ -37,11 +37,6 @@ export interface IAgentPermissionGate {
authorize(
context: ResolvedToolExecutionHookContext,
): Promise<AuthorizeToolExecutionResult | undefined>;
readonly hooks: Hooks<{
onDidRequestApproval: PermissionApprovalRequestContext;
onDidResolveApproval: PermissionApprovalResultContext;
}>;
}
export const IAgentPermissionGate =

View file

@ -29,15 +29,17 @@ import {
} from './permissionGate';
import { InstantiationType } from '#/_base/di/extensions';
import { LifecycleScope, registerScopedService } from '#/_base/di/scope';
import { OrderedHookSlot } from '#/hooks';
import { IEventBus } from '#/app/event';
declare module '#/app/event/eventBus' {
interface DomainEventMap {
'permission.approval.requested': PermissionApprovalRequestContext;
'permission.approval.resolved': PermissionApprovalResultContext;
}
}
export class AgentPermissionGate extends Disposable implements IAgentPermissionGate {
declare readonly _serviceBrand: undefined;
readonly hooks: IAgentPermissionGate['hooks'] = {
onDidRequestApproval: new OrderedHookSlot<PermissionApprovalRequestContext>(),
onDidResolveApproval: new OrderedHookSlot<PermissionApprovalResultContext>(),
};
constructor(
private readonly options: PermissionGateOptions = {},
@IAgentPermissionModeService private readonly modeService: IAgentPermissionModeService,
@ -46,6 +48,7 @@ export class AgentPermissionGate extends Disposable implements IAgentPermissionG
@ISessionContext private readonly session: ISessionContext,
@IInstantiationService private readonly instantiation: IInstantiationService,
@ITelemetryService private readonly telemetry: ITelemetryService,
@IEventBus private readonly eventBus: IEventBus,
@IAgentToolExecutorService toolExecutor: IAgentToolExecutorService,
) {
super();
@ -147,7 +150,7 @@ export class AgentPermissionGate extends Disposable implements IAgentPermissionG
if (approvalService === undefined) {
response = { decision: 'approved' };
} else {
void this.hooks.onDidRequestApproval.run(approvalContext).catch(() => undefined);
this.eventBus.publish({ type: 'permission.approval.requested', ...approvalContext });
try {
response = await abortable(
approvalService.request(approvalRequest),
@ -166,11 +169,12 @@ export class AgentPermissionGate extends Disposable implements IAgentPermissionG
session_cache_written: false,
has_feedback: false,
});
void this.hooks.onDidResolveApproval.run({
this.eventBus.publish({
type: 'permission.approval.resolved',
...approvalContext,
decision: 'error',
error: error instanceof Error ? error.message : String(error),
}).catch(() => undefined);
});
const resolved = result.resolveError?.(error);
if (resolved !== undefined) {
return this.permissionPolicyResolutionToAuthorize(resolved, context, policyName);
@ -184,10 +188,11 @@ export class AgentPermissionGate extends Disposable implements IAgentPermissionG
? context.execution.approvalRule
: undefined;
if (approvalService !== undefined) {
void this.hooks.onDidResolveApproval.run({
this.eventBus.publish({
type: 'permission.approval.resolved',
...approvalContext,
...response,
}).catch(() => undefined);
});
}
this.rulesService.recordApprovalResult({
turnId: context.turnId,

View file

@ -17,6 +17,7 @@ import { InstantiationType } from '#/_base/di/extensions';
import { LifecycleScope, registerScopedService } from '#/_base/di/scope';
import { IAgentSystemReminderService } from '#/agent/systemReminder';
import { IAgentTurnService } from '#/agent/turn';
import { IEventBus } from '#/app/event';
import { IAgentWireService, type IWireService } from '#/wire';
import SWARM_MODE_ENTER_REMINDER from './enter-reminder.md?raw';
import SWARM_MODE_EXIT_REMINDER from './exit-reminder.md?raw';
@ -30,15 +31,14 @@ export class AgentSwarmService extends Disposable implements IAgentSwarmService
@IAgentWireService private readonly wire: IWireService,
@IAgentSystemReminderService private readonly reminders: IAgentSystemReminderService,
@IAgentTurnService turnService: IAgentTurnService,
@IEventBus private readonly eventBus: IEventBus,
) {
super();
this._register(
turnService.hooks.onEnded.register('swarm-mode-auto-exit', (_ctx, next) => {
const done = next();
this.eventBus.subscribe('turn.ended', () => {
if (this.shouldAutoExit) {
this.exit();
}
return done;
}),
);
}

View file

@ -118,10 +118,6 @@ export interface IAgentTaskService {
waitForForegroundRelease(
taskId: string,
): Promise<ForegroundTaskReleaseReason | undefined>;
readonly hooks: Hooks<{
onDidNotify: AgentTaskNotificationContext;
}>;
}
export const IAgentTaskService =

View file

@ -19,6 +19,7 @@ import type { ContentPart } from '#/app/llmProtocol';
import { Disposable } from '#/_base/di';
import { escapeXml, escapeXmlAttr } from '#/_base/utils/xml-escape';
import { IEventBus } from '#/app/event';
import type { TaskOrigin } from '#/agent/contextMemory';
import { ITaskService, type ITaskHandle, TERMINAL_TASK_STATES } from '#/app/task';
import {
@ -55,7 +56,6 @@ import { TaskModel, taskStarted, taskTerminated } from './taskOps';
import { TaskListTool } from '#/agent/task/tools/task-list';
import { TaskOutputTool } from '#/agent/task/tools/task-output';
import { TaskStopTool } from '#/agent/task/tools/task-stop';
import { OrderedHookSlot } from '#/hooks';
interface ForegroundRelease {
readonly promise: Promise<ForegroundTaskReleaseReason>;
@ -123,11 +123,14 @@ export function isAgentTaskTerminal(status: AgentTaskStatus): boolean {
return TERMINAL_STATUSES.has(status);
}
declare module '#/app/event/eventBus' {
interface DomainEventMap {
'task.notified': AgentTaskNotificationContext;
}
}
export class AgentTaskService extends Disposable implements IAgentTaskService {
declare readonly _serviceBrand: undefined;
readonly hooks: IAgentTaskService['hooks'] = {
onDidNotify: new OrderedHookSlot<AgentTaskNotificationContext>(),
};
private readonly tasks = new Map<string, ManagedTask>();
private readonly ghosts = new Map<string, AgentTaskInfo>();
@ -145,6 +148,7 @@ export class AgentTaskService extends Disposable implements IAgentTaskService {
@ISessionContext session: ISessionContext,
@ITaskService private readonly taskService: ITaskService,
@IAgentWireService private readonly wire: IWireService,
@IEventBus private readonly eventBus: IEventBus,
) {
super();
this.persistence = new AgentTaskPersistence(
@ -155,9 +159,8 @@ export class AgentTaskService extends Disposable implements IAgentTaskService {
);
this._register(this.wire.onRestored(() => this.restoreAfterReplay()));
this._register(
context.hooks.onSpliced.register('task-notification-delivery', async (ctx, next) => {
await next();
for (const message of ctx.messages) {
this.eventBus.subscribe('context.spliced', (e) => {
for (const message of e.messages) {
if (isTaskOrigin(message.origin)) {
this.markDeliveredNotification(message.origin);
}
@ -865,14 +868,15 @@ export class AgentTaskService extends Disposable implements IAgentTaskService {
}
private fireNotificationHook(notification: AgentTaskNotification): void {
void this.hooks.onDidNotify.run({
this.eventBus.publish({
type: 'task.notified',
notificationType: notification.type,
title: notification.title,
body: notification.body,
severity: notification.severity,
sourceKind: notification.source_kind,
sourceId: notification.source_id,
}).catch(() => undefined);
});
}
private isTerminalNotificationSuppressed(taskId: string): boolean {

View file

@ -22,11 +22,6 @@ export interface IAgentToolRegistryService {
register(tool: ExecutableTool, options?: ToolRegistrationOptions): IDisposable;
list(): readonly ToolInfo[];
resolve(name: string): ExecutableTool | undefined;
readonly hooks: Hooks<{
onRegistered: { tool: ExecutableTool };
onUnregistered: { tool: ExecutableTool };
}>;
}
export const IAgentToolRegistryService = createDecorator<IAgentToolRegistryService>('agentToolRegistryService');

View file

@ -18,11 +18,6 @@ export class AgentToolRegistryService extends Disposable implements IAgentToolRe
declare readonly _serviceBrand: undefined;
private readonly tools = new Map<string, ToolEntry>();
readonly hooks = {
onRegistered: new OrderedHookSlot<{ tool: ExecutableTool }>(),
onUnregistered: new OrderedHookSlot<{ tool: ExecutableTool }>(),
};
constructor() {
super();
}
@ -33,8 +28,6 @@ export class AgentToolRegistryService extends Disposable implements IAgentToolRe
this.unregisterTool(tool.name);
this.tools.set(tool.name, entry);
void this.hooks.onRegistered.run({ tool: entry.tool });
return toDisposable(() => {
const current = this.tools.get(tool.name);
if (current !== entry) return;
@ -61,7 +54,6 @@ export class AgentToolRegistryService extends Disposable implements IAgentToolRe
const entry = this.tools.get(name);
if (entry === undefined) return undefined;
this.tools.delete(name);
void this.hooks.onUnregistered.run({ tool: entry.tool });
return entry;
}
}

View file

@ -15,21 +15,11 @@ export interface Turn {
readonly result: Promise<TurnResult>;
}
export interface TurnEndedContext {
readonly turn: Turn;
readonly result: TurnResult;
}
export interface IAgentTurnService {
readonly _serviceBrand: undefined;
launch(): Turn;
getActiveTurn(): Turn | undefined;
readonly hooks: Hooks<{
onLaunched: { turn: Turn };
onEnded: TurnEndedContext;
}>;
}
export const IAgentTurnService = createDecorator<IAgentTurnService>('agentTurnService');

View file

@ -25,7 +25,7 @@ import { IAgentLoopService } from '#/agent/loop';
import { IEventBus } from '#/app/event';
import { IAgentTelemetryContextService, ITelemetryService } from '#/app/telemetry';
import { IAgentWireService, type IWireService } from '#/wire';
import type { Turn, TurnEndedContext, TurnResult } from './turn';
import type { Turn, TurnResult } from './turn';
import { IAgentTurnService } from './turn';
import { promptTurn, TurnModel } from './turnOps';
@ -42,11 +42,6 @@ export class AgentTurnService implements IAgentTurnService {
declare readonly _serviceBrand: undefined;
private activeTurn: Turn | undefined;
readonly hooks = {
onLaunched: new OrderedHookSlot<{ turn: Turn }>(),
onEnded: new OrderedHookSlot<TurnEndedContext>(),
};
constructor(
@IAgentLoopService private readonly loop: IAgentLoopService,
@IAgentWireService private readonly wire: IWireService,
@ -77,7 +72,6 @@ export class AgentTurnService implements IAgentTurnService {
void ready.catch(() => undefined);
this.activeTurn = turn;
turn.result = this.runTurn(turn, ready);
void this.hooks.onLaunched.run({ turn });
return turn;
}
@ -128,9 +122,8 @@ export class AgentTurnService implements IAgentTurnService {
turnTelemetry.track('turn_interrupted', { at_step: result.steps ?? null });
}
}
if (result !== undefined) {
await this.hooks.onEnded.run({ turn, result });
}
// `turn.ended` is published to `IEventBus` above; subscribers (swarm /
// goal / externalHooks) react there — no hook slot to run here.
}
}
}

View file

@ -38,7 +38,6 @@ import { type ISessionContext, makeSessionContext } from '#/session/sessionConte
import type { IProcess, ISessionProcessRunner } from '#/session/process';
import { type BashInput, BashInputSchema, BashTool } from '#/os/backends/node-local/tools/bash';
import type { ExecutableToolContext, ExecutableToolResult, ToolExecution } from '#/agent/tool';
import { createHooks } from '#/hooks';
const posixEnv: IHostEnvironment = {
_serviceBrand: undefined,
@ -451,8 +450,6 @@ function createFakeTaskService(options: { maxRunningTasks?: number } = {}): {
const service: IAgentTaskService = {
_serviceBrand: undefined,
hooks: createHooks(['onDidNotify']) as IAgentTaskService['hooks'],
track(): never {
throw new Error('fake IAgentTaskService.track is not implemented');
},

View file

@ -39,13 +39,6 @@ function makeTurn(id: number): Turn {
};
}
function makeHooks(): IAgentTurnService['hooks'] {
return createHooks([
'onLaunched',
'onEnded',
]) as IAgentTurnService['hooks'];
}
function makeAgentLoopHookSlots(): IAgentLoopService['hooks'] {
return createHooks([
'beforeStep',
@ -61,7 +54,6 @@ export function stubTurn(options: StubTurnOptions = {}): StubTurn {
let nextId = typeof options.currentId === 'number' ? options.currentId : 0;
return {
_serviceBrand: undefined,
hooks: makeHooks(),
launch() {
const turn = makeTurn(nextId++);
launches.push(turn.id);
@ -84,13 +76,9 @@ export function stubTurn(options: StubTurnOptions = {}): StubTurn {
* returns a minimal {@link Turn}; `getActiveTurn` is a no-op.
*/
export function stubTurnWithHooks(): IAgentTurnService {
const turn = makeTurn(0);
return {
_serviceBrand: undefined,
hooks: makeHooks(),
launch: () => turn,
getActiveTurn: () => undefined,
};
// Turn-lifecycle hooks moved to `IEventBus`; no service registers turn hooks
// anymore, so this is now equivalent to `stubTurn()`.
return stubTurn();
}
/** An `IAgentLoopService` stub backed by real loop lifecycle hook slots. */