From cb8a88d0bd90c68b9070e5698c88f23aae0c206e Mon Sep 17 00:00:00 2001 From: Michael Freno Date: Sun, 9 Aug 2026 16:45:30 -0400 Subject: [PATCH] feat(agents): project-local agent overrides loadAgents merges /agents/*.md on top of the extension's baseline: a repo agent wins on name collision and may add brand-new agents. The unknown-agent error now names both searched directories. --- README.md | 5 ++- src/agent-runner.ts | 13 ++++++-- src/agents.ts | 79 ++++++++++++++++++++++++++++++++++---------- tests/agents.test.ts | 75 ++++++++++++++++++++++++++++++++++++++++- 4 files changed, 149 insertions(+), 23 deletions(-) diff --git a/README.md b/README.md index d696617..6df84a0 100644 --- a/README.md +++ b/README.md @@ -137,7 +137,10 @@ registerCheck(def) ← checks/*.ts self-register on load `cwd` (see `src/agent-runner.ts`), with the agent definition's system prompt and tool allowlist applied. Agent definitions are plain editable markdown in [`agents/`](./agents/) (frontmatter `name` + `allowedTools`, body = system - prompt) — tuning a sub-agent never needs TypeScript changes. + prompt) — tuning a sub-agent never needs TypeScript changes. A scanned + project can ship its own `agents/*.md` at its root: those are loaded as + overrides (repo agent wins on name collision), so teams can tune prompts or + add project-specific agents without touching the extension. - **Run-state** is a single JSON file at `/.pygienium/run-state.json` (`src/run-state.ts`): per-check phase progress, captured findings/changes text, and recon status. `/pygienium-status`, `/pygienium-resume`, and diff --git a/src/agent-runner.ts b/src/agent-runner.ts index e66f531..da2e9f3 100644 --- a/src/agent-runner.ts +++ b/src/agent-runner.ts @@ -18,6 +18,7 @@ import { mkdir, writeFile } from "node:fs/promises"; import { dirname, isAbsolute, join } from "node:path"; import type { AgentSessionEvent } from "@earendil-works/pi-coding-agent"; +import { loadAgents, extensionRoot, type AgentDef } from "./agents.js"; import type { ToolCallEntry } from "./phases.js"; export interface AgentTaskOptions { @@ -74,13 +75,19 @@ export function resetAgentRunner(): void { export async function defaultAgentRunner( opts: AgentTaskOptions, ): Promise { - const agent = - opts.agent ?? (await loadAgents({ cwd: opts.cwd })).get(opts.agentName); + const agents = await loadAgents({ cwd: opts.cwd }); + const agent = opts.agent ?? agents.get(opts.agentName); if (!agent) { + const names = [...agents.keys()]; return { ok: false, text: "", - error: `Unknown agent definition: "${opts.agentName}". Add agents/${opts.agentName}.md.`, + toolCalls: [], + error: + `Unknown agent definition: "${opts.agentName}". ` + + `Available agents: ${names.length > 0 ? names.join(", ") : "(none loaded — check agents/ directories exist)"}. ` + + `Searched ${extensionRoot()}/agents/ (extension) and ${opts.cwd}/agents/ (project-local). ` + + `Add agents/${opts.agentName}.md to either location.`, }; } diff --git a/src/agents.ts b/src/agents.ts index db02563..f38a7db 100644 --- a/src/agents.ts +++ b/src/agents.ts @@ -117,34 +117,77 @@ function asString(value: unknown): string | undefined { } /** - * Load every `agents/*.md` definition from the extension's `agents/` dir. - * Returns a map keyed by agent name. Missing directory → empty map (so an - * extension without shipped agents still boots, e.g. in tests). + * Load agent definitions: the extension's `agents/*.md` baseline, plus any + * repo-local `agents/*.md` at `/agents/` (when `cwd` is given). Repo + * agents override the extension's by name, so a project can tune a sub-agent's + * prompt or tool allowlist without editing the extension. Missing dirs are + * skipped silently; a dir with entries that all fail to parse logs each + * failure and continues. */ -export async function loadAgents(_opts?: { +export async function loadAgents(opts?: { cwd?: string; }): Promise> { - const dir = join(extensionRoot(), "agents"); + const extRoot = extensionRoot(); const result = new Map(); + + // Extension-shipped agents are the baseline. + await scanAgentDir(join(extRoot, "agents"), result); + + // Repo-local overrides, applied last so they win on name collisions. + if (opts?.cwd) { + await scanAgentDir(join(opts.cwd, "agents"), result, true); + } + + return result; +} + +/** + * Load every `agents/*.md` in `dir` into `result` (later dirs win on name + * collisions). `repoDir` suppresses the missing-dir warning: `/agents/` + * legitimately doesn't exist in most scanned projects. + */ +async function scanAgentDir( + dir: string, + result: Map, + repoDir = false, +): Promise { let entries: string[]; try { entries = await readdir(dir); - } catch { - return result; + } catch (err) { + if (!repoDir) { + console.error( + `[pygienium] agent loading: could not read agents dir at ${dir}: ${err instanceof Error ? err.message : String(err)}`, + ); + } + return; } + for (const entry of entries) { if (!entry.endsWith(".md")) continue; const sourcePath = join(dir, entry); - const raw = await readFile(sourcePath, "utf8"); - const { frontmatter, body } = parseFrontmatter(raw); - const name = asString(frontmatter.name) ?? entry.slice(0, -".md".length); - const allowedTools = asStringList(frontmatter.allowedTools); - result.set(name, { - name, - systemPrompt: body, - allowedTools, - sourcePath, - }); + try { + const raw = await readFile(sourcePath, "utf8"); + const { frontmatter, body } = parseFrontmatter(raw); + const name = asString(frontmatter.name) ?? entry.slice(0, -".md".length); + const allowedTools = asStringList(frontmatter.allowedTools); + result.set(name, { + name, + systemPrompt: body, + allowedTools, + sourcePath, + }); + } catch (err) { + console.error( + `[pygienium] agent loading: failed to load ${entry} from ${dir}: ${err instanceof Error ? err.message : String(err)}`, + ); + // Continue loading other agents even if one fails. + } + } + + if (!repoDir && result.size === 0 && entries.length > 0) { + console.error( + `[pygienium] agent loading: found ${entries.length} entries in ${dir} but loaded 0 agents`, + ); } - return result; } diff --git a/tests/agents.test.ts b/tests/agents.test.ts index 34fdb68..e0bed13 100644 --- a/tests/agents.test.ts +++ b/tests/agents.test.ts @@ -2,8 +2,29 @@ * agents.test.ts — markdown agent-definition loader. */ import { describe, expect, it } from "bun:test"; +import { mkdtemp, mkdir, rm, writeFile } from "node:fs/promises"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; import { extensionRoot, loadAgents } from "../src/agents.js"; +/** Build an agent markdown file with frontmatter + body. */ +async function writeAgent( + dir: string, + file: string, + name: string, + body: string, + tools?: string[], +): Promise { + const lines = ["---", `name: ${name}`]; + if (tools) { + lines.push("allowedTools:"); + for (const t of tools) lines.push(` - ${t}`); + } + lines.push("---", body, ""); + await writeFile(join(dir, file), lines.join("\n"), "utf8"); + return join(dir, file); +} + describe("loadAgents", () => { it("loads scanner.md and fixer.md shipped with the extension", async () => { const agents = await loadAgents(); @@ -20,6 +41,58 @@ describe("loadAgents", () => { }); it("extensionRoot resolves to the package directory", () => { - expect(extensionRoot()).toMatch(/pygienium$/); + expect(extensionRoot()).toMatch(/(pygenium|pygienium)$/); + }); + + it("project-local agents override the extension's by name", async () => { + const cwd = await mkdtemp(join(tmpdir(), "pygium-agents-")); + try { + await mkdir(join(cwd, "agents")); + const path = await writeAgent( + join(cwd, "agents"), + "scanner.md", + "scanner", + "Project-tuned scanner prompt.", + ["read", "grep", "find", "edit"], + ); + const agents = await loadAgents({ cwd }); + const scanner = agents.get("scanner"); + expect(scanner?.systemPrompt).toContain("Project-tuned"); + expect(scanner?.allowedTools).toContain("edit"); // repo override widens tools + expect(scanner?.sourcePath).toBe(path); + // Extension baseline is retained for agents the repo doesn't override. + expect(agents.get("fixer")).toBeDefined(); + } finally { + await rm(cwd, { recursive: true, force: true }); + } + }); + + it("project-local agents can add brand-new agents", async () => { + const cwd = await mkdtemp(join(tmpdir(), "pygium-agents-")); + try { + await mkdir(join(cwd, "agents")); + await writeAgent( + join(cwd, "agents"), + "judge.md", + "judge", + "Scoring judge.", + ); + const agents = await loadAgents({ cwd }); + expect(agents.has("judge")).toBe(true); + expect(agents.get("judge")?.systemPrompt).toContain("Scoring judge"); + } finally { + await rm(cwd, { recursive: true, force: true }); + } + }); + + it("a project without agents/ falls back to the extension agents", async () => { + const cwd = await mkdtemp(join(tmpdir(), "pygium-agents-")); + try { + const agents = await loadAgents({ cwd }); + expect(agents.has("scanner")).toBe(true); + expect(agents.has("fixer")).toBe(true); + } finally { + await rm(cwd, { recursive: true, force: true }); + } }); });