diff --git a/packages/agent-core-v2/docs/state-manifest.d.ts b/packages/agent-core-v2/docs/state-manifest.d.ts index 6294b09f02e..245a3487cb3 100644 --- a/packages/agent-core-v2/docs/state-manifest.d.ts +++ b/packages/agent-core-v2/docs/state-manifest.d.ts @@ -27,7 +27,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 (App: 0 keys · Workspace: 6 keys · Session: 9 keys · Agent: 83 keys) +// Index (App: 0 keys · Workspace: 6 keys · Session: 9 keys · Agent: 82 keys) // App // Workspace // workspaceDirs.ephemeralDirs src/workspace/workspaceDirs/workspaceDirsService.ts @@ -55,7 +55,6 @@ // agentPlugin.sessionStartRefreshPending src/agent/plugin/agentPluginService.ts // agentsMdReminder.cwd src/agent/agentsMdReminder/agentsMdReminderService.ts // agentsMdReminder.known src/agent/agentsMdReminder/agentsMdReminderService.ts -// agentsMdReminder.pending src/agent/agentsMdReminder/agentsMdReminderService.ts // agentsMdReminder.seeded src/agent/agentsMdReminder/agentsMdReminderService.ts // contextMemory src/agent/contextMemory/contextOps.ts // contextProjector.lastRepairSignature src/agent/contextProjector/contextProjectorService.ts @@ -1041,7 +1040,6 @@ export interface AgentStateSnapshot { // src/agent/agentsMdReminder/agentsMdReminderService.ts 'agentsMdReminder.cwd': string | undefined; 'agentsMdReminder.known': Set; - 'agentsMdReminder.pending': Set; 'agentsMdReminder.seeded': boolean; // src/agent/contextMemory/contextOps.ts // replayable · durable · undoable — folds: ContextAppendMessage, ContextAppendLoopEvent, ContextClear, ContextApplyCompaction diff --git a/packages/agent-core-v2/src/agent/agentsMdReminder/agentsMdReminderService.ts b/packages/agent-core-v2/src/agent/agentsMdReminder/agentsMdReminderService.ts index 2a2406ceedf..42ead234a9d 100644 --- a/packages/agent-core-v2/src/agent/agentsMdReminder/agentsMdReminderService.ts +++ b/packages/agent-core-v2/src/agent/agentsMdReminder/agentsMdReminderService.ts @@ -42,14 +42,12 @@ const AGENTS_MD_BASENAMES: ReadonlySet = new Set(AGENTS_MD_PLAIN const BASH_PARSE_OPTIONS = { timeoutMs: 20, maxNodes: 10_000 } as const; +const DISCOVERY_REMINDER_VARIANT = 'agents_md'; + export const agentsMdReminderKnownKey = defineState>( 'agentsMdReminder.known', () => new Set(), ); -export const agentsMdReminderPendingKey = defineState>( - 'agentsMdReminder.pending', - () => new Set(), -); export const agentsMdReminderCwdKey = defineState( 'agentsMdReminder.cwd', () => undefined as string | undefined, @@ -65,6 +63,10 @@ export class AgentAgentsMdReminderService { declare readonly _serviceBrand: undefined; + private readonly remindQueue = new Set(); + private readonly readRecently = new Set(); + private readonly telemetryFired = new Set(); + constructor( @IAgentToolExecutorService toolExecutor: IAgentToolExecutorService, @IAgentReminderService private readonly reminder: IAgentReminderService, @@ -80,11 +82,10 @@ export class AgentAgentsMdReminderService ) { super(); this.states.contributeState(agentsMdReminderKnownKey); - this.states.contributeState(agentsMdReminderPendingKey); this.states.contributeState(agentsMdReminderCwdKey); this.states.contributeState(agentsMdReminderSeededKey); this._register( - this.reminder.register('agents_md', (context) => + this.reminder.register(DISCOVERY_REMINDER_VARIANT, (context) => this.injectReminder(context), ), ); @@ -113,9 +114,7 @@ export class AgentAgentsMdReminderService const known = new Set(this.known); for (const path of paths) known.add(normalize(path)); this.states.set(agentsMdReminderKnownKey, known); - const pending = new Set(this.pending); - for (const path of paths) pending.delete(normalize(path)); - this.states.set(agentsMdReminderPendingKey, pending); + for (const path of paths) this.remindQueue.delete(normalize(path)); this.states.set(agentsMdReminderCwdKey, cwd); this.states.set(agentsMdReminderSeededKey, true); } @@ -135,42 +134,34 @@ export class AgentAgentsMdReminderService this.markKnown( list.filter((change) => change.action === 'modified').map((change) => change.path), ); - this.markPending( - list.filter((change) => change.action === 'created').map((change) => change.path), - ); this.markDeleted( list.filter((change) => change.action === 'deleted').map((change) => change.path), ); } - private readonly claimed = new Set(); - private get known(): Set { return this.states.get(agentsMdReminderKnownKey); } - private get pending(): Set { - return this.states.get(agentsMdReminderPendingKey); - } - private get agentCwd(): string { return this.states.get(agentsMdReminderCwdKey) ?? this.sessionContext.cwd; } - private hasPath(path: string): boolean { - return this.known.has(path) || this.pending.has(path); - } - private injectReminder( context: ContextInjectionContext, ): ContextInjectionResult | undefined { + const readRecently = new Set(this.readRecently); + this.readRecently.clear(); const known = this.known; - const pending = [...this.pending].filter((path) => !known.has(path)); - if (pending.length === 0) return undefined; + const queued = [...this.remindQueue].filter( + (path) => !known.has(path) && !readRecently.has(path), + ); + this.remindQueue.clear(); + if (queued.length === 0) return undefined; const covered = context.lastDisclosure ?? []; - const fresh = pending.filter((path) => !covered.includes(path)); + const fresh = queued.filter((path) => !covered.includes(path)); if (fresh.length === 0) return undefined; - return { content: reminderText(fresh), disclosure: pending }; + return { content: reminderText(fresh), disclosure: [...covered, ...fresh] }; } private async ensureSeeded(): Promise { @@ -190,68 +181,55 @@ export class AgentAgentsMdReminderService private async probeAndRemind(ctx: ToolDidExecuteContext): Promise { if (ctx.outcome !== 'executed') return; - const discovered: string[] = []; try { await this.ensureSeeded(); const { dirs, selfKnown } = this.targetDirs(ctx); const selfKnownSet = new Set(selfKnown); + const discovered: string[] = []; for (const dir of dirs) { for (const path of await this.probeDir(dir)) { - if (this.hasPath(path) || this.claimed.has(path) || selfKnownSet.has(path)) continue; - this.claimed.add(path); + if (this.known.has(path) || this.remindQueue.has(path) || selfKnownSet.has(path)) { + continue; + } discovered.push(path); } } - if (discovered.length === 0) { - this.markKnown(selfKnown); - return; + for (const path of selfKnown) { + this.remindQueue.delete(path); + this.readRecently.add(path); } - const properties: AgentsMdReminderShownEvent = { - turn_id: ctx.turnId, - tool_name: ctx.toolCall.name, - reminded_count: discovered.length, - trace_id: ctx.trace?.traceId, - }; - this.telemetry.track2('agents_md_reminder_shown', properties); - this.markKnown(selfKnown); - this.markPending(discovered); - } catch {} finally { - for (const path of discovered) this.claimed.delete(path); - } + if (discovered.length === 0) return; + const untracked = discovered.filter((path) => !this.telemetryFired.has(path)); + if (untracked.length > 0) { + const properties: AgentsMdReminderShownEvent = { + turn_id: ctx.turnId, + tool_name: ctx.toolCall.name, + reminded_count: untracked.length, + trace_id: ctx.trace?.traceId, + }; + this.telemetry.track2('agents_md_reminder_shown', properties); + for (const path of untracked) this.telemetryFired.add(path); + } + for (const path of discovered) this.remindQueue.add(path); + } catch {} } private markKnown(paths: readonly string[]): void { if (paths.length === 0) return; const known = new Set(this.known); - const pending = new Set(this.pending); - for (const path of paths) { - known.add(path); - pending.delete(path); - } + for (const path of paths) known.add(path); this.states.set(agentsMdReminderKnownKey, known); - this.states.set(agentsMdReminderPendingKey, pending); - } - - private markPending(paths: readonly string[]): void { - if (paths.length === 0) return; - const known = this.known; - const pending = new Set(this.pending); - for (const path of paths) { - if (!known.has(path)) pending.add(path); - } - this.states.set(agentsMdReminderPendingKey, pending); } private markDeleted(paths: readonly string[]): void { if (paths.length === 0) return; const known = new Set(this.known); - const pending = new Set(this.pending); for (const path of paths) { known.delete(path); - pending.delete(path); + this.remindQueue.delete(path); + this.telemetryFired.delete(path); } this.states.set(agentsMdReminderKnownKey, known); - this.states.set(agentsMdReminderPendingKey, pending); } private targetDirs(ctx: ToolDidExecuteContext): { dirs: string[]; selfKnown: string[] } { @@ -339,7 +317,7 @@ export class AgentAgentsMdReminderService const found: string[] = []; for (const chainDir of chain) { const candidates = agentsMdCandidatePaths(chainDir); - if (candidates.every((candidate) => this.hasPath(normalize(candidate)))) continue; + if (candidates.every((candidate) => this.known.has(normalize(candidate)))) continue; for (const path of await findAgentsMdInDir(deps, chainDir)) { found.push(normalize(path)); } diff --git a/packages/agent-core-v2/test/agent/agentsMdReminder/agentsMdReminder.test.ts b/packages/agent-core-v2/test/agent/agentsMdReminder/agentsMdReminder.test.ts index 2635a4604a6..cbe017666d1 100644 --- a/packages/agent-core-v2/test/agent/agentsMdReminder/agentsMdReminder.test.ts +++ b/packages/agent-core-v2/test/agent/agentsMdReminder/agentsMdReminder.test.ts @@ -53,10 +53,7 @@ import { OrderedHookSlot } from '#/hooks'; import { IEventDispatcher } from '#/state/eventDispatcher'; import type { ToolDidExecuteContext } from '#/agent/toolExecutor/toolHooks'; import { IAgentAgentsMdReminderService } from '#/agent/agentsMdReminder/agentsMdReminder'; -import { - AgentAgentsMdReminderService, - agentsMdReminderPendingKey, -} from '#/agent/agentsMdReminder/agentsMdReminderService'; +import { AgentAgentsMdReminderService } from '#/agent/agentsMdReminder/agentsMdReminderService'; import { extractBashTargetDirs } from '#/agent/agentsMdReminder/bashTargets'; import { recordingTelemetry, type TelemetryRecord } from '../../app/telemetry/stubs'; import { stubToolExecutorEvents, type ToolExecutorEventStubs } from '../toolExecutor/stubs'; @@ -371,7 +368,7 @@ describe('agentsMdReminder instructions change announcements', () => { expect(h.reminders).toHaveLength(0); }); - it('adds announced created paths to the pending set so the reminder re-injects until read', async () => { + it('reminds an announced created path on the next access to its directory', async () => { const h = createHarness(); const rootAgentsMd = await writeAgentsMd(workDir, 'root instructions'); h.reminder.seedInjected([], workDir); @@ -379,8 +376,11 @@ describe('agentsMdReminder instructions change announcements', () => { h.instructionsChange.fire([{ path: rootAgentsMd, action: 'created', kind: 'file' }]); expect(h.reminders).toHaveLength(1); - const pending = h.ix.get(IAgentStateService).get(agentsMdReminderPendingKey); - expect(pending.has(normalize(rootAgentsMd))).toBe(true); + expect(agentsMdMessages(h)).toHaveLength(0); + + await fire(h, didCtx('Read', { path: join(workDir, 'index.ts') })); + expect(agentsMdMessages(h)).toHaveLength(1); + expect(reminderText(h)).toContain(rootAgentsMd); }); }); @@ -422,7 +422,7 @@ describe('agentsMdReminder path-carrying tools', () => { expect(reminderText(h)).toContain(subAgentsMd); }); - it('marks an AGENTS.md known when read directly and never suggests it afterwards', async () => { + it('does not queue the file read in the triggering call, but re-reminds on a later access', async () => { const h = createHarness(); const subDir = join(workDir, 'packages', 'kap-server'); const subAgentsMd = await writeAgentsMd(subDir); @@ -433,7 +433,8 @@ describe('agentsMdReminder path-carrying tools', () => { const after = await fire(h, didCtx('Read', { path: join(subDir, 'src', 'index.ts') })); expect(outputText(after)).toBe('original result'); - expect(agentsMdMessages(h)).toHaveLength(0); + expect(agentsMdMessages(h)).toHaveLength(1); + expect(reminderText(h)).toContain(subAgentsMd); }); it('discovers the .kimi-code/AGENTS.md variant alongside the plain one', async () => { @@ -519,7 +520,7 @@ describe('agentsMdReminder re-injection after context loss', () => { expect(reminderText(h)).toContain(subAgentsMd); }); - it('keeps directly-read paths silent across compaction', async () => { + it('re-reminds a directly-read path on access after compaction drops the read content', async () => { const h = createHarness(); const subDir = join(workDir, 'packages', 'kap-server'); const subAgentsMd = await writeAgentsMd(subDir); @@ -531,7 +532,8 @@ describe('agentsMdReminder re-injection after context loss', () => { compact(h); await fire(h, didCtx('Read', { path: join(subDir, 'index.ts') })); - expect(agentsMdMessages(h)).toHaveLength(0); + expect(agentsMdMessages(h)).toHaveLength(1); + expect(reminderText(h)).toContain(subAgentsMd); }); it('keeps injected paths silent across compaction', async () => { @@ -579,7 +581,7 @@ describe('agentsMdReminder re-injection after context loss', () => { expect(agentsMdMessages(h)).toHaveLength(0); }); - it('re-reminds a pending path after an undo removes the reminder', async () => { + it('re-reminds a pending path on the next access after an undo removes the reminder', async () => { const h = createHarness(); const subDir = join(workDir, 'packages', 'kap-server'); const subAgentsMd = await writeAgentsMd(subDir); @@ -597,6 +599,9 @@ describe('agentsMdReminder re-injection after context loss', () => { expect(agentsMdMessages(h)).toHaveLength(0); await h.step(); + expect(agentsMdMessages(h)).toHaveLength(0); + + await fire(h, didCtx('Read', { path: join(subDir, 'other.ts') })); expect(agentsMdMessages(h)).toHaveLength(1); expect(reminderText(h)).toContain(subAgentsMd); }); @@ -632,7 +637,7 @@ describe('agentsMdReminder re-injection after context loss', () => { expect(messageText(messages[1]!)).not.toContain(agentsMdA); }); - it('re-reminds a created-and-announced path after compaction', async () => { + it('re-reminds a created-and-announced path on the next access after compaction', async () => { const h = createHarness(); const rootAgentsMd = await writeAgentsMd(workDir, 'root instructions'); h.reminder.seedInjected([], workDir); @@ -640,16 +645,27 @@ describe('agentsMdReminder re-injection after context loss', () => { h.instructionsChange.fire([{ path: rootAgentsMd, action: 'created', kind: 'file' }]); expect(h.reminders).toHaveLength(1); - await h.step(); - expect(agentsMdMessages(h)).toHaveLength(1); - compact(h); - expect(agentsMdMessages(h)).toHaveLength(0); - await h.step(); + await fire(h, didCtx('Read', { path: join(workDir, 'index.ts') })); expect(agentsMdMessages(h)).toHaveLength(1); expect(reminderText(h)).toContain(rootAgentsMd); }); + + it('does not re-remind at a bare step after compaction without a new access', async () => { + const h = createHarness(); + const subDir = join(workDir, 'packages', 'kap-server'); + await writeAgentsMd(subDir); + h.reminder.seedInjected([], workDir); + + await fire(h, didCtx('Read', { path: join(subDir, 'index.ts') })); + expect(agentsMdMessages(h)).toHaveLength(1); + + compact(h); + await h.step(); + + expect(agentsMdMessages(h)).toHaveLength(0); + }); }); describe('agentsMdReminder Bash coverage', () => { @@ -938,7 +954,7 @@ describe('agentsMdReminder probing boundaries', () => { expect(reminderText(h)).toContain(subAgentsMd); }); - it('marks an AGENTS.md known when it is written directly', async () => { + it('does not queue a directly written file, but re-reminds on a later access', async () => { const h = createHarness(); const subDir = join(workDir, 'packages', 'kap-server'); await mkdir(subDir, { recursive: true }); @@ -948,9 +964,11 @@ describe('agentsMdReminder probing boundaries', () => { expect(outputText(written)).toBe('original result'); expect(agentsMdMessages(h)).toHaveLength(0); + await writeAgentsMd(subDir); const after = await fire(h, didCtx('Read', { path: join(subDir, 'index.ts') })); expect(outputText(after)).toBe('original result'); - expect(agentsMdMessages(h)).toHaveLength(0); + expect(agentsMdMessages(h)).toHaveLength(1); + expect(reminderText(h)).toContain(agentsMdPath); }); it('reminds at most once for two parallel touches of the same directory', async () => { @@ -968,6 +986,78 @@ describe('agentsMdReminder probing boundaries', () => { expect(agentsMdMessages(h)).toHaveLength(1); }); + it('deduplicates staggered same-step completions that discover the same file', async () => { + const h = createHarness(); + const subDir = join(workDir, 'packages', 'kap-server'); + const subAgentsMd = await writeAgentsMd(subDir); + h.reminder.seedInjected([], workDir); + + await h.events.didExecuteSlot.run( + didCtx('Read', { path: join(subDir, 'a.ts') }, { id: 'call-a' }), + ); + await h.events.didExecuteSlot.run( + didCtx('Read', { path: join(subDir, 'b.ts') }, { id: 'call-b' }), + ); + await h.step(); + + expect(agentsMdMessages(h)).toHaveLength(1); + expect(reminderText(h)).toContain(subAgentsMd); + expect(h.telemetryEvents.filter((e) => e.event === 'agents_md_reminder_shown')).toHaveLength(1); + }); + + it('suppresses a queued reminder when a sibling call reads the file directly', async () => { + const h = createHarness(); + const subDir = join(workDir, 'packages', 'kap-server'); + const subAgentsMd = await writeAgentsMd(subDir); + h.reminder.seedInjected([], workDir); + + await h.events.didExecuteSlot.run( + didCtx('Read', { path: join(subDir, 'a.ts') }, { id: 'call-a' }), + ); + await h.events.didExecuteSlot.run( + didCtx('Read', { path: subAgentsMd }, { id: 'call-b' }), + ); + await h.step(); + + expect(agentsMdMessages(h)).toHaveLength(0); + }); + + it('suppresses a reminder when the direct read completes before the sibling access', async () => { + const h = createHarness(); + const subDir = join(workDir, 'packages', 'kap-server'); + const subAgentsMd = await writeAgentsMd(subDir); + h.reminder.seedInjected([], workDir); + + await h.events.didExecuteSlot.run( + didCtx('Read', { path: subAgentsMd }, { id: 'call-read' }), + ); + await h.events.didExecuteSlot.run( + didCtx('Read', { path: join(subDir, 'a.ts') }, { id: 'call-access' }), + ); + await h.step(); + + expect(agentsMdMessages(h)).toHaveLength(0); + + await fire(h, didCtx('Read', { path: join(subDir, 'b.ts') })); + expect(agentsMdMessages(h)).toHaveLength(1); + expect(reminderText(h)).toContain(subAgentsMd); + }); + + it('drops a queued reminder when the file is deleted before the step head', async () => { + const h = createHarness(); + const subDir = join(workDir, 'packages', 'kap-server'); + const subAgentsMd = await writeAgentsMd(subDir); + h.reminder.seedInjected([], workDir); + + await h.events.didExecuteSlot.run( + didCtx('Read', { path: join(subDir, 'a.ts') }, { id: 'call-a' }), + ); + h.instructionsChange.fire([{ path: subAgentsMd, action: 'deleted', kind: 'file' }]); + await h.step(); + + expect(agentsMdMessages(h)).toHaveLength(0); + }); + it('re-judges the project root at a nested repository', async () => { const h = createHarness(); const nested = join(workDir, 'packages', 'nested');