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:
@@ -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<AgentRunResult> {
|
||||
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.`,
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@@ -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 `<cwd>/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<Map<string, AgentDef>> {
|
||||
const dir = join(extensionRoot(), "agents");
|
||||
const extRoot = extensionRoot();
|
||||
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[];
|
||||
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;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user