From 5c38b192edee907112316707842d603f358b91d6 Mon Sep 17 00:00:00 2001 From: elkaix Date: Tue, 25 Aug 2026 00:01:36 -0400 Subject: [PATCH 1/4] refactor(agent-core-v2): reconcile reminder lifecycle Unify reminder activation and system prompts under one agent runtime. Keep lifecycle context active until asynchronous scope teardown finishes. Install gateway process handlers only after startup succeeds. --- .changeset/fix-restore-crash-loop.md | 5 + packages/agent-core-v2/AGENTS.md | 2 +- .../src/_base/di/instantiation.ts | 1 + .../src/_base/di/instantiationService.ts | 24 +- packages/agent-core-v2/src/_base/di/scope.ts | 4 +- .../agent-core-v2/src/_base/di/scopeUnits.ts | 18 +- packages/agent-core-v2/src/_base/di/test.ts | 4 +- .../src/_base/di/testInstantiationService.ts | 8 + .../src/_base/lifecycle/ledger.ts | 13 +- .../agentsMdReminderService.ts | 17 +- .../contextInjector/contextInjectorService.ts | 226 ------------- .../agent/contextMemory/compactionHandoff.ts | 2 +- .../interruptionReminderService.ts | 10 +- .../injection/permissionModeInjection.ts | 8 +- .../permissionMode/permissionModeService.ts | 9 +- .../src/agent/plugin/agentPluginService.ts | 31 +- .../src/agent/prompt/promptService.ts | 18 +- .../src/agent/prompt/promptStepRequests.ts | 11 +- .../systemReminder/systemReminderService.ts | 41 --- .../src/agent/task/taskService.ts | 11 +- .../src/agent/toolDedupe/toolDedupeService.ts | 2 +- .../toolSelectAnnouncementsService.ts | 13 +- .../toolSelect/toolSelectSchemasService.ts | 19 +- .../src/features/btw/btwService.ts | 11 +- .../features/dateChange/dateChangeService.ts | 23 +- .../agent/dynamicWorkflowService.ts | 15 +- .../injection/dynamicWorkflowInjection.ts | 12 +- .../src/features/goal/goalAgentRuntime.ts | 20 +- .../features/goal/injection/goalInjection.ts | 4 +- .../plan/injection/planModeInjection.ts | 4 +- .../src/features/plan/planService.ts | 11 +- .../reminder/internal/reminderActivation.ts | 24 ++ .../features/reminder/reminderAgentRuntime.ts | 317 ++++++++++++++++++ .../src/features/reminder/reminderFeature.ts | 15 + .../reminder}/systemReminder.ts | 12 +- .../reminder/types.ts} | 23 +- .../sessionInit/sessionInitService.ts | 11 +- .../src/features/todo/todoAgentRuntime.ts | 8 +- .../tower/injection/towerModeInjection.ts | 4 +- .../src/features/tower/towerService.ts | 11 +- packages/agent-core-v2/src/index.ts | 10 +- .../src/session/advisor/advisorService.ts | 18 +- .../agentLifecycle/agentLifecycleService.ts | 16 +- .../sessionLifecycleService.ts | 12 +- .../agent-core-v2/test/_base/di/child.test.ts | 58 ++++ .../agentsMdReminder/agentsMdReminder.test.ts | 30 +- .../fullCompaction/fullCompaction.test.ts | 1 + .../permissionMode/permissionMode.test.ts | 34 +- .../test/agent/plugin/agentPlugin.test.ts | 2 + .../test/agent/prompt/promptService.test.ts | 17 +- .../test/agent/task/taskService.test.ts | 44 +-- .../agent/toolSelect/toolSelect.e2e.test.ts | 2 + .../toolSelect/toolSelectService.test.ts | 16 +- .../test/app/config/config.test.ts | 1 + .../test/features/btw/btw.test.ts | 4 +- .../dateChange/dateChangeInjection.test.ts | 42 +-- .../dynamic_workflow/dynamic_workflow.test.ts | 61 +++- .../test/features/goal/goalFeature.test.ts | 2 - .../test/features/goal/goalOps.test.ts | 24 +- .../goal/injection/goalInjection.test.ts | 49 ++- .../plan/injection/planModeInjection.test.ts | 51 ++- .../test/features/plan/plan.test.ts | 15 +- .../test/features/plan/planGuard.test.ts | 10 +- .../reminder/reminder.test.ts} | 175 +++++----- .../test/features/reminder/stubs.ts | 109 ++++++ .../features/sessionInit/sessionInit.test.ts | 12 +- .../catalog/plugin-session-start.test.ts | 10 +- .../skill/workspace/skillCatalog.test.ts | 2 +- .../test/features/todo/sessionTodo.test.ts | 46 +-- .../test/features/tower/towerService.test.ts | 8 +- packages/agent-core-v2/test/harness/agent.ts | 13 +- .../session/advisor/sessionAdvisor.test.ts | 55 ++- .../agentLifecycle/agentLifecycle.test.ts | 232 ++++++++++--- packages/agent-core-v2/test/tool/tool.test.ts | 17 +- .../agent-core-v2/test/wire/resume.test.ts | 34 ++ packages/agent-gateway/src/start.ts | 23 +- packages/agent-gateway/test/boot.test.ts | 45 +++ packages/node-sdk/src/sdk-rpc-client-v2.ts | 6 +- 78 files changed, 1470 insertions(+), 858 deletions(-) create mode 100644 .changeset/fix-restore-crash-loop.md delete mode 100644 packages/agent-core-v2/src/agent/contextInjector/contextInjectorService.ts delete mode 100644 packages/agent-core-v2/src/agent/systemReminder/systemReminderService.ts create mode 100644 packages/agent-core-v2/src/features/reminder/internal/reminderActivation.ts create mode 100644 packages/agent-core-v2/src/features/reminder/reminderAgentRuntime.ts create mode 100644 packages/agent-core-v2/src/features/reminder/reminderFeature.ts rename packages/agent-core-v2/src/{agent/systemReminder => features/reminder}/systemReminder.ts (60%) rename packages/agent-core-v2/src/{agent/contextInjector/contextInjector.ts => features/reminder/types.ts} (62%) rename packages/agent-core-v2/test/{agent/contextInjector/contextInjector.test.ts => features/reminder/reminder.test.ts} (73%) create mode 100644 packages/agent-core-v2/test/features/reminder/stubs.ts diff --git a/.changeset/fix-restore-crash-loop.md b/.changeset/fix-restore-crash-loop.md new file mode 100644 index 000000000..2c70a6c13 --- /dev/null +++ b/.changeset/fix-restore-crash-loop.md @@ -0,0 +1,5 @@ +--- +"@pymodel/pythinker-code": patch +--- + +Fix repeated server crashes when resuming a session that stopped during a turn. diff --git a/packages/agent-core-v2/AGENTS.md b/packages/agent-core-v2/AGENTS.md index eebe6623c..951cdcd31 100644 --- a/packages/agent-core-v2/AGENTS.md +++ b/packages/agent-core-v2/AGENTS.md @@ -67,7 +67,7 @@ One accepted exception: `features/tower/protocol` manages the `.tower/` director ## Model-facing reminders -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`) that reconciles at every step head (before the step's request is built) 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. +Two delivery paths only — never introduce a third (no deferred-delivery queues, no mid-step splice channels), both owned by the `AgentReminder` Agent Runtime and obtained only through `IAgentLifecycleService.resolve(agentContext, AgentReminder)`: reminders that restate current state (goal state, plan mode, date change, …) call `register(variant, provider)` and reconcile at every step head before the request is built, re-emitting after compaction or undo; reminders that report a one-off event (goal cancelled, AGENTS.md discovered, `/init` finished, …) call `notify(content, { variant, ownerPromptId? })` at a safe event point (a step/restore hook, an idle moment, or the loop-event fold's deferred append). The runtime owns `` wrapping and stamps `{ kind: 'injection', variant }`; `kind: 'injection'` is a lifecycle classification (hidden from the UI, not an undo anchor, dropped by compaction), not a provenance claim, and prompt-owned attachments carry `ownerPromptId` so undo treats them as part of their host prompt. ## Docs diff --git a/packages/agent-core-v2/src/_base/di/instantiation.ts b/packages/agent-core-v2/src/_base/di/instantiation.ts index 4553e8d76..7adc73615 100644 --- a/packages/agent-core-v2/src/_base/di/instantiation.ts +++ b/packages/agent-core-v2/src/_base/di/instantiation.ts @@ -196,6 +196,7 @@ export interface IInstantiationService { provideAll(entries: ReadonlyArray): void; unprovide(id: ServiceIdentifier): void; dispose(): void; + disposeAsync(): Promise; } export const IInstantiationService: ServiceIdentifier = diff --git a/packages/agent-core-v2/src/_base/di/instantiationService.ts b/packages/agent-core-v2/src/_base/di/instantiationService.ts index 32bab2052..cd3ca5272 100644 --- a/packages/agent-core-v2/src/_base/di/instantiationService.ts +++ b/packages/agent-core-v2/src/_base/di/instantiationService.ts @@ -487,6 +487,10 @@ export class InstantiationService implements IInstantiationService { return this._ledger.register(disposer, label); } + anchorKernelFinalizer(disposer: Disposer, label: string): LedgerEntry { + return this._ledger.registerFinalizer(disposer, label); + } + private _getFiberHost(): FiberHost { this._fiberHost ??= { mintUid: () => ++this._root()._nextUnitUid, @@ -648,18 +652,31 @@ export class InstantiationService implements IInstantiationService { return new InstantiationService(services, this._strict, this, this._enableTracing); } + private _disposePromise: Promise | undefined; + dispose(): void { + void this.disposeAsync(); + } + + disposeAsync(): Promise { + this._disposePromise ??= this.disposeCore(); + return this._disposePromise; + } + + private disposeCore(): Promise { if (this._disposed) { - return; + return Promise.resolve(); } this._disposed = true; + const childTeardowns: Promise[] = []; + let teardown: void | Promise = undefined; try { for (const child of Array.from(this._children)) { - child.dispose(); + childTeardowns.push(child.disposeAsync()); } this._children.clear(); - void this._ledger.teardown('scope-close'); + teardown = this._ledger.teardown('scope-close'); this._services.dispose(); this.cascade.dispose(); for (const view of this._collectionViews.values()) { @@ -674,6 +691,7 @@ export class InstantiationService implements IInstantiationService { this._parent._children.delete(this); } } + return Promise.all([...childTeardowns, Promise.resolve(teardown)]).then(() => undefined); } private _createInstance(ctor: any, args: unknown[], _trace: Trace, unit?: { diff --git a/packages/agent-core-v2/src/_base/di/scope.ts b/packages/agent-core-v2/src/_base/di/scope.ts index ca6612098..4db1ae683 100644 --- a/packages/agent-core-v2/src/_base/di/scope.ts +++ b/packages/agent-core-v2/src/_base/di/scope.ts @@ -112,7 +112,7 @@ export interface IScopeHandle { readonly id: string; readonly kind: K; readonly accessor: ServicesAccessor; - dispose(): void; + dispose(): void | Promise; } export type IAppScopeHandle = IScopeHandle<'app'>; @@ -171,7 +171,7 @@ export function createScopedChildHandle( get: (serviceId: ServiceIdentifier): T => child.invokeFunction((a) => a.get(serviceId)), }; - return { id, kind, accessor, dispose: () => child.dispose() }; + return { id, kind, accessor, dispose: () => child.disposeAsync() }; } export class Scope implements IDisposable { diff --git a/packages/agent-core-v2/src/_base/di/scopeUnits.ts b/packages/agent-core-v2/src/_base/di/scopeUnits.ts index 7219222c3..db02af075 100644 --- a/packages/agent-core-v2/src/_base/di/scopeUnits.ts +++ b/packages/agent-core-v2/src/_base/di/scopeUnits.ts @@ -22,7 +22,7 @@ export function watchScopeUnits(container: InstantiationService, kind: ScopeKind const foldLedger = new Ledger(`scope-units:${kind}`); container.anchorKernelEntry((reason) => foldLedger.teardown(reason), `scope-units:${kind}`); - const materialized = new Map void>(); + const materialized = new Map void | Promise>(); const materialize = (record: StoredRecord): void => { const recipe = record.value as ServiceRecipe; @@ -32,7 +32,7 @@ export function watchScopeUnits(container: InstantiationService, kind: ScopeKind if (isClassRecipe(recipe)) { const instance = host.constructService(recipe, undefined) as Partial; unitLedger.register(() => { - instance.dispose?.(); + return instance.dispose?.(); }, `unit:${name}`); } else { const facade = new FiberRuntime( @@ -57,23 +57,23 @@ export function watchScopeUnits(container: InstantiationService, kind: ScopeKind } let retracted = false; - const retract = (): void => { + const retract = (): void | Promise => { if (retracted) { - return; + return undefined; } retracted = true; materialized.delete(record.id); - void unitLedger.teardown('unload'); + return unitLedger.teardown('unload'); }; if (!record.providerBook.isActive) { - retract(); + void retract(); return; } record.providerBook.register(() => { - retract(); + void retract(); }, `scope-units:${kind}`); foldLedger.register(() => { - retract(); + return retract(); }, `record:${name}`); materialized.set(record.id, retract); }; @@ -92,7 +92,7 @@ export function watchScopeUnits(container: InstantiationService, kind: ScopeKind } for (const [id, retract] of Array.from(materialized)) { if (!seen.has(id)) { - retract(); + void retract(); } } }; diff --git a/packages/agent-core-v2/src/_base/di/test.ts b/packages/agent-core-v2/src/_base/di/test.ts index 5bdfc3e25..5b41a4e68 100644 --- a/packages/agent-core-v2/src/_base/di/test.ts +++ b/packages/agent-core-v2/src/_base/di/test.ts @@ -29,7 +29,9 @@ export function createScopedTestHost(appStubs: ScopeSeed = []): ScopedTestHost { id: handle.id, kind: handle.kind, accessor: handle.accessor, - dispose: () => handle.dispose(), + dispose: () => { + void handle.dispose(); + }, } as Scope; } return app.createChild(kind, id, { seeds: stubs }); diff --git a/packages/agent-core-v2/src/_base/di/testInstantiationService.ts b/packages/agent-core-v2/src/_base/di/testInstantiationService.ts index 7537f4de2..aaa07a175 100644 --- a/packages/agent-core-v2/src/_base/di/testInstantiationService.ts +++ b/packages/agent-core-v2/src/_base/di/testInstantiationService.ts @@ -262,6 +262,14 @@ export class TestInstantiationService extends InstantiationService implements ID super.dispose(); } } + + public override disposeAsync(): Promise { + sinon.restore(); + if (this._properDispose) { + return super.disposeAsync(); + } + return Promise.resolve(); + } } interface SinonOptions { diff --git a/packages/agent-core-v2/src/_base/lifecycle/ledger.ts b/packages/agent-core-v2/src/_base/lifecycle/ledger.ts index f39b15c17..d49c9ce07 100644 --- a/packages/agent-core-v2/src/_base/lifecycle/ledger.ts +++ b/packages/agent-core-v2/src/_base/lifecycle/ledger.ts @@ -65,6 +65,11 @@ export class Ledger { return this._push({ label, kind: 'disposer', active: true, run: disposer }); } + registerFinalizer(disposer: Disposer, label: string = 'finalizer'): LedgerEntry { + this._assertActive('registerFinalizer'); + return this._push({ label, kind: 'disposer', active: true, run: disposer }, true); + } + effect(body: EffectBody, label: string = 'effect'): LedgerEntry { this._assertActive('effect'); const out = body(); @@ -151,11 +156,15 @@ export class Ledger { return infos; } - private _push(record: EntryRecord): LedgerEntry { + private _push(record: EntryRecord, front = false): LedgerEntry { if (Ledger.captureStacks) { record.stack = new Error('Ledger registration').stack; } - this._records.push(record); + if (front) { + this._records.unshift(record); + } else { + this._records.push(record); + } return { label: record.label, get disposed() { diff --git a/packages/agent-core-v2/src/agent/agentsMdReminder/agentsMdReminderService.ts b/packages/agent-core-v2/src/agent/agentsMdReminder/agentsMdReminderService.ts index 992a2ebbf..91a68b9c7 100644 --- a/packages/agent-core-v2/src/agent/agentsMdReminder/agentsMdReminderService.ts +++ b/packages/agent-core-v2/src/agent/agentsMdReminder/agentsMdReminderService.ts @@ -25,7 +25,9 @@ import { } from '#/agent/profile/context'; import { profileKey } from '#/agent/profile/profileOps'; import { IAgentStateService } from '#/agent/state/agentState'; -import { IAgentSystemReminderService } from '#/agent/systemReminder/systemReminder'; +import { AgentReminder, type ReminderRuntime } from '#/features/reminder/reminderAgentRuntime'; +import { IAgentScopeContext } from '#/agent/scopeContext/scopeContext'; +import { IAgentLifecycleService } from '#/session/agentLifecycle/agentLifecycle'; import { IAgentToolExecutorService } from '#/agent/toolExecutor/toolExecutor'; import type { ToolDidExecuteContext } from '#/agent/toolExecutor/toolHooks'; import { IEventDispatcher } from '#/state/eventDispatcher'; @@ -58,7 +60,8 @@ export class AgentAgentsMdReminderService constructor( @IAgentToolExecutorService toolExecutor: IAgentToolExecutorService, - @IAgentSystemReminderService private readonly reminders: IAgentSystemReminderService, + @IAgentLifecycleService private readonly agentLifecycle: IAgentLifecycleService, + @IAgentScopeContext private readonly scopeContext: IAgentScopeContext, @IAgentStateService private readonly states: IAgentStateService, @ISessionContext private readonly sessionContext: ISessionContext, @IAgentRuntimeService private readonly runtime: IAgentRuntimeService, @@ -111,8 +114,7 @@ export class AgentAgentsMdReminderService } if (entries.size === 0) return; const list = [...entries.values()]; - this.reminders.appendSystemReminder(changeReminderText(list), { - kind: 'injection', + this.reminder().notify(changeReminderText(list), { variant: 'agents_md_change', }); this.publishKnown( @@ -170,8 +172,7 @@ export class AgentAgentsMdReminderService trace_id: ctx.trace?.traceId, }; this.telemetry.track2('agents_md_reminder_shown', properties); - this.reminders.appendSystemReminder(reminderText(discovered), { - kind: 'injection', + this.reminder().notify(reminderText(discovered), { variant: 'agents_md', }); this.publishKnown([...selfKnown, ...discovered]); @@ -180,6 +181,10 @@ export class AgentAgentsMdReminderService } } + private reminder(): ReminderRuntime { + return this.agentLifecycle.resolve(this.scopeContext.agentContext, AgentReminder); + } + 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/contextInjectorService.ts b/packages/agent-core-v2/src/agent/contextInjector/contextInjectorService.ts deleted file mode 100644 index d4829d03e..000000000 --- a/packages/agent-core-v2/src/agent/contextInjector/contextInjectorService.ts +++ /dev/null @@ -1,226 +0,0 @@ -import { toDisposable, type IDisposable } from "#/_base/di/lifecycle"; -import { Service } from "#/_base/di/service"; -import { LifecycleScope } from '#/app/scopes'; -import { ScopeActivation, registerScopedService } from '#/_base/di/scope'; -import { ILogService } from '#/_base/log/log'; - -import { IAgentContextMemoryService } from '#/agent/contextMemory/contextMemory'; -import { ContextSpliced } from '#/agent/contextMemory/contextEvents'; -import { isCompactionSummaryMessage } from '#/agent/contextMemory/compactionHandoff'; -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 { - IAgentContextInjectorService, - type ContextInjectionContent, - type ContextInjectionContext, - type ContextInjectionMessage, - type ContextInjectionProvider, - type ContextInjectionResult, -} from './contextInjector'; - -interface ContextInjectionEntry { - readonly provider: ContextInjectionProvider; - readonly name: string; -} - -export class AgentContextInjectorService extends Service implements IAgentContextInjectorService { - declare readonly _serviceBrand: undefined; - private readonly entries = new Set(); - private compactionRearmPending = false; - - constructor( - @IAgentContextMemoryService private readonly context: IAgentContextMemoryService, - @IAgentLoopService private readonly loopService: IAgentLoopService, - @IAgentSystemReminderService private readonly reminders: IAgentSystemReminderService, - @IEventBus private readonly eventBus: IEventBus, - @ILogService private readonly log: ILogService, - ) { - super(); - this._register( - loopService.hooks.onWillBeginStep.register('context-injector', (ctx, next) => - this.reconcileAroundStep(ctx, next), - ), - ); - this._register( - this.eventBus.subscribe(ContextSpliced, (splice) => { - if (isCompactionSplice(splice)) this.compactionRearmPending = true; - }), - ); - } - - register( - name: string, - provider: ContextInjectionProvider, - ): IDisposable { - const entry: ContextInjectionEntry = { - provider: provider as ContextInjectionProvider, - name, - }; - this.entries.add(entry); - return toDisposable(() => { - this.entries.delete(entry); - }); - } - - 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 reconcileAroundStep( - ctx: BeforeStepContext, - next: (context?: BeforeStepContext) => Promise, - ): Promise { - const rearmed = this.takeCompactionRearm(); - await this.inject(ctx.firstStepOfTurn || rearmed); - await next(); - if (this.takeCompactionRearm()) { - await this.inject(true); - } - } - - /** Reads and clears the flag set when a compaction splice arrives. */ - private takeCompactionRearm(): boolean { - const pending = this.compactionRearmPending; - this.compactionRearmPending = false; - return pending; - } - - private async inject(isNewTurn: boolean): Promise { - for (const entry of this.entries) { - 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 providerContext( - entry: ContextInjectionEntry, - isNewTurn: boolean, - ): ContextInjectionContext { - const history = this.context.get(); - const injectedPositions = findInjections(history, entry.name); - const lastInjectedAt = injectedPositions.at(-1) ?? null; - const lastInjection = lastInjectedAt === null ? undefined : history[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 = isInjectionResult(content) - ? content - : { content }; - const origin = { - kind: 'injection' as const, - variant: entry.name, - disclosure: result.disclosure, - }; - 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 (resolved.length === 0) return; - this.context.append({ - role: 'user', - content: [...resolved], - toolCalls: [], - origin, - }); - } -} - -function isCompactionSplice(splice: { - readonly deleteCount: number; - readonly messages: readonly ContextMessage[]; -}): boolean { - return splice.deleteCount > 0 && splice.messages.some(isCompactionSummaryMessage); -} - -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, -): number[] { - const positions: number[] = []; - history.forEach((message, index) => { - if (message.origin?.kind === 'injection' && message.origin.variant === variant) { - positions.push(index); - } - }); - return positions; -} - -registerScopedService( - LifecycleScope.Agent, - IAgentContextInjectorService, - AgentContextInjectorService, - ScopeActivation.OnScopeCreated, - 'contextInjector', -); diff --git a/packages/agent-core-v2/src/agent/contextMemory/compactionHandoff.ts b/packages/agent-core-v2/src/agent/contextMemory/compactionHandoff.ts index fd05dda5a..11a6a9f73 100644 --- a/packages/agent-core-v2/src/agent/contextMemory/compactionHandoff.ts +++ b/packages/agent-core-v2/src/agent/contextMemory/compactionHandoff.ts @@ -1,6 +1,6 @@ import { estimateTokens, estimateTokensForMessage, estimateTokensForMessages } from '#/kosong/contract/tokens'; import type { ContentPart } from '#/kosong/contract/message'; -import { wrapSystemReminder } from '#/agent/systemReminder/systemReminder'; +import { wrapSystemReminder } from '#/features/reminder/systemReminder'; import summaryPrefixTemplate from './compaction-summary-prefix.md?raw'; import type { ContextMessage, PromptOrigin } from './types'; diff --git a/packages/agent-core-v2/src/agent/interruptionReminder/interruptionReminderService.ts b/packages/agent-core-v2/src/agent/interruptionReminder/interruptionReminderService.ts index 7ea0ea776..d5348bb69 100644 --- a/packages/agent-core-v2/src/agent/interruptionReminder/interruptionReminderService.ts +++ b/packages/agent-core-v2/src/agent/interruptionReminder/interruptionReminderService.ts @@ -5,7 +5,9 @@ import { IAgentContextMemoryService } from '#/agent/contextMemory/contextMemory' import type { ContextMessage } from '#/agent/contextMemory/types'; import { isVacuousContentPart } from '#/agent/contextMemory/vacuousContent'; import { TurnEnded } from '#/agent/loop/turnOps'; -import { IAgentSystemReminderService } from '#/agent/systemReminder/systemReminder'; +import { AgentReminder } from '#/features/reminder/reminderAgentRuntime'; +import { IAgentScopeContext } from '#/agent/scopeContext/scopeContext'; +import { IAgentLifecycleService } from '#/session/agentLifecycle/agentLifecycle'; import { IAgentStateService } from '#/agent/state/agentState'; import { IEventBus } from '#/app/event/eventBus'; @@ -27,7 +29,8 @@ export class AgentInterruptionReminderService constructor( @IEventBus eventBus: IEventBus, @IAgentContextMemoryService private readonly context: IAgentContextMemoryService, - @IAgentSystemReminderService private readonly reminders: IAgentSystemReminderService, + @IAgentLifecycleService agentLifecycle: IAgentLifecycleService, + @IAgentScopeContext scopeContext: IAgentScopeContext, @IAgentStateService agentState: IAgentStateService, ) { super(); @@ -37,8 +40,7 @@ export class AgentInterruptionReminderService 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', + agentLifecycle.resolve(scopeContext.agentContext, AgentReminder).notify(INTERRUPTION_REMINDER, { variant: INTERRUPTION_REMINDER_VARIANT, }); }), 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 c69effade..caf0c7647 100644 --- a/packages/agent-core-v2/src/agent/permissionMode/injection/permissionModeInjection.ts +++ b/packages/agent-core-v2/src/agent/permissionMode/injection/permissionModeInjection.ts @@ -1,9 +1,7 @@ import { Service } from '#/_base/di/service'; import { defineState } from '#/state/state'; -import { - IAgentContextInjectorService, - type ContextInjectionContext, -} from '#/agent/contextInjector/contextInjector'; +import type { ReminderRuntime } from '#/features/reminder/reminderAgentRuntime'; +import type { ContextInjectionContext } from '#/features/reminder/types'; import type { IAgentPermissionModeService } from '#/agent/permissionMode/permissionMode'; import type { PermissionMode } from '#/agent/permissionPolicy/types'; import { IAgentStateService } from '#/agent/state/agentState'; @@ -20,7 +18,7 @@ export const permissionModeLastModeKey = defineState export class PermissionModeInjection extends Service { constructor( private readonly permissionMode: Pick, - @IAgentContextInjectorService injector: IAgentContextInjectorService, + injector: ReminderRuntime, @IAgentStateService private readonly states: IAgentStateService, ) { super(); diff --git a/packages/agent-core-v2/src/agent/permissionMode/permissionModeService.ts b/packages/agent-core-v2/src/agent/permissionMode/permissionModeService.ts index 35393395e..0f0f7a7d3 100644 --- a/packages/agent-core-v2/src/agent/permissionMode/permissionModeService.ts +++ b/packages/agent-core-v2/src/agent/permissionMode/permissionModeService.ts @@ -1,10 +1,10 @@ import type { PermissionMode } from '#/agent/permissionPolicy/types'; -import { IInstantiationService } from '#/_base/di/instantiation'; import { Service } from '#/_base/di/service'; import { LifecycleScope } from '#/app/scopes'; import { ScopeActivation, registerScopedService } from '#/_base/di/scope'; import { Emitter, type Event } from '#/_base/event'; import { PermissionModeInjection } from '#/agent/permissionMode/injection/permissionModeInjection'; +import { activateReminderWhenReady } from '#/features/reminder/internal/reminderActivation'; import { IAgentScopeContext } from '#/agent/scopeContext/scopeContext'; import { ITelemetryService } from '#/app/telemetry/telemetry'; import { @@ -28,7 +28,6 @@ export class AgentPermissionModeService extends Service implements IAgentPermiss constructor( @IEventDispatcher private readonly dispatcher: IEventDispatcher, - @IInstantiationService instantiation: IInstantiationService, @IAgentScopeContext private readonly scopeContext: IAgentScopeContext, @IAgentLifecycleService private readonly agentLifecycle: IAgentLifecycleService, @ITelemetryService private readonly telemetry: ITelemetryService, @@ -37,7 +36,11 @@ export class AgentPermissionModeService extends Service implements IAgentPermiss super(); this.agentState.contributeState(permissionModeKey); this.agentState.contributeState(permissionModeConfiguredKey); - this._register(instantiation.createInstance(PermissionModeInjection, this)); + this._register( + activateReminderWhenReady(this.agentLifecycle, this.scopeContext, (reminder) => + new PermissionModeInjection(this, reminder, this.agentState), + ), + ); } get mode(): PermissionMode { diff --git a/packages/agent-core-v2/src/agent/plugin/agentPluginService.ts b/packages/agent-core-v2/src/agent/plugin/agentPluginService.ts index 0aec62b6b..23dfc16ab 100644 --- a/packages/agent-core-v2/src/agent/plugin/agentPluginService.ts +++ b/packages/agent-core-v2/src/agent/plugin/agentPluginService.ts @@ -4,17 +4,14 @@ import { ScopeActivation, registerScopedService } from '#/_base/di/scope'; import { ILogService } from '#/_base/log/log'; import { defineState } from '#/state/state'; import { escapeXmlAttr } from '#/_base/utils/xml-escape'; -import { - IAgentContextInjectorService, - type ContextInjectionContext, -} from '#/agent/contextInjector/contextInjector'; +import { activateReminderWhenReady } from '#/features/reminder/internal/reminderActivation'; +import { AgentReminder, type ReminderRuntime } from '#/features/reminder/reminderAgentRuntime'; +import type { ContextInjectionContext } from '#/features/reminder/types'; import { IAgentContextMemoryService } from '#/agent/contextMemory/contextMemory'; import { IAgentScopeContext } from '#/agent/scopeContext/scopeContext'; +import { IAgentLifecycleService } from '#/session/agentLifecycle/agentLifecycle'; import { IAgentStateService } from '#/agent/state/agentState'; -import { - IAgentSystemReminderService, - systemReminderContent, -} from '#/agent/systemReminder/systemReminder'; +import { systemReminderContent } from '#/features/reminder/systemReminder'; import { IPluginService } from '#/app/plugin/plugin'; import type { EnabledPluginSessionStart, PluginMutation } from '#/app/plugin/types'; import { PLUGIN_SKILL_SOURCE_ID } from '#/features/skill/catalog/skillSource'; @@ -70,8 +67,7 @@ export class AgentPluginService extends Service implements IAgentPluginService { constructor( @IAgentScopeContext private readonly scopeContext: IAgentScopeContext, - @IAgentContextInjectorService private readonly injector: IAgentContextInjectorService, - @IAgentSystemReminderService private readonly reminders: IAgentSystemReminderService, + @IAgentLifecycleService private readonly agentLifecycle: IAgentLifecycleService, @IAgentContextMemoryService private readonly context: IAgentContextMemoryService, @IPluginService private readonly plugins: IPluginService, @ISessionSkillCatalog private readonly skillCatalog: ISessionSkillCatalog, @@ -85,8 +81,10 @@ export class AgentPluginService extends Service implements IAgentPluginService { if (scopeContext.agentId !== MAIN_AGENT_ID) return; this.states.contributeState(pluginSessionStartRefreshPendingKey); this._register( - injector.register(SESSION_START_INJECTION_VARIANT, (injection) => - this.reconcileSessionStartReminder(injection), + activateReminderWhenReady(this.agentLifecycle, this.scopeContext, (reminder) => + reminder.register(SESSION_START_INJECTION_VARIANT, (injection) => + this.reconcileSessionStartReminder(injection), + ), ), ); this._register( @@ -102,14 +100,17 @@ export class AgentPluginService extends Service implements IAgentPluginService { this._register( this.plugins.onDidMutate(({ mutation }) => { this.pendingMutationCatalogChanges++; - this.reminders.appendSystemReminder(renderPluginChangeReminder(mutation), { - kind: 'injection', + this.reminder().notify(renderPluginChangeReminder(mutation), { variant: PLUGIN_CHANGE_INJECTION_VARIANT, }); }), ); } + private reminder(): ReminderRuntime { + return this.agentLifecycle.resolve(this.scopeContext.agentContext, AgentReminder); + } + private get refreshPending(): boolean { return this.states.get(pluginSessionStartRefreshPendingKey); } @@ -122,7 +123,7 @@ export class AgentPluginService extends Service implements IAgentPluginService { if (this.scopeContext.agentId !== MAIN_AGENT_ID) return; this.refreshPending = true; await this.skillCatalog.ready; - await this.injector.reconcileWhenIdle(SESSION_START_INJECTION_VARIANT); + await this.reminder().reconcileWhenIdle(SESSION_START_INJECTION_VARIANT); } private async renderSessionStartReminder(): Promise { diff --git a/packages/agent-core-v2/src/agent/prompt/promptService.ts b/packages/agent-core-v2/src/agent/prompt/promptService.ts index 13193bf03..f4e396bc9 100644 --- a/packages/agent-core-v2/src/agent/prompt/promptService.ts +++ b/packages/agent-core-v2/src/agent/prompt/promptService.ts @@ -12,7 +12,8 @@ import { IAgentFullCompactionService } from '#/agent/fullCompaction/fullCompacti import { IAgentLoopService, type Turn, type TurnResult } from '#/agent/loop/loop'; import { TurnSteer } from '#/agent/loop/turnOps'; import { IAgentStateService } from '#/agent/state/agentState'; -import { IAgentSystemReminderService } from '#/agent/systemReminder/systemReminder'; +import { AgentReminder, type ReminderRuntime } from '#/features/reminder/reminderAgentRuntime'; +import { IAgentLifecycleService } from '#/session/agentLifecycle/agentLifecycle'; import type { ExecutableToolResult } from '#/tool/toolContract'; import type { ToolDidExecuteContext } from '#/agent/toolExecutor/toolHooks'; import { IAgentToolExecutorService } from '#/agent/toolExecutor/toolExecutor'; @@ -150,7 +151,7 @@ export class AgentPromptService implements IAgentPromptService { constructor( @IAgentContextMemoryService private readonly context: IAgentContextMemoryService, - @IAgentSystemReminderService private readonly reminders: IAgentSystemReminderService, + @IAgentLifecycleService private readonly agentLifecycle: IAgentLifecycleService, @IInstantiationService private readonly instantiation: IInstantiationService, @IAgentLoopService private readonly loop: IAgentLoopService, @IAgentToolExecutorService toolExecutor: IAgentToolExecutorService, @@ -171,6 +172,10 @@ export class AgentPromptService implements IAgentPromptService { }); } + private reminder(): ReminderRuntime { + return this.agentLifecycle.resolve(this.scopeContext.agentContext, AgentReminder); + } + private get launching(): boolean { return this.states.get(promptLaunchingKey); } @@ -331,7 +336,7 @@ export class AgentPromptService implements IAgentPromptService { removed.push({ item, index }); this.pending.splice(index, 1); } - const request = new SteerStepRequest(rerouted, captions, this.reminders, (materialized) => { + const request = new SteerStepRequest(rerouted, captions, this.reminder(), (materialized) => { void this.dispatcher.dispatch( new TurnSteer({ agentId: this.scopeContext.agentId, @@ -380,7 +385,7 @@ export class AgentPromptService implements IAgentPromptService { async inject(message: ContextMessage): Promise { const { message: rerouted, captions } = this.extractCompressionCaptions(message); await this.materializeDaemonRefs(rerouted); - const request = new SteerStepRequest(rerouted, captions, this.reminders, (materialized) => { + const request = new SteerStepRequest(rerouted, captions, this.reminder(), (materialized) => { void this.dispatcher.dispatch( new TurnSteer({ agentId: this.scopeContext.agentId, @@ -413,7 +418,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.reminder())).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)); @@ -469,8 +474,7 @@ 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.reminder().notify(caption, { variant: 'image_compression', ownerPromptId, }); diff --git a/packages/agent-core-v2/src/agent/prompt/promptStepRequests.ts b/packages/agent-core-v2/src/agent/prompt/promptStepRequests.ts index e830d4c09..a432d33e4 100644 --- a/packages/agent-core-v2/src/agent/prompt/promptStepRequests.ts +++ b/packages/agent-core-v2/src/agent/prompt/promptStepRequests.ts @@ -2,7 +2,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 { ReminderRuntime } from '#/features/reminder/reminderAgentRuntime'; abstract class UserMessageStepRequest extends StepRequest { protected readonly message: ContextMessage; @@ -11,7 +11,7 @@ abstract class UserMessageStepRequest extends StepRequest { constructor( message: ContextMessage, private readonly captions: readonly string[], - private readonly reminders: IAgentSystemReminderService, + private readonly reminders: ReminderRuntime, options?: StepRequestOptions, ) { super(options); @@ -29,8 +29,7 @@ abstract class UserMessageStepRequest extends StepRequest { override onWillMaterialize(): void { for (const caption of this.captions) { - this.reminders.appendSystemReminder(caption, { - kind: 'injection', + this.reminders.notify(caption, { variant: 'image_compression', ownerPromptId: this.ownerPromptId, }); @@ -48,7 +47,7 @@ export class PromptStepRequest extends UserMessageStepRequest { constructor( message: ContextMessage, captions: readonly string[], - reminders: IAgentSystemReminderService, + reminders: ReminderRuntime, ) { super(message, captions, reminders, { admission: 'newTurn' }); } @@ -68,7 +67,7 @@ export class SteerStepRequest extends UserMessageStepRequest { constructor( message: ContextMessage, captions: readonly string[], - reminders: IAgentSystemReminderService, + reminders: ReminderRuntime, private readonly recordSteer: (message: ContextMessage) => void, private readonly forgetSteer: (request: SteerStepRequest) => void, admission: 'activeTurnOnly' | 'activeOrNewTurn' = 'activeTurnOnly', diff --git a/packages/agent-core-v2/src/agent/systemReminder/systemReminderService.ts b/packages/agent-core-v2/src/agent/systemReminder/systemReminderService.ts deleted file mode 100644 index d3a159e68..000000000 --- a/packages/agent-core-v2/src/agent/systemReminder/systemReminderService.ts +++ /dev/null @@ -1,41 +0,0 @@ -import { Service } from '#/_base/di/service'; -import { LifecycleScope } from '#/app/scopes'; -import { ScopeActivation, registerScopedService } from '#/_base/di/scope'; -import { IAgentContextMemoryService } from '#/agent/contextMemory/contextMemory'; -import type { ContextMessage, PromptOrigin } from '#/agent/contextMemory/types'; - -import { IAgentSystemReminderService, wrapSystemReminder } from './systemReminder'; - -export class AgentSystemReminderService extends Service implements IAgentSystemReminderService { - declare readonly _serviceBrand: undefined; - - constructor( - @IAgentContextMemoryService private readonly context: IAgentContextMemoryService, - ) { - super(); - } - - appendSystemReminder(content: string, origin: PromptOrigin): ContextMessage { - const message: ContextMessage = { - role: 'user', - content: [ - { - type: 'text', - text: wrapSystemReminder(content), - }, - ], - toolCalls: [], - origin, - }; - this.context.append(message); - return message; - } -} - -registerScopedService( - LifecycleScope.Agent, - IAgentSystemReminderService, - AgentSystemReminderService, - ScopeActivation.OnScopeCreated, - 'systemReminder', -); diff --git a/packages/agent-core-v2/src/agent/task/taskService.ts b/packages/agent-core-v2/src/agent/task/taskService.ts index 66eba1857..34806a7c7 100644 --- a/packages/agent-core-v2/src/agent/task/taskService.ts +++ b/packages/agent-core-v2/src/agent/task/taskService.ts @@ -25,7 +25,8 @@ import '#/agent/contextMemory/conversationTime'; import { IAgentConversationUndoParticipantRegistry } from '#/agent/contextMemory/conversationUndoParticipants'; import { IEventDispatcher } from '#/state/eventDispatcher'; import type { ContextMessage, TaskOrigin } from '#/agent/contextMemory/types'; -import { IAgentContextInjectorService } from '#/agent/contextInjector/contextInjector'; +import { activateReminderWhenReady } from '#/features/reminder/internal/reminderActivation'; +import { IAgentLifecycleService } from '#/session/agentLifecycle/agentLifecycle'; import { IAgentLoopService } from '#/agent/loop/loop'; import { MessageStepRequest } from '#/agent/loop/stepRequest'; import { IAgentScopeContext } from '#/agent/scopeContext/scopeContext'; @@ -238,7 +239,7 @@ export class AgentTaskService extends Disposable implements IAgentTaskService { @ITaskService private readonly taskService: ITaskService, @IEventBus private readonly eventBus: IEventBus, @IEventDispatcher private readonly dispatcher: IEventDispatcher, - @IAgentContextInjectorService injector: IAgentContextInjectorService, + @IAgentLifecycleService agentLifecycle: IAgentLifecycleService, @IAgentLoopService private readonly loop: IAgentLoopService, @IAgentConversationUndoParticipantRegistry undoParticipants: IAgentConversationUndoParticipantRegistry, @@ -291,8 +292,10 @@ export class AgentTaskService extends Disposable implements IAgentTaskService { }), ); this._register( - injector.register(ACTIVE_BACKGROUND_TASK_INJECTION_VARIANT, () => - this.activeBackgroundTaskReminder(), + activateReminderWhenReady(agentLifecycle, this.scopeContext, (reminder) => + reminder.register(ACTIVE_BACKGROUND_TASK_INJECTION_VARIANT, () => + this.activeBackgroundTaskReminder(), + ), ), ); } diff --git a/packages/agent-core-v2/src/agent/toolDedupe/toolDedupeService.ts b/packages/agent-core-v2/src/agent/toolDedupe/toolDedupeService.ts index 3ed810324..756bdc044 100644 --- a/packages/agent-core-v2/src/agent/toolDedupe/toolDedupeService.ts +++ b/packages/agent-core-v2/src/agent/toolDedupe/toolDedupeService.ts @@ -17,7 +17,7 @@ import { IAgentLoopService } from '#/agent/loop/loop'; import { IAgentStateService } from '#/agent/state/agentState'; import { IEventBus } from '#/app/event/eventBus'; import { TurnEnded } from '#/agent/loop/turnOps'; -import { wrapSystemReminder } from '#/agent/systemReminder/systemReminder'; +import { wrapSystemReminder } from '#/features/reminder/systemReminder'; import { IAgentToolExecutorService, type ToolCallDupType } from '#/agent/toolExecutor/toolExecutor'; import type { ContentPart } from '#/kosong/contract/message'; import { IAgentToolDedupeService, type ToolDedupeResult } from './toolDedupe'; diff --git a/packages/agent-core-v2/src/agent/toolSelect/toolSelectAnnouncementsService.ts b/packages/agent-core-v2/src/agent/toolSelect/toolSelectAnnouncementsService.ts index 3c0c22fc7..390ad68ef 100644 --- a/packages/agent-core-v2/src/agent/toolSelect/toolSelectAnnouncementsService.ts +++ b/packages/agent-core-v2/src/agent/toolSelect/toolSelectAnnouncementsService.ts @@ -1,7 +1,9 @@ import { Service } from '#/_base/di/service'; import { LifecycleScope } from '#/app/scopes'; import { ScopeActivation, registerScopedService } from '#/_base/di/scope'; -import { IAgentContextInjectorService } from '#/agent/contextInjector/contextInjector'; +import { activateReminderWhenReady } from '#/features/reminder/internal/reminderActivation'; +import { IAgentScopeContext } from '#/agent/scopeContext/scopeContext'; +import { IAgentLifecycleService } from '#/session/agentLifecycle/agentLifecycle'; import { LOADABLE_TOOLS_VARIANT } from './dynamicTools'; import { IAgentToolSelectService } from './toolSelect'; @@ -12,12 +14,15 @@ export class AgentToolSelectAnnouncementsService extends Service implements IAge constructor( @IAgentToolSelectService toolSelect: IAgentToolSelectService, - @IAgentContextInjectorService injector: IAgentContextInjectorService, + @IAgentLifecycleService agentLifecycle: IAgentLifecycleService, + @IAgentScopeContext scopeContext: IAgentScopeContext, ) { super(); this._register( - injector.register(LOADABLE_TOOLS_VARIANT, ({ isNewTurn }) => - isNewTurn ? toolSelect.loadableToolsAnnouncement() : undefined, + activateReminderWhenReady(agentLifecycle, scopeContext, (reminder) => + reminder.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 a2567fd05..bf9cdf85b 100644 --- a/packages/agent-core-v2/src/agent/toolSelect/toolSelectSchemasService.ts +++ b/packages/agent-core-v2/src/agent/toolSelect/toolSelectSchemasService.ts @@ -1,7 +1,9 @@ import { Service } from '#/_base/di/service'; import { LifecycleScope } from '#/app/scopes'; import { ScopeActivation, registerScopedService } from '#/_base/di/scope'; -import { IAgentContextInjectorService } from '#/agent/contextInjector/contextInjector'; +import { activateReminderWhenReady } from '#/features/reminder/internal/reminderActivation'; +import { IAgentScopeContext } from '#/agent/scopeContext/scopeContext'; +import { IAgentLifecycleService } from '#/session/agentLifecycle/agentLifecycle'; import { DYNAMIC_TOOL_SCHEMA_VARIANT } from './dynamicTools'; import { IAgentToolSelectService } from './toolSelect'; @@ -12,15 +14,18 @@ export class AgentToolSelectSchemasService extends Service implements IAgentTool constructor( @IAgentToolSelectService toolSelect: IAgentToolSelectService, - @IAgentContextInjectorService injector: IAgentContextInjectorService, + @IAgentLifecycleService agentLifecycle: IAgentLifecycleService, + @IAgentScopeContext scopeContext: IAgentScopeContext, ) { super(); this._register( - injector.register(DYNAMIC_TOOL_SCHEMA_VARIANT, () => { - const tools = toolSelect.drainPendingToolSchemas(); - if (tools === undefined) return undefined; - return { message: { role: 'system', content: [], tools } }; - }), + activateReminderWhenReady(agentLifecycle, scopeContext, (reminder) => + reminder.register(DYNAMIC_TOOL_SCHEMA_VARIANT, () => { + const tools = toolSelect.drainPendingToolSchemas(); + if (tools === undefined) return undefined; + return { message: { role: 'system', content: [], tools } }; + }), + ), ); } } diff --git a/packages/agent-core-v2/src/features/btw/btwService.ts b/packages/agent-core-v2/src/features/btw/btwService.ts index 626ed3b40..d91901d1a 100644 --- a/packages/agent-core-v2/src/features/btw/btwService.ts +++ b/packages/agent-core-v2/src/features/btw/btwService.ts @@ -1,4 +1,4 @@ -import { IAgentSystemReminderService } from '#/agent/systemReminder/systemReminder'; +import { AgentReminder } from '#/features/reminder/reminderAgentRuntime'; import { IAgentToolApprovalService } from '#/agent/toolApproval/toolApproval'; import { denyToolExecution } from '#/agent/toolExecutor/beforeToolExecuteEvent'; import { IAgentToolExecutorService } from '#/agent/toolExecutor/toolExecutor'; @@ -22,12 +22,9 @@ export class SessionBtwService implements ISessionBtwService { } const childContext = await this.agentLifecycle.fork(main.accessor.get(IAgentScopeContext).agentContext); const child = this.agentLifecycle.handleOf(childContext.agentId)!; - child.accessor - .get(IAgentSystemReminderService) - ?.appendSystemReminder(SIDE_QUESTION_SYSTEM_REMINDER, { - kind: 'injection', - variant: 'btw', - }); + this.agentLifecycle + .resolve(childContext, AgentReminder) + .notify(SIDE_QUESTION_SYSTEM_REMINDER, { variant: 'btw' }); const reason = child.accessor.get(IAgentToolApprovalService)?.formatDenyMessage( TOOL_CALL_DISABLED_MESSAGE, diff --git a/packages/agent-core-v2/src/features/dateChange/dateChangeService.ts b/packages/agent-core-v2/src/features/dateChange/dateChangeService.ts index 933e3a51c..9c138eb98 100644 --- a/packages/agent-core-v2/src/features/dateChange/dateChangeService.ts +++ b/packages/agent-core-v2/src/features/dateChange/dateChangeService.ts @@ -1,12 +1,14 @@ import { Disposable } from '#/_base/di/lifecycle'; import { defineState } from '#/state/state'; -import { - IAgentContextInjectorService, - type ContextInjectionContext, - type ContextInjectionResult, -} from '#/agent/contextInjector/contextInjector'; +import { activateReminderWhenReady } from '#/features/reminder/internal/reminderActivation'; +import type { + ContextInjectionContext, + ContextInjectionResult, +} from '#/features/reminder/types'; import { pickDisclosureBaseline } from './disclosureBaseline'; import { IAgentProfileService } from '#/agent/profile/profile'; +import { IAgentScopeContext } from '#/agent/scopeContext/scopeContext'; +import { IAgentLifecycleService } from '#/session/agentLifecycle/agentLifecycle'; import { IAgentStateService } from '#/agent/state/agentState'; import { IHostClock } from '#/os/interface/hostClock'; import { ISessionContext } from '#/session/sessionContext/sessionContext'; @@ -24,7 +26,8 @@ export class AgentDateChangeService extends Disposable implements IAgentDateChan declare readonly _serviceBrand: undefined; constructor( - @IAgentContextInjectorService injector: IAgentContextInjectorService, + @IAgentLifecycleService agentLifecycle: IAgentLifecycleService, + @IAgentScopeContext scopeContext: IAgentScopeContext, @IAgentProfileService private readonly profile: IAgentProfileService, @IAgentStateService private readonly states: IAgentStateService, @IHostClock private readonly clock: IHostClock, @@ -33,9 +36,11 @@ export class AgentDateChangeService extends Disposable implements IAgentDateChan super(); this._register(this.states.contributeState(dateChangeSeedKey)); this._register( - injector.register( - DATE_CHANGE_INJECTION_VARIANT, - (ctx) => this.reminder(ctx), + activateReminderWhenReady(agentLifecycle, scopeContext, (reminder) => + reminder.register( + DATE_CHANGE_INJECTION_VARIANT, + (ctx) => this.reminder(ctx), + ), ), ); } diff --git a/packages/agent-core-v2/src/features/dynamic_workflow/agent/dynamicWorkflowService.ts b/packages/agent-core-v2/src/features/dynamic_workflow/agent/dynamicWorkflowService.ts index 0b408867b..d03c5dbf0 100644 --- a/packages/agent-core-v2/src/features/dynamic_workflow/agent/dynamicWorkflowService.ts +++ b/packages/agent-core-v2/src/features/dynamic_workflow/agent/dynamicWorkflowService.ts @@ -1,5 +1,6 @@ import { Service } from '#/_base/di/service'; -import { IInstantiationService } from '#/_base/di/instantiation'; +import { activateReminderWhenReady } from '#/features/reminder/internal/reminderActivation'; +import { IAgentLifecycleService } from '#/session/agentLifecycle/agentLifecycle'; import { IAgentContextMemoryService } from '#/agent/contextMemory/contextMemory'; import { TurnEnded } from '#/agent/loop/turnOps'; import { IAgentToolApprovalService } from '#/agent/toolApproval/toolApproval'; @@ -19,7 +20,7 @@ export class AgentDynamicWorkflowService extends Service implements IAgentDynami constructor( @IEventDispatcher private readonly dispatcher: IEventDispatcher, - @IInstantiationService instantiation: IInstantiationService, + @IAgentLifecycleService agentLifecycle: IAgentLifecycleService, @IEventBus eventBus: IEventBus, @IAgentContextMemoryService private readonly context: IAgentContextMemoryService, @IAgentToolApprovalService private readonly toolApproval: IAgentToolApprovalService, @@ -30,9 +31,13 @@ export class AgentDynamicWorkflowService extends Service implements IAgentDynami super(); this.agentState.contributeState(dynamicWorkflowKey); this._register( - instantiation.createInstance(DynamicWorkflowInjection, { - getTrigger: () => this.agentState.get(dynamicWorkflowKey), - }), + activateReminderWhenReady(agentLifecycle, this.agentCtx, (reminder) => + new DynamicWorkflowInjection( + { getTrigger: () => this.agentState.get(dynamicWorkflowKey) }, + reminder, + this.context, + ), + ), ); this._register( eventBus.subscribe(TurnEnded, () => { diff --git a/packages/agent-core-v2/src/features/dynamic_workflow/agent/injection/dynamicWorkflowInjection.ts b/packages/agent-core-v2/src/features/dynamic_workflow/agent/injection/dynamicWorkflowInjection.ts index e43490dff..906466c11 100644 --- a/packages/agent-core-v2/src/features/dynamic_workflow/agent/injection/dynamicWorkflowInjection.ts +++ b/packages/agent-core-v2/src/features/dynamic_workflow/agent/injection/dynamicWorkflowInjection.ts @@ -1,9 +1,9 @@ import { Disposable } from '#/_base/di/lifecycle'; -import { - IAgentContextInjectorService, - type ContextInjectionContext, - type ContextInjectionResult, -} from '#/agent/contextInjector/contextInjector'; +import type { ReminderRuntime } from '#/features/reminder/reminderAgentRuntime'; +import type { + ContextInjectionContext, + ContextInjectionResult, +} from '#/features/reminder/types'; import { IAgentContextMemoryService } from '#/agent/contextMemory/contextMemory'; import DYNAMIC_WORKFLOW_MODE_ENTER_REMINDER from '../enter-reminder.md?raw'; @@ -25,7 +25,7 @@ export interface DynamicWorkflowInjectionOptions { export class DynamicWorkflowInjection extends Disposable { constructor( private readonly options: DynamicWorkflowInjectionOptions, - @IAgentContextInjectorService injector: IAgentContextInjectorService, + injector: ReminderRuntime, @IAgentContextMemoryService private readonly context: IAgentContextMemoryService, ) { super(); diff --git a/packages/agent-core-v2/src/features/goal/goalAgentRuntime.ts b/packages/agent-core-v2/src/features/goal/goalAgentRuntime.ts index e41c6bec8..16e059a7c 100644 --- a/packages/agent-core-v2/src/features/goal/goalAgentRuntime.ts +++ b/packages/agent-core-v2/src/features/goal/goalAgentRuntime.ts @@ -5,7 +5,7 @@ import { assign, fromCallback, sendTo, setup, type Snapshot } from 'xstate'; import { MutableDisposable, type IDisposable } from '#/_base/di/lifecycle'; import { abortError } from '#/_base/utils/abort'; import { isPlainRecord } from '#/_base/utils/canonical-args'; -import { IAgentContextInjectorService } from '#/agent/contextInjector/contextInjector'; +import { AgentReminder } from '#/features/reminder/reminderAgentRuntime'; import { ContextAppendMessage } from '#/agent/contextMemory/contextEvents'; import type { ContextMessage, PromptOrigin } from '#/agent/contextMemory/types'; import { GoalInjection, GOAL_WAIT_FOR_GUIDANCE } from '#/features/goal/injection/goalInjection'; @@ -28,7 +28,6 @@ import { type AgentRuntimeContext, type AgentRuntimeRestoreEvent, } from '#/agent/runtime/agentRuntime'; -import { IAgentSystemReminderService } from '#/agent/systemReminder/systemReminder'; import { IAgentToolApprovalService } from '#/agent/toolApproval/toolApproval'; import { IAgentToolExecutorService } from '#/agent/toolExecutor/toolExecutor'; import type { BeforeToolExecuteEvent } from '#/agent/toolExecutor/toolHooks'; @@ -47,7 +46,7 @@ import { toPythinkerErrorPayload, type PythinkerErrorPayload, } from '#/errors'; -import { MAIN_AGENT_ID } from '#/session/agentLifecycle/agentLifecycle'; +import { IAgentLifecycleService, MAIN_AGENT_ID } from '#/session/agentLifecycle/agentLifecycle'; import { ISessionUsageService } from '#/session/usage/sessionUsage'; import type { ExecutableToolResult } from '#/tool/toolContract'; @@ -238,6 +237,10 @@ function goalOperationContext(runtime: AgentRuntimeContext): G return { runtime, effects: runtime.getLogicState().effects }; } +function reminderOf(runtime: AgentRuntimeContext) { + return runtime.get(IAgentLifecycleService).resolve(runtime.agent, AgentReminder); +} + export class GoalRuntime { constructor(private readonly runtime: AgentRuntimeContext) {} @@ -451,8 +454,7 @@ async function cancelGoal(context: GoalOperationContext, _input: GoalReasonInput } clearInternal(context, actor); if (actor === 'user') { - context.runtime.get(IAgentSystemReminderService).appendSystemReminder(GOAL_CANCELLED_REMINDER, { - kind: 'injection', + reminderOf(context.runtime).notify(GOAL_CANCELLED_REMINDER, { variant: 'goal_cancelled', }); } @@ -631,8 +633,7 @@ function stopAfterBudgetReached(context: GoalOperationContext, ctx: AfterStepCon hasStepBudgetRemaining(maxSteps, ctx.step) ) { context.effects.budgetGraceTurns.add(ctx.turnId); - context.runtime.get(IAgentSystemReminderService).appendSystemReminder(GOAL_BUDGET_STOP_REMINDER, { - kind: 'injection', + reminderOf(context.runtime).notify(GOAL_BUDGET_STOP_REMINDER, { variant: GOAL_BUDGET_STOP_REMINDER_NAME, }); return true; @@ -860,8 +861,7 @@ function normalizeAfterReplay(context: GoalOperationContext): void { function appendForkClearedReminder(context: GoalOperationContext): void { if (!context.runtime.getState().forkNotice.reminderPending) return; - context.runtime.get(IAgentSystemReminderService).appendSystemReminder(GOAL_FORK_CLEARED_REMINDER, { - kind: 'injection', + reminderOf(context.runtime).notify(GOAL_FORK_CLEARED_REMINDER, { variant: GOAL_FORK_CLEARED_REMINDER_NAME, }); } @@ -1247,7 +1247,7 @@ const goalEffects = fromCallback(({ }); const disposables: IDisposable[] = [deadline]; if (input.runtime.agent.agentId === MAIN_AGENT_ID) { - disposables.push(new GoalInjection(handlers.injection, input.runtime.get(IAgentContextInjectorService))); + disposables.push(new GoalInjection(handlers.injection, reminderOf(input.runtime))); disposables.push(input.runtime.get(IEventBus).subscribe(TurnStarted, handlers.turnStarted)); disposables.push(input.runtime.get(ISessionUsageService).onDidRecord(handlers.usageRecorded)); const loop = input.runtime.get(IAgentLoopService); diff --git a/packages/agent-core-v2/src/features/goal/injection/goalInjection.ts b/packages/agent-core-v2/src/features/goal/injection/goalInjection.ts index 25b38bf7f..2ded29fff 100644 --- a/packages/agent-core-v2/src/features/goal/injection/goalInjection.ts +++ b/packages/agent-core-v2/src/features/goal/injection/goalInjection.ts @@ -1,7 +1,7 @@ import type { GoalSnapshot } from '#/features/goal/types'; import { Service } from "#/_base/di/service"; import { renderPrompt } from "#/_base/utils/render-prompt"; -import { IAgentContextInjectorService } from '#/agent/contextInjector/contextInjector'; +import type { ReminderRuntime } from '#/features/reminder/reminderAgentRuntime'; import GOAL_ACTIVE_REMINDER from './goal-active-reminder.md?raw'; import GOAL_BLOCKED_REMINDER from './goal-blocked-reminder.md?raw'; import GOAL_PAUSED_REMINDER from './goal-paused-reminder.md?raw'; @@ -17,7 +17,7 @@ export const GOAL_WAIT_FOR_GUIDANCE = export class GoalInjection extends Service { constructor( private readonly options: GoalInjectionOptions, - @IAgentContextInjectorService injector: IAgentContextInjectorService, + injector: ReminderRuntime, ) { super(); this._register( diff --git a/packages/agent-core-v2/src/features/plan/injection/planModeInjection.ts b/packages/agent-core-v2/src/features/plan/injection/planModeInjection.ts index 941a342aa..6456a8e65 100644 --- a/packages/agent-core-v2/src/features/plan/injection/planModeInjection.ts +++ b/packages/agent-core-v2/src/features/plan/injection/planModeInjection.ts @@ -1,6 +1,6 @@ import { Service } from '#/_base/di/service'; import { defineState } from '#/state/state'; -import { IAgentContextInjectorService } from '#/agent/contextInjector/contextInjector'; +import type { ReminderRuntime } from '#/features/reminder/reminderAgentRuntime'; import { IAgentContextMemoryService } from '#/agent/contextMemory/contextMemory'; import type { ContextMessage } from '#/agent/contextMemory/types'; import { IAgentPlanService } from '#/features/plan/plan'; @@ -22,7 +22,7 @@ export const planWasActiveKey = defineState('plan.wasActive', () => fal export class PlanModeInjection extends Service { constructor( - @IAgentContextInjectorService injector: IAgentContextInjectorService, + injector: ReminderRuntime, @IAgentPlanService private readonly plan: IAgentPlanService, @IAgentContextMemoryService private readonly context: IAgentContextMemoryService, @IAgentStateService private readonly states: IAgentStateService, diff --git a/packages/agent-core-v2/src/features/plan/planService.ts b/packages/agent-core-v2/src/features/plan/planService.ts index 4f5f5fbbf..8c2173998 100644 --- a/packages/agent-core-v2/src/features/plan/planService.ts +++ b/packages/agent-core-v2/src/features/plan/planService.ts @@ -7,7 +7,8 @@ import { unwrapErrorCause } from '#/_base/errors/errors'; import { Error2, ErrorCodes } from '#/errors'; import { generateHeroSlug } from '#/_base/utils/hero-slug'; import { IAgentContextMemoryService } from '#/agent/contextMemory/contextMemory'; -import { IAgentContextInjectorService } from '#/agent/contextInjector/contextInjector'; +import { activateReminderWhenReady } from '#/features/reminder/internal/reminderActivation'; +import { IAgentLifecycleService } from '#/session/agentLifecycle/agentLifecycle'; import { IAgentPermissionModeService } from '#/agent/permissionMode/permissionMode'; import { PlanModeInjection } from '#/features/plan/injection/planModeInjection'; import { IAgentScopeContext } from '#/agent/scopeContext/scopeContext'; @@ -52,7 +53,7 @@ export class AgentPlanService extends Service implements IAgentPlanService { @IAgentContextMemoryService private readonly context: IAgentContextMemoryService, @IHostFileSystem private readonly hostFs: IHostFileSystem, @IBlobStore private readonly blobs: IBlobStore, - @IAgentContextInjectorService injector: IAgentContextInjectorService, + @IAgentLifecycleService agentLifecycle: IAgentLifecycleService, @IAgentTelemetryContextService private readonly telemetryContext: IAgentTelemetryContextService, @IEventBus eventBus: IEventBus, @IEventDispatcher private readonly dispatcher: IEventDispatcher, @@ -84,7 +85,11 @@ export class AgentPlanService extends Service implements IAgentPlanService { }), ); - this._register(new PlanModeInjection(injector, this, this.context, agentState)); + this._register( + activateReminderWhenReady(agentLifecycle, this.agentCtx, (reminder) => + new PlanModeInjection(reminder, this, this.context, agentState), + ), + ); this._register(this.registerPlanGuard(toolExecutor)); } diff --git a/packages/agent-core-v2/src/features/reminder/internal/reminderActivation.ts b/packages/agent-core-v2/src/features/reminder/internal/reminderActivation.ts new file mode 100644 index 000000000..94d91adeb --- /dev/null +++ b/packages/agent-core-v2/src/features/reminder/internal/reminderActivation.ts @@ -0,0 +1,24 @@ +import { toDisposable, type IDisposable } from '#/_base/di/lifecycle'; +import type { IAgentScopeContext } from '#/agent/scopeContext/scopeContext'; +import { AgentReminder, type ReminderRuntime } from '#/features/reminder/reminderAgentRuntime'; +import type { IAgentLifecycleService } from '#/session/agentLifecycle/agentLifecycle'; + +export function activateReminderWhenReady( + lifecycle: IAgentLifecycleService, + scope: IAgentScopeContext, + activate: (runtime: ReminderRuntime) => IDisposable, +): IDisposable { + let active: IDisposable | undefined; + const tryActivate = (): void => { + if (active !== undefined || lifecycle.handleOf(scope.agentId) === undefined) return; + active = activate(lifecycle.resolve(scope.agentContext, AgentReminder)); + }; + const created = lifecycle.onDidCreateScope(({ context }) => { + if (context === scope.agentContext) tryActivate(); + }); + tryActivate(); + return toDisposable(() => { + created.dispose(); + active?.dispose(); + }); +} diff --git a/packages/agent-core-v2/src/features/reminder/reminderAgentRuntime.ts b/packages/agent-core-v2/src/features/reminder/reminderAgentRuntime.ts new file mode 100644 index 000000000..2bf6df2ef --- /dev/null +++ b/packages/agent-core-v2/src/features/reminder/reminderAgentRuntime.ts @@ -0,0 +1,317 @@ +import { fromCallback, setup } from 'xstate'; + +import { toDisposable, type IDisposable } from '#/_base/di/lifecycle'; +import { ILogService } from '#/_base/log/log'; +import { IAgentContextMemoryService } from '#/agent/contextMemory/contextMemory'; +import { isCompactionSummaryMessage } from '#/agent/contextMemory/compactionHandoff'; +import { ContextSpliced } from '#/agent/contextMemory/contextEvents'; +import type { ContextMessage } from '#/agent/contextMemory/types'; +import { IAgentLoopService, type BeforeStepContext } from '#/agent/loop/loop'; +import { + defineAgentRuntimeContract, + defineAgentRuntimeProvider, + type AgentRuntimeContext, + type AgentRuntimeRestoreEvent, +} from '#/agent/runtime/agentRuntime'; +import { IEventBus } from '#/app/event/eventBus'; + +import { wrapSystemReminder } from './systemReminder'; +import type { + ContextInjectionContent, + ContextInjectionContext, + ContextInjectionMessage, + ContextInjectionProvider, + ContextInjectionResult, + ReminderNotification, + ReminderRegistration, +} from './types'; + +interface ReminderEntry { + readonly provider: ContextInjectionProvider; + readonly variant: string; +} + +const REMINDER_VARIANT_PRIORITY = new Map([['date_change', -1]]); + +interface ReminderActorContext { + readonly entries: Set; + readonly runtime: AgentRuntimeContext; +} + +interface ReminderRegisterEvent { + readonly type: 'reminder.register'; + readonly entry: ReminderEntry; +} + +interface ReminderUnregisterEvent { + readonly type: 'reminder.unregister'; + readonly entry: ReminderEntry; +} + +type ReminderActorEvent = AgentRuntimeRestoreEvent | ReminderRegisterEvent | ReminderUnregisterEvent; + +function actorContext(runtime: AgentRuntimeContext): ReminderActorContext { + return runtime.getLogicState(); +} + +function appendReminder( + runtime: AgentRuntimeContext, + content: string, + notification: ReminderNotification, +): void { + runtime.get(IAgentContextMemoryService).append({ + role: 'user', + content: [{ type: 'text', text: wrapSystemReminder(content) }], + toolCalls: [], + origin: { + kind: 'injection', + variant: notification.variant, + ownerPromptId: notification.ownerPromptId, + }, + }); +} + +function providerContext( + runtime: AgentRuntimeContext, + entry: ReminderEntry, + isNewTurn: boolean, +): ContextInjectionContext { + const history = runtime.get(IAgentContextMemoryService).get(); + const injectedPositions = findInjections(history, entry.variant); + const lastInjectedAt = injectedPositions.at(-1) ?? null; + const lastInjection = lastInjectedAt === null ? undefined : history[lastInjectedAt]; + return { + injectedPositions, + lastInjectedAt, + lastInjection, + lastDisclosure: + lastInjection?.origin?.kind === 'injection' + ? lastInjection.origin.disclosure + : undefined, + isNewTurn, + }; +} + +async function injectEntry( + runtime: AgentRuntimeContext, + entry: ReminderEntry, + isNewTurn: boolean, +): Promise { + let content: Awaited>; + try { + content = await entry.provider(providerContext(runtime, entry, isNewTurn)); + } catch (error) { + runtime.get(ILogService).error('context provider failed; skipping it', { + name: entry.variant, + error, + }); + return; + } + if (!actorContext(runtime).entries.has(entry)) return; + appendResult(runtime, entry, content); +} + +function appendResult( + runtime: AgentRuntimeContext, + entry: ReminderEntry, + content: ContextInjectionContent | ContextInjectionResult | undefined, +): void { + if (content === undefined) return; + const result: ContextInjectionResult = isInjectionResult(content) + ? content + : { content }; + const origin = { + kind: 'injection' as const, + variant: entry.variant, + disclosure: result.disclosure, + }; + const resolved = result.content; + if (typeof resolved === 'string') { + if (resolved.trim().length === 0) return; + runtime.get(IAgentContextMemoryService).append({ + role: 'user', + content: [{ type: 'text', text: wrapSystemReminder(resolved) }], + toolCalls: [], + origin, + }); + return; + } + if (isRawInjectionMessage(resolved)) { + const message = resolved.message; + if (message.content.length === 0 && (message.tools === undefined || message.tools.length === 0)) { + return; + } + runtime.get(IAgentContextMemoryService).append({ + role: message.role, + content: [...message.content], + toolCalls: [], + tools: message.tools, + origin, + }); + return; + } + if (resolved.length === 0) return; + runtime.get(IAgentContextMemoryService).append({ + role: 'user', + content: [...resolved], + toolCalls: [], + origin, + }); +} + +async function inject(runtime: AgentRuntimeContext, isNewTurn: boolean): Promise { + const entries = [...actorContext(runtime).entries].sort( + (left, right) => + (REMINDER_VARIANT_PRIORITY.get(left.variant) ?? 0) - + (REMINDER_VARIANT_PRIORITY.get(right.variant) ?? 0), + ); + for (const entry of entries) await injectEntry(runtime, entry, isNewTurn); +} + +const reminderEffects = fromCallback(({ input }: { input: { readonly runtime: AgentRuntimeContext } }) => { + let compactionRearmPending = false; + const loop = input.runtime.get(IAgentLoopService); + const takeCompactionRearm = (): boolean => { + const pending = compactionRearmPending; + compactionRearmPending = false; + return pending; + }; + const reconcileAroundStep = async ( + context: BeforeStepContext, + next: (context?: BeforeStepContext) => Promise, + ): Promise => { + const rearmed = takeCompactionRearm(); + await inject(input.runtime, context.firstStepOfTurn || rearmed); + await next(); + if (takeCompactionRearm()) await inject(input.runtime, true); + }; + let hook: IDisposable; + try { + hook = loop.hooks.onWillBeginStep.register('context-injector', reconcileAroundStep, { + before: 'full-compaction', + }); + } catch { + hook = loop.hooks.onWillBeginStep.register('context-injector', reconcileAroundStep); + } + const splice = input.runtime.get(IEventBus).subscribe(ContextSpliced, (event) => { + if (isCompactionSplice(event)) compactionRearmPending = true; + }); + return () => { + splice.dispose(); + hook.dispose(); + actorContext(input.runtime).entries.clear(); + }; +}); + +const reminderActorLogic = setup({ + types: {} as { + context: ReminderActorContext; + input: AgentRuntimeContext; + events: ReminderActorEvent; + }, + actors: { reminderEffects }, +}).createMachine({ + context: ({ input }) => ({ entries: new Set(), runtime: input }), + initial: 'beforeRestore', + states: { + beforeRestore: { + on: { 'runtime.restore': 'active' }, + }, + active: { + invoke: { + src: 'reminderEffects', + input: ({ context }) => ({ runtime: context.runtime }), + }, + }, + }, + on: { + 'reminder.register': { + actions: ({ context, event }) => { context.entries.add(event.entry); }, + }, + 'reminder.unregister': { + actions: ({ context, event }) => { context.entries.delete(event.entry); }, + }, + }, +}); + +export class ReminderRuntime { + private readonly log: ILogService; + + constructor(private readonly runtime: AgentRuntimeContext) { + this.log = runtime.get(ILogService); + } + + register(variant: string, provider: ContextInjectionProvider): ReminderRegistration { + const entry: ReminderEntry = { + provider: provider as ContextInjectionProvider, + variant, + }; + this.runtime.send({ type: 'reminder.register', entry }); + return toDisposable(() => { + try { + this.runtime.send({ type: 'reminder.unregister', entry }); + } catch (error) { + this.log.debug('reminder unregister skipped after runtime disposal', { + error, + variant, + }); + } + }); + } + + notify(content: string, notification: ReminderNotification): void { + appendReminder(this.runtime, content, notification); + } + + async reconcileWhenIdle(variant: string): Promise { + const loop = this.runtime.get(IAgentLoopService); + const quiescence = loop.tryAcquireQuiescence(); + if (quiescence === undefined) return; + try { + for (const entry of actorContext(this.runtime).entries) { + if (entry.variant === variant) await injectEntry(this.runtime, entry, false); + } + } finally { + quiescence.dispose(); + } + } +} + +export const AgentReminder = defineAgentRuntimeContract('reminder'); + +export const reminderAgentRuntimeProvider = defineAgentRuntimeProvider( + AgentReminder, + { + id: 'reminder', + logic: reminderActorLogic, + eager: true, + createApi: (context) => new ReminderRuntime(context), + }, +); + +function isCompactionSplice(splice: { + readonly deleteCount: number; + readonly messages: readonly ContextMessage[]; +}): boolean { + return splice.deleteCount > 0 && splice.messages.some(isCompactionSummaryMessage); +} + +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): number[] { + const positions: number[] = []; + history.forEach((message, index) => { + if (message.origin?.kind === 'injection' && message.origin.variant === variant) positions.push(index); + }); + return positions; +} diff --git a/packages/agent-core-v2/src/features/reminder/reminderFeature.ts b/packages/agent-core-v2/src/features/reminder/reminderFeature.ts new file mode 100644 index 000000000..71b65f412 --- /dev/null +++ b/packages/agent-core-v2/src/features/reminder/reminderFeature.ts @@ -0,0 +1,15 @@ +import { Feature } from '#/features/feature'; +import { registerFeature } from '#/features/featureRegistry'; + +import { reminderAgentRuntimeProvider } from './reminderAgentRuntime'; + +export class ReminderFeature extends Feature { + static override readonly name = 'reminder'; + + constructor() { + super(); + this.contributeAgentRuntime(reminderAgentRuntimeProvider); + } +} + +registerFeature(ReminderFeature); diff --git a/packages/agent-core-v2/src/agent/systemReminder/systemReminder.ts b/packages/agent-core-v2/src/features/reminder/systemReminder.ts similarity index 60% rename from packages/agent-core-v2/src/agent/systemReminder/systemReminder.ts rename to packages/agent-core-v2/src/features/reminder/systemReminder.ts index 0baf08593..2272a9962 100644 --- a/packages/agent-core-v2/src/agent/systemReminder/systemReminder.ts +++ b/packages/agent-core-v2/src/features/reminder/systemReminder.ts @@ -1,6 +1,4 @@ -import { createDecorator } from "#/_base/di/instantiation"; - -import type { ContextMessage, PromptOrigin } from '#/agent/contextMemory/types'; +import type { ContextMessage } from '#/agent/contextMemory/types'; const SYSTEM_REMINDER_PREFIX = '\n'; const SYSTEM_REMINDER_SUFFIX = '\n'; @@ -16,11 +14,3 @@ export function systemReminderContent(message: ContextMessage): string | undefin } return text.slice(SYSTEM_REMINDER_PREFIX.length, text.length - SYSTEM_REMINDER_SUFFIX.length); } - -export interface IAgentSystemReminderService { - readonly _serviceBrand: undefined; - - appendSystemReminder(content: string, origin: PromptOrigin): ContextMessage; -} - -export const IAgentSystemReminderService = createDecorator('agentSystemReminderService'); diff --git a/packages/agent-core-v2/src/agent/contextInjector/contextInjector.ts b/packages/agent-core-v2/src/features/reminder/types.ts similarity index 62% rename from packages/agent-core-v2/src/agent/contextInjector/contextInjector.ts rename to packages/agent-core-v2/src/features/reminder/types.ts index a7ed9d678..0bb66679f 100644 --- a/packages/agent-core-v2/src/agent/contextInjector/contextInjector.ts +++ b/packages/agent-core-v2/src/features/reminder/types.ts @@ -1,8 +1,7 @@ -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 { IDisposable } from '#/_base/di/lifecycle'; import type { ContextMessage } from '#/agent/contextMemory/types'; +import type { ContentPart } from '#/kosong/contract/message'; +import type { Tool } from '#/kosong/contract/tool'; export interface ContextInjectionContext { readonly injectedPositions: readonly number[]; @@ -36,17 +35,9 @@ export type ContextInjectionProvider = ( | undefined | Promise | undefined>; -export interface IAgentContextInjectorService { - readonly _serviceBrand: undefined; +export interface ReminderRegistration extends IDisposable {} - register( - name: string, - provider: ContextInjectionProvider, - ): IDisposable; - - reconcileWhenIdle(name: string): Promise; +export interface ReminderNotification { + readonly variant: string; + readonly ownerPromptId?: string; } - -export const IAgentContextInjectorService = createDecorator( - 'agentContextInjectorService', -); diff --git a/packages/agent-core-v2/src/features/sessionInit/sessionInitService.ts b/packages/agent-core-v2/src/features/sessionInit/sessionInitService.ts index 767028a21..e714c64b2 100644 --- a/packages/agent-core-v2/src/features/sessionInit/sessionInitService.ts +++ b/packages/agent-core-v2/src/features/sessionInit/sessionInitService.ts @@ -7,7 +7,7 @@ import { loadAgentsMdDetailed } from '#/agent/profile/context'; import { IAgentAgentsMdReminderService } from '#/agent/agentsMdReminder/agentsMdReminder'; import { IAgentPermissionModeService } from '#/agent/permissionMode/permissionMode'; import { agentContextOf } from '#/agent/scopeContext/scopeContext'; -import { IAgentSystemReminderService } from '#/agent/systemReminder/systemReminder'; +import { AgentReminder } from '#/features/reminder/reminderAgentRuntime'; import { IEventDispatcher } from '#/state/eventDispatcher'; import { ErrorCodes, Error2 } from '#/errors'; import { IAgentLifecycleService, MAIN_AGENT_ID } from '#/session/agentLifecycle/agentLifecycle'; @@ -93,12 +93,9 @@ export class SessionInitService implements ISessionInitService { main.accessor .get(IAgentAgentsMdReminderService) .seedInjected(agentsMdPaths, this.sessionContext.cwd); - main.accessor - .get(IAgentSystemReminderService) - .appendSystemReminder(initCompletionReminder(agentsMd), { - kind: 'injection', - variant: 'init', - }); + this.agentLifecycle + .resolve(agentContextOf(main), AgentReminder) + .notify(initCompletionReminder(agentsMd), { variant: 'init' }); await main.accessor.get(IEventDispatcher).flush(); } catch (error) { if (isUserCancellation(error) || isAbortError(error)) { diff --git a/packages/agent-core-v2/src/features/todo/todoAgentRuntime.ts b/packages/agent-core-v2/src/features/todo/todoAgentRuntime.ts index 9272e26be..7bd950967 100644 --- a/packages/agent-core-v2/src/features/todo/todoAgentRuntime.ts +++ b/packages/agent-core-v2/src/features/todo/todoAgentRuntime.ts @@ -6,14 +6,14 @@ import { type AgentRuntimeContext, type AgentRuntimeRestoreEvent, } from '#/agent/runtime/agentRuntime'; -import { IAgentContextInjectorService } from '#/agent/contextInjector/contextInjector'; import { IAgentContextMemoryService } from '#/agent/contextMemory/contextMemory'; +import { AgentReminder } from '#/features/reminder/reminderAgentRuntime'; import { IAgentToolPolicyService } from '#/agent/toolPolicy/toolPolicy'; import { TODO_LIST_TOOL_NAME, readTodoItems, type TodoItem } from './todoItem'; import { TODO_LIST_REMINDER_VARIANT, todoListStaleReminder } from './todoListReminder'; import { ToolsUpdateStore, type TodoState } from './todoOps'; -import { MAIN_AGENT_ID } from '#/session/agentLifecycle/agentLifecycle'; +import { IAgentLifecycleService, MAIN_AGENT_ID } from '#/session/agentLifecycle/agentLifecycle'; import '#/agent/contextMemory/conversationTime'; @@ -42,7 +42,9 @@ const todoReminder = fromCallback(({ }; }) => { if (input.runtime.agent.agentId !== MAIN_AGENT_ID) return; - const injector = input.runtime.get(IAgentContextInjectorService); + const injector = input.runtime + .get(IAgentLifecycleService) + .resolve(input.runtime.agent, AgentReminder); const memory = input.runtime.get(IAgentContextMemoryService); const toolPolicy = input.runtime.get(IAgentToolPolicyService); const registration = injector.register(TODO_LIST_REMINDER_VARIANT, () => diff --git a/packages/agent-core-v2/src/features/tower/injection/towerModeInjection.ts b/packages/agent-core-v2/src/features/tower/injection/towerModeInjection.ts index 83d875a4f..c89bf855d 100644 --- a/packages/agent-core-v2/src/features/tower/injection/towerModeInjection.ts +++ b/packages/agent-core-v2/src/features/tower/injection/towerModeInjection.ts @@ -1,5 +1,5 @@ import { Service } from '#/_base/di/service'; -import { IAgentContextInjectorService } from '#/agent/contextInjector/contextInjector'; +import type { ReminderRuntime } from '#/features/reminder/reminderAgentRuntime'; import { IAgentContextMemoryService } from '#/agent/contextMemory/contextMemory'; import type { ContextMessage } from '#/agent/contextMemory/types'; import { IFlagService } from '#/app/flag/flag'; @@ -15,7 +15,7 @@ const TOWER_MODE_EXIT_DISCLOSURE = 'exit'; export class TowerModeInjection extends Service { constructor( - @IAgentContextInjectorService injector: IAgentContextInjectorService, + injector: ReminderRuntime, @IAgentTowerService private readonly tower: IAgentTowerService, @IAgentContextMemoryService private readonly context: IAgentContextMemoryService, @IFlagService private readonly flags: IFlagService, diff --git a/packages/agent-core-v2/src/features/tower/towerService.ts b/packages/agent-core-v2/src/features/tower/towerService.ts index 8afbfd966..b864475eb 100644 --- a/packages/agent-core-v2/src/features/tower/towerService.ts +++ b/packages/agent-core-v2/src/features/tower/towerService.ts @@ -2,7 +2,8 @@ import { join } from 'node:path'; import { Disposable } from '#/_base/di/lifecycle'; import { ScopeActivation, registerScopedService } from '#/_base/di/scope'; -import { IAgentContextInjectorService } from '#/agent/contextInjector/contextInjector'; +import { activateReminderWhenReady } from '#/features/reminder/internal/reminderActivation'; +import { IAgentLifecycleService } from '#/session/agentLifecycle/agentLifecycle'; import { IAgentContextMemoryService } from '#/agent/contextMemory/contextMemory'; import { IAgentProfileService } from '#/agent/profile/profile'; import { IAgentScopeContext } from '#/agent/scopeContext/scopeContext'; @@ -50,7 +51,7 @@ export class AgentTowerService extends Disposable implements IAgentTowerService @ISessionManager private readonly sessions: ISessionManager, @IFeatureManager featureManager: IFeatureManager, @IConfigService config: IConfigService, - @IAgentContextInjectorService injector: IAgentContextInjectorService, + @IAgentLifecycleService agentLifecycle: IAgentLifecycleService, @IAgentContextMemoryService context: IAgentContextMemoryService, @IEventBus eventBus: IEventBus, ) { @@ -92,7 +93,11 @@ export class AgentTowerService extends Disposable implements IAgentTowerService ); }), ); - this._register(new TowerModeInjection(injector, this, context, this.flags)); + this._register( + activateReminderWhenReady(agentLifecycle, this.agentCtx, (reminder) => + new TowerModeInjection(reminder, this, context, this.flags), + ), + ); this._register( toolExecutor.onBeforeExecuteTool((event) => { if (this.flags.enabled(TOWER_FLAG_ID)) return; diff --git a/packages/agent-core-v2/src/index.ts b/packages/agent-core-v2/src/index.ts index 89f5ea9d8..bc8df4700 100644 --- a/packages/agent-core-v2/src/index.ts +++ b/packages/agent-core-v2/src/index.ts @@ -338,6 +338,7 @@ export * from '#/features/plan/configSection'; export * from '#/features/plan/plan'; export * from '#/features/plan/planOps'; export * from '#/features/plan/planService'; +import '#/features/dateChange/dateChangeFeature'; import '#/features/plan/planFeature'; export * from '#/features/externalHooks/configSection'; export * from '#/features/externalHooks/app/externalHooksRunner'; @@ -637,11 +638,12 @@ export * from '#/agent/contextMemory/loopEventFold'; export * from '#/agent/contextMemory/messageId'; export * from '#/agent/contextMemory/contextTranscript'; export * from '#/agent/contextMemory/types'; -export * from '#/agent/systemReminder/systemReminder'; -export * from '#/agent/systemReminder/systemReminderService'; +export { AgentReminder, ReminderRuntime } from '#/features/reminder/reminderAgentRuntime'; +export * from '#/features/reminder/systemReminder'; +export * from '#/features/reminder/types'; +import '#/features/reminder/reminderFeature'; export * from '#/features/dateChange/dateChange'; export * from '#/features/dateChange/dateChangeService'; -import '#/features/dateChange/dateChangeFeature'; export * from '#/agent/contextProjector/contextProjector'; export * from '#/agent/contextProjector/contextProjectorService'; export * from '#/agent/contextProjector/mediaProjection'; @@ -651,8 +653,6 @@ export * from '#/session/tokenCounting/sessionTokenCounting'; export * from '#/session/tokenCounting/tokenCountingAgentModel'; export * from '#/session/tokenCounting/sessionTokenCountingService'; import '#/features/tokenCounting/tokenCountingFeature'; -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'; diff --git a/packages/agent-core-v2/src/session/advisor/advisorService.ts b/packages/agent-core-v2/src/session/advisor/advisorService.ts index 1bfe10389..86da3dbd1 100644 --- a/packages/agent-core-v2/src/session/advisor/advisorService.ts +++ b/packages/agent-core-v2/src/session/advisor/advisorService.ts @@ -3,16 +3,15 @@ import type { IAgentScopeHandle } from '#/_base/di/scope'; import { LifecycleScope } from '#/app/scopes'; import { ScopeActivation, registerScopedService } from '#/_base/di/scope'; import { ILogService } from '#/_base/log/log'; -import { - IAgentContextInjectorService, - type ContextInjectionContent, -} from '#/agent/contextInjector/contextInjector'; +import type { AgentContext } from '#/agent/agentContext/agentContext'; import { IAgentContextMemoryService } from '#/agent/contextMemory/contextMemory'; import { IAgentContextProjectorService } from '#/agent/contextProjector/contextProjector'; import { TurnEnded, TurnPrompt } from '#/agent/loop/turnOps'; import { IAgentProfileService } from '#/agent/profile/profile'; import { IConfigService } from '#/app/config/config'; import { IEventBus } from '#/app/event/eventBus'; +import { AgentReminder } from '#/features/reminder/reminderAgentRuntime'; +import type { ContextInjectionContent } from '#/features/reminder/types'; import { extractText, createUserMessage } from '#/kosong/contract/message'; import { IModelCatalog } from '#/kosong/model/catalog'; import { IModelService, type ModelRecord } from '#/kosong/model/model'; @@ -87,8 +86,8 @@ export class SessionAdvisorService extends Disposable implements ISessionAdvisor @ILogService private readonly log: Pick, ) { super(); - this._register(this.agents.onDidCreateScope(({ handle }) => { - this.bindMain(handle); + this._register(this.agents.onDidCreateScope(({ context, handle }) => { + this.bindMain(handle, context); })); this._register(this.agents.onDidClose((agent) => { if (agent.agentId === MAIN_AGENT_ID) this.disposeMainBindings(); @@ -99,10 +98,11 @@ export class SessionAdvisorService extends Disposable implements ISessionAdvisor this.disposeMainBindings(); })); const main = this.agents.handleOf(MAIN_AGENT_ID); - if (main !== undefined) this.bindMain(main); + const mainContext = this.agents.get(MAIN_AGENT_ID); + if (main !== undefined && mainContext !== undefined) this.bindMain(main, mainContext); } - private bindMain(handle: IAgentScopeHandle): void { + private bindMain(handle: IAgentScopeHandle, context: AgentContext): void { if (handle.id !== MAIN_AGENT_ID) return; this.disposeMainBindings(); const bindings = new DisposableStore(); @@ -115,7 +115,7 @@ export class SessionAdvisorService extends Disposable implements ISessionAdvisor if (userTurn && event.reason === 'completed') this.startReview(handle); })); bindings.add( - handle.accessor.get(IAgentContextInjectorService).register( + this.agents.resolve(context, AgentReminder).register( ADVISOR_INJECTION, ({ isNewTurn }) => this.takeAdvisory(isNewTurn), ), diff --git a/packages/agent-core-v2/src/session/agentLifecycle/agentLifecycleService.ts b/packages/agent-core-v2/src/session/agentLifecycle/agentLifecycleService.ts index be90c46eb..95842611c 100644 --- a/packages/agent-core-v2/src/session/agentLifecycle/agentLifecycleService.ts +++ b/packages/agent-core-v2/src/session/agentLifecycle/agentLifecycleService.ts @@ -222,6 +222,7 @@ export class AgentLifecycleService extends Disposable implements IAgentLifecycle eventBus?.activateAgent(agent); let managed: ManagedAgent | undefined; let didCreate = false; + let finalizerArmed = false; try { const handle = createScopedChildHandle( this.instantiation, @@ -237,13 +238,17 @@ export class AgentLifecycleService extends Disposable implements IAgentLifecycle }], ], configureContainer: (container) => { + container.anchorKernelFinalizer(() => { + eventBus?.deactivateAgent(agent); + }, 'agent-event-bus-deactivate'); + finalizerArmed = true; this.adopt({ id: agentId, kind: LifecycleScope.Agent, accessor: { get: (id) => container.invokeFunction((accessor) => accessor.get(id)), }, - dispose: () => { container.dispose(); }, + dispose: () => container.disposeAsync(), }); managed = this.roster.get(agentId); }, @@ -274,10 +279,10 @@ export class AgentLifecycleService extends Disposable implements IAgentLifecycle await managed.runtimeSet.close().catch(() => undefined); managed.killSpace(); try { - managed.handle.dispose(); + await managed.handle.dispose(); } catch { } } - eventBus?.deactivateAgent(agent); + if (!finalizerArmed) eventBus?.deactivateAgent(agent); if (didCreate) this.onDidCloseEmitter.fire(agent); throw error; } @@ -446,10 +451,7 @@ export class AgentLifecycleService extends Disposable implements IAgentLifecycle await Promise.all([loop.settled(), compactionSettled, prompt.drain(reason)]); await managed.runtimeSet.close(); managed.killSpace(); - handle.dispose(); - this.instantiation.invokeFunction((accessor) => - (accessor.get(ISessionEventBus) as ISessionEventBus | undefined)?.deactivateAgent(agent), - ); + await handle.dispose(); if (this.roster.get(agent.agentId) === managed) this.roster.delete(agent.agentId); this.onDidCloseEmitter.fire(agent); } diff --git a/packages/agent-core-v2/src/workspace/sessionLifecycle/sessionLifecycleService.ts b/packages/agent-core-v2/src/workspace/sessionLifecycle/sessionLifecycleService.ts index 0f90357e1..b5ed990d8 100644 --- a/packages/agent-core-v2/src/workspace/sessionLifecycle/sessionLifecycleService.ts +++ b/packages/agent-core-v2/src/workspace/sessionLifecycle/sessionLifecycleService.ts @@ -208,7 +208,7 @@ export class SessionLifecycleService extends Disposable implements ISessionLifec const sessionDir = handle.accessor.get(ISessionContext).sessionDir; this.sessions.delete(sessionId); await this.drainAgents(handle).catch(() => {}); - handle.dispose(); + void handle.dispose(); await this.hostFs.remove(sessionDir).catch(() => {}); throw error; } @@ -282,7 +282,7 @@ export class SessionLifecycleService extends Disposable implements ISessionLifec this.pluginAgentProfileLoader.ready, ]); } catch (error) { - handle.dispose(); + void handle.dispose(); void this.explicitAgentProfileLoader.reload().catch(() => undefined); throw error; } @@ -364,7 +364,7 @@ export class SessionLifecycleService extends Disposable implements ISessionLifec await this.announceCreated({ sessionId, handle, source: 'resume' }); } catch (error) { this.sessions.delete(sessionId); - handle.dispose(); + void handle.dispose(); throw error; } return handle; @@ -387,7 +387,7 @@ export class SessionLifecycleService extends Disposable implements ISessionLifec await this.appendLogStore.drainRetirements(); await drainSessionMetadataWrites(); await this.indexMirror.drain(); - handle.dispose(); + void handle.dispose(); await drainLogCloses(); this._onDidCloseSession.fire({ sessionId }); } @@ -408,7 +408,7 @@ export class SessionLifecycleService extends Disposable implements ISessionLifec this.sessions.delete(sessionId); await drainSessionMetadataWrites(); await this.indexMirror.drain(); - handle.dispose(); + void handle.dispose(); await drainLogCloses(); this._onDidArchiveSession.fire({ sessionId }); } @@ -589,7 +589,7 @@ export class SessionLifecycleService extends Disposable implements ISessionLifec } if (target !== undefined) { try { - target.dispose(); + void target.dispose(); } catch { } } diff --git a/packages/agent-core-v2/test/_base/di/child.test.ts b/packages/agent-core-v2/test/_base/di/child.test.ts index efdb838eb..5a13c7689 100644 --- a/packages/agent-core-v2/test/_base/di/child.test.ts +++ b/packages/agent-core-v2/test/_base/di/child.test.ts @@ -214,6 +214,64 @@ describe('InstantiationService.createChild', () => { expect(events).toEqual(['disposed']); }); + it('repeated disposeAsync returns the in-flight teardown promise', async () => { + const events: string[] = []; + let releaseGate!: () => void; + const ix = new InstantiationService(new ServiceCollection()); + ix.anchorKernelEntry(() => { + events.push('finalizer'); + }, 'finalizer'); + ix.anchorKernelEntry(() => { + events.push('gate-entered'); + return new Promise((resolve) => { + releaseGate = resolve; + }); + }, 'gate'); + + const first = ix.disposeAsync(); + const second = ix.disposeAsync(); + let secondSettled = false; + void second.then(() => { + secondSettled = true; + }); + await new Promise((resolve) => setTimeout(resolve, 10)); + expect(events).toEqual(['gate-entered']); + expect(secondSettled).toBe(false); + releaseGate(); + await Promise.all([first, second]); + expect(events).toEqual(['gate-entered', 'finalizer']); + }); + + it('disposeAsync awaits asynchronous child container teardown', async () => { + const events: string[] = []; + let releaseChildGate!: () => void; + const parent = new InstantiationService(new ServiceCollection()); + const child = parent.createChild(new ServiceCollection()) as InstantiationService; + child.anchorKernelEntry(() => { + events.push('child-finalizer'); + }, 'child-finalizer'); + child.anchorKernelEntry(() => { + events.push('child-gate-entered'); + return new Promise((resolve) => { + releaseChildGate = resolve; + }); + }, 'child-gate'); + parent.anchorKernelEntry(() => { + events.push('parent-finalizer'); + }, 'parent-finalizer'); + + let settled = false; + const disposal = parent.disposeAsync().then(() => { + settled = true; + }); + await new Promise((resolve) => setTimeout(resolve, 10)); + expect(events).toEqual(['child-gate-entered', 'parent-finalizer']); + expect(settled).toBe(false); + releaseChildGate(); + await disposal; + expect(events).toEqual(['child-gate-entered', 'parent-finalizer', 'child-finalizer']); + }); + it('parent dispose propagates to children', () => { const events: string[] = []; interface IParentSvc { 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 5bdeb12a4..0eab62c79 100644 --- a/packages/agent-core-v2/test/agent/agentsMdReminder/agentsMdReminder.test.ts +++ b/packages/agent-core-v2/test/agent/agentsMdReminder/agentsMdReminder.test.ts @@ -45,8 +45,9 @@ 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 { IAgentSystemReminderService } from '#/agent/systemReminder/systemReminder'; import type { PromptOrigin } from '#/agent/contextMemory/types'; +import { IAgentLifecycleService } from '#/session/agentLifecycle/agentLifecycle'; +import { createReminderStub, lifecycleWithReminder } from '../../features/reminder/stubs'; import { OrderedHookSlot } from '#/hooks'; import { IEventDispatcher } from '#/state/eventDispatcher'; import type { ToolDidExecuteContext } from '#/agent/toolExecutor/toolHooks'; @@ -122,12 +123,6 @@ function createHarness( }); reg.define(IAgentToolRegistryService, AgentToolRegistryService); reg.define(IAgentToolExecutorService, AgentToolExecutorService); - reg.defineInstance(IAgentScopeContext, { - _serviceBrand: undefined, - agentId: 'main', - agentContext: stubAgentContext('main', 0), - scope: (sub?: string): string => (sub ? `agents/main/${sub}` : 'agents/main'), - } satisfies IAgentScopeContext); reg.definePartialInstance(IFileSystemStorageService, { write: async () => {}, }); @@ -136,6 +131,12 @@ function createHarness( } else { reg.defineInstance(IAgentToolExecutorService, events.executor); } + reg.defineInstance(IAgentScopeContext, { + _serviceBrand: undefined, + agentId: 'main', + agentContext: stubAgentContext('main', 0), + scope: (sub?: string): string => (sub ? `agents/main/${sub}` : 'agents/main'), + } satisfies IAgentScopeContext); const dispatcher: IEventDispatcher = { _serviceBrand: undefined, hooks: { onDidRestore: new OrderedHookSlot() }, @@ -152,13 +153,14 @@ function createHarness( agentsMdPaths: options.restoredProfile?.agentsMdPaths, }); reg.defineInstance(IAgentStateService, agentState); - reg.defineInstance(IAgentSystemReminderService, { - _serviceBrand: undefined, - appendSystemReminder: (content: string, origin: PromptOrigin) => { - reminders.push({ content, origin }); - return { role: 'user', content: [], toolCalls: [], origin }; - }, - } satisfies IAgentSystemReminderService); + reg.defineInstance( + IAgentLifecycleService, + lifecycleWithReminder(createReminderStub({ + notify: (content, notification) => { + reminders.push({ content, origin: { kind: 'injection', ...notification } }); + }, + })), + ); reg.defineInstance(ISessionContext, { _serviceBrand: undefined, sessionId: 'session-1', 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 922bf2ea5..451c3ed4f 100644 --- a/packages/agent-core-v2/test/agent/fullCompaction/fullCompaction.test.ts +++ b/packages/agent-core-v2/test/agent/fullCompaction/fullCompaction.test.ts @@ -3340,6 +3340,7 @@ describe('goal reminder re-injection after full compaction', () => { provider: CATALOGUED_PROVIDER, modelCapabilities: CATALOGUED_MODEL_CAPABILITIES, }); + await ctx.restoreRuntimes(); await ctx.resolve(AgentGoal).createGoal({ objective: GOAL_OBJECTIVE }); ctx.appendExchange(1, 'old user one', 'old assistant one', 100); ctx.appendExchange(2, 'recent user two', 'recent assistant two', 950_000); 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 47f24cf04..5aafbe5a4 100644 --- a/packages/agent-core-v2/test/agent/permissionMode/permissionMode.test.ts +++ b/packages/agent-core-v2/test/agent/permissionMode/permissionMode.test.ts @@ -3,10 +3,10 @@ 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 { - IAgentContextInjectorService, - type ContextInjectionProvider, -} from '#/agent/contextInjector/contextInjector'; +import type { ReminderRuntime } from '#/features/reminder/reminderAgentRuntime'; +import type { ContextInjectionProvider } from '#/features/reminder/types'; +import { IAgentLifecycleService } from '#/session/agentLifecycle/agentLifecycle'; +import { lifecycleWithReminder } from '../../features/reminder/stubs'; import { IAgentPermissionModeService } from '#/agent/permissionMode/permissionMode'; import { PermissionModeInjection } from '#/agent/permissionMode/injection/permissionModeInjection'; import { AgentPermissionModeService } from '#/agent/permissionMode/permissionModeService'; @@ -38,9 +38,8 @@ let registeredInjection: } | undefined; -const injectorStub: IAgentContextInjectorService = { - _serviceBrand: undefined, - register: (name, provider) => { +const injectorStub: ReminderRuntime = { + register: (name: string, provider: ContextInjectionProvider) => { registeredInjection = { name, provider: provider as ContextInjectionProvider }; return { dispose: () => { @@ -48,8 +47,9 @@ const injectorStub: IAgentContextInjectorService = { }, }; }, + notify: () => {}, reconcileWhenIdle: async () => {}, -}; +} as unknown as ReminderRuntime; let disposables: DisposableStore; let ix: TestInstantiationService; @@ -65,7 +65,7 @@ beforeEach(() => { ix = disposables.add(new TestInstantiationService()); ix.stub(IFileSystemStorageService, new InMemoryStorageService()); ix.set(IAppendLogStore, new SyncDescriptor(AppendLogStore)); - ix.stub(IAgentContextInjectorService, injectorStub); + ix.stub(IAgentLifecycleService, lifecycleWithReminder(injectorStub)); ix.set(IAgentStateService, new AgentStateService()); ix.set(IAgentPermissionModeService, new SyncDescriptor(AgentPermissionModeService)); log = ix.get(IAppendLogStore); @@ -190,16 +190,16 @@ describe('AgentPermissionModeService (wire-backed)', () => { svc.setMode('auto'); let restoredProvider: ContextInjectionProvider | undefined; - const ix2 = disposables.add(new TestInstantiationService()); - ix2.stub(IAgentContextInjectorService, { - _serviceBrand: undefined, - register: (_name, provider) => { - restoredProvider = provider as ContextInjectionProvider; + const states = new AgentStateService(); + const reminder = { + register: (_name: string, provider: ContextInjectionProvider) => { + restoredProvider = provider; return { dispose: () => {} }; }, - }); - ix2.set(IAgentStateService, new AgentStateService()); - disposables.add(ix2.createInstance(PermissionModeInjection, svc)); + notify: () => {}, + reconcileWhenIdle: async () => {}, + } as unknown as ReminderRuntime; + disposables.add(new PermissionModeInjection(svc, reminder, states)); if (restoredProvider === undefined) throw new Error('expected restored provider'); const run = () => 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 ab35a9a00..f3da4deb8 100644 --- a/packages/agent-core-v2/test/agent/plugin/agentPlugin.test.ts +++ b/packages/agent-core-v2/test/agent/plugin/agentPlugin.test.ts @@ -47,6 +47,7 @@ function messageText(message: { readonly content: readonly { readonly type: stri } async function runInjectionBoundary(ctx: TestAgentContext): Promise { + await ctx.restoreRuntimes(); await ctx.get(IAgentLoopService).hooks.onWillBeginStep.run({ turnId: 0, step: 1, @@ -294,6 +295,7 @@ describe('AgentPluginService plugin session-start wiring', () => { agentService(IAgentPluginService, new SyncDescriptor(AgentPluginService)), ); ctx.get(IAgentPluginService); + await ctx.restoreRuntimes(); ctx.mockNextResponse({ type: 'text', text: 'first answer' }); await ctx.rpc.prompt({ input: [{ type: 'text', text: 'first prompt' }] }); 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 e860f57a9..4f6e04e8c 100644 --- a/packages/agent-core-v2/test/agent/prompt/promptService.test.ts +++ b/packages/agent-core-v2/test/agent/prompt/promptService.test.ts @@ -14,8 +14,9 @@ import { IAgentLoopService } from '#/agent/loop/loop'; import { IAgentPromptService } from '#/agent/prompt/prompt'; import { AgentPromptService, PromptQueued, PromptSteered } from '#/agent/prompt/promptService'; import { IAgentScopeContext, makeAgentScopeContext } from '#/agent/scopeContext/scopeContext'; -import { IAgentSystemReminderService } from '#/agent/systemReminder/systemReminder'; -import { AgentSystemReminderService } from '#/agent/systemReminder/systemReminderService'; +import { wrapSystemReminder } from '#/features/reminder/systemReminder'; +import { IAgentLifecycleService } from '#/session/agentLifecycle/agentLifecycle'; +import { createReminderStub, lifecycleWithReminder } from '../../features/reminder/stubs'; import { IAgentToolExecutorService } from '#/agent/toolExecutor/toolExecutor'; import { IAgentToolPolicyService } from '#/agent/toolPolicy/toolPolicy'; import { IEventBus, ISessionEventBus } from '#/app/event/eventBus'; @@ -61,6 +62,16 @@ function harness(loopOptions: StubLoopOptions = { pendingTurnResult: true }) { const disposables = new DisposableStore(); onTestFinished(() => disposables.dispose()); const context = stubContextMemory(); + const reminder = createReminderStub({ + notify: (content, notification) => { + context.append({ + role: 'user', + content: [{ type: 'text', text: wrapSystemReminder(content) }], + toolCalls: [], + origin: { kind: 'injection', ...notification }, + }); + }, + }); const loop = stubLoopWithHooks(loopOptions); const fullCompaction = { _serviceBrand: undefined, @@ -94,7 +105,7 @@ function harness(loopOptions: StubLoopOptions = { pendingTurnResult: true }) { reg.definePartialInstance(IAgentToolPolicyService, { setSessionDisabledTools: async () => {} }); reg.defineInstance(IAgentFullCompactionService, fullCompaction); reg.define(IEventBus, EventBusService); - reg.define(IAgentSystemReminderService, AgentSystemReminderService); + reg.defineInstance(IAgentLifecycleService, lifecycleWithReminder(reminder)); reg.define(IAgentPromptService, AgentPromptService); reg.definePartialInstance(ITelemetryService, { track: () => {}, track2: () => {} }); reg.definePartialInstance(ISessionMetadata, { 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 69ed10e2b..e687f3ded 100644 --- a/packages/agent-core-v2/test/agent/task/taskService.test.ts +++ b/packages/agent-core-v2/test/agent/task/taskService.test.ts @@ -7,11 +7,12 @@ import { DisposableStore, toDisposable } from '#/_base/di/lifecycle'; import { ILogService } from '#/_base/log/log'; import { TestInstantiationService } from '#/_base/di/test'; import { IAgentConversationUndoParticipantRegistry } from '#/agent/contextMemory/conversationUndoParticipants'; -import { - IAgentContextInjectorService, - type ContextInjectionContext, - type ContextInjectionProvider, -} from '#/agent/contextInjector/contextInjector'; +import type { + ContextInjectionContext, + ContextInjectionProvider, +} from '#/features/reminder/types'; +import { IAgentLifecycleService } from '#/session/agentLifecycle/agentLifecycle'; +import { createReminderStub, lifecycleWithReminder } from '../../features/reminder/stubs'; import { IAgentTaskService, type AgentTask, @@ -112,14 +113,17 @@ describe('AgentTaskService', () => { list: () => [], }); ix.stub(IWireService, stubWireService()); - ix.stub(IAgentContextInjectorService, { - register: (name, provider) => { - injectionProviders.set(name, provider as ContextInjectionProvider); - return toDisposable(() => { - injectionProviders.delete(name); - }); - }, - }); + ix.stub( + IAgentLifecycleService, + lifecycleWithReminder(createReminderStub({ + register: (name, provider) => { + injectionProviders.set(name, provider as ContextInjectionProvider); + return toDisposable(() => { + injectionProviders.delete(name); + }); + }, + })), + ); ix.stub(ITaskService, { run: () => { throw new Error('ITaskService.run is not used by this test'); @@ -697,9 +701,10 @@ describe('AgentTaskService', () => { list: () => [], }); ix.stub(IWireService, stubWireService()); - ix.stub(IAgentContextInjectorService, { - register: () => toDisposable(() => {}), - }); + ix.stub( + IAgentLifecycleService, + lifecycleWithReminder(createReminderStub()), + ); ix.stub(ITaskService, { run: () => { throw new Error('ITaskService.run is not used by this test'); @@ -753,9 +758,10 @@ describe('AgentTaskService', () => { register: () => toDisposable(() => {}), list: () => [], }); - ix.stub(IAgentContextInjectorService, { - register: () => toDisposable(() => {}), - }); + ix.stub( + IAgentLifecycleService, + lifecycleWithReminder(createReminderStub()), + ); ix.stub(ITaskService, { run: () => { throw new Error('ITaskService.run is not used by this test'); 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 47cb58c00..735ab6a28 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 @@ -97,6 +97,8 @@ describe('progressive tool disclosure end-to-end', () => { ctx.get(IAgentToolSelectSchemasService); ctx.get(IAgentToolExecutorService); ctx.configure({ modelCapabilities: DISCLOSURE_CAPABILITIES }); + await ctx.restorePersisted(); + await ctx.restoreRuntimes(); await ctx.rpc.setPermission({ mode: 'yolo' }); alpha = new StubMcpTool(MCP_ALPHA); registration = ctx.get(IAgentToolRegistryService).register(alpha, { source: 'mcp' }); 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 81ce9602b..80b1a631f 100644 --- a/packages/agent-core-v2/test/agent/toolSelect/toolSelectService.test.ts +++ b/packages/agent-core-v2/test/agent/toolSelect/toolSelectService.test.ts @@ -13,8 +13,8 @@ import { ContextSpliced } from '#/agent/contextMemory/contextEvents'; 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 { IAgentLifecycleService } from '#/session/agentLifecycle/agentLifecycle'; +import { createReminderHarness, lifecycleWithReminder } from '../../features/reminder/stubs'; import { CompactionCompleted } from '#/agent/fullCompaction/compactionOps'; import { IAgentLoopService, @@ -30,8 +30,6 @@ import type { StepRequest } from '#/agent/loop/stepRequest'; import { IAgentProfileService } from '#/agent/profile/profile'; import { IAgentToolPolicyService } from '#/agent/toolPolicy/toolPolicy'; import { IAgentScopeContext, makeAgentScopeContext } from '#/agent/scopeContext/scopeContext'; -import { IAgentSystemReminderService } from '#/agent/systemReminder/systemReminder'; -import { AgentSystemReminderService } from '#/agent/systemReminder/systemReminderService'; import type { ExecutableTool, ToolDisclosure, @@ -312,6 +310,10 @@ function registerSharedServices( reg.defineInstance(IEventBus, eventBus); reg.defineInstance(IAgentLoopService, loop); reg.defineInstance(IAgentContextMemoryService, contextMemory); + reg.defineInstance( + IAgentScopeContext, + makeAgentScopeContext({ agentId: 'main', agentScope: 'agents/main', generation: 1 }), + ); reg.definePartialInstance(IAgentProfileService, { getModelCapabilities: () => capabilities, }); @@ -330,12 +332,14 @@ function registerSharedServices( eventBus.publish(event); }, } as unknown as IEventDispatcher); - reg.define(IAgentContextInjectorService, AgentContextInjectorService); + reg.defineInstance( + IAgentLifecycleService, + lifecycleWithReminder(createReminderHarness(loop, contextMemory, eventBus)), + ); reg.define(IAgentToolRegistryService, AgentToolRegistryService); reg.define(IAgentToolSelectService, AgentToolSelectService); reg.define(IAgentToolSelectAnnouncementsService, AgentToolSelectAnnouncementsService); reg.define(IAgentToolSelectSchemasService, AgentToolSelectSchemasService); - reg.define(IAgentSystemReminderService, AgentSystemReminderService); registerLogServices(reg); } diff --git a/packages/agent-core-v2/test/app/config/config.test.ts b/packages/agent-core-v2/test/app/config/config.test.ts index d3b0661b7..a97959712 100644 --- a/packages/agent-core-v2/test/app/config/config.test.ts +++ b/packages/agent-core-v2/test/app/config/config.test.ts @@ -292,6 +292,7 @@ describe('Agent config', () => { }); it('keeps turn-start config for later steps and applies updates to the next turn', async () => { + await ctx.restoreRuntimes(); const lookupCall: ToolCall = { type: 'function', id: 'call_lookup', diff --git a/packages/agent-core-v2/test/features/btw/btw.test.ts b/packages/agent-core-v2/test/features/btw/btw.test.ts index e50e34850..10551e824 100644 --- a/packages/agent-core-v2/test/features/btw/btw.test.ts +++ b/packages/agent-core-v2/test/features/btw/btw.test.ts @@ -4,7 +4,6 @@ import { SyncDescriptor } from '#/_base/di/descriptors'; import { DisposableStore } from '#/_base/di/lifecycle'; import { IAgentScopeContext } from '#/agent/scopeContext/scopeContext'; import { TestInstantiationService } from '#/_base/di/test'; -import { IAgentSystemReminderService } from '#/agent/systemReminder/systemReminder'; import { IAgentToolApprovalService } from '#/agent/toolApproval/toolApproval'; import { IAgentToolExecutorService } from '#/agent/toolExecutor/toolExecutor'; import { @@ -38,7 +37,6 @@ describe('SessionBtwService', () => { id: 'agent-btw-1', accessor: { get: (id: unknown) => { - if (id === IAgentSystemReminderService) return { appendSystemReminder: appendReminder }; if (id === IAgentToolApprovalService) return { formatDenyMessage }; if (id === IAgentToolExecutorService) return executorEvents.executor; return undefined; @@ -65,6 +63,7 @@ describe('SessionBtwService', () => { ix.stub(IAgentLifecycleService, { _serviceBrand: undefined, fork, + resolve: () => ({ notify: appendReminder }), handleOf: (id: string) => { if (id === 'main') return main; if (id === 'agent-btw-1') return child; @@ -82,7 +81,6 @@ describe('SessionBtwService', () => { expect(id).toBe('agent-btw-1'); expect(fork).toHaveBeenCalledWith(expect.objectContaining({ agentId: 'main', generation: 1 })); expect(appendReminder).toHaveBeenCalledWith(SIDE_QUESTION_SYSTEM_REMINDER, { - kind: 'injection', variant: 'btw', }); }); diff --git a/packages/agent-core-v2/test/features/dateChange/dateChangeInjection.test.ts b/packages/agent-core-v2/test/features/dateChange/dateChangeInjection.test.ts index beb50d278..6b24f7417 100644 --- a/packages/agent-core-v2/test/features/dateChange/dateChangeInjection.test.ts +++ b/packages/agent-core-v2/test/features/dateChange/dateChangeInjection.test.ts @@ -4,7 +4,6 @@ import { join } from 'pathe'; import { afterEach, beforeEach, describe, expect, it } from 'vitest'; -import { FiberState } from '#/_base/di/fiber'; import { IAgentContextMemoryService } from '#/agent/contextMemory/contextMemory'; import type { ContextMessage } from '#/agent/contextMemory/types'; import { IAgentLoopService } from '#/agent/loop/loop'; @@ -14,9 +13,6 @@ import { DEFAULT_AGENT_PROFILE_NAME, type EnvironmentDisclosureSnapshot, } from '#/app/agentProfileCatalog/agentProfileCatalog'; -import { IFeatureManager } from '#/app/feature/featureManager'; -import { IAgentDateChangeService } from '#/features/dateChange/dateChange'; -import { DateChangeFeature } from '#/features/dateChange/dateChangeFeature'; import { dateChangeSeedKey } from '#/features/dateChange/dateChangeService'; import { IHostClock } from '#/os/interface/hostClock'; import { ISessionContext } from '#/session/sessionContext/sessionContext'; @@ -109,12 +105,13 @@ describe('AgentDateChangeService', () => { let loop: IAgentLoopService; let profile: IAgentProfileService; - beforeEach(() => { + beforeEach(async () => { clock = testHostClock(INITIAL_INSTANT); ctx = createTestAgent(appService(IHostClock, clock)); context = ctx.get(IAgentContextMemoryService); loop = ctx.get(IAgentLoopService); profile = ctx.get(IAgentProfileService); + await ctx.restoreRuntimes(); }); afterEach(async () => { @@ -222,6 +219,7 @@ describe('AgentDateChangeService', () => { context = ctx.get(IAgentContextMemoryService); loop = ctx.get(IAgentLoopService); await ctx.restorePersisted(); + await ctx.restoreRuntimes(); await runWillBeginStepHooks(loop); @@ -255,6 +253,7 @@ describe('AgentDateChangeService', () => { context = ctx.get(IAgentContextMemoryService); loop = ctx.get(IAgentLoopService); await ctx.restorePersisted(); + await ctx.restoreRuntimes(); await runWillBeginStepHooks(loop); const initial = dateReminders(context); @@ -276,6 +275,7 @@ describe('AgentDateChangeService', () => { context = ctx.get(IAgentContextMemoryService); loop = ctx.get(IAgentLoopService); profile = ctx.get(IAgentProfileService); + await ctx.restorePersisted(); await profile.bind({ profile: DEFAULT_AGENT_PROFILE_NAME, model: 'mock-model' }); @@ -466,41 +466,19 @@ describe('AgentDateChangeService', () => { expect(dateReminders(context)).toHaveLength(0); }); - it('withdraws and restores the eager service, provider, and seed with the Feature', async () => { - const manager = ctx.get(IFeatureManager); + it('keeps one provider registration across repeated runtime restore', async () => { const states = ctx.get(IAgentStateService); updateSystemPromptWithoutDate(profile, ctx.get(ISessionContext).cwd); - expect(manager.units().find((unit) => unit.name === 'dateChange')?.state).toBe( - FiberState.Active, - ); - expect(ctx.get(IAgentDateChangeService)).toBeDefined(); expect(states.has(dateChangeSeedKey)).toBe(true); await runWillBeginStepHooks(loop); - expect(states.get(dateChangeSeedKey)).toMatchObject({ localDate: '2026-07-29' }); - - await manager.unprovideUnit('dateChange'); - expect(() => ctx.get(IAgentDateChangeService)).toThrow(); - expect(states.has(dateChangeSeedKey)).toBe(false); - - clock.set('2026-07-30T04:00:00.000Z'); - await runWillBeginStepHooks(loop); expect(dateReminders(context)).toHaveLength(1); - manager.provideUnit(DateChangeFeature); - expect(ctx.get(IAgentDateChangeService)).toBeDefined(); - expect(states.has(dateChangeSeedKey)).toBe(true); - expect(states.get(dateChangeSeedKey)).toBeUndefined(); - + await ctx.restoreRuntimes(); + await ctx.restoreRuntimes(); + clock.set('2026-07-30T04:00:00.000Z'); await runWillBeginStepHooks(loop); - const restored = dateReminders(context); - expect(restored).toHaveLength(2); - expect(messageText(restored[1] as ContextMessage)).toContain('2026-07-30'); - clock.set('2026-07-31T04:00:00.000Z'); - await runWillBeginStepHooks(loop); - const reminders = dateReminders(context); - expect(reminders).toHaveLength(3); - expect(messageText(reminders[2] as ContextMessage)).toContain('2026-07-31'); + expect(dateReminders(context)).toHaveLength(2); }); }); diff --git a/packages/agent-core-v2/test/features/dynamic_workflow/dynamic_workflow.test.ts b/packages/agent-core-v2/test/features/dynamic_workflow/dynamic_workflow.test.ts index e1c987862..db948d05f 100644 --- a/packages/agent-core-v2/test/features/dynamic_workflow/dynamic_workflow.test.ts +++ b/packages/agent-core-v2/test/features/dynamic_workflow/dynamic_workflow.test.ts @@ -4,7 +4,7 @@ import { makeAgentScopeContext } from '#/agent/scopeContext/scopeContext'; import { type IAgentScopeHandle } from '#/_base/di/scope'; import { LifecycleScope } from '#/app/scopes'; import { SyncDescriptor } from '#/_base/di/descriptors'; -import { DisposableStore } from '#/_base/di/lifecycle'; +import { DisposableStore, toDisposable } from '#/_base/di/lifecycle'; import { TestInstantiationService } from '#/_base/di/test'; import { Event } from '#/_base/event'; import { ILogService } from '#/_base/log/log'; @@ -13,9 +13,9 @@ import { IModelCatalog, type Model } from '#/kosong/model/catalog'; import { stubLog } from '../../_base/log/stubs'; import { stubFlag } from '../../app/flag/stubs'; import type { IFlagService } from '#/app/flag/flag'; -import { IAgentContextInjectorService } from '#/agent/contextInjector/contextInjector'; -import { AgentContextInjectorService } from '#/agent/contextInjector/contextInjectorService'; import { IAgentContextMemoryService } from '#/agent/contextMemory/contextMemory'; +import type { ContextInjectionProvider, ContextInjectionResult } from '#/features/reminder/types'; +import { createReminderStub, lifecycleWithReminder } from '../reminder/stubs'; import { AgentContextMemoryService } from '#/agent/contextMemory/contextMemoryService'; import type { ContextMessage } from '#/agent/contextMemory/types'; import { DEFAULT_DYNAMIC_WORKFLOW_TIMEOUT_MS, DYNAMIC_WORKFLOW_SECTION } from '#/features/dynamic_workflow/configSection'; @@ -24,11 +24,7 @@ import { ISessionDynamicWorkflowService, type SessionDynamicWorkflowRunResult, t import { IAgentStateService } from '#/agent/state/agentState'; import { AgentStateService } from '#/agent/state/agentStateService'; import { ISessionTokenCountingService } from '#/session/tokenCounting/sessionTokenCounting'; -import { - IAgentSystemReminderService, - wrapSystemReminder, -} from '#/agent/systemReminder/systemReminder'; -import { AgentSystemReminderService } from '#/agent/systemReminder/systemReminderService'; +import { wrapSystemReminder } from '#/features/reminder/systemReminder'; import { IAgentDynamicWorkflowService } from '#/features/dynamic_workflow/agent/dynamic_workflow'; import { AgentDynamicWorkflowService } from '#/features/dynamic_workflow/agent/dynamicWorkflowService'; import DYNAMIC_WORKFLOW_MODE_ENTER_REMINDER from '../../../src/features/dynamic_workflow/agent/enter-reminder.md?raw'; @@ -303,11 +299,51 @@ describe('AgentDynamicWorkflowService', () => { ix.set(IAgentContextMemoryService, new SyncDescriptor(AgentContextMemoryService)); ix.stub(IFileSystemStorageService, new InMemoryStorageService()); ix.set(IAppendLogStore, new SyncDescriptor(AppendLogStore)); - ix.stub(IAgentLoopService, stubLoopWithHooks()); + const loop = stubLoopWithHooks(); + ix.stub(IAgentLoopService, loop); ix.set(IAgentStateService, new AgentStateService()); - ix.set(IAgentContextInjectorService, new SyncDescriptor(AgentContextInjectorService)); ix.set(IAgentToolRegistryService, new SyncDescriptor(AgentToolRegistryService)); - ix.stub(IAgentLifecycleService, {}); + let provider: ContextInjectionProvider | undefined; + const reminder = createReminderStub({ + register: (_variant, value) => { + provider = value as ContextInjectionProvider; + return toDisposable(() => { provider = undefined; }); + }, + }); + ix.stub(IAgentLifecycleService, lifecycleWithReminder(reminder)); + loop.hooks.onWillBeginStep.register('test-reminder', async ({ firstStepOfTurn }, next) => { + const context = ix.get(IAgentContextMemoryService); + const history = context.get(); + const positions = history.flatMap((message, index) => + message.origin?.kind === 'injection' && message.origin.variant === 'dynamic_workflow_mode' ? [index] : [], + ); + const lastInjectedAt = positions.at(-1) ?? null; + const lastInjection = lastInjectedAt === null ? undefined : history[lastInjectedAt]; + const value = await provider?.({ + injectedPositions: positions, + lastInjectedAt, + lastInjection, + lastDisclosure: lastInjection?.origin?.kind === 'injection' + ? lastInjection.origin.disclosure + : undefined, + isNewTurn: firstStepOfTurn, + }); + if (value !== undefined) { + const result: ContextInjectionResult = + typeof value === 'object' && !Array.isArray(value) && 'content' in value + ? value + : { content: value }; + if (typeof result.content === 'string') { + context.append({ + role: 'user', + content: [{ type: 'text', text: wrapSystemReminder(result.content) }], + toolCalls: [], + origin: { kind: 'injection', variant: 'dynamic_workflow_mode', disclosure: result.disclosure }, + }); + } + } + await next(); + }); ix.stub(ISessionDynamicWorkflowService, { getDynamicWorkflowItem: async () => undefined, run: async () => [], @@ -323,7 +359,6 @@ describe('AgentDynamicWorkflowService', () => { eventBus: ix.get(IEventBus), }); registerTestEventDispatcher(ix); - ix.set(IAgentSystemReminderService, new SyncDescriptor(AgentSystemReminderService)); ix.set(IAgentDynamicWorkflowService, new SyncDescriptor(AgentDynamicWorkflowService)); }); afterEach(() => disposables.dispose()); @@ -595,6 +630,8 @@ describe('dynamic_workflow context reconciliation', () => { it('renders the corrective exit again when undo removes the latest exit render', async () => { const ctx = createTestAgent(); try { + await ctx.restorePersisted(); + await ctx.restoreRuntimes(); const dynamic_workflow = ctx.get(IAgentDynamicWorkflowService); dynamic_workflow.enter('manual'); ctx.mockNextResponse({ type: 'text', text: 'first answer' }); diff --git a/packages/agent-core-v2/test/features/goal/goalFeature.test.ts b/packages/agent-core-v2/test/features/goal/goalFeature.test.ts index 1509fcd30..d34a5823e 100644 --- a/packages/agent-core-v2/test/features/goal/goalFeature.test.ts +++ b/packages/agent-core-v2/test/features/goal/goalFeature.test.ts @@ -6,12 +6,10 @@ import { registerScopedService, } from '#/_base/di/scope'; import { createScopedTestHost, stubPair } from '#/_base/di/test'; -import { IAgentContextInjectorService } from '#/agent/contextInjector/contextInjector'; import { IAgentLoopService } from '#/agent/loop/loop'; import { IAgentPermissionModeService } from '#/agent/permissionMode/permissionMode'; import { IAgentScopeContext } from '#/agent/scopeContext/scopeContext'; import { IAgentStateService } from '#/agent/state/agentState'; -import { IAgentSystemReminderService } from '#/agent/systemReminder/systemReminder'; import { IAgentToolApprovalService } from '#/agent/toolApproval/toolApproval'; import { IAgentToolExecutorService } from '#/agent/toolExecutor/toolExecutor'; import { IAgentToolPolicyService } from '#/agent/toolPolicy/toolPolicy'; diff --git a/packages/agent-core-v2/test/features/goal/goalOps.test.ts b/packages/agent-core-v2/test/features/goal/goalOps.test.ts index 8c7adad76..9827506a7 100644 --- a/packages/agent-core-v2/test/features/goal/goalOps.test.ts +++ b/packages/agent-core-v2/test/features/goal/goalOps.test.ts @@ -9,14 +9,14 @@ import { IEventBus, ISessionEventBus } from '#/app/event/eventBus'; import { EventBusService } from '#/app/event/eventBusService'; import { IConfigService } from '#/app/config/config'; import type { AgentRuntimeSet } from '#/agent/runtime/agentRuntimeSet'; -import { IAgentContextInjectorService } from '#/agent/contextInjector/contextInjector'; import { IAgentContextMemoryService } from '#/agent/contextMemory/contextMemory'; +import { IAgentLifecycleService } from '#/session/agentLifecycle/agentLifecycle'; +import { createReminderStub, lifecycleWithReminder } from '../reminder/stubs'; import { AgentGoal, type GoalRuntime } from '#/features/goal/goalAgentRuntime'; import { IGoalDeadlineScheduler } from '#/features/goal/goalDeadlineScheduler'; import { GoalDeadlineSchedulerService } from '#/features/goal/goalDeadlineSchedulerService'; import { IAgentLoopService } from '#/agent/loop/loop'; import { IAgentScopeContext } from '#/agent/scopeContext/scopeContext'; -import { IAgentSystemReminderService } from '#/agent/systemReminder/systemReminder'; import { IAgentToolExecutorService } from '#/agent/toolExecutor/toolExecutor'; import { ISessionUsageService } from '#/session/usage/sessionUsage'; import { ITelemetryService } from '#/app/telemetry/telemetry'; @@ -62,20 +62,6 @@ function createContextStub(): IAgentContextMemoryService { } as unknown as IAgentContextMemoryService; } -function createInjectorStub(): IAgentContextInjectorService { - return { - _serviceBrand: undefined, - register: () => noopDisposable(), - } as unknown as IAgentContextInjectorService; -} - -function createSystemReminderStub(): IAgentSystemReminderService { - return { - _serviceBrand: undefined, - appendSystemReminder: () => ({}), - } as unknown as IAgentSystemReminderService; -} - function createTelemetryStub(): ITelemetryService { return { _serviceBrand: undefined, @@ -141,8 +127,10 @@ function buildHost(key: string): GoalHost { onDidRecord: Event.None, } as unknown as ISessionUsageService); ix.stub(IAgentContextMemoryService, createContextStub()); - ix.stub(IAgentContextInjectorService, createInjectorStub()); - ix.stub(IAgentSystemReminderService, createSystemReminderStub()); + ix.stub( + IAgentLifecycleService, + lifecycleWithReminder(createReminderStub()), + ); ix.stub(ITelemetryService, createTelemetryStub()); ix.stub(IAgentToolExecutorService, createToolExecutorStub()); ix.stub(IConfigService, createConfigStub()); diff --git a/packages/agent-core-v2/test/features/goal/injection/goalInjection.test.ts b/packages/agent-core-v2/test/features/goal/injection/goalInjection.test.ts index af54e0c3c..470eab601 100644 --- a/packages/agent-core-v2/test/features/goal/injection/goalInjection.test.ts +++ b/packages/agent-core-v2/test/features/goal/injection/goalInjection.test.ts @@ -1,8 +1,9 @@ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; import type { ToolCall } from '#/kosong/contract/message'; -import { IAgentContextInjectorService } from '#/agent/contextInjector/contextInjector'; import { IAgentContextMemoryService } from '#/agent/contextMemory/contextMemory'; +import { IAgentLoopService } from '#/agent/loop/loop'; +import { runWillBeginStepHooks, type StubLoop } from '../../../agent/loop/stubs'; import { AgentGoal, type GoalRuntime } from '#/features/goal/goalAgentRuntime'; import { IAgentProfileService } from '#/agent/profile/profile'; import { IAgentDynamicWorkflowService } from '#/features/dynamic_workflow/agent/dynamic_workflow'; @@ -16,15 +17,9 @@ import { import { stubAgentDynamicWorkflow } from '../stubs'; type GoalServiceTestManager = GoalRuntime; -type InjectableContextInjector = IAgentContextInjectorService & { - inject(isNewTurn: boolean): Promise; -}; -async function injectDynamic( - injector: InjectableContextInjector, - isNewTurn: boolean, -): Promise { - await injector.inject(isNewTurn); +async function injectDynamic(ctx: TestAgentContext, isNewTurn: boolean): Promise { + await runWillBeginStepHooks(ctx.get(IAgentLoopService) as StubLoop, isNewTurn); } async function registerLookupTool( @@ -59,14 +54,13 @@ describe('GoalInjection content', () => { let ctx: TestAgentContext; let goals: GoalServiceTestManager; let context: IAgentContextMemoryService; - let injector: InjectableContextInjector; - beforeEach(() => { + beforeEach(async () => { ctx = createTestAgent(agentService(IAgentDynamicWorkflowService, stubAgentDynamicWorkflow())); goals = ctx.resolve(AgentGoal) as GoalServiceTestManager; - void ctx.restoreRuntimes(); context = ctx.get(IAgentContextMemoryService); - injector = ctx.get(IAgentContextInjectorService) as InjectableContextInjector; + await ctx.restorePersisted(); + await ctx.restoreRuntimes(); }); afterEach(async () => { @@ -81,7 +75,7 @@ describe('GoalInjection content', () => { configure: (goals: GoalServiceTestManager) => Promise, ): Promise { await configure(goals); - await injectDynamic(injector, true); + await injectDynamic(ctx, true); return lastGoalReminder(context); } @@ -94,16 +88,16 @@ describe('GoalInjection content', () => { agentService(IAgentDynamicWorkflowService, stubAgentDynamicWorkflow()), ); const localGoals = local.resolve(AgentGoal) as GoalServiceTestManager; - const localInjector = local.get(IAgentContextInjectorService) as InjectableContextInjector; const localContext = local.get(IAgentContextMemoryService); + const localLoop = local.get(IAgentLoopService) as StubLoop; await localGoals.createGoal({ objective: 'work' }); - await injectDynamic(localInjector, true); + await injectDynamic(local, true); expect(lastGoalReminder(localContext)).toBeUndefined(); - void local.restoreRuntimes(); - void local.restoreRuntimes(); - await injectDynamic(localInjector, true); + await local.restoreRuntimes(); + await local.restoreRuntimes(); + await injectDynamic(local, true); expect(lastGoalReminder(localContext)).toContain(''); expect(localContext.get().filter((message) => message.origin?.kind === 'injection' && message.origin.variant === 'goal' @@ -111,7 +105,7 @@ describe('GoalInjection content', () => { await local.dispose(); const count = localContext.get().length; - await injectDynamic(localInjector, true); + await runWillBeginStepHooks(localLoop, true); expect(localContext.get()).toHaveLength(count); }); @@ -268,19 +262,18 @@ describe('GoalInjection integration', () => { let ctx: TestAgentContext; let goals: GoalServiceTestManager; let profile: IAgentProfileService; - let injector: InjectableContextInjector; let persistence: InMemoryWireRecordPersistence; - beforeEach(() => { + beforeEach(async () => { persistence = new InMemoryWireRecordPersistence(); ctx = createTestAgent( wireRecordPersistenceServices(persistence), agentService(IAgentDynamicWorkflowService, stubAgentDynamicWorkflow()), ); goals = ctx.resolve(AgentGoal) as GoalServiceTestManager; - void ctx.restoreRuntimes(); profile = ctx.get(IAgentProfileService); - injector = ctx.get(IAgentContextInjectorService) as InjectableContextInjector; + await ctx.restorePersisted(); + await ctx.restoreRuntimes(); }); afterEach(async () => { @@ -294,7 +287,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, true); + await injectDynamic(ctx, true); const goalRecords = await flushedGoalReminderRecords(ctx, persistence); expect(goalRecords).toHaveLength(1); @@ -305,8 +298,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, true); - await injectDynamic(injector, false); + await injectDynamic(ctx, true); + await injectDynamic(ctx, false); await expect(flushedGoalReminderRecords(ctx, persistence)).resolves.toHaveLength(1); }); @@ -365,7 +358,7 @@ describe('GoalInjection integration', () => { }); it('writes no goal record when there is no active goal', async () => { - await injectDynamic(injector, true); + await injectDynamic(ctx, true); await expect(flushedGoalReminderRecords(ctx, persistence)).resolves.toHaveLength(0); }); diff --git a/packages/agent-core-v2/test/features/plan/injection/planModeInjection.test.ts b/packages/agent-core-v2/test/features/plan/injection/planModeInjection.test.ts index e330f515c..953503ec9 100644 --- a/packages/agent-core-v2/test/features/plan/injection/planModeInjection.test.ts +++ b/packages/agent-core-v2/test/features/plan/injection/planModeInjection.test.ts @@ -1,8 +1,9 @@ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; import { createFakeHostFs } from '../../../tools/fixtures/fake-exec'; -import { IAgentContextInjectorService } from '#/agent/contextInjector/contextInjector'; import { IAgentContextMemoryService } from '#/agent/contextMemory/contextMemory'; +import { IAgentLoopService } from '#/agent/loop/loop'; +import { runWillBeginStepHooks, type StubLoop } from '../../../agent/loop/stubs'; import type { ContextMessage } from '#/agent/contextMemory/types'; import { IAgentPlanService } from '#/features/plan/plan'; import { @@ -11,10 +12,6 @@ import { type TestAgentContext, } from '../../../harness'; -type InjectableDynamicInjector = { - inject(boundary: undefined, isNewTurn: boolean): Promise; -}; - async function enterPlan( plan: IAgentPlanService, id = 'test-plan', @@ -27,8 +24,8 @@ async function enterPlan( return status.path; } -async function injectDynamic(injector: InjectableDynamicInjector): Promise { - await injector.inject(undefined, false); +async function injectDynamic(ctx: TestAgentContext): Promise { + await runWillBeginStepHooks(ctx.get(IAgentLoopService) as StubLoop, false); } function appendAssistantTurn( @@ -56,11 +53,10 @@ function lastPlanReminder(context: IAgentContextMemoryService): string { describe('PlanModeService dynamic injection content', () => { let ctx: TestAgentContext; let context: IAgentContextMemoryService; - let injector: InjectableDynamicInjector; let plan: IAgentPlanService; let readText: (path: string) => Promise; - beforeEach(() => { + beforeEach(async () => { readText = async () => ''; ctx = createTestAgent(execEnvServices({ hostFs: createFakeHostFs({ @@ -70,8 +66,9 @@ describe('PlanModeService dynamic injection content', () => { }), })); context = ctx.get(IAgentContextMemoryService); - injector = ctx.get(IAgentContextInjectorService) as unknown as InjectableDynamicInjector; plan = ctx.get(IAgentPlanService); + await ctx.restorePersisted(); + await ctx.restoreRuntimes(); }); afterEach(async () => { @@ -85,7 +82,7 @@ describe('PlanModeService dynamic injection content', () => { it('injects the full reminder with the current plan file footer', async () => { const planFilePath = await enterPlan(plan); - await injectDynamic(injector); + await injectDynamic(ctx); const text = lastPlanReminder(context); expect(text).toContain('Write'); @@ -97,7 +94,7 @@ describe('PlanModeService dynamic injection content', () => { it('derives a plan file path before injecting the full reminder', async () => { const planFilePath = await enterPlan(plan, 'derived-plan'); - await injectDynamic(injector); + await injectDynamic(ctx); expect(planFilePath).toContain('derived-plan.md'); expect(lastPlanReminder(context)).toContain(`Plan file: ${planFilePath}`); @@ -106,15 +103,15 @@ describe('PlanModeService dynamic injection content', () => { it('injects the exit reminder when plan mode turns off after being active', async () => { await enterPlan(plan); - await injectDynamic(injector); + await injectDynamic(ctx); plan.exit(); - await injectDynamic(injector); + await injectDynamic(ctx); expect(planReminderMessages(context)).toHaveLength(2); }); it('does not inject anything when plan mode is inactive from the start', async () => { - await injectDynamic(injector); + await injectDynamic(ctx); expect(planReminderMessages(context)).toHaveLength(0); expect(context.get()).toHaveLength(0); @@ -127,7 +124,7 @@ describe('PlanModeService dynamic injection content', () => { id: 'restored-plan', }); - await injectDynamic(injector); + await injectDynamic(ctx); expect(lastPlanReminder(context)).toContain('Re-entering Plan Mode'); }); @@ -136,10 +133,9 @@ describe('PlanModeService dynamic injection content', () => { describe('PlanModeService dynamic injection cadence', () => { let ctx: TestAgentContext; let context: IAgentContextMemoryService; - let injector: InjectableDynamicInjector; let plan: IAgentPlanService; - beforeEach(() => { + beforeEach(async () => { ctx = createTestAgent(execEnvServices({ hostFs: createFakeHostFs({ mkdir: vi.fn().mockResolvedValue(undefined), @@ -148,8 +144,9 @@ describe('PlanModeService dynamic injection cadence', () => { }), })); context = ctx.get(IAgentContextMemoryService); - injector = ctx.get(IAgentContextInjectorService) as unknown as InjectableDynamicInjector; plan = ctx.get(IAgentPlanService); + await ctx.restorePersisted(); + await ctx.restoreRuntimes(); }); afterEach(async () => { @@ -163,9 +160,9 @@ describe('PlanModeService dynamic injection cadence', () => { it('skips reinjection before the assistant-turn threshold', async () => { await enterPlan(plan); - await injectDynamic(injector); + await injectDynamic(ctx); appendAssistantTurn(ctx, context, 'assistant one'); - await injectDynamic(injector); + await injectDynamic(ctx); expect(planReminderMessages(context)).toHaveLength(1); }); @@ -173,10 +170,10 @@ describe('PlanModeService dynamic injection cadence', () => { it('injects the sparse reminder after the short assistant-turn threshold', async () => { const planFilePath = await enterPlan(plan); - await injectDynamic(injector); + await injectDynamic(ctx); appendAssistantTurn(ctx, context, 'assistant one'); appendAssistantTurn(ctx, context, 'assistant two'); - await injectDynamic(injector); + await injectDynamic(ctx); const text = lastPlanReminder(context); expect(text).toContain('Plan mode still active'); @@ -187,11 +184,11 @@ describe('PlanModeService dynamic injection cadence', () => { it('refreshes the full reminder after the long assistant-turn threshold', async () => { await enterPlan(plan); - await injectDynamic(injector); + await injectDynamic(ctx); for (let i = 0; i < 5; i += 1) { appendAssistantTurn(ctx, context, `assistant ${String(i)}`); } - await injectDynamic(injector); + await injectDynamic(ctx); const text = lastPlanReminder(context); expect(text).toContain('Plan mode is active'); @@ -201,9 +198,9 @@ describe('PlanModeService dynamic injection cadence', () => { it('refreshes the full reminder if a user message appears after the last injection', async () => { await enterPlan(plan); - await injectDynamic(injector); + await injectDynamic(ctx); ctx.appendUserMessage([{ type: 'text', text: 'next task' }]); - await injectDynamic(injector); + await injectDynamic(ctx); const text = lastPlanReminder(context); expect(text).toContain('Plan mode is active'); diff --git a/packages/agent-core-v2/test/features/plan/plan.test.ts b/packages/agent-core-v2/test/features/plan/plan.test.ts index cfed9d2e8..ec55a2298 100644 --- a/packages/agent-core-v2/test/features/plan/plan.test.ts +++ b/packages/agent-core-v2/test/features/plan/plan.test.ts @@ -6,8 +6,9 @@ import type { ToolCall } from '#/kosong/contract/message'; import { dirname, join } from 'pathe'; import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; -import { IAgentContextInjectorService } from '#/agent/contextInjector/contextInjector'; import { IAgentContextMemoryService } from '#/agent/contextMemory/contextMemory'; +import { IAgentLoopService } from '#/agent/loop/loop'; +import { runWillBeginStepHooks, type StubLoop } from '../../agent/loop/stubs'; import { IAgentPlanService, type PlanData } from '#/features/plan/plan'; import { IAgentPermissionRulesService } from '#/agent/permissionRules/permissionRules'; import { IAgentProfileService } from '#/agent/profile/profile'; @@ -71,21 +72,16 @@ function createPlanFileFakes( }; } -type InjectableDynamicInjector = { - inject(boundary: undefined, isNewTurn: boolean): Promise; -}; - describe('Plan service', () => { let activeFakes: PlanFakes; let context: IAgentContextMemoryService; let ctx: TestAgentContext; - let injector: InjectableDynamicInjector; let permissionRules: IAgentPermissionRulesService; let plan: IAgentPlanService; let profile: IAgentProfileService; let tempDirs: string[]; - beforeEach(() => { + beforeEach(async () => { activeFakes = createPlanFakes(); tempDirs = []; ctx = createTestAgent( @@ -95,10 +91,11 @@ describe('Plan service', () => { }), ); context = ctx.get(IAgentContextMemoryService); - injector = ctx.get(IAgentContextInjectorService) as unknown as InjectableDynamicInjector; permissionRules = ctx.get(IAgentPermissionRulesService); plan = ctx.get(IAgentPlanService); profile = ctx.get(IAgentProfileService); + await ctx.restorePersisted(); + await ctx.restoreRuntimes(); }); afterEach(async () => { @@ -919,7 +916,7 @@ describe('Plan service', () => { } async function injectDynamic(): Promise { - await injector.inject(undefined, false); + await runWillBeginStepHooks(ctx.get(IAgentLoopService) as StubLoop, false); } }); diff --git a/packages/agent-core-v2/test/features/plan/planGuard.test.ts b/packages/agent-core-v2/test/features/plan/planGuard.test.ts index 2cd952bac..25270a7c0 100644 --- a/packages/agent-core-v2/test/features/plan/planGuard.test.ts +++ b/packages/agent-core-v2/test/features/plan/planGuard.test.ts @@ -2,7 +2,8 @@ import { afterEach, beforeEach, describe, expect, it, vi, type Mock } from 'vite import { DisposableStore } from '#/_base/di/lifecycle'; import { createServices, type TestInstantiationService } from '#/_base/di/test'; -import { IAgentContextInjectorService } from '#/agent/contextInjector/contextInjector'; +import { IAgentLifecycleService } from '#/session/agentLifecycle/agentLifecycle'; +import { createReminderStub, lifecycleWithReminder } from '../reminder/stubs'; import { IAgentContextMemoryService } from '#/agent/contextMemory/contextMemory'; import { IAgentPermissionModeService } from '#/agent/permissionMode/permissionMode'; import type { @@ -174,9 +175,10 @@ describe('AgentPlanService plan-guard listener', () => { sessionDir: SESSION_DIR, }); reg.definePartialInstance(IAgentContextMemoryService, {}); - reg.definePartialInstance(IAgentContextInjectorService, { - register: () => ({ dispose: () => {} }), - }); + reg.defineInstance( + IAgentLifecycleService, + lifecycleWithReminder(createReminderStub()), + ); reg.definePartialInstance(IAgentTelemetryContextService, { set: () => {} }); reg.defineInstance(IAgentToolExecutorService, executorEvents.executor); reg.defineInstance(IAgentToolApprovalService, toolApproval); diff --git a/packages/agent-core-v2/test/agent/contextInjector/contextInjector.test.ts b/packages/agent-core-v2/test/features/reminder/reminder.test.ts similarity index 73% rename from packages/agent-core-v2/test/agent/contextInjector/contextInjector.test.ts rename to packages/agent-core-v2/test/features/reminder/reminder.test.ts index 7e202723e..721e026d1 100644 --- a/packages/agent-core-v2/test/agent/contextInjector/contextInjector.test.ts +++ b/packages/agent-core-v2/test/features/reminder/reminder.test.ts @@ -1,39 +1,19 @@ 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 { AgentContextInjectorService } from '#/agent/contextInjector/contextInjectorService'; import { IAgentContextMemoryService } from '#/agent/contextMemory/contextMemory'; import { ContextSpliced } from '#/agent/contextMemory/contextEvents'; import type { ContextMessage } from '#/agent/contextMemory/types'; +import { contextMemoryKey } from '#/agent/contextMemory/contextOps'; import { IAgentLoopService } from '#/agent/loop/loop'; -import { IAgentProfileService } from '#/agent/profile/profile'; -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 { AgentReminder, type ReminderRuntime } from '#/features/reminder/reminderAgentRuntime'; +import type { AgentRuntimeDefinition } from '#/agent/runtime/agentRuntime'; import { IEventBus } from '#/app/event/eventBus'; -import { EventBusService } from '#/app/event/eventBusService'; -import { IWireService } from '#/wire/wire'; -import { registerLogServices } from '../../_base/log/stubs'; -import { registerContextMemoryServices, type StubContextMemory } from '../contextMemory/stubs'; +import { IFeatureManager } from '#/app/feature/featureManager'; +import { createTestAgent, type TestAgentContext } from '../../harness'; import { runWillBeginStepHooks, type StubLoop, - stubLoopWithHooks, - stubWire, -} from '../loop/stubs'; -import { stubAgentContext } from '../agentContext/stubs'; - -function injector(ix: TestInstantiationService): IAgentContextInjectorService { - return ix.get(IAgentContextInjectorService); -} +} from '../../agent/loop/stubs'; function userMessage(text: string): ContextMessage { return { @@ -59,31 +39,23 @@ function lastText(context: IAgentContextMemoryService): string | undefined { return part?.type === 'text' ? part.text : undefined; } -describe('AgentContextInjectorService', () => { - let disposables: DisposableStore; - let ix: TestInstantiationService; +describe('ReminderRuntime', () => { + let ctx: TestAgentContext; + let reminder: ReminderRuntime; let context: IAgentContextMemoryService; let loop: StubLoop; - beforeEach(() => { - disposables = new DisposableStore(); - loop = stubLoopWithHooks(); - ix = createServices(disposables, { - base: [registerContextMemoryServices, registerLogServices], - strict: true, - additionalServices: (reg) => { - reg.defineInstance(IAgentLoopService, loop); - reg.defineInstance(IWireService, stubWire()); - reg.defineInstance(IAgentStateService, new AgentStateService()); - reg.define(IAgentSystemReminderService, AgentSystemReminderService); - reg.define(IAgentContextInjectorService, AgentContextInjectorService); - }, - }); - context = ix.get(IAgentContextMemoryService); + beforeEach(async () => { + ctx = createTestAgent(); + context = ctx.get(IAgentContextMemoryService); + loop = ctx.get(IAgentLoopService) as StubLoop; + await ctx.restorePersisted(); + await ctx.restoreRuntimes(); + reminder = ctx.resolve(AgentReminder); }); - afterEach(() => { - disposables.dispose(); + afterEach(async () => { + await ctx.dispose(); }); async function runInjectionStep(firstStepOfTurn = false): Promise { @@ -95,26 +67,24 @@ describe('AgentContextInjectorService', () => { deleteCount: number, inserted: readonly ContextMessage[], ): void { - const backing = (context as StubContextMemory).messages as ContextMessage[]; + const backing = [...ctx.agentState.get(contextMemoryKey)]; backing.splice(start, deleteCount, ...inserted); - const eventBus = ix.get(IEventBus); - const agentContext = stubAgentContext('main', 1); - (eventBus as EventBusService).activateAgent(agentContext); - eventBus.publish( + ctx.agentState.set(contextMemoryKey, backing); + ctx.get(IEventBus).publish( new ContextSpliced({ agentId: 'main', start, deleteCount, messages: [...inserted], }), - agentContext, + ctx.agentContext, ); } it('registers providers and appends injection messages with the provider variant', async () => { const seen: Array = []; - injector(ix).register('recording_test', ({ lastInjectedAt }) => { + reminder.register('recording_test', ({ lastInjectedAt }) => { seen.push(lastInjectedAt); return 'recorded reminder'; }); @@ -131,7 +101,7 @@ describe('AgentContextInjectorService', () => { }); it('persists provider disclosure metadata on the injected message origin', async () => { - injector(ix).register('date_test', () => ({ + reminder.register('date_test', () => ({ content: 'date reminder', disclosure: { kind: 'date', @@ -156,7 +126,7 @@ describe('AgentContextInjectorService', () => { }); it('appends provider content parts verbatim without system-reminder wrapping', async () => { - injector(ix).register('media_test', () => [ + reminder.register('media_test', () => [ { type: 'text', text: 'caption' }, { type: 'image_url', imageUrl: { url: 'https://example.com/a.png' } }, ]); @@ -172,7 +142,7 @@ describe('AgentContextInjectorService', () => { }); it('skips injection when the provider returns an empty content array', async () => { - injector(ix).register('empty_test', () => []); + reminder.register('empty_test', () => []); await runInjectionStep(); @@ -182,7 +152,7 @@ describe('AgentContextInjectorService', () => { it('passes the previous injection index back to the provider', async () => { const seen: Array = []; - injector(ix).register('recording_test', ({ lastInjectedAt }) => { + reminder.register('recording_test', ({ lastInjectedAt }) => { seen.push(lastInjectedAt); return lastInjectedAt === null ? 'recorded reminder' : undefined; }); @@ -196,16 +166,16 @@ describe('AgentContextInjectorService', () => { it('reconciles only providers registered under the requested name while idle', async () => { const seen: string[] = []; - injector(ix).register('target', () => { + reminder.register('target', () => { seen.push('target'); return 'target reminder'; }); - injector(ix).register('other', () => { + reminder.register('other', () => { seen.push('other'); return 'other reminder'; }); - await injector(ix).reconcileWhenIdle('target'); + await reminder.reconcileWhenIdle('target'); expect(seen).toEqual(['target']); expect(context.get()).toHaveLength(1); @@ -214,7 +184,7 @@ describe('AgentContextInjectorService', () => { it('leaves reconciliation to the next step head when quiescence cannot be acquired', async () => { let calls = 0; - injector(ix).register('target', () => { + reminder.register('target', () => { calls++; return 'target reminder'; }); @@ -223,7 +193,7 @@ describe('AgentContextInjectorService', () => { }; loop.tryAcquireQuiescence = () => undefined; - await injector(ix).reconcileWhenIdle('target'); + await reminder.reconcileWhenIdle('target'); expect(calls).toBe(0); expect(context.get()).toHaveLength(0); @@ -232,7 +202,7 @@ describe('AgentContextInjectorService', () => { it('exposes all live injection positions alongside the newest one', async () => { const seen: Array = []; - injector(ix).register('recording_test', ({ injectedPositions, lastInjectedAt }) => { + reminder.register('recording_test', ({ injectedPositions, lastInjectedAt }) => { seen.push(injectedPositions); expect(lastInjectedAt).toBe(injectedPositions.at(-1) ?? null); return seen.length <= 2 ? 'recorded reminder' : undefined; @@ -249,7 +219,7 @@ describe('AgentContextInjectorService', () => { it('falls back to the previous surviving copy when the newest injection is deleted', async () => { const seen: Array = []; - injector(ix).register('recording_test', ({ lastInjectedAt }) => { + reminder.register('recording_test', ({ lastInjectedAt }) => { seen.push(lastInjectedAt); return seen.length <= 2 ? 'recorded reminder' : undefined; }); @@ -271,11 +241,11 @@ describe('AgentContextInjectorService', () => { const seenA: Array = []; const seenB: Array = []; - injector(ix).register('recording_a', ({ lastInjectedAt }) => { + reminder.register('recording_a', ({ lastInjectedAt }) => { seenA.push(lastInjectedAt); return lastInjectedAt === null ? 'recorded reminder A' : undefined; }); - injector(ix).register('recording_b', ({ lastInjectedAt }) => { + reminder.register('recording_b', ({ lastInjectedAt }) => { seenB.push(lastInjectedAt); return lastInjectedAt === null ? 'recorded reminder B' : undefined; }); @@ -296,7 +266,7 @@ describe('AgentContextInjectorService', () => { const seen: Array = []; context.append(userMessage('before reminder')); - injector(ix).register('recording_test', ({ lastInjectedAt }) => { + reminder.register('recording_test', ({ lastInjectedAt }) => { seen.push(lastInjectedAt); return lastInjectedAt === null ? 'recorded reminder' : undefined; }); @@ -324,11 +294,11 @@ describe('AgentContextInjectorService', () => { userMessage('old request'), userMessage('old follow-up'), ); - injector(ix).register('recording_a', ({ lastInjectedAt }) => { + reminder.register('recording_a', ({ lastInjectedAt }) => { seenA.push(lastInjectedAt); return lastInjectedAt === null ? 'recorded reminder A' : undefined; }); - injector(ix).register('recording_b', ({ lastInjectedAt }) => { + reminder.register('recording_b', ({ lastInjectedAt }) => { seenB.push(lastInjectedAt); return lastInjectedAt === null ? 'recorded reminder B' : undefined; }); @@ -348,7 +318,7 @@ describe('AgentContextInjectorService', () => { it('re-arms per-turn providers at the first step after a compaction splice', async () => { const seen: boolean[] = []; - injector(ix).register('per_turn_test', ({ isNewTurn }) => { + reminder.register('per_turn_test', ({ isNewTurn }) => { seen.push(isNewTurn); return isNewTurn ? 'per-turn reminder' : undefined; }); @@ -367,7 +337,7 @@ describe('AgentContextInjectorService', () => { it('does not re-arm the new-turn flag for non-compaction splices', async () => { const seen: boolean[] = []; - injector(ix).register('per_turn_test', ({ isNewTurn }) => { + reminder.register('per_turn_test', ({ isNewTurn }) => { seen.push(isNewTurn); return undefined; }); @@ -381,7 +351,7 @@ describe('AgentContextInjectorService', () => { it('re-reconciles within the same step when compaction lands inside the step hook chain', async () => { const seen: boolean[] = []; - injector(ix).register('per_turn_test', ({ isNewTurn }) => { + reminder.register('per_turn_test', ({ isNewTurn }) => { seen.push(isNewTurn); return isNewTurn ? 'per-turn reminder' : undefined; }); @@ -400,7 +370,7 @@ describe('AgentContextInjectorService', () => { }); it('appends tagged raw messages verbatim with the injection origin stamped', async () => { - injector(ix).register('schema_test', () => ({ + reminder.register('schema_test', () => ({ message: { role: 'system', content: [], @@ -419,7 +389,7 @@ describe('AgentContextInjectorService', () => { }); it('stamps the disclosure on tagged raw messages returned through the result wrapper', async () => { - injector(ix).register('schema_test', () => ({ + reminder.register('schema_test', () => ({ content: { message: { role: 'user', content: [{ type: 'text', text: 'raw' }] } }, disclosure: { kind: 'test_receipt', id: 'r1' }, })); @@ -434,7 +404,7 @@ describe('AgentContextInjectorService', () => { }); it('skips tagged raw messages with neither content nor tools', async () => { - injector(ix).register('empty_raw_test', () => ({ message: { role: 'system', content: [] } })); + reminder.register('empty_raw_test', () => ({ message: { role: 'system', content: [] } })); await runInjectionStep(); @@ -442,10 +412,10 @@ describe('AgentContextInjectorService', () => { }); it('skips a throwing step provider and still runs the rest', async () => { - injector(ix).register('step_throwing', () => { + reminder.register('step_throwing', () => { throw new Error('boom'); }); - injector(ix).register('step_surviving', () => 'surviving reminder'); + reminder.register('step_surviving', () => 'surviving reminder'); await runInjectionStep(); @@ -454,12 +424,59 @@ describe('AgentContextInjectorService', () => { }); 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'); + reminder.register('step_rejecting', () => Promise.reject(new Error('boom'))); + reminder.register('step_surviving', () => 'surviving reminder'); await runInjectionStep(); expect(context.get()).toHaveLength(1); expect(lastText(context)).toContain('surviving reminder'); }); + + it('exposes an opaque frozen contract token', () => { + expect(Object.isFrozen(AgentReminder)).toBe(true); + expect(Object.keys(AgentReminder)).toEqual([]); + const forged = Object.freeze({}) as AgentRuntimeDefinition; + expect(() => ctx.resolve(forged)).toThrow('Unknown agent runtime definition'); + }); + + it('installs effects only after restore and only once', async () => { + const local = createTestAgent(); + const localLoop = local.get(IAgentLoopService) as StubLoop; + const localReminder = local.resolve(AgentReminder); + let calls = 0; + localReminder.register('restore_test', () => { + calls += 1; + return undefined; + }); + + await runWillBeginStepHooks(localLoop, false); + expect(calls).toBe(0); + + await local.restorePersisted(); + await local.restoreRuntimes(); + await local.restoreRuntimes(); + await runWillBeginStepHooks(localLoop, false); + expect(calls).toBe(1); + + await local.dispose(); + await runWillBeginStepHooks(localLoop, false); + expect(calls).toBe(1); + }); + + it('cleans the registry and hook when the feature is withdrawn', async () => { + let calls = 0; + reminder.register('withdraw_test', () => { + calls += 1; + return undefined; + }); + await runInjectionStep(); + expect(calls).toBe(1); + + await ctx.get(IFeatureManager).unprovideUnit('reminder'); + await runInjectionStep(); + + expect(calls).toBe(1); + expect(() => ctx.resolve(AgentReminder)).toThrow('unavailable'); + }); }); diff --git a/packages/agent-core-v2/test/features/reminder/stubs.ts b/packages/agent-core-v2/test/features/reminder/stubs.ts new file mode 100644 index 000000000..de8b58527 --- /dev/null +++ b/packages/agent-core-v2/test/features/reminder/stubs.ts @@ -0,0 +1,109 @@ +import { toDisposable } from '#/_base/di/lifecycle'; +import type { IAgentContextMemoryService } from '#/agent/contextMemory/contextMemory'; +import { isCompactionSummaryMessage } from '#/agent/contextMemory/compactionHandoff'; +import { ContextSpliced } from '#/agent/contextMemory/contextEvents'; +import type { IAgentLoopService } from '#/agent/loop/loop'; +import type { IEventBus } from '#/app/event/eventBus'; +import { wrapSystemReminder } from '#/features/reminder/systemReminder'; +import type { IAgentLifecycleService } from '#/session/agentLifecycle/agentLifecycle'; +import type { ReminderRuntime } from '#/features/reminder/reminderAgentRuntime'; +import type { + ContextInjectionContent, + ContextInjectionMessage, + ContextInjectionProvider, + ContextInjectionResult, + ReminderNotification, +} from '#/features/reminder/types'; + +export function createReminderStub(input: { + register?(variant: string, provider: ContextInjectionProvider): { dispose(): void }; + notify?(content: string, notification: ReminderNotification): void; + reconcileWhenIdle?(variant: string): Promise; +} = {}): ReminderRuntime { + return { + register: input.register ?? (() => toDisposable(() => {})), + notify: input.notify ?? (() => {}), + reconcileWhenIdle: input.reconcileWhenIdle ?? (async () => {}), + } as ReminderRuntime; +} + +export function lifecycleWithReminder(reminder: ReminderRuntime): IAgentLifecycleService { + return { + resolve: () => reminder, + handleOf: () => ({}), + onDidCreateScope: () => toDisposable(() => {}), + } as unknown as IAgentLifecycleService; +} + +export function createReminderHarness( + loop: IAgentLoopService, + context: IAgentContextMemoryService, + eventBus?: IEventBus, +): ReminderRuntime { + const entries = new Map(); + let rearm = false; + eventBus?.subscribe(ContextSpliced, (event) => { + if (event.deleteCount > 0 && event.messages.some(isCompactionSummaryMessage)) rearm = true; + }); + loop.hooks.onWillBeginStep.register('test-reminder', async ({ firstStepOfTurn }, next) => { + const isNewTurn = firstStepOfTurn || rearm; + rearm = false; + for (const [variant, provider] of entries) { + const history = context.get(); + const positions = history.flatMap((message, index) => + message.origin?.kind === 'injection' && message.origin.variant === variant ? [index] : [], + ); + const lastInjectedAt = positions.at(-1) ?? null; + const lastInjection = lastInjectedAt === null ? undefined : history[lastInjectedAt]; + const value = await provider({ + injectedPositions: positions, + lastInjectedAt, + lastInjection, + lastDisclosure: lastInjection?.origin?.kind === 'injection' + ? lastInjection.origin.disclosure + : undefined, + isNewTurn, + }); + if (value === undefined) continue; + const result: ContextInjectionResult = + typeof value === 'object' && !Array.isArray(value) && 'content' in value + ? value + : { content: value as ContextInjectionContent }; + const origin = { kind: 'injection' as const, variant, disclosure: result.disclosure }; + const content = result.content; + if (typeof content === 'string') { + if (content.trim().length === 0) continue; + context.append({ + role: 'user', + content: [{ type: 'text', text: wrapSystemReminder(content) }], + toolCalls: [], + origin, + }); + continue; + } + if (Array.isArray(content)) { + if (content.length === 0) continue; + context.append({ role: 'user', content: [...content], toolCalls: [], origin }); + continue; + } + const message = (content as { readonly message: ContextInjectionMessage }).message; + if (message.content.length === 0 && (message.tools === undefined || message.tools.length === 0)) { + continue; + } + context.append({ + role: message.role, + content: [...message.content], + toolCalls: [], + tools: message.tools, + origin, + }); + } + await next(); + }); + return createReminderStub({ + register: (variant, provider) => { + entries.set(variant, provider as ContextInjectionProvider); + return toDisposable(() => { entries.delete(variant); }); + }, + }); +} diff --git a/packages/agent-core-v2/test/features/sessionInit/sessionInit.test.ts b/packages/agent-core-v2/test/features/sessionInit/sessionInit.test.ts index cdd124911..d33d61566 100644 --- a/packages/agent-core-v2/test/features/sessionInit/sessionInit.test.ts +++ b/packages/agent-core-v2/test/features/sessionInit/sessionInit.test.ts @@ -12,7 +12,6 @@ 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 { IEventDispatcher } from '#/state/eventDispatcher'; import { ErrorCodes, Error2 } from '#/errors'; import type { AgentContext } from '#/agent/agentContext/agentContext'; @@ -61,6 +60,7 @@ describe('SessionInitService', () => { }, notifyAgentTaskStopped: vi.fn(), handleOf: vi.fn((agentId: string) => handles[agentId]), + resolve: vi.fn(() => ({ notify: appendReminder })), create: vi.fn(async () => stubAgentContext('agent-0', 1)), run: vi.fn(async (agent: AgentContext) => ({ agentId: agent.agentId, @@ -84,9 +84,11 @@ describe('SessionInitService', () => { get: (id: unknown) => { if (id === IAgentLifecycleService) return lifecycle; if (id === ISessionSubagentService) return lifecycle; + if (id === IAgentScopeContext) { + return { agentContext: stubAgentContext('main', 1) }; + } if (id === IAgentProfileService) return profile; if (id === IAgentPermissionModeService) return permissionMode; - if (id === IAgentSystemReminderService) return { appendSystemReminder: appendReminder }; if (id === IAgentAgentsMdReminderService) return { seedInjected }; if (id === IEventDispatcher) { return { @@ -168,11 +170,11 @@ describe('SessionInitService', () => { expect((runArgs[1] as { prompt: string }).prompt).toContain('Task requirements:'); expect(appendReminder).toHaveBeenCalledTimes(1); - const [content, origin] = appendReminder.mock.calls[0] as [ + const [content, notification] = appendReminder.mock.calls[0] as [ string, - { kind: string; variant: string }, + { variant: string }, ]; - expect(origin).toEqual({ kind: 'injection', variant: 'init' }); + expect(notification).toEqual({ 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); diff --git a/packages/agent-core-v2/test/features/skill/catalog/plugin-session-start.test.ts b/packages/agent-core-v2/test/features/skill/catalog/plugin-session-start.test.ts index f9b469657..10fc2796d 100644 --- a/packages/agent-core-v2/test/features/skill/catalog/plugin-session-start.test.ts +++ b/packages/agent-core-v2/test/features/skill/catalog/plugin-session-start.test.ts @@ -1,7 +1,8 @@ import { describe, expect, it } from 'vitest'; -import { IAgentContextInjectorService } from '#/agent/contextInjector/contextInjector'; import type { ContextMessage } from '#/agent/contextMemory/types'; +import { IAgentLoopService } from '#/agent/loop/loop'; +import { runWillBeginStepHooks, type StubLoop } from '../../../agent/loop/stubs'; import type { LogContext, LogPayload } from '#/_base/log/log'; import { IPluginService } from '#/app/plugin/plugin'; import type { EnabledPluginSessionStart } from '#/app/plugin/types'; @@ -11,10 +12,6 @@ import { appService, logServices, skillServices, testAgent } from '../../../harn import { stubPluginService } from '../../../app/plugin/stubs'; import { stubSkill } from './stubs'; -type InjectableDynamicInjector = { - inject(): Promise; -}; - interface CapturedWarn { readonly message: string; readonly payload?: LogPayload; @@ -87,7 +84,8 @@ function sessionStartRuntime(input: { } async function injectDynamic(ctx: ReturnType): Promise { - await (ctx.get(IAgentContextInjectorService) as unknown as InjectableDynamicInjector).inject(); + await ctx.restoreRuntimes(); + await runWillBeginStepHooks(ctx.get(IAgentLoopService) as StubLoop, false); } function lastReminder(ctx: ReturnType): string { diff --git a/packages/agent-core-v2/test/features/skill/workspace/skillCatalog.test.ts b/packages/agent-core-v2/test/features/skill/workspace/skillCatalog.test.ts index e7c7e1b56..1a5da28b8 100644 --- a/packages/agent-core-v2/test/features/skill/workspace/skillCatalog.test.ts +++ b/packages/agent-core-v2/test/features/skill/workspace/skillCatalog.test.ts @@ -185,7 +185,7 @@ function makeHost( }); const disposeHost = host.dispose.bind(host); host.dispose = () => { - workspaceHandle.dispose(); + void workspaceHandle.dispose(); disposeHost(); }; return { host, workspace: workspaceHandle, config }; diff --git a/packages/agent-core-v2/test/features/todo/sessionTodo.test.ts b/packages/agent-core-v2/test/features/todo/sessionTodo.test.ts index 2bd515100..9e4f7ab3c 100644 --- a/packages/agent-core-v2/test/features/todo/sessionTodo.test.ts +++ b/packages/agent-core-v2/test/features/todo/sessionTodo.test.ts @@ -15,8 +15,8 @@ import { getAgentRuntimeDefinitionId, } from '#/agent/runtime/agentRuntime'; import { IAgentBlobService } from '#/agent/blob/agentBlobService'; -import { IAgentContextInjectorService } from '#/agent/contextInjector/contextInjector'; import { IAgentContextMemoryService } from '#/agent/contextMemory/contextMemory'; +import { createReminderStub, lifecycleWithReminder } from '../reminder/stubs'; import { IAgentScopeContext, makeAgentScopeContext } from '#/agent/scopeContext/scopeContext'; import { IAgentStateService } from '#/agent/state/agentState'; import { AgentStateService } from '#/agent/state/agentStateService'; @@ -121,14 +121,16 @@ function makeRuntimeAgent( ix.set(IAgentStateService, new AgentStateService()); ix.set(IEventBus, eventBus); ix.set(IWireService, stubWireJournal(journal)); - ix.set(IAgentContextInjectorService, { - _serviceBrand: undefined, - register: (variant: string) => { - registeredVariants.push(variant); - reminders += 1; - return toDisposable(() => { reminders -= 1; }); - }, - } as unknown as IAgentContextInjectorService); + ix.set( + IAgentLifecycleService, + lifecycleWithReminder(createReminderStub({ + register: (variant: string) => { + registeredVariants.push(variant); + reminders += 1; + return toDisposable(() => { reminders -= 1; }); + }, + })), + ); ix.set(IAgentContextMemoryService, { _serviceBrand: undefined, get: () => [], @@ -166,7 +168,7 @@ function makeRuntimeAgent( registry.untrack(managed); await managed.runtimeSet.close(); managed.killSpace(); - handle.dispose(); + await handle.dispose(); }, }; } @@ -203,13 +205,15 @@ describe('TodoAgentRuntime', () => { ix.set(IAgentStateService, new AgentStateService()); ix.set(IEventBus, new EventBusService()); ix.set(IWireService, stubWireJournal([])); - ix.set(IAgentContextInjectorService, { - _serviceBrand: undefined, - register: () => { - reminders += 1; - return toDisposable(() => { reminders -= 1; }); - }, - } as unknown as IAgentContextInjectorService); + ix.set( + IAgentLifecycleService, + lifecycleWithReminder(createReminderStub({ + register: () => { + reminders += 1; + return toDisposable(() => { reminders -= 1; }); + }, + })), + ); ix.set(IAgentContextMemoryService, { _serviceBrand: undefined, get: () => [], @@ -244,7 +248,7 @@ describe('TodoAgentRuntime', () => { expect(managed.runtimeSet.resolve(AgentTodo)).toBe(todo); expect(reminders).toBe(1); await managed.runtimeSet.close(); - handle.dispose(); + await handle.dispose(); }); it('rejects resolve and lease tracking once the runtime set is closed', async () => { @@ -412,7 +416,7 @@ describe('TodoAgentRuntime', () => { expect(creates).toBe(1); expect(managed.runtimeSet.inspect()[0]).toMatchObject({ status: 'materialized' }); await managed.runtimeSet.close(); - handle.dispose(); + await handle.dispose(); }); it('reports registered, materialized, retired, and definition generations', async () => { @@ -462,7 +466,7 @@ describe('TodoAgentRuntime', () => { managed.attachDurableRuntimes(); expect(managed.runtimeSet.inspect()[0]).toMatchObject({ status: 'materialized', state: [] }); await managed.runtimeSet.close(); - handle.dispose(); + await handle.dispose(); }); it('retains actor failure status and inspection diagnostics', async () => { @@ -512,7 +516,7 @@ describe('TodoAgentRuntime', () => { error: 'actor failed', }); await managed.runtimeSet.close(); - handle.dispose(); + await handle.dispose(); }); }); diff --git a/packages/agent-core-v2/test/features/tower/towerService.test.ts b/packages/agent-core-v2/test/features/tower/towerService.test.ts index 3fe152e38..50bffa362 100644 --- a/packages/agent-core-v2/test/features/tower/towerService.test.ts +++ b/packages/agent-core-v2/test/features/tower/towerService.test.ts @@ -9,7 +9,6 @@ import { afterEach, beforeEach, describe, expect, it, vi, type Mock } from 'vite 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 { IAgentContextMemoryService } from '#/agent/contextMemory/contextMemory'; import { IAgentProfileService } from '#/agent/profile/profile'; import { IAgentScopeContext, makeAgentScopeContext } from '#/agent/scopeContext/scopeContext'; @@ -38,11 +37,13 @@ import { InMemoryStorageService } from '#/persistence/backends/memory/inMemorySt import { IAppendLogStore } from '#/persistence/interface/appendLogStore'; import { IFileSystemStorageService } from '#/persistence/interface/storage'; import { ISessionContext } from '#/session/sessionContext/sessionContext'; +import { IAgentLifecycleService } from '#/session/agentLifecycle/agentLifecycle'; import { ToolAccesses } from '#/tool/toolContract'; import { AGENT_WIRE_RECORD_KEY, type WireRecord } from '#/wire/record'; import { stubToolExecutorEvents, type ToolExecutorEventStubs } from '../../agent/toolExecutor/stubs'; import { stubFlag } from '../../app/flag/stubs'; +import { createReminderStub, lifecycleWithReminder } from '../reminder/stubs'; import { registerTestAgentWire, registerTestEventDispatcher, @@ -134,10 +135,7 @@ describe('AgentTowerService', () => { ix.stub(IConfigService, { onDidChangeConfiguration: () => ({ dispose: () => {} }), } as unknown as IConfigService); - ix.stub(IAgentContextInjectorService, { - register: () => ({ dispose: () => {} }), - reconcileWhenIdle: async () => {}, - } as unknown as IAgentContextInjectorService); + ix.stub(IAgentLifecycleService, lifecycleWithReminder(createReminderStub())); ix.stub(IAgentContextMemoryService, { get: () => [], } as unknown as IAgentContextMemoryService); diff --git a/packages/agent-core-v2/test/harness/agent.ts b/packages/agent-core-v2/test/harness/agent.ts index 4d02714d4..72f17eca5 100644 --- a/packages/agent-core-v2/test/harness/agent.ts +++ b/packages/agent-core-v2/test/harness/agent.ts @@ -25,10 +25,11 @@ import { AgentBlobServiceImpl } from '#/agent/blob/agentBlobServiceImpl'; import { WorkspaceStateService } from '#/workspace/state/workspaceStateService'; import { IHostEnvironment } from '#/os/interface/hostEnvironment'; import { HostFileSystem } from '#/os/backends/node-local/hostFsService'; -import '#/agent/contextInjector/contextInjectorService'; +import '#/features/reminder/reminderFeature'; import { BUILTIN_REPLAYABLE_STATE_KEYS } from '../state/builtinReplayableKeys'; import type { ContextMessage } from '#/agent/contextMemory/types'; import { AgentCron } from '#/features/cron/cronAgentRuntime'; +import { AgentDateChangeService } from '#/features/dateChange/dateChangeService'; import { IAgentIdentity } from '#/app/agentIdentity/agentIdentity'; import { AgentGoal } from '#/features/goal/goalAgentRuntime'; import { IGoalDeadlineScheduler } from '#/features/goal/goalDeadlineScheduler'; @@ -1408,6 +1409,16 @@ export class AgentTestContext { reassertServiceOverrides(this.serviceOverrides, 'agent', this.agent.instantiation); this.initializeRestorableServices(); + this.disposables.push( + new AgentDateChangeService( + this.session.accessor.get(IAgentLifecycleService), + this.get(IAgentScopeContext), + this.get(IAgentProfileService), + this.get(IAgentStateService), + this.get(IHostClock), + this.get(ISessionContext), + ), + ); this.get(IAgentActivityView); const eventBus = this.get(IEventBus); diff --git a/packages/agent-core-v2/test/session/advisor/sessionAdvisor.test.ts b/packages/agent-core-v2/test/session/advisor/sessionAdvisor.test.ts index 7fd705a1c..62e85496c 100644 --- a/packages/agent-core-v2/test/session/advisor/sessionAdvisor.test.ts +++ b/packages/agent-core-v2/test/session/advisor/sessionAdvisor.test.ts @@ -1,16 +1,11 @@ import { describe, expect, it, vi } from 'vitest'; -import type { IDisposable } from '#/_base/di/lifecycle'; +import { toDisposable } from '#/_base/di/lifecycle'; import type { ServiceIdentifier, ServicesAccessor } from '#/_base/di/instantiation'; import type { IAgentScopeHandle } from '#/_base/di/scope'; import type { AgentContext } from '#/agent/agentContext/agentContext'; import { makeAgentScopeContext } from '#/agent/scopeContext/scopeContext'; import { Emitter } from '#/_base/event'; -import { - IAgentContextInjectorService, - type ContextInjectionContext, - type ContextInjectionProvider, -} from '#/agent/contextInjector/contextInjector'; import { IAgentContextMemoryService } from '#/agent/contextMemory/contextMemory'; import type { ContextMessage } from '#/agent/contextMemory/types'; import { IAgentContextProjectorService } from '#/agent/contextProjector/contextProjector'; @@ -19,6 +14,11 @@ import { IAgentProfileService } from '#/agent/profile/profile'; import type { IConfigService } from '#/app/config/config'; import { IEventBus } from '#/app/event/eventBus'; import { EventBusService } from '#/app/event/eventBusService'; +import { AgentReminder } from '#/features/reminder/reminderAgentRuntime'; +import type { + ContextInjectionContext, + ContextInjectionProvider, +} from '#/features/reminder/types'; import { LifecycleScope } from '#/app/scopes'; import type { ILogService } from '#/_base/log/log'; import { UNKNOWN_CAPABILITY } from '#/kosong/contract/capability'; @@ -37,6 +37,7 @@ import type { import type { AdvisorConfig } from '#/session/advisor/configSection'; import { AdvisorConfigSchema } from '#/session/advisor/configSection'; import { SessionAdvisorService } from '#/session/advisor/advisorService'; +import { createReminderStub } from '../../features/reminder/stubs'; const history: readonly ContextMessage[] = [ { @@ -68,28 +69,6 @@ interface Fixture { readonly service: SessionAdvisorService; } -class FakeInjector implements IAgentContextInjectorService { - declare readonly _serviceBrand: undefined; - private invoke: ((isNewTurn: boolean) => unknown) | undefined; - - register(_name: string, provider: ContextInjectionProvider): IDisposable { - this.invoke = (isNewTurn) => provider({ - injectedPositions: [], - lastInjectedAt: null, - isNewTurn, - } as ContextInjectionContext); - return { - dispose: () => { this.invoke = undefined; }, - }; - } - - async reconcileWhenIdle(): Promise {} - - async inject(isNewTurn: boolean): Promise { - return this.invoke?.(isNewTurn); - } -} - function model(id: string, providerName: string, baseUrl: string): Model { return { id, @@ -125,7 +104,17 @@ function fixture(options: { options.advisorProvider ?? 'same-provider', options.advisorBaseUrl ?? mainModel.baseUrl!, ); - const injector = new FakeInjector(); + let inject: ((isNewTurn: boolean) => unknown) | undefined; + const reminder = createReminderStub({ + register: (_variant: string, provider: ContextInjectionProvider) => { + inject = (isNewTurn) => provider({ + injectedPositions: [], + lastInjectedAt: null, + isNewTurn, + } as ContextInjectionContext); + return toDisposable(() => { inject = undefined; }); + }, + }); const memory = { _serviceBrand: undefined, get: () => history, @@ -141,7 +130,6 @@ function fixture(options: { } as IAgentProfileService; const services = new Map([ [IEventBus, bus], - [IAgentContextInjectorService, injector], [IAgentContextMemoryService, memory], [IAgentContextProjectorService, projector], [IAgentProfileService, profile], @@ -172,7 +160,10 @@ function fixture(options: { get: (id: string) => id === 'main' ? mainContext : undefined, handleOf: (id: string) => id === 'main' ? main : undefined, list: () => [mainContext], - resolve: () => { throw new Error('not used'); }, + resolve: (_agent, definition) => { + if (definition !== AgentReminder) throw new Error('unexpected runtime'); + return reminder as never; + }, inspect: () => ({ identity: { agentId: mainContext.agentId, generation: mainContext.generation }, contributions: [], @@ -243,7 +234,7 @@ function fixture(options: { debug, warn, service, - inject: async (isNewTurn = true) => injector.inject(isNewTurn), + inject: async (isNewTurn = true) => inject?.(isNewTurn), }; } diff --git a/packages/agent-core-v2/test/session/agentLifecycle/agentLifecycle.test.ts b/packages/agent-core-v2/test/session/agentLifecycle/agentLifecycle.test.ts index 06569ea0b..1f4df88af 100644 --- a/packages/agent-core-v2/test/session/agentLifecycle/agentLifecycle.test.ts +++ b/packages/agent-core-v2/test/session/agentLifecycle/agentLifecycle.test.ts @@ -2,6 +2,8 @@ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; import { SyncDescriptor } from '#/_base/di/descriptors'; import { Disposable, DisposableStore } from '#/_base/di/lifecycle'; +import { IInstantiationService } from '#/_base/di/instantiation'; +import { InstantiationService } from '#/_base/di/instantiationService'; import { LifecycleScope } from '#/app/scopes'; import { type ISessionScopeHandle } from '#/_base/di/scope'; import { TestInstantiationService } from '#/_base/di/test'; @@ -24,12 +26,12 @@ import { IAgentRuntimeService } from '#/agent/runtimeBinding/agentRuntime'; import { IAgentStateService } from '#/agent/state/agentState'; import { AgentStateService } from '#/agent/state/agentStateService'; import type { AgentContext } from '#/agent/agentContext/agentContext'; -import { IAgentContextInjectorService } from '#/agent/contextInjector/contextInjector'; import { IAgentContextMemoryService } from '#/agent/contextMemory/contextMemory'; +import { reminderAgentRuntimeProvider } from '#/features/reminder/reminderAgentRuntime'; import '#/agent/contextMemory/contextMemoryService'; import { INHERITED_IN_FLIGHT_TOOL_OUTPUT } from '#/agent/contextMemory/openToolExchange'; import type { ContextMessage } from '#/agent/contextMemory/types'; -import { agentContextOf } from '#/agent/scopeContext/scopeContext'; +import { agentContextOf, IAgentScopeContext } from '#/agent/scopeContext/scopeContext'; import { IAgentIdentity } from '#/app/agentIdentity/agentIdentity'; import { IBuiltinAgentProfileLoader } from '#/app/agentProfileCatalog/builtinAgentProfileLoader'; import { IModelCatalog } from '#/kosong/model/catalog'; @@ -62,7 +64,6 @@ import { interactionAgentRuntimeProvider } from '#/features/interaction/interact import { Ledger } from '#/_base/lifecycle/ledger'; import { BugIndicatingError } from '#/_base/errors/errors'; import { AgentRuntimeContributionPoint } from '#/agent/runtime/agentRuntime'; -import { TODO_LIST_REMINDER_VARIANT } from '#/features/todo/todoListReminder'; import { AgentTodo, todoAgentRuntimeProvider } from '#/features/todo/todoAgentRuntime'; import '#/agent/toolDedupe/toolDedupeService'; import { IBootstrapService } from '#/app/bootstrap/bootstrap'; @@ -70,6 +71,7 @@ import { IConfigService } from '#/app/config/config'; import { ISessionEventBus } from '#/app/event/eventBus'; import { EventBusService } from '#/app/event/eventBusService'; import '#/app/event/eventBusService'; +import { AgentActivityUpdated } from '#/agent/activityView/activityView'; import { IAgentBlobService } from '#/agent/blob/agentBlobService'; import { IAgentPluginService } from '#/agent/plugin/agentPlugin'; import { ILogService } from '#/_base/log/log'; @@ -97,6 +99,7 @@ import '#/agent/toolActivation/toolActivationService'; import { IAgentMediaToolsRegistrar } from '#/agent/media/mediaTools'; import { ISessionWorkspaceContext } from '#/session/workspaceContext/workspaceContext'; import { FakeRuntime } from '#/runtime/fakeRuntime'; +import { ScopeUnits } from '#/_base/di/fiber'; import { IRuntimeResolver, IWorkspaceInstanceManager, @@ -384,10 +387,6 @@ describe('AgentLifecycleService', () => { ix.stub(IAgentAgentsMdReminderService, { _serviceBrand: undefined, } as IAgentAgentsMdReminderService); - ix.stub(IAgentContextInjectorService, { - _serviceBrand: undefined, - register: () => ({ dispose: () => {} }), - } as unknown as IAgentContextInjectorService); ix.stub(ISessionAgentProfileCatalog, { _serviceBrand: undefined, ready: Promise.resolve(), @@ -457,6 +456,12 @@ describe('AgentLifecycleService', () => { _serviceBrand: undefined, compacting: null, } as unknown as IAgentFullCompactionService); + ix.fiberHost.addCollectionRecord( + AgentRuntimeContributionPoint, + 'test-reminder', + new Ledger('test-reminder'), + reminderAgentRuntimeProvider, + ); ix.set(IAgentLifecycleService, new SyncDescriptor(AgentLifecycleService)); }); afterEach(() => { @@ -503,6 +508,179 @@ describe('AgentLifecycleService', () => { expect(svc.handleOf('main')).toBeUndefined(); }); + it('remove keeps the lifecycle context active through async scope teardown', async () => { + const svc = ix.get(IAgentLifecycleService); + const bus = ix.get(ISessionEventBus); + const main = await svc.create({ agentId: 'main' }); + const seen: string[] = []; + disposables.add(bus.subscribe(AgentActivityUpdated, (event) => seen.push(event.lifecycle))); + const agentScope = ix.children.find((child) => child.debugLabel === 'main'); + expect(agentScope).toBeDefined(); + let releaseDrain!: () => void; + let gateEntered!: () => void; + const entered = new Promise((resolve) => { + gateEntered = resolve; + }); + agentScope!.anchorKernelEntry(() => { + gateEntered(); + return new Promise((resolve) => { + releaseDrain = resolve; + }); + }, 'test-async-disposer'); + const unhandled: unknown[] = []; + const onUnhandled = (reason: unknown): void => { + unhandled.push(reason); + }; + process.on('unhandledRejection', onUnhandled); + try { + const removal = svc.remove(main); + await entered; + bus.publish( + new AgentActivityUpdated({ lifecycle: 'disposed', background: [], agentId: 'main' }), + main, + ); + expect(seen).toEqual(['disposed']); + releaseDrain(); + await removal; + expect(() => + bus.publish( + new AgentActivityUpdated({ lifecycle: 'disposed', background: [], agentId: 'main' }), + main, + ), + ).toThrow("Agent event 'agent.activity.updated' has no active lifecycle context"); + expect(unhandled).toEqual([]); + } finally { + process.off('unhandledRejection', onUnhandled); + } + }); + + function contributeDisposeBeacon( + dispose: (eventBus: ISessionEventBus, scope: IAgentScopeContext) => void | Promise, + ): void { + class DisposeBeacon { + constructor( + @ISessionEventBus private readonly eventBus: ISessionEventBus, + @IAgentScopeContext private readonly scope: IAgentScopeContext, + ) {} + + dispose(): void | Promise { + return dispose(this.eventBus, this.scope); + } + } + + ix.fiberHost.addCollectionRecord( + ScopeUnits(LifecycleScope.Agent), + 'test', + new Ledger('test'), + DisposeBeacon, + ); + } + + function publishDisposed(eventBus: ISessionEventBus, scope: IAgentScopeContext): void { + eventBus.publish( + new AgentActivityUpdated({ + lifecycle: 'disposed', + background: [], + agentId: scope.agentId, + }), + scope.agentContext, + ); + } + + it('remove deactivates after scope-units contributed units are torn down', async () => { + const svc = ix.get(IAgentLifecycleService); + const bus = ix.get(ISessionEventBus); + const seen: string[] = []; + disposables.add(bus.subscribe(AgentActivityUpdated, (event) => seen.push(event.lifecycle))); + + contributeDisposeBeacon(publishDisposed); + + const unhandled: unknown[] = []; + const onUnhandled = (reason: unknown): void => { + unhandled.push(reason); + }; + process.on('unhandledRejection', onUnhandled); + try { + const main = await svc.create({ agentId: 'main' }); + await svc.remove(main); + expect(seen).toEqual(['disposed']); + expect(unhandled).toEqual([]); + } finally { + process.off('unhandledRejection', onUnhandled); + } + }); + + it('create failure after scope creation keeps the context active through async teardown', async () => { + registerAgent.mockRejectedValueOnce(new Error('boom')); + const svc = ix.get(IAgentLifecycleService); + const bus = ix.get(ISessionEventBus); + const seen: string[] = []; + disposables.add(bus.subscribe(AgentActivityUpdated, (event) => seen.push(event.lifecycle))); + + class GatedBeacon { + constructor( + @ISessionEventBus private readonly eventBus: ISessionEventBus, + @IAgentScopeContext private readonly scope: IAgentScopeContext, + @IInstantiationService instantiation: IInstantiationService, + ) { + (instantiation as InstantiationService).anchorKernelEntry( + () => new Promise((resolve) => setTimeout(resolve, 20)), + 'beacon-gate', + ); + } + + dispose(): void { + publishDisposed(this.eventBus, this.scope); + } + } + + ix.fiberHost.addCollectionRecord( + ScopeUnits(LifecycleScope.Agent), + 'test', + new Ledger('test'), + GatedBeacon, + ); + + const unhandled: unknown[] = []; + const onUnhandled = (reason: unknown): void => { + unhandled.push(reason); + }; + process.on('unhandledRejection', onUnhandled); + try { + await expect(svc.create({ agentId: 'main' })).rejects.toThrow('boom'); + expect(seen).toEqual(['disposed']); + expect(unhandled).toEqual([]); + } finally { + process.off('unhandledRejection', onUnhandled); + } + }); + + it('remove awaits asynchronous contributed-unit teardown before deactivating', async () => { + const svc = ix.get(IAgentLifecycleService); + const bus = ix.get(ISessionEventBus); + const seen: string[] = []; + disposables.add(bus.subscribe(AgentActivityUpdated, (event) => seen.push(event.lifecycle))); + + contributeDisposeBeacon(async (eventBus, scope) => { + await new Promise((resolve) => setTimeout(resolve, 0)); + publishDisposed(eventBus, scope); + }); + + const unhandled: unknown[] = []; + const onUnhandled = (reason: unknown): void => { + unhandled.push(reason); + }; + process.on('unhandledRejection', onUnhandled); + try { + const main = await svc.create({ agentId: 'main' }); + await svc.remove(main); + expect(seen).toEqual(['disposed']); + expect(unhandled).toEqual([]); + } finally { + process.off('unhandledRejection', onUnhandled); + } + }); + it('remove stops the agent background tasks before disposal', async () => { const svc = ix.get(IAgentLifecycleService); const main = await svc.create({ agentId: 'main' }); @@ -1156,20 +1334,6 @@ describe('AgentLifecycleService', () => { it('retires agent runtimes before disposing the agent scope on remove', async () => { const order: string[] = []; - let reminders = 0; - ix.stub(IAgentContextInjectorService, { - _serviceBrand: undefined, - register: (variant: string) => { - if (variant !== TODO_LIST_REMINDER_VARIANT) return { dispose: () => {} }; - reminders += 1; - return { - dispose: () => { - reminders -= 1; - order.push('reminder-disposed'); - }, - }; - }, - } as unknown as IAgentContextInjectorService); contributeTodo(); const svc = ix.get(IAgentLifecycleService); const willClose: string[] = []; @@ -1179,16 +1343,14 @@ describe('AgentLifecycleService', () => { const originalDispose = handle.dispose.bind(handle); handle.dispose = () => { order.push('scope-disposed'); - originalDispose(); + return originalDispose(); }; svc.resolve(main, AgentTodo).get(); - expect(reminders).toBe(1); await svc.remove(main); expect(willClose).toEqual(['main']); - expect(reminders).toBe(0); - expect(order).toEqual(['reminder-disposed', 'scope-disposed']); + expect(order).toEqual(['scope-disposed']); expect(svc.handleOf('main')).toBeUndefined(); }); @@ -1210,30 +1372,18 @@ describe('AgentLifecycleService', () => { }); it('retires a withdrawn runtime definition and rejects new resolves', async () => { - let reminders = 0; - ix.stub(IAgentContextInjectorService, { - _serviceBrand: undefined, - register: (variant: string) => { - if (variant !== TODO_LIST_REMINDER_VARIANT) return { dispose: () => {} }; - reminders += 1; - return { - dispose: () => { - reminders -= 1; - }, - }; - }, - } as unknown as IAgentContextInjectorService); const withdraw = contributeTodo(); const svc = ix.get(IAgentLifecycleService); const main = await svc.create({ agentId: 'main' }); svc.resolve(main, AgentTodo).get(); - expect(reminders).toBe(1); withdraw(); - await vi.waitFor(() => { expect(reminders).toBe(0); }); expect(() => svc.resolve(main, AgentTodo)).toThrow('unavailable'); - expect(svc.inspect(main).contributions[0]).toMatchObject({ id: 'todo', status: 'retired' }); + expect(svc.inspect(main).contributions.find((entry) => entry.id === 'todo')).toMatchObject({ + id: 'todo', + status: 'retired', + }); }); it('de-dupes concurrent create calls for the same agent id', async () => { diff --git a/packages/agent-core-v2/test/tool/tool.test.ts b/packages/agent-core-v2/test/tool/tool.test.ts index 83d81c994..672840153 100644 --- a/packages/agent-core-v2/test/tool/tool.test.ts +++ b/packages/agent-core-v2/test/tool/tool.test.ts @@ -14,7 +14,7 @@ import type { TokenUsage } from '#/kosong/contract/usage'; import { IModelCatalog, type Model } from '#/kosong/model/catalog'; import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; -import { IAgentContextInjectorService } from '#/agent/contextInjector/contextInjector'; +import { reminderAgentRuntimeProvider, AgentReminder } from '#/features/reminder/reminderAgentRuntime'; import { IAgentTaskService } from '#/agent/task/task'; import { IAgentContextMemoryService } from '#/agent/contextMemory/contextMemory'; import { ISessionTokenCountingService } from '#/session/tokenCounting/sessionTokenCounting'; @@ -280,12 +280,6 @@ function createAgentLifecycleStub(options: AgentLifecycleStubOptions = {}): Agen scope: (subKey?: string) => subKey ?? '', } as never; } - if (serviceId === IAgentContextInjectorService) { - return { - _serviceBrand: undefined, - register: () => ({ dispose: () => {} }), - } as never; - } if (serviceId === IAgentContextMemoryService) { return { _serviceBrand: undefined, @@ -454,6 +448,12 @@ function createAgentLifecycleStub(options: AgentLifecycleStubOptions = {}): Agen const adoptedHandle = adopted as IAgentScopeHandle; handles.set(adoptedHandle.id, adoptedHandle); adoptedManaged = new ManagedAgent(agentContextOf(adoptedHandle), adoptedHandle, [ + { + definition: AgentReminder, + provider: reminderAgentRuntimeProvider, + generation: 1, + active: true, + }, { definition: AgentTodo, provider: todoAgentRuntimeProvider, @@ -3859,6 +3859,7 @@ describe('Agent tools', () => { }); it('routes registered user tools through tool.call request/response', async () => { + await ctx.restoreRuntimes(); ctx.mockNextResponse({ type: 'text', text: 'I will look it up.' }, lookupCall); await ctx.rpc.prompt({ input: [{ type: 'text', text: 'Look up moon' }] }); expect( @@ -3875,8 +3876,8 @@ describe('Agent tools', () => { [emit] turn.started { "time": "