fix: align plugin session start injection

This commit is contained in:
_Kerman 2026-07-07 14:43:04 +08:00
parent d25cb76138
commit ec133ce2a4
10 changed files with 129 additions and 98 deletions

View file

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

View file

@ -4,4 +4,3 @@
export * from './contextInjector';
export * from './contextInjectorService';
export * from './pluginSessionStart';

View 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');

View file

@ -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',
);

View file

@ -0,0 +1,2 @@
export * from './agentPlugin';
export * from './agentPluginService';

View file

@ -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';

View file

@ -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);

View file

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

View file

@ -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' },
);
}

View file

@ -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);