From bbac3439e8de49e24dc97c26760ce92ad7803bc4 Mon Sep 17 00:00:00 2001 From: YaseenHQ Date: Thu, 13 Aug 2026 16:03:33 -0400 Subject: [PATCH] feat(agent): remind the agent about unread AGENTS.md files The instruction hierarchy loads once and only along the project-root to cwd chain, so a tool that reaches into a sibling directory operates there without ever seeing that directory's AGENTS.md. Hook onDidExecuteTool: when a tool touches a directory whose agent file was not injected, append a reminder naming it to that tool's result. Files are claimed before the reminder is attached, so two tools touching the same new directory in one step produce one reminder; failed results are skipped so a vetoed duplicate cannot swallow it. Off by default. --- .changeset/agents-md-reminder.md | 5 + .../agentsMdReminder/agentsMdReminder.ts | 25 +++ .../agentsMdReminderService.ts | 157 ++++++++++++++++++ .../src/agent/agentsMdReminder/flag.ts | 24 +++ .../agent/agentsMdReminder/touchedPaths.ts | 39 +++++ .../agentsMdReminder/agentsMdReminder.test.ts | 101 +++++++++++ 6 files changed, 351 insertions(+) create mode 100644 .changeset/agents-md-reminder.md create mode 100644 packages/agent-core-v2/src/agent/agentsMdReminder/agentsMdReminder.ts create mode 100644 packages/agent-core-v2/src/agent/agentsMdReminder/agentsMdReminderService.ts create mode 100644 packages/agent-core-v2/src/agent/agentsMdReminder/flag.ts create mode 100644 packages/agent-core-v2/src/agent/agentsMdReminder/touchedPaths.ts create mode 100644 packages/agent-core-v2/test/agent/agentsMdReminder/agentsMdReminder.test.ts diff --git a/.changeset/agents-md-reminder.md b/.changeset/agents-md-reminder.md new file mode 100644 index 00000000000..432e7046ded --- /dev/null +++ b/.changeset/agents-md-reminder.md @@ -0,0 +1,5 @@ +--- +"echadron": minor +--- + +Point out `AGENTS.md` files the agent has not read. Instructions load once, only along the project-root to cwd chain, so a tool reaching into a sibling directory works there without ever seeing its rules. When that happens, the tool result now carries a short reminder naming the file — once per file. Off by default; enable under Feature controls or with `ECHADRON_EXPERIMENTAL_AGENTS_MD_REMINDER=1`. diff --git a/packages/agent-core-v2/src/agent/agentsMdReminder/agentsMdReminder.ts b/packages/agent-core-v2/src/agent/agentsMdReminder/agentsMdReminder.ts new file mode 100644 index 00000000000..72c9e3ac653 --- /dev/null +++ b/packages/agent-core-v2/src/agent/agentsMdReminder/agentsMdReminder.ts @@ -0,0 +1,25 @@ +/** + * `agentsMdReminder` domain (L4) — contract. + * + * The instruction hierarchy is loaded once, and only along the project-root to + * cwd chain. A tool that reaches into a sibling directory therefore works + * without ever seeing that directory's `AGENTS.md`. This service watches + * executed tools and, the first time one touches a directory whose agent file + * was not part of the injected instructions, appends a short reminder to that + * tool result. + */ + +import { createDecorator, type ServiceIdentifier } from '#/_base/di/instantiation'; + +export interface IAgentAgentsMdReminderService { + readonly _serviceBrand: undefined; + + /** + * Record agent files already present in the injected instructions, so they + * are never suggested. Called after each successful profile bind. + */ + seedKnown(paths: Iterable): void; +} + +export const IAgentAgentsMdReminderService: ServiceIdentifier = + createDecorator('agentAgentsMdReminderService'); diff --git a/packages/agent-core-v2/src/agent/agentsMdReminder/agentsMdReminderService.ts b/packages/agent-core-v2/src/agent/agentsMdReminder/agentsMdReminderService.ts new file mode 100644 index 00000000000..688be3f7717 --- /dev/null +++ b/packages/agent-core-v2/src/agent/agentsMdReminder/agentsMdReminderService.ts @@ -0,0 +1,157 @@ +/** + * `agentsMdReminder` domain (L4) — implementation. + * + * Hooks `toolExecutor.onDidExecuteTool`. When a tool touches a directory whose + * agent file was not part of the injected instructions, appends a one-line + * reminder to that tool's result. + * + * Probing walks from the workspace root down to the touched directory using the + * same candidate names as the init-time load, so a file the model has already + * been given is never suggested. Each discovered file is claimed before the + * reminder is attached, so two tools touching the same new directory in one + * step produce one reminder rather than two. Bound at Agent scope and gated by + * the `agents-md-reminder` control, which is off by default. + */ + +import { Disposable } from '#/_base/di/lifecycle'; +import { LifecycleScope, ScopeActivation, registerScopedService } from '#/_base/di/scope'; +import { IFlagService } from '#/app/flag/flag'; +import { IAgentToolExecutorService } from '#/agent/toolExecutor/toolExecutor'; +import type { ToolDidExecuteContext } from '#/agent/toolExecutor/toolHooks'; +import { IHostFileSystem } from '#/os/interface/hostFileSystem'; +import { ISessionWorkspaceContext } from '#/session/workspaceContext/workspaceContext'; + +import { IAgentAgentsMdReminderService } from './agentsMdReminder'; +import { AGENTS_MD_REMINDER_FLAG_ID } from './flag'; +import { touchedPathForTool } from './touchedPaths'; + +/** Same candidates the init-time load walks, in the same order. */ +const AGENT_FILE_NAMES = ['AGENTS.md', 'agents.md'] as const; + +export function buildAgentsMdReminder(paths: readonly string[]): string { + const list = paths.map((path) => `- ${path}`).join('\n'); + return ( + `\n\n\nThis directory has instructions you have not read:\n${list}\n` + + 'Read it before making further changes there; it was outside the instruction ' + + 'hierarchy loaded at startup.\n' + ); +} + +export class AgentAgentsMdReminderService + extends Disposable + implements IAgentAgentsMdReminderService +{ + declare readonly _serviceBrand: undefined; + + /** Agent files already injected, or already suggested once. */ + private readonly known = new Set(); + /** Directories already probed, so a repeated touch costs no filesystem work. */ + private readonly probed = new Set(); + + constructor( + @IAgentToolExecutorService toolExecutor: IAgentToolExecutorService, + @IHostFileSystem private readonly fs: IHostFileSystem, + @ISessionWorkspaceContext private readonly workspace: ISessionWorkspaceContext, + @IFlagService private readonly flags: IFlagService, + ) { + super(); + this._register( + toolExecutor.hooks.onDidExecuteTool.register('agents-md-reminder', async (ctx, next) => { + await next(); + if (!this.flags.enabled(AGENTS_MD_REMINDER_FLAG_ID)) return; + await this.maybeAttach(ctx); + }), + ); + } + + seedKnown(paths: Iterable): void { + for (const path of paths) this.known.add(normalizePath(path)); + } + + private async maybeAttach(ctx: ToolDidExecuteContext): Promise { + // A vetoed duplicate carries a placeholder result that is swapped for the + // original's later; attaching here would discard the reminder while the + // file was already counted as suggested. + if (ctx.result.isError === true) return; + const touched = touchedPathForTool(ctx.toolCall.name, ctx.args); + if (touched === undefined) return; + + const directory = this.directoryOf(touched); + if (directory === undefined || this.probed.has(directory)) return; + this.probed.add(directory); + + const discovered = await this.probe(directory); + if (discovered.length === 0) return; + + const output = typeof ctx.result.output === 'string' ? ctx.result.output : ''; + ctx.result = { ...ctx.result, output: `${output}${buildAgentsMdReminder(discovered)}` }; + } + + /** + * Agent files between the workspace root and `directory` that were never + * injected. Claims each before returning it so concurrent calls cannot both + * report the same file. + */ + private async probe(directory: string): Promise { + const root = normalizePath(this.workspace.workDir); + if (!directory.startsWith(root)) return []; + + const found: string[] = []; + for (const dir of chainFrom(root, directory)) { + for (const name of AGENT_FILE_NAMES) { + const candidate = `${dir}/${name}`; + if (this.known.has(candidate)) continue; + let text: string; + try { + text = await this.fs.readText(candidate); + } catch { + continue; + } + // Claim before reporting: a blank file is still claimed so it is not + // re-read on every touch. + this.known.add(candidate); + if (text.trim().length > 0) found.push(candidate); + break; + } + } + return found; + } + + private directoryOf(touched: string): string | undefined { + const path = normalizePath( + touched.startsWith('/') ? touched : `${this.workspace.workDir}/${touched}`, + ); + // A trailing segment with a dot is treated as a file; anything else is + // already a directory. Wrong either way only costs one extra probe. + const lastSlash = path.lastIndexOf('/'); + if (lastSlash <= 0) return undefined; + const last = path.slice(lastSlash + 1); + return last.includes('.') ? path.slice(0, lastSlash) : path; + } +} + +function normalizePath(path: string): string { + const collapsed = path.replaceAll('\\', '/').replaceAll(/\/+/g, '/'); + return collapsed.length > 1 && collapsed.endsWith('/') ? collapsed.slice(0, -1) : collapsed; +} + +/** Every directory from `root` down to `directory`, inclusive. */ +function chainFrom(root: string, directory: string): string[] { + if (directory === root) return [root]; + const rest = directory.slice(root.length).split('/').filter((part) => part.length > 0); + const chain = [root]; + let current = root; + for (const part of rest) { + current = `${current}/${part}`; + chain.push(current); + } + return chain; +} + +registerScopedService( + LifecycleScope.Agent, + IAgentAgentsMdReminderService, + AgentAgentsMdReminderService, + ScopeActivation.OnScopeCreated, + 'agentsMdReminder', +); diff --git a/packages/agent-core-v2/src/agent/agentsMdReminder/flag.ts b/packages/agent-core-v2/src/agent/agentsMdReminder/flag.ts new file mode 100644 index 00000000000..042175d6f15 --- /dev/null +++ b/packages/agent-core-v2/src/agent/agentsMdReminder/flag.ts @@ -0,0 +1,24 @@ +/** + * `agentsMdReminder` domain (L4) — feature control. + * + * Off by default: it adds a filesystem probe per tool call that reaches a new + * directory, and appends text to tool results. Both are worth opting into + * rather than imposing. + */ + +import { type FlagDefinitionInput, registerFlagDefinition } from '#/app/flag/flagRegistry'; + +export const AGENTS_MD_REMINDER_FLAG_ID = 'agents-md-reminder'; +export const AGENTS_MD_REMINDER_FLAG_ENV = 'ECHADRON_EXPERIMENTAL_AGENTS_MD_REMINDER'; + +export const agentsMdReminderFlag: FlagDefinitionInput = { + id: AGENTS_MD_REMINDER_FLAG_ID, + title: 'AGENTS.md discovery reminder', + description: + 'When a tool reaches a directory whose AGENTS.md was not part of the loaded instructions, suggest reading it — once per file.', + env: AGENTS_MD_REMINDER_FLAG_ENV, + default: false, + surface: 'core', +}; + +registerFlagDefinition(agentsMdReminderFlag); diff --git a/packages/agent-core-v2/src/agent/agentsMdReminder/touchedPaths.ts b/packages/agent-core-v2/src/agent/agentsMdReminder/touchedPaths.ts new file mode 100644 index 00000000000..ac4777e6af4 --- /dev/null +++ b/packages/agent-core-v2/src/agent/agentsMdReminder/touchedPaths.ts @@ -0,0 +1,39 @@ +/** + * `agentsMdReminder` domain (L4) — which directory a tool call touched. + * + * Pure argument inspection, deliberately conservative: a wrong answer costs a + * pointless probe or a missed reminder, so anything ambiguous returns nothing + * rather than guessing. Bash is handled by its own module, since extracting + * operands from a command line needs the parser. + */ + +/** Tools whose first path-like argument names the file or directory touched. */ +const PATH_ARG_TOOLS = new Set(['Read', 'Edit', 'Write', 'ReadMediaFile']); +/** Tools that search under a root. */ +const SEARCH_ROOT_TOOLS = new Set(['Glob', 'Grep']); + +function firstString(args: unknown, keys: readonly string[]): string | undefined { + if (args === null || typeof args !== 'object') return undefined; + const record = args as Record; + for (const key of keys) { + const value = record[key]; + if (typeof value === 'string' && value.trim().length > 0) return value; + } + return undefined; +} + +/** + * The path a tool call reached for, or undefined when the tool does not touch + * the filesystem or the argument is not a plain path. + */ +export function touchedPathForTool(toolName: string, args: unknown): string | undefined { + if (PATH_ARG_TOOLS.has(toolName)) { + return firstString(args, ['path', 'file_path', 'filePath']); + } + if (SEARCH_ROOT_TOOLS.has(toolName)) { + // `path` is the search root; a bare pattern with no root stays in cwd, + // which the initial load already covered. + return firstString(args, ['path', 'dir', 'directory']); + } + return undefined; +} diff --git a/packages/agent-core-v2/test/agent/agentsMdReminder/agentsMdReminder.test.ts b/packages/agent-core-v2/test/agent/agentsMdReminder/agentsMdReminder.test.ts new file mode 100644 index 00000000000..9041e81b7da --- /dev/null +++ b/packages/agent-core-v2/test/agent/agentsMdReminder/agentsMdReminder.test.ts @@ -0,0 +1,101 @@ +import { describe, expect, it, vi } from 'vitest'; + +import { + AgentAgentsMdReminderService, + buildAgentsMdReminder, +} from '#/agent/agentsMdReminder/agentsMdReminderService'; +import { AGENTS_MD_REMINDER_FLAG_ID } from '#/agent/agentsMdReminder/flag'; +import { touchedPathForTool } from '#/agent/agentsMdReminder/touchedPaths'; + +const ROOT = '/repo'; + +function harness(files: Record, enabled = true) { + let hook: ((ctx: any, next: () => Promise) => Promise) | undefined; + const toolExecutor = { + hooks: { + onDidExecuteTool: { + register: (_name: string, fn: typeof hook) => { + hook = fn; + return { dispose: () => {} }; + }, + }, + }, + } as never; + const fs = { + readText: vi.fn(async (path: string) => { + const value = files[path]; + if (value === undefined) throw new Error('ENOENT'); + return value; + }), + } as never; + const service = new AgentAgentsMdReminderService( + toolExecutor, + fs, + { workDir: ROOT } as never, + { enabled: () => enabled } as never, + ); + const run = async (toolName: string, args: unknown, output = 'ok', isError = false) => { + const ctx = { + toolCall: { name: toolName }, + args, + result: { isError, output }, + }; + await hook?.(ctx, async () => {}); + return ctx.result.output as string; + }; + return { service, run, fs }; +} + +describe('AGENTS.md discovery reminder', () => { + it('suggests an agent file the loaded instructions never covered', async () => { + const { run } = harness({ '/repo/pkg/AGENTS.md': '# rules' }); + const output = await run('Read', { path: '/repo/pkg/thing.ts' }); + expect(output).toContain(buildAgentsMdReminder(['/repo/pkg/AGENTS.md'])); + }); + + it('never suggests a file that was already injected', async () => { + const { run, service } = harness({ '/repo/pkg/AGENTS.md': '# rules' }); + service.seedKnown(['/repo/pkg/AGENTS.md']); + expect(await run('Read', { path: '/repo/pkg/thing.ts' })).toBe('ok'); + }); + + it('suggests each file at most once', async () => { + const { run } = harness({ '/repo/pkg/AGENTS.md': '# rules' }); + expect(await run('Read', { path: '/repo/pkg/a.ts' })).toContain('AGENTS.md'); + expect(await run('Read', { path: '/repo/pkg/b.ts' })).toBe('ok'); + }); + + it('ignores a blank agent file but does not re-read it', async () => { + const { run, fs } = harness({ '/repo/pkg/AGENTS.md': ' \n' }); + expect(await run('Read', { path: '/repo/pkg/a.ts' })).toBe('ok'); + const callsAfterFirst = (fs as unknown as { readText: { mock: { calls: unknown[] } } }).readText + .mock.calls.length; + await run('Read', { path: '/repo/pkg/b.ts' }); + expect( + (fs as unknown as { readText: { mock: { calls: unknown[] } } }).readText.mock.calls.length, + ).toBe(callsAfterFirst); + }); + + it('stays silent when the control is off', async () => { + const { run } = harness({ '/repo/pkg/AGENTS.md': '# rules' }, false); + expect(await run('Read', { path: '/repo/pkg/thing.ts' })).toBe('ok'); + }); + + it('leaves a failed tool result alone', async () => { + // A vetoed duplicate carries a placeholder result that is swapped for the + // original's later, so attaching here would discard the reminder while the + // file was already counted as suggested. + const { run } = harness({ '/repo/pkg/AGENTS.md': '# rules' }); + expect(await run('Read', { path: '/repo/pkg/a.ts' }, 'boom', true)).toBe('boom'); + // Still eligible afterwards, since it was never claimed. + expect(await run('Read', { path: '/repo/pkg/a.ts' })).toContain('AGENTS.md'); + }); + + it('reads the touched path out of each tool shape', () => { + expect(touchedPathForTool('Read', { path: 'a/b.ts' })).toBe('a/b.ts'); + expect(touchedPathForTool('Edit', { file_path: 'a/b.ts' })).toBe('a/b.ts'); + expect(touchedPathForTool('Grep', { path: 'src' })).toBe('src'); + expect(touchedPathForTool('Grep', { pattern: 'x' })).toBeUndefined(); + expect(touchedPathForTool('WebSearch', { query: 'x' })).toBeUndefined(); + }); +});