mirror of
https://github.com/paperclipai/paperclip.git
synced 2026-10-28 12:04:41 +01:00
## Thinking Path > - Paperclip is the open source app people use to manage AI agents for work. > - Paperclip Runner needs repeatable evaluation contracts. > - Evaluation code must stay separate from provider launch and production orchestration. > - Offline fixtures need stable compatibility, scoring, traceability, and report rules. > - Published Runner consumers need only the supported evaluation contract surface. > - This pull request adds offline evaluation tooling and a workspace-private matrix kernel. > - The benefit is deterministic evaluation without credentials or paid provider calls. ## Linked Issues or Issue Description Refs #11297 This pull request extracts the offline evaluation unit from the earlier aggregate Runner work. ## What Changed - Add a workspace-private, provider-neutral evaluation matrix kernel. - Add the public `@paperclipai/paperclip-runner/evals` compatibility and native execution contracts. - Add fail-closed runnerd artifact and protocol compatibility checks. - Add deterministic workflow catalogs, scoring, traceability, and report generation. - Add sanitized Codex, OpenCode, and ACPX fixtures. - Add package-boundary and clean-consumer checks. - Add the eval package manifest to the Docker dependency stage. - Add the generated protocol fixture digest without changing the lockfile. ## Verification GitHub Actions must run: - Runner TypeScript and Rust type checks. - Runner unit and protocol tests. - Evaluation kernel tests. - Workflow traceability checks. - Clean-consumer and package-boundary checks. - Repository test, type-check, build, policy, and security gates. No local test command was run. The repository owner requested GitHub-only verification. ## Risks This is a large greenfield review surface with 51 files. The code does not launch a live provider or load credentials. Package and protocol drift fail closed. The workspace lockfile remains under the existing CI-owned process. ## Model Used OpenAI Codex with the GPT-5 agent model. The work used high reasoning, repository inspection, tool use, and parallel code review. ## Checklist - [x] I have included a thinking path that traces from project context to this change - [x] I have specified the model used (with version and capability details) - [x] I have checked ROADMAP.md and confirmed this PR does not duplicate planned core work - [x] I have searched GitHub for duplicate or related PRs and linked them above - [x] I have either (a) linked existing issues with `Fixes: #` / `Closes #` / `Refs #` OR (b) described the issue in-PR following the relevant issue template - [x] I have not referenced internal/instance-local Paperclip issues or links (only public GitHub `#NNN` / `github.com/paperclipai/paperclip` URLs) - [x] My branch name describes the change (e.g. `docs/...`, `fix/...`) and contains no internal Paperclip ticket id or instance-derived details - [ ] I have run tests locally and they pass - [x] I have added or updated tests where applicable - [x] I have updated relevant documentation to reflect my changes - [x] I have considered and documented any risks above - [ ] All Paperclip CI gates are green - [ ] Greptile is 5/5 with no open P2s, recommendations, or follow-ups - [x] I will address all Greptile and reviewer comments before requesting merge
262 lines
10 KiB
TypeScript
262 lines
10 KiB
TypeScript
import { runPaperclipEvalMatrix, type PaperclipEvalCandidate } from "@paperclipai/paperclip-eval-kernel";
|
|
|
|
import acpxFixture from "./fixtures/acpx-sanitized-provider.json" with { type: "json" };
|
|
import codexFixture from "./fixtures/codex-sanitized-provider.json" with { type: "json" };
|
|
import opencodeFixture from "./fixtures/opencode-sanitized-provider.json" with { type: "json" };
|
|
|
|
import {
|
|
canonicalProviderEventsFromAcpxRuntimeEvent,
|
|
canonicalProviderEventsFromCodex,
|
|
canonicalProviderEventsFromOpenCodePart,
|
|
type CanonicalProviderEvent,
|
|
} from "../provider-events.js";
|
|
import type { EvalObservation } from "./eval-scoring.js";
|
|
import {
|
|
RUNNER_WORKFLOW_OBSERVATION_SCHEMA,
|
|
assertRunnerWorkflowObservation,
|
|
type RunnerWorkflowCheck,
|
|
type RunnerWorkflowEvalCase,
|
|
type RunnerWorkflowObservation,
|
|
type RunnerWorkflowProvider,
|
|
} from "./workflow-contracts.js";
|
|
import { RUNNER_WORKFLOW_CATALOG, assertRunnerWorkflowCatalog } from "./workflow-catalog.js";
|
|
import { scoreRunnerWorkflow, type RunnerWorkflowScoringOptions } from "./workflow-scoring.js";
|
|
|
|
export interface DeterministicRunnerWorkflowCandidate {
|
|
provider: RunnerWorkflowProvider;
|
|
transport: "codex-app-server" | "opencode-server" | "acp-json-rpc";
|
|
fixtureRevision: "stress-sanitized-v1";
|
|
}
|
|
|
|
export const DETERMINISTIC_RUNNER_WORKFLOW_CANDIDATES: readonly PaperclipEvalCandidate<DeterministicRunnerWorkflowCandidate>[] = Object.freeze([
|
|
{ id: "fixture-codex", config: { provider: "codex", transport: "codex-app-server", fixtureRevision: "stress-sanitized-v1" } },
|
|
{ id: "fixture-opencode", config: { provider: "opencode", transport: "opencode-server", fixtureRevision: "stress-sanitized-v1" } },
|
|
{ id: "fixture-acpx", config: { provider: "acpx", transport: "acp-json-rpc", fixtureRevision: "stress-sanitized-v1" } },
|
|
]);
|
|
|
|
function providerFixtureEvents(provider: RunnerWorkflowProvider, caseId: string): CanonicalProviderEvent[] {
|
|
const materialize = <T>(value: T): T => JSON.parse(
|
|
JSON.stringify(value).replaceAll("fixture-", `${caseId}-`),
|
|
) as T;
|
|
if (provider === "codex") {
|
|
return codexFixture.records.flatMap((record) =>
|
|
canonicalProviderEventsFromCodex(record.method, materialize(record.params)));
|
|
}
|
|
if (provider === "opencode") {
|
|
return opencodeFixture.parts.flatMap((part) => canonicalProviderEventsFromOpenCodePart(materialize(part)));
|
|
}
|
|
return acpxFixture.events.flatMap((event) => {
|
|
const materialized = materialize(event);
|
|
return canonicalProviderEventsFromAcpxRuntimeEvent(
|
|
materialized as Parameters<typeof canonicalProviderEventsFromAcpxRuntimeEvent>[0],
|
|
materialized.toolCallId,
|
|
);
|
|
});
|
|
}
|
|
|
|
function check(id: string, passed: boolean, reason: string, evidenceIds: string[] = []): RunnerWorkflowCheck {
|
|
return { id, passed, ...(passed ? {} : { reason }), ...(evidenceIds.length === 0 ? {} : { evidenceIds }) };
|
|
}
|
|
|
|
function includesOrdered(actual: readonly string[], expected: readonly string[]): boolean {
|
|
let cursor = 0;
|
|
for (const marker of actual) {
|
|
if (marker === expected[cursor]) cursor += 1;
|
|
}
|
|
return cursor === expected.length;
|
|
}
|
|
|
|
function activityFamily(eventType: string): string {
|
|
return eventType.startsWith("tool.execution.")
|
|
? "tool_execution"
|
|
: eventType.split(".")[0] ?? eventType;
|
|
}
|
|
|
|
function deterministicObservation(
|
|
evalCase: RunnerWorkflowEvalCase,
|
|
candidateId: string,
|
|
provider: RunnerWorkflowProvider,
|
|
): RunnerWorkflowObservation {
|
|
const applicable = evalCase.providers.includes(provider);
|
|
const expectedCalls = evalCase.assertions.requiredOperationIds ?? [];
|
|
const providerEvents = providerFixtureEvents(provider, evalCase.id);
|
|
const providerEventTypes: string[] = providerEvents.map((event) => event.eventType);
|
|
const evidenceIds = [...new Set(providerEvents.map((event) => event.itemId))];
|
|
const observedActivityFamilies = [...new Set(providerEventTypes.map(activityFamily))];
|
|
const requiredPrp = evalCase.assertions.requiredPrpEventTypes ?? [];
|
|
const missingPrp = requiredPrp.filter((eventType) => !providerEventTypes.includes(eventType));
|
|
const forbiddenPrp = (evalCase.assertions.forbiddenPrpEventTypes ?? [])
|
|
.filter((eventType) => providerEventTypes.includes(eventType));
|
|
const missingCalls = expectedCalls;
|
|
const requiredActivityFamilies = evalCase.assertions.requiredActivityFamilies ?? [];
|
|
const missingActivityFamilies = requiredActivityFamilies
|
|
.filter((family) => !observedActivityFamilies.includes(family));
|
|
const expectedMarkers = evalCase.assertions.orderedMarkers ?? [];
|
|
const terminalPresent = providerEventTypes.includes("run.terminal");
|
|
const traceCapture = providerEvents.length === 0 ? "off" : "on";
|
|
const lifecycleChecks = [
|
|
check(
|
|
"required-prp-events",
|
|
missingPrp.length === 0,
|
|
`fixture is missing required PRP event(s): ${missingPrp.join(", ")}`,
|
|
evidenceIds,
|
|
),
|
|
check(
|
|
"forbidden-prp-events",
|
|
forbiddenPrp.length === 0,
|
|
`fixture contains forbidden PRP event(s): ${forbiddenPrp.join(", ")}`,
|
|
evidenceIds,
|
|
),
|
|
check("terminal-authority", terminalPresent, "fixture contains no terminal authority evidence", evidenceIds),
|
|
check("lifecycle-state", false, "fixture contains no issue, run, or semantic lifecycle evidence"),
|
|
check("attempt-bound", false, "fixture contains no attempt-count evidence"),
|
|
check("run-bound", false, "fixture contains no run-count evidence"),
|
|
check("owned-recovery", false, "fixture contains no recovery-owner evidence"),
|
|
];
|
|
const continuationChecks = [
|
|
check(
|
|
"causal-order",
|
|
expectedMarkers.length > 0 && includesOrdered(providerEventTypes, expectedMarkers),
|
|
"fixture provider events do not establish the expected conversation-marker order",
|
|
evidenceIds,
|
|
),
|
|
check("no-repeated-work", false, "fixture contains no repeated-work evidence"),
|
|
check("single-owned-wake", false, "fixture contains no continuation-wake ownership evidence"),
|
|
];
|
|
const presentationChecks = [
|
|
check("response-source", false, "fixture contains no response-resolution evidence"),
|
|
check("comment-count", false, "fixture contains no conversation comment evidence"),
|
|
check("ordered-presentation", false, "fixture contains no rendered presentation order"),
|
|
check("no-stuck-running", terminalPresent, "fixture contains no terminal presentation evidence", evidenceIds),
|
|
check(
|
|
"required-activity-families",
|
|
missingActivityFamilies.length === 0,
|
|
`fixture is missing required activity family/families: ${missingActivityFamilies.join(", ")}`,
|
|
evidenceIds,
|
|
),
|
|
];
|
|
const evidenceComplete = [
|
|
...lifecycleChecks,
|
|
...continuationChecks,
|
|
...presentationChecks,
|
|
].every((entry) => entry.passed)
|
|
&& missingCalls.length === 0;
|
|
const classification = !applicable
|
|
? "skipped"
|
|
: evidenceComplete
|
|
? "completed"
|
|
: "candidate_failure";
|
|
const base: EvalObservation = {
|
|
caseId: evalCase.id,
|
|
provenance: { source: "fixture", behavior: evalCase.id },
|
|
controlPlaneOwned: expectedCalls.length === 0,
|
|
expectedCalls,
|
|
observedCalls: [],
|
|
forbiddenCalls: [],
|
|
finalState: {
|
|
expected: expectedCalls.length === 0 ? "unchanged" : "mutated",
|
|
observed: "unchanged",
|
|
},
|
|
authorization: {
|
|
expected: expectedCalls.length === 0 ? "absent" : "allowed",
|
|
observed: "absent",
|
|
},
|
|
trace: {
|
|
...(providerEvents[0] === undefined ? {} : { itemId: providerEvents[0].itemId }),
|
|
receiptIds: [],
|
|
terminalPresent,
|
|
},
|
|
};
|
|
const failure = classification === "completed"
|
|
? undefined
|
|
: classification === "skipped"
|
|
? {
|
|
code: "provider_not_applicable",
|
|
category: "qualification" as const,
|
|
retryable: false,
|
|
message: `${provider} is not applicable to ${evalCase.id}`,
|
|
}
|
|
: {
|
|
code: "fixture_evidence_incomplete",
|
|
category: "candidate" as const,
|
|
retryable: false,
|
|
message: `Sanitized provider fixture lacks workflow evidence${missingCalls.length === 0 ? "" : ` and semantic receipt(s) for ${missingCalls.join(", ")}`}`,
|
|
};
|
|
const observation: RunnerWorkflowObservation = {
|
|
schema: RUNNER_WORKFLOW_OBSERVATION_SCHEMA,
|
|
caseId: evalCase.id,
|
|
candidateId,
|
|
provider,
|
|
classification,
|
|
base,
|
|
lifecycle: {
|
|
checks: lifecycleChecks,
|
|
},
|
|
continuation: {
|
|
wakeReasons: [],
|
|
consumedInputIds: [],
|
|
repeatedWorkSignals: [],
|
|
checks: continuationChecks,
|
|
},
|
|
presentation: {
|
|
orderedMarkers: providerEventTypes,
|
|
visibleActivityFamilies: observedActivityFamilies,
|
|
checks: presentationChecks,
|
|
},
|
|
traceLineage: {
|
|
capture: traceCapture,
|
|
frameCount: providerEvents.length,
|
|
byteCount: Buffer.byteLength(JSON.stringify(providerEvents)),
|
|
digestVerified: false,
|
|
ordered: false,
|
|
dispositions: [],
|
|
lineage: [],
|
|
},
|
|
metrics: {
|
|
attempts: 0,
|
|
toolCount: providerEvents.filter((event) => event.eventType.startsWith("tool.")).length,
|
|
},
|
|
observedPrpEventTypes: [...new Set(providerEventTypes)],
|
|
artifactDigests: [],
|
|
...(failure === undefined ? {} : { failure }),
|
|
};
|
|
assertRunnerWorkflowObservation(observation);
|
|
return observation;
|
|
}
|
|
|
|
export interface RunnerWorkflowMatrixEntry {
|
|
scenarioId: string;
|
|
candidateId: string;
|
|
observation: RunnerWorkflowObservation;
|
|
scorecard: ReturnType<typeof scoreRunnerWorkflow>;
|
|
}
|
|
|
|
/** Runs the complete offline workflow matrix using the provider-neutral eval kernel. */
|
|
export async function runDeterministicRunnerWorkflowMatrix(
|
|
scoring: RunnerWorkflowScoringOptions = { bundleId: "runner-workflows-deterministic-v1" },
|
|
): Promise<readonly RunnerWorkflowMatrixEntry[]> {
|
|
assertRunnerWorkflowCatalog();
|
|
const results = await runPaperclipEvalMatrix({
|
|
scenarios: RUNNER_WORKFLOW_CATALOG.map((entry) => ({ id: entry.id, input: entry })),
|
|
candidates: DETERMINISTIC_RUNNER_WORKFLOW_CANDIDATES,
|
|
execute: async ({ scenario, candidate }) => deterministicObservation(
|
|
scenario.input,
|
|
candidate.id,
|
|
candidate.config.provider,
|
|
),
|
|
score: ({ output }) => scoreRunnerWorkflow(output, scoring),
|
|
});
|
|
const entries = results.map((result) => ({
|
|
scenarioId: result.scenarioId,
|
|
candidateId: result.candidateId,
|
|
observation: result.output,
|
|
scorecard: result.score,
|
|
}));
|
|
for (const entry of entries) {
|
|
if (entry.observation.classification === "completed" && entry.scorecard.overall.passed !== true) {
|
|
throw new Error(`deterministic Runner workflow failed: ${entry.scenarioId}/${entry.candidateId}`);
|
|
}
|
|
}
|
|
return Object.freeze(entries);
|
|
}
|