feat(agents): project-local agent overrides
loadAgents merges <cwd>/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.
This commit is contained in:
@@ -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
|
`cwd` (see `src/agent-runner.ts`), with the agent definition's system prompt
|
||||||
and tool allowlist applied. Agent definitions are plain editable markdown in
|
and tool allowlist applied. Agent definitions are plain editable markdown in
|
||||||
[`agents/`](./agents/) (frontmatter `name` + `allowedTools`, body = system
|
[`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 `<cwd>/.pygienium/run-state.json`
|
- **Run-state** is a single JSON file at `<cwd>/.pygienium/run-state.json`
|
||||||
(`src/run-state.ts`): per-check phase progress, captured findings/changes
|
(`src/run-state.ts`): per-check phase progress, captured findings/changes
|
||||||
text, and recon status. `/pygienium-status`, `/pygienium-resume`, and
|
text, and recon status. `/pygienium-status`, `/pygienium-resume`, and
|
||||||
|
|||||||
@@ -18,6 +18,7 @@
|
|||||||
import { mkdir, writeFile } from "node:fs/promises";
|
import { mkdir, writeFile } from "node:fs/promises";
|
||||||
import { dirname, isAbsolute, join } from "node:path";
|
import { dirname, isAbsolute, join } from "node:path";
|
||||||
import type { AgentSessionEvent } from "@earendil-works/pi-coding-agent";
|
import type { AgentSessionEvent } from "@earendil-works/pi-coding-agent";
|
||||||
|
import { loadAgents, extensionRoot, type AgentDef } from "./agents.js";
|
||||||
import type { ToolCallEntry } from "./phases.js";
|
import type { ToolCallEntry } from "./phases.js";
|
||||||
|
|
||||||
export interface AgentTaskOptions {
|
export interface AgentTaskOptions {
|
||||||
@@ -74,13 +75,19 @@ export function resetAgentRunner(): void {
|
|||||||
export async function defaultAgentRunner(
|
export async function defaultAgentRunner(
|
||||||
opts: AgentTaskOptions,
|
opts: AgentTaskOptions,
|
||||||
): Promise<AgentRunResult> {
|
): Promise<AgentRunResult> {
|
||||||
const agent =
|
const agents = await loadAgents({ cwd: opts.cwd });
|
||||||
opts.agent ?? (await loadAgents({ cwd: opts.cwd })).get(opts.agentName);
|
const agent = opts.agent ?? agents.get(opts.agentName);
|
||||||
if (!agent) {
|
if (!agent) {
|
||||||
|
const names = [...agents.keys()];
|
||||||
return {
|
return {
|
||||||
ok: false,
|
ok: false,
|
||||||
text: "",
|
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.`,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -117,24 +117,56 @@ function asString(value: unknown): string | undefined {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Load every `agents/*.md` definition from the extension's `agents/` dir.
|
* Load agent definitions: the extension's `agents/*.md` baseline, plus any
|
||||||
* Returns a map keyed by agent name. Missing directory → empty map (so an
|
* repo-local `agents/*.md` at `<cwd>/agents/` (when `cwd` is given). Repo
|
||||||
* extension without shipped agents still boots, e.g. in tests).
|
* 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;
|
cwd?: string;
|
||||||
}): Promise<Map<string, AgentDef>> {
|
}): Promise<Map<string, AgentDef>> {
|
||||||
const dir = join(extensionRoot(), "agents");
|
const extRoot = extensionRoot();
|
||||||
const result = new Map<string, AgentDef>();
|
const result = new Map<string, AgentDef>();
|
||||||
|
|
||||||
|
// 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: `<cwd>/agents/`
|
||||||
|
* legitimately doesn't exist in most scanned projects.
|
||||||
|
*/
|
||||||
|
async function scanAgentDir(
|
||||||
|
dir: string,
|
||||||
|
result: Map<string, AgentDef>,
|
||||||
|
repoDir = false,
|
||||||
|
): Promise<void> {
|
||||||
let entries: string[];
|
let entries: string[];
|
||||||
try {
|
try {
|
||||||
entries = await readdir(dir);
|
entries = await readdir(dir);
|
||||||
} catch {
|
} catch (err) {
|
||||||
return result;
|
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) {
|
for (const entry of entries) {
|
||||||
if (!entry.endsWith(".md")) continue;
|
if (!entry.endsWith(".md")) continue;
|
||||||
const sourcePath = join(dir, entry);
|
const sourcePath = join(dir, entry);
|
||||||
|
try {
|
||||||
const raw = await readFile(sourcePath, "utf8");
|
const raw = await readFile(sourcePath, "utf8");
|
||||||
const { frontmatter, body } = parseFrontmatter(raw);
|
const { frontmatter, body } = parseFrontmatter(raw);
|
||||||
const name = asString(frontmatter.name) ?? entry.slice(0, -".md".length);
|
const name = asString(frontmatter.name) ?? entry.slice(0, -".md".length);
|
||||||
@@ -145,6 +177,17 @@ export async function loadAgents(_opts?: {
|
|||||||
allowedTools,
|
allowedTools,
|
||||||
sourcePath,
|
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;
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -2,8 +2,29 @@
|
|||||||
* agents.test.ts — markdown agent-definition loader.
|
* agents.test.ts — markdown agent-definition loader.
|
||||||
*/
|
*/
|
||||||
import { describe, expect, it } from "bun:test";
|
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";
|
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<string> {
|
||||||
|
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", () => {
|
describe("loadAgents", () => {
|
||||||
it("loads scanner.md and fixer.md shipped with the extension", async () => {
|
it("loads scanner.md and fixer.md shipped with the extension", async () => {
|
||||||
const agents = await loadAgents();
|
const agents = await loadAgents();
|
||||||
@@ -20,6 +41,58 @@ describe("loadAgents", () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
it("extensionRoot resolves to the package directory", () => {
|
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 });
|
||||||
|
}
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
Reference in New Issue
Block a user