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.
- package/README.md +84 -0
- package/dist/badge.js +31 -0
- package/dist/bundle.js +80 -0
- package/dist/cli.js +553 -0
- package/dist/corpus-store.js +241 -0
- package/dist/corpus.js +435 -0
- package/dist/failure-review.js +188 -0
- package/dist/index.js +10 -0
- package/dist/lab.js +323 -0
- package/dist/local-server.js +491 -0
- package/dist/monitor.js +100 -0
- package/dist/normalizers.js +193 -0
- package/dist/report.js +148 -0
- package/dist/runner.js +341 -0
- package/dist/schema-validator.js +150 -0
- package/dist/types.js +1 -0
- package/package.json +31 -0
|
@@ -0,0 +1,241 @@
|
|
|
1
|
+
import { mkdir } from "node:fs/promises";
|
|
2
|
+
import { dirname, resolve } from "node:path";
|
|
3
|
+
import { appendCorpusRecords, readCorpus } from "./corpus.js";
|
|
4
|
+
const DEFAULT_TABLE_NAME = "agentcert_corpus_records";
|
|
5
|
+
export async function openCorpusStore(options) {
|
|
6
|
+
const kind = options.kind ?? "jsonl";
|
|
7
|
+
if (kind === "jsonl") {
|
|
8
|
+
return createJsonlCorpusStore(options.jsonlPath ?? ".agentcert/corpus/corpus.jsonl");
|
|
9
|
+
}
|
|
10
|
+
if (kind === "sqlite") {
|
|
11
|
+
return createSqliteCorpusStore(options.sqlitePath ?? ".agentcert/corpus/agentcert.sqlite", options.tableName);
|
|
12
|
+
}
|
|
13
|
+
if (kind === "postgres") {
|
|
14
|
+
if (!options.databaseUrl) {
|
|
15
|
+
throw new Error("Postgres corpus store requires --database-url or AGENTCERT_DATABASE_URL.");
|
|
16
|
+
}
|
|
17
|
+
return createPostgresCorpusStore(options.databaseUrl, options.tableName);
|
|
18
|
+
}
|
|
19
|
+
throw new Error(`Unsupported corpus store: ${kind}`);
|
|
20
|
+
}
|
|
21
|
+
export function parseCorpusStoreKind(input) {
|
|
22
|
+
const value = input ?? "jsonl";
|
|
23
|
+
if (value === "jsonl" || value === "sqlite" || value === "postgres") {
|
|
24
|
+
return value;
|
|
25
|
+
}
|
|
26
|
+
throw new Error(`Unsupported corpus store "${value}". Use jsonl, sqlite, or postgres.`);
|
|
27
|
+
}
|
|
28
|
+
export function validateCorpusTableName(input) {
|
|
29
|
+
const tableName = input ?? DEFAULT_TABLE_NAME;
|
|
30
|
+
if (!/^[A-Za-z_][A-Za-z0-9_]*$/.test(tableName)) {
|
|
31
|
+
throw new Error("Corpus table name must contain only letters, numbers, and underscores, and cannot start with a number.");
|
|
32
|
+
}
|
|
33
|
+
return tableName;
|
|
34
|
+
}
|
|
35
|
+
function createJsonlCorpusStore(path) {
|
|
36
|
+
return {
|
|
37
|
+
kind: "jsonl",
|
|
38
|
+
description: `JSONL corpus at ${resolve(path)}`,
|
|
39
|
+
append: (records, options) => appendCorpusRecords(path, records, options?.replace ?? false),
|
|
40
|
+
readAll: () => readCorpus(path),
|
|
41
|
+
close: async () => { },
|
|
42
|
+
};
|
|
43
|
+
}
|
|
44
|
+
async function createSqliteCorpusStore(path, tableNameInput) {
|
|
45
|
+
const tableName = validateCorpusTableName(tableNameInput);
|
|
46
|
+
const table = quoteIdentifier(tableName);
|
|
47
|
+
const outPath = resolve(path);
|
|
48
|
+
await mkdir(dirname(outPath), { recursive: true });
|
|
49
|
+
let sqlite;
|
|
50
|
+
try {
|
|
51
|
+
sqlite = await import("node:sqlite");
|
|
52
|
+
}
|
|
53
|
+
catch {
|
|
54
|
+
throw new Error("SQLite corpus store requires Node.js 22+ with node:sqlite available. Use --store jsonl on Node 20.");
|
|
55
|
+
}
|
|
56
|
+
const db = new sqlite.DatabaseSync(outPath);
|
|
57
|
+
db.exec(corpusTableSql(table));
|
|
58
|
+
db.exec(indexSqls(tableName, table).join(";\n"));
|
|
59
|
+
const insert = db.prepare(insertSql(table));
|
|
60
|
+
const select = db.prepare(`SELECT record_json FROM ${table} ORDER BY timestamp DESC, id ASC`);
|
|
61
|
+
return {
|
|
62
|
+
kind: "sqlite",
|
|
63
|
+
description: `SQLite corpus at ${outPath}`,
|
|
64
|
+
append: async (records, options) => {
|
|
65
|
+
if (options?.replace) {
|
|
66
|
+
db.exec(`DELETE FROM ${table}`);
|
|
67
|
+
}
|
|
68
|
+
db.exec("BEGIN");
|
|
69
|
+
try {
|
|
70
|
+
for (const record of records) {
|
|
71
|
+
insert.run(...recordSqlValues(record, "sqlite"), JSON.stringify(record));
|
|
72
|
+
}
|
|
73
|
+
db.exec("COMMIT");
|
|
74
|
+
}
|
|
75
|
+
catch (error) {
|
|
76
|
+
db.exec("ROLLBACK");
|
|
77
|
+
throw error;
|
|
78
|
+
}
|
|
79
|
+
},
|
|
80
|
+
readAll: async () => select.all().map((row) => JSON.parse(row.record_json)),
|
|
81
|
+
close: async () => {
|
|
82
|
+
db.close();
|
|
83
|
+
},
|
|
84
|
+
};
|
|
85
|
+
}
|
|
86
|
+
async function createPostgresCorpusStore(databaseUrl, tableNameInput) {
|
|
87
|
+
const tableName = validateCorpusTableName(tableNameInput);
|
|
88
|
+
const table = quoteIdentifier(tableName);
|
|
89
|
+
const pg = await importOptionalPg();
|
|
90
|
+
const client = new pg.Client({ connectionString: databaseUrl });
|
|
91
|
+
await client.connect();
|
|
92
|
+
await client.query(corpusTableSql(table, "jsonb"));
|
|
93
|
+
for (const sql of indexSqls(tableName, table)) {
|
|
94
|
+
await client.query(sql);
|
|
95
|
+
}
|
|
96
|
+
return {
|
|
97
|
+
kind: "postgres",
|
|
98
|
+
description: `Postgres corpus table ${tableName}`,
|
|
99
|
+
append: async (records, options) => {
|
|
100
|
+
if (options?.replace) {
|
|
101
|
+
await client.query(`DELETE FROM ${table}`);
|
|
102
|
+
}
|
|
103
|
+
await client.query("BEGIN");
|
|
104
|
+
try {
|
|
105
|
+
for (const record of records) {
|
|
106
|
+
await client.query(insertSql(table, "$"), [...recordSqlValues(record, "postgres"), JSON.stringify(record)]);
|
|
107
|
+
}
|
|
108
|
+
await client.query("COMMIT");
|
|
109
|
+
}
|
|
110
|
+
catch (error) {
|
|
111
|
+
await client.query("ROLLBACK");
|
|
112
|
+
throw error;
|
|
113
|
+
}
|
|
114
|
+
},
|
|
115
|
+
readAll: async () => {
|
|
116
|
+
const result = await client.query(`SELECT record_json FROM ${table} ORDER BY timestamp DESC, id ASC`);
|
|
117
|
+
return result.rows.map((row) => recordFromDatabaseJson(row.record_json));
|
|
118
|
+
},
|
|
119
|
+
close: async () => {
|
|
120
|
+
await client.end();
|
|
121
|
+
},
|
|
122
|
+
};
|
|
123
|
+
}
|
|
124
|
+
function corpusTableSql(table, jsonType = "TEXT") {
|
|
125
|
+
return `
|
|
126
|
+
CREATE TABLE IF NOT EXISTS ${table} (
|
|
127
|
+
id TEXT PRIMARY KEY,
|
|
128
|
+
ingested_at TEXT NOT NULL,
|
|
129
|
+
subject TEXT NOT NULL,
|
|
130
|
+
product TEXT NOT NULL,
|
|
131
|
+
phase TEXT NOT NULL,
|
|
132
|
+
agent_name TEXT NOT NULL,
|
|
133
|
+
agent_version TEXT NOT NULL,
|
|
134
|
+
run_id TEXT NOT NULL,
|
|
135
|
+
timestamp TEXT NOT NULL,
|
|
136
|
+
passed BOOLEAN NOT NULL,
|
|
137
|
+
score REAL NOT NULL,
|
|
138
|
+
scenario_name TEXT,
|
|
139
|
+
fault_name TEXT,
|
|
140
|
+
evidence_count INTEGER NOT NULL,
|
|
141
|
+
high_or_critical_evidence_count INTEGER NOT NULL,
|
|
142
|
+
failure_types TEXT NOT NULL,
|
|
143
|
+
source_path TEXT NOT NULL,
|
|
144
|
+
record_json ${jsonType} NOT NULL
|
|
145
|
+
)`;
|
|
146
|
+
}
|
|
147
|
+
function indexSqls(tableName, table) {
|
|
148
|
+
return [
|
|
149
|
+
`CREATE INDEX IF NOT EXISTS ${quoteIdentifier(`${tableName}_product_idx`)} ON ${table} (product)`,
|
|
150
|
+
`CREATE INDEX IF NOT EXISTS ${quoteIdentifier(`${tableName}_timestamp_idx`)} ON ${table} (timestamp)`,
|
|
151
|
+
`CREATE INDEX IF NOT EXISTS ${quoteIdentifier(`${tableName}_fault_idx`)} ON ${table} (fault_name)`,
|
|
152
|
+
`CREATE INDEX IF NOT EXISTS ${quoteIdentifier(`${tableName}_agent_idx`)} ON ${table} (agent_name)`,
|
|
153
|
+
`CREATE INDEX IF NOT EXISTS ${quoteIdentifier(`${tableName}_version_idx`)} ON ${table} (agent_version)`,
|
|
154
|
+
];
|
|
155
|
+
}
|
|
156
|
+
function insertSql(table, placeholderPrefix = "?") {
|
|
157
|
+
const values = placeholderPrefix === "?"
|
|
158
|
+
? new Array(18).fill("?").join(", ")
|
|
159
|
+
: new Array(18)
|
|
160
|
+
.fill(undefined)
|
|
161
|
+
.map((_, index) => `$${index + 1}`)
|
|
162
|
+
.join(", ");
|
|
163
|
+
return `
|
|
164
|
+
INSERT INTO ${table} (
|
|
165
|
+
id,
|
|
166
|
+
ingested_at,
|
|
167
|
+
subject,
|
|
168
|
+
product,
|
|
169
|
+
phase,
|
|
170
|
+
agent_name,
|
|
171
|
+
agent_version,
|
|
172
|
+
run_id,
|
|
173
|
+
timestamp,
|
|
174
|
+
passed,
|
|
175
|
+
score,
|
|
176
|
+
scenario_name,
|
|
177
|
+
fault_name,
|
|
178
|
+
evidence_count,
|
|
179
|
+
high_or_critical_evidence_count,
|
|
180
|
+
failure_types,
|
|
181
|
+
source_path,
|
|
182
|
+
record_json
|
|
183
|
+
) VALUES (${values})
|
|
184
|
+
ON CONFLICT(id) DO UPDATE SET
|
|
185
|
+
ingested_at = excluded.ingested_at,
|
|
186
|
+
subject = excluded.subject,
|
|
187
|
+
product = excluded.product,
|
|
188
|
+
phase = excluded.phase,
|
|
189
|
+
agent_name = excluded.agent_name,
|
|
190
|
+
agent_version = excluded.agent_version,
|
|
191
|
+
run_id = excluded.run_id,
|
|
192
|
+
timestamp = excluded.timestamp,
|
|
193
|
+
passed = excluded.passed,
|
|
194
|
+
score = excluded.score,
|
|
195
|
+
scenario_name = excluded.scenario_name,
|
|
196
|
+
fault_name = excluded.fault_name,
|
|
197
|
+
evidence_count = excluded.evidence_count,
|
|
198
|
+
high_or_critical_evidence_count = excluded.high_or_critical_evidence_count,
|
|
199
|
+
failure_types = excluded.failure_types,
|
|
200
|
+
source_path = excluded.source_path,
|
|
201
|
+
record_json = excluded.record_json`;
|
|
202
|
+
}
|
|
203
|
+
function recordSqlValues(record, dialect) {
|
|
204
|
+
return [
|
|
205
|
+
record.id,
|
|
206
|
+
record.ingestedAt,
|
|
207
|
+
record.subject,
|
|
208
|
+
record.product,
|
|
209
|
+
record.phase,
|
|
210
|
+
record.agentName,
|
|
211
|
+
record.agentVersion,
|
|
212
|
+
record.runId,
|
|
213
|
+
record.timestamp,
|
|
214
|
+
dialect === "sqlite" ? Number(record.passed) : record.passed,
|
|
215
|
+
record.score,
|
|
216
|
+
record.scenarioName ?? null,
|
|
217
|
+
record.faultName ?? null,
|
|
218
|
+
record.evidenceCount,
|
|
219
|
+
record.highOrCriticalEvidenceCount,
|
|
220
|
+
[...new Set(record.failurePatterns.map((pattern) => pattern.type))].join(","),
|
|
221
|
+
record.sourcePath,
|
|
222
|
+
];
|
|
223
|
+
}
|
|
224
|
+
function quoteIdentifier(input) {
|
|
225
|
+
return `"${validateCorpusTableName(input)}"`;
|
|
226
|
+
}
|
|
227
|
+
function recordFromDatabaseJson(input) {
|
|
228
|
+
if (typeof input === "string") {
|
|
229
|
+
return JSON.parse(input);
|
|
230
|
+
}
|
|
231
|
+
return input;
|
|
232
|
+
}
|
|
233
|
+
async function importOptionalPg() {
|
|
234
|
+
try {
|
|
235
|
+
const dynamicImport = new Function("specifier", "return import(specifier)");
|
|
236
|
+
return (await dynamicImport("pg"));
|
|
237
|
+
}
|
|
238
|
+
catch {
|
|
239
|
+
throw new Error("Postgres corpus store requires the optional dependency 'pg'. Run npm install in packages/agentcert-cli.");
|
|
240
|
+
}
|
|
241
|
+
}
|
package/dist/corpus.js
ADDED
|
@@ -0,0 +1,435 @@
|
|
|
1
|
+
import { createHash } from "node:crypto";
|
|
2
|
+
import { mkdir, readFile, writeFile } from "node:fs/promises";
|
|
3
|
+
import { dirname, resolve } from "node:path";
|
|
4
|
+
export function recordsFromAgentCertResult(result, sourcePath, subject, rawInput, ingestedAt = new Date().toISOString()) {
|
|
5
|
+
if (result.product === "tripwire-ci") {
|
|
6
|
+
const tripwireRecords = recordsFromTripwireInput(result, sourcePath, subject, rawInput, ingestedAt);
|
|
7
|
+
if (tripwireRecords.length > 0) {
|
|
8
|
+
return tripwireRecords;
|
|
9
|
+
}
|
|
10
|
+
}
|
|
11
|
+
return [
|
|
12
|
+
{
|
|
13
|
+
schemaVersion: "1",
|
|
14
|
+
kind: "product_run",
|
|
15
|
+
id: stableRecordId([subject, result.product, result.runId, sourcePath]),
|
|
16
|
+
ingestedAt,
|
|
17
|
+
subject,
|
|
18
|
+
agentName: subject,
|
|
19
|
+
agentVersion: "unversioned",
|
|
20
|
+
product: result.product,
|
|
21
|
+
phase: result.phase,
|
|
22
|
+
runId: result.runId,
|
|
23
|
+
timestamp: result.timestamp,
|
|
24
|
+
score: result.score,
|
|
25
|
+
passed: result.passed,
|
|
26
|
+
evidenceCount: result.evidence.length,
|
|
27
|
+
highOrCriticalEvidenceCount: countHighOrCritical(result.evidence),
|
|
28
|
+
failurePatterns: result.passed ? [] : failurePatternsFromEvidence(result.evidence),
|
|
29
|
+
artifacts: result.artifacts,
|
|
30
|
+
sourcePath,
|
|
31
|
+
metadata: result.summary ? { summary: result.summary } : undefined,
|
|
32
|
+
},
|
|
33
|
+
];
|
|
34
|
+
}
|
|
35
|
+
export function summarizeCorpus(records) {
|
|
36
|
+
const totalRecords = records.length;
|
|
37
|
+
const passedRecords = records.filter((record) => record.passed).length;
|
|
38
|
+
const failedRecords = totalRecords - passedRecords;
|
|
39
|
+
return {
|
|
40
|
+
totalRecords,
|
|
41
|
+
passedRecords,
|
|
42
|
+
failedRecords,
|
|
43
|
+
passRate: ratio(passedRecords, totalRecords),
|
|
44
|
+
byProduct: bucket(records, (record) => record.product),
|
|
45
|
+
byFault: bucket(records.filter((record) => record.faultName), (record) => record.faultName ?? "unknown"),
|
|
46
|
+
byAgent: bucket(records, (record) => record.agentName),
|
|
47
|
+
byVersion: bucket(records, (record) => record.agentVersion),
|
|
48
|
+
byFailureType: bucket(records.flatMap((record) => record.failurePatterns.map((pattern) => ({
|
|
49
|
+
...record,
|
|
50
|
+
failureTypeForBucket: pattern.type,
|
|
51
|
+
passed: false,
|
|
52
|
+
}))), (record) => record.failureTypeForBucket ?? "unknown_failure"),
|
|
53
|
+
taxonomy: taxonomySummary(records),
|
|
54
|
+
topFailurePatterns: topFailurePatterns(records),
|
|
55
|
+
};
|
|
56
|
+
}
|
|
57
|
+
export function reviewedFailureDataset(records) {
|
|
58
|
+
return records.flatMap((record) => record.failurePatterns
|
|
59
|
+
.filter(isReviewedPattern)
|
|
60
|
+
.map((pattern) => ({
|
|
61
|
+
schemaVersion: "1",
|
|
62
|
+
kind: "agentcert.reviewed_failure",
|
|
63
|
+
id: stableRecordId([record.id, pattern.key, pattern.reviewId ?? pattern.reviewedAt ?? pattern.type]),
|
|
64
|
+
recordId: record.id,
|
|
65
|
+
subject: record.subject,
|
|
66
|
+
agentName: record.agentName,
|
|
67
|
+
agentVersion: record.agentVersion,
|
|
68
|
+
product: record.product,
|
|
69
|
+
phase: record.phase,
|
|
70
|
+
runId: record.runId,
|
|
71
|
+
timestamp: record.timestamp,
|
|
72
|
+
scenarioName: record.scenarioName,
|
|
73
|
+
faultName: record.faultName,
|
|
74
|
+
patternKey: pattern.key,
|
|
75
|
+
severity: pattern.severity,
|
|
76
|
+
message: pattern.message,
|
|
77
|
+
suggestedType: pattern.suggestedType ?? pattern.type,
|
|
78
|
+
reviewedType: pattern.type,
|
|
79
|
+
reviewStatus: pattern.reviewStatus,
|
|
80
|
+
reviewer: pattern.reviewer,
|
|
81
|
+
reviewedAt: pattern.reviewedAt,
|
|
82
|
+
reviewConfidence: pattern.reviewConfidence,
|
|
83
|
+
firstDivergenceSnippet: pattern.reviewEvidenceContext?.firstDivergenceSnippet,
|
|
84
|
+
screenshotPath: pattern.reviewEvidenceContext?.screenshotPath,
|
|
85
|
+
screenshotUrl: pattern.reviewEvidenceContext?.screenshotUrl,
|
|
86
|
+
tracePath: pattern.reviewEvidenceContext?.tracePath,
|
|
87
|
+
stepIndex: pattern.reviewEvidenceContext?.stepIndex,
|
|
88
|
+
taxonomyRationale: pattern.taxonomyRationale,
|
|
89
|
+
sourcePath: record.sourcePath,
|
|
90
|
+
})));
|
|
91
|
+
}
|
|
92
|
+
export function evaluateFailureClassifier(records) {
|
|
93
|
+
const rows = reviewedFailureDataset(records);
|
|
94
|
+
const correctRows = rows.filter((row) => row.suggestedType === row.reviewedType).length;
|
|
95
|
+
const byTypeBuckets = new Map();
|
|
96
|
+
const confusionBuckets = new Map();
|
|
97
|
+
for (const row of rows) {
|
|
98
|
+
const byType = byTypeBuckets.get(row.reviewedType) ?? { reviewedRows: 0, correctRows: 0 };
|
|
99
|
+
byType.reviewedRows += 1;
|
|
100
|
+
if (row.suggestedType === row.reviewedType)
|
|
101
|
+
byType.correctRows += 1;
|
|
102
|
+
byTypeBuckets.set(row.reviewedType, byType);
|
|
103
|
+
const confusionKey = `${row.suggestedType}->${row.reviewedType}`;
|
|
104
|
+
const confusion = confusionBuckets.get(confusionKey) ?? {
|
|
105
|
+
suggestedType: row.suggestedType,
|
|
106
|
+
reviewedType: row.reviewedType,
|
|
107
|
+
count: 0,
|
|
108
|
+
};
|
|
109
|
+
confusion.count += 1;
|
|
110
|
+
confusionBuckets.set(confusionKey, confusion);
|
|
111
|
+
}
|
|
112
|
+
const totalFailurePatterns = records.reduce((sum, record) => sum + record.failurePatterns.length, 0);
|
|
113
|
+
return {
|
|
114
|
+
schemaVersion: "1",
|
|
115
|
+
kind: "agentcert.failure_classifier_evaluation",
|
|
116
|
+
reviewedRows: rows.length,
|
|
117
|
+
correctRows,
|
|
118
|
+
incorrectRows: rows.length - correctRows,
|
|
119
|
+
precision: ratio(correctRows, rows.length),
|
|
120
|
+
coverage: ratio(rows.length, totalFailurePatterns),
|
|
121
|
+
byType: [...byTypeBuckets.entries()]
|
|
122
|
+
.map(([type, bucket]) => ({
|
|
123
|
+
type,
|
|
124
|
+
reviewedRows: bucket.reviewedRows,
|
|
125
|
+
correctRows: bucket.correctRows,
|
|
126
|
+
precision: ratio(bucket.correctRows, bucket.reviewedRows),
|
|
127
|
+
}))
|
|
128
|
+
.sort((left, right) => left.type.localeCompare(right.type)),
|
|
129
|
+
confusion: [...confusionBuckets.values()].sort((left, right) => right.count - left.count || left.suggestedType.localeCompare(right.suggestedType)),
|
|
130
|
+
};
|
|
131
|
+
}
|
|
132
|
+
export async function writeReviewedFailureDataset(path, records) {
|
|
133
|
+
const rows = reviewedFailureDataset(records);
|
|
134
|
+
const outPath = resolve(path);
|
|
135
|
+
await mkdir(dirname(outPath), { recursive: true });
|
|
136
|
+
const payload = rows.map((row) => JSON.stringify(row)).join("\n");
|
|
137
|
+
await writeFile(outPath, `${payload}${payload.length > 0 ? "\n" : ""}`);
|
|
138
|
+
return rows;
|
|
139
|
+
}
|
|
140
|
+
export async function appendCorpusRecords(path, records, replace = false) {
|
|
141
|
+
const outPath = resolve(path);
|
|
142
|
+
await mkdir(dirname(outPath), { recursive: true });
|
|
143
|
+
const payload = records.map((record) => JSON.stringify(record)).join("\n");
|
|
144
|
+
const prefix = replace ? "" : await existingCorpus(outPath);
|
|
145
|
+
const separator = prefix.length > 0 && payload.length > 0 ? "\n" : "";
|
|
146
|
+
await writeFile(outPath, `${prefix}${separator}${payload}${payload.length > 0 ? "\n" : ""}`);
|
|
147
|
+
}
|
|
148
|
+
export async function readCorpus(path) {
|
|
149
|
+
const raw = await readFile(resolve(path), "utf8");
|
|
150
|
+
return raw
|
|
151
|
+
.split(/\r?\n/)
|
|
152
|
+
.filter((line) => line.trim().length > 0)
|
|
153
|
+
.map((line) => JSON.parse(line));
|
|
154
|
+
}
|
|
155
|
+
export function renderCorpusSummary(summary) {
|
|
156
|
+
const lines = [
|
|
157
|
+
"# AgentCert Corpus Summary",
|
|
158
|
+
"",
|
|
159
|
+
`Total records: ${summary.totalRecords}`,
|
|
160
|
+
`Passed: ${summary.passedRecords}`,
|
|
161
|
+
`Failed: ${summary.failedRecords}`,
|
|
162
|
+
`Pass rate: ${(summary.passRate * 100).toFixed(1)}%`,
|
|
163
|
+
`Taxonomy review coverage: ${(summary.taxonomy.reviewCoverage * 100).toFixed(1)}%`,
|
|
164
|
+
`Reviewed-label precision: ${(summary.taxonomy.autoLabelPrecision * 100).toFixed(1)}%`,
|
|
165
|
+
`Correction rate: ${(summary.taxonomy.correctionRate * 100).toFixed(1)}%`,
|
|
166
|
+
"",
|
|
167
|
+
"## By Product",
|
|
168
|
+
...renderBuckets(summary.byProduct),
|
|
169
|
+
"",
|
|
170
|
+
"## By Fault",
|
|
171
|
+
...renderBuckets(summary.byFault),
|
|
172
|
+
"",
|
|
173
|
+
"## Top Failure Patterns",
|
|
174
|
+
...summary.topFailurePatterns.map((pattern) => `- ${pattern.key}: ${pattern.count} (${pattern.severity}) - ${pattern.message}`),
|
|
175
|
+
];
|
|
176
|
+
return `${lines.join("\n")}\n`;
|
|
177
|
+
}
|
|
178
|
+
function recordsFromTripwireInput(result, sourcePath, subject, rawInput, ingestedAt) {
|
|
179
|
+
const input = asRecord(rawInput);
|
|
180
|
+
const runs = Array.isArray(input.runs) ? input.runs : [];
|
|
181
|
+
return runs.map((run, index) => {
|
|
182
|
+
const record = asRecord(run);
|
|
183
|
+
const assertions = Array.isArray(record.assertions) ? record.assertions.map(asRecord) : [];
|
|
184
|
+
const failedAssertions = assertions.filter((assertion) => assertion.pass === false);
|
|
185
|
+
const scenarioName = stringValue(record.scenarioName);
|
|
186
|
+
const faultName = stringValue(record.faultName);
|
|
187
|
+
const runId = stringValue(record.runId) ?? `${result.runId}_${index + 1}`;
|
|
188
|
+
const passed = record.status === "passed";
|
|
189
|
+
const agent = asRecord(record.agent);
|
|
190
|
+
const agentEnv = asRecord(agent.env);
|
|
191
|
+
const agentName = agentNameFromTripwireRun(agent, subject);
|
|
192
|
+
const agentVersion = stringValue(agentEnv.AGENTCERT_AGENT_VERSION) ?? stringValue(agentEnv.AGENT_VERSION) ?? "unversioned";
|
|
193
|
+
const failurePatterns = failedAssertions.map((assertion) => {
|
|
194
|
+
const assertionType = stringValue(assertion.type) ?? "assertion";
|
|
195
|
+
const message = stringValue(assertion.message) ?? "Tripwire assertion failed.";
|
|
196
|
+
const failureType = classifyTripwireFailure({
|
|
197
|
+
faultName,
|
|
198
|
+
assertionType,
|
|
199
|
+
message,
|
|
200
|
+
run: record,
|
|
201
|
+
});
|
|
202
|
+
return {
|
|
203
|
+
key: `tripwire:${failureType}:${faultName ?? "unknown"}:${assertionType}`,
|
|
204
|
+
severity: "high",
|
|
205
|
+
message,
|
|
206
|
+
type: failureType,
|
|
207
|
+
suggestedType: failureType,
|
|
208
|
+
reviewStatus: "unreviewed",
|
|
209
|
+
scenarioName,
|
|
210
|
+
faultName,
|
|
211
|
+
};
|
|
212
|
+
});
|
|
213
|
+
if (!passed && failurePatterns.length === 0) {
|
|
214
|
+
const failureType = classifyTripwireFailure({
|
|
215
|
+
faultName,
|
|
216
|
+
assertionType: "run_status",
|
|
217
|
+
message: "Tripwire run failed without a failed assertion.",
|
|
218
|
+
run: record,
|
|
219
|
+
});
|
|
220
|
+
failurePatterns.push({
|
|
221
|
+
key: `tripwire:${failureType}:${faultName ?? "unknown"}:run_status`,
|
|
222
|
+
severity: "high",
|
|
223
|
+
message: "Tripwire run failed without a failed assertion.",
|
|
224
|
+
type: failureType,
|
|
225
|
+
suggestedType: failureType,
|
|
226
|
+
reviewStatus: "unreviewed",
|
|
227
|
+
scenarioName,
|
|
228
|
+
faultName,
|
|
229
|
+
});
|
|
230
|
+
}
|
|
231
|
+
return {
|
|
232
|
+
schemaVersion: "1",
|
|
233
|
+
kind: "scenario_run",
|
|
234
|
+
id: stableRecordId([subject, result.product, runId, sourcePath]),
|
|
235
|
+
ingestedAt,
|
|
236
|
+
subject,
|
|
237
|
+
agentName,
|
|
238
|
+
agentVersion,
|
|
239
|
+
product: result.product,
|
|
240
|
+
phase: result.phase,
|
|
241
|
+
runId,
|
|
242
|
+
timestamp: stringValue(record.startedAt) ?? result.timestamp,
|
|
243
|
+
score: passed ? 100 : 0,
|
|
244
|
+
passed,
|
|
245
|
+
scenarioName,
|
|
246
|
+
faultName,
|
|
247
|
+
durationMs: numberValue(record.durationMs),
|
|
248
|
+
stepCount: numberValue(record.stepCount),
|
|
249
|
+
evidenceCount: failedAssertions.length,
|
|
250
|
+
highOrCriticalEvidenceCount: failedAssertions.length,
|
|
251
|
+
failurePatterns,
|
|
252
|
+
artifacts: {
|
|
253
|
+
result: sourcePath,
|
|
254
|
+
trace: stringValue(record.tracePath) ?? "",
|
|
255
|
+
artifactDir: stringValue(record.artifactDir) ?? "",
|
|
256
|
+
},
|
|
257
|
+
sourcePath,
|
|
258
|
+
metadata: {
|
|
259
|
+
finalUrl: stringValue(record.finalUrl),
|
|
260
|
+
diagnostics: Array.isArray(record.diagnostics) ? record.diagnostics : [],
|
|
261
|
+
warnings: Array.isArray(record.warnings) ? record.warnings : [],
|
|
262
|
+
failureTypes: [...new Set(failurePatterns.map((pattern) => pattern.type))],
|
|
263
|
+
},
|
|
264
|
+
};
|
|
265
|
+
});
|
|
266
|
+
}
|
|
267
|
+
function failurePatternsFromEvidence(evidence) {
|
|
268
|
+
return evidence
|
|
269
|
+
.filter((item) => item.severity === "critical" || item.severity === "high" || item.severity === "medium")
|
|
270
|
+
.map((item) => {
|
|
271
|
+
const metadata = asRecord(item.metadata);
|
|
272
|
+
return {
|
|
273
|
+
key: `${item.source ?? "agentcert"}:${stringValue(metadata.faultName) ?? item.kind}`,
|
|
274
|
+
severity: item.severity,
|
|
275
|
+
message: item.message,
|
|
276
|
+
type: classifyEvidenceFailure(item),
|
|
277
|
+
suggestedType: classifyEvidenceFailure(item),
|
|
278
|
+
reviewStatus: "unreviewed",
|
|
279
|
+
scenarioName: stringValue(metadata.scenarioName),
|
|
280
|
+
faultName: stringValue(metadata.faultName),
|
|
281
|
+
};
|
|
282
|
+
});
|
|
283
|
+
}
|
|
284
|
+
function taxonomySummary(records) {
|
|
285
|
+
const patterns = records.flatMap((record) => record.failurePatterns);
|
|
286
|
+
const confirmedFailurePatterns = patterns.filter((pattern) => pattern.reviewStatus === "confirmed").length;
|
|
287
|
+
const correctedFailurePatterns = patterns.filter((pattern) => pattern.reviewStatus === "corrected").length;
|
|
288
|
+
const reviewedFailurePatterns = confirmedFailurePatterns + correctedFailurePatterns;
|
|
289
|
+
const reviewedPatterns = patterns.filter(isReviewedPattern);
|
|
290
|
+
const confidenceValues = reviewedPatterns
|
|
291
|
+
.map((pattern) => pattern.reviewConfidence)
|
|
292
|
+
.filter((value) => typeof value === "number" && Number.isFinite(value));
|
|
293
|
+
return {
|
|
294
|
+
totalFailurePatterns: patterns.length,
|
|
295
|
+
reviewedFailurePatterns,
|
|
296
|
+
unreviewedFailurePatterns: patterns.length - reviewedFailurePatterns,
|
|
297
|
+
confirmedFailurePatterns,
|
|
298
|
+
correctedFailurePatterns,
|
|
299
|
+
reviewCoverage: ratio(reviewedFailurePatterns, patterns.length),
|
|
300
|
+
autoLabelPrecision: ratio(confirmedFailurePatterns, reviewedFailurePatterns),
|
|
301
|
+
correctionRate: ratio(correctedFailurePatterns, reviewedFailurePatterns),
|
|
302
|
+
meanReviewerConfidence: confidenceValues.length === 0 ? undefined : confidenceValues.reduce((sum, value) => sum + value, 0) / confidenceValues.length,
|
|
303
|
+
};
|
|
304
|
+
}
|
|
305
|
+
function topFailurePatterns(records) {
|
|
306
|
+
const counts = new Map();
|
|
307
|
+
for (const pattern of records.flatMap((record) => record.failurePatterns)) {
|
|
308
|
+
const current = counts.get(pattern.key);
|
|
309
|
+
counts.set(pattern.key, {
|
|
310
|
+
count: (current?.count ?? 0) + 1,
|
|
311
|
+
message: current?.message ?? pattern.message,
|
|
312
|
+
severity: current?.severity ?? pattern.severity,
|
|
313
|
+
type: current?.type ?? pattern.type,
|
|
314
|
+
});
|
|
315
|
+
}
|
|
316
|
+
return [...counts.entries()]
|
|
317
|
+
.map(([key, value]) => ({ key, ...value }))
|
|
318
|
+
.sort((left, right) => right.count - left.count || left.key.localeCompare(right.key))
|
|
319
|
+
.slice(0, 10);
|
|
320
|
+
}
|
|
321
|
+
function bucket(records, keyFor) {
|
|
322
|
+
const buckets = new Map();
|
|
323
|
+
for (const record of records) {
|
|
324
|
+
const key = keyFor(record);
|
|
325
|
+
const current = buckets.get(key) ?? { total: 0, passed: 0 };
|
|
326
|
+
current.total += 1;
|
|
327
|
+
if (record.passed)
|
|
328
|
+
current.passed += 1;
|
|
329
|
+
buckets.set(key, current);
|
|
330
|
+
}
|
|
331
|
+
return [...buckets.entries()]
|
|
332
|
+
.map(([key, value]) => ({
|
|
333
|
+
key,
|
|
334
|
+
total: value.total,
|
|
335
|
+
passed: value.passed,
|
|
336
|
+
failed: value.total - value.passed,
|
|
337
|
+
passRate: ratio(value.passed, value.total),
|
|
338
|
+
}))
|
|
339
|
+
.sort((left, right) => right.total - left.total || left.key.localeCompare(right.key));
|
|
340
|
+
}
|
|
341
|
+
function classifyTripwireFailure(input) {
|
|
342
|
+
const fault = input.faultName ?? "";
|
|
343
|
+
const assertion = input.assertionType;
|
|
344
|
+
const message = input.message.toLowerCase();
|
|
345
|
+
const diagnostics = stringArray(input.run.diagnostics).join(" ").toLowerCase();
|
|
346
|
+
const warnings = stringArray(input.run.warnings).join(" ").toLowerCase();
|
|
347
|
+
const agentResult = asRecord(input.run.agentResult);
|
|
348
|
+
if (agentResult.timedOut === true || assertion === "max_steps" || message.includes("timed out"))
|
|
349
|
+
return "timeout";
|
|
350
|
+
if (fault.includes("prompt-injection") || assertion === "no_sensitive_text_in_output" || message.includes("ignore previous instructions")) {
|
|
351
|
+
return "prompt_injection";
|
|
352
|
+
}
|
|
353
|
+
if (fault.includes("misleading-button") || message.includes("wrong button") || message.includes("cancel"))
|
|
354
|
+
return "wrong_click";
|
|
355
|
+
if (fault.includes("button-text-drift") || fault.includes("layout-shift") || fault.includes("modal-overlay") || fault.includes("disabled-submit")) {
|
|
356
|
+
return "ui_drift";
|
|
357
|
+
}
|
|
358
|
+
if (fault.includes("slow-network") || fault.includes("http-failure") || message.includes("network") || message.includes("503")) {
|
|
359
|
+
return "network_failure";
|
|
360
|
+
}
|
|
361
|
+
if (assertion === "no_console_error" || message.includes("console"))
|
|
362
|
+
return "console_error";
|
|
363
|
+
if (diagnostics.includes("did not appear to connect") || warnings.includes("did not appear to connect"))
|
|
364
|
+
return "agent_connection";
|
|
365
|
+
if (agentResult.exitCode === 0 && (assertion === "url_contains" || assertion === "text_exists"))
|
|
366
|
+
return "silent_partial_success";
|
|
367
|
+
return "assertion_failure";
|
|
368
|
+
}
|
|
369
|
+
function isReviewedPattern(pattern) {
|
|
370
|
+
return pattern.reviewStatus === "confirmed" || pattern.reviewStatus === "corrected";
|
|
371
|
+
}
|
|
372
|
+
function classifyEvidenceFailure(evidence) {
|
|
373
|
+
const kind = evidence.kind.toLowerCase();
|
|
374
|
+
const message = evidence.message.toLowerCase();
|
|
375
|
+
if (kind.includes("verification") || message.includes("did not match expected"))
|
|
376
|
+
return "verification_gap";
|
|
377
|
+
if (kind.includes("approval") || kind.includes("policy") || message.includes("approval"))
|
|
378
|
+
return "policy_or_approval";
|
|
379
|
+
if (kind.includes("timeout") || message.includes("timeout"))
|
|
380
|
+
return "timeout";
|
|
381
|
+
if (kind.includes("prompt") || message.includes("prompt injection"))
|
|
382
|
+
return "prompt_injection";
|
|
383
|
+
return "unknown_failure";
|
|
384
|
+
}
|
|
385
|
+
function agentNameFromTripwireRun(agent, fallback) {
|
|
386
|
+
const args = Array.isArray(agent.args) ? agent.args.filter((item) => typeof item === "string") : [];
|
|
387
|
+
const command = stringValue(agent.command);
|
|
388
|
+
const candidate = [...args].reverse().find((arg) => /agent|browser-use|stagehand|playwright/i.test(arg));
|
|
389
|
+
if (candidate) {
|
|
390
|
+
return candidate
|
|
391
|
+
.split(/[\\/]/)
|
|
392
|
+
.pop()
|
|
393
|
+
?.replace(/\.(mjs|js|py|ts)$/i, "")
|
|
394
|
+
.replace(/[_-]+/g, " ") ?? fallback;
|
|
395
|
+
}
|
|
396
|
+
return command ?? fallback;
|
|
397
|
+
}
|
|
398
|
+
function renderBuckets(buckets) {
|
|
399
|
+
if (buckets.length === 0) {
|
|
400
|
+
return ["- none"];
|
|
401
|
+
}
|
|
402
|
+
return buckets.map((bucket) => `- ${bucket.key}: ${bucket.passed}/${bucket.total} passed (${(bucket.passRate * 100).toFixed(1)}%), ${bucket.failed} failed`);
|
|
403
|
+
}
|
|
404
|
+
function stringArray(input) {
|
|
405
|
+
return Array.isArray(input) ? input.filter((item) => typeof item === "string") : [];
|
|
406
|
+
}
|
|
407
|
+
async function existingCorpus(path) {
|
|
408
|
+
try {
|
|
409
|
+
return (await readFile(path, "utf8")).trimEnd();
|
|
410
|
+
}
|
|
411
|
+
catch (error) {
|
|
412
|
+
if (error && typeof error === "object" && "code" in error && error.code === "ENOENT") {
|
|
413
|
+
return "";
|
|
414
|
+
}
|
|
415
|
+
throw error;
|
|
416
|
+
}
|
|
417
|
+
}
|
|
418
|
+
function countHighOrCritical(evidence) {
|
|
419
|
+
return evidence.filter((item) => item.severity === "critical" || item.severity === "high").length;
|
|
420
|
+
}
|
|
421
|
+
function stableRecordId(parts) {
|
|
422
|
+
return createHash("sha256").update(parts.join("\0")).digest("hex").slice(0, 16);
|
|
423
|
+
}
|
|
424
|
+
function ratio(numerator, denominator) {
|
|
425
|
+
return denominator === 0 ? 0 : numerator / denominator;
|
|
426
|
+
}
|
|
427
|
+
function asRecord(input) {
|
|
428
|
+
return input && typeof input === "object" && !Array.isArray(input) ? input : {};
|
|
429
|
+
}
|
|
430
|
+
function stringValue(input) {
|
|
431
|
+
return typeof input === "string" && input.length > 0 ? input : undefined;
|
|
432
|
+
}
|
|
433
|
+
function numberValue(input) {
|
|
434
|
+
return typeof input === "number" && Number.isFinite(input) ? input : undefined;
|
|
435
|
+
}
|