@danypops/pi-eval-harness 0.1.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/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Daniel Popsuev
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
package/README.md ADDED
@@ -0,0 +1,53 @@
1
+ # @danypops/pi-eval-harness
2
+
3
+ Scores a real agent run's own tool-call behavior over Pi's real `AgentSessionEvent` stream --
4
+ AND/OR tool-call matching, graduated checker composition, and turn/tool-call/token-usage
5
+ rollups. Ported from Alef's own `packages/core/eval` evaluation framework, adapted to operate
6
+ directly on the real event union any `@danypops/pi-process-harness` (or `pi --mode rpc`) run
7
+ already produces, instead of a bespoke OTel span format.
8
+
9
+ ## Usage
10
+
11
+ ```ts
12
+ import { deriveTurns, expectsAll, extractToolExecutions, summarizeRunUsage } from "@danypops/pi-eval-harness";
13
+
14
+ const executions = extractToolExecutions(sessionEvents);
15
+ // [{ toolCallId, toolName, args, result, isError }, ...] in completion order
16
+
17
+ const checker = expectsAll([{ tool: "search_code", target: { pattern: "TODO" } }]);
18
+ const result = await checker.check({ executions });
19
+ // { pass, score, errors }
20
+
21
+ const turns = deriveTurns(sessionEvents);
22
+ const usage = summarizeRunUsage(turns);
23
+ // { turns, tokensIn, tokensOut, cacheReadTokens, costUsd, toolCalls, toolNames }
24
+ ```
25
+
26
+ ## Scope
27
+
28
+ - `extractToolExecutions` -- pairs `tool_execution_start`/`tool_execution_end` by `toolCallId`
29
+ into one real completed call per pair, in completion order.
30
+ - `matchesToolCall`/`describeToolCall` -- whether one completed execution satisfies a `ToolCall`
31
+ expectation (tool name, target args, produced output), and a human-readable description of it.
32
+ - `expectsAll`/`expectsAny`/`all` -- `Checker`s with AND/OR/composed semantics and graduated
33
+ `CheckerResult` scoring (0.0 hard fail, 1.0 full pass).
34
+ - `deriveTurns`/`summarizeRunUsage` -- one `Turn` per real `turn_end` event (model, token usage,
35
+ cost, ordered tool-call names), rolled up into whole-run totals.
36
+
37
+ ## Testing your own Checkers: fixture self-test discipline
38
+
39
+ Ported from Alef's own `Evaluation.fixture`/`FixtureSet` discipline: a `Checker` must be proven
40
+ correct against a small, hand-authored, known-good `ToolExecution[]` fixture -- with **zero**
41
+ live process spawns or LLM calls -- before it is ever trusted against a real run. This package's
42
+ own test suite follows exactly that pattern (see `test/checker.test.ts` and `test/tool-call.test.ts`):
43
+ build a `ToolExecution` fixture by hand, call `checker.check({ executions })` directly, assert the
44
+ exact `{ pass, score, errors }` you expect. No helper wraps this -- the whole point is that
45
+ `Checker.check()` is already a pure, synchronous-or-trivially-awaitable function; wrapping it
46
+ would only hide the assertion, not simplify it.
47
+
48
+ A `Checker` that only ever gets exercised against a real, expensive `pi-process-harness` run has
49
+ no fast, deterministic proof it is correct in isolation -- write the fixture test first.
50
+
51
+ ## License
52
+
53
+ MIT
@@ -0,0 +1,33 @@
1
+ import { type ToolCall } from "./tool-call.js";
2
+ import type { ToolExecution } from "./tool-executions.js";
3
+ /** Outcome of a Checker.check() call with graduated scoring, ported from Alef's own eval framework. */
4
+ export interface CheckerResult {
5
+ readonly pass: boolean;
6
+ /** Graduated score 0-1: 0.0 hard fail, 0.5 partial, 1.0 full pass. */
7
+ readonly score: number;
8
+ readonly errors: readonly string[];
9
+ }
10
+ /** Runtime context passed to a Checker -- the real completed tool executions from one run. */
11
+ export interface CheckerContext {
12
+ readonly executions: readonly ToolExecution[];
13
+ }
14
+ /** A pure, deterministic verifier over one run's real tool executions. */
15
+ export interface Checker {
16
+ check(context: CheckerContext): CheckerResult | Promise<CheckerResult>;
17
+ }
18
+ /**
19
+ * A Checker requiring every expectation to be satisfied by at least one execution (AND
20
+ * semantics) -- ported from Alef's `Evaluation.expects`.
21
+ */
22
+ export declare function expectsAll(expectations: readonly ToolCall[]): Checker;
23
+ /**
24
+ * A Checker requiring at least one expectation to be satisfied by at least one execution (OR
25
+ * semantics) -- ported from Alef's `Evaluation.expectsAny`.
26
+ */
27
+ export declare function expectsAny(expectations: readonly ToolCall[]): Checker;
28
+ /**
29
+ * Composes several checkers with AND semantics: the combined score is the minimum of every
30
+ * checker's own score, and every checker's errors are concatenated -- ported from Alef's
31
+ * `checker.ts` `all()`.
32
+ */
33
+ export declare function all(...checkers: readonly Checker[]): Checker;
@@ -0,0 +1,47 @@
1
+ import { describeToolCall, matchesToolCall } from "./tool-call.js";
2
+ /**
3
+ * A Checker requiring every expectation to be satisfied by at least one execution (AND
4
+ * semantics) -- ported from Alef's `Evaluation.expects`.
5
+ */
6
+ export function expectsAll(expectations) {
7
+ return {
8
+ check({ executions }) {
9
+ const errors = expectations
10
+ .filter((expectation) => !executions.some((execution) => matchesToolCall(execution, expectation)))
11
+ .map((expectation) => `Expected ${describeToolCall(expectation)}`);
12
+ return { pass: errors.length === 0, score: errors.length === 0 ? 1 : 0, errors };
13
+ },
14
+ };
15
+ }
16
+ /**
17
+ * A Checker requiring at least one expectation to be satisfied by at least one execution (OR
18
+ * semantics) -- ported from Alef's `Evaluation.expectsAny`.
19
+ */
20
+ export function expectsAny(expectations) {
21
+ return {
22
+ check({ executions }) {
23
+ if (expectations.length === 0)
24
+ return { pass: true, score: 1, errors: [] };
25
+ const satisfied = expectations.some((expectation) => executions.some((execution) => matchesToolCall(execution, expectation)));
26
+ if (satisfied)
27
+ return { pass: true, score: 1, errors: [] };
28
+ const description = expectations.map((expectation) => describeToolCall(expectation)).join(" OR ");
29
+ return { pass: false, score: 0, errors: [`Expected at least one: ${description}`] };
30
+ },
31
+ };
32
+ }
33
+ /**
34
+ * Composes several checkers with AND semantics: the combined score is the minimum of every
35
+ * checker's own score, and every checker's errors are concatenated -- ported from Alef's
36
+ * `checker.ts` `all()`.
37
+ */
38
+ export function all(...checkers) {
39
+ return {
40
+ async check(context) {
41
+ const results = await Promise.all(checkers.map((checker) => checker.check(context)));
42
+ const errors = results.flatMap((result) => result.errors);
43
+ const score = results.length === 0 ? 1 : Math.min(...results.map((result) => result.score));
44
+ return { pass: errors.length === 0, score, errors };
45
+ },
46
+ };
47
+ }
@@ -0,0 +1,4 @@
1
+ export { all, type Checker, type CheckerContext, type CheckerResult, expectsAll, expectsAny } from "./checker.js";
2
+ export { describeToolCall, matchesToolCall, type ToolCall } from "./tool-call.js";
3
+ export { extractToolExecutions, type ToolExecution } from "./tool-executions.js";
4
+ export { deriveTurns, type RunUsageSummary, summarizeRunUsage, type Turn } from "./turns.js";
package/dist/index.js ADDED
@@ -0,0 +1,4 @@
1
+ export { all, expectsAll, expectsAny } from "./checker.js";
2
+ export { describeToolCall, matchesToolCall } from "./tool-call.js";
3
+ export { extractToolExecutions } from "./tool-executions.js";
4
+ export { deriveTurns, summarizeRunUsage } from "./turns.js";
@@ -0,0 +1,36 @@
1
+ import type { ToolExecution } from "./tool-executions.js";
2
+ /**
3
+ * One expected tool interaction, ported from Alef's own `packages/core/eval` evaluation
4
+ * framework (`ToolCall`/`matchesToolCall`/`expects`/`expectsAny`) -- adapted from Alef's OTel
5
+ * `SpanRecord[]` onto this package's own `ToolExecution[]` (see `tool-executions.ts`), the real
6
+ * projection of a Pi `AgentSessionEvent` stream's completed tool calls.
7
+ *
8
+ * tool -- which tool was invoked (OR semantics across the array).
9
+ * target -- what the tool was called on, matched against its real input args.
10
+ * produces -- what the tool's output must contain.
11
+ *
12
+ * All non-undefined fields must match for the expectation to be satisfied.
13
+ */
14
+ export interface ToolCall {
15
+ /** Acceptable tool name(s) -- any one satisfies the call dimension. */
16
+ readonly tool: string | readonly string[];
17
+ /** Fields that must appear in the tool's input args. */
18
+ readonly target?: {
19
+ /** File path the tool operated on (substring or regex). */
20
+ readonly path?: string | RegExp;
21
+ /** Search pattern used (substring or regex). */
22
+ readonly pattern?: string | RegExp;
23
+ /** Symbol name targeted (substring or regex). */
24
+ readonly symbol?: string | RegExp;
25
+ /** URL fetched (substring or regex). */
26
+ readonly url?: string | RegExp;
27
+ /** Arbitrary payload field -- key/value or regex. */
28
+ readonly [key: string]: string | RegExp | undefined;
29
+ };
30
+ /** What the tool's output must contain (substring or regex). */
31
+ readonly produces?: string | RegExp;
32
+ }
33
+ /** Whether one real completed tool execution satisfies a tool-call expectation. */
34
+ export declare function matchesToolCall(execution: ToolExecution, expectation: ToolCall): boolean;
35
+ /** Formats a tool-call expectation as a human-readable description for a checker error. */
36
+ export declare function describeToolCall(expectation: ToolCall): string;
@@ -0,0 +1,53 @@
1
+ function matchValue(actual, expected) {
2
+ return expected instanceof RegExp ? expected.test(actual) : actual.includes(expected);
3
+ }
4
+ /** Stringifies a real tool result (which may be any JSON value, not only text) for `produces` matching. */
5
+ function stringifyResult(result) {
6
+ if (typeof result === "string")
7
+ return result;
8
+ try {
9
+ return JSON.stringify(result) ?? "";
10
+ }
11
+ catch {
12
+ return String(result);
13
+ }
14
+ }
15
+ function argValue(args, key) {
16
+ if (typeof args !== "object" || args === null)
17
+ return "";
18
+ const value = args[key];
19
+ return typeof value === "string" ? value : "";
20
+ }
21
+ /** Whether one real completed tool execution satisfies a tool-call expectation. */
22
+ export function matchesToolCall(execution, expectation) {
23
+ const tools = Array.isArray(expectation.tool) ? expectation.tool : [expectation.tool];
24
+ if (!tools.some((name) => execution.toolName === name))
25
+ return false;
26
+ if (expectation.target) {
27
+ for (const [key, pattern] of Object.entries(expectation.target)) {
28
+ if (pattern === undefined)
29
+ continue;
30
+ if (!matchValue(argValue(execution.args, key), pattern))
31
+ return false;
32
+ }
33
+ }
34
+ if (expectation.produces !== undefined) {
35
+ if (!matchValue(stringifyResult(execution.result), expectation.produces))
36
+ return false;
37
+ }
38
+ return true;
39
+ }
40
+ /** Formats a tool-call expectation as a human-readable description for a checker error. */
41
+ export function describeToolCall(expectation) {
42
+ const tools = Array.isArray(expectation.tool) ? expectation.tool.join("|") : expectation.tool;
43
+ const target = expectation.target
44
+ ? ` on ${Object.entries(expectation.target)
45
+ .filter(([, value]) => value !== undefined)
46
+ .map(([key, value]) => `${key}=${value instanceof RegExp ? value.source : String(value)}`)
47
+ .join(", ")}`
48
+ : "";
49
+ const produces = expectation.produces !== undefined
50
+ ? ` → ${expectation.produces instanceof RegExp ? expectation.produces.source : expectation.produces}`
51
+ : "";
52
+ return `${tools}${target}${produces}`;
53
+ }
@@ -0,0 +1,16 @@
1
+ import type { AgentSessionEvent } from "@earendil-works/pi-coding-agent";
2
+ /** One completed tool call, paired from its real tool_execution_start/tool_execution_end events. */
3
+ export interface ToolExecution {
4
+ readonly toolCallId: string;
5
+ readonly toolName: string;
6
+ readonly args: unknown;
7
+ readonly result: unknown;
8
+ readonly isError: boolean;
9
+ }
10
+ /**
11
+ * Pairs each `tool_execution_start` with its matching `tool_execution_end` by `toolCallId`, in
12
+ * completion order. A start with no matching end (a call still running when the stream ends, or
13
+ * aborted mid-flight) is dropped -- only a fully finished call is real trace data a checker can
14
+ * score.
15
+ */
16
+ export declare function extractToolExecutions(events: readonly AgentSessionEvent[]): ToolExecution[];
@@ -0,0 +1,29 @@
1
+ /**
2
+ * Pairs each `tool_execution_start` with its matching `tool_execution_end` by `toolCallId`, in
3
+ * completion order. A start with no matching end (a call still running when the stream ends, or
4
+ * aborted mid-flight) is dropped -- only a fully finished call is real trace data a checker can
5
+ * score.
6
+ */
7
+ export function extractToolExecutions(events) {
8
+ const starts = new Map();
9
+ const executions = [];
10
+ for (const event of events) {
11
+ if (event.type === "tool_execution_start") {
12
+ starts.set(event.toolCallId, { toolName: event.toolName, args: event.args });
13
+ continue;
14
+ }
15
+ if (event.type === "tool_execution_end") {
16
+ const start = starts.get(event.toolCallId);
17
+ if (start === undefined)
18
+ continue;
19
+ executions.push({
20
+ toolCallId: event.toolCallId,
21
+ toolName: start.toolName,
22
+ args: start.args,
23
+ result: event.result,
24
+ isError: event.isError,
25
+ });
26
+ }
27
+ }
28
+ return executions;
29
+ }
@@ -0,0 +1,38 @@
1
+ import type { AgentSessionEvent } from "@earendil-works/pi-coding-agent";
2
+ /**
3
+ * One real LLM turn, derived directly from a `turn_end` event -- unlike Alef's own
4
+ * `deriveturns` (which had to reconstruct turn boundaries by scanning for OTel `chat ` spans
5
+ * and attributing trailing command spans to them), Pi's real `AgentEvent` union already emits
6
+ * an explicit `turn_end` per turn, carrying the assistant's own message (with real `Usage`) and
7
+ * that turn's own `toolResults` directly -- no reconstruction needed.
8
+ */
9
+ export interface Turn {
10
+ /** 1-based turn index within the run. */
11
+ readonly turn: number;
12
+ /** Model id from the assistant message, empty when the turn's message isn't an assistant reply. */
13
+ readonly model: string;
14
+ readonly tokensIn: number;
15
+ readonly tokensOut: number;
16
+ readonly cacheReadTokens: number;
17
+ /** Real cost in USD from Usage.cost.total, when the provider reports pricing. */
18
+ readonly costUsd: number;
19
+ /** Number of tool calls dispatched from this turn. */
20
+ readonly toolCalls: number;
21
+ /** Names of tools called in this turn, in dispatch order. */
22
+ readonly toolNames: readonly string[];
23
+ }
24
+ /** Derives one Turn per real `turn_end` event, in the stream's own order. */
25
+ export declare function deriveTurns(events: readonly AgentSessionEvent[]): Turn[];
26
+ /** Aggregate token/cost/turn/tool-call totals across a whole run's turns. */
27
+ export interface RunUsageSummary {
28
+ readonly turns: number;
29
+ readonly tokensIn: number;
30
+ readonly tokensOut: number;
31
+ readonly cacheReadTokens: number;
32
+ readonly costUsd: number;
33
+ readonly toolCalls: number;
34
+ /** Every tool name called across the whole run, in dispatch order. */
35
+ readonly toolNames: readonly string[];
36
+ }
37
+ /** Rolls up per-turn Turn records into whole-run usage totals. */
38
+ export declare function summarizeRunUsage(turns: readonly Turn[]): RunUsageSummary;
package/dist/turns.js ADDED
@@ -0,0 +1,36 @@
1
+ /** Derives one Turn per real `turn_end` event, in the stream's own order. */
2
+ export function deriveTurns(events) {
3
+ const turns = [];
4
+ let turnIndex = 0;
5
+ for (const event of events) {
6
+ if (event.type !== "turn_end")
7
+ continue;
8
+ turnIndex++;
9
+ const message = event.message;
10
+ const isAssistant = message.role === "assistant";
11
+ const usage = isAssistant ? message.usage : undefined;
12
+ turns.push({
13
+ turn: turnIndex,
14
+ model: isAssistant ? message.model : "",
15
+ tokensIn: usage?.input ?? 0,
16
+ tokensOut: usage?.output ?? 0,
17
+ cacheReadTokens: usage?.cacheRead ?? 0,
18
+ costUsd: usage?.cost.total ?? 0,
19
+ toolCalls: event.toolResults.length,
20
+ toolNames: event.toolResults.map((result) => result.toolName),
21
+ });
22
+ }
23
+ return turns;
24
+ }
25
+ /** Rolls up per-turn Turn records into whole-run usage totals. */
26
+ export function summarizeRunUsage(turns) {
27
+ return {
28
+ turns: turns.length,
29
+ tokensIn: turns.reduce((sum, turn) => sum + turn.tokensIn, 0),
30
+ tokensOut: turns.reduce((sum, turn) => sum + turn.tokensOut, 0),
31
+ cacheReadTokens: turns.reduce((sum, turn) => sum + turn.cacheReadTokens, 0),
32
+ costUsd: turns.reduce((sum, turn) => sum + turn.costUsd, 0),
33
+ toolCalls: turns.reduce((sum, turn) => sum + turn.toolCalls, 0),
34
+ toolNames: turns.flatMap((turn) => turn.toolNames),
35
+ };
36
+ }
package/package.json ADDED
@@ -0,0 +1,47 @@
1
+ {
2
+ "name": "@danypops/pi-eval-harness",
3
+ "version": "0.1.0",
4
+ "description": "Scores a real agent run's own tool-call behavior -- AND/OR tool-call matching, graduated checker composition, and turn/tool-call/token-usage rollups -- over Pi's own real AgentSessionEvent stream (e.g. from @danypops/pi-process-harness).",
5
+ "license": "MIT",
6
+ "type": "module",
7
+ "main": "./dist/index.js",
8
+ "sideEffects": false,
9
+ "types": "./dist/index.d.ts",
10
+ "exports": {
11
+ ".": { "types": "./dist/index.d.ts", "default": "./dist/index.js" },
12
+ "./package.json": "./package.json"
13
+ },
14
+ "scripts": {
15
+ "build": "rm -rf dist && tsc -p tsconfig.build.json",
16
+ "test": "bun run build && bun test test",
17
+ "typecheck": "tsc --noEmit",
18
+ "format": "biome format --write ."
19
+ },
20
+ "repository": {
21
+ "type": "git",
22
+ "url": "git+https://github.com/DanyPops/pi-integral.git",
23
+ "directory": "packages/pi-eval-harness"
24
+ },
25
+ "homepage": "https://github.com/DanyPops/pi-integral/tree/main/packages/pi-eval-harness#readme",
26
+ "bugs": {
27
+ "url": "https://github.com/DanyPops/pi-integral/issues"
28
+ },
29
+ "peerDependencies": {
30
+ "@earendil-works/pi-coding-agent": "*"
31
+ },
32
+ "devDependencies": {
33
+ "@biomejs/biome": "^2.5.6",
34
+ "@danypops/pi-process-harness": "workspace:*",
35
+ "@earendil-works/pi-ai": "*",
36
+ "@earendil-works/pi-coding-agent": "*",
37
+ "@types/node": "^22.0.0",
38
+ "bun-types": "latest",
39
+ "typebox": "*",
40
+ "typescript": "^5.9.2"
41
+ },
42
+ "publishConfig": {
43
+ "access": "public"
44
+ },
45
+ "files": ["src", "dist", "README.md", "LICENSE"],
46
+ "keywords": ["pi", "pi-extension", "testing", "eval", "agent-evaluation", "tool-call"]
47
+ }
package/src/checker.ts ADDED
@@ -0,0 +1,67 @@
1
+ import { describeToolCall, matchesToolCall, type ToolCall } from "./tool-call.js";
2
+ import type { ToolExecution } from "./tool-executions.js";
3
+
4
+ /** Outcome of a Checker.check() call with graduated scoring, ported from Alef's own eval framework. */
5
+ export interface CheckerResult {
6
+ readonly pass: boolean;
7
+ /** Graduated score 0-1: 0.0 hard fail, 0.5 partial, 1.0 full pass. */
8
+ readonly score: number;
9
+ readonly errors: readonly string[];
10
+ }
11
+
12
+ /** Runtime context passed to a Checker -- the real completed tool executions from one run. */
13
+ export interface CheckerContext {
14
+ readonly executions: readonly ToolExecution[];
15
+ }
16
+
17
+ /** A pure, deterministic verifier over one run's real tool executions. */
18
+ export interface Checker {
19
+ check(context: CheckerContext): CheckerResult | Promise<CheckerResult>;
20
+ }
21
+
22
+ /**
23
+ * A Checker requiring every expectation to be satisfied by at least one execution (AND
24
+ * semantics) -- ported from Alef's `Evaluation.expects`.
25
+ */
26
+ export function expectsAll(expectations: readonly ToolCall[]): Checker {
27
+ return {
28
+ check({ executions }: CheckerContext): CheckerResult {
29
+ const errors = expectations
30
+ .filter((expectation) => !executions.some((execution) => matchesToolCall(execution, expectation)))
31
+ .map((expectation) => `Expected ${describeToolCall(expectation)}`);
32
+ return { pass: errors.length === 0, score: errors.length === 0 ? 1 : 0, errors };
33
+ },
34
+ };
35
+ }
36
+
37
+ /**
38
+ * A Checker requiring at least one expectation to be satisfied by at least one execution (OR
39
+ * semantics) -- ported from Alef's `Evaluation.expectsAny`.
40
+ */
41
+ export function expectsAny(expectations: readonly ToolCall[]): Checker {
42
+ return {
43
+ check({ executions }: CheckerContext): CheckerResult {
44
+ if (expectations.length === 0) return { pass: true, score: 1, errors: [] };
45
+ const satisfied = expectations.some((expectation) => executions.some((execution) => matchesToolCall(execution, expectation)));
46
+ if (satisfied) return { pass: true, score: 1, errors: [] };
47
+ const description = expectations.map((expectation) => describeToolCall(expectation)).join(" OR ");
48
+ return { pass: false, score: 0, errors: [`Expected at least one: ${description}`] };
49
+ },
50
+ };
51
+ }
52
+
53
+ /**
54
+ * Composes several checkers with AND semantics: the combined score is the minimum of every
55
+ * checker's own score, and every checker's errors are concatenated -- ported from Alef's
56
+ * `checker.ts` `all()`.
57
+ */
58
+ export function all(...checkers: readonly Checker[]): Checker {
59
+ return {
60
+ async check(context: CheckerContext): Promise<CheckerResult> {
61
+ const results = await Promise.all(checkers.map((checker) => checker.check(context)));
62
+ const errors = results.flatMap((result) => result.errors);
63
+ const score = results.length === 0 ? 1 : Math.min(...results.map((result) => result.score));
64
+ return { pass: errors.length === 0, score, errors };
65
+ },
66
+ };
67
+ }
package/src/index.ts ADDED
@@ -0,0 +1,4 @@
1
+ export { all, type Checker, type CheckerContext, type CheckerResult, expectsAll, expectsAny } from "./checker.js";
2
+ export { describeToolCall, matchesToolCall, type ToolCall } from "./tool-call.js";
3
+ export { extractToolExecutions, type ToolExecution } from "./tool-executions.js";
4
+ export { deriveTurns, type RunUsageSummary, summarizeRunUsage, type Turn } from "./turns.js";
@@ -0,0 +1,88 @@
1
+ import type { ToolExecution } from "./tool-executions.js";
2
+
3
+ /**
4
+ * One expected tool interaction, ported from Alef's own `packages/core/eval` evaluation
5
+ * framework (`ToolCall`/`matchesToolCall`/`expects`/`expectsAny`) -- adapted from Alef's OTel
6
+ * `SpanRecord[]` onto this package's own `ToolExecution[]` (see `tool-executions.ts`), the real
7
+ * projection of a Pi `AgentSessionEvent` stream's completed tool calls.
8
+ *
9
+ * tool -- which tool was invoked (OR semantics across the array).
10
+ * target -- what the tool was called on, matched against its real input args.
11
+ * produces -- what the tool's output must contain.
12
+ *
13
+ * All non-undefined fields must match for the expectation to be satisfied.
14
+ */
15
+ export interface ToolCall {
16
+ /** Acceptable tool name(s) -- any one satisfies the call dimension. */
17
+ readonly tool: string | readonly string[];
18
+ /** Fields that must appear in the tool's input args. */
19
+ readonly target?: {
20
+ /** File path the tool operated on (substring or regex). */
21
+ readonly path?: string | RegExp;
22
+ /** Search pattern used (substring or regex). */
23
+ readonly pattern?: string | RegExp;
24
+ /** Symbol name targeted (substring or regex). */
25
+ readonly symbol?: string | RegExp;
26
+ /** URL fetched (substring or regex). */
27
+ readonly url?: string | RegExp;
28
+ /** Arbitrary payload field -- key/value or regex. */
29
+ readonly [key: string]: string | RegExp | undefined;
30
+ };
31
+ /** What the tool's output must contain (substring or regex). */
32
+ readonly produces?: string | RegExp;
33
+ }
34
+
35
+ function matchValue(actual: string, expected: string | RegExp): boolean {
36
+ return expected instanceof RegExp ? expected.test(actual) : actual.includes(expected);
37
+ }
38
+
39
+ /** Stringifies a real tool result (which may be any JSON value, not only text) for `produces` matching. */
40
+ function stringifyResult(result: unknown): string {
41
+ if (typeof result === "string") return result;
42
+ try {
43
+ return JSON.stringify(result) ?? "";
44
+ } catch {
45
+ return String(result);
46
+ }
47
+ }
48
+
49
+ function argValue(args: unknown, key: string): string {
50
+ if (typeof args !== "object" || args === null) return "";
51
+ const value = (args as Record<string, unknown>)[key];
52
+ return typeof value === "string" ? value : "";
53
+ }
54
+
55
+ /** Whether one real completed tool execution satisfies a tool-call expectation. */
56
+ export function matchesToolCall(execution: ToolExecution, expectation: ToolCall): boolean {
57
+ const tools = Array.isArray(expectation.tool) ? expectation.tool : [expectation.tool];
58
+ if (!tools.some((name) => execution.toolName === name)) return false;
59
+
60
+ if (expectation.target) {
61
+ for (const [key, pattern] of Object.entries(expectation.target)) {
62
+ if (pattern === undefined) continue;
63
+ if (!matchValue(argValue(execution.args, key), pattern)) return false;
64
+ }
65
+ }
66
+
67
+ if (expectation.produces !== undefined) {
68
+ if (!matchValue(stringifyResult(execution.result), expectation.produces)) return false;
69
+ }
70
+
71
+ return true;
72
+ }
73
+
74
+ /** Formats a tool-call expectation as a human-readable description for a checker error. */
75
+ export function describeToolCall(expectation: ToolCall): string {
76
+ const tools = Array.isArray(expectation.tool) ? expectation.tool.join("|") : expectation.tool;
77
+ const target = expectation.target
78
+ ? ` on ${Object.entries(expectation.target)
79
+ .filter(([, value]) => value !== undefined)
80
+ .map(([key, value]) => `${key}=${value instanceof RegExp ? value.source : String(value)}`)
81
+ .join(", ")}`
82
+ : "";
83
+ const produces =
84
+ expectation.produces !== undefined
85
+ ? ` → ${expectation.produces instanceof RegExp ? expectation.produces.source : expectation.produces}`
86
+ : "";
87
+ return `${tools}${target}${produces}`;
88
+ }
@@ -0,0 +1,39 @@
1
+ import type { AgentSessionEvent } from "@earendil-works/pi-coding-agent";
2
+
3
+ /** One completed tool call, paired from its real tool_execution_start/tool_execution_end events. */
4
+ export interface ToolExecution {
5
+ readonly toolCallId: string;
6
+ readonly toolName: string;
7
+ readonly args: unknown;
8
+ readonly result: unknown;
9
+ readonly isError: boolean;
10
+ }
11
+
12
+ /**
13
+ * Pairs each `tool_execution_start` with its matching `tool_execution_end` by `toolCallId`, in
14
+ * completion order. A start with no matching end (a call still running when the stream ends, or
15
+ * aborted mid-flight) is dropped -- only a fully finished call is real trace data a checker can
16
+ * score.
17
+ */
18
+ export function extractToolExecutions(events: readonly AgentSessionEvent[]): ToolExecution[] {
19
+ const starts = new Map<string, { toolName: string; args: unknown }>();
20
+ const executions: ToolExecution[] = [];
21
+ for (const event of events) {
22
+ if (event.type === "tool_execution_start") {
23
+ starts.set(event.toolCallId, { toolName: event.toolName, args: event.args });
24
+ continue;
25
+ }
26
+ if (event.type === "tool_execution_end") {
27
+ const start = starts.get(event.toolCallId);
28
+ if (start === undefined) continue;
29
+ executions.push({
30
+ toolCallId: event.toolCallId,
31
+ toolName: start.toolName,
32
+ args: start.args,
33
+ result: event.result,
34
+ isError: event.isError,
35
+ });
36
+ }
37
+ }
38
+ return executions;
39
+ }
package/src/turns.ts ADDED
@@ -0,0 +1,73 @@
1
+ import type { AgentSessionEvent } from "@earendil-works/pi-coding-agent";
2
+
3
+ /**
4
+ * One real LLM turn, derived directly from a `turn_end` event -- unlike Alef's own
5
+ * `deriveturns` (which had to reconstruct turn boundaries by scanning for OTel `chat ` spans
6
+ * and attributing trailing command spans to them), Pi's real `AgentEvent` union already emits
7
+ * an explicit `turn_end` per turn, carrying the assistant's own message (with real `Usage`) and
8
+ * that turn's own `toolResults` directly -- no reconstruction needed.
9
+ */
10
+ export interface Turn {
11
+ /** 1-based turn index within the run. */
12
+ readonly turn: number;
13
+ /** Model id from the assistant message, empty when the turn's message isn't an assistant reply. */
14
+ readonly model: string;
15
+ readonly tokensIn: number;
16
+ readonly tokensOut: number;
17
+ readonly cacheReadTokens: number;
18
+ /** Real cost in USD from Usage.cost.total, when the provider reports pricing. */
19
+ readonly costUsd: number;
20
+ /** Number of tool calls dispatched from this turn. */
21
+ readonly toolCalls: number;
22
+ /** Names of tools called in this turn, in dispatch order. */
23
+ readonly toolNames: readonly string[];
24
+ }
25
+
26
+ /** Derives one Turn per real `turn_end` event, in the stream's own order. */
27
+ export function deriveTurns(events: readonly AgentSessionEvent[]): Turn[] {
28
+ const turns: Turn[] = [];
29
+ let turnIndex = 0;
30
+ for (const event of events) {
31
+ if (event.type !== "turn_end") continue;
32
+ turnIndex++;
33
+ const message = event.message;
34
+ const isAssistant = message.role === "assistant";
35
+ const usage = isAssistant ? message.usage : undefined;
36
+ turns.push({
37
+ turn: turnIndex,
38
+ model: isAssistant ? message.model : "",
39
+ tokensIn: usage?.input ?? 0,
40
+ tokensOut: usage?.output ?? 0,
41
+ cacheReadTokens: usage?.cacheRead ?? 0,
42
+ costUsd: usage?.cost.total ?? 0,
43
+ toolCalls: event.toolResults.length,
44
+ toolNames: event.toolResults.map((result) => result.toolName),
45
+ });
46
+ }
47
+ return turns;
48
+ }
49
+
50
+ /** Aggregate token/cost/turn/tool-call totals across a whole run's turns. */
51
+ export interface RunUsageSummary {
52
+ readonly turns: number;
53
+ readonly tokensIn: number;
54
+ readonly tokensOut: number;
55
+ readonly cacheReadTokens: number;
56
+ readonly costUsd: number;
57
+ readonly toolCalls: number;
58
+ /** Every tool name called across the whole run, in dispatch order. */
59
+ readonly toolNames: readonly string[];
60
+ }
61
+
62
+ /** Rolls up per-turn Turn records into whole-run usage totals. */
63
+ export function summarizeRunUsage(turns: readonly Turn[]): RunUsageSummary {
64
+ return {
65
+ turns: turns.length,
66
+ tokensIn: turns.reduce((sum, turn) => sum + turn.tokensIn, 0),
67
+ tokensOut: turns.reduce((sum, turn) => sum + turn.tokensOut, 0),
68
+ cacheReadTokens: turns.reduce((sum, turn) => sum + turn.cacheReadTokens, 0),
69
+ costUsd: turns.reduce((sum, turn) => sum + turn.costUsd, 0),
70
+ toolCalls: turns.reduce((sum, turn) => sum + turn.toolCalls, 0),
71
+ toolNames: turns.flatMap((turn) => turn.toolNames),
72
+ };
73
+ }