mirror of
https://github.com/paperclipai/paperclip.git
synced 2026-10-29 04:25:52 +01:00
## Thinking Path > - Paperclip manages AI agents and their work. > - Product E2E campaigns retain each attempt and its independent grades. > - Braintrust receives product and cleanup grades for each retained attempt. > - A six-case qualification produces twelve scores. The export allowed only eight. > - Experiment creation succeeded, but grade insertion stopped before dispatch. > - This fix allows fourteen scores so the six- and seven-case batches retain every result. > - Operators can inspect failed and successful grades without running an agent again. ## Linked Issues or Issue Description Refs: #17044 **What happened?** A retained six-case Product E2E campaign created its Braintrust experiment, then failed with `Experiment ingestion allowance exhausted`. Its twelve deterministic scores exceeded the eight-score cap. No insert request was sent. Agent results and model accounting remained saved. **Expected behavior** The bounded qualification batches must export both grades for every selected attempt. Failed grades must remain visible. **Steps to reproduce** Create an experiment through `openExperimentIngestion`. Insert six or seven events with `product_pass` and `cleanup_pass` scores. The old limit rejects the insert before it reaches the transport. **Paperclip version or commit** Reproduced on master `cff331f6ca0a652b5e41641c68dfa5fa584c22ae`. **Deployment mode** Local Docker controller built from source. The failure is in the shared Product export path. ## What Changed - Raise the finite experiment score cap from eight to fourteen. Keep the three-request, 4 MiB and 60-second limits. - Add regressions for the six- and seven-case batches. Keep the failed product grade in each test batch. - Verify that the receipt collector accepts the old eight-score policy and the new fourteen-score policy. It rejects a fifteen-score policy. - Document the seven-attempt export ceiling. This change does not add agent calls or automatic retries. ## Verification - Before the fix: both new batch regressions failed with `Experiment ingestion allowance exhausted`. - After the fix: 67 tests pass across experiment ingestion, ingestion accounting, experiment projection and scheduled finalization. - Command: `node node_modules/vitest/vitest.mjs run --config tests/runner-e2e/vitest.config.ts tests/runner-e2e/experiment-ingestion.test.ts tests/runner-e2e/ingestion-usage.test.ts tests/runner-e2e/scheduled-finalization.test.ts tests/runner-e2e/braintrust-experiment.test.ts --configLoader runner --cache=false`. - `git diff --check` passes. All 58 current-head checks are acceptable, including typecheck, build and tests. Both reviews are 5/5, with no unresolved threads. - Live agent attempts will not be repeated to verify this reporting fix. Recovery uses the saved results and a separate bounded ingestion receipt. ## Risks - New exports can submit at most six more deterministic scores per experiment. These are saved grades, not model judge calls. - Larger campaigns still fail closed. Existing receipt files keep their original policies and traffic counts. - This fix does not resolve the plan-lifecycle behavior failures observed in the qualification. ## Model Used OpenAI Codex, GPT-6 family, with reasoning, tool use and code execution. The exact serving variant and context-window size are not exposed in this session. ## 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 - [x] 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 - [x] All Paperclip CI gates are green - [x] Greptile is 5/5 with no open P2s, recommendations, or follow-ups - [x] I will address all Greptile and reviewer comments before requesting merge Co-authored-by: Paperclip <noreply@paperclip.ing>
92 lines
5.6 KiB
TypeScript
92 lines
5.6 KiB
TypeScript
import { createHash, randomUUID } from "node:crypto";
|
|
import { mkdir, open } from "node:fs/promises";
|
|
import path from "node:path";
|
|
import { isEvalProjectId } from "./eval-otlp-payload.js";
|
|
|
|
export const EVAL_EXPERIMENT_LIMITS = { maxRequests: 3, maxTotalBytes: 4 * 1024 * 1024, maxScores: 14 } as const;
|
|
|
|
export async function openExperimentIngestion(input: {
|
|
receiptPath: string; campaignId: string; projectId: string; deadlineAt: number; transport?: typeof fetch;
|
|
}) {
|
|
if (!path.isAbsolute(input.receiptPath) || !/^[A-Za-z0-9_.:-]{1,512}$/.test(input.campaignId)
|
|
|| !isEvalProjectId(input.projectId) || !Number.isFinite(input.deadlineAt)
|
|
|| input.deadlineAt <= Date.now() || input.deadlineAt > Date.now() + 60_000) throw new Error("Invalid experiment ingestion policy");
|
|
await mkdir(path.dirname(input.receiptPath), { recursive: true, mode: 0o700 });
|
|
const file = await open(input.receiptPath, "ax", 0o600);
|
|
let writes = Promise.resolve(), closed = false, poisoned = false, active = false;
|
|
let admittedRequests = 0, admittedBytes = 0, admittedScores = 0, responses = 0;
|
|
const record = (value: object) => {
|
|
writes = writes.then(async () => { await file.writeFile(JSON.stringify(value) + "\n"); await file.sync(); })
|
|
.catch(error => { poisoned = true; throw error; });
|
|
return writes;
|
|
};
|
|
try { await record({ type: "policy", version: 1, kind: "experiment", projectId: input.projectId, campaignId: input.campaignId,
|
|
limits: EVAL_EXPERIMENT_LIMITS, openedAt: new Date().toISOString(), deadlineAt: new Date(input.deadlineAt).toISOString() }); }
|
|
catch (error) { await file.close(); throw error; }
|
|
const transport: typeof fetch = async (target, init) => {
|
|
if (closed || poisoned || active || Date.now() >= input.deadlineAt) throw new Error("Experiment ingestion unavailable");
|
|
const url = String(target), method = init?.method;
|
|
const create = url === "https://api.braintrust.dev/v1/experiment" && method === "POST";
|
|
const insert = /^https:\/\/api\.braintrust\.dev\/v1\/experiment\/[0-9a-f-]{36}\/insert$/.test(url) && method === "POST";
|
|
const summarize = /^https:\/\/api\.braintrust\.dev\/v1\/experiment\/[0-9a-f-]{36}\/summarize$/.test(url) && method === "GET";
|
|
if (!create && !insert && !summarize || summarize && init?.body !== undefined
|
|
|| !summarize && typeof init?.body !== "string") throw new Error("Unqualified experiment ingestion request");
|
|
const body = summarize ? "" : init!.body as string;
|
|
const bytes = Buffer.byteLength(body);
|
|
if (bytes > EVAL_EXPERIMENT_LIMITS.maxTotalBytes) throw new Error("Experiment ingestion byte limit");
|
|
let scores = 0;
|
|
if (body) {
|
|
const payload = JSON.parse(body);
|
|
if (create && payload.project_id !== input.projectId) throw new Error("Experiment ingestion project mismatch");
|
|
if (insert) {
|
|
if (!Array.isArray(payload.events)) throw new Error("Invalid experiment ingestion events");
|
|
scores = payload.events.reduce((sum: number, event: { scores?: object }) => sum + Object.keys(event.scores ?? {}).length, 0);
|
|
}
|
|
}
|
|
if (admittedRequests >= EVAL_EXPERIMENT_LIMITS.maxRequests || admittedBytes + bytes > EVAL_EXPERIMENT_LIMITS.maxTotalBytes
|
|
|| admittedScores + scores > EVAL_EXPERIMENT_LIMITS.maxScores) throw new Error("Experiment ingestion allowance exhausted");
|
|
active = true;
|
|
const id = randomUUID();
|
|
admittedRequests++; admittedBytes += bytes; admittedScores += scores;
|
|
try {
|
|
await record({ type: "admitted", id, at: new Date().toISOString(), operation: create ? "create" : insert ? "insert" : "summarize",
|
|
bodyBytes: bytes, scores, payloadSha256: createHash("sha256").update(body).digest("hex") });
|
|
if (closed || poisoned || Date.now() >= input.deadlineAt) throw new Error("Experiment ingestion unavailable");
|
|
let response: Response;
|
|
try {
|
|
const upstream = await (input.transport ?? fetch)(target, { ...init, redirect: "error", signal: AbortSignal.any([
|
|
AbortSignal.timeout(Math.max(1, Math.min(15_000, input.deadlineAt - Date.now()))), ...(init?.signal ? [init.signal] : []),
|
|
]) });
|
|
const reader = upstream.body?.getReader(), chunks: Uint8Array[] = [];
|
|
let responseBytes = 0;
|
|
try {
|
|
while (reader) {
|
|
const next = await reader.read();
|
|
if (next.done) break;
|
|
responseBytes += next.value.length;
|
|
if (responseBytes > 1_048_576) throw new Error("Oversized experiment response");
|
|
chunks.push(next.value);
|
|
}
|
|
response = new Response(upstream.body === null ? null : Buffer.concat(chunks), { status: upstream.status, headers: upstream.headers });
|
|
} finally { await reader?.cancel().catch(() => {}); }
|
|
} catch {
|
|
await record({ type: "outcome", id, at: new Date().toISOString(), outcome: "unconfirmed" });
|
|
throw new Error("Experiment ingestion response unconfirmed");
|
|
}
|
|
try { await record({ type: "outcome", id, at: new Date().toISOString(), outcome: "response", httpStatus: response.status }); }
|
|
catch (error) { await response.body?.cancel().catch(() => {}); throw error; }
|
|
responses++;
|
|
return response;
|
|
} finally { active = false; }
|
|
};
|
|
let closing: Promise<void> | undefined;
|
|
return { transport, close: () => closing ??= (async () => {
|
|
closed = true;
|
|
try {
|
|
if (active) throw new Error("Experiment ingestion is still in flight");
|
|
await record({ type: "closed", at: new Date().toISOString(), admittedRequests, admittedBytes, admittedScores,
|
|
responses, unresolvedRequests: admittedRequests - responses });
|
|
} finally { await file.close(); }
|
|
})() };
|
|
}
|