agentcert 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.
@@ -0,0 +1,188 @@
1
+ import { createHash } from "node:crypto";
2
+ import { mkdir, readFile, writeFile } from "node:fs/promises";
3
+ import { dirname, resolve } from "node:path";
4
+ export const FAILURE_TYPES = [
5
+ "prompt_injection",
6
+ "wrong_click",
7
+ "timeout",
8
+ "verification_gap",
9
+ "silent_partial_success",
10
+ "network_failure",
11
+ "ui_drift",
12
+ "policy_or_approval",
13
+ "agent_connection",
14
+ "console_error",
15
+ "assertion_failure",
16
+ "unknown_failure",
17
+ ];
18
+ export function createFailureReview(input) {
19
+ const reviewedAt = input.reviewedAt ?? new Date().toISOString();
20
+ const reviewer = input.reviewer ?? "human-reviewer";
21
+ const confidence = normalizeReviewConfidence(input.confidence);
22
+ const id = stableReviewId({
23
+ reviewedAt,
24
+ reviewer,
25
+ target: input.target,
26
+ type: input.type,
27
+ status: input.status,
28
+ });
29
+ return {
30
+ schemaVersion: "1",
31
+ kind: "agentcert.failure_review",
32
+ id,
33
+ reviewedAt,
34
+ reviewer,
35
+ status: input.status,
36
+ target: input.target,
37
+ suggestedType: input.suggestedType,
38
+ type: input.type,
39
+ note: input.note,
40
+ confidence,
41
+ evidenceContext: input.evidenceContext,
42
+ taxonomyRationale: input.taxonomyRationale,
43
+ };
44
+ }
45
+ export function parseFailureType(input) {
46
+ if (input && FAILURE_TYPES.includes(input)) {
47
+ return input;
48
+ }
49
+ throw new Error(`Unknown failure type "${input ?? ""}". Use one of: ${FAILURE_TYPES.join(", ")}.`);
50
+ }
51
+ export function parseFailureReviewStatus(input, type, suggestedType) {
52
+ if (input === "confirmed" || input === "corrected") {
53
+ return input;
54
+ }
55
+ return suggestedType && type === suggestedType ? "confirmed" : "corrected";
56
+ }
57
+ export function parseReviewConfidence(input) {
58
+ if (input === undefined)
59
+ return undefined;
60
+ const value = typeof input === "number" ? input : Number(input);
61
+ return normalizeReviewConfidence(value);
62
+ }
63
+ export async function readFailureReviews(path) {
64
+ if (!path)
65
+ return [];
66
+ try {
67
+ const raw = await readFile(resolve(path), "utf8");
68
+ return raw
69
+ .split(/\r?\n/)
70
+ .filter((line) => line.trim().length > 0)
71
+ .map((line) => JSON.parse(line))
72
+ .filter((review) => review.kind === "agentcert.failure_review");
73
+ }
74
+ catch (error) {
75
+ if (error && typeof error === "object" && "code" in error && error.code === "ENOENT") {
76
+ return [];
77
+ }
78
+ throw error;
79
+ }
80
+ }
81
+ export async function appendFailureReview(path, review) {
82
+ const outPath = resolve(path);
83
+ await mkdir(dirname(outPath), { recursive: true });
84
+ const existing = await readFile(outPath, "utf8").catch((error) => {
85
+ if (error && typeof error === "object" && "code" in error && error.code === "ENOENT")
86
+ return "";
87
+ throw error;
88
+ });
89
+ const prefix = existing.trimEnd();
90
+ const separator = prefix.length > 0 ? "\n" : "";
91
+ await writeFile(outPath, `${prefix}${separator}${JSON.stringify(review)}\n`);
92
+ }
93
+ export function applyFailureReviews(records, reviews) {
94
+ if (reviews.length === 0) {
95
+ return records.map(normalizeRecordReviewState);
96
+ }
97
+ const sortedReviews = [...reviews].sort((left, right) => left.reviewedAt.localeCompare(right.reviewedAt));
98
+ return records.map((record) => {
99
+ const failurePatterns = record.failurePatterns.map((pattern) => applyReviewToPattern(record, pattern, sortedReviews));
100
+ return {
101
+ ...record,
102
+ failurePatterns,
103
+ metadata: {
104
+ ...record.metadata,
105
+ taxonomyReview: {
106
+ reviewedFailurePatterns: failurePatterns.filter((pattern) => pattern.reviewStatus === "confirmed" || pattern.reviewStatus === "corrected").length,
107
+ correctedFailurePatterns: failurePatterns.filter((pattern) => pattern.reviewStatus === "corrected").length,
108
+ unreviewedFailurePatterns: failurePatterns.filter((pattern) => (pattern.reviewStatus ?? "unreviewed") === "unreviewed").length,
109
+ },
110
+ },
111
+ };
112
+ });
113
+ }
114
+ export function findFailurePattern(records, target) {
115
+ for (const record of records) {
116
+ if (!recordMatchesTarget(record, target))
117
+ continue;
118
+ const pattern = record.failurePatterns.find((item) => item.key === target.patternKey);
119
+ if (pattern)
120
+ return { record, pattern };
121
+ }
122
+ return undefined;
123
+ }
124
+ function normalizeRecordReviewState(record) {
125
+ return {
126
+ ...record,
127
+ failurePatterns: record.failurePatterns.map((pattern) => ({
128
+ ...pattern,
129
+ suggestedType: pattern.suggestedType ?? pattern.type,
130
+ reviewStatus: pattern.reviewStatus ?? "unreviewed",
131
+ })),
132
+ };
133
+ }
134
+ function applyReviewToPattern(record, pattern, reviews) {
135
+ const suggestedType = pattern.suggestedType ?? pattern.type;
136
+ let next = {
137
+ ...pattern,
138
+ suggestedType,
139
+ reviewStatus: pattern.reviewStatus ?? "unreviewed",
140
+ };
141
+ for (const review of reviews) {
142
+ if (!reviewApplies(record, pattern, review))
143
+ continue;
144
+ next = {
145
+ ...next,
146
+ type: review.type,
147
+ suggestedType,
148
+ reviewStatus: review.status,
149
+ reviewId: review.id,
150
+ reviewedAt: review.reviewedAt,
151
+ reviewer: review.reviewer,
152
+ reviewNote: review.note,
153
+ reviewConfidence: review.confidence,
154
+ reviewEvidenceContext: review.evidenceContext,
155
+ taxonomyRationale: review.taxonomyRationale,
156
+ };
157
+ }
158
+ return next;
159
+ }
160
+ function reviewApplies(record, pattern, review) {
161
+ if (review.target.patternKey !== pattern.key)
162
+ return false;
163
+ return recordMatchesTarget(record, review.target);
164
+ }
165
+ function recordMatchesTarget(record, target) {
166
+ if (target.recordId && target.recordId !== record.id)
167
+ return false;
168
+ if (target.runId && target.runId !== record.runId)
169
+ return false;
170
+ if (target.product && target.product !== record.product)
171
+ return false;
172
+ if (target.scenarioName && target.scenarioName !== record.scenarioName)
173
+ return false;
174
+ if (target.faultName && target.faultName !== record.faultName)
175
+ return false;
176
+ return true;
177
+ }
178
+ function stableReviewId(input) {
179
+ return createHash("sha256").update(JSON.stringify(input)).digest("hex").slice(0, 16);
180
+ }
181
+ function normalizeReviewConfidence(input) {
182
+ if (input === undefined)
183
+ return undefined;
184
+ if (!Number.isFinite(input) || input < 0 || input > 1) {
185
+ throw new Error("Review confidence must be a number from 0 to 1.");
186
+ }
187
+ return input;
188
+ }
package/dist/index.js ADDED
@@ -0,0 +1,10 @@
1
+ export * from "./bundle.js";
2
+ export * from "./corpus.js";
3
+ export * from "./corpus-store.js";
4
+ export * from "./failure-review.js";
5
+ export * from "./local-server.js";
6
+ export * from "./monitor.js";
7
+ export * from "./normalizers.js";
8
+ export * from "./report.js";
9
+ export * from "./schema-validator.js";
10
+ export * from "./types.js";
package/dist/lab.js ADDED
@@ -0,0 +1,323 @@
1
+ import { access, mkdir, readFile, writeFile } from "node:fs/promises";
2
+ import { dirname, join, posix, resolve } from "node:path";
3
+ export async function buildRobustnessLabSnapshot(config, cwd = process.cwd()) {
4
+ const agents = [];
5
+ const matrix = [];
6
+ const corpus = config.corpusPath ? await readJsonlIfExists(resolve(cwd, config.corpusPath)) : [];
7
+ for (const agent of config.agents) {
8
+ const result = agent.resultPath ? await readJsonIfExists(resolve(cwd, agent.resultPath)) : undefined;
9
+ if (!result) {
10
+ agents.push({
11
+ id: agent.id,
12
+ name: agent.name,
13
+ kind: agent.kind,
14
+ status: "missing",
15
+ repositoryUrl: agent.repositoryUrl,
16
+ adapterPath: agent.adapterPath,
17
+ configPath: agent.configPath,
18
+ resultPath: agent.resultPath,
19
+ reportPath: agent.reportPath,
20
+ requiresModelKey: agent.requiresModelKey ?? false,
21
+ includedInPublicSnapshot: agent.includedInPublicSnapshot ?? false,
22
+ totalRuns: 0,
23
+ passedRuns: 0,
24
+ failedRuns: 0,
25
+ passRate: 0,
26
+ notes: agent.notes,
27
+ });
28
+ continue;
29
+ }
30
+ const runs = result.runs ?? [];
31
+ const traces = await loadTracesForRuns(agent, cwd, runs);
32
+ const passedRuns = numberValue(result.summary?.passedRuns) ?? runs.filter((run) => run.status === "passed").length;
33
+ const totalRuns = numberValue(result.summary?.totalRuns) ?? runs.length;
34
+ const failedRuns = numberValue(result.summary?.failedRuns) ?? Math.max(0, totalRuns - passedRuns);
35
+ const passRate = ratio(passedRuns, totalRuns);
36
+ agents.push({
37
+ id: agent.id,
38
+ name: agent.name,
39
+ kind: agent.kind,
40
+ status: "completed",
41
+ repositoryUrl: agent.repositoryUrl,
42
+ adapterPath: agent.adapterPath,
43
+ configPath: agent.configPath,
44
+ resultPath: agent.resultPath,
45
+ reportPath: agent.reportPath,
46
+ requiresModelKey: agent.requiresModelKey ?? false,
47
+ includedInPublicSnapshot: agent.includedInPublicSnapshot ?? false,
48
+ score: scoreValue(result.summary?.overallScore),
49
+ totalRuns,
50
+ passedRuns,
51
+ failedRuns,
52
+ passRate,
53
+ notes: agent.notes,
54
+ });
55
+ for (const run of runs) {
56
+ const tracePath = stringValue(run.tracePath);
57
+ const scenarioName = stringValue(run.scenarioName);
58
+ const faultName = stringValue(run.faultName) ?? "unknown";
59
+ const trace = tracePath ? traces.get(traceKey(scenarioName, faultName)) : undefined;
60
+ const cleanTrace = faultName === "clean" ? undefined : traces.get(traceKey(scenarioName, "clean"));
61
+ const primaryFailure = firstFailure(run);
62
+ const taxonomy = taxonomyForRun(corpus, run, primaryFailure);
63
+ const inferredType = primaryFailure ? inferFailureType(faultName, primaryFailure, run) : undefined;
64
+ matrix.push({
65
+ agentId: agent.id,
66
+ agentName: agent.name,
67
+ scenarioName,
68
+ faultName,
69
+ status: run.status === "passed" ? "passed" : "failed",
70
+ durationMs: numberValue(run.durationMs),
71
+ stepCount: numberValue(run.stepCount),
72
+ finalUrl: stringValue(run.finalUrl),
73
+ primaryFailure,
74
+ firstDivergence: firstDivergence(agent, tracePath, trace, cleanTrace, run),
75
+ taxonomyLabel: taxonomy?.type ?? inferredType,
76
+ suggestedTaxonomyLabel: taxonomy?.suggestedType ?? inferredType,
77
+ taxonomyReviewStatus: taxonomy?.reviewStatus ?? (inferredType ? "unreviewed" : undefined),
78
+ reviewerConfidence: taxonomy?.reviewConfidence,
79
+ taxonomyRationale: taxonomy?.taxonomyRationale,
80
+ tracePath: publicArtifactPath(agent, tracePath),
81
+ reportPath: publicArtifactPath(agent, agent.reportPath),
82
+ screenshotPath: tracePath ? screenshotPathFor(agent, tracePath, trace) : undefined,
83
+ });
84
+ }
85
+ }
86
+ const completedAgents = agents.filter((agent) => agent.status === "completed");
87
+ const passedRuns = matrix.filter((cell) => cell.status === "passed").length;
88
+ const failedRuns = matrix.length - passedRuns;
89
+ return {
90
+ schemaVersion: "1",
91
+ kind: "agentcert.real_agent_robustness_lab",
92
+ generatedAt: new Date().toISOString(),
93
+ name: config.name,
94
+ description: config.description,
95
+ summary: {
96
+ agentCount: agents.length,
97
+ completedAgentCount: completedAgents.length,
98
+ totalRuns: matrix.length,
99
+ passedRuns,
100
+ failedRuns,
101
+ passRate: ratio(passedRuns, matrix.length),
102
+ faultCount: new Set(matrix.map((cell) => cell.faultName)).size,
103
+ },
104
+ agents,
105
+ faults: summarizeFaults(matrix),
106
+ matrix,
107
+ limitations: config.limitations ?? [],
108
+ };
109
+ }
110
+ export async function writeRobustnessLabSnapshot(path, snapshot) {
111
+ const outPath = resolve(path);
112
+ await mkdir(dirname(outPath), { recursive: true });
113
+ await writeFile(outPath, `${JSON.stringify(snapshot, null, 2)}\n`);
114
+ }
115
+ export function renderRobustnessLabSummary(snapshot) {
116
+ const lines = [
117
+ "# Real Agent Robustness Lab",
118
+ "",
119
+ `Agents: ${snapshot.summary.completedAgentCount}/${snapshot.summary.agentCount} completed`,
120
+ `Runs: ${snapshot.summary.passedRuns}/${snapshot.summary.totalRuns} passed (${Math.round(snapshot.summary.passRate * 100)}%)`,
121
+ `Faults: ${snapshot.summary.faultCount}`,
122
+ "",
123
+ "## Agents",
124
+ ...snapshot.agents.map((agent) => `- ${agent.name}: ${agent.status}${agent.status === "completed" ? `, ${agent.passedRuns}/${agent.totalRuns} passed` : ""}`),
125
+ ];
126
+ return `${lines.join("\n")}\n`;
127
+ }
128
+ export async function readRobustnessLabConfig(path) {
129
+ return (await readJson(path));
130
+ }
131
+ function summarizeFaults(matrix) {
132
+ const buckets = new Map();
133
+ for (const cell of matrix) {
134
+ const bucket = buckets.get(cell.faultName) ?? { totalRuns: 0, passedRuns: 0 };
135
+ bucket.totalRuns += 1;
136
+ if (cell.status === "passed")
137
+ bucket.passedRuns += 1;
138
+ buckets.set(cell.faultName, bucket);
139
+ }
140
+ return [...buckets.entries()]
141
+ .map(([faultName, bucket]) => ({
142
+ faultName,
143
+ totalRuns: bucket.totalRuns,
144
+ passedRuns: bucket.passedRuns,
145
+ failedRuns: bucket.totalRuns - bucket.passedRuns,
146
+ passRate: ratio(bucket.passedRuns, bucket.totalRuns),
147
+ }))
148
+ .sort((left, right) => left.passRate - right.passRate || left.faultName.localeCompare(right.faultName));
149
+ }
150
+ async function loadTracesForRuns(agent, cwd, runs) {
151
+ const traces = new Map();
152
+ if (!agent.resultPath)
153
+ return traces;
154
+ const resultDir = dirname(resolve(cwd, agent.resultPath));
155
+ for (const run of runs) {
156
+ const scenarioName = stringValue(run.scenarioName);
157
+ const faultName = stringValue(run.faultName) ?? "unknown";
158
+ const tracePath = stringValue(run.tracePath);
159
+ if (!tracePath)
160
+ continue;
161
+ const trace = await readJsonIfExists(join(resultDir, tracePath));
162
+ if (trace)
163
+ traces.set(traceKey(scenarioName, faultName), trace);
164
+ }
165
+ return traces;
166
+ }
167
+ function screenshotPathFor(agent, tracePath, trace) {
168
+ const screenshot = [...(trace?.steps ?? [])].reverse().find((step) => step.screenshotPath)?.screenshotPath;
169
+ if (!screenshot)
170
+ return undefined;
171
+ return publicArtifactPath(agent, posix.join(posix.dirname(slashPath(tracePath)), slashPath(screenshot)));
172
+ }
173
+ function firstDivergence(agent, tracePath, trace, cleanTrace, run) {
174
+ if (tracePath && trace?.steps && cleanTrace?.steps) {
175
+ const maxSteps = Math.max(trace.steps.length, cleanTrace.steps.length);
176
+ for (let index = 0; index < maxSteps; index += 1) {
177
+ const current = trace.steps[index];
178
+ const baseline = cleanTrace.steps[index];
179
+ if (!current || !baseline) {
180
+ return divergence(agent, tracePath, current, baseline, "step-count", "Trace length diverged from the clean run.");
181
+ }
182
+ if (current.url !== baseline.url) {
183
+ return divergence(agent, tracePath, current, baseline, "url", "First observed URL diverged from the clean run.");
184
+ }
185
+ if (current.textHash !== baseline.textHash) {
186
+ return divergence(agent, tracePath, current, baseline, "text", "First visible text snapshot diverged from the clean run.");
187
+ }
188
+ if (current.domHash !== baseline.domHash) {
189
+ return divergence(agent, tracePath, current, baseline, "dom", "First DOM snapshot diverged from the clean run.");
190
+ }
191
+ }
192
+ }
193
+ const failure = firstFailure(run);
194
+ if (!failure)
195
+ return undefined;
196
+ return {
197
+ kind: "assertion",
198
+ note: failure,
199
+ };
200
+ }
201
+ function divergence(agent, tracePath, current, baseline, kind, note) {
202
+ return {
203
+ kind,
204
+ stepIndex: current?.stepIndex ?? baseline?.stepIndex,
205
+ baseline: divergenceValue(kind, baseline),
206
+ current: divergenceValue(kind, current),
207
+ note,
208
+ screenshotPath: current?.screenshotPath
209
+ ? publicArtifactPath(agent, posix.join(posix.dirname(slashPath(tracePath)), slashPath(current.screenshotPath)))
210
+ : undefined,
211
+ domSnapshotPath: current?.domSnapshotPath
212
+ ? publicArtifactPath(agent, posix.join(posix.dirname(slashPath(tracePath)), slashPath(current.domSnapshotPath)))
213
+ : undefined,
214
+ };
215
+ }
216
+ function divergenceValue(kind, step) {
217
+ if (!step)
218
+ return undefined;
219
+ if (kind === "url")
220
+ return step.url;
221
+ if (kind === "text")
222
+ return step.visibleTextSample ?? step.textHash;
223
+ if (kind === "dom")
224
+ return step.domHash;
225
+ return step.stepIndex === undefined ? undefined : `step ${step.stepIndex}`;
226
+ }
227
+ function traceKey(scenarioName, faultName) {
228
+ return `${scenarioName ?? "unknown"}:${faultName}`;
229
+ }
230
+ function publicArtifactPath(agent, path) {
231
+ if (!path)
232
+ return undefined;
233
+ const normalized = slashPath(path);
234
+ const prefix = agent.publicPathPrefix?.replace(/\/?$/, "/");
235
+ if (!prefix)
236
+ return normalized;
237
+ if (normalized.startsWith(prefix))
238
+ return normalized;
239
+ return `${prefix}${normalized}`;
240
+ }
241
+ function firstFailure(run) {
242
+ return run.assertions?.find((assertion) => assertion.pass === false)?.message;
243
+ }
244
+ function scoreValue(input) {
245
+ const value = numberValue(input);
246
+ if (value === undefined)
247
+ return undefined;
248
+ return value <= 1 ? Math.round(value * 100) : Math.round(value);
249
+ }
250
+ function ratio(numerator, denominator) {
251
+ return denominator === 0 ? 0 : numerator / denominator;
252
+ }
253
+ function stringValue(input) {
254
+ return typeof input === "string" && input.length > 0 ? input : undefined;
255
+ }
256
+ function numberValue(input) {
257
+ return typeof input === "number" && Number.isFinite(input) ? input : undefined;
258
+ }
259
+ async function readJson(path) {
260
+ const raw = await readFile(resolve(path), "utf8");
261
+ return JSON.parse(raw);
262
+ }
263
+ async function readJsonIfExists(path) {
264
+ try {
265
+ await access(path);
266
+ return (await readJson(path));
267
+ }
268
+ catch {
269
+ return undefined;
270
+ }
271
+ }
272
+ async function readJsonlIfExists(path) {
273
+ try {
274
+ await access(path);
275
+ const raw = await readFile(path, "utf8");
276
+ return raw
277
+ .split(/\r?\n/)
278
+ .filter((line) => line.trim().length > 0)
279
+ .map((line) => JSON.parse(line));
280
+ }
281
+ catch {
282
+ return [];
283
+ }
284
+ }
285
+ function taxonomyForRun(records, run, primaryFailure) {
286
+ const runId = stringValue(run.runId);
287
+ const scenarioName = stringValue(run.scenarioName);
288
+ const faultName = stringValue(run.faultName);
289
+ const record = records.find((candidate) => {
290
+ if (runId)
291
+ return candidate.runId === runId;
292
+ return candidate.scenarioName === scenarioName && candidate.faultName === faultName;
293
+ });
294
+ if (!record)
295
+ return undefined;
296
+ return (record.failurePatterns.find((pattern) => pattern.message === primaryFailure) ??
297
+ record.failurePatterns.find((pattern) => pattern.reviewStatus === "corrected" || pattern.reviewStatus === "confirmed") ??
298
+ record.failurePatterns[0]);
299
+ }
300
+ function inferFailureType(faultName, primaryFailure, run) {
301
+ const fault = faultName.toLowerCase();
302
+ const message = primaryFailure.toLowerCase();
303
+ const assertionType = run.assertions?.find((assertion) => assertion.pass === false)?.type ?? "";
304
+ if (assertionType === "max_steps" || message.includes("timed out") || message.includes("agent exited"))
305
+ return "timeout";
306
+ if (fault.includes("prompt-injection") || assertionType === "no_sensitive_text_in_output" || message.includes("ignore previous instructions")) {
307
+ return "prompt_injection";
308
+ }
309
+ if (fault.includes("misleading-button") || message.includes("wrong button") || message.includes("cancel"))
310
+ return "wrong_click";
311
+ if (fault.includes("button-text-drift") || fault.includes("layout-shift") || fault.includes("modal-overlay") || fault.includes("disabled-submit")) {
312
+ return "ui_drift";
313
+ }
314
+ if (assertionType === "no_console_error" || message.includes("console"))
315
+ return "console_error";
316
+ if (fault.includes("slow-network") || fault.includes("http-failure") || message.includes("network") || message.includes("503")) {
317
+ return "network_failure";
318
+ }
319
+ return "assertion_failure";
320
+ }
321
+ function slashPath(path) {
322
+ return path.replace(/\\/g, "/");
323
+ }