Skip to content
This repository was archived by the owner on Aug 25, 2026. It is now read-only.
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions .changeset/agents-md-reminder.md
Original file line number Diff line number Diff line change
@@ -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`.
Original file line number Diff line number Diff line change
@@ -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<string>): void;
}

export const IAgentAgentsMdReminderService: ServiceIdentifier<IAgentAgentsMdReminderService> =
createDecorator<IAgentAgentsMdReminderService>('agentAgentsMdReminderService');
Original file line number Diff line number Diff line change
@@ -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<system-reminder>\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</system-reminder>'
);
}

export class AgentAgentsMdReminderService
extends Disposable
implements IAgentAgentsMdReminderService
{
declare readonly _serviceBrand: undefined;

/** Agent files already injected, or already suggested once. */
private readonly known = new Set<string>();
/** Directories already probed, so a repeated touch costs no filesystem work. */
private readonly probed = new Set<string>();

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<string>): void {
for (const path of paths) this.known.add(normalizePath(path));
}

private async maybeAttach(ctx: ToolDidExecuteContext): Promise<void> {
// 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<string[]> {
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',
);
24 changes: 24 additions & 0 deletions packages/agent-core-v2/src/agent/agentsMdReminder/flag.ts
Original file line number Diff line number Diff line change
@@ -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);
39 changes: 39 additions & 0 deletions packages/agent-core-v2/src/agent/agentsMdReminder/touchedPaths.ts
Original file line number Diff line number Diff line change
@@ -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<string, unknown>;
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;
}
Original file line number Diff line number Diff line change
@@ -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<string, string>, enabled = true) {
let hook: ((ctx: any, next: () => Promise<void>) => Promise<void>) | 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();
});
});
Loading