mirror of
https://github.com/MoonshotAI/kimi-code.git
synced 2026-08-23 15:46:26 +00:00
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:
parent
226ced32d6
commit
37888e5e2b
6 changed files with 65 additions and 35 deletions
|
|
@ -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;
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -45,7 +45,6 @@ export class AgentPluginService extends Disposable implements IAgentPluginServic
|
|||
if (injectedPositions.length > 0) return undefined;
|
||||
return this.renderSessionStartReminder();
|
||||
},
|
||||
{ cadence: 'turn' },
|
||||
),
|
||||
);
|
||||
this._register(
|
||||
|
|
|
|||
|
|
@ -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> = [];
|
||||
|
||||
|
|
|
|||
|
|
@ -1212,7 +1212,6 @@ export class AgentTestContext {
|
|||
this.options['log'] as { warn(message: string, payload?: unknown): void } | undefined,
|
||||
);
|
||||
},
|
||||
{ cadence: 'turn' },
|
||||
);
|
||||
}
|
||||
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue