Skip to content

Commit 95de61e

Browse files
committed
shared agents.md behavior
1 parent 3d128ee commit 95de61e

4 files changed

Lines changed: 157 additions & 62 deletions

File tree

pi/extensions/agents-md.ts

Lines changed: 83 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,83 @@
1+
import { existsSync, readFileSync } from "node:fs";
2+
import { dirname, isAbsolute, join, relative, resolve, sep } from "node:path";
3+
import { isReadToolResult, type ExtensionAPI } from "@earendil-works/pi-coding-agent";
4+
5+
const LOCAL_FILENAME = "AGENTS.local.md";
6+
const SUBDIRECTORY_FILENAME = "AGENTS.md";
7+
8+
// Walk from cwd up to the filesystem root, collecting AGENTS.local.md files.
9+
// Ordered root-first, same as pi's own AGENTS.md discovery, so nearer files
10+
// take precedence when read top-to-bottom.
11+
export function findLocalAgentsFiles(cwd: string, projectTrusted: boolean): string[] {
12+
if (!projectTrusted) return [];
13+
14+
const found: string[] = [];
15+
let dir = cwd;
16+
while (true) {
17+
const candidate = join(dir, LOCAL_FILENAME);
18+
if (existsSync(candidate)) found.push(candidate);
19+
const parent = dirname(dir);
20+
if (parent === dir) break;
21+
dir = parent;
22+
}
23+
return found.reverse();
24+
}
25+
26+
export function findSubdirectoryAgentsFiles(cwd: string, readPath: string): string[] {
27+
const root = resolve(cwd);
28+
const target = resolve(root, readPath);
29+
const pathFromRoot = relative(root, target);
30+
if (pathFromRoot === ".." || pathFromRoot.startsWith(`..${sep}`) || isAbsolute(pathFromRoot)) return [];
31+
32+
const found: string[] = [];
33+
let dir = dirname(target);
34+
while (dir !== root) {
35+
const candidate = join(dir, SUBDIRECTORY_FILENAME);
36+
if (candidate !== target && existsSync(candidate)) found.push(candidate);
37+
const parent = dirname(dir);
38+
if (parent === dir) break;
39+
dir = parent;
40+
}
41+
return found.reverse();
42+
}
43+
44+
export default function localAgentsMdExtension(pi: ExtensionAPI): void {
45+
const loaded = new Set<string>();
46+
47+
pi.on("session_start", () => loaded.clear());
48+
49+
pi.on("before_agent_start", async (event, ctx) => {
50+
const files = findLocalAgentsFiles(ctx.cwd, ctx.isProjectTrusted());
51+
if (files.length === 0) return;
52+
53+
const blocks = files.map((path) => {
54+
const content = readFileSync(path, "utf8").trim();
55+
return `<local_instructions path="${path}">\n${content}\n</local_instructions>`;
56+
});
57+
58+
return {
59+
systemPrompt: `${event.systemPrompt}\n\n${blocks.join("\n\n")}`,
60+
};
61+
});
62+
63+
pi.on("tool_result", (event, ctx) => {
64+
if (!isReadToolResult(event) || event.isError || !ctx.isProjectTrusted()) return;
65+
const readPath = event.input.path;
66+
if (typeof readPath !== "string") return;
67+
68+
const files = findSubdirectoryAgentsFiles(ctx.cwd, readPath).filter((path) => !loaded.has(path));
69+
const blocks: string[] = [];
70+
for (const path of files) {
71+
try {
72+
const content = readFileSync(path, "utf8").trim();
73+
loaded.add(path);
74+
blocks.push(`<subdirectory_instructions path="${path}">\n${content}\n</subdirectory_instructions>`);
75+
} catch {}
76+
}
77+
if (blocks.length === 0) return;
78+
79+
return {
80+
content: [...event.content, { type: "text", text: `\n\n${blocks.join("\n\n")}` }],
81+
};
82+
});
83+
}

pi/extensions/local-agents-md.ts

