From dd5d28673403fa3cad97d49b73188e4712a3d2b7 Mon Sep 17 00:00:00 2001 From: 7Sageer <7sageer@djwcb.cn> Date: Wed, 5 Aug 2026 11:40:51 +0800 Subject: [PATCH 01/27] refactor(agent-core-v2): unify model-facing reminder scheduling Route every model-facing reminder through the contextInjector boundary scheduler. Past-tense events go through a persisted once-reminder queue (reminderQueue) that delivers exactly once at turn, step, compaction, and restore boundaries; present-tense state renders through context-injection providers reconciled against live history. - interruption, goal (cancel/budget/fork-cleared), image-compression captions, btw, and init reminders enqueue into reminderQueue instead of writing the context directly; the interruptionReminder wire model is removed and its recorded type is retired silently on replay - swarm mode announcements render through a provider seeded from the replayed history on restore, replacing live side effects and the ContextModel pop reducer on swarm_mode.exit - loadable-tools announcements become an isNewTurn-gated provider, dropping the compaction boundary flag - plugin session-start guidance re-renders as a supersedes reminder at the next boundary via a dirty flag instead of appending immediately - legacy system_trigger origins of migrated reminders still fold on replay --- .../agent-core-v2/docs/state-manifest.d.ts | 11 +- .../agent-core-v2/docs/wire-manifest.d.ts | 138 ++++++------ .../contextInjector/contextInjectorService.ts | 22 +- .../src/agent/contextMemory/contextOps.ts | 17 +- .../src/agent/goal/goalService.ts | 32 +-- .../interruptionReminderOps.ts | 43 ---- .../interruptionReminderService.ts | 74 +------ .../agent-core-v2/src/agent/loop/turnOps.ts | 3 +- .../src/agent/plugin/agentPluginService.ts | 74 ++++--- .../src/agent/prompt/promptService.ts | 17 +- .../src/agent/prompt/promptStepRequests.ts | 22 +- .../src/agent/reminderQueue/reminderQueue.ts | 38 ++++ .../agent/reminderQueue/reminderQueueOps.ts | 53 +++++ .../reminderQueue/reminderQueueService.ts | 96 +++++++++ .../agent/swarm/injection/swarmInjection.ts | 115 ++++++++++ .../src/agent/swarm/swarmService.ts | 56 ++--- .../agent/systemReminder/systemReminder.ts | 8 + .../src/agent/toolSelect/dynamicTools.ts | 17 +- .../toolSelect/toolSelectAnnouncements.ts | 4 +- .../toolSelectAnnouncementsService.ts | 64 ++---- packages/agent-core-v2/src/index.ts | 5 +- .../src/session/btw/btwService.ts | 12 +- .../session/sessionInit/sessionInitService.ts | 9 +- .../agent-core-v2/src/wire/wireService.ts | 11 +- .../contextInjector/contextInjector.test.ts | 25 +++ .../test/agent/contextMemory/context.test.ts | 6 +- .../test/agent/goal/goal.test.ts | 37 +++- .../test/agent/goal/goalOps.test.ts | 11 +- .../test/agent/loop/loop.test.ts | 100 ++++++--- .../test/agent/plugin/agentPlugin.test.ts | 26 +-- .../test/agent/prompt/promptService.test.ts | 66 +++++- .../agent/reminderQueue/reminderQueue.test.ts | 202 ++++++++++++++++++ .../test/agent/swarm/swarm.test.ts | 123 ++++++++++- .../agent/toolSelect/dynamicTools.test.ts | 4 +- .../toolSelect/toolSelectService.test.ts | 55 +++-- packages/agent-core-v2/test/index.test.ts | 12 +- .../test/session/btw/btw.test.ts | 14 +- .../session/sessionInit/sessionInit.test.ts | 20 +- .../agent-core-v2/test/wire/resume.test.ts | 13 +- .../test/wire/wireService.test.ts | 22 ++ 40 files changed, 1205 insertions(+), 472 deletions(-) delete mode 100644 packages/agent-core-v2/src/agent/interruptionReminder/interruptionReminderOps.ts create mode 100644 packages/agent-core-v2/src/agent/reminderQueue/reminderQueue.ts create mode 100644 packages/agent-core-v2/src/agent/reminderQueue/reminderQueueOps.ts create mode 100644 packages/agent-core-v2/src/agent/reminderQueue/reminderQueueService.ts create mode 100644 packages/agent-core-v2/src/agent/swarm/injection/swarmInjection.ts create mode 100644 packages/agent-core-v2/test/agent/reminderQueue/reminderQueue.test.ts diff --git a/packages/agent-core-v2/docs/state-manifest.d.ts b/packages/agent-core-v2/docs/state-manifest.d.ts index 63f921002a0..5152d1292f3 100644 --- a/packages/agent-core-v2/docs/state-manifest.d.ts +++ b/packages/agent-core-v2/docs/state-manifest.d.ts @@ -23,7 +23,7 @@ // references become '(circular)', and class instances collapse to a '(ClassName)' // marker — the wire shape of an entry is the JSON projection of the type here. // -// Index (App: 0 keys · Workspace: 6 keys · Session: 18 keys · Agent: 69 keys) +// Index (App: 0 keys · Workspace: 6 keys · Session: 18 keys · Agent: 70 keys) // App // Workspace // workspaceDirs.ephemeralDirs src/workspace/workspaceDirs/workspaceDirsService.ts @@ -57,6 +57,7 @@ // activityView.lastTurn src/agent/activityView/activityViewService.ts // activityView.lifecycle src/agent/activityView/activityViewService.ts // activityView.turn src/agent/activityView/activityViewService.ts +// agentPlugin.sessionStartDirty src/agent/plugin/agentPluginService.ts // agentsMdReminder.cwd src/agent/agentsMdReminder/agentsMdReminderService.ts // agentsMdReminder.known src/agent/agentsMdReminder/agentsMdReminderService.ts // agentsMdReminder.seeded src/agent/agentsMdReminder/agentsMdReminderService.ts @@ -103,6 +104,7 @@ // shellCommand.tasks src/agent/shellCommand/shellCommandService.ts // stepRetry.failedAttempts src/agent/stepRetry/stepRetryService.ts // stepRetry.lastFailedDriverId src/agent/stepRetry/stepRetryService.ts +// swarm.lastRendered src/agent/swarm/injection/swarmInjection.ts // task.activeTaskReminderPending src/agent/task/taskService.ts // task.deliveredNotificationKeys src/agent/task/taskService.ts // task.ghosts src/agent/task/taskService.ts @@ -117,7 +119,6 @@ // toolDedupe.syntheticCallIds src/agent/toolDedupe/toolDedupeService.ts // toolExecutor.dupTypeTurnId src/agent/toolExecutor/toolExecutorService.ts // toolExecutor.toolCallDupTypes src/agent/toolExecutor/toolExecutorService.ts -// toolSelect.needsBoundaryInjection src/agent/toolSelect/toolSelectAnnouncementsService.ts // toolSelect.pendingLoaded src/agent/toolSelect/toolSelectService.ts // usage.currentTurn src/agent/usage/usageService.ts // usage.currentTurnId src/agent/usage/usageService.ts @@ -1085,6 +1086,8 @@ export interface AgentStateSnapshot { 'permissionMode.lastMode': 'manual' | 'yolo' | 'auto' | undefined; // src/agent/plan/injection/planModeInjection.ts 'plan.wasActive': boolean; + // src/agent/plugin/agentPluginService.ts + 'agentPlugin.sessionStartDirty': boolean; // src/agent/profile/profileService.ts 'profile.activeToolNamesOverlay': readonly string[] | undefined; 'profile.agentsMdWarning': string | undefined; @@ -1098,6 +1101,8 @@ export interface AgentStateSnapshot { // src/agent/stepRetry/stepRetryService.ts 'stepRetry.failedAttempts': number; 'stepRetry.lastFailedDriverId': string | undefined; + // src/agent/swarm/injection/swarmInjection.ts + 'swarm.lastRendered': 'active' | 'inactive' | undefined; // src/agent/task/taskService.ts 'task.activeTaskReminderPending': boolean; 'task.deliveredNotificationKeys': Set; @@ -1155,8 +1160,6 @@ export interface AgentStateSnapshot { // src/agent/toolExecutor/toolExecutorService.ts 'toolExecutor.dupTypeTurnId': number | undefined; 'toolExecutor.toolCallDupTypes': Map; - // src/agent/toolSelect/toolSelectAnnouncementsService.ts - 'toolSelect.needsBoundaryInjection': boolean; // src/agent/toolSelect/toolSelectService.ts 'toolSelect.pendingLoaded': Set; // src/agent/usage/usageService.ts diff --git a/packages/agent-core-v2/docs/wire-manifest.d.ts b/packages/agent-core-v2/docs/wire-manifest.d.ts index 35318dff54d..24685b4176b 100644 --- a/packages/agent-core-v2/docs/wire-manifest.d.ts +++ b/packages/agent-core-v2/docs/wire-manifest.d.ts @@ -21,55 +21,56 @@ // owning model offloads inline media to blob storage), cross-reducers // (foreign models that also reduce this record on dispatch and replay). -// Index (48 record types) -// config.update profile persisted src/agent/profile/profileOps.ts -// context.append_loop_event contextMemory persisted src/agent/contextMemory/contextOps.ts -// context.append_message contextMemory persisted src/agent/contextMemory/contextOps.ts -// context.apply_compaction contextMemory persisted src/agent/contextMemory/contextOps.ts -// context.clear contextMemory persisted src/agent/contextMemory/contextOps.ts -// context.undo contextMemory persisted src/agent/contextMemory/contextOps.ts -// cron.add cron transient src/session/cron/cronOps.ts -// cron.cursor cron transient src/session/cron/cronOps.ts -// cron.delete cron transient src/session/cron/cronOps.ts -// forked goal persisted src/agent/goal/goalOps.ts -// full_compaction.begin fullCompaction persisted src/agent/fullCompaction/compactionOps.ts -// full_compaction.cancel fullCompaction persisted src/agent/fullCompaction/compactionOps.ts -// full_compaction.complete fullCompaction persisted src/agent/fullCompaction/compactionOps.ts -// goal.clear goal persisted src/agent/goal/goalOps.ts -// goal.create goal persisted src/agent/goal/goalOps.ts -// goal.update goal persisted src/agent/goal/goalOps.ts -// interaction.request interaction persisted src/session/interaction/interactionOps.ts -// interaction.resolved interaction persisted src/session/interaction/interactionOps.ts -// interruptionReminder.recorded interruptionReminder persisted src/agent/interruptionReminder/interruptionReminderOps.ts -// llm.request llm.requestTrace persisted src/agent/llmRequester/llmRequestOps.ts -// llm.tools_snapshot llm.requestTrace persisted src/agent/llmRequester/llmRequestOps.ts -// mcp.tools_discovered mcp.discovery persisted src/agent/mcp/mcpDiscoveryOps.ts -// permission.record_approval_result permissionRules persisted src/agent/permissionRules/permissionRulesOps.ts -// permission.rules.add permissionRules transient src/agent/permissionRules/permissionRulesOps.ts -// permission.set_mode permissionMode persisted src/agent/permissionMode/permissionModeOps.ts -// plan_mode.cancel plan persisted src/agent/plan/planOps.ts -// plan_mode.enter plan persisted src/agent/plan/planOps.ts -// plan_mode.exit plan persisted src/agent/plan/planOps.ts -// plan.revision plan persisted src/agent/plan/planOps.ts -// profile.bind profile persisted src/agent/profile/profileOps.ts -// skill.activate skill transient src/agent/skill/skillOps.ts -// swarm_mode.enter swarm persisted src/agent/swarm/swarmOps.ts -// swarm_mode.exit swarm persisted src/agent/swarm/swarmOps.ts -// task.started task persisted src/agent/task/taskOps.ts -// task.terminated task persisted src/agent/task/taskOps.ts -// token_counting.measured tokenCounting transient src/agent/tokenCounting/tokenCountingOps.ts -// token_counting.rebased tokenCounting transient src/agent/tokenCounting/tokenCountingOps.ts -// token_counting.truncated tokenCounting transient src/agent/tokenCounting/tokenCountingOps.ts -// tools.register_user_tool userTool persisted src/agent/userTool/userToolOps.ts -// tools.reset_active_tools profile.activeTools persisted src/agent/profile/profileOps.ts -// tools.set_active_tools profile.activeTools persisted src/agent/profile/profileOps.ts -// tools.unregister_user_tool userTool persisted src/agent/userTool/userToolOps.ts -// tools.update_store todo persisted src/session/todo/todoOps.ts -// turn.cancel turn persisted src/agent/loop/turnOps.ts -// turn.ended turn persisted src/agent/loop/turnOps.ts -// turn.prompt turn persisted src/agent/loop/turnOps.ts -// turn.steer turn persisted src/agent/loop/turnOps.ts -// usage.record usage persisted src/agent/usage/usageOps.ts +// Index (49 record types) +// config.update profile persisted src/agent/profile/profileOps.ts +// context.append_loop_event contextMemory persisted src/agent/contextMemory/contextOps.ts +// context.append_message contextMemory persisted src/agent/contextMemory/contextOps.ts +// context.apply_compaction contextMemory persisted src/agent/contextMemory/contextOps.ts +// context.clear contextMemory persisted src/agent/contextMemory/contextOps.ts +// context.undo contextMemory persisted src/agent/contextMemory/contextOps.ts +// cron.add cron transient src/session/cron/cronOps.ts +// cron.cursor cron transient src/session/cron/cronOps.ts +// cron.delete cron transient src/session/cron/cronOps.ts +// forked goal persisted src/agent/goal/goalOps.ts +// full_compaction.begin fullCompaction persisted src/agent/fullCompaction/compactionOps.ts +// full_compaction.cancel fullCompaction persisted src/agent/fullCompaction/compactionOps.ts +// full_compaction.complete fullCompaction persisted src/agent/fullCompaction/compactionOps.ts +// goal.clear goal persisted src/agent/goal/goalOps.ts +// goal.create goal persisted src/agent/goal/goalOps.ts +// goal.update goal persisted src/agent/goal/goalOps.ts +// interaction.request interaction persisted src/session/interaction/interactionOps.ts +// interaction.resolved interaction persisted src/session/interaction/interactionOps.ts +// llm.request llm.requestTrace persisted src/agent/llmRequester/llmRequestOps.ts +// llm.tools_snapshot llm.requestTrace persisted src/agent/llmRequester/llmRequestOps.ts +// mcp.tools_discovered mcp.discovery persisted src/agent/mcp/mcpDiscoveryOps.ts +// permission.record_approval_result permissionRules persisted src/agent/permissionRules/permissionRulesOps.ts +// permission.rules.add permissionRules transient src/agent/permissionRules/permissionRulesOps.ts +// permission.set_mode permissionMode persisted src/agent/permissionMode/permissionModeOps.ts +// plan_mode.cancel plan persisted src/agent/plan/planOps.ts +// plan_mode.enter plan persisted src/agent/plan/planOps.ts +// plan_mode.exit plan persisted src/agent/plan/planOps.ts +// plan.revision plan persisted src/agent/plan/planOps.ts +// profile.bind profile persisted src/agent/profile/profileOps.ts +// reminderQueue.delivered reminderQueue persisted src/agent/reminderQueue/reminderQueueOps.ts +// reminderQueue.enqueue reminderQueue persisted src/agent/reminderQueue/reminderQueueOps.ts +// skill.activate skill transient src/agent/skill/skillOps.ts +// swarm_mode.enter swarm persisted src/agent/swarm/swarmOps.ts +// swarm_mode.exit swarm persisted src/agent/swarm/swarmOps.ts +// task.started task persisted src/agent/task/taskOps.ts +// task.terminated task persisted src/agent/task/taskOps.ts +// token_counting.measured tokenCounting transient src/agent/tokenCounting/tokenCountingOps.ts +// token_counting.rebased tokenCounting transient src/agent/tokenCounting/tokenCountingOps.ts +// token_counting.truncated tokenCounting transient src/agent/tokenCounting/tokenCountingOps.ts +// tools.register_user_tool userTool persisted src/agent/userTool/userToolOps.ts +// tools.reset_active_tools profile.activeTools persisted src/agent/profile/profileOps.ts +// tools.set_active_tools profile.activeTools persisted src/agent/profile/profileOps.ts +// tools.unregister_user_tool userTool persisted src/agent/userTool/userToolOps.ts +// tools.update_store todo persisted src/session/todo/todoOps.ts +// turn.cancel turn persisted src/agent/loop/turnOps.ts +// turn.ended turn persisted src/agent/loop/turnOps.ts +// turn.prompt turn persisted src/agent/loop/turnOps.ts +// turn.steer turn persisted src/agent/loop/turnOps.ts +// usage.record usage persisted src/agent/usage/usageOps.ts /** * model: profile · persisted @@ -300,15 +301,6 @@ interface InteractionResolvedPayload { response: any; } -/** - * model: interruptionReminder · persisted - * owner: src/agent/interruptionReminder/interruptionReminderOps.ts - */ -interface InterruptionReminderRecordedPayload { - _name: 'interruptionReminder.recorded'; - turnId: number; -} - /** * model: llm.requestTrace · persisted * owner: src/agent/llmRequester/llmRequestOps.ts @@ -459,6 +451,29 @@ interface ProfileBindPayload { subagents?: string[]; } +/** + * model: reminderQueue · persisted + * owner: src/agent/reminderQueue/reminderQueueOps.ts + */ +interface ReminderQueueDeliveredPayload { + _name: 'reminderQueue.delivered'; + id: string; +} + +/** + * model: reminderQueue · persisted + * owner: src/agent/reminderQueue/reminderQueueOps.ts + */ +interface ReminderQueueEnqueuePayload { + _name: 'reminderQueue.enqueue'; + entry: { + id: string; + variant: string; + content: string; + ownerPromptId?: string; + }; +} + /** * model: skill · toEvent * owner: src/agent/skill/skillOps.ts @@ -489,7 +504,7 @@ interface SwarmModeEnterPayload { } /** - * model: swarm · persisted · toEvent · cross-reducers: contextMemory + * model: swarm · persisted · toEvent * owner: src/agent/swarm/swarmOps.ts */ interface SwarmModeExitPayload { @@ -598,7 +613,7 @@ interface ToolsUpdateStorePayload { } /** - * model: turn · persisted · cross-reducers: interruptionReminder + * model: turn · persisted * owner: src/agent/loop/turnOps.ts */ interface TurnCancelPayload { @@ -723,7 +738,6 @@ interface WirePayloadMap { "goal.update": GoalUpdatePayload; "interaction.request": InteractionRequestPayload; "interaction.resolved": InteractionResolvedPayload; - "interruptionReminder.recorded": InterruptionReminderRecordedPayload; "llm.request": LlmRequestPayload; "llm.tools_snapshot": LlmToolsSnapshotPayload; "mcp.tools_discovered": McpToolsDiscoveredPayload; @@ -735,6 +749,8 @@ interface WirePayloadMap { "plan_mode.exit": PlanModeExitPayload; "plan.revision": PlanRevisionPayload; "profile.bind": ProfileBindPayload; + "reminderQueue.delivered": ReminderQueueDeliveredPayload; + "reminderQueue.enqueue": ReminderQueueEnqueuePayload; "skill.activate": SkillActivatePayload; "swarm_mode.enter": SwarmModeEnterPayload; "swarm_mode.exit": SwarmModeExitPayload; diff --git a/packages/agent-core-v2/src/agent/contextInjector/contextInjectorService.ts b/packages/agent-core-v2/src/agent/contextInjector/contextInjectorService.ts index 610598eaced..ae0b8b83fa6 100644 --- a/packages/agent-core-v2/src/agent/contextInjector/contextInjectorService.ts +++ b/packages/agent-core-v2/src/agent/contextInjector/contextInjectorService.ts @@ -1,9 +1,12 @@ /** * `contextInjector` domain — `IAgentContextInjectorService` implementation. * - * Injects registered context providers through `loop` and `systemReminder`, - * tracks their positions in `contextMemory` through `eventBus`, and reconciles - * those positions after `wire` restoration. The plain-data `isNewTurn` flag is + * The unified boundary scheduler for every model-facing reminder. It drains + * the persisted once-reminder queue (`reminderQueue`, past-tense events, + * exactly-once) ahead of reconciling the registered context providers + * (present-tense state) through `loop` and `systemReminder`, tracks provider + * positions in `contextMemory` through `eventBus`, and reconciles those + * positions after `wire` restoration. The plain-data `isNewTurn` flag is * registered into `agentState` (`IAgentStateService`) and read/written through * it; `entries` stays a plain instance field (its values hold provider * functions, not plain data). Bound at Agent scope. @@ -15,6 +18,7 @@ import { defineState } from '#/_base/state/stateRegistry'; import { IAgentContextMemoryService } from '#/agent/contextMemory/contextMemory'; import { IAgentLoopService } from '#/agent/loop/loop'; +import { IAgentReminderQueueService } from '#/agent/reminderQueue/reminderQueue'; import { IAgentStateService } from '#/agent/state/agentState'; import { IAgentSystemReminderService } from '#/agent/systemReminder/systemReminder'; import { IEventBus } from '#/app/event/eventBus'; @@ -43,6 +47,7 @@ export class AgentContextInjectorService extends Disposable implements IAgentCon constructor( @IAgentContextMemoryService private readonly context: IAgentContextMemoryService, @IAgentLoopService loopService: IAgentLoopService, + @IAgentReminderQueueService private readonly reminderQueue: IAgentReminderQueueService, @IAgentSystemReminderService private readonly reminders: IAgentSystemReminderService, @IEventBus private readonly eventBus: IEventBus, @IWireService wire: IWireService, @@ -58,6 +63,9 @@ export class AgentContextInjectorService extends Disposable implements IAgentCon ); this._register( this.eventBus.subscribe('turn.started', () => { + // Draining here lands once-reminders ahead of the next prompt, which + // only materializes later (in the turn's first step). + this.reminderQueue.drain(); this.isNewTurn = true; }), ); @@ -68,6 +76,10 @@ export class AgentContextInjectorService extends Disposable implements IAgentCon ); this._register( wire.hooks.onDidRestore.register('context-injector', async (_ctx, next) => { + // Replay rebuilds the pending ledger; deliver what was left pending + // before the loop resumes, then fold the fresh messages into + // position tracking. + this.reminderQueue.drain(); this.resyncPositions(); await next(); }), @@ -104,6 +116,10 @@ export class AgentContextInjectorService extends Disposable implements IAgentCon } private async inject(): Promise { + // Once-reminders (past-tense events) drain ahead of the reconcile pass so + // state reminders never wedge themselves between an event and the message + // it annotates. + this.reminderQueue.drain(); const isNewTurn = this.isNewTurn; this.isNewTurn = false; for (const entry of this.entries) { diff --git a/packages/agent-core-v2/src/agent/contextMemory/contextOps.ts b/packages/agent-core-v2/src/agent/contextMemory/contextOps.ts index 7c9610cdb29..ad89eadde09 100644 --- a/packages/agent-core-v2/src/agent/contextMemory/contextOps.ts +++ b/packages/agent-core-v2/src/agent/contextMemory/contextOps.ts @@ -17,10 +17,8 @@ * same on-disk shape the v1 loop writes — and `contextAppendLoopEvent` folds * them into assistant / tool messages both at live dispatch time and on * replay, so v1- and v2-written sessions reduce - * identically. The swarm-mode exit reminder removal is a cross-model fold: - * `ContextModel` registers a reducer on `swarm_mode.exit` (see - * `popSwarmModeReminder`) so the pop replays from the `swarm_mode.exit` record - * itself. + * identically. Swarm-mode announcements are owned by the `swarm` domain's + * context-injection provider, not by this Model. * * `context.undo` counts conversation ticks with the single `isUndoAnchor` * predicate — the same definition the checkpoint @@ -119,19 +117,8 @@ export const ContextModel = defineModel('contextMemory', () => return changed ? result : state; }, }, - reducers: { - 'swarm_mode.exit': popSwarmModeReminder, - }, }); -function popSwarmModeReminder(state: ContextMessage[], _payload: unknown): ContextMessage[] { - const last = state[state.length - 1]; - if (last === undefined) return state; - const origin = last.origin; - if (origin?.kind !== 'injection' || origin.variant !== 'swarm_mode') return state; - return resetFold(state.slice(0, -1)) as ContextMessage[]; -} - declare module '#/wire/types' { interface PersistedOpMap { 'context.append_message': typeof contextAppendMessage; diff --git a/packages/agent-core-v2/src/agent/goal/goalService.ts b/packages/agent-core-v2/src/agent/goal/goalService.ts index 0b3633e2947..5edb7f3cdfa 100644 --- a/packages/agent-core-v2/src/agent/goal/goalService.ts +++ b/packages/agent-core-v2/src/agent/goal/goalService.ts @@ -16,7 +16,7 @@ * `StepRequest`s onto `loop` (the continuation message materializes when the * loop pops it), accounts live * turn usage through `usage`, observes terminal goal tool results through - * `toolExecutor`, writes system reminders through `systemReminder`, reports + * `toolExecutor`, enqueues one-time reminder events through `reminderQueue`, reports * telemetry through `telemetry`, and checks main-agent eligibility through * `scopeContext`. Measures time and arms hard deadlines through `goal`'s * App-scoped deadline scheduler. Two `onBeforeExecuteTool` veto listeners @@ -58,7 +58,7 @@ import { import { LOOP_CONTROL_SECTION, type LoopControl } from '#/agent/loop/configSection'; import { LoopErrors } from '#/agent/loop/errors'; import { ContinuationStepRequest, MessageStepRequest } from '#/agent/loop/stepRequest'; -import { IAgentSystemReminderService } from '#/agent/systemReminder/systemReminder'; +import { IAgentReminderQueueService } from '#/agent/reminderQueue/reminderQueue'; import { IAgentScopeContext } from '#/agent/scopeContext/scopeContext'; import { IAgentStateService } from '#/agent/state/agentState'; import type { ExecutableToolResult } from '#/tool/toolContract'; @@ -217,10 +217,10 @@ const GoalForkNoticeModel = defineModel( ); function isGoalForkClearedReminder(message: ContextMessage | undefined): boolean { - return ( - message?.origin?.kind === 'system_trigger' && - message.origin.name === GOAL_FORK_CLEARED_REMINDER_NAME - ); + const origin = message?.origin; + if (origin?.kind === 'injection') return origin.variant === GOAL_FORK_CLEARED_REMINDER_NAME; + // Legacy journals announced through `{kind: 'system_trigger'}`. + return origin?.kind === 'system_trigger' && origin.name === GOAL_FORK_CLEARED_REMINDER_NAME; } function isGoalContinuationOrigin(origin: TurnStartedEvent['origin']): boolean { @@ -285,7 +285,7 @@ export class AgentGoalService extends Disposable implements IAgentGoalService { constructor( @IWireService private readonly wire: IWireService, @IEventBus private readonly eventBus: IEventBus, - @IAgentSystemReminderService private readonly reminders: IAgentSystemReminderService, + @IAgentReminderQueueService private readonly reminderQueue: IAgentReminderQueueService, @ITelemetryService private readonly telemetry: ITelemetryService, @IAgentContextInjectorService dynamicInjector: IAgentContextInjectorService, @IAgentLoopService private readonly loopService: IAgentLoopService, @@ -626,9 +626,9 @@ export class AgentGoalService extends Disposable implements IAgentGoalService { } this.clearInternal(actor); if (actor === 'user') { - this.reminders.appendSystemReminder(GOAL_CANCELLED_REMINDER, { - kind: 'system_trigger', - name: 'goal_cancelled', + this.reminderQueue.enqueue({ + variant: 'goal_cancelled', + content: GOAL_CANCELLED_REMINDER, }); } return snapshot; @@ -804,9 +804,9 @@ export class AgentGoalService extends Disposable implements IAgentGoalService { hasStepBudgetRemaining(maxSteps, ctx.step) ) { this.budgetGraceTurns.add(ctx.turnId); - this.reminders.appendSystemReminder(GOAL_BUDGET_STOP_REMINDER, { - kind: 'system_trigger', - name: GOAL_BUDGET_STOP_REMINDER_NAME, + this.reminderQueue.enqueue({ + variant: GOAL_BUDGET_STOP_REMINDER_NAME, + content: GOAL_BUDGET_STOP_REMINDER, }); return true; } @@ -1018,9 +1018,9 @@ export class AgentGoalService extends Disposable implements IAgentGoalService { private appendForkClearedReminder(): void { if (!this.wire.getModel(GoalForkNoticeModel).reminderPending) return; - this.reminders.appendSystemReminder(GOAL_FORK_CLEARED_REMINDER, { - kind: 'system_trigger', - name: GOAL_FORK_CLEARED_REMINDER_NAME, + this.reminderQueue.enqueue({ + variant: GOAL_FORK_CLEARED_REMINDER_NAME, + content: GOAL_FORK_CLEARED_REMINDER, }); } diff --git a/packages/agent-core-v2/src/agent/interruptionReminder/interruptionReminderOps.ts b/packages/agent-core-v2/src/agent/interruptionReminder/interruptionReminderOps.ts deleted file mode 100644 index 0c7a39e13b6..00000000000 --- a/packages/agent-core-v2/src/agent/interruptionReminder/interruptionReminderOps.ts +++ /dev/null @@ -1,43 +0,0 @@ -/** - * `interruptionReminder` domain (L4) — persists and restores pending - * user-interruption reminders. - * - * Projects the `loop` domain's `turn.cancel` fact into the set of turns whose - * interruption reminder still has to reach the conversation, and owns the op - * that records a reminder's delivery. Consumed by the Agent-scope - * `interruptionReminderService`. - */ - -import { z } from 'zod'; - -import { defineModel } from '#/wire/model'; - -export const InterruptionReminderModel = defineModel( - 'interruptionReminder', - () => [], - { - reducers: { - 'turn.cancel': (state, { turnId, target, reason }) => { - if (target !== 'active' || reason !== 'user_cancelled' || turnId === undefined) { - return state; - } - if (state.includes(turnId)) return state; - return [...state, turnId].toSorted((a, b) => a - b); - }, - }, - }, -); - -declare module '#/wire/types' { - interface PersistedOpMap { - 'interruptionReminder.recorded': typeof interruptionReminderRecorded; - } -} - -export const interruptionReminderRecorded = InterruptionReminderModel.defineOp( - 'interruptionReminder.recorded', - { - schema: z.object({ turnId: z.number().int().nonnegative() }), - apply: (state, { turnId }) => state.filter((pendingTurnId) => pendingTurnId !== turnId), - }, -); diff --git a/packages/agent-core-v2/src/agent/interruptionReminder/interruptionReminderService.ts b/packages/agent-core-v2/src/agent/interruptionReminder/interruptionReminderService.ts index 1974cd741bb..cd27aa402b8 100644 --- a/packages/agent-core-v2/src/agent/interruptionReminder/interruptionReminderService.ts +++ b/packages/agent-core-v2/src/agent/interruptionReminder/interruptionReminderService.ts @@ -1,23 +1,18 @@ /** * `interruptionReminder` domain (L4) — `IAgentInterruptionReminderService` implementation. * - * Observes turn completion through `event`, persists reminder completion through - * its own wire model, reads conversation history through `contextMemory`, and - * appends model-visible notices through `systemReminder`. Reconciles reminders - * left pending by an interrupted restore. Bound at Agent scope. + * Observes turn completion through `eventBus` and enqueues a one-time + * user-interruption notice into the `reminderQueue`. Persistence, crash + * recovery, and dedup are owned by the queue; delivery happens at the unified + * boundary scheduler's drain points. Bound at Agent scope. */ import { Disposable } from '#/_base/di/lifecycle'; import { LifecycleScope, ScopeActivation, registerScopedService } from '#/_base/di/scope'; -import { IAgentContextMemoryService } from '#/agent/contextMemory/contextMemory'; -import type { ContextMessage } from '#/agent/contextMemory/types'; -import { isVacuousContentPart } from '#/agent/contextMemory/vacuousContent'; -import { IAgentSystemReminderService } from '#/agent/systemReminder/systemReminder'; +import { IAgentReminderQueueService } from '#/agent/reminderQueue/reminderQueue'; import { IEventBus } from '#/app/event/eventBus'; -import { IWireService } from '#/wire/wire'; import { IAgentInterruptionReminderService } from './interruptionReminder'; -import { interruptionReminderRecorded, InterruptionReminderModel } from './interruptionReminderOps'; export const INTERRUPTION_REMINDER_VARIANT = 'interruption'; @@ -35,68 +30,19 @@ export class AgentInterruptionReminderService constructor( @IEventBus eventBus: IEventBus, - @IAgentContextMemoryService private readonly context: IAgentContextMemoryService, - @IAgentSystemReminderService private readonly reminders: IAgentSystemReminderService, - @IWireService private readonly wire: IWireService, + @IAgentReminderQueueService private readonly reminderQueue: IAgentReminderQueueService, ) { super(); - this._register( - this.wire.hooks.onDidRestore.register('interruption-reminder', async (_ctx, next) => { - this.reconcilePendingReminders(); - await next(); - }), - ); this._register( eventBus.subscribe('turn.ended', (event) => { if (event.reason !== 'cancelled' || event.interruptReason !== 'user_cancelled') return; - this.recordReminder(event.turnId, true); + this.reminderQueue.enqueue({ + variant: INTERRUPTION_REMINDER_VARIANT, + content: INTERRUPTION_REMINDER, + }); }), ); } - - private reconcilePendingReminders(): void { - const pending = this.wire.getModel(InterruptionReminderModel); - for (const turnId of pending) this.recordReminder(turnId); - } - - private recordReminder(turnId: number, allowUntracked = false): void { - const pending = this.wire.getModel(InterruptionReminderModel).includes(turnId); - if (!pending && !allowUntracked) return; - if (!this.appendInterruptionReminder()) return; - if (pending) this.wire.dispatch(interruptionReminderRecorded({ turnId })); - } - - private appendInterruptionReminder(): boolean { - const before = this.context.get(); - const origin = lastDurableMessageOrigin(before); - if (origin?.kind === 'injection' && origin.variant === INTERRUPTION_REMINDER_VARIANT) return true; - this.reminders.appendSystemReminder(INTERRUPTION_REMINDER, { - kind: 'injection', - variant: INTERRUPTION_REMINDER_VARIANT, - }); - const after = this.context.get(); - if (after === before) return false; - const appended = lastDurableMessageOrigin(after); - return appended?.kind === 'injection' && appended.variant === INTERRUPTION_REMINDER_VARIANT; - } -} - -function lastDurableMessageOrigin( - messages: readonly ContextMessage[], -): ContextMessage['origin'] | undefined { - for (let i = messages.length - 1; i >= 0; i--) { - const message = messages[i]!; - if ( - message.role === 'assistant' && - message.partial === true && - message.toolCalls.length === 0 && - message.content.every(isVacuousContentPart) - ) { - continue; - } - return message.origin; - } - return undefined; } registerScopedService( diff --git a/packages/agent-core-v2/src/agent/loop/turnOps.ts b/packages/agent-core-v2/src/agent/loop/turnOps.ts index ad96e5b6385..830779dd284 100644 --- a/packages/agent-core-v2/src/agent/loop/turnOps.ts +++ b/packages/agent-core-v2/src/agent/loop/turnOps.ts @@ -6,8 +6,7 @@ * legacy loop-event observations. Also persists the terminal `turn.ended` * record (reason / error / durationMs) so downstream history rebuilds can * recover how a turn ended; the record carries no engine-restorable state, so - * its `apply` is a no-op. Consumed by the Agent-scope `loopService`; the - * `interruptionReminder` domain projects `turn.cancel` into its own model. + * its `apply` is a no-op. Consumed by the Agent-scope `loopService`. */ import { z } from 'zod'; diff --git a/packages/agent-core-v2/src/agent/plugin/agentPluginService.ts b/packages/agent-core-v2/src/agent/plugin/agentPluginService.ts index 947a3055533..890e8825c93 100644 --- a/packages/agent-core-v2/src/agent/plugin/agentPluginService.ts +++ b/packages/agent-core-v2/src/agent/plugin/agentPluginService.ts @@ -1,23 +1,28 @@ /** * `agentPlugin` domain — `IAgentPluginService` implementation. * - * Renders session-start skills from `plugin` and `sessionSkillCatalog`, injects - * them through `contextInjector` and `systemReminder`, and uses `contextMemory` - * to neutralize stale guidance. Main-agent-only (v1 parity): the service - * self-gates on `agentId === 'main'`; Agent scope creation instantiates it for - * every agent, so other agents construct it as a no-op. Resolves - * session prompt context through `sessionContext` and reports missing skills - * through `log`. Bound at Agent scope. + * Renders session-start skills from `plugin` and `sessionSkillCatalog` as a + * pure state channel through `contextInjector`: the provider re-renders the + * current session-start set whenever it is missing from the conversation + * (compaction recovery) or when the plugin skill catalog changed (a dirty + * flag forces one supersedes/neutralize render at the next boundary). Uses + * `contextMemory` to detect stale guidance worth neutralizing. Main-agent-only + * (v1 parity): the service self-gates on `agentId === 'main'`; Agent scope + * creation instantiates it for every agent, so other agents construct it as a + * no-op. Resolves session prompt context through `sessionContext` and reports + * missing skills through `log`. The plain-data dirty flag is registered into + * `agentState` (`IAgentStateService`). Bound at Agent scope. */ import { Disposable } from '#/_base/di/lifecycle'; import { LifecycleScope, ScopeActivation, registerScopedService } from '#/_base/di/scope'; import { ILogService } from '#/_base/log/log'; +import { defineState } from '#/_base/state/stateRegistry'; import { escapeXmlAttr } from '#/_base/utils/xml-escape'; import { IAgentContextInjectorService } from '#/agent/contextInjector/contextInjector'; import { IAgentContextMemoryService } from '#/agent/contextMemory/contextMemory'; import { IAgentScopeContext } from '#/agent/scopeContext/scopeContext'; -import { IAgentSystemReminderService } from '#/agent/systemReminder/systemReminder'; +import { IAgentStateService } from '#/agent/state/agentState'; import { IPluginService } from '#/app/plugin/plugin'; import type { EnabledPluginSessionStart } from '#/app/plugin/types'; import { PLUGIN_SKILL_SOURCE_ID } from '#/app/skillCatalog/skillSource'; @@ -31,39 +36,57 @@ const SESSION_START_INJECTION_VARIANT = 'plugin_session_start'; const MAIN_AGENT_ID = 'main'; +const SUPERSEDES_SUFFIX = + 'This supersedes any earlier plugin_session_start reminder in this session.'; + +export const pluginSessionStartDirtyKey = defineState( + 'agentPlugin.sessionStartDirty', + () => false, +); + export class AgentPluginService extends Disposable implements IAgentPluginService { declare readonly _serviceBrand: undefined; constructor( @IAgentScopeContext scopeContext: IAgentScopeContext, @IAgentContextInjectorService injector: IAgentContextInjectorService, - @IAgentSystemReminderService private readonly reminders: IAgentSystemReminderService, @IAgentContextMemoryService private readonly context: IAgentContextMemoryService, @IPluginService private readonly plugins: IPluginService, @ISessionSkillCatalog private readonly skillCatalog: ISessionSkillCatalog, @ISessionContext private readonly sessionContext: ISessionContext, @ILogService private readonly log: ILogService, + @IAgentStateService private readonly states: IAgentStateService, ) { super(); if (scopeContext.agentId !== MAIN_AGENT_ID) return; + this.states.register(pluginSessionStartDirtyKey); this._register( - injector.register( - SESSION_START_INJECTION_VARIANT, - async ({ injectedPositions }) => { - if (injectedPositions.length > 0) return undefined; - return this.renderSessionStartReminder(); - }, - ), + injector.register(SESSION_START_INJECTION_VARIANT, async ({ injectedPositions }) => { + if (this.dirty) { + this.dirty = false; + return this.renderSupersedingReminder(); + } + if (injectedPositions.length > 0) return undefined; + return this.renderSessionStartReminder(); + }), ); this._register( this.skillCatalog.onDidChange((sourceId) => { if (sourceId === PLUGIN_SKILL_SOURCE_ID) { - void this.appendFreshSessionStartReminder(); + this.dirty = true; } }), ); } + private get dirty(): boolean { + return this.states.get(pluginSessionStartDirtyKey); + } + + private set dirty(value: boolean) { + this.states.set(pluginSessionStartDirtyKey, value); + } + private async renderSessionStartReminder(): Promise { const sessionStarts = await this.plugins.enabledSessionStarts(); if (sessionStarts.length === 0) return undefined; @@ -76,20 +99,15 @@ export class AgentPluginService extends Disposable implements IAgentPluginServic }); } - async appendFreshSessionStartReminder(): Promise { + private async renderSupersedingReminder(): Promise { const reminder = await this.renderSessionStartReminder(); if (reminder !== undefined) { - this.reminders.appendSystemReminder( - `${reminder}\n\nThis supersedes any earlier plugin_session_start reminder in this session.`, - { kind: 'injection', variant: SESSION_START_INJECTION_VARIANT }, - ); - } else if (shouldNeutralizePluginSessionStart(this.context.get())) { - this.reminders.appendSystemReminder( - 'There are currently no active plugin session starts. ' + - 'This supersedes any earlier plugin_session_start reminder in this session.', - { kind: 'injection', variant: SESSION_START_INJECTION_VARIANT }, - ); + return `${reminder}\n\n${SUPERSEDES_SUFFIX}`; + } + if (shouldNeutralizePluginSessionStart(this.context.get())) { + return `There are currently no active plugin session starts. ${SUPERSEDES_SUFFIX}`; } + return undefined; } } diff --git a/packages/agent-core-v2/src/agent/prompt/promptService.ts b/packages/agent-core-v2/src/agent/prompt/promptService.ts index 3f3dd91d100..be379e74e29 100644 --- a/packages/agent-core-v2/src/agent/prompt/promptService.ts +++ b/packages/agent-core-v2/src/agent/prompt/promptService.ts @@ -23,8 +23,8 @@ import { USER_PROMPT_ORIGIN, type ContextMessage } from '#/agent/contextMemory/t import { IAgentFullCompactionService } from '#/agent/fullCompaction/fullCompaction'; import { IAgentLoopService, type Turn, type TurnResult } from '#/agent/loop/loop'; import { steerTurn } from '#/agent/loop/turnOps'; +import { IAgentReminderQueueService } from '#/agent/reminderQueue/reminderQueue'; import { IAgentStateService } from '#/agent/state/agentState'; -import { IAgentSystemReminderService } from '#/agent/systemReminder/systemReminder'; import type { ExecutableToolResult } from '#/tool/toolContract'; import type { ToolDidExecuteContext } from '#/agent/toolExecutor/toolHooks'; import { IAgentToolExecutorService } from '#/agent/toolExecutor/toolExecutor'; @@ -75,7 +75,7 @@ export class AgentPromptService implements IAgentPromptService { constructor( @IAgentContextMemoryService private readonly context: IAgentContextMemoryService, - @IAgentSystemReminderService private readonly reminders: IAgentSystemReminderService, + @IAgentReminderQueueService private readonly reminderQueue: IAgentReminderQueueService, @IInstantiationService private readonly instantiation: IInstantiationService, @IAgentLoopService private readonly loop: IAgentLoopService, @IAgentToolExecutorService toolExecutor: IAgentToolExecutorService, @@ -145,7 +145,7 @@ export class AgentPromptService implements IAgentPromptService { role: 'user', content: selected.flatMap((item) => item.message.content), toolCalls: [], origin: USER_PROMPT_ORIGIN, }; const { message: rerouted, captions } = this.extractCompressionCaptions(message); - const request = new SteerStepRequest(rerouted, captions, this.reminders, (materialized) => { + const request = new SteerStepRequest(rerouted, captions, this.reminderQueue, (materialized) => { this.wire.dispatch(steerTurn({ input: materialized.content, origin: materialized.origin ?? USER_PROMPT_ORIGIN })); }, () => {}); const turn = (await this.loop.enqueue(request).assigned).turn; @@ -169,7 +169,7 @@ export class AgentPromptService implements IAgentPromptService { async inject(message: ContextMessage): Promise { const { message: rerouted, captions } = this.extractCompressionCaptions(message); - const request = new SteerStepRequest(rerouted, captions, this.reminders, (materialized) => { + const request = new SteerStepRequest(rerouted, captions, this.reminderQueue, (materialized) => { this.wire.dispatch(steerTurn({ input: materialized.content, origin: materialized.origin ?? USER_PROMPT_ORIGIN })); }, () => {}, 'activeOrNewTurn'); return (await this.loop.enqueue(request).assigned).turn; @@ -195,7 +195,7 @@ export class AgentPromptService implements IAgentPromptService { item.completionDeferred.resolve({ promptId: item.id, result: undefined, state: 'blocked' }); this.publishCompleted(item.id, 'blocked'); return; } - const turn = (await this.loop.enqueue(new PromptStepRequest(message, captions, this.reminders)).assigned).turn; + const turn = (await this.loop.enqueue(new PromptStepRequest(message, captions, this.reminderQueue)).assigned).turn; if (turn === undefined) { this.pending.unshift(item); return; } item.state = 'running'; item.launchedDeferred.resolve(turn); this.active = Object.assign(item, { turn }); void turn.result.then((result) => this.settle(item, result)); @@ -244,13 +244,16 @@ export class AgentPromptService implements IAgentPromptService { private appendPrompt(message: ContextMessage, captions: readonly string[]): void { const ownerPromptId = message.id ?? newMessageId(); for (const caption of captions) { - this.reminders.appendSystemReminder(caption, { - kind: 'injection', + this.reminderQueue.enqueue({ variant: 'image_compression', + content: caption, ownerPromptId, }); } if (message.content.length > 0) this.context.append({ ...message, id: ownerPromptId }); + // A blocked prompt starts no turn, so no boundary will drain the queue — + // deliver the captions right after their message here. + this.reminderQueue.drain(); } private async deliverToolResult(ctx: ToolDidExecuteContext): Promise { const delivery = ctx.result.delivery; if (delivery === undefined) return; diff --git a/packages/agent-core-v2/src/agent/prompt/promptStepRequests.ts b/packages/agent-core-v2/src/agent/prompt/promptStepRequests.ts index f84ede987e0..c5ec9e8b164 100644 --- a/packages/agent-core-v2/src/agent/prompt/promptStepRequests.ts +++ b/packages/agent-core-v2/src/agent/prompt/promptStepRequests.ts @@ -5,8 +5,10 @@ * `PromptStepRequest` / `SteerStepRequest` carry an already-built user * `ContextMessage` (image-compression captions pre-split), apply the image * format gate as the last funnel before the history, and materialize it - * at pop time — caption reminders first, message second, mirroring the old - * `appendPrompt` ordering. `PromptStepRequest` uses `newTurn`, seeding the + * at pop time — captions are enqueued into the once-reminder queue with the + * message's id as `ownerPromptId`, and the unified boundary scheduler + * delivers them right after the message at the next step boundary. + * `PromptStepRequest` uses `newTurn`, seeding the * `turn.prompt` record from its message. `SteerStepRequest` uses * `activeOrNewTurn`, is mergeable, and survives turn boundaries; it records * the `turn.steer` wire op on materialization and unregisters itself from the @@ -20,7 +22,7 @@ import { USER_PROMPT_ORIGIN, type ContextMessage } from '#/agent/contextMemory/t import { newMessageId } from '#/agent/contextMemory/messageId'; import { StepRequest, type StepRequestOptions, type TurnSeed } from '#/agent/loop/stepRequest'; import { gateImageFormatParts } from '#/agent/media/image-compress'; -import type { IAgentSystemReminderService } from '#/agent/systemReminder/systemReminder'; +import type { IAgentReminderQueueService } from '#/agent/reminderQueue/reminderQueue'; abstract class UserMessageStepRequest extends StepRequest { protected readonly message: ContextMessage; @@ -29,7 +31,7 @@ abstract class UserMessageStepRequest extends StepRequest { constructor( message: ContextMessage, private readonly captions: readonly string[], - private readonly reminders: IAgentSystemReminderService, + private readonly reminderQueue: IAgentReminderQueueService, options?: StepRequestOptions, ) { super(options); @@ -47,9 +49,9 @@ abstract class UserMessageStepRequest extends StepRequest { override onWillMaterialize(): void { for (const caption of this.captions) { - this.reminders.appendSystemReminder(caption, { - kind: 'injection', + this.reminderQueue.enqueue({ variant: 'image_compression', + content: caption, ownerPromptId: this.ownerPromptId, }); } @@ -66,9 +68,9 @@ export class PromptStepRequest extends UserMessageStepRequest { constructor( message: ContextMessage, captions: readonly string[], - reminders: IAgentSystemReminderService, + reminderQueue: IAgentReminderQueueService, ) { - super(message, captions, reminders, { admission: 'newTurn' }); + super(message, captions, reminderQueue, { admission: 'newTurn' }); } override get turnSeed(): TurnSeed { @@ -82,12 +84,12 @@ export class SteerStepRequest extends UserMessageStepRequest { constructor( message: ContextMessage, captions: readonly string[], - reminders: IAgentSystemReminderService, + reminderQueue: IAgentReminderQueueService, private readonly recordSteer: (message: ContextMessage) => void, private readonly forgetSteer: (request: SteerStepRequest) => void, admission: 'activeTurnOnly' | 'activeOrNewTurn' = 'activeTurnOnly', ) { - super(message, captions, reminders, { + super(message, captions, reminderQueue, { mergeable: true, turnScoped: false, admission, diff --git a/packages/agent-core-v2/src/agent/reminderQueue/reminderQueue.ts b/packages/agent-core-v2/src/agent/reminderQueue/reminderQueue.ts new file mode 100644 index 00000000000..45ef1cb6fcc --- /dev/null +++ b/packages/agent-core-v2/src/agent/reminderQueue/reminderQueue.ts @@ -0,0 +1,38 @@ +import { createDecorator } from '#/_base/di/instantiation'; + +export interface OnceReminderInput { + /** Injection variant stamped on the delivered message's origin. */ + readonly variant: string; + /** Reminder body; wrapped into `` by the write head. */ + readonly content: string; + /** Optional provenance link to the prompt this reminder belongs to. */ + readonly ownerPromptId?: string; +} + +/** + * Exactly-once channel for past-tense reminder events ("guaranteed delivery, + * then forget"). Enqueued entries persist in the agent's wire journal and are + * delivered FIFO by the unified boundary scheduler (`contextInjector`): on + * `turn.started`, on `onWillBeginStep`, after compaction, and on restore + * (pending entries are drained before the loop resumes). After delivery the + * queue loses all interest in the message — compaction or undo removing it + * later never triggers a re-send. + */ +export interface IAgentReminderQueueService { + readonly _serviceBrand: undefined; + + /** Persist a pending entry; returns its id. */ + enqueue(input: OnceReminderInput): string; + + /** + * Deliver every pending entry in FIFO order, marking each delivered. + * Synchronous; idempotent across concurrent or repeated calls — an entry + * whose reminder already sits at the conversation tail (crash between + * append and delivered-record) is reconciled without a duplicate append. + */ + drain(): void; +} + +export const IAgentReminderQueueService = createDecorator( + 'agentReminderQueueService', +); diff --git a/packages/agent-core-v2/src/agent/reminderQueue/reminderQueueOps.ts b/packages/agent-core-v2/src/agent/reminderQueue/reminderQueueOps.ts new file mode 100644 index 00000000000..9c236732c5f --- /dev/null +++ b/packages/agent-core-v2/src/agent/reminderQueue/reminderQueueOps.ts @@ -0,0 +1,53 @@ +/** + * `reminderQueue` domain — wire Model (`ReminderQueueModel`) and the persisted + * Ops backing the exactly-once delivery ledger. + * + * State is the FIFO list of pending once-reminders. `reminderQueue.enqueue` + * appends a pending entry; `reminderQueue.delivered` clears it once the + * reminder has been appended to the conversation. Both Ops persist, so replay + * rebuilds the pending set and a crash anywhere between enqueue and delivery + * is reconciled by the next restore drain. The Model is a plain `defineModel` + * — deliberately NOT checkpointed — so `context.undo` never revives a + * delivered entry: a once-reminder's delivery is a world fact, not branch + * state. + */ + +import { z } from 'zod'; + +import { defineModel } from '#/wire/model'; + +export interface ReminderQueueEntry { + readonly id: string; + readonly variant: string; + readonly content: string; + readonly ownerPromptId?: string; +} + +const reminderQueueEntrySchema = z.object({ + id: z.string().min(1), + variant: z.string().min(1), + content: z.string(), + ownerPromptId: z.string().optional(), +}); + +export const ReminderQueueModel = defineModel( + 'reminderQueue', + () => [], +); + +declare module '#/wire/types' { + interface PersistedOpMap { + 'reminderQueue.enqueue': typeof reminderQueueEnqueue; + 'reminderQueue.delivered': typeof reminderQueueDelivered; + } +} + +export const reminderQueueEnqueue = ReminderQueueModel.defineOp('reminderQueue.enqueue', { + schema: z.object({ entry: reminderQueueEntrySchema }), + apply: (state, { entry }) => [...state, entry], +}); + +export const reminderQueueDelivered = ReminderQueueModel.defineOp('reminderQueue.delivered', { + schema: z.object({ id: z.string().min(1) }), + apply: (state, { id }) => state.filter((entry) => entry.id !== id), +}); diff --git a/packages/agent-core-v2/src/agent/reminderQueue/reminderQueueService.ts b/packages/agent-core-v2/src/agent/reminderQueue/reminderQueueService.ts new file mode 100644 index 00000000000..3272bbfbd21 --- /dev/null +++ b/packages/agent-core-v2/src/agent/reminderQueue/reminderQueueService.ts @@ -0,0 +1,96 @@ +/** + * `reminderQueue` domain — `IAgentReminderQueueService` implementation. + * + * Persists once-reminder entries through its own wire Model, delivers them + * through `systemReminder` (the unified write head), and reads the + * conversation tail through `contextMemory` for crash-window dedup. Delivery + * scheduling is owned by the unified boundary scheduler (`contextInjector`), + * not by this service. Bound at Agent scope. + */ + +import { randomUUID } from 'node:crypto'; + +import { Disposable } from '#/_base/di/lifecycle'; +import { LifecycleScope, ScopeActivation, registerScopedService } from '#/_base/di/scope'; +import { IAgentContextMemoryService } from '#/agent/contextMemory/contextMemory'; +import type { ContextMessage } from '#/agent/contextMemory/types'; +import { isVacuousContentPart } from '#/agent/contextMemory/vacuousContent'; +import { IAgentSystemReminderService } from '#/agent/systemReminder/systemReminder'; +import { IWireService } from '#/wire/wire'; + +import { IAgentReminderQueueService, type OnceReminderInput } from './reminderQueue'; +import { + ReminderQueueModel, + type ReminderQueueEntry, + reminderQueueDelivered, + reminderQueueEnqueue, +} from './reminderQueueOps'; + +export class AgentReminderQueueService extends Disposable implements IAgentReminderQueueService { + declare readonly _serviceBrand: undefined; + + constructor( + @IWireService private readonly wire: IWireService, + @IAgentSystemReminderService private readonly reminders: IAgentSystemReminderService, + @IAgentContextMemoryService private readonly context: IAgentContextMemoryService, + ) { + super(); + } + + enqueue(input: OnceReminderInput): string { + const entry: ReminderQueueEntry = { id: randomUUID(), ...input }; + this.wire.dispatch(reminderQueueEnqueue({ entry })); + return entry.id; + } + + drain(): void { + for (const entry of this.wire.getModel(ReminderQueueModel)) { + // Exactly-once dedup: a crash between the reminder append (persisted as + // `context.append_message`) and the delivered record leaves the entry + // pending with its reminder already at the conversation tail — reconcile + // by clearing the ledger instead of appending a duplicate. + if (!this.alreadyDeliveredAtTail(entry)) { + this.reminders.appendSystemReminder(entry.content, { + kind: 'injection', + variant: entry.variant, + ownerPromptId: entry.ownerPromptId, + }); + } + this.wire.dispatch(reminderQueueDelivered({ id: entry.id })); + } + } + + private alreadyDeliveredAtTail(entry: ReminderQueueEntry): boolean { + const last = lastDurableMessage(this.context.get()); + if (last?.origin?.kind !== 'injection' || last.origin.variant !== entry.variant) return false; + return messageText(last) === `\n${entry.content.trim()}\n`; + } +} + +function lastDurableMessage(messages: readonly ContextMessage[]): ContextMessage | undefined { + for (let i = messages.length - 1; i >= 0; i--) { + const message = messages[i]!; + if ( + message.role === 'assistant' && + message.partial === true && + message.toolCalls.length === 0 && + message.content.every(isVacuousContentPart) + ) { + continue; + } + return message; + } + return undefined; +} + +function messageText(message: ContextMessage): string { + return message.content.map((part) => (part.type === 'text' ? part.text : '')).join(''); +} + +registerScopedService( + LifecycleScope.Agent, + IAgentReminderQueueService, + AgentReminderQueueService, + ScopeActivation.OnScopeCreated, + 'reminderQueue', +); diff --git a/packages/agent-core-v2/src/agent/swarm/injection/swarmInjection.ts b/packages/agent-core-v2/src/agent/swarm/injection/swarmInjection.ts new file mode 100644 index 00000000000..fa0c66c3a03 --- /dev/null +++ b/packages/agent-core-v2/src/agent/swarm/injection/swarmInjection.ts @@ -0,0 +1,115 @@ +/** + * `swarm` domain — swarm-mode context injection. + * + * Owns the `swarm_mode` context-injection provider, mirroring + * `permissionMode`: the enter guidance is re-announced whenever its live + * positions are folded away (compaction), and enter/exit transitions render + * the corresponding reminder. Tool-triggered swarms never render — the + * AgentSwarm tool result already tells the model. The plain-data + * last-rendered flag is registered into `agentState` (`IAgentStateService`) + * and read/written through it; on wire restore it is seeded from the last + * `swarm_mode` reminder surviving in the replayed history, so a resume + * neither duplicates the live enter guidance nor leaves a stale one + * uncorrected (a seeded `active` that no longer matches the model renders + * the exit reminder). + */ + +import { Disposable } from '#/_base/di/lifecycle'; +import { defineState } from '#/_base/state/stateRegistry'; +import { + IAgentContextInjectorService, + type ContextInjectionContext, +} from '#/agent/contextInjector/contextInjector'; +import { IAgentContextMemoryService } from '#/agent/contextMemory/contextMemory'; +import type { ContextMessage } from '#/agent/contextMemory/types'; +import { IAgentStateService } from '#/agent/state/agentState'; +import { IWireService } from '#/wire/wire'; + +import SWARM_MODE_ENTER_REMINDER from '../enter-reminder.md?raw'; +import SWARM_MODE_EXIT_REMINDER from '../exit-reminder.md?raw'; +import type { SwarmModeTrigger } from '../swarm'; + +const SWARM_MODE_INJECTION_VARIANT = 'swarm_mode'; + +export const swarmLastRenderedKey = defineState<'active' | 'inactive' | undefined>( + 'swarm.lastRendered', + () => undefined as 'active' | 'inactive' | undefined, +); + +export interface SwarmInjectionOptions { + readonly getTrigger: () => SwarmModeTrigger | null; +} + +export class SwarmInjection extends Disposable { + constructor( + private readonly options: SwarmInjectionOptions, + @IAgentContextInjectorService dynamicInjector: IAgentContextInjectorService, + @IAgentStateService private readonly states: IAgentStateService, + @IAgentContextMemoryService private readonly context: IAgentContextMemoryService, + @IWireService wire: IWireService, + ) { + super(); + this.states.register(swarmLastRenderedKey); + this._register( + dynamicInjector.register(SWARM_MODE_INJECTION_VARIANT, (ctx) => this.reminder(ctx)), + ); + this._register( + wire.hooks.onDidRestore.register('swarm-injection', async (_ctx, next) => { + // Replay rebuilt the history; seed the live flag from the last + // rendered reminder so the resume neither duplicates live guidance + // nor leaves a stale one uncorrected. + const rendered = lastRenderedFromHistory(this.context.get()); + if (rendered !== undefined) this.lastRendered = rendered; + await next(); + }), + ); + } + + private get lastRendered(): 'active' | 'inactive' | undefined { + return this.states.get(swarmLastRenderedKey); + } + + private set lastRendered(value: 'active' | 'inactive' | undefined) { + this.states.set(swarmLastRenderedKey, value); + } + + private reminder({ injectedPositions }: ContextInjectionContext): string | undefined { + const trigger = this.options.getTrigger(); + const active = trigger !== null && trigger !== 'tool'; + if (active === (this.lastRendered === 'active')) { + if (injectedPositions.length > 0 || !active) return undefined; + return SWARM_MODE_ENTER_REMINDER; + } + this.lastRendered = active ? 'active' : 'inactive'; + return active ? SWARM_MODE_ENTER_REMINDER : SWARM_MODE_EXIT_REMINDER; + } +} + +/** + * Recover the rendered state from the replayed history: the last + * `swarm_mode` reminder whose content still matches an enter/exit render. + * Legacy exit reminders (variant `swarm_mode_exit`, written before the + * injector owned the render) are matched too; an unrecognized latest + * reminder seeds nothing. + */ +function lastRenderedFromHistory( + history: readonly ContextMessage[], +): 'active' | 'inactive' | undefined { + for (let i = history.length - 1; i >= 0; i--) { + const message = history[i]!; + const origin = message.origin; + if (origin?.kind !== 'injection') continue; + if (origin.variant !== SWARM_MODE_INJECTION_VARIANT && origin.variant !== 'swarm_mode_exit') { + continue; + } + const text = message.content.map((part) => (part.type === 'text' ? part.text : '')).join(''); + if (text === `\n${SWARM_MODE_ENTER_REMINDER.trim()}\n`) { + return 'active'; + } + if (text === `\n${SWARM_MODE_EXIT_REMINDER.trim()}\n`) { + return 'inactive'; + } + return undefined; + } + return undefined; +} diff --git a/packages/agent-core-v2/src/agent/swarm/swarmService.ts b/packages/agent-core-v2/src/agent/swarm/swarmService.ts index 901dd963cb1..9721434a55a 100644 --- a/packages/agent-core-v2/src/agent/swarm/swarmService.ts +++ b/packages/agent-core-v2/src/agent/swarm/swarmService.ts @@ -3,32 +3,25 @@ * * Tracks swarm-mode enter/exit in the `wire` `SwarmModel` (mutated only through * the `swarm_mode.enter` / `swarm_mode.exit` Ops, read through `wire.getModel`), - * mirrors it into `systemReminder` as live-only side effects, derives - * `agent.status.updated` from the Ops' `toEvent`, and auto-exits on turn end via - * `turn`. The enter-reminder removal on exit is a cross-model fold on - * `ContextModel`: dispatching `swarm_mode.exit` pops the - * reminder when it is the last message, both live and on replay — exactly like - * v1's restore-time `popMatchedMessage`. The service only publishes the - * live-only `context.spliced` event for that pop (so injector bookkeeping - * stays in step) and appends the exit reminder when nothing was - * popped. Bound at Agent scope. The service also guards AgentSwarm batch - * exclusivity through an `onBeforeExecuteTool` veto + * derives `agent.status.updated` from the Ops' `toEvent`, announces the mode + * through the `swarm_mode` context-injection provider (`SwarmInjection`), and + * auto-exits on turn end via `turn`. Bound at Agent scope. The service also + * guards AgentSwarm batch exclusivity through an `onBeforeExecuteTool` veto * listener: an AgentSwarm call must be the only tool call in its batch, * anything else is vetoed with a `toolApproval.formatDenyMessage`-formatted * reason. */ import { Disposable } from '#/_base/di/lifecycle'; +import { IInstantiationService } from '#/_base/di/instantiation'; import { LifecycleScope, ScopeActivation, registerScopedService } from '#/_base/di/scope'; -import { IAgentContextMemoryService } from '#/agent/contextMemory/contextMemory'; -import { IAgentSystemReminderService } from '#/agent/systemReminder/systemReminder'; import { IAgentToolApprovalService } from '#/agent/toolApproval/toolApproval'; import { denyToolExecution } from '#/agent/toolExecutor/beforeToolExecuteEvent'; import { IAgentToolExecutorService } from '#/agent/toolExecutor/toolExecutor'; import { IEventBus } from '#/app/event/eventBus'; import { IWireService } from '#/wire/wire'; -import SWARM_MODE_ENTER_REMINDER from './enter-reminder.md?raw'; -import SWARM_MODE_EXIT_REMINDER from './exit-reminder.md?raw'; + +import { SwarmInjection } from './injection/swarmInjection'; import { IAgentSwarmService, type SwarmModeTrigger } from './swarm'; import { swarmEnter, swarmExit, SwarmModel } from './swarmOps'; @@ -37,13 +30,17 @@ export class AgentSwarmService extends Disposable implements IAgentSwarmService constructor( @IWireService private readonly wire: IWireService, - @IAgentSystemReminderService private readonly reminders: IAgentSystemReminderService, - @IAgentContextMemoryService private readonly context: IAgentContextMemoryService, + @IInstantiationService instantiation: IInstantiationService, @IEventBus private readonly eventBus: IEventBus, @IAgentToolApprovalService private readonly toolApproval: IAgentToolApprovalService, @IAgentToolExecutorService toolExecutor: IAgentToolExecutorService, ) { super(); + this._register( + instantiation.createInstance(SwarmInjection, { + getTrigger: () => this.wire.getModel(SwarmModel), + }), + ); this._register( this.eventBus.subscribe('turn.ended', () => { if (this.shouldAutoExit) { @@ -75,36 +72,11 @@ export class AgentSwarmService extends Disposable implements IAgentSwarmService enter(trigger: SwarmModeTrigger): void { if (this.wire.getModel(SwarmModel) !== null) return; this.wire.dispatch(swarmEnter({ trigger })); - if (trigger !== 'tool') { - this.reminders.appendSystemReminder(SWARM_MODE_ENTER_REMINDER, { - kind: 'injection', - variant: 'swarm_mode', - }); - } } exit(): void { - const trigger = this.wire.getModel(SwarmModel); - if (trigger === null) return; - const history = this.context.get(); - const last = history[history.length - 1]; - const willPop = - last?.origin?.kind === 'injection' && last.origin.variant === 'swarm_mode'; + if (this.wire.getModel(SwarmModel) === null) return; this.wire.dispatch(swarmExit({})); - if (trigger === 'tool') return; - if (willPop) { - this.eventBus.publish({ - type: 'context.spliced', - start: history.length - 1, - deleteCount: 1, - messages: [], - }); - return; - } - this.reminders.appendSystemReminder(SWARM_MODE_EXIT_REMINDER, { - kind: 'injection', - variant: 'swarm_mode_exit', - }); } get isActive(): boolean { diff --git a/packages/agent-core-v2/src/agent/systemReminder/systemReminder.ts b/packages/agent-core-v2/src/agent/systemReminder/systemReminder.ts index 3ecf30eaed2..f397ea0e6d4 100644 --- a/packages/agent-core-v2/src/agent/systemReminder/systemReminder.ts +++ b/packages/agent-core-v2/src/agent/systemReminder/systemReminder.ts @@ -2,6 +2,14 @@ import { createDecorator } from "#/_base/di/instantiation"; import type { ContextMessage, PromptOrigin } from '#/agent/contextMemory/types'; +/** + * The unified reminder write head — wraps content into a `` + * message and appends it to the conversation. This is a low-level INTERNAL + * API: domain code must not call it directly. Past-tense events go through + * `IAgentReminderQueueService.enqueue` (exactly-once); present-tense state + * goes through `IAgentContextInjectorService.register` (reconciled). The only + * legitimate callers are those two scheduler components. + */ export interface IAgentSystemReminderService { readonly _serviceBrand: undefined; diff --git a/packages/agent-core-v2/src/agent/toolSelect/dynamicTools.ts b/packages/agent-core-v2/src/agent/toolSelect/dynamicTools.ts index b3535f3990c..67a698eb93d 100644 --- a/packages/agent-core-v2/src/agent/toolSelect/dynamicTools.ts +++ b/packages/agent-core-v2/src/agent/toolSelect/dynamicTools.ts @@ -14,9 +14,10 @@ * first real user prompt it finds regardless of origin: schema messages * survive only when the cut lands before them. * - loadable-tools announcements: `/` system - * reminders (origin `{kind: 'system_trigger', name: 'loadable-tools'}`) — - * undo removes them (they are not `injection`-origin), and the next - * turn-boundary diff self-heals by re-announcing the folded delta. + * reminders (origin `{kind: 'injection', variant: 'loadable-tools'}`; + * legacy journals used `{kind: 'system_trigger', name: 'loadable-tools'}` + * and both are folded) — the next turn-boundary diff self-heals by + * re-announcing the folded delta whenever the ledger drifts. * * The loaded-tool ledger is the history itself: there is deliberately no * separate persisted ledger, so undo/compaction/resume all self-heal by @@ -29,17 +30,17 @@ import type { ContextMessage } from '#/agent/contextMemory/types'; export const DYNAMIC_TOOL_SCHEMA_VARIANT = 'dynamic_tool_schema'; -export const LOADABLE_TOOLS_TRIGGER = 'loadable-tools'; +export const LOADABLE_TOOLS_VARIANT = 'loadable-tools'; export function isDynamicToolSchemaMessage(message: ContextMessage): boolean { return message.tools !== undefined && message.tools.length > 0; } export function isLoadableToolsAnnouncement(message: ContextMessage): boolean { - return ( - message.origin?.kind === 'system_trigger' && - message.origin.name === LOADABLE_TOOLS_TRIGGER - ); + const origin = message.origin; + if (origin?.kind === 'injection') return origin.variant === LOADABLE_TOOLS_VARIANT; + // Legacy journals announced through `{kind: 'system_trigger'}`. + return origin?.kind === 'system_trigger' && origin.name === LOADABLE_TOOLS_VARIANT; } export function stripDynamicToolContext( diff --git a/packages/agent-core-v2/src/agent/toolSelect/toolSelectAnnouncements.ts b/packages/agent-core-v2/src/agent/toolSelect/toolSelectAnnouncements.ts index 6be3c1f1ac1..7a80fd17db0 100644 --- a/packages/agent-core-v2/src/agent/toolSelect/toolSelectAnnouncements.ts +++ b/packages/agent-core-v2/src/agent/toolSelect/toolSelectAnnouncements.ts @@ -1,8 +1,8 @@ /** * `toolSelect` domain — `IAgentToolSelectAnnouncementsService` contract. * - * Defines the Agent-scope marker service that appends v1-compatible - * loadable-tools announcements through `systemReminder` at loop boundaries. + * Defines the Agent-scope marker service that announces v1-compatible + * loadable-tools diffs through the `contextInjector` boundary scheduler. */ import { createDecorator, type ServiceIdentifier } from '#/_base/di/instantiation'; diff --git a/packages/agent-core-v2/src/agent/toolSelect/toolSelectAnnouncementsService.ts b/packages/agent-core-v2/src/agent/toolSelect/toolSelectAnnouncementsService.ts index 9cbffa0239c..be55406f0a3 100644 --- a/packages/agent-core-v2/src/agent/toolSelect/toolSelectAnnouncementsService.ts +++ b/packages/agent-core-v2/src/agent/toolSelect/toolSelectAnnouncementsService.ts @@ -2,74 +2,36 @@ * `toolSelect` domain — `IAgentToolSelectAnnouncementsService` * implementation. * - * Appends v1-compatible loadable-tools diff announcements at turn boundaries - * through `systemReminder`, hooks into `loop` before each step, reads - * announcement text from `IAgentToolSelectService`, and observes compaction - * boundaries from `event`. Turn boundaries need no state: every turn starts - * at loop step 1, which always evaluates injection. The compaction-boundary - * flag (`needsBoundaryInjection`) is registered into `agentState` - * (`IAgentStateService`) and read/written through it. Bound at Agent scope. + * Registers v1-compatible loadable-tools diff announcements as a + * `contextInjector` provider (variant `loadable-tools`). The injector's + * `isNewTurn` covers exactly the old boundary set — every turn's first step + * and the post-compaction inject — so no local boundary state is needed. + * Reads announcement text from `IAgentToolSelectService`; the folded history + * itself remains the ledger, so undo/compaction/resume all self-heal by + * re-folding. Bound at Agent scope. */ import { Disposable } from '#/_base/di/lifecycle'; import { LifecycleScope, ScopeActivation, registerScopedService } from '#/_base/di/scope'; -import { defineState } from '#/_base/state/stateRegistry'; -import { IAgentLoopService } from '#/agent/loop/loop'; -import { IAgentStateService } from '#/agent/state/agentState'; -import { IAgentSystemReminderService } from '#/agent/systemReminder/systemReminder'; -import { IEventBus } from '#/app/event/eventBus'; +import { IAgentContextInjectorService } from '#/agent/contextInjector/contextInjector'; -import { LOADABLE_TOOLS_TRIGGER } from './dynamicTools'; +import { LOADABLE_TOOLS_VARIANT } from './dynamicTools'; import { IAgentToolSelectService } from './toolSelect'; import { IAgentToolSelectAnnouncementsService } from './toolSelectAnnouncements'; -export const toolSelectNeedsBoundaryInjectionKey = defineState( - 'toolSelect.needsBoundaryInjection', - () => false, -); - export class AgentToolSelectAnnouncementsService extends Disposable implements IAgentToolSelectAnnouncementsService { declare readonly _serviceBrand: undefined; constructor( @IAgentToolSelectService toolSelect: IAgentToolSelectService, - @IAgentSystemReminderService private readonly reminders: IAgentSystemReminderService, - @IEventBus eventBus: IEventBus, - @IAgentLoopService loopService: IAgentLoopService, - @IAgentStateService private readonly states: IAgentStateService, + @IAgentContextInjectorService dynamicInjector: IAgentContextInjectorService, ) { super(); - this.states.register(toolSelectNeedsBoundaryInjectionKey); this._register( - eventBus.subscribe('compaction.completed', () => { - this.needsBoundaryInjection = true; - }), + dynamicInjector.register(LOADABLE_TOOLS_VARIANT, ({ isNewTurn }) => + isNewTurn ? toolSelect.loadableToolsAnnouncement() : undefined, + ), ); - this._register( - loopService.hooks.onWillBeginStep.register('toolSelectAnnouncements', async (ctx, next) => { - await next(); - if (ctx.step !== 1 && !this.needsBoundaryInjection) return; - this.needsBoundaryInjection = false; - this.inject(toolSelect); - }), - ); - } - - private get needsBoundaryInjection(): boolean { - return this.states.get(toolSelectNeedsBoundaryInjectionKey); - } - - private set needsBoundaryInjection(value: boolean) { - this.states.set(toolSelectNeedsBoundaryInjectionKey, value); - } - - private inject(toolSelect: IAgentToolSelectService): void { - const announcement = toolSelect.loadableToolsAnnouncement(); - if (announcement === undefined) return; - this.reminders.appendSystemReminder(announcement, { - kind: 'system_trigger', - name: LOADABLE_TOOLS_TRIGGER, - }); } } diff --git a/packages/agent-core-v2/src/index.ts b/packages/agent-core-v2/src/index.ts index 7f81b4247ef..3f214cfc21c 100644 --- a/packages/agent-core-v2/src/index.ts +++ b/packages/agent-core-v2/src/index.ts @@ -263,6 +263,7 @@ export * from '#/agent/tools/agent-swarm/agent-swarm'; import '#/agent/tools/agent-swarm/agentSwarmTool'; export * from '#/agent/swarm/swarm'; export * from '#/agent/swarm/swarmService'; +export * from '#/agent/swarm/injection/swarmInjection'; export * from '#/agent/usage/usage'; export * from '#/agent/usage/usageService'; export * from '#/agent/toolDedupe/toolDedupe'; @@ -507,6 +508,9 @@ export * from '#/agent/tokenCounting/tokenCountingOps'; export * from '#/agent/tokenCounting/tokenCountingService'; export * from '#/agent/contextInjector/contextInjector'; export * from '#/agent/contextInjector/contextInjectorService'; +export * from '#/agent/reminderQueue/reminderQueue'; +export * from '#/agent/reminderQueue/reminderQueueOps'; +export * from '#/agent/reminderQueue/reminderQueueService'; export * from '#/agent/plugin/agentPlugin'; export * from '#/agent/plugin/agentPluginService'; import '#/agent/externalHooks/configSection'; @@ -528,7 +532,6 @@ export * from '#/agent/loop/loopContinuation'; export * from '#/agent/loop/loopContinuationService'; export * from '#/agent/interruptionReminder/interruptionReminder'; export * from '#/agent/interruptionReminder/interruptionReminderService'; -export * from '#/agent/interruptionReminder/interruptionReminderOps'; export * from '#/agent/mcp/mcp'; export * from '#/agent/mcp/mcpService'; export * from '#/agent/mcp/mcpDiscoveryOps'; diff --git a/packages/agent-core-v2/src/session/btw/btwService.ts b/packages/agent-core-v2/src/session/btw/btwService.ts index 4ee6362db52..e2afac06e5c 100644 --- a/packages/agent-core-v2/src/session/btw/btwService.ts +++ b/packages/agent-core-v2/src/session/btw/btwService.ts @@ -5,7 +5,8 @@ * `IAgentLifecycleService.fork`, then disables tool calls via an * `onBeforeExecuteTool` veto listener (blocks every tool call with the * `toolApproval.formatDenyMessage`-formatted TOOL_CALL_DISABLED_MESSAGE) and - * appends the side-channel system reminder. Bound at Session scope — + * enqueues the side-channel reminder into the child's `reminderQueue`. Bound + * at Session scope — * `fork('main')` is a session-level operation, so the service injects the * session's `IAgentLifecycleService` directly rather than resolving it through * the main agent's accessor. Callers materialize the main agent first; @@ -13,7 +14,7 @@ */ import { LifecycleScope, ScopeActivation, registerScopedService } from '#/_base/di/scope'; -import { IAgentSystemReminderService } from '#/agent/systemReminder/systemReminder'; +import { IAgentReminderQueueService } from '#/agent/reminderQueue/reminderQueue'; import { IAgentToolApprovalService } from '#/agent/toolApproval/toolApproval'; import { denyToolExecution } from '#/agent/toolExecutor/beforeToolExecuteEvent'; import { IAgentToolExecutorService } from '#/agent/toolExecutor/toolExecutor'; @@ -31,11 +32,8 @@ export class SessionBtwService implements ISessionBtwService { async start(): Promise { const child = await this.lifecycle.fork('main'); child.accessor - .get(IAgentSystemReminderService) - ?.appendSystemReminder(SIDE_QUESTION_SYSTEM_REMINDER, { - kind: 'system_trigger', - name: 'btw', - }); + .get(IAgentReminderQueueService) + ?.enqueue({ variant: 'btw', content: SIDE_QUESTION_SYSTEM_REMINDER }); const reason = child.accessor.get(IAgentToolApprovalService)?.formatDenyMessage( TOOL_CALL_DISABLED_MESSAGE, diff --git a/packages/agent-core-v2/src/session/sessionInit/sessionInitService.ts b/packages/agent-core-v2/src/session/sessionInit/sessionInitService.ts index 05919d379b9..f003b2c7d92 100644 --- a/packages/agent-core-v2/src/session/sessionInit/sessionInitService.ts +++ b/packages/agent-core-v2/src/session/sessionInit/sessionInitService.ts @@ -31,7 +31,7 @@ import { IAgentProfileService } from '#/agent/profile/profile'; import { loadAgentsMdDetailed } from '#/agent/profile/context'; import { IAgentAgentsMdReminderService } from '#/agent/agentsMdReminder/agentsMdReminder'; import { IAgentPermissionModeService } from '#/agent/permissionMode/permissionMode'; -import { IAgentSystemReminderService } from '#/agent/systemReminder/systemReminder'; +import { IAgentReminderQueueService } from '#/agent/reminderQueue/reminderQueue'; import { IWireService } from '#/wire/wire'; import { ErrorCodes, Error2 } from '#/errors'; import { IAgentLifecycleService, MAIN_AGENT_ID } from '#/session/agentLifecycle/agentLifecycle'; @@ -116,11 +116,8 @@ export class SessionInitService implements ISessionInitService { .get(IAgentAgentsMdReminderService) .seedInjected(agentsMdPaths, this.sessionContext.cwd); main.accessor - .get(IAgentSystemReminderService) - .appendSystemReminder(initCompletionReminder(agentsMd), { - kind: 'injection', - variant: 'init', - }); + .get(IAgentReminderQueueService) + .enqueue({ variant: 'init', content: initCompletionReminder(agentsMd) }); await main.accessor.get(IWireService).flush(); } catch (error) { if (isUserCancellation(error) || isAbortError(error)) { diff --git a/packages/agent-core-v2/src/wire/wireService.ts b/packages/agent-core-v2/src/wire/wireService.ts index af2ee012398..71d2e518129 100644 --- a/packages/agent-core-v2/src/wire/wireService.ts +++ b/packages/agent-core-v2/src/wire/wireService.ts @@ -49,6 +49,13 @@ import { const MAX_DRAIN = 100; +/** + * Record types this build intentionally removed. Replay drops them silently: + * their absence is by design, so they must not surface as unexpected errors + * the way genuinely unknown types do. + */ +const RETIRED_RECORD_TYPES: ReadonlySet = new Set(['interruptionReminder.recorded']); + export class CycleError extends WireError { constructor(readonly depth: number, readonly opTypes: readonly string[]) { super( @@ -213,7 +220,9 @@ export class WireService extends Disposable implements IWireService { private replayRecord(record: WireRecord, index: number): void { const descriptor = OP_REGISTRY.get(record.type); if (descriptor === undefined) { - this.reportSkippedRecord(record.type, index); + if (!RETIRED_RECORD_TYPES.has(record.type)) { + this.reportSkippedRecord(record.type, index); + } return; } const payload = descriptor.schema.safeParse(wireRecordToPayload(record)); diff --git a/packages/agent-core-v2/test/agent/contextInjector/contextInjector.test.ts b/packages/agent-core-v2/test/agent/contextInjector/contextInjector.test.ts index 95e0be5fa02..6636d4a20be 100644 --- a/packages/agent-core-v2/test/agent/contextInjector/contextInjector.test.ts +++ b/packages/agent-core-v2/test/agent/contextInjector/contextInjector.test.ts @@ -20,6 +20,7 @@ import { IAgentContextMemoryService } from '#/agent/contextMemory/contextMemory' import type { ContextMessage } from '#/agent/contextMemory/types'; import { IAgentLoopService } from '#/agent/loop/loop'; import { IAgentProfileService } from '#/agent/profile/profile'; +import { IAgentReminderQueueService } from '#/agent/reminderQueue/reminderQueue'; import { IAgentStateService } from '#/agent/state/agentState'; import { AgentStateService } from '#/agent/state/agentStateService'; import { IAgentSystemReminderService } from '#/agent/systemReminder/systemReminder'; @@ -75,6 +76,11 @@ describe('AgentContextInjectorService', () => { reg.defineInstance(IAgentLoopService, stubLoopWithHooks()); reg.defineInstance(IWireService, stubWire()); reg.defineInstance(IAgentStateService, new AgentStateService()); + reg.defineInstance(IAgentReminderQueueService, { + _serviceBrand: undefined, + enqueue: () => '', + drain: () => {}, + }); reg.define(IAgentSystemReminderService, AgentSystemReminderService); reg.define(IAgentContextInjectorService, AgentContextInjectorService); }, @@ -314,4 +320,23 @@ describe('AgentContextInjectorService', () => { { kind: 'injection', variant: 'per_turn_test' }, ]); }); + + it('drains the reminder queue before running the providers', async () => { + const calls: string[] = []; + ix.stub(IAgentReminderQueueService, { + _serviceBrand: undefined, + enqueue: () => '', + drain: () => { + calls.push('drain'); + }, + }); + injector(ix).register('ordering_test', () => { + calls.push('provider'); + return undefined; + }); + + await injector(ix).inject(); + + expect(calls).toEqual(['drain', 'provider']); + }); }); diff --git a/packages/agent-core-v2/test/agent/contextMemory/context.test.ts b/packages/agent-core-v2/test/agent/contextMemory/context.test.ts index fdad1f1d1f9..220e2478aab 100644 --- a/packages/agent-core-v2/test/agent/contextMemory/context.test.ts +++ b/packages/agent-core-v2/test/agent/contextMemory/context.test.ts @@ -672,7 +672,7 @@ describe('Agent context', () => { ]); }); - it('removes a pre-anchor image compression reminder when undoing its prompt', async () => { + it('removes the image compression reminder delivered after its prompt when undoing it', async () => { profile.update({ activeToolNames: [] }); const caption = buildImageCompressionCaption({ original: { width: 3264, height: 666, byteLength: 344 * 1024, mimeType: 'image/png' }, @@ -685,9 +685,11 @@ describe('Agent context', () => { }); await ctx.untilTurnEnd(); + // Captions ride the once-reminder queue now and land after their host + // prompt; undo still cuts the pair together. expect(context.get()).toMatchObject([ - { origin: { kind: 'injection', variant: 'image_compression' } }, { origin: { kind: 'user' } }, + { origin: { kind: 'injection', variant: 'image_compression' } }, { role: 'assistant' }, ]); diff --git a/packages/agent-core-v2/test/agent/goal/goal.test.ts b/packages/agent-core-v2/test/agent/goal/goal.test.ts index 571aaf31c14..2128061b2a9 100644 --- a/packages/agent-core-v2/test/agent/goal/goal.test.ts +++ b/packages/agent-core-v2/test/agent/goal/goal.test.ts @@ -15,6 +15,7 @@ import { USER_PROMPT_ORIGIN } from '#/agent/contextMemory/types'; import { IAgentGoalService } from '#/agent/goal/goal'; import { IGoalDeadlineScheduler } from '#/agent/goal/goalDeadlineScheduler'; import { type AgentGoalService } from '#/agent/goal/goalService'; +import { IAgentReminderQueueService } from '#/agent/reminderQueue/reminderQueue'; import { UpdateGoalToolInputSchema } from '#/agent/tools/goal/update-goal/update-goal'; import { UpdateGoalTool } from '#/agent/tools/goal/update-goal/updateGoalTool'; import { @@ -500,8 +501,11 @@ describe('AgentGoalService', () => { const removed = await goals.cancelGoal(); expect(removed.status).toBe('active'); expect(goals.getGoal()).toEqual({ goal: null }); + // The cancellation reminder rides the once-reminder queue now; the + // boundary scheduler delivers it ahead of the next prompt. + ctx.get(IAgentReminderQueueService).drain(); const reminder = context.get().at(-1); - expect(reminder?.origin).toEqual({ kind: 'system_trigger', name: 'goal_cancelled' }); + expect(reminder?.origin).toEqual({ kind: 'injection', variant: 'goal_cancelled' }); expect(JSON.stringify(reminder?.content)).toContain('Ignore earlier active-goal reminders'); await expect(goals.cancelGoal()).rejects.toMatchObject({ code: ErrorCodes.GOAL_NOT_FOUND }); }); @@ -2001,7 +2005,7 @@ describe('AgentGoalService mid-turn budget stop', () => { const toolResultIndex = history.findIndex((message) => message.role === 'tool'); const reminderIndex = history.findIndex( (message) => - message.origin?.kind === 'system_trigger' && message.origin.name === 'goal_budget_stop', + message.origin?.kind === 'injection' && message.origin.variant === 'goal_budget_stop', ); expect(toolResultIndex).toBeGreaterThanOrEqual(0); expect(reminderIndex).toBeGreaterThan(toolResultIndex); @@ -2303,14 +2307,41 @@ describe('AgentGoalService fork boundaries', () => { ]); expect(goals.getGoal().goal).toBeNull(); + // Enqueued during replay normalization; the boundary scheduler delivers + // it ahead of the next prompt. + ctx.get(IAgentReminderQueueService).drain(); const reminder = context.get().at(-1); - expect(reminder?.origin).toEqual({ kind: 'system_trigger', name: 'goal_fork_cleared' }); + expect(reminder?.origin).toEqual({ kind: 'injection', variant: 'goal_fork_cleared' }); const text = JSON.stringify(reminder?.content); expect(text).toContain('This fork does not have a current goal.'); expect(text).toContain('Ignore earlier active-goal reminders from the source session.'); expect(text).toContain('Handle requests normally unless the user starts a new goal.'); }); + it('does not re-deliver a fork-cleared reminder recorded with the legacy system_trigger origin', async () => { + await restoreGoalRecords(ctx, goals, [ + { type: 'goal.create', goalId: 'source-goal', objective: 'source work' }, + { type: 'forked' }, + { + type: 'context.append_message', + message: { + role: 'user', + content: [ + { type: 'text', text: '\nlegacy fork cleared\n' }, + ], + toolCalls: [], + origin: { kind: 'system_trigger', name: 'goal_fork_cleared' }, + }, + }, + ]); + + // The legacy reminder clears the pending flag during replay, so the + // normalization enqueues nothing and the drain delivers nothing. + ctx.get(IAgentReminderQueueService).drain(); + expect(context.get()).toHaveLength(1); + expect(context.get()[0]?.origin).toEqual({ kind: 'system_trigger', name: 'goal_fork_cleared' }); + }); + it('does not append a fork-cleared reminder when the fork had no goal', async () => { await restoreGoalRecords(ctx, goals, [{ type: 'forked' }]); diff --git a/packages/agent-core-v2/test/agent/goal/goalOps.test.ts b/packages/agent-core-v2/test/agent/goal/goalOps.test.ts index b23e0da322e..af83708f469 100644 --- a/packages/agent-core-v2/test/agent/goal/goalOps.test.ts +++ b/packages/agent-core-v2/test/agent/goal/goalOps.test.ts @@ -22,10 +22,10 @@ import { GoalDeadlineSchedulerService } from '#/agent/goal/goalDeadlineScheduler import { AgentGoalService } from '#/agent/goal/goalService'; import { GoalModel } from '#/agent/goal/goalOps'; import { IAgentLoopService } from '#/agent/loop/loop'; +import { IAgentReminderQueueService } from '#/agent/reminderQueue/reminderQueue'; import { IAgentScopeContext } from '#/agent/scopeContext/scopeContext'; import { IAgentStateService } from '#/agent/state/agentState'; import { AgentStateService } from '#/agent/state/agentStateService'; -import { IAgentSystemReminderService } from '#/agent/systemReminder/systemReminder'; import { IAgentToolExecutorService } from '#/agent/toolExecutor/toolExecutor'; import { IAgentUsageService } from '#/agent/usage/usage'; import { ITelemetryService } from '#/app/telemetry/telemetry'; @@ -71,11 +71,12 @@ function createInjectorStub(): IAgentContextInjectorService { } as unknown as IAgentContextInjectorService; } -function createRemindersStub(): IAgentSystemReminderService { +function createReminderQueueStub(): IAgentReminderQueueService { return { _serviceBrand: undefined, - appendSystemReminder: () => undefined, - } as unknown as IAgentSystemReminderService; + enqueue: () => '', + drain: () => undefined, + } as unknown as IAgentReminderQueueService; } function createTelemetryStub(): ITelemetryService { @@ -124,7 +125,7 @@ function buildHost(key: string): { } as unknown as IAgentUsageService); ix.stub(IAgentContextMemoryService, createContextStub()); ix.stub(IAgentContextInjectorService, createInjectorStub()); - ix.stub(IAgentSystemReminderService, createRemindersStub()); + ix.stub(IAgentReminderQueueService, createReminderQueueStub()); ix.stub(ITelemetryService, createTelemetryStub()); ix.stub(IAgentToolExecutorService, createToolExecutorStub()); ix.stub(IConfigService, createConfigStub()); diff --git a/packages/agent-core-v2/test/agent/loop/loop.test.ts b/packages/agent-core-v2/test/agent/loop/loop.test.ts index 493e579781e..1abecc44779 100644 --- a/packages/agent-core-v2/test/agent/loop/loop.test.ts +++ b/packages/agent-core-v2/test/agent/loop/loop.test.ts @@ -999,13 +999,15 @@ describe('interruption reminder', () => { ).length; } - it('preserves the partial stream and appends one reminder on user cancel', async () => { + it('preserves the partial stream and delivers one reminder at the next boundary on user cancel', async () => { ctx.mockNextResponse({ type: 'text', text: 'partial answer' }, { type: 'text', text: ' more' }); const subscription = cancelOnFirstDelta(); const turn = (await loop.enqueue(nextTurnMessage('Hello')).assigned).turn; await expect(turn.result).resolves.toMatchObject({ type: 'cancelled' }); subscription.dispose(); + // Boundary delivery: the reminder is enqueued at turn.ended but only + // drained when the next turn starts. expect(ctx.contextData().history).toEqual([ expect.objectContaining({ role: 'user', content: [{ type: 'text', text: 'Hello' }] }), { @@ -1014,18 +1016,8 @@ describe('interruption reminder', () => { toolCalls: [], partial: true, }, - expect.objectContaining({ - role: 'user', - origin: { kind: 'injection', variant: 'interruption' }, - }), - ]); - expect(interruptionReminders()).toHaveLength(1); - expect(interruptionReminders()[0]!.content).toEqual([ - { - type: 'text', - text: '\nThe previous turn was interrupted by the user before completion; any partial output shown above is incomplete. The user\'s next message continues the conversation.\n', - }, ]); + expect(interruptionReminders()).toHaveLength(0); const cancelRecord = ctx.allEvents.find( (entry) => entry.type === '[wire]' && entry.event === 'turn.cancel', @@ -1043,6 +1035,20 @@ describe('interruption reminder', () => { interruptReason: 'user_cancelled', }); expect(contentPartRecordsIn(ctx)).toBe(1); + + ctx.mockNextResponse({ type: 'text', text: 'second answer' }); + await ctx.rpc.prompt({ input: [{ type: 'text', text: 'Next' }] }); + await ctx.untilTurnEnd(); + + expect(interruptionReminders()).toHaveLength(1); + expect(interruptionReminders()[0]!.content).toEqual([ + { + type: 'text', + text: '\nThe previous turn was interrupted by the user before completion; any partial output shown above is incomplete. The user\'s next message continues the conversation.\n', + }, + ]); + // The reminder lands ahead of the next user message. + expect(ctx.contextData().history.indexOf(interruptionReminders()[0]!)).toBe(2); }); it('writes one active cancellation when cancel repeats before the turn settles', async () => { @@ -1065,10 +1071,19 @@ describe('interruption reminder', () => { (entry) => entry.type === '[wire]' && entry.event === 'turn.cancel', ), ).toHaveLength(1); + expect( + ctx.allEvents.filter( + (entry) => entry.type === '[wire]' && entry.event === 'reminderQueue.enqueue', + ), + ).toHaveLength(1); + + ctx.mockNextResponse({ type: 'text', text: 'second answer' }); + await ctx.rpc.prompt({ input: [{ type: 'text', text: 'Next' }] }); + await ctx.untilTurnEnd(); expect(interruptionReminders()).toHaveLength(1); expect( ctx.allEvents.filter( - (entry) => entry.type === '[wire]' && entry.event === 'interruptionReminder.recorded', + (entry) => entry.type === '[wire]' && entry.event === 'reminderQueue.delivered', ), ).toHaveLength(1); }); @@ -1106,8 +1121,9 @@ describe('interruption reminder', () => { const turn = (await loop.enqueue(nextTurnMessage('Hello')).assigned).turn; await expect(turn.result).resolves.toMatchObject({ type: 'cancelled' }); subscription.dispose(); - expect(interruptionReminders()).toHaveLength(1); + expect(interruptionReminders()).toHaveLength(0); + // A duplicate enqueue for the same notice collapses at drain time. ctx.get(IEventBus).publish({ type: 'turn.ended', turnId: 99, @@ -1115,15 +1131,22 @@ describe('interruption reminder', () => { interruptReason: 'user_cancelled', }); + ctx.mockNextResponse({ type: 'text', text: 'second answer' }); + await ctx.rpc.prompt({ input: [{ type: 'text', text: 'Next' }] }); + await ctx.untilTurnEnd(); expect(interruptionReminders()).toHaveLength(1); }); it('appends no reminder when a queued turn is user-cancelled before starting', async () => { let release!: () => void; + let armed = true; loop.hooks.onWillBeginStep.register('test-hang-queued-cancel', async (hookCtx, next) => { - await new Promise((resolve) => { - release = resolve; - }); + if (armed) { + armed = false; + await new Promise((resolve) => { + release = resolve; + }); + } await next(); }); ctx.mockNextResponse({ type: 'text', text: 'unreached' }); @@ -1136,7 +1159,9 @@ describe('interruption reminder', () => { loop.cancel(active.id); await expect(active.result).resolves.toMatchObject({ type: 'cancelled' }); - expect(interruptionReminders()).toHaveLength(1); + // Only the active turn's cancel enqueues a reminder; nothing is drained + // until the next turn boundary. + expect(interruptionReminders()).toHaveLength(0); const queuedCancel = ctx.allEvents.find( (entry) => entry.type === '[wire]' && @@ -1144,6 +1169,11 @@ describe('interruption reminder', () => { (entry.args as { target?: string }).target === 'queued', ); expect(queuedCancel?.args).toMatchObject({ target: 'queued', reason: 'user_cancelled' }); + + ctx.mockNextResponse({ type: 'text', text: 'second answer' }); + await ctx.rpc.prompt({ input: [{ type: 'text', text: 'Next' }] }); + await ctx.untilTurnEnd(); + expect(interruptionReminders()).toHaveLength(1); }); it('sends the partial output and reminder ahead of the next user message', async () => { @@ -1167,17 +1197,25 @@ describe('interruption reminder', () => { `); }); - it('removes the reminder together with the undone turn', async () => { + it('keeps the pending once-entry across undo (delivery is a world fact)', async () => { ctx.mockNextResponse({ type: 'text', text: 'partial answer' }); const subscription = cancelOnFirstDelta(); await ctx.rpc.prompt({ input: [{ type: 'text', text: 'Hello' }] }); await ctx.untilTurnEnd(); subscription.dispose(); - expect(interruptionReminders()).toHaveLength(1); + // Enqueued but not yet drained: nothing in the history yet. + expect(interruptionReminders()).toHaveLength(0); await ctx.undoHistory(1); expect(ctx.contextData().history).toEqual([]); + + // The ledger is not checkpointed: undo does not revoke the pending + // delivery, which lands at the next boundary. + ctx.mockNextResponse({ type: 'text', text: 'second answer' }); + await ctx.rpc.prompt({ input: [{ type: 'text', text: 'Next' }] }); + await ctx.untilTurnEnd(); + expect(interruptionReminders()).toHaveLength(1); }); it('preserves partial thinking on user cancel', async () => { @@ -1195,7 +1233,7 @@ describe('interruption reminder', () => { toolCalls: [], partial: true, }); - expect(interruptionReminders()).toHaveLength(1); + expect(interruptionReminders()).toHaveLength(0); }); it('records no partial content when the stream only produced whitespace', async () => { @@ -1209,7 +1247,6 @@ describe('interruption reminder', () => { expect(ctx.contextData().history).toEqual([ expect.objectContaining({ role: 'user' }), { role: 'assistant', content: [], toolCalls: [], partial: true }, - expect.objectContaining({ origin: { kind: 'injection', variant: 'interruption' } }), ]); }); @@ -1219,8 +1256,10 @@ describe('interruption reminder', () => { const firstTurn = (await loop.enqueue(nextTurnMessage('Hello')).assigned).turn; await expect(firstTurn.result).resolves.toMatchObject({ type: 'cancelled' }); first.dispose(); - expect(interruptionReminders()).toHaveLength(1); + expect(interruptionReminders()).toHaveLength(0); + // The pending entry drains as the retry turn starts; the retry turn's own + // cancel enqueues a duplicate that collapses at the next drain. ctx.mockNextResponse({ type: 'text', text: 'retried answer' }); const onStepStarted = ctx.get(IEventBus).subscribe('turn.step.started', () => { loop.cancel(); @@ -1255,11 +1294,22 @@ describe('interruption reminder', () => { await expect(turn.result).resolves.toMatchObject({ type: 'cancelled' }); expect(contentPartRecordsIn(local)).toBe(2); + // The reminder drains at the next turn boundary, ahead of the next + // user message. + expect(remindersIn(local)).toHaveLength(0); + + local.mockNextResponse({ type: 'text', text: 'follow-up answer' }); + await local.rpc.prompt({ input: [{ type: 'text', text: 'again' }] }); + await local.untilTurnEnd(); const history = local.contextData().history; expect(remindersIn(local)).toHaveLength(1); - expect(history.at(-1)?.origin).toEqual({ kind: 'injection', variant: 'interruption' }); - expect(history.at(-2)?.role).toBe('tool'); + const reminderIndex = history.indexOf(remindersIn(local)[0]!); + expect(history[reminderIndex - 1]?.role).toBe('tool'); + expect(history[reminderIndex + 1]).toMatchObject({ + role: 'user', + content: [{ type: 'text', text: 'again' }], + }); await local.expectResumeMatches(); } finally { diff --git a/packages/agent-core-v2/test/agent/plugin/agentPlugin.test.ts b/packages/agent-core-v2/test/agent/plugin/agentPlugin.test.ts index 953af0afdb1..a11ea1e755d 100644 --- a/packages/agent-core-v2/test/agent/plugin/agentPlugin.test.ts +++ b/packages/agent-core-v2/test/agent/plugin/agentPlugin.test.ts @@ -75,23 +75,6 @@ function findPluginSessionStartMessages(ctx: TestAgentContext) { ); } -function waitForPluginSessionStartMessage(ctx: TestAgentContext): Promise { - return new Promise((resolve) => { - const subscription = ctx.get(IEventBus).subscribe('context.spliced', (event) => { - if ( - event.messages.some( - (message) => - message.origin?.kind === 'injection' && - message.origin.variant === 'plugin_session_start', - ) - ) { - subscription.dispose(); - resolve(); - } - }); - }); -} - function messageText(message: { readonly content: readonly { readonly type: string; readonly text?: string }[] }): string { return message.content.map((part) => (part.type === 'text' ? (part.text ?? '') : '')).join(''); } @@ -223,9 +206,11 @@ describe('AgentPluginService plugin session-start wiring', () => { expect(findPluginSessionStartMessages(ctx)).toHaveLength(1); - const appended = waitForPluginSessionStartMessage(ctx); + // The catalog change only marks the provider dirty; the supersedes + // render lands at the next injection boundary. sinkChange.fire('plugin'); - await appended; + expect(findPluginSessionStartMessages(ctx)).toHaveLength(1); + await injectRegistered(ctx); const messages = findPluginSessionStartMessages(ctx); expect(messages.length).toBeGreaterThanOrEqual(2); @@ -269,10 +254,9 @@ describe('AgentPluginService plugin session-start wiring', () => { await injectRegistered(ctx); expect(findPluginSessionStartMessages(ctx)).toHaveLength(1); - const appended = waitForPluginSessionStartMessage(ctx); sinkChange.fire('user'); sinkChange.fire('plugin'); - await appended; + await injectRegistered(ctx); expect(findPluginSessionStartMessages(ctx)).toHaveLength(2); sinkChange.dispose(); diff --git a/packages/agent-core-v2/test/agent/prompt/promptService.test.ts b/packages/agent-core-v2/test/agent/prompt/promptService.test.ts index 7336d31c915..f0a2f785494 100644 --- a/packages/agent-core-v2/test/agent/prompt/promptService.test.ts +++ b/packages/agent-core-v2/test/agent/prompt/promptService.test.ts @@ -17,6 +17,10 @@ import { IAgentFullCompactionService } from '#/agent/fullCompaction/fullCompacti import { IAgentLoopService } from '#/agent/loop/loop'; import { IAgentPromptService } from '#/agent/prompt/prompt'; import { AgentPromptService } from '#/agent/prompt/promptService'; +import { + IAgentReminderQueueService, + type OnceReminderInput, +} from '#/agent/reminderQueue/reminderQueue'; import { IAgentSystemReminderService } from '#/agent/systemReminder/systemReminder'; import { AgentSystemReminderService } from '#/agent/systemReminder/systemReminderService'; import { IAgentToolExecutorService } from '#/agent/toolExecutor/toolExecutor'; @@ -34,6 +38,34 @@ function message(text: string): ContextMessage { return { role: 'user', content: [{ type: 'text', text }], toolCalls: [], origin: { kind: 'user' } }; } +/** + * In-memory once-reminder queue honoring the production contract: enqueue + * collects pending entries, drain delivers them FIFO through the real + * system-reminder write head. The stub wire behind this harness cannot back + * the persisted `AgentReminderQueueService` ledger, so the fake mirrors it. + */ +function createReminderQueueFake(reminders: IAgentSystemReminderService) { + const pending: Array<{ id: string } & OnceReminderInput> = []; + return { + _serviceBrand: undefined, + pending, + enqueue(input: OnceReminderInput): string { + const entry = { id: `reminder-${pending.length + 1}`, ...input }; + pending.push(entry); + return entry.id; + }, + drain(): void { + for (const entry of pending.splice(0)) { + reminders.appendSystemReminder(entry.content, { + kind: 'injection', + variant: entry.variant, + ownerPromptId: entry.ownerPromptId, + }); + } + }, + } satisfies IAgentReminderQueueService & { pending: Array<{ id: string } & OnceReminderInput> }; +} + function harness() { const disposables = new DisposableStore(); onTestFinished(() => disposables.dispose()); @@ -59,7 +91,9 @@ function harness() { reg.define(IAgentPromptService, AgentPromptService); } }); - return { prompt: ix.get(IAgentPromptService), loop, context, fullCompaction, eventBus: ix.get(IEventBus) }; + const reminderQueue = createReminderQueueFake(ix.get(IAgentSystemReminderService)); + ix.set(IAgentReminderQueueService, reminderQueue); + return { prompt: ix.get(IAgentPromptService), loop, context, fullCompaction, eventBus: ix.get(IEventBus), reminderQueue }; } describe('AgentPromptService', () => { @@ -134,6 +168,36 @@ describe('AgentPromptService', () => { await expect(handle.completion).resolves.toMatchObject({ state: 'blocked' }); }); + it('delivers a blocked prompt’s compression captions right after their host message', async () => { + const { prompt, context, reminderQueue } = harness(); + prompt.hooks.onBeforeSubmitPrompt.register('block', async (ctx, next) => { ctx.block = true; await next(); }); + const handle = await prompt.enqueue({ + id: 'prompt-caption', + message: message( + 'Image compressed to fit model limits: 800x600look at this', + ), + }); + await expect(handle.completion).resolves.toMatchObject({ state: 'blocked' }); + + // A blocked prompt starts no turn, so the service enqueues the caption + // and drains immediately — the reminder lands after its host message. + expect(reminderQueue.pending).toEqual([]); + const history = context.get(); + expect(history).toHaveLength(2); + expect(history[0]?.origin).toEqual({ kind: 'user' }); + expect(history[0]?.content).toEqual([{ type: 'text', text: 'look at this' }]); + expect(history[1]?.origin).toEqual({ + kind: 'injection', + variant: 'image_compression', + ownerPromptId: 'prompt-caption', + }); + const captionPart = history[1]?.content[0]; + expect(captionPart?.type).toBe('text'); + expect((captionPart as { text: string }).text).toContain( + 'Image compressed to fit model limits: 800x600', + ); + }); + it('settles the prompt as failed when the loop throws on launch', async () => { const { prompt, loop } = harness(); vi.spyOn(loop, 'enqueue').mockImplementation(() => { diff --git a/packages/agent-core-v2/test/agent/reminderQueue/reminderQueue.test.ts b/packages/agent-core-v2/test/agent/reminderQueue/reminderQueue.test.ts new file mode 100644 index 00000000000..8996f145e5e --- /dev/null +++ b/packages/agent-core-v2/test/agent/reminderQueue/reminderQueue.test.ts @@ -0,0 +1,202 @@ +/** + * Scenario: once-reminder queue — persistence, FIFO delivery, exactly-once + * crash-window reconciliation, and ledger immunity to undo. + * + * Exercises the real queue service against a real wire backed by an in-memory + * journal and a stub context. Run: `pnpm --filter @moonshot-ai/agent-core-v2 + * exec vitest run test/agent/reminderQueue/reminderQueue.test.ts`. + */ + +import { afterEach, beforeEach, describe, expect, it } from 'vitest'; + +import { DisposableStore } from '#/_base/di/lifecycle'; +import { createServices, type TestInstantiationService } from '#/_base/di/test'; +import { IAgentContextMemoryService } from '#/agent/contextMemory/contextMemory'; +import { CHECKPOINTED_MODELS } from '#/agent/contextMemory/conversationTime'; +import { IAgentReminderQueueService } from '#/agent/reminderQueue/reminderQueue'; +import { ReminderQueueModel } from '#/agent/reminderQueue/reminderQueueOps'; +import { AgentReminderQueueService } from '#/agent/reminderQueue/reminderQueueService'; +import { IAgentSystemReminderService } from '#/agent/systemReminder/systemReminder'; +import { AgentSystemReminderService } from '#/agent/systemReminder/systemReminderService'; +import { IWireService } from '#/wire/wire'; +import type { WireRecord } from '#/wire/record'; +import { registerContextMemoryServices } from '../contextMemory/stubs'; +import { recordingWireLog, registerTestAgentWire, restoreTestAgentWire } from '../../wire/stubs'; + +const SCOPE = 'reminder-queue-test'; + +describe('AgentReminderQueueService', () => { + let disposables: DisposableStore; + let ix: TestInstantiationService; + let records: WireRecord[]; + let log: ReturnType; + let wire: IWireService; + let context: IAgentContextMemoryService; + let queue: IAgentReminderQueueService; + let reminders: IAgentSystemReminderService; + + beforeEach(() => { + disposables = new DisposableStore(); + records = []; + log = recordingWireLog(records); + ix = createServices(disposables, { + base: [registerContextMemoryServices], + strict: true, + additionalServices: (reg) => { + reg.define(IAgentSystemReminderService, AgentSystemReminderService); + reg.define(IAgentReminderQueueService, AgentReminderQueueService); + }, + }); + wire = registerTestAgentWire(ix, SCOPE, { log }); + context = ix.get(IAgentContextMemoryService); + queue = ix.get(IAgentReminderQueueService); + reminders = ix.get(IAgentSystemReminderService); + }); + + afterEach(() => { + disposables.dispose(); + }); + + function injectionMessages() { + return context.get().filter((m) => m.origin?.kind === 'injection'); + } + + it('is not a checkpointed model (undo never revives the delivery ledger)', () => { + expect(CHECKPOINTED_MODELS.some((model) => model.name === ReminderQueueModel.name)).toBe(false); + }); + + it('persists enqueued entries and replays them into the pending ledger', async () => { + const journal = [ + { + type: 'reminderQueue.enqueue', + entry: { id: 'e1', variant: 'interruption', content: 'first' }, + }, + { + type: 'reminderQueue.enqueue', + entry: { id: 'e2', variant: 'init', content: 'second', ownerPromptId: 'p1' }, + }, + ] as unknown as WireRecord[]; + + await restoreTestAgentWire(wire, log, SCOPE, journal); + + const pending = wire.getModel(ReminderQueueModel); + expect(pending).toHaveLength(2); + expect(pending.map((entry) => [entry.variant, entry.content])).toEqual([ + ['interruption', 'first'], + ['init', 'second'], + ]); + expect(pending[1]!.ownerPromptId).toBe('p1'); + }); + + it('drains pending entries FIFO, wraps them as system reminders, and clears the ledger', () => { + queue.enqueue({ variant: 'a', content: 'first' }); + queue.enqueue({ variant: 'b', content: 'second', ownerPromptId: 'p1' }); + + queue.drain(); + + const messages = context.get(); + expect(messages).toHaveLength(2); + expect(messages[0]).toMatchObject({ + role: 'user', + content: [{ type: 'text', text: '\nfirst\n' }], + origin: { kind: 'injection', variant: 'a' }, + }); + expect(messages[1]).toMatchObject({ + content: [{ type: 'text', text: '\nsecond\n' }], + origin: { kind: 'injection', variant: 'b', ownerPromptId: 'p1' }, + }); + expect(wire.getModel(ReminderQueueModel)).toEqual([]); + expect(records.map((record) => record.type)).toEqual([ + 'reminderQueue.enqueue', + 'reminderQueue.enqueue', + 'reminderQueue.delivered', + 'reminderQueue.delivered', + ]); + }); + + it('collapses consecutive identical entries into a single reminder', () => { + queue.enqueue({ variant: 'interruption', content: 'same' }); + queue.enqueue({ variant: 'interruption', content: 'same' }); + + queue.drain(); + + expect(injectionMessages()).toHaveLength(1); + expect(wire.getModel(ReminderQueueModel)).toEqual([]); + }); + + it('delivers entries left pending by a crash (restore drain)', async () => { + const journal = [ + { + type: 'reminderQueue.enqueue', + entry: { id: 'e1', variant: 'interruption', content: 'pending at crash' }, + }, + ] as unknown as WireRecord[]; + + await restoreTestAgentWire(wire, log, SCOPE, journal); + + expect(wire.getModel(ReminderQueueModel)).toHaveLength(1); + queue.drain(); + expect(injectionMessages()).toHaveLength(1); + expect(injectionMessages()[0]).toMatchObject({ + content: [{ type: 'text', text: '\npending at crash\n' }], + origin: { kind: 'injection', variant: 'interruption' }, + }); + expect(wire.getModel(ReminderQueueModel)).toEqual([]); + }); + + it('reconciles an appended-but-unrecorded delivery after restore without duplicating', async () => { + // The reminder append landed (persisted as context.append_message, so it + // sits at the conversation tail after the context replays), but the + // delivered record never reached the journal. + reminders.appendSystemReminder('crash window', { + kind: 'injection', + variant: 'interruption', + }); + const journal = [ + { + type: 'reminderQueue.enqueue', + entry: { id: 'e1', variant: 'interruption', content: 'crash window' }, + }, + ] as unknown as WireRecord[]; + + await restoreTestAgentWire(wire, log, SCOPE, journal); + expect(wire.getModel(ReminderQueueModel)).toHaveLength(1); + + queue.drain(); + + expect(injectionMessages()).toHaveLength(1); + expect(wire.getModel(ReminderQueueModel)).toEqual([]); + }); + + it('skips vacuous partial assistant messages when matching the conversation tail', async () => { + reminders.appendSystemReminder('crash window', { + kind: 'injection', + variant: 'interruption', + }); + context.append({ role: 'assistant', content: [], toolCalls: [], partial: true }); + const journal = [ + { + type: 'reminderQueue.enqueue', + entry: { id: 'e1', variant: 'interruption', content: 'crash window' }, + }, + ] as unknown as WireRecord[]; + + await restoreTestAgentWire(wire, log, SCOPE, journal); + + queue.drain(); + + expect(injectionMessages()).toHaveLength(1); + expect(wire.getModel(ReminderQueueModel)).toEqual([]); + }); + + it('appends a fresh reminder when the tail holds an older, different one', () => { + queue.enqueue({ variant: 'interruption', content: 'earlier' }); + queue.drain(); + queue.enqueue({ variant: 'interruption', content: 'latest' }); + + queue.drain(); + + expect(injectionMessages()).toHaveLength(2); + expect(wire.getModel(ReminderQueueModel)).toEqual([]); + }); +}); diff --git a/packages/agent-core-v2/test/agent/swarm/swarm.test.ts b/packages/agent-core-v2/test/agent/swarm/swarm.test.ts index 3c209b4ea27..b54f7231cab 100644 --- a/packages/agent-core-v2/test/agent/swarm/swarm.test.ts +++ b/packages/agent-core-v2/test/agent/swarm/swarm.test.ts @@ -4,14 +4,21 @@ import { makeAgentScopeContext } from '#/agent/scopeContext/scopeContext'; import { SyncDescriptor } from '#/_base/di/descriptors'; import { DisposableStore } from '#/_base/di/lifecycle'; import { TestInstantiationService } from '#/_base/di/test'; +import { IAgentContextInjectorService } from '#/agent/contextInjector/contextInjector'; +import { AgentContextInjectorService } from '#/agent/contextInjector/contextInjectorService'; import { IAgentContextMemoryService } from '#/agent/contextMemory/contextMemory'; +import type { ContextMessage } from '#/agent/contextMemory/types'; import { DEFAULT_SUBAGENT_TIMEOUT_MS } from '#/session/subagent/configSection'; import { IAgentLifecycleService } from '#/session/agentLifecycle/agentLifecycle'; import { ISessionSwarmService, type SessionSwarmRunResult, type SessionSwarmTask } from '#/session/swarm/sessionSwarm'; +import { IAgentReminderQueueService } from '#/agent/reminderQueue/reminderQueue'; +import { IAgentStateService } from '#/agent/state/agentState'; +import { AgentStateService } from '#/agent/state/agentStateService'; import { IAgentSystemReminderService } from '#/agent/systemReminder/systemReminder'; import { AgentSystemReminderService } from '#/agent/systemReminder/systemReminderService'; import { IAgentSwarmService } from '#/agent/swarm/swarm'; import { AgentSwarmService } from '#/agent/swarm/swarmService'; +import SWARM_MODE_ENTER_REMINDER from '../../../src/agent/swarm/enter-reminder.md?raw'; import { SwarmModel } from '#/agent/swarm/swarmOps'; import { SECONDARY_DERIVED_MODEL_ID } from '#/app/kosongConfig/secondaryModelOverlay'; import { AgentSwarmToolInputSchema } from '#/agent/tools/agent-swarm/agent-swarm'; @@ -36,6 +43,7 @@ import { InMemoryStorageService } from '#/persistence/backends/memory/inMemorySt import { IAppendLogStore } from '#/persistence/interface/appendLogStore'; import { IFileSystemStorageService } from '#/persistence/interface/storage'; import { AGENT_WIRE_RECORD_KEY, type WireRecord } from '#/wire/record'; +import { IWireService } from '#/wire/wire'; import { type DomainEvent, IEventBus } from '#/app/event/eventBus'; import { EventBusService } from '#/app/event/eventBusService'; @@ -156,11 +164,18 @@ describe('AgentSwarmService', () => { beforeEach(() => { disposables = new DisposableStore(); ix = disposables.add(new TestInstantiationService()); - ix.stub(IAgentContextMemoryService, stubContextMemory()); + ix.set(IEventBus, new SyncDescriptor(EventBusService)); + ix.stub(IAgentContextMemoryService, stubContextMemory(ix.get(IEventBus))); ix.stub(IFileSystemStorageService, new InMemoryStorageService()); ix.set(IAppendLogStore, new SyncDescriptor(AppendLogStore)); - ix.set(IEventBus, new SyncDescriptor(EventBusService)); ix.stub(IAgentLoopService, stubLoopWithHooks()); + ix.stub(IAgentReminderQueueService, { + _serviceBrand: undefined, + enqueue: () => '', + drain: () => {}, + }); + ix.set(IAgentStateService, new AgentStateService()); + ix.set(IAgentContextInjectorService, new SyncDescriptor(AgentContextInjectorService)); ix.set(IAgentToolRegistryService, new SyncDescriptor(AgentToolRegistryService)); ix.stub(IAgentLifecycleService, {}); ix.stub(ISessionSwarmService, { @@ -206,13 +221,115 @@ describe('AgentSwarmService', () => { swarm.exit(); expect(swarm.isActive).toBe(false); + // Exit no longer pops the enter reminder out of the context — reminders + // are pure state renders through the injector, so no splice is emitted. expect(events).toEqual([ { type: 'agent.status.updated', swarmMode: true }, { type: 'agent.status.updated', swarmMode: false }, - { type: 'context.spliced', start: 0, deleteCount: 1, messages: [] }, ]); }); + it('renders the enter guidance on enter and the exit guidance on exit through the injector', async () => { + const swarm = ix.get(IAgentSwarmService); + const context = ix.get(IAgentContextMemoryService); + const injector = ix.get(IAgentContextInjectorService) as unknown as { + inject(): Promise; + }; + const textOf = (message: ContextMessage | undefined): string => + message?.content.map((part) => (part.type === 'text' ? part.text : '')).join('') ?? ''; + + swarm.enter('manual'); + await injector.inject(); + + let reminder = context.get().at(-1); + expect(reminder?.origin).toEqual({ kind: 'injection', variant: 'swarm_mode' }); + expect(textOf(reminder)).toContain('You are now in "agent swarm" mode.'); + + // No transition and the guidance is still live: nothing re-renders. + await injector.inject(); + expect(context.get()).toHaveLength(1); + + swarm.exit(); + await injector.inject(); + + reminder = context.get().at(-1); + expect(reminder?.origin).toEqual({ kind: 'injection', variant: 'swarm_mode' }); + expect(textOf(reminder)).toContain('Swarm Mode has ended.'); + // The exit render replaces, not appends to, the enter guidance — and the + // enter copy is never spliced out. + expect(context.get()).toHaveLength(2); + }); + + it('renders no reminder at all for tool-triggered swarms', async () => { + const swarm = ix.get(IAgentSwarmService); + const context = ix.get(IAgentContextMemoryService); + const injector = ix.get(IAgentContextInjectorService) as unknown as { + inject(): Promise; + }; + + swarm.enter('tool'); + await injector.inject(); + swarm.exit(); + await injector.inject(); + + expect(context.get()).toHaveLength(0); + }); + + it('does not duplicate the enter guidance on resume while it is still live in history', async () => { + const swarm = ix.get(IAgentSwarmService); + const context = ix.get(IAgentContextMemoryService); + const injector = ix.get(IAgentContextInjectorService) as unknown as { + inject(): Promise; + }; + + // A previous process rendered the guidance; replay rebuilds the trigger + // and the restore seed recovers the rendered flag from the history. + ix.get(IAgentSystemReminderService).appendSystemReminder(SWARM_MODE_ENTER_REMINDER, { + kind: 'injection', + variant: 'swarm_mode', + }); + await restoreTestAgentWire( + ix.get(IWireService), + ix.get(IAppendLogStore), + testWireScope('wire', 'swarm-test'), + [{ type: 'swarm_mode.enter', trigger: 'manual' }], + ); + + await injector.inject(); + + expect(swarm.isActive).toBe(true); + expect(context.get()).toHaveLength(1); + }); + + it('renders a corrective exit reminder when resume finds live enter guidance but swarm is off', async () => { + const swarm = ix.get(IAgentSwarmService); + const context = ix.get(IAgentContextMemoryService); + const injector = ix.get(IAgentContextInjectorService) as unknown as { + inject(): Promise; + }; + + ix.get(IAgentSystemReminderService).appendSystemReminder(SWARM_MODE_ENTER_REMINDER, { + kind: 'injection', + variant: 'swarm_mode', + }); + await restoreTestAgentWire( + ix.get(IWireService), + ix.get(IAppendLogStore), + testWireScope('wire', 'swarm-test'), + [{ type: 'swarm_mode.enter', trigger: 'manual' }, { type: 'swarm_mode.exit' }], + ); + + await injector.inject(); + + expect(swarm.isActive).toBe(false); + const reminder = context.get().at(-1); + expect(reminder?.origin).toEqual({ kind: 'injection', variant: 'swarm_mode' }); + const text = + reminder?.content.map((part) => (part.type === 'text' ? part.text : '')).join('') ?? ''; + expect(text).toContain('Swarm Mode has ended.'); + expect(context.get()).toHaveLength(2); + }); + it('dispatch persists enter/exit records and replay rebuilds the trigger (silent)', async () => { const swarm = ix.get(IAgentSwarmService); swarm.enter('manual'); diff --git a/packages/agent-core-v2/test/agent/toolSelect/dynamicTools.test.ts b/packages/agent-core-v2/test/agent/toolSelect/dynamicTools.test.ts index b0ba9efa5a5..7d4335f9681 100644 --- a/packages/agent-core-v2/test/agent/toolSelect/dynamicTools.test.ts +++ b/packages/agent-core-v2/test/agent/toolSelect/dynamicTools.test.ts @@ -14,7 +14,7 @@ import { foldAnnouncedToolNames, isDynamicToolSchemaMessage, isLoadableToolsAnnouncement, - LOADABLE_TOOLS_TRIGGER, + LOADABLE_TOOLS_VARIANT, renderLoadableToolsAnnouncement, stripDynamicToolContext, } from '#/agent/toolSelect/dynamicTools'; @@ -26,7 +26,7 @@ function announcement(added: readonly string[], removed: readonly string[]): Con role: 'user', content: [{ type: 'text', text }], toolCalls: [], - origin: { kind: 'system_trigger', name: LOADABLE_TOOLS_TRIGGER }, + origin: { kind: 'injection', variant: LOADABLE_TOOLS_VARIANT }, }; } diff --git a/packages/agent-core-v2/test/agent/toolSelect/toolSelectService.test.ts b/packages/agent-core-v2/test/agent/toolSelect/toolSelectService.test.ts index d3009bfb0df..38cb15d35a3 100644 --- a/packages/agent-core-v2/test/agent/toolSelect/toolSelectService.test.ts +++ b/packages/agent-core-v2/test/agent/toolSelect/toolSelectService.test.ts @@ -22,6 +22,8 @@ import { IAgentContextMemoryService } from '#/agent/contextMemory/contextMemory' import type { UndoCut } from '#/agent/contextMemory/contextOps'; import type { ContextMessage } from '#/agent/contextMemory/types'; import type { LoopRecordedEvent } from '#/agent/contextMemory/loopEventFold'; +import { IAgentContextInjectorService } from '#/agent/contextInjector/contextInjector'; +import { AgentContextInjectorService } from '#/agent/contextInjector/contextInjectorService'; import { IAgentLoopService, type AfterStepContext, @@ -33,6 +35,7 @@ import { } from '#/agent/loop/loop'; import type { StepRequest } from '#/agent/loop/stepRequest'; import { IAgentProfileService } from '#/agent/profile/profile'; +import { IAgentReminderQueueService } from '#/agent/reminderQueue/reminderQueue'; import { IAgentToolPolicyService } from '#/agent/toolPolicy/toolPolicy'; import { IAgentScopeContext, makeAgentScopeContext } from '#/agent/scopeContext/scopeContext'; import { IAgentSystemReminderService } from '#/agent/systemReminder/systemReminder'; @@ -46,7 +49,7 @@ import { IAgentToolExecutorService, type ToolExecutionResult } from '#/agent/too import { AgentToolExecutorService } from '#/agent/toolExecutor/toolExecutorService'; import { IAgentToolRegistryService } from '#/agent/toolRegistry/toolRegistry'; import { AgentToolRegistryService } from '#/agent/toolRegistry/toolRegistryService'; -import { DYNAMIC_TOOL_SCHEMA_VARIANT, LOADABLE_TOOLS_TRIGGER } from '#/agent/toolSelect/dynamicTools'; +import { DYNAMIC_TOOL_SCHEMA_VARIANT, LOADABLE_TOOLS_VARIANT } from '#/agent/toolSelect/dynamicTools'; import { TOOL_SELECT_FLAG_ID } from '#/agent/toolSelect/flag'; import { IAgentToolSelectService, SELECT_TOOLS_TOOL_NAME } from '#/agent/toolSelect/toolSelect'; import { IAgentToolSelectAnnouncementsService } from '#/agent/toolSelect/toolSelectAnnouncements'; @@ -54,10 +57,11 @@ import { AgentToolSelectAnnouncementsService } from '#/agent/toolSelect/toolSele import { AgentToolSelectService } from '#/agent/toolSelect/toolSelectService'; import { SelectToolsTool } from '#/agent/tools/select-tools/selectToolsTool'; import { ITelemetryService } from '#/app/telemetry/telemetry'; +import { IWireService } from '#/wire/wire'; import { registerLogServices } from '../../_base/log/stubs'; import { recordingTelemetry } from '../../app/telemetry/stubs'; import { registerStateServices } from '../../state/stubs'; -import { stubToolExecutor } from '../loop/stubs'; +import { stubToolExecutor, stubWire } from '../loop/stubs'; import { registerToolResultTruncationServices } from '../toolResultTruncation/stubs'; const MCP_ALPHA = 'mcp__srv__alpha'; @@ -285,7 +289,8 @@ class FakeContextMemory implements IAgentContextMemoryService { role: 'user', content: [{ type: 'text', text: `\n${content.trim()}\n` }], toolCalls: [], - origin: { kind: 'system_trigger', name: LOADABLE_TOOLS_TRIGGER }, + // v1 journal compat: older announcements carry the system_trigger origin. + origin: { kind: 'system_trigger', name: LOADABLE_TOOLS_VARIANT }, }); } } @@ -319,6 +324,13 @@ function registerSharedServices( reg.definePartialInstance(IFlagService, { enabled: (id: string) => (id === TOOL_SELECT_FLAG_ID ? flagEnabled : false), }); + reg.defineInstance(IWireService, stubWire()); + reg.defineInstance(IAgentReminderQueueService, { + _serviceBrand: undefined, + enqueue: () => '', + drain: () => {}, + }); + reg.define(IAgentContextInjectorService, AgentContextInjectorService); reg.define(IAgentToolRegistryService, AgentToolRegistryService); reg.define(IAgentToolSelectService, AgentToolSelectService); reg.define(IAgentToolSelectAnnouncementsService, AgentToolSelectAnnouncementsService); @@ -400,6 +412,14 @@ function registerUser( return registration; } +function announcementText(message: ContextMessage): string { + return message.content.map((part) => (part.type === 'text' ? part.text : '')).join(''); +} + +function isNewAnnouncement(message: ContextMessage): boolean { + return message.origin?.kind === 'injection' && message.origin.variant === LOADABLE_TOOLS_VARIANT; +} + async function announce(h: Harness, step = 1): Promise { const before = h.contextMemory.appended.length; await h.loop.hooks.onWillBeginStep.run({ @@ -407,18 +427,22 @@ async function announce(h: Harness, step = 1): Promise { step, signal: new AbortController().signal, }); - const announcement = h.contextMemory.appended - .slice(before) - .find( - (message) => - message.origin?.kind === 'system_trigger' && - message.origin.name === LOADABLE_TOOLS_TRIGGER, - ); + const announcement = h.contextMemory.appended.slice(before).find(isNewAnnouncement); + h.contextMemory.landAppended(); + if (announcement === undefined) return undefined; + return announcementText(announcement); +} + +async function announceAfterCompaction(h: Harness): Promise { + const injector = h.ix.get(IAgentContextInjectorService) as unknown as { + injectAfterCompaction(): Promise; + }; + const before = h.contextMemory.appended.length; + await injector.injectAfterCompaction(); + const announcement = h.contextMemory.appended.slice(before).find(isNewAnnouncement); h.contextMemory.landAppended(); if (announcement === undefined) return undefined; - return announcement.content - .map((part) => (part.type === 'text' ? part.text : '')) - .join(''); + return announcementText(announcement); } async function execute( @@ -1015,9 +1039,8 @@ describe('AgentToolSelectService loadable-tools announcements', () => { expect(await announce(h, 2)).toBeUndefined(); h.contextMemory.clear(); - h.eventBus.emit('compaction.completed'); - - const reannounced = await announce(h, 2); + // The compaction pipeline re-arms per-turn providers through the injector. + const reannounced = await announceAfterCompaction(h); expect(reannounced).toContain(`\n${MCP_ALPHA}\n${MCP_BETA}\n`); }); diff --git a/packages/agent-core-v2/test/index.test.ts b/packages/agent-core-v2/test/index.test.ts index 1c8719f2b66..5879794f9ac 100644 --- a/packages/agent-core-v2/test/index.test.ts +++ b/packages/agent-core-v2/test/index.test.ts @@ -6,6 +6,7 @@ import { IAgentContextMemoryService, IAgentTokenCountingService, IAgentGoalService, + IAgentReminderQueueService, type ContextMessage, type WireRecord, } from '#/index'; @@ -87,7 +88,8 @@ const V2_RECORD_TYPES: ReadonlySet = new Set([ 'interaction.request', 'interaction.resolved', 'plan.revision', - 'interruptionReminder.recorded', + 'reminderQueue.enqueue', + 'reminderQueue.delivered', 'turn.ended', ]); @@ -404,8 +406,9 @@ describe('AgentRecords persistence metadata', () => { 'forked', ]); expect(ctx.get(IAgentGoalService).getGoal().goal).toBeNull(); + await ctx.get(IAgentReminderQueueService).drain(); const reminder = context.get().at(-1); - expect(reminder?.origin).toEqual({ kind: 'system_trigger', name: 'goal_fork_cleared' }); + expect(reminder?.origin).toEqual({ kind: 'injection', variant: 'goal_fork_cleared' }); expect(JSON.stringify(reminder?.content)).toContain('This fork does not have a current goal.'); }); @@ -430,9 +433,10 @@ describe('AgentRecords persistence metadata', () => { goalId: 'fork-goal', objective: 'fork work', }); + await ctx.get(IAgentReminderQueueService).drain(); expect(context.get().at(-1)?.origin).toEqual({ - kind: 'system_trigger', - name: 'goal_fork_cleared', + kind: 'injection', + variant: 'goal_fork_cleared', }); }); diff --git a/packages/agent-core-v2/test/session/btw/btw.test.ts b/packages/agent-core-v2/test/session/btw/btw.test.ts index 21766a19b12..2bee18d9ddd 100644 --- a/packages/agent-core-v2/test/session/btw/btw.test.ts +++ b/packages/agent-core-v2/test/session/btw/btw.test.ts @@ -3,7 +3,7 @@ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; import { SyncDescriptor } from '#/_base/di/descriptors'; import { DisposableStore } from '#/_base/di/lifecycle'; import { TestInstantiationService } from '#/_base/di/test'; -import { IAgentSystemReminderService } from '#/agent/systemReminder/systemReminder'; +import { IAgentReminderQueueService } from '#/agent/reminderQueue/reminderQueue'; import { IAgentToolApprovalService } from '#/agent/toolApproval/toolApproval'; import { IAgentToolExecutorService } from '#/agent/toolExecutor/toolExecutor'; import type { ToolCall } from '#/kosong/contract/message'; @@ -21,14 +21,14 @@ describe('SessionBtwService', () => { let disposables: DisposableStore; let ix: TestInstantiationService; let fork: ReturnType; - let appendSystemReminder: ReturnType; + let enqueue: ReturnType; let formatDenyMessage: ReturnType; let executorEvents: ToolExecutorEventStubs; beforeEach(() => { disposables = new DisposableStore(); ix = disposables.add(new TestInstantiationService()); - appendSystemReminder = vi.fn(); + enqueue = vi.fn(() => 'reminder-id'); // The suffix mimics the worker-rejection guidance formatDenyMessage appends // for forked sub agents, so the assertion proves the reason went through it. formatDenyMessage = vi.fn((message: string) => `${message} [worker guidance]`); @@ -38,7 +38,7 @@ describe('SessionBtwService', () => { id: 'agent-btw-1', accessor: { get: (id: unknown) => { - if (id === IAgentSystemReminderService) return { appendSystemReminder }; + if (id === IAgentReminderQueueService) return { enqueue }; if (id === IAgentToolApprovalService) return { formatDenyMessage }; if (id === IAgentToolExecutorService) return executorEvents.executor; return undefined; @@ -60,9 +60,9 @@ describe('SessionBtwService', () => { expect(id).toBe('agent-btw-1'); expect(fork).toHaveBeenCalledWith('main'); - expect(appendSystemReminder).toHaveBeenCalledWith(SIDE_QUESTION_SYSTEM_REMINDER, { - kind: 'system_trigger', - name: 'btw', + expect(enqueue).toHaveBeenCalledWith({ + variant: 'btw', + content: SIDE_QUESTION_SYSTEM_REMINDER, }); }); diff --git a/packages/agent-core-v2/test/session/sessionInit/sessionInit.test.ts b/packages/agent-core-v2/test/session/sessionInit/sessionInit.test.ts index df80d910d52..27ddfc1c193 100644 --- a/packages/agent-core-v2/test/session/sessionInit/sessionInit.test.ts +++ b/packages/agent-core-v2/test/session/sessionInit/sessionInit.test.ts @@ -12,7 +12,7 @@ import { IHostFileSystem, type HostFileStat } from '#/os/interface/hostFileSyste import { IAgentPermissionModeService } from '#/agent/permissionMode/permissionMode'; import { IAgentProfileService } from '#/agent/profile/profile'; import { IAgentAgentsMdReminderService } from '#/agent/agentsMdReminder/agentsMdReminder'; -import { IAgentSystemReminderService } from '#/agent/systemReminder/systemReminder'; +import { IAgentReminderQueueService } from '#/agent/reminderQueue/reminderQueue'; import { IWireService } from '#/wire/wire'; import { ErrorCodes, Error2 } from '#/errors'; import { IAgentLifecycleService } from '#/session/agentLifecycle/agentLifecycle'; @@ -30,7 +30,7 @@ describe('SessionInitService', () => { let disposables: DisposableStore; let ix: TestInstantiationService; let events: unknown[]; - let appendSystemReminder: ReturnType; + let enqueue: ReturnType; let seedInjected: ReturnType; let flush: ReturnType; let republishStatus: ReturnType; @@ -42,7 +42,7 @@ describe('SessionInitService', () => { disposables = new DisposableStore(); ix = disposables.add(new TestInstantiationService()); events = []; - appendSystemReminder = vi.fn(); + enqueue = vi.fn(() => 'reminder-id'); seedInjected = vi.fn(); flush = vi.fn(async () => {}); republishStatus = vi.fn(() => { @@ -83,7 +83,7 @@ describe('SessionInitService', () => { if (id === ISessionSubagentService) return lifecycle; if (id === IAgentProfileService) return profile; if (id === IAgentPermissionModeService) return permissionMode; - if (id === IAgentSystemReminderService) return { appendSystemReminder }; + if (id === IAgentReminderQueueService) return { enqueue }; if (id === IAgentAgentsMdReminderService) return { seedInjected }; if (id === IWireService) return { flush }; if (id === IEventBus) return eventBus; @@ -150,12 +150,12 @@ describe('SessionInitService', () => { expect(runArgs[1]).toMatchObject({ kind: 'prompt' }); expect((runArgs[1] as { prompt: string }).prompt).toContain('Task requirements:'); - expect(appendSystemReminder).toHaveBeenCalledTimes(1); - const [reminder, origin] = appendSystemReminder.mock.calls[0] as [string, unknown]; - expect(origin).toEqual({ kind: 'injection', variant: 'init' }); - expect(reminder).toContain('The user just ran `/init` slash command.'); - expect(reminder).toContain('Latest AGENTS.md file content:'); - expect(reminder).toContain(AGENTS_MD); + expect(enqueue).toHaveBeenCalledTimes(1); + const [input] = enqueue.mock.calls[0] as [{ variant: string; content: string }]; + expect(input.variant).toBe('init'); + expect(input.content).toContain('The user just ran `/init` slash command.'); + expect(input.content).toContain('Latest AGENTS.md file content:'); + expect(input.content).toContain(AGENTS_MD); expect(seedInjected).toHaveBeenCalledWith([AGENTS_MD_PATH], WORK_DIR); diff --git a/packages/agent-core-v2/test/wire/resume.test.ts b/packages/agent-core-v2/test/wire/resume.test.ts index 098c3279468..9355e5c6c26 100644 --- a/packages/agent-core-v2/test/wire/resume.test.ts +++ b/packages/agent-core-v2/test/wire/resume.test.ts @@ -65,7 +65,7 @@ describe('Agent resume', () => { expect(persistence.records.filter((record) => record.type === 'metadata')).toHaveLength(1); }); - it('reconciles a pending user interruption after restore when the reminder is missing', async () => { + it('drains a pending once-reminder after restore (crash before delivery)', async () => { const persistence = new RecordingAgentPersistence([ resumeConfigRecord(), { @@ -98,6 +98,15 @@ describe('Agent resume', () => { }, }, { type: 'turn.cancel', turnId: 0, target: 'active', reason: 'user_cancelled' }, + { + type: 'reminderQueue.enqueue', + entry: { + id: 'r1', + variant: 'interruption', + content: + "The previous turn was interrupted by the user before completion; any partial output shown above is incomplete. The user's next message continues the conversation.", + }, + }, ] as unknown as WireRecord[]); const ctx = testAgent({ persistence, autoConfigure: false }); @@ -119,7 +128,7 @@ describe('Agent resume', () => { }), ); expect(persistence.appended).toContainEqual( - expect.objectContaining({ type: 'interruptionReminder.recorded', turnId: 0 }), + expect.objectContaining({ type: 'reminderQueue.delivered', id: 'r1' }), ); await ctx.expectResumeMatches(); diff --git a/packages/agent-core-v2/test/wire/wireService.test.ts b/packages/agent-core-v2/test/wire/wireService.test.ts index 8e68877765b..cd16146d4bb 100644 --- a/packages/agent-core-v2/test/wire/wireService.test.ts +++ b/packages/agent-core-v2/test/wire/wireService.test.ts @@ -269,6 +269,28 @@ describe('WireService', () => { } }); + it('skips retired record types during replay without reporting them', async () => { + const unexpected: unknown[] = []; + setUnexpectedErrorHandler((error) => unexpected.push(error)); + try { + await restoreTestAgentWire( + wire, + log, + testWireScope(SCOPE, KEY), + [ + { type: 'store.counter.add', by: 2 }, + { type: 'interruptionReminder.recorded', turnId: 0 }, + { type: 'store.counter.add', by: 3 }, + ], + ); + + expect(wire.getModel(CounterModel)).toEqual({ value: 5 }); + expect(unexpected).toEqual([]); + } finally { + resetUnexpectedErrorHandler(); + } + }); + it('freezes state: getModel is frozen and mutation throws in strict mode', () => { wire.dispatch(counterAdd({ by: 2 })); const state = wire.getModel(CounterModel); From 2fb23f4d3a5b1bac50dc4fdc1847ed224026977b Mon Sep 17 00:00:00 2001 From: 7Sageer <7sageer@djwcb.cn> Date: Wed, 5 Aug 2026 13:41:01 +0800 Subject: [PATCH 02/27] fix(agent-core-v2): make system reminders undo-aware --- .../agent-core-v2/docs/state-manifest.d.ts | 9 +- .../agent-core-v2/docs/wire-manifest.d.ts | 122 ++++++++++-------- .../contextInjector/contextInjectorService.ts | 8 -- .../agent/contextMemory/conversationTime.ts | 4 + .../src/agent/goal/goalService.ts | 1 - .../interruptionReminderOps.ts | 68 ++++++++++ .../interruptionReminderService.ts | 76 +++++++---- .../src/agent/plugin/agentPluginService.ts | 110 ++++++++++------ .../src/agent/prompt/promptService.ts | 17 +-- .../src/agent/prompt/promptStepRequests.ts | 21 ++- .../src/agent/reminderQueue/reminderQueue.ts | 27 +--- .../agent/reminderQueue/reminderQueueOps.ts | 2 - .../reminderQueue/reminderQueueService.ts | 5 - .../agent/swarm/injection/swarmInjection.ts | 64 ++------- .../agent/systemReminder/systemReminder.ts | 16 +-- .../src/agent/toolSelect/dynamicTools.ts | 1 - packages/agent-core-v2/src/index.ts | 1 + .../agent-core-v2/src/wire/wireService.ts | 11 +- .../test/agent/contextMemory/context.test.ts | 14 +- .../test/agent/goal/goal.test.ts | 6 - .../test/agent/loop/loop.test.ts | 66 +++++----- .../test/agent/plugin/agentPlugin.test.ts | 82 ++++++++++-- .../test/agent/prompt/promptService.test.ts | 49 +------ .../agent/reminderQueue/reminderQueue.test.ts | 10 +- .../test/agent/swarm/swarm.test.ts | 85 ++++++++---- .../toolSelect/toolSelectService.test.ts | 2 - packages/agent-core-v2/test/index.test.ts | 1 + .../agent-core-v2/test/wire/resume.test.ts | 56 ++++++-- .../test/wire/wireService.test.ts | 3 +- 29 files changed, 531 insertions(+), 406 deletions(-) create mode 100644 packages/agent-core-v2/src/agent/interruptionReminder/interruptionReminderOps.ts diff --git a/packages/agent-core-v2/docs/state-manifest.d.ts b/packages/agent-core-v2/docs/state-manifest.d.ts index 5152d1292f3..273e123253c 100644 --- a/packages/agent-core-v2/docs/state-manifest.d.ts +++ b/packages/agent-core-v2/docs/state-manifest.d.ts @@ -23,7 +23,7 @@ // references become '(circular)', and class instances collapse to a '(ClassName)' // marker — the wire shape of an entry is the JSON projection of the type here. // -// Index (App: 0 keys · Workspace: 6 keys · Session: 18 keys · Agent: 70 keys) +// Index (App: 0 keys · Workspace: 6 keys · Session: 18 keys · Agent: 69 keys) // App // Workspace // workspaceDirs.ephemeralDirs src/workspace/workspaceDirs/workspaceDirsService.ts @@ -57,7 +57,7 @@ // activityView.lastTurn src/agent/activityView/activityViewService.ts // activityView.lifecycle src/agent/activityView/activityViewService.ts // activityView.turn src/agent/activityView/activityViewService.ts -// agentPlugin.sessionStartDirty src/agent/plugin/agentPluginService.ts +// agentPlugin.sessionStartRefreshPending src/agent/plugin/agentPluginService.ts // agentsMdReminder.cwd src/agent/agentsMdReminder/agentsMdReminderService.ts // agentsMdReminder.known src/agent/agentsMdReminder/agentsMdReminderService.ts // agentsMdReminder.seeded src/agent/agentsMdReminder/agentsMdReminderService.ts @@ -104,7 +104,6 @@ // shellCommand.tasks src/agent/shellCommand/shellCommandService.ts // stepRetry.failedAttempts src/agent/stepRetry/stepRetryService.ts // stepRetry.lastFailedDriverId src/agent/stepRetry/stepRetryService.ts -// swarm.lastRendered src/agent/swarm/injection/swarmInjection.ts // task.activeTaskReminderPending src/agent/task/taskService.ts // task.deliveredNotificationKeys src/agent/task/taskService.ts // task.ghosts src/agent/task/taskService.ts @@ -1087,7 +1086,7 @@ export interface AgentStateSnapshot { // src/agent/plan/injection/planModeInjection.ts 'plan.wasActive': boolean; // src/agent/plugin/agentPluginService.ts - 'agentPlugin.sessionStartDirty': boolean; + 'agentPlugin.sessionStartRefreshPending': boolean; // src/agent/profile/profileService.ts 'profile.activeToolNamesOverlay': readonly string[] | undefined; 'profile.agentsMdWarning': string | undefined; @@ -1101,8 +1100,6 @@ export interface AgentStateSnapshot { // src/agent/stepRetry/stepRetryService.ts 'stepRetry.failedAttempts': number; 'stepRetry.lastFailedDriverId': string | undefined; - // src/agent/swarm/injection/swarmInjection.ts - 'swarm.lastRendered': 'active' | 'inactive' | undefined; // src/agent/task/taskService.ts 'task.activeTaskReminderPending': boolean; 'task.deliveredNotificationKeys': Set; diff --git a/packages/agent-core-v2/docs/wire-manifest.d.ts b/packages/agent-core-v2/docs/wire-manifest.d.ts index 24685b4176b..e979da00f74 100644 --- a/packages/agent-core-v2/docs/wire-manifest.d.ts +++ b/packages/agent-core-v2/docs/wire-manifest.d.ts @@ -21,56 +21,57 @@ // owning model offloads inline media to blob storage), cross-reducers // (foreign models that also reduce this record on dispatch and replay). -// Index (49 record types) -// config.update profile persisted src/agent/profile/profileOps.ts -// context.append_loop_event contextMemory persisted src/agent/contextMemory/contextOps.ts -// context.append_message contextMemory persisted src/agent/contextMemory/contextOps.ts -// context.apply_compaction contextMemory persisted src/agent/contextMemory/contextOps.ts -// context.clear contextMemory persisted src/agent/contextMemory/contextOps.ts -// context.undo contextMemory persisted src/agent/contextMemory/contextOps.ts -// cron.add cron transient src/session/cron/cronOps.ts -// cron.cursor cron transient src/session/cron/cronOps.ts -// cron.delete cron transient src/session/cron/cronOps.ts -// forked goal persisted src/agent/goal/goalOps.ts -// full_compaction.begin fullCompaction persisted src/agent/fullCompaction/compactionOps.ts -// full_compaction.cancel fullCompaction persisted src/agent/fullCompaction/compactionOps.ts -// full_compaction.complete fullCompaction persisted src/agent/fullCompaction/compactionOps.ts -// goal.clear goal persisted src/agent/goal/goalOps.ts -// goal.create goal persisted src/agent/goal/goalOps.ts -// goal.update goal persisted src/agent/goal/goalOps.ts -// interaction.request interaction persisted src/session/interaction/interactionOps.ts -// interaction.resolved interaction persisted src/session/interaction/interactionOps.ts -// llm.request llm.requestTrace persisted src/agent/llmRequester/llmRequestOps.ts -// llm.tools_snapshot llm.requestTrace persisted src/agent/llmRequester/llmRequestOps.ts -// mcp.tools_discovered mcp.discovery persisted src/agent/mcp/mcpDiscoveryOps.ts -// permission.record_approval_result permissionRules persisted src/agent/permissionRules/permissionRulesOps.ts -// permission.rules.add permissionRules transient src/agent/permissionRules/permissionRulesOps.ts -// permission.set_mode permissionMode persisted src/agent/permissionMode/permissionModeOps.ts -// plan_mode.cancel plan persisted src/agent/plan/planOps.ts -// plan_mode.enter plan persisted src/agent/plan/planOps.ts -// plan_mode.exit plan persisted src/agent/plan/planOps.ts -// plan.revision plan persisted src/agent/plan/planOps.ts -// profile.bind profile persisted src/agent/profile/profileOps.ts -// reminderQueue.delivered reminderQueue persisted src/agent/reminderQueue/reminderQueueOps.ts -// reminderQueue.enqueue reminderQueue persisted src/agent/reminderQueue/reminderQueueOps.ts -// skill.activate skill transient src/agent/skill/skillOps.ts -// swarm_mode.enter swarm persisted src/agent/swarm/swarmOps.ts -// swarm_mode.exit swarm persisted src/agent/swarm/swarmOps.ts -// task.started task persisted src/agent/task/taskOps.ts -// task.terminated task persisted src/agent/task/taskOps.ts -// token_counting.measured tokenCounting transient src/agent/tokenCounting/tokenCountingOps.ts -// token_counting.rebased tokenCounting transient src/agent/tokenCounting/tokenCountingOps.ts -// token_counting.truncated tokenCounting transient src/agent/tokenCounting/tokenCountingOps.ts -// tools.register_user_tool userTool persisted src/agent/userTool/userToolOps.ts -// tools.reset_active_tools profile.activeTools persisted src/agent/profile/profileOps.ts -// tools.set_active_tools profile.activeTools persisted src/agent/profile/profileOps.ts -// tools.unregister_user_tool userTool persisted src/agent/userTool/userToolOps.ts -// tools.update_store todo persisted src/session/todo/todoOps.ts -// turn.cancel turn persisted src/agent/loop/turnOps.ts -// turn.ended turn persisted src/agent/loop/turnOps.ts -// turn.prompt turn persisted src/agent/loop/turnOps.ts -// turn.steer turn persisted src/agent/loop/turnOps.ts -// usage.record usage persisted src/agent/usage/usageOps.ts +// Index (50 record types) +// config.update profile persisted src/agent/profile/profileOps.ts +// context.append_loop_event contextMemory persisted src/agent/contextMemory/contextOps.ts +// context.append_message contextMemory persisted src/agent/contextMemory/contextOps.ts +// context.apply_compaction contextMemory persisted src/agent/contextMemory/contextOps.ts +// context.clear contextMemory persisted src/agent/contextMemory/contextOps.ts +// context.undo contextMemory persisted src/agent/contextMemory/contextOps.ts +// cron.add cron transient src/session/cron/cronOps.ts +// cron.cursor cron transient src/session/cron/cronOps.ts +// cron.delete cron transient src/session/cron/cronOps.ts +// forked goal persisted src/agent/goal/goalOps.ts +// full_compaction.begin fullCompaction persisted src/agent/fullCompaction/compactionOps.ts +// full_compaction.cancel fullCompaction persisted src/agent/fullCompaction/compactionOps.ts +// full_compaction.complete fullCompaction persisted src/agent/fullCompaction/compactionOps.ts +// goal.clear goal persisted src/agent/goal/goalOps.ts +// goal.create goal persisted src/agent/goal/goalOps.ts +// goal.update goal persisted src/agent/goal/goalOps.ts +// interaction.request interaction persisted src/session/interaction/interactionOps.ts +// interaction.resolved interaction persisted src/session/interaction/interactionOps.ts +// interruptionReminder.recorded interruptionReminder persisted src/agent/interruptionReminder/interruptionReminderOps.ts +// llm.request llm.requestTrace persisted src/agent/llmRequester/llmRequestOps.ts +// llm.tools_snapshot llm.requestTrace persisted src/agent/llmRequester/llmRequestOps.ts +// mcp.tools_discovered mcp.discovery persisted src/agent/mcp/mcpDiscoveryOps.ts +// permission.record_approval_result permissionRules persisted src/agent/permissionRules/permissionRulesOps.ts +// permission.rules.add permissionRules transient src/agent/permissionRules/permissionRulesOps.ts +// permission.set_mode permissionMode persisted src/agent/permissionMode/permissionModeOps.ts +// plan_mode.cancel plan persisted src/agent/plan/planOps.ts +// plan_mode.enter plan persisted src/agent/plan/planOps.ts +// plan_mode.exit plan persisted src/agent/plan/planOps.ts +// plan.revision plan persisted src/agent/plan/planOps.ts +// profile.bind profile persisted src/agent/profile/profileOps.ts +// reminderQueue.delivered reminderQueue persisted src/agent/reminderQueue/reminderQueueOps.ts +// reminderQueue.enqueue reminderQueue persisted src/agent/reminderQueue/reminderQueueOps.ts +// skill.activate skill transient src/agent/skill/skillOps.ts +// swarm_mode.enter swarm persisted src/agent/swarm/swarmOps.ts +// swarm_mode.exit swarm persisted src/agent/swarm/swarmOps.ts +// task.started task persisted src/agent/task/taskOps.ts +// task.terminated task persisted src/agent/task/taskOps.ts +// token_counting.measured tokenCounting transient src/agent/tokenCounting/tokenCountingOps.ts +// token_counting.rebased tokenCounting transient src/agent/tokenCounting/tokenCountingOps.ts +// token_counting.truncated tokenCounting transient src/agent/tokenCounting/tokenCountingOps.ts +// tools.register_user_tool userTool persisted src/agent/userTool/userToolOps.ts +// tools.reset_active_tools profile.activeTools persisted src/agent/profile/profileOps.ts +// tools.set_active_tools profile.activeTools persisted src/agent/profile/profileOps.ts +// tools.unregister_user_tool userTool persisted src/agent/userTool/userToolOps.ts +// tools.update_store todo persisted src/session/todo/todoOps.ts +// turn.cancel turn persisted src/agent/loop/turnOps.ts +// turn.ended turn persisted src/agent/loop/turnOps.ts +// turn.prompt turn persisted src/agent/loop/turnOps.ts +// turn.steer turn persisted src/agent/loop/turnOps.ts +// usage.record usage persisted src/agent/usage/usageOps.ts /** * model: profile · persisted @@ -100,7 +101,7 @@ interface ContextAppendLoopEventPayload { } /** - * model: contextMemory · persisted · blobs · cross-reducers: plan, goalForkNotice, task.notificationDelivery, todo + * model: contextMemory · persisted · blobs · cross-reducers: plan, goalForkNotice, task.notificationDelivery, interruptionReminder, todo * owner: src/agent/contextMemory/contextOps.ts */ interface ContextAppendMessagePayload { @@ -135,14 +136,14 @@ interface ContextAppendMessagePayload { } /** - * model: contextMemory · persisted · blobs · cross-reducers: plan, task.notificationDelivery, todo + * model: contextMemory · persisted · blobs · cross-reducers: plan, task.notificationDelivery, interruptionReminder, todo * owner: src/agent/contextMemory/contextOps.ts * shared base: ...contextCompactionBaseShape */ type ContextApplyCompactionPayload = { _name: 'context.apply_compaction'; } & ({ summary: string, compactedCount: number, contextSummary?: string } | { contextSummary: string, compactedCount: number, summary?: string } | { summary: ContextMessage, count: number, compactedCount?: number }); /** - * model: contextMemory · persisted · blobs · cross-reducers: plan, task.notificationDelivery, todo + * model: contextMemory · persisted · blobs · cross-reducers: plan, task.notificationDelivery, interruptionReminder, todo * owner: src/agent/contextMemory/contextOps.ts */ interface ContextClearPayload { @@ -150,7 +151,7 @@ interface ContextClearPayload { } /** - * model: contextMemory · persisted · blobs · cross-reducers: plan, task.notificationDelivery, todo + * model: contextMemory · persisted · blobs · cross-reducers: plan, task.notificationDelivery, interruptionReminder, todo * owner: src/agent/contextMemory/contextOps.ts */ interface ContextUndoPayload { @@ -301,6 +302,15 @@ interface InteractionResolvedPayload { response: any; } +/** + * model: interruptionReminder · persisted + * owner: src/agent/interruptionReminder/interruptionReminderOps.ts + */ +interface InterruptionReminderRecordedPayload { + _name: 'interruptionReminder.recorded'; + turnId: number; +} + /** * model: llm.requestTrace · persisted * owner: src/agent/llmRequester/llmRequestOps.ts @@ -470,7 +480,6 @@ interface ReminderQueueEnqueuePayload { id: string; variant: string; content: string; - ownerPromptId?: string; }; } @@ -613,7 +622,7 @@ interface ToolsUpdateStorePayload { } /** - * model: turn · persisted + * model: turn · persisted · cross-reducers: interruptionReminder * owner: src/agent/loop/turnOps.ts */ interface TurnCancelPayload { @@ -738,6 +747,7 @@ interface WirePayloadMap { "goal.update": GoalUpdatePayload; "interaction.request": InteractionRequestPayload; "interaction.resolved": InteractionResolvedPayload; + "interruptionReminder.recorded": InterruptionReminderRecordedPayload; "llm.request": LlmRequestPayload; "llm.tools_snapshot": LlmToolsSnapshotPayload; "mcp.tools_discovered": McpToolsDiscoveredPayload; diff --git a/packages/agent-core-v2/src/agent/contextInjector/contextInjectorService.ts b/packages/agent-core-v2/src/agent/contextInjector/contextInjectorService.ts index ae0b8b83fa6..6636490559d 100644 --- a/packages/agent-core-v2/src/agent/contextInjector/contextInjectorService.ts +++ b/packages/agent-core-v2/src/agent/contextInjector/contextInjectorService.ts @@ -63,8 +63,6 @@ export class AgentContextInjectorService extends Disposable implements IAgentCon ); this._register( this.eventBus.subscribe('turn.started', () => { - // Draining here lands once-reminders ahead of the next prompt, which - // only materializes later (in the turn's first step). this.reminderQueue.drain(); this.isNewTurn = true; }), @@ -76,9 +74,6 @@ export class AgentContextInjectorService extends Disposable implements IAgentCon ); this._register( wire.hooks.onDidRestore.register('context-injector', async (_ctx, next) => { - // Replay rebuilds the pending ledger; deliver what was left pending - // before the loop resumes, then fold the fresh messages into - // position tracking. this.reminderQueue.drain(); this.resyncPositions(); await next(); @@ -116,9 +111,6 @@ export class AgentContextInjectorService extends Disposable implements IAgentCon } private async inject(): Promise { - // Once-reminders (past-tense events) drain ahead of the reconcile pass so - // state reminders never wedge themselves between an event and the message - // it annotates. this.reminderQueue.drain(); const isNewTurn = this.isNewTurn; this.isNewTurn = false; diff --git a/packages/agent-core-v2/src/agent/contextMemory/conversationTime.ts b/packages/agent-core-v2/src/agent/contextMemory/conversationTime.ts index b69bf20851f..deb569e1229 100644 --- a/packages/agent-core-v2/src/agent/contextMemory/conversationTime.ts +++ b/packages/agent-core-v2/src/agent/contextMemory/conversationTime.ts @@ -7,6 +7,7 @@ */ import { defineModel, type ModelDef } from '#/wire/model'; +import type { ModelReducers } from '#/wire/types'; import type { ContextMessage } from './types'; @@ -45,6 +46,7 @@ export const CHECKPOINTED_MODELS: ModelDef>[] = []; export interface CheckpointModelOptions { readonly onAppendMessage?: (current: T, message: ContextMessage) => T; + readonly reducers?: ModelReducers>; } export function defineCheckpointedModel( @@ -52,11 +54,13 @@ export function defineCheckpointedModel( initial: () => T, opts?: CheckpointModelOptions, ): ModelDef> { + const customReducers = opts?.reducers ?? {}; const def = defineModel>( name, () => ({ current: initial(), checkpoints: [] }), { reducers: { + ...customReducers, 'context.append_message': (state, { message }) => { if (isUndoAnchor(message)) { return { ...state, checkpoints: [...state.checkpoints, state.current] }; diff --git a/packages/agent-core-v2/src/agent/goal/goalService.ts b/packages/agent-core-v2/src/agent/goal/goalService.ts index 5edb7f3cdfa..3467fdec44a 100644 --- a/packages/agent-core-v2/src/agent/goal/goalService.ts +++ b/packages/agent-core-v2/src/agent/goal/goalService.ts @@ -219,7 +219,6 @@ const GoalForkNoticeModel = defineModel( function isGoalForkClearedReminder(message: ContextMessage | undefined): boolean { const origin = message?.origin; if (origin?.kind === 'injection') return origin.variant === GOAL_FORK_CLEARED_REMINDER_NAME; - // Legacy journals announced through `{kind: 'system_trigger'}`. return origin?.kind === 'system_trigger' && origin.name === GOAL_FORK_CLEARED_REMINDER_NAME; } diff --git a/packages/agent-core-v2/src/agent/interruptionReminder/interruptionReminderOps.ts b/packages/agent-core-v2/src/agent/interruptionReminder/interruptionReminderOps.ts new file mode 100644 index 00000000000..62cba21e8b3 --- /dev/null +++ b/packages/agent-core-v2/src/agent/interruptionReminder/interruptionReminderOps.ts @@ -0,0 +1,68 @@ +/** + * `interruptionReminder` domain — checkpointed wire Model for user-cancelled + * interruption reminders. + * + * Projects the durable `turn.cancel` fact into pending model-facing state, + * follows conversation undo through `contextMemory`'s checkpoint protocol, and + * clears the pending state when the injection is appended. Bound at Agent scope. + */ + +import { z } from 'zod'; + +import { + defineCheckpointedModel, + type Checkpointed, +} from '#/agent/contextMemory/conversationTime'; + +export const INTERRUPTION_REMINDER_VARIANT = 'interruption'; + +export const INTERRUPTION_REMINDER = [ + 'The previous turn was interrupted by the user before completion;', + 'any partial output shown above is incomplete.', + "The user's next message continues the conversation.", +].join(' '); + +export type InterruptionReminderState = Checkpointed; + +export const InterruptionReminderModel = defineCheckpointedModel( + 'interruptionReminder', + () => [], + { + onAppendMessage: (current, message) => + message.origin?.kind === 'injection' && + message.origin.variant === INTERRUPTION_REMINDER_VARIANT && + current.length > 0 + ? [] + : current, + reducers: { + 'turn.cancel': (state, { turnId, target, reason }) => { + if ( + turnId === undefined || + target !== 'active' || + reason !== 'user_cancelled' || + state.current.includes(turnId) + ) { + return state; + } + return { ...state, current: [...state.current, turnId] }; + }, + }, + }, +); + +declare module '#/wire/types' { + interface PersistedOpMap { + 'interruptionReminder.recorded': typeof interruptionReminderRecorded; + } +} + +export const interruptionReminderRecorded = InterruptionReminderModel.defineOp( + 'interruptionReminder.recorded', + { + schema: z.object({ turnId: z.number().int().nonnegative() }), + apply: (state, { turnId }) => ({ + ...state, + current: state.current.filter((pendingTurnId) => pendingTurnId !== turnId), + }), + }, +); diff --git a/packages/agent-core-v2/src/agent/interruptionReminder/interruptionReminderService.ts b/packages/agent-core-v2/src/agent/interruptionReminder/interruptionReminderService.ts index cd27aa402b8..7dd465f505f 100644 --- a/packages/agent-core-v2/src/agent/interruptionReminder/interruptionReminderService.ts +++ b/packages/agent-core-v2/src/agent/interruptionReminder/interruptionReminderService.ts @@ -1,26 +1,27 @@ /** - * `interruptionReminder` domain (L4) — `IAgentInterruptionReminderService` implementation. + * `interruptionReminder` domain — `IAgentInterruptionReminderService` implementation. * - * Observes turn completion through `eventBus` and enqueues a one-time - * user-interruption notice into the `reminderQueue`. Persistence, crash - * recovery, and dedup are owned by the queue; delivery happens at the unified - * boundary scheduler's drain points. Bound at Agent scope. + * Projects checkpointed user-cancellation facts from `wire` through the + * `contextInjector` boundary, and reads `contextMemory` to collapse retry-only + * duplicate notices. Bound at Agent scope. */ import { Disposable } from '#/_base/di/lifecycle'; import { LifecycleScope, ScopeActivation, registerScopedService } from '#/_base/di/scope'; -import { IAgentReminderQueueService } from '#/agent/reminderQueue/reminderQueue'; -import { IEventBus } from '#/app/event/eventBus'; +import { IAgentContextInjectorService } from '#/agent/contextInjector/contextInjector'; +import { IAgentContextMemoryService } from '#/agent/contextMemory/contextMemory'; +import { isUndoAnchor } from '#/agent/contextMemory/conversationTime'; +import type { ContextMessage } from '#/agent/contextMemory/types'; +import { isVacuousContentPart } from '#/agent/contextMemory/vacuousContent'; +import { IWireService } from '#/wire/wire'; import { IAgentInterruptionReminderService } from './interruptionReminder'; - -export const INTERRUPTION_REMINDER_VARIANT = 'interruption'; - -const INTERRUPTION_REMINDER = [ - 'The previous turn was interrupted by the user before completion;', - 'any partial output shown above is incomplete.', - "The user's next message continues the conversation.", -].join(' '); +import { + INTERRUPTION_REMINDER, + INTERRUPTION_REMINDER_VARIANT, + interruptionReminderRecorded, + InterruptionReminderModel, +} from './interruptionReminderOps'; export class AgentInterruptionReminderService extends Disposable @@ -29,20 +30,47 @@ export class AgentInterruptionReminderService declare readonly _serviceBrand: undefined; constructor( - @IEventBus eventBus: IEventBus, - @IAgentReminderQueueService private readonly reminderQueue: IAgentReminderQueueService, + @IAgentContextInjectorService injector: IAgentContextInjectorService, + @IAgentContextMemoryService private readonly context: IAgentContextMemoryService, + @IWireService private readonly wire: IWireService, ) { super(); this._register( - eventBus.subscribe('turn.ended', (event) => { - if (event.reason !== 'cancelled' || event.interruptReason !== 'user_cancelled') return; - this.reminderQueue.enqueue({ - variant: INTERRUPTION_REMINDER_VARIANT, - content: INTERRUPTION_REMINDER, - }); - }), + injector.register(INTERRUPTION_REMINDER_VARIANT, () => this.reconcileReminder()), ); } + + private reconcileReminder(): string | undefined { + const pending = this.wire.getModel(InterruptionReminderModel).current; + if (pending.length === 0) return undefined; + const origin = lastComparableMessage(this.context.get())?.origin; + if (origin?.kind !== 'injection' || origin.variant !== INTERRUPTION_REMINDER_VARIANT) { + return INTERRUPTION_REMINDER; + } + this.wire.dispatch(...pending.map((turnId) => interruptionReminderRecorded({ turnId }))); + return undefined; + } +} + +function lastComparableMessage(messages: readonly ContextMessage[]): ContextMessage | undefined { + let skippedCurrentPrompt = false; + for (let index = messages.length - 1; index >= 0; index--) { + const message = messages[index]!; + if ( + message.role === 'assistant' && + message.partial === true && + message.toolCalls.length === 0 && + message.content.every(isVacuousContentPart) + ) { + continue; + } + if (!skippedCurrentPrompt && isUndoAnchor(message)) { + skippedCurrentPrompt = true; + continue; + } + return message; + } + return undefined; } registerScopedService( diff --git a/packages/agent-core-v2/src/agent/plugin/agentPluginService.ts b/packages/agent-core-v2/src/agent/plugin/agentPluginService.ts index 890e8825c93..ceead32c5c1 100644 --- a/packages/agent-core-v2/src/agent/plugin/agentPluginService.ts +++ b/packages/agent-core-v2/src/agent/plugin/agentPluginService.ts @@ -1,17 +1,13 @@ /** * `agentPlugin` domain — `IAgentPluginService` implementation. * - * Renders session-start skills from `plugin` and `sessionSkillCatalog` as a - * pure state channel through `contextInjector`: the provider re-renders the - * current session-start set whenever it is missing from the conversation - * (compaction recovery) or when the plugin skill catalog changed (a dirty - * flag forces one supersedes/neutralize render at the next boundary). Uses - * `contextMemory` to detect stale guidance worth neutralizing. Main-agent-only - * (v1 parity): the service self-gates on `agentId === 'main'`; Agent scope - * creation instantiates it for every agent, so other agents construct it as a - * no-op. Resolves session prompt context through `sessionContext` and reports - * missing skills through `log`. The plain-data dirty flag is registered into - * `agentState` (`IAgentStateService`). Bound at Agent scope. + * Renders session-start skills from `plugin` and `sessionSkillCatalog` through + * `contextInjector`, reconciling the desired instructions against the latest + * surviving render in `contextMemory`. Main-agent-only (v1 parity): the + * service self-gates on `agentId === 'main'`; Agent scope creation instantiates + * it for every agent, so other agents construct it as a no-op. Resolves session + * prompt context through `sessionContext` and reports missing skills through + * `log`; stores the refresh signal through `agentState`. Bound at Agent scope. */ import { Disposable } from '#/_base/di/lifecycle'; @@ -21,6 +17,7 @@ import { defineState } from '#/_base/state/stateRegistry'; import { escapeXmlAttr } from '#/_base/utils/xml-escape'; import { IAgentContextInjectorService } from '#/agent/contextInjector/contextInjector'; import { IAgentContextMemoryService } from '#/agent/contextMemory/contextMemory'; +import type { ContextMessage } from '#/agent/contextMemory/types'; import { IAgentScopeContext } from '#/agent/scopeContext/scopeContext'; import { IAgentStateService } from '#/agent/state/agentState'; import { IPluginService } from '#/app/plugin/plugin'; @@ -39,8 +36,11 @@ const MAIN_AGENT_ID = 'main'; const SUPERSEDES_SUFFIX = 'This supersedes any earlier plugin_session_start reminder in this session.'; -export const pluginSessionStartDirtyKey = defineState( - 'agentPlugin.sessionStartDirty', +const NO_ACTIVE_SESSION_STARTS = + `There are currently no active plugin session starts. ${SUPERSEDES_SUFFIX}`; + +export const pluginSessionStartRefreshPendingKey = defineState( + 'agentPlugin.sessionStartRefreshPending', () => false, ); @@ -59,32 +59,25 @@ export class AgentPluginService extends Disposable implements IAgentPluginServic ) { super(); if (scopeContext.agentId !== MAIN_AGENT_ID) return; - this.states.register(pluginSessionStartDirtyKey); + this.states.register(pluginSessionStartRefreshPendingKey); this._register( - injector.register(SESSION_START_INJECTION_VARIANT, async ({ injectedPositions }) => { - if (this.dirty) { - this.dirty = false; - return this.renderSupersedingReminder(); - } - if (injectedPositions.length > 0) return undefined; - return this.renderSessionStartReminder(); - }), + injector.register(SESSION_START_INJECTION_VARIANT, () => + this.reconcileSessionStartReminder(), + ), ); this._register( this.skillCatalog.onDidChange((sourceId) => { - if (sourceId === PLUGIN_SKILL_SOURCE_ID) { - this.dirty = true; - } + if (sourceId === PLUGIN_SKILL_SOURCE_ID) this.refreshPending = true; }), ); } - private get dirty(): boolean { - return this.states.get(pluginSessionStartDirtyKey); + private get refreshPending(): boolean { + return this.states.get(pluginSessionStartRefreshPendingKey); } - private set dirty(value: boolean) { - this.states.set(pluginSessionStartDirtyKey, value); + private set refreshPending(value: boolean) { + this.states.set(pluginSessionStartRefreshPendingKey, value); } private async renderSessionStartReminder(): Promise { @@ -99,16 +92,61 @@ export class AgentPluginService extends Disposable implements IAgentPluginServic }); } - private async renderSupersedingReminder(): Promise { - const reminder = await this.renderSessionStartReminder(); - if (reminder !== undefined) { - return `${reminder}\n\n${SUPERSEDES_SUFFIX}`; + private async reconcileSessionStartReminder(): Promise { + const forceRefresh = this.refreshPending; + this.refreshPending = false; + const desired = await this.renderSessionStartReminder(); + const history = this.context.get(); + const latest = lastPluginSessionStart(history); + if (desired === undefined) { + if ( + latest === undefined && + (!forceRefresh || !shouldNeutralizePluginSessionStart(history)) + ) { + return undefined; + } + if ( + latest !== undefined && + messageText(latest) === systemReminderText(NO_ACTIVE_SESSION_STARTS) + ) { + return undefined; + } + return NO_ACTIVE_SESSION_STARTS; + } + if (latest === undefined) return desired; + const rendered = messageText(latest); + if ( + !forceRefresh && + ( + rendered === systemReminderText(desired) || + rendered === systemReminderText(`${desired}\n\n${SUPERSEDES_SUFFIX}`) + ) + ) { + return undefined; } - if (shouldNeutralizePluginSessionStart(this.context.get())) { - return `There are currently no active plugin session starts. ${SUPERSEDES_SUFFIX}`; + return `${desired}\n\n${SUPERSEDES_SUFFIX}`; + } +} + +function lastPluginSessionStart(history: readonly ContextMessage[]): ContextMessage | undefined { + for (let index = history.length - 1; index >= 0; index--) { + const message = history[index]!; + if ( + message.origin?.kind === 'injection' && + message.origin.variant === SESSION_START_INJECTION_VARIANT + ) { + return message; } - return undefined; } + return undefined; +} + +function messageText(message: ContextMessage): string { + return message.content.map((part) => (part.type === 'text' ? part.text : '')).join(''); +} + +function systemReminderText(content: string): string { + return `\n${content.trim()}\n`; } interface RenderPluginSessionStartReminderInput { diff --git a/packages/agent-core-v2/src/agent/prompt/promptService.ts b/packages/agent-core-v2/src/agent/prompt/promptService.ts index be379e74e29..3f3dd91d100 100644 --- a/packages/agent-core-v2/src/agent/prompt/promptService.ts +++ b/packages/agent-core-v2/src/agent/prompt/promptService.ts @@ -23,8 +23,8 @@ import { USER_PROMPT_ORIGIN, type ContextMessage } from '#/agent/contextMemory/t import { IAgentFullCompactionService } from '#/agent/fullCompaction/fullCompaction'; import { IAgentLoopService, type Turn, type TurnResult } from '#/agent/loop/loop'; import { steerTurn } from '#/agent/loop/turnOps'; -import { IAgentReminderQueueService } from '#/agent/reminderQueue/reminderQueue'; import { IAgentStateService } from '#/agent/state/agentState'; +import { IAgentSystemReminderService } from '#/agent/systemReminder/systemReminder'; import type { ExecutableToolResult } from '#/tool/toolContract'; import type { ToolDidExecuteContext } from '#/agent/toolExecutor/toolHooks'; import { IAgentToolExecutorService } from '#/agent/toolExecutor/toolExecutor'; @@ -75,7 +75,7 @@ export class AgentPromptService implements IAgentPromptService { constructor( @IAgentContextMemoryService private readonly context: IAgentContextMemoryService, - @IAgentReminderQueueService private readonly reminderQueue: IAgentReminderQueueService, + @IAgentSystemReminderService private readonly reminders: IAgentSystemReminderService, @IInstantiationService private readonly instantiation: IInstantiationService, @IAgentLoopService private readonly loop: IAgentLoopService, @IAgentToolExecutorService toolExecutor: IAgentToolExecutorService, @@ -145,7 +145,7 @@ export class AgentPromptService implements IAgentPromptService { role: 'user', content: selected.flatMap((item) => item.message.content), toolCalls: [], origin: USER_PROMPT_ORIGIN, }; const { message: rerouted, captions } = this.extractCompressionCaptions(message); - const request = new SteerStepRequest(rerouted, captions, this.reminderQueue, (materialized) => { + const request = new SteerStepRequest(rerouted, captions, this.reminders, (materialized) => { this.wire.dispatch(steerTurn({ input: materialized.content, origin: materialized.origin ?? USER_PROMPT_ORIGIN })); }, () => {}); const turn = (await this.loop.enqueue(request).assigned).turn; @@ -169,7 +169,7 @@ export class AgentPromptService implements IAgentPromptService { async inject(message: ContextMessage): Promise { const { message: rerouted, captions } = this.extractCompressionCaptions(message); - const request = new SteerStepRequest(rerouted, captions, this.reminderQueue, (materialized) => { + const request = new SteerStepRequest(rerouted, captions, this.reminders, (materialized) => { this.wire.dispatch(steerTurn({ input: materialized.content, origin: materialized.origin ?? USER_PROMPT_ORIGIN })); }, () => {}, 'activeOrNewTurn'); return (await this.loop.enqueue(request).assigned).turn; @@ -195,7 +195,7 @@ export class AgentPromptService implements IAgentPromptService { item.completionDeferred.resolve({ promptId: item.id, result: undefined, state: 'blocked' }); this.publishCompleted(item.id, 'blocked'); return; } - const turn = (await this.loop.enqueue(new PromptStepRequest(message, captions, this.reminderQueue)).assigned).turn; + const turn = (await this.loop.enqueue(new PromptStepRequest(message, captions, this.reminders)).assigned).turn; if (turn === undefined) { this.pending.unshift(item); return; } item.state = 'running'; item.launchedDeferred.resolve(turn); this.active = Object.assign(item, { turn }); void turn.result.then((result) => this.settle(item, result)); @@ -244,16 +244,13 @@ export class AgentPromptService implements IAgentPromptService { private appendPrompt(message: ContextMessage, captions: readonly string[]): void { const ownerPromptId = message.id ?? newMessageId(); for (const caption of captions) { - this.reminderQueue.enqueue({ + this.reminders.appendSystemReminder(caption, { + kind: 'injection', variant: 'image_compression', - content: caption, ownerPromptId, }); } if (message.content.length > 0) this.context.append({ ...message, id: ownerPromptId }); - // A blocked prompt starts no turn, so no boundary will drain the queue — - // deliver the captions right after their message here. - this.reminderQueue.drain(); } private async deliverToolResult(ctx: ToolDidExecuteContext): Promise { const delivery = ctx.result.delivery; if (delivery === undefined) return; diff --git a/packages/agent-core-v2/src/agent/prompt/promptStepRequests.ts b/packages/agent-core-v2/src/agent/prompt/promptStepRequests.ts index c5ec9e8b164..c67687180fd 100644 --- a/packages/agent-core-v2/src/agent/prompt/promptStepRequests.ts +++ b/packages/agent-core-v2/src/agent/prompt/promptStepRequests.ts @@ -5,9 +5,8 @@ * `PromptStepRequest` / `SteerStepRequest` carry an already-built user * `ContextMessage` (image-compression captions pre-split), apply the image * format gate as the last funnel before the history, and materialize it - * at pop time — captions are enqueued into the once-reminder queue with the - * message's id as `ownerPromptId`, and the unified boundary scheduler - * delivers them right after the message at the next step boundary. + * at pop time — caption reminders are appended before the host message, + * preserving the prompt-owned undo boundary. * `PromptStepRequest` uses `newTurn`, seeding the * `turn.prompt` record from its message. `SteerStepRequest` uses * `activeOrNewTurn`, is mergeable, and survives turn boundaries; it records @@ -22,7 +21,7 @@ import { USER_PROMPT_ORIGIN, type ContextMessage } from '#/agent/contextMemory/t import { newMessageId } from '#/agent/contextMemory/messageId'; import { StepRequest, type StepRequestOptions, type TurnSeed } from '#/agent/loop/stepRequest'; import { gateImageFormatParts } from '#/agent/media/image-compress'; -import type { IAgentReminderQueueService } from '#/agent/reminderQueue/reminderQueue'; +import type { IAgentSystemReminderService } from '#/agent/systemReminder/systemReminder'; abstract class UserMessageStepRequest extends StepRequest { protected readonly message: ContextMessage; @@ -31,7 +30,7 @@ abstract class UserMessageStepRequest extends StepRequest { constructor( message: ContextMessage, private readonly captions: readonly string[], - private readonly reminderQueue: IAgentReminderQueueService, + private readonly reminders: IAgentSystemReminderService, options?: StepRequestOptions, ) { super(options); @@ -49,9 +48,9 @@ abstract class UserMessageStepRequest extends StepRequest { override onWillMaterialize(): void { for (const caption of this.captions) { - this.reminderQueue.enqueue({ + this.reminders.appendSystemReminder(caption, { + kind: 'injection', variant: 'image_compression', - content: caption, ownerPromptId: this.ownerPromptId, }); } @@ -68,9 +67,9 @@ export class PromptStepRequest extends UserMessageStepRequest { constructor( message: ContextMessage, captions: readonly string[], - reminderQueue: IAgentReminderQueueService, + reminders: IAgentSystemReminderService, ) { - super(message, captions, reminderQueue, { admission: 'newTurn' }); + super(message, captions, reminders, { admission: 'newTurn' }); } override get turnSeed(): TurnSeed { @@ -84,12 +83,12 @@ export class SteerStepRequest extends UserMessageStepRequest { constructor( message: ContextMessage, captions: readonly string[], - reminderQueue: IAgentReminderQueueService, + reminders: IAgentSystemReminderService, private readonly recordSteer: (message: ContextMessage) => void, private readonly forgetSteer: (request: SteerStepRequest) => void, admission: 'activeTurnOnly' | 'activeOrNewTurn' = 'activeTurnOnly', ) { - super(message, captions, reminderQueue, { + super(message, captions, reminders, { mergeable: true, turnScoped: false, admission, diff --git a/packages/agent-core-v2/src/agent/reminderQueue/reminderQueue.ts b/packages/agent-core-v2/src/agent/reminderQueue/reminderQueue.ts index 45ef1cb6fcc..c81ed2300ad 100644 --- a/packages/agent-core-v2/src/agent/reminderQueue/reminderQueue.ts +++ b/packages/agent-core-v2/src/agent/reminderQueue/reminderQueue.ts @@ -1,35 +1,22 @@ +/** + * `reminderQueue` domain — world-event exactly-once reminder contract. + * + * Persists and drains reminders whose delivery is a durable fact rather than + * conversation state. Bound at Agent scope. + */ + import { createDecorator } from '#/_base/di/instantiation'; export interface OnceReminderInput { - /** Injection variant stamped on the delivered message's origin. */ readonly variant: string; - /** Reminder body; wrapped into `` by the write head. */ readonly content: string; - /** Optional provenance link to the prompt this reminder belongs to. */ - readonly ownerPromptId?: string; } -/** - * Exactly-once channel for past-tense reminder events ("guaranteed delivery, - * then forget"). Enqueued entries persist in the agent's wire journal and are - * delivered FIFO by the unified boundary scheduler (`contextInjector`): on - * `turn.started`, on `onWillBeginStep`, after compaction, and on restore - * (pending entries are drained before the loop resumes). After delivery the - * queue loses all interest in the message — compaction or undo removing it - * later never triggers a re-send. - */ export interface IAgentReminderQueueService { readonly _serviceBrand: undefined; - /** Persist a pending entry; returns its id. */ enqueue(input: OnceReminderInput): string; - /** - * Deliver every pending entry in FIFO order, marking each delivered. - * Synchronous; idempotent across concurrent or repeated calls — an entry - * whose reminder already sits at the conversation tail (crash between - * append and delivered-record) is reconciled without a duplicate append. - */ drain(): void; } diff --git a/packages/agent-core-v2/src/agent/reminderQueue/reminderQueueOps.ts b/packages/agent-core-v2/src/agent/reminderQueue/reminderQueueOps.ts index 9c236732c5f..c98db3706d0 100644 --- a/packages/agent-core-v2/src/agent/reminderQueue/reminderQueueOps.ts +++ b/packages/agent-core-v2/src/agent/reminderQueue/reminderQueueOps.ts @@ -20,14 +20,12 @@ export interface ReminderQueueEntry { readonly id: string; readonly variant: string; readonly content: string; - readonly ownerPromptId?: string; } const reminderQueueEntrySchema = z.object({ id: z.string().min(1), variant: z.string().min(1), content: z.string(), - ownerPromptId: z.string().optional(), }); export const ReminderQueueModel = defineModel( diff --git a/packages/agent-core-v2/src/agent/reminderQueue/reminderQueueService.ts b/packages/agent-core-v2/src/agent/reminderQueue/reminderQueueService.ts index 3272bbfbd21..b5f6ba6ee41 100644 --- a/packages/agent-core-v2/src/agent/reminderQueue/reminderQueueService.ts +++ b/packages/agent-core-v2/src/agent/reminderQueue/reminderQueueService.ts @@ -45,15 +45,10 @@ export class AgentReminderQueueService extends Disposable implements IAgentRemin drain(): void { for (const entry of this.wire.getModel(ReminderQueueModel)) { - // Exactly-once dedup: a crash between the reminder append (persisted as - // `context.append_message`) and the delivered record leaves the entry - // pending with its reminder already at the conversation tail — reconcile - // by clearing the ledger instead of appending a duplicate. if (!this.alreadyDeliveredAtTail(entry)) { this.reminders.appendSystemReminder(entry.content, { kind: 'injection', variant: entry.variant, - ownerPromptId: entry.ownerPromptId, }); } this.wire.dispatch(reminderQueueDelivered({ id: entry.id })); diff --git a/packages/agent-core-v2/src/agent/swarm/injection/swarmInjection.ts b/packages/agent-core-v2/src/agent/swarm/injection/swarmInjection.ts index fa0c66c3a03..8d7697ddba2 100644 --- a/packages/agent-core-v2/src/agent/swarm/injection/swarmInjection.ts +++ b/packages/agent-core-v2/src/agent/swarm/injection/swarmInjection.ts @@ -5,25 +5,15 @@ * `permissionMode`: the enter guidance is re-announced whenever its live * positions are folded away (compaction), and enter/exit transitions render * the corresponding reminder. Tool-triggered swarms never render — the - * AgentSwarm tool result already tells the model. The plain-data - * last-rendered flag is registered into `agentState` (`IAgentStateService`) - * and read/written through it; on wire restore it is seeded from the last - * `swarm_mode` reminder surviving in the replayed history, so a resume - * neither duplicates the live enter guidance nor leaves a stale one - * uncorrected (a seeded `active` that no longer matches the model renders - * the exit reminder). + * AgentSwarm tool result already tells the model. Reconciliation derives the + * rendered state from the latest surviving `contextMemory` message, so undo, + * compaction, and restore cannot leave a stale in-memory render flag. */ import { Disposable } from '#/_base/di/lifecycle'; -import { defineState } from '#/_base/state/stateRegistry'; -import { - IAgentContextInjectorService, - type ContextInjectionContext, -} from '#/agent/contextInjector/contextInjector'; +import { IAgentContextInjectorService } from '#/agent/contextInjector/contextInjector'; import { IAgentContextMemoryService } from '#/agent/contextMemory/contextMemory'; import type { ContextMessage } from '#/agent/contextMemory/types'; -import { IAgentStateService } from '#/agent/state/agentState'; -import { IWireService } from '#/wire/wire'; import SWARM_MODE_ENTER_REMINDER from '../enter-reminder.md?raw'; import SWARM_MODE_EXIT_REMINDER from '../exit-reminder.md?raw'; @@ -31,11 +21,6 @@ import type { SwarmModeTrigger } from '../swarm'; const SWARM_MODE_INJECTION_VARIANT = 'swarm_mode'; -export const swarmLastRenderedKey = defineState<'active' | 'inactive' | undefined>( - 'swarm.lastRendered', - () => undefined as 'active' | 'inactive' | undefined, -); - export interface SwarmInjectionOptions { readonly getTrigger: () => SwarmModeTrigger | null; } @@ -44,54 +29,23 @@ export class SwarmInjection extends Disposable { constructor( private readonly options: SwarmInjectionOptions, @IAgentContextInjectorService dynamicInjector: IAgentContextInjectorService, - @IAgentStateService private readonly states: IAgentStateService, @IAgentContextMemoryService private readonly context: IAgentContextMemoryService, - @IWireService wire: IWireService, ) { super(); - this.states.register(swarmLastRenderedKey); - this._register( - dynamicInjector.register(SWARM_MODE_INJECTION_VARIANT, (ctx) => this.reminder(ctx)), - ); this._register( - wire.hooks.onDidRestore.register('swarm-injection', async (_ctx, next) => { - // Replay rebuilt the history; seed the live flag from the last - // rendered reminder so the resume neither duplicates live guidance - // nor leaves a stale one uncorrected. - const rendered = lastRenderedFromHistory(this.context.get()); - if (rendered !== undefined) this.lastRendered = rendered; - await next(); - }), + dynamicInjector.register(SWARM_MODE_INJECTION_VARIANT, () => this.reminder()), ); } - private get lastRendered(): 'active' | 'inactive' | undefined { - return this.states.get(swarmLastRenderedKey); - } - - private set lastRendered(value: 'active' | 'inactive' | undefined) { - this.states.set(swarmLastRenderedKey, value); - } - - private reminder({ injectedPositions }: ContextInjectionContext): string | undefined { + private reminder(): string | undefined { const trigger = this.options.getTrigger(); const active = trigger !== null && trigger !== 'tool'; - if (active === (this.lastRendered === 'active')) { - if (injectedPositions.length > 0 || !active) return undefined; - return SWARM_MODE_ENTER_REMINDER; - } - this.lastRendered = active ? 'active' : 'inactive'; - return active ? SWARM_MODE_ENTER_REMINDER : SWARM_MODE_EXIT_REMINDER; + const rendered = lastRenderedFromHistory(this.context.get()); + if (active) return rendered === 'active' ? undefined : SWARM_MODE_ENTER_REMINDER; + return rendered === 'active' ? SWARM_MODE_EXIT_REMINDER : undefined; } } -/** - * Recover the rendered state from the replayed history: the last - * `swarm_mode` reminder whose content still matches an enter/exit render. - * Legacy exit reminders (variant `swarm_mode_exit`, written before the - * injector owned the render) are matched too; an unrecognized latest - * reminder seeds nothing. - */ function lastRenderedFromHistory( history: readonly ContextMessage[], ): 'active' | 'inactive' | undefined { diff --git a/packages/agent-core-v2/src/agent/systemReminder/systemReminder.ts b/packages/agent-core-v2/src/agent/systemReminder/systemReminder.ts index f397ea0e6d4..6fec1608d69 100644 --- a/packages/agent-core-v2/src/agent/systemReminder/systemReminder.ts +++ b/packages/agent-core-v2/src/agent/systemReminder/systemReminder.ts @@ -1,15 +1,13 @@ +/** + * `systemReminder` domain — low-level model-facing reminder write contract. + * + * Defines the Agent-scoped write head used by context injection, the durable + * world-event queue, and prompt-owned media annotations. Bound at Agent scope. + */ + import { createDecorator } from "#/_base/di/instantiation"; import type { ContextMessage, PromptOrigin } from '#/agent/contextMemory/types'; - -/** - * The unified reminder write head — wraps content into a `` - * message and appends it to the conversation. This is a low-level INTERNAL - * API: domain code must not call it directly. Past-tense events go through - * `IAgentReminderQueueService.enqueue` (exactly-once); present-tense state - * goes through `IAgentContextInjectorService.register` (reconciled). The only - * legitimate callers are those two scheduler components. - */ export interface IAgentSystemReminderService { readonly _serviceBrand: undefined; diff --git a/packages/agent-core-v2/src/agent/toolSelect/dynamicTools.ts b/packages/agent-core-v2/src/agent/toolSelect/dynamicTools.ts index 67a698eb93d..483bf9ba314 100644 --- a/packages/agent-core-v2/src/agent/toolSelect/dynamicTools.ts +++ b/packages/agent-core-v2/src/agent/toolSelect/dynamicTools.ts @@ -39,7 +39,6 @@ export function isDynamicToolSchemaMessage(message: ContextMessage): boolean { export function isLoadableToolsAnnouncement(message: ContextMessage): boolean { const origin = message.origin; if (origin?.kind === 'injection') return origin.variant === LOADABLE_TOOLS_VARIANT; - // Legacy journals announced through `{kind: 'system_trigger'}`. return origin?.kind === 'system_trigger' && origin.name === LOADABLE_TOOLS_VARIANT; } diff --git a/packages/agent-core-v2/src/index.ts b/packages/agent-core-v2/src/index.ts index 3f214cfc21c..0e0fe4dda58 100644 --- a/packages/agent-core-v2/src/index.ts +++ b/packages/agent-core-v2/src/index.ts @@ -532,6 +532,7 @@ export * from '#/agent/loop/loopContinuation'; export * from '#/agent/loop/loopContinuationService'; export * from '#/agent/interruptionReminder/interruptionReminder'; export * from '#/agent/interruptionReminder/interruptionReminderService'; +export * from '#/agent/interruptionReminder/interruptionReminderOps'; export * from '#/agent/mcp/mcp'; export * from '#/agent/mcp/mcpService'; export * from '#/agent/mcp/mcpDiscoveryOps'; diff --git a/packages/agent-core-v2/src/wire/wireService.ts b/packages/agent-core-v2/src/wire/wireService.ts index 71d2e518129..af2ee012398 100644 --- a/packages/agent-core-v2/src/wire/wireService.ts +++ b/packages/agent-core-v2/src/wire/wireService.ts @@ -49,13 +49,6 @@ import { const MAX_DRAIN = 100; -/** - * Record types this build intentionally removed. Replay drops them silently: - * their absence is by design, so they must not surface as unexpected errors - * the way genuinely unknown types do. - */ -const RETIRED_RECORD_TYPES: ReadonlySet = new Set(['interruptionReminder.recorded']); - export class CycleError extends WireError { constructor(readonly depth: number, readonly opTypes: readonly string[]) { super( @@ -220,9 +213,7 @@ export class WireService extends Disposable implements IWireService { private replayRecord(record: WireRecord, index: number): void { const descriptor = OP_REGISTRY.get(record.type); if (descriptor === undefined) { - if (!RETIRED_RECORD_TYPES.has(record.type)) { - this.reportSkippedRecord(record.type, index); - } + this.reportSkippedRecord(record.type, index); return; } const payload = descriptor.schema.safeParse(wireRecordToPayload(record)); diff --git a/packages/agent-core-v2/test/agent/contextMemory/context.test.ts b/packages/agent-core-v2/test/agent/contextMemory/context.test.ts index 220e2478aab..8f54ea05d90 100644 --- a/packages/agent-core-v2/test/agent/contextMemory/context.test.ts +++ b/packages/agent-core-v2/test/agent/contextMemory/context.test.ts @@ -672,7 +672,7 @@ describe('Agent context', () => { ]); }); - it('removes the image compression reminder delivered after its prompt when undoing it', async () => { + it('removes the prompt-owned image compression reminder when undoing its prompt', async () => { profile.update({ activeToolNames: [] }); const caption = buildImageCompressionCaption({ original: { width: 3264, height: 666, byteLength: 344 * 1024, mimeType: 'image/png' }, @@ -685,11 +685,15 @@ describe('Agent context', () => { }); await ctx.untilTurnEnd(); - // Captions ride the once-reminder queue now and land after their host - // prompt; undo still cuts the pair together. expect(context.get()).toMatchObject([ - { origin: { kind: 'user' } }, - { origin: { kind: 'injection', variant: 'image_compression' } }, + { + origin: { + kind: 'injection', + variant: 'image_compression', + ownerPromptId: expect.any(String), + }, + }, + { origin: { kind: 'user' }, id: expect.any(String) }, { role: 'assistant' }, ]); diff --git a/packages/agent-core-v2/test/agent/goal/goal.test.ts b/packages/agent-core-v2/test/agent/goal/goal.test.ts index 2128061b2a9..6f593f319e6 100644 --- a/packages/agent-core-v2/test/agent/goal/goal.test.ts +++ b/packages/agent-core-v2/test/agent/goal/goal.test.ts @@ -501,8 +501,6 @@ describe('AgentGoalService', () => { const removed = await goals.cancelGoal(); expect(removed.status).toBe('active'); expect(goals.getGoal()).toEqual({ goal: null }); - // The cancellation reminder rides the once-reminder queue now; the - // boundary scheduler delivers it ahead of the next prompt. ctx.get(IAgentReminderQueueService).drain(); const reminder = context.get().at(-1); expect(reminder?.origin).toEqual({ kind: 'injection', variant: 'goal_cancelled' }); @@ -2307,8 +2305,6 @@ describe('AgentGoalService fork boundaries', () => { ]); expect(goals.getGoal().goal).toBeNull(); - // Enqueued during replay normalization; the boundary scheduler delivers - // it ahead of the next prompt. ctx.get(IAgentReminderQueueService).drain(); const reminder = context.get().at(-1); expect(reminder?.origin).toEqual({ kind: 'injection', variant: 'goal_fork_cleared' }); @@ -2335,8 +2331,6 @@ describe('AgentGoalService fork boundaries', () => { }, ]); - // The legacy reminder clears the pending flag during replay, so the - // normalization enqueues nothing and the drain delivers nothing. ctx.get(IAgentReminderQueueService).drain(); expect(context.get()).toHaveLength(1); expect(context.get()[0]?.origin).toEqual({ kind: 'system_trigger', name: 'goal_fork_cleared' }); diff --git a/packages/agent-core-v2/test/agent/loop/loop.test.ts b/packages/agent-core-v2/test/agent/loop/loop.test.ts index 1abecc44779..948dee7dede 100644 --- a/packages/agent-core-v2/test/agent/loop/loop.test.ts +++ b/packages/agent-core-v2/test/agent/loop/loop.test.ts @@ -1006,8 +1006,6 @@ describe('interruption reminder', () => { await expect(turn.result).resolves.toMatchObject({ type: 'cancelled' }); subscription.dispose(); - // Boundary delivery: the reminder is enqueued at turn.ended but only - // drained when the next turn starts. expect(ctx.contextData().history).toEqual([ expect.objectContaining({ role: 'user', content: [{ type: 'text', text: 'Hello' }] }), { @@ -1047,8 +1045,7 @@ describe('interruption reminder', () => { text: '\nThe previous turn was interrupted by the user before completion; any partial output shown above is incomplete. The user\'s next message continues the conversation.\n', }, ]); - // The reminder lands ahead of the next user message. - expect(ctx.contextData().history.indexOf(interruptionReminders()[0]!)).toBe(2); + expect(ctx.contextData().history.indexOf(interruptionReminders()[0]!)).toBe(3); }); it('writes one active cancellation when cancel repeats before the turn settles', async () => { @@ -1063,29 +1060,16 @@ describe('interruption reminder', () => { const turn = (await loop.enqueue(nextTurnMessage('Hello')).assigned).turn; await expect(turn.result).resolves.toMatchObject({ type: 'cancelled' }); subscription.dispose(); - await ctx.wire.flush(); - expect(results).toEqual([true, true]); expect( ctx.allEvents.filter( (entry) => entry.type === '[wire]' && entry.event === 'turn.cancel', ), ).toHaveLength(1); - expect( - ctx.allEvents.filter( - (entry) => entry.type === '[wire]' && entry.event === 'reminderQueue.enqueue', - ), - ).toHaveLength(1); - ctx.mockNextResponse({ type: 'text', text: 'second answer' }); await ctx.rpc.prompt({ input: [{ type: 'text', text: 'Next' }] }); await ctx.untilTurnEnd(); expect(interruptionReminders()).toHaveLength(1); - expect( - ctx.allEvents.filter( - (entry) => entry.type === '[wire]' && entry.event === 'reminderQueue.delivered', - ), - ).toHaveLength(1); }); it('preserves the partial stream but appends no reminder on programmatic abort', async () => { @@ -1123,7 +1107,6 @@ describe('interruption reminder', () => { subscription.dispose(); expect(interruptionReminders()).toHaveLength(0); - // A duplicate enqueue for the same notice collapses at drain time. ctx.get(IEventBus).publish({ type: 'turn.ended', turnId: 99, @@ -1159,9 +1142,7 @@ describe('interruption reminder', () => { loop.cancel(active.id); await expect(active.result).resolves.toMatchObject({ type: 'cancelled' }); - // Only the active turn's cancel enqueues a reminder; nothing is drained - // until the next turn boundary. - expect(interruptionReminders()).toHaveLength(0); + expect(interruptionReminders()).toHaveLength(1); const queuedCancel = ctx.allEvents.find( (entry) => entry.type === '[wire]' && @@ -1176,7 +1157,7 @@ describe('interruption reminder', () => { expect(interruptionReminders()).toHaveLength(1); }); - it('sends the partial output and reminder ahead of the next user message', async () => { + it('sends the partial output and reminder in the next atomic step', async () => { ctx.mockNextResponse({ type: 'text', text: 'partial answer' }, { type: 'text', text: ' more' }); const subscription = cancelOnFirstDelta(); await ctx.rpc.prompt({ input: [{ type: 'text', text: 'Hello' }] }); @@ -1192,30 +1173,27 @@ describe('interruption reminder', () => { messages: assistant: text "partial answer" - user: text "\\nThe previous turn was interrupted by the user before completion; any partial output shown above is incomplete. The user's next message continues the conversation.\\n" user: text "Next" + user: text "\\nThe previous turn was interrupted by the user before completion; any partial output shown above is incomplete. The user's next message continues the conversation.\\n" `); }); - it('keeps the pending once-entry across undo (delivery is a world fact)', async () => { + it('drops the pending interruption when its cancelled turn is undone', async () => { ctx.mockNextResponse({ type: 'text', text: 'partial answer' }); const subscription = cancelOnFirstDelta(); await ctx.rpc.prompt({ input: [{ type: 'text', text: 'Hello' }] }); await ctx.untilTurnEnd(); subscription.dispose(); - // Enqueued but not yet drained: nothing in the history yet. expect(interruptionReminders()).toHaveLength(0); await ctx.undoHistory(1); expect(ctx.contextData().history).toEqual([]); - // The ledger is not checkpointed: undo does not revoke the pending - // delivery, which lands at the next boundary. ctx.mockNextResponse({ type: 'text', text: 'second answer' }); await ctx.rpc.prompt({ input: [{ type: 'text', text: 'Next' }] }); await ctx.untilTurnEnd(); - expect(interruptionReminders()).toHaveLength(1); + expect(interruptionReminders()).toHaveLength(0); }); it('preserves partial thinking on user cancel', async () => { @@ -1258,8 +1236,6 @@ describe('interruption reminder', () => { first.dispose(); expect(interruptionReminders()).toHaveLength(0); - // The pending entry drains as the retry turn starts; the retry turn's own - // cancel enqueues a duplicate that collapses at the next drain. ctx.mockNextResponse({ type: 'text', text: 'retried answer' }); const onStepStarted = ctx.get(IEventBus).subscribe('turn.step.started', () => { loop.cancel(); @@ -1275,6 +1251,30 @@ describe('interruption reminder', () => { expect(interruptionReminders()).toHaveLength(1); }); + it('renders a new interruption reminder after an intervening completed turn', async () => { + ctx.mockNextResponse({ type: 'text', text: 'first partial answer' }); + const first = cancelOnFirstDelta(); + await ctx.rpc.prompt({ input: [{ type: 'text', text: 'first prompt' }] }); + await ctx.untilTurnEnd(); + first.dispose(); + + ctx.mockNextResponse({ type: 'text', text: 'completed answer' }); + await ctx.rpc.prompt({ input: [{ type: 'text', text: 'completed prompt' }] }); + await ctx.untilTurnEnd(); + expect(interruptionReminders()).toHaveLength(1); + + ctx.mockNextResponse({ type: 'text', text: 'second partial answer' }); + const second = cancelOnFirstDelta(); + await ctx.rpc.prompt({ input: [{ type: 'text', text: 'second prompt' }] }); + await ctx.untilTurnEnd(); + second.dispose(); + + ctx.mockNextResponse({ type: 'text', text: 'final answer' }); + await ctx.rpc.prompt({ input: [{ type: 'text', text: 'final prompt' }] }); + await ctx.untilTurnEnd(); + expect(interruptionReminders()).toHaveLength(2); + }); + it('does not duplicate recorded content when cancelled during tool execution', async () => { const local = createTestAgent(permissionModeServices('yolo')); try { @@ -1294,8 +1294,6 @@ describe('interruption reminder', () => { await expect(turn.result).resolves.toMatchObject({ type: 'cancelled' }); expect(contentPartRecordsIn(local)).toBe(2); - // The reminder drains at the next turn boundary, ahead of the next - // user message. expect(remindersIn(local)).toHaveLength(0); local.mockNextResponse({ type: 'text', text: 'follow-up answer' }); @@ -1305,8 +1303,8 @@ describe('interruption reminder', () => { const history = local.contextData().history; expect(remindersIn(local)).toHaveLength(1); const reminderIndex = history.indexOf(remindersIn(local)[0]!); - expect(history[reminderIndex - 1]?.role).toBe('tool'); - expect(history[reminderIndex + 1]).toMatchObject({ + expect(history.slice(0, reminderIndex).some((message) => message.role === 'tool')).toBe(true); + expect(history[reminderIndex - 1]).toMatchObject({ role: 'user', content: [{ type: 'text', text: 'again' }], }); diff --git a/packages/agent-core-v2/test/agent/plugin/agentPlugin.test.ts b/packages/agent-core-v2/test/agent/plugin/agentPlugin.test.ts index a11ea1e755d..8d1c85874b7 100644 --- a/packages/agent-core-v2/test/agent/plugin/agentPlugin.test.ts +++ b/packages/agent-core-v2/test/agent/plugin/agentPlugin.test.ts @@ -13,8 +13,8 @@ import { SyncDescriptor } from '#/_base/di/descriptors'; import { Emitter } from '#/_base/event'; import { IAgentPluginService } from '#/agent/plugin/agentPlugin'; import { AgentPluginService } from '#/agent/plugin/agentPluginService'; -import { IAgentContextInjectorService } from '#/agent/contextInjector/contextInjector'; import { USER_PROMPT_ORIGIN } from '#/agent/contextMemory/types'; +import { IAgentLoopService } from '#/agent/loop/loop'; import { IEventBus } from '#/app/event/eventBus'; import { IPluginService } from '#/app/plugin/plugin'; import type { EnabledPluginSessionStart, ReloadSummary } from '#/app/plugin/types'; @@ -79,8 +79,12 @@ function messageText(message: { readonly content: readonly { readonly type: stri return message.content.map((part) => (part.type === 'text' ? (part.text ?? '') : '')).join(''); } -async function injectRegistered(ctx: TestAgentContext): Promise { - await (ctx.get(IAgentContextInjectorService) as unknown as { inject(): Promise }).inject(); +async function runInjectionBoundary(ctx: TestAgentContext): Promise { + await ctx.get(IAgentLoopService).hooks.onWillBeginStep.run({ + turnId: 0, + step: 1, + signal: new AbortController().signal, + }); } describe('AgentPluginService plugin session-start wiring', () => { @@ -110,7 +114,7 @@ describe('AgentPluginService plugin session-start wiring', () => { ctx.get(IAgentPluginService); - await injectRegistered(ctx); + await runInjectionBoundary(ctx); const injected = findPluginSessionStartMessages(ctx).at(-1); expect(injected).toBeDefined(); @@ -139,13 +143,13 @@ describe('AgentPluginService plugin session-start wiring', () => { ctx.get(IAgentPluginService); - await injectRegistered(ctx); + await runInjectionBoundary(ctx); ctx.get(IEventBus).publish({ type: 'turn.started', turnId: 2, origin: USER_PROMPT_ORIGIN, }); - await injectRegistered(ctx); + await runInjectionBoundary(ctx); expect(findPluginSessionStartMessages(ctx)).toHaveLength(1); }); @@ -166,7 +170,7 @@ describe('AgentPluginService plugin session-start wiring', () => { ctx.get(IAgentPluginService); - await injectRegistered(ctx); + await runInjectionBoundary(ctx); expect(findPluginSessionStartMessages(ctx)).toHaveLength(0); }); @@ -202,15 +206,13 @@ describe('AgentPluginService plugin session-start wiring', () => { ctx.get(IAgentPluginService); - await injectRegistered(ctx); + await runInjectionBoundary(ctx); expect(findPluginSessionStartMessages(ctx)).toHaveLength(1); - // The catalog change only marks the provider dirty; the supersedes - // render lands at the next injection boundary. sinkChange.fire('plugin'); expect(findPluginSessionStartMessages(ctx)).toHaveLength(1); - await injectRegistered(ctx); + await runInjectionBoundary(ctx); const messages = findPluginSessionStartMessages(ctx); expect(messages.length).toBeGreaterThanOrEqual(2); @@ -251,14 +253,68 @@ describe('AgentPluginService plugin session-start wiring', () => { ctx.get(IAgentPluginService); - await injectRegistered(ctx); + await runInjectionBoundary(ctx); expect(findPluginSessionStartMessages(ctx)).toHaveLength(1); sinkChange.fire('user'); sinkChange.fire('plugin'); - await injectRegistered(ctx); + await runInjectionBoundary(ctx); expect(findPluginSessionStartMessages(ctx)).toHaveLength(2); sinkChange.dispose(); }); + + it('reconciles the current plugin guidance after undo removes its latest render', async () => { + const catalog = new InMemorySkillCatalog(); + catalog.register(pluginSkill()); + const sinkChange = new Emitter(); + const skillCatalog: ISessionSkillCatalog = { + _serviceBrand: undefined, + catalog, + ready: Promise.resolve(), + onDidChange: sinkChange.event, + load: async () => {}, + reload: async () => {}, + list: async () => catalog.listSkills().map(summarizeSkill), + }; + + ctx = createTestAgent( + { autoConfigure: true }, + appService( + IPluginService, + pluginServiceStub({ + sessionStarts: [{ pluginId: 'demo', skillName: 'demo-skill' }], + }), + ), + skillServices(skillCatalog), + agentService(IAgentPluginService, new SyncDescriptor(AgentPluginService)), + ); + ctx.get(IAgentPluginService); + + ctx.mockNextResponse({ type: 'text', text: 'first answer' }); + await ctx.rpc.prompt({ input: [{ type: 'text', text: 'first prompt' }] }); + await ctx.untilTurnEnd(); + + catalog.register( + { ...pluginSkill(), content: 'Do the updated demo thing.' }, + { replace: true }, + ); + sinkChange.fire('plugin'); + ctx.mockNextResponse({ type: 'text', text: 'second answer' }); + await ctx.rpc.prompt({ input: [{ type: 'text', text: 'second prompt' }] }); + await ctx.untilTurnEnd(); + + await ctx.undoHistory(1); + ctx.mockNextResponse({ type: 'text', text: 'third answer' }); + await ctx.rpc.prompt({ input: [{ type: 'text', text: 'third prompt' }] }); + await ctx.untilTurnEnd(); + + const latest = findPluginSessionStartMessages(ctx).at(-1); + expect(latest).toBeDefined(); + expect(messageText(latest!)).toContain('Do the updated demo thing.'); + expect(messageText(latest!)).toContain( + 'supersedes any earlier plugin_session_start reminder', + ); + sinkChange.dispose(); + }); }); diff --git a/packages/agent-core-v2/test/agent/prompt/promptService.test.ts b/packages/agent-core-v2/test/agent/prompt/promptService.test.ts index f0a2f785494..c1f82551f7a 100644 --- a/packages/agent-core-v2/test/agent/prompt/promptService.test.ts +++ b/packages/agent-core-v2/test/agent/prompt/promptService.test.ts @@ -17,10 +17,6 @@ import { IAgentFullCompactionService } from '#/agent/fullCompaction/fullCompacti import { IAgentLoopService } from '#/agent/loop/loop'; import { IAgentPromptService } from '#/agent/prompt/prompt'; import { AgentPromptService } from '#/agent/prompt/promptService'; -import { - IAgentReminderQueueService, - type OnceReminderInput, -} from '#/agent/reminderQueue/reminderQueue'; import { IAgentSystemReminderService } from '#/agent/systemReminder/systemReminder'; import { AgentSystemReminderService } from '#/agent/systemReminder/systemReminderService'; import { IAgentToolExecutorService } from '#/agent/toolExecutor/toolExecutor'; @@ -38,34 +34,6 @@ function message(text: string): ContextMessage { return { role: 'user', content: [{ type: 'text', text }], toolCalls: [], origin: { kind: 'user' } }; } -/** - * In-memory once-reminder queue honoring the production contract: enqueue - * collects pending entries, drain delivers them FIFO through the real - * system-reminder write head. The stub wire behind this harness cannot back - * the persisted `AgentReminderQueueService` ledger, so the fake mirrors it. - */ -function createReminderQueueFake(reminders: IAgentSystemReminderService) { - const pending: Array<{ id: string } & OnceReminderInput> = []; - return { - _serviceBrand: undefined, - pending, - enqueue(input: OnceReminderInput): string { - const entry = { id: `reminder-${pending.length + 1}`, ...input }; - pending.push(entry); - return entry.id; - }, - drain(): void { - for (const entry of pending.splice(0)) { - reminders.appendSystemReminder(entry.content, { - kind: 'injection', - variant: entry.variant, - ownerPromptId: entry.ownerPromptId, - }); - } - }, - } satisfies IAgentReminderQueueService & { pending: Array<{ id: string } & OnceReminderInput> }; -} - function harness() { const disposables = new DisposableStore(); onTestFinished(() => disposables.dispose()); @@ -91,9 +59,7 @@ function harness() { reg.define(IAgentPromptService, AgentPromptService); } }); - const reminderQueue = createReminderQueueFake(ix.get(IAgentSystemReminderService)); - ix.set(IAgentReminderQueueService, reminderQueue); - return { prompt: ix.get(IAgentPromptService), loop, context, fullCompaction, eventBus: ix.get(IEventBus), reminderQueue }; + return { prompt: ix.get(IAgentPromptService), loop, context, fullCompaction, eventBus: ix.get(IEventBus) }; } describe('AgentPromptService', () => { @@ -169,7 +135,7 @@ describe('AgentPromptService', () => { }); it('delivers a blocked prompt’s compression captions right after their host message', async () => { - const { prompt, context, reminderQueue } = harness(); + const { prompt, context } = harness(); prompt.hooks.onBeforeSubmitPrompt.register('block', async (ctx, next) => { ctx.block = true; await next(); }); const handle = await prompt.enqueue({ id: 'prompt-caption', @@ -179,19 +145,16 @@ describe('AgentPromptService', () => { }); await expect(handle.completion).resolves.toMatchObject({ state: 'blocked' }); - // A blocked prompt starts no turn, so the service enqueues the caption - // and drains immediately — the reminder lands after its host message. - expect(reminderQueue.pending).toEqual([]); const history = context.get(); expect(history).toHaveLength(2); - expect(history[0]?.origin).toEqual({ kind: 'user' }); - expect(history[0]?.content).toEqual([{ type: 'text', text: 'look at this' }]); - expect(history[1]?.origin).toEqual({ + expect(history[0]?.origin).toEqual({ kind: 'injection', variant: 'image_compression', ownerPromptId: 'prompt-caption', }); - const captionPart = history[1]?.content[0]; + expect(history[1]?.origin).toEqual({ kind: 'user' }); + expect(history[1]?.content).toEqual([{ type: 'text', text: 'look at this' }]); + const captionPart = history[0]?.content[0]; expect(captionPart?.type).toBe('text'); expect((captionPart as { text: string }).text).toContain( 'Image compressed to fit model limits: 800x600', diff --git a/packages/agent-core-v2/test/agent/reminderQueue/reminderQueue.test.ts b/packages/agent-core-v2/test/agent/reminderQueue/reminderQueue.test.ts index 8996f145e5e..53c5a5eee19 100644 --- a/packages/agent-core-v2/test/agent/reminderQueue/reminderQueue.test.ts +++ b/packages/agent-core-v2/test/agent/reminderQueue/reminderQueue.test.ts @@ -73,7 +73,7 @@ describe('AgentReminderQueueService', () => { }, { type: 'reminderQueue.enqueue', - entry: { id: 'e2', variant: 'init', content: 'second', ownerPromptId: 'p1' }, + entry: { id: 'e2', variant: 'init', content: 'second' }, }, ] as unknown as WireRecord[]; @@ -85,12 +85,11 @@ describe('AgentReminderQueueService', () => { ['interruption', 'first'], ['init', 'second'], ]); - expect(pending[1]!.ownerPromptId).toBe('p1'); }); it('drains pending entries FIFO, wraps them as system reminders, and clears the ledger', () => { queue.enqueue({ variant: 'a', content: 'first' }); - queue.enqueue({ variant: 'b', content: 'second', ownerPromptId: 'p1' }); + queue.enqueue({ variant: 'b', content: 'second' }); queue.drain(); @@ -103,7 +102,7 @@ describe('AgentReminderQueueService', () => { }); expect(messages[1]).toMatchObject({ content: [{ type: 'text', text: '\nsecond\n' }], - origin: { kind: 'injection', variant: 'b', ownerPromptId: 'p1' }, + origin: { kind: 'injection', variant: 'b' }, }); expect(wire.getModel(ReminderQueueModel)).toEqual([]); expect(records.map((record) => record.type)).toEqual([ @@ -145,9 +144,6 @@ describe('AgentReminderQueueService', () => { }); it('reconciles an appended-but-unrecorded delivery after restore without duplicating', async () => { - // The reminder append landed (persisted as context.append_message, so it - // sits at the conversation tail after the context replays), but the - // delivered record never reached the journal. reminders.appendSystemReminder('crash window', { kind: 'injection', variant: 'interruption', diff --git a/packages/agent-core-v2/test/agent/swarm/swarm.test.ts b/packages/agent-core-v2/test/agent/swarm/swarm.test.ts index b54f7231cab..029f0ab6a22 100644 --- a/packages/agent-core-v2/test/agent/swarm/swarm.test.ts +++ b/packages/agent-core-v2/test/agent/swarm/swarm.test.ts @@ -1,3 +1,13 @@ +/** + * Scenario: swarm service policy, context reconciliation, persistence, and + * tool execution. + * + * Exercises the Agent-scoped service through DI and public loop boundaries, + * with storage, session swarm execution, and approvals stubbed. Run: + * `pnpm --filter @moonshot-ai/agent-core-v2 exec vitest run + * test/agent/swarm/swarm.test.ts`. + */ + import { afterEach, beforeEach, describe, expect, it, vi, type Mock } from 'vitest'; import { makeAgentScopeContext } from '#/agent/scopeContext/scopeContext'; @@ -53,9 +63,14 @@ import { registerTestAgentWire, restoreTestAgentWire, testWireScope } from '../. import { stubLoopWithHooks } from '../loop/stubs'; import { stubToolExecutorEvents, type ToolExecutorEventStubs } from '../toolExecutor/stubs'; import { stubFlag } from '../../app/flag/stubs'; +import { createTestAgent } from '../../harness'; const signal = new AbortController().signal; +async function runInjectionBoundary(loop: IAgentLoopService): Promise { + await loop.hooks.onWillBeginStep.run({ turnId: 0, step: 1, signal }); +} + function context( args: Input, toolCallId = 'call_swarm', @@ -221,8 +236,6 @@ describe('AgentSwarmService', () => { swarm.exit(); expect(swarm.isActive).toBe(false); - // Exit no longer pops the enter reminder out of the context — reminders - // are pure state renders through the injector, so no splice is emitted. expect(events).toEqual([ { type: 'agent.status.updated', swarmMode: true }, { type: 'agent.status.updated', swarmMode: false }, @@ -232,45 +245,36 @@ describe('AgentSwarmService', () => { it('renders the enter guidance on enter and the exit guidance on exit through the injector', async () => { const swarm = ix.get(IAgentSwarmService); const context = ix.get(IAgentContextMemoryService); - const injector = ix.get(IAgentContextInjectorService) as unknown as { - inject(): Promise; - }; const textOf = (message: ContextMessage | undefined): string => message?.content.map((part) => (part.type === 'text' ? part.text : '')).join('') ?? ''; swarm.enter('manual'); - await injector.inject(); + await runInjectionBoundary(ix.get(IAgentLoopService)); let reminder = context.get().at(-1); expect(reminder?.origin).toEqual({ kind: 'injection', variant: 'swarm_mode' }); expect(textOf(reminder)).toContain('You are now in "agent swarm" mode.'); - // No transition and the guidance is still live: nothing re-renders. - await injector.inject(); + await runInjectionBoundary(ix.get(IAgentLoopService)); expect(context.get()).toHaveLength(1); swarm.exit(); - await injector.inject(); + await runInjectionBoundary(ix.get(IAgentLoopService)); reminder = context.get().at(-1); expect(reminder?.origin).toEqual({ kind: 'injection', variant: 'swarm_mode' }); expect(textOf(reminder)).toContain('Swarm Mode has ended.'); - // The exit render replaces, not appends to, the enter guidance — and the - // enter copy is never spliced out. expect(context.get()).toHaveLength(2); }); it('renders no reminder at all for tool-triggered swarms', async () => { const swarm = ix.get(IAgentSwarmService); const context = ix.get(IAgentContextMemoryService); - const injector = ix.get(IAgentContextInjectorService) as unknown as { - inject(): Promise; - }; swarm.enter('tool'); - await injector.inject(); + await runInjectionBoundary(ix.get(IAgentLoopService)); swarm.exit(); - await injector.inject(); + await runInjectionBoundary(ix.get(IAgentLoopService)); expect(context.get()).toHaveLength(0); }); @@ -278,12 +282,6 @@ describe('AgentSwarmService', () => { it('does not duplicate the enter guidance on resume while it is still live in history', async () => { const swarm = ix.get(IAgentSwarmService); const context = ix.get(IAgentContextMemoryService); - const injector = ix.get(IAgentContextInjectorService) as unknown as { - inject(): Promise; - }; - - // A previous process rendered the guidance; replay rebuilds the trigger - // and the restore seed recovers the rendered flag from the history. ix.get(IAgentSystemReminderService).appendSystemReminder(SWARM_MODE_ENTER_REMINDER, { kind: 'injection', variant: 'swarm_mode', @@ -295,7 +293,7 @@ describe('AgentSwarmService', () => { [{ type: 'swarm_mode.enter', trigger: 'manual' }], ); - await injector.inject(); + await runInjectionBoundary(ix.get(IAgentLoopService)); expect(swarm.isActive).toBe(true); expect(context.get()).toHaveLength(1); @@ -304,10 +302,6 @@ describe('AgentSwarmService', () => { it('renders a corrective exit reminder when resume finds live enter guidance but swarm is off', async () => { const swarm = ix.get(IAgentSwarmService); const context = ix.get(IAgentContextMemoryService); - const injector = ix.get(IAgentContextInjectorService) as unknown as { - inject(): Promise; - }; - ix.get(IAgentSystemReminderService).appendSystemReminder(SWARM_MODE_ENTER_REMINDER, { kind: 'injection', variant: 'swarm_mode', @@ -319,7 +313,7 @@ describe('AgentSwarmService', () => { [{ type: 'swarm_mode.enter', trigger: 'manual' }, { type: 'swarm_mode.exit' }], ); - await injector.inject(); + await runInjectionBoundary(ix.get(IAgentLoopService)); expect(swarm.isActive).toBe(false); const reminder = context.get().at(-1); @@ -414,6 +408,41 @@ describe('AgentSwarmService', () => { }); }); +describe('swarm context reconciliation', () => { + it('renders the corrective exit again when undo removes the latest exit render', async () => { + const ctx = createTestAgent(); + try { + const swarm = ctx.get(IAgentSwarmService); + swarm.enter('manual'); + ctx.mockNextResponse({ type: 'text', text: 'first answer' }); + await ctx.rpc.prompt({ input: [{ type: 'text', text: 'first prompt' }] }); + await ctx.untilTurnEnd(); + + swarm.exit(); + ctx.mockNextResponse({ type: 'text', text: 'second answer' }); + await ctx.rpc.prompt({ input: [{ type: 'text', text: 'second prompt' }] }); + await ctx.untilTurnEnd(); + + await ctx.undoHistory(1); + ctx.mockNextResponse({ type: 'text', text: 'third answer' }); + await ctx.rpc.prompt({ input: [{ type: 'text', text: 'third prompt' }] }); + await ctx.untilTurnEnd(); + + const reminders = ctx.contextData().history.filter( + (message) => + message.origin?.kind === 'injection' && message.origin.variant === 'swarm_mode', + ); + const latest = reminders.at(-1); + const text = latest?.content + .map((part) => (part.type === 'text' ? part.text : '')) + .join(''); + expect(text).toContain('Swarm Mode has ended.'); + } finally { + await ctx.dispose(); + } + }); +}); + describe('AgentSwarmTool', () => { it('applies one subagent_type across templated subagents', async () => { const host = mockSwarmHost({ diff --git a/packages/agent-core-v2/test/agent/toolSelect/toolSelectService.test.ts b/packages/agent-core-v2/test/agent/toolSelect/toolSelectService.test.ts index 38cb15d35a3..12c3edcce79 100644 --- a/packages/agent-core-v2/test/agent/toolSelect/toolSelectService.test.ts +++ b/packages/agent-core-v2/test/agent/toolSelect/toolSelectService.test.ts @@ -289,7 +289,6 @@ class FakeContextMemory implements IAgentContextMemoryService { role: 'user', content: [{ type: 'text', text: `\n${content.trim()}\n` }], toolCalls: [], - // v1 journal compat: older announcements carry the system_trigger origin. origin: { kind: 'system_trigger', name: LOADABLE_TOOLS_VARIANT }, }); } @@ -1039,7 +1038,6 @@ describe('AgentToolSelectService loadable-tools announcements', () => { expect(await announce(h, 2)).toBeUndefined(); h.contextMemory.clear(); - // The compaction pipeline re-arms per-turn providers through the injector. const reannounced = await announceAfterCompaction(h); expect(reannounced).toContain(`\n${MCP_ALPHA}\n${MCP_BETA}\n`); }); diff --git a/packages/agent-core-v2/test/index.test.ts b/packages/agent-core-v2/test/index.test.ts index 5879794f9ac..672dbf18b62 100644 --- a/packages/agent-core-v2/test/index.test.ts +++ b/packages/agent-core-v2/test/index.test.ts @@ -88,6 +88,7 @@ const V2_RECORD_TYPES: ReadonlySet = new Set([ 'interaction.request', 'interaction.resolved', 'plan.revision', + 'interruptionReminder.recorded', 'reminderQueue.enqueue', 'reminderQueue.delivered', 'turn.ended', diff --git a/packages/agent-core-v2/test/wire/resume.test.ts b/packages/agent-core-v2/test/wire/resume.test.ts index 9355e5c6c26..7ba8114bce4 100644 --- a/packages/agent-core-v2/test/wire/resume.test.ts +++ b/packages/agent-core-v2/test/wire/resume.test.ts @@ -65,7 +65,7 @@ describe('Agent resume', () => { expect(persistence.records.filter((record) => record.type === 'metadata')).toHaveLength(1); }); - it('drains a pending once-reminder after restore (crash before delivery)', async () => { + it('reconciles a legacy user interruption after restore when delivery was missing', async () => { const persistence = new RecordingAgentPersistence([ resumeConfigRecord(), { @@ -98,21 +98,18 @@ describe('Agent resume', () => { }, }, { type: 'turn.cancel', turnId: 0, target: 'active', reason: 'user_cancelled' }, - { - type: 'reminderQueue.enqueue', - entry: { - id: 'r1', - variant: 'interruption', - content: - "The previous turn was interrupted by the user before completion; any partial output shown above is incomplete. The user's next message continues the conversation.", - }, - }, ] as unknown as WireRecord[]); const ctx = testAgent({ persistence, autoConfigure: false }); try { await ctx.restorePersisted(); + expect(ctx.context.get()).not.toContainEqual( + expect.objectContaining({ origin: { kind: 'injection', variant: 'interruption' } }), + ); + ctx.mockNextResponse({ type: 'text', text: 'Fresh response after resume.' }); + await ctx.rpc.prompt({ input: [{ type: 'text', text: 'Fresh prompt after resume' }] }); + await ctx.untilTurnEnd(); expect(ctx.context.get()).toContainEqual( expect.objectContaining({ role: 'user', @@ -127,9 +124,6 @@ describe('Agent resume', () => { }), }), ); - expect(persistence.appended).toContainEqual( - expect.objectContaining({ type: 'reminderQueue.delivered', id: 'r1' }), - ); await ctx.expectResumeMatches(); } finally { @@ -137,6 +131,42 @@ describe('Agent resume', () => { } }); + it('does not reconcile a legacy interruption whose delivery was recorded', async () => { + const persistence = new RecordingAgentPersistence([ + resumeConfigRecord(), + { + type: 'context.append_message', + message: { + role: 'user', + content: [{ type: 'text', text: 'Hello' }], + toolCalls: [], + origin: { kind: 'user' }, + }, + }, + { + type: 'turn.prompt', + input: [{ type: 'text', text: 'Hello' }], + origin: { kind: 'user' }, + }, + { type: 'turn.cancel', turnId: 0, target: 'active', reason: 'user_cancelled' }, + { type: 'interruptionReminder.recorded', turnId: 0 }, + ] as unknown as WireRecord[]); + const ctx = testAgent({ persistence, autoConfigure: false }); + + try { + await ctx.restorePersisted(); + ctx.mockNextResponse({ type: 'text', text: 'Fresh response after resume.' }); + await ctx.rpc.prompt({ input: [{ type: 'text', text: 'Fresh prompt after resume' }] }); + await ctx.untilTurnEnd(); + + expect(ctx.context.get()).not.toContainEqual( + expect.objectContaining({ origin: { kind: 'injection', variant: 'interruption' } }), + ); + } finally { + await ctx.dispose(); + } + }); + it('replays persisted records without restarting turns, compactions, plan turns, or tools', async () => { const persistence = new RecordingAgentPersistence(resumeHistory() as unknown as WireRecord[]); const execWithEnv = vi.fn().mockRejectedValue(new Error('Bash should not execute on resume')); diff --git a/packages/agent-core-v2/test/wire/wireService.test.ts b/packages/agent-core-v2/test/wire/wireService.test.ts index cd16146d4bb..95c013df755 100644 --- a/packages/agent-core-v2/test/wire/wireService.test.ts +++ b/packages/agent-core-v2/test/wire/wireService.test.ts @@ -16,6 +16,7 @@ import { WIRE_PROTOCOL_VERSION } from '#/wire/migration/migration'; import { IWireService } from '#/wire/wire'; import { AGENT_WIRE_RECORD_KEY, type WireRecord } from '#/wire/record'; import { CycleError } from '#/wire/wireService'; +import '#/agent/interruptionReminder/interruptionReminderOps'; import { registerTestAgentWire, restoreTestAgentWire, testWireScope } from './stubs'; @@ -269,7 +270,7 @@ describe('WireService', () => { } }); - it('skips retired record types during replay without reporting them', async () => { + it('replays legacy interruption records without reporting them', async () => { const unexpected: unknown[] = []; setUnexpectedErrorHandler((error) => unexpected.push(error)); try { From d1551a044162874f90f97beda2a70311c262ff47 Mon Sep 17 00:00:00 2001 From: 7Sageer <7sageer@djwcb.cn> Date: Wed, 5 Aug 2026 14:31:10 +0800 Subject: [PATCH 03/27] test(agent-core-v2): migrate plugin session-start harness --- .../test/agent/plugin/agentPlugin.test.ts | 44 +++------------- .../agent-core-v2/test/app/plugin/stubs.ts | 35 +++++++++++++ .../skillCatalog/plugin-session-start.test.ts | 29 +++++++---- packages/agent-core-v2/test/harness/agent.ts | 50 ------------------- 4 files changed, 60 insertions(+), 98 deletions(-) create mode 100644 packages/agent-core-v2/test/app/plugin/stubs.ts diff --git a/packages/agent-core-v2/test/agent/plugin/agentPlugin.test.ts b/packages/agent-core-v2/test/agent/plugin/agentPlugin.test.ts index 8d1c85874b7..982e57e90fb 100644 --- a/packages/agent-core-v2/test/agent/plugin/agentPlugin.test.ts +++ b/packages/agent-core-v2/test/agent/plugin/agentPlugin.test.ts @@ -17,13 +17,13 @@ import { USER_PROMPT_ORIGIN } from '#/agent/contextMemory/types'; import { IAgentLoopService } from '#/agent/loop/loop'; import { IEventBus } from '#/app/event/eventBus'; import { IPluginService } from '#/app/plugin/plugin'; -import type { EnabledPluginSessionStart, ReloadSummary } from '#/app/plugin/types'; import { InMemorySkillCatalog } from '#/app/skillCatalog/registry'; import { summarizeSkill } from '#/app/skillCatalog/types'; import type { SkillDefinition } from '#/app/skillCatalog/types'; import { ISessionSkillCatalog } from '#/session/sessionSkillCatalog/skillCatalog'; import { agentService, appService, createTestAgent, skillServices, type TestAgentContext } from '../../harness'; +import { stubPluginService } from '../../app/plugin/stubs'; function pluginSkill(): SkillDefinition { return { @@ -38,36 +38,6 @@ function pluginSkill(): SkillDefinition { }; } -interface PluginServiceStubOptions { - readonly sessionStarts: readonly EnabledPluginSessionStart[]; - readonly reloadEmitter?: Emitter; -} - -function pluginServiceStub(options: PluginServiceStubOptions): IPluginService { - const reloadEmitter = options.reloadEmitter; - return { - _serviceBrand: undefined, - onDidReload: reloadEmitter !== undefined ? reloadEmitter.event : () => ({ dispose: () => {} }), - listPlugins: async () => [], - installPlugin: async () => ({ id: '' }) as never, - setPluginEnabled: async () => {}, - setPluginMcpServerEnabled: async () => {}, - removePlugin: async () => {}, - reloadPlugins: async (): Promise => ({ added: [], removed: [], errors: [] }), - getPluginInfo: async () => { - throw new Error('getPluginInfo is not used by these tests'); - }, - listPluginCommands: async () => [], - checkUpdates: async () => [], - pluginSkillRoots: async () => [], - pluginAgentRoots: async () => [], - enabledSessionStarts: async () => options.sessionStarts, - enabledSystemPrompts: async () => [], - enabledMcpServers: async () => ({}), - enabledHooks: async () => [], - }; -} - function findPluginSessionStartMessages(ctx: TestAgentContext) { return ctx.contextData().history.filter( (message) => @@ -103,7 +73,7 @@ describe('AgentPluginService plugin session-start wiring', () => { { autoConfigure: true }, appService( IPluginService, - pluginServiceStub({ sessionStarts: [{ pluginId: 'demo', skillName: 'demo-skill' }] }), + stubPluginService({ sessionStarts: [{ pluginId: 'demo', skillName: 'demo-skill' }] }), ), skillServices(catalog), agentService( @@ -132,7 +102,7 @@ describe('AgentPluginService plugin session-start wiring', () => { { autoConfigure: true }, appService( IPluginService, - pluginServiceStub({ sessionStarts: [{ pluginId: 'demo', skillName: 'demo-skill' }] }), + stubPluginService({ sessionStarts: [{ pluginId: 'demo', skillName: 'demo-skill' }] }), ), skillServices(catalog), agentService( @@ -160,7 +130,7 @@ describe('AgentPluginService plugin session-start wiring', () => { ctx = createTestAgent( { autoConfigure: true }, - appService(IPluginService, pluginServiceStub({ sessionStarts: [] })), + appService(IPluginService, stubPluginService({ sessionStarts: [] })), skillServices(catalog), agentService( IAgentPluginService, @@ -193,7 +163,7 @@ describe('AgentPluginService plugin session-start wiring', () => { { autoConfigure: true }, appService( IPluginService, - pluginServiceStub({ + stubPluginService({ sessionStarts: [{ pluginId: 'demo', skillName: 'demo-skill' }], }), ), @@ -240,7 +210,7 @@ describe('AgentPluginService plugin session-start wiring', () => { { autoConfigure: true }, appService( IPluginService, - pluginServiceStub({ + stubPluginService({ sessionStarts: [{ pluginId: 'demo', skillName: 'demo-skill' }], }), ), @@ -282,7 +252,7 @@ describe('AgentPluginService plugin session-start wiring', () => { { autoConfigure: true }, appService( IPluginService, - pluginServiceStub({ + stubPluginService({ sessionStarts: [{ pluginId: 'demo', skillName: 'demo-skill' }], }), ), diff --git a/packages/agent-core-v2/test/app/plugin/stubs.ts b/packages/agent-core-v2/test/app/plugin/stubs.ts new file mode 100644 index 00000000000..d68f6505f00 --- /dev/null +++ b/packages/agent-core-v2/test/app/plugin/stubs.ts @@ -0,0 +1,35 @@ +/** + * `plugin` domain test stubs — shared plugin boundary fixtures. + */ + +import { Event } from '#/_base/event'; +import type { IPluginService } from '#/app/plugin/plugin'; +import type { EnabledPluginSessionStart, ReloadSummary } from '#/app/plugin/types'; + +interface StubPluginServiceOptions { + readonly sessionStarts: readonly EnabledPluginSessionStart[]; +} + +export function stubPluginService(options: StubPluginServiceOptions): IPluginService { + return { + _serviceBrand: undefined, + onDidReload: Event.None as IPluginService['onDidReload'], + listPlugins: async () => [], + installPlugin: async () => ({ id: '' }) as never, + setPluginEnabled: async () => {}, + setPluginMcpServerEnabled: async () => {}, + removePlugin: async () => {}, + reloadPlugins: async (): Promise => ({ added: [], removed: [], errors: [] }), + getPluginInfo: async () => { + throw new Error('getPluginInfo is not used by this stub'); + }, + listPluginCommands: async () => [], + checkUpdates: async () => [], + pluginSkillRoots: async () => [], + pluginAgentRoots: async () => [], + enabledSessionStarts: async () => options.sessionStarts, + enabledSystemPrompts: async () => [], + enabledMcpServers: async () => ({}), + enabledHooks: async () => [], + }; +} diff --git a/packages/agent-core-v2/test/app/skillCatalog/plugin-session-start.test.ts b/packages/agent-core-v2/test/app/skillCatalog/plugin-session-start.test.ts index 64bb554d4e2..bdff959d61f 100644 --- a/packages/agent-core-v2/test/app/skillCatalog/plugin-session-start.test.ts +++ b/packages/agent-core-v2/test/app/skillCatalog/plugin-session-start.test.ts @@ -12,10 +12,12 @@ import { describe, expect, it } from 'vitest'; import { IAgentContextInjectorService } from '#/agent/contextInjector/contextInjector'; import type { ContextMessage } from '#/agent/contextMemory/types'; import type { LogContext, LogPayload } from '#/_base/log/log'; +import { IPluginService } from '#/app/plugin/plugin'; import type { EnabledPluginSessionStart } from '#/app/plugin/types'; import { InMemorySkillCatalog } from '#/app/skillCatalog/registry'; import type { SkillDefinition } from '#/app/skillCatalog/types'; -import { testAgent } from '../../harness'; +import { appService, logServices, skillServices, testAgent } from '../../harness'; +import { stubPluginService } from '../plugin/stubs'; import { stubSkill } from './stubs'; type InjectableDynamicInjector = { @@ -35,6 +37,12 @@ interface RecordingLogger { createChild(ctx: LogContext): RecordingLogger; } +const CURRENT_PLUGIN_SESSION_START_REMINDER = ` + +body + +`; + function skill( name: string, body: string, @@ -76,12 +84,11 @@ function sessionStartRuntime(input: { for (const skill of input.skills) { skills.register(skill); } - const ctx = testAgent({ - skills, - pluginSessionStarts: input.sessionStarts, - log: recordingLogger(warnings), - }); - ctx.configure(); + const ctx = testAgent( + appService(IPluginService, stubPluginService({ sessionStarts: input.sessionStarts })), + skillServices(skills), + logServices(recordingLogger(warnings)), + ); if (input.history !== undefined) { ctx.context.append(...input.history); } @@ -158,14 +165,14 @@ describe('plugin session-start dynamic injection', () => { expect(pluginSessionStartMessages(ctx)).toHaveLength(1); }); - it('does not re-inject when a live-spliced history already contains plugin sessionStart', async () => { + it('does not re-inject when live-spliced history contains the current plugin sessionStart', async () => { const { ctx } = sessionStartRuntime({ sessionStarts: [{ pluginId: 'superpowers', skillName: 'using-superpowers' }], skills: [skill('using-superpowers', 'body', { id: 'superpowers' })], history: [ { role: 'user', - content: [{ type: 'text', text: 'old' }], + content: [{ type: 'text', text: CURRENT_PLUGIN_SESSION_START_REMINDER }], toolCalls: [], origin: { kind: 'injection', variant: 'plugin_session_start' }, }, @@ -177,7 +184,7 @@ describe('plugin session-start dynamic injection', () => { expect(pluginSessionStartMessages(ctx)).toHaveLength(1); }); - it('does not re-inject after a silent wire replay restored a plugin sessionStart (cold resume)', async () => { + it('does not re-inject after wire replay restores the current plugin sessionStart', async () => { const { ctx } = sessionStartRuntime({ sessionStarts: [{ pluginId: 'superpowers', skillName: 'using-superpowers' }], skills: [skill('using-superpowers', 'body', { id: 'superpowers' })], @@ -188,7 +195,7 @@ describe('plugin session-start dynamic injection', () => { time: 1, message: { role: 'user', - content: [{ type: 'text', text: 'old' }], + content: [{ type: 'text', text: CURRENT_PLUGIN_SESSION_START_REMINDER }], toolCalls: [], origin: { kind: 'injection', variant: 'plugin_session_start' }, }, diff --git a/packages/agent-core-v2/test/harness/agent.ts b/packages/agent-core-v2/test/harness/agent.ts index 1030e6634af..0d4a8db03a9 100644 --- a/packages/agent-core-v2/test/harness/agent.ts +++ b/packages/agent-core-v2/test/harness/agent.ts @@ -10,7 +10,6 @@ import type { IAgentScopeHandle } from '#/_base/di/scope'; import { Emitter, Event } from '#/_base/event'; import { IAgentLifecycleService } from '#/session/agentLifecycle/agentLifecycle'; import type { Promisable, PromisifyMethods } from '#/_base/utils/types'; -import { escapeXmlAttr } from '#/_base/utils/xml-escape'; import type { AgentTaskInfo } from '#/agent/task/task'; import { IAgentBlobService } from '#/agent/blob/agentBlobService'; import { AgentBlobServiceImpl } from '#/agent/blob/agentBlobServiceImpl'; @@ -84,7 +83,6 @@ import type { generate as kosongGenerate } from '#/kosong/contract/generate'; import type { ChatProvider, GenerateOptions, StreamedMessage } from '#/kosong/contract/provider'; import type { ILogger, LogContext, LogLevel } from '#/_base/log/log'; import { ILogOptions } from '#/_base/log/logConfig'; -import type { EnabledPluginSessionStart } from '#/app/plugin/types'; import { WIRE_PROTOCOL_VERSION, AgentTaskService, @@ -988,30 +986,6 @@ class ConfigBackedModelCatalog extends ModelCatalog { } } -function renderPluginSessionStartReminder( - sessionStarts: readonly EnabledPluginSessionStart[], - catalog: SkillCatalog, - log?: { warn(message: string, payload?: unknown): void }, -): string | undefined { - if (sessionStarts.length === 0) return undefined; - const blocks: string[] = []; - for (const sessionStart of sessionStarts) { - const skill = catalog.getPluginSkill(sessionStart.pluginId, sessionStart.skillName); - if (skill === undefined) { - log?.warn('plugin sessionStart skill not found', { - pluginId: sessionStart.pluginId, - skillName: sessionStart.skillName, - }); - continue; - } - blocks.push( - `\n${catalog.renderSkillPrompt(skill, '')}\n`, - ); - } - return blocks.length > 0 ? blocks.join('\n') : undefined; -} - export class AgentTestContext { private readonly serviceOverrides: readonly TestAgentScopedServiceOverride[]; private readonly options: TestAgentOptions; @@ -1021,7 +995,6 @@ export class AgentTestContext { private readonly agent: Scope; private readonly disposables: IDisposable[] = []; private suppressWireSnapshot = false; - private pluginSessionStartRegistered = false; kimiConfig: KimiConfig; private cwd = process.cwd(); private closed = false; @@ -1437,29 +1410,6 @@ export class AgentTestContext { profile.update({ activeToolNames: [...tools] }); } - const sessionStarts = this.options['pluginSessionStarts'] as - | readonly EnabledPluginSessionStart[] - | undefined; - const skillCatalog = this.options['skills'] as SkillCatalog | undefined; - if ( - !this.pluginSessionStartRegistered && - sessionStarts !== undefined && - skillCatalog !== undefined - ) { - this.pluginSessionStartRegistered = true; - this.get(IAgentContextInjectorService).register( - 'plugin_session_start', - async ({ injectedPositions }) => { - if (injectedPositions.length > 0) return undefined; - return renderPluginSessionStartReminder( - sessionStarts, - skillCatalog, - this.options['log'] as { warn(message: string, payload?: unknown): void } | undefined, - ); - }, - ); - } - this.snapshots.drain(); } From a6edc7ba19a4fc94526846a596238c1633a7b9c0 Mon Sep 17 00:00:00 2001 From: 7Sageer <7sageer@djwcb.cn> Date: Wed, 5 Aug 2026 16:04:02 +0800 Subject: [PATCH 04/27] fix(agent-core-v2): preserve reminder boundary ordering --- .../agent/contextInjector/contextInjector.ts | 11 ++ .../contextInjector/contextInjectorService.ts | 138 +++++++++++++----- .../interruptionReminderService.ts | 17 +-- .../contextInjector/contextInjector.test.ts | 13 ++ .../test/agent/loop/loop.test.ts | 6 +- .../permissionMode/permissionMode.test.ts | 1 + packages/agent-core-v2/test/index.test.ts | 4 +- packages/node-sdk/test/v1-v2-parity.test.ts | 45 ++++-- 8 files changed, 171 insertions(+), 64 deletions(-) diff --git a/packages/agent-core-v2/src/agent/contextInjector/contextInjector.ts b/packages/agent-core-v2/src/agent/contextInjector/contextInjector.ts index e0114977f09..9ee221dfc2b 100644 --- a/packages/agent-core-v2/src/agent/contextInjector/contextInjector.ts +++ b/packages/agent-core-v2/src/agent/contextInjector/contextInjector.ts @@ -26,6 +26,10 @@ export type ContextInjectionProvider = ( | undefined | Promise; +export type SyncContextInjectionProvider = ( + context: ContextInjectionContext, +) => ContextInjectionContent | ContextInjectionResult | undefined; + export interface IAgentContextInjectorService { readonly _serviceBrand: undefined; @@ -34,6 +38,13 @@ export interface IAgentContextInjectorService { provider: ContextInjectionProvider, ): IDisposable; + /** Register a synchronous provider that runs after `turn.started` and + * before the turn's first step request materializes its prompt. */ + registerAtTurnStart( + name: string, + provider: SyncContextInjectionProvider, + ): IDisposable; + injectAfterCompaction(): Promise; } diff --git a/packages/agent-core-v2/src/agent/contextInjector/contextInjectorService.ts b/packages/agent-core-v2/src/agent/contextInjector/contextInjectorService.ts index d2039df90d1..a658763d94e 100644 --- a/packages/agent-core-v2/src/agent/contextInjector/contextInjectorService.ts +++ b/packages/agent-core-v2/src/agent/contextInjector/contextInjectorService.ts @@ -15,7 +15,7 @@ * functions, not plain data). Bound at Agent scope. */ -import { Disposable, toDisposable } from "#/_base/di/lifecycle"; +import { Disposable, toDisposable, type IDisposable } from "#/_base/di/lifecycle"; import { LifecycleScope, ScopeActivation, registerScopedService } from '#/_base/di/scope'; import { defineState } from '#/_base/state/stateRegistry'; @@ -32,12 +32,14 @@ import { type ContextInjectionContent, type ContextInjectionProvider, type ContextInjectionResult, + type SyncContextInjectionProvider, } from './contextInjector'; interface ContextInjectionEntry { readonly provider: ContextInjectionProvider; readonly name: string; readonly positions: number[]; + readonly boundary: 'step' | 'turn-start'; } export const contextInjectorIsNewTurnKey = defineState( @@ -63,13 +65,14 @@ export class AgentContextInjectorService extends Disposable implements IAgentCon this._register( loopService.hooks.onWillBeginStep.register('context-injector', async (_ctx, next) => { await next(); - await this.inject(); + await this.inject('step'); }), ); this._register( this.eventBus.subscribe('turn.started', () => { this.reminderQueue.drain(); this.isNewTurn = true; + this.injectAtTurnStart(); }), ); this._register( @@ -97,12 +100,28 @@ export class AgentContextInjectorService extends Disposable implements IAgentCon register( name: string, provider: ContextInjectionProvider, - ) { + ): IDisposable { + return this.registerProvider(name, provider, 'step'); + } + + registerAtTurnStart( + name: string, + provider: SyncContextInjectionProvider, + ): IDisposable { + return this.registerProvider(name, provider, 'turn-start'); + } + + private registerProvider( + name: string, + provider: ContextInjectionProvider, + boundary: ContextInjectionEntry['boundary'], + ): IDisposable { const positions = findInjections(this.context.get(), name); const entry: ContextInjectionEntry = { provider, name, positions, + boundary, }; this.entries.add(entry); return toDisposable(() => { @@ -115,49 +134,84 @@ export class AgentContextInjectorService extends Disposable implements IAgentCon await this.inject(); } - private async inject(): Promise { + private async inject(boundary?: ContextInjectionEntry['boundary']): Promise { this.reminderQueue.drain(); const isNewTurn = this.isNewTurn; this.isNewTurn = false; - const history = this.context.get(); for (const entry of this.entries) { - const injectedPositions: readonly number[] = [...entry.positions]; - const lastInjectedAt = injectedPositions.at(-1) ?? null; - const lastInjection = lastInjectedAt === null ? undefined : history[lastInjectedAt]; - const content = await entry.provider({ - injectedPositions, - lastInjectedAt, - lastInjection, - lastDisclosure: - lastInjection?.origin?.kind === 'injection' - ? lastInjection.origin.disclosure - : undefined, - isNewTurn, - }); + // A turn-start provider is also reconciled at the step boundary as a + // fallback for facts that arrive after `turn.started` (for example, a + // queued turn cancelled while another turn is already running). + if ( + boundary !== undefined && + entry.boundary !== boundary && + !(boundary === 'step' && entry.boundary === 'turn-start') + ) continue; + const content = await entry.provider(this.providerContext(entry, isNewTurn)); if (!this.entries.has(entry)) continue; - if (content === undefined) continue; - const result: ContextInjectionResult = - typeof content === 'object' && content !== null && !Array.isArray(content) - ? (content as ContextInjectionResult) - : { content: content as ContextInjectionContent }; - const origin = { - kind: 'injection' as const, - variant: entry.name, - disclosure: result.disclosure, - }; - if (typeof result.content === 'string') { - if (result.content.trim().length === 0) continue; - this.reminders.appendSystemReminder(result.content, origin); - continue; + this.appendResult(entry, content); + } + } + + private injectAtTurnStart(): void { + for (const entry of this.entries) { + if (entry.boundary !== 'turn-start') continue; + const content = entry.provider(this.providerContext(entry, true)); + if (isThenable(content)) { + throw new TypeError(`Turn-start context provider "${entry.name}" returned a Promise`); } - if (result.content.length === 0) continue; - this.context.append({ - role: 'user', - content: [...result.content], - toolCalls: [], - origin, - }); + if (!this.entries.has(entry)) continue; + this.appendResult(entry, content); + } + } + + private providerContext( + entry: ContextInjectionEntry, + isNewTurn: boolean, + ): Parameters[0] { + const injectedPositions: readonly number[] = [...entry.positions]; + const lastInjectedAt = injectedPositions.at(-1) ?? null; + const lastInjection = lastInjectedAt === null + ? undefined + : this.context.get()[lastInjectedAt]; + return { + injectedPositions, + lastInjectedAt, + lastInjection, + lastDisclosure: + lastInjection?.origin?.kind === 'injection' + ? lastInjection.origin.disclosure + : undefined, + isNewTurn, + }; + } + + private appendResult( + entry: ContextInjectionEntry, + content: ContextInjectionContent | ContextInjectionResult | undefined, + ): void { + if (content === undefined) return; + const result: ContextInjectionResult = + typeof content === 'object' && content !== null && !Array.isArray(content) + ? (content as ContextInjectionResult) + : { content: content as ContextInjectionContent }; + const origin = { + kind: 'injection' as const, + variant: entry.name, + disclosure: result.disclosure, + }; + if (typeof result.content === 'string') { + if (result.content.trim().length === 0) return; + this.reminders.appendSystemReminder(result.content, origin); + return; } + if (result.content.length === 0) return; + this.context.append({ + role: 'user', + content: [...result.content], + toolCalls: [], + origin, + }); } private resyncPositions(): void { @@ -220,6 +274,12 @@ function findInjections( return positions; } +function isThenable(value: unknown): value is PromiseLike { + return ( + (typeof value === 'object' && value !== null) || typeof value === 'function' + ) && 'then' in value && typeof value.then === 'function'; +} + registerScopedService( LifecycleScope.Agent, IAgentContextInjectorService, diff --git a/packages/agent-core-v2/src/agent/interruptionReminder/interruptionReminderService.ts b/packages/agent-core-v2/src/agent/interruptionReminder/interruptionReminderService.ts index 7dd465f505f..018f3b8e8ea 100644 --- a/packages/agent-core-v2/src/agent/interruptionReminder/interruptionReminderService.ts +++ b/packages/agent-core-v2/src/agent/interruptionReminder/interruptionReminderService.ts @@ -2,15 +2,16 @@ * `interruptionReminder` domain — `IAgentInterruptionReminderService` implementation. * * Projects checkpointed user-cancellation facts from `wire` through the - * `contextInjector` boundary, and reads `contextMemory` to collapse retry-only - * duplicate notices. Bound at Agent scope. + * `contextInjector` turn-start boundary, and reads `contextMemory` to collapse + * retry-only duplicate notices. The turn-start placement keeps the notice + * between the interrupted turn and the next user prompt on provider wires. + * Bound at Agent scope. */ import { Disposable } from '#/_base/di/lifecycle'; import { LifecycleScope, ScopeActivation, registerScopedService } from '#/_base/di/scope'; import { IAgentContextInjectorService } from '#/agent/contextInjector/contextInjector'; import { IAgentContextMemoryService } from '#/agent/contextMemory/contextMemory'; -import { isUndoAnchor } from '#/agent/contextMemory/conversationTime'; import type { ContextMessage } from '#/agent/contextMemory/types'; import { isVacuousContentPart } from '#/agent/contextMemory/vacuousContent'; import { IWireService } from '#/wire/wire'; @@ -36,7 +37,10 @@ export class AgentInterruptionReminderService ) { super(); this._register( - injector.register(INTERRUPTION_REMINDER_VARIANT, () => this.reconcileReminder()), + injector.registerAtTurnStart( + INTERRUPTION_REMINDER_VARIANT, + () => this.reconcileReminder(), + ), ); } @@ -53,7 +57,6 @@ export class AgentInterruptionReminderService } function lastComparableMessage(messages: readonly ContextMessage[]): ContextMessage | undefined { - let skippedCurrentPrompt = false; for (let index = messages.length - 1; index >= 0; index--) { const message = messages[index]!; if ( @@ -64,10 +67,6 @@ function lastComparableMessage(messages: readonly ContextMessage[]): ContextMess ) { continue; } - if (!skippedCurrentPrompt && isUndoAnchor(message)) { - skippedCurrentPrompt = true; - continue; - } return message; } return undefined; diff --git a/packages/agent-core-v2/test/agent/contextInjector/contextInjector.test.ts b/packages/agent-core-v2/test/agent/contextInjector/contextInjector.test.ts index 56dcac57281..972f13d4b6c 100644 --- a/packages/agent-core-v2/test/agent/contextInjector/contextInjector.test.ts +++ b/packages/agent-core-v2/test/agent/contextInjector/contextInjector.test.ts @@ -355,4 +355,17 @@ describe('AgentContextInjectorService', () => { expect(calls).toEqual(['drain', 'provider']); }); + + it('runs synchronous turn-start providers before the next prompt materializes', () => { + injector(ix).registerAtTurnStart('turn_start_test', () => 'turn-start reminder'); + + ix.get(IEventBus).publish({ + type: 'turn.started', + turnId: 0, + origin: { kind: 'user' }, + }); + + expect(context.get()).toHaveLength(1); + expect(lastText(context)).toContain('turn-start reminder'); + }); }); diff --git a/packages/agent-core-v2/test/agent/loop/loop.test.ts b/packages/agent-core-v2/test/agent/loop/loop.test.ts index e09f3f2b538..f645dbd5826 100644 --- a/packages/agent-core-v2/test/agent/loop/loop.test.ts +++ b/packages/agent-core-v2/test/agent/loop/loop.test.ts @@ -1045,7 +1045,7 @@ describe('interruption reminder', () => { text: '\nThe previous turn was interrupted by the user before completion; any partial output shown above is incomplete. The user\'s next message continues the conversation.\n', }, ]); - expect(ctx.contextData().history.indexOf(interruptionReminders()[0]!)).toBe(3); + expect(ctx.contextData().history.indexOf(interruptionReminders()[0]!)).toBe(2); }); it('writes one active cancellation when cancel repeats before the turn settles', async () => { @@ -1173,8 +1173,8 @@ describe('interruption reminder', () => { messages: assistant: text "partial answer" - user: text "Next" user: text "\\nThe previous turn was interrupted by the user before completion; any partial output shown above is incomplete. The user's next message continues the conversation.\\n" + user: text "Next" `); }); @@ -1304,7 +1304,7 @@ describe('interruption reminder', () => { expect(remindersIn(local)).toHaveLength(1); const reminderIndex = history.indexOf(remindersIn(local)[0]!); expect(history.slice(0, reminderIndex).some((message) => message.role === 'tool')).toBe(true); - expect(history[reminderIndex - 1]).toMatchObject({ + expect(history[reminderIndex + 1]).toMatchObject({ role: 'user', content: [{ type: 'text', text: 'again' }], }); diff --git a/packages/agent-core-v2/test/agent/permissionMode/permissionMode.test.ts b/packages/agent-core-v2/test/agent/permissionMode/permissionMode.test.ts index 6bb726af7e1..4d7ebf9e212 100644 --- a/packages/agent-core-v2/test/agent/permissionMode/permissionMode.test.ts +++ b/packages/agent-core-v2/test/agent/permissionMode/permissionMode.test.ts @@ -43,6 +43,7 @@ const injectorStub: IAgentContextInjectorService = { }, }; }, + registerAtTurnStart: () => ({ dispose: () => {} }), injectAfterCompaction: async () => {}, }; diff --git a/packages/agent-core-v2/test/index.test.ts b/packages/agent-core-v2/test/index.test.ts index 672dbf18b62..376ce04a72d 100644 --- a/packages/agent-core-v2/test/index.test.ts +++ b/packages/agent-core-v2/test/index.test.ts @@ -407,7 +407,7 @@ describe('AgentRecords persistence metadata', () => { 'forked', ]); expect(ctx.get(IAgentGoalService).getGoal().goal).toBeNull(); - await ctx.get(IAgentReminderQueueService).drain(); + ctx.get(IAgentReminderQueueService).drain(); const reminder = context.get().at(-1); expect(reminder?.origin).toEqual({ kind: 'injection', variant: 'goal_fork_cleared' }); expect(JSON.stringify(reminder?.content)).toContain('This fork does not have a current goal.'); @@ -434,7 +434,7 @@ describe('AgentRecords persistence metadata', () => { goalId: 'fork-goal', objective: 'fork work', }); - await ctx.get(IAgentReminderQueueService).drain(); + ctx.get(IAgentReminderQueueService).drain(); expect(context.get().at(-1)?.origin).toEqual({ kind: 'injection', variant: 'goal_fork_cleared', diff --git a/packages/node-sdk/test/v1-v2-parity.test.ts b/packages/node-sdk/test/v1-v2-parity.test.ts index c10c0ce98a4..3b077fbd5b4 100644 --- a/packages/node-sdk/test/v1-v2-parity.test.ts +++ b/packages/node-sdk/test/v1-v2-parity.test.ts @@ -16,6 +16,8 @@ import { join } from 'node:path'; import { afterEach, describe, expect, it } from 'vitest'; import { + IAgentContextInjectorService, + IAgentLifecycleService, ISessionApprovalService, ISessionQuestionService, getLiveSessionById, @@ -4235,10 +4237,9 @@ describe('v1↔v2 event & interaction parity', () => { // Residual surface parity (exportSession / startBtw / swarm mode / listSkills) // // The last migrated methods, driven through `SDKRpcClient` / `SDKRpcClientV2` -// directly like the other session batches. No provider calls: export reads -// persisted state, btw/swarm are context-only, and the `swarm()` case uses the -// model-less prompt failure path (its turn start/end is what drives the -// task-trigger auto-exit on both engines). +// directly like the other session batches. Reminder assertions force the v2 +// context-injection boundary explicitly, so they test model-facing +// materialization without contacting a provider. // --------------------------------------------------------------------------- /** Poll a condition until it holds or the budget runs out (engine-async settle). */ @@ -4377,10 +4378,23 @@ describe('v1↔v2 residual surface parity', () => { key === 'origin' ? undefined : value, ), ); - expect(stripOrigins(v2Context)).toEqual(stripOrigins(v1Context)); + // v2 queues the side-question reminder until its next injection + // boundary; v1 materializes it while forking. The inherited context is + // already identical before that boundary. + expect(v2Context.history).toHaveLength(1); + expect(v1Context.history).toHaveLength(2); + const v2Session = getLiveSessionById(pair.v2.engineAccessor, sessionId); + const v2Child = v2Session?.accessor.get(IAgentLifecycleService).get(v2ChildId); + expect(v2Child).toBeDefined(); + await v2Child!.accessor.get(IAgentContextInjectorService).injectAfterCompaction(); + const [v1Materialized, v2Materialized] = await Promise.all([ + pair.v1.withInteractiveAgent(v1ChildId, () => pair.v1.getContext({ sessionId })), + pair.v2.withInteractiveAgent(v2ChildId, () => pair.v2.getContext({ sessionId })), + ]); + expect(stripOrigins(v2Materialized)).toEqual(stripOrigins(v1Materialized)); // Non-vacuous: the inherited import plus the side-question reminder // (byte-identical template on both engines). - const v1History = v1Context.history; + const v1History = v1Materialized.history; expect(v1History.length).toBeGreaterThanOrEqual(2); const reminder = v1History.at(-1); expect(JSON.stringify(reminder)).toContain('side-channel conversation'); @@ -4409,8 +4423,13 @@ describe('v1↔v2 residual surface parity', () => { expect(v1Active.swarmMode).toBe(true); expect(v2Active.swarmMode).toBe(true); const project = KNOWN_DIFFS.getContext; - const [v1Entered, v2Entered] = await historyOnBoth(); - expect(project(v2Entered)).toEqual(project(v1Entered)); + const [v1Entered] = await historyOnBoth(); + const v2Session = getLiveSessionById(pair.v2.engineAccessor, input.sessionId); + const v2Agent = v2Session?.accessor.get(IAgentLifecycleService).get('main'); + expect(v2Agent).toBeDefined(); + await v2Agent!.accessor.get(IAgentContextInjectorService).injectAfterCompaction(); + const v2Materialized = await pair.v2.getContext(input); + expect(project(v2Materialized)).toEqual(project(v1Entered)); expect(v1Entered.history).toHaveLength(1); expect(JSON.stringify(v1Entered.history[0])).toContain(''); @@ -4432,8 +4451,12 @@ describe('v1↔v2 residual surface parity', () => { expect(v1Inactive.swarmMode).toBe(false); expect(v2Inactive.swarmMode).toBe(false); const [v1Exited, v2Exited] = await historyOnBoth(); - expect(project(v2Exited)).toEqual(project(v1Exited)); expect(v1Exited.history).toHaveLength(0); + expect(v2Exited.history).toHaveLength(1); + await v2Agent!.accessor.get(IAgentContextInjectorService).injectAfterCompaction(); + const v2ExitMaterialized = await pair.v2.getContext(input); + expect(v2ExitMaterialized.history).toHaveLength(2); + expect(JSON.stringify(v2ExitMaterialized.history.at(-1))).toContain('Swarm Mode has ended.'); // Exit is idempotent too: a second exit is a silent no-op on both. await Promise.all([ @@ -4442,7 +4465,7 @@ describe('v1↔v2 residual surface parity', () => { ]); const [v1Idle, v2Idle] = await historyOnBoth(); expect(v1Idle.history).toHaveLength(0); - expect(v2Idle.history).toHaveLength(0); + expect(v2Idle.history).toHaveLength(2); // The `tool` trigger injects no reminder on either engine. await Promise.all([ @@ -4454,7 +4477,7 @@ describe('v1↔v2 residual surface parity', () => { expect(v2Tool.swarmMode).toBe(true); const [v1ToolHistory, v2ToolHistory] = await historyOnBoth(); expect(v1ToolHistory.history).toHaveLength(0); - expect(v2ToolHistory.history).toHaveLength(0); + expect(v2ToolHistory.history).toHaveLength(2); await Promise.all([ pair.v1.setSwarmMode({ ...input, enabled: false }), pair.v2.setSwarmMode({ ...input, enabled: false }), From da7f6b568730890d0ba0e4dc07a9536d537e526b Mon Sep 17 00:00:00 2001 From: 7Sageer <7sageer@djwcb.cn> Date: Wed, 5 Aug 2026 16:37:39 +0800 Subject: [PATCH 05/27] refactor(agent-core-v2): narrow reminder and swarm helper exposure - drop the swarmInjection re-export from the package index; SwarmInjection stays a domain-internal collaborator like permissionMode/plan injections - move INTERRUPTION_REMINDER text back to a private constant in the service; only the variant stays in the Ops module - make reminderQueue.enqueue return void; no caller consumed the entry id --- .../agent/interruptionReminder/interruptionReminderOps.ts | 6 ------ .../interruptionReminder/interruptionReminderService.ts | 7 ++++++- .../agent-core-v2/src/agent/reminderQueue/reminderQueue.ts | 2 +- .../src/agent/reminderQueue/reminderQueueService.ts | 3 +-- packages/agent-core-v2/src/index.ts | 1 - 5 files changed, 8 insertions(+), 11 deletions(-) diff --git a/packages/agent-core-v2/src/agent/interruptionReminder/interruptionReminderOps.ts b/packages/agent-core-v2/src/agent/interruptionReminder/interruptionReminderOps.ts index 62cba21e8b3..24ee1b58c8f 100644 --- a/packages/agent-core-v2/src/agent/interruptionReminder/interruptionReminderOps.ts +++ b/packages/agent-core-v2/src/agent/interruptionReminder/interruptionReminderOps.ts @@ -16,12 +16,6 @@ import { export const INTERRUPTION_REMINDER_VARIANT = 'interruption'; -export const INTERRUPTION_REMINDER = [ - 'The previous turn was interrupted by the user before completion;', - 'any partial output shown above is incomplete.', - "The user's next message continues the conversation.", -].join(' '); - export type InterruptionReminderState = Checkpointed; export const InterruptionReminderModel = defineCheckpointedModel( diff --git a/packages/agent-core-v2/src/agent/interruptionReminder/interruptionReminderService.ts b/packages/agent-core-v2/src/agent/interruptionReminder/interruptionReminderService.ts index 018f3b8e8ea..5b96b18ae2f 100644 --- a/packages/agent-core-v2/src/agent/interruptionReminder/interruptionReminderService.ts +++ b/packages/agent-core-v2/src/agent/interruptionReminder/interruptionReminderService.ts @@ -18,12 +18,17 @@ import { IWireService } from '#/wire/wire'; import { IAgentInterruptionReminderService } from './interruptionReminder'; import { - INTERRUPTION_REMINDER, INTERRUPTION_REMINDER_VARIANT, interruptionReminderRecorded, InterruptionReminderModel, } from './interruptionReminderOps'; +const INTERRUPTION_REMINDER = [ + 'The previous turn was interrupted by the user before completion;', + 'any partial output shown above is incomplete.', + "The user's next message continues the conversation.", +].join(' '); + export class AgentInterruptionReminderService extends Disposable implements IAgentInterruptionReminderService diff --git a/packages/agent-core-v2/src/agent/reminderQueue/reminderQueue.ts b/packages/agent-core-v2/src/agent/reminderQueue/reminderQueue.ts index c81ed2300ad..895c8d3c959 100644 --- a/packages/agent-core-v2/src/agent/reminderQueue/reminderQueue.ts +++ b/packages/agent-core-v2/src/agent/reminderQueue/reminderQueue.ts @@ -15,7 +15,7 @@ export interface OnceReminderInput { export interface IAgentReminderQueueService { readonly _serviceBrand: undefined; - enqueue(input: OnceReminderInput): string; + enqueue(input: OnceReminderInput): void; drain(): void; } diff --git a/packages/agent-core-v2/src/agent/reminderQueue/reminderQueueService.ts b/packages/agent-core-v2/src/agent/reminderQueue/reminderQueueService.ts index b5f6ba6ee41..532c50bc265 100644 --- a/packages/agent-core-v2/src/agent/reminderQueue/reminderQueueService.ts +++ b/packages/agent-core-v2/src/agent/reminderQueue/reminderQueueService.ts @@ -37,10 +37,9 @@ export class AgentReminderQueueService extends Disposable implements IAgentRemin super(); } - enqueue(input: OnceReminderInput): string { + enqueue(input: OnceReminderInput): void { const entry: ReminderQueueEntry = { id: randomUUID(), ...input }; this.wire.dispatch(reminderQueueEnqueue({ entry })); - return entry.id; } drain(): void { diff --git a/packages/agent-core-v2/src/index.ts b/packages/agent-core-v2/src/index.ts index a8874844000..733e1d376f8 100644 --- a/packages/agent-core-v2/src/index.ts +++ b/packages/agent-core-v2/src/index.ts @@ -274,7 +274,6 @@ export * from '#/agent/tools/agent-swarm/agent-swarm'; import '#/agent/tools/agent-swarm/agentSwarmTool'; export * from '#/agent/swarm/swarm'; export * from '#/agent/swarm/swarmService'; -export * from '#/agent/swarm/injection/swarmInjection'; export * from '#/agent/usage/usage'; export * from '#/agent/usage/usageService'; export * from '#/agent/toolDedupe/toolDedupe'; From 23b1e7577f2b6aff657f044db84da95282e26f2d Mon Sep 17 00:00:00 2001 From: 7Sageer <7sageer@djwcb.cn> Date: Wed, 5 Aug 2026 17:36:10 +0800 Subject: [PATCH 06/27] chore(agent-core-v2): keep comments in module headers --- .../agent-core-v2/src/agent/contextInjector/contextInjector.ts | 2 -- .../src/agent/contextInjector/contextInjectorService.ts | 3 --- 2 files changed, 5 deletions(-) diff --git a/packages/agent-core-v2/src/agent/contextInjector/contextInjector.ts b/packages/agent-core-v2/src/agent/contextInjector/contextInjector.ts index 9ee221dfc2b..cc70a811476 100644 --- a/packages/agent-core-v2/src/agent/contextInjector/contextInjector.ts +++ b/packages/agent-core-v2/src/agent/contextInjector/contextInjector.ts @@ -38,8 +38,6 @@ export interface IAgentContextInjectorService { provider: ContextInjectionProvider, ): IDisposable; - /** Register a synchronous provider that runs after `turn.started` and - * before the turn's first step request materializes its prompt. */ registerAtTurnStart( name: string, provider: SyncContextInjectionProvider, diff --git a/packages/agent-core-v2/src/agent/contextInjector/contextInjectorService.ts b/packages/agent-core-v2/src/agent/contextInjector/contextInjectorService.ts index a658763d94e..75e2c1f793a 100644 --- a/packages/agent-core-v2/src/agent/contextInjector/contextInjectorService.ts +++ b/packages/agent-core-v2/src/agent/contextInjector/contextInjectorService.ts @@ -139,9 +139,6 @@ export class AgentContextInjectorService extends Disposable implements IAgentCon const isNewTurn = this.isNewTurn; this.isNewTurn = false; for (const entry of this.entries) { - // A turn-start provider is also reconciled at the step boundary as a - // fallback for facts that arrive after `turn.started` (for example, a - // queued turn cancelled while another turn is already running). if ( boundary !== undefined && entry.boundary !== boundary && From 81864fbd45bde48c5fa524b82c46aafbacc18536 Mon Sep 17 00:00:00 2001 From: 7Sageer <7sageer@djwcb.cn> Date: Wed, 5 Aug 2026 17:47:42 +0800 Subject: [PATCH 07/27] refactor(agent-core-v2): track reminder state via injection disclosure - derive swarm active/inactive state from ctx.lastDisclosure instead of byte-matching rendered markdown, with variant-only fallback for legacy swarm_mode/swarm_mode_exit journal entries - record once_reminder disclosure (entry id) on queue-appended messages and dedupe the crash window by the contiguous tail id set, covering multi-entry drains - move reminderQueue draining behind a sync onWillInject event so the injector no longer depends on the queue domain - centralize the system-reminder wrap format behind wrapSystemReminder / systemReminderContent and use injector-provided positions in the plugin session-start provider - spell out the step-boundary fallback and sync-only contract of registerAtTurnStart via shouldRunAtBoundary --- .../agent-core-v2/docs/state-manifest.d.ts | 24 ++++++- .../agent/contextInjector/contextInjector.ts | 6 ++ .../contextInjector/contextInjectorService.ts | 55 ++++++++++------ .../src/agent/contextMemory/types.ts | 21 ++++-- .../src/agent/plugin/agentPluginService.ts | 60 ++++++----------- .../reminderQueue/reminderQueueService.ts | 61 +++++++++-------- .../agent/swarm/injection/swarmInjection.ts | 64 ++++++++++-------- .../agent/systemReminder/systemReminder.ts | 21 +++++- .../systemReminder/systemReminderService.ts | 11 +++- .../contextInjector/contextInjector.test.ts | 22 ++----- .../test/agent/goal/goal.test.ts | 12 +++- .../permissionMode/permissionMode.test.ts | 2 + .../agent/reminderQueue/reminderQueue.test.ts | 65 +++++++++++++++++++ .../test/agent/swarm/swarm.test.ts | 45 ++++++++++--- .../toolSelect/toolSelectService.test.ts | 6 -- packages/agent-core-v2/test/index.test.ts | 7 +- packages/node-sdk/test/v1-v2-parity.test.ts | 20 +++++- 17 files changed, 339 insertions(+), 163 deletions(-) diff --git a/packages/agent-core-v2/docs/state-manifest.d.ts b/packages/agent-core-v2/docs/state-manifest.d.ts index 69bc11ec8f3..a8c20da0673 100644 --- a/packages/agent-core-v2/docs/state-manifest.d.ts +++ b/packages/agent-core-v2/docs/state-manifest.d.ts @@ -751,11 +751,17 @@ export interface AgentStateSnapshot { readonly kind: 'injection'; readonly variant: string; readonly ownerPromptId?: string; - readonly disclosure?: /* ContextInjectionDisclosure — packages/agent-core-v2/src/agent/contextMemory/types.ts */ { + readonly disclosure?: { readonly kind: 'date'; readonly renderGeneration: number; readonly localDate: string; readonly timeZone: string; + } | { + readonly kind: 'swarm_mode'; + readonly state: 'active' | 'inactive'; + } | { + readonly kind: 'once_reminder'; + readonly id: string; }; } | /* ShellCommandOrigin — packages/agent-core-v2/src/agent/contextMemory/types.ts */ { readonly kind: 'shell_command'; @@ -881,11 +887,17 @@ export interface AgentStateSnapshot { readonly kind: 'injection'; readonly variant: string; readonly ownerPromptId?: string; - readonly disclosure?: /* ContextInjectionDisclosure — packages/agent-core-v2/src/agent/contextMemory/types.ts */ { + readonly disclosure?: { readonly kind: 'date'; readonly renderGeneration: number; readonly localDate: string; readonly timeZone: string; + } | { + readonly kind: 'swarm_mode'; + readonly state: 'active' | 'inactive'; + } | { + readonly kind: 'once_reminder'; + readonly id: string; }; } | /* ShellCommandOrigin — packages/agent-core-v2/src/agent/contextMemory/types.ts */ { readonly kind: 'shell_command'; @@ -943,11 +955,17 @@ export interface AgentStateSnapshot { readonly kind: 'injection'; readonly variant: string; readonly ownerPromptId?: string; - readonly disclosure?: /* ContextInjectionDisclosure — packages/agent-core-v2/src/agent/contextMemory/types.ts */ { + readonly disclosure?: { readonly kind: 'date'; readonly renderGeneration: number; readonly localDate: string; readonly timeZone: string; + } | { + readonly kind: 'swarm_mode'; + readonly state: 'active' | 'inactive'; + } | { + readonly kind: 'once_reminder'; + readonly id: string; }; } | /* ShellCommandOrigin — packages/agent-core-v2/src/agent/contextMemory/types.ts */ { readonly kind: 'shell_command'; diff --git a/packages/agent-core-v2/src/agent/contextInjector/contextInjector.ts b/packages/agent-core-v2/src/agent/contextInjector/contextInjector.ts index cc70a811476..c7b3443b56e 100644 --- a/packages/agent-core-v2/src/agent/contextInjector/contextInjector.ts +++ b/packages/agent-core-v2/src/agent/contextInjector/contextInjector.ts @@ -1,5 +1,6 @@ import { createDecorator } from "#/_base/di/instantiation"; import type { IDisposable } from "#/_base/di/lifecycle"; +import type { Event } from "#/_base/event"; import type { ContentPart } from "#/kosong/contract/message"; import type { ContextInjectionDisclosure, ContextMessage } from '#/agent/contextMemory/types'; @@ -33,6 +34,11 @@ export type SyncContextInjectionProvider = ( export interface IAgentContextInjectorService { readonly _serviceBrand: undefined; + /** Fired synchronously at every injection boundary (turn start, step, + * compaction follow-up, wire restore) before any provider runs. This is + * where boundary participants such as the once-reminder queue drain. */ + readonly onWillInject: Event; + register( name: string, provider: ContextInjectionProvider, diff --git a/packages/agent-core-v2/src/agent/contextInjector/contextInjectorService.ts b/packages/agent-core-v2/src/agent/contextInjector/contextInjectorService.ts index 75e2c1f793a..626fb31e6d9 100644 --- a/packages/agent-core-v2/src/agent/contextInjector/contextInjectorService.ts +++ b/packages/agent-core-v2/src/agent/contextInjector/contextInjectorService.ts @@ -1,27 +1,35 @@ /** * `contextInjector` domain — `IAgentContextInjectorService` implementation. * - * The unified boundary scheduler for every model-facing reminder. It drains - * the persisted once-reminder queue (`reminderQueue`, past-tense events, - * exactly-once) ahead of reconciling the registered context providers + * The unified boundary scheduler for every model-facing reminder. At each + * injection boundary (turn start, step, compaction follow-up, wire restore) + * it first fires `onWillInject` — where boundary participants such as the + * `reminderQueue` drain their persisted once-reminders (past-tense events, + * exactly-once) — then reconciles the registered context providers * (present-tense state) through `loop` and `systemReminder`, tracks provider - * positions in `contextMemory` through `eventBus`, and reconciles those - * positions after `wire` restoration. Each provider call receives the - * newest surviving injection of its own variant (`lastInjection`) and the - * typed disclosure recorded on it (`lastDisclosure`), so providers never read - * context layout or position indexes themselves. The plain-data `isNewTurn` - * flag is registered into `agentState` (`IAgentStateService`) and read/written - * through it; `entries` stays a plain instance field (its values hold provider - * functions, not plain data). Bound at Agent scope. + * positions in `contextMemory` through `eventBus`, and resyncs those + * positions after `wire` restoration. Each provider call receives the newest + * surviving injection of its own variant (`lastInjection`) and the typed + * disclosure recorded on it (`lastDisclosure`), so providers never read + * context layout or position indexes themselves. Turn-start providers run + * synchronously after `turn.started` — before the turn's first step request + * materializes its prompt — and must stay synchronous (registration throws on + * a Promise-returning provider); they are also reconciled at every later step + * boundary as a fallback for facts that arrive after `turn.started` (for + * example, a queued turn cancelled while another turn is already running). + * The plain-data `isNewTurn` flag is registered + * into `agentState` (`IAgentStateService`) and read/written through it; + * `entries` stays a plain instance field (its values hold provider functions, + * not plain data). Bound at Agent scope. */ import { Disposable, toDisposable, type IDisposable } from "#/_base/di/lifecycle"; import { LifecycleScope, ScopeActivation, registerScopedService } from '#/_base/di/scope'; +import { Emitter, type Event } from '#/_base/event'; import { defineState } from '#/_base/state/stateRegistry'; import { IAgentContextMemoryService } from '#/agent/contextMemory/contextMemory'; import { IAgentLoopService } from '#/agent/loop/loop'; -import { IAgentReminderQueueService } from '#/agent/reminderQueue/reminderQueue'; import { IAgentStateService } from '#/agent/state/agentState'; import { IAgentSystemReminderService } from '#/agent/systemReminder/systemReminder'; import { IEventBus } from '#/app/event/eventBus'; @@ -50,11 +58,12 @@ export const contextInjectorIsNewTurnKey = defineState( export class AgentContextInjectorService extends Disposable implements IAgentContextInjectorService { declare readonly _serviceBrand: undefined; private readonly entries = new Set(); + private readonly willInject = this._register(new Emitter()); + readonly onWillInject: Event = this.willInject.event; constructor( @IAgentContextMemoryService private readonly context: IAgentContextMemoryService, @IAgentLoopService loopService: IAgentLoopService, - @IAgentReminderQueueService private readonly reminderQueue: IAgentReminderQueueService, @IAgentSystemReminderService private readonly reminders: IAgentSystemReminderService, @IEventBus private readonly eventBus: IEventBus, @IWireService wire: IWireService, @@ -70,7 +79,7 @@ export class AgentContextInjectorService extends Disposable implements IAgentCon ); this._register( this.eventBus.subscribe('turn.started', () => { - this.reminderQueue.drain(); + this.willInject.fire(); this.isNewTurn = true; this.injectAtTurnStart(); }), @@ -82,7 +91,7 @@ export class AgentContextInjectorService extends Disposable implements IAgentCon ); this._register( wire.hooks.onDidRestore.register('context-injector', async (_ctx, next) => { - this.reminderQueue.drain(); + this.willInject.fire(); this.resyncPositions(); await next(); }), @@ -135,15 +144,11 @@ export class AgentContextInjectorService extends Disposable implements IAgentCon } private async inject(boundary?: ContextInjectionEntry['boundary']): Promise { - this.reminderQueue.drain(); + this.willInject.fire(); const isNewTurn = this.isNewTurn; this.isNewTurn = false; for (const entry of this.entries) { - if ( - boundary !== undefined && - entry.boundary !== boundary && - !(boundary === 'step' && entry.boundary === 'turn-start') - ) continue; + if (boundary !== undefined && !shouldRunAtBoundary(entry, boundary)) continue; const content = await entry.provider(this.providerContext(entry, isNewTurn)); if (!this.entries.has(entry)) continue; this.appendResult(entry, content); @@ -271,6 +276,14 @@ function findInjections( return positions; } +function shouldRunAtBoundary( + entry: ContextInjectionEntry, + boundary: NonNullable, +): boolean { + if (entry.boundary === boundary) return true; + return boundary === 'step' && entry.boundary === 'turn-start'; +} + function isThenable(value: unknown): value is PromiseLike { return ( (typeof value === 'object' && value !== null) || typeof value === 'function' diff --git a/packages/agent-core-v2/src/agent/contextMemory/types.ts b/packages/agent-core-v2/src/agent/contextMemory/types.ts index 5b8c59cdb3a..59619b91e52 100644 --- a/packages/agent-core-v2/src/agent/contextMemory/types.ts +++ b/packages/agent-core-v2/src/agent/contextMemory/types.ts @@ -37,12 +37,21 @@ export interface InjectionOrigin { readonly disclosure?: ContextInjectionDisclosure; } -export type ContextInjectionDisclosure = { - readonly kind: 'date'; - readonly renderGeneration: number; - readonly localDate: string; - readonly timeZone: string; -}; +export type ContextInjectionDisclosure = + | { + readonly kind: 'date'; + readonly renderGeneration: number; + readonly localDate: string; + readonly timeZone: string; + } + | { + readonly kind: 'swarm_mode'; + readonly state: 'active' | 'inactive'; + } + | { + readonly kind: 'once_reminder'; + readonly id: string; + }; export interface ShellCommandOrigin { readonly kind: 'shell_command'; diff --git a/packages/agent-core-v2/src/agent/plugin/agentPluginService.ts b/packages/agent-core-v2/src/agent/plugin/agentPluginService.ts index ceead32c5c1..ac110fe32d8 100644 --- a/packages/agent-core-v2/src/agent/plugin/agentPluginService.ts +++ b/packages/agent-core-v2/src/agent/plugin/agentPluginService.ts @@ -3,9 +3,10 @@ * * Renders session-start skills from `plugin` and `sessionSkillCatalog` through * `contextInjector`, reconciling the desired instructions against the latest - * surviving render in `contextMemory`. Main-agent-only (v1 parity): the - * service self-gates on `agentId === 'main'`; Agent scope creation instantiates - * it for every agent, so other agents construct it as a no-op. Resolves session + * surviving render reported by the injector (`lastInjection`) and unwrapped + * through `systemReminder`. Main-agent-only (v1 parity): the service + * self-gates on `agentId === 'main'`; Agent scope creation instantiates it for + * every agent, so other agents construct it as a no-op. Resolves session * prompt context through `sessionContext` and reports missing skills through * `log`; stores the refresh signal through `agentState`. Bound at Agent scope. */ @@ -15,11 +16,14 @@ import { LifecycleScope, ScopeActivation, registerScopedService } from '#/_base/ import { ILogService } from '#/_base/log/log'; import { defineState } from '#/_base/state/stateRegistry'; import { escapeXmlAttr } from '#/_base/utils/xml-escape'; -import { IAgentContextInjectorService } from '#/agent/contextInjector/contextInjector'; +import { + IAgentContextInjectorService, + type ContextInjectionContext, +} from '#/agent/contextInjector/contextInjector'; import { IAgentContextMemoryService } from '#/agent/contextMemory/contextMemory'; -import type { ContextMessage } from '#/agent/contextMemory/types'; import { IAgentScopeContext } from '#/agent/scopeContext/scopeContext'; import { IAgentStateService } from '#/agent/state/agentState'; +import { systemReminderContent } from '#/agent/systemReminder/systemReminder'; import { IPluginService } from '#/app/plugin/plugin'; import type { EnabledPluginSessionStart } from '#/app/plugin/types'; import { PLUGIN_SKILL_SOURCE_ID } from '#/app/skillCatalog/skillSource'; @@ -61,8 +65,8 @@ export class AgentPluginService extends Disposable implements IAgentPluginServic if (scopeContext.agentId !== MAIN_AGENT_ID) return; this.states.register(pluginSessionStartRefreshPendingKey); this._register( - injector.register(SESSION_START_INJECTION_VARIANT, () => - this.reconcileSessionStartReminder(), + injector.register(SESSION_START_INJECTION_VARIANT, (injection) => + this.reconcileSessionStartReminder(injection), ), ); this._register( @@ -92,35 +96,30 @@ export class AgentPluginService extends Disposable implements IAgentPluginServic }); } - private async reconcileSessionStartReminder(): Promise { + private async reconcileSessionStartReminder( + injection: ContextInjectionContext, + ): Promise { const forceRefresh = this.refreshPending; this.refreshPending = false; const desired = await this.renderSessionStartReminder(); - const history = this.context.get(); - const latest = lastPluginSessionStart(history); + const latest = injection.lastInjection; if (desired === undefined) { if ( latest === undefined && - (!forceRefresh || !shouldNeutralizePluginSessionStart(history)) + (!forceRefresh || !shouldNeutralizePluginSessionStart(this.context.get())) ) { return undefined; } - if ( - latest !== undefined && - messageText(latest) === systemReminderText(NO_ACTIVE_SESSION_STARTS) - ) { + if (latest !== undefined && systemReminderContent(latest) === NO_ACTIVE_SESSION_STARTS) { return undefined; } return NO_ACTIVE_SESSION_STARTS; } if (latest === undefined) return desired; - const rendered = messageText(latest); + const rendered = systemReminderContent(latest); if ( !forceRefresh && - ( - rendered === systemReminderText(desired) || - rendered === systemReminderText(`${desired}\n\n${SUPERSEDES_SUFFIX}`) - ) + (rendered === desired.trim() || rendered === `${desired}\n\n${SUPERSEDES_SUFFIX}`.trim()) ) { return undefined; } @@ -128,27 +127,6 @@ export class AgentPluginService extends Disposable implements IAgentPluginServic } } -function lastPluginSessionStart(history: readonly ContextMessage[]): ContextMessage | undefined { - for (let index = history.length - 1; index >= 0; index--) { - const message = history[index]!; - if ( - message.origin?.kind === 'injection' && - message.origin.variant === SESSION_START_INJECTION_VARIANT - ) { - return message; - } - } - return undefined; -} - -function messageText(message: ContextMessage): string { - return message.content.map((part) => (part.type === 'text' ? part.text : '')).join(''); -} - -function systemReminderText(content: string): string { - return `\n${content.trim()}\n`; -} - interface RenderPluginSessionStartReminderInput { readonly sessionStarts: readonly EnabledPluginSessionStart[]; readonly catalog: SkillCatalog | undefined; diff --git a/packages/agent-core-v2/src/agent/reminderQueue/reminderQueueService.ts b/packages/agent-core-v2/src/agent/reminderQueue/reminderQueueService.ts index 532c50bc265..263c0e48abe 100644 --- a/packages/agent-core-v2/src/agent/reminderQueue/reminderQueueService.ts +++ b/packages/agent-core-v2/src/agent/reminderQueue/reminderQueueService.ts @@ -2,16 +2,20 @@ * `reminderQueue` domain — `IAgentReminderQueueService` implementation. * * Persists once-reminder entries through its own wire Model, delivers them - * through `systemReminder` (the unified write head), and reads the - * conversation tail through `contextMemory` for crash-window dedup. Delivery - * scheduling is owned by the unified boundary scheduler (`contextInjector`), - * not by this service. Bound at Agent scope. + * through `systemReminder` (the unified write head), and schedules itself: + * it drains on every `contextInjector` injection boundary (`onWillInject`). + * Crash-window dedup reads the conversation tail through `contextMemory` and + * matches the entry id recorded as the delivered reminder's disclosure, so a + * reminder appended but not yet marked delivered is never re-appended after a + * restart; entries identical within one drain collapse into a single + * reminder. Bound at Agent scope. */ import { randomUUID } from 'node:crypto'; import { Disposable } from '#/_base/di/lifecycle'; import { LifecycleScope, ScopeActivation, registerScopedService } from '#/_base/di/scope'; +import { IAgentContextInjectorService } from '#/agent/contextInjector/contextInjector'; import { IAgentContextMemoryService } from '#/agent/contextMemory/contextMemory'; import type { ContextMessage } from '#/agent/contextMemory/types'; import { isVacuousContentPart } from '#/agent/contextMemory/vacuousContent'; @@ -33,8 +37,12 @@ export class AgentReminderQueueService extends Disposable implements IAgentRemin @IWireService private readonly wire: IWireService, @IAgentSystemReminderService private readonly reminders: IAgentSystemReminderService, @IAgentContextMemoryService private readonly context: IAgentContextMemoryService, + @IAgentContextInjectorService injector: IAgentContextInjectorService, ) { super(); + this._register(injector.onWillInject(() => { + this.drain(); + })); } enqueue(input: OnceReminderInput): void { @@ -43,42 +51,43 @@ export class AgentReminderQueueService extends Disposable implements IAgentRemin } drain(): void { + const deliveredIds = this.deliveredIdsAtTail(); + const rendered = new Set(); for (const entry of this.wire.getModel(ReminderQueueModel)) { - if (!this.alreadyDeliveredAtTail(entry)) { + const collapseKey = `${entry.variant}\n${entry.content}`; + if (!deliveredIds.has(entry.id) && !rendered.has(collapseKey)) { this.reminders.appendSystemReminder(entry.content, { kind: 'injection', variant: entry.variant, + disclosure: { kind: 'once_reminder', id: entry.id }, }); + rendered.add(collapseKey); } this.wire.dispatch(reminderQueueDelivered({ id: entry.id })); } } - private alreadyDeliveredAtTail(entry: ReminderQueueEntry): boolean { - const last = lastDurableMessage(this.context.get()); - if (last?.origin?.kind !== 'injection' || last.origin.variant !== entry.variant) return false; - return messageText(last) === `\n${entry.content.trim()}\n`; - } -} - -function lastDurableMessage(messages: readonly ContextMessage[]): ContextMessage | undefined { - for (let i = messages.length - 1; i >= 0; i--) { - const message = messages[i]!; - if ( - message.role === 'assistant' && - message.partial === true && - message.toolCalls.length === 0 && - message.content.every(isVacuousContentPart) - ) { - continue; + private deliveredIdsAtTail(): ReadonlySet { + const messages = this.context.get(); + const ids = new Set(); + for (let i = messages.length - 1; i >= 0; i--) { + const message = messages[i]!; + if (isSkippableTailAssistant(message)) continue; + const origin = message.origin; + if (origin?.kind !== 'injection') break; + if (origin.disclosure?.kind === 'once_reminder') ids.add(origin.disclosure.id); } - return message; + return ids; } - return undefined; } -function messageText(message: ContextMessage): string { - return message.content.map((part) => (part.type === 'text' ? part.text : '')).join(''); +function isSkippableTailAssistant(message: ContextMessage): boolean { + return ( + message.role === 'assistant' && + message.partial === true && + message.toolCalls.length === 0 && + message.content.every(isVacuousContentPart) + ); } registerScopedService( diff --git a/packages/agent-core-v2/src/agent/swarm/injection/swarmInjection.ts b/packages/agent-core-v2/src/agent/swarm/injection/swarmInjection.ts index 8d7697ddba2..628622f274a 100644 --- a/packages/agent-core-v2/src/agent/swarm/injection/swarmInjection.ts +++ b/packages/agent-core-v2/src/agent/swarm/injection/swarmInjection.ts @@ -6,20 +6,28 @@ * positions are folded away (compaction), and enter/exit transitions render * the corresponding reminder. Tool-triggered swarms never render — the * AgentSwarm tool result already tells the model. Reconciliation derives the - * rendered state from the latest surviving `contextMemory` message, so undo, - * compaction, and restore cannot leave a stale in-memory render flag. + * rendered state from the typed disclosure recorded on the newest surviving + * injection, never from rendered text, so reminder copy edits cannot corrupt + * the state of restored sessions. Sessions written before disclosures existed + * fall back to variant matching against `contextMemory`: a surviving + * `swarm_mode` injection means active, a surviving legacy `swarm_mode_exit` + * injection means inactive, whichever is latest wins. */ import { Disposable } from '#/_base/di/lifecycle'; -import { IAgentContextInjectorService } from '#/agent/contextInjector/contextInjector'; +import { + IAgentContextInjectorService, + type ContextInjectionContext, + type ContextInjectionResult, +} from '#/agent/contextInjector/contextInjector'; import { IAgentContextMemoryService } from '#/agent/contextMemory/contextMemory'; -import type { ContextMessage } from '#/agent/contextMemory/types'; import SWARM_MODE_ENTER_REMINDER from '../enter-reminder.md?raw'; import SWARM_MODE_EXIT_REMINDER from '../exit-reminder.md?raw'; import type { SwarmModeTrigger } from '../swarm'; const SWARM_MODE_INJECTION_VARIANT = 'swarm_mode'; +const LEGACY_SWARM_MODE_EXIT_VARIANT = 'swarm_mode_exit'; export interface SwarmInjectionOptions { readonly getTrigger: () => SwarmModeTrigger | null; @@ -33,37 +41,39 @@ export class SwarmInjection extends Disposable { ) { super(); this._register( - dynamicInjector.register(SWARM_MODE_INJECTION_VARIANT, () => this.reminder()), + dynamicInjector.register(SWARM_MODE_INJECTION_VARIANT, (ctx) => this.reminder(ctx)), ); } - private reminder(): string | undefined { + private reminder(ctx: ContextInjectionContext): ContextInjectionResult | undefined { const trigger = this.options.getTrigger(); const active = trigger !== null && trigger !== 'tool'; - const rendered = lastRenderedFromHistory(this.context.get()); - if (active) return rendered === 'active' ? undefined : SWARM_MODE_ENTER_REMINDER; - return rendered === 'active' ? SWARM_MODE_EXIT_REMINDER : undefined; + const rendered = this.renderedState(ctx); + if (active) { + return rendered === 'active' + ? undefined + : { + content: SWARM_MODE_ENTER_REMINDER, + disclosure: { kind: 'swarm_mode', state: 'active' }, + }; + } + return rendered === 'active' + ? { + content: SWARM_MODE_EXIT_REMINDER, + disclosure: { kind: 'swarm_mode', state: 'inactive' }, + } + : undefined; } -} -function lastRenderedFromHistory( - history: readonly ContextMessage[], -): 'active' | 'inactive' | undefined { - for (let i = history.length - 1; i >= 0; i--) { - const message = history[i]!; - const origin = message.origin; - if (origin?.kind !== 'injection') continue; - if (origin.variant !== SWARM_MODE_INJECTION_VARIANT && origin.variant !== 'swarm_mode_exit') { - continue; - } - const text = message.content.map((part) => (part.type === 'text' ? part.text : '')).join(''); - if (text === `\n${SWARM_MODE_ENTER_REMINDER.trim()}\n`) { - return 'active'; - } - if (text === `\n${SWARM_MODE_EXIT_REMINDER.trim()}\n`) { - return 'inactive'; + private renderedState(ctx: ContextInjectionContext): 'active' | 'inactive' | undefined { + if (ctx.lastDisclosure?.kind === 'swarm_mode') return ctx.lastDisclosure.state; + const history = this.context.get(); + for (let i = history.length - 1; i >= 0; i--) { + const origin = history[i]!.origin; + if (origin?.kind !== 'injection') continue; + if (origin.variant === LEGACY_SWARM_MODE_EXIT_VARIANT) return 'inactive'; + if (origin.variant === SWARM_MODE_INJECTION_VARIANT) return 'active'; } return undefined; } - return undefined; } diff --git a/packages/agent-core-v2/src/agent/systemReminder/systemReminder.ts b/packages/agent-core-v2/src/agent/systemReminder/systemReminder.ts index 6fec1608d69..762a9322381 100644 --- a/packages/agent-core-v2/src/agent/systemReminder/systemReminder.ts +++ b/packages/agent-core-v2/src/agent/systemReminder/systemReminder.ts @@ -2,12 +2,31 @@ * `systemReminder` domain — low-level model-facing reminder write contract. * * Defines the Agent-scoped write head used by context injection, the durable - * world-event queue, and prompt-owned media annotations. Bound at Agent scope. + * world-event queue, and prompt-owned media annotations, and owns the + * `` text format: `wrapSystemReminder` is the only writer, + * `systemReminderContent` the only reader, so no consumer reconstructs the + * format by hand. Bound at Agent scope. */ import { createDecorator } from "#/_base/di/instantiation"; import type { ContextMessage, PromptOrigin } from '#/agent/contextMemory/types'; + +const SYSTEM_REMINDER_PREFIX = '\n'; +const SYSTEM_REMINDER_SUFFIX = '\n'; + +export function wrapSystemReminder(content: string): string { + return `${SYSTEM_REMINDER_PREFIX}${content.trim()}${SYSTEM_REMINDER_SUFFIX}`; +} + +export function systemReminderContent(message: ContextMessage): string | undefined { + const text = message.content.map((part) => (part.type === 'text' ? part.text : '')).join(''); + if (!text.startsWith(SYSTEM_REMINDER_PREFIX) || !text.endsWith(SYSTEM_REMINDER_SUFFIX)) { + return undefined; + } + return text.slice(SYSTEM_REMINDER_PREFIX.length, text.length - SYSTEM_REMINDER_SUFFIX.length); +} + export interface IAgentSystemReminderService { readonly _serviceBrand: undefined; diff --git a/packages/agent-core-v2/src/agent/systemReminder/systemReminderService.ts b/packages/agent-core-v2/src/agent/systemReminder/systemReminderService.ts index 1c037efafa3..677d7fc09f6 100644 --- a/packages/agent-core-v2/src/agent/systemReminder/systemReminderService.ts +++ b/packages/agent-core-v2/src/agent/systemReminder/systemReminderService.ts @@ -1,9 +1,16 @@ +/** + * `systemReminder` domain — `IAgentSystemReminderService` implementation. + * + * Appends model-facing reminder messages, wrapped by `wrapSystemReminder`, + * into the conversation through `contextMemory`. Bound at Agent scope. + */ + import { Disposable } from "#/_base/di/lifecycle"; import { LifecycleScope, ScopeActivation, registerScopedService } from '#/_base/di/scope'; import { IAgentContextMemoryService } from '#/agent/contextMemory/contextMemory'; import type { ContextMessage, PromptOrigin } from '#/agent/contextMemory/types'; -import { IAgentSystemReminderService } from './systemReminder'; +import { IAgentSystemReminderService, wrapSystemReminder } from './systemReminder'; export class AgentSystemReminderService extends Disposable implements IAgentSystemReminderService { declare readonly _serviceBrand: undefined; @@ -20,7 +27,7 @@ export class AgentSystemReminderService extends Disposable implements IAgentSyst content: [ { type: 'text', - text: `\n${content.trim()}\n`, + text: wrapSystemReminder(content), }, ], toolCalls: [], diff --git a/packages/agent-core-v2/test/agent/contextInjector/contextInjector.test.ts b/packages/agent-core-v2/test/agent/contextInjector/contextInjector.test.ts index 972f13d4b6c..713db7df8f6 100644 --- a/packages/agent-core-v2/test/agent/contextInjector/contextInjector.test.ts +++ b/packages/agent-core-v2/test/agent/contextInjector/contextInjector.test.ts @@ -20,7 +20,6 @@ import { IAgentContextMemoryService } from '#/agent/contextMemory/contextMemory' import type { ContextMessage } from '#/agent/contextMemory/types'; import { IAgentLoopService } from '#/agent/loop/loop'; import { IAgentProfileService } from '#/agent/profile/profile'; -import { IAgentReminderQueueService } from '#/agent/reminderQueue/reminderQueue'; import { IAgentStateService } from '#/agent/state/agentState'; import { AgentStateService } from '#/agent/state/agentStateService'; import { IAgentSystemReminderService } from '#/agent/systemReminder/systemReminder'; @@ -83,11 +82,6 @@ describe('AgentContextInjectorService', () => { reg.defineInstance(IAgentLoopService, loop); reg.defineInstance(IWireService, stubWire()); reg.defineInstance(IAgentStateService, new AgentStateService()); - reg.defineInstance(IAgentReminderQueueService, { - _serviceBrand: undefined, - enqueue: () => '', - drain: () => {}, - }); reg.define(IAgentSystemReminderService, AgentSystemReminderService); reg.define(IAgentContextInjectorService, AgentContextInjectorService); }, @@ -337,15 +331,13 @@ describe('AgentContextInjectorService', () => { ]); }); - it('drains the reminder queue before running the providers', async () => { + it('fires onWillInject before running the providers', async () => { const calls: string[] = []; - ix.stub(IAgentReminderQueueService, { - _serviceBrand: undefined, - enqueue: () => '', - drain: () => { - calls.push('drain'); - }, - }); + disposables.add( + injector(ix).onWillInject(() => { + calls.push('willInject'); + }), + ); injector(ix).register('ordering_test', () => { calls.push('provider'); return undefined; @@ -353,7 +345,7 @@ describe('AgentContextInjectorService', () => { await injector(ix).inject(); - expect(calls).toEqual(['drain', 'provider']); + expect(calls).toEqual(['willInject', 'provider']); }); it('runs synchronous turn-start providers before the next prompt materializes', () => { diff --git a/packages/agent-core-v2/test/agent/goal/goal.test.ts b/packages/agent-core-v2/test/agent/goal/goal.test.ts index 6f593f319e6..fc1e5121e77 100644 --- a/packages/agent-core-v2/test/agent/goal/goal.test.ts +++ b/packages/agent-core-v2/test/agent/goal/goal.test.ts @@ -503,7 +503,11 @@ describe('AgentGoalService', () => { expect(goals.getGoal()).toEqual({ goal: null }); ctx.get(IAgentReminderQueueService).drain(); const reminder = context.get().at(-1); - expect(reminder?.origin).toEqual({ kind: 'injection', variant: 'goal_cancelled' }); + expect(reminder?.origin).toEqual({ + kind: 'injection', + variant: 'goal_cancelled', + disclosure: { kind: 'once_reminder', id: expect.any(String) }, + }); expect(JSON.stringify(reminder?.content)).toContain('Ignore earlier active-goal reminders'); await expect(goals.cancelGoal()).rejects.toMatchObject({ code: ErrorCodes.GOAL_NOT_FOUND }); }); @@ -2307,7 +2311,11 @@ describe('AgentGoalService fork boundaries', () => { expect(goals.getGoal().goal).toBeNull(); ctx.get(IAgentReminderQueueService).drain(); const reminder = context.get().at(-1); - expect(reminder?.origin).toEqual({ kind: 'injection', variant: 'goal_fork_cleared' }); + expect(reminder?.origin).toEqual({ + kind: 'injection', + variant: 'goal_fork_cleared', + disclosure: { kind: 'once_reminder', id: expect.any(String) }, + }); const text = JSON.stringify(reminder?.content); expect(text).toContain('This fork does not have a current goal.'); expect(text).toContain('Ignore earlier active-goal reminders from the source session.'); diff --git a/packages/agent-core-v2/test/agent/permissionMode/permissionMode.test.ts b/packages/agent-core-v2/test/agent/permissionMode/permissionMode.test.ts index 4d7ebf9e212..271348e8df1 100644 --- a/packages/agent-core-v2/test/agent/permissionMode/permissionMode.test.ts +++ b/packages/agent-core-v2/test/agent/permissionMode/permissionMode.test.ts @@ -3,6 +3,7 @@ import { afterEach, beforeEach, describe, expect, it } from 'vitest'; import { SyncDescriptor } from '#/_base/di/descriptors'; import { DisposableStore } from '#/_base/di/lifecycle'; import { TestInstantiationService } from '#/_base/di/test'; +import { Emitter } from '#/_base/event'; import { IAgentContextInjectorService, type ContextInjectionProvider, @@ -35,6 +36,7 @@ let registeredInjection: const injectorStub: IAgentContextInjectorService = { _serviceBrand: undefined, + onWillInject: new Emitter().event, register: (name, provider) => { registeredInjection = { name, provider }; return { diff --git a/packages/agent-core-v2/test/agent/reminderQueue/reminderQueue.test.ts b/packages/agent-core-v2/test/agent/reminderQueue/reminderQueue.test.ts index 53c5a5eee19..cde99a8f583 100644 --- a/packages/agent-core-v2/test/agent/reminderQueue/reminderQueue.test.ts +++ b/packages/agent-core-v2/test/agent/reminderQueue/reminderQueue.test.ts @@ -11,6 +11,8 @@ import { afterEach, beforeEach, describe, expect, it } from 'vitest'; import { DisposableStore } from '#/_base/di/lifecycle'; import { createServices, type TestInstantiationService } from '#/_base/di/test'; +import { Emitter } from '#/_base/event'; +import { IAgentContextInjectorService } from '#/agent/contextInjector/contextInjector'; import { IAgentContextMemoryService } from '#/agent/contextMemory/contextMemory'; import { CHECKPOINTED_MODELS } from '#/agent/contextMemory/conversationTime'; import { IAgentReminderQueueService } from '#/agent/reminderQueue/reminderQueue'; @@ -34,15 +36,24 @@ describe('AgentReminderQueueService', () => { let context: IAgentContextMemoryService; let queue: IAgentReminderQueueService; let reminders: IAgentSystemReminderService; + let willInject: Emitter; beforeEach(() => { disposables = new DisposableStore(); records = []; log = recordingWireLog(records); + willInject = disposables.add(new Emitter()); ix = createServices(disposables, { base: [registerContextMemoryServices], strict: true, additionalServices: (reg) => { + reg.defineInstance(IAgentContextInjectorService, { + _serviceBrand: undefined, + onWillInject: willInject.event, + register: () => ({ dispose() {} }), + registerAtTurnStart: () => ({ dispose() {} }), + injectAfterCompaction: async () => {}, + }); reg.define(IAgentSystemReminderService, AgentSystemReminderService); reg.define(IAgentReminderQueueService, AgentReminderQueueService); }, @@ -147,6 +158,7 @@ describe('AgentReminderQueueService', () => { reminders.appendSystemReminder('crash window', { kind: 'injection', variant: 'interruption', + disclosure: { kind: 'once_reminder', id: 'e1' }, }); const journal = [ { @@ -164,10 +176,42 @@ describe('AgentReminderQueueService', () => { expect(wire.getModel(ReminderQueueModel)).toEqual([]); }); + it('reconciles multiple appended-but-unrecorded deliveries after restore', async () => { + reminders.appendSystemReminder('first', { + kind: 'injection', + variant: 'a', + disclosure: { kind: 'once_reminder', id: 'e1' }, + }); + reminders.appendSystemReminder('second', { + kind: 'injection', + variant: 'b', + disclosure: { kind: 'once_reminder', id: 'e2' }, + }); + const journal = [ + { + type: 'reminderQueue.enqueue', + entry: { id: 'e1', variant: 'a', content: 'first' }, + }, + { + type: 'reminderQueue.enqueue', + entry: { id: 'e2', variant: 'b', content: 'second' }, + }, + ] as unknown as WireRecord[]; + + await restoreTestAgentWire(wire, log, SCOPE, journal); + expect(wire.getModel(ReminderQueueModel)).toHaveLength(2); + + queue.drain(); + + expect(injectionMessages()).toHaveLength(2); + expect(wire.getModel(ReminderQueueModel)).toEqual([]); + }); + it('skips vacuous partial assistant messages when matching the conversation tail', async () => { reminders.appendSystemReminder('crash window', { kind: 'injection', variant: 'interruption', + disclosure: { kind: 'once_reminder', id: 'e1' }, }); context.append({ role: 'assistant', content: [], toolCalls: [], partial: true }); const journal = [ @@ -195,4 +239,25 @@ describe('AgentReminderQueueService', () => { expect(injectionMessages()).toHaveLength(2); expect(wire.getModel(ReminderQueueModel)).toEqual([]); }); + + it('records the entry id as the delivered reminder disclosure', () => { + queue.enqueue({ variant: 'a', content: 'first' }); + + queue.drain(); + + expect(injectionMessages()[0]?.origin).toEqual({ + kind: 'injection', + variant: 'a', + disclosure: { kind: 'once_reminder', id: expect.any(String) }, + }); + }); + + it('drains itself on every injection boundary', () => { + queue.enqueue({ variant: 'a', content: 'first' }); + + willInject.fire(); + + expect(injectionMessages()).toHaveLength(1); + expect(wire.getModel(ReminderQueueModel)).toEqual([]); + }); }); diff --git a/packages/agent-core-v2/test/agent/swarm/swarm.test.ts b/packages/agent-core-v2/test/agent/swarm/swarm.test.ts index 60095b4de8a..8e56fdb1287 100644 --- a/packages/agent-core-v2/test/agent/swarm/swarm.test.ts +++ b/packages/agent-core-v2/test/agent/swarm/swarm.test.ts @@ -21,7 +21,6 @@ import type { ContextMessage } from '#/agent/contextMemory/types'; import { DEFAULT_SUBAGENT_TIMEOUT_MS } from '#/session/subagent/configSection'; import { IAgentLifecycleService } from '#/session/agentLifecycle/agentLifecycle'; import { ISessionSwarmService, type SessionSwarmRunResult, type SessionSwarmTask } from '#/session/swarm/sessionSwarm'; -import { IAgentReminderQueueService } from '#/agent/reminderQueue/reminderQueue'; import { IAgentStateService } from '#/agent/state/agentState'; import { AgentStateService } from '#/agent/state/agentStateService'; import { IAgentSystemReminderService } from '#/agent/systemReminder/systemReminder'; @@ -199,11 +198,6 @@ describe('AgentSwarmService', () => { ix.stub(IFileSystemStorageService, new InMemoryStorageService()); ix.set(IAppendLogStore, new SyncDescriptor(AppendLogStore)); ix.stub(IAgentLoopService, stubLoopWithHooks()); - ix.stub(IAgentReminderQueueService, { - _serviceBrand: undefined, - enqueue: () => '', - drain: () => {}, - }); ix.set(IAgentStateService, new AgentStateService()); ix.set(IAgentContextInjectorService, new SyncDescriptor(AgentContextInjectorService)); ix.set(IAgentToolRegistryService, new SyncDescriptor(AgentToolRegistryService)); @@ -267,7 +261,11 @@ describe('AgentSwarmService', () => { await runInjectionBoundary(ix.get(IAgentLoopService)); let reminder = context.get().at(-1); - expect(reminder?.origin).toEqual({ kind: 'injection', variant: 'swarm_mode' }); + expect(reminder?.origin).toEqual({ + kind: 'injection', + variant: 'swarm_mode', + disclosure: { kind: 'swarm_mode', state: 'active' }, + }); expect(textOf(reminder)).toContain('You are now in "agent swarm" mode.'); await runInjectionBoundary(ix.get(IAgentLoopService)); @@ -277,7 +275,11 @@ describe('AgentSwarmService', () => { await runInjectionBoundary(ix.get(IAgentLoopService)); reminder = context.get().at(-1); - expect(reminder?.origin).toEqual({ kind: 'injection', variant: 'swarm_mode' }); + expect(reminder?.origin).toEqual({ + kind: 'injection', + variant: 'swarm_mode', + disclosure: { kind: 'swarm_mode', state: 'inactive' }, + }); expect(textOf(reminder)).toContain('Swarm Mode has ended.'); expect(context.get()).toHaveLength(2); }); @@ -332,13 +334,38 @@ describe('AgentSwarmService', () => { expect(swarm.isActive).toBe(false); const reminder = context.get().at(-1); - expect(reminder?.origin).toEqual({ kind: 'injection', variant: 'swarm_mode' }); + expect(reminder?.origin).toEqual({ + kind: 'injection', + variant: 'swarm_mode', + disclosure: { kind: 'swarm_mode', state: 'inactive' }, + }); const text = reminder?.content.map((part) => (part.type === 'text' ? part.text : '')).join('') ?? ''; expect(text).toContain('Swarm Mode has ended.'); expect(context.get()).toHaveLength(2); }); + it('derives the rendered state from the disclosure, not the reminder text', async () => { + const swarm = ix.get(IAgentSwarmService); + const context = ix.get(IAgentContextMemoryService); + ix.get(IAgentSystemReminderService).appendSystemReminder('outdated enter copy', { + kind: 'injection', + variant: 'swarm_mode', + disclosure: { kind: 'swarm_mode', state: 'active' }, + }); + await restoreTestAgentWire( + ix.get(IWireService), + ix.get(IAppendLogStore), + testWireScope('wire', 'swarm-test'), + [{ type: 'swarm_mode.enter', trigger: 'manual' }], + ); + + await runInjectionBoundary(ix.get(IAgentLoopService)); + + expect(swarm.isActive).toBe(true); + expect(context.get()).toHaveLength(1); + }); + it('dispatch persists enter/exit records and replay rebuilds the trigger (silent)', async () => { const swarm = ix.get(IAgentSwarmService); swarm.enter('manual'); diff --git a/packages/agent-core-v2/test/agent/toolSelect/toolSelectService.test.ts b/packages/agent-core-v2/test/agent/toolSelect/toolSelectService.test.ts index 12c3edcce79..46b037c7bef 100644 --- a/packages/agent-core-v2/test/agent/toolSelect/toolSelectService.test.ts +++ b/packages/agent-core-v2/test/agent/toolSelect/toolSelectService.test.ts @@ -35,7 +35,6 @@ import { } from '#/agent/loop/loop'; import type { StepRequest } from '#/agent/loop/stepRequest'; import { IAgentProfileService } from '#/agent/profile/profile'; -import { IAgentReminderQueueService } from '#/agent/reminderQueue/reminderQueue'; import { IAgentToolPolicyService } from '#/agent/toolPolicy/toolPolicy'; import { IAgentScopeContext, makeAgentScopeContext } from '#/agent/scopeContext/scopeContext'; import { IAgentSystemReminderService } from '#/agent/systemReminder/systemReminder'; @@ -324,11 +323,6 @@ function registerSharedServices( enabled: (id: string) => (id === TOOL_SELECT_FLAG_ID ? flagEnabled : false), }); reg.defineInstance(IWireService, stubWire()); - reg.defineInstance(IAgentReminderQueueService, { - _serviceBrand: undefined, - enqueue: () => '', - drain: () => {}, - }); reg.define(IAgentContextInjectorService, AgentContextInjectorService); reg.define(IAgentToolRegistryService, AgentToolRegistryService); reg.define(IAgentToolSelectService, AgentToolSelectService); diff --git a/packages/agent-core-v2/test/index.test.ts b/packages/agent-core-v2/test/index.test.ts index 376ce04a72d..a875d41e6cf 100644 --- a/packages/agent-core-v2/test/index.test.ts +++ b/packages/agent-core-v2/test/index.test.ts @@ -409,7 +409,11 @@ describe('AgentRecords persistence metadata', () => { expect(ctx.get(IAgentGoalService).getGoal().goal).toBeNull(); ctx.get(IAgentReminderQueueService).drain(); const reminder = context.get().at(-1); - expect(reminder?.origin).toEqual({ kind: 'injection', variant: 'goal_fork_cleared' }); + expect(reminder?.origin).toEqual({ + kind: 'injection', + variant: 'goal_fork_cleared', + disclosure: { kind: 'once_reminder', id: expect.any(String) }, + }); expect(JSON.stringify(reminder?.content)).toContain('This fork does not have a current goal.'); }); @@ -438,6 +442,7 @@ describe('AgentRecords persistence metadata', () => { expect(context.get().at(-1)?.origin).toEqual({ kind: 'injection', variant: 'goal_fork_cleared', + disclosure: { kind: 'once_reminder', id: expect.any(String) }, }); }); diff --git a/packages/node-sdk/test/v1-v2-parity.test.ts b/packages/node-sdk/test/v1-v2-parity.test.ts index 3b077fbd5b4..8ac92069ed4 100644 --- a/packages/node-sdk/test/v1-v2-parity.test.ts +++ b/packages/node-sdk/test/v1-v2-parity.test.ts @@ -169,6 +169,16 @@ function scrubHomePrefixes(value: unknown, home: HomePair): unknown { * the comparison still covers everything not listed here. Keep empty unless a * gap is genuinely accepted; remove entries as gaps close. */ +function stripOriginDisclosure(history: readonly unknown[]): readonly unknown[] { + return history.map((message) => { + const record = message as { readonly origin?: { readonly disclosure?: unknown } }; + if (record.origin?.disclosure === undefined) return message; + const origin = { ...record.origin }; + delete origin.disclosure; + return { ...record, origin }; + }); +} + const KNOWN_DIFFS = { // v2's flag registry is per-domain and already carries flags v1 does not // have (minidb backend, subagent); v1-only flags would be the symmetric @@ -247,10 +257,14 @@ const KNOWN_DIFFS = { // provider-MEASURED prefix. In-memory appends (e.g. importContext) count // identically on both engines via the shared `estimateTokensForMessages`, // but once the provider reports measured usage the counts diverge by - // design. Histories compare in full; the count compares only in the - // pre-LLM state where both sides still estimate. + // design. Histories compare in full except for `origin.disclosure` — v2 + // records typed reminder-render state there (swarm mode, once-reminder + // ids) and v1 has no such field; the count compares only in the pre-LLM + // state where both sides still estimate. getContext: (context: { readonly history: readonly unknown[] }): unknown => - context.history.length === 0 ? context : { history: context.history }, + context.history.length === 0 + ? context + : { history: stripOriginDisclosure(context.history) }, // Plan ids are random per engine (hero slugs) and the plan path embeds // both the per-home session dir and the id, so the comparison covers the // content and the path LAYOUT (id scrubbed); the id itself is asserted From 15ef35c7a543520e69d1e71ef2f4e26281a67863 Mon Sep 17 00:00:00 2001 From: 7Sageer <7sageer@djwcb.cn> Date: Wed, 5 Aug 2026 18:31:52 +0800 Subject: [PATCH 08/27] fix(agent-core-v2): isolate failing turn-start providers and warn once per missing sessionStart skill --- .../contextInjector/contextInjectorService.ts | 23 +++++++++-- .../src/agent/plugin/agentPluginService.ts | 19 ++++++--- .../contextInjector/contextInjector.test.ts | 41 ++++++++++++++++++- .../test/agent/swarm/swarm.test.ts | 3 ++ .../skillCatalog/plugin-session-start.test.ts | 15 +++++++ 5 files changed, 90 insertions(+), 11 deletions(-) diff --git a/packages/agent-core-v2/src/agent/contextInjector/contextInjectorService.ts b/packages/agent-core-v2/src/agent/contextInjector/contextInjectorService.ts index 626fb31e6d9..cf2344993dd 100644 --- a/packages/agent-core-v2/src/agent/contextInjector/contextInjectorService.ts +++ b/packages/agent-core-v2/src/agent/contextInjector/contextInjectorService.ts @@ -13,8 +13,9 @@ * disclosure recorded on it (`lastDisclosure`), so providers never read * context layout or position indexes themselves. Turn-start providers run * synchronously after `turn.started` — before the turn's first step request - * materializes its prompt — and must stay synchronous (registration throws on - * a Promise-returning provider); they are also reconciled at every later step + * materializes its prompt — and must stay synchronous (a provider that throws + * or returns a Promise is logged and skipped, so one bad provider cannot + * starve the rest); they are also reconciled at every later step * boundary as a fallback for facts that arrive after `turn.started` (for * example, a queued turn cancelled while another turn is already running). * The plain-data `isNewTurn` flag is registered @@ -26,6 +27,7 @@ import { Disposable, toDisposable, type IDisposable } from "#/_base/di/lifecycle"; import { LifecycleScope, ScopeActivation, registerScopedService } from '#/_base/di/scope'; import { Emitter, type Event } from '#/_base/event'; +import { ILogService } from '#/_base/log/log'; import { defineState } from '#/_base/state/stateRegistry'; import { IAgentContextMemoryService } from '#/agent/contextMemory/contextMemory'; @@ -66,6 +68,7 @@ export class AgentContextInjectorService extends Disposable implements IAgentCon @IAgentLoopService loopService: IAgentLoopService, @IAgentSystemReminderService private readonly reminders: IAgentSystemReminderService, @IEventBus private readonly eventBus: IEventBus, + @ILogService private readonly log: ILogService, @IWireService wire: IWireService, @IAgentStateService private readonly states: IAgentStateService, ) { @@ -158,9 +161,21 @@ export class AgentContextInjectorService extends Disposable implements IAgentCon private injectAtTurnStart(): void { for (const entry of this.entries) { if (entry.boundary !== 'turn-start') continue; - const content = entry.provider(this.providerContext(entry, true)); + let content: ReturnType; + try { + content = entry.provider(this.providerContext(entry, true)); + } catch (error) { + this.log.error('turn-start context provider failed; skipping it', { + name: entry.name, + error, + }); + continue; + } if (isThenable(content)) { - throw new TypeError(`Turn-start context provider "${entry.name}" returned a Promise`); + this.log.error('turn-start context provider returned a Promise; skipping it', { + name: entry.name, + }); + continue; } if (!this.entries.has(entry)) continue; this.appendResult(entry, content); diff --git a/packages/agent-core-v2/src/agent/plugin/agentPluginService.ts b/packages/agent-core-v2/src/agent/plugin/agentPluginService.ts index ac110fe32d8..4636c25ad33 100644 --- a/packages/agent-core-v2/src/agent/plugin/agentPluginService.ts +++ b/packages/agent-core-v2/src/agent/plugin/agentPluginService.ts @@ -50,6 +50,7 @@ export const pluginSessionStartRefreshPendingKey = defineState( export class AgentPluginService extends Disposable implements IAgentPluginService { declare readonly _serviceBrand: undefined; + private readonly warnedMissingSessionStartSkills = new Set(); constructor( @IAgentScopeContext scopeContext: IAgentScopeContext, @@ -93,6 +94,7 @@ export class AgentPluginService extends Disposable implements IAgentPluginServic catalog: this.skillCatalog.catalog, log: this.log, sessionId: this.sessionContext.sessionId, + warnedSkills: this.warnedMissingSessionStartSkills, }); } @@ -132,22 +134,29 @@ interface RenderPluginSessionStartReminderInput { readonly catalog: SkillCatalog | undefined; readonly log?: { warn(message: string, payload?: unknown): void }; readonly sessionId?: string; + readonly warnedSkills: Set; } function renderPluginSessionStartReminder( input: RenderPluginSessionStartReminderInput, ): string | undefined { - const { sessionStarts, catalog, log, sessionId } = input; + const { sessionStarts, catalog, log, sessionId, warnedSkills } = input; if (sessionStarts.length === 0) return undefined; if (catalog === undefined) return undefined; const blocks: string[] = []; for (const sessionStart of sessionStarts) { const skill = catalog.getPluginSkill(sessionStart.pluginId, sessionStart.skillName); if (skill === undefined) { - log?.warn('plugin sessionStart skill not found', { - pluginId: sessionStart.pluginId, - skillName: sessionStart.skillName, - }); + // The provider reconciles at every step boundary, so a missing skill + // would otherwise re-warn on every step. + const key = `${sessionStart.pluginId}:${sessionStart.skillName}`; + if (!warnedSkills.has(key)) { + warnedSkills.add(key); + log?.warn('plugin sessionStart skill not found', { + pluginId: sessionStart.pluginId, + skillName: sessionStart.skillName, + }); + } continue; } blocks.push( diff --git a/packages/agent-core-v2/test/agent/contextInjector/contextInjector.test.ts b/packages/agent-core-v2/test/agent/contextInjector/contextInjector.test.ts index 713db7df8f6..ffad5a4721d 100644 --- a/packages/agent-core-v2/test/agent/contextInjector/contextInjector.test.ts +++ b/packages/agent-core-v2/test/agent/contextInjector/contextInjector.test.ts @@ -14,7 +14,10 @@ import { createServices, type TestInstantiationService, } from '#/_base/di/test'; -import { IAgentContextInjectorService } from '#/agent/contextInjector/contextInjector'; +import { + IAgentContextInjectorService, + type SyncContextInjectionProvider, +} from '#/agent/contextInjector/contextInjector'; import { AgentContextInjectorService } from '#/agent/contextInjector/contextInjectorService'; import { IAgentContextMemoryService } from '#/agent/contextMemory/contextMemory'; import type { ContextMessage } from '#/agent/contextMemory/types'; @@ -26,6 +29,7 @@ import { IAgentSystemReminderService } from '#/agent/systemReminder/systemRemind import { AgentSystemReminderService } from '#/agent/systemReminder/systemReminderService'; import { IEventBus } from '#/app/event/eventBus'; import { IWireService } from '#/wire/wire'; +import { registerLogServices } from '../../_base/log/stubs'; import { registerContextMemoryServices, type StubContextMemory } from '../contextMemory/stubs'; import { runWillBeginStepHooks, @@ -76,7 +80,7 @@ describe('AgentContextInjectorService', () => { disposables = new DisposableStore(); loop = stubLoopWithHooks(); ix = createServices(disposables, { - base: [registerContextMemoryServices], + base: [registerContextMemoryServices, registerLogServices], strict: true, additionalServices: (reg) => { reg.defineInstance(IAgentLoopService, loop); @@ -360,4 +364,37 @@ describe('AgentContextInjectorService', () => { expect(context.get()).toHaveLength(1); expect(lastText(context)).toContain('turn-start reminder'); }); + + it('skips a throwing turn-start provider and still runs the rest', () => { + injector(ix).registerAtTurnStart('turn_start_throwing', () => { + throw new Error('boom'); + }); + injector(ix).registerAtTurnStart('turn_start_surviving', () => 'surviving reminder'); + + ix.get(IEventBus).publish({ + type: 'turn.started', + turnId: 0, + origin: { kind: 'user' }, + }); + + expect(context.get()).toHaveLength(1); + expect(lastText(context)).toContain('surviving reminder'); + }); + + it('skips a Promise-returning turn-start provider and still runs the rest', () => { + injector(ix).registerAtTurnStart( + 'turn_start_async', + (() => Promise.resolve('async reminder')) as unknown as SyncContextInjectionProvider, + ); + injector(ix).registerAtTurnStart('turn_start_surviving', () => 'surviving reminder'); + + ix.get(IEventBus).publish({ + type: 'turn.started', + turnId: 0, + origin: { kind: 'user' }, + }); + + expect(context.get()).toHaveLength(1); + expect(lastText(context)).toContain('surviving reminder'); + }); }); diff --git a/packages/agent-core-v2/test/agent/swarm/swarm.test.ts b/packages/agent-core-v2/test/agent/swarm/swarm.test.ts index 8e56fdb1287..4b3e06dd7a7 100644 --- a/packages/agent-core-v2/test/agent/swarm/swarm.test.ts +++ b/packages/agent-core-v2/test/agent/swarm/swarm.test.ts @@ -14,6 +14,8 @@ import { makeAgentScopeContext } from '#/agent/scopeContext/scopeContext'; import { SyncDescriptor } from '#/_base/di/descriptors'; import { DisposableStore } from '#/_base/di/lifecycle'; import { TestInstantiationService } from '#/_base/di/test'; +import { ILogService } from '#/_base/log/log'; +import { stubLog } from '../../_base/log/stubs'; import { IAgentContextInjectorService } from '#/agent/contextInjector/contextInjector'; import { AgentContextInjectorService } from '#/agent/contextInjector/contextInjectorService'; import { IAgentContextMemoryService } from '#/agent/contextMemory/contextMemory'; @@ -194,6 +196,7 @@ describe('AgentSwarmService', () => { disposables = new DisposableStore(); ix = disposables.add(new TestInstantiationService()); ix.set(IEventBus, new SyncDescriptor(EventBusService)); + ix.stub(ILogService, stubLog()); ix.stub(IAgentContextMemoryService, stubContextMemory(ix.get(IEventBus))); ix.stub(IFileSystemStorageService, new InMemoryStorageService()); ix.set(IAppendLogStore, new SyncDescriptor(AppendLogStore)); diff --git a/packages/agent-core-v2/test/app/skillCatalog/plugin-session-start.test.ts b/packages/agent-core-v2/test/app/skillCatalog/plugin-session-start.test.ts index ca725c27317..9709ef68571 100644 --- a/packages/agent-core-v2/test/app/skillCatalog/plugin-session-start.test.ts +++ b/packages/agent-core-v2/test/app/skillCatalog/plugin-session-start.test.ts @@ -228,6 +228,21 @@ describe('plugin session-start dynamic injection', () => { ); }); + it('warns only once for a missing skill across repeated reconciliations', async () => { + const { ctx, warnings } = sessionStartRuntime({ + sessionStarts: [{ pluginId: 'demo', skillName: 'missing' }], + skills: [], + }); + + await injectDynamic(ctx); + await injectDynamic(ctx); + await injectDynamic(ctx); + + expect( + warnings.filter((warning) => warning.message === 'plugin sessionStart skill not found'), + ).toHaveLength(1); + }); + it('emits nothing when no sessionStart declarations are present', async () => { const { ctx } = sessionStartRuntime({ sessionStarts: [], skills: [] }); From 61f524261e4a81f2ab102acb82bde3a43cdb69f1 Mon Sep 17 00:00:00 2001 From: 7Sageer <7sageer@djwcb.cn> Date: Thu, 6 Aug 2026 13:21:29 +0800 Subject: [PATCH 09/27] refactor(agent-core-v2): compute injection positions on read Drop the per-provider positions cache from the context injector: the registration scan, the context.spliced index arithmetic, and the post-restore resync all existed only to mirror what the history already records. Each provider call now derives its injected positions by scanning context memory for its surviving injection messages, so silent history edits (such as vacuous-step folds) can no longer desync a cached index. --- .../contextInjector/contextInjectorService.ts | 70 +++---------------- .../injection/permissionModeInjection.ts | 8 +-- 2 files changed, 12 insertions(+), 66 deletions(-) diff --git a/packages/agent-core-v2/src/agent/contextInjector/contextInjectorService.ts b/packages/agent-core-v2/src/agent/contextInjector/contextInjectorService.ts index cf2344993dd..77b6d4f7cc9 100644 --- a/packages/agent-core-v2/src/agent/contextInjector/contextInjectorService.ts +++ b/packages/agent-core-v2/src/agent/contextInjector/contextInjectorService.ts @@ -6,12 +6,13 @@ * it first fires `onWillInject` — where boundary participants such as the * `reminderQueue` drain their persisted once-reminders (past-tense events, * exactly-once) — then reconciles the registered context providers - * (present-tense state) through `loop` and `systemReminder`, tracks provider - * positions in `contextMemory` through `eventBus`, and resyncs those - * positions after `wire` restoration. Each provider call receives the newest - * surviving injection of its own variant (`lastInjection`) and the typed - * disclosure recorded on it (`lastDisclosure`), so providers never read - * context layout or position indexes themselves. Turn-start providers run + * (present-tense state) through `loop` and `systemReminder`. Provider + * positions are never cached: each provider call derives them by scanning + * `contextMemory` for its own surviving injection messages, so splices, + * compaction folds, and `wire` restoration need no index bookkeeping. Each + * provider call receives the newest surviving injection of its own variant + * (`lastInjection`) and the typed disclosure recorded on it (`lastDisclosure`), + * so providers never read context layout or position indexes themselves. Turn-start providers run * synchronously after `turn.started` — before the turn's first step request * materializes its prompt — and must stay synchronous (a provider that throws * or returns a Promise is logged and skipped, so one bad provider cannot @@ -48,7 +49,6 @@ import { interface ContextInjectionEntry { readonly provider: ContextInjectionProvider; readonly name: string; - readonly positions: number[]; readonly boundary: 'step' | 'turn-start'; } @@ -87,15 +87,9 @@ export class AgentContextInjectorService extends Disposable implements IAgentCon this.injectAtTurnStart(); }), ); - this._register( - this.eventBus.subscribe('context.spliced', (e) => { - this.handleSplice(e); - }), - ); this._register( wire.hooks.onDidRestore.register('context-injector', async (_ctx, next) => { this.willInject.fire(); - this.resyncPositions(); await next(); }), ); @@ -128,11 +122,9 @@ export class AgentContextInjectorService extends Disposable implements IAgentCon provider: ContextInjectionProvider, boundary: ContextInjectionEntry['boundary'], ): IDisposable { - const positions = findInjections(this.context.get(), name); const entry: ContextInjectionEntry = { provider, name, - positions, boundary, }; this.entries.add(entry); @@ -186,7 +178,7 @@ export class AgentContextInjectorService extends Disposable implements IAgentCon entry: ContextInjectionEntry, isNewTurn: boolean, ): Parameters[0] { - const injectedPositions: readonly number[] = [...entry.positions]; + const injectedPositions = findInjections(this.context.get(), entry.name); const lastInjectedAt = injectedPositions.at(-1) ?? null; const lastInjection = lastInjectedAt === null ? undefined @@ -230,54 +222,8 @@ export class AgentContextInjectorService extends Disposable implements IAgentCon origin, }); } - - private resyncPositions(): void { - const history = this.context.get(); - for (const entry of this.entries) { - const found = findInjections(history, entry.name); - entry.positions.length = 0; - entry.positions.push(...found); - } - } - - private handleSplice(splice: ContextSplice): void { - let insertedInjections: Map | undefined; - splice.messages.forEach((message, offset) => { - if (message.origin?.kind !== 'injection') return; - insertedInjections ??= new Map(); - const positions = insertedInjections.get(message.origin.variant); - if (positions === undefined) { - insertedInjections.set(message.origin.variant, [splice.start + offset]); - } else { - positions.push(splice.start + offset); - } - }); - if (insertedInjections === undefined && splice.deleteCount === 0) return; - - const deletedEnd = splice.start + splice.deleteCount; - const delta = splice.messages.length - splice.deleteCount; - for (const entry of this.entries) { - const adopted = insertedInjections?.get(entry.name) ?? []; - const positions = entry.positions; - if (adopted.length === 0 && positions.length === 0) continue; - let lo = 0; - while (lo < positions.length && positions[lo]! < splice.start) lo++; - let hi = lo; - while (hi < positions.length && positions[hi]! < deletedEnd) hi++; - for (let index = hi; index < positions.length; index++) { - positions[index] = positions[index]! + delta; - } - positions.splice(lo, hi - lo, ...adopted); - } - } } -type ContextSplice = { - readonly start: number; - readonly deleteCount: number; - readonly messages: readonly ContextMessage[]; -}; - function findInjections( history: readonly ContextMessage[], variant: string, diff --git a/packages/agent-core-v2/src/agent/permissionMode/injection/permissionModeInjection.ts b/packages/agent-core-v2/src/agent/permissionMode/injection/permissionModeInjection.ts index b9a672a1b37..05f4d1a86f3 100644 --- a/packages/agent-core-v2/src/agent/permissionMode/injection/permissionModeInjection.ts +++ b/packages/agent-core-v2/src/agent/permissionMode/injection/permissionModeInjection.ts @@ -3,10 +3,10 @@ * * Owns the `permission_mode` context-injection provider. It reads the live mode * from `IAgentPermissionModeService` and registers reminders through - * `contextInjector`. Dedup is history-derived: the framework mirrors this - * variant's live positions across splices, so a reminder folded away by - * compaction (or undo) is re-announced on the next inject, matching v1's - * compaction behavior. The plain-data state (`lastMode`) is registered into + * `contextInjector`. Dedup is history-derived: the framework derives this + * variant's live positions from the surviving history, so a reminder folded + * away by compaction (or undo) is re-announced on the next inject, matching + * v1's compaction behavior. The plain-data state (`lastMode`) is registered into * `agentState` (`IAgentStateService`) and read/written through it. */ From 8a867478217db1d4b24bbdb223ed50b8710003bb Mon Sep 17 00:00:00 2001 From: 7Sageer <7sageer@djwcb.cn> Date: Thu, 6 Aug 2026 13:53:01 +0800 Subject: [PATCH 10/27] refactor(agent-core-v2): formalize injector once-channels and raw message results --- .../agent/contextInjector/contextInjector.ts | 23 +++- .../contextInjector/contextInjectorService.ts | 112 ++++++++++++++---- .../reminderQueue/reminderQueueService.ts | 5 +- .../contextInjector/contextInjector.test.ts | 72 +++++++++-- .../permissionMode/permissionMode.test.ts | 3 +- .../agent/reminderQueue/reminderQueue.test.ts | 12 +- 6 files changed, 183 insertions(+), 44 deletions(-) diff --git a/packages/agent-core-v2/src/agent/contextInjector/contextInjector.ts b/packages/agent-core-v2/src/agent/contextInjector/contextInjector.ts index c7b3443b56e..2ab9771aba3 100644 --- a/packages/agent-core-v2/src/agent/contextInjector/contextInjector.ts +++ b/packages/agent-core-v2/src/agent/contextInjector/contextInjector.ts @@ -1,7 +1,7 @@ import { createDecorator } from "#/_base/di/instantiation"; import type { IDisposable } from "#/_base/di/lifecycle"; -import type { Event } from "#/_base/event"; import type { ContentPart } from "#/kosong/contract/message"; +import type { Tool } from "#/kosong/contract/tool"; import type { ContextInjectionDisclosure, ContextMessage } from '#/agent/contextMemory/types'; export interface ContextInjectionContext { @@ -12,7 +12,16 @@ export interface ContextInjectionContext { readonly isNewTurn: boolean; } -export type ContextInjectionContent = string | readonly ContentPart[]; +export interface ContextInjectionMessage { + readonly role: 'user' | 'system'; + readonly content: readonly ContentPart[]; + readonly tools?: readonly Tool[]; +} + +export type ContextInjectionContent = + | string + | readonly ContentPart[] + | { readonly message: ContextInjectionMessage }; export interface ContextInjectionResult { readonly content: ContextInjectionContent; @@ -34,10 +43,12 @@ export type SyncContextInjectionProvider = ( export interface IAgentContextInjectorService { readonly _serviceBrand: undefined; - /** Fired synchronously at every injection boundary (turn start, step, - * compaction follow-up, wire restore) before any provider runs. This is - * where boundary participants such as the once-reminder queue drain. */ - readonly onWillInject: Event; + /** Registers a past-tense, exactly-once delivery channel (such as the + * `reminderQueue`) drained synchronously at every injection boundary + * (turn start, step, compaction follow-up, wire restore) before any + * provider runs. A drain that throws or returns a Promise is logged and + * skipped, so one bad channel cannot starve the rest. */ + registerOnceChannel(name: string, drain: () => void): IDisposable; register( name: string, diff --git a/packages/agent-core-v2/src/agent/contextInjector/contextInjectorService.ts b/packages/agent-core-v2/src/agent/contextInjector/contextInjectorService.ts index 77b6d4f7cc9..680bf865c0b 100644 --- a/packages/agent-core-v2/src/agent/contextInjector/contextInjectorService.ts +++ b/packages/agent-core-v2/src/agent/contextInjector/contextInjectorService.ts @@ -3,16 +3,23 @@ * * The unified boundary scheduler for every model-facing reminder. At each * injection boundary (turn start, step, compaction follow-up, wire restore) - * it first fires `onWillInject` — where boundary participants such as the - * `reminderQueue` drain their persisted once-reminders (past-tense events, - * exactly-once) — then reconciles the registered context providers - * (present-tense state) through `loop` and `systemReminder`. Provider + * it first drains the registered once-channels (`registerOnceChannel`) — + * past-tense, exactly-once deliveries such as the `reminderQueue`'s + * persisted once-reminders — then reconciles the registered context + * providers (present-tense state) through `loop` and `systemReminder`. A + * once-channel drain that throws or returns a Promise is logged and + * skipped, so one bad channel cannot starve the providers. Provider * positions are never cached: each provider call derives them by scanning * `contextMemory` for its own surviving injection messages, so splices, * compaction folds, and `wire` restoration need no index bookkeeping. Each * provider call receives the newest surviving injection of its own variant * (`lastInjection`) and the typed disclosure recorded on it (`lastDisclosure`), - * so providers never read context layout or position indexes themselves. Turn-start providers run + * so providers never read context layout or position indexes themselves. + * Provider results route by shape: strings become `` + * messages through `systemReminder`, content parts append as bare user + * messages, and tagged raw messages (`{ message }`) append verbatim with the + * injection origin stamped — the form for structured payloads such as the + * dynamic-tool schema declaration's `tools` field. Turn-start providers run * synchronously after `turn.started` — before the turn's first step request * materializes its prompt — and must stay synchronous (a provider that throws * or returns a Promise is logged and skipped, so one bad provider cannot @@ -21,13 +28,12 @@ * example, a queued turn cancelled while another turn is already running). * The plain-data `isNewTurn` flag is registered * into `agentState` (`IAgentStateService`) and read/written through it; - * `entries` stays a plain instance field (its values hold provider functions, - * not plain data). Bound at Agent scope. + * `entries` and `onceChannels` stay plain instance fields (their values + * hold functions, not plain data). Bound at Agent scope. */ import { Disposable, toDisposable, type IDisposable } from "#/_base/di/lifecycle"; import { LifecycleScope, ScopeActivation, registerScopedService } from '#/_base/di/scope'; -import { Emitter, type Event } from '#/_base/event'; import { ILogService } from '#/_base/log/log'; import { defineState } from '#/_base/state/stateRegistry'; @@ -41,6 +47,7 @@ import { IWireService } from '#/wire/wire'; import { IAgentContextInjectorService, type ContextInjectionContent, + type ContextInjectionMessage, type ContextInjectionProvider, type ContextInjectionResult, type SyncContextInjectionProvider, @@ -52,6 +59,11 @@ interface ContextInjectionEntry { readonly boundary: 'step' | 'turn-start'; } +interface OnceChannelEntry { + readonly name: string; + readonly drain: () => unknown; +} + export const contextInjectorIsNewTurnKey = defineState( 'contextInjector.isNewTurn', () => true, @@ -60,8 +72,7 @@ export const contextInjectorIsNewTurnKey = defineState( export class AgentContextInjectorService extends Disposable implements IAgentContextInjectorService { declare readonly _serviceBrand: undefined; private readonly entries = new Set(); - private readonly willInject = this._register(new Emitter()); - readonly onWillInject: Event = this.willInject.event; + private readonly onceChannels = new Set(); constructor( @IAgentContextMemoryService private readonly context: IAgentContextMemoryService, @@ -82,14 +93,14 @@ export class AgentContextInjectorService extends Disposable implements IAgentCon ); this._register( this.eventBus.subscribe('turn.started', () => { - this.willInject.fire(); + this.runOnceChannels(); this.isNewTurn = true; this.injectAtTurnStart(); }), ); this._register( wire.hooks.onDidRestore.register('context-injector', async (_ctx, next) => { - this.willInject.fire(); + this.runOnceChannels(); await next(); }), ); @@ -117,6 +128,29 @@ export class AgentContextInjectorService extends Disposable implements IAgentCon return this.registerProvider(name, provider, 'turn-start'); } + registerOnceChannel(name: string, drain: () => void): IDisposable { + const entry: OnceChannelEntry = { name, drain }; + this.onceChannels.add(entry); + return toDisposable(() => { + this.onceChannels.delete(entry); + }); + } + + private runOnceChannels(): void { + for (const entry of this.onceChannels) { + try { + const result: unknown = entry.drain(); + if (isThenable(result)) { + this.log.error('once-channel drain returned a Promise; skipping it', { + name: entry.name, + }); + } + } catch (error) { + this.log.error('once-channel drain failed; skipping it', { name: entry.name, error }); + } + } + } + private registerProvider( name: string, provider: ContextInjectionProvider, @@ -139,7 +173,7 @@ export class AgentContextInjectorService extends Disposable implements IAgentCon } private async inject(boundary?: ContextInjectionEntry['boundary']): Promise { - this.willInject.fire(); + this.runOnceChannels(); const isNewTurn = this.isNewTurn; this.isNewTurn = false; for (const entry of this.entries) { @@ -200,30 +234,64 @@ export class AgentContextInjectorService extends Disposable implements IAgentCon content: ContextInjectionContent | ContextInjectionResult | undefined, ): void { if (content === undefined) return; - const result: ContextInjectionResult = - typeof content === 'object' && content !== null && !Array.isArray(content) - ? (content as ContextInjectionResult) - : { content: content as ContextInjectionContent }; + const result: ContextInjectionResult = isInjectionResult(content) + ? content + : { content }; const origin = { kind: 'injection' as const, variant: entry.name, disclosure: result.disclosure, }; - if (typeof result.content === 'string') { - if (result.content.trim().length === 0) return; - this.reminders.appendSystemReminder(result.content, origin); + const resolved = result.content; + if (typeof resolved === 'string') { + if (resolved.trim().length === 0) return; + this.reminders.appendSystemReminder(resolved, origin); + return; + } + if (isRawInjectionMessage(resolved)) { + const message = resolved.message; + if ( + message.content.length === 0 && + (message.tools === undefined || message.tools.length === 0) + ) { + return; + } + this.context.append({ + role: message.role, + content: [...message.content], + toolCalls: [], + tools: message.tools, + origin, + }); return; } - if (result.content.length === 0) return; + if (resolved.length === 0) return; this.context.append({ role: 'user', - content: [...result.content], + content: [...resolved], toolCalls: [], origin, }); } } +function isRawInjectionMessage( + content: Exclude, +): content is { readonly message: ContextInjectionMessage } { + return !Array.isArray(content); +} + +function isInjectionResult( + content: ContextInjectionContent | ContextInjectionResult, +): content is ContextInjectionResult { + return ( + typeof content === 'object' && + content !== null && + !Array.isArray(content) && + 'content' in content + ); +} + function findInjections( history: readonly ContextMessage[], variant: string, diff --git a/packages/agent-core-v2/src/agent/reminderQueue/reminderQueueService.ts b/packages/agent-core-v2/src/agent/reminderQueue/reminderQueueService.ts index 263c0e48abe..6045faae95e 100644 --- a/packages/agent-core-v2/src/agent/reminderQueue/reminderQueueService.ts +++ b/packages/agent-core-v2/src/agent/reminderQueue/reminderQueueService.ts @@ -3,7 +3,8 @@ * * Persists once-reminder entries through its own wire Model, delivers them * through `systemReminder` (the unified write head), and schedules itself: - * it drains on every `contextInjector` injection boundary (`onWillInject`). + * it drains on every `contextInjector` injection boundary + * (`registerOnceChannel`). * Crash-window dedup reads the conversation tail through `contextMemory` and * matches the entry id recorded as the delivered reminder's disclosure, so a * reminder appended but not yet marked delivered is never re-appended after a @@ -40,7 +41,7 @@ export class AgentReminderQueueService extends Disposable implements IAgentRemin @IAgentContextInjectorService injector: IAgentContextInjectorService, ) { super(); - this._register(injector.onWillInject(() => { + this._register(injector.registerOnceChannel('reminder-queue', () => { this.drain(); })); } diff --git a/packages/agent-core-v2/test/agent/contextInjector/contextInjector.test.ts b/packages/agent-core-v2/test/agent/contextInjector/contextInjector.test.ts index ffad5a4721d..923820932cf 100644 --- a/packages/agent-core-v2/test/agent/contextInjector/contextInjector.test.ts +++ b/packages/agent-core-v2/test/agent/contextInjector/contextInjector.test.ts @@ -335,13 +335,29 @@ describe('AgentContextInjectorService', () => { ]); }); - it('fires onWillInject before running the providers', async () => { + it('drains once-channels before running the providers', async () => { const calls: string[] = []; - disposables.add( - injector(ix).onWillInject(() => { - calls.push('willInject'); - }), - ); + injector(ix).registerOnceChannel('test_channel', () => { + calls.push('once-channel'); + }); + injector(ix).register('ordering_test', () => { + calls.push('provider'); + return undefined; + }); + + await injector(ix).inject(); + + expect(calls).toEqual(['once-channel', 'provider']); + }); + + it('skips a throwing once-channel drain and still runs the rest', async () => { + const calls: string[] = []; + injector(ix).registerOnceChannel('throwing_channel', () => { + throw new Error('boom'); + }); + injector(ix).registerOnceChannel('surviving_channel', () => { + calls.push('surviving'); + }); injector(ix).register('ordering_test', () => { calls.push('provider'); return undefined; @@ -349,7 +365,49 @@ describe('AgentContextInjectorService', () => { await injector(ix).inject(); - expect(calls).toEqual(['willInject', 'provider']); + expect(calls).toEqual(['surviving', 'provider']); + }); + + it('appends tagged raw messages verbatim with the injection origin stamped', async () => { + injector(ix).register('schema_test', () => ({ + message: { + role: 'system', + content: [], + tools: [{ name: 'TestTool', description: 'test tool', parameters: { type: 'object' } }], + }, + })); + + await runInjectionStep(); + + const message = context.get().at(-1); + expect(message?.role).toBe('system'); + expect(message?.tools).toEqual([ + { name: 'TestTool', description: 'test tool', parameters: { type: 'object' } }, + ]); + expect(message?.origin).toEqual({ kind: 'injection', variant: 'schema_test' }); + }); + + it('stamps the disclosure on tagged raw messages returned through the result wrapper', async () => { + injector(ix).register('schema_test', () => ({ + content: { message: { role: 'user', content: [{ type: 'text', text: 'raw' }] } }, + disclosure: { kind: 'once_reminder', id: 'r1' }, + })); + + await runInjectionStep(); + + expect(context.get().at(-1)?.origin).toEqual({ + kind: 'injection', + variant: 'schema_test', + disclosure: { kind: 'once_reminder', id: 'r1' }, + }); + }); + + it('skips tagged raw messages with neither content nor tools', async () => { + injector(ix).register('empty_raw_test', () => ({ message: { role: 'system', content: [] } })); + + await runInjectionStep(); + + expect(context.get()).toHaveLength(0); }); it('runs synchronous turn-start providers before the next prompt materializes', () => { diff --git a/packages/agent-core-v2/test/agent/permissionMode/permissionMode.test.ts b/packages/agent-core-v2/test/agent/permissionMode/permissionMode.test.ts index 271348e8df1..572c4f7c9fb 100644 --- a/packages/agent-core-v2/test/agent/permissionMode/permissionMode.test.ts +++ b/packages/agent-core-v2/test/agent/permissionMode/permissionMode.test.ts @@ -3,7 +3,6 @@ import { afterEach, beforeEach, describe, expect, it } from 'vitest'; import { SyncDescriptor } from '#/_base/di/descriptors'; import { DisposableStore } from '#/_base/di/lifecycle'; import { TestInstantiationService } from '#/_base/di/test'; -import { Emitter } from '#/_base/event'; import { IAgentContextInjectorService, type ContextInjectionProvider, @@ -36,7 +35,7 @@ let registeredInjection: const injectorStub: IAgentContextInjectorService = { _serviceBrand: undefined, - onWillInject: new Emitter().event, + registerOnceChannel: () => ({ dispose: () => {} }), register: (name, provider) => { registeredInjection = { name, provider }; return { diff --git a/packages/agent-core-v2/test/agent/reminderQueue/reminderQueue.test.ts b/packages/agent-core-v2/test/agent/reminderQueue/reminderQueue.test.ts index cde99a8f583..499795256c2 100644 --- a/packages/agent-core-v2/test/agent/reminderQueue/reminderQueue.test.ts +++ b/packages/agent-core-v2/test/agent/reminderQueue/reminderQueue.test.ts @@ -11,7 +11,6 @@ import { afterEach, beforeEach, describe, expect, it } from 'vitest'; import { DisposableStore } from '#/_base/di/lifecycle'; import { createServices, type TestInstantiationService } from '#/_base/di/test'; -import { Emitter } from '#/_base/event'; import { IAgentContextInjectorService } from '#/agent/contextInjector/contextInjector'; import { IAgentContextMemoryService } from '#/agent/contextMemory/contextMemory'; import { CHECKPOINTED_MODELS } from '#/agent/contextMemory/conversationTime'; @@ -36,20 +35,23 @@ describe('AgentReminderQueueService', () => { let context: IAgentContextMemoryService; let queue: IAgentReminderQueueService; let reminders: IAgentSystemReminderService; - let willInject: Emitter; + let onceDrains: Array<() => void>; beforeEach(() => { disposables = new DisposableStore(); records = []; log = recordingWireLog(records); - willInject = disposables.add(new Emitter()); + onceDrains = []; ix = createServices(disposables, { base: [registerContextMemoryServices], strict: true, additionalServices: (reg) => { reg.defineInstance(IAgentContextInjectorService, { _serviceBrand: undefined, - onWillInject: willInject.event, + registerOnceChannel: (_name: string, drain: () => void) => { + onceDrains.push(drain); + return { dispose() {} }; + }, register: () => ({ dispose() {} }), registerAtTurnStart: () => ({ dispose() {} }), injectAfterCompaction: async () => {}, @@ -255,7 +257,7 @@ describe('AgentReminderQueueService', () => { it('drains itself on every injection boundary', () => { queue.enqueue({ variant: 'a', content: 'first' }); - willInject.fire(); + for (const drain of onceDrains) drain(); expect(injectionMessages()).toHaveLength(1); expect(wire.getModel(ReminderQueueModel)).toEqual([]); From 54e1091ef5b998f5ffe5de38e270c066706c2002 Mon Sep 17 00:00:00 2001 From: 7Sageer <7sageer@djwcb.cn> Date: Thu, 6 Aug 2026 14:39:50 +0800 Subject: [PATCH 11/27] refactor(agent-core-v2): declare dynamic tool schemas at injection boundaries Move the dynamic-tool schema declaration out of toolSelect.load(): the loaded names are recorded as pending and drained by a dedicated toolSelectSchemas provider through the contextInjector boundary scheduler, so the declaration message lands at a quiescent boundary instead of mid-step inside a streaming tool exchange. The folded history remains the loaded-tool ledger, so undo, compaction, and resume still self-heal by re-folding. --- .../src/agent/toolSelect/toolSelect.ts | 7 +- .../src/agent/toolSelect/toolSelectSchemas.ts | 15 +++++ .../toolSelect/toolSelectSchemasService.ts | 46 +++++++++++++ .../src/agent/toolSelect/toolSelectService.ts | 38 +++++------ packages/agent-core-v2/src/index.ts | 2 + .../agent/toolSelect/toolSelect.e2e.test.ts | 2 + .../toolSelect/toolSelectService.test.ts | 64 ++++++++++++------- 7 files changed, 130 insertions(+), 44 deletions(-) create mode 100644 packages/agent-core-v2/src/agent/toolSelect/toolSelectSchemas.ts create mode 100644 packages/agent-core-v2/src/agent/toolSelect/toolSelectSchemasService.ts diff --git a/packages/agent-core-v2/src/agent/toolSelect/toolSelect.ts b/packages/agent-core-v2/src/agent/toolSelect/toolSelect.ts index e3ce761aba9..b58f3d601f4 100644 --- a/packages/agent-core-v2/src/agent/toolSelect/toolSelect.ts +++ b/packages/agent-core-v2/src/agent/toolSelect/toolSelect.ts @@ -2,12 +2,13 @@ * `toolSelect` domain — progressive tool disclosure contract. * * Defines the Agent-scope service that shapes provider-visible tool/history - * views, loads selected dynamic schemas, and reports loadable-tool - * announcements. + * views, records selected dynamic schemas as pending declarations, and + * reports loadable-tool announcements. */ import { createDecorator, type ServiceIdentifier } from '#/_base/di/instantiation'; import type { ContextMessage } from '#/agent/contextMemory/types'; +import type { Tool } from '#/kosong/contract/tool'; import type { ToolInfo } from '#/tool/toolContract'; export const SELECT_TOOLS_TOOL_NAME = 'select_tools'; @@ -33,6 +34,8 @@ export interface IAgentToolSelectService { load(names: readonly string[]): LoadToolsResult; + drainPendingToolSchemas(): readonly Tool[] | undefined; + loadableToolsAnnouncement(): string | undefined; } diff --git a/packages/agent-core-v2/src/agent/toolSelect/toolSelectSchemas.ts b/packages/agent-core-v2/src/agent/toolSelect/toolSelectSchemas.ts new file mode 100644 index 00000000000..2cbaf96d0b9 --- /dev/null +++ b/packages/agent-core-v2/src/agent/toolSelect/toolSelectSchemas.ts @@ -0,0 +1,15 @@ +/** + * `toolSelect` domain — `IAgentToolSelectSchemasService` contract. + * + * Defines the Agent-scope marker service that declares pending dynamic-tool + * schemas into the history through the `contextInjector` boundary scheduler. + */ + +import { createDecorator, type ServiceIdentifier } from '#/_base/di/instantiation'; + +export interface IAgentToolSelectSchemasService { + readonly _serviceBrand: undefined; +} + +export const IAgentToolSelectSchemasService: ServiceIdentifier = + createDecorator('agentToolSelectSchemasService'); diff --git a/packages/agent-core-v2/src/agent/toolSelect/toolSelectSchemasService.ts b/packages/agent-core-v2/src/agent/toolSelect/toolSelectSchemasService.ts new file mode 100644 index 00000000000..0d6f9890aae --- /dev/null +++ b/packages/agent-core-v2/src/agent/toolSelect/toolSelectSchemasService.ts @@ -0,0 +1,46 @@ +/** + * `toolSelect` domain — `IAgentToolSelectSchemasService` implementation. + * + * Registers dynamic-tool schema declarations as a `contextInjector` + * provider (variant `dynamic_tool_schema`): every injection boundary drains + * the tools `toolSelect` marked loaded since the last declaration and + * appends them as a `role: 'system'` raw injection message whose `tools` + * field carries the full definitions, so the declaration lands at a + * quiescent boundary instead of mid-step inside a streaming tool exchange. + * The folded history itself remains the loaded-tool ledger, so undo, + * compaction, and resume self-heal by re-folding. Bound at Agent scope. + */ + +import { Disposable } from '#/_base/di/lifecycle'; +import { LifecycleScope, ScopeActivation, registerScopedService } from '#/_base/di/scope'; +import { IAgentContextInjectorService } from '#/agent/contextInjector/contextInjector'; + +import { DYNAMIC_TOOL_SCHEMA_VARIANT } from './dynamicTools'; +import { IAgentToolSelectService } from './toolSelect'; +import { IAgentToolSelectSchemasService } from './toolSelectSchemas'; + +export class AgentToolSelectSchemasService extends Disposable implements IAgentToolSelectSchemasService { + declare readonly _serviceBrand: undefined; + + constructor( + @IAgentToolSelectService toolSelect: IAgentToolSelectService, + @IAgentContextInjectorService dynamicInjector: IAgentContextInjectorService, + ) { + super(); + this._register( + dynamicInjector.register(DYNAMIC_TOOL_SCHEMA_VARIANT, () => { + const tools = toolSelect.drainPendingToolSchemas(); + if (tools === undefined) return undefined; + return { message: { role: 'system', content: [], tools } }; + }), + ); + } +} + +registerScopedService( + LifecycleScope.Agent, + IAgentToolSelectSchemasService, + AgentToolSelectSchemasService, + ScopeActivation.OnScopeCreated, + 'toolSelect', +); diff --git a/packages/agent-core-v2/src/agent/toolSelect/toolSelectService.ts b/packages/agent-core-v2/src/agent/toolSelect/toolSelectService.ts index e2cae477eed..e52d5f34ab0 100644 --- a/packages/agent-core-v2/src/agent/toolSelect/toolSelectService.ts +++ b/packages/agent-core-v2/src/agent/toolSelect/toolSelectService.ts @@ -2,13 +2,15 @@ * `toolSelect` domain — `IAgentToolSelectService` implementation. * * Shapes the provider-visible tool and history views for progressive tool - * disclosure, loads dynamic schemas into `contextMemory`, and exposes - * loadable-tools announcement text. Reads live tools from `toolRegistry`, - * active-tool and capability state from `profile`, gates through `flag`, - * hooks into `toolExecutor`, and listens to context lifecycle events through - * `event`. The mutable load-tracking state (`pendingLoaded`) is registered - * into `agentState` (`IAgentStateService`) and read/written through it. Bound - * at Agent scope. + * disclosure, tracks loaded dynamic schemas as pending declarations drained + * by the `contextInjector` boundary provider (the declaration lands at a + * quiescent boundary instead of mid-step inside a streaming tool exchange), + * and exposes loadable-tools announcement text. Reads live tools from + * `toolRegistry`, active-tool and capability state from `profile`, gates + * through `flag`, hooks into `toolExecutor`, and listens to context + * lifecycle events through `event`. The mutable load-tracking state + * (`pendingLoaded`) is registered into `agentState` (`IAgentStateService`) + * and read/written through it. Bound at Agent scope. */ import { Disposable } from '#/_base/di/lifecycle'; @@ -28,7 +30,6 @@ import { IAgentToolRegistryService } from '#/agent/toolRegistry/toolRegistry'; import { collectLoadedDynamicToolNames, - DYNAMIC_TOOL_SCHEMA_VARIANT, foldAnnouncedToolNames, renderLoadableToolsAnnouncement, stripDynamicToolContext, @@ -143,22 +144,21 @@ export class AgentToolSelectService extends Disposable implements IAgentToolSele } } if (toLoad.length > 0) { - toLoad.sort((a, b) => a.localeCompare(b)); - const tools = toLoad - .map((name) => this.schemaOf(name)) - .filter((tool): tool is Tool => tool !== undefined); - this.context.append({ - role: 'system', - content: [], - toolCalls: [], - tools, - origin: { kind: 'injection', variant: DYNAMIC_TOOL_SCHEMA_VARIANT }, - }); for (const name of toLoad) this.pendingLoaded.add(name); } return { toLoad, alreadyAvailable, unknown }; } + drainPendingToolSchemas(): readonly Tool[] | undefined { + if (!this.enabled() || this.pendingLoaded.size === 0) return undefined; + const names = [...this.pendingLoaded].toSorted((a, b) => a.localeCompare(b)); + for (const name of names) this.pendingLoaded.delete(name); + const tools = names + .map((name) => this.schemaOf(name)) + .filter((tool): tool is Tool => tool !== undefined); + return tools.length === 0 ? undefined : tools; + } + loadableToolsAnnouncement(): string | undefined { if (!this.enabled()) return undefined; const loadable = this.loadableToolNames(); diff --git a/packages/agent-core-v2/src/index.ts b/packages/agent-core-v2/src/index.ts index 733e1d376f8..e59b03e958b 100644 --- a/packages/agent-core-v2/src/index.ts +++ b/packages/agent-core-v2/src/index.ts @@ -288,6 +288,8 @@ export * from '#/agent/toolSelect/toolSelect'; export * from '#/agent/toolSelect/toolSelectService'; export * from '#/agent/toolSelect/toolSelectAnnouncements'; export * from '#/agent/toolSelect/toolSelectAnnouncementsService'; +export * from '#/agent/toolSelect/toolSelectSchemas'; +export * from '#/agent/toolSelect/toolSelectSchemasService'; import '#/agent/toolPolicy/configSection'; export * from '#/agent/toolPolicy/configSection'; export * from '#/agent/toolPolicy/evaluate'; diff --git a/packages/agent-core-v2/test/agent/toolSelect/toolSelect.e2e.test.ts b/packages/agent-core-v2/test/agent/toolSelect/toolSelect.e2e.test.ts index 69f151edb0d..11363362b7b 100644 --- a/packages/agent-core-v2/test/agent/toolSelect/toolSelect.e2e.test.ts +++ b/packages/agent-core-v2/test/agent/toolSelect/toolSelect.e2e.test.ts @@ -29,6 +29,7 @@ import { IAgentToolRegistryService } from '#/agent/toolRegistry/toolRegistry'; import { TOOL_SELECT_FLAG_ENV } from '#/agent/toolSelect/flag'; import { IAgentToolSelectService } from '#/agent/toolSelect/toolSelect'; import { IAgentToolSelectAnnouncementsService } from '#/agent/toolSelect/toolSelectAnnouncements'; +import { IAgentToolSelectSchemasService } from '#/agent/toolSelect/toolSelectSchemas'; import { IAgentUserToolService } from '#/agent/userTool/userTool'; import '#/agent/tools/select-tools/selectToolsTool'; @@ -113,6 +114,7 @@ describe('progressive tool disclosure end-to-end', () => { ctx = createTestAgent(); ctx.get(IAgentToolSelectService); ctx.get(IAgentToolSelectAnnouncementsService); + ctx.get(IAgentToolSelectSchemasService); ctx.get(IAgentToolExecutorService); ctx.configure({ modelCapabilities: DISCLOSURE_CAPABILITIES }); await ctx.rpc.setPermission({ mode: 'yolo' }); diff --git a/packages/agent-core-v2/test/agent/toolSelect/toolSelectService.test.ts b/packages/agent-core-v2/test/agent/toolSelect/toolSelectService.test.ts index 46b037c7bef..b91822af96d 100644 --- a/packages/agent-core-v2/test/agent/toolSelect/toolSelectService.test.ts +++ b/packages/agent-core-v2/test/agent/toolSelect/toolSelectService.test.ts @@ -53,6 +53,8 @@ import { TOOL_SELECT_FLAG_ID } from '#/agent/toolSelect/flag'; import { IAgentToolSelectService, SELECT_TOOLS_TOOL_NAME } from '#/agent/toolSelect/toolSelect'; import { IAgentToolSelectAnnouncementsService } from '#/agent/toolSelect/toolSelectAnnouncements'; import { AgentToolSelectAnnouncementsService } from '#/agent/toolSelect/toolSelectAnnouncementsService'; +import { IAgentToolSelectSchemasService } from '#/agent/toolSelect/toolSelectSchemas'; +import { AgentToolSelectSchemasService } from '#/agent/toolSelect/toolSelectSchemasService'; import { AgentToolSelectService } from '#/agent/toolSelect/toolSelectService'; import { SelectToolsTool } from '#/agent/tools/select-tools/selectToolsTool'; import { ITelemetryService } from '#/app/telemetry/telemetry'; @@ -327,12 +329,14 @@ function registerSharedServices( reg.define(IAgentToolRegistryService, AgentToolRegistryService); reg.define(IAgentToolSelectService, AgentToolSelectService); reg.define(IAgentToolSelectAnnouncementsService, AgentToolSelectAnnouncementsService); + reg.define(IAgentToolSelectSchemasService, AgentToolSelectSchemasService); reg.define(IAgentSystemReminderService, AgentSystemReminderService); registerLogServices(reg); } function mountAnnouncements(ix: TestInstantiationService): void { ix.get(IAgentToolSelectAnnouncementsService); + ix.get(IAgentToolSelectSchemasService); } function createHarness(): Harness { @@ -438,6 +442,23 @@ async function announceAfterCompaction(h: Harness): Promise return announcementText(announcement); } +async function declareSchemas(h: Harness, step = 1): Promise { + const before = h.contextMemory.appended.length; + await h.loop.hooks.onWillBeginStep.run({ + turnId: 1, + step, + signal: new AbortController().signal, + }); + const fresh = h.contextMemory.appended.splice(before); + const declared = fresh.find( + (message) => + message.origin?.kind === 'injection' && + message.origin.variant === DYNAMIC_TOOL_SCHEMA_VARIANT, + ); + if (declared !== undefined) h.contextMemory.history.push(declared); + return declared; +} + async function execute( h: ExecutorHarness, call: ToolCall, @@ -695,7 +716,7 @@ describe('AgentToolSelectService.load', () => { flagEnabled = true; }); - it('settles per name: toLoad, alreadyAvailable, unknown', () => { + it('settles per name: toLoad, alreadyAvailable, unknown', async () => { const h = createHarness(); registerMcp(h, new StubMcpTool(MCP_ALPHA)); registerMcp(h, new StubMcpTool(MCP_BETA)); @@ -706,14 +727,14 @@ describe('AgentToolSelectService.load', () => { expect(result.alreadyAvailable).toEqual([MCP_ALPHA]); expect(result.unknown).toEqual([MCP_GONE]); - expect(h.contextMemory.appended).toHaveLength(1); - const appended = h.contextMemory.appended[0]!; - expect(appended.role).toBe('system'); - expect(appended.tools?.map((tool) => tool.name)).toEqual([MCP_BETA]); - expect(appended.origin).toEqual({ kind: 'injection', variant: DYNAMIC_TOOL_SCHEMA_VARIANT }); + expect(h.contextMemory.appended).toHaveLength(0); + const declared = await declareSchemas(h); + expect(declared?.role).toBe('system'); + expect(declared?.tools?.map((tool) => tool.name)).toEqual([MCP_BETA]); + expect(declared?.origin).toEqual({ kind: 'injection', variant: DYNAMIC_TOOL_SCHEMA_VARIANT }); }); - it('loads the schema of an opted-in user tool', () => { + it('loads the schema of an opted-in user tool', async () => { const h = createHarness(); registerUser(h, new EchoTool(USER_DEFERRED), 'deferred'); @@ -722,24 +743,21 @@ describe('AgentToolSelectService.load', () => { alreadyAvailable: [], unknown: [], }); - expect(h.contextMemory.appended[0]?.tools?.map((tool) => tool.name)).toEqual([ - USER_DEFERRED, - ]); + const declared = await declareSchemas(h); + expect(declared?.tools?.map((tool) => tool.name)).toEqual([USER_DEFERRED]); }); - it('sorts the injected schemas by name', () => { + it('sorts the declared schemas by name', async () => { const h = createHarness(); registerMcp(h, new StubMcpTool(MCP_BETA)); registerMcp(h, new StubMcpTool(MCP_ALPHA)); h.sut.load([MCP_BETA, MCP_ALPHA]); - expect(h.contextMemory.appended[0]!.tools?.map((tool) => tool.name)).toEqual([ - MCP_ALPHA, - MCP_BETA, - ]); + const declared = await declareSchemas(h); + expect(declared?.tools?.map((tool) => tool.name)).toEqual([MCP_ALPHA, MCP_BETA]); }); - it('reports names filtered out by the profile as unknown', () => { + it('reports names filtered out by the profile as unknown', async () => { const h = createHarness(); registerMcp(h, new StubMcpTool(MCP_ALPHA)); registerMcp(h, new StubMcpTool(MCP_BETA)); @@ -748,9 +766,11 @@ describe('AgentToolSelectService.load', () => { const result = h.sut.load([MCP_ALPHA, MCP_BETA]); expect(result.toLoad).toEqual([MCP_ALPHA]); expect(result.unknown).toEqual([MCP_BETA]); + const declared = await declareSchemas(h); + expect(declared?.tools?.map((tool) => tool.name)).toEqual([MCP_ALPHA]); }); - it('pending ledger leads the history inside the defer window', () => { + it('pending ledger leads the history inside the defer window', async () => { const h = createHarness(); registerMcp(h, new StubMcpTool(MCP_ALPHA)); @@ -760,7 +780,7 @@ describe('AgentToolSelectService.load', () => { expect(reselect.alreadyAvailable).toEqual([MCP_ALPHA]); expect(reselect.toLoad).toEqual([]); - h.contextMemory.landAppended(); + await declareSchemas(h); const afterLanding = h.sut.load([MCP_ALPHA]); expect(afterLanding.alreadyAvailable).toEqual([MCP_ALPHA]); }); @@ -770,7 +790,6 @@ describe('AgentToolSelectService.load', () => { registerMcp(h, new StubMcpTool(MCP_ALPHA)); h.sut.load([MCP_ALPHA]); - h.contextMemory.appended.length = 0; h.eventBus.emit('compaction.completed'); expect(h.sut.load([MCP_ALPHA]).toLoad).toEqual([MCP_ALPHA]); }); @@ -780,20 +799,19 @@ describe('AgentToolSelectService.load', () => { registerMcp(h, new StubMcpTool(MCP_ALPHA)); h.sut.load([MCP_ALPHA]); - h.contextMemory.appended.length = 0; h.eventBus.emit('context.spliced', { start: 0, deleteCount: 2, messages: [] }); expect(h.sut.load([MCP_ALPHA]).toLoad).toEqual([MCP_ALPHA]); }); - it('reconciles the pending ledger with history when a mid-history splice removes schema messages', () => { + it('reconciles the pending ledger with history when a mid-history splice removes schema messages', async () => { const h = createHarness(); registerMcp(h, new StubMcpTool(MCP_ALPHA)); registerMcp(h, new StubMcpTool(MCP_BETA)); h.sut.load([MCP_ALPHA]); - h.contextMemory.landAppended(); + await declareSchemas(h); h.sut.load([MCP_BETA]); - h.contextMemory.landAppended(); + await declareSchemas(h, 2); expect(h.sut.load([MCP_ALPHA]).alreadyAvailable).toEqual([MCP_ALPHA]); expect(h.sut.load([MCP_BETA]).alreadyAvailable).toEqual([MCP_BETA]); From 9cb307603cb78d1a1da74a5edac1e8da330bcfbe Mon Sep 17 00:00:00 2001 From: 7Sageer <7sageer@djwcb.cn> Date: Thu, 6 Aug 2026 14:39:57 +0800 Subject: [PATCH 12/27] refactor(agent-core-v2): deliver AGENTS.md reminders through the reminder queue The tool hook now only observes and enqueues a once-per-agent reminder through the reminderQueue once-channel instead of prepending text to the tool result: results stay verbatim for the truncation pipeline and the reminder can never be truncated away with an oversized output. The reminderQueue is resolved lazily through the instantiation service at enqueue time, breaking the contextInjector -> loop -> llmRequester -> profile -> agentsMdReminder constructor cycle. --- .../agentsMdReminderService.ts | 106 ++++---- .../agentsMdReminder/agentsMdReminder.test.ts | 250 ++++++++++-------- 2 files changed, 184 insertions(+), 172 deletions(-) diff --git a/packages/agent-core-v2/src/agent/agentsMdReminder/agentsMdReminderService.ts b/packages/agent-core-v2/src/agent/agentsMdReminder/agentsMdReminderService.ts index 76d2a59d055..d8808fd757a 100644 --- a/packages/agent-core-v2/src/agent/agentsMdReminder/agentsMdReminderService.ts +++ b/packages/agent-core-v2/src/agent/agentsMdReminder/agentsMdReminderService.ts @@ -4,37 +4,35 @@ * * Self-wiring plugin: registers an `onDidExecuteTool` hook on `toolExecutor` * that probes the directories a tool call touches for AGENTS.md files the - * system prompt did not inject, and prepends a once-per-agent - * `` to the result suggesting the model read them (head - * insertion on purpose: oversized results are truncated to a short head - * preview later in the execution pipeline, and a tail reminder would be - * silently dropped after the file was already counted as reminded). - * `Read`/`Edit`/`Write` consume the canonical file access declared by their - * resolved execution (a successful touch landing on an AGENTS.md itself marks - * just that file known), `Glob`/`Grep` consume their canonical search root, - * and `Bash` contributes its explicit `cwd` plus the literal directory - * operands extracted from the command's syntax tree (see `./bashTargets`), - * resolved against the frozen + * system prompt did not inject, and enqueues a once-per-agent reminder + * through the `reminderQueue` once-channel, delivered as a standalone + * `` at the next injection boundary. The hook only + * observes — it never rewrites the tool result, so results stay verbatim + * for the truncation pipeline and the reminder can never be truncated away + * with an oversized output. `Read`/`Edit`/`Write` consume the canonical + * file access declared by their resolved execution (a successful touch + * landing on an AGENTS.md itself marks just that file known), + * `Glob`/`Grep` consume their canonical search root, and `Bash` + * contributes its explicit `cwd` plus the literal directory operands + * extracted from the command's syntax tree (see `./bashTargets`), resolved + * against the frozen * `sessionContext.cwd` exactly like the Bash tool itself (`args.cwd ?? * sessionContext.cwd` — a base that deliberately differs from the live agent * cwd after a chdir). Only calls whose `ToolDidExecuteContext.outcome` is * `executed` are probed: preflight rejects, resolution failures, aborts, * permission vetoes, and synthetic/duplicate results have not touched the - * requested resource and are left unchanged. The hook is ordered before - * `toolDedupe` so an executed original carries the reminder into the - * deferred result returned for a duplicate; no dedupe implementation state is - * needed here. The ordered registration throws when its target is absent, so - * scopes without `toolDedupe` fall back to plain append-order registration, - * which still lands ahead of a `toolDedupe` hook constructed later. + * requested resource and are ignored. * * Known-set discipline: candidates are claimed synchronously per discovered * file into an in-memory `claimed` set (parallel calls can never duplicate a * reminder and a failed attempt releases the claim), while `agentState` * (`agentsMdReminder.known`) is only ever whole-value replaced after the - * reminder text is attached and the telemetry emitted — never mutated in - * place, and never ahead of the reminder it records. Probing anchors at the - * nearest existing ancestor (so `Write` into a not-yet-created directory - * still resolves), walks `findProjectRoot → touched dir`, skips chain + * telemetry emitted and the reminder enqueued — never mutated in place, and + * never ahead of the reminder it records. The telemetry event fires before + * the enqueue so a telemetry failure simply retries the reminder on the next + * qualifying touch. Probing anchors at the nearest existing ancestor (so + * `Write` into a not-yet-created directory still resolves), walks + * `findProjectRoot → touched dir`, skips chain * directories whose candidates are all known, and applies the same * per-directory candidate rules as the init-time load (shared through * `profile/context`'s `findAgentsMdInDir`; blank files are included in @@ -43,7 +41,7 @@ * picked up on the next touch; there is no negative cache. Probing is * lexical like the tools' own path policy: a symlinked directory's AGENTS.md * is discovered through the link at its lexical address, never by realpath. - * The hook never throws — a probe failure yields the untouched result. + * The hook never throws — a probe failure enqueues nothing. * * Seeding: `profile` reports the injected paths after every successful * bind/apply/refresh and `sessionInit` re-seeds after `/init`. A prompt can @@ -57,23 +55,25 @@ * probes go through the os `IHostFileSystem`, the home directory through * `IHostEnvironment`, the brand home through `bootstrap`, syntax * trees through `bashParser`, and the shown-event - * through `telemetry`. Bound at Agent scope. + * through `telemetry`. The `reminderQueue` is resolved lazily through + * `instantiation` at enqueue time — a constructor dependency would close the + * `contextInjector → loop → llmRequester → profile → agentsMdReminder` + * cycle. Bound at Agent scope. */ import { basename, dirname, isAbsolute, join, normalize } from 'pathe'; import { Disposable } from '#/_base/di/lifecycle'; +import { IInstantiationService } from '#/_base/di/instantiation'; import { LifecycleScope, ScopeActivation, registerScopedService } from '#/_base/di/scope'; import { defineState } from '#/_base/state/stateRegistry'; import { IBashParserService } from '#/app/bashParser/bashParser'; import { IBootstrapService } from '#/app/bootstrap/bootstrap'; import type { AgentsMdReminderShownEvent } from '#/app/telemetry/events'; import { ITelemetryService } from '#/app/telemetry/telemetry'; -import type { ContentPart } from '#/kosong/contract/message'; import { IHostEnvironment } from '#/os/interface/hostEnvironment'; import { IHostFileSystem } from '#/os/interface/hostFileSystem'; import { ISessionContext } from '#/session/sessionContext/sessionContext'; -import type { ExecutableToolOutput, ExecutableToolResult } from '#/tool/toolContract'; import { normalizeUserPath } from '#/tool/path-access'; import { AGENTS_MD_PLAIN_NAMES, @@ -86,6 +86,7 @@ import { } from '#/agent/profile/context'; import { ProfileModel } from '#/agent/profile/profileOps'; import { IAgentStateService } from '#/agent/state/agentState'; +import { IAgentReminderQueueService } from '#/agent/reminderQueue/reminderQueue'; import { IAgentToolExecutorService } from '#/agent/toolExecutor/toolExecutor'; import type { ToolDidExecuteContext } from '#/agent/toolExecutor/toolHooks'; import { IWireService } from '#/wire/wire'; @@ -118,6 +119,7 @@ export class AgentAgentsMdReminderService constructor( @IAgentToolExecutorService toolExecutor: IAgentToolExecutorService, + @IInstantiationService private readonly instantiation: IInstantiationService, @IAgentStateService private readonly states: IAgentStateService, @ISessionContext private readonly sessionContext: ISessionContext, @IHostFileSystem private readonly fs: IHostFileSystem, @@ -141,14 +143,10 @@ export class AgentAgentsMdReminderService }), ); const handler = async (ctx: ToolDidExecuteContext, next: () => Promise): Promise => { - ctx.result = await this.augmentWithReminder(ctx); + await this.probeAndEnqueue(ctx); await next(); }; - try { - this._register(toolExecutor.hooks.onDidExecuteTool.register('agentsMdReminder', handler, { before: 'toolDedupe' })); - } catch { - this._register(toolExecutor.hooks.onDidExecuteTool.register('agentsMdReminder', handler)); - } + this._register(toolExecutor.hooks.onDidExecuteTool.register('agentsMdReminder', handler)); } seedInjected(paths: readonly string[], cwd: string): void { @@ -179,8 +177,8 @@ export class AgentAgentsMdReminderService this.seedInjected(paths, this.agentCwd); } - private async augmentWithReminder(ctx: ToolDidExecuteContext): Promise { - if (ctx.outcome !== 'executed') return ctx.result; + private async probeAndEnqueue(ctx: ToolDidExecuteContext): Promise { + if (ctx.outcome !== 'executed') return; const discovered: string[] = []; try { await this.ensureSeeded(); @@ -195,9 +193,8 @@ export class AgentAgentsMdReminderService } if (discovered.length === 0) { this.publishKnown(selfKnown); - return ctx.result; + return; } - const result = prependReminder(ctx.result, reminderText(discovered)); const properties: AgentsMdReminderShownEvent = { turn_id: ctx.turnId, tool_name: ctx.toolCall.name, @@ -205,15 +202,24 @@ export class AgentAgentsMdReminderService trace_id: ctx.trace?.traceId, }; this.telemetry.track2('agents_md_reminder_shown', properties); + this.reminderQueue.enqueue({ variant: 'agents_md', content: reminderText(discovered) }); this.publishKnown([...selfKnown, ...discovered]); - return result; } catch { - return ctx.result; + // A probe or enqueue failure simply retries on the next qualifying touch. } finally { for (const path of discovered) this.claimed.delete(path); } } + private reminderQueueService: IAgentReminderQueueService | undefined; + + private get reminderQueue(): IAgentReminderQueueService { + this.reminderQueueService ??= this.instantiation.invokeFunction((accessor) => + accessor.get(IAgentReminderQueueService), + ); + return this.reminderQueueService; + } + private publishKnown(paths: readonly string[]): void { if (paths.length === 0) return; const merged = new Set(this.known); @@ -332,34 +338,12 @@ function stringArg(args: unknown, key: string): string | undefined { function reminderText(paths: readonly string[]): string { return ( - '\n' + - 'The path(s) touched by this call are covered by AGENTS.md instruction file(s) that were not part of the injected instructions:\n' + + 'The path(s) touched by a recent tool call are covered by AGENTS.md instruction file(s) that were not part of the injected instructions:\n' + paths.map((path) => `- ${path}`).join('\n') + - '\nRead them before making changes in those directories. Each file is suggested at most once per agent.' + - '\n\n\n' + '\nRead them before making changes in those directories. Each file is suggested at most once per agent.' ); } -function prependReminder(result: ExecutableToolResult, text: string): ExecutableToolResult { - const output = result.output; - let newOutput: ExecutableToolOutput; - if (typeof output === 'string') { - newOutput = text + output; - } else { - const parts: ContentPart[] = [...output]; - const first = parts[0]; - if (first !== undefined && first.type === 'text') { - parts[0] = { type: 'text', text: text + first.text }; - } else { - parts.unshift({ type: 'text', text }); - } - newOutput = parts; - } - return result.isError === true - ? { ...result, output: newOutput, isError: true } - : { ...result, output: newOutput }; -} - registerScopedService( LifecycleScope.Agent, IAgentAgentsMdReminderService, diff --git a/packages/agent-core-v2/test/agent/agentsMdReminder/agentsMdReminder.test.ts b/packages/agent-core-v2/test/agent/agentsMdReminder/agentsMdReminder.test.ts index b9309eb3dbe..41dcd453f37 100644 --- a/packages/agent-core-v2/test/agent/agentsMdReminder/agentsMdReminder.test.ts +++ b/packages/agent-core-v2/test/agent/agentsMdReminder/agentsMdReminder.test.ts @@ -1,6 +1,6 @@ /** * Scenario: discover uninjected AGENTS.md files from canonical tool accesses and Bash targets. - * Responsibilities: seeding, once-only reminders, result delivery, probing, and path extraction. + * Responsibilities: seeding, once-only reminders, queue delivery, probing, and path extraction. * Wiring: real reminder, executor, parser, and host filesystem with telemetry/event stubs. * Run: pnpm exec vitest run test/agent/agentsMdReminder/agentsMdReminder.test.ts */ @@ -46,12 +46,13 @@ import { AgentStateService } from '#/agent/state/agentStateService'; import { IAgentLoopService } from '#/agent/loop/loop'; import { IAgentToolDedupeService } from '#/agent/toolDedupe/toolDedupe'; import { AgentToolDedupeService } from '#/agent/toolDedupe/toolDedupeService'; +import { + IAgentReminderQueueService, + type OnceReminderInput, +} from '#/agent/reminderQueue/reminderQueue'; import { OrderedHookSlot } from '#/hooks'; import { IWireService } from '#/wire/wire'; -import type { - ResolvedToolExecutionHookContext, - ToolDidExecuteContext, -} from '#/agent/toolExecutor/toolHooks'; +import type { ToolDidExecuteContext } from '#/agent/toolExecutor/toolHooks'; import { IAgentAgentsMdReminderService } from '#/agent/agentsMdReminder/agentsMdReminder'; import { AgentAgentsMdReminderService } from '#/agent/agentsMdReminder/agentsMdReminderService'; import { extractBashTargetDirs } from '#/agent/agentsMdReminder/bashTargets'; @@ -83,6 +84,7 @@ interface Harness { readonly reminder: IAgentAgentsMdReminderService; readonly wire: IWireService; readonly telemetryEvents: TelemetryRecord[]; + readonly enqueued: OnceReminderInput[]; } function createHarness( @@ -100,6 +102,7 @@ function createHarness( } = {}, ): Harness { const telemetryEvents: TelemetryRecord[] = []; + const enqueued: OnceReminderInput[] = []; const events = stubToolExecutorEvents(); const ix = createServices(disposables, { additionalServices: (reg) => { @@ -137,6 +140,13 @@ function createHarness( reg.defineInstance(IWireService, wire); reg.defineInstance(IBootstrapService, { homeDir } as unknown as IBootstrapService); reg.defineInstance(IAgentStateService, new AgentStateService()); + reg.defineInstance(IAgentReminderQueueService, { + _serviceBrand: undefined, + enqueue: (input) => { + enqueued.push(input); + }, + drain: () => {}, + } satisfies IAgentReminderQueueService); reg.defineInstance(ISessionContext, { _serviceBrand: undefined, sessionId: 'session-1', @@ -168,7 +178,7 @@ function createHarness( }); const reminder = ix.get(IAgentAgentsMdReminderService); const wire = ix.get(IWireService); - return { ix, events, reminder, wire, telemetryEvents }; + return { ix, events, reminder, wire, telemetryEvents, enqueued }; } function didCtx( @@ -215,23 +225,6 @@ function testAccesses(name: string, args: unknown): ToolAccessesType | undefined return undefined; } -function willCtx(id: string, name: string, args: unknown): ResolvedToolExecutionHookContext { - const toolCall: ToolCall = { - type: 'function', - id, - name, - arguments: JSON.stringify(args), - }; - return { - turnId: 1, - signal: new AbortController().signal, - toolCall, - toolCalls: [toolCall], - args, - execution: { approvalRule: 'x', execute: async () => ({ output: '' }) }, - }; -} - async function fire(h: Harness, ctx: ToolDidExecuteContext): Promise { await h.events.didExecuteSlot.run(ctx); return ctx.result; @@ -246,6 +239,10 @@ function outputText(result: ExecutableToolResult): string { .join(''); } +function enqueuedText(h: Harness): string { + return h.enqueued.map((entry) => entry.content).join('\n'); +} + async function writeAgentsMd(dir: string, content = 'instructions'): Promise { await mkdir(dir, { recursive: true }); const path = join(dir, 'AGENTS.md'); @@ -254,7 +251,7 @@ async function writeAgentsMd(dir: string, content = 'instructions'): Promise { - it('appends a reminder listing the uninjected AGENTS.md when Read touches its directory', async () => { + it('enqueues a reminder listing the uninjected AGENTS.md when Read touches its directory', async () => { const h = createHarness(); const rootAgentsMd = await writeAgentsMd(workDir, 'root instructions'); const subDir = join(workDir, 'packages', 'kap-server'); @@ -263,9 +260,14 @@ describe('agentsMdReminder path-carrying tools', () => { const result = await fire(h, didCtx('Read', { path: join(subDir, 'src', 'index.ts') })); - const text = outputText(result); - expect(text).toContain('original result'); - expect(text).toContain(''); + expect(outputText(result)).toBe('original result'); + expect(h.enqueued).toHaveLength(1); + expect(h.enqueued[0]?.variant).toBe('agents_md'); + expect(h.enqueued[0]?.content.startsWith('The path(s) touched by a recent tool call')).toBe( + true, + ); + expect(h.enqueued[0]?.content).not.toContain(''); + const text = enqueuedText(h); expect(text).toContain(subAgentsMd); expect(text).not.toContain(rootAgentsMd); }); @@ -278,8 +280,10 @@ describe('agentsMdReminder path-carrying tools', () => { const first = await fire(h, didCtx('Read', { path: join(subDir, 'a.ts') })); const second = await fire(h, didCtx('Edit', { path: join(subDir, 'b.ts') })); - expect(outputText(first)).toContain(subAgentsMd); - expect(outputText(second)).not.toContain(''); + expect(outputText(first)).toBe('original result'); + expect(outputText(second)).toBe('original result'); + expect(h.enqueued).toHaveLength(1); + expect(enqueuedText(h)).toContain(subAgentsMd); }); it('marks an AGENTS.md known when read directly and never suggests it afterwards', async () => { @@ -288,10 +292,12 @@ describe('agentsMdReminder path-carrying tools', () => { const subAgentsMd = await writeAgentsMd(subDir); const direct = await fire(h, didCtx('Read', { path: subAgentsMd })); - expect(outputText(direct)).not.toContain(''); + expect(outputText(direct)).toBe('original result'); + expect(h.enqueued).toHaveLength(0); const after = await fire(h, didCtx('Read', { path: join(subDir, 'src', 'index.ts') })); - expect(outputText(after)).not.toContain(subAgentsMd); + expect(outputText(after)).toBe('original result'); + expect(h.enqueued).toHaveLength(0); }); it('discovers the .kimi-code/AGENTS.md variant alongside the plain one', async () => { @@ -303,7 +309,8 @@ describe('agentsMdReminder path-carrying tools', () => { const result = await fire(h, didCtx('Read', { path: join(subDir, 'index.ts') })); - const text = outputText(result); + expect(outputText(result)).toBe('original result'); + const text = enqueuedText(h); expect(text).toContain(dotKimi); expect(text).toContain(plain); }); @@ -319,7 +326,8 @@ describe('agentsMdReminder path-carrying tools', () => { didCtx('Write', { path: join(workDir, 'new-pkg', 'src', 'index.ts'), content: 'x' }), ); - expect(outputText(result)).toContain(rootAgentsMd); + expect(outputText(result)).toBe('original result'); + expect(enqueuedText(h)).toContain(rootAgentsMd); }); it('does not remind for seeded paths on the injected chain', async () => { @@ -329,7 +337,8 @@ describe('agentsMdReminder path-carrying tools', () => { const result = await fire(h, didCtx('Glob', { pattern: '**/*.ts' })); - expect(outputText(result)).not.toContain(''); + expect(outputText(result)).toBe('original result'); + expect(h.enqueued).toHaveLength(0); }); it('tracks the shown event through telemetry', async () => { @@ -356,7 +365,8 @@ describe('agentsMdReminder Bash coverage', () => { const result = await fire(h, didCtx('Bash', { command: 'ls packages/kap-server' })); - expect(outputText(result)).toContain(subAgentsMd); + expect(outputText(result)).toBe('original result'); + expect(enqueuedText(h)).toContain(subAgentsMd); }); it('rebases relative operands across a literal cd', async () => { @@ -365,7 +375,8 @@ describe('agentsMdReminder Bash coverage', () => { const result = await fire(h, didCtx('Bash', { command: 'cd packages && ls kap-server' })); - expect(outputText(result)).toContain(subAgentsMd); + expect(outputText(result)).toBe('original result'); + expect(enqueuedText(h)).toContain(subAgentsMd); }); it('extracts find roots and stops at the expression', async () => { @@ -377,7 +388,8 @@ describe('agentsMdReminder Bash coverage', () => { didCtx('Bash', { command: "find packages/kap-server -name '*.ts'" }), ); - expect(outputText(result)).toContain(subAgentsMd); + expect(outputText(result)).toBe('original result'); + expect(enqueuedText(h)).toContain(subAgentsMd); }); it('extracts quoted directory operands', async () => { @@ -386,7 +398,8 @@ describe('agentsMdReminder Bash coverage', () => { const result = await fire(h, didCtx('Bash', { command: 'ls "packages/kap-server"' })); - expect(outputText(result)).toContain(subAgentsMd); + expect(outputText(result)).toBe('original result'); + expect(enqueuedText(h)).toContain(subAgentsMd); }); it('probes an explicit cwd even when the command lists nothing', async () => { @@ -398,7 +411,8 @@ describe('agentsMdReminder Bash coverage', () => { didCtx('Bash', { command: 'git status', cwd: 'packages/kap-server' }), ); - expect(outputText(result)).toContain(subAgentsMd); + expect(outputText(result)).toBe('original result'); + expect(enqueuedText(h)).toContain(subAgentsMd); }); it('skips operands that are not statically resolvable', async () => { @@ -407,13 +421,14 @@ describe('agentsMdReminder Bash coverage', () => { for (const command of ['ls $DIR', 'ls *.ts', 'ls $(pwd)', 'echo packages/kap-server']) { const result = await fire(h, didCtx('Bash', { command })); - expect(outputText(result)).not.toContain(''); + expect(outputText(result)).toBe('original result'); } + expect(h.enqueued).toHaveLength(0); }); }); describe('agentsMdReminder result shapes and edge cases', () => { - it('prepends the reminder to the first text part of ContentPart[] outputs', async () => { + it('leaves ContentPart[] results untouched and enqueues the reminder', async () => { const h = createHarness(); const subAgentsMd = await writeAgentsMd(join(workDir, 'packages', 'kap-server')); @@ -426,10 +441,9 @@ describe('agentsMdReminder result shapes and edge cases', () => { ), ); - expect(Array.isArray(result.output)).toBe(true); - expect(outputText(result).startsWith('')).toBe(true); - expect(outputText(result)).toContain('part one'); - expect(outputText(result)).toContain(subAgentsMd); + expect(result.output).toEqual([{ type: 'text', text: 'part one' }]); + expect(h.enqueued).toHaveLength(1); + expect(enqueuedText(h)).toContain(subAgentsMd); }); it('does not mark an AGENTS.md known when the direct read failed', async () => { @@ -441,33 +455,29 @@ describe('agentsMdReminder result shapes and edge cases', () => { h, didCtx('Read', { path: agentsMdPath }, { result: { output: 'not found', isError: true } }), ); - expect(outputText(failed)).not.toContain(''); + expect(outputText(failed)).toBe('not found'); + expect(h.enqueued).toHaveLength(0); await writeAgentsMd(subDir); const after = await fire(h, didCtx('Read', { path: join(subDir, 'index.ts') })); - expect(outputText(after)).toContain(agentsMdPath); + expect(outputText(after)).toBe('original result'); + expect(enqueuedText(h)).toContain(agentsMdPath); }); }); -describe('agentsMdReminder toolDedupe interplay', () => { - it('delivers the reminder through a same-step duplicate resolved by toolDedupe', async () => { - const h = createHarness({ withDedupe: true }); - h.ix.get(IAgentToolDedupeService); +describe('agentsMdReminder duplicate calls', () => { + it('reminds exactly once for two same-step calls touching the same directory', async () => { + const h = createHarness(); const subAgentsMd = await writeAgentsMd(join(workDir, 'packages', 'kap-server')); const args = { path: join(workDir, 'packages', 'kap-server', 'index.ts') }; - await h.events.fireBeforeExecute(willCtx('call-1', 'Read', args)); - const did1 = didCtx('Read', args, { id: 'call-1' }); - await h.events.didExecuteSlot.run(did1); - expect(outputText(did1.result)).toContain(subAgentsMd); + const first = await fire(h, didCtx('Read', args, { id: 'call-1' })); + const second = await fire(h, didCtx('Read', args, { id: 'call-2' })); - const decision = await h.events.fireBeforeExecute(willCtx('call-2', 'Read', args)); - const did2 = didCtx('Read', args, { - id: 'call-2', - result: decision?.veto ?? { output: '' }, - }); - await h.events.didExecuteSlot.run(did2); - expect(outputText(did2.result)).toContain(subAgentsMd); + expect(outputText(first)).toBe('original result'); + expect(outputText(second)).toBe('original result'); + expect(h.enqueued).toHaveLength(1); + expect(enqueuedText(h)).toContain(subAgentsMd); }); it('leaves the vetoed placeholder untouched and reminds exactly once on the visible results', async () => { @@ -503,10 +513,10 @@ describe('agentsMdReminder toolDedupe interplay', () => { expect(results).toHaveLength(2); for (const item of results) { - const text = outputText(item.result); - expect(text).toContain('file contents'); - expect(text).toContain(subAgentsMd); + expect(outputText(item.result)).toBe('file contents'); } + expect(h.enqueued).toHaveLength(1); + expect(enqueuedText(h)).toContain(subAgentsMd); const shown = h.telemetryEvents.filter((e) => e.event === 'agents_md_reminder_shown'); expect(shown).toHaveLength(1); }); @@ -523,19 +533,20 @@ describe('agentsMdReminder lazy seeding after a restore', () => { didCtx('Read', { path: join(workDir, 'packages', 'kap-server', 'index.ts') }), ); - const text = outputText(result); + expect(outputText(result)).toBe('original result'); + const text = enqueuedText(h); expect(text).toContain(subAgentsMd); expect(text).not.toContain(rootAgentsMd); }); it('treats the brand-home AGENTS.md as injected after a restore', async () => { const h = createHarness(); - const brandAgentsMd = await writeAgentsMd(homeDir, 'brand instructions'); + await writeAgentsMd(homeDir, 'brand instructions'); const result = await fire(h, didCtx('Read', { path: join(homeDir, 'notes.txt') })); expect(outputText(result)).toBe('original result'); - expect(outputText(result)).not.toContain(brandAgentsMd); + expect(h.enqueued).toHaveLength(0); expect(h.telemetryEvents).toHaveLength(0); }); }); @@ -555,7 +566,8 @@ describe('agentsMdReminder persisted restore provenance', () => { await h.wire.hooks.onDidRestore.run({}); const result = await fire(h, didCtx('Read', { path: join(subDir, 'index.ts') })); - expect(outputText(result)).toContain(subAgentsMd); + expect(outputText(result)).toBe('original result'); + expect(enqueuedText(h)).toContain(subAgentsMd); }); it('recovers injected paths from a legacy restored prompt without path provenance', async () => { @@ -569,7 +581,8 @@ describe('agentsMdReminder persisted restore provenance', () => { await h.wire.hooks.onDidRestore.run({}); const result = await fire(h, didCtx('Read', { path: join(workDir, 'index.ts') })); - expect(outputText(result)).not.toContain(''); + expect(outputText(result)).toBe('original result'); + expect(h.enqueued).toHaveLength(0); }); }); @@ -581,7 +594,8 @@ describe('agentsMdReminder Bash operand hygiene', () => { const result = await fire(h, didCtx('Bash', { command: 'ls -w 80 packages/kap-server' })); - const text = outputText(result); + expect(outputText(result)).toBe('original result'); + const text = enqueuedText(h); expect(text).toContain(subAgentsMd); expect(text).not.toContain(eighty); }); @@ -595,7 +609,8 @@ describe('agentsMdReminder Bash operand hygiene', () => { didCtx('Bash', { command: "find -L packages/kap-server -name '*.ts'" }), ); - expect(outputText(result)).toContain(subAgentsMd); + expect(outputText(result)).toBe('original result'); + expect(enqueuedText(h)).toContain(subAgentsMd); }); }); @@ -608,7 +623,8 @@ describe('agentsMdReminder probing boundaries', () => { const result = await fire(h, didCtx('Read', { path: join(subDir, 'index.ts') })); - expect(outputText(result)).not.toContain(''); + expect(outputText(result)).toBe('original result'); + expect(h.enqueued).toHaveLength(0); }); it('still reminds when the triggering call ended in an error result', async () => { @@ -623,7 +639,8 @@ describe('agentsMdReminder probing boundaries', () => { }), ); - expect(outputText(result)).toContain(subAgentsMd); + expect(outputText(result)).toBe('not found'); + expect(enqueuedText(h)).toContain(subAgentsMd); }); it('marks an AGENTS.md known when it is written directly', async () => { @@ -633,10 +650,12 @@ describe('agentsMdReminder probing boundaries', () => { const agentsMdPath = normalize(join(subDir, 'AGENTS.md')); const written = await fire(h, didCtx('Write', { path: agentsMdPath, content: 'x' })); - expect(outputText(written)).not.toContain(''); + expect(outputText(written)).toBe('original result'); + expect(h.enqueued).toHaveLength(0); const after = await fire(h, didCtx('Read', { path: join(subDir, 'index.ts') })); - expect(outputText(after)).not.toContain(agentsMdPath); + expect(outputText(after)).toBe('original result'); + expect(h.enqueued).toHaveLength(0); }); it('reminds at most once for two parallel touches of the same directory', async () => { @@ -649,10 +668,9 @@ describe('agentsMdReminder probing boundaries', () => { fire(h, didCtx('Read', { path: join(subDir, 'b.ts') }, { id: 'call-b' })), ]); - const reminders = [first, second].filter((result) => - outputText(result).includes(''), - ); - expect(reminders).toHaveLength(1); + expect(outputText(first)).toBe('original result'); + expect(outputText(second)).toBe('original result'); + expect(h.enqueued).toHaveLength(1); }); it('re-judges the project root at a nested repository', async () => { @@ -664,7 +682,8 @@ describe('agentsMdReminder probing boundaries', () => { const result = await fire(h, didCtx('Read', { path: join(nested, 'index.ts') })); - const text = outputText(result); + expect(outputText(result)).toBe('original result'); + const text = enqueuedText(h); expect(text).toContain(nestedAgentsMd); expect(text).not.toContain(rootAgentsMd); }); @@ -679,7 +698,8 @@ describe('agentsMdReminder probing boundaries', () => { try { const result = await fire(h, didCtx('Read', { path: join(leaf, 'index.ts') })); - const text = outputText(result); + expect(outputText(result)).toBe('original result'); + const text = enqueuedText(h); expect(text).toContain(leafAgentsMd); expect(text).not.toContain(outerAgentsMd); } finally { @@ -696,7 +716,8 @@ describe('agentsMdReminder probing boundaries', () => { try { const result = await fire(h, didCtx('Read', { path: join(workDir, 'link', 'index.ts') })); - const text = outputText(result); + expect(outputText(result)).toBe('original result'); + const text = enqueuedText(h); expect(text).toContain(normalize(join(workDir, 'link', 'AGENTS.md'))); expect(text).not.toContain(targetAgentsMd); } finally { @@ -717,6 +738,7 @@ describe('agentsMdReminder round-2 hardening', () => { ); expect(outputText(result)).toBe('original result'); + expect(h.enqueued).toHaveLength(0); expect(h.telemetryEvents).toHaveLength(0); }); @@ -729,9 +751,11 @@ describe('agentsMdReminder round-2 hardening', () => { const result = await fire(h, didCtx('Bash', { command: 'true' })); expect(outputText(result)).toBe('original result'); + expect(h.enqueued).toHaveLength(0); const listed = await fire(h, didCtx('Bash', { command: 'ls packages' })); - expect(outputText(listed)).toContain(subAgentsMd); + expect(outputText(listed)).toBe('original result'); + expect(enqueuedText(h)).toContain(subAgentsMd); }); it('ignores a whitespace-only AGENTS.md just like the init-time load', async () => { @@ -742,7 +766,8 @@ describe('agentsMdReminder round-2 hardening', () => { const result = await fire(h, didCtx('Read', { path: join(subDir, 'index.ts') })); - expect(outputText(result)).not.toContain(''); + expect(outputText(result)).toBe('original result'); + expect(h.enqueued).toHaveLength(0); }); it('keeps known-sets isolated between agents', async () => { @@ -754,8 +779,12 @@ describe('agentsMdReminder round-2 hardening', () => { const firstResult = await fire(first, didCtx('Read', { path: join(subDir, 'index.ts') })); const secondResult = await fire(second, didCtx('Read', { path: join(subDir, 'index.ts') })); - expect(outputText(firstResult)).toContain(subAgentsMd); - expect(outputText(secondResult)).toContain(subAgentsMd); + expect(outputText(firstResult)).toBe('original result'); + expect(outputText(secondResult)).toBe('original result'); + expect(first.enqueued).toHaveLength(1); + expect(second.enqueued).toHaveLength(1); + expect(enqueuedText(first)).toContain(subAgentsMd); + expect(enqueuedText(second)).toContain(subAgentsMd); }); it('releases the claim when attaching the reminder fails, so the next touch retries', async () => { @@ -772,26 +801,16 @@ describe('agentsMdReminder round-2 hardening', () => { const failed = await fire(h, didCtx('Read', { path: join(subDir, 'a.ts') })); expect(outputText(failed)).toBe('original result'); + expect(h.enqueued).toHaveLength(0); shouldThrow = false; const retried = await fire(h, didCtx('Read', { path: join(subDir, 'b.ts') })); - expect(outputText(retried)).toContain(subAgentsMd); + expect(outputText(retried)).toBe('original result'); + expect(h.enqueued).toHaveLength(1); + expect(enqueuedText(h)).toContain(subAgentsMd); }); - it('prepends the reminder so it survives head-only truncation', async () => { - const h = createHarness(); - const subAgentsMd = await writeAgentsMd(join(workDir, 'packages', 'kap-server')); - - const result = await fire( - h, - didCtx('Read', { path: join(workDir, 'packages', 'kap-server', 'index.ts') }), - ); - - expect(outputText(result).startsWith('')).toBe(true); - expect(outputText(result)).toContain(subAgentsMd); - }); - - it('survives the real executor pipeline with oversized results', async () => { + it('leaves oversized results to the truncation pipeline and enqueues the reminder instead', async () => { const h = createHarness({ withRealExecutor: true }); const subAgentsMd = await writeAgentsMd(join(workDir, 'packages', 'kap-server')); @@ -827,8 +846,10 @@ describe('agentsMdReminder round-2 hardening', () => { expect(typeof output).toBe('string'); const text = output as string; expect(text).toContain('output_path:'); - expect(text.indexOf('')).toBeLessThan(2_000); - expect(text).toContain(subAgentsMd); + expect(text).not.toContain(''); + expect(text).not.toContain(subAgentsMd); + expect(h.enqueued).toHaveLength(1); + expect(enqueuedText(h)).toContain(subAgentsMd); }); it('uses the resolved file access instead of reparsing the raw path', async () => { @@ -867,13 +888,15 @@ describe('agentsMdReminder round-2 hardening', () => { } expect(results).toHaveLength(1); - expect(outputText(results[0]!.result)).toContain(homeAgentsMd); + expect(outputText(results[0]!.result)).toBe('home file contents'); + expect(h.enqueued).toHaveLength(1); + expect(enqueuedText(h)).toContain(homeAgentsMd); }); it('does not probe or remind when permission vetoes an access-bearing call', async () => { const h = createHarness({ withRealExecutor: true }); const subDir = join(workDir, 'packages', 'kap-server'); - const subAgentsMd = await writeAgentsMd(subDir); + await writeAgentsMd(subDir); const hostFs = h.ix.get(IHostFileSystem); const stat = vi.spyOn(hostFs, 'stat'); const readText = vi.spyOn(hostFs, 'readText'); @@ -915,7 +938,7 @@ describe('agentsMdReminder round-2 hardening', () => { expect(results).toHaveLength(1); expect(outputText(results[0]!.result)).toBe('permission denied'); - expect(outputText(results[0]!.result)).not.toContain(subAgentsMd); + expect(h.enqueued).toHaveLength(0); expect(stat).not.toHaveBeenCalled(); expect(readText).not.toHaveBeenCalled(); expect( @@ -1005,7 +1028,7 @@ describe('agentsMdReminder cancellation outcomes', () => { const results = await pending; const queued = results.find((item) => item.toolCallId === 'call-queued-read'); expect(queued).toBeDefined(); - expect(outputText(queued!.result)).not.toContain(''); + expect(h.enqueued).toHaveLength(0); expect( h.telemetryEvents.filter((event) => event.event === 'agents_md_reminder_shown'), ).toEqual([]); @@ -1027,7 +1050,9 @@ describe('agentsMdReminder cancellation outcomes', () => { )) { real.push(item); } - expect(outputText(real[0]!.result)).toContain(subAgentsMd); + expect(outputText(real[0]!.result)).toBe('read result'); + expect(h.enqueued).toHaveLength(1); + expect(enqueuedText(h)).toContain(subAgentsMd); }); }); @@ -1041,7 +1066,8 @@ describe('agentsMdReminder Bash parse degradation', () => { didCtx('Bash', { command: "ls '", cwd: 'packages/kap-server' }), ); - expect(outputText(result)).toContain(subAgentsMd); + expect(outputText(result)).toBe('original result'); + expect(enqueuedText(h)).toContain(subAgentsMd); }); it('skips entirely when an unparseable command has no explicit cwd', async () => { @@ -1051,6 +1077,7 @@ describe('agentsMdReminder Bash parse degradation', () => { const result = await fire(h, didCtx('Bash', { command: "ls '" })); expect(outputText(result)).toBe('original result'); + expect(h.enqueued).toHaveLength(0); }); }); @@ -1094,7 +1121,8 @@ describe('agentsMdReminder Windows Bash paths', () => { const result = await fire(h, didCtx('Bash', args)); - expect(outputText(result)).toContain(agentsMdPath); + expect(outputText(result)).toBe('original result'); + expect(enqueuedText(h)).toContain(agentsMdPath); } }); }); From 167f23dd9a4e1f9048f3317bcfd3f3357eb53949 Mon Sep 17 00:00:00 2001 From: 7Sageer <7sageer@djwcb.cn> Date: Thu, 6 Aug 2026 14:40:09 +0800 Subject: [PATCH 13/27] refactor(agent-core-v2): make injection disclosures opaque and domain-owned contextMemory no longer declares the ContextInjectionDisclosure union: InjectionOrigin.disclosure becomes an opaque unknown, and providers bind their own payload type through register, so lastDisclosure arrives at the provider already typed by its own variant. The date, swarm_mode, and once_reminder payload shapes move into the dateChange, swarm, and reminderQueue domains respectively; reminderQueue keeps a runtime guard for its cross-message tail scan, the only place that reads disclosures it did not write. Persisted origin shapes are byte-identical, so existing journals replay unchanged. --- .../agent-core-v2/docs/state-manifest.d.ts | 39 ++----------------- .../agent/contextInjector/contextInjector.ts | 32 +++++++-------- .../contextInjector/contextInjectorService.ts | 27 ++++++------- .../contextInjector/disclosureBaseline.ts | 13 +------ .../src/agent/contextMemory/types.ts | 18 +-------- .../src/agent/dateChange/dateChangeService.ts | 21 ++++++---- .../src/agent/reminderQueue/reminderQueue.ts | 11 ++++++ .../reminderQueue/reminderQueueService.ts | 12 ++++-- .../agent/swarm/injection/swarmInjection.ts | 20 ++++++++-- .../permissionMode/permissionMode.test.ts | 4 +- .../test/agent/task/taskService.test.ts | 2 +- 11 files changed, 88 insertions(+), 111 deletions(-) diff --git a/packages/agent-core-v2/docs/state-manifest.d.ts b/packages/agent-core-v2/docs/state-manifest.d.ts index a8c20da0673..b378ab9288d 100644 --- a/packages/agent-core-v2/docs/state-manifest.d.ts +++ b/packages/agent-core-v2/docs/state-manifest.d.ts @@ -751,18 +751,7 @@ export interface AgentStateSnapshot { readonly kind: 'injection'; readonly variant: string; readonly ownerPromptId?: string; - readonly disclosure?: { - readonly kind: 'date'; - readonly renderGeneration: number; - readonly localDate: string; - readonly timeZone: string; - } | { - readonly kind: 'swarm_mode'; - readonly state: 'active' | 'inactive'; - } | { - readonly kind: 'once_reminder'; - readonly id: string; - }; + readonly disclosure?: unknown; } | /* ShellCommandOrigin — packages/agent-core-v2/src/agent/contextMemory/types.ts */ { readonly kind: 'shell_command'; readonly phase: 'input' | 'output'; @@ -887,18 +876,7 @@ export interface AgentStateSnapshot { readonly kind: 'injection'; readonly variant: string; readonly ownerPromptId?: string; - readonly disclosure?: { - readonly kind: 'date'; - readonly renderGeneration: number; - readonly localDate: string; - readonly timeZone: string; - } | { - readonly kind: 'swarm_mode'; - readonly state: 'active' | 'inactive'; - } | { - readonly kind: 'once_reminder'; - readonly id: string; - }; + readonly disclosure?: unknown; } | /* ShellCommandOrigin — packages/agent-core-v2/src/agent/contextMemory/types.ts */ { readonly kind: 'shell_command'; readonly phase: 'input' | 'output'; @@ -955,18 +933,7 @@ export interface AgentStateSnapshot { readonly kind: 'injection'; readonly variant: string; readonly ownerPromptId?: string; - readonly disclosure?: { - readonly kind: 'date'; - readonly renderGeneration: number; - readonly localDate: string; - readonly timeZone: string; - } | { - readonly kind: 'swarm_mode'; - readonly state: 'active' | 'inactive'; - } | { - readonly kind: 'once_reminder'; - readonly id: string; - }; + readonly disclosure?: unknown; } | /* ShellCommandOrigin — packages/agent-core-v2/src/agent/contextMemory/types.ts */ { readonly kind: 'shell_command'; readonly phase: 'input' | 'output'; diff --git a/packages/agent-core-v2/src/agent/contextInjector/contextInjector.ts b/packages/agent-core-v2/src/agent/contextInjector/contextInjector.ts index 2ab9771aba3..3dc7d307c6c 100644 --- a/packages/agent-core-v2/src/agent/contextInjector/contextInjector.ts +++ b/packages/agent-core-v2/src/agent/contextInjector/contextInjector.ts @@ -2,13 +2,13 @@ import { createDecorator } from "#/_base/di/instantiation"; import type { IDisposable } from "#/_base/di/lifecycle"; import type { ContentPart } from "#/kosong/contract/message"; import type { Tool } from "#/kosong/contract/tool"; -import type { ContextInjectionDisclosure, ContextMessage } from '#/agent/contextMemory/types'; +import type { ContextMessage } from '#/agent/contextMemory/types'; -export interface ContextInjectionContext { +export interface ContextInjectionContext { readonly injectedPositions: readonly number[]; readonly lastInjectedAt: number | null; readonly lastInjection?: ContextMessage; - readonly lastDisclosure?: ContextInjectionDisclosure; + readonly lastDisclosure?: D; readonly isNewTurn: boolean; } @@ -23,22 +23,22 @@ export type ContextInjectionContent = | readonly ContentPart[] | { readonly message: ContextInjectionMessage }; -export interface ContextInjectionResult { +export interface ContextInjectionResult { readonly content: ContextInjectionContent; - readonly disclosure?: ContextInjectionDisclosure; + readonly disclosure?: D; } -export type ContextInjectionProvider = ( - context: ContextInjectionContext, +export type ContextInjectionProvider = ( + context: ContextInjectionContext, ) => | ContextInjectionContent - | ContextInjectionResult + | ContextInjectionResult | undefined - | Promise; + | Promise | undefined>; -export type SyncContextInjectionProvider = ( - context: ContextInjectionContext, -) => ContextInjectionContent | ContextInjectionResult | undefined; +export type SyncContextInjectionProvider = ( + context: ContextInjectionContext, +) => ContextInjectionContent | ContextInjectionResult | undefined; export interface IAgentContextInjectorService { readonly _serviceBrand: undefined; @@ -50,14 +50,14 @@ export interface IAgentContextInjectorService { * skipped, so one bad channel cannot starve the rest. */ registerOnceChannel(name: string, drain: () => void): IDisposable; - register( + register( name: string, - provider: ContextInjectionProvider, + provider: ContextInjectionProvider, ): IDisposable; - registerAtTurnStart( + registerAtTurnStart( name: string, - provider: SyncContextInjectionProvider, + provider: SyncContextInjectionProvider, ): IDisposable; injectAfterCompaction(): Promise; diff --git a/packages/agent-core-v2/src/agent/contextInjector/contextInjectorService.ts b/packages/agent-core-v2/src/agent/contextInjector/contextInjectorService.ts index 680bf865c0b..eafddddb2eb 100644 --- a/packages/agent-core-v2/src/agent/contextInjector/contextInjectorService.ts +++ b/packages/agent-core-v2/src/agent/contextInjector/contextInjectorService.ts @@ -47,6 +47,7 @@ import { IWireService } from '#/wire/wire'; import { IAgentContextInjectorService, type ContextInjectionContent, + type ContextInjectionContext, type ContextInjectionMessage, type ContextInjectionProvider, type ContextInjectionResult, @@ -54,7 +55,7 @@ import { } from './contextInjector'; interface ContextInjectionEntry { - readonly provider: ContextInjectionProvider; + readonly provider: ContextInjectionProvider; readonly name: string; readonly boundary: 'step' | 'turn-start'; } @@ -114,16 +115,16 @@ export class AgentContextInjectorService extends Disposable implements IAgentCon this.states.set(contextInjectorIsNewTurnKey, value); } - register( + register( name: string, - provider: ContextInjectionProvider, + provider: ContextInjectionProvider, ): IDisposable { return this.registerProvider(name, provider, 'step'); } - registerAtTurnStart( + registerAtTurnStart( name: string, - provider: SyncContextInjectionProvider, + provider: SyncContextInjectionProvider, ): IDisposable { return this.registerProvider(name, provider, 'turn-start'); } @@ -151,13 +152,13 @@ export class AgentContextInjectorService extends Disposable implements IAgentCon } } - private registerProvider( + private registerProvider( name: string, - provider: ContextInjectionProvider, + provider: ContextInjectionProvider, boundary: ContextInjectionEntry['boundary'], ): IDisposable { const entry: ContextInjectionEntry = { - provider, + provider: provider as ContextInjectionProvider, name, boundary, }; @@ -211,7 +212,7 @@ export class AgentContextInjectorService extends Disposable implements IAgentCon private providerContext( entry: ContextInjectionEntry, isNewTurn: boolean, - ): Parameters[0] { + ): ContextInjectionContext { const injectedPositions = findInjections(this.context.get(), entry.name); const lastInjectedAt = injectedPositions.at(-1) ?? null; const lastInjection = lastInjectedAt === null @@ -231,10 +232,10 @@ export class AgentContextInjectorService extends Disposable implements IAgentCon private appendResult( entry: ContextInjectionEntry, - content: ContextInjectionContent | ContextInjectionResult | undefined, + content: ContextInjectionContent | ContextInjectionResult | undefined, ): void { if (content === undefined) return; - const result: ContextInjectionResult = isInjectionResult(content) + const result: ContextInjectionResult = isInjectionResult(content) ? content : { content }; const origin = { @@ -282,8 +283,8 @@ function isRawInjectionMessage( } function isInjectionResult( - content: ContextInjectionContent | ContextInjectionResult, -): content is ContextInjectionResult { + content: ContextInjectionContent | ContextInjectionResult, +): content is ContextInjectionResult { return ( typeof content === 'object' && content !== null && diff --git a/packages/agent-core-v2/src/agent/contextInjector/disclosureBaseline.ts b/packages/agent-core-v2/src/agent/contextInjector/disclosureBaseline.ts index ceff302caa2..5932a1edaac 100644 --- a/packages/agent-core-v2/src/agent/contextInjector/disclosureBaseline.ts +++ b/packages/agent-core-v2/src/agent/contextInjector/disclosureBaseline.ts @@ -1,5 +1,5 @@ /** - * `contextInjector` domain (L4) — disclosure-baseline helpers for reminder + * `contextInjector` domain (L4) — disclosure-baseline helper for reminder * providers (currently `date_change`). * * A provider's baseline answers "what has the model already seen" from up to @@ -11,17 +11,6 @@ * part of the barrel export. */ -import type { ContextInjectionDisclosure } from '#/agent/contextMemory/types'; - -export function disclosureOfKind( - disclosure: ContextInjectionDisclosure | undefined, - kind: K, -): Extract | undefined { - return disclosure?.kind === kind - ? (disclosure as Extract) - : undefined; -} - export function pickDisclosureBaseline( ...candidates: readonly (T | undefined)[] ): T | undefined { diff --git a/packages/agent-core-v2/src/agent/contextMemory/types.ts b/packages/agent-core-v2/src/agent/contextMemory/types.ts index 59619b91e52..b21fe3c7a51 100644 --- a/packages/agent-core-v2/src/agent/contextMemory/types.ts +++ b/packages/agent-core-v2/src/agent/contextMemory/types.ts @@ -34,25 +34,9 @@ export interface InjectionOrigin { readonly kind: 'injection'; readonly variant: string; readonly ownerPromptId?: string; - readonly disclosure?: ContextInjectionDisclosure; + readonly disclosure?: unknown; } -export type ContextInjectionDisclosure = - | { - readonly kind: 'date'; - readonly renderGeneration: number; - readonly localDate: string; - readonly timeZone: string; - } - | { - readonly kind: 'swarm_mode'; - readonly state: 'active' | 'inactive'; - } - | { - readonly kind: 'once_reminder'; - readonly id: string; - }; - export interface ShellCommandOrigin { readonly kind: 'shell_command'; readonly phase: 'input' | 'output'; diff --git a/packages/agent-core-v2/src/agent/dateChange/dateChangeService.ts b/packages/agent-core-v2/src/agent/dateChange/dateChangeService.ts index f7aa2b2e2a4..2d8db5bce45 100644 --- a/packages/agent-core-v2/src/agent/dateChange/dateChangeService.ts +++ b/packages/agent-core-v2/src/agent/dateChange/dateChangeService.ts @@ -23,10 +23,7 @@ import { type ContextInjectionContext, type ContextInjectionResult, } from '#/agent/contextInjector/contextInjector'; -import { - disclosureOfKind, - pickDisclosureBaseline, -} from '#/agent/contextInjector/disclosureBaseline'; +import { pickDisclosureBaseline } from '#/agent/contextInjector/disclosureBaseline'; import { IAgentProfileService } from '#/agent/profile/profile'; import { IAgentStateService } from '#/agent/state/agentState'; import { IHostClock } from '#/os/interface/hostClock'; @@ -54,13 +51,16 @@ export class AgentDateChangeService extends Disposable implements IAgentDateChan super(); this.states.register(dateChangeSeedKey); this._register( - dynamicInjector.register(DATE_CHANGE_INJECTION_VARIANT, (ctx) => this.reminder(ctx)), + dynamicInjector.register( + DATE_CHANGE_INJECTION_VARIANT, + (ctx) => this.reminder(ctx), + ), ); } private reminder({ lastDisclosure, - }: ContextInjectionContext): ContextInjectionResult | undefined { + }: ContextInjectionContext): ContextInjectionResult | undefined { const profileData = this.profile.data(); const environment = profileData.environmentDisclosure; if ( @@ -73,7 +73,7 @@ export class AgentDateChangeService extends Disposable implements IAgentDateChan const renderGeneration = profileData.renderGeneration ?? 0; const current = currentDateDisclosure(this.clock); const baseline = pickDisclosureBaseline( - disclosureOfKind(lastDisclosure, 'date'), + lastDisclosure, this.dateFromProfile(), this.states.get(dateChangeSeedKey), ); @@ -112,6 +112,13 @@ export class AgentDateChangeService extends Disposable implements IAgentDateChan } } +export interface DateInjectionDisclosure { + readonly kind: 'date'; + readonly renderGeneration: number; + readonly localDate: string; + readonly timeZone: string; +} + interface DateDisclosure { readonly localDate: string; readonly timeZone: string; diff --git a/packages/agent-core-v2/src/agent/reminderQueue/reminderQueue.ts b/packages/agent-core-v2/src/agent/reminderQueue/reminderQueue.ts index 895c8d3c959..13219b28f5e 100644 --- a/packages/agent-core-v2/src/agent/reminderQueue/reminderQueue.ts +++ b/packages/agent-core-v2/src/agent/reminderQueue/reminderQueue.ts @@ -12,6 +12,17 @@ export interface OnceReminderInput { readonly content: string; } +export interface OnceReminderDisclosure { + readonly kind: 'once_reminder'; + readonly id: string; +} + +export function isOnceReminderDisclosure(value: unknown): value is OnceReminderDisclosure { + if (typeof value !== 'object' || value === null) return false; + const candidate = value as { readonly kind?: unknown; readonly id?: unknown }; + return candidate.kind === 'once_reminder' && typeof candidate.id === 'string'; +} + export interface IAgentReminderQueueService { readonly _serviceBrand: undefined; diff --git a/packages/agent-core-v2/src/agent/reminderQueue/reminderQueueService.ts b/packages/agent-core-v2/src/agent/reminderQueue/reminderQueueService.ts index 6045faae95e..eb7afa0ca20 100644 --- a/packages/agent-core-v2/src/agent/reminderQueue/reminderQueueService.ts +++ b/packages/agent-core-v2/src/agent/reminderQueue/reminderQueueService.ts @@ -23,7 +23,12 @@ import { isVacuousContentPart } from '#/agent/contextMemory/vacuousContent'; import { IAgentSystemReminderService } from '#/agent/systemReminder/systemReminder'; import { IWireService } from '#/wire/wire'; -import { IAgentReminderQueueService, type OnceReminderInput } from './reminderQueue'; +import { + IAgentReminderQueueService, + isOnceReminderDisclosure, + type OnceReminderDisclosure, + type OnceReminderInput, +} from './reminderQueue'; import { ReminderQueueModel, type ReminderQueueEntry, @@ -60,7 +65,7 @@ export class AgentReminderQueueService extends Disposable implements IAgentRemin this.reminders.appendSystemReminder(entry.content, { kind: 'injection', variant: entry.variant, - disclosure: { kind: 'once_reminder', id: entry.id }, + disclosure: { kind: 'once_reminder', id: entry.id } satisfies OnceReminderDisclosure, }); rendered.add(collapseKey); } @@ -76,7 +81,8 @@ export class AgentReminderQueueService extends Disposable implements IAgentRemin if (isSkippableTailAssistant(message)) continue; const origin = message.origin; if (origin?.kind !== 'injection') break; - if (origin.disclosure?.kind === 'once_reminder') ids.add(origin.disclosure.id); + const disclosure = origin.disclosure; + if (isOnceReminderDisclosure(disclosure)) ids.add(disclosure.id); } return ids; } diff --git a/packages/agent-core-v2/src/agent/swarm/injection/swarmInjection.ts b/packages/agent-core-v2/src/agent/swarm/injection/swarmInjection.ts index 628622f274a..2c44e3ce735 100644 --- a/packages/agent-core-v2/src/agent/swarm/injection/swarmInjection.ts +++ b/packages/agent-core-v2/src/agent/swarm/injection/swarmInjection.ts @@ -29,6 +29,11 @@ import type { SwarmModeTrigger } from '../swarm'; const SWARM_MODE_INJECTION_VARIANT = 'swarm_mode'; const LEGACY_SWARM_MODE_EXIT_VARIANT = 'swarm_mode_exit'; +interface SwarmModeInjectionDisclosure { + readonly kind: 'swarm_mode'; + readonly state: 'active' | 'inactive'; +} + export interface SwarmInjectionOptions { readonly getTrigger: () => SwarmModeTrigger | null; } @@ -41,11 +46,16 @@ export class SwarmInjection extends Disposable { ) { super(); this._register( - dynamicInjector.register(SWARM_MODE_INJECTION_VARIANT, (ctx) => this.reminder(ctx)), + dynamicInjector.register( + SWARM_MODE_INJECTION_VARIANT, + (ctx) => this.reminder(ctx), + ), ); } - private reminder(ctx: ContextInjectionContext): ContextInjectionResult | undefined { + private reminder( + ctx: ContextInjectionContext, + ): ContextInjectionResult | undefined { const trigger = this.options.getTrigger(); const active = trigger !== null && trigger !== 'tool'; const rendered = this.renderedState(ctx); @@ -65,8 +75,10 @@ export class SwarmInjection extends Disposable { : undefined; } - private renderedState(ctx: ContextInjectionContext): 'active' | 'inactive' | undefined { - if (ctx.lastDisclosure?.kind === 'swarm_mode') return ctx.lastDisclosure.state; + private renderedState( + ctx: ContextInjectionContext, + ): 'active' | 'inactive' | undefined { + if (ctx.lastDisclosure !== undefined) return ctx.lastDisclosure.state; const history = this.context.get(); for (let i = history.length - 1; i >= 0; i--) { const origin = history[i]!.origin; diff --git a/packages/agent-core-v2/test/agent/permissionMode/permissionMode.test.ts b/packages/agent-core-v2/test/agent/permissionMode/permissionMode.test.ts index 572c4f7c9fb..6d5c2aa1362 100644 --- a/packages/agent-core-v2/test/agent/permissionMode/permissionMode.test.ts +++ b/packages/agent-core-v2/test/agent/permissionMode/permissionMode.test.ts @@ -37,7 +37,7 @@ const injectorStub: IAgentContextInjectorService = { _serviceBrand: undefined, registerOnceChannel: () => ({ dispose: () => {} }), register: (name, provider) => { - registeredInjection = { name, provider }; + registeredInjection = { name, provider: provider as ContextInjectionProvider }; return { dispose: () => { if (registeredInjection?.provider === provider) registeredInjection = undefined; @@ -179,7 +179,7 @@ describe('AgentPermissionModeService (wire-backed)', () => { ix2.stub(IAgentContextInjectorService, { _serviceBrand: undefined, register: (_name, provider) => { - restoredProvider = provider; + restoredProvider = provider as ContextInjectionProvider; return { dispose: () => {} }; }, injectAfterCompaction: async () => {}, diff --git a/packages/agent-core-v2/test/agent/task/taskService.test.ts b/packages/agent-core-v2/test/agent/task/taskService.test.ts index 8ec6e8c2cbf..b08862166e6 100644 --- a/packages/agent-core-v2/test/agent/task/taskService.test.ts +++ b/packages/agent-core-v2/test/agent/task/taskService.test.ts @@ -101,7 +101,7 @@ describe('AgentTaskService', () => { ix.stub(IEventBus, eventBus); ix.stub(IAgentContextInjectorService, { register: (name, provider) => { - injectionProviders.set(name, provider); + injectionProviders.set(name, provider as ContextInjectionProvider); return toDisposable(() => { injectionProviders.delete(name); }); From f2b9fc6a143d0db5da113344a2b6c809a1b66955 Mon Sep 17 00:00:00 2001 From: 7Sageer <7sageer@djwcb.cn> Date: Thu, 6 Aug 2026 15:26:05 +0800 Subject: [PATCH 14/27] fix(agent-core-v2): isolate failing step context providers A step or compaction boundary provider that threw or rejected made the injector's inject() promise reject, which propagated through the onWillBeginStep hook chain and failed the whole turn, and starved every provider registered after it. Log and skip the bad provider instead, matching the turn-start path's existing isolation. --- .../contextInjector/contextInjectorService.ts | 13 +++++++++-- .../contextInjector/contextInjector.test.ts | 22 +++++++++++++++++++ 2 files changed, 33 insertions(+), 2 deletions(-) diff --git a/packages/agent-core-v2/src/agent/contextInjector/contextInjectorService.ts b/packages/agent-core-v2/src/agent/contextInjector/contextInjectorService.ts index eafddddb2eb..d89e1bd123e 100644 --- a/packages/agent-core-v2/src/agent/contextInjector/contextInjectorService.ts +++ b/packages/agent-core-v2/src/agent/contextInjector/contextInjectorService.ts @@ -8,7 +8,10 @@ * persisted once-reminders — then reconciles the registered context * providers (present-tense state) through `loop` and `systemReminder`. A * once-channel drain that throws or returns a Promise is logged and - * skipped, so one bad channel cannot starve the providers. Provider + * skipped, so one bad channel cannot starve the providers. A provider that + * throws or rejects at a step or compaction boundary is likewise logged and + * skipped, so one bad provider can neither starve the rest nor fail the + * turn. Provider * positions are never cached: each provider call derives them by scanning * `contextMemory` for its own surviving injection messages, so splices, * compaction folds, and `wire` restoration need no index bookkeeping. Each @@ -179,7 +182,13 @@ export class AgentContextInjectorService extends Disposable implements IAgentCon this.isNewTurn = false; for (const entry of this.entries) { if (boundary !== undefined && !shouldRunAtBoundary(entry, boundary)) continue; - const content = await entry.provider(this.providerContext(entry, isNewTurn)); + let content: Awaited>; + try { + content = await entry.provider(this.providerContext(entry, isNewTurn)); + } catch (error) { + this.log.error('context provider failed; skipping it', { name: entry.name, error }); + continue; + } if (!this.entries.has(entry)) continue; this.appendResult(entry, content); } diff --git a/packages/agent-core-v2/test/agent/contextInjector/contextInjector.test.ts b/packages/agent-core-v2/test/agent/contextInjector/contextInjector.test.ts index 923820932cf..deb0f21673f 100644 --- a/packages/agent-core-v2/test/agent/contextInjector/contextInjector.test.ts +++ b/packages/agent-core-v2/test/agent/contextInjector/contextInjector.test.ts @@ -423,6 +423,28 @@ describe('AgentContextInjectorService', () => { expect(lastText(context)).toContain('turn-start reminder'); }); + it('skips a throwing step provider and still runs the rest', async () => { + injector(ix).register('step_throwing', () => { + throw new Error('boom'); + }); + injector(ix).register('step_surviving', () => 'surviving reminder'); + + await runInjectionStep(); + + expect(context.get()).toHaveLength(1); + expect(lastText(context)).toContain('surviving reminder'); + }); + + it('skips a rejecting step provider and still runs the rest', async () => { + injector(ix).register('step_rejecting', () => Promise.reject(new Error('boom'))); + injector(ix).register('step_surviving', () => 'surviving reminder'); + + await runInjectionStep(); + + expect(context.get()).toHaveLength(1); + expect(lastText(context)).toContain('surviving reminder'); + }); + it('skips a throwing turn-start provider and still runs the rest', () => { injector(ix).registerAtTurnStart('turn_start_throwing', () => { throw new Error('boom'); From 774b3e84d40cb5dfcc4327ac8c3ee533bfaa6f18 Mon Sep 17 00:00:00 2001 From: 7Sageer <7sageer@djwcb.cn> Date: Thu, 6 Aug 2026 15:26:18 +0800 Subject: [PATCH 15/27] refactor(agent-core-v2): derive injector isNewTurn per injection boundary Replace the shared read-and-clear isNewTurn flag (set by turn.started and injectAfterCompaction, consumed by the first inject()) with values each trigger supplies from an authoritative source: the loop marks a turn's first step via BeforeStepContext.firstStepOfTurn (standalone runs never count), and the compaction follow-up passes true explicitly, so interleaved triggers can no longer consume or steal the marker. A compaction follow-up that lands inside a step hook chain (the auto-compaction path) doubles as that step's new-turn delivery: the enclosing step then injects with isNewTurn false, so the upcoming request receives one new-turn injection, not two. --- .../agent-core-v2/docs/state-manifest.d.ts | 5 +- .../contextInjector/contextInjectorService.ts | 66 +++++++++---------- packages/agent-core-v2/src/agent/loop/loop.ts | 1 + .../src/agent/loop/loopService.ts | 7 +- .../contextInjector/contextInjector.test.ts | 46 +++++++++++-- .../test/agent/goal/goal.test.ts | 17 +++++ .../goal/injection/goalInjection.test.ts | 21 +++--- .../test/agent/goal/tools/goal-tools.test.ts | 1 + .../agent-core-v2/test/agent/loop/stubs.ts | 6 +- .../agent-core-v2/test/agent/mcp/mcp.test.ts | 2 +- .../plan/injection/planModeInjection.test.ts | 4 +- .../test/agent/plan/plan.test.ts | 4 +- .../test/agent/plugin/agentPlugin.test.ts | 1 + .../test/agent/swarm/swarm.test.ts | 2 +- .../test/agent/toolDedupe/toolDedupe.test.ts | 3 +- .../toolSelect/toolSelectService.test.ts | 2 + .../externalHooksRunner/integration.test.ts | 1 + 17 files changed, 127 insertions(+), 62 deletions(-) diff --git a/packages/agent-core-v2/docs/state-manifest.d.ts b/packages/agent-core-v2/docs/state-manifest.d.ts index b378ab9288d..2917adbae9d 100644 --- a/packages/agent-core-v2/docs/state-manifest.d.ts +++ b/packages/agent-core-v2/docs/state-manifest.d.ts @@ -23,7 +23,7 @@ // references become '(circular)', and class instances collapse to a '(ClassName)' // marker — the wire shape of an entry is the JSON projection of the type here. // -// Index (App: 0 keys · Workspace: 6 keys · Session: 18 keys · Agent: 70 keys) +// Index (App: 0 keys · Workspace: 6 keys · Session: 18 keys · Agent: 69 keys) // App // Workspace // workspaceDirs.ephemeralDirs src/workspace/workspaceDirs/workspaceDirsService.ts @@ -61,7 +61,6 @@ // agentsMdReminder.cwd src/agent/agentsMdReminder/agentsMdReminderService.ts // agentsMdReminder.known src/agent/agentsMdReminder/agentsMdReminderService.ts // agentsMdReminder.seeded src/agent/agentsMdReminder/agentsMdReminderService.ts -// contextInjector.isNewTurn src/agent/contextInjector/contextInjectorService.ts // contextProjector.lastRepairSignature src/agent/contextProjector/contextProjectorService.ts // dateChange.seed src/agent/dateChange/dateChangeService.ts // externalHooks.stopHookContinuationUsed src/agent/externalHooks/externalHooksService.ts @@ -996,8 +995,6 @@ export interface AgentStateSnapshot { 'agentsMdReminder.cwd': string | undefined; 'agentsMdReminder.known': Set; 'agentsMdReminder.seeded': boolean; - // src/agent/contextInjector/contextInjectorService.ts - 'contextInjector.isNewTurn': boolean; // src/agent/contextProjector/contextProjectorService.ts 'contextProjector.lastRepairSignature': string | null; // src/agent/dateChange/dateChangeService.ts diff --git a/packages/agent-core-v2/src/agent/contextInjector/contextInjectorService.ts b/packages/agent-core-v2/src/agent/contextInjector/contextInjectorService.ts index d89e1bd123e..ee7f321ab4e 100644 --- a/packages/agent-core-v2/src/agent/contextInjector/contextInjectorService.ts +++ b/packages/agent-core-v2/src/agent/contextInjector/contextInjectorService.ts @@ -11,10 +11,10 @@ * skipped, so one bad channel cannot starve the providers. A provider that * throws or rejects at a step or compaction boundary is likewise logged and * skipped, so one bad provider can neither starve the rest nor fail the - * turn. Provider - * positions are never cached: each provider call derives them by scanning - * `contextMemory` for its own surviving injection messages, so splices, - * compaction folds, and `wire` restoration need no index bookkeeping. Each + * turn. Provider positions are never cached: each provider call derives + * them by scanning `contextMemory` for its own surviving injection + * messages, so splices, compaction folds, and `wire` restoration need no + * index bookkeeping. Each * provider call receives the newest surviving injection of its own variant * (`lastInjection`) and the typed disclosure recorded on it (`lastDisclosure`), * so providers never read context layout or position indexes themselves. @@ -29,20 +29,24 @@ * starve the rest); they are also reconciled at every later step * boundary as a fallback for facts that arrive after `turn.started` (for * example, a queued turn cancelled while another turn is already running). - * The plain-data `isNewTurn` flag is registered - * into `agentState` (`IAgentStateService`) and read/written through it; - * `entries` and `onceChannels` stay plain instance fields (their values - * hold functions, not plain data). Bound at Agent scope. + * `isNewTurn` is derived per boundary and never stored: the step hook reads + * it from the loop's own step counter (`BeforeStepContext.firstStepOfTurn`), + * and the compaction follow-up passes it explicitly, so interleaved triggers + * cannot consume or steal a shared flag. A compaction follow-up that lands + * inside a step hook chain (the auto-compaction path) doubles as that + * step's new-turn delivery — the enclosing step then injects with + * `isNewTurn: false`, so the upcoming request receives one new-turn + * injection, not two. `entries` and `onceChannels` stay plain instance + * fields (their values hold functions, not plain data). + * Bound at Agent scope. */ import { Disposable, toDisposable, type IDisposable } from "#/_base/di/lifecycle"; import { LifecycleScope, ScopeActivation, registerScopedService } from '#/_base/di/scope'; import { ILogService } from '#/_base/log/log'; -import { defineState } from '#/_base/state/stateRegistry'; import { IAgentContextMemoryService } from '#/agent/contextMemory/contextMemory'; -import { IAgentLoopService } from '#/agent/loop/loop'; -import { IAgentStateService } from '#/agent/state/agentState'; +import { IAgentLoopService, type BeforeStepContext } from '#/agent/loop/loop'; import { IAgentSystemReminderService } from '#/agent/systemReminder/systemReminder'; import { IEventBus } from '#/app/event/eventBus'; import type { ContextMessage } from '#/agent/contextMemory/types'; @@ -68,15 +72,12 @@ interface OnceChannelEntry { readonly drain: () => unknown; } -export const contextInjectorIsNewTurnKey = defineState( - 'contextInjector.isNewTurn', - () => true, -); - export class AgentContextInjectorService extends Disposable implements IAgentContextInjectorService { declare readonly _serviceBrand: undefined; private readonly entries = new Set(); private readonly onceChannels = new Set(); + private enclosingStep: BeforeStepContext | undefined; + private newTurnDeliveredTo: BeforeStepContext | undefined; constructor( @IAgentContextMemoryService private readonly context: IAgentContextMemoryService, @@ -85,20 +86,22 @@ export class AgentContextInjectorService extends Disposable implements IAgentCon @IEventBus private readonly eventBus: IEventBus, @ILogService private readonly log: ILogService, @IWireService wire: IWireService, - @IAgentStateService private readonly states: IAgentStateService, ) { super(); - this.states.register(contextInjectorIsNewTurnKey); this._register( - loopService.hooks.onWillBeginStep.register('context-injector', async (_ctx, next) => { - await next(); - await this.inject('step'); + loopService.hooks.onWillBeginStep.register('context-injector', async (ctx, next) => { + this.enclosingStep = ctx; + try { + await next(); + } finally { + this.enclosingStep = undefined; + } + await this.inject('step', ctx.firstStepOfTurn && this.newTurnDeliveredTo !== ctx); }), ); this._register( this.eventBus.subscribe('turn.started', () => { this.runOnceChannels(); - this.isNewTurn = true; this.injectAtTurnStart(); }), ); @@ -110,14 +113,6 @@ export class AgentContextInjectorService extends Disposable implements IAgentCon ); } - private get isNewTurn(): boolean { - return this.states.get(contextInjectorIsNewTurnKey); - } - - private set isNewTurn(value: boolean) { - this.states.set(contextInjectorIsNewTurnKey, value); - } - register( name: string, provider: ContextInjectionProvider, @@ -172,14 +167,15 @@ export class AgentContextInjectorService extends Disposable implements IAgentCon } async injectAfterCompaction(): Promise { - this.isNewTurn = true; - await this.inject(); + this.newTurnDeliveredTo = this.enclosingStep; + await this.inject(undefined, true); } - private async inject(boundary?: ContextInjectionEntry['boundary']): Promise { + private async inject( + boundary: ContextInjectionEntry['boundary'] | undefined, + isNewTurn: boolean, + ): Promise { this.runOnceChannels(); - const isNewTurn = this.isNewTurn; - this.isNewTurn = false; for (const entry of this.entries) { if (boundary !== undefined && !shouldRunAtBoundary(entry, boundary)) continue; let content: Awaited>; diff --git a/packages/agent-core-v2/src/agent/loop/loop.ts b/packages/agent-core-v2/src/agent/loop/loop.ts index d13c066c6a1..38853feb4bb 100644 --- a/packages/agent-core-v2/src/agent/loop/loop.ts +++ b/packages/agent-core-v2/src/agent/loop/loop.ts @@ -32,6 +32,7 @@ export function isMaxStepsExceededError(error: unknown): boolean { export interface BeforeStepContext { readonly turnId: number; readonly step: number; + readonly firstStepOfTurn: boolean; readonly signal: AbortSignal; } diff --git a/packages/agent-core-v2/src/agent/loop/loopService.ts b/packages/agent-core-v2/src/agent/loop/loopService.ts index 68d2e9cd844..c9bcbd08dc1 100644 --- a/packages/agent-core-v2/src/agent/loop/loopService.ts +++ b/packages/agent-core-v2/src/agent/loop/loopService.ts @@ -618,6 +618,7 @@ export class AgentLoopService extends Disposable implements IAgentLoopService { begun.step.signal, runtime.turnSignal, begun.step.number, + runtime.job !== undefined && begun.step.number === 1, begun.step.uuid, options.onStarted, ); @@ -802,11 +803,12 @@ export class AgentLoopService extends Disposable implements IAgentLoopService { signal: AbortSignal, turnSignal: AbortSignal, currentStep: number, + firstStepOfTurn: boolean, stepUuid: string, onStarted: ((step: number) => void) | undefined, ): Promise { this.activeRequestTrace = undefined; - await this.hooks.onWillBeginStep.run({ turnId, step: currentStep, signal }); + await this.hooks.onWillBeginStep.run({ turnId, step: currentStep, firstStepOfTurn, signal }); const markStepStarted = this.beginStep(turnId, signal, currentStep, stepUuid, onStarted); const streamParts = this.createStreamPartHandler(turnId, markStepStarted); const request = this.llmRequester.start( @@ -837,6 +839,7 @@ export class AgentLoopService extends Disposable implements IAgentLoopService { turnId, signal, currentStep, + firstStepOfTurn, response.usage, finishReason, ); @@ -994,12 +997,14 @@ export class AgentLoopService extends Disposable implements IAgentLoopService { turnId: number, signal: AbortSignal, currentStep: number, + firstStepOfTurn: boolean, usage: TokenUsage, finishReason: FinishReason, ): Promise { const context: AfterStepContext = { turnId, step: currentStep, + firstStepOfTurn, signal, usage, finishReason, diff --git a/packages/agent-core-v2/test/agent/contextInjector/contextInjector.test.ts b/packages/agent-core-v2/test/agent/contextInjector/contextInjector.test.ts index deb0f21673f..f71a4525f5e 100644 --- a/packages/agent-core-v2/test/agent/contextInjector/contextInjector.test.ts +++ b/packages/agent-core-v2/test/agent/contextInjector/contextInjector.test.ts @@ -39,7 +39,7 @@ import { } from '../loop/stubs'; type InjectableContextInjector = IAgentContextInjectorService & { - inject(): Promise; + inject(boundary: undefined, isNewTurn: boolean): Promise; }; function injector(ix: TestInstantiationService): InjectableContextInjector { @@ -97,8 +97,8 @@ describe('AgentContextInjectorService', () => { disposables.dispose(); }); - async function runInjectionStep(): Promise { - await runWillBeginStepHooks(loop); + async function runInjectionStep(firstStepOfTurn = false): Promise { + await runWillBeginStepHooks(loop, firstStepOfTurn); } function spliceContext( @@ -323,7 +323,7 @@ describe('AgentContextInjectorService', () => { return isNewTurn ? 'per-turn reminder' : undefined; }); - await runInjectionStep(); + await runInjectionStep(true); await runInjectionStep(); spliceContext(0, 1, [compactionSummary('Compacted summary.')]); await injector(ix).injectAfterCompaction(); @@ -335,6 +335,40 @@ describe('AgentContextInjectorService', () => { ]); }); + it('keeps the first step marked as a new turn when compaction lands in between', async () => { + const seen: boolean[] = []; + injector(ix).register('per_turn_test', ({ isNewTurn }) => { + seen.push(isNewTurn); + return undefined; + }); + + ix.get(IEventBus).publish({ + type: 'turn.started', + turnId: 0, + origin: { kind: 'user' }, + }); + await injector(ix).injectAfterCompaction(); + await runInjectionStep(true); + + expect(seen).toEqual([true, true]); + }); + + it('delivers one new-turn injection when compaction fires inside the step hook chain', async () => { + const seen: boolean[] = []; + injector(ix).register('per_turn_test', ({ isNewTurn }) => { + seen.push(isNewTurn); + return undefined; + }); + loop.hooks.onWillBeginStep.register('test-compaction', async (_ctx, next) => { + await injector(ix).injectAfterCompaction(); + await next(); + }); + + await runInjectionStep(true); + + expect(seen).toEqual([true, false]); + }); + it('drains once-channels before running the providers', async () => { const calls: string[] = []; injector(ix).registerOnceChannel('test_channel', () => { @@ -345,7 +379,7 @@ describe('AgentContextInjectorService', () => { return undefined; }); - await injector(ix).inject(); + await injector(ix).inject(undefined, false); expect(calls).toEqual(['once-channel', 'provider']); }); @@ -363,7 +397,7 @@ describe('AgentContextInjectorService', () => { return undefined; }); - await injector(ix).inject(); + await injector(ix).inject(undefined, false); expect(calls).toEqual(['surviving', 'provider']); }); diff --git a/packages/agent-core-v2/test/agent/goal/goal.test.ts b/packages/agent-core-v2/test/agent/goal/goal.test.ts index fc1e5121e77..8a61794e62b 100644 --- a/packages/agent-core-v2/test/agent/goal/goal.test.ts +++ b/packages/agent-core-v2/test/agent/goal/goal.test.ts @@ -216,11 +216,13 @@ async function runGoalStep(loopService: StubLoop, turn: Turn): Promise const step = { turnId: turn.id, step: 1, + firstStepOfTurn: true, signal: turn.signal, }; const afterStep: AfterStepContext = { turnId: turn.id, step: 1, + firstStepOfTurn: true, signal: turn.signal, usage: zeroUsage, finishReason: 'completed' as const, @@ -908,6 +910,7 @@ describe('AgentGoalService core workflow hooks', () => { await loopService.hooks.onWillBeginStep.run({ turnId: turn.id, step: 1, + firstStepOfTurn: true, signal: turn.signal, }); @@ -933,6 +936,7 @@ describe('AgentGoalService core workflow hooks', () => { await loopService.hooks.onWillBeginStep.run({ turnId: turn.id, step: 1, + firstStepOfTurn: true, signal: turn.signal, }); @@ -978,6 +982,7 @@ describe('AgentGoalService core workflow hooks', () => { await loopService.hooks.onWillBeginStep.run({ turnId: turn.id, step: 1, + firstStepOfTurn: true, signal: turn.signal, }); const toolCall: ToolCall = { @@ -1009,6 +1014,7 @@ describe('AgentGoalService core workflow hooks', () => { await loopService.hooks.onWillBeginStep.run({ turnId: turn.id, step: 1, + firstStepOfTurn: true, signal: turn.signal, }); const toolCall: ToolCall = { @@ -1036,6 +1042,7 @@ describe('AgentGoalService core workflow hooks', () => { await loopService.hooks.onWillBeginStep.run({ turnId: oldTurn.id, step: 1, + firstStepOfTurn: true, signal: oldTurn.signal, }); recordStepUsage(usageService, goals, oldTurn, { ...zeroUsage, output: 5 }); @@ -1061,6 +1068,7 @@ describe('AgentGoalService core workflow hooks', () => { await loopService.hooks.onDidFinishStep.run({ turnId: oldTurn.id, step: 1, + firstStepOfTurn: true, signal: oldTurn.signal, usage: zeroUsage, finishReason: 'completed', @@ -1147,6 +1155,7 @@ describe('AgentGoalService core workflow hooks', () => { await loopService.hooks.onWillBeginStep.run({ turnId: continuationTurn.id, step: 1, + firstStepOfTurn: true, signal: continuationTurn.signal, }); recordStepUsage(usageService, goals, continuationTurn, { ...zeroUsage, output: 7 }); @@ -1310,6 +1319,7 @@ describe('AgentGoalService core workflow hooks', () => { await loopService.hooks.onWillBeginStep.run({ turnId: turn.id, step: 1, + firstStepOfTurn: true, signal: turn.signal, }); @@ -1321,6 +1331,7 @@ describe('AgentGoalService core workflow hooks', () => { const afterStep: AfterStepContext = { turnId: turn.id, step: 1, + firstStepOfTurn: true, signal: turn.signal, usage: zeroUsage, finishReason: 'completed', @@ -1360,6 +1371,7 @@ describe('AgentGoalService core workflow hooks', () => { await loopService.hooks.onWillBeginStep.run({ turnId: continuation.id, step: 1, + firstStepOfTurn: true, signal: continuation.signal, }); @@ -1380,6 +1392,7 @@ describe('AgentGoalService core workflow hooks', () => { await loopService.hooks.onWillBeginStep.run({ turnId: turn.id, step: 1, + firstStepOfTurn: true, signal: turn.signal, }); await goals.markBlocked({}, 'model'); @@ -1388,6 +1401,7 @@ describe('AgentGoalService core workflow hooks', () => { const afterStep: AfterStepContext = { turnId: turn.id, step: 1, + firstStepOfTurn: true, signal: turn.signal, usage: zeroUsage, finishReason: 'completed', @@ -1511,11 +1525,13 @@ describe('AgentGoalService core workflow hooks', () => { const step = { turnId: turn.id, step: 1, + firstStepOfTurn: true, signal: turn.signal, }; const afterStep: AfterStepContext = { turnId: turn.id, step: 1, + firstStepOfTurn: true, signal: turn.signal, usage: zeroUsage, finishReason: 'completed' as const, @@ -1537,6 +1553,7 @@ describe('AgentGoalService core workflow hooks', () => { const secondAfterStep: AfterStepContext = { turnId: turn.id, step: 2, + firstStepOfTurn: false, signal: turn.signal, usage: zeroUsage, finishReason: 'completed' as const, diff --git a/packages/agent-core-v2/test/agent/goal/injection/goalInjection.test.ts b/packages/agent-core-v2/test/agent/goal/injection/goalInjection.test.ts index 3cf96af16a3..28ff16bc8b7 100644 --- a/packages/agent-core-v2/test/agent/goal/injection/goalInjection.test.ts +++ b/packages/agent-core-v2/test/agent/goal/injection/goalInjection.test.ts @@ -17,10 +17,15 @@ import { import { stubAgentSwarm } from '../stubs'; type GoalServiceTestManager = IAgentGoalService & AgentGoalService; -type InjectableContextInjector = IAgentContextInjectorService & { inject(): Promise }; +type InjectableContextInjector = IAgentContextInjectorService & { + inject(boundary: undefined, isNewTurn: boolean): Promise; +}; -async function injectDynamic(injector: InjectableContextInjector): Promise { - await injector.inject(); +async function injectDynamic( + injector: InjectableContextInjector, + isNewTurn: boolean, +): Promise { + await injector.inject(undefined, isNewTurn); } async function registerLookupTool( @@ -76,7 +81,7 @@ describe('GoalInjection content', () => { configure: (goals: GoalServiceTestManager) => Promise, ): Promise { await configure(goals); - await injectDynamic(injector); + await injectDynamic(injector, true); return lastGoalReminder(context); } @@ -292,7 +297,7 @@ describe('GoalInjection integration', () => { it('main-agent dynamic injection writes a context.append_message with origin.variant goal', async () => { await goals.createGoal({ objective: 'Ship feature X' }); - await injectDynamic(injector); + await injectDynamic(injector, true); const goalRecords = await flushedGoalReminderRecords(ctx, persistence); expect(goalRecords).toHaveLength(1); @@ -303,8 +308,8 @@ describe('GoalInjection integration', () => { it('dynamic injection writes at most once for one turn boundary', async () => { await goals.createGoal({ objective: 'Ship feature X' }); - await injectDynamic(injector); - await injectDynamic(injector); + await injectDynamic(injector, true); + await injectDynamic(injector, false); await expect(flushedGoalReminderRecords(ctx, persistence)).resolves.toHaveLength(1); }); @@ -370,7 +375,7 @@ describe('GoalInjection integration', () => { }); it('writes no goal record when there is no active goal', async () => { - await injectDynamic(injector); + await injectDynamic(injector, true); await expect(flushedGoalReminderRecords(ctx, persistence)).resolves.toHaveLength(0); }); diff --git a/packages/agent-core-v2/test/agent/goal/tools/goal-tools.test.ts b/packages/agent-core-v2/test/agent/goal/tools/goal-tools.test.ts index 27984824f8a..07f36943f62 100644 --- a/packages/agent-core-v2/test/agent/goal/tools/goal-tools.test.ts +++ b/packages/agent-core-v2/test/agent/goal/tools/goal-tools.test.ts @@ -402,6 +402,7 @@ describe('goal tools', () => { await loopService.hooks.onWillBeginStep.run({ turnId, step: 1, + firstStepOfTurn: true, signal: abortController.signal, }); } diff --git a/packages/agent-core-v2/test/agent/loop/stubs.ts b/packages/agent-core-v2/test/agent/loop/stubs.ts index 227bf84b3c1..7f16a87ff03 100644 --- a/packages/agent-core-v2/test/agent/loop/stubs.ts +++ b/packages/agent-core-v2/test/agent/loop/stubs.ts @@ -79,10 +79,14 @@ export function stubLoopWithHooks(options: StubLoopOptions = {}): StubLoop { }; return stub; } -export async function runWillBeginStepHooks(loop: IAgentLoopService): Promise { +export async function runWillBeginStepHooks( + loop: IAgentLoopService, + firstStepOfTurn = false, +): Promise { await loop.hooks.onWillBeginStep.run({ turnId: 0, step: 0, + firstStepOfTurn, signal: new AbortController().signal, }); } diff --git a/packages/agent-core-v2/test/agent/mcp/mcp.test.ts b/packages/agent-core-v2/test/agent/mcp/mcp.test.ts index 7754432d5ed..c90630fc465 100644 --- a/packages/agent-core-v2/test/agent/mcp/mcp.test.ts +++ b/packages/agent-core-v2/test/agent/mcp/mcp.test.ts @@ -262,7 +262,7 @@ describe('AgentMcpService', () => { const loop = ix.get(IAgentLoopService); let settled = false; const step = loop.hooks.onWillBeginStep - .run({ turnId: 1, step: 1, signal: new AbortController().signal }) + .run({ turnId: 1, step: 1, firstStepOfTurn: true, signal: new AbortController().signal }) .then(() => { settled = true; }); diff --git a/packages/agent-core-v2/test/agent/plan/injection/planModeInjection.test.ts b/packages/agent-core-v2/test/agent/plan/injection/planModeInjection.test.ts index 87df3bd2900..ed2c270df3d 100644 --- a/packages/agent-core-v2/test/agent/plan/injection/planModeInjection.test.ts +++ b/packages/agent-core-v2/test/agent/plan/injection/planModeInjection.test.ts @@ -12,7 +12,7 @@ import { } from '../../../harness'; type InjectableDynamicInjector = { - inject(): Promise; + inject(boundary: undefined, isNewTurn: boolean): Promise; }; async function enterPlan( @@ -28,7 +28,7 @@ async function enterPlan( } async function injectDynamic(injector: InjectableDynamicInjector): Promise { - await injector.inject(); + await injector.inject(undefined, false); } function appendAssistantTurn( diff --git a/packages/agent-core-v2/test/agent/plan/plan.test.ts b/packages/agent-core-v2/test/agent/plan/plan.test.ts index cb2dccf472e..bcf5ae205f6 100644 --- a/packages/agent-core-v2/test/agent/plan/plan.test.ts +++ b/packages/agent-core-v2/test/agent/plan/plan.test.ts @@ -78,7 +78,7 @@ function createPlanFileFakes( } type InjectableDynamicInjector = { - inject(): Promise; + inject(boundary: undefined, isNewTurn: boolean): Promise; }; describe('Plan service', () => { @@ -919,7 +919,7 @@ describe('Plan service', () => { } async function injectDynamic(): Promise { - await injector.inject(); + await injector.inject(undefined, false); } }); diff --git a/packages/agent-core-v2/test/agent/plugin/agentPlugin.test.ts b/packages/agent-core-v2/test/agent/plugin/agentPlugin.test.ts index 982e57e90fb..f4c871e93ac 100644 --- a/packages/agent-core-v2/test/agent/plugin/agentPlugin.test.ts +++ b/packages/agent-core-v2/test/agent/plugin/agentPlugin.test.ts @@ -53,6 +53,7 @@ async function runInjectionBoundary(ctx: TestAgentContext): Promise { await ctx.get(IAgentLoopService).hooks.onWillBeginStep.run({ turnId: 0, step: 1, + firstStepOfTurn: true, signal: new AbortController().signal, }); } diff --git a/packages/agent-core-v2/test/agent/swarm/swarm.test.ts b/packages/agent-core-v2/test/agent/swarm/swarm.test.ts index 4b3e06dd7a7..fe643f1044d 100644 --- a/packages/agent-core-v2/test/agent/swarm/swarm.test.ts +++ b/packages/agent-core-v2/test/agent/swarm/swarm.test.ts @@ -71,7 +71,7 @@ import { createTestAgent } from '../../harness'; const signal = new AbortController().signal; async function runInjectionBoundary(loop: IAgentLoopService): Promise { - await loop.hooks.onWillBeginStep.run({ turnId: 0, step: 1, signal }); + await loop.hooks.onWillBeginStep.run({ turnId: 0, step: 1, firstStepOfTurn: true, signal }); } function context( diff --git a/packages/agent-core-v2/test/agent/toolDedupe/toolDedupe.test.ts b/packages/agent-core-v2/test/agent/toolDedupe/toolDedupe.test.ts index 4e1c5325c6c..f1fea028d01 100644 --- a/packages/agent-core-v2/test/agent/toolDedupe/toolDedupe.test.ts +++ b/packages/agent-core-v2/test/agent/toolDedupe/toolDedupe.test.ts @@ -167,7 +167,7 @@ function beforeStep( step: number, signal = new AbortController().signal, ): Promise { - return h.loop.hooks.onWillBeginStep.run({ turnId, step, signal }); + return h.loop.hooks.onWillBeginStep.run({ turnId, step, firstStepOfTurn: step === 1, signal }); } function afterStep( @@ -179,6 +179,7 @@ function afterStep( return h.loop.hooks.onDidFinishStep.run({ turnId, step, + firstStepOfTurn: step === 1, signal, usage: ZERO_USAGE, finishReason: 'completed', diff --git a/packages/agent-core-v2/test/agent/toolSelect/toolSelectService.test.ts b/packages/agent-core-v2/test/agent/toolSelect/toolSelectService.test.ts index b91822af96d..82346352748 100644 --- a/packages/agent-core-v2/test/agent/toolSelect/toolSelectService.test.ts +++ b/packages/agent-core-v2/test/agent/toolSelect/toolSelectService.test.ts @@ -422,6 +422,7 @@ async function announce(h: Harness, step = 1): Promise { await h.loop.hooks.onWillBeginStep.run({ turnId: 1, step, + firstStepOfTurn: step === 1, signal: new AbortController().signal, }); const announcement = h.contextMemory.appended.slice(before).find(isNewAnnouncement); @@ -447,6 +448,7 @@ async function declareSchemas(h: Harness, step = 1): Promise Date: Thu, 6 Aug 2026 15:52:28 +0800 Subject: [PATCH 16/27] refactor(agent-core-v2): unify disclosure placement and injector param naming --- .../src/agent/dateChange/dateChange.ts | 12 ++++++++++++ .../src/agent/dateChange/dateChangeService.ts | 13 +++---------- .../agent-core-v2/src/agent/goal/goalService.ts | 4 ++-- .../src/agent/goal/injection/goalInjection.ts | 4 ++-- .../injection/permissionModeInjection.ts | 4 ++-- .../src/agent/plan/injection/planModeInjection.ts | 4 ++-- .../agent-core-v2/src/agent/plan/planService.ts | 4 ++-- .../src/agent/swarm/injection/swarmInjection.ts | 4 ++-- .../toolSelect/toolSelectAnnouncementsService.ts | 4 ++-- .../agent/toolSelect/toolSelectSchemasService.ts | 4 ++-- 10 files changed, 31 insertions(+), 26 deletions(-) diff --git a/packages/agent-core-v2/src/agent/dateChange/dateChange.ts b/packages/agent-core-v2/src/agent/dateChange/dateChange.ts index cccb3396eba..e78be7d56e3 100644 --- a/packages/agent-core-v2/src/agent/dateChange/dateChange.ts +++ b/packages/agent-core-v2/src/agent/dateChange/dateChange.ts @@ -8,6 +8,18 @@ import { createDecorator, type ServiceIdentifier } from '#/_base/di/instantiation'; +/** + * Typed disclosure recorded on `date_change` injections: the render baseline + * the model has most recently seen, handed back to the provider on the next + * reconciliation. + */ +export interface DateInjectionDisclosure { + readonly kind: 'date'; + readonly renderGeneration: number; + readonly localDate: string; + readonly timeZone: string; +} + export interface IAgentDateChangeService { readonly _serviceBrand: undefined; } diff --git a/packages/agent-core-v2/src/agent/dateChange/dateChangeService.ts b/packages/agent-core-v2/src/agent/dateChange/dateChangeService.ts index 2d8db5bce45..cd8f78e7ffc 100644 --- a/packages/agent-core-v2/src/agent/dateChange/dateChangeService.ts +++ b/packages/agent-core-v2/src/agent/dateChange/dateChangeService.ts @@ -29,7 +29,7 @@ import { IAgentStateService } from '#/agent/state/agentState'; import { IHostClock } from '#/os/interface/hostClock'; import { ISessionContext } from '#/session/sessionContext/sessionContext'; -import { IAgentDateChangeService } from './dateChange'; +import { type DateInjectionDisclosure, IAgentDateChangeService } from './dateChange'; const DATE_CHANGE_INJECTION_VARIANT = 'date_change'; @@ -42,7 +42,7 @@ export class AgentDateChangeService extends Disposable implements IAgentDateChan declare readonly _serviceBrand: undefined; constructor( - @IAgentContextInjectorService dynamicInjector: IAgentContextInjectorService, + @IAgentContextInjectorService injector: IAgentContextInjectorService, @IAgentProfileService private readonly profile: IAgentProfileService, @IAgentStateService private readonly states: IAgentStateService, @IHostClock private readonly clock: IHostClock, @@ -51,7 +51,7 @@ export class AgentDateChangeService extends Disposable implements IAgentDateChan super(); this.states.register(dateChangeSeedKey); this._register( - dynamicInjector.register( + injector.register( DATE_CHANGE_INJECTION_VARIANT, (ctx) => this.reminder(ctx), ), @@ -112,13 +112,6 @@ export class AgentDateChangeService extends Disposable implements IAgentDateChan } } -export interface DateInjectionDisclosure { - readonly kind: 'date'; - readonly renderGeneration: number; - readonly localDate: string; - readonly timeZone: string; -} - interface DateDisclosure { readonly localDate: string; readonly timeZone: string; diff --git a/packages/agent-core-v2/src/agent/goal/goalService.ts b/packages/agent-core-v2/src/agent/goal/goalService.ts index 3467fdec44a..f370da9d97c 100644 --- a/packages/agent-core-v2/src/agent/goal/goalService.ts +++ b/packages/agent-core-v2/src/agent/goal/goalService.ts @@ -286,7 +286,7 @@ export class AgentGoalService extends Disposable implements IAgentGoalService { @IEventBus private readonly eventBus: IEventBus, @IAgentReminderQueueService private readonly reminderQueue: IAgentReminderQueueService, @ITelemetryService private readonly telemetry: ITelemetryService, - @IAgentContextInjectorService dynamicInjector: IAgentContextInjectorService, + @IAgentContextInjectorService injector: IAgentContextInjectorService, @IAgentLoopService private readonly loopService: IAgentLoopService, @IAgentToolExecutorService toolExecutor: IAgentToolExecutorService, @IAgentToolApprovalService private readonly toolApproval: IAgentToolApprovalService, @@ -316,7 +316,7 @@ export class AgentGoalService extends Disposable implements IAgentGoalService { { getGoal: () => this.getGoal().goal, }, - dynamicInjector, + injector, ), ); this._register( diff --git a/packages/agent-core-v2/src/agent/goal/injection/goalInjection.ts b/packages/agent-core-v2/src/agent/goal/injection/goalInjection.ts index 040f75a082c..8385f42cf23 100644 --- a/packages/agent-core-v2/src/agent/goal/injection/goalInjection.ts +++ b/packages/agent-core-v2/src/agent/goal/injection/goalInjection.ts @@ -13,11 +13,11 @@ export interface GoalInjectionOptions { export class GoalInjection extends Disposable { constructor( private readonly options: GoalInjectionOptions, - @IAgentContextInjectorService dynamicInjector: IAgentContextInjectorService, + @IAgentContextInjectorService injector: IAgentContextInjectorService, ) { super(); this._register( - dynamicInjector.register('goal', ({ isNewTurn }) => (isNewTurn ? this.reminder() : undefined)), + injector.register('goal', ({ isNewTurn }) => (isNewTurn ? this.reminder() : undefined)), ); } diff --git a/packages/agent-core-v2/src/agent/permissionMode/injection/permissionModeInjection.ts b/packages/agent-core-v2/src/agent/permissionMode/injection/permissionModeInjection.ts index 05f4d1a86f3..48a22fb98cf 100644 --- a/packages/agent-core-v2/src/agent/permissionMode/injection/permissionModeInjection.ts +++ b/packages/agent-core-v2/src/agent/permissionMode/injection/permissionModeInjection.ts @@ -32,13 +32,13 @@ export const permissionModeLastModeKey = defineState export class PermissionModeInjection extends Disposable { constructor( private readonly permissionMode: Pick, - @IAgentContextInjectorService dynamicInjector: IAgentContextInjectorService, + @IAgentContextInjectorService injector: IAgentContextInjectorService, @IAgentStateService private readonly states: IAgentStateService, ) { super(); this.states.register(permissionModeLastModeKey); this._register( - dynamicInjector.register(PERMISSION_MODE_INJECTION_VARIANT, (ctx) => this.reminder(ctx)), + injector.register(PERMISSION_MODE_INJECTION_VARIANT, (ctx) => this.reminder(ctx)), ); } diff --git a/packages/agent-core-v2/src/agent/plan/injection/planModeInjection.ts b/packages/agent-core-v2/src/agent/plan/injection/planModeInjection.ts index 454a5059dd5..2842ad60c15 100644 --- a/packages/agent-core-v2/src/agent/plan/injection/planModeInjection.ts +++ b/packages/agent-core-v2/src/agent/plan/injection/planModeInjection.ts @@ -34,7 +34,7 @@ export const planWasActiveKey = defineState('plan.wasActive', () => fal export class PlanModeInjection extends Disposable { constructor( - @IAgentContextInjectorService dynamicInjector: IAgentContextInjectorService, + @IAgentContextInjectorService injector: IAgentContextInjectorService, @IAgentPlanService private readonly plan: IAgentPlanService, @IAgentContextMemoryService private readonly context: IAgentContextMemoryService, @IAgentStateService private readonly states: IAgentStateService, @@ -43,7 +43,7 @@ export class PlanModeInjection extends Disposable { this.states.register(planWasActiveKey); this._register( - dynamicInjector.register(PLAN_MODE_INJECTION_VARIANT, async ({ lastInjectedAt: injectedAt }) => { + injector.register(PLAN_MODE_INJECTION_VARIANT, async ({ lastInjectedAt: injectedAt }) => { const data = await this.plan.status(); if (data === null) { if (!this.states.get(planWasActiveKey)) return undefined; diff --git a/packages/agent-core-v2/src/agent/plan/planService.ts b/packages/agent-core-v2/src/agent/plan/planService.ts index 5ca8ddb29d7..23ffbf87eba 100644 --- a/packages/agent-core-v2/src/agent/plan/planService.ts +++ b/packages/agent-core-v2/src/agent/plan/planService.ts @@ -73,7 +73,7 @@ export class AgentPlanService extends Disposable implements IAgentPlanService { @IAgentContextMemoryService private readonly context: IAgentContextMemoryService, @IHostFileSystem private readonly hostFs: IHostFileSystem, @IBlobStore private readonly blobs: IBlobStore, - @IAgentContextInjectorService dynamicInjector: IAgentContextInjectorService, + @IAgentContextInjectorService injector: IAgentContextInjectorService, @IAgentTelemetryContextService private readonly telemetryContext: IAgentTelemetryContextService, @IEventBus eventBus: IEventBus, @IWireService private readonly wire: IWireService, @@ -105,7 +105,7 @@ export class AgentPlanService extends Disposable implements IAgentPlanService { }), ); - this._register(new PlanModeInjection(dynamicInjector, this, this.context, states)); + this._register(new PlanModeInjection(injector, this, this.context, states)); this._register(this.registerPlanGuard(toolExecutor)); } diff --git a/packages/agent-core-v2/src/agent/swarm/injection/swarmInjection.ts b/packages/agent-core-v2/src/agent/swarm/injection/swarmInjection.ts index 2c44e3ce735..3fd0255c230 100644 --- a/packages/agent-core-v2/src/agent/swarm/injection/swarmInjection.ts +++ b/packages/agent-core-v2/src/agent/swarm/injection/swarmInjection.ts @@ -41,12 +41,12 @@ export interface SwarmInjectionOptions { export class SwarmInjection extends Disposable { constructor( private readonly options: SwarmInjectionOptions, - @IAgentContextInjectorService dynamicInjector: IAgentContextInjectorService, + @IAgentContextInjectorService injector: IAgentContextInjectorService, @IAgentContextMemoryService private readonly context: IAgentContextMemoryService, ) { super(); this._register( - dynamicInjector.register( + injector.register( SWARM_MODE_INJECTION_VARIANT, (ctx) => this.reminder(ctx), ), diff --git a/packages/agent-core-v2/src/agent/toolSelect/toolSelectAnnouncementsService.ts b/packages/agent-core-v2/src/agent/toolSelect/toolSelectAnnouncementsService.ts index be55406f0a3..3378eaccd09 100644 --- a/packages/agent-core-v2/src/agent/toolSelect/toolSelectAnnouncementsService.ts +++ b/packages/agent-core-v2/src/agent/toolSelect/toolSelectAnnouncementsService.ts @@ -24,11 +24,11 @@ export class AgentToolSelectAnnouncementsService extends Disposable implements I constructor( @IAgentToolSelectService toolSelect: IAgentToolSelectService, - @IAgentContextInjectorService dynamicInjector: IAgentContextInjectorService, + @IAgentContextInjectorService injector: IAgentContextInjectorService, ) { super(); this._register( - dynamicInjector.register(LOADABLE_TOOLS_VARIANT, ({ isNewTurn }) => + injector.register(LOADABLE_TOOLS_VARIANT, ({ isNewTurn }) => isNewTurn ? toolSelect.loadableToolsAnnouncement() : undefined, ), ); diff --git a/packages/agent-core-v2/src/agent/toolSelect/toolSelectSchemasService.ts b/packages/agent-core-v2/src/agent/toolSelect/toolSelectSchemasService.ts index 0d6f9890aae..8a3b5371ba7 100644 --- a/packages/agent-core-v2/src/agent/toolSelect/toolSelectSchemasService.ts +++ b/packages/agent-core-v2/src/agent/toolSelect/toolSelectSchemasService.ts @@ -24,11 +24,11 @@ export class AgentToolSelectSchemasService extends Disposable implements IAgentT constructor( @IAgentToolSelectService toolSelect: IAgentToolSelectService, - @IAgentContextInjectorService dynamicInjector: IAgentContextInjectorService, + @IAgentContextInjectorService injector: IAgentContextInjectorService, ) { super(); this._register( - dynamicInjector.register(DYNAMIC_TOOL_SCHEMA_VARIANT, () => { + injector.register(DYNAMIC_TOOL_SCHEMA_VARIANT, () => { const tools = toolSelect.drainPendingToolSchemas(); if (tools === undefined) return undefined; return { message: { role: 'system', content: [], tools } }; From 49d7dfa9c54f48e97f1e8cdc73e38626c1b2f97e Mon Sep 17 00:00:00 2001 From: 7Sageer <7sageer@djwcb.cn> Date: Thu, 6 Aug 2026 16:42:52 +0800 Subject: [PATCH 17/27] fix(agent-core-v2): keep pending tool schemas across compaction splices MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A load announced by select_tools sits in pendingLoaded until the next injection boundary declares it. A compaction fold in that window publishes a replacement splice, and the splice-time reconciliation dropped the pending entries before the post-compaction inject could declare them — the model was told "Loaded: X" yet X never became available. Drop pending entries only on removal splices (undo/clear, which carry no replacement messages); compaction's replacement splice keeps them so the declaration lands at the post-compaction boundary. --- .../src/agent/toolSelect/toolSelectService.ts | 7 +++++-- .../agent/toolSelect/toolSelectService.test.ts | 16 ++++++++++++++++ 2 files changed, 21 insertions(+), 2 deletions(-) diff --git a/packages/agent-core-v2/src/agent/toolSelect/toolSelectService.ts b/packages/agent-core-v2/src/agent/toolSelect/toolSelectService.ts index e52d5f34ab0..7782640ddd5 100644 --- a/packages/agent-core-v2/src/agent/toolSelect/toolSelectService.ts +++ b/packages/agent-core-v2/src/agent/toolSelect/toolSelectService.ts @@ -5,7 +5,10 @@ * disclosure, tracks loaded dynamic schemas as pending declarations drained * by the `contextInjector` boundary provider (the declaration lands at a * quiescent boundary instead of mid-step inside a streaming tool exchange), - * and exposes loadable-tools announcement text. Reads live tools from + * and exposes loadable-tools announcement text. Removal splices + * (`undo`/`clear`) drop pending entries whose announcing exchange left the + * conversation, while compaction's replacement splice keeps them, so the + * declaration still lands at the post-compaction boundary. Reads live tools from * `toolRegistry`, active-tool and capability state from `profile`, gates * through `flag`, hooks into `toolExecutor`, and listens to context * lifecycle events through `event`. The mutable load-tracking state @@ -75,7 +78,7 @@ export class AgentToolSelectService extends Disposable implements IAgentToolSele ); this._register( eventBus.subscribe('context.spliced', (splice) => { - if (splice.deleteCount === 0 || this.pendingLoaded.size === 0) return; + if (splice.deleteCount === 0 || splice.messages.length > 0) return; this.dropPendingLoadedNotLanded(); }), ); diff --git a/packages/agent-core-v2/test/agent/toolSelect/toolSelectService.test.ts b/packages/agent-core-v2/test/agent/toolSelect/toolSelectService.test.ts index 82346352748..f0c3dee0767 100644 --- a/packages/agent-core-v2/test/agent/toolSelect/toolSelectService.test.ts +++ b/packages/agent-core-v2/test/agent/toolSelect/toolSelectService.test.ts @@ -805,6 +805,22 @@ describe('AgentToolSelectService.load', () => { expect(h.sut.load([MCP_ALPHA]).toLoad).toEqual([MCP_ALPHA]); }); + it('keeps the pending ledger across a compaction replacement splice', async () => { + const h = createHarness(); + registerMcp(h, new StubMcpTool(MCP_ALPHA)); + + h.sut.load([MCP_ALPHA]); + h.eventBus.emit('context.spliced', { + start: 0, + deleteCount: 2, + messages: [userMessage('Compacted summary.')], + }); + + expect(h.sut.load([MCP_ALPHA]).alreadyAvailable).toEqual([MCP_ALPHA]); + const declared = await declareSchemas(h); + expect(declared?.tools?.map((tool) => tool.name)).toEqual([MCP_ALPHA]); + }); + it('reconciles the pending ledger with history when a mid-history splice removes schema messages', async () => { const h = createHarness(); registerMcp(h, new StubMcpTool(MCP_ALPHA)); From 04195a7435d1242901949b99814d8936faddae4e Mon Sep 17 00:00:00 2001 From: 7Sageer <7sageer@djwcb.cn> Date: Thu, 6 Aug 2026 16:42:58 +0800 Subject: [PATCH 18/27] fix(agent-core-v2): consume the plugin session-start refresh after a successful render reconcileSessionStartReminder cleared the refresh-pending flag before awaiting the render, so a throwing render (skipped by the injector's provider isolation) lost the forced refresh until the next catalog change. Consume the flag only after the render resolves, and move the warn-once rationale into the module header per the comment convention. --- .../src/agent/plugin/agentPluginService.ts | 10 ++++++---- 1 file changed, 6 insertions(+), 4 deletions(-) diff --git a/packages/agent-core-v2/src/agent/plugin/agentPluginService.ts b/packages/agent-core-v2/src/agent/plugin/agentPluginService.ts index 4636c25ad33..54afd0c61c3 100644 --- a/packages/agent-core-v2/src/agent/plugin/agentPluginService.ts +++ b/packages/agent-core-v2/src/agent/plugin/agentPluginService.ts @@ -8,7 +8,11 @@ * self-gates on `agentId === 'main'`; Agent scope creation instantiates it for * every agent, so other agents construct it as a no-op. Resolves session * prompt context through `sessionContext` and reports missing skills through - * `log`; stores the refresh signal through `agentState`. Bound at Agent scope. + * `log` (once per plugin:skill key — the provider re-renders on every + * boundary, so an unguarded warn would repeat every step); stores the + * refresh signal through `agentState`, consumed only after a successful + * render so a failed render retries at the next boundary. Bound at Agent + * scope. */ import { Disposable } from '#/_base/di/lifecycle'; @@ -102,8 +106,8 @@ export class AgentPluginService extends Disposable implements IAgentPluginServic injection: ContextInjectionContext, ): Promise { const forceRefresh = this.refreshPending; - this.refreshPending = false; const desired = await this.renderSessionStartReminder(); + this.refreshPending = false; const latest = injection.lastInjection; if (desired === undefined) { if ( @@ -147,8 +151,6 @@ function renderPluginSessionStartReminder( for (const sessionStart of sessionStarts) { const skill = catalog.getPluginSkill(sessionStart.pluginId, sessionStart.skillName); if (skill === undefined) { - // The provider reconciles at every step boundary, so a missing skill - // would otherwise re-warn on every step. const key = `${sessionStart.pluginId}:${sessionStart.skillName}`; if (!warnedSkills.has(key)) { warnedSkills.add(key); From c6033dd66388fcbcab11cae1ef3462fbb44a2546 Mon Sep 17 00:00:00 2001 From: 7Sageer <7sageer@djwcb.cn> Date: Thu, 6 Aug 2026 17:35:35 +0800 Subject: [PATCH 19/27] refactor(agent-core-v2): remove the generic reminder queue --- packages/agent-core-v2/AGENTS.md | 9 + .../agent-core-v2/docs/wire-manifest.d.ts | 28 +- .../agentsMdReminderService.ts | 45 ++- .../agent/contextInjector/contextInjector.ts | 7 - .../contextInjector/contextInjectorService.ts | 112 +++----- .../src/agent/goal/goalService.ts | 18 +- .../src/agent/reminderQueue/reminderQueue.ts | 36 --- .../agent/reminderQueue/reminderQueueOps.ts | 51 ---- .../reminderQueue/reminderQueueService.ts | 106 ------- packages/agent-core-v2/src/index.ts | 3 - .../src/session/btw/btwService.ts | 11 +- .../session/sessionInit/sessionInitService.ts | 9 +- .../agentsMdReminder/agentsMdReminder.test.ts | 157 ++++++----- .../contextInjector/contextInjector.test.ts | 37 +-- .../test/agent/goal/goal.test.ts | 6 - .../test/agent/goal/goalOps.test.ts | 11 +- .../permissionMode/permissionMode.test.ts | 1 - .../agent/reminderQueue/reminderQueue.test.ts | 265 ------------------ packages/agent-core-v2/test/index.test.ts | 7 - .../test/session/btw/btw.test.ts | 12 +- .../session/sessionInit/sessionInit.test.ts | 23 +- 21 files changed, 192 insertions(+), 762 deletions(-) delete mode 100644 packages/agent-core-v2/src/agent/reminderQueue/reminderQueue.ts delete mode 100644 packages/agent-core-v2/src/agent/reminderQueue/reminderQueueOps.ts delete mode 100644 packages/agent-core-v2/src/agent/reminderQueue/reminderQueueService.ts delete mode 100644 packages/agent-core-v2/test/agent/reminderQueue/reminderQueue.test.ts diff --git a/packages/agent-core-v2/AGENTS.md b/packages/agent-core-v2/AGENTS.md index ec40fc8ffdc..a49bd379d7e 100644 --- a/packages/agent-core-v2/AGENTS.md +++ b/packages/agent-core-v2/AGENTS.md @@ -61,6 +61,15 @@ Business code must not `import 'node:fs'`, write SQL, hand-roll append-logs / at `context.undo` is the only persisted undo fact. `contextMemory/conversationTime.ts` owns the conversation clock (`isUndoAnchor` — the single tick predicate used by `computeUndoCut`, the checkpoint reducers, and the transcript reducer) and the checkpoint protocol. A wire Model whose state must follow conversation undo (todo, plan, task-notification delivery, …) **MUST** be defined with `defineCheckpointedModel` — never hand-roll the push/clear/restore reducers — which also registers it into `CHECKPOINTED_MODELS` for the undo pipeline's pre-cut depth check. World-time state (turn counters, task registries, revision counters) must stay outside checkpointed Models. +## Model-facing reminders + +Two sanctioned paths, chosen by fact shape — do not introduce a third (no deferred-delivery queues, no mid-step splice channels): + +- **Present-tense state** (goal state, plan mode, todo staleness, date change, …) → register a provider with `contextInjector` (`register` / `registerAtTurnStart`). It reconciles at every injection boundary and may re-emit after compaction or undo. +- **Past-tense one-off facts** (goal cancelled, AGENTS.md discovered, `/init` finished, …) → append at the event point through `IAgentSystemReminderService.appendSystemReminder` with origin `{ kind: 'injection', variant: '' }`. The event point must be a safe position on its own (a step/restore hook, an idle moment, or rely on the loop-event fold's deferred append). + +`kind: 'injection'` is a lifecycle classification (hidden from the UI, not an undo anchor, dropped by compaction), not a provenance claim; prompt-owned attachments additionally carry `ownerPromptId` so undo treats them as part of their host prompt. + ## Docs Per-domain references live in `docs/`. diff --git a/packages/agent-core-v2/docs/wire-manifest.d.ts b/packages/agent-core-v2/docs/wire-manifest.d.ts index 56488e9aa48..f75ba87b4d7 100644 --- a/packages/agent-core-v2/docs/wire-manifest.d.ts +++ b/packages/agent-core-v2/docs/wire-manifest.d.ts @@ -21,7 +21,7 @@ // owning model offloads inline media to blob storage), cross-reducers // (foreign models that also reduce this record on dispatch and replay). -// Index (50 record types) +// Index (48 record types) // config.update profile persisted src/agent/profile/profileOps.ts // context.append_loop_event contextMemory persisted src/agent/contextMemory/contextOps.ts // context.append_message contextMemory persisted src/agent/contextMemory/contextOps.ts @@ -52,8 +52,6 @@ // plan_mode.exit plan persisted src/agent/plan/planOps.ts // plan.revision plan persisted src/agent/plan/planOps.ts // profile.bind profile persisted src/agent/profile/profileOps.ts -// reminderQueue.delivered reminderQueue persisted src/agent/reminderQueue/reminderQueueOps.ts -// reminderQueue.enqueue reminderQueue persisted src/agent/reminderQueue/reminderQueueOps.ts // skill.activate skill transient src/agent/skill/skillOps.ts // swarm_mode.enter swarm persisted src/agent/swarm/swarmOps.ts // swarm_mode.exit swarm persisted src/agent/swarm/swarmOps.ts @@ -473,28 +471,6 @@ interface ProfileBindPayload { subagents?: string[]; } -/** - * model: reminderQueue · persisted - * owner: src/agent/reminderQueue/reminderQueueOps.ts - */ -interface ReminderQueueDeliveredPayload { - _name: 'reminderQueue.delivered'; - id: string; -} - -/** - * model: reminderQueue · persisted - * owner: src/agent/reminderQueue/reminderQueueOps.ts - */ -interface ReminderQueueEnqueuePayload { - _name: 'reminderQueue.enqueue'; - entry: { - id: string; - variant: string; - content: string; - }; -} - /** * model: skill · toEvent * owner: src/agent/skill/skillOps.ts @@ -771,8 +747,6 @@ interface WirePayloadMap { "plan_mode.exit": PlanModeExitPayload; "plan.revision": PlanRevisionPayload; "profile.bind": ProfileBindPayload; - "reminderQueue.delivered": ReminderQueueDeliveredPayload; - "reminderQueue.enqueue": ReminderQueueEnqueuePayload; "skill.activate": SkillActivatePayload; "swarm_mode.enter": SwarmModeEnterPayload; "swarm_mode.exit": SwarmModeExitPayload; diff --git a/packages/agent-core-v2/src/agent/agentsMdReminder/agentsMdReminderService.ts b/packages/agent-core-v2/src/agent/agentsMdReminder/agentsMdReminderService.ts index d8808fd757a..2cee0776a33 100644 --- a/packages/agent-core-v2/src/agent/agentsMdReminder/agentsMdReminderService.ts +++ b/packages/agent-core-v2/src/agent/agentsMdReminder/agentsMdReminderService.ts @@ -4,9 +4,9 @@ * * Self-wiring plugin: registers an `onDidExecuteTool` hook on `toolExecutor` * that probes the directories a tool call touches for AGENTS.md files the - * system prompt did not inject, and enqueues a once-per-agent reminder - * through the `reminderQueue` once-channel, delivered as a standalone - * `` at the next injection boundary. The hook only + * system prompt did not inject, and appends a once-per-agent + * `` through `systemReminder` right at the tool-execution + * point. The hook only * observes — it never rewrites the tool result, so results stay verbatim * for the truncation pipeline and the reminder can never be truncated away * with an oversized output. `Read`/`Edit`/`Write` consume the canonical @@ -27,9 +27,9 @@ * file into an in-memory `claimed` set (parallel calls can never duplicate a * reminder and a failed attempt releases the claim), while `agentState` * (`agentsMdReminder.known`) is only ever whole-value replaced after the - * telemetry emitted and the reminder enqueued — never mutated in place, and + * telemetry emitted and the reminder appended — never mutated in place, and * never ahead of the reminder it records. The telemetry event fires before - * the enqueue so a telemetry failure simply retries the reminder on the next + * the append so a telemetry failure simply retries the reminder on the next * qualifying touch. Probing anchors at the nearest existing ancestor (so * `Write` into a not-yet-created directory still resolves), walks * `findProjectRoot → touched dir`, skips chain @@ -41,7 +41,7 @@ * picked up on the next touch; there is no negative cache. Probing is * lexical like the tools' own path policy: a symlinked directory's AGENTS.md * is discovered through the link at its lexical address, never by realpath. - * The hook never throws — a probe failure enqueues nothing. + * The hook never throws — a probe failure appends nothing. * * Seeding: `profile` reports the injected paths after every successful * bind/apply/refresh and `sessionInit` re-seeds after `/init`. A prompt can @@ -54,17 +54,14 @@ * `agentState` as well; restored provenance comes from `wire`/`profile`; fs * probes go through the os `IHostFileSystem`, the home directory through * `IHostEnvironment`, the brand home through `bootstrap`, syntax - * trees through `bashParser`, and the shown-event - * through `telemetry`. The `reminderQueue` is resolved lazily through - * `instantiation` at enqueue time — a constructor dependency would close the - * `contextInjector → loop → llmRequester → profile → agentsMdReminder` - * cycle. Bound at Agent scope. + * trees through `bashParser`, the shown-event + * through `telemetry`, and the reminder write head through + * `systemReminder`. Bound at Agent scope. */ import { basename, dirname, isAbsolute, join, normalize } from 'pathe'; import { Disposable } from '#/_base/di/lifecycle'; -import { IInstantiationService } from '#/_base/di/instantiation'; import { LifecycleScope, ScopeActivation, registerScopedService } from '#/_base/di/scope'; import { defineState } from '#/_base/state/stateRegistry'; import { IBashParserService } from '#/app/bashParser/bashParser'; @@ -86,7 +83,7 @@ import { } from '#/agent/profile/context'; import { ProfileModel } from '#/agent/profile/profileOps'; import { IAgentStateService } from '#/agent/state/agentState'; -import { IAgentReminderQueueService } from '#/agent/reminderQueue/reminderQueue'; +import { IAgentSystemReminderService } from '#/agent/systemReminder/systemReminder'; import { IAgentToolExecutorService } from '#/agent/toolExecutor/toolExecutor'; import type { ToolDidExecuteContext } from '#/agent/toolExecutor/toolHooks'; import { IWireService } from '#/wire/wire'; @@ -119,7 +116,7 @@ export class AgentAgentsMdReminderService constructor( @IAgentToolExecutorService toolExecutor: IAgentToolExecutorService, - @IInstantiationService private readonly instantiation: IInstantiationService, + @IAgentSystemReminderService private readonly reminders: IAgentSystemReminderService, @IAgentStateService private readonly states: IAgentStateService, @ISessionContext private readonly sessionContext: ISessionContext, @IHostFileSystem private readonly fs: IHostFileSystem, @@ -143,7 +140,7 @@ export class AgentAgentsMdReminderService }), ); const handler = async (ctx: ToolDidExecuteContext, next: () => Promise): Promise => { - await this.probeAndEnqueue(ctx); + await this.probeAndRemind(ctx); await next(); }; this._register(toolExecutor.hooks.onDidExecuteTool.register('agentsMdReminder', handler)); @@ -177,7 +174,7 @@ export class AgentAgentsMdReminderService this.seedInjected(paths, this.agentCwd); } - private async probeAndEnqueue(ctx: ToolDidExecuteContext): Promise { + private async probeAndRemind(ctx: ToolDidExecuteContext): Promise { if (ctx.outcome !== 'executed') return; const discovered: string[] = []; try { @@ -202,24 +199,18 @@ export class AgentAgentsMdReminderService trace_id: ctx.trace?.traceId, }; this.telemetry.track2('agents_md_reminder_shown', properties); - this.reminderQueue.enqueue({ variant: 'agents_md', content: reminderText(discovered) }); + this.reminders.appendSystemReminder(reminderText(discovered), { + kind: 'injection', + variant: 'agents_md', + }); this.publishKnown([...selfKnown, ...discovered]); } catch { - // A probe or enqueue failure simply retries on the next qualifying touch. + // A probe or append failure simply retries on the next qualifying touch. } finally { for (const path of discovered) this.claimed.delete(path); } } - private reminderQueueService: IAgentReminderQueueService | undefined; - - private get reminderQueue(): IAgentReminderQueueService { - this.reminderQueueService ??= this.instantiation.invokeFunction((accessor) => - accessor.get(IAgentReminderQueueService), - ); - return this.reminderQueueService; - } - private publishKnown(paths: readonly string[]): void { if (paths.length === 0) return; const merged = new Set(this.known); diff --git a/packages/agent-core-v2/src/agent/contextInjector/contextInjector.ts b/packages/agent-core-v2/src/agent/contextInjector/contextInjector.ts index 3dc7d307c6c..c964ec78c4e 100644 --- a/packages/agent-core-v2/src/agent/contextInjector/contextInjector.ts +++ b/packages/agent-core-v2/src/agent/contextInjector/contextInjector.ts @@ -43,13 +43,6 @@ export type SyncContextInjectionProvider = ( export interface IAgentContextInjectorService { readonly _serviceBrand: undefined; - /** Registers a past-tense, exactly-once delivery channel (such as the - * `reminderQueue`) drained synchronously at every injection boundary - * (turn start, step, compaction follow-up, wire restore) before any - * provider runs. A drain that throws or returns a Promise is logged and - * skipped, so one bad channel cannot starve the rest. */ - registerOnceChannel(name: string, drain: () => void): IDisposable; - register( name: string, provider: ContextInjectionProvider, diff --git a/packages/agent-core-v2/src/agent/contextInjector/contextInjectorService.ts b/packages/agent-core-v2/src/agent/contextInjector/contextInjectorService.ts index ee7f321ab4e..3bbd225c142 100644 --- a/packages/agent-core-v2/src/agent/contextInjector/contextInjectorService.ts +++ b/packages/agent-core-v2/src/agent/contextInjector/contextInjectorService.ts @@ -1,43 +1,42 @@ /** * `contextInjector` domain — `IAgentContextInjectorService` implementation. * - * The unified boundary scheduler for every model-facing reminder. At each - * injection boundary (turn start, step, compaction follow-up, wire restore) - * it first drains the registered once-channels (`registerOnceChannel`) — - * past-tense, exactly-once deliveries such as the `reminderQueue`'s - * persisted once-reminders — then reconciles the registered context - * providers (present-tense state) through `loop` and `systemReminder`. A - * once-channel drain that throws or returns a Promise is logged and - * skipped, so one bad channel cannot starve the providers. A provider that - * throws or rejects at a step or compaction boundary is likewise logged and - * skipped, so one bad provider can neither starve the rest nor fail the - * turn. Provider positions are never cached: each provider call derives - * them by scanning `contextMemory` for its own surviving injection - * messages, so splices, compaction folds, and `wire` restoration need no - * index bookkeeping. Each - * provider call receives the newest surviving injection of its own variant - * (`lastInjection`) and the typed disclosure recorded on it (`lastDisclosure`), - * so providers never read context layout or position indexes themselves. - * Provider results route by shape: strings become `` - * messages through `systemReminder`, content parts append as bare user - * messages, and tagged raw messages (`{ message }`) append verbatim with the - * injection origin stamped — the form for structured payloads such as the - * dynamic-tool schema declaration's `tools` field. Turn-start providers run - * synchronously after `turn.started` — before the turn's first step request - * materializes its prompt — and must stay synchronous (a provider that throws - * or returns a Promise is logged and skipped, so one bad provider cannot - * starve the rest); they are also reconciled at every later step - * boundary as a fallback for facts that arrive after `turn.started` (for - * example, a queued turn cancelled while another turn is already running). - * `isNewTurn` is derived per boundary and never stored: the step hook reads - * it from the loop's own step counter (`BeforeStepContext.firstStepOfTurn`), - * and the compaction follow-up passes it explicitly, so interleaved triggers - * cannot consume or steal a shared flag. A compaction follow-up that lands - * inside a step hook chain (the auto-compaction path) doubles as that - * step's new-turn delivery — the enclosing step then injects with - * `isNewTurn: false`, so the upcoming request receives one new-turn - * injection, not two. `entries` and `onceChannels` stay plain instance - * fields (their values hold functions, not plain data). + * The reconcile scheduler for the registered context providers + * (present-tense state reminders). At each injection boundary (turn start, + * step, compaction follow-up) it reconciles the registered providers + * through `loop` hooks and routes their results into the conversation: + * strings become `` messages through `systemReminder`, + * content parts append as bare user messages, and tagged raw messages + * (`{ message }`) append verbatim with the injection origin stamped — the + * form for structured payloads such as the dynamic-tool schema + * declaration's `tools` field. Past-tense one-off reminders (a cancelled + * goal, a discovered AGENTS.md, …) do NOT pass through this service: + * their producers append them at the event point through `systemReminder`, + * sharing the same `injection` origin vocabulary. A provider that throws + * or rejects at a step or compaction boundary is logged and skipped, so + * one bad provider can neither starve the rest nor fail the turn. + * Provider positions are never cached: each provider call derives them by + * scanning `contextMemory` for its own surviving injection messages, so + * splices, compaction folds, and `wire` restoration need no index + * bookkeeping. Each provider call receives the newest surviving injection + * of its own variant (`lastInjection`) and the typed disclosure recorded + * on it (`lastDisclosure`), so providers never read context layout or + * position indexes themselves. Turn-start providers run synchronously + * after `turn.started` — before the turn's first step request materializes + * its prompt — and must stay synchronous (a provider that throws or + * returns a Promise is logged and skipped, so one bad provider cannot + * starve the rest); they are also reconciled at every later step boundary + * as a fallback for facts that arrive after `turn.started` (for example, a + * queued turn cancelled while another turn is already running). + * `isNewTurn` is derived per boundary and never stored: the step hook + * reads it from the loop's own step counter + * (`BeforeStepContext.firstStepOfTurn`), and the compaction follow-up + * passes it explicitly, so interleaved triggers cannot consume or steal a + * shared flag. A compaction follow-up that lands inside a step hook chain + * (the auto-compaction path) doubles as that step's new-turn delivery — + * the enclosing step then injects with `isNewTurn: false`, so the upcoming + * request receives one new-turn injection, not two. `entries` stays a + * plain instance field (its values hold functions, not plain data). * Bound at Agent scope. */ @@ -50,7 +49,6 @@ import { IAgentLoopService, type BeforeStepContext } from '#/agent/loop/loop'; import { IAgentSystemReminderService } from '#/agent/systemReminder/systemReminder'; import { IEventBus } from '#/app/event/eventBus'; import type { ContextMessage } from '#/agent/contextMemory/types'; -import { IWireService } from '#/wire/wire'; import { IAgentContextInjectorService, type ContextInjectionContent, @@ -67,15 +65,9 @@ interface ContextInjectionEntry { readonly boundary: 'step' | 'turn-start'; } -interface OnceChannelEntry { - readonly name: string; - readonly drain: () => unknown; -} - export class AgentContextInjectorService extends Disposable implements IAgentContextInjectorService { declare readonly _serviceBrand: undefined; private readonly entries = new Set(); - private readonly onceChannels = new Set(); private enclosingStep: BeforeStepContext | undefined; private newTurnDeliveredTo: BeforeStepContext | undefined; @@ -85,7 +77,6 @@ export class AgentContextInjectorService extends Disposable implements IAgentCon @IAgentSystemReminderService private readonly reminders: IAgentSystemReminderService, @IEventBus private readonly eventBus: IEventBus, @ILogService private readonly log: ILogService, - @IWireService wire: IWireService, ) { super(); this._register( @@ -101,16 +92,9 @@ export class AgentContextInjectorService extends Disposable implements IAgentCon ); this._register( this.eventBus.subscribe('turn.started', () => { - this.runOnceChannels(); this.injectAtTurnStart(); }), ); - this._register( - wire.hooks.onDidRestore.register('context-injector', async (_ctx, next) => { - this.runOnceChannels(); - await next(); - }), - ); } register( @@ -127,29 +111,6 @@ export class AgentContextInjectorService extends Disposable implements IAgentCon return this.registerProvider(name, provider, 'turn-start'); } - registerOnceChannel(name: string, drain: () => void): IDisposable { - const entry: OnceChannelEntry = { name, drain }; - this.onceChannels.add(entry); - return toDisposable(() => { - this.onceChannels.delete(entry); - }); - } - - private runOnceChannels(): void { - for (const entry of this.onceChannels) { - try { - const result: unknown = entry.drain(); - if (isThenable(result)) { - this.log.error('once-channel drain returned a Promise; skipping it', { - name: entry.name, - }); - } - } catch (error) { - this.log.error('once-channel drain failed; skipping it', { name: entry.name, error }); - } - } - } - private registerProvider( name: string, provider: ContextInjectionProvider, @@ -175,7 +136,6 @@ export class AgentContextInjectorService extends Disposable implements IAgentCon boundary: ContextInjectionEntry['boundary'] | undefined, isNewTurn: boolean, ): Promise { - this.runOnceChannels(); for (const entry of this.entries) { if (boundary !== undefined && !shouldRunAtBoundary(entry, boundary)) continue; let content: Awaited>; diff --git a/packages/agent-core-v2/src/agent/goal/goalService.ts b/packages/agent-core-v2/src/agent/goal/goalService.ts index f370da9d97c..7496bf0a796 100644 --- a/packages/agent-core-v2/src/agent/goal/goalService.ts +++ b/packages/agent-core-v2/src/agent/goal/goalService.ts @@ -16,7 +16,7 @@ * `StepRequest`s onto `loop` (the continuation message materializes when the * loop pops it), accounts live * turn usage through `usage`, observes terminal goal tool results through - * `toolExecutor`, enqueues one-time reminder events through `reminderQueue`, reports + * `toolExecutor`, appends one-time reminder events through `systemReminder`, reports * telemetry through `telemetry`, and checks main-agent eligibility through * `scopeContext`. Measures time and arms hard deadlines through `goal`'s * App-scoped deadline scheduler. Two `onBeforeExecuteTool` veto listeners @@ -58,9 +58,9 @@ import { import { LOOP_CONTROL_SECTION, type LoopControl } from '#/agent/loop/configSection'; import { LoopErrors } from '#/agent/loop/errors'; import { ContinuationStepRequest, MessageStepRequest } from '#/agent/loop/stepRequest'; -import { IAgentReminderQueueService } from '#/agent/reminderQueue/reminderQueue'; import { IAgentScopeContext } from '#/agent/scopeContext/scopeContext'; import { IAgentStateService } from '#/agent/state/agentState'; +import { IAgentSystemReminderService } from '#/agent/systemReminder/systemReminder'; import type { ExecutableToolResult } from '#/tool/toolContract'; import { IAgentPermissionModeService } from '#/agent/permissionMode/permissionMode'; import type { PermissionMode } from '#/agent/permissionPolicy/types'; @@ -284,7 +284,7 @@ export class AgentGoalService extends Disposable implements IAgentGoalService { constructor( @IWireService private readonly wire: IWireService, @IEventBus private readonly eventBus: IEventBus, - @IAgentReminderQueueService private readonly reminderQueue: IAgentReminderQueueService, + @IAgentSystemReminderService private readonly reminders: IAgentSystemReminderService, @ITelemetryService private readonly telemetry: ITelemetryService, @IAgentContextInjectorService injector: IAgentContextInjectorService, @IAgentLoopService private readonly loopService: IAgentLoopService, @@ -625,9 +625,9 @@ export class AgentGoalService extends Disposable implements IAgentGoalService { } this.clearInternal(actor); if (actor === 'user') { - this.reminderQueue.enqueue({ + this.reminders.appendSystemReminder(GOAL_CANCELLED_REMINDER, { + kind: 'injection', variant: 'goal_cancelled', - content: GOAL_CANCELLED_REMINDER, }); } return snapshot; @@ -803,9 +803,9 @@ export class AgentGoalService extends Disposable implements IAgentGoalService { hasStepBudgetRemaining(maxSteps, ctx.step) ) { this.budgetGraceTurns.add(ctx.turnId); - this.reminderQueue.enqueue({ + this.reminders.appendSystemReminder(GOAL_BUDGET_STOP_REMINDER, { + kind: 'injection', variant: GOAL_BUDGET_STOP_REMINDER_NAME, - content: GOAL_BUDGET_STOP_REMINDER, }); return true; } @@ -1017,9 +1017,9 @@ export class AgentGoalService extends Disposable implements IAgentGoalService { private appendForkClearedReminder(): void { if (!this.wire.getModel(GoalForkNoticeModel).reminderPending) return; - this.reminderQueue.enqueue({ + this.reminders.appendSystemReminder(GOAL_FORK_CLEARED_REMINDER, { + kind: 'injection', variant: GOAL_FORK_CLEARED_REMINDER_NAME, - content: GOAL_FORK_CLEARED_REMINDER, }); } diff --git a/packages/agent-core-v2/src/agent/reminderQueue/reminderQueue.ts b/packages/agent-core-v2/src/agent/reminderQueue/reminderQueue.ts deleted file mode 100644 index 13219b28f5e..00000000000 --- a/packages/agent-core-v2/src/agent/reminderQueue/reminderQueue.ts +++ /dev/null @@ -1,36 +0,0 @@ -/** - * `reminderQueue` domain — world-event exactly-once reminder contract. - * - * Persists and drains reminders whose delivery is a durable fact rather than - * conversation state. Bound at Agent scope. - */ - -import { createDecorator } from '#/_base/di/instantiation'; - -export interface OnceReminderInput { - readonly variant: string; - readonly content: string; -} - -export interface OnceReminderDisclosure { - readonly kind: 'once_reminder'; - readonly id: string; -} - -export function isOnceReminderDisclosure(value: unknown): value is OnceReminderDisclosure { - if (typeof value !== 'object' || value === null) return false; - const candidate = value as { readonly kind?: unknown; readonly id?: unknown }; - return candidate.kind === 'once_reminder' && typeof candidate.id === 'string'; -} - -export interface IAgentReminderQueueService { - readonly _serviceBrand: undefined; - - enqueue(input: OnceReminderInput): void; - - drain(): void; -} - -export const IAgentReminderQueueService = createDecorator( - 'agentReminderQueueService', -); diff --git a/packages/agent-core-v2/src/agent/reminderQueue/reminderQueueOps.ts b/packages/agent-core-v2/src/agent/reminderQueue/reminderQueueOps.ts deleted file mode 100644 index c98db3706d0..00000000000 --- a/packages/agent-core-v2/src/agent/reminderQueue/reminderQueueOps.ts +++ /dev/null @@ -1,51 +0,0 @@ -/** - * `reminderQueue` domain — wire Model (`ReminderQueueModel`) and the persisted - * Ops backing the exactly-once delivery ledger. - * - * State is the FIFO list of pending once-reminders. `reminderQueue.enqueue` - * appends a pending entry; `reminderQueue.delivered` clears it once the - * reminder has been appended to the conversation. Both Ops persist, so replay - * rebuilds the pending set and a crash anywhere between enqueue and delivery - * is reconciled by the next restore drain. The Model is a plain `defineModel` - * — deliberately NOT checkpointed — so `context.undo` never revives a - * delivered entry: a once-reminder's delivery is a world fact, not branch - * state. - */ - -import { z } from 'zod'; - -import { defineModel } from '#/wire/model'; - -export interface ReminderQueueEntry { - readonly id: string; - readonly variant: string; - readonly content: string; -} - -const reminderQueueEntrySchema = z.object({ - id: z.string().min(1), - variant: z.string().min(1), - content: z.string(), -}); - -export const ReminderQueueModel = defineModel( - 'reminderQueue', - () => [], -); - -declare module '#/wire/types' { - interface PersistedOpMap { - 'reminderQueue.enqueue': typeof reminderQueueEnqueue; - 'reminderQueue.delivered': typeof reminderQueueDelivered; - } -} - -export const reminderQueueEnqueue = ReminderQueueModel.defineOp('reminderQueue.enqueue', { - schema: z.object({ entry: reminderQueueEntrySchema }), - apply: (state, { entry }) => [...state, entry], -}); - -export const reminderQueueDelivered = ReminderQueueModel.defineOp('reminderQueue.delivered', { - schema: z.object({ id: z.string().min(1) }), - apply: (state, { id }) => state.filter((entry) => entry.id !== id), -}); diff --git a/packages/agent-core-v2/src/agent/reminderQueue/reminderQueueService.ts b/packages/agent-core-v2/src/agent/reminderQueue/reminderQueueService.ts deleted file mode 100644 index eb7afa0ca20..00000000000 --- a/packages/agent-core-v2/src/agent/reminderQueue/reminderQueueService.ts +++ /dev/null @@ -1,106 +0,0 @@ -/** - * `reminderQueue` domain — `IAgentReminderQueueService` implementation. - * - * Persists once-reminder entries through its own wire Model, delivers them - * through `systemReminder` (the unified write head), and schedules itself: - * it drains on every `contextInjector` injection boundary - * (`registerOnceChannel`). - * Crash-window dedup reads the conversation tail through `contextMemory` and - * matches the entry id recorded as the delivered reminder's disclosure, so a - * reminder appended but not yet marked delivered is never re-appended after a - * restart; entries identical within one drain collapse into a single - * reminder. Bound at Agent scope. - */ - -import { randomUUID } from 'node:crypto'; - -import { Disposable } from '#/_base/di/lifecycle'; -import { LifecycleScope, ScopeActivation, registerScopedService } from '#/_base/di/scope'; -import { IAgentContextInjectorService } from '#/agent/contextInjector/contextInjector'; -import { IAgentContextMemoryService } from '#/agent/contextMemory/contextMemory'; -import type { ContextMessage } from '#/agent/contextMemory/types'; -import { isVacuousContentPart } from '#/agent/contextMemory/vacuousContent'; -import { IAgentSystemReminderService } from '#/agent/systemReminder/systemReminder'; -import { IWireService } from '#/wire/wire'; - -import { - IAgentReminderQueueService, - isOnceReminderDisclosure, - type OnceReminderDisclosure, - type OnceReminderInput, -} from './reminderQueue'; -import { - ReminderQueueModel, - type ReminderQueueEntry, - reminderQueueDelivered, - reminderQueueEnqueue, -} from './reminderQueueOps'; - -export class AgentReminderQueueService extends Disposable implements IAgentReminderQueueService { - declare readonly _serviceBrand: undefined; - - constructor( - @IWireService private readonly wire: IWireService, - @IAgentSystemReminderService private readonly reminders: IAgentSystemReminderService, - @IAgentContextMemoryService private readonly context: IAgentContextMemoryService, - @IAgentContextInjectorService injector: IAgentContextInjectorService, - ) { - super(); - this._register(injector.registerOnceChannel('reminder-queue', () => { - this.drain(); - })); - } - - enqueue(input: OnceReminderInput): void { - const entry: ReminderQueueEntry = { id: randomUUID(), ...input }; - this.wire.dispatch(reminderQueueEnqueue({ entry })); - } - - drain(): void { - const deliveredIds = this.deliveredIdsAtTail(); - const rendered = new Set(); - for (const entry of this.wire.getModel(ReminderQueueModel)) { - const collapseKey = `${entry.variant}\n${entry.content}`; - if (!deliveredIds.has(entry.id) && !rendered.has(collapseKey)) { - this.reminders.appendSystemReminder(entry.content, { - kind: 'injection', - variant: entry.variant, - disclosure: { kind: 'once_reminder', id: entry.id } satisfies OnceReminderDisclosure, - }); - rendered.add(collapseKey); - } - this.wire.dispatch(reminderQueueDelivered({ id: entry.id })); - } - } - - private deliveredIdsAtTail(): ReadonlySet { - const messages = this.context.get(); - const ids = new Set(); - for (let i = messages.length - 1; i >= 0; i--) { - const message = messages[i]!; - if (isSkippableTailAssistant(message)) continue; - const origin = message.origin; - if (origin?.kind !== 'injection') break; - const disclosure = origin.disclosure; - if (isOnceReminderDisclosure(disclosure)) ids.add(disclosure.id); - } - return ids; - } -} - -function isSkippableTailAssistant(message: ContextMessage): boolean { - return ( - message.role === 'assistant' && - message.partial === true && - message.toolCalls.length === 0 && - message.content.every(isVacuousContentPart) - ); -} - -registerScopedService( - LifecycleScope.Agent, - IAgentReminderQueueService, - AgentReminderQueueService, - ScopeActivation.OnScopeCreated, - 'reminderQueue', -); diff --git a/packages/agent-core-v2/src/index.ts b/packages/agent-core-v2/src/index.ts index e59b03e958b..e93792ee6be 100644 --- a/packages/agent-core-v2/src/index.ts +++ b/packages/agent-core-v2/src/index.ts @@ -522,9 +522,6 @@ export * from '#/agent/tokenCounting/tokenCountingOps'; export * from '#/agent/tokenCounting/tokenCountingService'; export * from '#/agent/contextInjector/contextInjector'; export * from '#/agent/contextInjector/contextInjectorService'; -export * from '#/agent/reminderQueue/reminderQueue'; -export * from '#/agent/reminderQueue/reminderQueueOps'; -export * from '#/agent/reminderQueue/reminderQueueService'; export * from '#/agent/plugin/agentPlugin'; export * from '#/agent/plugin/agentPluginService'; import '#/agent/externalHooks/configSection'; diff --git a/packages/agent-core-v2/src/session/btw/btwService.ts b/packages/agent-core-v2/src/session/btw/btwService.ts index e2afac06e5c..1edffa05793 100644 --- a/packages/agent-core-v2/src/session/btw/btwService.ts +++ b/packages/agent-core-v2/src/session/btw/btwService.ts @@ -5,7 +5,7 @@ * `IAgentLifecycleService.fork`, then disables tool calls via an * `onBeforeExecuteTool` veto listener (blocks every tool call with the * `toolApproval.formatDenyMessage`-formatted TOOL_CALL_DISABLED_MESSAGE) and - * enqueues the side-channel reminder into the child's `reminderQueue`. Bound + * appends the side-channel reminder through the child's `systemReminder`. Bound * at Session scope — * `fork('main')` is a session-level operation, so the service injects the * session's `IAgentLifecycleService` directly rather than resolving it through @@ -14,7 +14,7 @@ */ import { LifecycleScope, ScopeActivation, registerScopedService } from '#/_base/di/scope'; -import { IAgentReminderQueueService } from '#/agent/reminderQueue/reminderQueue'; +import { IAgentSystemReminderService } from '#/agent/systemReminder/systemReminder'; import { IAgentToolApprovalService } from '#/agent/toolApproval/toolApproval'; import { denyToolExecution } from '#/agent/toolExecutor/beforeToolExecuteEvent'; import { IAgentToolExecutorService } from '#/agent/toolExecutor/toolExecutor'; @@ -32,8 +32,11 @@ export class SessionBtwService implements ISessionBtwService { async start(): Promise { const child = await this.lifecycle.fork('main'); child.accessor - .get(IAgentReminderQueueService) - ?.enqueue({ variant: 'btw', content: SIDE_QUESTION_SYSTEM_REMINDER }); + .get(IAgentSystemReminderService) + ?.appendSystemReminder(SIDE_QUESTION_SYSTEM_REMINDER, { + kind: 'injection', + variant: 'btw', + }); const reason = child.accessor.get(IAgentToolApprovalService)?.formatDenyMessage( TOOL_CALL_DISABLED_MESSAGE, diff --git a/packages/agent-core-v2/src/session/sessionInit/sessionInitService.ts b/packages/agent-core-v2/src/session/sessionInit/sessionInitService.ts index f003b2c7d92..05919d379b9 100644 --- a/packages/agent-core-v2/src/session/sessionInit/sessionInitService.ts +++ b/packages/agent-core-v2/src/session/sessionInit/sessionInitService.ts @@ -31,7 +31,7 @@ import { IAgentProfileService } from '#/agent/profile/profile'; import { loadAgentsMdDetailed } from '#/agent/profile/context'; import { IAgentAgentsMdReminderService } from '#/agent/agentsMdReminder/agentsMdReminder'; import { IAgentPermissionModeService } from '#/agent/permissionMode/permissionMode'; -import { IAgentReminderQueueService } from '#/agent/reminderQueue/reminderQueue'; +import { IAgentSystemReminderService } from '#/agent/systemReminder/systemReminder'; import { IWireService } from '#/wire/wire'; import { ErrorCodes, Error2 } from '#/errors'; import { IAgentLifecycleService, MAIN_AGENT_ID } from '#/session/agentLifecycle/agentLifecycle'; @@ -116,8 +116,11 @@ export class SessionInitService implements ISessionInitService { .get(IAgentAgentsMdReminderService) .seedInjected(agentsMdPaths, this.sessionContext.cwd); main.accessor - .get(IAgentReminderQueueService) - .enqueue({ variant: 'init', content: initCompletionReminder(agentsMd) }); + .get(IAgentSystemReminderService) + .appendSystemReminder(initCompletionReminder(agentsMd), { + kind: 'injection', + variant: 'init', + }); await main.accessor.get(IWireService).flush(); } catch (error) { if (isUserCancellation(error) || isAbortError(error)) { diff --git a/packages/agent-core-v2/test/agent/agentsMdReminder/agentsMdReminder.test.ts b/packages/agent-core-v2/test/agent/agentsMdReminder/agentsMdReminder.test.ts index 41dcd453f37..6dd7cf4a3f3 100644 --- a/packages/agent-core-v2/test/agent/agentsMdReminder/agentsMdReminder.test.ts +++ b/packages/agent-core-v2/test/agent/agentsMdReminder/agentsMdReminder.test.ts @@ -46,10 +46,8 @@ import { AgentStateService } from '#/agent/state/agentStateService'; import { IAgentLoopService } from '#/agent/loop/loop'; import { IAgentToolDedupeService } from '#/agent/toolDedupe/toolDedupe'; import { AgentToolDedupeService } from '#/agent/toolDedupe/toolDedupeService'; -import { - IAgentReminderQueueService, - type OnceReminderInput, -} from '#/agent/reminderQueue/reminderQueue'; +import { IAgentSystemReminderService } from '#/agent/systemReminder/systemReminder'; +import type { PromptOrigin } from '#/agent/contextMemory/types'; import { OrderedHookSlot } from '#/hooks'; import { IWireService } from '#/wire/wire'; import type { ToolDidExecuteContext } from '#/agent/toolExecutor/toolHooks'; @@ -78,13 +76,18 @@ afterEach(async () => { await rm(workDir, { recursive: true, force: true }); }); +interface CapturedReminder { + readonly content: string; + readonly origin: PromptOrigin; +} + interface Harness { readonly ix: TestInstantiationService; readonly events: ToolExecutorEventStubs; readonly reminder: IAgentAgentsMdReminderService; readonly wire: IWireService; readonly telemetryEvents: TelemetryRecord[]; - readonly enqueued: OnceReminderInput[]; + readonly reminders: CapturedReminder[]; } function createHarness( @@ -102,7 +105,7 @@ function createHarness( } = {}, ): Harness { const telemetryEvents: TelemetryRecord[] = []; - const enqueued: OnceReminderInput[] = []; + const reminders: CapturedReminder[] = []; const events = stubToolExecutorEvents(); const ix = createServices(disposables, { additionalServices: (reg) => { @@ -140,13 +143,13 @@ function createHarness( reg.defineInstance(IWireService, wire); reg.defineInstance(IBootstrapService, { homeDir } as unknown as IBootstrapService); reg.defineInstance(IAgentStateService, new AgentStateService()); - reg.defineInstance(IAgentReminderQueueService, { + reg.defineInstance(IAgentSystemReminderService, { _serviceBrand: undefined, - enqueue: (input) => { - enqueued.push(input); + appendSystemReminder: (content: string, origin: PromptOrigin) => { + reminders.push({ content, origin }); + return { role: 'user', content: [], toolCalls: [], origin }; }, - drain: () => {}, - } satisfies IAgentReminderQueueService); + } satisfies IAgentSystemReminderService); reg.defineInstance(ISessionContext, { _serviceBrand: undefined, sessionId: 'session-1', @@ -178,7 +181,7 @@ function createHarness( }); const reminder = ix.get(IAgentAgentsMdReminderService); const wire = ix.get(IWireService); - return { ix, events, reminder, wire, telemetryEvents, enqueued }; + return { ix, events, reminder, wire, telemetryEvents, reminders }; } function didCtx( @@ -239,8 +242,8 @@ function outputText(result: ExecutableToolResult): string { .join(''); } -function enqueuedText(h: Harness): string { - return h.enqueued.map((entry) => entry.content).join('\n'); +function reminderText(h: Harness): string { + return h.reminders.map((entry) => entry.content).join('\n'); } async function writeAgentsMd(dir: string, content = 'instructions'): Promise { @@ -251,7 +254,7 @@ async function writeAgentsMd(dir: string, content = 'instructions'): Promise { - it('enqueues a reminder listing the uninjected AGENTS.md when Read touches its directory', async () => { + it('appends a reminder listing the uninjected AGENTS.md when Read touches its directory', async () => { const h = createHarness(); const rootAgentsMd = await writeAgentsMd(workDir, 'root instructions'); const subDir = join(workDir, 'packages', 'kap-server'); @@ -261,13 +264,13 @@ describe('agentsMdReminder path-carrying tools', () => { const result = await fire(h, didCtx('Read', { path: join(subDir, 'src', 'index.ts') })); expect(outputText(result)).toBe('original result'); - expect(h.enqueued).toHaveLength(1); - expect(h.enqueued[0]?.variant).toBe('agents_md'); - expect(h.enqueued[0]?.content.startsWith('The path(s) touched by a recent tool call')).toBe( + expect(h.reminders).toHaveLength(1); + expect(h.reminders[0]?.origin).toEqual({ kind: 'injection', variant: 'agents_md' }); + expect(h.reminders[0]?.content.startsWith('The path(s) touched by a recent tool call')).toBe( true, ); - expect(h.enqueued[0]?.content).not.toContain(''); - const text = enqueuedText(h); + expect(h.reminders[0]?.content).not.toContain(''); + const text = reminderText(h); expect(text).toContain(subAgentsMd); expect(text).not.toContain(rootAgentsMd); }); @@ -282,8 +285,8 @@ describe('agentsMdReminder path-carrying tools', () => { expect(outputText(first)).toBe('original result'); expect(outputText(second)).toBe('original result'); - expect(h.enqueued).toHaveLength(1); - expect(enqueuedText(h)).toContain(subAgentsMd); + expect(h.reminders).toHaveLength(1); + expect(reminderText(h)).toContain(subAgentsMd); }); it('marks an AGENTS.md known when read directly and never suggests it afterwards', async () => { @@ -293,11 +296,11 @@ describe('agentsMdReminder path-carrying tools', () => { const direct = await fire(h, didCtx('Read', { path: subAgentsMd })); expect(outputText(direct)).toBe('original result'); - expect(h.enqueued).toHaveLength(0); + expect(h.reminders).toHaveLength(0); const after = await fire(h, didCtx('Read', { path: join(subDir, 'src', 'index.ts') })); expect(outputText(after)).toBe('original result'); - expect(h.enqueued).toHaveLength(0); + expect(h.reminders).toHaveLength(0); }); it('discovers the .kimi-code/AGENTS.md variant alongside the plain one', async () => { @@ -310,7 +313,7 @@ describe('agentsMdReminder path-carrying tools', () => { const result = await fire(h, didCtx('Read', { path: join(subDir, 'index.ts') })); expect(outputText(result)).toBe('original result'); - const text = enqueuedText(h); + const text = reminderText(h); expect(text).toContain(dotKimi); expect(text).toContain(plain); }); @@ -327,7 +330,7 @@ describe('agentsMdReminder path-carrying tools', () => { ); expect(outputText(result)).toBe('original result'); - expect(enqueuedText(h)).toContain(rootAgentsMd); + expect(reminderText(h)).toContain(rootAgentsMd); }); it('does not remind for seeded paths on the injected chain', async () => { @@ -338,7 +341,7 @@ describe('agentsMdReminder path-carrying tools', () => { const result = await fire(h, didCtx('Glob', { pattern: '**/*.ts' })); expect(outputText(result)).toBe('original result'); - expect(h.enqueued).toHaveLength(0); + expect(h.reminders).toHaveLength(0); }); it('tracks the shown event through telemetry', async () => { @@ -366,7 +369,7 @@ describe('agentsMdReminder Bash coverage', () => { const result = await fire(h, didCtx('Bash', { command: 'ls packages/kap-server' })); expect(outputText(result)).toBe('original result'); - expect(enqueuedText(h)).toContain(subAgentsMd); + expect(reminderText(h)).toContain(subAgentsMd); }); it('rebases relative operands across a literal cd', async () => { @@ -376,7 +379,7 @@ describe('agentsMdReminder Bash coverage', () => { const result = await fire(h, didCtx('Bash', { command: 'cd packages && ls kap-server' })); expect(outputText(result)).toBe('original result'); - expect(enqueuedText(h)).toContain(subAgentsMd); + expect(reminderText(h)).toContain(subAgentsMd); }); it('extracts find roots and stops at the expression', async () => { @@ -389,7 +392,7 @@ describe('agentsMdReminder Bash coverage', () => { ); expect(outputText(result)).toBe('original result'); - expect(enqueuedText(h)).toContain(subAgentsMd); + expect(reminderText(h)).toContain(subAgentsMd); }); it('extracts quoted directory operands', async () => { @@ -399,7 +402,7 @@ describe('agentsMdReminder Bash coverage', () => { const result = await fire(h, didCtx('Bash', { command: 'ls "packages/kap-server"' })); expect(outputText(result)).toBe('original result'); - expect(enqueuedText(h)).toContain(subAgentsMd); + expect(reminderText(h)).toContain(subAgentsMd); }); it('probes an explicit cwd even when the command lists nothing', async () => { @@ -412,7 +415,7 @@ describe('agentsMdReminder Bash coverage', () => { ); expect(outputText(result)).toBe('original result'); - expect(enqueuedText(h)).toContain(subAgentsMd); + expect(reminderText(h)).toContain(subAgentsMd); }); it('skips operands that are not statically resolvable', async () => { @@ -423,7 +426,7 @@ describe('agentsMdReminder Bash coverage', () => { const result = await fire(h, didCtx('Bash', { command })); expect(outputText(result)).toBe('original result'); } - expect(h.enqueued).toHaveLength(0); + expect(h.reminders).toHaveLength(0); }); }); @@ -442,8 +445,8 @@ describe('agentsMdReminder result shapes and edge cases', () => { ); expect(result.output).toEqual([{ type: 'text', text: 'part one' }]); - expect(h.enqueued).toHaveLength(1); - expect(enqueuedText(h)).toContain(subAgentsMd); + expect(h.reminders).toHaveLength(1); + expect(reminderText(h)).toContain(subAgentsMd); }); it('does not mark an AGENTS.md known when the direct read failed', async () => { @@ -456,12 +459,12 @@ describe('agentsMdReminder result shapes and edge cases', () => { didCtx('Read', { path: agentsMdPath }, { result: { output: 'not found', isError: true } }), ); expect(outputText(failed)).toBe('not found'); - expect(h.enqueued).toHaveLength(0); + expect(h.reminders).toHaveLength(0); await writeAgentsMd(subDir); const after = await fire(h, didCtx('Read', { path: join(subDir, 'index.ts') })); expect(outputText(after)).toBe('original result'); - expect(enqueuedText(h)).toContain(agentsMdPath); + expect(reminderText(h)).toContain(agentsMdPath); }); }); @@ -476,8 +479,8 @@ describe('agentsMdReminder duplicate calls', () => { expect(outputText(first)).toBe('original result'); expect(outputText(second)).toBe('original result'); - expect(h.enqueued).toHaveLength(1); - expect(enqueuedText(h)).toContain(subAgentsMd); + expect(h.reminders).toHaveLength(1); + expect(reminderText(h)).toContain(subAgentsMd); }); it('leaves the vetoed placeholder untouched and reminds exactly once on the visible results', async () => { @@ -515,8 +518,8 @@ describe('agentsMdReminder duplicate calls', () => { for (const item of results) { expect(outputText(item.result)).toBe('file contents'); } - expect(h.enqueued).toHaveLength(1); - expect(enqueuedText(h)).toContain(subAgentsMd); + expect(h.reminders).toHaveLength(1); + expect(reminderText(h)).toContain(subAgentsMd); const shown = h.telemetryEvents.filter((e) => e.event === 'agents_md_reminder_shown'); expect(shown).toHaveLength(1); }); @@ -534,7 +537,7 @@ describe('agentsMdReminder lazy seeding after a restore', () => { ); expect(outputText(result)).toBe('original result'); - const text = enqueuedText(h); + const text = reminderText(h); expect(text).toContain(subAgentsMd); expect(text).not.toContain(rootAgentsMd); }); @@ -546,7 +549,7 @@ describe('agentsMdReminder lazy seeding after a restore', () => { const result = await fire(h, didCtx('Read', { path: join(homeDir, 'notes.txt') })); expect(outputText(result)).toBe('original result'); - expect(h.enqueued).toHaveLength(0); + expect(h.reminders).toHaveLength(0); expect(h.telemetryEvents).toHaveLength(0); }); }); @@ -567,7 +570,7 @@ describe('agentsMdReminder persisted restore provenance', () => { const result = await fire(h, didCtx('Read', { path: join(subDir, 'index.ts') })); expect(outputText(result)).toBe('original result'); - expect(enqueuedText(h)).toContain(subAgentsMd); + expect(reminderText(h)).toContain(subAgentsMd); }); it('recovers injected paths from a legacy restored prompt without path provenance', async () => { @@ -582,7 +585,7 @@ describe('agentsMdReminder persisted restore provenance', () => { const result = await fire(h, didCtx('Read', { path: join(workDir, 'index.ts') })); expect(outputText(result)).toBe('original result'); - expect(h.enqueued).toHaveLength(0); + expect(h.reminders).toHaveLength(0); }); }); @@ -595,7 +598,7 @@ describe('agentsMdReminder Bash operand hygiene', () => { const result = await fire(h, didCtx('Bash', { command: 'ls -w 80 packages/kap-server' })); expect(outputText(result)).toBe('original result'); - const text = enqueuedText(h); + const text = reminderText(h); expect(text).toContain(subAgentsMd); expect(text).not.toContain(eighty); }); @@ -610,7 +613,7 @@ describe('agentsMdReminder Bash operand hygiene', () => { ); expect(outputText(result)).toBe('original result'); - expect(enqueuedText(h)).toContain(subAgentsMd); + expect(reminderText(h)).toContain(subAgentsMd); }); }); @@ -624,7 +627,7 @@ describe('agentsMdReminder probing boundaries', () => { const result = await fire(h, didCtx('Read', { path: join(subDir, 'index.ts') })); expect(outputText(result)).toBe('original result'); - expect(h.enqueued).toHaveLength(0); + expect(h.reminders).toHaveLength(0); }); it('still reminds when the triggering call ended in an error result', async () => { @@ -640,7 +643,7 @@ describe('agentsMdReminder probing boundaries', () => { ); expect(outputText(result)).toBe('not found'); - expect(enqueuedText(h)).toContain(subAgentsMd); + expect(reminderText(h)).toContain(subAgentsMd); }); it('marks an AGENTS.md known when it is written directly', async () => { @@ -651,11 +654,11 @@ describe('agentsMdReminder probing boundaries', () => { const written = await fire(h, didCtx('Write', { path: agentsMdPath, content: 'x' })); expect(outputText(written)).toBe('original result'); - expect(h.enqueued).toHaveLength(0); + expect(h.reminders).toHaveLength(0); const after = await fire(h, didCtx('Read', { path: join(subDir, 'index.ts') })); expect(outputText(after)).toBe('original result'); - expect(h.enqueued).toHaveLength(0); + expect(h.reminders).toHaveLength(0); }); it('reminds at most once for two parallel touches of the same directory', async () => { @@ -670,7 +673,7 @@ describe('agentsMdReminder probing boundaries', () => { expect(outputText(first)).toBe('original result'); expect(outputText(second)).toBe('original result'); - expect(h.enqueued).toHaveLength(1); + expect(h.reminders).toHaveLength(1); }); it('re-judges the project root at a nested repository', async () => { @@ -683,7 +686,7 @@ describe('agentsMdReminder probing boundaries', () => { const result = await fire(h, didCtx('Read', { path: join(nested, 'index.ts') })); expect(outputText(result)).toBe('original result'); - const text = enqueuedText(h); + const text = reminderText(h); expect(text).toContain(nestedAgentsMd); expect(text).not.toContain(rootAgentsMd); }); @@ -699,7 +702,7 @@ describe('agentsMdReminder probing boundaries', () => { const result = await fire(h, didCtx('Read', { path: join(leaf, 'index.ts') })); expect(outputText(result)).toBe('original result'); - const text = enqueuedText(h); + const text = reminderText(h); expect(text).toContain(leafAgentsMd); expect(text).not.toContain(outerAgentsMd); } finally { @@ -717,7 +720,7 @@ describe('agentsMdReminder probing boundaries', () => { const result = await fire(h, didCtx('Read', { path: join(workDir, 'link', 'index.ts') })); expect(outputText(result)).toBe('original result'); - const text = enqueuedText(h); + const text = reminderText(h); expect(text).toContain(normalize(join(workDir, 'link', 'AGENTS.md'))); expect(text).not.toContain(targetAgentsMd); } finally { @@ -738,7 +741,7 @@ describe('agentsMdReminder round-2 hardening', () => { ); expect(outputText(result)).toBe('original result'); - expect(h.enqueued).toHaveLength(0); + expect(h.reminders).toHaveLength(0); expect(h.telemetryEvents).toHaveLength(0); }); @@ -751,11 +754,11 @@ describe('agentsMdReminder round-2 hardening', () => { const result = await fire(h, didCtx('Bash', { command: 'true' })); expect(outputText(result)).toBe('original result'); - expect(h.enqueued).toHaveLength(0); + expect(h.reminders).toHaveLength(0); const listed = await fire(h, didCtx('Bash', { command: 'ls packages' })); expect(outputText(listed)).toBe('original result'); - expect(enqueuedText(h)).toContain(subAgentsMd); + expect(reminderText(h)).toContain(subAgentsMd); }); it('ignores a whitespace-only AGENTS.md just like the init-time load', async () => { @@ -767,7 +770,7 @@ describe('agentsMdReminder round-2 hardening', () => { const result = await fire(h, didCtx('Read', { path: join(subDir, 'index.ts') })); expect(outputText(result)).toBe('original result'); - expect(h.enqueued).toHaveLength(0); + expect(h.reminders).toHaveLength(0); }); it('keeps known-sets isolated between agents', async () => { @@ -781,10 +784,10 @@ describe('agentsMdReminder round-2 hardening', () => { expect(outputText(firstResult)).toBe('original result'); expect(outputText(secondResult)).toBe('original result'); - expect(first.enqueued).toHaveLength(1); - expect(second.enqueued).toHaveLength(1); - expect(enqueuedText(first)).toContain(subAgentsMd); - expect(enqueuedText(second)).toContain(subAgentsMd); + expect(first.reminders).toHaveLength(1); + expect(second.reminders).toHaveLength(1); + expect(reminderText(first)).toContain(subAgentsMd); + expect(reminderText(second)).toContain(subAgentsMd); }); it('releases the claim when attaching the reminder fails, so the next touch retries', async () => { @@ -801,13 +804,13 @@ describe('agentsMdReminder round-2 hardening', () => { const failed = await fire(h, didCtx('Read', { path: join(subDir, 'a.ts') })); expect(outputText(failed)).toBe('original result'); - expect(h.enqueued).toHaveLength(0); + expect(h.reminders).toHaveLength(0); shouldThrow = false; const retried = await fire(h, didCtx('Read', { path: join(subDir, 'b.ts') })); expect(outputText(retried)).toBe('original result'); - expect(h.enqueued).toHaveLength(1); - expect(enqueuedText(h)).toContain(subAgentsMd); + expect(h.reminders).toHaveLength(1); + expect(reminderText(h)).toContain(subAgentsMd); }); it('leaves oversized results to the truncation pipeline and enqueues the reminder instead', async () => { @@ -848,8 +851,8 @@ describe('agentsMdReminder round-2 hardening', () => { expect(text).toContain('output_path:'); expect(text).not.toContain(''); expect(text).not.toContain(subAgentsMd); - expect(h.enqueued).toHaveLength(1); - expect(enqueuedText(h)).toContain(subAgentsMd); + expect(h.reminders).toHaveLength(1); + expect(reminderText(h)).toContain(subAgentsMd); }); it('uses the resolved file access instead of reparsing the raw path', async () => { @@ -889,8 +892,8 @@ describe('agentsMdReminder round-2 hardening', () => { expect(results).toHaveLength(1); expect(outputText(results[0]!.result)).toBe('home file contents'); - expect(h.enqueued).toHaveLength(1); - expect(enqueuedText(h)).toContain(homeAgentsMd); + expect(h.reminders).toHaveLength(1); + expect(reminderText(h)).toContain(homeAgentsMd); }); it('does not probe or remind when permission vetoes an access-bearing call', async () => { @@ -938,7 +941,7 @@ describe('agentsMdReminder round-2 hardening', () => { expect(results).toHaveLength(1); expect(outputText(results[0]!.result)).toBe('permission denied'); - expect(h.enqueued).toHaveLength(0); + expect(h.reminders).toHaveLength(0); expect(stat).not.toHaveBeenCalled(); expect(readText).not.toHaveBeenCalled(); expect( @@ -1028,7 +1031,7 @@ describe('agentsMdReminder cancellation outcomes', () => { const results = await pending; const queued = results.find((item) => item.toolCallId === 'call-queued-read'); expect(queued).toBeDefined(); - expect(h.enqueued).toHaveLength(0); + expect(h.reminders).toHaveLength(0); expect( h.telemetryEvents.filter((event) => event.event === 'agents_md_reminder_shown'), ).toEqual([]); @@ -1051,8 +1054,8 @@ describe('agentsMdReminder cancellation outcomes', () => { real.push(item); } expect(outputText(real[0]!.result)).toBe('read result'); - expect(h.enqueued).toHaveLength(1); - expect(enqueuedText(h)).toContain(subAgentsMd); + expect(h.reminders).toHaveLength(1); + expect(reminderText(h)).toContain(subAgentsMd); }); }); @@ -1067,7 +1070,7 @@ describe('agentsMdReminder Bash parse degradation', () => { ); expect(outputText(result)).toBe('original result'); - expect(enqueuedText(h)).toContain(subAgentsMd); + expect(reminderText(h)).toContain(subAgentsMd); }); it('skips entirely when an unparseable command has no explicit cwd', async () => { @@ -1077,7 +1080,7 @@ describe('agentsMdReminder Bash parse degradation', () => { const result = await fire(h, didCtx('Bash', { command: "ls '" })); expect(outputText(result)).toBe('original result'); - expect(h.enqueued).toHaveLength(0); + expect(h.reminders).toHaveLength(0); }); }); @@ -1122,7 +1125,7 @@ describe('agentsMdReminder Windows Bash paths', () => { const result = await fire(h, didCtx('Bash', args)); expect(outputText(result)).toBe('original result'); - expect(enqueuedText(h)).toContain(agentsMdPath); + expect(reminderText(h)).toContain(agentsMdPath); } }); }); diff --git a/packages/agent-core-v2/test/agent/contextInjector/contextInjector.test.ts b/packages/agent-core-v2/test/agent/contextInjector/contextInjector.test.ts index f71a4525f5e..7390b7a7c96 100644 --- a/packages/agent-core-v2/test/agent/contextInjector/contextInjector.test.ts +++ b/packages/agent-core-v2/test/agent/contextInjector/contextInjector.test.ts @@ -369,39 +369,6 @@ describe('AgentContextInjectorService', () => { expect(seen).toEqual([true, false]); }); - it('drains once-channels before running the providers', async () => { - const calls: string[] = []; - injector(ix).registerOnceChannel('test_channel', () => { - calls.push('once-channel'); - }); - injector(ix).register('ordering_test', () => { - calls.push('provider'); - return undefined; - }); - - await injector(ix).inject(undefined, false); - - expect(calls).toEqual(['once-channel', 'provider']); - }); - - it('skips a throwing once-channel drain and still runs the rest', async () => { - const calls: string[] = []; - injector(ix).registerOnceChannel('throwing_channel', () => { - throw new Error('boom'); - }); - injector(ix).registerOnceChannel('surviving_channel', () => { - calls.push('surviving'); - }); - injector(ix).register('ordering_test', () => { - calls.push('provider'); - return undefined; - }); - - await injector(ix).inject(undefined, false); - - expect(calls).toEqual(['surviving', 'provider']); - }); - it('appends tagged raw messages verbatim with the injection origin stamped', async () => { injector(ix).register('schema_test', () => ({ message: { @@ -424,7 +391,7 @@ describe('AgentContextInjectorService', () => { it('stamps the disclosure on tagged raw messages returned through the result wrapper', async () => { injector(ix).register('schema_test', () => ({ content: { message: { role: 'user', content: [{ type: 'text', text: 'raw' }] } }, - disclosure: { kind: 'once_reminder', id: 'r1' }, + disclosure: { kind: 'test_receipt', id: 'r1' }, })); await runInjectionStep(); @@ -432,7 +399,7 @@ describe('AgentContextInjectorService', () => { expect(context.get().at(-1)?.origin).toEqual({ kind: 'injection', variant: 'schema_test', - disclosure: { kind: 'once_reminder', id: 'r1' }, + disclosure: { kind: 'test_receipt', id: 'r1' }, }); }); diff --git a/packages/agent-core-v2/test/agent/goal/goal.test.ts b/packages/agent-core-v2/test/agent/goal/goal.test.ts index 8a61794e62b..319f0a9efce 100644 --- a/packages/agent-core-v2/test/agent/goal/goal.test.ts +++ b/packages/agent-core-v2/test/agent/goal/goal.test.ts @@ -15,7 +15,6 @@ import { USER_PROMPT_ORIGIN } from '#/agent/contextMemory/types'; import { IAgentGoalService } from '#/agent/goal/goal'; import { IGoalDeadlineScheduler } from '#/agent/goal/goalDeadlineScheduler'; import { type AgentGoalService } from '#/agent/goal/goalService'; -import { IAgentReminderQueueService } from '#/agent/reminderQueue/reminderQueue'; import { UpdateGoalToolInputSchema } from '#/agent/tools/goal/update-goal/update-goal'; import { UpdateGoalTool } from '#/agent/tools/goal/update-goal/updateGoalTool'; import { @@ -503,12 +502,10 @@ describe('AgentGoalService', () => { const removed = await goals.cancelGoal(); expect(removed.status).toBe('active'); expect(goals.getGoal()).toEqual({ goal: null }); - ctx.get(IAgentReminderQueueService).drain(); const reminder = context.get().at(-1); expect(reminder?.origin).toEqual({ kind: 'injection', variant: 'goal_cancelled', - disclosure: { kind: 'once_reminder', id: expect.any(String) }, }); expect(JSON.stringify(reminder?.content)).toContain('Ignore earlier active-goal reminders'); await expect(goals.cancelGoal()).rejects.toMatchObject({ code: ErrorCodes.GOAL_NOT_FOUND }); @@ -2326,12 +2323,10 @@ describe('AgentGoalService fork boundaries', () => { ]); expect(goals.getGoal().goal).toBeNull(); - ctx.get(IAgentReminderQueueService).drain(); const reminder = context.get().at(-1); expect(reminder?.origin).toEqual({ kind: 'injection', variant: 'goal_fork_cleared', - disclosure: { kind: 'once_reminder', id: expect.any(String) }, }); const text = JSON.stringify(reminder?.content); expect(text).toContain('This fork does not have a current goal.'); @@ -2356,7 +2351,6 @@ describe('AgentGoalService fork boundaries', () => { }, ]); - ctx.get(IAgentReminderQueueService).drain(); expect(context.get()).toHaveLength(1); expect(context.get()[0]?.origin).toEqual({ kind: 'system_trigger', name: 'goal_fork_cleared' }); }); diff --git a/packages/agent-core-v2/test/agent/goal/goalOps.test.ts b/packages/agent-core-v2/test/agent/goal/goalOps.test.ts index af83708f469..eb877db2566 100644 --- a/packages/agent-core-v2/test/agent/goal/goalOps.test.ts +++ b/packages/agent-core-v2/test/agent/goal/goalOps.test.ts @@ -22,10 +22,10 @@ import { GoalDeadlineSchedulerService } from '#/agent/goal/goalDeadlineScheduler import { AgentGoalService } from '#/agent/goal/goalService'; import { GoalModel } from '#/agent/goal/goalOps'; import { IAgentLoopService } from '#/agent/loop/loop'; -import { IAgentReminderQueueService } from '#/agent/reminderQueue/reminderQueue'; import { IAgentScopeContext } from '#/agent/scopeContext/scopeContext'; import { IAgentStateService } from '#/agent/state/agentState'; import { AgentStateService } from '#/agent/state/agentStateService'; +import { IAgentSystemReminderService } from '#/agent/systemReminder/systemReminder'; import { IAgentToolExecutorService } from '#/agent/toolExecutor/toolExecutor'; import { IAgentUsageService } from '#/agent/usage/usage'; import { ITelemetryService } from '#/app/telemetry/telemetry'; @@ -71,12 +71,11 @@ function createInjectorStub(): IAgentContextInjectorService { } as unknown as IAgentContextInjectorService; } -function createReminderQueueStub(): IAgentReminderQueueService { +function createSystemReminderStub(): IAgentSystemReminderService { return { _serviceBrand: undefined, - enqueue: () => '', - drain: () => undefined, - } as unknown as IAgentReminderQueueService; + appendSystemReminder: () => ({}), + } as unknown as IAgentSystemReminderService; } function createTelemetryStub(): ITelemetryService { @@ -125,7 +124,7 @@ function buildHost(key: string): { } as unknown as IAgentUsageService); ix.stub(IAgentContextMemoryService, createContextStub()); ix.stub(IAgentContextInjectorService, createInjectorStub()); - ix.stub(IAgentReminderQueueService, createReminderQueueStub()); + ix.stub(IAgentSystemReminderService, createSystemReminderStub()); ix.stub(ITelemetryService, createTelemetryStub()); ix.stub(IAgentToolExecutorService, createToolExecutorStub()); ix.stub(IConfigService, createConfigStub()); diff --git a/packages/agent-core-v2/test/agent/permissionMode/permissionMode.test.ts b/packages/agent-core-v2/test/agent/permissionMode/permissionMode.test.ts index 6d5c2aa1362..5f2ce6cd1b5 100644 --- a/packages/agent-core-v2/test/agent/permissionMode/permissionMode.test.ts +++ b/packages/agent-core-v2/test/agent/permissionMode/permissionMode.test.ts @@ -35,7 +35,6 @@ let registeredInjection: const injectorStub: IAgentContextInjectorService = { _serviceBrand: undefined, - registerOnceChannel: () => ({ dispose: () => {} }), register: (name, provider) => { registeredInjection = { name, provider: provider as ContextInjectionProvider }; return { diff --git a/packages/agent-core-v2/test/agent/reminderQueue/reminderQueue.test.ts b/packages/agent-core-v2/test/agent/reminderQueue/reminderQueue.test.ts deleted file mode 100644 index 499795256c2..00000000000 --- a/packages/agent-core-v2/test/agent/reminderQueue/reminderQueue.test.ts +++ /dev/null @@ -1,265 +0,0 @@ -/** - * Scenario: once-reminder queue — persistence, FIFO delivery, exactly-once - * crash-window reconciliation, and ledger immunity to undo. - * - * Exercises the real queue service against a real wire backed by an in-memory - * journal and a stub context. Run: `pnpm --filter @moonshot-ai/agent-core-v2 - * exec vitest run test/agent/reminderQueue/reminderQueue.test.ts`. - */ - -import { afterEach, beforeEach, describe, expect, it } from 'vitest'; - -import { DisposableStore } from '#/_base/di/lifecycle'; -import { createServices, type TestInstantiationService } from '#/_base/di/test'; -import { IAgentContextInjectorService } from '#/agent/contextInjector/contextInjector'; -import { IAgentContextMemoryService } from '#/agent/contextMemory/contextMemory'; -import { CHECKPOINTED_MODELS } from '#/agent/contextMemory/conversationTime'; -import { IAgentReminderQueueService } from '#/agent/reminderQueue/reminderQueue'; -import { ReminderQueueModel } from '#/agent/reminderQueue/reminderQueueOps'; -import { AgentReminderQueueService } from '#/agent/reminderQueue/reminderQueueService'; -import { IAgentSystemReminderService } from '#/agent/systemReminder/systemReminder'; -import { AgentSystemReminderService } from '#/agent/systemReminder/systemReminderService'; -import { IWireService } from '#/wire/wire'; -import type { WireRecord } from '#/wire/record'; -import { registerContextMemoryServices } from '../contextMemory/stubs'; -import { recordingWireLog, registerTestAgentWire, restoreTestAgentWire } from '../../wire/stubs'; - -const SCOPE = 'reminder-queue-test'; - -describe('AgentReminderQueueService', () => { - let disposables: DisposableStore; - let ix: TestInstantiationService; - let records: WireRecord[]; - let log: ReturnType; - let wire: IWireService; - let context: IAgentContextMemoryService; - let queue: IAgentReminderQueueService; - let reminders: IAgentSystemReminderService; - let onceDrains: Array<() => void>; - - beforeEach(() => { - disposables = new DisposableStore(); - records = []; - log = recordingWireLog(records); - onceDrains = []; - ix = createServices(disposables, { - base: [registerContextMemoryServices], - strict: true, - additionalServices: (reg) => { - reg.defineInstance(IAgentContextInjectorService, { - _serviceBrand: undefined, - registerOnceChannel: (_name: string, drain: () => void) => { - onceDrains.push(drain); - return { dispose() {} }; - }, - register: () => ({ dispose() {} }), - registerAtTurnStart: () => ({ dispose() {} }), - injectAfterCompaction: async () => {}, - }); - reg.define(IAgentSystemReminderService, AgentSystemReminderService); - reg.define(IAgentReminderQueueService, AgentReminderQueueService); - }, - }); - wire = registerTestAgentWire(ix, SCOPE, { log }); - context = ix.get(IAgentContextMemoryService); - queue = ix.get(IAgentReminderQueueService); - reminders = ix.get(IAgentSystemReminderService); - }); - - afterEach(() => { - disposables.dispose(); - }); - - function injectionMessages() { - return context.get().filter((m) => m.origin?.kind === 'injection'); - } - - it('is not a checkpointed model (undo never revives the delivery ledger)', () => { - expect(CHECKPOINTED_MODELS.some((model) => model.name === ReminderQueueModel.name)).toBe(false); - }); - - it('persists enqueued entries and replays them into the pending ledger', async () => { - const journal = [ - { - type: 'reminderQueue.enqueue', - entry: { id: 'e1', variant: 'interruption', content: 'first' }, - }, - { - type: 'reminderQueue.enqueue', - entry: { id: 'e2', variant: 'init', content: 'second' }, - }, - ] as unknown as WireRecord[]; - - await restoreTestAgentWire(wire, log, SCOPE, journal); - - const pending = wire.getModel(ReminderQueueModel); - expect(pending).toHaveLength(2); - expect(pending.map((entry) => [entry.variant, entry.content])).toEqual([ - ['interruption', 'first'], - ['init', 'second'], - ]); - }); - - it('drains pending entries FIFO, wraps them as system reminders, and clears the ledger', () => { - queue.enqueue({ variant: 'a', content: 'first' }); - queue.enqueue({ variant: 'b', content: 'second' }); - - queue.drain(); - - const messages = context.get(); - expect(messages).toHaveLength(2); - expect(messages[0]).toMatchObject({ - role: 'user', - content: [{ type: 'text', text: '\nfirst\n' }], - origin: { kind: 'injection', variant: 'a' }, - }); - expect(messages[1]).toMatchObject({ - content: [{ type: 'text', text: '\nsecond\n' }], - origin: { kind: 'injection', variant: 'b' }, - }); - expect(wire.getModel(ReminderQueueModel)).toEqual([]); - expect(records.map((record) => record.type)).toEqual([ - 'reminderQueue.enqueue', - 'reminderQueue.enqueue', - 'reminderQueue.delivered', - 'reminderQueue.delivered', - ]); - }); - - it('collapses consecutive identical entries into a single reminder', () => { - queue.enqueue({ variant: 'interruption', content: 'same' }); - queue.enqueue({ variant: 'interruption', content: 'same' }); - - queue.drain(); - - expect(injectionMessages()).toHaveLength(1); - expect(wire.getModel(ReminderQueueModel)).toEqual([]); - }); - - it('delivers entries left pending by a crash (restore drain)', async () => { - const journal = [ - { - type: 'reminderQueue.enqueue', - entry: { id: 'e1', variant: 'interruption', content: 'pending at crash' }, - }, - ] as unknown as WireRecord[]; - - await restoreTestAgentWire(wire, log, SCOPE, journal); - - expect(wire.getModel(ReminderQueueModel)).toHaveLength(1); - queue.drain(); - expect(injectionMessages()).toHaveLength(1); - expect(injectionMessages()[0]).toMatchObject({ - content: [{ type: 'text', text: '\npending at crash\n' }], - origin: { kind: 'injection', variant: 'interruption' }, - }); - expect(wire.getModel(ReminderQueueModel)).toEqual([]); - }); - - it('reconciles an appended-but-unrecorded delivery after restore without duplicating', async () => { - reminders.appendSystemReminder('crash window', { - kind: 'injection', - variant: 'interruption', - disclosure: { kind: 'once_reminder', id: 'e1' }, - }); - const journal = [ - { - type: 'reminderQueue.enqueue', - entry: { id: 'e1', variant: 'interruption', content: 'crash window' }, - }, - ] as unknown as WireRecord[]; - - await restoreTestAgentWire(wire, log, SCOPE, journal); - expect(wire.getModel(ReminderQueueModel)).toHaveLength(1); - - queue.drain(); - - expect(injectionMessages()).toHaveLength(1); - expect(wire.getModel(ReminderQueueModel)).toEqual([]); - }); - - it('reconciles multiple appended-but-unrecorded deliveries after restore', async () => { - reminders.appendSystemReminder('first', { - kind: 'injection', - variant: 'a', - disclosure: { kind: 'once_reminder', id: 'e1' }, - }); - reminders.appendSystemReminder('second', { - kind: 'injection', - variant: 'b', - disclosure: { kind: 'once_reminder', id: 'e2' }, - }); - const journal = [ - { - type: 'reminderQueue.enqueue', - entry: { id: 'e1', variant: 'a', content: 'first' }, - }, - { - type: 'reminderQueue.enqueue', - entry: { id: 'e2', variant: 'b', content: 'second' }, - }, - ] as unknown as WireRecord[]; - - await restoreTestAgentWire(wire, log, SCOPE, journal); - expect(wire.getModel(ReminderQueueModel)).toHaveLength(2); - - queue.drain(); - - expect(injectionMessages()).toHaveLength(2); - expect(wire.getModel(ReminderQueueModel)).toEqual([]); - }); - - it('skips vacuous partial assistant messages when matching the conversation tail', async () => { - reminders.appendSystemReminder('crash window', { - kind: 'injection', - variant: 'interruption', - disclosure: { kind: 'once_reminder', id: 'e1' }, - }); - context.append({ role: 'assistant', content: [], toolCalls: [], partial: true }); - const journal = [ - { - type: 'reminderQueue.enqueue', - entry: { id: 'e1', variant: 'interruption', content: 'crash window' }, - }, - ] as unknown as WireRecord[]; - - await restoreTestAgentWire(wire, log, SCOPE, journal); - - queue.drain(); - - expect(injectionMessages()).toHaveLength(1); - expect(wire.getModel(ReminderQueueModel)).toEqual([]); - }); - - it('appends a fresh reminder when the tail holds an older, different one', () => { - queue.enqueue({ variant: 'interruption', content: 'earlier' }); - queue.drain(); - queue.enqueue({ variant: 'interruption', content: 'latest' }); - - queue.drain(); - - expect(injectionMessages()).toHaveLength(2); - expect(wire.getModel(ReminderQueueModel)).toEqual([]); - }); - - it('records the entry id as the delivered reminder disclosure', () => { - queue.enqueue({ variant: 'a', content: 'first' }); - - queue.drain(); - - expect(injectionMessages()[0]?.origin).toEqual({ - kind: 'injection', - variant: 'a', - disclosure: { kind: 'once_reminder', id: expect.any(String) }, - }); - }); - - it('drains itself on every injection boundary', () => { - queue.enqueue({ variant: 'a', content: 'first' }); - - for (const drain of onceDrains) drain(); - - expect(injectionMessages()).toHaveLength(1); - expect(wire.getModel(ReminderQueueModel)).toEqual([]); - }); -}); diff --git a/packages/agent-core-v2/test/index.test.ts b/packages/agent-core-v2/test/index.test.ts index a875d41e6cf..17dac2d67f4 100644 --- a/packages/agent-core-v2/test/index.test.ts +++ b/packages/agent-core-v2/test/index.test.ts @@ -6,7 +6,6 @@ import { IAgentContextMemoryService, IAgentTokenCountingService, IAgentGoalService, - IAgentReminderQueueService, type ContextMessage, type WireRecord, } from '#/index'; @@ -89,8 +88,6 @@ const V2_RECORD_TYPES: ReadonlySet = new Set([ 'interaction.resolved', 'plan.revision', 'interruptionReminder.recorded', - 'reminderQueue.enqueue', - 'reminderQueue.delivered', 'turn.ended', ]); @@ -407,12 +404,10 @@ describe('AgentRecords persistence metadata', () => { 'forked', ]); expect(ctx.get(IAgentGoalService).getGoal().goal).toBeNull(); - ctx.get(IAgentReminderQueueService).drain(); const reminder = context.get().at(-1); expect(reminder?.origin).toEqual({ kind: 'injection', variant: 'goal_fork_cleared', - disclosure: { kind: 'once_reminder', id: expect.any(String) }, }); expect(JSON.stringify(reminder?.content)).toContain('This fork does not have a current goal.'); }); @@ -438,11 +433,9 @@ describe('AgentRecords persistence metadata', () => { goalId: 'fork-goal', objective: 'fork work', }); - ctx.get(IAgentReminderQueueService).drain(); expect(context.get().at(-1)?.origin).toEqual({ kind: 'injection', variant: 'goal_fork_cleared', - disclosure: { kind: 'once_reminder', id: expect.any(String) }, }); }); diff --git a/packages/agent-core-v2/test/session/btw/btw.test.ts b/packages/agent-core-v2/test/session/btw/btw.test.ts index 2bee18d9ddd..ebd9944efe7 100644 --- a/packages/agent-core-v2/test/session/btw/btw.test.ts +++ b/packages/agent-core-v2/test/session/btw/btw.test.ts @@ -3,7 +3,7 @@ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; import { SyncDescriptor } from '#/_base/di/descriptors'; import { DisposableStore } from '#/_base/di/lifecycle'; import { TestInstantiationService } from '#/_base/di/test'; -import { IAgentReminderQueueService } from '#/agent/reminderQueue/reminderQueue'; +import { IAgentSystemReminderService } from '#/agent/systemReminder/systemReminder'; import { IAgentToolApprovalService } from '#/agent/toolApproval/toolApproval'; import { IAgentToolExecutorService } from '#/agent/toolExecutor/toolExecutor'; import type { ToolCall } from '#/kosong/contract/message'; @@ -21,14 +21,14 @@ describe('SessionBtwService', () => { let disposables: DisposableStore; let ix: TestInstantiationService; let fork: ReturnType; - let enqueue: ReturnType; + let appendReminder: ReturnType; let formatDenyMessage: ReturnType; let executorEvents: ToolExecutorEventStubs; beforeEach(() => { disposables = new DisposableStore(); ix = disposables.add(new TestInstantiationService()); - enqueue = vi.fn(() => 'reminder-id'); + appendReminder = vi.fn(() => 'reminder-id'); // The suffix mimics the worker-rejection guidance formatDenyMessage appends // for forked sub agents, so the assertion proves the reason went through it. formatDenyMessage = vi.fn((message: string) => `${message} [worker guidance]`); @@ -38,7 +38,7 @@ describe('SessionBtwService', () => { id: 'agent-btw-1', accessor: { get: (id: unknown) => { - if (id === IAgentReminderQueueService) return { enqueue }; + if (id === IAgentSystemReminderService) return { appendSystemReminder: appendReminder }; if (id === IAgentToolApprovalService) return { formatDenyMessage }; if (id === IAgentToolExecutorService) return executorEvents.executor; return undefined; @@ -60,9 +60,9 @@ describe('SessionBtwService', () => { expect(id).toBe('agent-btw-1'); expect(fork).toHaveBeenCalledWith('main'); - expect(enqueue).toHaveBeenCalledWith({ + expect(appendReminder).toHaveBeenCalledWith(SIDE_QUESTION_SYSTEM_REMINDER, { + kind: 'injection', variant: 'btw', - content: SIDE_QUESTION_SYSTEM_REMINDER, }); }); diff --git a/packages/agent-core-v2/test/session/sessionInit/sessionInit.test.ts b/packages/agent-core-v2/test/session/sessionInit/sessionInit.test.ts index 27ddfc1c193..ec7e6de0406 100644 --- a/packages/agent-core-v2/test/session/sessionInit/sessionInit.test.ts +++ b/packages/agent-core-v2/test/session/sessionInit/sessionInit.test.ts @@ -12,7 +12,7 @@ import { IHostFileSystem, type HostFileStat } from '#/os/interface/hostFileSyste import { IAgentPermissionModeService } from '#/agent/permissionMode/permissionMode'; import { IAgentProfileService } from '#/agent/profile/profile'; import { IAgentAgentsMdReminderService } from '#/agent/agentsMdReminder/agentsMdReminder'; -import { IAgentReminderQueueService } from '#/agent/reminderQueue/reminderQueue'; +import { IAgentSystemReminderService } from '#/agent/systemReminder/systemReminder'; import { IWireService } from '#/wire/wire'; import { ErrorCodes, Error2 } from '#/errors'; import { IAgentLifecycleService } from '#/session/agentLifecycle/agentLifecycle'; @@ -30,7 +30,7 @@ describe('SessionInitService', () => { let disposables: DisposableStore; let ix: TestInstantiationService; let events: unknown[]; - let enqueue: ReturnType; + let appendReminder: ReturnType; let seedInjected: ReturnType; let flush: ReturnType; let republishStatus: ReturnType; @@ -42,7 +42,7 @@ describe('SessionInitService', () => { disposables = new DisposableStore(); ix = disposables.add(new TestInstantiationService()); events = []; - enqueue = vi.fn(() => 'reminder-id'); + appendReminder = vi.fn(() => 'reminder-id'); seedInjected = vi.fn(); flush = vi.fn(async () => {}); republishStatus = vi.fn(() => { @@ -83,7 +83,7 @@ describe('SessionInitService', () => { if (id === ISessionSubagentService) return lifecycle; if (id === IAgentProfileService) return profile; if (id === IAgentPermissionModeService) return permissionMode; - if (id === IAgentReminderQueueService) return { enqueue }; + if (id === IAgentSystemReminderService) return { appendSystemReminder: appendReminder }; if (id === IAgentAgentsMdReminderService) return { seedInjected }; if (id === IWireService) return { flush }; if (id === IEventBus) return eventBus; @@ -150,12 +150,15 @@ describe('SessionInitService', () => { expect(runArgs[1]).toMatchObject({ kind: 'prompt' }); expect((runArgs[1] as { prompt: string }).prompt).toContain('Task requirements:'); - expect(enqueue).toHaveBeenCalledTimes(1); - const [input] = enqueue.mock.calls[0] as [{ variant: string; content: string }]; - expect(input.variant).toBe('init'); - expect(input.content).toContain('The user just ran `/init` slash command.'); - expect(input.content).toContain('Latest AGENTS.md file content:'); - expect(input.content).toContain(AGENTS_MD); + expect(appendReminder).toHaveBeenCalledTimes(1); + const [content, origin] = appendReminder.mock.calls[0] as [ + string, + { kind: string; variant: string }, + ]; + expect(origin).toEqual({ kind: 'injection', variant: 'init' }); + expect(content).toContain('The user just ran `/init` slash command.'); + expect(content).toContain('Latest AGENTS.md file content:'); + expect(content).toContain(AGENTS_MD); expect(seedInjected).toHaveBeenCalledWith([AGENTS_MD_PATH], WORK_DIR); From 6039100c727dbacde17fa487b2698077b2213b53 Mon Sep 17 00:00:00 2001 From: 7Sageer <7sageer@djwcb.cn> Date: Thu, 6 Aug 2026 19:43:17 +0800 Subject: [PATCH 20/27] chore(agent-core-v2): drop the stale reminder-queue mention in systemReminder --- .../agent-core-v2/src/agent/systemReminder/systemReminder.ts | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/packages/agent-core-v2/src/agent/systemReminder/systemReminder.ts b/packages/agent-core-v2/src/agent/systemReminder/systemReminder.ts index 762a9322381..106c2de2a1a 100644 --- a/packages/agent-core-v2/src/agent/systemReminder/systemReminder.ts +++ b/packages/agent-core-v2/src/agent/systemReminder/systemReminder.ts @@ -1,8 +1,8 @@ /** * `systemReminder` domain — low-level model-facing reminder write contract. * - * Defines the Agent-scoped write head used by context injection, the durable - * world-event queue, and prompt-owned media annotations, and owns the + * Defines the Agent-scoped write head used by context injection, event-point + * one-off reminders, and prompt-owned media annotations, and owns the * `` text format: `wrapSystemReminder` is the only writer, * `systemReminderContent` the only reader, so no consumer reconstructs the * format by hand. Bound at Agent scope. From bb906b675f4eb70b9a4c349afcc9c579cc1d61f9 Mon Sep 17 00:00:00 2001 From: 7Sageer <7sageer@djwcb.cn> Date: Tue, 11 Aug 2026 11:57:04 +0800 Subject: [PATCH 21/27] test(node-sdk): align side-question fork parity with event-point reminders --- packages/node-sdk/test/v1-v2-parity.test.ts | 20 ++++++-------------- 1 file changed, 6 insertions(+), 14 deletions(-) diff --git a/packages/node-sdk/test/v1-v2-parity.test.ts b/packages/node-sdk/test/v1-v2-parity.test.ts index 8ac92069ed4..d02d488d114 100644 --- a/packages/node-sdk/test/v1-v2-parity.test.ts +++ b/packages/node-sdk/test/v1-v2-parity.test.ts @@ -4392,23 +4392,15 @@ describe('v1↔v2 residual surface parity', () => { key === 'origin' ? undefined : value, ), ); - // v2 queues the side-question reminder until its next injection - // boundary; v1 materializes it while forking. The inherited context is - // already identical before that boundary. - expect(v2Context.history).toHaveLength(1); + // Both engines materialize the side-question reminder while forking: + // v2 appends it at the fork event point (a past-tense one-off fact), + // so the inherited contexts are already identical right after fork. expect(v1Context.history).toHaveLength(2); - const v2Session = getLiveSessionById(pair.v2.engineAccessor, sessionId); - const v2Child = v2Session?.accessor.get(IAgentLifecycleService).get(v2ChildId); - expect(v2Child).toBeDefined(); - await v2Child!.accessor.get(IAgentContextInjectorService).injectAfterCompaction(); - const [v1Materialized, v2Materialized] = await Promise.all([ - pair.v1.withInteractiveAgent(v1ChildId, () => pair.v1.getContext({ sessionId })), - pair.v2.withInteractiveAgent(v2ChildId, () => pair.v2.getContext({ sessionId })), - ]); - expect(stripOrigins(v2Materialized)).toEqual(stripOrigins(v1Materialized)); + expect(v2Context.history).toHaveLength(2); + expect(stripOrigins(v2Context)).toEqual(stripOrigins(v1Context)); // Non-vacuous: the inherited import plus the side-question reminder // (byte-identical template on both engines). - const v1History = v1Materialized.history; + const v1History = v1Context.history; expect(v1History.length).toBeGreaterThanOrEqual(2); const reminder = v1History.at(-1); expect(JSON.stringify(reminder)).toContain('side-channel conversation'); From dcf45489ad02d00e8194492b2f58887f023326fc Mon Sep 17 00:00:00 2001 From: 7Sageer <7sageer@djwcb.cn> Date: Tue, 11 Aug 2026 12:10:38 +0800 Subject: [PATCH 22/27] chore(agent-core-v2): address reminder review standards --- .../agentsMdReminderService.ts | 65 ++----------------- .../contextInjector/contextInjectorService.ts | 41 ++---------- .../src/agent/dateChange/dateChange.ts | 10 +-- .../agent/swarm/injection/swarmInjection.ts | 14 +--- .../toolSelect/toolSelectSchemasService.ts | 10 +-- .../contextInjector/contextInjector.test.ts | 8 +-- .../test/agent/swarm/swarm.test.ts | 34 ++++++++-- 7 files changed, 47 insertions(+), 135 deletions(-) diff --git a/packages/agent-core-v2/src/agent/agentsMdReminder/agentsMdReminderService.ts b/packages/agent-core-v2/src/agent/agentsMdReminder/agentsMdReminderService.ts index 3965961f086..61a135f9bf3 100644 --- a/packages/agent-core-v2/src/agent/agentsMdReminder/agentsMdReminderService.ts +++ b/packages/agent-core-v2/src/agent/agentsMdReminder/agentsMdReminderService.ts @@ -2,61 +2,12 @@ * `agentsMdReminder` domain — `IAgentAgentsMdReminderService` * implementation. * - * Self-wiring plugin: registers an `onDidExecuteTool` hook on `toolExecutor` - * that probes the directories a tool call touches for AGENTS.md files the - * system prompt did not inject, and appends a once-per-agent - * `` through `systemReminder` right at the tool-execution - * point. The hook only - * observes — it never rewrites the tool result, so results stay verbatim - * for the truncation pipeline and the reminder can never be truncated away - * with an oversized output. `Read`/`Edit`/`Write` consume the canonical - * file access declared by their resolved execution (a successful touch - * landing on an AGENTS.md itself marks just that file known), - * `Glob`/`Grep` consume their canonical search root, and `Bash` - * contributes its explicit `cwd` plus the literal directory operands - * extracted from the command's syntax tree (see `./bashTargets`), resolved - * against the frozen - * `sessionContext.cwd` exactly like the Bash tool itself (`args.cwd ?? - * sessionContext.cwd` — a base that deliberately differs from the live agent - * cwd after a chdir). Only calls whose `ToolDidExecuteContext.outcome` is - * `executed` are probed: preflight rejects, resolution failures, aborts, - * permission vetoes, and synthetic/duplicate results have not touched the - * requested resource and are ignored. - * - * Known-set discipline: candidates are claimed synchronously per discovered - * file into an in-memory `claimed` set (parallel calls can never duplicate a - * reminder and a failed attempt releases the claim), while `agentState` - * (`agentsMdReminder.known`) is only ever whole-value replaced after the - * telemetry emitted and the reminder appended — never mutated in place, and - * never ahead of the reminder it records. The telemetry event fires before - * the append so a telemetry failure simply retries the reminder on the next - * qualifying touch. Probing anchors at the nearest existing ancestor (so - * `Write` into a not-yet-created directory still resolves), walks - * `findProjectRoot → touched dir`, skips chain - * directories whose candidates are all known, and applies the same - * per-directory candidate rules as the init-time load (shared through - * `profile/context`'s `findAgentsMdInDir`; blank files are included in - * neither). Directories with unknown candidates are re-statted on every - * qualifying call — deliberate, so an AGENTS.md created mid-session is - * picked up on the next touch; there is no negative cache. Probing is - * lexical like the tools' own path policy: a symlinked directory's AGENTS.md - * is discovered through the link at its lexical address, never by realpath. - * The hook never throws — a probe failure appends nothing. - * - * Seeding: `profile` reports the injected paths after every successful - * bind/apply/refresh and `sessionInit` re-seeds after `/init`. A prompt can - * also commit without any of those entry points — session resume and forks - * restore the already-rendered system prompt (AGENTS.md content included) - * from the wire journal or a binding snapshot. The wire restore hook seeds - * the exact persisted paths (legacy prompts recover their source annotations), - * so the first qualifying call of a never-seeded agent does not confuse the - * current filesystem with the restored prompt. The seeded cwd lives in - * `agentState` as well; restored provenance comes from `wire`/`profile`; fs - * probes go through the os `IHostFileSystem`, the home directory through - * `IHostEnvironment`, the brand home through `bootstrap`, syntax - * trees through `bashParser`, the shown-event - * through `telemetry`, and the reminder write head through - * `systemReminder`. Bound at Agent scope. + * Discovers AGENTS.md files reached through `toolExecutor` and the tool path + * policy, parsing Bash targets through `bashParser` and probing through the os + * services. Restores prompt provenance through `wire` and `profile`, resolves + * roots through `sessionContext` and `bootstrap`, stores discovery state in + * `agentState`, appends through `systemReminder`, and reports through + * `telemetry`. Bound at Agent scope. */ import { basename, dirname, isAbsolute, join, normalize } from 'pathe'; @@ -205,9 +156,7 @@ export class AgentAgentsMdReminderService variant: 'agents_md', }); this.publishKnown([...selfKnown, ...discovered]); - } catch { - // A probe or append failure simply retries on the next qualifying touch. - } finally { + } catch {} finally { for (const path of discovered) this.claimed.delete(path); } } diff --git a/packages/agent-core-v2/src/agent/contextInjector/contextInjectorService.ts b/packages/agent-core-v2/src/agent/contextInjector/contextInjectorService.ts index 291b2fa338e..d8ba4871890 100644 --- a/packages/agent-core-v2/src/agent/contextInjector/contextInjectorService.ts +++ b/packages/agent-core-v2/src/agent/contextInjector/contextInjectorService.ts @@ -1,43 +1,10 @@ /** * `contextInjector` domain — `IAgentContextInjectorService` implementation. * - * The reconcile scheduler for the registered context providers - * (present-tense state reminders). At each injection boundary (turn start, - * step, compaction follow-up) it reconciles the registered providers - * through `loop` hooks and routes their results into the conversation: - * strings become `` messages through `systemReminder`, - * content parts append as bare user messages, and tagged raw messages - * (`{ message }`) append verbatim with the injection origin stamped — the - * form for structured payloads such as the dynamic-tool schema - * declaration's `tools` field. Past-tense one-off reminders (a cancelled - * goal, a discovered AGENTS.md, …) do NOT pass through this service: - * their producers append them at the event point through `systemReminder`, - * sharing the same `injection` origin vocabulary. A provider that throws - * or rejects at a step or compaction boundary is logged and skipped, so - * one bad provider can neither starve the rest nor fail the turn. - * Provider positions are never cached: each provider call derives them by - * scanning `contextMemory` for its own surviving injection messages, so - * splices, compaction folds, and `wire` restoration need no index - * bookkeeping. Each provider call receives the newest surviving injection - * of its own variant (`lastInjection`) and the typed disclosure recorded - * on it (`lastDisclosure`), so providers never read context layout or - * position indexes themselves. Turn-start providers run synchronously - * after `turn.started` — before the turn's first step request materializes - * its prompt — and must stay synchronous (a provider that throws or - * returns a Promise is logged and skipped, so one bad provider cannot - * starve the rest); they are also reconciled at every later step boundary - * as a fallback for facts that arrive after `turn.started` (for example, a - * queued turn cancelled while another turn is already running). - * `isNewTurn` is derived per boundary and never stored: the step hook - * reads it from the loop's own step counter - * (`BeforeStepContext.firstStepOfTurn`), and the compaction follow-up - * passes it explicitly, so interleaved triggers cannot consume or steal a - * shared flag. A compaction follow-up that lands inside a step hook chain - * (the auto-compaction path) doubles as that step's new-turn delivery — - * the enclosing step then injects with `isNewTurn: false`, so the upcoming - * request receives one new-turn injection, not two. `entries` stays a - * plain instance field (its values hold functions, not plain data). - * Bound at Agent scope. + * Reconciles registered model-context providers against `contextMemory`, + * observes turn and step boundaries through `eventBus` and `loop`, writes + * reminders through `systemReminder`, and reports provider failures through + * `log`. Bound at Agent scope. */ import { toDisposable, type IDisposable } from "#/_base/di/lifecycle"; diff --git a/packages/agent-core-v2/src/agent/dateChange/dateChange.ts b/packages/agent-core-v2/src/agent/dateChange/dateChange.ts index e78be7d56e3..d6c19dc53fa 100644 --- a/packages/agent-core-v2/src/agent/dateChange/dateChange.ts +++ b/packages/agent-core-v2/src/agent/dateChange/dateChange.ts @@ -1,18 +1,12 @@ /** * `dateChange` domain (L4) — `IAgentDateChangeService` contract. * - * Defines the Agent-scope marker service that announces calendar-date changes - * through a `date_change` context-injection reminder when a session outlives - * the date rendered into its system prompt. + * Defines the Agent-scope marker service and typed disclosure for model-facing + * calendar-date reminders. */ import { createDecorator, type ServiceIdentifier } from '#/_base/di/instantiation'; -/** - * Typed disclosure recorded on `date_change` injections: the render baseline - * the model has most recently seen, handed back to the provider on the next - * reconciliation. - */ export interface DateInjectionDisclosure { readonly kind: 'date'; readonly renderGeneration: number; diff --git a/packages/agent-core-v2/src/agent/swarm/injection/swarmInjection.ts b/packages/agent-core-v2/src/agent/swarm/injection/swarmInjection.ts index 3fd0255c230..694372757ce 100644 --- a/packages/agent-core-v2/src/agent/swarm/injection/swarmInjection.ts +++ b/packages/agent-core-v2/src/agent/swarm/injection/swarmInjection.ts @@ -1,17 +1,9 @@ /** * `swarm` domain — swarm-mode context injection. * - * Owns the `swarm_mode` context-injection provider, mirroring - * `permissionMode`: the enter guidance is re-announced whenever its live - * positions are folded away (compaction), and enter/exit transitions render - * the corresponding reminder. Tool-triggered swarms never render — the - * AgentSwarm tool result already tells the model. Reconciliation derives the - * rendered state from the typed disclosure recorded on the newest surviving - * injection, never from rendered text, so reminder copy edits cannot corrupt - * the state of restored sessions. Sessions written before disclosures existed - * fall back to variant matching against `contextMemory`: a surviving - * `swarm_mode` injection means active, a surviving legacy `swarm_mode_exit` - * injection means inactive, whichever is latest wins. + * Registers swarm-mode guidance through `contextInjector` and reads + * `contextMemory` for restored legacy state. Used by the Agent-scoped swarm + * service. */ import { Disposable } from '#/_base/di/lifecycle'; diff --git a/packages/agent-core-v2/src/agent/toolSelect/toolSelectSchemasService.ts b/packages/agent-core-v2/src/agent/toolSelect/toolSelectSchemasService.ts index d2a04c23fb3..b491164ec28 100644 --- a/packages/agent-core-v2/src/agent/toolSelect/toolSelectSchemasService.ts +++ b/packages/agent-core-v2/src/agent/toolSelect/toolSelectSchemasService.ts @@ -1,14 +1,8 @@ /** * `toolSelect` domain — `IAgentToolSelectSchemasService` implementation. * - * Registers dynamic-tool schema declarations as a `contextInjector` - * provider (variant `dynamic_tool_schema`): every injection boundary drains - * the tools `toolSelect` marked loaded since the last declaration and - * appends them as a `role: 'system'` raw injection message whose `tools` - * field carries the full definitions, so the declaration lands at a - * quiescent boundary instead of mid-step inside a streaming tool exchange. - * The folded history itself remains the loaded-tool ledger, so undo, - * compaction, and resume self-heal by re-folding. Bound at Agent scope. + * Declares pending dynamic-tool schemas from `toolSelect` through + * `contextInjector`. Bound at Agent scope. */ import { Service } from '#/_base/di/service'; diff --git a/packages/agent-core-v2/test/agent/contextInjector/contextInjector.test.ts b/packages/agent-core-v2/test/agent/contextInjector/contextInjector.test.ts index 7390b7a7c96..5e022cd3fe4 100644 --- a/packages/agent-core-v2/test/agent/contextInjector/contextInjector.test.ts +++ b/packages/agent-core-v2/test/agent/contextInjector/contextInjector.test.ts @@ -38,12 +38,8 @@ import { stubWire, } from '../loop/stubs'; -type InjectableContextInjector = IAgentContextInjectorService & { - inject(boundary: undefined, isNewTurn: boolean): Promise; -}; - -function injector(ix: TestInstantiationService): InjectableContextInjector { - return ix.get(IAgentContextInjectorService) as InjectableContextInjector; +function injector(ix: TestInstantiationService): IAgentContextInjectorService { + return ix.get(IAgentContextInjectorService); } function userMessage(text: string): ContextMessage { diff --git a/packages/agent-core-v2/test/agent/swarm/swarm.test.ts b/packages/agent-core-v2/test/agent/swarm/swarm.test.ts index 9c573bb9b35..eedd33e2f10 100644 --- a/packages/agent-core-v2/test/agent/swarm/swarm.test.ts +++ b/packages/agent-core-v2/test/agent/swarm/swarm.test.ts @@ -74,6 +74,12 @@ async function runInjectionBoundary(loop: IAgentLoopService): Promise { await loop.hooks.onWillBeginStep.run({ turnId: 0, step: 1, firstStepOfTurn: true, signal }); } +function messageText(message: ContextMessage | undefined): string { + return ( + message?.content.map((part) => (part.type === 'text' ? part.text : '')).join('') ?? '' + ); +} + function context( args: Input, toolCallId = 'call_swarm', @@ -252,36 +258,50 @@ describe('AgentSwarmService', () => { ]); }); - it('renders the enter guidance on enter and the exit guidance on exit through the injector', async () => { + it('renders enter guidance when manual swarm mode becomes active', async () => { const swarm = ix.get(IAgentSwarmService); const context = ix.get(IAgentContextMemoryService); - const textOf = (message: ContextMessage | undefined): string => - message?.content.map((part) => (part.type === 'text' ? part.text : '')).join('') ?? ''; swarm.enter('manual'); await runInjectionBoundary(ix.get(IAgentLoopService)); - let reminder = context.get().at(-1); + const reminder = context.get().at(-1); expect(reminder?.origin).toEqual({ kind: 'injection', variant: 'swarm_mode', disclosure: { kind: 'swarm_mode', state: 'active' }, }); - expect(textOf(reminder)).toContain('You are now in "agent swarm" mode.'); + expect(messageText(reminder)).toContain('You are now in "agent swarm" mode.'); + expect(context.get()).toHaveLength(1); + }); + it('keeps one enter guidance when a later boundary sees the same active state', async () => { + const swarm = ix.get(IAgentSwarmService); + const context = ix.get(IAgentContextMemoryService); + + swarm.enter('manual'); await runInjectionBoundary(ix.get(IAgentLoopService)); + await runInjectionBoundary(ix.get(IAgentLoopService)); + expect(context.get()).toHaveLength(1); + }); + it('renders exit guidance when manual swarm mode becomes inactive', async () => { + const swarm = ix.get(IAgentSwarmService); + const context = ix.get(IAgentContextMemoryService); + + swarm.enter('manual'); + await runInjectionBoundary(ix.get(IAgentLoopService)); swarm.exit(); await runInjectionBoundary(ix.get(IAgentLoopService)); - reminder = context.get().at(-1); + const reminder = context.get().at(-1); expect(reminder?.origin).toEqual({ kind: 'injection', variant: 'swarm_mode', disclosure: { kind: 'swarm_mode', state: 'inactive' }, }); - expect(textOf(reminder)).toContain('Swarm Mode has ended.'); + expect(messageText(reminder)).toContain('Swarm Mode has ended.'); expect(context.get()).toHaveLength(2); }); From 2cada79a3476a9328e63a9fb041ea0438c726b27 Mon Sep 17 00:00:00 2001 From: 7Sageer <7sageer@djwcb.cn> Date: Tue, 11 Aug 2026 12:34:34 +0800 Subject: [PATCH 23/27] docs(agent-core-v2): condense the model-facing reminders section --- packages/agent-core-v2/AGENTS.md | 7 +------ 1 file changed, 1 insertion(+), 6 deletions(-) diff --git a/packages/agent-core-v2/AGENTS.md b/packages/agent-core-v2/AGENTS.md index 4d2c053990a..fb060e01627 100644 --- a/packages/agent-core-v2/AGENTS.md +++ b/packages/agent-core-v2/AGENTS.md @@ -88,12 +88,7 @@ Business code must not `import 'node:fs'`, write SQL, hand-roll append-logs / at ## Model-facing reminders -Two sanctioned paths, chosen by fact shape — do not introduce a third (no deferred-delivery queues, no mid-step splice channels): - -- **Present-tense state** (goal state, plan mode, todo staleness, date change, …) → register a provider with `contextInjector` (`register` / `registerAtTurnStart`). It reconciles at every injection boundary and may re-emit after compaction or undo. -- **Past-tense one-off facts** (goal cancelled, AGENTS.md discovered, `/init` finished, …) → append at the event point through `IAgentSystemReminderService.appendSystemReminder` with origin `{ kind: 'injection', variant: '' }`. The event point must be a safe position on its own (a step/restore hook, an idle moment, or rely on the loop-event fold's deferred append). - -`kind: 'injection'` is a lifecycle classification (hidden from the UI, not an undo anchor, dropped by compaction), not a provenance claim; prompt-owned attachments additionally carry `ownerPromptId` so undo treats them as part of their host prompt. +Two delivery paths only — never introduce a third (no deferred-delivery queues, no mid-step splice channels): reminders that restate current state (goal state, plan mode, date change, …) register a `contextInjector` provider (`register` / `registerAtTurnStart`) that reconciles at every injection boundary and re-emits after compaction or undo; reminders that report a one-off event (goal cancelled, AGENTS.md discovered, `/init` finished, …) append at the event point through `IAgentSystemReminderService.appendSystemReminder` with origin `{ kind: 'injection', variant: '' }`, where the event point must itself be a safe position (a step/restore hook, an idle moment, or the loop-event fold's deferred append). `kind: 'injection'` is a lifecycle classification (hidden from the UI, not an undo anchor, dropped by compaction), not a provenance claim; prompt-owned attachments additionally carry `ownerPromptId` so undo treats them as part of their host prompt. ## Docs From 76073d72a81caa8e8cb191ad8a037ac00382af11 Mon Sep 17 00:00:00 2001 From: 7Sageer <7sageer@djwcb.cn> Date: Tue, 11 Aug 2026 13:44:49 +0800 Subject: [PATCH 24/27] refactor(agent-core-v2): write all system reminders through wrapSystemReminder --- .../agent/contextMemory/compactionHandoff.ts | 7 ++-- .../src/agent/toolDedupe/toolDedupeService.ts | 38 ++++++++++--------- 2 files changed, 24 insertions(+), 21 deletions(-) diff --git a/packages/agent-core-v2/src/agent/contextMemory/compactionHandoff.ts b/packages/agent-core-v2/src/agent/contextMemory/compactionHandoff.ts index 63a8af0e425..913fb6ba829 100644 --- a/packages/agent-core-v2/src/agent/contextMemory/compactionHandoff.ts +++ b/packages/agent-core-v2/src/agent/contextMemory/compactionHandoff.ts @@ -13,6 +13,7 @@ import { estimateTokens, estimateTokensForMessage, estimateTokensForMessages } from '#/kosong/contract/tokens'; import type { ContentPart } from '#/kosong/contract/message'; +import { wrapSystemReminder } from '#/agent/systemReminder/systemReminder'; import summaryPrefixTemplate from './compaction-summary-prefix.md?raw'; import type { ContextMessage, PromptOrigin } from './types'; @@ -162,11 +163,9 @@ export function createCompactionElisionMessage(omittedTokens: number): ContextMe } export function buildCompactionElisionText(omittedTokens: number): string { - return [ - '', + return wrapSystemReminder( `Some of this conversation's user messages were omitted here during compaction: the messages above this note are the oldest user input, the messages below are the most recent, and roughly ${String(omittedTokens)} tokens in between were dropped. The omitted content is covered by the compaction summary at the end of the conversation.`, - '', - ].join('\n'); + ); } export function collectCompactableUserMessages(messages: readonly T[]): T[] { diff --git a/packages/agent-core-v2/src/agent/toolDedupe/toolDedupeService.ts b/packages/agent-core-v2/src/agent/toolDedupe/toolDedupeService.ts index 617336bd9a0..0418dc571b5 100644 --- a/packages/agent-core-v2/src/agent/toolDedupe/toolDedupeService.ts +++ b/packages/agent-core-v2/src/agent/toolDedupe/toolDedupeService.ts @@ -28,35 +28,39 @@ import type { LLMRequestTrace } from '#/kosong/contract/requestTrace'; import { parseToolCallArguments } from '#/tool/tool-args-parse'; import { IAgentLoopService } from '#/agent/loop/loop'; import { IAgentStateService } from '#/agent/state/agentState'; +import { wrapSystemReminder } from '#/agent/systemReminder/systemReminder'; import { IAgentToolExecutorService, type ToolCallDupType } from '#/agent/toolExecutor/toolExecutor'; import type { ContentPart } from '#/kosong/contract/message'; import { IAgentToolDedupeService, type ToolDedupeResult } from './toolDedupe'; const REMINDER_TEXT_1 = - '\n\n\n' + - 'The same tool call has been repeated several times in a row. ' + - 'Before making your next call, write one sentence stating what new information you expect it to produce. ' + - 'Then act on that sentence: if it names something this result does not already give you, choose the action that best provides it; otherwise, continue with the evidence you already have.' + - '\n'; + '\n\n' + + wrapSystemReminder( + 'The same tool call has been repeated several times in a row. ' + + 'Before making your next call, write one sentence stating what new information you expect it to produce. ' + + 'Then act on that sentence: if it names something this result does not already give you, choose the action that best provides it; otherwise, continue with the evidence you already have.', + ); function makeReminderText2(repeatCount: number): string { return ( - '\n\n\n' + - `The same tool call has now been issued ${String(repeatCount)} times in a row. ` + - 'Choose exactly one of the following and state your choice before acting:\n' + - '(1) Falsification check: run the cheapest test that could conclusively disprove your current approach, if such a test exists.\n' + - '(2) Missing input: tell the user precisely what information or decision you need to proceed, and ask for it.\n' + - '(3) Conclude: deliver your best result based on the evidence already gathered, listing anything that remains uncertain.' + - '\n' + '\n\n' + + wrapSystemReminder( + `The same tool call has now been issued ${String(repeatCount)} times in a row. ` + + 'Choose exactly one of the following and state your choice before acting:\n' + + '(1) Falsification check: run the cheapest test that could conclusively disprove your current approach, if such a test exists.\n' + + '(2) Missing input: tell the user precisely what information or decision you need to proceed, and ask for it.\n' + + '(3) Conclude: deliver your best result based on the evidence already gathered, listing anything that remains uncertain.', + ) ); } const REMINDER_TEXT_3 = - '\n\n\n' + - 'Write your final response now, without any further tool calls. ' + - 'Cover: the current blocker, each approach you have tried and what it established, and the specific information or decision you need from the user to unblock progress. ' + - 'Text only.' + - '\n'; + '\n\n' + + wrapSystemReminder( + 'Write your final response now, without any further tool calls. ' + + 'Cover: the current blocker, each approach you have tried and what it established, and the specific information or decision you need from the user to unblock progress. ' + + 'Text only.', + ); const REPEAT_REMINDER_1_START = 3; const REPEAT_REMINDER_2_START = 5; From d5cf43f5366d105ee673583cf7a92e2baf6f9d8c Mon Sep 17 00:00:00 2001 From: 7Sageer <7sageer@djwcb.cn> Date: Tue, 11 Aug 2026 17:11:05 +0800 Subject: [PATCH 25/27] fix(agent-core-v2): preserve reminder lifecycle invariants --- .../agent-core-v2/docs/wire-manifest.d.ts | 121 ++++++++++-------- .../agent/contextInjector/contextInjector.ts | 2 + .../contextInjector/contextInjectorService.ts | 37 ++++-- .../src/agent/contextMemory/contextMemory.ts | 2 + .../contextMemory/contextMemoryService.ts | 25 +++- .../src/agent/contextMemory/contextOps.ts | 12 +- .../fullCompaction/fullCompactionService.ts | 55 +++++--- .../interruptionReminderOps.ts | 47 ++----- .../interruptionReminderService.ts | 50 +++----- .../src/agent/loop/loopService.ts | 8 +- .../agent-core-v2/src/agent/loop/turnOps.ts | 3 +- .../src/agent/plugin/agentPlugin.ts | 2 + .../src/agent/plugin/agentPluginOps.ts | 38 ++++++ .../src/agent/plugin/agentPluginService.ts | 57 ++++++++- .../src/agent/swarm/swarmService.ts | 13 +- .../src/agent/toolSelect/toolSelectService.ts | 11 +- packages/agent-core-v2/src/index.ts | 1 + .../contextInjector/contextInjector.test.ts | 35 +++++ .../test/agent/contextMemory/stubs.ts | 4 + .../fullCompaction/fullCompaction.test.ts | 38 ++++++ .../test/agent/loop/loop.test.ts | 25 ++-- .../permissionMode/permissionMode.test.ts | 2 + .../test/agent/plugin/agentPlugin.test.ts | 58 ++++++++- .../test/agent/swarm/swarm.test.ts | 102 +++++++++------ .../toolSelect/toolSelectService.test.ts | 23 +++- .../test/app/config/config.test.ts | 1 + .../externalHooksRunner/integration.test.ts | 1 + .../test/features/plan/plan.test.ts | 2 + packages/agent-core-v2/test/index.test.ts | 1 + .../agentLifecycle/agentLifecycle.test.ts | 1 + packages/agent-core-v2/test/tool/tool.test.ts | 1 + .../agent-core-v2/test/wire/resume.test.ts | 6 +- packages/node-sdk/src/sdk-rpc-client-v2.ts | 36 +++++- packages/node-sdk/test/v1-v2-parity.test.ts | 90 ++++++++++--- 34 files changed, 659 insertions(+), 251 deletions(-) create mode 100644 packages/agent-core-v2/src/agent/plugin/agentPluginOps.ts diff --git a/packages/agent-core-v2/docs/wire-manifest.d.ts b/packages/agent-core-v2/docs/wire-manifest.d.ts index e680fefa18f..1b3a0fed0a1 100644 --- a/packages/agent-core-v2/docs/wire-manifest.d.ts +++ b/packages/agent-core-v2/docs/wire-manifest.d.ts @@ -21,55 +21,56 @@ // owning model offloads inline media to blob storage), cross-reducers // (foreign models that also reduce this record on dispatch and replay). -// Index (48 record types) -// config.update profile persisted src/agent/profile/profileOps.ts -// context.append_loop_event contextMemory persisted src/agent/contextMemory/contextOps.ts -// context.append_message contextMemory persisted src/agent/contextMemory/contextOps.ts -// context.apply_compaction contextMemory persisted src/agent/contextMemory/contextOps.ts -// context.clear contextMemory persisted src/agent/contextMemory/contextOps.ts -// context.undo contextMemory persisted src/agent/contextMemory/contextOps.ts -// cron.add cron transient src/session/cron/cronOps.ts -// cron.cursor cron transient src/session/cron/cronOps.ts -// cron.delete cron transient src/session/cron/cronOps.ts -// forked goal persisted src/agent/goal/goalOps.ts -// full_compaction.begin fullCompaction persisted src/agent/fullCompaction/compactionOps.ts -// full_compaction.cancel fullCompaction persisted src/agent/fullCompaction/compactionOps.ts -// full_compaction.complete fullCompaction persisted src/agent/fullCompaction/compactionOps.ts -// goal.clear goal persisted src/agent/goal/goalOps.ts -// goal.create goal persisted src/agent/goal/goalOps.ts -// goal.update goal persisted src/agent/goal/goalOps.ts -// interaction.request interaction persisted src/session/interaction/interactionOps.ts -// interaction.resolved interaction persisted src/session/interaction/interactionOps.ts -// interruptionReminder.recorded interruptionReminder persisted src/agent/interruptionReminder/interruptionReminderOps.ts -// llm.request llm.requestTrace persisted src/agent/llmRequester/llmRequestOps.ts -// llm.tools_snapshot llm.requestTrace persisted src/agent/llmRequester/llmRequestOps.ts -// mcp.tools_discovered mcp.discovery persisted src/agent/mcp/mcpDiscoveryOps.ts -// permission.record_approval_result permissionRules persisted src/agent/permissionRules/permissionRulesOps.ts -// permission.rules.add permissionRules transient src/agent/permissionRules/permissionRulesOps.ts -// permission.set_mode permissionMode persisted src/agent/permissionMode/permissionModeOps.ts -// plan_mode.cancel plan persisted src/features/plan/planOps.ts -// plan_mode.enter plan persisted src/features/plan/planOps.ts -// plan_mode.exit plan persisted src/features/plan/planOps.ts -// plan.revision plan persisted src/features/plan/planOps.ts -// profile.bind profile persisted src/agent/profile/profileOps.ts -// skill.activate skill transient src/agent/skill/skillOps.ts -// swarm_mode.enter swarm persisted src/agent/swarm/swarmOps.ts -// swarm_mode.exit swarm persisted src/agent/swarm/swarmOps.ts -// task.started task persisted src/agent/task/taskOps.ts -// task.terminated task persisted src/agent/task/taskOps.ts -// token_counting.measured tokenCounting transient src/agent/tokenCounting/tokenCountingOps.ts -// token_counting.rebased tokenCounting transient src/agent/tokenCounting/tokenCountingOps.ts -// token_counting.truncated tokenCounting transient src/agent/tokenCounting/tokenCountingOps.ts -// tools.register_user_tool userTool persisted src/agent/userTool/userToolOps.ts -// tools.reset_active_tools profile.activeTools persisted src/agent/profile/profileOps.ts -// tools.set_active_tools profile.activeTools persisted src/agent/profile/profileOps.ts -// tools.unregister_user_tool userTool persisted src/agent/userTool/userToolOps.ts -// tools.update_store todo persisted src/session/todo/todoOps.ts -// turn.cancel turn persisted src/agent/loop/turnOps.ts -// turn.ended turn persisted src/agent/loop/turnOps.ts -// turn.prompt turn persisted src/agent/loop/turnOps.ts -// turn.steer turn persisted src/agent/loop/turnOps.ts -// usage.record usage persisted src/agent/usage/usageOps.ts +// Index (49 record types) +// config.update profile persisted src/agent/profile/profileOps.ts +// context.append_loop_event contextMemory persisted src/agent/contextMemory/contextOps.ts +// context.append_message contextMemory persisted src/agent/contextMemory/contextOps.ts +// context.apply_compaction contextMemory persisted src/agent/contextMemory/contextOps.ts +// context.clear contextMemory persisted src/agent/contextMemory/contextOps.ts +// context.undo contextMemory persisted src/agent/contextMemory/contextOps.ts +// cron.add cron transient src/session/cron/cronOps.ts +// cron.cursor cron transient src/session/cron/cronOps.ts +// cron.delete cron transient src/session/cron/cronOps.ts +// forked goal persisted src/agent/goal/goalOps.ts +// full_compaction.begin fullCompaction persisted src/agent/fullCompaction/compactionOps.ts +// full_compaction.cancel fullCompaction persisted src/agent/fullCompaction/compactionOps.ts +// full_compaction.complete fullCompaction persisted src/agent/fullCompaction/compactionOps.ts +// goal.clear goal persisted src/agent/goal/goalOps.ts +// goal.create goal persisted src/agent/goal/goalOps.ts +// goal.update goal persisted src/agent/goal/goalOps.ts +// interaction.request interaction persisted src/session/interaction/interactionOps.ts +// interaction.resolved interaction persisted src/session/interaction/interactionOps.ts +// interruptionReminder.recorded interruptionReminder persisted src/agent/interruptionReminder/interruptionReminderOps.ts +// llm.request llm.requestTrace persisted src/agent/llmRequester/llmRequestOps.ts +// llm.tools_snapshot llm.requestTrace persisted src/agent/llmRequester/llmRequestOps.ts +// mcp.tools_discovered mcp.discovery persisted src/agent/mcp/mcpDiscoveryOps.ts +// permission.record_approval_result permissionRules persisted src/agent/permissionRules/permissionRulesOps.ts +// permission.rules.add permissionRules transient src/agent/permissionRules/permissionRulesOps.ts +// permission.set_mode permissionMode persisted src/agent/permissionMode/permissionModeOps.ts +// plan_mode.cancel plan persisted src/features/plan/planOps.ts +// plan_mode.enter plan persisted src/features/plan/planOps.ts +// plan_mode.exit plan persisted src/features/plan/planOps.ts +// plan.revision plan persisted src/features/plan/planOps.ts +// plugin.session_start pluginSessionStartSnapshot persisted src/agent/plugin/agentPluginOps.ts +// profile.bind profile persisted src/agent/profile/profileOps.ts +// skill.activate skill transient src/agent/skill/skillOps.ts +// swarm_mode.enter swarm persisted src/agent/swarm/swarmOps.ts +// swarm_mode.exit swarm persisted src/agent/swarm/swarmOps.ts +// task.started task persisted src/agent/task/taskOps.ts +// task.terminated task persisted src/agent/task/taskOps.ts +// token_counting.measured tokenCounting transient src/agent/tokenCounting/tokenCountingOps.ts +// token_counting.rebased tokenCounting transient src/agent/tokenCounting/tokenCountingOps.ts +// token_counting.truncated tokenCounting transient src/agent/tokenCounting/tokenCountingOps.ts +// tools.register_user_tool userTool persisted src/agent/userTool/userToolOps.ts +// tools.reset_active_tools profile.activeTools persisted src/agent/profile/profileOps.ts +// tools.set_active_tools profile.activeTools persisted src/agent/profile/profileOps.ts +// tools.unregister_user_tool userTool persisted src/agent/userTool/userToolOps.ts +// tools.update_store todo persisted src/session/todo/todoOps.ts +// turn.cancel turn persisted src/agent/loop/turnOps.ts +// turn.ended turn persisted src/agent/loop/turnOps.ts +// turn.prompt turn persisted src/agent/loop/turnOps.ts +// turn.steer turn persisted src/agent/loop/turnOps.ts +// usage.record usage persisted src/agent/usage/usageOps.ts /** * model: profile · persisted @@ -105,7 +106,7 @@ interface ContextAppendLoopEventPayload { } /** - * model: contextMemory · persisted · blobs · cross-reducers: plan, goalForkNotice, task.notificationDelivery, interruptionReminder, todo + * model: contextMemory · persisted · blobs · cross-reducers: plan, goalForkNotice, task.notificationDelivery, todo * owner: src/agent/contextMemory/contextOps.ts */ interface ContextAppendMessagePayload { @@ -140,14 +141,14 @@ interface ContextAppendMessagePayload { } /** - * model: contextMemory · persisted · blobs · cross-reducers: plan, task.notificationDelivery, interruptionReminder, todo + * model: contextMemory · persisted · blobs · cross-reducers: plan, task.notificationDelivery, todo * owner: src/agent/contextMemory/contextOps.ts * shared base: ...contextCompactionBaseShape */ type ContextApplyCompactionPayload = { _name: 'context.apply_compaction'; } & ({ summary: string, compactedCount: number, contextSummary?: string } | { contextSummary: string, compactedCount: number, summary?: string } | { summary: ContextMessage, count: number, compactedCount?: number }); /** - * model: contextMemory · persisted · blobs · cross-reducers: plan, task.notificationDelivery, interruptionReminder, todo + * model: contextMemory · persisted · blobs · cross-reducers: plan, task.notificationDelivery, todo * owner: src/agent/contextMemory/contextOps.ts */ interface ContextClearPayload { @@ -155,7 +156,7 @@ interface ContextClearPayload { } /** - * model: contextMemory · persisted · blobs · cross-reducers: plan, task.notificationDelivery, interruptionReminder, todo + * model: contextMemory · persisted · blobs · cross-reducers: plan, task.notificationDelivery, todo * owner: src/agent/contextMemory/contextOps.ts */ interface ContextUndoPayload { @@ -448,6 +449,15 @@ interface PlanRevisionPayload { bytes: number; } +/** + * model: pluginSessionStartSnapshot · persisted + * owner: src/agent/plugin/agentPluginOps.ts + */ +interface PluginSessionStartPayload { + _name: 'plugin.session_start'; + content: string | null; +} + /** * model: profile · persisted · cross-reducers: profile.activeTools * owner: src/agent/profile/profileOps.ts @@ -501,7 +511,7 @@ interface SwarmModeEnterPayload { } /** - * model: swarm · persisted · toEvent + * model: swarm · persisted · toEvent · cross-reducers: contextMemory * owner: src/agent/swarm/swarmOps.ts */ interface SwarmModeExitPayload { @@ -610,7 +620,7 @@ interface ToolsUpdateStorePayload { } /** - * model: turn · persisted · cross-reducers: interruptionReminder + * model: turn · persisted * owner: src/agent/loop/turnOps.ts */ interface TurnCancelPayload { @@ -746,6 +756,7 @@ interface WirePayloadMap { "plan_mode.enter": PlanModeEnterPayload; "plan_mode.exit": PlanModeExitPayload; "plan.revision": PlanRevisionPayload; + "plugin.session_start": PluginSessionStartPayload; "profile.bind": ProfileBindPayload; "skill.activate": SkillActivatePayload; "swarm_mode.enter": SwarmModeEnterPayload; diff --git a/packages/agent-core-v2/src/agent/contextInjector/contextInjector.ts b/packages/agent-core-v2/src/agent/contextInjector/contextInjector.ts index c964ec78c4e..420144b4bdc 100644 --- a/packages/agent-core-v2/src/agent/contextInjector/contextInjector.ts +++ b/packages/agent-core-v2/src/agent/contextInjector/contextInjector.ts @@ -53,6 +53,8 @@ export interface IAgentContextInjectorService { provider: SyncContextInjectionProvider, ): IDisposable; + reconcileWhenIdle(name: string): Promise; + injectAfterCompaction(): Promise; } diff --git a/packages/agent-core-v2/src/agent/contextInjector/contextInjectorService.ts b/packages/agent-core-v2/src/agent/contextInjector/contextInjectorService.ts index d8ba4871890..633fe232848 100644 --- a/packages/agent-core-v2/src/agent/contextInjector/contextInjectorService.ts +++ b/packages/agent-core-v2/src/agent/contextInjector/contextInjectorService.ts @@ -42,7 +42,7 @@ export class AgentContextInjectorService extends Service implements IAgentContex constructor( @IAgentContextMemoryService private readonly context: IAgentContextMemoryService, - @IAgentLoopService loopService: IAgentLoopService, + @IAgentLoopService private readonly loopService: IAgentLoopService, @IAgentSystemReminderService private readonly reminders: IAgentSystemReminderService, @IEventBus private readonly eventBus: IEventBus, @ILogService private readonly log: ILogService, @@ -101,22 +101,39 @@ export class AgentContextInjectorService extends Service implements IAgentContex await this.inject(undefined, true); } + async reconcileWhenIdle(name: string): Promise { + const quiescence = this.loopService.tryAcquireQuiescence(); + if (quiescence === undefined) return; + try { + for (const entry of this.entries) { + if (entry.name !== name) continue; + await this.injectEntry(entry, false); + } + } finally { + quiescence.dispose(); + } + } + private async inject( boundary: ContextInjectionEntry['boundary'] | undefined, isNewTurn: boolean, ): Promise { for (const entry of this.entries) { if (boundary !== undefined && !shouldRunAtBoundary(entry, boundary)) continue; - let content: Awaited>; - try { - content = await entry.provider(this.providerContext(entry, isNewTurn)); - } catch (error) { - this.log.error('context provider failed; skipping it', { name: entry.name, error }); - continue; - } - if (!this.entries.has(entry)) continue; - this.appendResult(entry, content); + await this.injectEntry(entry, isNewTurn); + } + } + + private async injectEntry(entry: ContextInjectionEntry, isNewTurn: boolean): Promise { + let content: Awaited>; + try { + content = await entry.provider(this.providerContext(entry, isNewTurn)); + } catch (error) { + this.log.error('context provider failed; skipping it', { name: entry.name, error }); + return; } + if (!this.entries.has(entry)) return; + this.appendResult(entry, content); } private injectAtTurnStart(): void { diff --git a/packages/agent-core-v2/src/agent/contextMemory/contextMemory.ts b/packages/agent-core-v2/src/agent/contextMemory/contextMemory.ts index 89b5be4f6d0..46950e98961 100644 --- a/packages/agent-core-v2/src/agent/contextMemory/contextMemory.ts +++ b/packages/agent-core-v2/src/agent/contextMemory/contextMemory.ts @@ -44,6 +44,8 @@ export interface IAgentContextMemoryService { appendLoopEvent(event: LoopRecordedEvent): void; + publishTrailingRemoval(previous: readonly ContextMessage[]): boolean; + clear(): void; undo(count: number): UndoCut; diff --git a/packages/agent-core-v2/src/agent/contextMemory/contextMemoryService.ts b/packages/agent-core-v2/src/agent/contextMemory/contextMemoryService.ts index fb5e00e7e48..d095be61594 100644 --- a/packages/agent-core-v2/src/agent/contextMemory/contextMemoryService.ts +++ b/packages/agent-core-v2/src/agent/contextMemory/contextMemoryService.ts @@ -3,11 +3,11 @@ * * Owns per-agent conversation history through `wire`, maintains measurements * with `tokenCounting`, and broadcasts live mutations through `event`. Every - * splice-shaped mutation (`clear` / `applyCompaction` / `undo`) publishes - * `context.spliced` from the live path only — replay rebuilds silently — and - * `undo` additionally truncates the measured-anchor ledger when the cut - * crosses an anchor, letting `tokenCounting` restore the surviving prefix's - * REAL size from the remaining anchors. Bound at Agent scope. + * splice-shaped mutation (`clear` / `applyCompaction` / `undo`, plus verified + * cross-model trailing removal) publishes `context.spliced` from the live path + * only — replay rebuilds silently — and truncates the measured-anchor ledger + * when a cut crosses an anchor, letting `tokenCounting` restore the surviving + * prefix's REAL size from the remaining anchors. Bound at Agent scope. */ import { Disposable } from '#/_base/di/lifecycle'; @@ -89,6 +89,21 @@ export class AgentContextMemoryService extends Disposable implements IAgentConte this.wire.dispatch(contextAppendLoopEvent({ event })); } + publishTrailingRemoval(previous: readonly ContextMessage[]): boolean { + const cutIndex = previous.length - 1; + if (cutIndex < 0) return false; + const current = this.get(); + if ( + current.length !== cutIndex || + current.some((message, index) => message !== previous[index]) + ) { + return false; + } + this.wire.dispatch(...this.sizeOpsForCut(cutIndex)); + this.publishSplice({ start: cutIndex, deleteCount: 1, messages: [] }); + return true; + } + clear(): void { const deleteCount = this.get().length; if (deleteCount === 0) return; diff --git a/packages/agent-core-v2/src/agent/contextMemory/contextOps.ts b/packages/agent-core-v2/src/agent/contextMemory/contextOps.ts index ad89eadde09..a6ebad053ef 100644 --- a/packages/agent-core-v2/src/agent/contextMemory/contextOps.ts +++ b/packages/agent-core-v2/src/agent/contextMemory/contextOps.ts @@ -18,7 +18,8 @@ * them into assistant / tool messages both at live dispatch time and on * replay, so v1- and v2-written sessions reduce * identically. Swarm-mode announcements are owned by the `swarm` domain's - * context-injection provider, not by this Model. + * context-injection provider; `swarm_mode.exit` additionally pops a trailing + * enter reminder through a replayable cross-model reducer. * * `context.undo` counts conversation ticks with the single `isUndoAnchor` * predicate — the same definition the checkpoint @@ -117,8 +118,17 @@ export const ContextModel = defineModel('contextMemory', () => return changed ? result : state; }, }, + reducers: { + 'swarm_mode.exit': popSwarmModeReminder, + }, }); +function popSwarmModeReminder(state: ContextMessage[]): ContextMessage[] { + const last = state.at(-1); + if (last?.origin?.kind !== 'injection' || last.origin.variant !== 'swarm_mode') return state; + return resetFold(state.slice(0, -1)) as ContextMessage[]; +} + declare module '#/wire/types' { interface PersistedOpMap { 'context.append_message': typeof contextAppendMessage; diff --git a/packages/agent-core-v2/src/agent/fullCompaction/fullCompactionService.ts b/packages/agent-core-v2/src/agent/fullCompaction/fullCompactionService.ts index 27cd51d08dc..e9bb78f2cf7 100644 --- a/packages/agent-core-v2/src/agent/fullCompaction/fullCompactionService.ts +++ b/packages/agent-core-v2/src/agent/fullCompaction/fullCompactionService.ts @@ -17,6 +17,7 @@ * runs. */ +import type { IDisposable } from '#/_base/di/lifecycle'; import { Service } from "#/_base/di/service"; import { IInstantiationService } from '#/_base/di/instantiation'; import { LifecycleScope } from '#/app/scopes'; @@ -97,6 +98,7 @@ type CompactionTelemetryProperties = Pick< interface ActiveCompaction extends FullCompactionTask { readonly originTurnId?: number; + readonly quiescence?: IDisposable; trace?: LLMRequestTrace; blockedByTurn: boolean; } @@ -329,22 +331,37 @@ export class AgentFullCompactionService extends Service implements IAgentFullCom if (!this.reserveCompactionSlot(data.source)) return false; const tokenCount = this.validateCompactionStart(data.source); - this.wire.dispatch(fullCompactionBegin(data)); + const quiescence = data.source === 'manual' + ? this.loopService.tryAcquireQuiescence() + : undefined; + if (data.source === 'manual' && quiescence === undefined) { + throw new Error2( + ErrorCodes.COMPACTION_UNABLE, + 'Cannot compact while a turn is active or another context change is running. Wait for it to finish, then retry.', + ); + } + try { + this.wire.dispatch(fullCompactionBegin(data)); - const active = this.createActiveCompaction( - data.source, - tokenCount, - data.source === 'auto' ? this.activeTurnId : undefined, - ); - this._compacting = active.task; - active.task.abortController.signal.addEventListener( - 'abort', - () => this.cancelActive(active.task), - { once: true }, - ); - void this.compactionWorker(active.task, data).then(active.resolve, active.reject); - void active.task.promise.catch(() => undefined); - return true; + const active = this.createActiveCompaction( + data.source, + tokenCount, + data.source === 'auto' ? this.activeTurnId : undefined, + quiescence, + ); + this._compacting = active.task; + active.task.abortController.signal.addEventListener( + 'abort', + () => this.cancelActive(active.task), + { once: true }, + ); + void this.compactionWorker(active.task, data).then(active.resolve, active.reject); + void active.task.promise.catch(() => undefined); + return true; + } catch (error) { + quiescence?.dispose(); + throw error; + } } private reserveCompactionSlot(source: CompactionBeginData['source']): boolean { @@ -374,6 +391,7 @@ export class AgentFullCompactionService extends Service implements IAgentFullCom trigger: CompactionBeginData['source'], tokenCount: number, originTurnId: number | undefined, + quiescence: IDisposable | undefined, ): { readonly task: ActiveCompaction; readonly resolve: (result: CompactionResult) => void; @@ -393,6 +411,7 @@ export class AgentFullCompactionService extends Service implements IAgentFullCom trigger, tokenCount, originTurnId, + quiescence, get traceId() { return this.trace?.traceId; }, @@ -585,7 +604,11 @@ export class AgentFullCompactionService extends Service implements IAgentFullCom }); throw error; } finally { - this._onDidFinishCompaction.fire(active); + try { + this._onDidFinishCompaction.fire(active); + } finally { + active.quiescence?.dispose(); + } } } diff --git a/packages/agent-core-v2/src/agent/interruptionReminder/interruptionReminderOps.ts b/packages/agent-core-v2/src/agent/interruptionReminder/interruptionReminderOps.ts index 24ee1b58c8f..ae12da6f0e1 100644 --- a/packages/agent-core-v2/src/agent/interruptionReminder/interruptionReminderOps.ts +++ b/packages/agent-core-v2/src/agent/interruptionReminder/interruptionReminderOps.ts @@ -1,47 +1,23 @@ /** - * `interruptionReminder` domain — checkpointed wire Model for user-cancelled - * interruption reminders. + * `interruptionReminder` domain — legacy wire compatibility tombstone. * - * Projects the durable `turn.cancel` fact into pending model-facing state, - * follows conversation undo through `contextMemory`'s checkpoint protocol, and - * clears the pending state when the injection is appended. Bound at Agent scope. + * Retains the historical `interruptionReminder.recorded` Op as a no-op so old + * Agent journals replay without unknown-record diagnostics. New interruption + * reminders append at the cancellation event point and write no domain-owned + * delivery state. Scope-agnostic. */ import { z } from 'zod'; -import { - defineCheckpointedModel, - type Checkpointed, -} from '#/agent/contextMemory/conversationTime'; +import { defineModel } from '#/wire/model'; export const INTERRUPTION_REMINDER_VARIANT = 'interruption'; -export type InterruptionReminderState = Checkpointed; +export type InterruptionReminderState = null; -export const InterruptionReminderModel = defineCheckpointedModel( +export const InterruptionReminderModel = defineModel( 'interruptionReminder', - () => [], - { - onAppendMessage: (current, message) => - message.origin?.kind === 'injection' && - message.origin.variant === INTERRUPTION_REMINDER_VARIANT && - current.length > 0 - ? [] - : current, - reducers: { - 'turn.cancel': (state, { turnId, target, reason }) => { - if ( - turnId === undefined || - target !== 'active' || - reason !== 'user_cancelled' || - state.current.includes(turnId) - ) { - return state; - } - return { ...state, current: [...state.current, turnId] }; - }, - }, - }, + () => null, ); declare module '#/wire/types' { @@ -54,9 +30,6 @@ export const interruptionReminderRecorded = InterruptionReminderModel.defineOp( 'interruptionReminder.recorded', { schema: z.object({ turnId: z.number().int().nonnegative() }), - apply: (state, { turnId }) => ({ - ...state, - current: state.current.filter((pendingTurnId) => pendingTurnId !== turnId), - }), + apply: (state) => state, }, ); diff --git a/packages/agent-core-v2/src/agent/interruptionReminder/interruptionReminderService.ts b/packages/agent-core-v2/src/agent/interruptionReminder/interruptionReminderService.ts index bcb0c0a5788..4fa1f48aa03 100644 --- a/packages/agent-core-v2/src/agent/interruptionReminder/interruptionReminderService.ts +++ b/packages/agent-core-v2/src/agent/interruptionReminder/interruptionReminderService.ts @@ -1,28 +1,22 @@ /** * `interruptionReminder` domain — `IAgentInterruptionReminderService` implementation. * - * Projects checkpointed user-cancellation facts from `wire` through the - * `contextInjector` turn-start boundary, and reads `contextMemory` to collapse - * retry-only duplicate notices. The turn-start placement keeps the notice - * between the interrupted turn and the next user prompt on provider wires. - * Bound at Agent scope. + * Observes completed turns through `eventBus`, appends user-cancellation facts + * through `systemReminder` at the event point, and reads `contextMemory` to + * collapse retry-only duplicate notices. Bound at Agent scope. */ -import { Service } from '#/_base/di/service'; +import { Disposable } from '#/_base/di/lifecycle'; import { LifecycleScope } from '#/app/scopes'; import { ScopeActivation, registerScopedService } from '#/_base/di/scope'; -import { IAgentContextInjectorService } from '#/agent/contextInjector/contextInjector'; import { IAgentContextMemoryService } from '#/agent/contextMemory/contextMemory'; import type { ContextMessage } from '#/agent/contextMemory/types'; import { isVacuousContentPart } from '#/agent/contextMemory/vacuousContent'; -import { IWireService } from '#/wire/wire'; +import { IAgentSystemReminderService } from '#/agent/systemReminder/systemReminder'; +import { IEventBus } from '#/app/event/eventBus'; import { IAgentInterruptionReminderService } from './interruptionReminder'; -import { - INTERRUPTION_REMINDER_VARIANT, - interruptionReminderRecorded, - InterruptionReminderModel, -} from './interruptionReminderOps'; +import { INTERRUPTION_REMINDER_VARIANT } from './interruptionReminderOps'; const INTERRUPTION_REMINDER = [ 'The previous turn was interrupted by the user before completion;', @@ -31,35 +25,29 @@ const INTERRUPTION_REMINDER = [ ].join(' '); export class AgentInterruptionReminderService - extends Service + extends Disposable implements IAgentInterruptionReminderService { declare readonly _serviceBrand: undefined; constructor( - @IAgentContextInjectorService injector: IAgentContextInjectorService, + @IEventBus eventBus: IEventBus, @IAgentContextMemoryService private readonly context: IAgentContextMemoryService, - @IWireService private readonly wire: IWireService, + @IAgentSystemReminderService private readonly reminders: IAgentSystemReminderService, ) { super(); this._register( - injector.registerAtTurnStart( - INTERRUPTION_REMINDER_VARIANT, - () => this.reconcileReminder(), - ), + eventBus.subscribe('turn.ended', (event) => { + if (event.reason !== 'cancelled' || event.interruptReason !== 'user_cancelled') return; + const origin = lastComparableMessage(this.context.get())?.origin; + if (origin?.kind === 'injection' && origin.variant === INTERRUPTION_REMINDER_VARIANT) return; + this.reminders.appendSystemReminder(INTERRUPTION_REMINDER, { + kind: 'injection', + variant: INTERRUPTION_REMINDER_VARIANT, + }); + }), ); } - - private reconcileReminder(): string | undefined { - const pending = this.wire.getModel(InterruptionReminderModel).current; - if (pending.length === 0) return undefined; - const origin = lastComparableMessage(this.context.get())?.origin; - if (origin?.kind !== 'injection' || origin.variant !== INTERRUPTION_REMINDER_VARIANT) { - return INTERRUPTION_REMINDER; - } - this.wire.dispatch(...pending.map((turnId) => interruptionReminderRecorded({ turnId }))); - return undefined; - } } function lastComparableMessage(messages: readonly ContextMessage[]): ContextMessage | undefined { diff --git a/packages/agent-core-v2/src/agent/loop/loopService.ts b/packages/agent-core-v2/src/agent/loop/loopService.ts index a6b5d11cd79..9e57491c093 100644 --- a/packages/agent-core-v2/src/agent/loop/loopService.ts +++ b/packages/agent-core-v2/src/agent/loop/loopService.ts @@ -252,7 +252,13 @@ export class AgentLoopService extends Disposable implements IAgentLoopService { tryAcquireQuiescence(): IDisposable | undefined { if (this.disposing) throw abortError('Agent loop disposed'); - if (this.activeTurnJob !== undefined || this.hasPendingRequests()) return undefined; + if ( + this.quiescenceDepth > 0 || + this.activeTurnJob !== undefined || + this.hasPendingRequests() + ) { + return undefined; + } this.quiescenceDepth += 1; return toDisposable(() => this.releaseQuiescence()); } diff --git a/packages/agent-core-v2/src/agent/loop/turnOps.ts b/packages/agent-core-v2/src/agent/loop/turnOps.ts index 9901b077ede..7c033c03b05 100644 --- a/packages/agent-core-v2/src/agent/loop/turnOps.ts +++ b/packages/agent-core-v2/src/agent/loop/turnOps.ts @@ -6,8 +6,7 @@ * legacy loop-event observations. Also persists the terminal `turn.ended` * record (reason / error / durationMs) so downstream history rebuilds and * cold-resumed read models (e.g. the activity view) can recover how the last - * turn ended. Consumed by the Agent-scope `loopService`; the - * `interruptionReminder` domain projects `turn.cancel` into its own model. + * turn ended. Consumed by the Agent-scope `loopService`. */ import { z } from 'zod'; diff --git a/packages/agent-core-v2/src/agent/plugin/agentPlugin.ts b/packages/agent-core-v2/src/agent/plugin/agentPlugin.ts index 512f1a56586..a1ab9517f1d 100644 --- a/packages/agent-core-v2/src/agent/plugin/agentPlugin.ts +++ b/packages/agent-core-v2/src/agent/plugin/agentPlugin.ts @@ -9,6 +9,8 @@ import { createDecorator, type ServiceIdentifier } from '#/_base/di/instantiatio export interface IAgentPluginService { readonly _serviceBrand: undefined; + + refreshSessionStart(): Promise; } export const IAgentPluginService: ServiceIdentifier = diff --git a/packages/agent-core-v2/src/agent/plugin/agentPluginOps.ts b/packages/agent-core-v2/src/agent/plugin/agentPluginOps.ts new file mode 100644 index 00000000000..64a15dfbf4d --- /dev/null +++ b/packages/agent-core-v2/src/agent/plugin/agentPluginOps.ts @@ -0,0 +1,38 @@ +/** + * `agentPlugin` domain — durable session-start guidance snapshot. + * + * Owns the Agent wire Model that freezes the main agent's rendered plugin + * session-start guidance until an explicit reload replaces it. Bound at Agent + * scope through `wire`. + */ + +import { z } from 'zod'; + +import { defineModel } from '#/wire/model'; + +export interface PluginSessionStartSnapshotState { + readonly initialized: boolean; + readonly content?: string; +} + +export const PluginSessionStartSnapshotModel = defineModel( + 'pluginSessionStartSnapshot', + () => ({ initialized: false }), +); + +declare module '#/wire/types' { + interface PersistedOpMap { + 'plugin.session_start': typeof pluginSessionStartSnapshotSet; + } +} + +export const pluginSessionStartSnapshotSet = PluginSessionStartSnapshotModel.defineOp( + 'plugin.session_start', + { + schema: z.object({ content: z.string().nullable() }), + apply: (_state, { content }) => ({ + initialized: true, + content: content ?? undefined, + }), + }, +); diff --git a/packages/agent-core-v2/src/agent/plugin/agentPluginService.ts b/packages/agent-core-v2/src/agent/plugin/agentPluginService.ts index b7b216ff4f1..9d19e896c6d 100644 --- a/packages/agent-core-v2/src/agent/plugin/agentPluginService.ts +++ b/packages/agent-core-v2/src/agent/plugin/agentPluginService.ts @@ -4,10 +4,11 @@ * Renders session-start skills from `plugin` and `sessionSkillCatalog` through * `contextInjector`, reconciling the desired instructions against the latest * surviving render reported by the injector (`lastInjection`) and unwrapped - * through `systemReminder`. The session-start refresh on plugin-source - * catalog changes fires only for an explicit plugin reload: a mutation-driven - * reload (install / enable / disable / remove) skips it — the live session - * keeps the guidance it started with — and instead appends a `plugin_change` + * through `systemReminder`. The rendered guidance is frozen through a durable + * `wire` snapshot until an explicit reload. The session-start refresh on + * plugin-source catalog changes fires only for an explicit plugin reload: a + * mutation-driven reload (install / enable / disable / remove) skips it — the + * live session keeps the guidance it started with — and instead appends a `plugin_change` * system reminder through `systemReminder` (`plugin` `onDidMutate` — never on * an explicit reload, whose resumed session would otherwise inherit a stale * notice), naming the mutated plugin and telling the model the live session @@ -46,8 +47,13 @@ import { PLUGIN_SKILL_SOURCE_ID } from '#/app/skillCatalog/skillSource'; import type { SkillCatalog, SkillDefinition } from '#/app/skillCatalog/types'; import { ISessionContext } from '#/session/sessionContext/sessionContext'; import { ISessionSkillCatalog } from '#/session/sessionSkillCatalog/skillCatalog'; +import { IWireService } from '#/wire/wire'; import { IAgentPluginService } from './agentPlugin'; +import { + PluginSessionStartSnapshotModel, + pluginSessionStartSnapshotSet, +} from './agentPluginOps'; const SESSION_START_INJECTION_VARIANT = 'plugin_session_start'; @@ -94,8 +100,8 @@ export class AgentPluginService extends Service implements IAgentPluginService { private pendingMutationCatalogChanges = 0; constructor( - @IAgentScopeContext scopeContext: IAgentScopeContext, - @IAgentContextInjectorService injector: IAgentContextInjectorService, + @IAgentScopeContext private readonly scopeContext: IAgentScopeContext, + @IAgentContextInjectorService private readonly injector: IAgentContextInjectorService, @IAgentSystemReminderService private readonly reminders: IAgentSystemReminderService, @IAgentContextMemoryService private readonly context: IAgentContextMemoryService, @IPluginService private readonly plugins: IPluginService, @@ -103,6 +109,7 @@ export class AgentPluginService extends Service implements IAgentPluginService { @ISessionContext private readonly sessionContext: ISessionContext, @ILogService private readonly log: ILogService, @IAgentStateService private readonly states: IAgentStateService, + @IWireService private readonly wire: IWireService, ) { super(); if (scopeContext.agentId !== MAIN_AGENT_ID) return; @@ -146,6 +153,13 @@ export class AgentPluginService extends Service implements IAgentPluginService { this.states.set(pluginSessionStartRefreshPendingKey, value); } + async refreshSessionStart(): Promise { + if (this.scopeContext.agentId !== MAIN_AGENT_ID) return; + this.refreshPending = true; + await this.skillCatalog.ready; + await this.injector.reconcileWhenIdle(SESSION_START_INJECTION_VARIANT); + } + private async renderSessionStartReminder(): Promise { const sessionStarts = await this.plugins.enabledSessionStarts(); if (sessionStarts.length === 0) return undefined; @@ -163,7 +177,7 @@ export class AgentPluginService extends Service implements IAgentPluginService { injection: ContextInjectionContext, ): Promise { const forceRefresh = this.refreshPending; - const desired = await this.renderSessionStartReminder(); + const desired = await this.resolveDesiredSessionStart(injection, forceRefresh); this.refreshPending = false; const latest = injection.lastInjection; if (desired === undefined) { @@ -188,6 +202,35 @@ export class AgentPluginService extends Service implements IAgentPluginService { } return `${desired}\n\n${SUPERSEDES_SUFFIX}`; } + + private async resolveDesiredSessionStart( + injection: ContextInjectionContext, + forceRefresh: boolean, + ): Promise { + const snapshot = this.wire.getModel(PluginSessionStartSnapshotModel); + if (!forceRefresh && snapshot.initialized) return snapshot.content; + if (!forceRefresh && injection.lastInjection !== undefined) { + const rendered = systemReminderContent(injection.lastInjection); + if (rendered !== undefined) { + const content = frozenSessionStartContent(rendered); + this.recordSessionStartSnapshot(content); + return content; + } + } + const content = await this.renderSessionStartReminder(); + this.recordSessionStartSnapshot(content); + return content; + } + + private recordSessionStartSnapshot(content: string | undefined): void { + this.wire.dispatch(pluginSessionStartSnapshotSet({ content: content ?? null })); + } +} + +function frozenSessionStartContent(rendered: string): string | undefined { + if (rendered === NO_ACTIVE_SESSION_STARTS) return undefined; + const suffix = `\n\n${SUPERSEDES_SUFFIX}`; + return rendered.endsWith(suffix) ? rendered.slice(0, -suffix.length) : rendered; } interface RenderPluginSessionStartReminderInput { diff --git a/packages/agent-core-v2/src/agent/swarm/swarmService.ts b/packages/agent-core-v2/src/agent/swarm/swarmService.ts index 438e37f953b..b27cdfe1d84 100644 --- a/packages/agent-core-v2/src/agent/swarm/swarmService.ts +++ b/packages/agent-core-v2/src/agent/swarm/swarmService.ts @@ -4,10 +4,11 @@ * Tracks swarm-mode enter/exit in the `wire` `SwarmModel` (mutated only through * the `swarm_mode.enter` / `swarm_mode.exit` Ops, read through `wire.getModel`), * derives `agent.status.updated` from the Ops' `toEvent`, announces the mode - * through the `swarm_mode` context-injection provider (`SwarmInjection`), and + * through the `swarm_mode` context-injection provider (`SwarmInjection`), + * mirrors replayable trailing-enter removal through `contextMemory`, and * auto-exits on turn end via `turn`. Bound at Agent scope. The service also * guards AgentSwarm batch exclusivity through an `onBeforeExecuteTool` veto - * listener: an AgentSwarm call must be the only tool call in its batch, + * listener: an AgentSwarm call must be the only tool call in its batch; * anything else is vetoed with a `toolApproval.formatDenyMessage`-formatted * reason. */ @@ -16,6 +17,7 @@ import { Service } from '#/_base/di/service'; import { IInstantiationService } from '#/_base/di/instantiation'; import { LifecycleScope } from '#/app/scopes'; import { ScopeActivation, registerScopedService } from '#/_base/di/scope'; +import { IAgentContextMemoryService } from '#/agent/contextMemory/contextMemory'; import { IAgentToolApprovalService } from '#/agent/toolApproval/toolApproval'; import { denyToolExecution } from '#/agent/toolExecutor/beforeToolExecuteEvent'; import { IAgentToolExecutorService } from '#/agent/toolExecutor/toolExecutor'; @@ -32,7 +34,8 @@ export class AgentSwarmService extends Service implements IAgentSwarmService { constructor( @IWireService private readonly wire: IWireService, @IInstantiationService instantiation: IInstantiationService, - @IEventBus private readonly eventBus: IEventBus, + @IEventBus eventBus: IEventBus, + @IAgentContextMemoryService private readonly context: IAgentContextMemoryService, @IAgentToolApprovalService private readonly toolApproval: IAgentToolApprovalService, @IAgentToolExecutorService toolExecutor: IAgentToolExecutorService, ) { @@ -43,7 +46,7 @@ export class AgentSwarmService extends Service implements IAgentSwarmService { }), ); this._register( - this.eventBus.subscribe('turn.ended', () => { + eventBus.subscribe('turn.ended', () => { if (this.shouldAutoExit) { this.exit(); } @@ -77,7 +80,9 @@ export class AgentSwarmService extends Service implements IAgentSwarmService { exit(): void { if (this.wire.getModel(SwarmModel) === null) return; + const history = this.context.get(); this.wire.dispatch(swarmExit({})); + this.context.publishTrailingRemoval(history); } get isActive(): boolean { diff --git a/packages/agent-core-v2/src/agent/toolSelect/toolSelectService.ts b/packages/agent-core-v2/src/agent/toolSelect/toolSelectService.ts index 04c52fa5ef0..5d936f5420f 100644 --- a/packages/agent-core-v2/src/agent/toolSelect/toolSelectService.ts +++ b/packages/agent-core-v2/src/agent/toolSelect/toolSelectService.ts @@ -156,10 +156,13 @@ export class AgentToolSelectService extends Service implements IAgentToolSelectS drainPendingToolSchemas(): readonly Tool[] | undefined { if (!this.enabled() || this.pendingLoaded.size === 0) return undefined; const names = [...this.pendingLoaded].toSorted((a, b) => a.localeCompare(b)); - for (const name of names) this.pendingLoaded.delete(name); - const tools = names - .map((name) => this.schemaOf(name)) - .filter((tool): tool is Tool => tool !== undefined); + const tools: Tool[] = []; + for (const name of names) { + const tool = this.schemaOf(name); + if (tool === undefined) continue; + this.pendingLoaded.delete(name); + tools.push(tool); + } return tools.length === 0 ? undefined : tools; } diff --git a/packages/agent-core-v2/src/index.ts b/packages/agent-core-v2/src/index.ts index bda9dc2427d..0ae45dcb74d 100644 --- a/packages/agent-core-v2/src/index.ts +++ b/packages/agent-core-v2/src/index.ts @@ -568,6 +568,7 @@ export * from '#/agent/tokenCounting/tokenCountingService'; export * from '#/agent/contextInjector/contextInjector'; export * from '#/agent/contextInjector/contextInjectorService'; export * from '#/agent/plugin/agentPlugin'; +export * from '#/agent/plugin/agentPluginOps'; export * from '#/agent/plugin/agentPluginService'; import '#/agent/externalHooks/configSection'; export * from '#/agent/externalHooks/externalHooks'; diff --git a/packages/agent-core-v2/test/agent/contextInjector/contextInjector.test.ts b/packages/agent-core-v2/test/agent/contextInjector/contextInjector.test.ts index 5e022cd3fe4..69d8615b7dc 100644 --- a/packages/agent-core-v2/test/agent/contextInjector/contextInjector.test.ts +++ b/packages/agent-core-v2/test/agent/contextInjector/contextInjector.test.ts @@ -195,6 +195,41 @@ describe('AgentContextInjectorService', () => { expect(context.get()).toHaveLength(1); }); + it('reconciles only providers registered under the requested name while idle', async () => { + const seen: string[] = []; + injector(ix).register('target', () => { + seen.push('target'); + return 'target reminder'; + }); + injector(ix).register('other', () => { + seen.push('other'); + return 'other reminder'; + }); + + await injector(ix).reconcileWhenIdle('target'); + + expect(seen).toEqual(['target']); + expect(context.get()).toHaveLength(1); + expect(context.get()[0]?.origin).toEqual({ kind: 'injection', variant: 'target' }); + }); + + it('leaves reconciliation to a normal boundary when quiescence cannot be acquired', async () => { + let calls = 0; + injector(ix).register('target', () => { + calls++; + return 'target reminder'; + }); + loop.settled = async () => { + throw new Error('idle reconciliation must not wait for an active turn'); + }; + loop.tryAcquireQuiescence = () => undefined; + + await injector(ix).reconcileWhenIdle('target'); + + expect(calls).toBe(0); + expect(context.get()).toHaveLength(0); + }); + it('exposes all live injection positions alongside the newest one', async () => { const seen: Array = []; diff --git a/packages/agent-core-v2/test/agent/contextMemory/stubs.ts b/packages/agent-core-v2/test/agent/contextMemory/stubs.ts index ba6aef5624c..ea8155fe292 100644 --- a/packages/agent-core-v2/test/agent/contextMemory/stubs.ts +++ b/packages/agent-core-v2/test/agent/contextMemory/stubs.ts @@ -53,6 +53,7 @@ export function stubContextMemory(eventBus?: IEventBus): StubContextMemory { publishSplice(eventBus, { start, deleteCount: 0, messages: [...inserted] }); }, appendLoopEvent: () => {}, + publishTrailingRemoval: () => false, clear: () => { const deleteCount = messages.length; if (deleteCount === 0) return; @@ -106,6 +107,9 @@ class StubContextMemoryService implements IAgentContextMemoryService { appendLoopEvent(event: LoopRecordedEvent): void { this.impl.appendLoopEvent(event); } + publishTrailingRemoval(previous: readonly ContextMessage[]): boolean { + return this.impl.publishTrailingRemoval(previous); + } undo(count: number): UndoCut { return this.impl.undo(count); } diff --git a/packages/agent-core-v2/test/agent/fullCompaction/fullCompaction.test.ts b/packages/agent-core-v2/test/agent/fullCompaction/fullCompaction.test.ts index f32a9c1959c..dcacff00cdc 100644 --- a/packages/agent-core-v2/test/agent/fullCompaction/fullCompaction.test.ts +++ b/packages/agent-core-v2/test/agent/fullCompaction/fullCompaction.test.ts @@ -318,6 +318,44 @@ describe('FullCompaction', () => { await ctx.expectResumeMatches(); }); + it('holds the loop quiescence lease for the full manual compaction', async () => { + const ctx = testAgent(); + ctx.configure({ + provider: CATALOGUED_PROVIDER, + modelCapabilities: CATALOGUED_MODEL_CAPABILITIES, + }); + ctx.appendExchange(1, 'old user one', 'old assistant one', 20); + ctx.appendExchange(2, 'recent user two', 'recent assistant two', 80); + let release!: () => void; + const canCompact = new Promise((resolve) => { + release = resolve; + }); + let started!: () => void; + const compactionStarted = new Promise((resolve) => { + started = resolve; + }); + const hook = ctx.get(IAgentFullCompactionService).hooks.onWillCompact.register( + 'test-quiescence', + async (_task, next) => { + started(); + await canCompact; + await next(); + }, + ); + ctx.mockNextResponse({ type: 'text', text: 'Compacted summary.' }); + + expect(ctx.get(IAgentFullCompactionService).begin({ source: 'manual' })).toBe(true); + await compactionStarted; + expect(ctx.get(IAgentLoopService).tryAcquireQuiescence()).toBeUndefined(); + + release(); + await ctx.get(IAgentFullCompactionService).compacting?.promise; + const lease = ctx.get(IAgentLoopService).tryAcquireQuiescence(); + expect(lease).toBeDefined(); + lease?.dispose(); + hook.dispose(); + }); + it('refreshes the active profile system prompt after compaction without resetting active tools', async () => { const homeDir = mkdtempSync(join(tmpdir(), 'kimi-compact-refresh-home-')); const workDir = mkdtempSync(join(tmpdir(), 'kimi-compact-refresh-work-')); diff --git a/packages/agent-core-v2/test/agent/loop/loop.test.ts b/packages/agent-core-v2/test/agent/loop/loop.test.ts index fde9f93b047..358da7ded93 100644 --- a/packages/agent-core-v2/test/agent/loop/loop.test.ts +++ b/packages/agent-core-v2/test/agent/loop/loop.test.ts @@ -64,6 +64,7 @@ describe('Agent loop', () => { [emit] agent.activity.updated { "lifecycle": "ready", "turn": { "turnId": 0, "origin": { "kind": "user" }, "phase": "running", "step": 0, "ending": false, "pendingApprovals": [], "activeToolCalls": [], "since": "