Files
AgentCompany/tests/eval/types.ts
T
ZacharyZhang-NY 9a5823b242 C4 follow-up #4: add message_does_not_contain_substring assertion kind + pin C4 invariants
Closes the fourth ask from the C4 cross-review (docs/proposals/C4-followup.md
Item 4). The eval harness previously had no way to assert the absence of a
pattern in a trace — only positive `message_contains_substring`. That meant
the C4 "no more node -e / bash+fetch from CEO" invariant was only
demonstrated by the diff between two commits, not by any running assertion
that future regressions would trip.

Three changes:

1. Extend the Assertion union in tests/eval/types.ts with
   `message_does_not_contain_substring` (role + substring). Symmetric to
   the positive kind; fails on first match with a specific reason.

2. Add the case in assertion-judge.ts. Same traversal as the positive
   branch but inverted exit condition.

3. Add two harness self-tests covering pass + fail, so the new kind is
   exercised even when the fixtures directory is empty.

4. Pin two C4-regression assertions onto the recorded ceo-bootstrap-empty
   fixture: the assistant response must NOT contain "node -e" OR
   "/api/companies". Any future regression that reintroduces the
   HTTP-path-in-prompt pattern or the bash+node -e shell fallback will
   now fail pnpm test:eval instead of living in the fixture silently.

typecheck green, test:eval up from 8 → 10 tests (2 new self-tests), all
passing.
2026-04-20 14:53:24 -04:00

174 lines
5.4 KiB
TypeScript

/**
* Type definitions for the B5 minimal eval harness.
*
* Design invariants:
* - Fixtures are recorded LLM traces (history real runs), not synthesized mocks.
* A fixture is the serialized truth of a turn that actually happened against
* a real provider; replaying it tells us whether the *harness* still reaches
* the same tool-call shape given the same prompt/context.
* - Every type here reuses `@mariozechner/pi-ai` primitives verbatim. Shadow
* types are forbidden; if pi-ai changes its `AssistantMessage` shape, this
* harness breaks loud — that is the intent.
* - No runtime field here is optional for "fallback reasons". If a field is
* optional, it encodes real optionality of the source data (e.g. a turn
* record that does not carry usage because it is a user turn).
*/
import type {
AssistantMessage,
ToolResultMessage,
UserMessage,
} from "@mariozechner/pi-ai";
/**
* Top-level fixture file meta header.
*
* Every JSONL fixture begins with exactly one `meta` record. The runner will
* throw if the first line is not a meta record — no fallback default metadata
* is synthesized.
*/
export interface FixtureMeta {
type: "meta";
/** Stable fixture identifier; used as the scenario name in eval output. */
fixtureId: string;
/** pi-agent-core version used to record this fixture. */
piAgentCoreVersion: string;
/** ISO-8601 timestamp when the fixture was recorded against a real provider. */
recordedAt: string;
/** Short human description of what the scenario exercises. */
scenario: string;
/** sha256 of the system prompt used at recording time. Used to detect drift. */
systemPromptHash: string;
}
/**
* Token ledger captured for assistant turns.
*
* Shape mirrors `AssistantMessage.usage` but uses snake_case fields that match
* the JSONL spec printed in `README.md`. The mapping is 1:1 with pi-ai's
* `Usage` primitives (`input`, `output`, `cacheRead`, `cacheWrite`) — renamed
* for the on-disk format to stay provider-neutral.
*/
export interface TokenLedger {
input: number;
cache_read: number;
cache_creation: number;
output: number;
}
export interface UserTurn {
type: "turn";
turnIndex: number;
role: "user";
/** Verbatim user content blocks from the real recording. */
content: UserMessage["content"];
}
export interface AssistantTurn {
type: "turn";
turnIndex: number;
role: "assistant";
/** Verbatim assistant content blocks (text / thinking / toolCall). */
content: AssistantMessage["content"];
/** Token ledger recorded for this assistant turn. Required on assistant turns. */
tokenLedger: TokenLedger;
}
export interface ToolResultTurn {
type: "turn";
turnIndex: number;
role: "toolResult";
toolUseId: string;
content: ToolResultMessage["content"];
}
export type TraceTurn = UserTurn | AssistantTurn | ToolResultTurn;
/**
* Structural assertions that can be evaluated against a replayed trace.
*
* Deliberately minimal: assertions express tool-call shape, order, count, and
* textual substrings. Semantic quality judgments (tone, plan soundness) are
* out of scope for this tier and will be handled by the LLM-as-judge tier
* described in B5.1.
*/
export type Assertion =
| {
kind: "tool_called";
name: string;
min_count?: number;
max_count?: number;
/** Ordinal position in the trace (0-based) among all tool calls. */
order?: number;
}
| { kind: "no_tool_called"; name: string }
| {
kind: "message_contains_substring";
role: "assistant" | "user";
substring: string;
}
| {
/**
* Negative substring check. Fails when *any* message of `role` contains
* `substring`; used to pin post-refactor invariants where a particular
* pattern must disappear from the trace (for example, asserting that a
* CEO bootstrap no longer emits `node -e` after C4).
*/
kind: "message_does_not_contain_substring";
role: "assistant" | "user";
substring: string;
}
| {
kind: "tool_call_arg_matches";
name: string;
/** Dotted JSON path into the tool call's arguments object, e.g. "agent.role". */
arg_path: string;
/** ECMAScript regex source evaluated against the argument string. */
matches_regex: string;
}
| { kind: "trace_max_turns"; max: number };
export interface AssertionsBlock {
type: "assertions";
assertions: Assertion[];
}
/**
* A JSONL fixture file is parsed into exactly one of these records per line.
* The loader rejects files that do not begin with `meta` and end with
* `assertions`.
*/
export type FixtureLine = FixtureMeta | TraceTurn | AssertionsBlock;
/**
* Parsed fixture file ready to be replayed by the runner.
*/
export interface FixtureFile {
meta: FixtureMeta;
turns: TraceTurn[];
assertions: Assertion[];
}
/**
* Per-assertion outcome within a single eval run.
*/
export interface AssertionResult {
assertion: Assertion;
passed: boolean;
/** Populated when `passed === false`; concise human-readable explanation. */
reason?: string;
}
/**
* Result of evaluating one fixture against the current code path.
*/
export interface EvalResult {
fixtureId: string;
passed: boolean;
assertions: AssertionResult[];
/** Aggregated token ledger summed across all assistant turns in the fixture. */
tokenLedger: TokenLedger;
/** Wall-clock duration of the replay, in milliseconds. */
durationMs: number;
}