Lines changed: 0 additions & 39 deletions
This file was deleted.
Lines changed: 74 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,74 @@
1+
import assert from "node:assert/strict";
2+
import { mkdtempSync, mkdirSync, rmSync, writeFileSync } from "node:fs";
3+
import { tmpdir } from "node:os";
4+
import { join } from "node:path";
5+
import test from "node:test";
6+
import type { ExtensionAPI, ExtensionContext, ToolResultEvent } from "@earendil-works/pi-coding-agent";
7+
import localAgentsMdExtension, { findLocalAgentsFiles, findSubdirectoryAgentsFiles } from "../agents-md.ts";
8+
9+
test("does not discover project-local instructions in an untrusted project", () => {
10+
const root = mkdtempSync(join(tmpdir(), "local-agents-"));
11+
try {
12+
const nested = join(root, "project", "nested");
13+
mkdirSync(nested, { recursive: true });
14+
const parentFile = join(root, "project", "AGENTS.local.md");
15+
const childFile = join(nested, "AGENTS.local.md");
16+
writeFileSync(parentFile, "parent");
17+
writeFileSync(childFile, "child");
18+
19+
assert.deepEqual(findLocalAgentsFiles(nested, true), [parentFile, childFile]);
20+
assert.deepEqual(findLocalAgentsFiles(nested, false), []);
21+
} finally {
22+
rmSync(root, { recursive: true, force: true });
23+
}
24+
});
25+
26+
test("discovers AGENTS.md files between the project root and a read file", () => {
27+
const root = mkdtempSync(join(tmpdir(), "subdirectory-agents-"));
28+
try {
29+
const sourceDir = join(root, "nested", "child");
30+
mkdirSync(sourceDir, { recursive: true });
31+
const parentFile = join(root, "nested", "AGENTS.md");
32+
const childFile = join(sourceDir, "AGENTS.md");
33+
writeFileSync(parentFile, "parent");
34+
writeFileSync(childFile, "child");
35+
36+
assert.deepEqual(findSubdirectoryAgentsFiles(root, join(sourceDir, "source.ts")), [parentFile, childFile]);
37+
assert.deepEqual(findSubdirectoryAgentsFiles(root, join(root, "outside.ts")), []);
38+
} finally {
39+
rmSync(root, { recursive: true, force: true });
40+
}
41+
});
42+
43+
test("adds newly discovered instructions to a read result once", () => {
44+
const root = mkdtempSync(join(tmpdir(), "subdirectory-agents-"));
45+
try {
46+
const sourceDir = join(root, "nested");
47+
mkdirSync(sourceDir);
48+
writeFileSync(join(sourceDir, "AGENTS.md"), "Use bun test.");
49+
50+
let handler: ((event: ToolResultEvent, ctx: ExtensionContext) => unknown) | undefined;
51+
localAgentsMdExtension({
52+
on(event: string, callback: unknown) {
53+
if (event === "tool_result") handler = callback as typeof handler;
54+
},
55+
} as ExtensionAPI);
56+
const event = {
57+
type: "tool_result",
58+
toolCallId: "test",
59+
toolName: "read",
60+
input: { path: join(sourceDir, "source.ts") },
61+
content: [{ type: "text", text: "source" }],
62+
details: undefined,
63+
isError: false,
64+
} as ToolResultEvent;
65+
const ctx = { cwd: root, isProjectTrusted: () => true } as ExtensionContext;
66+
67+
assert.ok(handler);
68+
const result = handler(event, ctx) as { content: Array<{ type: "text"; text: string }> };
69+
assert.match(result.content.at(-1)?.text ?? "", /Use bun test/);
70+
assert.equal(handler(event, ctx), undefined);
71+
} finally {
72+
rmSync(root, { recursive: true, force: true });
73+
}
74+
});

pi/extensions/test/local-agents-md.test.ts

Lines changed: 0 additions & 23 deletions
This file was deleted.

0 commit comments

Comments
 (0)