diff --git a/packages/agent-core-v2/docs/state-manifest.d.ts b/packages/agent-core-v2/docs/state-manifest.d.ts index 4f6bded88c..3e397ab5ba 100644 --- a/packages/agent-core-v2/docs/state-manifest.d.ts +++ b/packages/agent-core-v2/docs/state-manifest.d.ts @@ -23,7 +23,7 @@ // references become '(circular)', and class instances collapse to a '(ClassName)' // marker — the wire shape of an entry is the JSON projection of the type here. // -// Index (Session: 28 keys · Agent: 68 keys) +// Index (Session: 28 keys · Agent: 70 keys) // Session // cron.inFlight src/session/cron/sessionCronServiceImpl.ts // cron.lastSeenAt src/session/cron/sessionCronServiceImpl.ts @@ -59,9 +59,11 @@ // activityView.lastTurn src/agent/activityView/activityViewService.ts // activityView.lifecycle src/agent/activityView/activityViewService.ts // activityView.turn src/agent/activityView/activityViewService.ts +// agentsMdReminder.seededContent src/agent/profile/agentsMdReminderService.ts // contextInjector.isNewTurn src/agent/contextInjector/contextInjectorService.ts // contextProjector.lastRepairSignature src/agent/contextProjector/contextProjectorService.ts // contextSize.lastEmittedTokens src/agent/contextSize/contextSizeService.ts +// dateChange.seededDate src/agent/dateChange/dateChangeService.ts // externalHooks.stopHookContinuationUsed src/agent/externalHooks/externalHooksService.ts // faultInjection.armed src/agent/faultInjection/faultInjectionService.ts // faultInjection.fired src/agent/faultInjection/faultInjectionService.ts @@ -582,6 +584,10 @@ export interface SessionStateSnapshot { }[]; getKimiSkillsDescription: () => string; getModelSkillListing: () => string; + getModelSkillDisclosure: () => /* ModelSkillDisclosure — packages/agent-core-v2/src/app/skillCatalog/types.ts */ { + readonly names: readonly string[]; + readonly listing: string; + }; }; // src/session/sessionToolPolicy/sessionToolPolicyService.ts 'sessionToolPolicy.state': /* SessionToolPolicyState — packages/agent-core-v2/src/session/sessionToolPolicy/sessionToolPolicyService.ts */ { @@ -978,6 +984,8 @@ export interface AgentStateSnapshot { 'contextProjector.lastRepairSignature': string | null; // src/agent/contextSize/contextSizeService.ts 'contextSize.lastEmittedTokens': number; + // src/agent/dateChange/dateChangeService.ts + 'dateChange.seededDate': string | undefined; // src/agent/externalHooks/externalHooksService.ts 'externalHooks.stopHookContinuationUsed': boolean; // src/agent/faultInjection/faultInjectionService.ts @@ -1010,7 +1018,7 @@ export interface AgentStateSnapshot { 'llmRequester.lastConfigLogSignature': string | undefined; 'llmRequester.mediaDegradedTurns': Set; 'llmRequester.mediaStrippedTurns': Map; 'llmRequester.turnConfigs': Map = + createDecorator('agentDateChangeService'); diff --git a/packages/agent-core-v2/src/agent/dateChange/dateChangeService.ts b/packages/agent-core-v2/src/agent/dateChange/dateChangeService.ts new file mode 100644 index 0000000000..bf79973302 --- /dev/null +++ b/packages/agent-core-v2/src/agent/dateChange/dateChangeService.ts @@ -0,0 +1,116 @@ +/** + * `dateChange` domain (L4) — `IAgentDateChangeService` implementation. + * + * Owns the `date_change` context-injection provider. The system prompt is only + * re-rendered at profile (re)bind and after compaction, so a session that runs + * past midnight keeps a stale date; this provider appends a system-reminder at + * the next step boundary instead. The baseline is history-derived: the last + * `date_change` reminder in context, else the date rendered into the current + * system prompt, else the volatile `seededDate` adopted at first evaluation + * (custom profiles without a date line). Dedup and resume safety fall out of + * the ladder — nothing is persisted beyond the reminder messages themselves. + * The plain-data state (`seededDate`) is registered into `agentState` + * (`IAgentStateService`) and read/written through it. Bound at Agent scope. + */ + +import { Disposable } from '#/_base/di/lifecycle'; +import { LifecycleScope, ScopeActivation, registerScopedService } from '#/_base/di/scope'; +import { defineState } from '#/_base/state/stateRegistry'; +import { + IAgentContextInjectorService, + type ContextInjectionContext, +} from '#/agent/contextInjector/contextInjector'; +import { IAgentContextMemoryService } from '#/agent/contextMemory/contextMemory'; +import type { ContextMessage } from '#/agent/contextMemory/types'; +import { IAgentProfileService } from '#/agent/profile/profile'; +import { IAgentStateService } from '#/agent/state/agentState'; + +import { IAgentDateChangeService } from './dateChange'; + +const DATE_CHANGE_INJECTION_VARIANT = 'date_change'; + +const SYSTEM_PROMPT_NOW_PATTERN = /current date and time in ISO format is `([^`]+)`/; +const REMINDER_DATE_PATTERN = /Today's date is now (\d{4}-\d{2}-\d{2})/; + +export const dateChangeSeededDateKey = defineState( + 'dateChange.seededDate', + () => undefined, +); + +export class AgentDateChangeService extends Disposable implements IAgentDateChangeService { + declare readonly _serviceBrand: undefined; + + constructor( + @IAgentContextInjectorService dynamicInjector: IAgentContextInjectorService, + @IAgentContextMemoryService private readonly context: IAgentContextMemoryService, + @IAgentProfileService private readonly profile: IAgentProfileService, + @IAgentStateService private readonly states: IAgentStateService, + ) { + super(); + this.states.register(dateChangeSeededDateKey); + this._register( + dynamicInjector.register(DATE_CHANGE_INJECTION_VARIANT, (ctx) => this.reminder(ctx)), + ); + } + + private get seededDate(): string | undefined { + return this.states.get(dateChangeSeededDateKey); + } + + private set seededDate(value: string | undefined) { + this.states.set(dateChangeSeededDateKey, value); + } + + private reminder({ lastInjectedAt }: ContextInjectionContext): string | undefined { + const today = localDateKey(new Date()); + const baseline = this.baseline(lastInjectedAt) ?? this.adopt(today); + if (baseline === today) return undefined; + return `The date has changed. Today's date is now ${today}. The date and time stated in your system prompt are stale; rely on this reminder for the current date. DO NOT mention this to the user explicitly.`; + } + + private baseline(lastInjectedAt: number | null): string | undefined { + return this.dateFromHistory(lastInjectedAt) ?? this.dateFromSystemPrompt() ?? this.seededDate; + } + + private adopt(today: string): string { + this.seededDate = today; + return today; + } + + private dateFromHistory(lastInjectedAt: number | null): string | undefined { + if (lastInjectedAt === null) return undefined; + const message: ContextMessage | undefined = this.context.get()[lastInjectedAt]; + if (message === undefined) return undefined; + const match = REMINDER_DATE_PATTERN.exec(messageText(message)); + return match?.[1]; + } + + private dateFromSystemPrompt(): string | undefined { + const match = SYSTEM_PROMPT_NOW_PATTERN.exec(this.profile.getSystemPrompt()); + if (match?.[1] === undefined) return undefined; + const rendered = new Date(match[1]); + if (Number.isNaN(rendered.getTime())) return undefined; + return localDateKey(rendered); + } +} + +function messageText(message: ContextMessage): string { + return message.content + .map((part) => (part.type === 'text' ? part.text : '')) + .join(''); +} + +function localDateKey(date: Date): string { + const year = date.getFullYear(); + const month = `${date.getMonth() + 1}`.padStart(2, '0'); + const day = `${date.getDate()}`.padStart(2, '0'); + return `${year}-${month}-${day}`; +} + +registerScopedService( + LifecycleScope.Agent, + IAgentDateChangeService, + AgentDateChangeService, + ScopeActivation.OnScopeCreated, + 'dateChange', +); diff --git a/packages/agent-core-v2/src/agent/plugin/agentPluginOps.ts b/packages/agent-core-v2/src/agent/plugin/agentPluginOps.ts new file mode 100644 index 0000000000..394d1bc6d2 --- /dev/null +++ b/packages/agent-core-v2/src/agent/plugin/agentPluginOps.ts @@ -0,0 +1,48 @@ +/** + * `agentPlugin` domain (L4) — persistent plugin session-start baseline. + * + * Defines the checkpointed Agent wire model used by `agentPlugin` to keep the + * last model-facing session-start fingerprint aligned with replay, resume, and + * conversation undo. + */ + +import { z } from 'zod'; + +import { defineCheckpointedModel } from '#/agent/contextMemory/conversationTime'; + +export interface AgentPluginModelState { + readonly sessionStartFingerprint?: string; + readonly sessionStartActive: boolean; +} + +export const AgentPluginModel = defineCheckpointedModel( + 'agentPlugin', + () => ({ sessionStartActive: false }), +); + +export const setPluginSessionStartBaseline = AgentPluginModel.defineOp( + 'plugin.session_start.set_baseline', + { + schema: z.object({ + fingerprint: z.string(), + active: z.boolean(), + }), + apply: (state, payload) => + state.current.sessionStartFingerprint === payload.fingerprint && + state.current.sessionStartActive === payload.active + ? state + : { + ...state, + current: { + sessionStartFingerprint: payload.fingerprint, + sessionStartActive: payload.active, + }, + }, + }, +); + +declare module '#/wire/types' { + interface PersistedOpMap { + 'plugin.session_start.set_baseline': typeof setPluginSessionStartBaseline; + } +} diff --git a/packages/agent-core-v2/src/agent/plugin/agentPluginService.ts b/packages/agent-core-v2/src/agent/plugin/agentPluginService.ts index 3fc8198b61..0b1513dc84 100644 --- a/packages/agent-core-v2/src/agent/plugin/agentPluginService.ts +++ b/packages/agent-core-v2/src/agent/plugin/agentPluginService.ts @@ -2,14 +2,18 @@ * `agentPlugin` domain (L4) — `IAgentPluginService` implementation. * * Renders session-start skills from `plugin` and `sessionSkillCatalog`, injects - * them through `contextInjector` and `systemReminder`, and uses `contextMemory` - * to neutralize stale guidance. Main-agent-only (v1 parity): the service + * them through `contextInjector` and uses `contextMemory` to neutralize stale + * guidance. Main-agent-only (v1 parity): the service * self-gates on `agentId === 'main'`; Agent scope creation instantiates it for * every agent, so other agents construct it as a no-op. Resolves * session prompt context through `sessionContext` and reports missing skills - * through `log`. Bound at Agent scope. + * through `log`. Persists the conversation-time rendered baseline through + * `wire`, while catalog revisions remain world-time state. Bound at Agent + * scope. */ +import { createHash } from 'node:crypto'; + import { Disposable } from '#/_base/di/lifecycle'; import { LifecycleScope, ScopeActivation, registerScopedService } from '#/_base/di/scope'; import { ILogService } from '#/_base/log/log'; @@ -17,17 +21,25 @@ import { escapeXmlAttr } from '#/_base/utils/xml-escape'; import { IAgentContextInjectorService } from '#/agent/contextInjector/contextInjector'; import { IAgentContextMemoryService } from '#/agent/contextMemory/contextMemory'; import { IAgentScopeContext } from '#/agent/scopeContext/scopeContext'; -import { IAgentSystemReminderService } from '#/agent/systemReminder/systemReminder'; import { IPluginService } from '#/app/plugin/plugin'; import type { EnabledPluginSessionStart } from '#/app/plugin/types'; import type { SkillCatalog, SkillDefinition } from '#/app/skillCatalog/types'; import { ISessionContext } from '#/session/sessionContext/sessionContext'; import { ISessionSkillCatalog } from '#/session/sessionSkillCatalog/skillCatalog'; import { PLUGIN_SKILL_SOURCE_ID } from '#/session/sessionSkillCatalog/pluginSkillSource'; +import { IWireService } from '#/wire/wire'; import { IAgentPluginService } from './agentPlugin'; +import { + AgentPluginModel, + setPluginSessionStartBaseline, +} from './agentPluginOps'; const SESSION_START_INJECTION_VARIANT = 'plugin_session_start'; +const SESSION_START_SUPERSEDED_NOTE = + 'This supersedes any earlier plugin_session_start reminder in this session.'; +const SESSION_START_INACTIVE_REMINDER = + 'There are currently no active plugin session starts. ' + SESSION_START_SUPERSEDED_NOTE; // The main agent's id, kept as a local literal: `MAIN_AGENT_ID` lives in the // L6 `agentLifecycle` domain and this L4 domain must not import it. @@ -36,14 +48,19 @@ const MAIN_AGENT_ID = 'main'; export class AgentPluginService extends Disposable implements IAgentPluginService { declare readonly _serviceBrand: undefined; + private operationTail: Promise = Promise.resolve(); + private catalogRevision = 0; + private renderedCatalogRevision = -1; + private renderedSessionStartReminder: string | undefined; + constructor( @IAgentScopeContext scopeContext: IAgentScopeContext, @IAgentContextInjectorService injector: IAgentContextInjectorService, - @IAgentSystemReminderService private readonly reminders: IAgentSystemReminderService, @IAgentContextMemoryService private readonly context: IAgentContextMemoryService, @IPluginService private readonly plugins: IPluginService, @ISessionSkillCatalog private readonly skillCatalog: ISessionSkillCatalog, @ISessionContext private readonly sessionContext: ISessionContext, + @IWireService private readonly wire: IWireService, @ILogService private readonly log: ILogService, ) { super(); @@ -55,16 +72,13 @@ export class AgentPluginService extends Disposable implements IAgentPluginServic this._register( injector.register( SESSION_START_INJECTION_VARIANT, - async ({ injectedPositions }) => { - if (injectedPositions.length > 0) return undefined; - return this.renderSessionStartReminder(); - }, + async () => this.enqueue(() => this.reconcileSessionStartReminder()), ), ); this._register( this.skillCatalog.onDidChange((sourceId) => { if (sourceId === PLUGIN_SKILL_SOURCE_ID) { - void this.appendFreshSessionStartReminder(); + this.catalogRevision += 1; } }), ); @@ -82,21 +96,89 @@ export class AgentPluginService extends Disposable implements IAgentPluginServic }); } - async appendFreshSessionStartReminder(): Promise { - const reminder = await this.renderSessionStartReminder(); + private async renderLatestSessionStartReminder(): Promise { + while (this.renderedCatalogRevision !== this.catalogRevision) { + const revision = this.catalogRevision; + const reminder = await this.renderSessionStartReminder(); + if (revision !== this.catalogRevision) continue; + this.renderedSessionStartReminder = reminder; + this.renderedCatalogRevision = revision; + } + return this.renderedSessionStartReminder; + } + + private enqueue(operation: () => Promise): Promise { + const next = this.operationTail.then(operation); + this.operationTail = next.then( + () => undefined, + (error: unknown) => { + this.log.warn('failed to reconcile plugin sessionStart reminder', error); + }, + ); + return next; + } + + private async reconcileSessionStartReminder(): Promise { + const reminder = await this.renderLatestSessionStartReminder(); + const history = this.context.get(); + const baseline = this.wire.getModel(AgentPluginModel).current; + const fingerprint = pluginSessionStartFingerprint(reminder); + const active = reminder !== undefined; + if ( + baseline.sessionStartFingerprint === fingerprint && + baseline.sessionStartActive === active && + isPluginSessionStartBaselineLive(history, reminder) + ) { + return undefined; + } + + let content: string | undefined; if (reminder !== undefined) { - this.reminders.appendSystemReminder( - `${reminder}\n\nThis supersedes any earlier plugin_session_start reminder in this session.`, - { 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: SESSION_START_INJECTION_VARIANT }, - ); + content = hasPluginSessionStartReminder(history) + ? `${reminder}\n\n${SESSION_START_SUPERSEDED_NOTE}` + : reminder; + } else if (baseline.sessionStartActive || hasPluginSessionStartReminder(history)) { + content = SESSION_START_INACTIVE_REMINDER; + } + if (content !== undefined) this.markSessionStartBaseline(reminder); + return content; + } + + private markSessionStartBaseline(reminder: string | undefined): void { + const fingerprint = pluginSessionStartFingerprint(reminder); + const active = reminder !== undefined; + const baseline = this.wire.getModel(AgentPluginModel).current; + if ( + baseline.sessionStartFingerprint === fingerprint && + baseline.sessionStartActive === active + ) { + return; } + this.wire.dispatch(setPluginSessionStartBaseline({ fingerprint, active })); + } +} + +function isPluginSessionStartBaselineLive( + history: readonly { + readonly content: readonly { readonly type: string; readonly text?: string }[]; + readonly origin?: { readonly kind: string; readonly variant?: string }; + }[], + reminder: string | undefined, +): boolean { + const latest = history.findLast((message) => isPluginSessionStartReminder(message)); + if (latest === undefined) { + return reminder === undefined; } + const text = latest.content + .map((part) => (part.type === 'text' ? (part.text ?? '') : '')) + .join(''); + return reminder === undefined + ? text.includes(SESSION_START_INACTIVE_REMINDER) + : text.includes(reminder); +} + +function pluginSessionStartFingerprint(reminder: string | undefined): string { + return createHash('sha256').update(reminder ?? '').digest('hex'); } interface RenderPluginSessionStartReminderInput { @@ -129,16 +211,19 @@ function renderPluginSessionStartReminder( return blocks.length > 0 ? blocks.join('\n') : undefined; } -function shouldNeutralizePluginSessionStart( +function hasPluginSessionStartReminder( 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 === SESSION_START_INJECTION_VARIANT; - } - return kind === 'compaction_summary'; - }); + return history.some((message) => isPluginSessionStartReminder(message)); +} + +function isPluginSessionStartReminder(message: { + readonly origin?: { readonly kind: string; readonly variant?: string }; +}): boolean { + return ( + message.origin?.kind === 'injection' && + message.origin.variant === SESSION_START_INJECTION_VARIANT + ); } function renderSessionStartBlock( diff --git a/packages/agent-core-v2/src/agent/plugin/index.ts b/packages/agent-core-v2/src/agent/plugin/index.ts index a6e2b9b335..61899c8709 100644 --- a/packages/agent-core-v2/src/agent/plugin/index.ts +++ b/packages/agent-core-v2/src/agent/plugin/index.ts @@ -1,2 +1,3 @@ export * from './agentPlugin'; +export * from './agentPluginOps'; export * from './agentPluginService'; diff --git a/packages/agent-core-v2/src/agent/profile/agentsMdReminder.ts b/packages/agent-core-v2/src/agent/profile/agentsMdReminder.ts new file mode 100644 index 0000000000..c00096e5c2 --- /dev/null +++ b/packages/agent-core-v2/src/agent/profile/agentsMdReminder.ts @@ -0,0 +1,17 @@ +/** + * `profile` domain (L4) — `IAgentAgentsMdReminderService` contract. + * + * Defines the Agent-scope marker service that announces AGENTS.md content + * changes (edits, creations, removals) through an `agents_md` + * context-injection reminder when the files drift from what the system prompt + * was rendered with. + */ + +import { createDecorator, type ServiceIdentifier } from '#/_base/di/instantiation'; + +export interface IAgentAgentsMdReminderService { + readonly _serviceBrand: undefined; +} + +export const IAgentAgentsMdReminderService: ServiceIdentifier = + createDecorator('agentAgentsMdReminderService'); diff --git a/packages/agent-core-v2/src/agent/profile/agentsMdReminderService.ts b/packages/agent-core-v2/src/agent/profile/agentsMdReminderService.ts new file mode 100644 index 0000000000..60908081be --- /dev/null +++ b/packages/agent-core-v2/src/agent/profile/agentsMdReminderService.ts @@ -0,0 +1,198 @@ +/** + * `profile` domain (L4) — `IAgentAgentsMdReminderService` implementation. + * + * Owns the `agents_md` context-injection provider. The AGENTS.md instruction + * hierarchy is baked into the system prompt at (re)bind and after compaction, + * so a user editing AGENTS.md mid-session leaves the model following stale + * rules; this provider injects the fresh content at the next step boundary + * when it differs. Unlike skills, removals and content edits announce too — + * a deleted rule fails silently, never on invocation. The baseline is + * history-derived: the last `agents_md` reminder in context, else the fenced + * AGENTS.md block of the current system prompt, else the volatile + * `seededContent` adopted at first evaluation (custom profiles without the + * fenced block). The live content is read once and then only re-read when the + * shared `pathWatch` subscription reports a candidate change — never + * per step, so the step pipeline carries no filesystem IO (fake-timer retry + * loops included); cwd changes re-arm the watch and force one re-read. The + * plain-data state (`seededContent`) is registered into `agentState` + * (`IAgentStateService`) and read/written through it. Bound at Agent scope. + */ + +import { Disposable } from '#/_base/di/lifecycle'; +import { LifecycleScope, ScopeActivation, registerScopedService } from '#/_base/di/scope'; +import { defineState } from '#/_base/state/stateRegistry'; + +import { + IAgentContextInjectorService, + type ContextInjectionContext, +} from '#/agent/contextInjector/contextInjector'; +import { IAgentContextMemoryService } from '#/agent/contextMemory/contextMemory'; +import type { ContextMessage } from '#/agent/contextMemory/types'; +import { IAgentStateService } from '#/agent/state/agentState'; +import { IBootstrapService } from '#/app/bootstrap/bootstrap'; +import { + type IPathWatch, + IPathWatchService, +} from '#/app/pathWatch/pathWatch'; +import { IHostEnvironment } from '#/os/interface/hostEnvironment'; +import { IHostFileSystem } from '#/os/interface/hostFileSystem'; + +import { agentsMdCandidatePaths, loadAgentsMd } from './context'; +import { IAgentProfileService } from './profile'; +import { IAgentAgentsMdReminderService } from './agentsMdReminder'; + +const AGENTS_MD_INJECTION_VARIANT = 'agents_md'; + +const SYSTEM_PROMPT_AGENTS_MD_HEADING = 'The applicable `AGENTS.md` instructions are:'; +const SYSTEM_PROMPT_FENCE = '```````'; +const CURRENT_BLOCK_START = ''; +const CURRENT_BLOCK_END = ''; + +export const agentsMdReminderSeededContentKey = defineState( + 'agentsMdReminder.seededContent', + () => undefined, +); + +export class AgentAgentsMdReminderService extends Disposable implements IAgentAgentsMdReminderService { + declare readonly _serviceBrand: undefined; + + private readonly watcher: IPathWatch; + private watchCwd: string | undefined; + private changeVersion = 0; + private loadedVersion = -1; + private current: string | undefined; + + constructor( + @IAgentContextInjectorService dynamicInjector: IAgentContextInjectorService, + @IAgentContextMemoryService private readonly context: IAgentContextMemoryService, + @IAgentProfileService private readonly profile: IAgentProfileService, + @IAgentStateService private readonly states: IAgentStateService, + @IHostFileSystem private readonly fs: IHostFileSystem, + @IHostEnvironment private readonly env: IHostEnvironment, + @IBootstrapService private readonly bootstrap: IBootstrapService, + @IPathWatchService pathWatch: IPathWatchService, + ) { + super(); + this.states.register(agentsMdReminderSeededContentKey); + this.watcher = this._register( + pathWatch.createWatch({ target: 'file' }, () => { + this.changeVersion += 1; + }), + ); + this._register( + dynamicInjector.register(AGENTS_MD_INJECTION_VARIANT, (ctx) => this.reminder(ctx)), + ); + } + + private get seededContent(): string | undefined { + return this.states.get(agentsMdReminderSeededContentKey); + } + + private set seededContent(value: string | undefined) { + this.states.set(agentsMdReminderSeededContentKey, value); + } + + private async reminder({ lastInjectedAt }: ContextInjectionContext): Promise { + try { + const current = await this.currentContent(); + const baseline = this.baseline(lastInjectedAt) ?? this.adopt(current); + if (baseline === current) return undefined; + return buildAgentsMdReminder(current); + } catch { + return undefined; + } + } + + private async currentContent(): Promise { + const cwd = this.profile.data().cwd; + if (cwd !== this.watchCwd) { + this.watchCwd = cwd; + this.changeVersion += 1; + await this.armWatch(cwd); + } + if (this.loadedVersion === this.changeVersion && this.current !== undefined) { + return this.current; + } + const loadingVersion = this.changeVersion; + const content = await loadAgentsMd( + { fs: this.fs, homeDir: this.env.homeDir }, + cwd, + this.bootstrap.homeDir, + ); + this.current = content; + this.loadedVersion = loadingVersion; + return content; + } + + private async armWatch(cwd: string): Promise { + try { + const paths = await agentsMdCandidatePaths( + { fs: this.fs, homeDir: this.env.homeDir }, + this.bootstrap.homeDir, + cwd, + ); + if (cwd !== this.watchCwd) return; + await this.watcher.setPaths(paths); + } catch { + } + } + + private baseline(lastInjectedAt: number | null): string | undefined { + return this.contentFromHistory(lastInjectedAt) ?? this.contentFromSystemPrompt() ?? this.seededContent; + } + + private adopt(current: string): string { + this.seededContent = current; + return current; + } + + private contentFromHistory(lastInjectedAt: number | null): string | undefined { + if (lastInjectedAt === null) return undefined; + const message: ContextMessage | undefined = this.context.get()[lastInjectedAt]; + if (message === undefined) return undefined; + return extractCurrentBlock(messageText(message)); + } + + private contentFromSystemPrompt(): string | undefined { + const prompt = this.profile.getSystemPrompt(); + const headingIndex = prompt.indexOf(SYSTEM_PROMPT_AGENTS_MD_HEADING); + if (headingIndex < 0) return undefined; + const openFence = prompt.indexOf(SYSTEM_PROMPT_FENCE, headingIndex); + if (openFence < 0) return undefined; + const contentStart = openFence + SYSTEM_PROMPT_FENCE.length; + const closeFence = prompt.indexOf(SYSTEM_PROMPT_FENCE, contentStart); + if (closeFence < 0) return undefined; + return prompt.slice(contentStart, closeFence).trim(); + } +} + +function buildAgentsMdReminder(current: string): string { + const body = + current.length > 0 + ? 'The AGENTS.md instructions have changed since your system prompt was rendered. The content below is current and supersedes the AGENTS.md instructions in your system prompt.' + : 'The AGENTS.md instructions that fed your system prompt have been removed (or are now empty); they no longer apply.'; + return `${body}\n\n${CURRENT_BLOCK_START}\n${current}\n${CURRENT_BLOCK_END}\n\nDO NOT mention this to the user explicitly.`; +} + +function extractCurrentBlock(text: string): string | undefined { + const start = text.indexOf(CURRENT_BLOCK_START); + if (start < 0) return undefined; + const contentStart = start + CURRENT_BLOCK_START.length; + const end = text.indexOf(CURRENT_BLOCK_END, contentStart); + if (end < 0) return undefined; + return text.slice(contentStart, end).trim(); +} + +function messageText(message: ContextMessage): string { + return message.content + .map((part) => (part.type === 'text' ? part.text : '')) + .join(''); +} + +registerScopedService( + LifecycleScope.Agent, + IAgentAgentsMdReminderService, + AgentAgentsMdReminderService, + ScopeActivation.OnScopeCreated, + 'profile', +); diff --git a/packages/agent-core-v2/src/agent/profile/context.ts b/packages/agent-core-v2/src/agent/profile/context.ts index ebd5619acf..34a44e88f1 100644 --- a/packages/agent-core-v2/src/agent/profile/context.ts +++ b/packages/agent-core-v2/src/agent/profile/context.ts @@ -72,6 +72,36 @@ export async function loadAgentsMd( return result.content; } +export async function agentsMdCandidatePaths( + deps: ProfileContextDeps, + brandHome: string | undefined, + workDir: string, +): Promise { + return (await agentsMdCandidateGroups(deps, brandHome, [workDir])).flat(); +} + +async function agentsMdCandidateGroups( + deps: ProfileContextDeps, + brandHome: string | undefined, + workDirs: readonly string[], +): Promise { + const realHome = deps.homeDir; + const brandDir = brandHome ?? join(realHome, '.kimi-code'); + const groups: string[][] = [[join(brandDir, 'AGENTS.md')]]; + const genericDir = join(realHome, '.agents'); + groups.push([join(genericDir, 'AGENTS.md'), join(genericDir, 'agents.md')]); + + for (const workDir of workDirs) { + const rootWorkDir = normalize(workDir); + const projectRoot = await findProjectRoot(deps, rootWorkDir); + for (const dir of dirsRootToLeaf(rootWorkDir, projectRoot)) { + groups.push([join(dir, '.kimi-code', 'AGENTS.md')]); + groups.push([join(dir, 'AGENTS.md'), join(dir, 'agents.md')]); + } + } + return groups; +} + interface LoadedAgentsMd { readonly content: string; readonly warning: string | undefined; @@ -99,28 +129,10 @@ async function loadAgentsMdForRoots( return true; }; - const realHome = deps.homeDir; - const brandDir = brandHome ?? join(realHome, '.kimi-code'); - await collect(join(brandDir, 'AGENTS.md')); - - const genericDirs = [join(realHome, '.agents')]; - const genericFiles = genericDirs.flatMap((dir) => - ['AGENTS.md', 'agents.md'].map((name) => join(dir, name)), - ); - for (const file of genericFiles) { - if (await collect(file)) break; - } - - for (const workDir of workDirs) { - const rootWorkDir = normalize(workDir); - const projectRoot = await findProjectRoot(deps, rootWorkDir); - const dirs = dirsRootToLeaf(rootWorkDir, projectRoot); - - for (const dir of dirs) { - await collect(join(dir, '.kimi-code', 'AGENTS.md')); - for (const fileName of ['AGENTS.md', 'agents.md']) { - if (await collect(join(dir, fileName))) break; - } + const groups = await agentsMdCandidateGroups(deps, brandHome, workDirs); + for (const candidates of groups) { + for (const candidate of candidates) { + if (await collect(candidate)) break; } } diff --git a/packages/agent-core-v2/src/agent/profile/profile.ts b/packages/agent-core-v2/src/agent/profile/profile.ts index 72e5359e0e..9e973c8db1 100644 --- a/packages/agent-core-v2/src/agent/profile/profile.ts +++ b/packages/agent-core-v2/src/agent/profile/profile.ts @@ -54,6 +54,7 @@ export type AgentConfigUpdateData = Partial<{ export interface SystemPromptContext extends AgentProfileContext { readonly agentsMdWarning?: string; + readonly disclosedSkillNames?: readonly string[]; } export type ResolvedAgentProfile = AgentProfile; @@ -61,6 +62,7 @@ export type ResolvedAgentProfile = AgentProfile; export interface ProfileData extends AgentConfigData { readonly activeToolNames?: readonly string[]; readonly disallowedTools?: readonly string[]; + readonly disclosedSkillNames?: readonly string[]; readonly subagents?: readonly string[]; } @@ -82,6 +84,7 @@ export interface ProfileBindingSnapshot { readonly systemPrompt: string; readonly activeToolNames?: readonly string[]; readonly disallowedTools?: readonly string[]; + readonly disclosedSkillNames?: readonly string[]; readonly subagents?: readonly string[]; } diff --git a/packages/agent-core-v2/src/agent/profile/profileService.ts b/packages/agent-core-v2/src/agent/profile/profileService.ts index 9c282609b5..d43ace2e24 100644 --- a/packages/agent-core-v2/src/agent/profile/profileService.ts +++ b/packages/agent-core-v2/src/agent/profile/profileService.ts @@ -82,9 +82,9 @@ import { IHostFileSystem } from '#/os/interface/hostFileSystem'; import { ISessionContext } from '#/session/sessionContext/sessionContext'; import type { ToolSource } from '#/tool/toolContract'; import { ISessionWorkspaceContext } from '#/session/workspaceContext/workspaceContext'; -import { ISessionSkillCatalog } from '#/session/sessionSkillCatalog/skillCatalog'; import { ISessionAgentProfileCatalog } from '#/session/sessionAgentProfileCatalog/sessionAgentProfileCatalog'; import { ISessionToolPolicy } from '#/session/sessionToolPolicy/sessionToolPolicy'; +import { IAgentSkillDisclosureService } from '#/agent/skillDisclosure/skillDisclosure'; import type { ResolvedAgentProfile, SystemPromptContext } from '#/agent/profile/profile'; import { IAgentStateService } from '#/agent/state/agentState'; @@ -169,6 +169,7 @@ export class AgentProfileService extends Disposable implements IAgentProfileServ declare readonly _serviceBrand: undefined; private optionsValue: ProfileServiceOptions = {}; + private systemPromptRevision = 0; private get activeToolNames(): ActiveToolsState { return ( @@ -193,7 +194,7 @@ export class AgentProfileService extends Disposable implements IAgentProfileServ @IBootstrapService private readonly bootstrap: IBootstrapService, @ISessionWorkspaceContext private readonly workspace: ISessionWorkspaceContext, @ISessionAgentProfileCatalog private readonly catalog: ISessionAgentProfileCatalog, - @ISessionSkillCatalog private readonly skillCatalog: ISessionSkillCatalog, + @IAgentSkillDisclosureService private readonly skillDisclosure: IAgentSkillDisclosureService, @ISessionToolPolicy private readonly sessionToolPolicy: ISessionToolPolicy, @IAgentToolRegistryService private readonly toolRegistry: IAgentToolRegistryService, @IAgentProfileCatalogService private readonly builtinProfiles: IAgentProfileCatalogService, @@ -255,12 +256,16 @@ export class AgentProfileService extends Disposable implements IAgentProfileServ update(changed: ProfileUpdateData): void { const { activeToolNames, ...configChanged } = changed; - if ( + const profileChanged = changed.profileName !== undefined && - this.activeProfile?.name !== changed.profileName - ) { + this.activeProfile?.name !== changed.profileName; + if (profileChanged) { this.activeProfile = undefined; } + const cwdChanged = changed.cwd !== undefined && changed.cwd !== this.cwd; + if (cwdChanged || profileChanged || changed.systemPrompt !== undefined) { + this.systemPromptRevision += 1; + } if (Object.keys(configChanged).length > 0) { this.wire.dispatch(configUpdate(this.resolveConfigPayload(configChanged))); this.afterConfigDispatch(configChanged); @@ -268,9 +273,13 @@ export class AgentProfileService extends Disposable implements IAgentProfileServ if (activeToolNames !== undefined) { this.setActiveTools(activeToolNames); } + if (cwdChanged) { + void this.refreshSystemPrompt(); + } } applyBindingSnapshot(snapshot: ProfileBindingSnapshot): void { + this.systemPromptRevision += 1; this.activeProfile = undefined; this.activeToolNamesOverlay = undefined; this.wire.dispatch( @@ -285,6 +294,9 @@ export class AgentProfileService extends Disposable implements IAgentProfileServ subagents: snapshot.subagents, }), ); + if (snapshot.disclosedSkillNames !== undefined) { + this.skillDisclosure.markDisclosed(snapshot.disclosedSkillNames); + } this.afterConfigDispatch({ cwd: snapshot.cwd, modelAlias: snapshot.modelAlias, @@ -328,6 +340,7 @@ export class AgentProfileService extends Disposable implements IAgentProfileServ this.assertBindable(profile.name); const currentProfileName = this.profileName; const systemPrompt = profile.systemPrompt(context); + this.systemPromptRevision += 1; this.activeProfile = profile; this.cacheAgentsMdWarning(context); @@ -355,6 +368,7 @@ export class AgentProfileService extends Disposable implements IAgentProfileServ systemPrompt, disallowedTools: profile.disallowedTools ?? [], }); + this.recordSkillDisclosure(context); this.publishAgentsMdWarning(); this.publishToolPatternWarnings(profile); @@ -416,6 +430,7 @@ export class AgentProfileService extends Disposable implements IAgentProfileServ systemPrompt: profile.systemPrompt(context), disallowedTools: profile.disallowedTools ?? [], }); + this.recordSkillDisclosure(context); this.setActiveTools(profile.tools); } @@ -428,6 +443,7 @@ export class AgentProfileService extends Disposable implements IAgentProfileServ } async refreshSystemPrompt(): Promise { + const revision = ++this.systemPromptRevision; const profile = this.resolveActiveProfile(); if (profile === undefined) return; @@ -435,6 +451,7 @@ export class AgentProfileService extends Disposable implements IAgentProfileServ try { context = await this.buildSystemPromptContext(profile, this.cwd); } catch (error) { + if (revision !== this.systemPromptRevision) return; this.eventBus.publish({ type: 'warning', message: `System prompt refresh skipped: ${error instanceof Error ? error.message : String(error)}`, @@ -442,11 +459,13 @@ export class AgentProfileService extends Disposable implements IAgentProfileServ }); return; } + if (revision !== this.systemPromptRevision) return; this.activeProfile = profile; this.update({ profileName: profile.name, systemPrompt: profile.systemPrompt(context), }); + this.recordSkillDisclosure(context); this.cacheAgentsMdWarning(context); this.publishAgentsMdWarning(); } @@ -466,6 +485,7 @@ export class AgentProfileService extends Disposable implements IAgentProfileServ systemPrompt: this.systemPrompt, activeToolNames: this.activeToolNames === undefined ? undefined : [...this.activeToolNames], disallowedTools: [...(this.profileState.disallowedTools ?? [])], + disclosedSkillNames: this.skillDisclosure.disclosedNames(), subagents: this.profileState.subagents === undefined ? undefined : [...this.profileState.subagents], }; @@ -650,7 +670,7 @@ export class AgentProfileService extends Disposable implements IAgentProfileServ } private get cwd(): string { - return this.profileState.cwd ?? this.readConfiguredCwd() ?? ''; + return this.profileState.cwd ?? this.readConfiguredCwd() ?? this.sessionContext.cwd; } private get model(): string { @@ -840,7 +860,8 @@ export class AgentProfileService extends Disposable implements IAgentProfileServ this.bootstrap.homeDir, { additionalDirs: options?.additionalDirs ?? this.workspace.additionalDirs }, ); - const skills = await this.resolveSkillListing(); + const skillActive = this.isToolActiveForProfile(profile, 'Skill'); + const skillDisclosure = await this.skillDisclosure.resolve(skillActive); return { ...base, cwd: effectiveCwd, @@ -848,8 +869,9 @@ export class AgentProfileService extends Disposable implements IAgentProfileServ shellName: this.env.shellName, shellPath: this.env.shellPath, now: new Date().toISOString(), - skills, - skillActive: this.isToolActiveForProfile(profile, 'Skill'), + skills: skillDisclosure.listing, + skillActive, + disclosedSkillNames: skillDisclosure.names, productName: this.hostIdentity.productName, replyStyleGuide: this.hostIdentity.replyStyleGuide, }; @@ -871,12 +893,9 @@ export class AgentProfileService extends Disposable implements IAgentProfileServ ); } - private async resolveSkillListing(): Promise { - try { - await this.skillCatalog.ready; - return this.skillCatalog.catalog.getModelSkillListing(); - } catch { - return ''; + private recordSkillDisclosure(context: SystemPromptContext): void { + if (context.disclosedSkillNames !== undefined) { + this.skillDisclosure.markDisclosed(context.disclosedSkillNames); } } diff --git a/packages/agent-core-v2/src/agent/skillDisclosure/skillDisclosure.ts b/packages/agent-core-v2/src/agent/skillDisclosure/skillDisclosure.ts new file mode 100644 index 0000000000..68910361b1 --- /dev/null +++ b/packages/agent-core-v2/src/agent/skillDisclosure/skillDisclosure.ts @@ -0,0 +1,26 @@ +/** + * `skillDisclosure` domain (L4) — effective model-visible skill projection. + * + * Defines the Agent-scoped service that resolves a structured skill names plus + * listing snapshot and records the names already disclosed to the model. + */ + +import { createDecorator, type ServiceIdentifier } from '#/_base/di/instantiation'; + +export interface SkillDisclosureSnapshot { + readonly names: readonly string[]; + readonly listing: string; +} + +export interface IAgentSkillDisclosureService { + readonly _serviceBrand: undefined; + + resolve(skillActive: boolean): Promise; + disclosedNames(): readonly string[] | undefined; + legacyNames(systemPrompt: string): readonly string[] | undefined; + listedNames(listing: string): readonly string[]; + markDisclosed(names: readonly string[]): void; +} + +export const IAgentSkillDisclosureService: ServiceIdentifier = + createDecorator('agentSkillDisclosureService'); diff --git a/packages/agent-core-v2/src/agent/skillDisclosure/skillDisclosureOps.ts b/packages/agent-core-v2/src/agent/skillDisclosure/skillDisclosureOps.ts new file mode 100644 index 0000000000..773afabbe1 --- /dev/null +++ b/packages/agent-core-v2/src/agent/skillDisclosure/skillDisclosureOps.ts @@ -0,0 +1,40 @@ +/** + * `skillDisclosure` domain (L4) — persistent disclosed-skill names model. + * + * Defines the Agent wire model and whole-set replacement operation used to + * restore the system-prompt skill baseline across replay and forks. + */ + +import { z } from 'zod'; + +import { defineModel } from '#/wire/model'; + +export interface SkillDisclosureModelState { + readonly names?: readonly string[]; +} + +export const SkillDisclosureModel = defineModel( + 'skillDisclosure', + () => ({}), +); + +export const setDisclosedSkills = SkillDisclosureModel.defineOp('skill.disclosure.set', { + schema: z.object({ names: z.array(z.string()).readonly() }), + apply: (state, payload) => + stringArrayEqual(state.names, payload.names) ? state : { names: payload.names }, +}); + +function stringArrayEqual( + left: readonly string[] | undefined, + right: readonly string[] | undefined, +): boolean { + if (left === right) return true; + if (left === undefined || right === undefined || left.length !== right.length) return false; + return left.every((value, index) => value === right[index]); +} + +declare module '#/wire/types' { + interface PersistedOpMap { + 'skill.disclosure.set': typeof setDisclosedSkills; + } +} diff --git a/packages/agent-core-v2/src/agent/skillDisclosure/skillDisclosureService.ts b/packages/agent-core-v2/src/agent/skillDisclosure/skillDisclosureService.ts new file mode 100644 index 0000000000..3b0ba4d8aa --- /dev/null +++ b/packages/agent-core-v2/src/agent/skillDisclosure/skillDisclosureService.ts @@ -0,0 +1,81 @@ +/** + * `skillDisclosure` domain (L4) — `IAgentSkillDisclosureService` implementation. + * + * Reads structured model-facing skills from `sessionSkillCatalog`, normalizes + * their identities, and persists the disclosed-name baseline through `wire`. + * Bound at Agent scope. + */ + +import { LifecycleScope, ScopeActivation, registerScopedService } from '#/_base/di/scope'; +import { normalizeSkillName } from '#/app/skillCatalog/types'; +import { ISessionSkillCatalog } from '#/session/sessionSkillCatalog/skillCatalog'; +import { IWireService } from '#/wire/wire'; + +import { + IAgentSkillDisclosureService, + type SkillDisclosureSnapshot, +} from './skillDisclosure'; +import { setDisclosedSkills, SkillDisclosureModel } from './skillDisclosureOps'; + +export class AgentSkillDisclosureService implements IAgentSkillDisclosureService { + declare readonly _serviceBrand: undefined; + + constructor( + @ISessionSkillCatalog private readonly skillCatalog: ISessionSkillCatalog, + @IWireService private readonly wire: IWireService, + ) {} + + async resolve(skillActive: boolean): Promise { + if (!skillActive) return { names: [], listing: '' }; + try { + await this.skillCatalog.ready; + const disclosure = this.skillCatalog.catalog.getModelSkillDisclosure(); + return { + names: normalizeNames(disclosure.names), + listing: disclosure.listing, + }; + } catch { + return { names: [], listing: '' }; + } + } + + disclosedNames(): readonly string[] | undefined { + return this.wire.getModel(SkillDisclosureModel).names; + } + + legacyNames(systemPrompt: string): readonly string[] | undefined { + if (!systemPrompt.includes('## Available skills')) return undefined; + return this.listedNames(systemPrompt); + } + + listedNames(listing: string): readonly string[] { + const lines = listing.split(/\r?\n/); + const names = this.skillCatalog.catalog + .getModelSkillDisclosure() + .names.filter((name) => lines.some((line) => line.startsWith(`- ${name}: `))); + return normalizeNames(names); + } + + markDisclosed(names: readonly string[]): void { + const normalized = normalizeNames(names); + const current = this.disclosedNames(); + if (current !== undefined && sameNames(current, normalized)) return; + this.wire.dispatch(setDisclosedSkills({ names: normalized })); + } +} + +function normalizeNames(names: readonly string[]): readonly string[] { + return [...new Set(names.map(normalizeSkillName))].toSorted(); +} + +function sameNames(left: readonly string[], right: readonly string[]): boolean { + return left.length === right.length && left.every((name, index) => name === right[index]); +} + +registerScopedService( + LifecycleScope.Agent, + IAgentSkillDisclosureService, + AgentSkillDisclosureService, + ScopeActivation.OnScopeCreated, + 'skillDisclosure', +); diff --git a/packages/agent-core-v2/src/agent/skillDisclosure/skillListReminder.ts b/packages/agent-core-v2/src/agent/skillDisclosure/skillListReminder.ts new file mode 100644 index 0000000000..d516ef9b48 --- /dev/null +++ b/packages/agent-core-v2/src/agent/skillDisclosure/skillListReminder.ts @@ -0,0 +1,15 @@ +/** + * `skillDisclosure` domain (L4) — skill-list reminder contract. + * + * Defines the Agent-scoped marker service that announces structured skill + * additions after the model's last committed disclosure baseline. + */ + +import { createDecorator, type ServiceIdentifier } from '#/_base/di/instantiation'; + +export interface IAgentSkillListReminderService { + readonly _serviceBrand: undefined; +} + +export const IAgentSkillListReminderService: ServiceIdentifier = + createDecorator('agentSkillListReminderService'); diff --git a/packages/agent-core-v2/src/agent/skillDisclosure/skillListReminderService.ts b/packages/agent-core-v2/src/agent/skillDisclosure/skillListReminderService.ts new file mode 100644 index 0000000000..f77f21e847 --- /dev/null +++ b/packages/agent-core-v2/src/agent/skillDisclosure/skillListReminderService.ts @@ -0,0 +1,89 @@ +/** + * `skillDisclosure` domain (L4) — skill-list reminder provider. + * + * Registers through `contextInjector`, applies the active `toolPolicy`, and + * compares structured snapshots from `skillDisclosure`; additions emit the + * full superseding listing and advance the persistent baseline. Bound at + * Agent scope. + */ + +import { Disposable } from '#/_base/di/lifecycle'; +import { LifecycleScope, ScopeActivation, registerScopedService } from '#/_base/di/scope'; +import { + IAgentContextInjectorService, + type ContextInjectionContext, +} from '#/agent/contextInjector/contextInjector'; +import { IAgentContextMemoryService } from '#/agent/contextMemory/contextMemory'; +import type { ContextMessage } from '#/agent/contextMemory/types'; +import { IAgentProfileService } from '#/agent/profile/profile'; +import { IAgentToolPolicyService } from '#/agent/toolPolicy/toolPolicy'; + +import { IAgentSkillDisclosureService } from './skillDisclosure'; +import { IAgentSkillListReminderService } from './skillListReminder'; + +const SKILL_LIST_INJECTION_VARIANT = 'skill_list'; + +export class AgentSkillListReminderService extends Disposable implements IAgentSkillListReminderService { + declare readonly _serviceBrand: undefined; + + constructor( + @IAgentContextInjectorService contextInjector: IAgentContextInjectorService, + @IAgentContextMemoryService private readonly context: IAgentContextMemoryService, + @IAgentSkillDisclosureService private readonly disclosure: IAgentSkillDisclosureService, + @IAgentProfileService private readonly profile: IAgentProfileService, + @IAgentToolPolicyService private readonly toolPolicy: IAgentToolPolicyService, + ) { + super(); + this._register( + contextInjector.register(SKILL_LIST_INJECTION_VARIANT, (ctx) => this.reminder(ctx)), + ); + } + + private async reminder({ lastInjectedAt }: ContextInjectionContext): Promise { + try { + if (!this.toolPolicy.isToolActive('Skill')) return undefined; + const current = await this.disclosure.resolve(true); + const disclosed = this.disclosure.disclosedNames(); + const promptBaseline = this.disclosure.legacyNames(this.profile.getSystemPrompt()); + const baseline = + this.namesFromHistory(lastInjectedAt) ?? disclosed ?? promptBaseline; + if (baseline === undefined) { + this.disclosure.markDisclosed(current.names); + return undefined; + } + if (disclosed === undefined && promptBaseline !== undefined) { + this.disclosure.markDisclosed(promptBaseline); + } + if (!current.names.some((name) => !baseline.includes(name))) { + return undefined; + } + return buildSkillListReminder(current.listing); + } catch { + return undefined; + } + } + + private namesFromHistory(lastInjectedAt: number | null): readonly string[] | undefined { + if (lastInjectedAt === null) return undefined; + const message: ContextMessage | undefined = this.context.get()[lastInjectedAt]; + return message === undefined ? undefined : this.disclosure.listedNames(messageText(message)); + } +} + +function buildSkillListReminder(listing: string): string { + return `The skill list has changed since your system prompt was rendered; new skills are available. The listing below is the current source of truth.\n\n${listing}\n\nDO NOT mention this to the user explicitly.`; +} + +function messageText(message: ContextMessage): string { + return message.content + .map((part) => (part.type === 'text' ? part.text : '')) + .join(''); +} + +registerScopedService( + LifecycleScope.Agent, + IAgentSkillListReminderService, + AgentSkillListReminderService, + ScopeActivation.OnScopeCreated, + 'skillDisclosure', +); diff --git a/packages/agent-core-v2/src/app/pathWatch/pathWatch.ts b/packages/agent-core-v2/src/app/pathWatch/pathWatch.ts new file mode 100644 index 0000000000..b46c13ff39 --- /dev/null +++ b/packages/agent-core-v2/src/app/pathWatch/pathWatch.ts @@ -0,0 +1,46 @@ +/** + * `pathWatch` domain (L2) — shared absolute-path monitoring contract. + * + * Defines the App-scoped factory for disposable path subscriptions that keep + * raw filesystem changes available to domain projections. Each subscription + * can replace its candidate set while the shared owner reuses equivalent host + * watcher handles across App, Session and Agent consumers. + */ + +import type { IDisposable } from '#/_base/di/lifecycle'; +import { createDecorator, type ServiceIdentifier } from '#/_base/di/instantiation'; +import type { HostFsChange } from '#/os/interface/hostFsWatch'; + +export type PathWatchTargetKind = 'file' | 'directory'; + +export interface PathWatchOptions { + readonly target: PathWatchTargetKind; + readonly recursive?: boolean; + readonly depth?: number; + readonly followSymlinks?: boolean; + readonly pollingIntervalMs?: number; + readonly ignoredPathNames?: readonly string[]; + readonly ignoreDotDirectories?: boolean; + readonly debounceMs?: number; +} + +export interface PathWatchEvent { + readonly watchedPath: string; + readonly change?: HostFsChange; +} + +export interface IPathWatch extends IDisposable { + setPaths(paths: readonly string[]): Promise; +} + +export interface IPathWatchService { + readonly _serviceBrand: undefined; + + createWatch( + options: PathWatchOptions, + onDidChange: (event: PathWatchEvent) => void, + ): IPathWatch; +} + +export const IPathWatchService: ServiceIdentifier = + createDecorator('pathWatchService'); diff --git a/packages/agent-core-v2/src/app/pathWatch/pathWatchService.ts b/packages/agent-core-v2/src/app/pathWatch/pathWatchService.ts new file mode 100644 index 0000000000..78e438b68e --- /dev/null +++ b/packages/agent-core-v2/src/app/pathWatch/pathWatchService.ts @@ -0,0 +1,611 @@ +/** + * `pathWatch` domain (L2) — `IPathWatchService` implementation. + * + * Probes through `hostFs`, watches through `hostFsWatch`, and owns the shared + * raw-handle pool, missing-path ancestor progression, lexical/canonical target + * tracking, raw-change fanout, per-consumer debounce, reference counts, and + * disposal barriers. Bound at App scope. + */ + +import { dirname, join, normalize, relative } from 'pathe'; + +import { Disposable, type IDisposable } from '#/_base/di/lifecycle'; +import { LifecycleScope, ScopeActivation, registerScopedService } from '#/_base/di/scope'; +import { IHostFileSystem } from '#/os/interface/hostFileSystem'; +import { + type HostFsChange, + type HostFsWatchOptions, + type IHostFsWatchHandle, + IHostFsWatchService, +} from '#/os/interface/hostFsWatch'; + +import { + type IPathWatch, + IPathWatchService, + type PathWatchEvent, + type PathWatchOptions, +} from './pathWatch'; + +const DEFAULT_DEBOUNCE_MS = 300; + +interface NormalizedWatchOptions { + readonly target: 'file' | 'directory'; + readonly recursive: boolean; + readonly depth: number | undefined; + readonly followSymlinks: boolean; + readonly pollingIntervalMs: number | undefined; + readonly ignoredPathNames: readonly string[]; + readonly ignoreDotDirectories: boolean; + readonly debounceMs: number; +} + +interface RawWatchSpec { + readonly path: string; + readonly recursive: boolean; + readonly depth: number | undefined; + readonly followSymlinks: boolean; + readonly pollingIntervalMs: number | undefined; + readonly ignoredPathNames: readonly string[]; + readonly ignoreDotDirectories: boolean; +} + +interface RawWatchEntry { + readonly handle: IHostFsWatchHandle; + readonly listeners: Set<(change: HostFsChange) => void>; +} + +interface RawWatchLease extends IDisposable { + readonly key: string; + readonly ready: Promise; +} + +interface WatchSlot { + readonly key: string; + readonly lease: RawWatchLease; +} + +interface SharedPathState { + readonly key: string; + readonly lexicalPath: string; + readonly options: NormalizedWatchOptions; + readonly subscribers: Set; + targetSlot: WatchSlot | undefined; + lexicalSlot: WatchSlot | undefined; + targetAncestorSlot: WatchSlot | undefined; + canonicalTarget: string | undefined; + missingCanonicalPath: string | undefined; + available: boolean; + initialized: boolean; + advanceTail: Promise; +} + +export class PathWatchService extends Disposable implements IPathWatchService { + declare readonly _serviceBrand: undefined; + + private readonly states = new Map(); + private readonly rawWatches = new Map(); + private readonly subscriptions = new Set(); + private disposed = false; + + constructor( + @IHostFileSystem private readonly hostFs: IHostFileSystem, + @IHostFsWatchService private readonly hostFsWatch: IHostFsWatchService, + ) { + super(); + } + + createWatch( + options: PathWatchOptions, + onDidChange: (event: PathWatchEvent) => void, + ): IPathWatch { + const watch = new PathWatch(this, normalizeOptions(options), onDidChange); + if (this.disposed) { + watch.dispose(); + return watch; + } + this.subscriptions.add(watch); + return watch; + } + + setPaths(watch: PathWatch, paths: readonly string[]): Promise { + if (this.disposed || watch.isDisposed) return Promise.resolve(); + const nextKeys = new Set(); + const waits: Promise[] = []; + for (const candidate of paths) { + const lexicalPath = normalizePath(candidate); + const key = pathStateKey(lexicalPath, watch.options); + if (nextKeys.has(key)) continue; + nextKeys.add(key); + if (watch.stateKeys.has(key)) continue; + const state = this.acquireState(key, lexicalPath, watch.options, watch); + waits.push(state.advanceTail); + } + for (const key of watch.stateKeys) { + if (!nextKeys.has(key)) this.releaseState(key, watch); + } + watch.replaceStateKeys(nextKeys); + return Promise.all(waits).then(() => undefined); + } + + releaseWatch(watch: PathWatch): void { + this.subscriptions.delete(watch); + for (const key of watch.stateKeys) this.releaseState(key, watch); + watch.replaceStateKeys(new Set()); + } + + override dispose(): void { + if (this.disposed) return; + this.disposed = true; + for (const watch of this.subscriptions) watch.dispose(); + for (const state of this.states.values()) this.teardownState(state); + this.states.clear(); + for (const entry of this.rawWatches.values()) entry.handle.dispose(); + this.rawWatches.clear(); + super.dispose(); + } + + private acquireState( + key: string, + lexicalPath: string, + options: NormalizedWatchOptions, + subscriber: PathWatch, + ): SharedPathState { + let state = this.states.get(key); + if (state === undefined) { + state = { + key, + lexicalPath, + options, + subscribers: new Set(), + targetSlot: undefined, + lexicalSlot: undefined, + targetAncestorSlot: undefined, + canonicalTarget: undefined, + missingCanonicalPath: undefined, + available: false, + initialized: false, + advanceTail: Promise.resolve(), + }; + this.states.set(key, state); + } + state.subscribers.add(subscriber); + void this.queueAdvance(state); + return state; + } + + private releaseState(key: string, subscriber: PathWatch): void { + const state = this.states.get(key); + if (state === undefined) return; + state.subscribers.delete(subscriber); + if (state.subscribers.size > 0) return; + this.states.delete(key); + this.teardownState(state); + } + + private queueAdvance(state: SharedPathState): Promise { + const next = state.advanceTail.then(() => this.advanceState(state)); + state.advanceTail = next.catch(() => undefined); + return state.advanceTail; + } + + private async advanceState(state: SharedPathState): Promise { + if (!this.isStateLive(state)) return; + const canonicalTarget = await this.resolveTarget(state.lexicalPath, state.options.target); + if (!this.isStateLive(state)) return; + if (canonicalTarget === undefined) { + await this.armMissingState(state); + return; + } + const changedTarget = state.canonicalTarget !== undefined && state.canonicalTarget !== canonicalTarget; + const appeared = state.initialized && !state.available; + state.available = true; + state.initialized = true; + state.canonicalTarget = canonicalTarget; + state.missingCanonicalPath = undefined; + state.targetSlot = this.replaceSlot( + state.targetSlot, + targetWatchSpec(canonicalTarget, state.options), + (change) => { + this.onTargetChange(state, change); + }, + ); + const lexicalParent = normalizePath(dirname(state.lexicalPath)); + if (lexicalParent === canonicalTarget) { + this.clearSlot(state.lexicalSlot); + state.lexicalSlot = undefined; + } else { + state.lexicalSlot = this.replaceSlot( + state.lexicalSlot, + shallowWatchSpec(lexicalParent, state.options.pollingIntervalMs), + (change) => { + this.onLexicalParentChange(state, change); + }, + ); + } + this.clearSlot(state.targetAncestorSlot); + state.targetAncestorSlot = undefined; + await Promise.all([ + state.targetSlot.lease.ready, + state.lexicalSlot?.lease.ready, + ]); + if (!this.isStateLive(state)) return; + if (appeared || changedTarget) this.notify(state); + } + + private async armMissingState(state: SharedPathState): Promise { + const disappeared = state.initialized && state.available; + state.available = false; + state.initialized = true; + state.canonicalTarget = undefined; + this.clearSlot(state.targetSlot); + state.targetSlot = undefined; + const lexicalAnchor = await this.nearestExistingDirectory(state.lexicalPath); + if (!this.isStateLive(state)) return; + const missingCanonicalPath = await this.resolveMissingCanonicalPath( + state.lexicalPath, + lexicalAnchor, + ); + if (!this.isStateLive(state)) return; + state.missingCanonicalPath = missingCanonicalPath; + state.lexicalSlot = this.replaceSlot( + state.lexicalSlot, + shallowWatchSpec(lexicalAnchor, state.options.pollingIntervalMs), + (change) => { + this.onMissingChainChange(state, 'lexical', change); + }, + ); + let targetAnchor: string | undefined; + if (missingCanonicalPath === undefined || missingCanonicalPath === state.lexicalPath) { + this.clearSlot(state.targetAncestorSlot); + state.targetAncestorSlot = undefined; + } else { + targetAnchor = await this.nearestExistingDirectory(missingCanonicalPath); + if (!this.isStateLive(state)) return; + state.targetAncestorSlot = this.replaceSlot( + state.targetAncestorSlot, + shallowWatchSpec(targetAnchor, state.options.pollingIntervalMs), + (change) => { + this.onMissingChainChange(state, 'canonical', change); + }, + ); + } + await Promise.all([ + state.lexicalSlot.lease.ready, + state.targetAncestorSlot?.lease.ready, + ]); + if (!this.isStateLive(state)) return; + if (disappeared) this.notify(state); + if (await this.missingStateAdvanced(state, lexicalAnchor, missingCanonicalPath, targetAnchor)) { + void this.queueAdvance(state); + } + } + + private async missingStateAdvanced( + state: SharedPathState, + lexicalAnchor: string, + missingCanonicalPath: string | undefined, + targetAnchor: string | undefined, + ): Promise { + if ((await this.resolveTarget(state.lexicalPath, state.options.target)) !== undefined) { + return this.isStateLive(state); + } + if (!this.isStateLive(state)) return false; + const nextLexicalAnchor = await this.nearestExistingDirectory(state.lexicalPath); + if (!this.isStateLive(state)) return false; + const nextMissingCanonicalPath = await this.resolveMissingCanonicalPath( + state.lexicalPath, + nextLexicalAnchor, + ); + if (!this.isStateLive(state)) return false; + const nextTargetAnchor = + nextMissingCanonicalPath === undefined || nextMissingCanonicalPath === state.lexicalPath + ? undefined + : await this.nearestExistingDirectory(nextMissingCanonicalPath); + return ( + this.isStateLive(state) && + (nextLexicalAnchor !== lexicalAnchor || + nextMissingCanonicalPath !== missingCanonicalPath || + nextTargetAnchor !== targetAnchor) + ); + } + + private onTargetChange(state: SharedPathState, change: HostFsChange): void { + if (!this.isStateLive(state)) return; + this.notify(state, change); + if (change.action === 'deleted') void this.queueAdvance(state); + } + + private onLexicalParentChange(state: SharedPathState, change: HostFsChange): void { + if (!this.isStateLive(state)) return; + if (normalizePath(change.path) === state.lexicalPath) void this.queueAdvance(state); + } + + private onMissingChainChange( + state: SharedPathState, + pathKind: 'lexical' | 'canonical', + change: HostFsChange, + ): void { + if (!this.isStateLive(state)) return; + const target = + pathKind === 'lexical' ? state.lexicalPath : state.missingCanonicalPath; + if (target === undefined) return; + if (isOnPathChain(target, change.path)) void this.queueAdvance(state); + } + + private notify(state: SharedPathState, change?: HostFsChange): void { + const event: PathWatchEvent = { watchedPath: state.lexicalPath, change }; + for (const subscriber of state.subscribers) subscriber.signal(event); + } + + private isStateLive(state: SharedPathState): boolean { + return !this.disposed && this.states.get(state.key) === state && state.subscribers.size > 0; + } + + private async resolveTarget( + lexicalPath: string, + target: 'file' | 'directory', + ): Promise { + try { + const stat = await this.hostFs.stat(lexicalPath); + if (target === 'file' ? !stat.isFile : !stat.isDirectory) return undefined; + return normalizePath(await this.hostFs.realpath(lexicalPath)); + } catch { + return undefined; + } + } + + private async nearestExistingDirectory(candidate: string): Promise { + let current = normalizePath(candidate); + while (true) { + try { + if ((await this.hostFs.stat(current)).isDirectory) return current; + } catch { + } + const parent = normalizePath(dirname(current)); + if (parent === current) return current; + current = parent; + } + } + + private async resolveMissingCanonicalPath( + lexicalPath: string, + lexicalAnchor: string, + ): Promise { + try { + const canonicalAnchor = normalizePath(await this.hostFs.realpath(lexicalAnchor)); + return normalizePath(join(canonicalAnchor, relative(lexicalAnchor, lexicalPath))); + } catch { + return undefined; + } + } + + private replaceSlot( + current: WatchSlot | undefined, + spec: RawWatchSpec, + listener: (change: HostFsChange) => void, + ): WatchSlot { + const key = rawWatchKey(spec); + if (current?.key === key) return current; + const lease = this.acquireRawWatch(spec, listener); + current?.lease.dispose(); + return { key, lease }; + } + + private clearSlot(current: WatchSlot | undefined): void { + current?.lease.dispose(); + } + + private acquireRawWatch( + spec: RawWatchSpec, + listener: (change: HostFsChange) => void, + ): RawWatchLease { + const key = rawWatchKey(spec); + let entry = this.rawWatches.get(key); + if (entry === undefined) { + const listeners = new Set<(change: HostFsChange) => void>(); + const options: HostFsWatchOptions = { + recursive: spec.recursive, + depth: spec.depth, + followSymlinks: spec.followSymlinks, + pollingIntervalMs: spec.pollingIntervalMs, + ignored: createIgnoredPredicate(spec), + }; + const handle = this.hostFsWatch.watch(spec.path, options); + entry = { handle, listeners }; + this.rawWatches.set(key, entry); + handle.onDidChange((change) => { + for (const currentListener of listeners) currentListener(change); + }); + } + entry.listeners.add(listener); + let disposed = false; + return { + key, + ready: entry.handle.ready, + dispose: () => { + if (disposed) return; + disposed = true; + const current = this.rawWatches.get(key); + if (current === undefined) return; + current.listeners.delete(listener); + if (current.listeners.size > 0) return; + current.handle.dispose(); + this.rawWatches.delete(key); + }, + }; + } + + private teardownState(state: SharedPathState): void { + this.clearSlot(state.targetSlot); + this.clearSlot(state.lexicalSlot); + this.clearSlot(state.targetAncestorSlot); + state.targetSlot = undefined; + state.lexicalSlot = undefined; + state.targetAncestorSlot = undefined; + } +} + +class PathWatch implements IPathWatch { + private keys = new Set(); + private timer: ReturnType | undefined; + private pendingEvent: PathWatchEvent | undefined; + private disposed = false; + + constructor( + private readonly owner: PathWatchService, + readonly options: NormalizedWatchOptions, + private readonly onDidChange: (event: PathWatchEvent) => void, + ) {} + + get isDisposed(): boolean { + return this.disposed; + } + + get stateKeys(): ReadonlySet { + return this.keys; + } + + setPaths(paths: readonly string[]): Promise { + return this.owner.setPaths(this, paths); + } + + replaceStateKeys(keys: Set): void { + this.keys = keys; + } + + signal(event: PathWatchEvent): void { + if (this.disposed) return; + this.pendingEvent = event; + if (this.timer !== undefined) return; + if (this.options.debounceMs === 0) { + this.flush(); + return; + } + const timer = setTimeout(() => { + this.timer = undefined; + this.flush(); + }, this.options.debounceMs); + timer.unref?.(); + this.timer = timer; + } + + dispose(): void { + if (this.disposed) return; + this.disposed = true; + if (this.timer !== undefined) { + clearTimeout(this.timer); + this.timer = undefined; + } + this.pendingEvent = undefined; + this.owner.releaseWatch(this); + } + + private flush(): void { + const event = this.pendingEvent; + this.pendingEvent = undefined; + if (!this.disposed && event !== undefined) this.onDidChange(event); + } +} + +function normalizeOptions(options: PathWatchOptions): NormalizedWatchOptions { + return { + target: options.target, + recursive: options.recursive ?? false, + depth: options.depth, + followSymlinks: options.followSymlinks ?? false, + pollingIntervalMs: options.pollingIntervalMs, + ignoredPathNames: [...new Set(options.ignoredPathNames ?? [])].toSorted(), + ignoreDotDirectories: options.ignoreDotDirectories ?? false, + debounceMs: options.debounceMs ?? DEFAULT_DEBOUNCE_MS, + }; +} + +function pathStateKey(path: string, options: NormalizedWatchOptions): string { + return JSON.stringify([ + path, + options.target, + options.recursive, + options.depth, + options.followSymlinks, + options.pollingIntervalMs, + options.ignoredPathNames, + options.ignoreDotDirectories, + ]); +} + +function targetWatchSpec(path: string, options: NormalizedWatchOptions): RawWatchSpec { + return { + path, + recursive: options.recursive, + depth: options.depth, + followSymlinks: options.followSymlinks, + pollingIntervalMs: options.pollingIntervalMs, + ignoredPathNames: options.ignoredPathNames, + ignoreDotDirectories: options.ignoreDotDirectories, + }; +} + +function shallowWatchSpec( + path: string, + pollingIntervalMs: number | undefined, +): RawWatchSpec { + return { + path, + recursive: false, + depth: undefined, + followSymlinks: false, + pollingIntervalMs, + ignoredPathNames: [], + ignoreDotDirectories: false, + }; +} + +function rawWatchKey(spec: RawWatchSpec): string { + return JSON.stringify([ + spec.path, + spec.recursive, + spec.depth, + spec.followSymlinks, + spec.pollingIntervalMs, + spec.ignoredPathNames, + spec.ignoreDotDirectories, + ]); +} + +function createIgnoredPredicate(spec: RawWatchSpec): ((path: string) => boolean) | undefined { + if (spec.ignoredPathNames.length === 0 && !spec.ignoreDotDirectories) return undefined; + const ignoredNames = new Set(spec.ignoredPathNames); + return (candidate) => { + const rel = relative(spec.path, normalizePath(candidate)); + if (rel === '' || rel.startsWith('..')) return false; + const segments = rel.split(/[\\/]+/).filter((segment) => segment.length > 0); + return segments.some( + (segment, index) => + ignoredNames.has(segment) || + (spec.ignoreDotDirectories && + segment.startsWith('.') && + (index < segments.length - 1 || !segment.endsWith('.md'))), + ); + }; +} + +function isOnPathChain(target: string, changedPath: string): boolean { + const normalizedTarget = normalizePath(target); + const normalizedChange = normalizePath(changedPath); + return ( + normalizedTarget === normalizedChange || + normalizedTarget.startsWith(`${normalizedChange}/`) + ); +} + +function normalizePath(value: string): string { + return normalize(value).replaceAll('\\', '/'); +} + +registerScopedService( + LifecycleScope.App, + IPathWatchService, + PathWatchService, + ScopeActivation.OnScopeCreated, + 'pathWatch', +); diff --git a/packages/agent-core-v2/src/app/skillCatalog/fileSkillDiscovery.ts b/packages/agent-core-v2/src/app/skillCatalog/fileSkillDiscovery.ts index 5d40eca819..3d0bc4d3d9 100644 --- a/packages/agent-core-v2/src/app/skillCatalog/fileSkillDiscovery.ts +++ b/packages/agent-core-v2/src/app/skillCatalog/fileSkillDiscovery.ts @@ -14,26 +14,30 @@ import { ILogService, type LogPayload } from '#/_base/log/log'; import { SkillParseError, UnsupportedSkillTypeError, parseSkillText } from './parser'; import type { SkillDiscoveryResult, ISkillDiscovery } from './skillDiscovery'; +import { isSkillLoadAborted } from './skillSource'; +import { isSkillTraversalDirectory, SKILL_SCAN_MAX_DEPTH } from './skillTraversal'; import type { SkillDefinition, SkillRoot, SkippedSkill } from './types'; import { normalizeSkillName } from './types'; -const MAX_SKILL_SCAN_DEPTH = 8; - export class FileSkillDiscovery implements ISkillDiscovery { declare readonly _serviceBrand: undefined; constructor(@ILogService private readonly log: ILogService) {} - async discover(roots: readonly SkillRoot[]): Promise { + async discover( + roots: readonly SkillRoot[], + signal?: AbortSignal, + ): Promise { return discoverFileSkills(roots, (message, payload) => { this.log.warn(message, payload); - }); + }, signal); } } export async function discoverFileSkills( roots: readonly SkillRoot[], warn?: (message: string, payload?: LogPayload) => void, + signal?: AbortSignal, ): Promise { const byDiscoveryKey = new Map(); const skipped: SkippedSkill[] = []; @@ -45,7 +49,7 @@ export async function discoverFileSkills( depth: number, subSkillParentName?: string, ): Promise { - if (depth > MAX_SKILL_SCAN_DEPTH) return; + if (isSkillLoadAborted(signal) || depth > SKILL_SCAN_MAX_DEPTH) return; let entries: readonly string[]; try { @@ -57,16 +61,18 @@ export async function discoverFileSkills( const directorySkills = new Set(); const subdirs: string[] = []; for (const entry of entries) { + if (isSkillLoadAborted(signal)) return; + if (!isSkillTraversalDirectory(entry)) continue; const entryPath = path.join(dirPath, entry); if (await isFile(path.join(entryPath, 'SKILL.md'))) { directorySkills.add(entry); } - if (entry === 'node_modules' || entry.startsWith('.')) continue; if (await isDir(entryPath)) subdirs.push(entry); } const allowedSubSkillBundles = new Map(); for (const entry of directorySkills) { + if (isSkillLoadAborted(signal)) return; const skill = await parseAndRegister({ byDiscoveryKey, skipped, @@ -97,6 +103,7 @@ export async function discoverFileSkills( } for (const entry of entries) { + if (isSkillLoadAborted(signal)) return; if (!entry.endsWith('.md')) continue; if (entry === 'SKILL.md') continue; const skillName = entry.slice(0, -'.md'.length); @@ -115,6 +122,7 @@ export async function discoverFileSkills( } for (const entry of subdirs) { + if (isSkillLoadAborted(signal)) return; if (directorySkills.has(entry) && !allowedSubSkillBundles.has(entry)) continue; const allowedSubSkillParentName = allowedSubSkillBundles.get(entry); await walkSkillDir( @@ -128,6 +136,7 @@ export async function discoverFileSkills( } for (const root of roots) { + if (isSkillLoadAborted(signal)) break; await walkSkillDir(root.path, root, true, 0); } diff --git a/packages/agent-core-v2/src/app/skillCatalog/registry.ts b/packages/agent-core-v2/src/app/skillCatalog/registry.ts index 22d18b1ba3..e4ea32199e 100644 --- a/packages/agent-core-v2/src/app/skillCatalog/registry.ts +++ b/packages/agent-core-v2/src/app/skillCatalog/registry.ts @@ -15,6 +15,7 @@ import type { SkillCatalog, SkillDefinition, SkillMetadata, + ModelSkillDisclosure, SkillSource, SkippedSkill, } from './types'; @@ -115,15 +116,22 @@ export class InMemorySkillCatalog implements SkillCatalog { } getModelSkillListing(): string { - const lines = ['DISREGARD any earlier skill listings. Current available skills:']; - const listing = renderGroupedSkills( - this.listInvocableSkills().filter((skill) => skill.metadata.isSubSkill !== true), - formatModelSkill, + return this.getModelSkillDisclosure().listing; + } + + getModelSkillDisclosure(): ModelSkillDisclosure { + const skills = this.listInvocableSkills().filter( + (skill) => skill.metadata.isSubSkill !== true, ); + const lines = ['DISREGARD any earlier skill listings. Current available skills:']; + const listing = renderGroupedSkills(skills, formatModelSkill); if (listing.length > 0) { lines.push(listing); } - return lines.length === 1 ? '' : lines.join('\n'); + return { + names: skills.map((skill) => skill.name), + listing: lines.length === 1 ? '' : lines.join('\n'), + }; } private indexPluginSkill( @@ -285,5 +293,5 @@ function tokenizeArgs(raw: string): string[] { } function escapeRegExp(value: string): string { - return value.replace(/[\\^$.*+?()[\]{}|]/g, '\\$&'); + return value.replaceAll(/[\\^$.*+?()[\]{}|]/g, '\\$&'); } diff --git a/packages/agent-core-v2/src/app/skillCatalog/skillDiscovery.ts b/packages/agent-core-v2/src/app/skillCatalog/skillDiscovery.ts index 2bf77a2206..f015df4ee8 100644 --- a/packages/agent-core-v2/src/app/skillCatalog/skillDiscovery.ts +++ b/packages/agent-core-v2/src/app/skillCatalog/skillDiscovery.ts @@ -23,7 +23,7 @@ export interface SkillDiscoveryResult { export interface ISkillDiscovery { readonly _serviceBrand: undefined; - discover(roots: readonly SkillRoot[]): Promise; + discover(roots: readonly SkillRoot[], signal?: AbortSignal): Promise; } export const ISkillDiscovery = createDecorator('skillDiscovery'); diff --git a/packages/agent-core-v2/src/app/skillCatalog/skillRoots.ts b/packages/agent-core-v2/src/app/skillCatalog/skillRoots.ts index c0a1c76330..773f70d357 100644 --- a/packages/agent-core-v2/src/app/skillCatalog/skillRoots.ts +++ b/packages/agent-core-v2/src/app/skillCatalog/skillRoots.ts @@ -24,42 +24,101 @@ export interface SkillRootsOptions { readonly mergeAllAvailableSkills?: boolean; } -export async function userRoots( +export interface SkillRootResolution { + readonly candidates: readonly string[]; + readonly roots: readonly SkillRoot[]; +} + +export async function resolveUserSkillRoots( homeDir: string, osHomeDir: string, options: SkillRootsOptions = {}, -): Promise { +): Promise { + const candidates = userRootCandidates(homeDir, osHomeDir); const roots: SkillRoot[] = []; const mergeAllAvailableSkills = options.mergeAllAvailableSkills ?? true; await pushBrandGroup(roots, USER_BRAND_DIRS, homeDir, 'user', mergeAllAvailableSkills); await pushFirstExisting(roots, USER_GENERIC_DIRS, osHomeDir, 'user'); - return roots; + return { candidates, roots }; } -export async function projectRoots( - workDir: string, +export async function userRoots( + homeDir: string, + osHomeDir: string, options: SkillRootsOptions = {}, ): Promise { + return (await resolveUserSkillRoots(homeDir, osHomeDir, options)).roots; +} + +export async function resolveProjectSkillRoots( + workDir: string, + options: SkillRootsOptions = {}, +): Promise { const projectRoot = await findProjectRoot(workDir); + const candidates = [ + ...PROJECT_BRAND_DIRS.map((dir) => path.join(projectRoot, dir)), + ...PROJECT_GENERIC_DIRS.map((dir) => path.join(projectRoot, dir)), + ]; const roots: SkillRoot[] = []; const mergeAllAvailableSkills = options.mergeAllAvailableSkills ?? true; await pushBrandGroup(roots, PROJECT_BRAND_DIRS, projectRoot, 'project', mergeAllAvailableSkills); await pushFirstExisting(roots, PROJECT_GENERIC_DIRS, projectRoot, 'project'); - return roots; + return { candidates, roots }; } -export async function configuredRoots( +export async function projectRoots( + workDir: string, + options: SkillRootsOptions = {}, +): Promise { + return (await resolveProjectSkillRoots(workDir, options)).roots; +} + +export async function resolveConfiguredSkillRoots( dirs: readonly string[], workDir: string, osHomeDir: string, source: SkillSource, -): Promise { +): Promise { const projectRoot = await findProjectRoot(workDir); + const candidates = dirs.map((dir) => resolveConfiguredDir(dir, projectRoot, osHomeDir)); const roots: SkillRoot[] = []; - for (const dir of dirs) { - await pushExistingRoot(roots, resolveConfiguredDir(dir, projectRoot, osHomeDir), source); + for (const candidate of candidates) { + await pushExistingRoot(roots, candidate, source); } - return roots; + return { candidates, roots }; +} + +export async function configuredRoots( + dirs: readonly string[], + workDir: string, + osHomeDir: string, + source: SkillSource, +): Promise { + return (await resolveConfiguredSkillRoots(dirs, workDir, osHomeDir, source)).roots; +} + +export function userRootCandidates(homeDir: string, osHomeDir: string): readonly string[] { + return [ + ...USER_BRAND_DIRS.map((dir) => path.join(homeDir, dir)), + ...USER_GENERIC_DIRS.map((dir) => path.join(osHomeDir, dir)), + ]; +} + +export async function projectRootCandidates(workDir: string): Promise { + const projectRoot = await findProjectRoot(workDir); + return [ + ...PROJECT_BRAND_DIRS.map((dir) => path.join(projectRoot, dir)), + ...PROJECT_GENERIC_DIRS.map((dir) => path.join(projectRoot, dir)), + ]; +} + +export async function configuredRootCandidates( + dirs: readonly string[], + workDir: string, + osHomeDir: string, +): Promise { + const projectRoot = await findProjectRoot(workDir); + return dirs.map((dir) => resolveConfiguredDir(dir, projectRoot, osHomeDir)); } async function findProjectRoot(workDir: string): Promise { diff --git a/packages/agent-core-v2/src/app/skillCatalog/skillSource.ts b/packages/agent-core-v2/src/app/skillCatalog/skillSource.ts index 9db2f74d45..b38bfd18ff 100644 --- a/packages/agent-core-v2/src/app/skillCatalog/skillSource.ts +++ b/packages/agent-core-v2/src/app/skillCatalog/skillSource.ts @@ -35,5 +35,9 @@ export interface ISkillSource { readonly id: string; readonly priority: number; readonly onDidChange?: Event; - load(): Promise; + load(signal?: AbortSignal): Promise; +} + +export function isSkillLoadAborted(signal: AbortSignal | undefined): boolean { + return signal?.aborted ?? false; } diff --git a/packages/agent-core-v2/src/app/skillCatalog/skillTraversal.ts b/packages/agent-core-v2/src/app/skillCatalog/skillTraversal.ts new file mode 100644 index 0000000000..ea56576966 --- /dev/null +++ b/packages/agent-core-v2/src/app/skillCatalog/skillTraversal.ts @@ -0,0 +1,25 @@ +/** + * `skillCatalog` domain (L3) — shared skill-tree traversal policy. + * + * Defines the directory exclusions and bounded depth used by both filesystem + * discovery and live monitoring, keeping their observable tree topology + * aligned. Pure policy; no scoped state. + */ + +import type { PathWatchOptions } from '#/app/pathWatch/pathWatch'; + +export const SKILL_SCAN_MAX_DEPTH = 8; +export const SKILL_WATCH_MAX_DEPTH = SKILL_SCAN_MAX_DEPTH + 2; + +export const SKILL_ROOT_WATCH_OPTIONS: PathWatchOptions = { + target: 'directory', + recursive: true, + depth: SKILL_WATCH_MAX_DEPTH, + followSymlinks: true, + ignoredPathNames: ['node_modules'], + ignoreDotDirectories: true, +}; + +export function isSkillTraversalDirectory(name: string): boolean { + return name !== 'node_modules' && !name.startsWith('.'); +} diff --git a/packages/agent-core-v2/src/app/skillCatalog/types.ts b/packages/agent-core-v2/src/app/skillCatalog/types.ts index 210cfe62d0..8ab3199bd4 100644 --- a/packages/agent-core-v2/src/app/skillCatalog/types.ts +++ b/packages/agent-core-v2/src/app/skillCatalog/types.ts @@ -52,6 +52,11 @@ export interface SkippedSkill { readonly reason: string; } +export interface ModelSkillDisclosure { + readonly names: readonly string[]; + readonly listing: string; +} + export interface SkillCatalog { getSkill(name: string): SkillDefinition | undefined; getPluginSkill(pluginId: string, name: string): SkillDefinition | undefined; @@ -64,6 +69,7 @@ export interface SkillCatalog { listInvocableSkills(): readonly SkillDefinition[]; getSkillRoots(): readonly string[]; getSkippedByPolicy(): readonly SkippedSkill[]; + getModelSkillDisclosure(): ModelSkillDisclosure; getModelSkillListing(): string; } diff --git a/packages/agent-core-v2/src/app/skillCatalog/userFileSkillSource.ts b/packages/agent-core-v2/src/app/skillCatalog/userFileSkillSource.ts index 53d48bd4f1..22112817cf 100644 --- a/packages/agent-core-v2/src/app/skillCatalog/userFileSkillSource.ts +++ b/packages/agent-core-v2/src/app/skillCatalog/userFileSkillSource.ts @@ -3,7 +3,9 @@ * * Discovers user skills from the bootstrap home directories through * `ISkillDiscovery`, contributing them at priority 20 (above extra / plugin / - * builtin, below workspace). Reads home paths from `bootstrap`. Bound at App scope. + * builtin, below workspace). Reads home paths from `bootstrap`. Watches the + * candidate root paths (existing or not) through `pathWatch` and + * re-fires `onDidChange` on debounced fs changes. Bound at App scope. */ import { createDecorator, type ServiceIdentifier } from '#/_base/di/instantiation'; @@ -12,6 +14,10 @@ import { Emitter, type Event } from '#/_base/event'; import { LifecycleScope, ScopeActivation, registerScopedService } from '#/_base/di/scope'; import { IBootstrapService } from '#/app/bootstrap/bootstrap'; import { IConfigService } from '#/app/config/config'; +import { + type IPathWatch, + IPathWatchService, +} from '#/app/pathWatch/pathWatch'; import { MERGE_ALL_AVAILABLE_SKILLS_SECTION, @@ -19,8 +25,14 @@ import { } from './configSection'; import { ISkillCatalogRuntimeOptions } from './skillCatalogRuntimeOptions'; import { ISkillDiscovery } from './skillDiscovery'; -import { userRoots } from './skillRoots'; -import { SKILL_SOURCE_PRIORITY, type ISkillSource, type SkillContribution } from './skillSource'; +import { resolveUserSkillRoots } from './skillRoots'; +import { + isSkillLoadAborted, + SKILL_SOURCE_PRIORITY, + type ISkillSource, + type SkillContribution, +} from './skillSource'; +import { SKILL_ROOT_WATCH_OPTIONS } from './skillTraversal'; export interface IUserFileSkillSource extends ISkillSource { readonly _serviceBrand: undefined; @@ -36,14 +48,21 @@ export class UserFileSkillSource extends Disposable implements IUserFileSkillSou readonly priority = SKILL_SOURCE_PRIORITY.user; private readonly onDidChangeEmitter = this._register(new Emitter()); readonly onDidChange: Event = this.onDidChangeEmitter.event; + private readonly watcher: IPathWatch; constructor( @ISkillDiscovery private readonly discovery: ISkillDiscovery, @IBootstrapService private readonly bootstrap: IBootstrapService, @IConfigService private readonly config: IConfigService, @ISkillCatalogRuntimeOptions private readonly runtimeOptions: ISkillCatalogRuntimeOptions, + @IPathWatchService pathWatch: IPathWatchService, ) { super(); + this.watcher = this._register( + pathWatch.createWatch(SKILL_ROOT_WATCH_OPTIONS, () => { + this.onDidChangeEmitter.fire(); + }), + ); this._register( this.config.onDidSectionChange((event) => { if (event.domain === MERGE_ALL_AVAILABLE_SKILLS_SECTION) this.onDidChangeEmitter.fire(); @@ -51,16 +70,23 @@ export class UserFileSkillSource extends Disposable implements IUserFileSkillSou ); } - async load(): Promise { + async load(signal?: AbortSignal): Promise { if ((this.runtimeOptions.explicitDirs?.length ?? 0) > 0) { return { skills: [] }; } await this.config.ready; + if (isSkillLoadAborted(signal)) return { skills: [] }; const mergeAllAvailableSkills = this.config.get(MERGE_ALL_AVAILABLE_SKILLS_SECTION) ?? true; - return this.discovery.discover( - await userRoots(this.bootstrap.homeDir, this.bootstrap.osHomeDir, { mergeAllAvailableSkills }), + const resolution = await resolveUserSkillRoots( + this.bootstrap.homeDir, + this.bootstrap.osHomeDir, + { mergeAllAvailableSkills }, ); + if (isSkillLoadAborted(signal)) return { skills: [] }; + await this.watcher.setPaths(resolution.candidates); + if (isSkillLoadAborted(signal)) return { skills: [] }; + return this.discovery.discover(resolution.roots, signal); } } diff --git a/packages/agent-core-v2/src/index.ts b/packages/agent-core-v2/src/index.ts index 3f667afe1e..32d39f7c42 100644 --- a/packages/agent-core-v2/src/index.ts +++ b/packages/agent-core-v2/src/index.ts @@ -41,6 +41,8 @@ export * from '#/os/interface/terminalErrors'; export * from '#/os/backends/node-local/hostEnvironmentService'; export * from '#/os/backends/node-local/hostFsService'; export * from '#/os/backends/node-local/hostFsWatchService'; +export * from '#/app/pathWatch/pathWatch'; +import '#/app/pathWatch/pathWatchService'; export * from '#/os/backends/node-local/hostProcessService'; export * from '#/os/backends/node-local/hostTerminalService'; export * from '#/agent/tools/os/bash/bash'; @@ -191,6 +193,11 @@ export * from '#/agent/tools/skill/skill'; import '#/agent/tools/skill/skillTool'; export * from '#/agent/skill/skill'; export * from '#/agent/skill/skillService'; +export * from '#/agent/skillDisclosure/skillDisclosure'; +import '#/agent/skillDisclosure/skillDisclosureService'; +export * from '#/agent/skillDisclosure/skillListReminder'; +import '#/agent/skillDisclosure/skillListReminderService'; +export * from '#/agent/skillDisclosure/skillDisclosureOps'; export * from '#/app/skillCatalog/types'; export * from '#/app/skillCatalog/configSection'; export * from '#/app/skillCatalog/skillCatalogRuntimeOptions'; @@ -201,6 +208,7 @@ export * from '#/app/skillCatalog/skillDiscovery'; export * from '#/app/skillCatalog/inMemorySkillDiscovery'; export * from '#/app/skillCatalog/skillSource'; export * from '#/app/skillCatalog/skillRoots'; +export * from '#/app/skillCatalog/skillTraversal'; export * from '#/app/skillCatalog/builtin/builtin'; export * from '#/app/skillCatalog/builtinSkillSource'; export * from '#/app/skillCatalog/userFileSkillSource'; @@ -465,6 +473,8 @@ export * from '#/agent/contextMemory/contextTranscript'; export * from '#/agent/contextMemory/types'; export * from '#/agent/systemReminder/systemReminder'; export * from '#/agent/systemReminder/systemReminderService'; +export * from '#/agent/dateChange/dateChange'; +export * from '#/agent/dateChange/dateChangeService'; export * from '#/agent/contextProjector/contextProjector'; export * from '#/agent/contextProjector/contextProjectorService'; export * from '#/agent/contextSize/contextSize'; @@ -519,6 +529,8 @@ export * from '#/agent/permissionRules/permissionRulesService'; export * from '#/agent/profile/profile'; export * from '#/agent/profile/profileService'; export * from '#/agent/profile/context'; +export * from '#/agent/profile/agentsMdReminder'; +export * from '#/agent/profile/agentsMdReminderService'; export * from '#/agent/prompt/prompt'; export * from '#/agent/prompt/promptService'; import '#/app/messageLegacy/errors'; diff --git a/packages/agent-core-v2/src/os/backends/node-local/hostFsWatchService.ts b/packages/agent-core-v2/src/os/backends/node-local/hostFsWatchService.ts index b750701ccb..c561b85414 100644 --- a/packages/agent-core-v2/src/os/backends/node-local/hostFsWatchService.ts +++ b/packages/agent-core-v2/src/os/backends/node-local/hostFsWatchService.ts @@ -24,10 +24,12 @@ import { const DEFAULT_IGNORED = (p: string): boolean => /(?:^|[/\\])\.git(?:$|[/\\])/.test(p); class HostFsWatchHandle implements IHostFsWatchHandle { + readonly ready: Promise; readonly onDidChange: Event; private readonly emitter: Emitter; private readonly watcher: FSWatcher; + private readyResolver: (() => void) | undefined; private disposed = false; constructor(path: string, options: HostFsWatchOptions | undefined) { @@ -36,10 +38,21 @@ class HostFsWatchHandle implements IHostFsWatchHandle { this.watcher = new FSWatcher({ ignoreInitial: true, persistent: false, - followSymlinks: false, - depth: options?.recursive === false ? 0 : undefined, + followSymlinks: options?.followSymlinks ?? false, + depth: options?.recursive === false ? 0 : options?.depth, + usePolling: options?.pollingIntervalMs !== undefined, + interval: options?.pollingIntervalMs ?? 100, ignored: options?.ignored ?? DEFAULT_IGNORED, }); + this.ready = new Promise((resolve) => { + this.readyResolver = resolve; + }); + this.watcher.once('ready', () => { + this.markReady(); + }); + this.watcher.once('error', () => { + this.markReady(); + }); this.watcher.on('all', (eventName: string, absPath: string) => { const mapped = mapChokidarEvent(eventName, absPath); if (mapped !== undefined) this.emitter.fire(mapped); @@ -53,9 +66,16 @@ class HostFsWatchHandle implements IHostFsWatchHandle { dispose(): void { if (this.disposed) return; this.disposed = true; + this.markReady(); void this.watcher.close().catch(() => undefined); this.emitter.dispose(); } + + private markReady(): void { + const resolve = this.readyResolver; + this.readyResolver = undefined; + resolve?.(); + } } export class HostFsWatchService implements IHostFsWatchService { diff --git a/packages/agent-core-v2/src/os/interface/hostFsWatch.ts b/packages/agent-core-v2/src/os/interface/hostFsWatch.ts index 890ce98c61..db79422318 100644 --- a/packages/agent-core-v2/src/os/interface/hostFsWatch.ts +++ b/packages/agent-core-v2/src/os/interface/hostFsWatch.ts @@ -4,9 +4,8 @@ * Defines the `IHostFsWatchService`, a thin primitive over the host OS file * watcher. It reports raw create/modify/delete events under an absolute path * and knows nothing about sessions, connections, workspaces or wire frames. - * App-scoped — one shared instance. Higher layers (e.g. `sessionFsWatch`) - * subscribe, confine events to a workspace, debounce/coalesce and re-expose - * them as domain events. + * App-scoped — one shared instance. The higher `pathWatch` layer pools raw + * handles and preserves these changes for domain-specific projections. */ import { createDecorator, type ServiceIdentifier } from '#/_base/di/instantiation'; @@ -24,10 +23,14 @@ export interface HostFsChange { export interface HostFsWatchOptions { readonly recursive?: boolean; + readonly depth?: number; + readonly followSymlinks?: boolean; + readonly pollingIntervalMs?: number; readonly ignored?: (path: string) => boolean; } export interface IHostFsWatchHandle extends IDisposable { + readonly ready: Promise; readonly onDidChange: Event; } diff --git a/packages/agent-core-v2/src/session/agentLifecycle/agentLifecycleService.ts b/packages/agent-core-v2/src/session/agentLifecycle/agentLifecycleService.ts index df5e66a165..3875f5025a 100644 --- a/packages/agent-core-v2/src/session/agentLifecycle/agentLifecycleService.ts +++ b/packages/agent-core-v2/src/session/agentLifecycle/agentLifecycleService.ts @@ -250,7 +250,10 @@ export class AgentLifecycleService extends Disposable implements IAgentLifecycle childProfile.applyBindingSnapshot(sourceData); if (override?.model !== undefined) await childProfile.setModel(override.model); if (override?.thinking !== undefined) childProfile.setThinking(override.thinking); - if (override?.cwd !== undefined) childProfile.update({ cwd: override.cwd }); + if (override?.cwd !== undefined) { + childProfile.update({ cwd: override.cwd }); + await childProfile.refreshSystemPrompt(); + } } const sourceMessages = source.accessor.get(IAgentContextMemoryService)?.get(); diff --git a/packages/agent-core-v2/src/session/sessionFs/fsWatch.ts b/packages/agent-core-v2/src/session/sessionFs/fsWatch.ts index 6e44c47780..b26bd26bdf 100644 --- a/packages/agent-core-v2/src/session/sessionFs/fsWatch.ts +++ b/packages/agent-core-v2/src/session/sessionFs/fsWatch.ts @@ -1,9 +1,9 @@ /** * `sessionFsWatch` domain (L2) — workspace-confined filesystem change feed. * - * Defines the `ISessionFsWatchService` that turns the os `IHostFsWatchService` - * raw events into a workspace-relative, debounced, `.gitignore`-aware change - * feed (`FsChangeEvent`) for the session. Callers declare the set of + * Defines the `ISessionFsWatchService` that projects shared `pathWatch` raw + * events into a workspace-relative, debounced, `.gitignore`-aware change feed + * (`FsChangeEvent`) for the session. Callers declare the set of * workspace-relative paths they care about; events outside that subtree are * dropped. Session-scoped — the scope itself is the session, so no * `sessionId` is threaded through. diff --git a/packages/agent-core-v2/src/session/sessionFs/fsWatchService.ts b/packages/agent-core-v2/src/session/sessionFs/fsWatchService.ts index e335f708b7..900696b05f 100644 --- a/packages/agent-core-v2/src/session/sessionFs/fsWatchService.ts +++ b/packages/agent-core-v2/src/session/sessionFs/fsWatchService.ts @@ -1,11 +1,12 @@ /** * `sessionFsWatch` domain (L2) — `ISessionFsWatchService` implementation. * - * Subscribes to the os `IHostFsWatchService` on the workspace root, confines - * events to the caller-declared subtree and to non-`.gitignore`d paths, + * Subscribes to the shared `pathWatch` service on the workspace root, confines + * its raw events to the caller-declared subtree and to non-`.gitignore`d paths, * debounces them into fixed windows and re-exposes them as workspace-relative - * `FsChangeEvent`s. The os watcher is started lazily on the first non-empty + * `FsChangeEvent`s. The path watch is started lazily on the first non-empty * subscription and stopped when the subscription set becomes empty. The + * workspace `.gitignore` is read through `hostFs`. The * plain-data state (`watched`, `pending`, `rawCount`, `truncated`, * `gitignoreLoaded`) is registered into `sessionState` (`ISessionStateService`) * and read/written through it. Path confinement is lexical @@ -16,17 +17,14 @@ import { isAbsolute, join, relative, sep } from 'node:path'; import ignore, { type Ignore } from 'ignore'; -import { Disposable, type IDisposable } from '#/_base/di/lifecycle'; +import { Disposable } from '#/_base/di/lifecycle'; import { Emitter, type Event } from '#/_base/event'; import { LifecycleScope, ScopeActivation, registerScopedService } from '#/_base/di/scope'; import { defineState } from '#/_base/state/stateRegistry'; +import { type IPathWatch, IPathWatchService } from '#/app/pathWatch/pathWatch'; import { ErrorCodes, Error2 } from '#/errors'; import { IHostFileSystem } from '#/os/interface/hostFileSystem'; -import { - type HostFsChange, - type IHostFsWatchHandle, - IHostFsWatchService, -} from '#/os/interface/hostFsWatch'; +import type { HostFsChange } from '#/os/interface/hostFsWatch'; import { ISessionStateService } from '#/session/state/sessionState'; import { ISessionWorkspaceContext } from '#/session/workspaceContext/workspaceContext'; import type { FsChangeEntry, FsChangeEvent } from './fsWatch'; @@ -61,8 +59,7 @@ export class SessionFsWatchService extends Disposable implements ISessionFsWatch private readonly emitter = this._register(new Emitter()); readonly onDidChangeFiles: Event = this.emitter.event; - private handle: IHostFsWatchHandle | undefined; - private handleSub: IDisposable | undefined; + private handle: IPathWatch | undefined; private debounceTimer: NodeJS.Timeout | undefined; @@ -80,7 +77,7 @@ export class SessionFsWatchService extends Disposable implements ISessionFsWatch constructor( @ISessionStateService private readonly states: ISessionStateService, @ISessionWorkspaceContext private readonly workspace: ISessionWorkspaceContext, - @IHostFsWatchService private readonly hostFsWatch: IHostFsWatchService, + @IPathWatchService private readonly pathWatch: IPathWatchService, @IHostFileSystem private readonly hostFs: IHostFileSystem, ) { super(); @@ -153,14 +150,17 @@ export class SessionFsWatchService extends Disposable implements ISessionFsWatch private ensureHandle(): void { if (this.handle !== undefined) return; this.loadGitignore(); - const handle = this.hostFsWatch.watch(this.workspace.workDir, { recursive: true }); + const handle = this.pathWatch.createWatch( + { target: 'directory', recursive: true, debounceMs: 0 }, + ({ change }) => { + if (change !== undefined) this.onRaw(change); + }, + ); this.handle = handle; - this.handleSub = handle.onDidChange((e) => this.onRaw(e)); + void handle.setPaths([this.workspace.workDir]); } private teardownHandle(): void { - this.handleSub?.dispose(); - this.handleSub = undefined; this.handle?.dispose(); this.handle = undefined; } @@ -192,7 +192,9 @@ export class SessionFsWatchService extends Disposable implements ISessionFsWatch this.pending = []; } if (this.debounceTimer === undefined) { - const timer = setTimeout(() => this.flush(), this.debounceMs); + const timer = setTimeout(() => { + this.flush(); + }, this.debounceMs); timer.unref?.(); this.debounceTimer = timer; } diff --git a/packages/agent-core-v2/src/session/sessionSkillCatalog/explicitFileSkillSource.ts b/packages/agent-core-v2/src/session/sessionSkillCatalog/explicitFileSkillSource.ts index d2096c7443..28e4cadf3f 100644 --- a/packages/agent-core-v2/src/session/sessionSkillCatalog/explicitFileSkillSource.ts +++ b/packages/agent-core-v2/src/session/sessionSkillCatalog/explicitFileSkillSource.ts @@ -4,17 +4,31 @@ * Mirrors v1 SDK `skillDirs`: when runtime options provide `explicitDirs`, this * source contributes those directories as the user source, resolving relative * paths against the session project root. When no explicit dirs are configured, - * it yields nothing so default user / project discovery remains active. Bound at - * Session scope so each session resolves paths against its own workDir. + * it yields nothing so default user / project discovery remains active. Watches + * the explicit directories through `pathWatch` and re-fires + * `onDidChange` on debounced fs changes. Bound at Session scope so each session + * resolves paths against its own workDir. */ import { createDecorator, type ServiceIdentifier } from '#/_base/di/instantiation'; +import { Disposable } from '#/_base/di/lifecycle'; +import { Emitter, type Event } from '#/_base/event'; import { LifecycleScope, ScopeActivation, registerScopedService } from '#/_base/di/scope'; import { IBootstrapService } from '#/app/bootstrap/bootstrap'; -import { configuredRoots } from '#/app/skillCatalog/skillRoots'; +import { + type IPathWatch, + IPathWatchService, +} from '#/app/pathWatch/pathWatch'; +import { resolveConfiguredSkillRoots } from '#/app/skillCatalog/skillRoots'; import { ISkillCatalogRuntimeOptions } from '#/app/skillCatalog/skillCatalogRuntimeOptions'; import { ISkillDiscovery } from '#/app/skillCatalog/skillDiscovery'; -import { SKILL_SOURCE_PRIORITY, type ISkillSource, type SkillContribution } from '#/app/skillCatalog/skillSource'; +import { + isSkillLoadAborted, + SKILL_SOURCE_PRIORITY, + type ISkillSource, + type SkillContribution, +} from '#/app/skillCatalog/skillSource'; +import { SKILL_ROOT_WATCH_OPTIONS } from '#/app/skillCatalog/skillTraversal'; import { ISessionWorkspaceContext } from '#/session/workspaceContext/workspaceContext'; export interface IExplicitFileSkillSource extends ISkillSource { @@ -24,27 +38,45 @@ export interface IExplicitFileSkillSource extends ISkillSource { export const IExplicitFileSkillSource: ServiceIdentifier = createDecorator('explicitFileSkillSource'); -export class ExplicitFileSkillSource implements IExplicitFileSkillSource { +export class ExplicitFileSkillSource extends Disposable implements IExplicitFileSkillSource { declare readonly _serviceBrand: undefined; readonly id = 'explicit'; readonly priority = SKILL_SOURCE_PRIORITY.user; + private readonly onDidChangeEmitter = this._register(new Emitter()); + readonly onDidChange: Event = this.onDidChangeEmitter.event; + private readonly watcher: IPathWatch; constructor( @ISkillDiscovery private readonly discovery: ISkillDiscovery, @ISkillCatalogRuntimeOptions private readonly runtimeOptions: ISkillCatalogRuntimeOptions, @ISessionWorkspaceContext private readonly workspace: ISessionWorkspaceContext, @IBootstrapService private readonly bootstrap: IBootstrapService, - ) {} + @IPathWatchService pathWatch: IPathWatchService, + ) { + super(); + this.watcher = this._register( + pathWatch.createWatch(SKILL_ROOT_WATCH_OPTIONS, () => { + this.onDidChangeEmitter.fire(); + }), + ); + } - async load(): Promise { + async load(signal?: AbortSignal): Promise { const explicitDirs = this.runtimeOptions.explicitDirs ?? []; if (explicitDirs.length === 0) { return { skills: [] }; } - return this.discovery.discover( - await configuredRoots(explicitDirs, this.workspace.workDir, this.bootstrap.osHomeDir, 'user'), + const resolution = await resolveConfiguredSkillRoots( + explicitDirs, + this.workspace.workDir, + this.bootstrap.osHomeDir, + 'user', ); + if (isSkillLoadAborted(signal)) return { skills: [] }; + await this.watcher.setPaths(resolution.candidates); + if (isSkillLoadAborted(signal)) return { skills: [] }; + return this.discovery.discover(resolution.roots, signal); } } diff --git a/packages/agent-core-v2/src/session/sessionSkillCatalog/extraFileSkillSource.ts b/packages/agent-core-v2/src/session/sessionSkillCatalog/extraFileSkillSource.ts index 6d8d834c3b..21eba84541 100644 --- a/packages/agent-core-v2/src/session/sessionSkillCatalog/extraFileSkillSource.ts +++ b/packages/agent-core-v2/src/session/sessionSkillCatalog/extraFileSkillSource.ts @@ -4,8 +4,10 @@ * Discovers user-configured extra skill directories (`extraSkillDirs`) through * `ISkillDiscovery`, contributing them at priority 10 (above plugin / builtin, * below user / workspace). Relative paths resolve against the session project - * root; `~` and `~/...` resolve against the bootstrap home dir. Bound at Session - * scope so each session reads its own workspace root. + * root; `~` and `~/...` resolve against the bootstrap home dir. Watches the + * configured directories through `pathWatch` and re-fires + * `onDidChange` on debounced fs changes. Bound at Session scope so each + * session reads its own workspace root. */ import { createDecorator, type ServiceIdentifier } from '#/_base/di/instantiation'; @@ -14,13 +16,23 @@ import { Emitter, type Event } from '#/_base/event'; import { LifecycleScope, ScopeActivation, registerScopedService } from '#/_base/di/scope'; import { IBootstrapService } from '#/app/bootstrap/bootstrap'; import { IConfigService } from '#/app/config/config'; +import { + type IPathWatch, + IPathWatchService, +} from '#/app/pathWatch/pathWatch'; import { EXTRA_SKILL_DIRS_SECTION, type ExtraSkillDirsConfig, } from '#/app/skillCatalog/configSection'; -import { configuredRoots } from '#/app/skillCatalog/skillRoots'; +import { resolveConfiguredSkillRoots } from '#/app/skillCatalog/skillRoots'; import { ISkillDiscovery } from '#/app/skillCatalog/skillDiscovery'; -import { SKILL_SOURCE_PRIORITY, type ISkillSource, type SkillContribution } from '#/app/skillCatalog/skillSource'; +import { + isSkillLoadAborted, + SKILL_SOURCE_PRIORITY, + type ISkillSource, + type SkillContribution, +} from '#/app/skillCatalog/skillSource'; +import { SKILL_ROOT_WATCH_OPTIONS } from '#/app/skillCatalog/skillTraversal'; import { ISessionWorkspaceContext } from '#/session/workspaceContext/workspaceContext'; export interface IExtraFileSkillSource extends ISkillSource { @@ -37,14 +49,21 @@ export class ExtraFileSkillSource extends Disposable implements IExtraFileSkillS readonly priority = SKILL_SOURCE_PRIORITY.extra; private readonly onDidChangeEmitter = this._register(new Emitter()); readonly onDidChange: Event = this.onDidChangeEmitter.event; + private readonly watcher: IPathWatch; constructor( @ISkillDiscovery private readonly discovery: ISkillDiscovery, @IConfigService private readonly config: IConfigService, @ISessionWorkspaceContext private readonly workspace: ISessionWorkspaceContext, @IBootstrapService private readonly bootstrap: IBootstrapService, + @IPathWatchService pathWatch: IPathWatchService, ) { super(); + this.watcher = this._register( + pathWatch.createWatch(SKILL_ROOT_WATCH_OPTIONS, () => { + this.onDidChangeEmitter.fire(); + }), + ); this._register( this.config.onDidSectionChange((event) => { if (event.domain === EXTRA_SKILL_DIRS_SECTION) this.onDidChangeEmitter.fire(); @@ -52,12 +71,20 @@ export class ExtraFileSkillSource extends Disposable implements IExtraFileSkillS ); } - async load(): Promise { + async load(signal?: AbortSignal): Promise { await this.config.ready; + if (isSkillLoadAborted(signal)) return { skills: [] }; const extraSkillDirs = this.config.get(EXTRA_SKILL_DIRS_SECTION) ?? []; - return this.discovery.discover( - await configuredRoots(extraSkillDirs, this.workspace.workDir, this.bootstrap.osHomeDir, 'extra'), + const resolution = await resolveConfiguredSkillRoots( + extraSkillDirs, + this.workspace.workDir, + this.bootstrap.osHomeDir, + 'extra', ); + if (isSkillLoadAborted(signal)) return { skills: [] }; + await this.watcher.setPaths(resolution.candidates); + if (isSkillLoadAborted(signal)) return { skills: [] }; + return this.discovery.discover(resolution.roots, signal); } } diff --git a/packages/agent-core-v2/src/session/sessionSkillCatalog/pluginSkillSource.ts b/packages/agent-core-v2/src/session/sessionSkillCatalog/pluginSkillSource.ts index 958c219528..16ac20ac89 100644 --- a/packages/agent-core-v2/src/session/sessionSkillCatalog/pluginSkillSource.ts +++ b/packages/agent-core-v2/src/session/sessionSkillCatalog/pluginSkillSource.ts @@ -4,16 +4,27 @@ * Discovers skills contributed by enabled plugins through `ISkillDiscovery` * (roots from `plugin.pluginSkillRoots()`), contributing them at priority 5 * (above builtin, below extra / user / workspace, so project, user and extra skills win name - * collisions). Re-emits - * `plugin.onDidReload` as `onDidChange` so the sink re-pulls plugin skills when - * plugins reload. Bound at Session scope. + * collisions). Watches the resolved roots through `pathWatch` and + * re-emits both filesystem changes and `plugin.onDidReload` through + * `onDidChange`. Bound at Session scope. */ import { createDecorator, type ServiceIdentifier } from '#/_base/di/instantiation'; -import type { Event } from '#/_base/event'; +import { Disposable } from '#/_base/di/lifecycle'; +import { Emitter, type Event } from '#/_base/event'; import { LifecycleScope, ScopeActivation, registerScopedService } from '#/_base/di/scope'; +import { + type IPathWatch, + IPathWatchService, +} from '#/app/pathWatch/pathWatch'; import { ISkillDiscovery } from '#/app/skillCatalog/skillDiscovery'; -import { SKILL_SOURCE_PRIORITY, type ISkillSource, type SkillContribution } from '#/app/skillCatalog/skillSource'; +import { + isSkillLoadAborted, + SKILL_SOURCE_PRIORITY, + type ISkillSource, + type SkillContribution, +} from '#/app/skillCatalog/skillSource'; +import { SKILL_ROOT_WATCH_OPTIONS } from '#/app/skillCatalog/skillTraversal'; import { IPluginService } from '#/app/plugin/plugin'; export interface IPluginSkillSource extends ISkillSource { @@ -25,25 +36,37 @@ export const IPluginSkillSource: ServiceIdentifier = export const PLUGIN_SKILL_SOURCE_ID = 'plugin'; -export class PluginSkillSource implements IPluginSkillSource { +export class PluginSkillSource extends Disposable implements IPluginSkillSource { declare readonly _serviceBrand: undefined; readonly id = PLUGIN_SKILL_SOURCE_ID; readonly priority = SKILL_SOURCE_PRIORITY.plugin; - readonly onDidChange: Event = (listener, thisArg, disposables) => - this.plugins.onDidReload( - () => listener.call(thisArg, undefined as void), - undefined, - disposables, - ); + private readonly onDidChangeEmitter = this._register(new Emitter()); + readonly onDidChange: Event = this.onDidChangeEmitter.event; + private readonly watcher: IPathWatch; constructor( @ISkillDiscovery private readonly discovery: ISkillDiscovery, @IPluginService private readonly plugins: IPluginService, - ) {} + @IPathWatchService pathWatch: IPathWatchService, + ) { + super(); + this.watcher = this._register( + pathWatch.createWatch(SKILL_ROOT_WATCH_OPTIONS, () => { + this.onDidChangeEmitter.fire(); + }), + ); + this._register(this.plugins.onDidReload(() => { + this.onDidChangeEmitter.fire(); + })); + } - async load(): Promise { - return this.discovery.discover(await this.plugins.pluginSkillRoots()); + async load(signal?: AbortSignal): Promise { + const roots = await this.plugins.pluginSkillRoots(); + if (isSkillLoadAborted(signal)) return { skills: [] }; + await this.watcher.setPaths(roots.map((root) => root.path)); + if (isSkillLoadAborted(signal)) return { skills: [] }; + return this.discovery.discover(roots, signal); } } diff --git a/packages/agent-core-v2/src/session/sessionSkillCatalog/skillCatalogService.ts b/packages/agent-core-v2/src/session/sessionSkillCatalog/skillCatalogService.ts index 511ef94579..d32f6ec5cc 100644 --- a/packages/agent-core-v2/src/session/sessionSkillCatalog/skillCatalogService.ts +++ b/packages/agent-core-v2/src/session/sessionSkillCatalog/skillCatalogService.ts @@ -42,6 +42,8 @@ export class SessionSkillCatalogService private readonly sources: readonly ISkillSource[]; private readonly sourceLoadTails = new Map>(); + private readonly loadAbort = new AbortController(); + private disposed = false; readonly ready: Promise; private readonly onDidChangeEmitter = this._register(new Emitter()); readonly onDidChange: Event = this.onDidChangeEmitter.event; @@ -60,7 +62,9 @@ export class SessionSkillCatalogService this.states.register(skillCatalogMergedKey); this.sources = [builtin, user, explicit, extra, workspace, plugin].toSorted((a, b) => a.priority - b.priority); for (const s of this.sources) { - if (s.onDidChange) this._register(s.onDidChange(() => { void this.reloadSource(s.id); })); + if (s.onDidChange) { + this._register(s.onDidChange(() => { void this.reloadSource(s.id).catch(() => undefined); })); + } } this.ready = this.loadAll(); } @@ -89,17 +93,21 @@ export class SessionSkillCatalogService } async reload(): Promise { + if (this.disposed) return; await this.loadAll(); + if (this.disposed) return; this.onDidChangeEmitter.fire('catalog'); } set(id: string, c: SkillContribution, { priority }: { readonly priority: number }): void { + if (this.disposed) return; this.contributions.set(id, { c, priority }); this.remerge(); this.onDidChangeEmitter.fire(id); } remove(id: string): void { + if (this.disposed) return; this.contributions.delete(id); this.remerge(); this.onDidChangeEmitter.fire(id); @@ -107,12 +115,15 @@ export class SessionSkillCatalogService private async loadAll(): Promise { for (const s of this.sources) { + if (this.disposed) return; await this.loadSource(s); } + if (this.disposed) return; this.remerge(); } private async reloadSource(id: string): Promise { + if (this.disposed) return; const s = this.sources.find((x) => x.id === id); if (!s) return; await this.loadSource(s, true); @@ -121,7 +132,9 @@ export class SessionSkillCatalogService private loadSource(source: ISkillSource, fireChange = false): Promise { const previous = this.sourceLoadTails.get(source) ?? Promise.resolve(); const current = previous.catch(() => undefined).then(async () => { - const contribution = await source.load(); + if (this.disposed) return; + const contribution = await source.load(this.loadAbort.signal); + if (this.disposed || this.loadAbort.signal.aborted) return; this.contributions.set(source.id, { c: contribution, priority: source.priority }); if (fireChange) { this.remerge(); @@ -138,6 +151,14 @@ export class SessionSkillCatalogService return current; } + override dispose(): void { + if (this.disposed) return; + this.disposed = true; + this.loadAbort.abort(); + this.sourceLoadTails.clear(); + super.dispose(); + } + private remerge(): void { const m = new InMemorySkillCatalog(); const ordered = [...this.contributions.values()].toSorted((a, b) => a.priority - b.priority); diff --git a/packages/agent-core-v2/src/session/sessionSkillCatalog/workspaceFileSkillSource.ts b/packages/agent-core-v2/src/session/sessionSkillCatalog/workspaceFileSkillSource.ts index e4398a730b..3f5b64ece8 100644 --- a/packages/agent-core-v2/src/session/sessionSkillCatalog/workspaceFileSkillSource.ts +++ b/packages/agent-core-v2/src/session/sessionSkillCatalog/workspaceFileSkillSource.ts @@ -3,8 +3,10 @@ * * Discovers project skills from the session's current `workDir` * (`workspaceContext`) through `ISkillDiscovery`, contributing them at priority - * 30 (above user / extra / plugin / builtin). Bound at Session scope so each session reads - * its own workspace root. + * 30 (above user / extra / plugin / builtin). Watches the candidate root paths + * (existing or not) through `pathWatch` and re-fires `onDidChange` on + * debounced fs changes. Bound at Session scope so each session reads its own + * workspace root. */ import { createDecorator, type ServiceIdentifier } from '#/_base/di/instantiation'; @@ -12,14 +14,24 @@ import { Disposable } from '#/_base/di/lifecycle'; import { Emitter, type Event } from '#/_base/event'; import { LifecycleScope, ScopeActivation, registerScopedService } from '#/_base/di/scope'; import { IConfigService } from '#/app/config/config'; +import { + type IPathWatch, + IPathWatchService, +} from '#/app/pathWatch/pathWatch'; import { MERGE_ALL_AVAILABLE_SKILLS_SECTION, type MergeAllAvailableSkillsConfig, } from '#/app/skillCatalog/configSection'; import { ISkillCatalogRuntimeOptions } from '#/app/skillCatalog/skillCatalogRuntimeOptions'; import { ISkillDiscovery } from '#/app/skillCatalog/skillDiscovery'; -import { projectRoots } from '#/app/skillCatalog/skillRoots'; -import { SKILL_SOURCE_PRIORITY, type ISkillSource, type SkillContribution } from '#/app/skillCatalog/skillSource'; +import { resolveProjectSkillRoots } from '#/app/skillCatalog/skillRoots'; +import { + isSkillLoadAborted, + SKILL_SOURCE_PRIORITY, + type ISkillSource, + type SkillContribution, +} from '#/app/skillCatalog/skillSource'; +import { SKILL_ROOT_WATCH_OPTIONS } from '#/app/skillCatalog/skillTraversal'; import { ISessionWorkspaceContext } from '#/session/workspaceContext/workspaceContext'; export interface IWorkspaceFileSkillSource extends ISkillSource { @@ -36,14 +48,21 @@ export class WorkspaceFileSkillSource extends Disposable implements IWorkspaceFi readonly priority = SKILL_SOURCE_PRIORITY.workspace; private readonly onDidChangeEmitter = this._register(new Emitter()); readonly onDidChange: Event = this.onDidChangeEmitter.event; + private readonly watcher: IPathWatch; constructor( @ISkillDiscovery private readonly discovery: ISkillDiscovery, @ISessionWorkspaceContext private readonly workspace: ISessionWorkspaceContext, @IConfigService private readonly config: IConfigService, @ISkillCatalogRuntimeOptions private readonly runtimeOptions: ISkillCatalogRuntimeOptions, + @IPathWatchService pathWatch: IPathWatchService, ) { super(); + this.watcher = this._register( + pathWatch.createWatch(SKILL_ROOT_WATCH_OPTIONS, () => { + this.onDidChangeEmitter.fire(); + }), + ); this._register( this.config.onDidSectionChange((event) => { if (event.domain === MERGE_ALL_AVAILABLE_SKILLS_SECTION) this.onDidChangeEmitter.fire(); @@ -51,14 +70,21 @@ export class WorkspaceFileSkillSource extends Disposable implements IWorkspaceFi ); } - async load(): Promise { + async load(signal?: AbortSignal): Promise { if ((this.runtimeOptions.explicitDirs?.length ?? 0) > 0) { return { skills: [] }; } await this.config.ready; + if (isSkillLoadAborted(signal)) return { skills: [] }; const mergeAllAvailableSkills = this.config.get(MERGE_ALL_AVAILABLE_SKILLS_SECTION) ?? true; - return this.discovery.discover(await projectRoots(this.workspace.workDir, { mergeAllAvailableSkills })); + const resolution = await resolveProjectSkillRoots(this.workspace.workDir, { + mergeAllAvailableSkills, + }); + if (isSkillLoadAborted(signal)) return { skills: [] }; + await this.watcher.setPaths(resolution.candidates); + if (isSkillLoadAborted(signal)) return { skills: [] }; + return this.discovery.discover(resolution.roots, signal); } } diff --git a/packages/agent-core-v2/test/agent/dateChange/dateChangeInjection.test.ts b/packages/agent-core-v2/test/agent/dateChange/dateChangeInjection.test.ts new file mode 100644 index 0000000000..13c3d9859e --- /dev/null +++ b/packages/agent-core-v2/test/agent/dateChange/dateChangeInjection.test.ts @@ -0,0 +1,135 @@ +/** + * Scenario: `date_change` context injection announces calendar-date changes. + * + * Exercises the real provider through the harness injector: baselines come + * from the last reminder in history, then the system prompt's rendered date, + * then a silent adoption for dateless prompts. Run: `pnpm --filter + * @moonshot-ai/agent-core-v2 exec vitest run + * test/agent/dateChange/dateChangeInjection.test.ts`. + */ + +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; + +import { IAgentContextInjectorService } from '#/agent/contextInjector/contextInjector'; +import { IAgentContextMemoryService } from '#/agent/contextMemory/contextMemory'; +import type { ContextMessage } from '#/agent/contextMemory/types'; +import { IAgentProfileService } from '#/agent/profile/profile'; + +import { createTestAgent, type TestAgentContext } from '../../harness'; + +type InjectableDynamicInjector = { + inject(): Promise; +}; + +function localDateKey(date: Date): string { + const year = date.getFullYear(); + const month = `${date.getMonth() + 1}`.padStart(2, '0'); + const day = `${date.getDate()}`.padStart(2, '0'); + return `${year}-${month}-${day}`; +} + +function systemPromptWithDate(iso: string): string { + return [ + 'You are a deterministic test agent.', + '', + `The current date and time in ISO format is \`${iso}\`. This was captured when the session started and does not update.`, + ].join('\n'); +} + +function dateReminders(context: IAgentContextMemoryService): readonly ContextMessage[] { + return context.get().filter((message) => { + return message.origin?.kind === 'injection' && message.origin.variant === 'date_change'; + }); +} + +function messageText(message: ContextMessage): string { + return message.content + .map((part) => (part.type === 'text' ? part.text : '')) + .join(''); +} + +describe('AgentDateChangeService', () => { + let ctx: TestAgentContext; + let context: IAgentContextMemoryService; + let injector: InjectableDynamicInjector; + let profile: IAgentProfileService; + + beforeEach(() => { + vi.useFakeTimers({ toFake: ['Date'] }); + vi.setSystemTime(new Date(2026, 6, 29, 12)); + ctx = createTestAgent(); + context = ctx.get(IAgentContextMemoryService); + injector = ctx.get(IAgentContextInjectorService) as unknown as InjectableDynamicInjector; + profile = ctx.get(IAgentProfileService); + }); + + afterEach(async () => { + try { + try { + await ctx.expectResumeMatches(); + } finally { + await ctx.dispose(); + } + } finally { + vi.useRealTimers(); + } + }); + + it('does not inject when the system prompt date is today', async () => { + profile.update({ systemPrompt: systemPromptWithDate(new Date().toISOString()) }); + + await injector.inject(); + + expect(dateReminders(context)).toHaveLength(0); + }); + + it('injects once when the rendered date is stale, then stays quiet', async () => { + const yesterday = new Date(); + yesterday.setDate(yesterday.getDate() - 1); + profile.update({ systemPrompt: systemPromptWithDate(yesterday.toISOString()) }); + + await injector.inject(); + + const reminders = dateReminders(context); + expect(reminders).toHaveLength(1); + const first = reminders[0]; + expect(first).toBeDefined(); + const text = messageText(first as ContextMessage); + expect(text).toContain(`Today's date is now ${localDateKey(new Date())}`); + expect(text).toContain('stale'); + expect(text).toContain('DO NOT mention this to the user explicitly'); + + await injector.inject(); + expect(dateReminders(context)).toHaveLength(1); + }); + + it('announces each date crossed by a long-lived session', async () => { + profile.update({ systemPrompt: systemPromptWithDate(new Date().toISOString()) }); + await injector.inject(); + + vi.setSystemTime(new Date(2026, 6, 30, 12)); + await injector.inject(); + + let reminders = dateReminders(context); + expect(reminders).toHaveLength(1); + expect(messageText(reminders[0] as ContextMessage)).toContain( + "Today's date is now 2026-07-30", + ); + + vi.setSystemTime(new Date(2026, 6, 31, 12)); + await injector.inject(); + + reminders = dateReminders(context); + expect(reminders).toHaveLength(2); + expect(messageText(reminders[1] as ContextMessage)).toContain( + "Today's date is now 2026-07-31", + ); + }); + + it('adopts today silently when the system prompt carries no date line', async () => { + await injector.inject(); + + expect(dateReminders(context)).toHaveLength(0); + expect(context.get()).toHaveLength(0); + }); +}); diff --git a/packages/agent-core-v2/test/agent/loop/loop.test.ts b/packages/agent-core-v2/test/agent/loop/loop.test.ts index 02a7a24767..b9b8cc58ff 100644 --- a/packages/agent-core-v2/test/agent/loop/loop.test.ts +++ b/packages/agent-core-v2/test/agent/loop/loop.test.ts @@ -101,6 +101,7 @@ describe('Agent loop', () => { [emit] agent.activity.updated { "lifecycle": "ready", "turn": { "turnId": 0, "origin": { "kind": "user" }, "phase": "running", "step": 0, "ending": false, "pendingApprovals": [], "activeToolCalls": [], "since": "