pi-extensible-workflows 0.3.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +72 -0
- package/dist/src/agent-execution.d.ts +213 -0
- package/dist/src/agent-execution.js +537 -0
- package/dist/src/ambient-workflow-evals.d.ts +112 -0
- package/dist/src/ambient-workflow-evals.js +375 -0
- package/dist/src/cli.d.ts +6 -0
- package/dist/src/cli.js +26 -0
- package/dist/src/doctor.d.ts +59 -0
- package/dist/src/doctor.js +269 -0
- package/dist/src/eval-capture-extension.d.ts +5 -0
- package/dist/src/eval-capture-extension.js +48 -0
- package/dist/src/index.d.ts +362 -0
- package/dist/src/index.js +2839 -0
- package/dist/src/persistence.d.ts +90 -0
- package/dist/src/persistence.js +530 -0
- package/dist/src/session-inspector.d.ts +59 -0
- package/dist/src/session-inspector.js +396 -0
- package/dist/src/workflow-evals-child.d.ts +1 -0
- package/dist/src/workflow-evals-child.js +13 -0
- package/dist/src/workflow-evals.d.ts +290 -0
- package/dist/src/workflow-evals.js +1221 -0
- package/evals/cases/custom-model-read.yaml +15 -0
- package/evals/cases/direct-answer.yaml +6 -0
- package/evals/cases/mixed-parallel-pipeline.yaml +18 -0
- package/evals/cases/output-schema.yaml +22 -0
- package/evals/cases/parallel.yaml +15 -0
- package/evals/cases/pipeline.yaml +10 -0
- package/evals/cases/ready-for-agent-parallel-merge.yaml +32 -0
- package/evals/cases/required-role.yaml +14 -0
- package/evals/cases/role-model-mixed.yaml +18 -0
- package/evals/cases/two-agents.yaml +10 -0
- package/package.json +65 -0
- package/skills/pi-extensible-workflows/SKILL.md +109 -0
- package/src/agent-execution.ts +529 -0
- package/src/ambient-workflow-evals.ts +452 -0
- package/src/cli.ts +23 -0
- package/src/doctor.ts +285 -0
- package/src/eval-capture-extension.ts +54 -0
- package/src/index.ts +2519 -0
- package/src/persistence.ts +470 -0
- package/src/session-inspector.ts +370 -0
- package/src/workflow-evals-child.ts +15 -0
- package/src/workflow-evals.ts +876 -0
- package/test/fixtures/ready-for-agent-tasks.md +9 -0
- package/test/fixtures/workflow-eval-roles/developer.md +18 -0
- package/test/fixtures/workflow-eval-roles/reviewer.md +18 -0
- package/test/fixtures/workflow-eval-roles/scout.md +17 -0
|
@@ -0,0 +1,1221 @@
|
|
|
1
|
+
import { spawn } from "node:child_process";
|
|
2
|
+
import { randomUUID } from "node:crypto";
|
|
3
|
+
import { chmodSync, copyFileSync, cpSync, existsSync, mkdirSync, mkdtempSync, readFileSync, readdirSync, rmSync, writeFileSync } from "node:fs";
|
|
4
|
+
import { tmpdir } from "node:os";
|
|
5
|
+
import { basename, dirname, join } from "node:path";
|
|
6
|
+
import { fileURLToPath } from "node:url";
|
|
7
|
+
import { Value } from "typebox/value";
|
|
8
|
+
import { getAgentDir, parseFrontmatter, SessionManager } from "@earendil-works/pi-coding-agent";
|
|
9
|
+
import { CAPTURE_ERROR_PREFIX, CAPTURE_IDENTITY, resolveWorkflowSkillPath } from "./eval-capture-extension.js";
|
|
10
|
+
export { resolveWorkflowSkillPath } from "./eval-capture-extension.js";
|
|
11
|
+
import { ERROR_CODES, inspectWorkflowScript, loadAgentDefinitions, runWorkflow, WorkflowError } from "./index.js";
|
|
12
|
+
const CASE_PROCESS_GRACE_MS = 1_000;
|
|
13
|
+
export const SAFE_PARENT_EVAL_TOOLS = Object.freeze(["read", "grep", "find", "bash", "workflow"]);
|
|
14
|
+
const EVAL_MODEL_TOKEN = "$EVAL_MODEL";
|
|
15
|
+
const semantic = (description) => [{ id: "intent", description }];
|
|
16
|
+
const JSON_RESULT_TYPES = ["null", "boolean", "number", "integer", "string", "array", "object"];
|
|
17
|
+
const WORKFLOW_CALL_KINDS = ["agent", "parallel", "pipeline", "checkpoint", "phase", "withWorktree"];
|
|
18
|
+
const AGENT_OPTION_NAMES = ["role", "model", "thinking", "tools", "retries"];
|
|
19
|
+
const expectationKeys = ["firstSignificantAction", "firstTool", "firstBatchToolSequence", "parentToolSequence", "workflowCallCount", "requiredOperations", "forbiddenOperations", "requiredRoles", "minimumAgentCalls", "requireOutputSchema", "expectedResults", "agentPolicies", "requiredAgentOrder", "requiredAgentStructures", "requiredDataFlow"];
|
|
20
|
+
const caseKeys = ["id", "prompt", "timeoutMs", "maxCost", "expectations", "expectedWorkflowCalls", "semanticCriteria"];
|
|
21
|
+
const shapeKeys = ["type", "equals", "nonEmpty", "requiredKeys", "propertyTypes", "forbiddenProperties", "count", "minCount", "properties"];
|
|
22
|
+
const outputShapeKeys = ["type", "requiredKeys", "propertyTypes", "forbiddenProperties", "count", "minCount"];
|
|
23
|
+
function evalField(path, key) { return path ? `${path}.${key}` : key; }
|
|
24
|
+
function evalValidationError(source, path, message) { throw new Error(`YAML ${source} field ${path}: ${message}`); }
|
|
25
|
+
function evalObject(value, source, path) { if (!isObject(value))
|
|
26
|
+
evalValidationError(source, path, "must be an object"); return value; }
|
|
27
|
+
function evalKeys(value, allowed, source, path) { for (const key of Object.keys(value))
|
|
28
|
+
if (!allowed.includes(key))
|
|
29
|
+
evalValidationError(source, evalField(path, key), "unknown field"); }
|
|
30
|
+
function evalRequired(value, key, source, path) { if (!Object.prototype.hasOwnProperty.call(value, key))
|
|
31
|
+
evalValidationError(source, evalField(path, key), "is required"); return value[key]; }
|
|
32
|
+
function evalString(value, source, path, nonEmpty = false) { if (typeof value !== "string" || nonEmpty && !value.trim())
|
|
33
|
+
evalValidationError(source, path, nonEmpty ? "must be a non-empty string" : "must be a string"); return value; }
|
|
34
|
+
function evalNumber(value, source, path, integer = false, positive = false) { if (typeof value !== "number" || !Number.isFinite(value) || integer && !Number.isInteger(value) || positive && value <= 0)
|
|
35
|
+
evalValidationError(source, path, positive ? "must be a positive number" : integer ? "must be a non-negative integer" : "must be a finite number"); return value; }
|
|
36
|
+
function evalNonNegativeInteger(value, source, path) { const result = evalNumber(value, source, path, true); if (result < 0)
|
|
37
|
+
evalValidationError(source, path, "must be a non-negative integer"); return result; }
|
|
38
|
+
function evalStringArray(value, source, path) { if (!Array.isArray(value))
|
|
39
|
+
evalValidationError(source, path, "must be an array of strings"); for (const [index, item] of value.entries())
|
|
40
|
+
evalString(item, source, `${path}[${String(index)}]`); }
|
|
41
|
+
function evalEnum(value, values, source, path) { const result = evalString(value, source, path); if (!values.includes(result))
|
|
42
|
+
evalValidationError(source, path, `must be one of ${values.join(", ")}`); return result; }
|
|
43
|
+
function evalEnumArray(value, values, source, path) { evalStringArray(value, source, path); for (const [index, item] of value.entries())
|
|
44
|
+
evalEnum(item, values, source, `${path}[${String(index)}]`); }
|
|
45
|
+
function evalJson(value, seen = new Set()) { if (value === null || typeof value === "string" || typeof value === "boolean")
|
|
46
|
+
return true; if (typeof value === "number")
|
|
47
|
+
return Number.isFinite(value); if (!Array.isArray(value) && !isObject(value))
|
|
48
|
+
return false; if (seen.has(value))
|
|
49
|
+
return false; seen.add(value); const valid = Array.isArray(value) ? value.every((item) => evalJson(item, seen)) : Object.getPrototypeOf(value) === Object.prototype && Object.values(value).every((item) => evalJson(item, seen)); seen.delete(value); return valid; }
|
|
50
|
+
function evalJsonValue(value, source, path) { if (!evalJson(value))
|
|
51
|
+
evalValidationError(source, path, "must be a JSON-compatible value"); }
|
|
52
|
+
function evalStringMap(value, source, path, values) { const object = evalObject(value, source, path); for (const [key, item] of Object.entries(object))
|
|
53
|
+
evalEnum(item, values, source, evalField(path, key)); }
|
|
54
|
+
function evalSequence(value, source, path) { if (Array.isArray(value)) {
|
|
55
|
+
evalStringArray(value, source, path);
|
|
56
|
+
return;
|
|
57
|
+
} const object = evalObject(value, source, path); evalKeys(object, ["equals", "startsWith"], source, path); if (!Object.keys(object).length)
|
|
58
|
+
evalValidationError(source, path, "must contain equals or startsWith"); for (const key of ["equals", "startsWith"])
|
|
59
|
+
if (Object.prototype.hasOwnProperty.call(object, key))
|
|
60
|
+
evalStringArray(object[key], source, evalField(path, key)); }
|
|
61
|
+
function evalJsonShape(value, source, path) { const object = evalObject(value, source, path); evalKeys(object, shapeKeys, source, path); if (Object.prototype.hasOwnProperty.call(object, "type"))
|
|
62
|
+
evalEnum(object.type, JSON_RESULT_TYPES, source, evalField(path, "type")); if (Object.prototype.hasOwnProperty.call(object, "equals"))
|
|
63
|
+
evalJsonValue(object.equals, source, evalField(path, "equals")); if (Object.prototype.hasOwnProperty.call(object, "nonEmpty") && typeof object.nonEmpty !== "boolean")
|
|
64
|
+
evalValidationError(source, evalField(path, "nonEmpty"), "must be a boolean"); for (const key of ["requiredKeys", "forbiddenProperties"])
|
|
65
|
+
if (Object.prototype.hasOwnProperty.call(object, key))
|
|
66
|
+
evalStringArray(object[key], source, evalField(path, key)); if (Object.prototype.hasOwnProperty.call(object, "propertyTypes"))
|
|
67
|
+
evalStringMap(object.propertyTypes, source, evalField(path, "propertyTypes"), JSON_RESULT_TYPES); for (const key of ["count", "minCount"])
|
|
68
|
+
if (Object.prototype.hasOwnProperty.call(object, key))
|
|
69
|
+
evalNonNegativeInteger(object[key], source, evalField(path, key)); if (Object.prototype.hasOwnProperty.call(object, "properties")) {
|
|
70
|
+
const properties = evalObject(object.properties, source, evalField(path, "properties"));
|
|
71
|
+
for (const [key, nested] of Object.entries(properties))
|
|
72
|
+
evalJsonShape(nested, source, evalField(evalField(path, "properties"), key));
|
|
73
|
+
} if (typeof object.count === "number" && typeof object.minCount === "number" && object.minCount > object.count)
|
|
74
|
+
evalValidationError(source, path, "minCount cannot exceed count"); }
|
|
75
|
+
function evalOutputShape(value, source, path) { const object = evalObject(value, source, path); evalKeys(object, outputShapeKeys, source, path); if (Object.prototype.hasOwnProperty.call(object, "type"))
|
|
76
|
+
evalEnum(object.type, JSON_RESULT_TYPES, source, evalField(path, "type")); for (const key of ["requiredKeys", "forbiddenProperties"])
|
|
77
|
+
if (Object.prototype.hasOwnProperty.call(object, key))
|
|
78
|
+
evalStringArray(object[key], source, evalField(path, key)); if (Object.prototype.hasOwnProperty.call(object, "propertyTypes"))
|
|
79
|
+
evalStringMap(object.propertyTypes, source, evalField(path, "propertyTypes"), JSON_RESULT_TYPES); for (const key of ["count", "minCount"])
|
|
80
|
+
if (Object.prototype.hasOwnProperty.call(object, key))
|
|
81
|
+
evalNonNegativeInteger(object[key], source, evalField(path, key)); if (typeof object.count === "number" && typeof object.minCount === "number" && object.minCount > object.count)
|
|
82
|
+
evalValidationError(source, path, "minCount cannot exceed count"); }
|
|
83
|
+
function evalAgentSelector(value, source, path) { const object = evalObject(value, source, path); evalKeys(object, ["role", "model", "promptIncludes", "execution"], source, path); for (const key of ["role", "model", "promptIncludes"])
|
|
84
|
+
if (Object.prototype.hasOwnProperty.call(object, key))
|
|
85
|
+
evalString(object[key], source, evalField(path, key)); if (Object.prototype.hasOwnProperty.call(object, "execution"))
|
|
86
|
+
evalEnum(object.execution, ["parallel", "sequential"], source, evalField(path, "execution")); }
|
|
87
|
+
function evalAgentPolicy(value, source, path) { const object = evalObject(value, source, path); evalKeys(object, ["callIndex", "role", "model", "forbidOptions", "tools"], source, path); evalNonNegativeInteger(evalRequired(object, "callIndex", source, path), source, evalField(path, "callIndex")); for (const key of ["role", "model"])
|
|
88
|
+
if (Object.prototype.hasOwnProperty.call(object, key))
|
|
89
|
+
evalString(object[key], source, evalField(path, key)); if (Object.prototype.hasOwnProperty.call(object, "forbidOptions"))
|
|
90
|
+
evalEnumArray(object.forbidOptions, AGENT_OPTION_NAMES, source, evalField(path, "forbidOptions")); if (Object.prototype.hasOwnProperty.call(object, "tools")) {
|
|
91
|
+
const tools = evalObject(object.tools, source, evalField(path, "tools"));
|
|
92
|
+
evalKeys(tools, ["mode", "values"], source, evalField(path, "tools"));
|
|
93
|
+
evalEnum(tools.mode, ["omitted", "empty", "exact"], source, evalField(evalField(path, "tools"), "mode"));
|
|
94
|
+
if (Object.prototype.hasOwnProperty.call(tools, "values"))
|
|
95
|
+
evalStringArray(tools.values, source, evalField(evalField(path, "tools"), "values"));
|
|
96
|
+
} }
|
|
97
|
+
function evalExpectations(value, source, path) { const object = evalObject(value, source, path); evalKeys(object, expectationKeys, source, path); if (Object.prototype.hasOwnProperty.call(object, "firstSignificantAction")) {
|
|
98
|
+
const action = evalObject(object.firstSignificantAction, source, evalField(path, "firstSignificantAction"));
|
|
99
|
+
evalKeys(action, ["kind", "name"], source, evalField(path, "firstSignificantAction"));
|
|
100
|
+
const kind = evalEnum(action.kind, ["tool", "text", "thinking"], source, evalField(evalField(path, "firstSignificantAction"), "kind"));
|
|
101
|
+
if (kind === "tool")
|
|
102
|
+
evalString(evalRequired(action, "name", source, evalField(path, "firstSignificantAction")), source, evalField(evalField(path, "firstSignificantAction"), "name"), true);
|
|
103
|
+
else if (Object.prototype.hasOwnProperty.call(action, "name"))
|
|
104
|
+
evalValidationError(source, evalField(evalField(path, "firstSignificantAction"), "name"), "is only valid for kind tool");
|
|
105
|
+
} if (Object.prototype.hasOwnProperty.call(object, "firstTool"))
|
|
106
|
+
evalString(object.firstTool, source, evalField(path, "firstTool"), true); for (const key of ["firstBatchToolSequence", "parentToolSequence"])
|
|
107
|
+
if (Object.prototype.hasOwnProperty.call(object, key))
|
|
108
|
+
evalSequence(object[key], source, evalField(path, key)); if (Object.prototype.hasOwnProperty.call(object, "workflowCallCount")) {
|
|
109
|
+
const count = object.workflowCallCount;
|
|
110
|
+
if (typeof count === "number")
|
|
111
|
+
evalNonNegativeInteger(count, source, evalField(path, "workflowCallCount"));
|
|
112
|
+
else {
|
|
113
|
+
const range = evalObject(count, source, evalField(path, "workflowCallCount"));
|
|
114
|
+
evalKeys(range, ["min", "max"], source, evalField(path, "workflowCallCount"));
|
|
115
|
+
if (!Object.keys(range).length)
|
|
116
|
+
evalValidationError(source, evalField(path, "workflowCallCount"), "must contain min or max");
|
|
117
|
+
for (const key of ["min", "max"])
|
|
118
|
+
if (Object.prototype.hasOwnProperty.call(range, key))
|
|
119
|
+
evalNonNegativeInteger(range[key], source, evalField(evalField(path, "workflowCallCount"), key));
|
|
120
|
+
if (typeof range.min === "number" && typeof range.max === "number" && range.min > range.max)
|
|
121
|
+
evalValidationError(source, evalField(path, "workflowCallCount"), "min cannot exceed max");
|
|
122
|
+
}
|
|
123
|
+
} for (const key of ["requiredOperations", "forbiddenOperations"])
|
|
124
|
+
if (Object.prototype.hasOwnProperty.call(object, key))
|
|
125
|
+
evalEnumArray(object[key], WORKFLOW_CALL_KINDS, source, evalField(path, key)); for (const key of ["requiredRoles"])
|
|
126
|
+
if (Object.prototype.hasOwnProperty.call(object, key))
|
|
127
|
+
evalStringArray(object[key], source, evalField(path, key)); if (Object.prototype.hasOwnProperty.call(object, "minimumAgentCalls"))
|
|
128
|
+
evalNonNegativeInteger(object.minimumAgentCalls, source, evalField(path, "minimumAgentCalls")); if (Object.prototype.hasOwnProperty.call(object, "requireOutputSchema")) {
|
|
129
|
+
if (typeof object.requireOutputSchema !== "boolean")
|
|
130
|
+
evalOutputShape(object.requireOutputSchema, source, evalField(path, "requireOutputSchema"));
|
|
131
|
+
} if (Object.prototype.hasOwnProperty.call(object, "expectedResults")) {
|
|
132
|
+
if (!Array.isArray(object.expectedResults))
|
|
133
|
+
evalValidationError(source, evalField(path, "expectedResults"), "must be an array");
|
|
134
|
+
for (const [index, item] of object.expectedResults.entries()) {
|
|
135
|
+
const expected = evalObject(item, source, `${path}.expectedResults[${String(index)}]`);
|
|
136
|
+
evalKeys(expected, ["workflowIndex", "equals", "match"], source, `${path}.expectedResults[${String(index)}]`);
|
|
137
|
+
if (Object.prototype.hasOwnProperty.call(expected, "workflowIndex"))
|
|
138
|
+
evalNonNegativeInteger(expected.workflowIndex, source, `${path}.expectedResults[${String(index)}].workflowIndex`);
|
|
139
|
+
if (Object.prototype.hasOwnProperty.call(expected, "equals"))
|
|
140
|
+
evalJsonValue(expected.equals, source, `${path}.expectedResults[${String(index)}].equals`);
|
|
141
|
+
if (Object.prototype.hasOwnProperty.call(expected, "match"))
|
|
142
|
+
evalJsonShape(expected.match, source, `${path}.expectedResults[${String(index)}].match`);
|
|
143
|
+
if (!Object.prototype.hasOwnProperty.call(expected, "equals") && !Object.prototype.hasOwnProperty.call(expected, "match"))
|
|
144
|
+
evalValidationError(source, `${path}.expectedResults[${String(index)}]`, "must contain equals or match");
|
|
145
|
+
}
|
|
146
|
+
} if (Object.prototype.hasOwnProperty.call(object, "agentPolicies")) {
|
|
147
|
+
if (!Array.isArray(object.agentPolicies))
|
|
148
|
+
evalValidationError(source, evalField(path, "agentPolicies"), "must be an array");
|
|
149
|
+
for (const [index, item] of object.agentPolicies.entries())
|
|
150
|
+
evalAgentPolicy(item, source, `${path}.agentPolicies[${String(index)}]`);
|
|
151
|
+
} for (const key of ["requiredAgentOrder"])
|
|
152
|
+
if (Object.prototype.hasOwnProperty.call(object, key)) {
|
|
153
|
+
if (!Array.isArray(object[key]))
|
|
154
|
+
evalValidationError(source, evalField(path, key), "must be an array");
|
|
155
|
+
for (const [index, item] of object[key].entries())
|
|
156
|
+
evalAgentSelector(item, source, `${path}.${key}[${String(index)}]`);
|
|
157
|
+
} if (Object.prototype.hasOwnProperty.call(object, "requiredAgentStructures")) {
|
|
158
|
+
if (!Array.isArray(object.requiredAgentStructures))
|
|
159
|
+
evalValidationError(source, evalField(path, "requiredAgentStructures"), "must be an array");
|
|
160
|
+
for (const [index, item] of object.requiredAgentStructures.entries()) {
|
|
161
|
+
const structure = evalObject(item, source, `${path}.requiredAgentStructures[${String(index)}]`);
|
|
162
|
+
evalKeys(structure, ["execution", "operation", "agents"], source, `${path}.requiredAgentStructures[${String(index)}]`);
|
|
163
|
+
evalEnum(evalRequired(structure, "execution", source, `${path}.requiredAgentStructures[${String(index)}]`), ["parallel", "sequential"], source, `${path}.requiredAgentStructures[${String(index)}].execution`);
|
|
164
|
+
if (Object.prototype.hasOwnProperty.call(structure, "operation"))
|
|
165
|
+
evalEnum(structure.operation, ["parallel", "pipeline"], source, `${path}.requiredAgentStructures[${String(index)}].operation`);
|
|
166
|
+
const agents = evalRequired(structure, "agents", source, `${path}.requiredAgentStructures[${String(index)}]`);
|
|
167
|
+
if (!Array.isArray(agents))
|
|
168
|
+
evalValidationError(source, `${path}.requiredAgentStructures[${String(index)}].agents`, "must be a non-empty array");
|
|
169
|
+
if (!agents.length)
|
|
170
|
+
evalValidationError(source, `${path}.requiredAgentStructures[${String(index)}].agents`, "must be a non-empty array");
|
|
171
|
+
for (const [agentIndex, agent] of agents.entries())
|
|
172
|
+
evalAgentSelector(agent, source, `${path}.requiredAgentStructures[${String(index)}].agents[${String(agentIndex)}]`);
|
|
173
|
+
}
|
|
174
|
+
} if (Object.prototype.hasOwnProperty.call(object, "requiredDataFlow")) {
|
|
175
|
+
if (!Array.isArray(object.requiredDataFlow))
|
|
176
|
+
evalValidationError(source, evalField(path, "requiredDataFlow"), "must be an array");
|
|
177
|
+
for (const [index, item] of object.requiredDataFlow.entries()) {
|
|
178
|
+
const flow = evalObject(item, source, `${path}.requiredDataFlow[${String(index)}]`);
|
|
179
|
+
evalKeys(flow, ["binding", "toAgentIndex"], source, `${path}.requiredDataFlow[${String(index)}]`);
|
|
180
|
+
evalString(evalRequired(flow, "binding", source, `${path}.requiredDataFlow[${String(index)}]`), source, `${path}.requiredDataFlow[${String(index)}].binding`, true);
|
|
181
|
+
evalNonNegativeInteger(evalRequired(flow, "toAgentIndex", source, `${path}.requiredDataFlow[${String(index)}]`), source, `${path}.requiredDataFlow[${String(index)}].toAgentIndex`);
|
|
182
|
+
}
|
|
183
|
+
} return object; }
|
|
184
|
+
function evalCase(value, source) { const object = evalObject(value, source, "<case>"); evalKeys(object, caseKeys, source, ""); evalString(evalRequired(object, "id", source, ""), source, "id", true); evalString(evalRequired(object, "prompt", source, ""), source, "prompt", true); if (Object.prototype.hasOwnProperty.call(object, "timeoutMs"))
|
|
185
|
+
evalNumber(object.timeoutMs, source, "timeoutMs", true, true); evalNumber(evalRequired(object, "maxCost", source, ""), source, "maxCost", false, true); evalExpectations(evalRequired(object, "expectations", source, ""), source, "expectations"); if (Object.prototype.hasOwnProperty.call(object, "expectedWorkflowCalls"))
|
|
186
|
+
evalNonNegativeInteger(object.expectedWorkflowCalls, source, "expectedWorkflowCalls"); if (Object.prototype.hasOwnProperty.call(object, "semanticCriteria")) {
|
|
187
|
+
if (!Array.isArray(object.semanticCriteria))
|
|
188
|
+
evalValidationError(source, "semanticCriteria", "must be an array");
|
|
189
|
+
const ids = new Set();
|
|
190
|
+
for (const [index, item] of object.semanticCriteria.entries()) {
|
|
191
|
+
const criterion = evalObject(item, source, `semanticCriteria[${String(index)}]`);
|
|
192
|
+
evalKeys(criterion, ["id", "description"], source, `semanticCriteria[${String(index)}]`);
|
|
193
|
+
const id = evalString(evalRequired(criterion, "id", source, `semanticCriteria[${String(index)}]`), source, `semanticCriteria[${String(index)}].id`, true);
|
|
194
|
+
evalString(evalRequired(criterion, "description", source, `semanticCriteria[${String(index)}]`), source, `semanticCriteria[${String(index)}].description`, true);
|
|
195
|
+
if (ids.has(id))
|
|
196
|
+
evalValidationError(source, `semanticCriteria[${String(index)}].id`, `duplicate criterion id ${JSON.stringify(id)}`);
|
|
197
|
+
ids.add(id);
|
|
198
|
+
}
|
|
199
|
+
} return object; }
|
|
200
|
+
function evalYaml(content, source) { try {
|
|
201
|
+
const parsed = parseFrontmatter(`---\n${content.replace(/\r\n?/g, "\n")}\n---\n`);
|
|
202
|
+
if (parsed.body.trim())
|
|
203
|
+
evalValidationError(source, "<document>", "must contain one YAML document");
|
|
204
|
+
return parsed.frontmatter;
|
|
205
|
+
}
|
|
206
|
+
catch (error) {
|
|
207
|
+
if (error instanceof Error && error.message.startsWith("YAML "))
|
|
208
|
+
throw error;
|
|
209
|
+
evalValidationError(source, "<document>", `malformed YAML: ${error instanceof Error ? error.message : String(error)}`);
|
|
210
|
+
} }
|
|
211
|
+
function evalCasesDirectory() { const moduleDirectory = dirname(fileURLToPath(import.meta.url)); const candidates = [join(moduleDirectory, "../evals/cases"), join(moduleDirectory, "../../evals/cases")]; const found = candidates.find((candidate) => existsSync(candidate)); if (!found)
|
|
212
|
+
evalValidationError(candidates[0], "<document>", "cases directory not found"); return found; }
|
|
213
|
+
export function validateWorkflowEvalCases(values, source = "cases") { if (!Array.isArray(values))
|
|
214
|
+
evalValidationError(source, "<document>", "must be an array"); const cases = values.map((value, index) => evalCase(value, `${source}[${String(index)}]`)); const seen = new Map(); for (const [index, candidate] of cases.entries()) {
|
|
215
|
+
const path = `${source}[${String(index)}]`;
|
|
216
|
+
const first = seen.get(candidate.id);
|
|
217
|
+
if (first)
|
|
218
|
+
evalValidationError(source, `${path}.id`, `duplicate id ${JSON.stringify(candidate.id)} (first declared at ${first})`);
|
|
219
|
+
seen.set(candidate.id, `${path}.id`);
|
|
220
|
+
} return cases; }
|
|
221
|
+
export function loadWorkflowEvalCases(directory = evalCasesDirectory()) { let entries; try {
|
|
222
|
+
entries = readdirSync(directory, { withFileTypes: true, encoding: "utf8" });
|
|
223
|
+
}
|
|
224
|
+
catch (error) {
|
|
225
|
+
evalValidationError(directory, "<document>", `cannot read cases directory: ${error instanceof Error ? error.message : String(error)}`);
|
|
226
|
+
} const files = entries.filter((entry) => entry.isFile() && entry.name.endsWith(".yaml")).map((entry) => entry.name).sort((left, right) => left < right ? -1 : left > right ? 1 : 0); if (!files.length)
|
|
227
|
+
evalValidationError(directory, "<document>", "no .yaml case files found"); const cases = []; const seen = new Map(); for (const file of files) {
|
|
228
|
+
const source = join(directory, file);
|
|
229
|
+
const candidate = evalCase(evalYaml(readFileSync(source, "utf8"), source), source);
|
|
230
|
+
const first = seen.get(candidate.id);
|
|
231
|
+
if (first)
|
|
232
|
+
evalValidationError(source, "id", `duplicate id ${JSON.stringify(candidate.id)} (first declared at ${first} field id)`);
|
|
233
|
+
seen.set(candidate.id, source);
|
|
234
|
+
cases.push(candidate);
|
|
235
|
+
} return Object.freeze(cases); }
|
|
236
|
+
export const INITIAL_WORKFLOW_EVAL_CASES = loadWorkflowEvalCases();
|
|
237
|
+
function isObject(value) { return typeof value === "object" && value !== null && !Array.isArray(value); }
|
|
238
|
+
function isJson(value) { if (value === null || typeof value === "string" || typeof value === "boolean")
|
|
239
|
+
return true; if (typeof value === "number")
|
|
240
|
+
return Number.isFinite(value); if (Array.isArray(value))
|
|
241
|
+
return value.every(isJson); return isObject(value) && Object.values(value).every(isJson); }
|
|
242
|
+
function asJsonObject(value) { return isObject(value) && Object.values(value).every(isJson) ? value : undefined; }
|
|
243
|
+
function jsonType(value) { if (value === null)
|
|
244
|
+
return "null"; if (Array.isArray(value))
|
|
245
|
+
return "array"; if (typeof value === "number")
|
|
246
|
+
return Number.isInteger(value) ? "integer" : "number"; if (typeof value === "object")
|
|
247
|
+
return "object"; if (typeof value === "string")
|
|
248
|
+
return "string"; if (typeof value === "boolean")
|
|
249
|
+
return "boolean"; return "null"; }
|
|
250
|
+
function jsonTypeMatches(value, expected) { const actual = jsonType(value); return actual === expected || expected === "number" && actual === "integer"; }
|
|
251
|
+
function equalJson(left, right) { if (left === right)
|
|
252
|
+
return true; if (Array.isArray(left) && Array.isArray(right))
|
|
253
|
+
return left.length === right.length && left.every((value, index) => equalJson(value, right[index])); if (isObject(left) && isObject(right)) {
|
|
254
|
+
const leftKeys = Object.keys(left);
|
|
255
|
+
const rightKeys = Object.keys(right);
|
|
256
|
+
return leftKeys.length === rightKeys.length && leftKeys.every((key) => Object.prototype.hasOwnProperty.call(right, key) && equalJson(left[key], right[key]));
|
|
257
|
+
} return false; }
|
|
258
|
+
function isSequenceShape(value) { return !Array.isArray(value); }
|
|
259
|
+
function sequenceMatches(actual, expected) { if (!isSequenceShape(expected))
|
|
260
|
+
return equalJson(actual, expected); if (expected.equals !== undefined && !sequenceMatches(actual, expected.equals))
|
|
261
|
+
return false; return expected.startsWith === undefined || expected.startsWith.every((name, index) => actual[index] === name); }
|
|
262
|
+
function countFor(value) { if (Array.isArray(value))
|
|
263
|
+
return value.length; if (isObject(value))
|
|
264
|
+
return Object.keys(value).length; return undefined; }
|
|
265
|
+
export function matchesJsonResult(shape, value) {
|
|
266
|
+
if (shape.equals !== undefined && !equalJson(shape.equals, value))
|
|
267
|
+
return false;
|
|
268
|
+
if (shape.type !== undefined && !jsonTypeMatches(value, shape.type))
|
|
269
|
+
return false;
|
|
270
|
+
if (shape.nonEmpty && (value === "" || value === null || Array.isArray(value) && value.length === 0 || isObject(value) && Object.keys(value).length === 0))
|
|
271
|
+
return false;
|
|
272
|
+
const objectValue = isObject(value) ? value : undefined;
|
|
273
|
+
if (shape.requiredKeys && (!objectValue || shape.requiredKeys.some((key) => !Object.prototype.hasOwnProperty.call(objectValue, key))))
|
|
274
|
+
return false;
|
|
275
|
+
if (shape.forbiddenProperties && objectValue && shape.forbiddenProperties.some((key) => Object.prototype.hasOwnProperty.call(objectValue, key)))
|
|
276
|
+
return false;
|
|
277
|
+
if (shape.propertyTypes && (!objectValue || Object.entries(shape.propertyTypes).some(([key, type]) => !Object.prototype.hasOwnProperty.call(objectValue, key) || !jsonTypeMatches(objectValue[key], type))))
|
|
278
|
+
return false;
|
|
279
|
+
if (shape.properties && (!objectValue || Object.entries(shape.properties).some(([key, nested]) => !Object.prototype.hasOwnProperty.call(objectValue, key) || !matchesJsonResult(nested, objectValue[key]))))
|
|
280
|
+
return false;
|
|
281
|
+
const count = countFor(value);
|
|
282
|
+
if (shape.count !== undefined && count !== shape.count)
|
|
283
|
+
return false;
|
|
284
|
+
if (shape.minCount !== undefined && (count === undefined || count < shape.minCount))
|
|
285
|
+
return false;
|
|
286
|
+
return true;
|
|
287
|
+
}
|
|
288
|
+
function schemaTypeMatches(actual, expected) { return actual === expected || expected === "number" && actual === "integer"; }
|
|
289
|
+
function matchesOutputSchemaShape(shape, schema) {
|
|
290
|
+
if (shape.type !== undefined && !schemaTypeMatches(schema.type, shape.type))
|
|
291
|
+
return false;
|
|
292
|
+
const properties = isObject(schema.properties) ? schema.properties : {};
|
|
293
|
+
const required = Array.isArray(schema.required) ? schema.required.filter((key) => typeof key === "string") : [];
|
|
294
|
+
if (shape.requiredKeys && shape.requiredKeys.some((key) => !required.includes(key)))
|
|
295
|
+
return false;
|
|
296
|
+
if (shape.forbiddenProperties?.some((key) => Object.prototype.hasOwnProperty.call(properties, key)))
|
|
297
|
+
return false;
|
|
298
|
+
if (shape.propertyTypes && Object.entries(shape.propertyTypes).some(([key, type]) => !isObject(properties[key]) || !schemaTypeMatches(properties[key].type, type)))
|
|
299
|
+
return false;
|
|
300
|
+
return true;
|
|
301
|
+
}
|
|
302
|
+
export function matchesOutputSchema(shape, schema) { return matchesOutputSchemaShape(shape, schema); }
|
|
303
|
+
function usageFrom(message) {
|
|
304
|
+
if (message.role !== "assistant" || !isObject(message.usage))
|
|
305
|
+
return undefined;
|
|
306
|
+
const usage = message.usage;
|
|
307
|
+
const cost = isObject(usage.cost) && typeof usage.cost.total === "number" ? usage.cost.total : 0;
|
|
308
|
+
const responseModel = typeof message.responseModel === "string" ? message.responseModel : message.model;
|
|
309
|
+
const model = typeof message.provider === "string" && typeof responseModel === "string" ? `${message.provider}/${responseModel}` : "unknown/unknown";
|
|
310
|
+
return { input: typeof usage.input === "number" ? usage.input : 0, output: typeof usage.output === "number" ? usage.output : 0, cacheRead: typeof usage.cacheRead === "number" ? usage.cacheRead : 0, cacheWrite: typeof usage.cacheWrite === "number" ? usage.cacheWrite : 0, totalTokens: typeof usage.totalTokens === "number" ? usage.totalTokens : (typeof usage.input === "number" ? usage.input : 0) + (typeof usage.output === "number" ? usage.output : 0) + (typeof usage.cacheRead === "number" ? usage.cacheRead : 0) + (typeof usage.cacheWrite === "number" ? usage.cacheWrite : 0), cost, model };
|
|
311
|
+
}
|
|
312
|
+
export function extractParentOracle(entries) {
|
|
313
|
+
const batches = [];
|
|
314
|
+
const workflowToolResults = [];
|
|
315
|
+
const modelCosts = new Map();
|
|
316
|
+
const totals = { input: 0, output: 0, cacheRead: 0, cacheWrite: 0, totalTokens: 0, cost: 0 };
|
|
317
|
+
for (const entry of entries) {
|
|
318
|
+
if (!isObject(entry) || entry.type !== "message" || !isObject(entry.message))
|
|
319
|
+
continue;
|
|
320
|
+
const message = entry.message;
|
|
321
|
+
if (message.role === "toolResult" && message.toolName === "workflow") {
|
|
322
|
+
const details = isJson(message.details) ? { details: message.details } : {};
|
|
323
|
+
const text = Array.isArray(message.content) ? message.content.flatMap((part) => isObject(part) && part.type === "text" && typeof part.text === "string" ? [part.text] : []).join("\n") : "";
|
|
324
|
+
workflowToolResults.push({ ...(typeof message.toolCallId === "string" ? { toolCallId: message.toolCallId } : {}), ...details, ...(typeof message.isError === "boolean" ? { isError: message.isError } : {}), ...(text ? { text } : {}) });
|
|
325
|
+
continue;
|
|
326
|
+
}
|
|
327
|
+
if (message.role !== "assistant")
|
|
328
|
+
continue;
|
|
329
|
+
const rawParts = Array.isArray(message.content) ? message.content : [];
|
|
330
|
+
const parts = rawParts.filter(isJson);
|
|
331
|
+
const tools = parts.flatMap((part) => isObject(part) && part.type === "toolCall" && typeof part.name === "string" ? [part.name] : []);
|
|
332
|
+
const usage = usageFrom(message);
|
|
333
|
+
batches.push({ index: batches.length, parts, tools, ...(usage ? { usage: { input: usage.input, output: usage.output, cacheRead: usage.cacheRead, cacheWrite: usage.cacheWrite, totalTokens: usage.totalTokens, cost: usage.cost, models: [{ model: usage.model, cost: usage.cost }] } } : {}) });
|
|
334
|
+
if (usage) {
|
|
335
|
+
totals.input += usage.input;
|
|
336
|
+
totals.output += usage.output;
|
|
337
|
+
totals.cacheRead += usage.cacheRead;
|
|
338
|
+
totals.cacheWrite += usage.cacheWrite;
|
|
339
|
+
totals.totalTokens += usage.totalTokens;
|
|
340
|
+
totals.cost += usage.cost;
|
|
341
|
+
modelCosts.set(usage.model, (modelCosts.get(usage.model) ?? 0) + usage.cost);
|
|
342
|
+
}
|
|
343
|
+
}
|
|
344
|
+
const firstBatch = batches[0];
|
|
345
|
+
const parentToolSequence = batches.flatMap(({ tools }) => tools);
|
|
346
|
+
const firstWorkflowIndex = parentToolSequence.indexOf("workflow");
|
|
347
|
+
const firstWorkflowBatch = batches.find(({ tools }) => tools.includes("workflow"));
|
|
348
|
+
const skillReads = batches.flatMap(({ parts }) => parts.flatMap((part) => {
|
|
349
|
+
if (!isObject(part) || part.type !== "toolCall" || part.name !== "read" || !isObject(part.arguments) || typeof part.arguments.path !== "string" || !/SKILL\.md$/.test(part.arguments.path))
|
|
350
|
+
return [];
|
|
351
|
+
return [part.arguments.path];
|
|
352
|
+
}));
|
|
353
|
+
let firstSignificantAction;
|
|
354
|
+
for (const part of batches.flatMap(({ parts }) => parts)) {
|
|
355
|
+
if (!isObject(part))
|
|
356
|
+
continue;
|
|
357
|
+
if (part.type === "toolCall" && typeof part.name === "string" && part.name.trim()) {
|
|
358
|
+
firstSignificantAction = { kind: "tool", name: part.name };
|
|
359
|
+
break;
|
|
360
|
+
}
|
|
361
|
+
if (part.type === "text" && typeof part.text === "string" && part.text.trim()) {
|
|
362
|
+
firstSignificantAction = { kind: "text" };
|
|
363
|
+
break;
|
|
364
|
+
}
|
|
365
|
+
if (part.type === "thinking" && typeof part.thinking === "string" && part.thinking.trim()) {
|
|
366
|
+
firstSignificantAction = { kind: "thinking" };
|
|
367
|
+
break;
|
|
368
|
+
}
|
|
369
|
+
}
|
|
370
|
+
return { assistantBatches: batches, workflowToolResults, skillReads, ...(firstSignificantAction ? { firstSignificantAction } : {}), ...(parentToolSequence[0] ? { firstTool: parentToolSequence[0] } : {}), firstBatchToolSequence: firstBatch?.tools ?? [], toolsBeforeFirstWorkflow: firstWorkflowIndex < 0 ? parentToolSequence : parentToolSequence.slice(0, firstWorkflowIndex), firstWorkflowBatchToolSequence: firstWorkflowBatch?.tools ?? [], parentToolSequence, workflowCallCount: parentToolSequence.filter((name) => name === "workflow").length, usage: { ...totals, models: [...modelCosts].map(([model, cost]) => ({ model, cost })) } };
|
|
371
|
+
}
|
|
372
|
+
export function extractParentOracleFile(path) {
|
|
373
|
+
const entries = readFileSync(path, "utf8").split("\n").filter(Boolean).map((line) => JSON.parse(line));
|
|
374
|
+
return extractParentOracle(entries);
|
|
375
|
+
}
|
|
376
|
+
function resultForCall(oracle, call, callIndex) {
|
|
377
|
+
return call.toolCallId ? oracle.workflowToolResults.find(({ toolCallId }) => toolCallId === call.toolCallId) ?? oracle.workflowToolResults[callIndex] : oracle.workflowToolResults[callIndex];
|
|
378
|
+
}
|
|
379
|
+
export function extractCapturedWorkflows(oracle) {
|
|
380
|
+
let callIndex = 0;
|
|
381
|
+
return oracle.assistantBatches.flatMap((batch) => batch.parts.flatMap((part) => {
|
|
382
|
+
if (!isObject(part) || part.type !== "toolCall" || part.name !== "workflow")
|
|
383
|
+
return [];
|
|
384
|
+
const index = callIndex++;
|
|
385
|
+
const args = isJson(part.arguments) ? part.arguments : null;
|
|
386
|
+
const toolCallId = typeof part.id === "string" ? part.id : undefined;
|
|
387
|
+
const call = { batch: batch.index, ...(toolCallId ? { toolCallId } : {}), arguments: args };
|
|
388
|
+
const result = resultForCall(oracle, call, index);
|
|
389
|
+
const details = result ? asJsonObject(result.details) : undefined;
|
|
390
|
+
const validation = details && isObject(details.validation) ? details.validation : undefined;
|
|
391
|
+
const validatedScript = validation && typeof validation.script === "string" ? validation.script : undefined;
|
|
392
|
+
const argsObject = asJsonObject(args);
|
|
393
|
+
return [{ ...call, ...(typeof argsObject?.script === "string" ? { script: argsObject.script } : validatedScript ? { script: validatedScript } : {}) }];
|
|
394
|
+
}));
|
|
395
|
+
}
|
|
396
|
+
function validationErrorCode(text) {
|
|
397
|
+
const prefixed = text.match(new RegExp(`${CAPTURE_ERROR_PREFIX}([A-Z_]+):`))?.[1];
|
|
398
|
+
if (prefixed && ERROR_CODES.includes(prefixed))
|
|
399
|
+
return prefixed;
|
|
400
|
+
return ERROR_CODES.find((code) => text.includes(code));
|
|
401
|
+
}
|
|
402
|
+
export function captureValidationReports(oracle, calls) {
|
|
403
|
+
const errors = [];
|
|
404
|
+
const reports = calls.map((call, callIndex) => {
|
|
405
|
+
const result = resultForCall(oracle, call, callIndex);
|
|
406
|
+
const details = result ? asJsonObject(result.details) : undefined;
|
|
407
|
+
const validation = details && isObject(details.validation) ? details.validation : undefined;
|
|
408
|
+
if (result && !result.isError && details?.captureIdentity === CAPTURE_IDENTITY && details.realWorkflowAgentsLaunched === 0 && validation?.valid === true)
|
|
409
|
+
return { callIndex, valid: true };
|
|
410
|
+
const text = result?.text ?? "";
|
|
411
|
+
if (result?.isError) {
|
|
412
|
+
const errorCode = validationErrorCode(text);
|
|
413
|
+
return { callIndex, valid: false, ...(errorCode ? { errorCode } : {}), ...(text ? { message: text } : {}) };
|
|
414
|
+
}
|
|
415
|
+
errors.push(`workflow ${String(callIndex)} did not return a recognized production-validation capture result`);
|
|
416
|
+
return { callIndex, valid: false, ...(text ? { message: text } : {}) };
|
|
417
|
+
});
|
|
418
|
+
if (oracle.workflowToolResults.length !== calls.length)
|
|
419
|
+
errors.push(`capture had ${String(oracle.workflowToolResults.length)} workflow tool results for ${String(calls.length)} calls`);
|
|
420
|
+
return { reports, errors, verified: errors.length === 0 };
|
|
421
|
+
}
|
|
422
|
+
export function evalExpectationErrors(oracle, expectations) {
|
|
423
|
+
const errors = [];
|
|
424
|
+
if (expectations.firstSignificantAction && JSON.stringify(oracle.firstSignificantAction) !== JSON.stringify(expectations.firstSignificantAction))
|
|
425
|
+
errors.push(`first significant action was ${JSON.stringify(oracle.firstSignificantAction)}`);
|
|
426
|
+
if (expectations.firstTool !== undefined && oracle.firstTool !== expectations.firstTool)
|
|
427
|
+
errors.push(`first tool was ${String(oracle.firstTool)}`);
|
|
428
|
+
if (expectations.firstBatchToolSequence && !sequenceMatches(oracle.firstBatchToolSequence, expectations.firstBatchToolSequence))
|
|
429
|
+
errors.push(`first batch tools were ${JSON.stringify(oracle.firstBatchToolSequence)}`);
|
|
430
|
+
if (expectations.parentToolSequence && !sequenceMatches(oracle.parentToolSequence, expectations.parentToolSequence))
|
|
431
|
+
errors.push(`parent tools were ${JSON.stringify(oracle.parentToolSequence)}`);
|
|
432
|
+
if (expectations.workflowCallCount !== undefined) {
|
|
433
|
+
const { min, max } = typeof expectations.workflowCallCount === "number" ? { min: expectations.workflowCallCount, max: expectations.workflowCallCount } : expectations.workflowCallCount;
|
|
434
|
+
if (min !== undefined && oracle.workflowCallCount < min || max !== undefined && oracle.workflowCallCount > max)
|
|
435
|
+
errors.push(`workflow call count was ${String(oracle.workflowCallCount)}`);
|
|
436
|
+
}
|
|
437
|
+
return errors;
|
|
438
|
+
}
|
|
439
|
+
export function replayExpectationErrors(calls, reports, expectations) {
|
|
440
|
+
const errors = [];
|
|
441
|
+
const staticCalls = calls.flatMap((call) => { try {
|
|
442
|
+
return call.script ? inspectWorkflowScript(call.script) : [];
|
|
443
|
+
}
|
|
444
|
+
catch {
|
|
445
|
+
return [];
|
|
446
|
+
} });
|
|
447
|
+
for (const kind of expectations.requiredOperations ?? [])
|
|
448
|
+
if (!staticCalls.some((call) => call.kind === kind))
|
|
449
|
+
errors.push(`replay had no ${kind} call`);
|
|
450
|
+
for (const role of expectations.requiredRoles ?? [])
|
|
451
|
+
if (!staticCalls.some((call) => call.kind === "agent" && call.role === role))
|
|
452
|
+
errors.push(`replay had no agent role ${role}`);
|
|
453
|
+
const agentCalls = reports.flatMap((report) => report.trace?.agentCalls ?? []);
|
|
454
|
+
if (expectations.minimumAgentCalls !== undefined && agentCalls.length < expectations.minimumAgentCalls)
|
|
455
|
+
errors.push(`replay had ${String(agentCalls.length)} agent calls`);
|
|
456
|
+
for (const policy of expectations.agentPolicies ?? []) {
|
|
457
|
+
const call = agentCalls[policy.callIndex];
|
|
458
|
+
if (!call) {
|
|
459
|
+
errors.push(`agent policy ${String(policy.callIndex)} had no matching call`);
|
|
460
|
+
continue;
|
|
461
|
+
}
|
|
462
|
+
if (policy.role !== undefined && call.options.role !== policy.role)
|
|
463
|
+
errors.push(`agent ${String(policy.callIndex)} role was ${JSON.stringify(call.options.role)}`);
|
|
464
|
+
if (policy.model !== undefined && call.options.model !== policy.model)
|
|
465
|
+
errors.push(`agent ${String(policy.callIndex)} model was ${JSON.stringify(call.options.model)}`);
|
|
466
|
+
for (const option of policy.forbidOptions ?? [])
|
|
467
|
+
if (Object.prototype.hasOwnProperty.call(call.options, option))
|
|
468
|
+
errors.push(`agent ${String(policy.callIndex)} unexpectedly specified ${option}`);
|
|
469
|
+
if (policy.tools) {
|
|
470
|
+
const present = Object.prototype.hasOwnProperty.call(call.options, "tools");
|
|
471
|
+
const tools = Array.isArray(call.options.tools) ? call.options.tools : [];
|
|
472
|
+
if (policy.tools.mode === "omitted" && present)
|
|
473
|
+
errors.push(`agent ${String(policy.callIndex)} tools were not omitted`);
|
|
474
|
+
if (policy.tools.mode === "empty" && (!present || tools.length !== 0))
|
|
475
|
+
errors.push(`agent ${String(policy.callIndex)} tools were not explicitly empty`);
|
|
476
|
+
if (policy.tools.mode === "exact" && (!present || JSON.stringify(tools) !== JSON.stringify(policy.tools.values ?? [])))
|
|
477
|
+
errors.push(`agent ${String(policy.callIndex)} tools were ${JSON.stringify(tools)}`);
|
|
478
|
+
}
|
|
479
|
+
}
|
|
480
|
+
const runtimeSchemas = agentCalls.flatMap(({ options }) => isObject(options.outputSchema) ? [options.outputSchema] : []);
|
|
481
|
+
const staticSchemas = staticCalls.flatMap((call) => call.kind === "agent" && call.outputSchema ? [call.outputSchema] : []);
|
|
482
|
+
const outputSchemas = runtimeSchemas.length ? runtimeSchemas : staticSchemas;
|
|
483
|
+
if (expectations.requireOutputSchema) {
|
|
484
|
+
if (typeof expectations.requireOutputSchema === "boolean") {
|
|
485
|
+
if (!outputSchemas.length)
|
|
486
|
+
errors.push("replay had no outputSchema");
|
|
487
|
+
}
|
|
488
|
+
else {
|
|
489
|
+
const shape = expectations.requireOutputSchema;
|
|
490
|
+
if (shape.count !== undefined && outputSchemas.length !== shape.count)
|
|
491
|
+
errors.push(`replay had ${String(outputSchemas.length)} output schemas`);
|
|
492
|
+
if (shape.minCount !== undefined && outputSchemas.length < shape.minCount)
|
|
493
|
+
errors.push(`replay had ${String(outputSchemas.length)} output schemas`);
|
|
494
|
+
if (!outputSchemas.some((schemaValue) => matchesOutputSchemaShape(shape, schemaValue)))
|
|
495
|
+
errors.push("replay had no outputSchema matching the required shape");
|
|
496
|
+
}
|
|
497
|
+
}
|
|
498
|
+
for (const [expectedIndex, expected] of (expectations.expectedResults ?? []).entries()) {
|
|
499
|
+
const index = expected.workflowIndex ?? expectedIndex;
|
|
500
|
+
const report = reports[index];
|
|
501
|
+
if (!report || report.error || report.result === undefined) {
|
|
502
|
+
errors.push(`replay result ${String(index)} was unavailable`);
|
|
503
|
+
continue;
|
|
504
|
+
}
|
|
505
|
+
if (expected.equals !== undefined && !equalJson(expected.equals, report.result))
|
|
506
|
+
errors.push(`replay result ${String(index)} did not equal the expected JSON`);
|
|
507
|
+
if (expected.match && !matchesJsonResult(expected.match, report.result))
|
|
508
|
+
errors.push(`replay result ${String(index)} did not match the expected shape`);
|
|
509
|
+
}
|
|
510
|
+
return errors;
|
|
511
|
+
}
|
|
512
|
+
function staticCallRows(calls) {
|
|
513
|
+
return calls.flatMap(({ script }) => {
|
|
514
|
+
if (!script)
|
|
515
|
+
return [];
|
|
516
|
+
try {
|
|
517
|
+
return inspectWorkflowScript(script).map((call) => ({ call, source: script.slice(call.start, call.end) }));
|
|
518
|
+
}
|
|
519
|
+
catch {
|
|
520
|
+
return [];
|
|
521
|
+
}
|
|
522
|
+
});
|
|
523
|
+
}
|
|
524
|
+
function matchesAgentExpectation(call, expected) {
|
|
525
|
+
if (!call)
|
|
526
|
+
return false;
|
|
527
|
+
return (expected.role === undefined || call.role === expected.role) && (expected.model === undefined || call.model === expected.model) && (expected.promptIncludes === undefined || call.prompt?.includes(expected.promptIncludes) === true) && (expected.execution === undefined || (call.execution ?? "sequential") === expected.execution);
|
|
528
|
+
}
|
|
529
|
+
function structureGroupKey(call, kind) {
|
|
530
|
+
const scopes = (call.structure ?? []).filter((scope) => scope.kind === kind);
|
|
531
|
+
return scopes.length ? JSON.stringify(scopes.map(({ kind: scopeKind, name }) => [scopeKind, name])) : undefined;
|
|
532
|
+
}
|
|
533
|
+
function distinctAgentMatches(rows, expected) {
|
|
534
|
+
const used = new Set();
|
|
535
|
+
return expected.every((selector) => {
|
|
536
|
+
const index = rows.findIndex((call, candidateIndex) => !used.has(candidateIndex) && matchesAgentExpectation(call, selector));
|
|
537
|
+
if (index < 0)
|
|
538
|
+
return false;
|
|
539
|
+
used.add(index);
|
|
540
|
+
return true;
|
|
541
|
+
});
|
|
542
|
+
}
|
|
543
|
+
function agentStructureMatches(rows, expected) {
|
|
544
|
+
const agents = rows.filter(({ call }) => call.kind === "agent").map(({ call }) => call);
|
|
545
|
+
if (expected.execution === "parallel") {
|
|
546
|
+
const groups = new Map();
|
|
547
|
+
for (const call of agents) {
|
|
548
|
+
if ((call.execution ?? "sequential") !== "parallel" || expected.operation && !(call.structure ?? []).some((scope) => scope.kind === expected.operation))
|
|
549
|
+
continue;
|
|
550
|
+
const key = structureGroupKey(call, "parallel");
|
|
551
|
+
if (key)
|
|
552
|
+
groups.set(key, [...(groups.get(key) ?? []), call]);
|
|
553
|
+
}
|
|
554
|
+
return [...groups.values()].some((group) => distinctAgentMatches(group, expected.agents));
|
|
555
|
+
}
|
|
556
|
+
const candidates = agents.filter((call) => (call.execution ?? "sequential") === "sequential" && (!expected.operation || (call.structure ?? []).some((scope) => scope.kind === expected.operation)));
|
|
557
|
+
let start = 0;
|
|
558
|
+
return expected.agents.every((selector) => {
|
|
559
|
+
const index = candidates.findIndex((call, candidateIndex) => candidateIndex >= start && matchesAgentExpectation(call, selector));
|
|
560
|
+
if (index < 0)
|
|
561
|
+
return false;
|
|
562
|
+
start = index + 1;
|
|
563
|
+
return true;
|
|
564
|
+
});
|
|
565
|
+
}
|
|
566
|
+
export function staticExpectationResults(calls, expectations) {
|
|
567
|
+
const rows = staticCallRows(calls);
|
|
568
|
+
const staticCalls = rows.map(({ call }) => call);
|
|
569
|
+
const agentRows = rows.filter(({ call }) => call.kind === "agent");
|
|
570
|
+
const agentCalls = agentRows.map(({ call }) => call);
|
|
571
|
+
const results = [];
|
|
572
|
+
const add = (id, pass, evidence) => { results.push({ id, pass, evidence }); };
|
|
573
|
+
if (calls.some((call) => !call.script))
|
|
574
|
+
add("script", false, "A selected workflow had no resolved script.");
|
|
575
|
+
for (const kind of expectations.requiredOperations ?? [])
|
|
576
|
+
add(`operation:${kind}`, staticCalls.some((call) => call.kind === kind), `Required ${kind} operation.`);
|
|
577
|
+
for (const kind of expectations.forbiddenOperations ?? [])
|
|
578
|
+
add(`forbidden-operation:${kind}`, !staticCalls.some((call) => call.kind === kind), `Forbidden ${kind} operation.`);
|
|
579
|
+
for (const role of expectations.requiredRoles ?? [])
|
|
580
|
+
add(`role:${role}`, agentCalls.some((call) => call.role === role), `Required agent role ${role}.`);
|
|
581
|
+
if (expectations.minimumAgentCalls !== undefined)
|
|
582
|
+
add("minimum-agent-calls", agentCalls.length >= expectations.minimumAgentCalls, `Found ${String(agentCalls.length)} static agent calls; required ${String(expectations.minimumAgentCalls)}.`);
|
|
583
|
+
for (const policy of expectations.agentPolicies ?? []) {
|
|
584
|
+
const call = agentCalls[policy.callIndex];
|
|
585
|
+
const options = call?.options ?? {};
|
|
586
|
+
const optionKeys = new Set(call?.optionKeys ?? Object.keys(options));
|
|
587
|
+
const failures = [];
|
|
588
|
+
if (!call)
|
|
589
|
+
failures.push("missing call");
|
|
590
|
+
if (call && policy.role !== undefined && call.role !== policy.role)
|
|
591
|
+
failures.push(`role ${JSON.stringify(call.role)}`);
|
|
592
|
+
if (call && policy.model !== undefined && call.model !== policy.model)
|
|
593
|
+
failures.push(`model ${JSON.stringify(call.model)}`);
|
|
594
|
+
for (const option of policy.forbidOptions ?? [])
|
|
595
|
+
if (optionKeys.has(option))
|
|
596
|
+
failures.push(`specified ${option}`);
|
|
597
|
+
if (policy.tools) {
|
|
598
|
+
const present = optionKeys.has("tools");
|
|
599
|
+
const tools = Array.isArray(options.tools) ? options.tools : [];
|
|
600
|
+
if (policy.tools.mode === "omitted" && present)
|
|
601
|
+
failures.push("tools present");
|
|
602
|
+
if (policy.tools.mode === "empty" && (!present || tools.length !== 0))
|
|
603
|
+
failures.push("tools not empty");
|
|
604
|
+
if (policy.tools.mode === "exact" && (!present || JSON.stringify(tools) !== JSON.stringify(policy.tools.values ?? [])))
|
|
605
|
+
failures.push(`tools ${JSON.stringify(tools)}`);
|
|
606
|
+
}
|
|
607
|
+
add(`agent-policy:${String(policy.callIndex)}`, failures.length === 0, failures.length ? failures.join(", ") : "Agent policy matched.");
|
|
608
|
+
}
|
|
609
|
+
if (expectations.requireOutputSchema) {
|
|
610
|
+
const schemas = agentCalls.flatMap((call) => call.outputSchema ? [call.outputSchema] : []);
|
|
611
|
+
const shape = expectations.requireOutputSchema;
|
|
612
|
+
const pass = typeof shape === "boolean" ? schemas.length > 0 : (shape.count === undefined || schemas.length === shape.count) && (shape.minCount === undefined || schemas.length >= shape.minCount) && schemas.some((schemaValue) => matchesOutputSchemaShape(shape, schemaValue));
|
|
613
|
+
add("output-schema", pass, `Found ${String(schemas.length)} matching candidate schemas.`);
|
|
614
|
+
}
|
|
615
|
+
if (expectations.requiredAgentOrder) {
|
|
616
|
+
const order = expectations.requiredAgentOrder;
|
|
617
|
+
const pass = order.every((expected, index) => matchesAgentExpectation(agentCalls[index], expected));
|
|
618
|
+
add("agent-order", pass, `Checked ${String(order.length)} ordered agent selectors.`);
|
|
619
|
+
}
|
|
620
|
+
for (const [index, structure] of (expectations.requiredAgentStructures ?? []).entries()) {
|
|
621
|
+
const pass = agentStructureMatches(rows, structure);
|
|
622
|
+
add(`agent-structure:${String(index)}`, pass, `Required ${structure.execution} agent structure${structure.operation ? ` in ${structure.operation}` : ""}.`);
|
|
623
|
+
}
|
|
624
|
+
for (const flow of expectations.requiredDataFlow ?? []) {
|
|
625
|
+
const source = agentRows[flow.toAgentIndex]?.source ?? "";
|
|
626
|
+
const escaped = flow.binding.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
|
|
627
|
+
const pass = new RegExp(`prompt\\s*\\([\\s\\S]*\\{${escaped}\\}[\\s\\S]*\\{[\\s\\S]*\\b${escaped}\\b`).test(source);
|
|
628
|
+
add(`data-flow:${flow.binding}:${String(flow.toAgentIndex)}`, pass, `Expected prompt interpolation of ${flow.binding} into agent ${String(flow.toAgentIndex)}.`);
|
|
629
|
+
}
|
|
630
|
+
return results;
|
|
631
|
+
}
|
|
632
|
+
function combinations(values, count, start = 0, prefix = []) {
|
|
633
|
+
if (prefix.length === count)
|
|
634
|
+
return [[...prefix]];
|
|
635
|
+
const output = [];
|
|
636
|
+
for (let index = start; index <= values.length - (count - prefix.length); index += 1)
|
|
637
|
+
output.push(...combinations(values, count, index + 1, [...prefix, values[index]]));
|
|
638
|
+
return output;
|
|
639
|
+
}
|
|
640
|
+
export function selectStaticCandidate(calls, validations, expectations, requiredCount = 1) {
|
|
641
|
+
if (requiredCount === 0)
|
|
642
|
+
return { callIndices: [], reports: [] };
|
|
643
|
+
const validIndices = validations.filter(({ valid }) => valid).map(({ callIndex }) => callIndex);
|
|
644
|
+
const reports = [];
|
|
645
|
+
for (const callIndices of combinations(validIndices, requiredCount)) {
|
|
646
|
+
const criteria = staticExpectationResults(callIndices.map((index) => calls[index]), expectations);
|
|
647
|
+
const report = { callIndices, criteria, passed: criteria.every(({ pass }) => pass) };
|
|
648
|
+
reports.push(report);
|
|
649
|
+
if (report.passed)
|
|
650
|
+
return { callIndices, reports };
|
|
651
|
+
}
|
|
652
|
+
return { callIndices: [], reports };
|
|
653
|
+
}
|
|
654
|
+
export function assertEvalScriptSafe(script) {
|
|
655
|
+
for (const call of inspectWorkflowScript(script))
|
|
656
|
+
if (call.kind === "agent" && typeof call.retries === "number" && call.retries > 0)
|
|
657
|
+
throw new WorkflowError("INVALID_METADATA", "Evaluation scripts must not request retries > 0");
|
|
658
|
+
}
|
|
659
|
+
function exampleForSchema(schema) {
|
|
660
|
+
if (Object.prototype.hasOwnProperty.call(schema, "const") && isJson(schema.const))
|
|
661
|
+
return schema.const;
|
|
662
|
+
if (Array.isArray(schema.enum) && schema.enum.length && isJson(schema.enum[0]))
|
|
663
|
+
return schema.enum[0];
|
|
664
|
+
for (const key of ["anyOf", "oneOf", "allOf"]) {
|
|
665
|
+
const choices = schema[key];
|
|
666
|
+
if (Array.isArray(choices) && isObject(choices[0]))
|
|
667
|
+
return exampleForSchema(choices[0]);
|
|
668
|
+
}
|
|
669
|
+
const type = typeof schema.type === "string" ? schema.type : Array.isArray(schema.type) ? schema.type.find((item) => typeof item === "string" && item !== "null") : undefined;
|
|
670
|
+
if (type === "object" || isObject(schema.properties)) {
|
|
671
|
+
const properties = isObject(schema.properties) ? schema.properties : {};
|
|
672
|
+
return Object.fromEntries(Object.entries(properties).flatMap(([key, value]) => isObject(value) ? [[key, exampleForSchema(value)]] : []));
|
|
673
|
+
}
|
|
674
|
+
if (type === "array")
|
|
675
|
+
return [];
|
|
676
|
+
if (type === "number" || type === "integer")
|
|
677
|
+
return 1;
|
|
678
|
+
if (type === "boolean")
|
|
679
|
+
return true;
|
|
680
|
+
return "fake";
|
|
681
|
+
}
|
|
682
|
+
export function matchesJsonSchema(schema, value) { return Value.Check(schema, value); }
|
|
683
|
+
export async function replayWorkflowScript(script, args = null, signal) {
|
|
684
|
+
assertEvalScriptSafe(script);
|
|
685
|
+
const agentCalls = [];
|
|
686
|
+
const phases = [];
|
|
687
|
+
const logs = [];
|
|
688
|
+
let active = 0;
|
|
689
|
+
let maxConcurrentAgents = 0;
|
|
690
|
+
const execution = runWorkflow(script, args, {
|
|
691
|
+
agent: async (prompt, options, agentSignal, identity) => {
|
|
692
|
+
if (typeof options.retries === "number" && options.retries > 0)
|
|
693
|
+
throw new WorkflowError("INVALID_METADATA", "Evaluation retries are disabled");
|
|
694
|
+
agentCalls.push({ prompt, options: structuredClone(options), identity });
|
|
695
|
+
active += 1;
|
|
696
|
+
maxConcurrentAgents = Math.max(maxConcurrentAgents, active);
|
|
697
|
+
try {
|
|
698
|
+
await new Promise((resolve) => setTimeout(resolve, 10));
|
|
699
|
+
const outputSchema = options.outputSchema;
|
|
700
|
+
if (isObject(outputSchema)) {
|
|
701
|
+
const value = exampleForSchema(outputSchema);
|
|
702
|
+
if (!matchesJsonSchema(outputSchema, value))
|
|
703
|
+
throw new WorkflowError("RESULT_INVALID", "Fake agent result does not match outputSchema");
|
|
704
|
+
return value;
|
|
705
|
+
}
|
|
706
|
+
return `fake:${prompt}`;
|
|
707
|
+
}
|
|
708
|
+
finally {
|
|
709
|
+
active -= 1;
|
|
710
|
+
}
|
|
711
|
+
},
|
|
712
|
+
phase: (name) => { phases.push(name); },
|
|
713
|
+
log: (message) => { logs.push(message); },
|
|
714
|
+
}, signal);
|
|
715
|
+
const result = await execution.result;
|
|
716
|
+
return { result, trace: { agentCalls, phases, logs, maxConcurrentAgents } };
|
|
717
|
+
}
|
|
718
|
+
const reportProgress = (message) => { if (process.env.PI_WORKFLOW_EVAL_PROGRESS === "1")
|
|
719
|
+
process.stderr.write(`[eval] ${message}\n`); };
|
|
720
|
+
function terminateProcess(child, signal) {
|
|
721
|
+
try {
|
|
722
|
+
if (child.pid && globalThis.process.platform !== "win32")
|
|
723
|
+
globalThis.process.kill(-child.pid, signal);
|
|
724
|
+
else
|
|
725
|
+
child.kill(signal);
|
|
726
|
+
return true;
|
|
727
|
+
}
|
|
728
|
+
catch {
|
|
729
|
+
return false;
|
|
730
|
+
}
|
|
731
|
+
}
|
|
732
|
+
async function killProcessGroup(child) {
|
|
733
|
+
let terminated = terminateProcess(child, "SIGTERM");
|
|
734
|
+
await new Promise((resolve) => setTimeout(resolve, 100));
|
|
735
|
+
if (child.exitCode === null)
|
|
736
|
+
terminated = terminateProcess(child, "SIGKILL") || terminated;
|
|
737
|
+
return terminated;
|
|
738
|
+
}
|
|
739
|
+
async function runPiCapture(input, cwd, home, sessionDir, sessionId) {
|
|
740
|
+
const args = ["--offline", "--no-extensions", "--extension", fileURLToPath(new URL("./eval-capture-extension.js", import.meta.url)), "--no-skills", "--skill", resolveWorkflowSkillPath(), "--no-context-files", "--no-builtin-tools", "--tools", SAFE_PARENT_EVAL_TOOLS.join(","), "--mode", "json", "--session-dir", sessionDir, "--session-id", sessionId];
|
|
741
|
+
if (input.model.includes("/"))
|
|
742
|
+
args.push("--model", input.model);
|
|
743
|
+
else {
|
|
744
|
+
if (input.provider)
|
|
745
|
+
args.push("--provider", input.provider);
|
|
746
|
+
args.push("--model", input.model);
|
|
747
|
+
}
|
|
748
|
+
args.push("--thinking", input.thinking ?? "off");
|
|
749
|
+
args.push("--print", input.case.prompt);
|
|
750
|
+
const controller = new AbortController();
|
|
751
|
+
let timedOut = false;
|
|
752
|
+
let budgetExceeded = false;
|
|
753
|
+
let processGroupTerminated = false;
|
|
754
|
+
let stoppedIntentionally = false;
|
|
755
|
+
let workflowCallSeen = false;
|
|
756
|
+
let streamCost = 0;
|
|
757
|
+
let lineBuffer = "";
|
|
758
|
+
let stderr = "";
|
|
759
|
+
let spawnError;
|
|
760
|
+
let killPromise;
|
|
761
|
+
const child = spawn(input.piCommand ?? process.env.PI_WORKFLOW_EVAL_PI ?? "pi", args, { cwd, env: { ...process.env, HOME: home, PI_CODING_AGENT_DIR: join(home, ".pi", "agent"), PI_CODING_AGENT_SESSION_DIR: sessionDir, PI_OFFLINE: "1", PI_SKIP_VERSION_CHECK: "1", PI_TELEMETRY: "0" }, detached: process.platform !== "win32", stdio: ["ignore", "pipe", "pipe"], signal: controller.signal });
|
|
762
|
+
const requestKill = () => { killPromise ??= killProcessGroup(child); return killPromise; };
|
|
763
|
+
const stopIntentionally = () => { if (stoppedIntentionally)
|
|
764
|
+
return; stoppedIntentionally = true; void requestKill().then((terminated) => { processGroupTerminated ||= terminated; }); };
|
|
765
|
+
const isValidatedCapture = (value) => {
|
|
766
|
+
if (!isObject(value) || value.toolName !== "workflow" || value.isError === true)
|
|
767
|
+
return false;
|
|
768
|
+
const details = isObject(value.details) ? value.details : undefined;
|
|
769
|
+
const validation = details && isObject(details.validation) ? details.validation : undefined;
|
|
770
|
+
return details?.captureIdentity === CAPTURE_IDENTITY && details.realWorkflowAgentsLaunched === 0 && validation?.valid === true;
|
|
771
|
+
};
|
|
772
|
+
const inspectLine = (line) => {
|
|
773
|
+
try {
|
|
774
|
+
const event = JSON.parse(line);
|
|
775
|
+
if (!isObject(event))
|
|
776
|
+
return;
|
|
777
|
+
if (event.type === "message_end" && isObject(event.message)) {
|
|
778
|
+
const tools = Array.isArray(event.message.content) ? event.message.content.flatMap((part) => isObject(part) && part.type === "toolCall" && typeof part.name === "string" ? [part.name] : []) : [];
|
|
779
|
+
if (tools.includes("workflow"))
|
|
780
|
+
workflowCallSeen = true;
|
|
781
|
+
if (tools.length)
|
|
782
|
+
reportProgress(`${input.case.id}: parent tools: ${tools.join(", ")}`);
|
|
783
|
+
const usage = usageFrom(event.message);
|
|
784
|
+
if (!usage)
|
|
785
|
+
return;
|
|
786
|
+
streamCost += usage.cost;
|
|
787
|
+
reportProgress(`${input.case.id}: parent turn complete, ${String(usage.totalTokens)} tokens, $${streamCost.toFixed(4)} total`);
|
|
788
|
+
if (streamCost > input.maxCost && !budgetExceeded) {
|
|
789
|
+
budgetExceeded = true;
|
|
790
|
+
controller.abort();
|
|
791
|
+
void requestKill().then((terminated) => { processGroupTerminated ||= terminated; });
|
|
792
|
+
}
|
|
793
|
+
return;
|
|
794
|
+
}
|
|
795
|
+
if (event.type === "turn_end") {
|
|
796
|
+
const toolResults = Array.isArray(event.toolResults) ? event.toolResults : [];
|
|
797
|
+
if (toolResults.some((result) => isObject(result) && result.toolName === "workflow"))
|
|
798
|
+
workflowCallSeen = true;
|
|
799
|
+
if (toolResults.some(isValidatedCapture))
|
|
800
|
+
stopIntentionally();
|
|
801
|
+
return;
|
|
802
|
+
}
|
|
803
|
+
if (event.type === "agent_end" && !workflowCallSeen) {
|
|
804
|
+
const messages = Array.isArray(event.messages) ? event.messages : [];
|
|
805
|
+
let assistant;
|
|
806
|
+
for (let index = messages.length - 1; index >= 0; index -= 1) {
|
|
807
|
+
const message = messages[index];
|
|
808
|
+
if (isObject(message) && message.role === "assistant") {
|
|
809
|
+
assistant = message;
|
|
810
|
+
break;
|
|
811
|
+
}
|
|
812
|
+
}
|
|
813
|
+
if (isObject(assistant) && (assistant.stopReason === "error" || assistant.stopReason === "aborted"))
|
|
814
|
+
return;
|
|
815
|
+
stopIntentionally();
|
|
816
|
+
}
|
|
817
|
+
}
|
|
818
|
+
catch { /* The JSON stream may contain a diagnostic line. */ }
|
|
819
|
+
};
|
|
820
|
+
child.stdout.on("data", (chunk) => { lineBuffer += chunk.toString(); const lines = lineBuffer.split("\n"); lineBuffer = lines.pop() ?? ""; for (const line of lines)
|
|
821
|
+
if (line)
|
|
822
|
+
inspectLine(line); });
|
|
823
|
+
child.stderr.on("data", (chunk) => { stderr = `${stderr}${chunk.toString()}`.slice(-64_000); });
|
|
824
|
+
child.once("error", (error) => { spawnError = error.message; });
|
|
825
|
+
const close = new Promise((resolve) => { child.once("close", (code) => { resolve(code); }); });
|
|
826
|
+
const timer = input.case.timeoutMs === undefined ? undefined : setTimeout(() => { timedOut = true; controller.abort(); void requestKill().then((terminated) => { processGroupTerminated ||= terminated; }); }, input.case.timeoutMs);
|
|
827
|
+
const exitCode = await close;
|
|
828
|
+
if (timer)
|
|
829
|
+
clearTimeout(timer);
|
|
830
|
+
if (lineBuffer)
|
|
831
|
+
inspectLine(lineBuffer);
|
|
832
|
+
if (killPromise)
|
|
833
|
+
processGroupTerminated ||= await killPromise;
|
|
834
|
+
return { exitCode, timedOut, budgetExceeded, processGroupTerminated, stoppedIntentionally, stderr, ...(spawnError ? { error: spawnError } : {}) };
|
|
835
|
+
}
|
|
836
|
+
function addUsage(left, right) {
|
|
837
|
+
const models = new Map();
|
|
838
|
+
for (const item of [...left.models, ...right.models])
|
|
839
|
+
models.set(item.model, (models.get(item.model) ?? 0) + item.cost);
|
|
840
|
+
return { input: left.input + right.input, output: left.output + right.output, cacheRead: left.cacheRead + right.cacheRead, cacheWrite: left.cacheWrite + right.cacheWrite, totalTokens: left.totalTokens + right.totalTokens, cost: left.cost + right.cost, models: [...models].map(([model, cost]) => ({ model, cost })) };
|
|
841
|
+
}
|
|
842
|
+
export function parseSemanticJudge(raw, criteria) {
|
|
843
|
+
const cleaned = raw.trim().replace(/^```(?:json)?\s*/i, "").replace(/\s*```$/, "");
|
|
844
|
+
const parsed = JSON.parse(cleaned);
|
|
845
|
+
const parsedCriteria = isObject(parsed) ? parsed.criteria : undefined;
|
|
846
|
+
if (!Array.isArray(parsedCriteria))
|
|
847
|
+
throw new Error("Semantic judge must return a criteria array.");
|
|
848
|
+
const criteriaValues = parsedCriteria;
|
|
849
|
+
return criteria.map(({ id }) => {
|
|
850
|
+
const item = criteriaValues.find((candidate) => isObject(candidate) && candidate.id === id);
|
|
851
|
+
if (!isObject(item) || typeof item.pass !== "boolean" || typeof item.evidence !== "string" || !item.evidence.trim())
|
|
852
|
+
throw new Error(`Semantic judge omitted criterion ${id}.`);
|
|
853
|
+
return { id, pass: item.pass, evidence: item.evidence.trim() };
|
|
854
|
+
});
|
|
855
|
+
}
|
|
856
|
+
function semanticJudgePrompt(evalCase, calls, cwd, home) {
|
|
857
|
+
const roles = loadAgentDefinitions(cwd, join(home, ".pi", "agent"));
|
|
858
|
+
const usedRoles = new Set(calls.flatMap(({ script }) => { try {
|
|
859
|
+
return script ? inspectWorkflowScript(script).flatMap((call) => call.kind === "agent" && call.role ? [call.role] : []) : [];
|
|
860
|
+
}
|
|
861
|
+
catch {
|
|
862
|
+
return [];
|
|
863
|
+
} }));
|
|
864
|
+
const roleText = [...usedRoles].map((role) => `${role}: ${roles[role]?.description ?? "no description"}`).join("\n") || "none";
|
|
865
|
+
const docs = "agent(prompt, options) delegates; parallel(name, tasks) runs independent tasks concurrently; pipeline(name, items, stages) applies ordered stages; prompt(template, data) carries values into prompts. A role owns model/thinking/tools policy.";
|
|
866
|
+
return `Judge whether the captured workflow design satisfies each criterion. Do not execute it. Return only JSON: {"criteria":[{"id":"criterion id","pass":true,"evidence":"specific script evidence"}]}.\n\nOriginal request:\n${evalCase.prompt}\n\nCriteria:\n${JSON.stringify(evalCase.semanticCriteria ?? [])}\n\nDSL:\n${docs}\n\nRelevant roles:\n${roleText}\n\nCaptured workflow script(s):\n${calls.map((call, index) => `--- ${String(index)} ---\n${call.script ?? "<missing>"}`).join("\n")}`;
|
|
867
|
+
}
|
|
868
|
+
async function runSemanticJudge(input, calls, cwd, home, sessionDir, maxCost) {
|
|
869
|
+
const args = ["--offline", "--no-extensions", "--no-skills", "--no-context-files", "--no-tools", "--mode", "json", "--session-dir", sessionDir, "--session-id", randomUUID()];
|
|
870
|
+
if (input.model.includes("/"))
|
|
871
|
+
args.push("--model", input.model);
|
|
872
|
+
else {
|
|
873
|
+
if (input.provider)
|
|
874
|
+
args.push("--provider", input.provider);
|
|
875
|
+
args.push("--model", input.model);
|
|
876
|
+
}
|
|
877
|
+
args.push("--thinking", "off", "--print", semanticJudgePrompt(input.case, calls, cwd, home));
|
|
878
|
+
const controller = new AbortController();
|
|
879
|
+
let timedOut = false;
|
|
880
|
+
let budgetExceeded = false;
|
|
881
|
+
let processGroupTerminated = false;
|
|
882
|
+
let stderr = "";
|
|
883
|
+
let spawnError;
|
|
884
|
+
let killPromise;
|
|
885
|
+
let lineBuffer = "";
|
|
886
|
+
let raw = "";
|
|
887
|
+
let usage = emptyAccounting();
|
|
888
|
+
const child = spawn(input.piCommand ?? process.env.PI_WORKFLOW_EVAL_PI ?? "pi", args, { cwd, env: { ...process.env, HOME: home, PI_CODING_AGENT_DIR: join(home, ".pi", "agent"), PI_CODING_AGENT_SESSION_DIR: sessionDir, PI_OFFLINE: "1", PI_SKIP_VERSION_CHECK: "1", PI_TELEMETRY: "0" }, detached: process.platform !== "win32", stdio: ["ignore", "pipe", "pipe"], signal: controller.signal });
|
|
889
|
+
const requestKill = () => { killPromise ??= killProcessGroup(child); return killPromise; };
|
|
890
|
+
const inspectLine = (line) => {
|
|
891
|
+
try {
|
|
892
|
+
const event = JSON.parse(line);
|
|
893
|
+
if (!isObject(event) || event.type !== "message_end" || !isObject(event.message) || event.message.role !== "assistant")
|
|
894
|
+
return;
|
|
895
|
+
const measured = usageFrom(event.message);
|
|
896
|
+
if (measured)
|
|
897
|
+
usage = addUsage(usage, { input: measured.input, output: measured.output, cacheRead: measured.cacheRead, cacheWrite: measured.cacheWrite, totalTokens: measured.totalTokens, cost: measured.cost, models: [{ model: measured.model, cost: measured.cost }] });
|
|
898
|
+
if (Array.isArray(event.message.content))
|
|
899
|
+
raw = event.message.content.flatMap((part) => isObject(part) && part.type === "text" && typeof part.text === "string" ? [part.text] : []).join("\n");
|
|
900
|
+
if (usage.cost > maxCost && !budgetExceeded) {
|
|
901
|
+
budgetExceeded = true;
|
|
902
|
+
controller.abort();
|
|
903
|
+
void requestKill().then((terminated) => { processGroupTerminated ||= terminated; });
|
|
904
|
+
}
|
|
905
|
+
}
|
|
906
|
+
catch { /* Ignore diagnostics in the JSON stream. */ }
|
|
907
|
+
};
|
|
908
|
+
child.stdout.on("data", (chunk) => { lineBuffer += chunk.toString(); const lines = lineBuffer.split("\n"); lineBuffer = lines.pop() ?? ""; for (const line of lines)
|
|
909
|
+
if (line)
|
|
910
|
+
inspectLine(line); });
|
|
911
|
+
child.stderr.on("data", (chunk) => { stderr = `${stderr}${chunk.toString()}`.slice(-64_000); });
|
|
912
|
+
child.once("error", (error) => { spawnError = error.message; });
|
|
913
|
+
const close = new Promise((resolve) => { child.once("close", resolve); });
|
|
914
|
+
const timer = input.case.timeoutMs === undefined ? undefined : setTimeout(() => { timedOut = true; controller.abort(); void requestKill().then((terminated) => { processGroupTerminated ||= terminated; }); }, input.case.timeoutMs);
|
|
915
|
+
const exitCode = await close;
|
|
916
|
+
if (timer)
|
|
917
|
+
clearTimeout(timer);
|
|
918
|
+
if (lineBuffer)
|
|
919
|
+
inspectLine(lineBuffer);
|
|
920
|
+
if (killPromise)
|
|
921
|
+
processGroupTerminated ||= await killPromise;
|
|
922
|
+
return { raw, usage, exitCode, timedOut, budgetExceeded, processGroupTerminated, stoppedIntentionally: false, stderr, ...(spawnError ? { error: spawnError } : {}) };
|
|
923
|
+
}
|
|
924
|
+
function seedEvalProject(cwd, home, model) {
|
|
925
|
+
const source = process.env.PI_WORKFLOW_EVAL_SOURCE_PROJECT_DIR;
|
|
926
|
+
if (!source)
|
|
927
|
+
return;
|
|
928
|
+
const excluded = new Set([".git", "node_modules", "dist", ".tmp"]);
|
|
929
|
+
for (const entry of readdirSync(source)) {
|
|
930
|
+
if (excluded.has(entry))
|
|
931
|
+
continue;
|
|
932
|
+
cpSync(join(source, entry), join(cwd, entry), { recursive: true, filter: (path) => !excluded.has(basename(path)) });
|
|
933
|
+
}
|
|
934
|
+
const roles = join(source, "test", "fixtures", "workflow-eval-roles");
|
|
935
|
+
const target = join(home, ".pi", "agent", "pi-extensible-workflows", "roles");
|
|
936
|
+
if (!existsSync(roles))
|
|
937
|
+
return;
|
|
938
|
+
mkdirSync(target, { recursive: true, mode: 0o700 });
|
|
939
|
+
cpSync(roles, target, { recursive: true });
|
|
940
|
+
for (const name of readdirSync(target).filter((entry) => entry.endsWith(".md"))) {
|
|
941
|
+
const path = join(target, name);
|
|
942
|
+
const content = readFileSync(path, "utf8");
|
|
943
|
+
const frontmatterEnd = content.startsWith("---\n") ? content.indexOf("\n---", 4) : -1;
|
|
944
|
+
if (frontmatterEnd >= 0)
|
|
945
|
+
writeFileSync(path, `${content.slice(0, frontmatterEnd).replace(/^model:.*$/m, `model: ${model}`)}${content.slice(frontmatterEnd)}`);
|
|
946
|
+
}
|
|
947
|
+
}
|
|
948
|
+
async function findParentSession(cwd, sessionDir, sessionId) {
|
|
949
|
+
try {
|
|
950
|
+
const sessions = await SessionManager.list(cwd, sessionDir);
|
|
951
|
+
const found = sessions.find((session) => session.id === sessionId);
|
|
952
|
+
if (found)
|
|
953
|
+
return found.path;
|
|
954
|
+
}
|
|
955
|
+
catch { /* Fall through to the JSONL scan. */ }
|
|
956
|
+
const visit = (directory) => {
|
|
957
|
+
if (!existsSync(directory))
|
|
958
|
+
return undefined;
|
|
959
|
+
for (const entry of readdirSync(directory, { withFileTypes: true })) {
|
|
960
|
+
const path = join(directory, entry.name);
|
|
961
|
+
if (entry.isDirectory()) {
|
|
962
|
+
const found = visit(path);
|
|
963
|
+
if (found)
|
|
964
|
+
return found;
|
|
965
|
+
}
|
|
966
|
+
else if (entry.name.endsWith(".jsonl")) {
|
|
967
|
+
try {
|
|
968
|
+
const header = JSON.parse(readFileSync(path, "utf8").split("\n")[0] ?? "{}");
|
|
969
|
+
if (isObject(header) && header.id === sessionId)
|
|
970
|
+
return path;
|
|
971
|
+
}
|
|
972
|
+
catch { /* Ignore incomplete files. */ }
|
|
973
|
+
}
|
|
974
|
+
}
|
|
975
|
+
return undefined;
|
|
976
|
+
};
|
|
977
|
+
return visit(sessionDir);
|
|
978
|
+
}
|
|
979
|
+
function emptyAccounting(cost = 0) { return { input: 0, output: 0, cacheRead: 0, cacheWrite: 0, totalTokens: 0, cost, models: [] }; }
|
|
980
|
+
function emptyMetrics(requiredWorkflowCallCount = 1) { return { parentUsageThroughCandidate: null, parentOutputTokensThroughCandidate: null, nonWorkflowToolSequenceBeforeCandidate: [], nonWorkflowToolCallCountBeforeCandidate: 0, workflowCallCountBeforeCandidate: 0, invalidWorkflowCallCount: 0, productionValidationErrorCodes: [], candidateCallIndices: [], staticCandidates: [], semanticCriteria: [], anyValidCandidate: false, requiredWorkflowCallCount, surplusWorkflowCallCount: 0 }; }
|
|
981
|
+
function resultFromFailure(input, status, errors, processExited = true, processGroupTerminated = false, diagnostics = [], cost = 0) { const required = input.case.expectedWorkflowCalls ?? (input.case.expectations.workflowCallCount === 0 ? 0 : 1); return { id: input.case.id, status, limits: { ...(input.case.timeoutMs === undefined ? {} : { timeoutMs: input.case.timeoutMs }), maxCost: input.maxCost }, workflows: [], productionValidation: [], metrics: emptyMetrics(required), accounting: emptyAccounting(cost), accountingTrustworthy: false, diagnostics, errors, cleanup: { processExited, processGroupTerminated, tempRootRemoved: false, captureIdentityVerified: false, realWorkflowAgentsLaunched: null } }; }
|
|
982
|
+
function withTempRootRemoved(result) { return { ...result, cleanup: { ...result.cleanup, tempRootRemoved: true } }; }
|
|
983
|
+
function seedPiIdentity(home) {
|
|
984
|
+
const source = process.env.PI_WORKFLOW_EVAL_SOURCE_AGENT_DIR;
|
|
985
|
+
if (!source)
|
|
986
|
+
return;
|
|
987
|
+
const target = join(home, ".pi", "agent");
|
|
988
|
+
mkdirSync(target, { recursive: true, mode: 0o700 });
|
|
989
|
+
for (const name of ["auth.json", "models.json"]) {
|
|
990
|
+
const path = join(source, name);
|
|
991
|
+
if (existsSync(path)) {
|
|
992
|
+
const destination = join(target, name);
|
|
993
|
+
copyFileSync(path, destination);
|
|
994
|
+
chmodSync(destination, 0o600);
|
|
995
|
+
}
|
|
996
|
+
}
|
|
997
|
+
}
|
|
998
|
+
function usageThroughCandidate(oracle, calls, indices) {
|
|
999
|
+
const last = indices.at(-1);
|
|
1000
|
+
if (last === undefined)
|
|
1001
|
+
return null;
|
|
1002
|
+
return oracle.assistantBatches.filter(({ index }) => index <= (calls[last]?.batch ?? -1)).reduce((sum, batch) => addUsage(sum, batch.usage ?? emptyAccounting()), emptyAccounting());
|
|
1003
|
+
}
|
|
1004
|
+
function preliminaryTools(oracle, firstCandidateIndex) {
|
|
1005
|
+
if (firstCandidateIndex === undefined)
|
|
1006
|
+
return [];
|
|
1007
|
+
let seen = 0;
|
|
1008
|
+
const tools = [];
|
|
1009
|
+
for (const tool of oracle.parentToolSequence) {
|
|
1010
|
+
if (tool === "workflow") {
|
|
1011
|
+
if (seen === firstCandidateIndex)
|
|
1012
|
+
break;
|
|
1013
|
+
seen += 1;
|
|
1014
|
+
}
|
|
1015
|
+
else
|
|
1016
|
+
tools.push(tool);
|
|
1017
|
+
}
|
|
1018
|
+
return tools;
|
|
1019
|
+
}
|
|
1020
|
+
export async function captureEvalCase(input) {
|
|
1021
|
+
const root = mkdtempSync(join(process.env.PI_WORKFLOW_EVAL_CASE_ROOT ?? tmpdir(), "pi-workflow-capture-"));
|
|
1022
|
+
const cwd = join(root, "project");
|
|
1023
|
+
const home = join(root, "home");
|
|
1024
|
+
const sessionDir = join(root, "sessions");
|
|
1025
|
+
const sessionId = randomUUID();
|
|
1026
|
+
try {
|
|
1027
|
+
mkdirSync(cwd, { recursive: true });
|
|
1028
|
+
mkdirSync(home, { recursive: true });
|
|
1029
|
+
mkdirSync(sessionDir, { recursive: true });
|
|
1030
|
+
seedPiIdentity(home);
|
|
1031
|
+
seedEvalProject(cwd, home, input.model.includes("/") ? input.model : input.provider ? `${input.provider}/${input.model}` : input.model);
|
|
1032
|
+
reportProgress(`${input.case.id}: parent model starting`);
|
|
1033
|
+
const pi = await runPiCapture(input, cwd, home, sessionDir, sessionId);
|
|
1034
|
+
reportProgress(`${input.case.id}: parent model finished`);
|
|
1035
|
+
const diagnostics = [pi.stderr, pi.error ? `Pi process error: ${pi.error}` : ""].filter(Boolean);
|
|
1036
|
+
const sessionFile = await findParentSession(cwd, sessionDir, sessionId);
|
|
1037
|
+
if (!sessionFile) {
|
|
1038
|
+
const status = pi.timedOut ? "timed_out" : pi.budgetExceeded ? "budget_exceeded" : "failed";
|
|
1039
|
+
return withTempRootRemoved(resultFromFailure(input, status, ["Parent Pi session was not written."], pi.exitCode !== null, pi.processGroupTerminated, diagnostics));
|
|
1040
|
+
}
|
|
1041
|
+
const oracle = extractParentOracleFile(sessionFile);
|
|
1042
|
+
const workflows = extractCapturedWorkflows(oracle);
|
|
1043
|
+
const validation = captureValidationReports(oracle, workflows);
|
|
1044
|
+
const requiredCount = input.case.expectedWorkflowCalls ?? (input.case.expectations.workflowCallCount === 0 ? 0 : 1);
|
|
1045
|
+
const selection = selectStaticCandidate(workflows, validation.reports, input.case.expectations, requiredCount);
|
|
1046
|
+
const parentUsageThroughCandidate = usageThroughCandidate(oracle, workflows, selection.callIndices);
|
|
1047
|
+
const parentAccounting = parentUsageThroughCandidate ?? oracle.usage;
|
|
1048
|
+
const unsafeTool = oracle.parentToolSequence.find((tool) => !SAFE_PARENT_EVAL_TOOLS.includes(tool));
|
|
1049
|
+
const errors = [...evalExpectationErrors(oracle, input.case.expectations), ...validation.errors, ...(unsafeTool ? [`parent tool is outside the safe eval allowlist: ${unsafeTool}`] : [])];
|
|
1050
|
+
if (requiredCount > 0 && selection.callIndices.length === 0)
|
|
1051
|
+
errors.push("Catastrophic validity failure: no production-valid workflow candidate satisfied static expectations.");
|
|
1052
|
+
let judge;
|
|
1053
|
+
let judgeProcess;
|
|
1054
|
+
if (selection.callIndices.length > 0) {
|
|
1055
|
+
const criteria = input.case.semanticCriteria ?? semantic("The workflow design is semantically appropriate for the original request.");
|
|
1056
|
+
const judgeCase = { ...input.case, semanticCriteria: criteria };
|
|
1057
|
+
reportProgress(`${input.case.id}: semantic judge starting`);
|
|
1058
|
+
judgeProcess = await runSemanticJudge({ ...input, case: judgeCase }, selection.callIndices.map((index) => workflows[index]), cwd, home, sessionDir, Math.max(0, input.maxCost - parentAccounting.cost));
|
|
1059
|
+
reportProgress(`${input.case.id}: semantic judge finished`);
|
|
1060
|
+
diagnostics.push(judgeProcess.stderr, judgeProcess.error ? `Judge process error: ${judgeProcess.error}` : "");
|
|
1061
|
+
if (judgeProcess.exitCode !== 0 || judgeProcess.error)
|
|
1062
|
+
errors.push("Semantic judge process failed.");
|
|
1063
|
+
else {
|
|
1064
|
+
try {
|
|
1065
|
+
const criterionResults = parseSemanticJudge(judgeProcess.raw, criteria);
|
|
1066
|
+
judge = { criteria: criterionResults, usage: judgeProcess.usage, raw: judgeProcess.raw };
|
|
1067
|
+
if (criterionResults.some(({ pass }) => !pass))
|
|
1068
|
+
errors.push("Semantic judge rejected one or more criteria.");
|
|
1069
|
+
}
|
|
1070
|
+
catch (error) {
|
|
1071
|
+
errors.push(`Invalid semantic judge output: ${error instanceof Error ? error.message : String(error)}`);
|
|
1072
|
+
}
|
|
1073
|
+
}
|
|
1074
|
+
}
|
|
1075
|
+
const before = preliminaryTools(oracle, selection.callIndices[0]);
|
|
1076
|
+
const accounting = addUsage(parentAccounting, judgeProcess?.usage ?? emptyAccounting());
|
|
1077
|
+
const metrics = {
|
|
1078
|
+
parentUsageThroughCandidate,
|
|
1079
|
+
parentOutputTokensThroughCandidate: parentUsageThroughCandidate?.output ?? null,
|
|
1080
|
+
nonWorkflowToolSequenceBeforeCandidate: before,
|
|
1081
|
+
nonWorkflowToolCallCountBeforeCandidate: before.length,
|
|
1082
|
+
workflowCallCountBeforeCandidate: selection.callIndices[0] ?? oracle.workflowCallCount,
|
|
1083
|
+
invalidWorkflowCallCount: validation.reports.filter(({ valid }) => !valid).length,
|
|
1084
|
+
productionValidationErrorCodes: validation.reports.flatMap(({ errorCode }) => errorCode ? [errorCode] : []),
|
|
1085
|
+
candidateCallIndices: selection.callIndices,
|
|
1086
|
+
staticCandidates: selection.reports,
|
|
1087
|
+
semanticCriteria: judge?.criteria ?? [],
|
|
1088
|
+
anyValidCandidate: selection.callIndices.length > 0,
|
|
1089
|
+
requiredWorkflowCallCount: requiredCount,
|
|
1090
|
+
surplusWorkflowCallCount: Math.max(0, validation.reports.filter(({ valid }) => valid).length - requiredCount),
|
|
1091
|
+
};
|
|
1092
|
+
const timedOut = pi.timedOut || Boolean(judgeProcess?.timedOut);
|
|
1093
|
+
const overBudget = pi.budgetExceeded || Boolean(judgeProcess?.budgetExceeded) || accounting.cost > input.maxCost;
|
|
1094
|
+
const intentionalStop = pi.stoppedIntentionally && (pi.exitCode === 0 || pi.exitCode === null || pi.exitCode === 143);
|
|
1095
|
+
const piSucceeded = pi.exitCode === 0 || intentionalStop;
|
|
1096
|
+
const status = timedOut ? "timed_out" : overBudget ? "budget_exceeded" : errors.length || !piSucceeded ? "failed" : "passed";
|
|
1097
|
+
const result = { id: input.case.id, status, limits: { ...(input.case.timeoutMs === undefined ? {} : { timeoutMs: input.case.timeoutMs }), maxCost: input.maxCost }, oracle, workflows, productionValidation: validation.reports, ...(judge ? { semanticJudge: judge } : {}), metrics, accounting, accountingTrustworthy: !timedOut && piSucceeded && (!judgeProcess || judgeProcess.exitCode === 0), diagnostics: diagnostics.filter(Boolean), errors, cleanup: { processExited: (pi.exitCode !== null || pi.stoppedIntentionally) && (!judgeProcess || judgeProcess.exitCode !== null), processGroupTerminated: pi.processGroupTerminated || Boolean(judgeProcess?.processGroupTerminated), tempRootRemoved: false, captureIdentityVerified: validation.verified, realWorkflowAgentsLaunched: validation.verified ? 0 : null } };
|
|
1098
|
+
return withTempRootRemoved(result);
|
|
1099
|
+
}
|
|
1100
|
+
finally {
|
|
1101
|
+
rmSync(root, { recursive: true, force: true });
|
|
1102
|
+
}
|
|
1103
|
+
}
|
|
1104
|
+
export async function runIsolatedProcess(payload, options) {
|
|
1105
|
+
const root = mkdtempSync(join(tmpdir(), "pi-workflow-eval-case-"));
|
|
1106
|
+
const inputPath = join(root, "input.json");
|
|
1107
|
+
const outputPath = join(root, "output.json");
|
|
1108
|
+
try {
|
|
1109
|
+
writeFileSync(inputPath, `${JSON.stringify({ payload, outputPath })}\n`, { mode: 0o600 });
|
|
1110
|
+
const controller = new AbortController();
|
|
1111
|
+
const child = spawn(process.execPath, [options.childPath, inputPath], { cwd: root, env: { ...process.env, ...options.env, HOME: join(root, "home"), PI_CODING_AGENT_DIR: join(root, "home", ".pi", "agent"), PI_CODING_AGENT_SESSION_DIR: join(root, "sessions"), PI_WORKFLOW_EVAL_CASE_ROOT: root }, detached: process.platform !== "win32", stdio: ["ignore", "ignore", "pipe"], signal: controller.signal });
|
|
1112
|
+
let timedOut = false;
|
|
1113
|
+
let processGroupTerminated = false;
|
|
1114
|
+
let stderr = "";
|
|
1115
|
+
let processError;
|
|
1116
|
+
let killPromise;
|
|
1117
|
+
child.stderr.on("data", (chunk) => { const text = chunk.toString(); stderr = `${stderr}${text}`.slice(-64_000); options.onStderr?.(text); });
|
|
1118
|
+
child.once("error", (error) => { processError = error.message; });
|
|
1119
|
+
const close = new Promise((resolve) => { child.once("close", (code) => { resolve(code); }); });
|
|
1120
|
+
const timer = options.timeoutMs === undefined ? undefined : setTimeout(() => { timedOut = true; controller.abort(); killPromise ??= killProcessGroup(child); void killPromise.then((terminated) => { processGroupTerminated ||= terminated; }); }, options.timeoutMs);
|
|
1121
|
+
const exitCode = await close;
|
|
1122
|
+
if (timer)
|
|
1123
|
+
clearTimeout(timer);
|
|
1124
|
+
if (killPromise)
|
|
1125
|
+
processGroupTerminated ||= await killPromise;
|
|
1126
|
+
if (!existsSync(outputPath))
|
|
1127
|
+
return { timedOut, exitCode, processGroupTerminated, stderr, ...(processError ? { error: processError } : {}) };
|
|
1128
|
+
try {
|
|
1129
|
+
const value = JSON.parse(readFileSync(outputPath, "utf8"));
|
|
1130
|
+
return { value, timedOut, exitCode, processGroupTerminated, stderr, ...(processError ? { error: processError } : {}) };
|
|
1131
|
+
}
|
|
1132
|
+
catch (error) {
|
|
1133
|
+
return { timedOut, exitCode, processGroupTerminated, stderr, error: `Invalid child JSON: ${error instanceof Error ? error.message : String(error)}` };
|
|
1134
|
+
}
|
|
1135
|
+
}
|
|
1136
|
+
finally {
|
|
1137
|
+
rmSync(root, { recursive: true, force: true });
|
|
1138
|
+
}
|
|
1139
|
+
}
|
|
1140
|
+
function materializeCase(candidate, model) {
|
|
1141
|
+
return JSON.parse(JSON.stringify(candidate).replaceAll(EVAL_MODEL_TOKEN, model));
|
|
1142
|
+
}
|
|
1143
|
+
export async function runWorkflowEvals(options = {}) {
|
|
1144
|
+
const model = options.model ?? process.env.PI_WORKFLOW_EVAL_MODEL;
|
|
1145
|
+
if (!model)
|
|
1146
|
+
throw new Error("Set --model or PI_WORKFLOW_EVAL_MODEL before running model evals.");
|
|
1147
|
+
const explicitModel = model.includes("/") ? model : options.provider ? `${options.provider}/${model}` : model;
|
|
1148
|
+
const candidates = options.cases ?? INITIAL_WORKFLOW_EVAL_CASES;
|
|
1149
|
+
validateWorkflowEvalCases(candidates, options.cases ? "options.cases" : "evals/cases");
|
|
1150
|
+
const cases = candidates.filter((candidate) => !options.caseIds?.length || options.caseIds.includes(candidate.id)).map((candidate) => materializeCase(candidate, explicitModel));
|
|
1151
|
+
const sourceAgentDir = getAgentDir();
|
|
1152
|
+
const ceiling = options.spendCeiling ?? Number(process.env.PI_WORKFLOW_EVAL_SPEND_CEILING ?? "1");
|
|
1153
|
+
if (!Number.isFinite(ceiling) || ceiling <= 0)
|
|
1154
|
+
throw new Error("spend ceiling must be positive");
|
|
1155
|
+
const artifactDir = options.artifactsDir ?? join(process.cwd(), ".tmp", "workflow-evals", new Date().toISOString().replace(/[:.]/g, "-"));
|
|
1156
|
+
mkdirSync(artifactDir, { recursive: true });
|
|
1157
|
+
const results = [];
|
|
1158
|
+
const skipped = [];
|
|
1159
|
+
let spent = 0;
|
|
1160
|
+
for (const candidate of cases) {
|
|
1161
|
+
const remaining = ceiling - spent;
|
|
1162
|
+
if (remaining <= 0) {
|
|
1163
|
+
const skippedResult = resultFromFailure({ case: { ...candidate, ...(options.timeoutMs === undefined ? {} : { timeoutMs: options.timeoutMs }) }, model, maxCost: candidate.maxCost }, "skipped", ["Run spend ceiling reached."]);
|
|
1164
|
+
skipped.push(candidate.id);
|
|
1165
|
+
results.push(skippedResult);
|
|
1166
|
+
writeFileSync(join(artifactDir, `${candidate.id}.json`), `${JSON.stringify(skippedResult, null, 2)}\n`, { mode: 0o600 });
|
|
1167
|
+
continue;
|
|
1168
|
+
}
|
|
1169
|
+
const input = { case: { ...candidate, ...(options.timeoutMs === undefined ? {} : { timeoutMs: options.timeoutMs }) }, model, ...(options.provider ? { provider: options.provider } : {}), ...(options.thinking ? { thinking: options.thinking } : {}), ...(options.piCommand ? { piCommand: options.piCommand } : {}), maxCost: Math.min(candidate.maxCost, remaining) };
|
|
1170
|
+
const started = Date.now();
|
|
1171
|
+
options.onProgress?.(`[eval] ${candidate.id}: starting, budget $${input.maxCost.toFixed(2)}, timeout ${input.case.timeoutMs === undefined ? "off" : `${String(input.case.timeoutMs)}ms`}`);
|
|
1172
|
+
const isolated = await runIsolatedProcess(input, { childPath: fileURLToPath(new URL("./workflow-evals-child.js", import.meta.url)), ...(input.case.timeoutMs === undefined ? {} : { timeoutMs: input.case.timeoutMs * 2 + CASE_PROCESS_GRACE_MS }), env: { PI_WORKFLOW_EVAL_SOURCE_AGENT_DIR: sourceAgentDir, PI_WORKFLOW_EVAL_SOURCE_PROJECT_DIR: process.cwd(), PI_WORKFLOW_EVAL_PROGRESS: options.onProgress ? "1" : "0" }, ...(options.onProgress ? { onStderr: (chunk) => { for (const line of chunk.trimEnd().split("\n"))
|
|
1173
|
+
if (line)
|
|
1174
|
+
options.onProgress?.(line); } } : {}) });
|
|
1175
|
+
const childStderr = isolated.stderr.split("\n").filter((line) => !line.startsWith("[eval] ")).join("\n").trim();
|
|
1176
|
+
const diagnostics = [childStderr, isolated.error ? `Case process error: ${isolated.error}` : ""].filter(Boolean);
|
|
1177
|
+
const trustworthy = Boolean(isolated.value) && !isolated.timedOut && isolated.exitCode === 0 && !isolated.error && Boolean(isolated.value?.accountingTrustworthy);
|
|
1178
|
+
const untrustedStatus = isolated.timedOut ? "timed_out" : isolated.value?.status === "timed_out" || isolated.value?.status === "budget_exceeded" ? isolated.value.status : "failed";
|
|
1179
|
+
const base = isolated.value ?? resultFromFailure(input, untrustedStatus, [isolated.timedOut ? "Case process timed out." : isolated.error ? isolated.error : "Case process returned no artifact.", ...diagnostics], isolated.exitCode !== null, isolated.processGroupTerminated, diagnostics, input.maxCost);
|
|
1180
|
+
const result = { ...base, ...(trustworthy ? {} : { status: untrustedStatus, accounting: { ...base.accounting, cost: input.maxCost }, accountingTrustworthy: false }), diagnostics: [...base.diagnostics, ...diagnostics] };
|
|
1181
|
+
spent += result.accounting.cost;
|
|
1182
|
+
options.onProgress?.(`[eval] ${candidate.id}: ${result.status} after ${((Date.now() - started) / 1000).toFixed(1)}s, $${result.accounting.cost.toFixed(4)}, ${String(result.accounting.totalTokens)} tokens`);
|
|
1183
|
+
results.push(result);
|
|
1184
|
+
writeFileSync(join(artifactDir, `${candidate.id}.json`), `${JSON.stringify(result, null, 2)}\n`, { mode: 0o600 });
|
|
1185
|
+
}
|
|
1186
|
+
return { artifactDir, cases: results, spent, skipped };
|
|
1187
|
+
}
|
|
1188
|
+
export function formatEvalSummary(result) {
|
|
1189
|
+
const rows = result.cases.flatMap((item) => {
|
|
1190
|
+
const invalid = item.productionValidation.filter(({ valid }) => !valid);
|
|
1191
|
+
const staticCriteria = item.metrics.staticCandidates.flatMap(({ criteria }) => criteria).filter(({ pass }) => !pass);
|
|
1192
|
+
const semantic = item.metrics.semanticCriteria.map(({ id, pass, evidence }) => ` judge ${pass ? "PASS" : "FAIL"} ${id}: ${evidence}`);
|
|
1193
|
+
return [
|
|
1194
|
+
`${item.id}: ${item.status}`,
|
|
1195
|
+
` usage: $${item.accounting.cost.toFixed(4)}, ${String(item.accounting.totalTokens)} tokens (${String(item.accounting.input)} input, ${String(item.accounting.output)} output, ${String(item.accounting.cacheRead)} cache read)`,
|
|
1196
|
+
` workflows: ${String(item.workflows.length)} captured, ${String(item.productionValidation.filter(({ valid }) => valid).length)} production-valid`,
|
|
1197
|
+
...invalid.map(({ callIndex, errorCode, message }) => ` validation FAIL call ${String(callIndex)}${errorCode ? ` ${errorCode}` : ""}: ${message ?? "unknown error"}`),
|
|
1198
|
+
...staticCriteria.map(({ id, evidence }) => ` static FAIL ${id}: ${evidence}`),
|
|
1199
|
+
...semantic,
|
|
1200
|
+
...item.errors.map((error) => ` error: ${error}`),
|
|
1201
|
+
];
|
|
1202
|
+
});
|
|
1203
|
+
return [`Workflow evals: ${String(result.cases.length)} cases, $${result.spent.toFixed(4)} spent`, ...rows, result.skipped.length ? `Skipped: ${result.skipped.join(", ")}` : "", `Artifacts: ${result.artifactDir}`].filter(Boolean).join("\n");
|
|
1204
|
+
}
|
|
1205
|
+
async function main() {
|
|
1206
|
+
const args = process.argv.slice(2);
|
|
1207
|
+
const value = (name) => { const index = args.indexOf(name); return index >= 0 ? args[index + 1] : undefined; };
|
|
1208
|
+
const caseIds = value("--case")?.split(",").map((item) => item.trim()).filter(Boolean);
|
|
1209
|
+
const model = value("--model");
|
|
1210
|
+
const provider = value("--provider");
|
|
1211
|
+
const thinking = value("--thinking");
|
|
1212
|
+
const piCommand = value("--pi");
|
|
1213
|
+
const artifactsDir = value("--artifacts");
|
|
1214
|
+
const timeoutValue = Number(value("--timeout-ms") ?? "0");
|
|
1215
|
+
const result = await runWorkflowEvals({ ...(model ? { model } : {}), ...(provider ? { provider } : {}), ...(thinking ? { thinking } : {}), ...(piCommand ? { piCommand } : {}), ...(artifactsDir ? { artifactsDir } : {}), spendCeiling: Number(value("--spend-ceiling") ?? process.env.PI_WORKFLOW_EVAL_SPEND_CEILING ?? "1"), ...(timeoutValue ? { timeoutMs: timeoutValue } : {}), ...(caseIds?.length ? { caseIds } : {}), onProgress: (message) => { process.stderr.write(`${message}\n`); } });
|
|
1216
|
+
process.stdout.write(`${formatEvalSummary(result)}\n`);
|
|
1217
|
+
if (result.cases.some((item) => item.status !== "passed"))
|
|
1218
|
+
process.exitCode = 1;
|
|
1219
|
+
}
|
|
1220
|
+
if (process.argv[1] && fileURLToPath(import.meta.url) === process.argv[1])
|
|
1221
|
+
void main().catch((error) => { process.stderr.write(`${error instanceof Error ? error.message : String(error)}\n`); process.exitCode = 1; });
|