From 8a9f9f590b55354dc5e3d1f11ed46b08d53da9f1 Mon Sep 17 00:00:00 2001 From: 7Sageer <7sageer@djwcb.cn> Date: Tue, 28 Jul 2026 18:45:34 +0800 Subject: [PATCH 01/11] feat(agent-core-v2): announce date changes and new skills via system reminders The system prompt is only re-rendered at profile bind and after compaction, so a long session keeps a stale date after midnight and a skill added mid-session is invisible to the model. Inject the two facts as messages through the per-step context injector instead: - date_change: before each step, compares the local date against a baseline derived from the last such reminder in context, else the date rendered into the current system prompt. - skill_list: watches the file-based skill roots (chokidar, 300ms debounce, parent-dir re-bind so roots created mid-session are detected), reloads only the changed source, and injects the full fresh listing only when new skill names appear. Both baselines are history-derived (no extra state files), so resume does not double-inject and compaction resets them naturally. The test harness now stubs the fs watch service so sessions no longer spawn real chokidar watchers per test. --- .../agent-core-v2/docs/state-manifest.d.ts | 10 +- .../scripts/check-domain-layers.mjs | 10 ++ .../src/agent/dateChange/dateChange.ts | 16 ++ .../src/agent/dateChange/dateChangeService.ts | 116 ++++++++++++++ .../src/agent/skill/skillListReminder.ts | 16 ++ .../agent/skill/skillListReminderService.ts | 137 +++++++++++++++++ .../src/app/skillCatalog/skillRoots.ts | 29 ++++ .../app/skillCatalog/skillSourceWatcher.ts | 101 +++++++++++++ .../app/skillCatalog/userFileSkillSource.ts | 16 +- packages/agent-core-v2/src/index.ts | 5 + .../explicitFileSkillSource.ts | 28 +++- .../extraFileSkillSource.ts | 18 ++- .../skillCatalogService.ts | 6 +- .../workspaceFileSkillSource.ts | 16 +- .../dateChange/dateChangeInjection.test.ts | 107 +++++++++++++ .../agent/skill/skillListReminder.test.ts | 142 ++++++++++++++++++ .../test/app/skillCatalog/skillRoots.test.ts | 52 ++++++- packages/agent-core-v2/test/harness/agent.ts | 15 ++ packages/agent-core-v2/test/os/stubs.ts | 51 +++++++ .../sessionSkillCatalog/skillCatalog.test.ts | 98 +++++++++++- 20 files changed, 971 insertions(+), 18 deletions(-) create mode 100644 packages/agent-core-v2/src/agent/dateChange/dateChange.ts create mode 100644 packages/agent-core-v2/src/agent/dateChange/dateChangeService.ts create mode 100644 packages/agent-core-v2/src/agent/skill/skillListReminder.ts create mode 100644 packages/agent-core-v2/src/agent/skill/skillListReminderService.ts create mode 100644 packages/agent-core-v2/src/app/skillCatalog/skillSourceWatcher.ts create mode 100644 packages/agent-core-v2/test/agent/dateChange/dateChangeInjection.test.ts create mode 100644 packages/agent-core-v2/test/agent/skill/skillListReminder.test.ts create mode 100644 packages/agent-core-v2/test/os/stubs.ts diff --git a/packages/agent-core-v2/docs/state-manifest.d.ts b/packages/agent-core-v2/docs/state-manifest.d.ts index 90050ef3ba..6dbaa6a758 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 @@ -62,6 +62,7 @@ // 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 @@ -102,6 +103,7 @@ // profile.emittedToolPatternWarnings src/agent/profile/profileService.ts // prompt.launching src/agent/prompt/promptService.ts // shellCommand.tasks src/agent/shellCommand/shellCommandService.ts +// skillList.seededNames src/agent/skill/skillListReminderService.ts // stepRetry.failedAttempts src/agent/stepRetry/stepRetryService.ts // stepRetry.lastFailedDriverId src/agent/stepRetry/stepRetryService.ts // task.activeTaskReminderPending src/agent/task/taskService.ts @@ -978,6 +980,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 +1014,7 @@ export interface AgentStateSnapshot { 'llmRequester.lastConfigLogSignature': string | undefined; 'llmRequester.mediaDegradedTurns': Set; 'llmRequester.mediaStrippedTurns': Map; 'llmRequester.turnConfigs': Map; + // src/agent/skill/skillListReminderService.ts + 'skillList.seededNames': ReadonlySet | undefined; // src/agent/stepRetry/stepRetryService.ts 'stepRetry.failedAttempts': number; 'stepRetry.lastFailedDriverId': string | undefined; diff --git a/packages/agent-core-v2/scripts/check-domain-layers.mjs b/packages/agent-core-v2/scripts/check-domain-layers.mjs index b29f110e9e..328f4d1735 100644 --- a/packages/agent-core-v2/scripts/check-domain-layers.mjs +++ b/packages/agent-core-v2/scripts/check-domain-layers.mjs @@ -191,6 +191,10 @@ const DOMAIN_LAYER = new Map([ ['contextInjector', 4], ['agentPlugin', 4], ['systemReminder', 4], + // `dateChange` owns the `date_change` context-injection provider (stale + // system-prompt date announcement) — agent behaviour beside the other + // injection owners. + ['dateChange', 4], ['contextProjector', 4], ['contextSize', 4], ['fullCompaction', 4], @@ -513,6 +517,12 @@ const ALLOWED_EXCEPTIONS = new Set([ 'replayBuilder>sessionMetadata', 'skill>contextMemory', 'skill>prompt', + // `skillListReminder` (skill, L3) owns the `skill_list` context-injection + // provider: it registers through `contextInjector` (L4) and reads the + // rendered system prompt from `profile` (L4) as its diff baseline — the same + // shape as the `permissionMode>contextInjector` exception. + 'skill>contextInjector', + 'skill>profile', 'swarm>sessionMetadata', 'btw>agentLifecycle', 'toolExecutor>loop', diff --git a/packages/agent-core-v2/src/agent/dateChange/dateChange.ts b/packages/agent-core-v2/src/agent/dateChange/dateChange.ts new file mode 100644 index 0000000000..cccb3396eb --- /dev/null +++ b/packages/agent-core-v2/src/agent/dateChange/dateChange.ts @@ -0,0 +1,16 @@ +/** + * `dateChange` domain (L4) — `IAgentDateChangeService` contract. + * + * Defines the Agent-scope marker service that announces calendar-date changes + * through a `date_change` context-injection reminder when a session outlives + * the date rendered into its system prompt. + */ + +import { createDecorator, type ServiceIdentifier } from '#/_base/di/instantiation'; + +export interface IAgentDateChangeService { + readonly _serviceBrand: undefined; +} + +export const IAgentDateChangeService: ServiceIdentifier = + 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/skill/skillListReminder.ts b/packages/agent-core-v2/src/agent/skill/skillListReminder.ts new file mode 100644 index 0000000000..a999eed21d --- /dev/null +++ b/packages/agent-core-v2/src/agent/skill/skillListReminder.ts @@ -0,0 +1,16 @@ +/** + * `skill` domain (L3) — `IAgentSkillListReminderService` contract. + * + * Defines the Agent-scope marker service that announces skill-list additions + * through a `skill_list` context-injection reminder when the session catalog + * gains skills the system prompt's listing does not know about. + */ + +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/skill/skillListReminderService.ts b/packages/agent-core-v2/src/agent/skill/skillListReminderService.ts new file mode 100644 index 0000000000..f83c5d4b44 --- /dev/null +++ b/packages/agent-core-v2/src/agent/skill/skillListReminderService.ts @@ -0,0 +1,137 @@ +/** + * `skill` domain (L3) — `IAgentSkillListReminderService` implementation. + * + * Owns the `skill_list` context-injection provider. The system prompt's skill + * listing is only re-rendered at profile (re)bind and after compaction, so a + * skill added mid-session is invisible to the model; this provider announces + * additions with the full fresh listing (which is supersede-worded) at the + * next step boundary. Only additions trigger a reminder — removals and text + * changes are ignored, since a removed skill fails naturally on invocation. + * The baseline is history-derived: the last `skill_list` reminder in context, + * else the `## Available skills` section of the current system prompt, else + * the volatile `seededNames` adopted at first evaluation (profiles without a + * skills section). The plain-data state (`seededNames`) 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 { ISessionSkillCatalog } from '#/session/sessionSkillCatalog/skillCatalog'; + +import { IAgentSkillListReminderService } from './skillListReminder'; + +const SKILL_LIST_INJECTION_VARIANT = 'skill_list'; + +const SKILLS_SECTION_HEADING = '## Available skills'; +const NEXT_HEADING_PATTERN = /\n#{1,2} /; +const LISTING_NAME_PATTERN = /^- ([^:\n]+?): /gm; + +export const skillListSeededNamesKey = defineState | undefined>( + 'skillList.seededNames', + () => undefined, +); + +export class AgentSkillListReminderService extends Disposable implements IAgentSkillListReminderService { + declare readonly _serviceBrand: undefined; + + constructor( + @IAgentContextInjectorService dynamicInjector: IAgentContextInjectorService, + @IAgentContextMemoryService private readonly context: IAgentContextMemoryService, + @IAgentProfileService private readonly profile: IAgentProfileService, + @ISessionSkillCatalog private readonly skillCatalog: ISessionSkillCatalog, + @IAgentStateService private readonly states: IAgentStateService, + ) { + super(); + this.states.register(skillListSeededNamesKey); + this._register( + dynamicInjector.register(SKILL_LIST_INJECTION_VARIANT, (ctx) => this.reminder(ctx)), + ); + } + + private get seededNames(): ReadonlySet | undefined { + return this.states.get(skillListSeededNamesKey); + } + + private set seededNames(value: ReadonlySet | undefined) { + this.states.set(skillListSeededNamesKey, value); + } + + private async reminder({ lastInjectedAt }: ContextInjectionContext): Promise { + try { + await this.skillCatalog.ready; + const listing = this.skillCatalog.catalog.getModelSkillListing(); + const currentNames = extractSkillNames(listing); + if (currentNames.size === 0) return undefined; + const baseline = this.baseline(lastInjectedAt) ?? this.adopt(currentNames); + for (const name of currentNames) { + if (!baseline.has(name)) return buildSkillListReminder(listing); + } + return undefined; + } catch { + // A broken catalog must never break the step loop. + return undefined; + } + } + + private baseline(lastInjectedAt: number | null): ReadonlySet | undefined { + return this.namesFromHistory(lastInjectedAt) ?? this.namesFromSystemPrompt() ?? this.seededNames; + } + + private adopt(currentNames: ReadonlySet): ReadonlySet { + this.seededNames = currentNames; + return currentNames; + } + + private namesFromHistory(lastInjectedAt: number | null): ReadonlySet | undefined { + if (lastInjectedAt === null) return undefined; + const message: ContextMessage | undefined = this.context.get()[lastInjectedAt]; + if (message === undefined) return undefined; + const names = extractSkillNames(messageText(message)); + return names.size > 0 ? names : undefined; + } + + private namesFromSystemPrompt(): ReadonlySet | undefined { + const prompt = this.profile.getSystemPrompt(); + const start = prompt.indexOf(SKILLS_SECTION_HEADING); + if (start < 0) return undefined; + const rest = prompt.slice(start + SKILLS_SECTION_HEADING.length); + const end = rest.search(NEXT_HEADING_PATTERN); + return extractSkillNames(end < 0 ? rest : rest.slice(0, end)); + } +} + +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 extractSkillNames(text: string): ReadonlySet { + const names = new Set(); + for (const match of text.matchAll(LISTING_NAME_PATTERN)) { + if (match[1] !== undefined) names.add(match[1].toLowerCase()); + } + return names; +} + +function messageText(message: ContextMessage): string { + return message.content + .map((part) => (part.type === 'text' ? part.text : '')) + .join(''); +} + +registerScopedService( + LifecycleScope.Agent, + IAgentSkillListReminderService, + AgentSkillListReminderService, + ScopeActivation.OnScopeCreated, + 'skill', +); diff --git a/packages/agent-core-v2/src/app/skillCatalog/skillRoots.ts b/packages/agent-core-v2/src/app/skillCatalog/skillRoots.ts index c0a1c76330..28e12d9737 100644 --- a/packages/agent-core-v2/src/app/skillCatalog/skillRoots.ts +++ b/packages/agent-core-v2/src/app/skillCatalog/skillRoots.ts @@ -62,6 +62,35 @@ export async function configuredRoots( return roots; } +/** + * Watch candidates: every plausible root path WITHOUT existence filtering, so + * file watchers can also observe a skills directory being created mid-session + * (chokidar reports paths that appear after the watch was armed). + */ +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 { const start = path.resolve(workDir); let current = start; diff --git a/packages/agent-core-v2/src/app/skillCatalog/skillSourceWatcher.ts b/packages/agent-core-v2/src/app/skillCatalog/skillSourceWatcher.ts new file mode 100644 index 0000000000..2d6bd2e97d --- /dev/null +++ b/packages/agent-core-v2/src/app/skillCatalog/skillSourceWatcher.ts @@ -0,0 +1,101 @@ +/** + * `skillCatalog` domain (L3) — file-watch helper for file-based skill sources. + * + * Watches a source's candidate root paths through the os `IHostFsWatchService`, + * debounces raw fs events into one callback, and lets the owning source re-fire + * its `onDidChange` so the session catalog reloads just that source. Each + * candidate is watched recursively, and its parent directory is watched + * non-recursively as well: chokidar cannot bind a deep watch whose parent is + * still missing, so a parent event re-binds the recursive watch — this is how + * a skills root created mid-session (first `.agents/skills` in a project) gets + * detected. Plain helper constructed and disposed by each file skill source — + * not a scoped service. + */ + +import { Disposable } from '#/_base/di/lifecycle'; +import { + type IHostFsWatchHandle, + IHostFsWatchService, +} from '#/os/interface/hostFsWatch'; + +export const SKILL_SOURCE_WATCH_DEBOUNCE_MS = 300; + +interface WatchEntry { + recursive: IHostFsWatchHandle; + parent: IHostFsWatchHandle | undefined; +} + +export class SkillSourceWatcher extends Disposable { + private readonly entries = new Map(); + private timer: NodeJS.Timeout | undefined; + + constructor( + private readonly hostFsWatch: IHostFsWatchService, + private readonly onChanged: () => void, + ) { + super(); + } + + setPaths(paths: readonly string[]): void { + const next = new Set(paths); + for (const [path, entry] of this.entries) { + if (next.has(path)) continue; + entry.recursive.dispose(); + entry.parent?.dispose(); + this.entries.delete(path); + } + for (const path of next) { + if (!this.entries.has(path)) this.watchCandidate(path); + } + } + + private watchCandidate(path: string): void { + const recursive = this.hostFsWatch.watch(path, { recursive: true }); + recursive.onDidChange(() => this.schedule()); + const separator = path.includes('\\') ? '\\' : '/'; + const parentPath = path.slice(0, Math.max(0, path.lastIndexOf(separator))); + const entry: WatchEntry = { recursive, parent: undefined }; + if (parentPath.length > 0 && parentPath !== path) { + const parent = this.hostFsWatch.watch(parentPath, { recursive: false }); + parent.onDidChange(() => this.onParentChange(path)); + entry.parent = parent; + } + this.entries.set(path, entry); + } + + private onParentChange(path: string): void { + const entry = this.entries.get(path); + if (entry === undefined) return; + // The parent (re)appeared or churned: re-bind the recursive watch so it + // can descend into the now-existing subtree, then re-scan via the + // debounced callback. + entry.recursive.dispose(); + const recursive = this.hostFsWatch.watch(path, { recursive: true }); + recursive.onDidChange(() => this.schedule()); + entry.recursive = recursive; + this.schedule(); + } + + private schedule(): void { + if (this.timer !== undefined) return; + const timer = setTimeout(() => { + this.timer = undefined; + this.onChanged(); + }, SKILL_SOURCE_WATCH_DEBOUNCE_MS); + timer.unref?.(); + this.timer = timer; + } + + override dispose(): void { + if (this.timer !== undefined) { + clearTimeout(this.timer); + this.timer = undefined; + } + for (const entry of this.entries.values()) { + entry.recursive.dispose(); + entry.parent?.dispose(); + } + this.entries.clear(); + super.dispose(); + } +} diff --git a/packages/agent-core-v2/src/app/skillCatalog/userFileSkillSource.ts b/packages/agent-core-v2/src/app/skillCatalog/userFileSkillSource.ts index 53d48bd4f1..9de202cb5e 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 `SkillSourceWatcher` and + * re-fires `onDidChange` on debounced fs changes. Bound at App scope. */ import { createDecorator, type ServiceIdentifier } from '#/_base/di/instantiation'; @@ -12,6 +14,7 @@ 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 { IHostFsWatchService } from '#/os/interface/hostFsWatch'; import { MERGE_ALL_AVAILABLE_SKILLS_SECTION, @@ -19,7 +22,8 @@ import { } from './configSection'; import { ISkillCatalogRuntimeOptions } from './skillCatalogRuntimeOptions'; import { ISkillDiscovery } from './skillDiscovery'; -import { userRoots } from './skillRoots'; +import { userRootCandidates, userRoots } from './skillRoots'; +import { SkillSourceWatcher } from './skillSourceWatcher'; import { SKILL_SOURCE_PRIORITY, type ISkillSource, type SkillContribution } from './skillSource'; export interface IUserFileSkillSource extends ISkillSource { @@ -36,14 +40,19 @@ 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: SkillSourceWatcher; constructor( @ISkillDiscovery private readonly discovery: ISkillDiscovery, @IBootstrapService private readonly bootstrap: IBootstrapService, @IConfigService private readonly config: IConfigService, @ISkillCatalogRuntimeOptions private readonly runtimeOptions: ISkillCatalogRuntimeOptions, + @IHostFsWatchService hostFsWatch: IHostFsWatchService, ) { super(); + this.watcher = this._register( + new SkillSourceWatcher(hostFsWatch, () => this.onDidChangeEmitter.fire()), + ); this._register( this.config.onDidSectionChange((event) => { if (event.domain === MERGE_ALL_AVAILABLE_SKILLS_SECTION) this.onDidChangeEmitter.fire(); @@ -58,6 +67,9 @@ export class UserFileSkillSource extends Disposable implements IUserFileSkillSou await this.config.ready; const mergeAllAvailableSkills = this.config.get(MERGE_ALL_AVAILABLE_SKILLS_SECTION) ?? true; + this.watcher.setPaths( + userRootCandidates(this.bootstrap.homeDir, this.bootstrap.osHomeDir), + ); return this.discovery.discover( await userRoots(this.bootstrap.homeDir, this.bootstrap.osHomeDir, { mergeAllAvailableSkills }), ); diff --git a/packages/agent-core-v2/src/index.ts b/packages/agent-core-v2/src/index.ts index 3f667afe1e..2a0d56bf91 100644 --- a/packages/agent-core-v2/src/index.ts +++ b/packages/agent-core-v2/src/index.ts @@ -191,6 +191,8 @@ export * from '#/agent/tools/skill/skill'; import '#/agent/tools/skill/skillTool'; export * from '#/agent/skill/skill'; export * from '#/agent/skill/skillService'; +export * from '#/agent/skill/skillListReminder'; +export * from '#/agent/skill/skillListReminderService'; export * from '#/app/skillCatalog/types'; export * from '#/app/skillCatalog/configSection'; export * from '#/app/skillCatalog/skillCatalogRuntimeOptions'; @@ -201,6 +203,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/skillSourceWatcher'; export * from '#/app/skillCatalog/builtin/builtin'; export * from '#/app/skillCatalog/builtinSkillSource'; export * from '#/app/skillCatalog/userFileSkillSource'; @@ -465,6 +468,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'; diff --git a/packages/agent-core-v2/src/session/sessionSkillCatalog/explicitFileSkillSource.ts b/packages/agent-core-v2/src/session/sessionSkillCatalog/explicitFileSkillSource.ts index d2096c7443..00e3f5639c 100644 --- a/packages/agent-core-v2/src/session/sessionSkillCatalog/explicitFileSkillSource.ts +++ b/packages/agent-core-v2/src/session/sessionSkillCatalog/explicitFileSkillSource.ts @@ -4,17 +4,23 @@ * 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 `SkillSourceWatcher` 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 { configuredRootCandidates, configuredRoots } from '#/app/skillCatalog/skillRoots'; import { ISkillCatalogRuntimeOptions } from '#/app/skillCatalog/skillCatalogRuntimeOptions'; import { ISkillDiscovery } from '#/app/skillCatalog/skillDiscovery'; +import { SkillSourceWatcher } from '#/app/skillCatalog/skillSourceWatcher'; import { SKILL_SOURCE_PRIORITY, type ISkillSource, type SkillContribution } from '#/app/skillCatalog/skillSource'; +import { IHostFsWatchService } from '#/os/interface/hostFsWatch'; import { ISessionWorkspaceContext } from '#/session/workspaceContext/workspaceContext'; export interface IExplicitFileSkillSource extends ISkillSource { @@ -24,24 +30,36 @@ 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: SkillSourceWatcher; constructor( @ISkillDiscovery private readonly discovery: ISkillDiscovery, @ISkillCatalogRuntimeOptions private readonly runtimeOptions: ISkillCatalogRuntimeOptions, @ISessionWorkspaceContext private readonly workspace: ISessionWorkspaceContext, @IBootstrapService private readonly bootstrap: IBootstrapService, - ) {} + @IHostFsWatchService hostFsWatch: IHostFsWatchService, + ) { + super(); + this.watcher = this._register( + new SkillSourceWatcher(hostFsWatch, () => this.onDidChangeEmitter.fire()), + ); + } async load(): Promise { const explicitDirs = this.runtimeOptions.explicitDirs ?? []; if (explicitDirs.length === 0) { return { skills: [] }; } + this.watcher.setPaths( + await configuredRootCandidates(explicitDirs, this.workspace.workDir, this.bootstrap.osHomeDir), + ); return this.discovery.discover( await configuredRoots(explicitDirs, this.workspace.workDir, this.bootstrap.osHomeDir, 'user'), ); diff --git a/packages/agent-core-v2/src/session/sessionSkillCatalog/extraFileSkillSource.ts b/packages/agent-core-v2/src/session/sessionSkillCatalog/extraFileSkillSource.ts index 6d8d834c3b..1bc4acccbb 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 `SkillSourceWatcher` 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'; @@ -18,9 +20,11 @@ import { EXTRA_SKILL_DIRS_SECTION, type ExtraSkillDirsConfig, } from '#/app/skillCatalog/configSection'; -import { configuredRoots } from '#/app/skillCatalog/skillRoots'; +import { configuredRootCandidates, configuredRoots } from '#/app/skillCatalog/skillRoots'; import { ISkillDiscovery } from '#/app/skillCatalog/skillDiscovery'; +import { SkillSourceWatcher } from '#/app/skillCatalog/skillSourceWatcher'; import { SKILL_SOURCE_PRIORITY, type ISkillSource, type SkillContribution } from '#/app/skillCatalog/skillSource'; +import { IHostFsWatchService } from '#/os/interface/hostFsWatch'; import { ISessionWorkspaceContext } from '#/session/workspaceContext/workspaceContext'; export interface IExtraFileSkillSource extends ISkillSource { @@ -37,14 +41,19 @@ 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: SkillSourceWatcher; constructor( @ISkillDiscovery private readonly discovery: ISkillDiscovery, @IConfigService private readonly config: IConfigService, @ISessionWorkspaceContext private readonly workspace: ISessionWorkspaceContext, @IBootstrapService private readonly bootstrap: IBootstrapService, + @IHostFsWatchService hostFsWatch: IHostFsWatchService, ) { super(); + this.watcher = this._register( + new SkillSourceWatcher(hostFsWatch, () => this.onDidChangeEmitter.fire()), + ); this._register( this.config.onDidSectionChange((event) => { if (event.domain === EXTRA_SKILL_DIRS_SECTION) this.onDidChangeEmitter.fire(); @@ -55,6 +64,9 @@ export class ExtraFileSkillSource extends Disposable implements IExtraFileSkillS async load(): Promise { await this.config.ready; const extraSkillDirs = this.config.get(EXTRA_SKILL_DIRS_SECTION) ?? []; + this.watcher.setPaths( + await configuredRootCandidates(extraSkillDirs, this.workspace.workDir, this.bootstrap.osHomeDir), + ); return this.discovery.discover( await configuredRoots(extraSkillDirs, this.workspace.workDir, this.bootstrap.osHomeDir, 'extra'), ); diff --git a/packages/agent-core-v2/src/session/sessionSkillCatalog/skillCatalogService.ts b/packages/agent-core-v2/src/session/sessionSkillCatalog/skillCatalogService.ts index 511ef94579..cd579ac3f3 100644 --- a/packages/agent-core-v2/src/session/sessionSkillCatalog/skillCatalogService.ts +++ b/packages/agent-core-v2/src/session/sessionSkillCatalog/skillCatalogService.ts @@ -60,7 +60,11 @@ 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) { + // A failed source reload keeps the previous contribution (skip this + // update); the catch only silences the otherwise-unhandled rejection. + this._register(s.onDidChange(() => { void this.reloadSource(s.id).catch(() => undefined); })); + } } this.ready = this.loadAll(); } diff --git a/packages/agent-core-v2/src/session/sessionSkillCatalog/workspaceFileSkillSource.ts b/packages/agent-core-v2/src/session/sessionSkillCatalog/workspaceFileSkillSource.ts index e4398a730b..b139b71343 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 `SkillSourceWatcher` 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'; @@ -18,8 +20,10 @@ import { } from '#/app/skillCatalog/configSection'; import { ISkillCatalogRuntimeOptions } from '#/app/skillCatalog/skillCatalogRuntimeOptions'; import { ISkillDiscovery } from '#/app/skillCatalog/skillDiscovery'; -import { projectRoots } from '#/app/skillCatalog/skillRoots'; +import { projectRootCandidates, projectRoots } from '#/app/skillCatalog/skillRoots'; +import { SkillSourceWatcher } from '#/app/skillCatalog/skillSourceWatcher'; import { SKILL_SOURCE_PRIORITY, type ISkillSource, type SkillContribution } from '#/app/skillCatalog/skillSource'; +import { IHostFsWatchService } from '#/os/interface/hostFsWatch'; import { ISessionWorkspaceContext } from '#/session/workspaceContext/workspaceContext'; export interface IWorkspaceFileSkillSource extends ISkillSource { @@ -36,14 +40,19 @@ 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: SkillSourceWatcher; constructor( @ISkillDiscovery private readonly discovery: ISkillDiscovery, @ISessionWorkspaceContext private readonly workspace: ISessionWorkspaceContext, @IConfigService private readonly config: IConfigService, @ISkillCatalogRuntimeOptions private readonly runtimeOptions: ISkillCatalogRuntimeOptions, + @IHostFsWatchService hostFsWatch: IHostFsWatchService, ) { super(); + this.watcher = this._register( + new SkillSourceWatcher(hostFsWatch, () => this.onDidChangeEmitter.fire()), + ); this._register( this.config.onDidSectionChange((event) => { if (event.domain === MERGE_ALL_AVAILABLE_SKILLS_SECTION) this.onDidChangeEmitter.fire(); @@ -58,6 +67,7 @@ export class WorkspaceFileSkillSource extends Disposable implements IWorkspaceFi await this.config.ready; const mergeAllAvailableSkills = this.config.get(MERGE_ALL_AVAILABLE_SKILLS_SECTION) ?? true; + this.watcher.setPaths(await projectRootCandidates(this.workspace.workDir)); return this.discovery.discover(await projectRoots(this.workspace.workDir, { mergeAllAvailableSkills })); } } 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..5460ddd061 --- /dev/null +++ b/packages/agent-core-v2/test/agent/dateChange/dateChangeInjection.test.ts @@ -0,0 +1,107 @@ +/** + * 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 } 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(() => { + ctx = createTestAgent(); + context = ctx.get(IAgentContextMemoryService); + injector = ctx.get(IAgentContextInjectorService) as unknown as InjectableDynamicInjector; + profile = ctx.get(IAgentProfileService); + }); + + afterEach(async () => { + try { + await ctx.expectResumeMatches(); + } finally { + await ctx.dispose(); + } + }); + + 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('adopts today silently when the system prompt carries no date line', async () => { + // The harness default system prompt has no date line. + await injector.inject(); + + expect(dateReminders(context)).toHaveLength(0); + expect(context.get()).toHaveLength(0); + }); +}); diff --git a/packages/agent-core-v2/test/agent/skill/skillListReminder.test.ts b/packages/agent-core-v2/test/agent/skill/skillListReminder.test.ts new file mode 100644 index 0000000000..36f5ff063a --- /dev/null +++ b/packages/agent-core-v2/test/agent/skill/skillListReminder.test.ts @@ -0,0 +1,142 @@ +/** + * Scenario: `skill_list` context injection announces skill-list additions. + * + * Exercises the real provider through the harness injector against a mutable + * in-memory catalog: baselines come from the last reminder in history, then + * the system prompt's `## Available skills` section, then a silent adoption + * for sectionless prompts. Only additions announce — removals and text-only + * changes stay quiet. Run: `pnpm --filter @moonshot-ai/agent-core-v2 exec + * vitest run test/agent/skill/skillListReminder.test.ts`. + */ + +import { afterEach, beforeEach, describe, expect, it } from 'vitest'; + +import { InMemorySkillCatalog } from '#/app/skillCatalog/registry'; +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 { stubSkill } from '../../app/skillCatalog/stubs'; +import { createTestAgent, skillServices, type TestAgentContext } from '../../harness'; + +type InjectableDynamicInjector = { + inject(): Promise; +}; + +function systemPromptWithSkills(listing: string): string { + return [ + 'You are a deterministic test agent.', + '', + '# Skills', + '', + 'Skills are reusable, composable capabilities.', + '', + '## Available skills', + '', + 'Skills are grouped by scope.', + '', + listing, + '', + '# Ultimate Reminders', + '', + '- Always, keep it stupidly simple.', + ].join('\n'); +} + +function skillListReminders(context: IAgentContextMemoryService): readonly ContextMessage[] { + return context.get().filter((message) => { + return message.origin?.kind === 'injection' && message.origin.variant === 'skill_list'; + }); +} + +function messageText(message: ContextMessage): string { + return message.content + .map((part) => (part.type === 'text' ? part.text : '')) + .join(''); +} + +describe('AgentSkillListReminderService', () => { + let catalog: InMemorySkillCatalog; + let ctx: TestAgentContext; + let context: IAgentContextMemoryService; + let injector: InjectableDynamicInjector; + let profile: IAgentProfileService; + + beforeEach(() => { + catalog = new InMemorySkillCatalog(); + ctx = createTestAgent(skillServices(catalog)); + context = ctx.get(IAgentContextMemoryService); + injector = ctx.get(IAgentContextInjectorService) as unknown as InjectableDynamicInjector; + profile = ctx.get(IAgentProfileService); + }); + + afterEach(async () => { + try { + await ctx.expectResumeMatches(); + } finally { + await ctx.dispose(); + } + }); + + it('does not inject when the catalog matches the system prompt listing', async () => { + catalog.registerBuiltinSkill(stubSkill('skill-a', { source: 'builtin' })); + profile.update({ systemPrompt: systemPromptWithSkills(catalog.getModelSkillListing()) }); + + await injector.inject(); + + expect(skillListReminders(context)).toHaveLength(0); + }); + + it('announces an added skill with the full fresh listing, then stays quiet', async () => { + catalog.registerBuiltinSkill(stubSkill('skill-a', { source: 'builtin' })); + profile.update({ systemPrompt: systemPromptWithSkills(catalog.getModelSkillListing()) }); + + catalog.registerBuiltinSkill(stubSkill('skill-b', { source: 'builtin' })); + await injector.inject(); + + const reminders = skillListReminders(context); + expect(reminders).toHaveLength(1); + const first = reminders[0]; + expect(first).toBeDefined(); + const text = messageText(first as ContextMessage); + expect(text).toContain('DISREGARD any earlier skill listings'); + expect(text).toContain('- skill-a:'); + expect(text).toContain('- skill-b:'); + expect(text).toContain('DO NOT mention this to the user explicitly'); + + await injector.inject(); + expect(skillListReminders(context)).toHaveLength(1); + }); + + it('ignores removals and text-only changes', async () => { + const announced = new InMemorySkillCatalog(); + announced.registerBuiltinSkill(stubSkill('skill-a', { source: 'builtin' })); + announced.registerBuiltinSkill(stubSkill('skill-b', { source: 'builtin' })); + profile.update({ systemPrompt: systemPromptWithSkills(announced.getModelSkillListing()) }); + + // The live catalog lost skill-b and reworded skill-a: no addition, no reminder. + catalog.registerBuiltinSkill( + stubSkill('skill-a', { source: 'builtin', description: 'reworded description' }), + ); + await injector.inject(); + + expect(skillListReminders(context)).toHaveLength(0); + }); + + it('adopts the live list silently when the system prompt has no skills section', async () => { + // The harness default system prompt has no skills section. + catalog.registerBuiltinSkill(stubSkill('skill-a', { source: 'builtin' })); + await injector.inject(); + expect(skillListReminders(context)).toHaveLength(0); + + catalog.registerBuiltinSkill(stubSkill('skill-b', { source: 'builtin' })); + await injector.inject(); + + const reminders = skillListReminders(context); + expect(reminders).toHaveLength(1); + const first = reminders[0]; + expect(first).toBeDefined(); + expect(messageText(first as ContextMessage)).toContain('- skill-b:'); + }); +}); diff --git a/packages/agent-core-v2/test/app/skillCatalog/skillRoots.test.ts b/packages/agent-core-v2/test/app/skillCatalog/skillRoots.test.ts index ae4e7735f6..135e1c6e25 100644 --- a/packages/agent-core-v2/test/app/skillCatalog/skillRoots.test.ts +++ b/packages/agent-core-v2/test/app/skillCatalog/skillRoots.test.ts @@ -4,7 +4,14 @@ import { tmpdir } from 'node:os'; import { join } from 'pathe'; import { afterEach, beforeEach, describe, expect, it } from 'vitest'; -import { configuredRoots, projectRoots, userRoots } from '#/app/skillCatalog/skillRoots'; +import { + configuredRootCandidates, + configuredRoots, + projectRootCandidates, + projectRoots, + userRootCandidates, + userRoots, +} from '#/app/skillCatalog/skillRoots'; describe('skillRoots', () => { let root: string; @@ -113,4 +120,47 @@ describe('skillRoots', () => { expect(paths).toContain(await realpath(join(root, 'relative'))); }); }); + + describe('watch candidates', () => { + it('lists user candidate paths without existence filtering', () => { + const homeDir = join(root, 'brand-home'); + const osHomeDir = join(root, 'os-home'); + + const candidates = userRootCandidates(homeDir, osHomeDir); + + expect(candidates).toEqual([ + join(homeDir, 'skills'), + join(osHomeDir, '.agents/skills'), + ]); + }); + + it('lists project candidate paths at the .git root without existence filtering', async () => { + await markGitRoot(); + const child = join(root, 'src/pkg'); + await mkdir(child, { recursive: true }); + + const candidates = await projectRootCandidates(child); + + expect(candidates).toEqual([ + join(root, '.kimi-code/skills'), + join(root, '.agents/skills'), + ]); + }); + + it('lists configured candidate paths without existence filtering', async () => { + await markGitRoot(); + const homeDir = join(root, 'home'); + + const candidates = await configuredRootCandidates( + ['~/notes', 'missing-relative'], + root, + homeDir, + ); + + expect(candidates).toEqual([ + join(homeDir, 'notes'), + join(root, 'missing-relative'), + ]); + }); + }); }); diff --git a/packages/agent-core-v2/test/harness/agent.ts b/packages/agent-core-v2/test/harness/agent.ts index 7c9d868ca0..fb8f7f51c5 100644 --- a/packages/agent-core-v2/test/harness/agent.ts +++ b/packages/agent-core-v2/test/harness/agent.ts @@ -15,6 +15,7 @@ import type { AgentTaskInfo } from '#/agent/task/task'; import { IAgentBlobService } from '#/agent/blob/agentBlobService'; import { AgentBlobServiceImpl } from '#/agent/blob/agentBlobServiceImpl'; import { IHostEnvironment } from '#/os/interface/hostEnvironment'; +import type { HostFsChange } from '#/os/interface/hostFsWatch'; import { IAgentContextInjectorService } from '#/agent/contextInjector/contextInjector'; import { CHECKPOINTED_MODELS, type Checkpointed } from '#/agent/contextMemory/conversationTime'; import type { ContextMessage } from '#/agent/contextMemory/types'; @@ -119,6 +120,7 @@ import { AgentSwarmService, ITelemetryService, IHostTerminalService, + IHostFsWatchService, IAgentToolRegistryService, IAgentToolActivationService, IAgentUserToolService, @@ -1096,6 +1098,9 @@ export class AgentTestContext { ); } reg.defineInstance(IHostTerminalService, createHostTerminalService()); + // Real fs watching belongs outside the harness: sessions otherwise + // spin up chokidar handles on candidate skill roots for every test. + reg.defineInstance(IHostFsWatchService, createHostFsWatchService()); reg.defineInstance( IHostEnvironment, { @@ -2195,6 +2200,16 @@ function createHostTerminalService(): IHostTerminalService { }; } +function createHostFsWatchService(): IHostFsWatchService { + return { + _serviceBrand: undefined, + watch: () => ({ + onDidChange: Event.None as Event, + dispose: () => { }, + }), + }; +} + const failOnResumeGenerate: GenerateFn = async () => { throw new Error('Resume replay unexpectedly called the LLM'); }; diff --git a/packages/agent-core-v2/test/os/stubs.ts b/packages/agent-core-v2/test/os/stubs.ts new file mode 100644 index 0000000000..1111e2a41a --- /dev/null +++ b/packages/agent-core-v2/test/os/stubs.ts @@ -0,0 +1,51 @@ +/** + * `os` test stubs — shared `IHostFsWatchService` fake for unit tests. + * + * Lives under `test/` (not `src/`) so test-support code stays out of the + * production tree. Import from a relative path (`./stubs` or `../os/stubs`). + * The fake records watched paths and lets tests fire synthetic change events; + * events reach a watcher when the changed path is its root or underneath it. + */ + +import { Emitter } from '#/_base/event'; +import { + type HostFsChange, + type HostFsWatchOptions, + type IHostFsWatchHandle, + IHostFsWatchService, +} from '#/os/interface/hostFsWatch'; + +export interface StubHostFsWatch extends IHostFsWatchService { + fire(path: string, change?: Partial): void; + watchedPaths(): readonly string[]; +} + +export function stubHostFsWatch(): StubHostFsWatch { + const watchers: Array<{ readonly path: string; readonly emitter: Emitter }> = []; + return { + _serviceBrand: undefined, + watch(path: string, _options?: HostFsWatchOptions): IHostFsWatchHandle { + const emitter = new Emitter(); + const entry = { path, emitter }; + watchers.push(entry); + return { + onDidChange: emitter.event, + dispose: () => { + const index = watchers.indexOf(entry); + if (index >= 0) watchers.splice(index, 1); + emitter.dispose(); + }, + }; + }, + fire(path: string, change?: Partial): void { + for (const watcher of watchers) { + if (path === watcher.path || path.startsWith(`${watcher.path}/`)) { + watcher.emitter.fire({ path, action: 'modified', kind: 'file', ...change }); + } + } + }, + watchedPaths(): readonly string[] { + return watchers.map((watcher) => watcher.path); + }, + }; +} diff --git a/packages/agent-core-v2/test/session/sessionSkillCatalog/skillCatalog.test.ts b/packages/agent-core-v2/test/session/sessionSkillCatalog/skillCatalog.test.ts index 20be3e9b7d..8f983c6d90 100644 --- a/packages/agent-core-v2/test/session/sessionSkillCatalog/skillCatalog.test.ts +++ b/packages/agent-core-v2/test/session/sessionSkillCatalog/skillCatalog.test.ts @@ -47,10 +47,12 @@ import { ISessionStateService } from '#/session/state/sessionState'; import { SessionStateService } from '#/session/state/sessionStateService'; import { ISkillDiscovery } from '#/app/skillCatalog/skillDiscovery'; import type { SkillRoot } from '#/app/skillCatalog/types'; +import { IHostFsWatchService } from '#/os/interface/hostFsWatch'; import { stubBootstrap } from '../../app/bootstrap/stubs'; import { stubSkill } from '../../app/skillCatalog/stubs'; import { stubProviderService } from '../../app/provider/stubs'; +import { stubHostFsWatch, type StubHostFsWatch } from '../../os/stubs'; const bootstrapStub = stubBootstrap('/home'); @@ -156,6 +158,7 @@ function makeHost( pluginReloadEmitter?: Emitter, ) { const config = configStub(); + const watch = stubHostFsWatch(); const runtimeOptions = { _serviceBrand: undefined, explicitDirs, @@ -166,9 +169,10 @@ function makeHost( stubPair(IConfigService, config), stubPair(ISkillCatalogRuntimeOptions, runtimeOptions), stubPair(IPluginService, pluginStub(pluginRoots, pluginReloadEmitter)), + stubPair(IHostFsWatchService, watch), ]); const session = host.child(LifecycleScope.Session, 's1', [stubPair(ISessionWorkspaceContext, ws)]); - return { host, session, config }; + return { host, session, config, watch }; } function waitForEvents(event: Event, count: number): Promise { @@ -363,6 +367,7 @@ describe('SessionSkillCatalogService', () => { stubPair(IConfigService, config), stubPair(ISkillCatalogRuntimeOptions, runtimeOptions), stubPair(IPluginService, pluginStub()), + stubPair(IHostFsWatchService, stubHostFsWatch()), ]); const session = host.child(LifecycleScope.Session, 's1', [stubPair(ISessionWorkspaceContext, ws)]); @@ -402,6 +407,7 @@ describe('SessionSkillCatalogService', () => { stubPair(IConfigService, config), stubPair(ISkillCatalogRuntimeOptions, runtimeOptions), stubPair(IPluginService, pluginStub()), + stubPair(IHostFsWatchService, stubHostFsWatch()), ]); const session = host.child(LifecycleScope.Session, 's1', [stubPair(ISessionWorkspaceContext, ws)]); @@ -619,6 +625,7 @@ describe('SessionSkillCatalogService', () => { _serviceBrand: undefined, } as unknown as ISkillCatalogRuntimeOptions), stubPair(IPluginService, pluginStub()), + stubPair(IHostFsWatchService, stubHostFsWatch()), ]); const session = host.child(LifecycleScope.Session, 's1', [ stubPair(ISessionWorkspaceContext, ws), @@ -678,6 +685,7 @@ describe('SessionSkillCatalogService', () => { _serviceBrand: undefined, } as unknown as ISkillCatalogRuntimeOptions), stubPair(IPluginService, pluginService), + stubPair(IHostFsWatchService, stubHostFsWatch()), ]); const session = host.child(LifecycleScope.Session, 's1', [ stubPair(ISessionWorkspaceContext, ws), @@ -736,6 +744,7 @@ describe('SessionSkillCatalogService', () => { _serviceBrand: undefined, } as unknown as ISkillCatalogRuntimeOptions), stubPair(IProviderService, stubProviderService()), + stubPair(IHostFsWatchService, stubHostFsWatch()), ]); const { stub: ws } = workspaceStub('/work'); const session = host.child(LifecycleScope.Session, 's1', [ @@ -782,4 +791,91 @@ describe('SessionSkillCatalogService', () => { await rm(homeDir, { recursive: true, force: true }); } }); + + it('reloads the user source after a debounced fs event under a watched root', async () => { + const store = new InMemorySkillDiscovery(); + store.setUserSkills([stubSkill('initial')]); + const { stub: ws } = workspaceStub('/work'); + const { host, session, watch } = makeHost(store, ws); + + try { + const catalog = session.accessor.get(ISessionSkillCatalog); + await catalog.load(); + expect(catalog.catalog.getSkill('initial')).toBeDefined(); + expect(watch.watchedPaths()).toContain('/home/skills'); + expect(watch.watchedPaths()).toContain('/home/test/.agents/skills'); + + store.setUserSkills([stubSkill('initial'), stubSkill('added')]); + const refreshed = new Promise((resolve) => { + const subscription = catalog.onDidChange((sourceId) => { + subscription.dispose(); + resolve(sourceId); + }); + }); + watch.fire('/home/test/.agents/skills/added/SKILL.md', { action: 'created' }); + const sourceId = await refreshed; + + expect(sourceId).toBe('user'); + expect(catalog.catalog.getSkill('added')).toBeDefined(); + } finally { + host.dispose(); + } + }); + + it('reloads the workspace source after an fs event under a project skills root', async () => { + const store = new InMemorySkillDiscovery(); + const { stub: ws } = workspaceStub('/work'); + const { host, session, watch } = makeHost(store, ws); + + try { + const catalog = session.accessor.get(ISessionSkillCatalog); + await catalog.load(); + expect(watch.watchedPaths()).toContain('/work/.kimi-code/skills'); + expect(watch.watchedPaths()).toContain('/work/.agents/skills'); + + store.setProjectSkills([stubSkill('project-added')]); + const refreshed = new Promise((resolve) => { + const subscription = catalog.onDidChange((sourceId) => { + subscription.dispose(); + resolve(sourceId); + }); + }); + watch.fire('/work/.agents/skills/project-added/SKILL.md', { action: 'created' }); + const sourceId = await refreshed; + + expect(sourceId).toBe('workspace'); + expect(catalog.catalog.getSkill('project-added')).toBeDefined(); + } finally { + host.dispose(); + } + }); + + it('keeps the previous contribution when a watch-triggered reload fails', async () => { + const store = new InMemorySkillDiscovery(); + store.setUserSkills([stubSkill('initial')]); + let failDiscovery = false; + const discovery: ISkillDiscovery = { + _serviceBrand: undefined, + discover: async (roots) => { + if (failDiscovery) throw new Error('scan failed'); + return store.discover(roots); + }, + }; + const { stub: ws } = workspaceStub('/work'); + const { host, session, watch } = makeHost(discovery, ws); + + try { + const catalog = session.accessor.get(ISessionSkillCatalog); + await catalog.load(); + expect(catalog.catalog.getSkill('initial')).toBeDefined(); + + failDiscovery = true; + watch.fire('/home/test/.agents/skills/added/SKILL.md', { action: 'created' }); + await new Promise((resolve) => setTimeout(resolve, 500)); + + expect(catalog.catalog.getSkill('initial')).toBeDefined(); + } finally { + host.dispose(); + } + }); }); From 3c408ff5f7d2499233dc1410906ab933fbc784f8 Mon Sep 17 00:00:00 2001 From: 7Sageer <7sageer@djwcb.cn> Date: Tue, 28 Jul 2026 19:36:04 +0800 Subject: [PATCH 02/11] feat(agent-core-v2): announce AGENTS.md changes and refresh the prompt on cwd change MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Two more system-prompt facts that drift mid-session: - agents_md: re-reads the AGENTS.md chain when a watch over the chain's candidate paths reports a change (never per step, so the step pipeline carries no filesystem IO), and injects the fresh content when it differs from the last reminder in context or the system prompt's fenced AGENTS.md block. Edits, creations, and removals all announce — a deleted rule fails silently, never on invocation. - cwd: profile.update({cwd}) now re-renders the system prompt, since a working-directory change invalidates the whole environment section (cwd, directory listing, AGENTS.md chain, project skill roots), the same class of change as the existing tool-policy refresh triggers. --- .../agent-core-v2/docs/state-manifest.d.ts | 7 +- .../src/agent/profile/agentsMdReminder.ts | 17 ++ .../agent/profile/agentsMdReminderService.ts | 203 ++++++++++++++++++ .../src/agent/profile/context.ts | 24 +++ .../src/agent/profile/profileService.ts | 7 + .../app/skillCatalog/skillSourceWatcher.ts | 33 +-- packages/agent-core-v2/src/index.ts | 2 + .../agent/profile/agentsMdReminder.test.ts | 191 ++++++++++++++++ .../test/agent/profile/binding.test.ts | 29 +++ 9 files changed, 499 insertions(+), 14 deletions(-) create mode 100644 packages/agent-core-v2/src/agent/profile/agentsMdReminder.ts create mode 100644 packages/agent-core-v2/src/agent/profile/agentsMdReminderService.ts create mode 100644 packages/agent-core-v2/test/agent/profile/agentsMdReminder.test.ts diff --git a/packages/agent-core-v2/docs/state-manifest.d.ts b/packages/agent-core-v2/docs/state-manifest.d.ts index 6dbaa6a758..e8ee96a020 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: 70 keys) +// Index (Session: 28 keys · Agent: 71 keys) // Session // cron.inFlight src/session/cron/sessionCronServiceImpl.ts // cron.lastSeenAt src/session/cron/sessionCronServiceImpl.ts @@ -59,6 +59,7 @@ // 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 @@ -1014,7 +1015,7 @@ export interface AgentStateSnapshot { 'llmRequester.lastConfigLogSignature': string | undefined; 'llmRequester.mediaDegradedTurns': Set; 'llmRequester.mediaStrippedTurns': Map; 'llmRequester.turnConfigs': Map = + 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..aa2f4ef213 --- /dev/null +++ b/packages/agent-core-v2/src/agent/profile/agentsMdReminderService.ts @@ -0,0 +1,203 @@ +/** + * `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 + * `SkillSourceWatcher` over `agentsMdCandidatePaths` reports a 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 { normalize } from 'pathe'; + +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 { SkillSourceWatcher } from '#/app/skillCatalog/skillSourceWatcher'; +import { IHostEnvironment } from '#/os/interface/hostEnvironment'; +import { IHostFileSystem } from '#/os/interface/hostFileSystem'; +import { IHostFsWatchService } from '#/os/interface/hostFsWatch'; + +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: SkillSourceWatcher; + private candidates: ReadonlySet = new Set(); + private watchCwd: string | undefined; + private dirty = true; + 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, + @IHostFsWatchService hostFsWatch: IHostFsWatchService, + ) { + super(); + this.states.register(agentsMdReminderSeededContentKey); + this.watcher = this._register( + new SkillSourceWatcher( + hostFsWatch, + () => { + this.dirty = true; + }, + (change) => this.candidates.has(normalize(change.path)), + ), + ); + 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 { + // A filesystem failure must never break the step loop. + return undefined; + } + } + + private async currentContent(): Promise { + const cwd = this.profile.data().cwd; + if (cwd !== this.watchCwd) { + this.watchCwd = cwd; + this.dirty = true; + this.candidates = new Set(); + void this.armWatch(cwd); + } + if (!this.dirty && this.current !== undefined) return this.current; + const content = await loadAgentsMd( + { fs: this.fs, homeDir: this.env.homeDir }, + cwd, + this.bootstrap.homeDir, + ); + this.current = content; + this.dirty = false; + return content; + } + + private async armWatch(cwd: string): Promise { + try { + const paths = await agentsMdCandidatePaths( + { fs: this.fs, homeDir: this.env.homeDir }, + this.bootstrap.homeDir, + cwd, + ); + // Arm only if the provider has not moved to another cwd meanwhile. + if (cwd !== this.watchCwd) return; + this.candidates = new Set(paths.map((path) => normalize(path))); + this.watcher.setPaths(paths); + } catch { + // Keep the read-once cache; change detection degrades to cwd switches. + } + } + + 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..2b56995e45 100644 --- a/packages/agent-core-v2/src/agent/profile/context.ts +++ b/packages/agent-core-v2/src/agent/profile/context.ts @@ -72,6 +72,30 @@ export async function loadAgentsMd( return result.content; } +/** + * Every AGENTS.md path the loader chain can draw from, in chain order and + * without existence filtering — the watch candidates for change detection + * (`agentsMdReminder`). Keep in sync with `loadAgentsMdForRoots`. + */ +export async function agentsMdCandidatePaths( + deps: ProfileContextDeps, + brandHome: string | undefined, + workDir: string, +): Promise { + const realHome = deps.homeDir; + const brandDir = brandHome ?? join(realHome, '.kimi-code'); + const paths: string[] = [join(brandDir, 'AGENTS.md')]; + const genericDir = join(realHome, '.agents'); + paths.push(join(genericDir, 'AGENTS.md'), join(genericDir, 'agents.md')); + + const projectRoot = await findProjectRoot(deps, normalize(workDir)); + for (const dir of dirsRootToLeaf(normalize(workDir), projectRoot)) { + paths.push(join(dir, '.kimi-code', 'AGENTS.md')); + paths.push(join(dir, 'AGENTS.md'), join(dir, 'agents.md')); + } + return paths; +} + interface LoadedAgentsMd { readonly content: string; readonly warning: string | undefined; diff --git a/packages/agent-core-v2/src/agent/profile/profileService.ts b/packages/agent-core-v2/src/agent/profile/profileService.ts index 9c282609b5..f26b8c1045 100644 --- a/packages/agent-core-v2/src/agent/profile/profileService.ts +++ b/packages/agent-core-v2/src/agent/profile/profileService.ts @@ -261,6 +261,10 @@ export class AgentProfileService extends Disposable implements IAgentProfileServ ) { this.activeProfile = undefined; } + // A cwd change invalidates the whole environment section (cwd, listing, + // AGENTS.md chain, project skill roots) — re-render rather than annotate. + // Compared before the dispatch, which applies the new cwd to the Model. + const cwdChanged = changed.cwd !== undefined && changed.cwd !== this.cwd; if (Object.keys(configChanged).length > 0) { this.wire.dispatch(configUpdate(this.resolveConfigPayload(configChanged))); this.afterConfigDispatch(configChanged); @@ -268,6 +272,9 @@ export class AgentProfileService extends Disposable implements IAgentProfileServ if (activeToolNames !== undefined) { this.setActiveTools(activeToolNames); } + if (cwdChanged) { + void this.refreshSystemPrompt(); + } } applyBindingSnapshot(snapshot: ProfileBindingSnapshot): void { diff --git a/packages/agent-core-v2/src/app/skillCatalog/skillSourceWatcher.ts b/packages/agent-core-v2/src/app/skillCatalog/skillSourceWatcher.ts index 2d6bd2e97d..aa5ac0cd09 100644 --- a/packages/agent-core-v2/src/app/skillCatalog/skillSourceWatcher.ts +++ b/packages/agent-core-v2/src/app/skillCatalog/skillSourceWatcher.ts @@ -1,19 +1,21 @@ /** - * `skillCatalog` domain (L3) — file-watch helper for file-based skill sources. + * `skillCatalog` domain (L3) — file-watch helper for file-based producers. * - * Watches a source's candidate root paths through the os `IHostFsWatchService`, - * debounces raw fs events into one callback, and lets the owning source re-fire - * its `onDidChange` so the session catalog reloads just that source. Each - * candidate is watched recursively, and its parent directory is watched - * non-recursively as well: chokidar cannot bind a deep watch whose parent is - * still missing, so a parent event re-binds the recursive watch — this is how - * a skills root created mid-session (first `.agents/skills` in a project) gets - * detected. Plain helper constructed and disposed by each file skill source — - * not a scoped service. + * Watches a producer's candidate paths (skill roots, AGENTS.md files) through + * the os `IHostFsWatchService`, debounces raw fs events into one callback, and + * lets the owner re-fire its change signal. Each candidate is watched + * recursively, and its parent directory is watched non-recursively as well: + * chokidar cannot bind a deep watch whose parent is still missing, so a + * parent event re-binds the recursive watch — this is how a path created + * mid-session (first `.agents/skills` in a project, a new `.kimi-code/AGENTS.md`) + * gets detected. An optional `filter` confines recursive-handle events to + * relevant paths (parent-handle events always pass — they carry the re-bind). + * Plain helper constructed and disposed by its owner — not a scoped service. */ import { Disposable } from '#/_base/di/lifecycle'; import { + type HostFsChange, type IHostFsWatchHandle, IHostFsWatchService, } from '#/os/interface/hostFsWatch'; @@ -32,6 +34,7 @@ export class SkillSourceWatcher extends Disposable { constructor( private readonly hostFsWatch: IHostFsWatchService, private readonly onChanged: () => void, + private readonly filter?: (change: HostFsChange) => boolean, ) { super(); } @@ -51,12 +54,16 @@ export class SkillSourceWatcher extends Disposable { private watchCandidate(path: string): void { const recursive = this.hostFsWatch.watch(path, { recursive: true }); - recursive.onDidChange(() => this.schedule()); + recursive.onDidChange((change) => { + if (this.filter === undefined || this.filter(change)) this.schedule(); + }); const separator = path.includes('\\') ? '\\' : '/'; const parentPath = path.slice(0, Math.max(0, path.lastIndexOf(separator))); const entry: WatchEntry = { recursive, parent: undefined }; if (parentPath.length > 0 && parentPath !== path) { const parent = this.hostFsWatch.watch(parentPath, { recursive: false }); + // Parent events always pass the filter: they re-bind the recursive watch + // so a candidate whose subtree did not exist yet still gets detected. parent.onDidChange(() => this.onParentChange(path)); entry.parent = parent; } @@ -71,7 +78,9 @@ export class SkillSourceWatcher extends Disposable { // debounced callback. entry.recursive.dispose(); const recursive = this.hostFsWatch.watch(path, { recursive: true }); - recursive.onDidChange(() => this.schedule()); + recursive.onDidChange((change) => { + if (this.filter === undefined || this.filter(change)) this.schedule(); + }); entry.recursive = recursive; this.schedule(); } diff --git a/packages/agent-core-v2/src/index.ts b/packages/agent-core-v2/src/index.ts index 2a0d56bf91..9693c27a8b 100644 --- a/packages/agent-core-v2/src/index.ts +++ b/packages/agent-core-v2/src/index.ts @@ -524,6 +524,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/test/agent/profile/agentsMdReminder.test.ts b/packages/agent-core-v2/test/agent/profile/agentsMdReminder.test.ts new file mode 100644 index 0000000000..d1a36312de --- /dev/null +++ b/packages/agent-core-v2/test/agent/profile/agentsMdReminder.test.ts @@ -0,0 +1,191 @@ +/** + * Scenario: `agents_md` context injection announces AGENTS.md content changes. + * + * Exercises the real provider through the harness injector against a fake + * host fs whose AGENTS.md files the test edits and deletes, with a stub + * `IHostFsWatchService` driving change events: baselines come from the last + * reminder in history, then the fenced AGENTS.md block of the system prompt, + * then a silent adoption for blockless prompts. Edits, creations, and + * removals all announce. Run: `pnpm --filter @moonshot-ai/agent-core-v2 exec + * vitest run test/agent/profile/agentsMdReminder.test.ts`. + */ + +import { afterEach, beforeEach, describe, expect, it } from 'vitest'; + +import { IAgentContextInjectorService } from '#/agent/contextInjector/contextInjector'; +import { IAgentContextMemoryService } from '#/agent/contextMemory/contextMemory'; +import type { ContextMessage } from '#/agent/contextMemory/types'; +import { loadAgentsMd } from '#/agent/profile/context'; +import { IAgentProfileService } from '#/agent/profile/profile'; +import { IHostFsWatchService } from '#/os/interface/hostFsWatch'; +import type { HostFileStat, IHostFileSystem } from '#/os/interface/hostFileSystem'; + +import { stubHostFsWatch, type StubHostFsWatch } from '../../os/stubs'; +import { createFakeHostFs } from '../../tools/fixtures/fake-exec'; +import { appService, createTestAgent, execEnvServices, type TestAgentContext } from '../../harness'; + +type InjectableDynamicInjector = { + inject(): Promise; +}; + +const TEST_HOME_DIR = '/home/test'; +const BRAND_HOME_DIR = '/tmp/kimi-code-agent-app-v2-test'; +const WATCH_DEBOUNCE_MS = 300; + +function systemPromptWithAgentsMd(content: string): string { + return [ + 'You are a deterministic test agent.', + '', + 'The applicable `AGENTS.md` instructions are:', + '', + '```````', + content, + '```````', + ].join('\n'); +} + +function agentsMdReminders(context: IAgentContextMemoryService): readonly ContextMessage[] { + return context.get().filter((message) => { + return message.origin?.kind === 'injection' && message.origin.variant === 'agents_md'; + }); +} + +function messageText(message: ContextMessage): string { + return message.content + .map((part) => (part.type === 'text' ? part.text : '')) + .join(''); +} + +describe('AgentAgentsMdReminderService', () => { + let cwd: string; + let agentsMdPath: string; + let files: Map; + let dirs: Set; + let hostFs: IHostFileSystem; + let watch: StubHostFsWatch; + let ctx: TestAgentContext; + let context: IAgentContextMemoryService; + let injector: InjectableDynamicInjector; + let profile: IAgentProfileService; + + function fileStat(path: string): HostFileStat { + return { isFile: true, isDirectory: false, size: files.get(path)?.length ?? 0 }; + } + + async function currentAgentsMd(): Promise { + return loadAgentsMd({ fs: hostFs, homeDir: TEST_HOME_DIR }, cwd, BRAND_HOME_DIR); + } + + async function settleWatchDebounce(): Promise { + await new Promise((resolve) => setTimeout(resolve, WATCH_DEBOUNCE_MS + 150)); + } + + beforeEach(() => { + cwd = process.cwd(); + agentsMdPath = `${cwd}/AGENTS.md`; + files = new Map(); + dirs = new Set([cwd, `${cwd}/.git`]); + hostFs = createFakeHostFs({ + stat: async (path: string) => { + if (!files.has(path)) throw new Error(`ENOENT: ${path}`); + return fileStat(path); + }, + lstat: async (path: string) => { + if (files.has(path)) return fileStat(path); + if (dirs.has(path)) return { isFile: false, isDirectory: true, size: 0 }; + throw new Error(`ENOENT: ${path}`); + }, + readText: async (path: string) => { + const content = files.get(path); + if (content === undefined) throw new Error(`ENOENT: ${path}`); + return content; + }, + readdir: async () => [], + }); + watch = stubHostFsWatch(); + ctx = createTestAgent( + execEnvServices({ hostFs }), + appService(IHostFsWatchService, watch), + ); + context = ctx.get(IAgentContextMemoryService); + injector = ctx.get(IAgentContextInjectorService) as unknown as InjectableDynamicInjector; + profile = ctx.get(IAgentProfileService); + }); + + afterEach(async () => { + try { + await ctx.expectResumeMatches(); + } finally { + await ctx.dispose(); + } + }); + + it('stays quiet when no AGENTS.md exists and the prompt has no fenced block', async () => { + await injector.inject(); + + expect(agentsMdReminders(context)).toHaveLength(0); + expect(context.get()).toHaveLength(0); + }); + + it('stays quiet when the file content matches the system prompt block', async () => { + files.set(agentsMdPath, 'rule one'); + profile.update({ systemPrompt: systemPromptWithAgentsMd(await currentAgentsMd()) }); + + await injector.inject(); + + expect(agentsMdReminders(context)).toHaveLength(0); + }); + + it('injects the fresh content after an edit, then stays quiet', async () => { + files.set(agentsMdPath, 'rule one'); + profile.update({ systemPrompt: systemPromptWithAgentsMd(await currentAgentsMd()) }); + await injector.inject(); + + files.set(agentsMdPath, 'rule two'); + watch.fire(agentsMdPath, { action: 'modified' }); + await settleWatchDebounce(); + await injector.inject(); + + const reminders = agentsMdReminders(context); + expect(reminders).toHaveLength(1); + const first = reminders[0]; + expect(first).toBeDefined(); + const text = messageText(first as ContextMessage); + expect(text).toContain('rule two'); + expect(text).toContain('supersedes'); + expect(text).toContain('DO NOT mention this to the user explicitly'); + + await injector.inject(); + expect(agentsMdReminders(context)).toHaveLength(1); + }); + + it('announces removal when the last AGENTS.md file disappears', async () => { + files.set(agentsMdPath, 'rule one'); + profile.update({ systemPrompt: systemPromptWithAgentsMd(await currentAgentsMd()) }); + await injector.inject(); + + files.clear(); + watch.fire(agentsMdPath, { action: 'deleted' }); + await settleWatchDebounce(); + await injector.inject(); + + const reminders = agentsMdReminders(context); + expect(reminders).toHaveLength(1); + const first = reminders[0]; + expect(first).toBeDefined(); + expect(messageText(first as ContextMessage)).toContain('removed'); + }); + + it('announces a file created while the prompt shows an empty block', async () => { + profile.update({ systemPrompt: systemPromptWithAgentsMd('') }); + files.set(agentsMdPath, 'fresh rule'); + + await injector.inject(); + + const reminders = agentsMdReminders(context); + expect(reminders).toHaveLength(1); + const first = reminders[0]; + expect(first).toBeDefined(); + expect(messageText(first as ContextMessage)).toContain('fresh rule'); + }); +}); diff --git a/packages/agent-core-v2/test/agent/profile/binding.test.ts b/packages/agent-core-v2/test/agent/profile/binding.test.ts index 8e2f4545c9..6fcb9dc9b1 100644 --- a/packages/agent-core-v2/test/agent/profile/binding.test.ts +++ b/packages/agent-core-v2/test/agent/profile/binding.test.ts @@ -154,6 +154,35 @@ describe('AgentProfileService.bind', () => { }); }); + it('re-renders the system prompt when cwd changes after binding', async () => { + const { profile: svc } = buildContext(); + await svc.bind({ profile: DEFAULT_AGENT_PROFILE_NAME, model: MOCK_MODEL, cwd: homeDir }); + expect(svc.getSystemPrompt()).toContain(`The current working directory is \`${homeDir}\``); + + const nextCwd = await mkdtemp(join(tmpdir(), 'kimi-cwd-next-')); + try { + svc.update({ cwd: nextCwd }); + await vi.waitFor(() => { + expect(svc.getSystemPrompt()).toContain( + `The current working directory is \`${nextCwd}\``, + ); + }); + } finally { + await rm(nextCwd, { recursive: true, force: true }); + } + }); + + it('keeps the rendered system prompt when cwd is set to its current value', async () => { + const { profile: svc } = buildContext(); + await svc.bind({ profile: DEFAULT_AGENT_PROFILE_NAME, model: MOCK_MODEL, cwd: homeDir }); + const before = svc.getSystemPrompt(); + + svc.update({ cwd: homeDir }); + await new Promise((resolve) => setTimeout(resolve, 50)); + + expect(svc.getSystemPrompt()).toBe(before); + }); + it('restores the subagent allowlist from the binding record without catalog resolution', async () => { const persistence = new InMemoryWireRecordPersistence(); ctx = createTestAgent({ persistence }, hostEnvironmentServices(homeDir)); From 0b70e0e7da84268065dc569b996d87042f2bc969 Mon Sep 17 00:00:00 2001 From: 7Sageer <7sageer@djwcb.cn> Date: Tue, 28 Jul 2026 19:56:04 +0800 Subject: [PATCH 03/11] fix(agent-core-v2): respect tool policy in skill reminders --- .../agent-core-v2/docs/state-manifest.d.ts | 6 ++--- .../scripts/check-domain-layers.mjs | 7 +----- .../skillListReminder.ts | 2 +- .../skillListReminderService.ts | 24 +++++++------------ packages/agent-core-v2/src/index.ts | 4 ++-- .../skillListReminder.test.ts | 20 +++++++++++++--- 6 files changed, 33 insertions(+), 30 deletions(-) rename packages/agent-core-v2/src/agent/{skill => skillListReminder}/skillListReminder.ts (88%) rename packages/agent-core-v2/src/agent/{skill => skillListReminder}/skillListReminderService.ts (82%) rename packages/agent-core-v2/test/agent/{skill => skillListReminder}/skillListReminder.test.ts (87%) diff --git a/packages/agent-core-v2/docs/state-manifest.d.ts b/packages/agent-core-v2/docs/state-manifest.d.ts index e8ee96a020..97dc01a940 100644 --- a/packages/agent-core-v2/docs/state-manifest.d.ts +++ b/packages/agent-core-v2/docs/state-manifest.d.ts @@ -104,7 +104,7 @@ // profile.emittedToolPatternWarnings src/agent/profile/profileService.ts // prompt.launching src/agent/prompt/promptService.ts // shellCommand.tasks src/agent/shellCommand/shellCommandService.ts -// skillList.seededNames src/agent/skill/skillListReminderService.ts +// skillList.seededNames src/agent/skillListReminder/skillListReminderService.ts // stepRetry.failedAttempts src/agent/stepRetry/stepRetryService.ts // stepRetry.lastFailedDriverId src/agent/stepRetry/stepRetryService.ts // task.activeTaskReminderPending src/agent/task/taskService.ts @@ -1015,7 +1015,7 @@ export interface AgentStateSnapshot { 'llmRequester.lastConfigLogSignature': string | undefined; 'llmRequester.mediaDegradedTurns': Set; 'llmRequester.mediaStrippedTurns': Map; 'llmRequester.turnConfigs': Map; - // src/agent/skill/skillListReminderService.ts + // src/agent/skillListReminder/skillListReminderService.ts 'skillList.seededNames': ReadonlySet | undefined; // src/agent/stepRetry/stepRetryService.ts 'stepRetry.failedAttempts': number; diff --git a/packages/agent-core-v2/scripts/check-domain-layers.mjs b/packages/agent-core-v2/scripts/check-domain-layers.mjs index 328f4d1735..251183bc87 100644 --- a/packages/agent-core-v2/scripts/check-domain-layers.mjs +++ b/packages/agent-core-v2/scripts/check-domain-layers.mjs @@ -191,6 +191,7 @@ const DOMAIN_LAYER = new Map([ ['contextInjector', 4], ['agentPlugin', 4], ['systemReminder', 4], + ['skillListReminder', 4], // `dateChange` owns the `date_change` context-injection provider (stale // system-prompt date announcement) — agent behaviour beside the other // injection owners. @@ -517,12 +518,6 @@ const ALLOWED_EXCEPTIONS = new Set([ 'replayBuilder>sessionMetadata', 'skill>contextMemory', 'skill>prompt', - // `skillListReminder` (skill, L3) owns the `skill_list` context-injection - // provider: it registers through `contextInjector` (L4) and reads the - // rendered system prompt from `profile` (L4) as its diff baseline — the same - // shape as the `permissionMode>contextInjector` exception. - 'skill>contextInjector', - 'skill>profile', 'swarm>sessionMetadata', 'btw>agentLifecycle', 'toolExecutor>loop', diff --git a/packages/agent-core-v2/src/agent/skill/skillListReminder.ts b/packages/agent-core-v2/src/agent/skillListReminder/skillListReminder.ts similarity index 88% rename from packages/agent-core-v2/src/agent/skill/skillListReminder.ts rename to packages/agent-core-v2/src/agent/skillListReminder/skillListReminder.ts index a999eed21d..80855868ea 100644 --- a/packages/agent-core-v2/src/agent/skill/skillListReminder.ts +++ b/packages/agent-core-v2/src/agent/skillListReminder/skillListReminder.ts @@ -1,5 +1,5 @@ /** - * `skill` domain (L3) — `IAgentSkillListReminderService` contract. + * `skillListReminder` domain (L4) — skill-list freshness reminder contract. * * Defines the Agent-scope marker service that announces skill-list additions * through a `skill_list` context-injection reminder when the session catalog diff --git a/packages/agent-core-v2/src/agent/skill/skillListReminderService.ts b/packages/agent-core-v2/src/agent/skillListReminder/skillListReminderService.ts similarity index 82% rename from packages/agent-core-v2/src/agent/skill/skillListReminderService.ts rename to packages/agent-core-v2/src/agent/skillListReminder/skillListReminderService.ts index f83c5d4b44..50113aae61 100644 --- a/packages/agent-core-v2/src/agent/skill/skillListReminderService.ts +++ b/packages/agent-core-v2/src/agent/skillListReminder/skillListReminderService.ts @@ -1,18 +1,10 @@ /** - * `skill` domain (L3) — `IAgentSkillListReminderService` implementation. + * `skillListReminder` domain (L4) — skill-list freshness reminder provider. * - * Owns the `skill_list` context-injection provider. The system prompt's skill - * listing is only re-rendered at profile (re)bind and after compaction, so a - * skill added mid-session is invisible to the model; this provider announces - * additions with the full fresh listing (which is supersede-worded) at the - * next step boundary. Only additions trigger a reminder — removals and text - * changes are ignored, since a removed skill fails naturally on invocation. - * The baseline is history-derived: the last `skill_list` reminder in context, - * else the `## Available skills` section of the current system prompt, else - * the volatile `seededNames` adopted at first evaluation (profiles without a - * skills section). The plain-data state (`seededNames`) is registered into - * `agentState` (`IAgentStateService`) and read/written through it. Bound at - * Agent scope. + * Registers through `contextInjector`, compares the session catalog with the + * last listing visible through `contextMemory` or `profile`, and gates model + * disclosure through `toolPolicy`. Keeps its per-agent fallback baseline in + * `agentState`. Bound at Agent scope. */ import { Disposable } from '#/_base/di/lifecycle'; @@ -26,6 +18,7 @@ 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 { IAgentToolPolicyService } from '#/agent/toolPolicy/toolPolicy'; import { ISessionSkillCatalog } from '#/session/sessionSkillCatalog/skillCatalog'; import { IAgentSkillListReminderService } from './skillListReminder'; @@ -48,6 +41,7 @@ export class AgentSkillListReminderService extends Disposable implements IAgentS @IAgentContextInjectorService dynamicInjector: IAgentContextInjectorService, @IAgentContextMemoryService private readonly context: IAgentContextMemoryService, @IAgentProfileService private readonly profile: IAgentProfileService, + @IAgentToolPolicyService private readonly toolPolicy: IAgentToolPolicyService, @ISessionSkillCatalog private readonly skillCatalog: ISessionSkillCatalog, @IAgentStateService private readonly states: IAgentStateService, ) { @@ -68,6 +62,7 @@ export class AgentSkillListReminderService extends Disposable implements IAgentS private async reminder({ lastInjectedAt }: ContextInjectionContext): Promise { try { + if (!this.toolPolicy.isToolActive('Skill')) return undefined; await this.skillCatalog.ready; const listing = this.skillCatalog.catalog.getModelSkillListing(); const currentNames = extractSkillNames(listing); @@ -78,7 +73,6 @@ export class AgentSkillListReminderService extends Disposable implements IAgentS } return undefined; } catch { - // A broken catalog must never break the step loop. return undefined; } } @@ -133,5 +127,5 @@ registerScopedService( IAgentSkillListReminderService, AgentSkillListReminderService, ScopeActivation.OnScopeCreated, - 'skill', + 'skillListReminder', ); diff --git a/packages/agent-core-v2/src/index.ts b/packages/agent-core-v2/src/index.ts index 9693c27a8b..15070cffe8 100644 --- a/packages/agent-core-v2/src/index.ts +++ b/packages/agent-core-v2/src/index.ts @@ -191,8 +191,8 @@ export * from '#/agent/tools/skill/skill'; import '#/agent/tools/skill/skillTool'; export * from '#/agent/skill/skill'; export * from '#/agent/skill/skillService'; -export * from '#/agent/skill/skillListReminder'; -export * from '#/agent/skill/skillListReminderService'; +export * from '#/agent/skillListReminder/skillListReminder'; +import '#/agent/skillListReminder/skillListReminderService'; export * from '#/app/skillCatalog/types'; export * from '#/app/skillCatalog/configSection'; export * from '#/app/skillCatalog/skillCatalogRuntimeOptions'; diff --git a/packages/agent-core-v2/test/agent/skill/skillListReminder.test.ts b/packages/agent-core-v2/test/agent/skillListReminder/skillListReminder.test.ts similarity index 87% rename from packages/agent-core-v2/test/agent/skill/skillListReminder.test.ts rename to packages/agent-core-v2/test/agent/skillListReminder/skillListReminder.test.ts index 36f5ff063a..660a2bca10 100644 --- a/packages/agent-core-v2/test/agent/skill/skillListReminder.test.ts +++ b/packages/agent-core-v2/test/agent/skillListReminder/skillListReminder.test.ts @@ -4,9 +4,10 @@ * Exercises the real provider through the harness injector against a mutable * in-memory catalog: baselines come from the last reminder in history, then * the system prompt's `## Available skills` section, then a silent adoption - * for sectionless prompts. Only additions announce — removals and text-only - * changes stay quiet. Run: `pnpm --filter @moonshot-ai/agent-core-v2 exec - * vitest run test/agent/skill/skillListReminder.test.ts`. + * for sectionless prompts. Only additions announce; inactive tool policy, + * removals, and text-only changes stay quiet. Run: `pnpm --filter + * @moonshot-ai/agent-core-v2 exec vitest run + * test/agent/skillListReminder/skillListReminder.test.ts`. */ import { afterEach, beforeEach, describe, expect, it } from 'vitest'; @@ -109,6 +110,19 @@ describe('AgentSkillListReminderService', () => { expect(skillListReminders(context)).toHaveLength(1); }); + it('stays quiet when the active tool policy disables Skill', async () => { + catalog.registerBuiltinSkill(stubSkill('skill-a', { source: 'builtin' })); + profile.update({ + systemPrompt: systemPromptWithSkills(catalog.getModelSkillListing()), + disallowedTools: ['Skill'], + }); + + catalog.registerBuiltinSkill(stubSkill('skill-b', { source: 'builtin' })); + await injector.inject(); + + expect(skillListReminders(context)).toHaveLength(0); + }); + it('ignores removals and text-only changes', async () => { const announced = new InMemorySkillCatalog(); announced.registerBuiltinSkill(stubSkill('skill-a', { source: 'builtin' })); From e9464a27390991befaeace4879b973143331af4d Mon Sep 17 00:00:00 2001 From: 7Sageer <7sageer@djwcb.cn> Date: Wed, 29 Jul 2026 00:39:04 +0800 Subject: [PATCH 04/11] refactor(agent-core-v2): harden live reminder architecture --- .../agent-core-v2/docs/state-manifest.d.ts | 11 +- .../agent-core-v2/docs/wire-manifest.d.ts | 13 +- .../scripts/check-domain-layers.mjs | 13 +- .../agent/profile/agentsMdReminderService.ts | 45 +- .../src/agent/profile/context.ts | 53 +- .../src/agent/profile/profile.ts | 3 + .../src/agent/profile/profileService.ts | 46 +- .../agent/skillDisclosure/skillDisclosure.ts | 25 + .../skillDisclosure/skillDisclosureOps.ts | 40 ++ .../skillDisclosure/skillDisclosureService.ts | 77 +++ .../skillListReminder.ts | 7 +- .../skillListReminderService.ts | 69 ++ .../skillListReminderService.ts | 131 ---- .../fileSourceMonitor/fileSourceMonitor.ts | 38 ++ .../fileSourceMonitorService.ts | 591 ++++++++++++++++++ .../app/skillCatalog/fileSkillDiscovery.ts | 21 +- .../src/app/skillCatalog/registry.ts | 20 +- .../src/app/skillCatalog/skillDiscovery.ts | 2 +- .../src/app/skillCatalog/skillRoots.ts | 57 +- .../src/app/skillCatalog/skillSource.ts | 6 +- .../app/skillCatalog/skillSourceWatcher.ts | 110 ---- .../src/app/skillCatalog/skillTraversal.ts | 24 + .../src/app/skillCatalog/types.ts | 6 + .../app/skillCatalog/userFileSkillSource.ts | 40 +- packages/agent-core-v2/src/index.ts | 11 +- .../backends/node-local/hostFsWatchService.ts | 18 +- .../src/os/interface/hostFsWatch.ts | 3 + .../explicitFileSkillSource.ts | 40 +- .../extraFileSkillSource.ts | 41 +- .../sessionSkillCatalog/pluginSkillSource.ts | 13 +- .../skillCatalogService.ts | 23 +- .../workspaceFileSkillSource.ts | 36 +- .../dateChange/dateChangeInjection.test.ts | 12 +- .../test/agent/loop/loop.test.ts | 1 + .../agent/profile/agentsMdReminder.test.ts | 59 +- .../test/agent/profile/apply-profile.test.ts | 61 +- .../test/agent/profile/binding.test.ts | 45 +- .../test/agent/profile/profileOps.test.ts | 21 +- .../skillDisclosure.test.ts} | 128 ++-- .../test/agent/stepRetry/stepRetry.test.ts | 2 +- .../fileSourceMonitor.test.ts | 319 ++++++++++ .../sessionLifecycle/sessionLifecycle.test.ts | 1 + .../skillCatalog/skill-tool-manager.test.ts | 4 + packages/agent-core-v2/test/harness/agent.ts | 1 + packages/agent-core-v2/test/index.test.ts | 1 + .../node-local/hostFsWatchService.test.ts | 10 + packages/agent-core-v2/test/os/stubs.ts | 44 +- .../agentLifecycle/agentLifecycle.test.ts | 7 +- .../session/sessionFs/fsWatchService.test.ts | 1 + .../sessionSkillCatalog/skillCatalog.test.ts | 107 ++++ packages/agent-core-v2/test/tool/tool.test.ts | 1 + 51 files changed, 1943 insertions(+), 515 deletions(-) create mode 100644 packages/agent-core-v2/src/agent/skillDisclosure/skillDisclosure.ts create mode 100644 packages/agent-core-v2/src/agent/skillDisclosure/skillDisclosureOps.ts create mode 100644 packages/agent-core-v2/src/agent/skillDisclosure/skillDisclosureService.ts rename packages/agent-core-v2/src/agent/{skillListReminder => skillDisclosure}/skillListReminder.ts (55%) create mode 100644 packages/agent-core-v2/src/agent/skillDisclosure/skillListReminderService.ts delete mode 100644 packages/agent-core-v2/src/agent/skillListReminder/skillListReminderService.ts create mode 100644 packages/agent-core-v2/src/app/fileSourceMonitor/fileSourceMonitor.ts create mode 100644 packages/agent-core-v2/src/app/fileSourceMonitor/fileSourceMonitorService.ts delete mode 100644 packages/agent-core-v2/src/app/skillCatalog/skillSourceWatcher.ts create mode 100644 packages/agent-core-v2/src/app/skillCatalog/skillTraversal.ts rename packages/agent-core-v2/test/agent/{skillListReminder/skillListReminder.test.ts => skillDisclosure/skillDisclosure.test.ts} (51%) create mode 100644 packages/agent-core-v2/test/app/fileSourceMonitor/fileSourceMonitor.test.ts diff --git a/packages/agent-core-v2/docs/state-manifest.d.ts b/packages/agent-core-v2/docs/state-manifest.d.ts index 97dc01a940..090087127d 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: 71 keys) +// Index (Session: 28 keys · Agent: 70 keys) // Session // cron.inFlight src/session/cron/sessionCronServiceImpl.ts // cron.lastSeenAt src/session/cron/sessionCronServiceImpl.ts @@ -104,7 +104,6 @@ // profile.emittedToolPatternWarnings src/agent/profile/profileService.ts // prompt.launching src/agent/prompt/promptService.ts // shellCommand.tasks src/agent/shellCommand/shellCommandService.ts -// skillList.seededNames src/agent/skillListReminder/skillListReminderService.ts // stepRetry.failedAttempts src/agent/stepRetry/stepRetryService.ts // stepRetry.lastFailedDriverId src/agent/stepRetry/stepRetryService.ts // task.activeTaskReminderPending src/agent/task/taskService.ts @@ -585,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 */ { @@ -1015,7 +1018,7 @@ export interface AgentStateSnapshot { 'llmRequester.lastConfigLogSignature': string | undefined; 'llmRequester.mediaDegradedTurns': Set; 'llmRequester.mediaStrippedTurns': Map; 'llmRequester.turnConfigs': Map; - // src/agent/skillListReminder/skillListReminderService.ts - 'skillList.seededNames': ReadonlySet | undefined; // src/agent/stepRetry/stepRetryService.ts 'stepRetry.failedAttempts': number; 'stepRetry.lastFailedDriverId': string | undefined; diff --git a/packages/agent-core-v2/docs/wire-manifest.d.ts b/packages/agent-core-v2/docs/wire-manifest.d.ts index 01917f495d..22984ddc13 100644 --- a/packages/agent-core-v2/docs/wire-manifest.d.ts +++ b/packages/agent-core-v2/docs/wire-manifest.d.ts @@ -21,7 +21,7 @@ // owning model offloads inline media to blob storage), cross-reducers // (foreign models that also reduce this record on dispatch and replay). -// Index (44 record types) +// Index (45 record types) // config.update profile persisted src/agent/profile/profileOps.ts // context_size.measured contextSize transient src/agent/contextSize/contextSizeOps.ts // context.append_loop_event contextMemory persisted src/agent/contextMemory/contextOps.ts @@ -53,6 +53,7 @@ // plan.revision plan persisted src/agent/plan/planOps.ts // profile.bind profile persisted src/agent/profile/profileOps.ts // skill.activate skill transient src/agent/skill/skillOps.ts +// skill.disclosure.set skillDisclosure persisted src/agent/skillDisclosure/skillDisclosureOps.ts // swarm_mode.enter swarm persisted src/agent/swarm/swarmOps.ts // swarm_mode.exit swarm persisted src/agent/swarm/swarmOps.ts // task.started task persisted src/agent/task/taskOps.ts @@ -475,6 +476,15 @@ interface SkillActivatePayload { }; } +/** + * model: skillDisclosure · persisted + * owner: src/agent/skillDisclosure/skillDisclosureOps.ts + */ +interface SkillDisclosureSetPayload { + _name: 'skill.disclosure.set'; + names: string[]; +} + /** * model: swarm · persisted · toEvent * owner: src/agent/swarm/swarmOps.ts @@ -646,6 +656,7 @@ interface WirePayloadMap { "plan.revision": PlanRevisionPayload; "profile.bind": ProfileBindPayload; "skill.activate": SkillActivatePayload; + "skill.disclosure.set": SkillDisclosureSetPayload; "swarm_mode.enter": SwarmModeEnterPayload; "swarm_mode.exit": SwarmModeExitPayload; "task.started": TaskStartedPayload; diff --git a/packages/agent-core-v2/scripts/check-domain-layers.mjs b/packages/agent-core-v2/scripts/check-domain-layers.mjs index 251183bc87..e88539f812 100644 --- a/packages/agent-core-v2/scripts/check-domain-layers.mjs +++ b/packages/agent-core-v2/scripts/check-domain-layers.mjs @@ -38,9 +38,8 @@ import { readFileSync, readdirSync, statSync } from 'node:fs'; import { dirname, join, relative, resolve } from 'node:path'; -import { fileURLToPath } from 'node:url'; -const __dirname = dirname(fileURLToPath(import.meta.url)); +const __dirname = import.meta.dirname; const PKG_ROOT = resolve(__dirname, '..'); export const SRC_ROOT = join(PKG_ROOT, 'src'); const TEST_ROOT = join(PKG_ROOT, 'test'); @@ -142,6 +141,7 @@ const DOMAIN_LAYER = new Map([ ['model', 2], ['sessionIndex', 2], ['sessionStore', 2], + ['fileSourceMonitor', 2], // L3 — registries & capabilities ['tool', 3], ['skill', 3], @@ -191,10 +191,7 @@ const DOMAIN_LAYER = new Map([ ['contextInjector', 4], ['agentPlugin', 4], ['systemReminder', 4], - ['skillListReminder', 4], - // `dateChange` owns the `date_change` context-injection provider (stale - // system-prompt date announcement) — agent behaviour beside the other - // injection owners. + ['skillDisclosure', 4], ['dateChange', 4], ['contextProjector', 4], ['contextSize', 4], @@ -348,7 +345,7 @@ function kosongInfoOf(absPath) { const segments = rel.split(/[\\/]/); if (segments[0] !== 'kosong') return undefined; const sub = segments[1]; - const last = segments[segments.length - 1] ?? ''; + const last = segments.at(-1) ?? ''; return { // A file directly under `src/kosong/` has no subdomain. sub: sub === undefined || sub.endsWith('.ts') ? undefined : sub, @@ -768,7 +765,7 @@ function main() { return 1; } -const isMain = process.argv[1] && resolve(process.argv[1]) === fileURLToPath(import.meta.url); +const isMain = process.argv[1] && resolve(process.argv[1]) === import.meta.filename; if (isMain) { process.exit(main()); } diff --git a/packages/agent-core-v2/src/agent/profile/agentsMdReminderService.ts b/packages/agent-core-v2/src/agent/profile/agentsMdReminderService.ts index aa2f4ef213..fe652b0c81 100644 --- a/packages/agent-core-v2/src/agent/profile/agentsMdReminderService.ts +++ b/packages/agent-core-v2/src/agent/profile/agentsMdReminderService.ts @@ -11,7 +11,7 @@ * 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 - * `SkillSourceWatcher` over `agentsMdCandidatePaths` reports a change — never + * shared `fileSourceMonitor` 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` @@ -21,7 +21,6 @@ import { Disposable } from '#/_base/di/lifecycle'; import { LifecycleScope, ScopeActivation, registerScopedService } from '#/_base/di/scope'; import { defineState } from '#/_base/state/stateRegistry'; -import { normalize } from 'pathe'; import { IAgentContextInjectorService, @@ -31,10 +30,12 @@ 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 { SkillSourceWatcher } from '#/app/skillCatalog/skillSourceWatcher'; +import { + IFileSourceMonitor, + type IFileSourceWatch, +} from '#/app/fileSourceMonitor/fileSourceMonitor'; import { IHostEnvironment } from '#/os/interface/hostEnvironment'; import { IHostFileSystem } from '#/os/interface/hostFileSystem'; -import { IHostFsWatchService } from '#/os/interface/hostFsWatch'; import { agentsMdCandidatePaths, loadAgentsMd } from './context'; import { IAgentProfileService } from './profile'; @@ -55,10 +56,10 @@ export const agentsMdReminderSeededContentKey = defineState( export class AgentAgentsMdReminderService extends Disposable implements IAgentAgentsMdReminderService { declare readonly _serviceBrand: undefined; - private readonly watcher: SkillSourceWatcher; - private candidates: ReadonlySet = new Set(); + private readonly watcher: IFileSourceWatch; private watchCwd: string | undefined; - private dirty = true; + private changeVersion = 0; + private loadedVersion = -1; private current: string | undefined; constructor( @@ -69,18 +70,14 @@ export class AgentAgentsMdReminderService extends Disposable implements IAgentAg @IHostFileSystem private readonly fs: IHostFileSystem, @IHostEnvironment private readonly env: IHostEnvironment, @IBootstrapService private readonly bootstrap: IBootstrapService, - @IHostFsWatchService hostFsWatch: IHostFsWatchService, + @IFileSourceMonitor fileSourceMonitor: IFileSourceMonitor, ) { super(); this.states.register(agentsMdReminderSeededContentKey); this.watcher = this._register( - new SkillSourceWatcher( - hostFsWatch, - () => { - this.dirty = true; - }, - (change) => this.candidates.has(normalize(change.path)), - ), + fileSourceMonitor.createWatch({ target: 'file' }, () => { + this.changeVersion += 1; + }), ); this._register( dynamicInjector.register(AGENTS_MD_INJECTION_VARIANT, (ctx) => this.reminder(ctx)), @@ -102,7 +99,6 @@ export class AgentAgentsMdReminderService extends Disposable implements IAgentAg if (baseline === current) return undefined; return buildAgentsMdReminder(current); } catch { - // A filesystem failure must never break the step loop. return undefined; } } @@ -111,18 +107,20 @@ export class AgentAgentsMdReminderService extends Disposable implements IAgentAg const cwd = this.profile.data().cwd; if (cwd !== this.watchCwd) { this.watchCwd = cwd; - this.dirty = true; - this.candidates = new Set(); - void this.armWatch(cwd); + this.changeVersion += 1; + await this.armWatch(cwd); + } + if (this.loadedVersion === this.changeVersion && this.current !== undefined) { + return this.current; } - if (!this.dirty && 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.dirty = false; + this.loadedVersion = loadingVersion; return content; } @@ -133,12 +131,9 @@ export class AgentAgentsMdReminderService extends Disposable implements IAgentAg this.bootstrap.homeDir, cwd, ); - // Arm only if the provider has not moved to another cwd meanwhile. if (cwd !== this.watchCwd) return; - this.candidates = new Set(paths.map((path) => normalize(path))); - this.watcher.setPaths(paths); + await this.watcher.setPaths(paths); } catch { - // Keep the read-once cache; change detection degrades to cwd switches. } } diff --git a/packages/agent-core-v2/src/agent/profile/context.ts b/packages/agent-core-v2/src/agent/profile/context.ts index 2b56995e45..09e47e6502 100644 --- a/packages/agent-core-v2/src/agent/profile/context.ts +++ b/packages/agent-core-v2/src/agent/profile/context.ts @@ -75,25 +75,36 @@ export async function loadAgentsMd( /** * Every AGENTS.md path the loader chain can draw from, in chain order and * without existence filtering — the watch candidates for change detection - * (`agentsMdReminder`). Keep in sync with `loadAgentsMdForRoots`. + * (`agentsMdReminder`). */ 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 paths: string[] = [join(brandDir, 'AGENTS.md')]; + const groups: string[][] = [[join(brandDir, 'AGENTS.md')]]; const genericDir = join(realHome, '.agents'); - paths.push(join(genericDir, 'AGENTS.md'), join(genericDir, 'agents.md')); + groups.push([join(genericDir, 'AGENTS.md'), join(genericDir, 'agents.md')]); - const projectRoot = await findProjectRoot(deps, normalize(workDir)); - for (const dir of dirsRootToLeaf(normalize(workDir), projectRoot)) { - paths.push(join(dir, '.kimi-code', 'AGENTS.md')); - paths.push(join(dir, 'AGENTS.md'), join(dir, '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 paths; + return groups; } interface LoadedAgentsMd { @@ -123,28 +134,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 f26b8c1045..c77588a249 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,16 +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; } - // A cwd change invalidates the whole environment section (cwd, listing, - // AGENTS.md chain, project skill roots) — re-render rather than annotate. - // Compared before the dispatch, which applies the new cwd to the Model. 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); @@ -278,6 +279,7 @@ export class AgentProfileService extends Disposable implements IAgentProfileServ } applyBindingSnapshot(snapshot: ProfileBindingSnapshot): void { + this.systemPromptRevision += 1; this.activeProfile = undefined; this.activeToolNamesOverlay = undefined; this.wire.dispatch( @@ -292,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, @@ -335,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); @@ -362,6 +368,7 @@ export class AgentProfileService extends Disposable implements IAgentProfileServ systemPrompt, disallowedTools: profile.disallowedTools ?? [], }); + this.recordSkillDisclosure(context); this.publishAgentsMdWarning(); this.publishToolPatternWarnings(profile); @@ -423,6 +430,7 @@ export class AgentProfileService extends Disposable implements IAgentProfileServ systemPrompt: profile.systemPrompt(context), disallowedTools: profile.disallowedTools ?? [], }); + this.recordSkillDisclosure(context); this.setActiveTools(profile.tools); } @@ -435,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; @@ -442,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)}`, @@ -449,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(); } @@ -473,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], }; @@ -847,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, @@ -855,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, }; @@ -878,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..f8db27410a --- /dev/null +++ b/packages/agent-core-v2/src/agent/skillDisclosure/skillDisclosure.ts @@ -0,0 +1,25 @@ +/** + * `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; + 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..da9e1eaa56 --- /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 model-visible 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..3e85964031 --- /dev/null +++ b/packages/agent-core-v2/src/agent/skillDisclosure/skillDisclosureService.ts @@ -0,0 +1,77 @@ +/** + * `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; + const lines = systemPrompt.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/skillListReminder/skillListReminder.ts b/packages/agent-core-v2/src/agent/skillDisclosure/skillListReminder.ts similarity index 55% rename from packages/agent-core-v2/src/agent/skillListReminder/skillListReminder.ts rename to packages/agent-core-v2/src/agent/skillDisclosure/skillListReminder.ts index 80855868ea..d516ef9b48 100644 --- a/packages/agent-core-v2/src/agent/skillListReminder/skillListReminder.ts +++ b/packages/agent-core-v2/src/agent/skillDisclosure/skillListReminder.ts @@ -1,9 +1,8 @@ /** - * `skillListReminder` domain (L4) — skill-list freshness reminder contract. + * `skillDisclosure` domain (L4) — skill-list reminder contract. * - * Defines the Agent-scope marker service that announces skill-list additions - * through a `skill_list` context-injection reminder when the session catalog - * gains skills the system prompt's listing does not know about. + * 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'; 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..6d1989dac7 --- /dev/null +++ b/packages/agent-core-v2/src/agent/skillDisclosure/skillListReminderService.ts @@ -0,0 +1,69 @@ +/** + * `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 } from '#/agent/contextInjector/contextInjector'; +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, + @IAgentSkillDisclosureService private readonly disclosure: IAgentSkillDisclosureService, + @IAgentProfileService private readonly profile: IAgentProfileService, + @IAgentToolPolicyService private readonly toolPolicy: IAgentToolPolicyService, + ) { + super(); + this._register( + contextInjector.register(SKILL_LIST_INJECTION_VARIANT, () => this.reminder()), + ); + } + + private async reminder(): Promise { + try { + if (!this.toolPolicy.isToolActive('Skill')) return undefined; + const current = await this.disclosure.resolve(true); + const disclosed = this.disclosure.disclosedNames(); + const baseline = + disclosed ?? this.disclosure.legacyNames(this.profile.getSystemPrompt()); + if (baseline === undefined) { + this.disclosure.markDisclosed(current.names); + return undefined; + } + if (!current.names.some((name) => !baseline.includes(name))) { + if (disclosed === undefined) this.disclosure.markDisclosed(current.names); + return undefined; + } + this.disclosure.markDisclosed(current.names); + return buildSkillListReminder(current.listing); + } catch { + return undefined; + } + } +} + +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.`; +} + +registerScopedService( + LifecycleScope.Agent, + IAgentSkillListReminderService, + AgentSkillListReminderService, + ScopeActivation.OnScopeCreated, + 'skillDisclosure', +); diff --git a/packages/agent-core-v2/src/agent/skillListReminder/skillListReminderService.ts b/packages/agent-core-v2/src/agent/skillListReminder/skillListReminderService.ts deleted file mode 100644 index 50113aae61..0000000000 --- a/packages/agent-core-v2/src/agent/skillListReminder/skillListReminderService.ts +++ /dev/null @@ -1,131 +0,0 @@ -/** - * `skillListReminder` domain (L4) — skill-list freshness reminder provider. - * - * Registers through `contextInjector`, compares the session catalog with the - * last listing visible through `contextMemory` or `profile`, and gates model - * disclosure through `toolPolicy`. Keeps its per-agent fallback baseline in - * `agentState`. 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 { IAgentToolPolicyService } from '#/agent/toolPolicy/toolPolicy'; -import { ISessionSkillCatalog } from '#/session/sessionSkillCatalog/skillCatalog'; - -import { IAgentSkillListReminderService } from './skillListReminder'; - -const SKILL_LIST_INJECTION_VARIANT = 'skill_list'; - -const SKILLS_SECTION_HEADING = '## Available skills'; -const NEXT_HEADING_PATTERN = /\n#{1,2} /; -const LISTING_NAME_PATTERN = /^- ([^:\n]+?): /gm; - -export const skillListSeededNamesKey = defineState | undefined>( - 'skillList.seededNames', - () => undefined, -); - -export class AgentSkillListReminderService extends Disposable implements IAgentSkillListReminderService { - declare readonly _serviceBrand: undefined; - - constructor( - @IAgentContextInjectorService dynamicInjector: IAgentContextInjectorService, - @IAgentContextMemoryService private readonly context: IAgentContextMemoryService, - @IAgentProfileService private readonly profile: IAgentProfileService, - @IAgentToolPolicyService private readonly toolPolicy: IAgentToolPolicyService, - @ISessionSkillCatalog private readonly skillCatalog: ISessionSkillCatalog, - @IAgentStateService private readonly states: IAgentStateService, - ) { - super(); - this.states.register(skillListSeededNamesKey); - this._register( - dynamicInjector.register(SKILL_LIST_INJECTION_VARIANT, (ctx) => this.reminder(ctx)), - ); - } - - private get seededNames(): ReadonlySet | undefined { - return this.states.get(skillListSeededNamesKey); - } - - private set seededNames(value: ReadonlySet | undefined) { - this.states.set(skillListSeededNamesKey, value); - } - - private async reminder({ lastInjectedAt }: ContextInjectionContext): Promise { - try { - if (!this.toolPolicy.isToolActive('Skill')) return undefined; - await this.skillCatalog.ready; - const listing = this.skillCatalog.catalog.getModelSkillListing(); - const currentNames = extractSkillNames(listing); - if (currentNames.size === 0) return undefined; - const baseline = this.baseline(lastInjectedAt) ?? this.adopt(currentNames); - for (const name of currentNames) { - if (!baseline.has(name)) return buildSkillListReminder(listing); - } - return undefined; - } catch { - return undefined; - } - } - - private baseline(lastInjectedAt: number | null): ReadonlySet | undefined { - return this.namesFromHistory(lastInjectedAt) ?? this.namesFromSystemPrompt() ?? this.seededNames; - } - - private adopt(currentNames: ReadonlySet): ReadonlySet { - this.seededNames = currentNames; - return currentNames; - } - - private namesFromHistory(lastInjectedAt: number | null): ReadonlySet | undefined { - if (lastInjectedAt === null) return undefined; - const message: ContextMessage | undefined = this.context.get()[lastInjectedAt]; - if (message === undefined) return undefined; - const names = extractSkillNames(messageText(message)); - return names.size > 0 ? names : undefined; - } - - private namesFromSystemPrompt(): ReadonlySet | undefined { - const prompt = this.profile.getSystemPrompt(); - const start = prompt.indexOf(SKILLS_SECTION_HEADING); - if (start < 0) return undefined; - const rest = prompt.slice(start + SKILLS_SECTION_HEADING.length); - const end = rest.search(NEXT_HEADING_PATTERN); - return extractSkillNames(end < 0 ? rest : rest.slice(0, end)); - } -} - -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 extractSkillNames(text: string): ReadonlySet { - const names = new Set(); - for (const match of text.matchAll(LISTING_NAME_PATTERN)) { - if (match[1] !== undefined) names.add(match[1].toLowerCase()); - } - return names; -} - -function messageText(message: ContextMessage): string { - return message.content - .map((part) => (part.type === 'text' ? part.text : '')) - .join(''); -} - -registerScopedService( - LifecycleScope.Agent, - IAgentSkillListReminderService, - AgentSkillListReminderService, - ScopeActivation.OnScopeCreated, - 'skillListReminder', -); diff --git a/packages/agent-core-v2/src/app/fileSourceMonitor/fileSourceMonitor.ts b/packages/agent-core-v2/src/app/fileSourceMonitor/fileSourceMonitor.ts new file mode 100644 index 0000000000..13edff8ed3 --- /dev/null +++ b/packages/agent-core-v2/src/app/fileSourceMonitor/fileSourceMonitor.ts @@ -0,0 +1,38 @@ +/** + * `fileSourceMonitor` domain (L2) — shared live-file-source monitoring contract. + * + * Defines the App-scoped factory for disposable path subscriptions. Each + * subscription can replace its candidate set while the shared owner reuses + * equivalent host watcher handles across Session and Agent consumers. + */ + +import type { IDisposable } from '#/_base/di/lifecycle'; +import { createDecorator, type ServiceIdentifier } from '#/_base/di/instantiation'; + +export type FileSourceTargetKind = 'file' | 'directory'; + +export interface FileSourceWatchOptions { + readonly target: FileSourceTargetKind; + readonly recursive?: boolean; + readonly depth?: number; + readonly pollingIntervalMs?: number; + readonly ignoredPathNames?: readonly string[]; + readonly ignoreDotDirectories?: boolean; + readonly debounceMs?: number; +} + +export interface IFileSourceWatch extends IDisposable { + setPaths(paths: readonly string[]): Promise; +} + +export interface IFileSourceMonitor { + readonly _serviceBrand: undefined; + + createWatch( + options: FileSourceWatchOptions, + onDidChange: () => void, + ): IFileSourceWatch; +} + +export const IFileSourceMonitor: ServiceIdentifier = + createDecorator('fileSourceMonitor'); diff --git a/packages/agent-core-v2/src/app/fileSourceMonitor/fileSourceMonitorService.ts b/packages/agent-core-v2/src/app/fileSourceMonitor/fileSourceMonitorService.ts new file mode 100644 index 0000000000..0f94228bfc --- /dev/null +++ b/packages/agent-core-v2/src/app/fileSourceMonitor/fileSourceMonitorService.ts @@ -0,0 +1,591 @@ +/** + * `fileSourceMonitor` domain (L2) — `IFileSourceMonitor` implementation. + * + * Probes through `hostFs`, watches through `hostFsWatch`, and owns the shared + * raw-handle pool, missing-path ancestor progression, lexical/canonical target + * tracking, 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 FileSourceWatchOptions, + IFileSourceMonitor, + type IFileSourceWatch, +} from './fileSourceMonitor'; + +const DEFAULT_DEBOUNCE_MS = 300; + +interface NormalizedWatchOptions { + readonly target: 'file' | 'directory'; + readonly recursive: boolean; + readonly depth: number | undefined; + 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 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 FileSourceMonitorService extends Disposable implements IFileSourceMonitor { + 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: FileSourceWatchOptions, + onDidChange: () => void, + ): IFileSourceWatch { + const watch = new FileSourceWatch(this, normalizeOptions(options), onDidChange); + if (this.disposed) { + watch.dispose(); + return watch; + } + this.subscriptions.add(watch); + return watch; + } + + setPaths(watch: FileSourceWatch, 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: FileSourceWatch): 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: FileSourceWatch, + ): 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: FileSourceWatch): 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); + 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): void { + for (const subscriber of state.subscribers) subscriber.signal(); + } + + 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, + 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 FileSourceWatch implements IFileSourceWatch { + private keys = new Set(); + private timer: ReturnType | undefined; + private disposed = false; + + constructor( + private readonly owner: FileSourceMonitorService, + readonly options: NormalizedWatchOptions, + private readonly onDidChange: () => 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(): void { + if (this.disposed || this.timer !== undefined) return; + if (this.options.debounceMs === 0) { + this.onDidChange(); + return; + } + const timer = setTimeout(() => { + this.timer = undefined; + if (!this.disposed) this.onDidChange(); + }, 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.owner.releaseWatch(this); + } +} + +function normalizeOptions(options: FileSourceWatchOptions): NormalizedWatchOptions { + return { + target: options.target, + recursive: options.recursive ?? false, + depth: options.depth, + 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.pollingIntervalMs, + options.ignoredPathNames, + options.ignoreDotDirectories, + ]); +} + +function targetWatchSpec(path: string, options: NormalizedWatchOptions): RawWatchSpec { + return { + path, + recursive: options.recursive, + depth: options.depth, + pollingIntervalMs: options.pollingIntervalMs, + ignoredPathNames: options.ignoredPathNames, + ignoreDotDirectories: options.ignoreDotDirectories, + }; +} + +function shallowWatchSpec( + path: string, + pollingIntervalMs: number | undefined, +): RawWatchSpec { + return { + path, + recursive: false, + depth: undefined, + pollingIntervalMs, + ignoredPathNames: [], + ignoreDotDirectories: false, + }; +} + +function rawWatchKey(spec: RawWatchSpec): string { + return JSON.stringify([ + spec.path, + spec.recursive, + spec.depth, + 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, + IFileSourceMonitor, + FileSourceMonitorService, + ScopeActivation.OnScopeCreated, + 'fileSourceMonitor', +); 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 28e12d9737..12a22a058e 100644 --- a/packages/agent-core-v2/src/app/skillCatalog/skillRoots.ts +++ b/packages/agent-core-v2/src/app/skillCatalog/skillRoots.ts @@ -24,42 +24,77 @@ 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; } /** 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/skillSourceWatcher.ts b/packages/agent-core-v2/src/app/skillCatalog/skillSourceWatcher.ts deleted file mode 100644 index aa5ac0cd09..0000000000 --- a/packages/agent-core-v2/src/app/skillCatalog/skillSourceWatcher.ts +++ /dev/null @@ -1,110 +0,0 @@ -/** - * `skillCatalog` domain (L3) — file-watch helper for file-based producers. - * - * Watches a producer's candidate paths (skill roots, AGENTS.md files) through - * the os `IHostFsWatchService`, debounces raw fs events into one callback, and - * lets the owner re-fire its change signal. Each candidate is watched - * recursively, and its parent directory is watched non-recursively as well: - * chokidar cannot bind a deep watch whose parent is still missing, so a - * parent event re-binds the recursive watch — this is how a path created - * mid-session (first `.agents/skills` in a project, a new `.kimi-code/AGENTS.md`) - * gets detected. An optional `filter` confines recursive-handle events to - * relevant paths (parent-handle events always pass — they carry the re-bind). - * Plain helper constructed and disposed by its owner — not a scoped service. - */ - -import { Disposable } from '#/_base/di/lifecycle'; -import { - type HostFsChange, - type IHostFsWatchHandle, - IHostFsWatchService, -} from '#/os/interface/hostFsWatch'; - -export const SKILL_SOURCE_WATCH_DEBOUNCE_MS = 300; - -interface WatchEntry { - recursive: IHostFsWatchHandle; - parent: IHostFsWatchHandle | undefined; -} - -export class SkillSourceWatcher extends Disposable { - private readonly entries = new Map(); - private timer: NodeJS.Timeout | undefined; - - constructor( - private readonly hostFsWatch: IHostFsWatchService, - private readonly onChanged: () => void, - private readonly filter?: (change: HostFsChange) => boolean, - ) { - super(); - } - - setPaths(paths: readonly string[]): void { - const next = new Set(paths); - for (const [path, entry] of this.entries) { - if (next.has(path)) continue; - entry.recursive.dispose(); - entry.parent?.dispose(); - this.entries.delete(path); - } - for (const path of next) { - if (!this.entries.has(path)) this.watchCandidate(path); - } - } - - private watchCandidate(path: string): void { - const recursive = this.hostFsWatch.watch(path, { recursive: true }); - recursive.onDidChange((change) => { - if (this.filter === undefined || this.filter(change)) this.schedule(); - }); - const separator = path.includes('\\') ? '\\' : '/'; - const parentPath = path.slice(0, Math.max(0, path.lastIndexOf(separator))); - const entry: WatchEntry = { recursive, parent: undefined }; - if (parentPath.length > 0 && parentPath !== path) { - const parent = this.hostFsWatch.watch(parentPath, { recursive: false }); - // Parent events always pass the filter: they re-bind the recursive watch - // so a candidate whose subtree did not exist yet still gets detected. - parent.onDidChange(() => this.onParentChange(path)); - entry.parent = parent; - } - this.entries.set(path, entry); - } - - private onParentChange(path: string): void { - const entry = this.entries.get(path); - if (entry === undefined) return; - // The parent (re)appeared or churned: re-bind the recursive watch so it - // can descend into the now-existing subtree, then re-scan via the - // debounced callback. - entry.recursive.dispose(); - const recursive = this.hostFsWatch.watch(path, { recursive: true }); - recursive.onDidChange((change) => { - if (this.filter === undefined || this.filter(change)) this.schedule(); - }); - entry.recursive = recursive; - this.schedule(); - } - - private schedule(): void { - if (this.timer !== undefined) return; - const timer = setTimeout(() => { - this.timer = undefined; - this.onChanged(); - }, SKILL_SOURCE_WATCH_DEBOUNCE_MS); - timer.unref?.(); - this.timer = timer; - } - - override dispose(): void { - if (this.timer !== undefined) { - clearTimeout(this.timer); - this.timer = undefined; - } - for (const entry of this.entries.values()) { - entry.recursive.dispose(); - entry.parent?.dispose(); - } - this.entries.clear(); - super.dispose(); - } -} 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..3b930e463e --- /dev/null +++ b/packages/agent-core-v2/src/app/skillCatalog/skillTraversal.ts @@ -0,0 +1,24 @@ +/** + * `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 { FileSourceWatchOptions } from '#/app/fileSourceMonitor/fileSourceMonitor'; + +export const SKILL_SCAN_MAX_DEPTH = 8; +export const SKILL_WATCH_MAX_DEPTH = SKILL_SCAN_MAX_DEPTH + 2; + +export const SKILL_ROOT_WATCH_OPTIONS: FileSourceWatchOptions = { + target: 'directory', + recursive: true, + depth: SKILL_WATCH_MAX_DEPTH, + 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 9de202cb5e..b8c3f9df51 100644 --- a/packages/agent-core-v2/src/app/skillCatalog/userFileSkillSource.ts +++ b/packages/agent-core-v2/src/app/skillCatalog/userFileSkillSource.ts @@ -4,7 +4,7 @@ * 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`. Watches the - * candidate root paths (existing or not) through `SkillSourceWatcher` and + * candidate root paths (existing or not) through `fileSourceMonitor` and * re-fires `onDidChange` on debounced fs changes. Bound at App scope. */ @@ -14,7 +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 { IHostFsWatchService } from '#/os/interface/hostFsWatch'; +import { + IFileSourceMonitor, + type IFileSourceWatch, +} from '#/app/fileSourceMonitor/fileSourceMonitor'; import { MERGE_ALL_AVAILABLE_SKILLS_SECTION, @@ -22,9 +25,14 @@ import { } from './configSection'; import { ISkillCatalogRuntimeOptions } from './skillCatalogRuntimeOptions'; import { ISkillDiscovery } from './skillDiscovery'; -import { userRootCandidates, userRoots } from './skillRoots'; -import { SkillSourceWatcher } from './skillSourceWatcher'; -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; @@ -40,18 +48,18 @@ 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: SkillSourceWatcher; + private readonly watcher: IFileSourceWatch; constructor( @ISkillDiscovery private readonly discovery: ISkillDiscovery, @IBootstrapService private readonly bootstrap: IBootstrapService, @IConfigService private readonly config: IConfigService, @ISkillCatalogRuntimeOptions private readonly runtimeOptions: ISkillCatalogRuntimeOptions, - @IHostFsWatchService hostFsWatch: IHostFsWatchService, + @IFileSourceMonitor fileSourceMonitor: IFileSourceMonitor, ) { super(); this.watcher = this._register( - new SkillSourceWatcher(hostFsWatch, () => this.onDidChangeEmitter.fire()), + fileSourceMonitor.createWatch(SKILL_ROOT_WATCH_OPTIONS, () => this.onDidChangeEmitter.fire()), ); this._register( this.config.onDidSectionChange((event) => { @@ -60,19 +68,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; - this.watcher.setPaths( - userRootCandidates(this.bootstrap.homeDir, this.bootstrap.osHomeDir), - ); - 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 15070cffe8..d42aaa9844 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/fileSourceMonitor/fileSourceMonitor'; +import '#/app/fileSourceMonitor/fileSourceMonitorService'; export * from '#/os/backends/node-local/hostProcessService'; export * from '#/os/backends/node-local/hostTerminalService'; export * from '#/agent/tools/os/bash/bash'; @@ -191,8 +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/skillListReminder/skillListReminder'; -import '#/agent/skillListReminder/skillListReminderService'; +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'; @@ -203,7 +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/skillSourceWatcher'; +export * from '#/app/skillCatalog/skillTraversal'; export * from '#/app/skillCatalog/builtin/builtin'; export * from '#/app/skillCatalog/builtinSkillSource'; export * from '#/app/skillCatalog/userFileSkillSource'; 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..025adc2c0e 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) { @@ -37,9 +39,16 @@ class HostFsWatchHandle implements IHostFsWatchHandle { ignoreInitial: true, persistent: false, followSymlinks: false, - depth: options?.recursive === false ? 0 : undefined, + depth: options?.recursive === false ? 0 : options?.depth, + usePolling: options?.pollingIntervalMs !== undefined, + interval: options?.pollingIntervalMs, 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 +62,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..1ea80429e1 100644 --- a/packages/agent-core-v2/src/os/interface/hostFsWatch.ts +++ b/packages/agent-core-v2/src/os/interface/hostFsWatch.ts @@ -24,10 +24,13 @@ export interface HostFsChange { export interface HostFsWatchOptions { readonly recursive?: boolean; + readonly depth?: number; + 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/sessionSkillCatalog/explicitFileSkillSource.ts b/packages/agent-core-v2/src/session/sessionSkillCatalog/explicitFileSkillSource.ts index 00e3f5639c..48ac87f864 100644 --- a/packages/agent-core-v2/src/session/sessionSkillCatalog/explicitFileSkillSource.ts +++ b/packages/agent-core-v2/src/session/sessionSkillCatalog/explicitFileSkillSource.ts @@ -5,7 +5,7 @@ * 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. Watches - * the explicit directories through `SkillSourceWatcher` and re-fires + * the explicit directories through `fileSourceMonitor` and re-fires * `onDidChange` on debounced fs changes. Bound at Session scope so each session * resolves paths against its own workDir. */ @@ -15,12 +15,20 @@ 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 { configuredRootCandidates, configuredRoots } from '#/app/skillCatalog/skillRoots'; +import { + IFileSourceMonitor, + type IFileSourceWatch, +} from '#/app/fileSourceMonitor/fileSourceMonitor'; +import { resolveConfiguredSkillRoots } from '#/app/skillCatalog/skillRoots'; import { ISkillCatalogRuntimeOptions } from '#/app/skillCatalog/skillCatalogRuntimeOptions'; import { ISkillDiscovery } from '#/app/skillCatalog/skillDiscovery'; -import { SkillSourceWatcher } from '#/app/skillCatalog/skillSourceWatcher'; -import { SKILL_SOURCE_PRIORITY, type ISkillSource, type SkillContribution } from '#/app/skillCatalog/skillSource'; -import { IHostFsWatchService } from '#/os/interface/hostFsWatch'; +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 { @@ -37,32 +45,36 @@ export class ExplicitFileSkillSource extends Disposable implements IExplicitFile readonly priority = SKILL_SOURCE_PRIORITY.user; private readonly onDidChangeEmitter = this._register(new Emitter()); readonly onDidChange: Event = this.onDidChangeEmitter.event; - private readonly watcher: SkillSourceWatcher; + private readonly watcher: IFileSourceWatch; constructor( @ISkillDiscovery private readonly discovery: ISkillDiscovery, @ISkillCatalogRuntimeOptions private readonly runtimeOptions: ISkillCatalogRuntimeOptions, @ISessionWorkspaceContext private readonly workspace: ISessionWorkspaceContext, @IBootstrapService private readonly bootstrap: IBootstrapService, - @IHostFsWatchService hostFsWatch: IHostFsWatchService, + @IFileSourceMonitor fileSourceMonitor: IFileSourceMonitor, ) { super(); this.watcher = this._register( - new SkillSourceWatcher(hostFsWatch, () => this.onDidChangeEmitter.fire()), + fileSourceMonitor.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: [] }; } - this.watcher.setPaths( - await configuredRootCandidates(explicitDirs, this.workspace.workDir, this.bootstrap.osHomeDir), - ); - 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 1bc4acccbb..be3fbafd2b 100644 --- a/packages/agent-core-v2/src/session/sessionSkillCatalog/extraFileSkillSource.ts +++ b/packages/agent-core-v2/src/session/sessionSkillCatalog/extraFileSkillSource.ts @@ -5,7 +5,7 @@ * `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. Watches the - * configured directories through `SkillSourceWatcher` and re-fires + * configured directories through `fileSourceMonitor` and re-fires * `onDidChange` on debounced fs changes. Bound at Session scope so each * session reads its own workspace root. */ @@ -16,15 +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 { + IFileSourceMonitor, + type IFileSourceWatch, +} from '#/app/fileSourceMonitor/fileSourceMonitor'; import { EXTRA_SKILL_DIRS_SECTION, type ExtraSkillDirsConfig, } from '#/app/skillCatalog/configSection'; -import { configuredRootCandidates, configuredRoots } from '#/app/skillCatalog/skillRoots'; +import { resolveConfiguredSkillRoots } from '#/app/skillCatalog/skillRoots'; import { ISkillDiscovery } from '#/app/skillCatalog/skillDiscovery'; -import { SkillSourceWatcher } from '#/app/skillCatalog/skillSourceWatcher'; -import { SKILL_SOURCE_PRIORITY, type ISkillSource, type SkillContribution } from '#/app/skillCatalog/skillSource'; -import { IHostFsWatchService } from '#/os/interface/hostFsWatch'; +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 { @@ -41,18 +49,18 @@ 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: SkillSourceWatcher; + private readonly watcher: IFileSourceWatch; constructor( @ISkillDiscovery private readonly discovery: ISkillDiscovery, @IConfigService private readonly config: IConfigService, @ISessionWorkspaceContext private readonly workspace: ISessionWorkspaceContext, @IBootstrapService private readonly bootstrap: IBootstrapService, - @IHostFsWatchService hostFsWatch: IHostFsWatchService, + @IFileSourceMonitor fileSourceMonitor: IFileSourceMonitor, ) { super(); this.watcher = this._register( - new SkillSourceWatcher(hostFsWatch, () => this.onDidChangeEmitter.fire()), + fileSourceMonitor.createWatch(SKILL_ROOT_WATCH_OPTIONS, () => this.onDidChangeEmitter.fire()), ); this._register( this.config.onDidSectionChange((event) => { @@ -61,15 +69,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) ?? []; - this.watcher.setPaths( - await configuredRootCandidates(extraSkillDirs, this.workspace.workDir, this.bootstrap.osHomeDir), - ); - 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..b1d28ff3d8 100644 --- a/packages/agent-core-v2/src/session/sessionSkillCatalog/pluginSkillSource.ts +++ b/packages/agent-core-v2/src/session/sessionSkillCatalog/pluginSkillSource.ts @@ -13,7 +13,12 @@ import { createDecorator, type ServiceIdentifier } from '#/_base/di/instantiatio import type { Event } from '#/_base/event'; import { LifecycleScope, ScopeActivation, registerScopedService } from '#/_base/di/scope'; 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 { IPluginService } from '#/app/plugin/plugin'; export interface IPluginSkillSource extends ISkillSource { @@ -42,8 +47,10 @@ export class PluginSkillSource implements IPluginSkillSource { @IPluginService private readonly plugins: IPluginService, ) {} - 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: [] }; + 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 cd579ac3f3..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; @@ -61,8 +63,6 @@ export class SessionSkillCatalogService this.sources = [builtin, user, explicit, extra, workspace, plugin].toSorted((a, b) => a.priority - b.priority); for (const s of this.sources) { if (s.onDidChange) { - // A failed source reload keeps the previous contribution (skip this - // update); the catch only silences the otherwise-unhandled rejection. this._register(s.onDidChange(() => { void this.reloadSource(s.id).catch(() => undefined); })); } } @@ -93,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); @@ -111,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); @@ -125,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(); @@ -142,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 b139b71343..5cb80adb8e 100644 --- a/packages/agent-core-v2/src/session/sessionSkillCatalog/workspaceFileSkillSource.ts +++ b/packages/agent-core-v2/src/session/sessionSkillCatalog/workspaceFileSkillSource.ts @@ -4,7 +4,7 @@ * Discovers project skills from the session's current `workDir` * (`workspaceContext`) through `ISkillDiscovery`, contributing them at priority * 30 (above user / extra / plugin / builtin). Watches the candidate root paths - * (existing or not) through `SkillSourceWatcher` and re-fires `onDidChange` on + * (existing or not) through `fileSourceMonitor` and re-fires `onDidChange` on * debounced fs changes. Bound at Session scope so each session reads its own * workspace root. */ @@ -14,16 +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 { + IFileSourceMonitor, + type IFileSourceWatch, +} from '#/app/fileSourceMonitor/fileSourceMonitor'; 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 { projectRootCandidates, projectRoots } from '#/app/skillCatalog/skillRoots'; -import { SkillSourceWatcher } from '#/app/skillCatalog/skillSourceWatcher'; -import { SKILL_SOURCE_PRIORITY, type ISkillSource, type SkillContribution } from '#/app/skillCatalog/skillSource'; -import { IHostFsWatchService } from '#/os/interface/hostFsWatch'; +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 { @@ -40,18 +48,18 @@ 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: SkillSourceWatcher; + private readonly watcher: IFileSourceWatch; constructor( @ISkillDiscovery private readonly discovery: ISkillDiscovery, @ISessionWorkspaceContext private readonly workspace: ISessionWorkspaceContext, @IConfigService private readonly config: IConfigService, @ISkillCatalogRuntimeOptions private readonly runtimeOptions: ISkillCatalogRuntimeOptions, - @IHostFsWatchService hostFsWatch: IHostFsWatchService, + @IFileSourceMonitor fileSourceMonitor: IFileSourceMonitor, ) { super(); this.watcher = this._register( - new SkillSourceWatcher(hostFsWatch, () => this.onDidChangeEmitter.fire()), + fileSourceMonitor.createWatch(SKILL_ROOT_WATCH_OPTIONS, () => this.onDidChangeEmitter.fire()), ); this._register( this.config.onDidSectionChange((event) => { @@ -60,15 +68,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; - this.watcher.setPaths(await projectRootCandidates(this.workspace.workDir)); - 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 index 5460ddd061..0444898daa 100644 --- a/packages/agent-core-v2/test/agent/dateChange/dateChangeInjection.test.ts +++ b/packages/agent-core-v2/test/agent/dateChange/dateChangeInjection.test.ts @@ -8,7 +8,7 @@ * test/agent/dateChange/dateChangeInjection.test.ts`. */ -import { afterEach, beforeEach, describe, expect, it } from 'vitest'; +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; import { IAgentContextInjectorService } from '#/agent/contextInjector/contextInjector'; import { IAgentContextMemoryService } from '#/agent/contextMemory/contextMemory'; @@ -55,6 +55,8 @@ describe('AgentDateChangeService', () => { 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; @@ -63,9 +65,13 @@ describe('AgentDateChangeService', () => { afterEach(async () => { try { - await ctx.expectResumeMatches(); + try { + await ctx.expectResumeMatches(); + } finally { + await ctx.dispose(); + } } finally { - await ctx.dispose(); + vi.useRealTimers(); } }); 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": "