mirror of
https://github.com/MoonshotAI/kimi-code.git
synced 2026-08-23 07:37:18 +00:00
fix: align plugin session start injection
This commit is contained in:
parent
d25cb76138
commit
ec133ce2a4
10 changed files with 129 additions and 98 deletions
|
|
@ -141,6 +141,7 @@ const DOMAIN_LAYER = new Map([
|
|||
['toolDedupe', 4],
|
||||
['contextMemory', 4],
|
||||
['contextInjector', 4],
|
||||
['agentPlugin', 4],
|
||||
['systemReminder', 4],
|
||||
['contextProjector', 4],
|
||||
['contextSize', 4],
|
||||
|
|
@ -225,6 +226,7 @@ function domainFromRel(rel, { exemptRootFile }) {
|
|||
if (SCOPE_DIRS.has(segments[0])) {
|
||||
// `src/{scope}/{domain}/…`
|
||||
if (segments[0] === 'agent' && segments[1] === 'task') return 'agentTask';
|
||||
if (segments[0] === 'agent' && segments[1] === 'plugin') return 'agentPlugin';
|
||||
return segments[1];
|
||||
}
|
||||
// Top-level `src/*.ts` facades are not domains — exempt from layering.
|
||||
|
|
|
|||
|
|
@ -4,4 +4,3 @@
|
|||
|
||||
export * from './contextInjector';
|
||||
export * from './contextInjectorService';
|
||||
export * from './pluginSessionStart';
|
||||
|
|
|
|||
15
packages/agent-core-v2/src/agent/plugin/agentPlugin.ts
Normal file
15
packages/agent-core-v2/src/agent/plugin/agentPlugin.ts
Normal file
|
|
@ -0,0 +1,15 @@
|
|||
/**
|
||||
* `agentPlugin` domain (L4) — Agent-scope plugin integration contract.
|
||||
*
|
||||
* Bridges App-scope plugin declarations into the main agent's runtime context.
|
||||
* Bound at Agent scope and instantiated only for the main agent.
|
||||
*/
|
||||
|
||||
import { createDecorator, type ServiceIdentifier } from '#/_base/di/instantiation';
|
||||
|
||||
export interface IAgentPluginService {
|
||||
readonly _serviceBrand: undefined;
|
||||
}
|
||||
|
||||
export const IAgentPluginService: ServiceIdentifier<IAgentPluginService> =
|
||||
createDecorator<IAgentPluginService>('agentPluginService');
|
||||
|
|
@ -1,49 +1,35 @@
|
|||
/**
|
||||
* `contextInjector` domain (L4) — plugin session-start reminder injection.
|
||||
* `agentPlugin` domain (L4) — `IAgentPluginService` implementation.
|
||||
*
|
||||
* Production equivalent of v1's `agent/injection/plugin-session-start.ts`.
|
||||
* Registers a turn-cadence `plugin_session_start` injection with
|
||||
* `IAgentContextInjectorService` so enabled plugins' `sessionStart` skills are
|
||||
* rendered into the main agent's context once per turn (deduped against
|
||||
* replayed history by the context-injector). On the Session skill-catalog
|
||||
* sink's `onDidChange`, force-appends a fresh reminder — or a neutralizing
|
||||
* reminder when no session start is resolvable but stale guidance may linger —
|
||||
* mirroring the `/reload` re-injection flow.
|
||||
* Renders enabled plugins' `sessionStart` skills into the main agent's context.
|
||||
* The normal injection path mirrors v1: add one reminder while no live
|
||||
* `plugin_session_start` injection exists. Reload paths force-append a fresh
|
||||
* reminder, or neutralize stale guidance when no session start is active.
|
||||
*/
|
||||
|
||||
import { Disposable } from '#/_base/di/lifecycle';
|
||||
import { createDecorator, type ServiceIdentifier } from '#/_base/di/instantiation';
|
||||
import { InstantiationType } from '#/_base/di/extensions';
|
||||
import { Disposable } from '#/_base/di/lifecycle';
|
||||
import { LifecycleScope, registerScopedService } from '#/_base/di/scope';
|
||||
import { escapeXmlAttr } from '#/_base/utils/xml-escape';
|
||||
import { IAgentContextMemoryService } from '#/agent/contextMemory';
|
||||
import { ILogService } from '#/_base/log/log';
|
||||
import { escapeXmlAttr } from '#/_base/utils/xml-escape';
|
||||
import { IAgentContextInjectorService } from '#/agent/contextInjector';
|
||||
import { IAgentContextMemoryService } from '#/agent/contextMemory';
|
||||
import { IAgentSystemReminderService } from '#/agent/systemReminder';
|
||||
import { IPluginService } from '#/app/plugin/plugin';
|
||||
import type { EnabledPluginSessionStart } from '#/app/plugin/types';
|
||||
import { ISessionSkillCatalog } from '#/session/sessionSkillCatalog/skillCatalog';
|
||||
import { ISessionContext } from '#/session/sessionContext/sessionContext';
|
||||
import type { SkillCatalog, SkillDefinition } from '#/app/skillCatalog/types';
|
||||
import { IAgentSystemReminderService } from '#/agent/systemReminder';
|
||||
import { ISessionContext } from '#/session/sessionContext/sessionContext';
|
||||
import { ISessionSkillCatalog } from '#/session/sessionSkillCatalog/skillCatalog';
|
||||
|
||||
import { IAgentContextInjectorService } from './contextInjector';
|
||||
import { IAgentPluginService } from './agentPlugin';
|
||||
|
||||
const INJECTION_VARIANT = 'plugin_session_start';
|
||||
const SESSION_START_INJECTION_VARIANT = 'plugin_session_start';
|
||||
|
||||
export interface IPluginSessionStartInjectorService {
|
||||
readonly _serviceBrand: undefined;
|
||||
}
|
||||
|
||||
export const IPluginSessionStartInjectorService: ServiceIdentifier<IPluginSessionStartInjectorService> =
|
||||
createDecorator<IPluginSessionStartInjectorService>('pluginSessionStartInjectorService');
|
||||
|
||||
export class PluginSessionStartInjectorService
|
||||
extends Disposable
|
||||
implements IPluginSessionStartInjectorService
|
||||
{
|
||||
export class AgentPluginService extends Disposable implements IAgentPluginService {
|
||||
declare readonly _serviceBrand: undefined;
|
||||
|
||||
constructor(
|
||||
@IAgentContextInjectorService private readonly injector: IAgentContextInjectorService,
|
||||
@IAgentContextInjectorService injector: IAgentContextInjectorService,
|
||||
@IAgentSystemReminderService private readonly reminders: IAgentSystemReminderService,
|
||||
@IAgentContextMemoryService private readonly context: IAgentContextMemoryService,
|
||||
@IPluginService private readonly plugins: IPluginService,
|
||||
|
|
@ -53,16 +39,23 @@ export class PluginSessionStartInjectorService
|
|||
) {
|
||||
super();
|
||||
this._register(
|
||||
this.injector.register(INJECTION_VARIANT, () => this.renderReminder(), { cadence: 'turn' }),
|
||||
injector.register(
|
||||
SESSION_START_INJECTION_VARIANT,
|
||||
async ({ injectedPositions }) => {
|
||||
if (injectedPositions.length > 0) return undefined;
|
||||
return this.renderSessionStartReminder();
|
||||
},
|
||||
{ cadence: 'turn' },
|
||||
),
|
||||
);
|
||||
this._register(
|
||||
this.skillCatalog.onDidChange(() => {
|
||||
void this.appendReminderOnReload();
|
||||
void this.appendFreshSessionStartReminder();
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
private async renderReminder(): Promise<string | undefined> {
|
||||
private async renderSessionStartReminder(): Promise<string | undefined> {
|
||||
const sessionStarts = await this.plugins.enabledSessionStarts();
|
||||
if (sessionStarts.length === 0) return undefined;
|
||||
await this.skillCatalog.ready;
|
||||
|
|
@ -74,38 +67,31 @@ export class PluginSessionStartInjectorService
|
|||
});
|
||||
}
|
||||
|
||||
private async appendReminderOnReload(): Promise<void> {
|
||||
const sessionStarts = await this.plugins.enabledSessionStarts();
|
||||
await this.skillCatalog.ready;
|
||||
const reminder = renderPluginSessionStartReminder({
|
||||
sessionStarts,
|
||||
catalog: this.skillCatalog.catalog,
|
||||
log: this.log,
|
||||
sessionId: this.sessionContext.sessionId,
|
||||
});
|
||||
async appendFreshSessionStartReminder(): Promise<void> {
|
||||
const reminder = await this.renderSessionStartReminder();
|
||||
if (reminder !== undefined) {
|
||||
this.reminders.appendSystemReminder(
|
||||
`${reminder}\n\nThis supersedes any earlier plugin_session_start reminder in this session.`,
|
||||
{ kind: 'injection', variant: INJECTION_VARIANT },
|
||||
{ kind: 'injection', variant: SESSION_START_INJECTION_VARIANT },
|
||||
);
|
||||
} else if (shouldNeutralizePluginSessionStart(this.context.get())) {
|
||||
this.reminders.appendSystemReminder(
|
||||
'There are currently no active plugin session starts. ' +
|
||||
'This supersedes any earlier plugin_session_start reminder in this session.',
|
||||
{ kind: 'injection', variant: INJECTION_VARIANT },
|
||||
{ kind: 'injection', variant: SESSION_START_INJECTION_VARIANT },
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export interface RenderPluginSessionStartReminderInput {
|
||||
interface RenderPluginSessionStartReminderInput {
|
||||
readonly sessionStarts: readonly EnabledPluginSessionStart[];
|
||||
readonly catalog: SkillCatalog | undefined;
|
||||
readonly log?: { warn(message: string, payload?: unknown): void };
|
||||
readonly sessionId?: string;
|
||||
}
|
||||
|
||||
export function renderPluginSessionStartReminder(
|
||||
function renderPluginSessionStartReminder(
|
||||
input: RenderPluginSessionStartReminderInput,
|
||||
): string | undefined {
|
||||
const { sessionStarts, catalog, log, sessionId } = input;
|
||||
|
|
@ -128,18 +114,13 @@ export function renderPluginSessionStartReminder(
|
|||
return blocks.length > 0 ? blocks.join('\n') : undefined;
|
||||
}
|
||||
|
||||
/**
|
||||
* True when the context may still carry stale plugin guidance — an earlier
|
||||
* `<plugin_session_start>` reminder or a compaction summary that may have
|
||||
* folded one in — so a neutralizing reminder should replace it.
|
||||
*/
|
||||
export function shouldNeutralizePluginSessionStart(
|
||||
function shouldNeutralizePluginSessionStart(
|
||||
history: readonly { readonly origin?: { readonly kind: string; readonly variant?: string } }[],
|
||||
): boolean {
|
||||
return history.some((message) => {
|
||||
const kind = message.origin?.kind;
|
||||
if (kind === 'injection') {
|
||||
return message.origin?.variant === INJECTION_VARIANT;
|
||||
return message.origin?.variant === SESSION_START_INJECTION_VARIANT;
|
||||
}
|
||||
return kind === 'compaction_summary';
|
||||
});
|
||||
|
|
@ -158,8 +139,8 @@ function renderSessionStartBlock(
|
|||
|
||||
registerScopedService(
|
||||
LifecycleScope.Agent,
|
||||
IPluginSessionStartInjectorService,
|
||||
PluginSessionStartInjectorService,
|
||||
IAgentPluginService,
|
||||
AgentPluginService,
|
||||
InstantiationType.Delayed,
|
||||
'contextInjector',
|
||||
'agentPlugin',
|
||||
);
|
||||
2
packages/agent-core-v2/src/agent/plugin/index.ts
Normal file
2
packages/agent-core-v2/src/agent/plugin/index.ts
Normal file
|
|
@ -0,0 +1,2 @@
|
|||
export * from './agentPlugin';
|
||||
export * from './agentPluginService';
|
||||
|
|
@ -93,6 +93,7 @@ export * from '#/agent/systemReminder';
|
|||
export * from '#/agent/contextProjector';
|
||||
export * from '#/agent/contextSize';
|
||||
export * from '#/agent/contextInjector';
|
||||
export * from '#/agent/plugin';
|
||||
export * from '#/agent/externalHooks';
|
||||
export * from '#/agent/fullCompaction';
|
||||
export * from '#/agent/llmRequester';
|
||||
|
|
|
|||
|
|
@ -4,17 +4,17 @@
|
|||
* The main agent is an ordinary agent whose only distinction is
|
||||
* `agentId === 'main'`; `IAgentLifecycleService` itself knows nothing about
|
||||
* it. What *is* main-specific is session bootstrap business: the plugin
|
||||
* session-start injector registers its turn-cadence injection on the main
|
||||
* agent only (matching v1's `pluginSessionStarts: type === 'main' ? … :
|
||||
* undefined`). `ensureMainAgent` concentrates that business in one place so
|
||||
* every bootstrapper (session resume, legacy session/message services, the
|
||||
* server edge) creates the main agent the same way.
|
||||
* session-start service registers main-agent-only plugin guidance (matching
|
||||
* v1's `pluginSessionStarts: type === 'main' ? … : undefined`). `ensureMainAgent`
|
||||
* concentrates that business in one place so every bootstrapper (session
|
||||
* resume, legacy session/message services, the server edge) creates the main
|
||||
* agent the same way.
|
||||
*
|
||||
* Not a Service: a pure composition helper over the session handle.
|
||||
*/
|
||||
|
||||
import type { ISessionScopeHandle, IAgentScopeHandle } from '#/_base/di/scope';
|
||||
import { IPluginSessionStartInjectorService } from '#/agent/contextInjector';
|
||||
import { IAgentPluginService } from '#/agent/plugin';
|
||||
import type { BindAgentInput } from '#/agent/profile';
|
||||
|
||||
import { IAgentLifecycleService } from './agentLifecycle';
|
||||
|
|
@ -38,9 +38,9 @@ export async function ensureMainAgent(
|
|||
const existing = agents.getHandle(MAIN_AGENT_ID);
|
||||
if (existing !== undefined) return existing;
|
||||
const main = await agents.create({ agentId: MAIN_AGENT_ID, binding: opts?.binding });
|
||||
// Force-instantiate the plugin session-start injector so it registers its
|
||||
// turn-cadence injection before the first turn. Main-agent-only business.
|
||||
main.accessor.get(IPluginSessionStartInjectorService);
|
||||
// Force-instantiate the agent plugin service so main-agent-only plugin
|
||||
// guidance is registered before the first turn.
|
||||
main.accessor.get(IAgentPluginService);
|
||||
// Notify main-only capabilities (e.g. the cron tool registrar) that the main
|
||||
// agent is ready, so they bind to it without filtering every `onDidCreate`.
|
||||
agents.notifyMainCreated(main);
|
||||
|
|
|
|||
|
|
@ -11,7 +11,7 @@ import { AgentLifecycleService } from '#/session/agentLifecycle/agentLifecycleSe
|
|||
import { IBootstrapService } from '#/app/bootstrap/bootstrap';
|
||||
import { IConfigService } from '#/app/config/config';
|
||||
import { IAgentBlobService } from '#/agent/blob';
|
||||
import { IPluginSessionStartInjectorService } from '#/agent/contextInjector';
|
||||
import { IAgentPluginService } from '#/agent/plugin';
|
||||
import { ILogService } from '#/_base/log/log';
|
||||
import { IPluginService } from '#/app/plugin/plugin';
|
||||
import { IAppendLogStore } from '#/persistence/interface/appendLogStore';
|
||||
|
|
@ -178,8 +178,9 @@ describe('AgentLifecycleService', () => {
|
|||
acquire: () => ({ dispose: () => {} }),
|
||||
} satisfies IAtomicDocumentStore);
|
||||
ix.stub(ILogService, noopLog);
|
||||
ix.stub(IPluginSessionStartInjectorService, {
|
||||
ix.stub(IAgentPluginService, {
|
||||
_serviceBrand: undefined,
|
||||
appendFreshSessionStartReminder: async () => {},
|
||||
});
|
||||
ix.stub(IAgentToolRegistryService, {
|
||||
_serviceBrand: undefined,
|
||||
|
|
|
|||
|
|
@ -1199,12 +1199,14 @@ export class AgentTestContext {
|
|||
this.pluginSessionStartRegistered = true;
|
||||
this.get(IAgentContextInjectorService).register(
|
||||
'plugin_session_start',
|
||||
async () =>
|
||||
renderPluginSessionStartReminder(
|
||||
async ({ injectedPositions }) => {
|
||||
if (injectedPositions.length > 0) return undefined;
|
||||
return renderPluginSessionStartReminder(
|
||||
sessionStarts,
|
||||
skillCatalog,
|
||||
this.options['log'] as { warn(message: string, payload?: unknown): void } | undefined,
|
||||
),
|
||||
);
|
||||
},
|
||||
{ cadence: 'turn' },
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -3,9 +3,11 @@ import { afterEach, describe, expect, it } from 'vitest';
|
|||
import { SyncDescriptor } from '#/_base/di/descriptors';
|
||||
import { Emitter } from '#/_base/event';
|
||||
import {
|
||||
IPluginSessionStartInjectorService,
|
||||
PluginSessionStartInjectorService,
|
||||
} from '#/agent/contextInjector/pluginSessionStart';
|
||||
IAgentPluginService,
|
||||
AgentPluginService,
|
||||
} from '#/agent/plugin';
|
||||
import { IAgentContextInjectorService } from '#/agent/contextInjector';
|
||||
import { IEventBus } from '#/app/event/eventBus';
|
||||
import { IPluginService } from '#/app/plugin/plugin';
|
||||
import type { EnabledPluginSessionStart, ReloadSummary } from '#/app/plugin/types';
|
||||
import { InMemorySkillCatalog } from '#/app/skillCatalog/registry';
|
||||
|
|
@ -64,7 +66,11 @@ function messageText(message: { readonly content: readonly { readonly type: stri
|
|||
return message.content.map((part) => (part.type === 'text' ? (part.text ?? '') : '')).join('');
|
||||
}
|
||||
|
||||
describe('PluginSessionStartInjectorService (production wiring)', () => {
|
||||
async function injectRegistered(ctx: TestAgentContext): Promise<void> {
|
||||
await (ctx.get(IAgentContextInjectorService) as unknown as { inject(): Promise<void> }).inject();
|
||||
}
|
||||
|
||||
describe('AgentPluginService plugin session-start wiring', () => {
|
||||
let ctx: TestAgentContext | undefined;
|
||||
|
||||
afterEach(async () => {
|
||||
|
|
@ -72,7 +78,7 @@ describe('PluginSessionStartInjectorService (production wiring)', () => {
|
|||
ctx = undefined;
|
||||
});
|
||||
|
||||
it('injects the plugin session-start reminder through the real service during a turn', async () => {
|
||||
it('injects the plugin session-start reminder through the real service registration', async () => {
|
||||
const catalog = new InMemorySkillCatalog();
|
||||
catalog.register(pluginSkill());
|
||||
|
||||
|
|
@ -84,17 +90,15 @@ describe('PluginSessionStartInjectorService (production wiring)', () => {
|
|||
),
|
||||
skillServices(catalog),
|
||||
agentService(
|
||||
IPluginSessionStartInjectorService,
|
||||
new SyncDescriptor(PluginSessionStartInjectorService),
|
||||
IAgentPluginService,
|
||||
new SyncDescriptor(AgentPluginService),
|
||||
),
|
||||
);
|
||||
|
||||
// Force-instantiate the real injector (production does this from createMain).
|
||||
ctx.get(IPluginSessionStartInjectorService);
|
||||
// Force-instantiate the real service (production does this from createMain).
|
||||
ctx.get(IAgentPluginService);
|
||||
|
||||
ctx.mockNextResponse({ type: 'text', text: 'done' });
|
||||
await ctx.rpc.prompt({ input: [{ type: 'text', text: 'hello' }] });
|
||||
await ctx.untilTurnEnd();
|
||||
await injectRegistered(ctx);
|
||||
|
||||
const injected = findPluginSessionStartMessages(ctx).at(-1);
|
||||
expect(injected).toBeDefined();
|
||||
|
|
@ -104,6 +108,35 @@ describe('PluginSessionStartInjectorService (production wiring)', () => {
|
|||
expect(text).toContain('Always be helpful.');
|
||||
});
|
||||
|
||||
it('does not re-inject the plugin session-start reminder on later turns while it remains live', async () => {
|
||||
const catalog = new InMemorySkillCatalog();
|
||||
catalog.register(pluginSkill());
|
||||
|
||||
ctx = createTestAgent(
|
||||
{ autoConfigure: true },
|
||||
appService(
|
||||
IPluginService,
|
||||
pluginServiceStub({ sessionStarts: [{ pluginId: 'demo', skillName: 'demo-skill' }] }),
|
||||
),
|
||||
skillServices(catalog),
|
||||
agentService(
|
||||
IAgentPluginService,
|
||||
new SyncDescriptor(AgentPluginService),
|
||||
),
|
||||
);
|
||||
|
||||
ctx.get(IAgentPluginService);
|
||||
|
||||
await injectRegistered(ctx);
|
||||
ctx.get(IEventBus).publish({
|
||||
type: 'turn.started',
|
||||
turnId: 2,
|
||||
});
|
||||
await injectRegistered(ctx);
|
||||
|
||||
expect(findPluginSessionStartMessages(ctx)).toHaveLength(1);
|
||||
});
|
||||
|
||||
it('does not inject when no plugin session starts are enabled', async () => {
|
||||
const catalog = new InMemorySkillCatalog();
|
||||
catalog.register(pluginSkill());
|
||||
|
|
@ -113,16 +146,14 @@ describe('PluginSessionStartInjectorService (production wiring)', () => {
|
|||
appService(IPluginService, pluginServiceStub({ sessionStarts: [] })),
|
||||
skillServices(catalog),
|
||||
agentService(
|
||||
IPluginSessionStartInjectorService,
|
||||
new SyncDescriptor(PluginSessionStartInjectorService),
|
||||
IAgentPluginService,
|
||||
new SyncDescriptor(AgentPluginService),
|
||||
),
|
||||
);
|
||||
|
||||
ctx.get(IPluginSessionStartInjectorService);
|
||||
ctx.get(IAgentPluginService);
|
||||
|
||||
ctx.mockNextResponse({ type: 'text', text: 'done' });
|
||||
await ctx.rpc.prompt({ input: [{ type: 'text', text: 'hello' }] });
|
||||
await ctx.untilTurnEnd();
|
||||
await injectRegistered(ctx);
|
||||
|
||||
expect(findPluginSessionStartMessages(ctx)).toHaveLength(0);
|
||||
});
|
||||
|
|
@ -150,25 +181,22 @@ describe('PluginSessionStartInjectorService (production wiring)', () => {
|
|||
),
|
||||
skillServices(skillCatalog),
|
||||
agentService(
|
||||
IPluginSessionStartInjectorService,
|
||||
new SyncDescriptor(PluginSessionStartInjectorService),
|
||||
IAgentPluginService,
|
||||
new SyncDescriptor(AgentPluginService),
|
||||
),
|
||||
);
|
||||
|
||||
ctx.get(IPluginSessionStartInjectorService);
|
||||
ctx.get(IAgentPluginService);
|
||||
|
||||
ctx.mockNextResponse({ type: 'text', text: 'done' });
|
||||
await ctx.rpc.prompt({ input: [{ type: 'text', text: 'hello' }] });
|
||||
await ctx.untilTurnEnd();
|
||||
await injectRegistered(ctx);
|
||||
|
||||
expect(findPluginSessionStartMessages(ctx)).toHaveLength(1);
|
||||
|
||||
// Simulate the skill-catalog sink firing onDidChange (e.g. after a plugin
|
||||
// reload re-pulls the plugin source). appendReminderOnReload is async
|
||||
// reload re-pulls the plugin source). appendFreshSessionStartReminder is async
|
||||
// (awaits skillCatalog.ready); let it settle.
|
||||
sinkChange.fire();
|
||||
await Promise.resolve();
|
||||
await Promise.resolve();
|
||||
await new Promise((resolve) => setTimeout(resolve, 0));
|
||||
|
||||
const messages = findPluginSessionStartMessages(ctx);
|
||||
expect(messages.length).toBeGreaterThanOrEqual(2);
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue