feat(pi-workflow): reuse standard agent profiles
This commit is contained in:
@@ -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(
|
||||
|
||||
Reference in New Issue
Block a user