diff --git a/packages/agent-core-v2/scripts/check-domain-layers.mjs b/packages/agent-core-v2/scripts/check-domain-layers.mjs index b244061fb..b0686eb22 100644 --- a/packages/agent-core-v2/scripts/check-domain-layers.mjs +++ b/packages/agent-core-v2/scripts/check-domain-layers.mjs @@ -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. diff --git a/packages/agent-core-v2/src/agent/contextInjector/index.ts b/packages/agent-core-v2/src/agent/contextInjector/index.ts index 89d76169f..08029731c 100644 --- a/packages/agent-core-v2/src/agent/contextInjector/index.ts +++ b/packages/agent-core-v2/src/agent/contextInjector/index.ts @@ -4,4 +4,3 @@ export * from './contextInjector'; export * from './contextInjectorService'; -export * from './pluginSessionStart'; diff --git a/packages/agent-core-v2/src/agent/plugin/agentPlugin.ts b/packages/agent-core-v2/src/agent/plugin/agentPlugin.ts new file mode 100644 index 000000000..a39f4a61d --- /dev/null +++ b/packages/agent-core-v2/src/agent/plugin/agentPlugin.ts @@ -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 = + createDecorator('agentPluginService'); diff --git a/packages/agent-core-v2/src/agent/contextInjector/pluginSessionStart.ts b/packages/agent-core-v2/src/agent/plugin/agentPluginService.ts similarity index 60% rename from packages/agent-core-v2/src/agent/contextInjector/pluginSessionStart.ts rename to packages/agent-core-v2/src/agent/plugin/agentPluginService.ts index 722f44397..980871dca 100644 --- a/packages/agent-core-v2/src/agent/contextInjector/pluginSessionStart.ts +++ b/packages/agent-core-v2/src/agent/plugin/agentPluginService.ts @@ -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 = - createDecorator('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 { + private async renderSessionStartReminder(): Promise { 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 { - 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 { + 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 - * `` 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', ); diff --git a/packages/agent-core-v2/src/agent/plugin/index.ts b/packages/agent-core-v2/src/agent/plugin/index.ts new file mode 100644 index 000000000..a6e2b9b33 --- /dev/null +++ b/packages/agent-core-v2/src/agent/plugin/index.ts @@ -0,0 +1,2 @@ +export * from './agentPlugin'; +export * from './agentPluginService'; diff --git a/packages/agent-core-v2/src/index.ts b/packages/agent-core-v2/src/index.ts index e74a33947..ddba9050f 100644 --- a/packages/agent-core-v2/src/index.ts +++ b/packages/agent-core-v2/src/index.ts @@ -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'; diff --git a/packages/agent-core-v2/src/session/agentLifecycle/mainAgent.ts b/packages/agent-core-v2/src/session/agentLifecycle/mainAgent.ts index c654df4de..526f44798 100644 --- a/packages/agent-core-v2/src/session/agentLifecycle/mainAgent.ts +++ b/packages/agent-core-v2/src/session/agentLifecycle/mainAgent.ts @@ -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); diff --git a/packages/agent-core-v2/test/agentLifecycle/agentLifecycle.test.ts b/packages/agent-core-v2/test/agentLifecycle/agentLifecycle.test.ts index a9787b067..87a0d9a71 100644 --- a/packages/agent-core-v2/test/agentLifecycle/agentLifecycle.test.ts +++ b/packages/agent-core-v2/test/agentLifecycle/agentLifecycle.test.ts @@ -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, diff --git a/packages/agent-core-v2/test/harness/agent.ts b/packages/agent-core-v2/test/harness/agent.ts index 1ae20500b..016287de8 100644 --- a/packages/agent-core-v2/test/harness/agent.ts +++ b/packages/agent-core-v2/test/harness/agent.ts @@ -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' }, ); } diff --git a/packages/agent-core-v2/test/plugin/plugin-session-start.test.ts b/packages/agent-core-v2/test/plugin/plugin-session-start.test.ts index fb86d438c..d230fe186 100644 --- a/packages/agent-core-v2/test/plugin/plugin-session-start.test.ts +++ b/packages/agent-core-v2/test/plugin/plugin-session-start.test.ts @@ -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 { + await (ctx.get(IAgentContextInjectorService) as unknown as { inject(): Promise }).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);