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.
This commit is contained in:
_Kerman 2026-07-07 15:35:37 +08:00
parent 226ced32d6
commit 37888e5e2b
6 changed files with 65 additions and 35 deletions

View file

@ -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 `<system-reminder>` 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<string | undefined>;
) => ContextInjectionContent | undefined | Promise<ContextInjectionContent | undefined>;
export interface IAgentContextInjectorService {
readonly _serviceBrand: undefined;
register(
variant: string,
name: string,
provider: ContextInjectionProvider,
options?: ContextInjectionOptions,
): IDisposable;
}

View file

@ -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<ContextInjectionEntry>();
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<void> {
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;
}
}
}
}

View file

@ -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 {

View file

@ -45,7 +45,6 @@ export class AgentPluginService extends Disposable implements IAgentPluginServic
if (injectedPositions.length > 0) return undefined;
return this.renderSessionStartReminder();
},
{ cadence: 'turn' },
),
);
this._register(

View file

@ -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<number | null> = [];

View file

@ -1212,7 +1212,6 @@ export class AgentTestContext {
this.options['log'] as { warn(message: string, payload?: unknown): void } | undefined,
);
},
{ cadence: 'turn' },
);
}