From 37888e5e2bdc180c3758236e9fea164dfebea1dc Mon Sep 17 00:00:00 2001 From: _Kerman Date: Tue, 7 Jul 2026 15:35:37 +0800 Subject: [PATCH] feat(agent-core-v2): allow rich context injection content Context injections can now return either plain reminder text or pre-built content parts, with turn-boundary state exposed to providers instead of the cadence option. --- .../agent/contextInjector/contextInjector.ts | 21 +++++--- .../contextInjector/contextInjectorService.ts | 49 +++++++++---------- .../src/agent/goal/injection/goalInjection.ts | 4 +- .../src/agent/plugin/agentPluginService.ts | 1 - .../test/contextInjector/manager.test.ts | 24 +++++++++ packages/agent-core-v2/test/harness/agent.ts | 1 - 6 files changed, 65 insertions(+), 35 deletions(-) diff --git a/packages/agent-core-v2/src/agent/contextInjector/contextInjector.ts b/packages/agent-core-v2/src/agent/contextInjector/contextInjector.ts index d5b126e5c..bbca51b63 100644 --- a/packages/agent-core-v2/src/agent/contextInjector/contextInjector.ts +++ b/packages/agent-core-v2/src/agent/contextInjector/contextInjector.ts @@ -1,28 +1,37 @@ import { createDecorator } from "#/_base/di/instantiation"; import type { IDisposable } from "#/_base/di/lifecycle"; +import type { ContentPart } from "#/app/llmProtocol/message"; export interface ContextInjectionContext { /** Live positions of this variant's injections in the current history, ascending. */ readonly injectedPositions: readonly number[]; /** Position of the newest live injection; `null` when none survive. */ readonly lastInjectedAt: number | null; + /** + * `true` on the first inject run after a `turn.started` event (or after the + * service starts), then `false` until the next turn. Injectors that should + * fire once per turn can gate on this flag. + */ + readonly isNewTurn: boolean; } -export interface ContextInjectionOptions { - readonly cadence?: 'step' | 'turn'; -} +/** + * Content a context injection provider can return. A plain `string` is wrapped + * in `` tags; a {@link ContentPart} array is appended verbatim, + * allowing providers to inject rich content (e.g. multi-part or media content). + */ +export type ContextInjectionContent = string | readonly ContentPart[]; export type ContextInjectionProvider = ( context: ContextInjectionContext, -) => string | undefined | Promise; +) => ContextInjectionContent | undefined | Promise; export interface IAgentContextInjectorService { readonly _serviceBrand: undefined; register( - variant: string, + name: string, provider: ContextInjectionProvider, - options?: ContextInjectionOptions, ): IDisposable; } diff --git a/packages/agent-core-v2/src/agent/contextInjector/contextInjectorService.ts b/packages/agent-core-v2/src/agent/contextInjector/contextInjectorService.ts index 46c0d3054..14e2e90a0 100644 --- a/packages/agent-core-v2/src/agent/contextInjector/contextInjectorService.ts +++ b/packages/agent-core-v2/src/agent/contextInjector/contextInjectorService.ts @@ -9,22 +9,20 @@ import { IEventBus } from '#/app/event/eventBus'; import type { ContextMessage } from '#/agent/contextMemory'; import { IAgentContextInjectorService, - type ContextInjectionOptions, type ContextInjectionProvider, } from './contextInjector'; interface ContextInjectionEntry { - readonly cadence: ContextInjectionOptions['cadence']; readonly provider: ContextInjectionProvider; - readonly variant: string; + readonly name: string; /** Live positions of this variant's injection messages, ascending. */ readonly positions: number[]; - turnConsumed: boolean; } export class AgentContextInjectorService extends Disposable implements IAgentContextInjectorService { declare readonly _serviceBrand: undefined; private readonly entries = new Set(); + private isNewTurn = true; constructor( @IAgentContextMemoryService private readonly context: IAgentContextMemoryService, @@ -41,27 +39,21 @@ export class AgentContextInjectorService extends Disposable implements IAgentCon ); this._register( this.eventBus.subscribe('turn.started', () => { - for (const entry of this.entries) { - entry.turnConsumed = false; - } + this.isNewTurn = true; }), ); this.eventBus.subscribe('context.spliced', (e) => this.handleSplice(e)); } register( - variant: string, + name: string, provider: ContextInjectionProvider, - options: ContextInjectionOptions = {}, ) { - const cadence = options.cadence ?? 'step'; - const positions = findInjections(this.context.get(), variant); + const positions = findInjections(this.context.get(), name); const entry: ContextInjectionEntry = { - cadence, provider, - variant, + name, positions, - turnConsumed: cadence === 'turn' && positions.length > 0, }; this.entries.add(entry); return toDisposable(() => { @@ -70,21 +62,29 @@ export class AgentContextInjectorService extends Disposable implements IAgentCon } private async inject(): Promise { + const isNewTurn = this.isNewTurn; + this.isNewTurn = false; for (const entry of this.entries) { - if (entry.cadence === 'turn') { - if (entry.turnConsumed) continue; - entry.turnConsumed = true; - } const injectedPositions: readonly number[] = [...entry.positions]; const content = await entry.provider({ injectedPositions, lastInjectedAt: injectedPositions.at(-1) ?? null, + isNewTurn, }); if (!this.entries.has(entry)) continue; - if (content === undefined || content.trim().length === 0) continue; - this.reminders.appendSystemReminder(content, { - kind: 'injection', - variant: entry.variant, + if (content === undefined) continue; + const origin = { kind: 'injection' as const, variant: entry.name }; + if (typeof content === 'string') { + if (content.trim().length === 0) continue; + this.reminders.appendSystemReminder(content, origin); + continue; + } + if (content.length === 0) continue; + this.context.append({ + role: 'user', + content: [...content], + toolCalls: [], + origin, }); } } @@ -106,7 +106,7 @@ export class AgentContextInjectorService extends Disposable implements IAgentCon const deletedEnd = splice.start + splice.deleteCount; const delta = splice.messages.length - splice.deleteCount; for (const entry of this.entries) { - const adopted = insertedInjections?.get(entry.variant) ?? []; + const adopted = insertedInjections?.get(entry.name) ?? []; const positions = entry.positions; if (adopted.length === 0 && positions.length === 0) continue; // Mirror the context splice onto the ascending positions array: shift @@ -120,9 +120,6 @@ export class AgentContextInjectorService extends Disposable implements IAgentCon positions[index] = positions[index]! + delta; } positions.splice(lo, hi - lo, ...adopted); - if (adopted.length > 0 && entry.cadence === 'turn') { - entry.turnConsumed = true; - } } } } diff --git a/packages/agent-core-v2/src/agent/goal/injection/goalInjection.ts b/packages/agent-core-v2/src/agent/goal/injection/goalInjection.ts index 5913fd0c5..93f3f1efc 100644 --- a/packages/agent-core-v2/src/agent/goal/injection/goalInjection.ts +++ b/packages/agent-core-v2/src/agent/goal/injection/goalInjection.ts @@ -16,7 +16,9 @@ export class GoalInjection extends Disposable { @IAgentContextInjectorService dynamicInjector: IAgentContextInjectorService, ) { super(); - this._register(dynamicInjector.register('goal', () => this.reminder(), { cadence: 'turn' })); + this._register( + dynamicInjector.register('goal', ({ isNewTurn }) => (isNewTurn ? this.reminder() : undefined)), + ); } private reminder(): string | undefined { diff --git a/packages/agent-core-v2/src/agent/plugin/agentPluginService.ts b/packages/agent-core-v2/src/agent/plugin/agentPluginService.ts index 980871dca..2b01cb9c1 100644 --- a/packages/agent-core-v2/src/agent/plugin/agentPluginService.ts +++ b/packages/agent-core-v2/src/agent/plugin/agentPluginService.ts @@ -45,7 +45,6 @@ export class AgentPluginService extends Disposable implements IAgentPluginServic if (injectedPositions.length > 0) return undefined; return this.renderSessionStartReminder(); }, - { cadence: 'turn' }, ), ); this._register( diff --git a/packages/agent-core-v2/test/contextInjector/manager.test.ts b/packages/agent-core-v2/test/contextInjector/manager.test.ts index a6f9cd535..d283edf4b 100644 --- a/packages/agent-core-v2/test/contextInjector/manager.test.ts +++ b/packages/agent-core-v2/test/contextInjector/manager.test.ts @@ -89,6 +89,30 @@ describe('AgentContextInjectorService', () => { }); }); + it('appends provider content parts verbatim without system-reminder wrapping', async () => { + injector(ix).register('media_test', () => [ + { type: 'text', text: 'caption' }, + { type: 'image_url', imageUrl: { url: 'https://example.com/a.png' } }, + ]); + + await injector(ix).inject(); + + const message = context.get().at(-1); + expect(message?.content).toEqual([ + { type: 'text', text: 'caption' }, + { type: 'image_url', imageUrl: { url: 'https://example.com/a.png' } }, + ]); + expect(message?.origin).toEqual({ kind: 'injection', variant: 'media_test' }); + }); + + it('skips injection when the provider returns an empty content array', async () => { + injector(ix).register('empty_test', () => []); + + await injector(ix).inject(); + + expect(context.get()).toHaveLength(0); + }); + it('passes the previous injection index back to the provider', async () => { const seen: Array = []; diff --git a/packages/agent-core-v2/test/harness/agent.ts b/packages/agent-core-v2/test/harness/agent.ts index 129946a7e..163b904bc 100644 --- a/packages/agent-core-v2/test/harness/agent.ts +++ b/packages/agent-core-v2/test/harness/agent.ts @@ -1212,7 +1212,6 @@ export class AgentTestContext { this.options['log'] as { warn(message: string, payload?: unknown): void } | undefined, ); }, - { cadence: 'turn' }, ); }