feat(pi-workflow): reuse standard agent profiles
This commit is contained in:
@@ -29,6 +29,13 @@ test("configuration defaults to concurrency 8 with immutable ceiling 32", async
|
||||
});
|
||||
assert.deepEqual(config, DEFAULT_WORKFLOW_CONFIG);
|
||||
assert.equal(config.concurrency.ceiling, MAX_CONCURRENCY);
|
||||
assert.deepEqual(config.standardAgents, {
|
||||
enabled: true,
|
||||
roles: {
|
||||
scout: ["discover"],
|
||||
worker: ["implement"],
|
||||
},
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -106,6 +113,60 @@ test("untrusted project configuration is never applied", async () => {
|
||||
});
|
||||
});
|
||||
|
||||
test("trusted configuration can replace or disable standard-agent role overlays", async () => {
|
||||
await withTempDir(async (directory) => {
|
||||
const agentDir = path.join(directory, "agent");
|
||||
await writeJson(path.join(agentDir, "pi-workflow.json"), {
|
||||
standardAgents: {
|
||||
roles: {
|
||||
"codebase-analyzer": ["discover"],
|
||||
worker: ["implement", "verify"],
|
||||
},
|
||||
},
|
||||
});
|
||||
let config = await loadWorkflowConfig({
|
||||
cwd: directory,
|
||||
agentDir,
|
||||
projectTrusted: false,
|
||||
});
|
||||
assert.deepEqual(config.standardAgents.roles, {
|
||||
"codebase-analyzer": ["discover"],
|
||||
worker: ["implement", "verify"],
|
||||
});
|
||||
|
||||
await writeJson(path.join(directory, ".pi", "pi-workflow.json"), {
|
||||
standardAgents: { enabled: false },
|
||||
});
|
||||
config = await loadWorkflowConfig({
|
||||
cwd: directory,
|
||||
agentDir,
|
||||
projectTrusted: true,
|
||||
});
|
||||
assert.equal(config.standardAgents.enabled, false);
|
||||
});
|
||||
});
|
||||
|
||||
test("invalid standard-agent names and roles fail before a run starts", async () => {
|
||||
await withTempDir(async (directory) => {
|
||||
const agentDir = path.join(directory, "agent");
|
||||
await writeJson(path.join(agentDir, "pi-workflow.json"), {
|
||||
standardAgents: { roles: { Worker: ["implement"] } },
|
||||
});
|
||||
await assert.rejects(
|
||||
loadWorkflowConfig({ cwd: directory, agentDir, projectTrusted: false }),
|
||||
/lowercase kebab case/,
|
||||
);
|
||||
|
||||
await writeJson(path.join(agentDir, "pi-workflow.json"), {
|
||||
standardAgents: { roles: { worker: ["remediate"] } },
|
||||
});
|
||||
await assert.rejects(
|
||||
loadWorkflowConfig({ cwd: directory, agentDir, projectTrusted: false }),
|
||||
/unsupported role remediate/,
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
test("configuration cannot raise ceilings or activate unaudited tools", async () => {
|
||||
await withTempDir(async (directory) => {
|
||||
const agentDir = path.join(directory, "agent");
|
||||
|
||||
@@ -7,6 +7,8 @@ import {
|
||||
MAX_ASSIGNMENTS_PER_RUN,
|
||||
MAX_CONCURRENCY,
|
||||
MAX_REVIEW_CYCLES,
|
||||
PROFILE_ROLES,
|
||||
type ProfileRole,
|
||||
type WorkflowConfig,
|
||||
} from "./domain.ts";
|
||||
|
||||
@@ -15,10 +17,13 @@ const TOP_LEVEL_KEYS = new Set([
|
||||
"maxAssignmentsPerRun",
|
||||
"maxReviewCycles",
|
||||
"analysisTools",
|
||||
"standardAgents",
|
||||
"worktrees",
|
||||
]);
|
||||
const CONCURRENCY_KEYS = new Set(["default", "ceiling"]);
|
||||
const STANDARD_AGENT_KEYS = new Set(["enabled", "roles"]);
|
||||
const WORKTREE_KEYS = new Set(["root"]);
|
||||
const PROFILE_NAME = /^[a-z0-9]+(?:-[a-z0-9]+)*$/;
|
||||
|
||||
export interface LoadWorkflowConfigOptions {
|
||||
cwd: string;
|
||||
@@ -97,6 +102,15 @@ function cloneConfig(config: WorkflowConfig): WorkflowConfig {
|
||||
maxAssignmentsPerRun: config.maxAssignmentsPerRun,
|
||||
maxReviewCycles: config.maxReviewCycles,
|
||||
analysisTools: [...config.analysisTools],
|
||||
standardAgents: {
|
||||
enabled: config.standardAgents.enabled,
|
||||
roles: Object.fromEntries(
|
||||
Object.entries(config.standardAgents.roles).map(([name, roles]) => [
|
||||
name,
|
||||
[...roles],
|
||||
]),
|
||||
),
|
||||
},
|
||||
worktrees: { ...config.worktrees },
|
||||
};
|
||||
}
|
||||
@@ -192,6 +206,57 @@ function applyConfigLayer(
|
||||
);
|
||||
}
|
||||
|
||||
if (value.standardAgents !== undefined) {
|
||||
if (!isRecord(value.standardAgents)) {
|
||||
throw new Error(`${label}.standardAgents must be an object`);
|
||||
}
|
||||
assertKnownKeys(
|
||||
value.standardAgents,
|
||||
STANDARD_AGENT_KEYS,
|
||||
`${label}.standardAgents`,
|
||||
);
|
||||
if (value.standardAgents.enabled !== undefined) {
|
||||
if (typeof value.standardAgents.enabled !== "boolean") {
|
||||
throw new Error(`${label}.standardAgents.enabled must be a boolean`);
|
||||
}
|
||||
next.standardAgents.enabled = value.standardAgents.enabled;
|
||||
}
|
||||
if (value.standardAgents.roles !== undefined) {
|
||||
if (!isRecord(value.standardAgents.roles)) {
|
||||
throw new Error(`${label}.standardAgents.roles must be an object`);
|
||||
}
|
||||
const roles: Record<string, ProfileRole[]> = {};
|
||||
for (const [name, rawRoles] of Object.entries(
|
||||
value.standardAgents.roles,
|
||||
)) {
|
||||
if (!PROFILE_NAME.test(name)) {
|
||||
throw new Error(
|
||||
`${label}.standardAgents.roles agent names must be lowercase kebab case`,
|
||||
);
|
||||
}
|
||||
const requested = stringArray(
|
||||
rawRoles,
|
||||
`${label}.standardAgents.roles.${name}`,
|
||||
);
|
||||
if (requested.length === 0) {
|
||||
throw new Error(
|
||||
`${label}.standardAgents.roles.${name} must not be empty`,
|
||||
);
|
||||
}
|
||||
const invalid = requested.filter(
|
||||
(role) => !PROFILE_ROLES.includes(role as ProfileRole),
|
||||
);
|
||||
if (invalid.length > 0) {
|
||||
throw new Error(
|
||||
`${label}.standardAgents.roles.${name} contains unsupported role ${invalid.join(", ")}`,
|
||||
);
|
||||
}
|
||||
roles[name] = requested as ProfileRole[];
|
||||
}
|
||||
next.standardAgents.roles = roles;
|
||||
}
|
||||
}
|
||||
|
||||
if (value.worktrees !== undefined) {
|
||||
if (!isRecord(value.worktrees)) {
|
||||
throw new Error(`${label}.worktrees must be an object`);
|
||||
|
||||
@@ -46,7 +46,12 @@ export interface AgentProfile {
|
||||
tools?: string[];
|
||||
selection: ProfileSelection;
|
||||
instructions: string;
|
||||
source: "bundled" | "project";
|
||||
source:
|
||||
| "bundled"
|
||||
| "project"
|
||||
| "standard-package"
|
||||
| "standard-global"
|
||||
| "standard-project";
|
||||
sourcePath: string;
|
||||
resolvedPath: string;
|
||||
digest: string;
|
||||
@@ -446,6 +451,11 @@ export const MAX_CONCURRENCY = 32;
|
||||
export const MAX_ASSIGNMENTS_PER_RUN = 1_024;
|
||||
export const MAX_REVIEW_CYCLES = 20;
|
||||
|
||||
export interface StandardAgentConfig {
|
||||
enabled: boolean;
|
||||
roles: Record<string, ProfileRole[]>;
|
||||
}
|
||||
|
||||
export interface WorkflowConfig {
|
||||
concurrency: {
|
||||
default: number;
|
||||
@@ -454,6 +464,7 @@ export interface WorkflowConfig {
|
||||
maxAssignmentsPerRun: number;
|
||||
maxReviewCycles: number;
|
||||
analysisTools: string[];
|
||||
standardAgents: StandardAgentConfig;
|
||||
worktrees: {
|
||||
root: string;
|
||||
};
|
||||
@@ -467,6 +478,13 @@ export const DEFAULT_WORKFLOW_CONFIG: WorkflowConfig = {
|
||||
maxAssignmentsPerRun: 128,
|
||||
maxReviewCycles: 3,
|
||||
analysisTools: [...ANALYSIS_TOOL_ALLOWLIST],
|
||||
standardAgents: {
|
||||
enabled: true,
|
||||
roles: {
|
||||
scout: ["discover"],
|
||||
worker: ["implement"],
|
||||
},
|
||||
},
|
||||
worktrees: {
|
||||
root: ".worktrees",
|
||||
},
|
||||
|
||||
@@ -415,6 +415,12 @@ test("mutating children receive guarded file tools without process execution", a
|
||||
).profiles;
|
||||
const profile = profiles.find((candidate) => candidate.roles.includes("implement"));
|
||||
assert.ok(profile);
|
||||
const standardWorker = {
|
||||
...profile,
|
||||
name: "worker",
|
||||
source: "standard-package" as const,
|
||||
tools: [...(profile.tools ?? []), "bash", "subagent"],
|
||||
};
|
||||
const inherited = model("provider-a", "parent");
|
||||
const executor = new PiAssignmentExecutor({
|
||||
modelRegistry: registry([inherited]),
|
||||
@@ -476,7 +482,7 @@ test("mutating children receive guarded file tools without process execution", a
|
||||
runId: "run-mutation",
|
||||
role: "design",
|
||||
request: "Generate an owned file",
|
||||
profile,
|
||||
profile: standardWorker,
|
||||
facts: {
|
||||
stage: "design",
|
||||
taskKind: "feature",
|
||||
|
||||
@@ -61,6 +61,7 @@ export function createWorkflowRuntime(
|
||||
const catalog = await loadAgentProfiles({
|
||||
cwd: ctx.cwd,
|
||||
projectTrusted: ctx.isProjectTrusted(),
|
||||
standardAgents: config.standardAgents,
|
||||
});
|
||||
const executor = new PiAssignmentExecutor({
|
||||
modelRegistry: ctx.modelRegistry,
|
||||
|
||||
@@ -30,6 +30,17 @@ function profileDocument(options: {
|
||||
return `---\nname: ${options.name}\ndescription: ${options.description ?? options.name}\nroles: ${options.roles ?? "[discover]"}\n${options.extra ?? ""}selection:\n${options.selection ?? " stages: [discover]\n anyTags: []\n allTags: []\n pathGlobs: []\n keywords: []\n"}---\n\n${options.body ?? "Follow the assignment."}\n`;
|
||||
}
|
||||
|
||||
function standardAgentDocument(options: {
|
||||
name: string;
|
||||
description?: string;
|
||||
model?: string;
|
||||
tools?: string;
|
||||
thinking?: string;
|
||||
body?: string;
|
||||
}) {
|
||||
return `---\nname: ${options.name}\ndescription: ${options.description ?? options.name}\n${options.model ? `model: ${options.model}\n` : ""}${options.tools ? `tools: ${options.tools}\n` : ""}${options.thinking ? `thinking: ${options.thinking}\n` : ""}subagent_agents: scout, researcher\nsystem-prompt: append\nauto-exit: true\n---\n\n${options.body ?? "Follow the standard assignment."}\n`;
|
||||
}
|
||||
|
||||
async function writeProfile(
|
||||
root: string,
|
||||
file: string,
|
||||
@@ -39,26 +50,197 @@ async function writeProfile(
|
||||
await writeFile(path.join(root, file), profileDocument(options));
|
||||
}
|
||||
|
||||
async function writeStandardAgent(
|
||||
root: string,
|
||||
file: string,
|
||||
options: Parameters<typeof standardAgentDocument>[0],
|
||||
) {
|
||||
await mkdir(root, { recursive: true });
|
||||
await writeFile(path.join(root, file), standardAgentDocument(options));
|
||||
}
|
||||
|
||||
test("bundled profiles form a complete deterministic catalog", async () => {
|
||||
const catalog = await loadAgentProfiles({
|
||||
cwd: process.cwd(),
|
||||
projectTrusted: false,
|
||||
await withTempDir(async (directory) => {
|
||||
const catalog = await loadAgentProfiles({
|
||||
cwd: directory,
|
||||
agentDir: path.join(directory, "agent"),
|
||||
projectTrusted: false,
|
||||
});
|
||||
assert.deepEqual(
|
||||
catalog.profiles.map((profile) => profile.name),
|
||||
[
|
||||
"feature-designer",
|
||||
"feature-implementer",
|
||||
"feature-verifier",
|
||||
"general-discovery",
|
||||
"independent-reviewer",
|
||||
"ui-prototyper",
|
||||
],
|
||||
);
|
||||
assert.equal(catalog.diagnostics.length, 0);
|
||||
assert.ok(
|
||||
catalog.profiles.every((profile) => /^[a-f0-9]{64}$/.test(profile.digest)),
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
test("standard package, global, and trusted project agents use role overlays and precedence", async () => {
|
||||
await withTempDir(async (directory) => {
|
||||
const bundled = path.join(directory, "bundled");
|
||||
const packageRoot = path.join(directory, "package-agents");
|
||||
const agentDir = path.join(directory, "agent");
|
||||
const globalRoot = path.join(agentDir, "agents");
|
||||
const projectRoot = path.join(directory, ".pi", "agents");
|
||||
await writeProfile(bundled, "general.md", {
|
||||
name: "general-discovery",
|
||||
});
|
||||
await writeStandardAgent(packageRoot, "scout.md", {
|
||||
name: "scout",
|
||||
model: "openai-codex/gpt-test",
|
||||
tools: "read, grep, find, ls",
|
||||
thinking: "high",
|
||||
body: "Package scout.",
|
||||
});
|
||||
await writeStandardAgent(packageRoot, "worker.md", {
|
||||
name: "worker",
|
||||
tools: "read, write, edit, bash",
|
||||
body: "Package worker.",
|
||||
});
|
||||
await writeStandardAgent(globalRoot, "scout.md", {
|
||||
name: "scout",
|
||||
body: "Global scout.",
|
||||
});
|
||||
await writeStandardAgent(globalRoot, "unmapped.md", {
|
||||
name: "unmapped",
|
||||
});
|
||||
await writeStandardAgent(projectRoot, "worker.md", {
|
||||
name: "worker",
|
||||
body: "Project worker.",
|
||||
});
|
||||
|
||||
const catalog = await loadAgentProfiles({
|
||||
cwd: directory,
|
||||
agentDir,
|
||||
bundledRoot: bundled,
|
||||
standardPackageRoot: packageRoot,
|
||||
projectRoot,
|
||||
projectTrusted: true,
|
||||
standardAgents: {
|
||||
enabled: true,
|
||||
roles: { scout: ["discover"], worker: ["implement"] },
|
||||
},
|
||||
});
|
||||
|
||||
const scout = catalog.profiles.find((profile) => profile.name === "scout");
|
||||
assert.equal(scout?.source, "standard-global");
|
||||
assert.equal(scout?.instructions, "Global scout.");
|
||||
assert.deepEqual(scout?.roles, ["discover"]);
|
||||
assert.deepEqual(scout?.selection.stages, ["discover"]);
|
||||
|
||||
const worker = catalog.profiles.find((profile) => profile.name === "worker");
|
||||
assert.equal(worker?.source, "standard-project");
|
||||
assert.equal(worker?.instructions, "Project worker.");
|
||||
assert.deepEqual(worker?.roles, ["implement"]);
|
||||
assert.equal(catalog.profiles.some((profile) => profile.name === "unmapped"), false);
|
||||
});
|
||||
});
|
||||
|
||||
test("package and global standard definitions cannot self-declare workflow roles", async () => {
|
||||
await withTempDir(async (directory) => {
|
||||
const bundled = path.join(directory, "bundled");
|
||||
const packageRoot = path.join(directory, "package-agents");
|
||||
await writeProfile(bundled, "general.md", { name: "general-discovery" });
|
||||
await writeProfile(packageRoot, "worker.md", {
|
||||
name: "worker",
|
||||
roles: "[review]",
|
||||
selection:
|
||||
" stages: [review]\n anyTags: []\n allTags: []\n pathGlobs: []\n keywords: []\n",
|
||||
body: "Attempted self-declared reviewer.",
|
||||
});
|
||||
await writeProfile(packageRoot, "unmapped.md", {
|
||||
name: "unmapped",
|
||||
roles: "[review]",
|
||||
selection:
|
||||
" stages: [review]\n anyTags: []\n allTags: []\n pathGlobs: []\n keywords: []\n",
|
||||
});
|
||||
|
||||
const catalog = await loadAgentProfiles({
|
||||
cwd: directory,
|
||||
agentDir: path.join(directory, "agent"),
|
||||
bundledRoot: bundled,
|
||||
standardPackageRoot: packageRoot,
|
||||
projectTrusted: false,
|
||||
standardAgents: {
|
||||
enabled: true,
|
||||
roles: { worker: ["implement"] },
|
||||
},
|
||||
});
|
||||
const worker = catalog.profiles.find((profile) => profile.name === "worker");
|
||||
assert.equal(worker?.source, "standard-package");
|
||||
assert.deepEqual(worker?.roles, ["implement"]);
|
||||
assert.deepEqual(worker?.selection.stages, ["implement"]);
|
||||
assert.equal(catalog.profiles.some((profile) => profile.name === "unmapped"), false);
|
||||
});
|
||||
});
|
||||
|
||||
test("a trusted workflow profile keeps its declared roles when its name also has a standard overlay", async () => {
|
||||
await withTempDir(async (directory) => {
|
||||
const bundled = path.join(directory, "bundled");
|
||||
const packageRoot = path.join(directory, "package-agents");
|
||||
const projectRoot = path.join(directory, ".pi", "agents");
|
||||
await writeProfile(bundled, "general.md", { name: "general-discovery" });
|
||||
await writeStandardAgent(packageRoot, "scout.md", { name: "scout" });
|
||||
await writeProfile(projectRoot, "scout.md", {
|
||||
name: "scout",
|
||||
roles: "[verify]",
|
||||
selection:
|
||||
" stages: [verify]\n anyTags: []\n allTags: []\n pathGlobs: []\n keywords: []\n",
|
||||
body: "Project verifier.",
|
||||
});
|
||||
|
||||
const catalog = await loadAgentProfiles({
|
||||
cwd: directory,
|
||||
agentDir: path.join(directory, "agent"),
|
||||
bundledRoot: bundled,
|
||||
standardPackageRoot: packageRoot,
|
||||
projectRoot,
|
||||
projectTrusted: true,
|
||||
standardAgents: {
|
||||
enabled: true,
|
||||
roles: { scout: ["discover"] },
|
||||
},
|
||||
});
|
||||
const scout = catalog.profiles.find((profile) => profile.name === "scout");
|
||||
assert.equal(scout?.source, "project");
|
||||
assert.deepEqual(scout?.roles, ["verify"]);
|
||||
assert.deepEqual(scout?.selection.stages, ["verify"]);
|
||||
assert.equal(scout?.instructions, "Project verifier.");
|
||||
});
|
||||
});
|
||||
|
||||
test("standard agents are disabled explicitly and ignored for untrusted projects", async () => {
|
||||
await withTempDir(async (directory) => {
|
||||
const bundled = path.join(directory, "bundled");
|
||||
const packageRoot = path.join(directory, "package-agents");
|
||||
const projectRoot = path.join(directory, ".pi", "agents");
|
||||
await writeProfile(bundled, "general.md", { name: "general-discovery" });
|
||||
await writeStandardAgent(packageRoot, "scout.md", { name: "scout" });
|
||||
await writeStandardAgent(projectRoot, "worker.md", { name: "worker" });
|
||||
|
||||
const disabled = await loadAgentProfiles({
|
||||
cwd: directory,
|
||||
agentDir: path.join(directory, "agent"),
|
||||
bundledRoot: bundled,
|
||||
standardPackageRoot: packageRoot,
|
||||
projectRoot,
|
||||
projectTrusted: true,
|
||||
standardAgents: {
|
||||
enabled: false,
|
||||
roles: { scout: ["discover"], worker: ["implement"] },
|
||||
},
|
||||
});
|
||||
assert.deepEqual(disabled.profiles.map((profile) => profile.name), ["general-discovery"]);
|
||||
});
|
||||
assert.deepEqual(
|
||||
catalog.profiles.map((profile) => profile.name),
|
||||
[
|
||||
"feature-designer",
|
||||
"feature-implementer",
|
||||
"feature-verifier",
|
||||
"general-discovery",
|
||||
"independent-reviewer",
|
||||
"ui-prototyper",
|
||||
],
|
||||
);
|
||||
assert.equal(catalog.diagnostics.length, 0);
|
||||
assert.ok(
|
||||
catalog.profiles.every((profile) => /^[a-f0-9]{64}$/.test(profile.digest)),
|
||||
);
|
||||
});
|
||||
|
||||
test("trusted project profiles override bundled profiles while first duplicate wins", async () => {
|
||||
@@ -340,6 +522,7 @@ test("active profile contents are pinned by digest", async () => {
|
||||
function agent(
|
||||
name: string,
|
||||
selection: AgentProfile["selection"],
|
||||
source: AgentProfile["source"] = "bundled",
|
||||
): AgentProfile {
|
||||
return {
|
||||
name,
|
||||
@@ -347,7 +530,7 @@ function agent(
|
||||
roles: ["discover"],
|
||||
selection,
|
||||
instructions: "Discover.",
|
||||
source: "bundled",
|
||||
source,
|
||||
sourcePath: name,
|
||||
resolvedPath: name,
|
||||
digest: name.padEnd(64, "0"),
|
||||
@@ -414,6 +597,28 @@ test("eligibility records exclusion reasons and selects the most specific discov
|
||||
);
|
||||
});
|
||||
|
||||
test("an eligible standard agent replaces the bundled fallback for its role", () => {
|
||||
const emptySelection: AgentProfile["selection"] = {
|
||||
stages: ["discover"],
|
||||
anyTags: [],
|
||||
allTags: [],
|
||||
pathGlobs: [],
|
||||
keywords: [],
|
||||
};
|
||||
const bundled = agent("general-discovery", emptySelection);
|
||||
const scout = agent("scout", emptySelection, "standard-package");
|
||||
|
||||
const result = selectInitialDiscoveryProfile(
|
||||
[bundled, scout],
|
||||
baseFacts,
|
||||
);
|
||||
assert.equal(result.selected.name, "scout");
|
||||
assert.deepEqual(
|
||||
result.evaluations.find((item) => item.profile.name === "general-discovery")?.reasons,
|
||||
["eligible standard agent available for role discover"],
|
||||
);
|
||||
});
|
||||
|
||||
test("eligibility enforces every criterion and uses a code-point name tie-breaker", () => {
|
||||
const emptySelection: AgentProfile["selection"] = {
|
||||
stages: ["discover"],
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -161,6 +161,8 @@ test("supported version-zero snapshots migrate missing lifecycle collections", a
|
||||
delete record.affectedUiSurfaces;
|
||||
delete record.reviewCycles;
|
||||
delete record.verificationCycles;
|
||||
delete (legacy.effectiveConfig as Partial<typeof legacy.effectiveConfig>)
|
||||
.standardAgents;
|
||||
const runDirectory = path.join(
|
||||
directory,
|
||||
"state",
|
||||
@@ -181,6 +183,10 @@ test("supported version-zero snapshots migrate missing lifecycle collections", a
|
||||
assert.deepEqual(loaded.prototypeCandidates, []);
|
||||
assert.deepEqual(loaded.commandReceipts, []);
|
||||
assert.equal(loaded.prototypeCleanupCompleted, false);
|
||||
assert.deepEqual(
|
||||
loaded.effectiveConfig.standardAgents,
|
||||
DEFAULT_WORKFLOW_CONFIG.standardAgents,
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
@@ -12,10 +12,11 @@ import {
|
||||
stat,
|
||||
} from "node:fs/promises";
|
||||
import * as path from "node:path";
|
||||
import type {
|
||||
PlanArtifact,
|
||||
WorkflowReportArtifact,
|
||||
WorkflowRun,
|
||||
import {
|
||||
DEFAULT_WORKFLOW_CONFIG,
|
||||
type PlanArtifact,
|
||||
type WorkflowReportArtifact,
|
||||
type WorkflowRun,
|
||||
} from "./domain.ts";
|
||||
import { deriveRunLabel } from "./run-label.ts";
|
||||
|
||||
@@ -299,6 +300,9 @@ export class WorkflowStore {
|
||||
run.affectedUiSurfaces ??= [];
|
||||
run.reviewCycles ??= 0;
|
||||
run.verificationCycles ??= 0;
|
||||
run.effectiveConfig.standardAgents ??= structuredClone(
|
||||
DEFAULT_WORKFLOW_CONFIG.standardAgents,
|
||||
);
|
||||
for (const assignment of run.assignments) {
|
||||
if (assignment.gitBefore) assignment.gitBefore.pathStatuses ??= {};
|
||||
if (assignment.gitAfter) assignment.gitAfter.pathStatuses ??= {};
|
||||
|
||||
Reference in New Issue
Block a user