feat(pi-workflow): reuse standard agent profiles

This commit is contained in:
jb
2026-08-26 22:51:20 -04:00
parent a4784b75c3
commit aa50f0fd16
12 changed files with 606 additions and 54 deletions
+169 -18
View File
@@ -1,6 +1,7 @@
import { createHash } from "node:crypto";
import { lstat, readdir, readFile, realpath, stat } from "node:fs/promises";
import * as path from "node:path";
import { getAgentDir } from "@earendil-works/pi-coding-agent";
import {
PROFILE_ROLES,
REASONING_EFFORTS,
@@ -11,6 +12,7 @@ import {
type ProfileDiagnostic,
type ProfileRole,
type ProfileSelection,
type StandardAgentConfig,
type WorkflowFacts,
} from "./domain.ts";
@@ -44,11 +46,16 @@ interface LoadSourceResult {
diagnostics: ProfileDiagnostic[];
}
type ProfileSourceMode = "workflow" | "standard" | "project";
export interface LoadAgentProfilesOptions {
cwd: string;
projectTrusted: boolean;
agentDir?: string;
bundledRoot?: string;
standardPackageRoot?: string;
projectRoot?: string;
standardAgents?: StandardAgentConfig;
}
function reason(error: unknown) {
@@ -219,7 +226,7 @@ function parseFrontmatter(
throw new Error(`frontmatter line ${lineIndex + 1}: expected key: value`);
const key = text.slice(0, separator).trim();
const rawValue = text.slice(separator + 1).trim();
if (!/^[a-zA-Z][a-zA-Z0-9]*$/.test(key)) {
if (!/^[a-zA-Z][a-zA-Z0-9_-]*$/.test(key)) {
throw new Error(
`frontmatter line ${lineIndex + 1}: invalid key ${JSON.stringify(key)}`,
);
@@ -341,6 +348,61 @@ function parseSelection(value: unknown) {
return { selection, warnings };
}
function buildStandardProfile(
parsed: ParsedFrontmatter,
contents: string,
sourcePath: string,
resolvedPath: string,
source: AgentProfile["source"],
fallbackName: string,
roles: ProfileRole[],
) {
const name = optionalString(parsed.values.name, "name") ?? fallbackName;
if (!PROFILE_NAME.test(name)) {
throw new Error("name must be lowercase kebab case");
}
const description =
optionalString(parsed.values.description, "description") ?? name;
const model = optionalString(parsed.values.model, "model");
const effort = optionalString(parsed.values.thinking, "thinking");
if (effort && !REASONING_EFFORTS.includes(effort as never)) {
throw new Error(`thinking must be one of ${REASONING_EFFORTS.join(", ")}`);
}
const tools =
parsed.values.tools === undefined
? undefined
: stringList(parsed.values.tools, "tools", true);
if (tools?.some((tool) => !TOOL_NAME.test(tool))) {
throw new Error("tools contains an invalid tool name");
}
if (parsed.body.length === 0) {
throw new Error("agent instructions must not be empty");
}
const selection: ProfileSelection = {
stages: [...roles],
anyTags: [],
allTags: [],
pathGlobs: [],
keywords: [],
};
const digestInput = `${contents}\nworkflow-role-overlay:${JSON.stringify(roles)}`;
const profile: AgentProfile = {
name,
description,
roles: [...roles],
...(model ? { model } : {}),
...(effort ? { effort: effort as AgentProfile["effort"] } : {}),
...(tools ? { tools } : {}),
selection,
instructions: parsed.body,
source,
sourcePath,
resolvedPath,
digest: createHash("sha256").update(digestInput).digest("hex"),
};
return { profile, warnings: [] as string[] };
}
function buildProfile(
contents: string,
sourcePath: string,
@@ -415,6 +477,8 @@ async function loadSource(
root: string,
source: AgentProfile["source"],
required: boolean,
mode: ProfileSourceMode = "workflow",
standardRoles?: Record<string, ProfileRole[]>,
): Promise<LoadSourceResult> {
const diagnostics: ProfileDiagnostic[] = [];
let resolvedRoot: string;
@@ -466,12 +530,30 @@ async function loadSource(
throw new Error("profile is not a regular file");
}
const contents = await readFile(resolvedPath, "utf8");
const { profile, warnings } = buildProfile(
contents,
sourcePath,
resolvedPath,
source,
);
const parsed = parseFrontmatter(contents, sourcePath);
const declaredName =
optionalString(parsed.values.name, "name") ??
entry.name.replace(/\.md$/, "");
const isStandardDefinition =
mode === "standard" ||
(mode === "project" && parsed.values.roles === undefined);
let built;
if (isStandardDefinition) {
const roles = standardRoles?.[declaredName];
if (!roles) continue;
built = buildStandardProfile(
parsed,
contents,
sourcePath,
resolvedPath,
source === "project" ? "standard-project" : source,
entry.name.replace(/\.md$/, ""),
roles,
);
} else {
built = buildProfile(contents, sourcePath, resolvedPath, source);
}
const { profile, warnings } = built;
for (const message of warnings) {
diagnostics.push({ sourcePath, severity: "warning", message });
}
@@ -535,23 +617,73 @@ export async function loadAgentProfiles(
const bundledRoot =
options.bundledRoot ?? path.join(import.meta.dirname, "agents");
const bundled = await loadSource(bundledRoot, "bundled", true);
const standardAgents = options.standardAgents ?? {
enabled: false,
roles: {},
} satisfies StandardAgentConfig;
const agentDir = options.agentDir ?? getAgentDir();
const standardPackageRoot =
options.standardPackageRoot ??
path.join(
agentDir,
"extensions",
"pi-interactive-subagents",
"agents",
);
const standardPackage = standardAgents.enabled
? await loadSource(
standardPackageRoot,
"standard-package",
false,
"standard",
standardAgents.roles,
)
: { profiles: [], diagnostics: [] };
const standardGlobal = standardAgents.enabled
? await loadSource(
path.join(agentDir, "agents"),
"standard-global",
false,
"standard",
standardAgents.roles,
)
: { profiles: [], diagnostics: [] };
const projectRoot = options.projectTrusted
? (options.projectRoot ?? (await findNearestProjectRoot(options.cwd)))
: undefined;
if (projectRoot) await assertProjectProfileAncestry(projectRoot);
const project = projectRoot
? await loadSource(projectRoot, "project", false)
? await loadSource(
projectRoot,
"project",
false,
"project",
standardAgents.enabled ? standardAgents.roles : undefined,
)
: { profiles: [], diagnostics: [] };
const byName = new Map(
bundled.profiles.map((profile) => [profile.name, profile]),
);
for (const profile of project.profiles) byName.set(profile.name, profile);
for (const profile of standardPackage.profiles) {
byName.set(profile.name, profile);
}
for (const profile of standardGlobal.profiles) {
byName.set(profile.name, profile);
}
for (const profile of project.profiles) {
byName.set(profile.name, profile);
}
return {
profiles: [...byName.values()].sort((left, right) =>
compareText(left.name, right.name),
),
diagnostics: [...bundled.diagnostics, ...project.diagnostics],
diagnostics: [
...bundled.diagnostics,
...standardPackage.diagnostics,
...standardGlobal.diagnostics,
...project.diagnostics,
],
};
}
@@ -637,14 +769,33 @@ export function eligibleProfiles(
facts: WorkflowFacts,
role: ProfileRole,
) {
return profiles
.map((profile) => evaluateProfileEligibility(profile, facts, role))
.sort(
(left, right) =>
Number(right.eligible) - Number(left.eligible) ||
right.specificity - left.specificity ||
compareText(left.profile.name, right.profile.name),
);
const evaluations = profiles.map((profile) =>
evaluateProfileEligibility(profile, facts, role),
);
const hasEligibleStandard = evaluations.some(
(evaluation) =>
evaluation.eligible && evaluation.profile.source.startsWith("standard-"),
);
const ranked = hasEligibleStandard
? evaluations.map((evaluation) =>
evaluation.eligible && evaluation.profile.source === "bundled"
? {
...evaluation,
eligible: false,
reasons: [
...evaluation.reasons,
`eligible standard agent available for role ${role}`,
],
}
: evaluation,
)
: evaluations;
return ranked.sort(
(left, right) =>
Number(right.eligible) - Number(left.eligible) ||
right.specificity - left.specificity ||
compareText(left.profile.name, right.profile.name),
);
}
export function selectInitialDiscoveryProfile(