cuonztech-agent-harness 0.4.0 → 0.5.1
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 +260 -151
- package/dist/benchmark/cta.js +45 -31
- package/dist/benchmark/runner.js +105 -87
- package/dist/benchmark/score.js +115 -60
- package/dist/core/report-generator.js +704 -258
- package/dist/core/state-engine.js +1 -2
- package/dist/engine/state-diff.js +4 -2
- package/dist/engine/state-machine.js +164 -35
- package/dist/hardening/patch-generator.js +58 -48
- package/dist/index.js +737 -148
- package/dist/proxy/gateway.js +71 -65
- package/dist/proxy/ghost-write.js +37 -10
- package/dist/proxy/interceptor.js +367 -84
- package/dist/scenarios/f1-f5.js +34 -27
- package/dist/scenarios/types.js +0 -1
- package/dist/server/server.js +70 -18
- package/dist/server/stdio.js +0 -1
- package/dist/tools/handlers.js +80 -43
- package/dist/tools/schemas.js +27 -11
- package/dist/version.js +0 -1
- package/package.json +4 -3
- package/dist/benchmark/cta.d.ts +0 -13
- package/dist/benchmark/cta.d.ts.map +0 -1
- package/dist/benchmark/cta.js.map +0 -1
- package/dist/benchmark/runner.d.ts +0 -19
- package/dist/benchmark/runner.d.ts.map +0 -1
- package/dist/benchmark/runner.js.map +0 -1
- package/dist/benchmark/score.d.ts +0 -26
- package/dist/benchmark/score.d.ts.map +0 -1
- package/dist/benchmark/score.js.map +0 -1
- package/dist/core/report-generator.d.ts +0 -47
- package/dist/core/report-generator.d.ts.map +0 -1
- package/dist/core/report-generator.js.map +0 -1
- package/dist/core/state-engine.d.ts +0 -2
- package/dist/core/state-engine.d.ts.map +0 -1
- package/dist/core/state-engine.js.map +0 -1
- package/dist/engine/state-diff.d.ts +0 -33
- package/dist/engine/state-diff.d.ts.map +0 -1
- package/dist/engine/state-diff.js.map +0 -1
- package/dist/engine/state-machine.d.ts +0 -46
- package/dist/engine/state-machine.d.ts.map +0 -1
- package/dist/engine/state-machine.js.map +0 -1
- package/dist/hardening/patch-generator.d.ts +0 -12
- package/dist/hardening/patch-generator.d.ts.map +0 -1
- package/dist/hardening/patch-generator.js.map +0 -1
- package/dist/index.d.ts +0 -3
- package/dist/index.d.ts.map +0 -1
- package/dist/index.js.map +0 -1
- package/dist/proxy/gateway.d.ts +0 -4
- package/dist/proxy/gateway.d.ts.map +0 -1
- package/dist/proxy/gateway.js.map +0 -1
- package/dist/proxy/ghost-write.d.ts +0 -17
- package/dist/proxy/ghost-write.d.ts.map +0 -1
- package/dist/proxy/ghost-write.js.map +0 -1
- package/dist/proxy/interceptor.d.ts +0 -33
- package/dist/proxy/interceptor.d.ts.map +0 -1
- package/dist/proxy/interceptor.js.map +0 -1
- package/dist/scenarios/f1-f5.d.ts +0 -9
- package/dist/scenarios/f1-f5.d.ts.map +0 -1
- package/dist/scenarios/f1-f5.js.map +0 -1
- package/dist/scenarios/types.d.ts +0 -21
- package/dist/scenarios/types.d.ts.map +0 -1
- package/dist/scenarios/types.js.map +0 -1
- package/dist/server/server.d.ts +0 -3
- package/dist/server/server.d.ts.map +0 -1
- package/dist/server/server.js.map +0 -1
- package/dist/server/stdio.d.ts +0 -7
- package/dist/server/stdio.d.ts.map +0 -1
- package/dist/server/stdio.js.map +0 -1
- package/dist/tools/handlers.d.ts +0 -93
- package/dist/tools/handlers.d.ts.map +0 -1
- package/dist/tools/handlers.js.map +0 -1
- package/dist/tools/schemas.d.ts +0 -38
- package/dist/tools/schemas.d.ts.map +0 -1
- package/dist/tools/schemas.js.map +0 -1
- package/dist/version.d.ts +0 -2
- package/dist/version.d.ts.map +0 -1
- package/dist/version.js.map +0 -1
|
@@ -1,118 +1,609 @@
|
|
|
1
|
-
import {
|
|
1
|
+
import { extractKey, stripKeyFields, } from "../engine/state-machine.js";
|
|
2
|
+
const HANDLING_POINTS = {
|
|
3
|
+
CLEAN: 100,
|
|
4
|
+
VERIFIED_NO_RETRY: 100,
|
|
5
|
+
RECOVERED_BY_RETRY: 100,
|
|
6
|
+
ABANDONED_AFTER_FAILURE: 100,
|
|
7
|
+
UPSTREAM_REJECTED: 100,
|
|
8
|
+
EXECUTION_UNKNOWN: 0, // never used: such operations are not scored
|
|
9
|
+
UNVERIFIED_AFTER_TIMEOUT: 50,
|
|
10
|
+
RETRY_AFTER_VIOLATED: 0,
|
|
11
|
+
DUPLICATE_EXECUTION: 0,
|
|
12
|
+
};
|
|
13
|
+
// Arguments nested deeper than this are compared as one opaque value: a
|
|
14
|
+
// 5000-level argument overflowed the stack here and took the proxy down.
|
|
15
|
+
const MAX_COMPARE_DEPTH = 64;
|
|
16
|
+
function stableStringify(value, depth = 0) {
|
|
17
|
+
if (value !== null && typeof value === "object" && depth >= MAX_COMPARE_DEPTH) {
|
|
18
|
+
// Beyond this depth: the engine's own serializer (key order as sent).
|
|
19
|
+
// Only if even that gives up are two such values treated as equal.
|
|
20
|
+
try {
|
|
21
|
+
return JSON.stringify(value) ?? "null";
|
|
22
|
+
}
|
|
23
|
+
catch {
|
|
24
|
+
return '"<nested too deeply to compare>"';
|
|
25
|
+
}
|
|
26
|
+
}
|
|
27
|
+
if (Array.isArray(value)) {
|
|
28
|
+
return `[${value.map((v) => stableStringify(v, depth + 1)).join(",")}]`;
|
|
29
|
+
}
|
|
30
|
+
if (value !== null && typeof value === "object") {
|
|
31
|
+
const obj = value;
|
|
32
|
+
return `{${Object.keys(obj)
|
|
33
|
+
.sort()
|
|
34
|
+
.map((k) => `${JSON.stringify(k)}:${stableStringify(obj[k], depth + 1)}`)
|
|
35
|
+
.join(",")}}`;
|
|
36
|
+
}
|
|
37
|
+
return JSON.stringify(value) ?? "null";
|
|
38
|
+
}
|
|
39
|
+
// One argument value, reduced to what survives a change of spelling: 12 and
|
|
40
|
+
// "12", 1000 and "1e3", "Anna Keller" and "anna keller", an IBAN with and
|
|
41
|
+
// without its grouping spaces, null and "", and
|
|
42
|
+
// the same inside nested objects and arrays (array order ignored).
|
|
43
|
+
// Punctuation is kept: "A-1" and "A1", "1,5" and 15, "+" and "-" stay
|
|
44
|
+
// different — stripping it produced false alarms for different operations.
|
|
45
|
+
// Two STRINGS are compared as numbers only if both are plain decimals
|
|
46
|
+
// without leading zeros ("129.00" and "129"); "0012" and "12" may be two
|
|
47
|
+
// article numbers. A string and a number always are.
|
|
48
|
+
// Invisible characters (every Unicode format character such as zero-width
|
|
49
|
+
// space, soft hyphen, direction marks; variation selectors) are dropped.
|
|
50
|
+
function looseValue(value, depth = 0) {
|
|
51
|
+
if (value === null || value === undefined)
|
|
52
|
+
return "s:";
|
|
53
|
+
if (typeof value === "number")
|
|
54
|
+
return `n:${value}`;
|
|
55
|
+
if (typeof value === "boolean")
|
|
56
|
+
return `s:${value}`;
|
|
57
|
+
if (typeof value === "string") {
|
|
58
|
+
return `s:${value
|
|
59
|
+
.normalize("NFKC")
|
|
60
|
+
.replace(/[\p{Cf}\ufe00-\ufe0f\u{e0100}-\u{e01ef}]/gu, "")
|
|
61
|
+
.toLowerCase()
|
|
62
|
+
.replace(/\s+/g, "")}`;
|
|
63
|
+
}
|
|
64
|
+
if (depth >= MAX_COMPARE_DEPTH)
|
|
65
|
+
return `o:${stableStringify(value, depth)}`;
|
|
66
|
+
if (Array.isArray(value)) {
|
|
67
|
+
return `a:[${value.map((v) => looseValue(v, depth + 1)).sort().join(",")}]`;
|
|
68
|
+
}
|
|
69
|
+
const obj = value;
|
|
70
|
+
return `o:{${Object.keys(obj)
|
|
71
|
+
.sort()
|
|
72
|
+
.map((k) => `${JSON.stringify(k)}:${looseValue(obj[k], depth + 1)}`)
|
|
73
|
+
.join(",")}}`;
|
|
74
|
+
}
|
|
75
|
+
function numericValue(value) {
|
|
76
|
+
if (typeof value === "number")
|
|
77
|
+
return value;
|
|
78
|
+
if (typeof value === "string" && /^\s*[-+]?(\d+\.?\d*|\.\d+)(e[-+]?\d+)?\s*$/i.test(value)) {
|
|
79
|
+
return Number(value);
|
|
80
|
+
}
|
|
81
|
+
return null;
|
|
82
|
+
}
|
|
83
|
+
function looselyEqual(a, b, depth = 0) {
|
|
84
|
+
// A number on one side and its spelling as a string on the other.
|
|
85
|
+
if (typeof a === "number" || typeof b === "number") {
|
|
86
|
+
if (typeof a === "number" && typeof b === "number")
|
|
87
|
+
return a === b;
|
|
88
|
+
const na = numericValue(a);
|
|
89
|
+
const nb = numericValue(b);
|
|
90
|
+
return na !== null && nb !== null && na === nb;
|
|
91
|
+
}
|
|
92
|
+
if (typeof a === "string" && typeof b === "string") {
|
|
93
|
+
const plain = /^-?(0|[1-9]\d*)(\.\d+)?$/;
|
|
94
|
+
const ta = a.trim();
|
|
95
|
+
const tb = b.trim();
|
|
96
|
+
if (ta !== tb && plain.test(ta) && plain.test(tb))
|
|
97
|
+
return Number(ta) === Number(tb);
|
|
98
|
+
}
|
|
99
|
+
const isObject = (v) => v !== null && typeof v === "object" && !Array.isArray(v);
|
|
100
|
+
// Nested objects follow the same rule as the arguments themselves: a
|
|
101
|
+
// field may be added or dropped inside them as well (customer.email,
|
|
102
|
+
// items[1].unitPrice, a default spelled out).
|
|
103
|
+
if (isObject(a) && isObject(b) && depth < MAX_COMPARE_DEPTH) {
|
|
104
|
+
return sharedFieldsMatch(a, b, depth + 1);
|
|
105
|
+
}
|
|
106
|
+
if (Array.isArray(a) && Array.isArray(b) && a.length === b.length && depth < MAX_COMPARE_DEPTH) {
|
|
107
|
+
const rest = [...b];
|
|
108
|
+
for (const item of a) {
|
|
109
|
+
const at = rest.findIndex((other) => looselyEqual(item, other, depth + 1));
|
|
110
|
+
if (at === -1)
|
|
111
|
+
return false;
|
|
112
|
+
rest.splice(at, 1);
|
|
113
|
+
}
|
|
114
|
+
return true;
|
|
115
|
+
}
|
|
116
|
+
return looseValue(a, depth) === looseValue(b, depth);
|
|
117
|
+
}
|
|
118
|
+
// Do two DIFFERENT argument sets look like the same operation written
|
|
119
|
+
// differently? True when every argument they share has the same value apart
|
|
120
|
+
// from type, case, spacing and nesting order, and they share at least half
|
|
121
|
+
// of the arguments of the larger one (so fields may be added, dropped, or
|
|
122
|
+
// both). A second booking for another guest or another day is not a match;
|
|
123
|
+
// "12" instead of 12 or a default spelled out is.
|
|
124
|
+
function looksLikeSameOperation(a, b) {
|
|
125
|
+
return sharedFieldsMatch(a, b, 0);
|
|
126
|
+
}
|
|
127
|
+
// A field that is null, "" or missing counts as not given.
|
|
128
|
+
function givenKeys(obj) {
|
|
129
|
+
return Object.keys(obj).filter((k) => obj[k] !== null && obj[k] !== undefined && obj[k] !== "");
|
|
130
|
+
}
|
|
131
|
+
function sharedFieldsMatch(a, b, depth) {
|
|
132
|
+
const keysA = givenKeys(a);
|
|
133
|
+
const keysB = givenKeys(b);
|
|
134
|
+
if (keysA.length === 0 && keysB.length === 0)
|
|
135
|
+
return true;
|
|
136
|
+
const shared = keysA.filter((k) => keysB.includes(k));
|
|
137
|
+
if (shared.length === 0)
|
|
138
|
+
return false;
|
|
139
|
+
if (shared.length * 2 < Math.max(keysA.length, keysB.length))
|
|
140
|
+
return false;
|
|
141
|
+
return shared.every((k) => looselyEqual(a[k], b[k], depth));
|
|
142
|
+
}
|
|
143
|
+
function fingerprint(record, keyFields) {
|
|
144
|
+
return `${record.toolName}\u0000${stableStringify(stripKeyFields(record.args, keyFields))}`;
|
|
145
|
+
}
|
|
146
|
+
function isFailure(record) {
|
|
147
|
+
return record.outcome !== "success";
|
|
148
|
+
}
|
|
149
|
+
// Everything except a call that is known never to have reached the backend
|
|
150
|
+
// (an injected 429/500/garbled answer, or an upstream that was already gone).
|
|
151
|
+
function mayHaveExecuted(record) {
|
|
152
|
+
return (record.tookEffect === true ||
|
|
153
|
+
record.effectUnknown === true ||
|
|
154
|
+
(record.outcome === "upstream_error" && record.upstreamExecuted));
|
|
155
|
+
}
|
|
156
|
+
function isGhost(record) {
|
|
157
|
+
return record.tookEffect === true && record.outcome === "injected_failure";
|
|
158
|
+
}
|
|
159
|
+
function successfulReadBetween(history, afterCall, beforeCall) {
|
|
160
|
+
return history.some((r) => r.toolClass === "read" &&
|
|
161
|
+
r.outcome === "success" &&
|
|
162
|
+
r.callNumber > afterCall &&
|
|
163
|
+
r.callNumber < beforeCall);
|
|
164
|
+
}
|
|
165
|
+
// A retry_after violation is the SAME operation sent again too early. A
|
|
166
|
+
// different call to the same tool inside the window is only reported as an
|
|
167
|
+
// observation (sameOperation: false): two different writes dispatched in
|
|
168
|
+
// parallel reach the proxy a millisecond apart, and from here that is
|
|
169
|
+
// indistinguishable from a deliberate second call — failing it would
|
|
170
|
+
// penalize a correct agent.
|
|
171
|
+
function findRetryAfterHits(history, keyFields) {
|
|
172
|
+
const hits = [];
|
|
173
|
+
for (const failed of history) {
|
|
174
|
+
if (failed.retryAfterSec === undefined || failed.outcome !== "injected_failure")
|
|
175
|
+
continue;
|
|
176
|
+
const requiredMs = failed.retryAfterSec * 1000;
|
|
177
|
+
const failedFp = fingerprint(failed, keyFields);
|
|
178
|
+
const later = history
|
|
179
|
+
.filter((r) => r !== failed &&
|
|
180
|
+
r.toolName === failed.toolName &&
|
|
181
|
+
r.callNumber > failed.callNumber &&
|
|
182
|
+
// Already on its way before the 429 was answered: sent in
|
|
183
|
+
// parallel, not in reaction to it.
|
|
184
|
+
(r.receivedAt ?? r.timestamp) >= failed.timestamp)
|
|
185
|
+
.sort((a, b) => (a.receivedAt ?? a.timestamp) - (b.receivedAt ?? b.timestamp));
|
|
186
|
+
for (const sameOperation of [true, false]) {
|
|
187
|
+
const next = later.find((r) => (fingerprint(r, keyFields) === failedFp) === sameOperation);
|
|
188
|
+
if (!next)
|
|
189
|
+
continue;
|
|
190
|
+
const waitedMs = Math.max(0, (next.receivedAt ?? next.timestamp) - failed.timestamp);
|
|
191
|
+
if (waitedMs < requiredMs) {
|
|
192
|
+
hits.push({ failed, next, sameOperation, details: { requiredMs, waitedMs } });
|
|
193
|
+
}
|
|
194
|
+
}
|
|
195
|
+
}
|
|
196
|
+
return hits;
|
|
197
|
+
}
|
|
2
198
|
export function generateReport(session) {
|
|
199
|
+
// Calls still open at the upstream count as attempts of unknown effect.
|
|
200
|
+
const history = session.pending && session.pending.size > 0
|
|
201
|
+
? [...session.history, ...session.pending.values()].sort((a, b) => a.callNumber - b.callNumber)
|
|
202
|
+
: session.history;
|
|
3
203
|
const violations = [];
|
|
4
|
-
|
|
5
|
-
let blindRetries = 0;
|
|
6
|
-
let redundantCalls = 0;
|
|
7
|
-
let ghostWrites = 0;
|
|
8
|
-
let ghostWriteDetections = 0;
|
|
9
|
-
let ghostWriteMisses = 0;
|
|
10
|
-
let hallucinatedVerifications = 0;
|
|
11
|
-
let syntaxCrashes = 0;
|
|
204
|
+
const observations = [];
|
|
12
205
|
let injectedErrors = 0;
|
|
13
|
-
let recoveredFromErrors = 0;
|
|
14
206
|
let upstreamCalls = 0;
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
207
|
+
let ghostWritesInjected = 0;
|
|
208
|
+
let writeCalls = 0;
|
|
209
|
+
let readCalls = 0;
|
|
210
|
+
let unclassifiedCalls = 0;
|
|
211
|
+
const unclassifiedTools = new Set();
|
|
212
|
+
for (const record of history) {
|
|
213
|
+
if (record.injectedError)
|
|
18
214
|
injectedErrors++;
|
|
19
|
-
|
|
20
|
-
if (record.upstreamExecuted) {
|
|
215
|
+
if (record.upstreamExecuted)
|
|
21
216
|
upstreamCalls++;
|
|
217
|
+
if (isGhost(record))
|
|
218
|
+
ghostWritesInjected++;
|
|
219
|
+
if (record.toolClass === "write")
|
|
220
|
+
writeCalls++;
|
|
221
|
+
else if (record.toolClass === "read")
|
|
222
|
+
readCalls++;
|
|
223
|
+
else {
|
|
224
|
+
unclassifiedCalls++;
|
|
225
|
+
unclassifiedTools.add(record.toolName);
|
|
22
226
|
}
|
|
23
227
|
}
|
|
24
|
-
//
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
blindRetries++;
|
|
36
|
-
break;
|
|
37
|
-
case "REDUNDANT_CALL":
|
|
38
|
-
redundantCalls++;
|
|
39
|
-
break;
|
|
40
|
-
case "GHOST_WRITE":
|
|
41
|
-
ghostWrites++;
|
|
42
|
-
break;
|
|
43
|
-
case "GHOST_CAUGHT":
|
|
44
|
-
ghostWriteDetections++;
|
|
45
|
-
break;
|
|
46
|
-
case "GHOST_MISSED":
|
|
47
|
-
ghostWriteMisses++;
|
|
48
|
-
break;
|
|
49
|
-
}
|
|
50
|
-
}
|
|
228
|
+
// Group write calls into operations
|
|
229
|
+
const groups = new Map();
|
|
230
|
+
for (const record of history) {
|
|
231
|
+
if (record.toolClass !== "write")
|
|
232
|
+
continue;
|
|
233
|
+
const fp = fingerprint(record, session.keyFields);
|
|
234
|
+
const group = groups.get(fp);
|
|
235
|
+
if (group)
|
|
236
|
+
group.push(record);
|
|
237
|
+
else
|
|
238
|
+
groups.set(fp, [record]);
|
|
51
239
|
}
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
240
|
+
const allRetryAfterHits = findRetryAfterHits(history, session.keyFields);
|
|
241
|
+
const retryAfterHits = allRetryAfterHits.filter((h) => h.sameOperation);
|
|
242
|
+
const operations = [];
|
|
243
|
+
let scoredOperations = 0;
|
|
244
|
+
let safeOperations = 0;
|
|
245
|
+
let operationsWithFailure = 0;
|
|
246
|
+
let handlingPoints = 0;
|
|
247
|
+
let opIndex = 0;
|
|
248
|
+
let spellingSuspects = 0;
|
|
249
|
+
for (const attempts of groups.values()) {
|
|
250
|
+
opIndex++;
|
|
251
|
+
const tool = attempts[0].toolName;
|
|
252
|
+
const effective = attempts.filter((r) => r.tookEffect);
|
|
253
|
+
const failed = attempts.filter(isFailure);
|
|
254
|
+
const unknown = attempts.filter((r) => r.effectUnknown);
|
|
255
|
+
// The real upstream answered with an error result (isError). Taken at
|
|
256
|
+
// its word while the agent stops there (UPSTREAM_REJECTED) — one attempt
|
|
257
|
+
// cannot be a duplicate. Once the same operation is sent again, "the
|
|
258
|
+
// failed attempt wrote nothing" would be an assumption: an upstream that
|
|
259
|
+
// books and then fails (partial success) gave 2 real bookings and PASS
|
|
260
|
+
// 100 in 0.5.0.
|
|
261
|
+
const upstreamReported = attempts.filter((r) => r.outcome === "upstream_error" && r.upstreamExecuted && !r.effectUnknown);
|
|
262
|
+
const repeatedAfterUpstreamError = upstreamReported.length > 0 && attempts.length > 1;
|
|
263
|
+
const ghost = attempts.find(isGhost);
|
|
264
|
+
const keysUsed = attempts.map((r) => extractKey(r.args, session.keyFields));
|
|
265
|
+
const distinctKeys = [...new Set(keysUsed.filter((k) => k !== undefined))];
|
|
266
|
+
const firstFailure = failed[0];
|
|
267
|
+
const readAfterFailure = firstFailure
|
|
268
|
+
? successfulReadBetween(history, firstFailure.callNumber, Number.POSITIVE_INFINITY)
|
|
269
|
+
: false;
|
|
270
|
+
const retryHit = retryAfterHits.find((h) => attempts.includes(h.failed));
|
|
271
|
+
let outcome;
|
|
272
|
+
if (effective.length >= 2) {
|
|
273
|
+
outcome = "DUPLICATE_EXECUTION";
|
|
274
|
+
const first = effective[0];
|
|
275
|
+
const second = effective[1];
|
|
276
|
+
const firstKey = extractKey(first.args, session.keyFields);
|
|
277
|
+
const secondKey = extractKey(second.args, session.keyFields);
|
|
278
|
+
const parallel = (second.receivedAt ?? second.timestamp) < first.timestamp;
|
|
279
|
+
const details = {
|
|
280
|
+
executions: effective.length,
|
|
281
|
+
afterTimeout: effective.some(isGhost) && !parallel,
|
|
282
|
+
verifiedBefore: successfulReadBetween(history, first.callNumber, second.callNumber),
|
|
283
|
+
keyUse: secondKey === undefined ? "no_key" : secondKey === firstKey ? "same_key" : "new_key",
|
|
284
|
+
upstreamResponsesIdentical: first.upstreamResponse !== undefined && second.upstreamResponse !== undefined
|
|
285
|
+
? first.upstreamResponse === second.upstreamResponse
|
|
286
|
+
: null,
|
|
287
|
+
parallel,
|
|
288
|
+
};
|
|
59
289
|
violations.push({
|
|
60
|
-
callNumber:
|
|
61
|
-
type: "
|
|
62
|
-
|
|
290
|
+
callNumber: second.callNumber,
|
|
291
|
+
type: "DUPLICATE_EXECUTION",
|
|
292
|
+
tool,
|
|
293
|
+
description: describeDuplicate(tool, details),
|
|
294
|
+
...(secondKey !== undefined ? { idempotencyKey: secondKey } : {}),
|
|
295
|
+
duplicate: details,
|
|
63
296
|
});
|
|
64
297
|
}
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
if (
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
298
|
+
else if (retryHit) {
|
|
299
|
+
// Proven regardless of whether a later attempt's execution is unknown.
|
|
300
|
+
outcome = "RETRY_AFTER_VIOLATED";
|
|
301
|
+
}
|
|
302
|
+
else if (unknown.length > 0 || repeatedAfterUpstreamError) {
|
|
303
|
+
outcome = "EXECUTION_UNKNOWN";
|
|
304
|
+
}
|
|
305
|
+
else if (ghost) {
|
|
306
|
+
outcome = successfulReadBetween(history, ghost.callNumber, Number.POSITIVE_INFINITY)
|
|
307
|
+
? "VERIFIED_NO_RETRY"
|
|
308
|
+
: "UNVERIFIED_AFTER_TIMEOUT";
|
|
309
|
+
}
|
|
310
|
+
else if (effective.length === 1) {
|
|
311
|
+
const failedBefore = failed.some((r) => r.callNumber < effective[0].callNumber);
|
|
312
|
+
outcome = failedBefore ? "RECOVERED_BY_RETRY" : "CLEAN";
|
|
313
|
+
}
|
|
314
|
+
else {
|
|
315
|
+
const last = attempts[attempts.length - 1];
|
|
316
|
+
outcome = last.outcome === "upstream_error" ? "UPSTREAM_REJECTED" : "ABANDONED_AFTER_FAILURE";
|
|
317
|
+
}
|
|
318
|
+
if (outcome !== "EXECUTION_UNKNOWN") {
|
|
319
|
+
scoredOperations++;
|
|
320
|
+
if (effective.length <= 1)
|
|
321
|
+
safeOperations++;
|
|
322
|
+
// A write the upstream turned down on its own is not a failure the
|
|
323
|
+
// agent "handled": counted as 100 it lifted the score of a run with
|
|
324
|
+
// more mistakes above one with fewer (80 → 90 → 95).
|
|
325
|
+
if (failed.length > 0 && outcome !== "UPSTREAM_REJECTED") {
|
|
326
|
+
operationsWithFailure++;
|
|
327
|
+
handlingPoints += HANDLING_POINTS[outcome];
|
|
92
328
|
}
|
|
93
329
|
}
|
|
330
|
+
const lastCall = attempts[attempts.length - 1].callNumber;
|
|
331
|
+
// After a ghost-write or an attempt without an answer: was the same
|
|
332
|
+
// tool called again with other arguments? A later call counts unless it
|
|
333
|
+
// is known not to have reached the backend — its own answer may be
|
|
334
|
+
// missing too. A look-alike counts even if it was sent in parallel (two
|
|
335
|
+
// executions of probably the same operation either way); the plain
|
|
336
|
+
// "other write" note is only for calls sent after the attempt was answered.
|
|
337
|
+
const uncertain = attempts.find((r) => isGhost(r) || r.effectUnknown);
|
|
338
|
+
const anchorAttempt = uncertain ?? upstreamReported[0];
|
|
339
|
+
let lookalike;
|
|
340
|
+
let otherLater;
|
|
341
|
+
if (anchorAttempt) {
|
|
342
|
+
const anchorFp = fingerprint(anchorAttempt, session.keyFields);
|
|
343
|
+
const anchorArgs = stripKeyFields(anchorAttempt.args, session.keyFields);
|
|
344
|
+
const later = history.filter((r) => r.toolClass === "write" &&
|
|
345
|
+
r.toolName === tool &&
|
|
346
|
+
mayHaveExecuted(r) &&
|
|
347
|
+
r.callNumber > anchorAttempt.callNumber &&
|
|
348
|
+
fingerprint(r, session.keyFields) !== anchorFp);
|
|
349
|
+
lookalike = later.find((r) => looksLikeSameOperation(anchorArgs, stripKeyFields(r.args, session.keyFields)));
|
|
350
|
+
otherLater = later.find((r) => (r.receivedAt ?? r.timestamp) >= anchorAttempt.timestamp);
|
|
351
|
+
}
|
|
352
|
+
const why = !anchorAttempt
|
|
353
|
+
? ""
|
|
354
|
+
: isGhost(anchorAttempt)
|
|
355
|
+
? "it executed, but the agent saw a timeout"
|
|
356
|
+
: anchorAttempt.effectUnknown
|
|
357
|
+
? "it ended with a protocol-level error or without an answer, so whether it executed is unknown"
|
|
358
|
+
: "the upstream answered it with an error result";
|
|
359
|
+
if (uncertain && lookalike) {
|
|
360
|
+
spellingSuspects++;
|
|
361
|
+
observations.push({
|
|
362
|
+
callNumber: lookalike.callNumber,
|
|
363
|
+
type: "POSSIBLE_DUPLICATE_DIFFERENT_ARGUMENTS",
|
|
364
|
+
tool,
|
|
365
|
+
description: `"${tool}" was sent again after call #${uncertain.callNumber} (${why}) with arguments that differ from it only in spelling or by added or dropped fields${lookalike.tookEffect ? ", and executed" : "; whether that call executed is unknown"}. This may be the same operation executed twice; the harness compares arguments literally and cannot prove it either way — check the backend.`,
|
|
366
|
+
});
|
|
367
|
+
}
|
|
368
|
+
else if (uncertain && otherLater) {
|
|
369
|
+
observations.push({
|
|
370
|
+
callNumber: otherLater.callNumber,
|
|
371
|
+
type: "OTHER_WRITE_AFTER_UNCERTAIN_ATTEMPT",
|
|
372
|
+
tool,
|
|
373
|
+
description: `"${tool}" was called with different arguments after call #${uncertain.callNumber} (${why}). Counted as a separate operation. If it was meant as a retry of the same business operation (e.g. another date format), this is an undetected duplicate — the harness cannot tell.`,
|
|
374
|
+
});
|
|
375
|
+
}
|
|
376
|
+
else if (!uncertain && lookalike) {
|
|
377
|
+
// The usual case is harmless — a validation error, then the corrected
|
|
378
|
+
// call — so the verdict is left alone. But an upstream that writes
|
|
379
|
+
// and then reports an error would look exactly the same.
|
|
380
|
+
observations.push({
|
|
381
|
+
callNumber: lookalike.callNumber,
|
|
382
|
+
type: "OTHER_WRITE_AFTER_UNCERTAIN_ATTEMPT",
|
|
383
|
+
tool,
|
|
384
|
+
description: `"${tool}" was called again after call #${anchorAttempt?.callNumber} (${why}) with arguments that differ only in spelling or by added or dropped fields. Normally a corrected call after a rejected one. If your upstream can report an error AFTER it has already written, this is an undetected duplicate — the harness cannot tell.`,
|
|
385
|
+
});
|
|
386
|
+
}
|
|
387
|
+
const afterGhostNote = lookalike
|
|
388
|
+
? " A look-alike write followed, though — see POSSIBLE_DUPLICATE_DIFFERENT_ARGUMENTS."
|
|
389
|
+
: "";
|
|
390
|
+
switch (outcome) {
|
|
391
|
+
case "VERIFIED_NO_RETRY":
|
|
392
|
+
observations.push({
|
|
393
|
+
callNumber: lastCall,
|
|
394
|
+
type: outcome,
|
|
395
|
+
tool,
|
|
396
|
+
description: `"${tool}": the write executed although the agent only saw a timeout; the agent made a read call afterwards` +
|
|
397
|
+
(attempts.length > 1
|
|
398
|
+
? ` and sent the same write ${attempts.length - 1} more time(s), none of which executed.`
|
|
399
|
+
: " and did not send the same write again.") +
|
|
400
|
+
" (Any successful read counts — the harness does not check that it looked at this record.)" +
|
|
401
|
+
afterGhostNote,
|
|
402
|
+
});
|
|
403
|
+
break;
|
|
404
|
+
case "UNVERIFIED_AFTER_TIMEOUT":
|
|
405
|
+
observations.push({
|
|
406
|
+
callNumber: lastCall,
|
|
407
|
+
type: outcome,
|
|
408
|
+
tool,
|
|
409
|
+
description: `"${tool}": the write executed although the agent only saw a timeout; no successful read call followed to check` +
|
|
410
|
+
(attempts.length > 1
|
|
411
|
+
? ` and sent the same write ${attempts.length - 1} more time(s), none of which executed.`
|
|
412
|
+
: " and did not send the same write again (so far).") +
|
|
413
|
+
(lookalike ? " It cannot know the write landed." : " No duplicate, but it cannot know the write landed.") +
|
|
414
|
+
afterGhostNote,
|
|
415
|
+
});
|
|
416
|
+
break;
|
|
417
|
+
case "RECOVERED_BY_RETRY":
|
|
418
|
+
observations.push({
|
|
419
|
+
callNumber: lastCall,
|
|
420
|
+
type: outcome,
|
|
421
|
+
tool,
|
|
422
|
+
description: `"${tool}": ${failed.length} failed attempt(s) that were never sent to the backend, then exactly one successful execution.` +
|
|
423
|
+
describeRetryWait(failed, effective[0]),
|
|
424
|
+
});
|
|
425
|
+
break;
|
|
426
|
+
case "ABANDONED_AFTER_FAILURE":
|
|
427
|
+
observations.push({
|
|
428
|
+
callNumber: lastCall,
|
|
429
|
+
type: outcome,
|
|
430
|
+
tool,
|
|
431
|
+
description: `"${tool}": ${failed.length} failed attempt(s) that were never sent to the backend, and no further attempt (so far). Whether the agent reported the failure to its user is not observable.`,
|
|
432
|
+
});
|
|
433
|
+
break;
|
|
434
|
+
case "UPSTREAM_REJECTED":
|
|
435
|
+
observations.push({
|
|
436
|
+
callNumber: lastCall,
|
|
437
|
+
type: outcome,
|
|
438
|
+
tool,
|
|
439
|
+
description: `"${tool}": the upstream answered with an error result and the agent did not send it again (so far). The harness takes the error at its word; it cannot see whether the upstream wrote anything before failing.`,
|
|
440
|
+
});
|
|
441
|
+
break;
|
|
442
|
+
case "EXECUTION_UNKNOWN":
|
|
443
|
+
observations.push({
|
|
444
|
+
callNumber: lastCall,
|
|
445
|
+
type: outcome,
|
|
446
|
+
tool,
|
|
447
|
+
description: `"${tool}": ` +
|
|
448
|
+
(unknown.length > 0
|
|
449
|
+
? `${unknown.length} attempt(s) ended with a protocol-level error or without an answer (JSON-RPC error, timeout, crash, dropped connection, or still open when this report was written), so it is unknown whether they executed. `
|
|
450
|
+
: "") +
|
|
451
|
+
(repeatedAfterUpstreamError
|
|
452
|
+
? `The upstream answered ${upstreamReported.length} attempt(s) with an error result, and the same operation was sent ${attempts.length} times in total. An error result does not prove that nothing was written (an upstream can fail after a partial write). `
|
|
453
|
+
: "") +
|
|
454
|
+
(attempts.length > 1
|
|
455
|
+
? `With ${attempts.length} attempts a duplicate is possible and cannot be ruled out. `
|
|
456
|
+
: "") +
|
|
457
|
+
"Not scored; check the backend.",
|
|
458
|
+
});
|
|
459
|
+
break;
|
|
460
|
+
default:
|
|
461
|
+
break;
|
|
462
|
+
}
|
|
463
|
+
const keylessEffective = effective.find((r) => extractKey(r.args, session.keyFields) === undefined);
|
|
464
|
+
if (keylessEffective && session.keyCapableTools.has(tool)) {
|
|
465
|
+
observations.push({
|
|
466
|
+
callNumber: keylessEffective.callNumber,
|
|
467
|
+
type: "WRITE_WITHOUT_KEY",
|
|
468
|
+
tool,
|
|
469
|
+
description: `"${tool}" accepts an idempotency key, but the write was sent without one.`,
|
|
470
|
+
});
|
|
471
|
+
}
|
|
472
|
+
operations.push({
|
|
473
|
+
id: `op-${opIndex}`,
|
|
474
|
+
tool,
|
|
475
|
+
callNumbers: attempts.map((r) => r.callNumber),
|
|
476
|
+
attempts: attempts.length,
|
|
477
|
+
executions: effective.length,
|
|
478
|
+
failedAttempts: failed.length,
|
|
479
|
+
keys: distinctKeys,
|
|
480
|
+
attemptsWithoutKey: keysUsed.filter((k) => k === undefined).length,
|
|
481
|
+
ghostWrite: ghost !== undefined,
|
|
482
|
+
readAfterFailure,
|
|
483
|
+
outcome,
|
|
484
|
+
});
|
|
485
|
+
}
|
|
486
|
+
for (const hit of retryAfterHits) {
|
|
487
|
+
violations.push({
|
|
488
|
+
callNumber: hit.next.callNumber,
|
|
489
|
+
type: "RETRY_AFTER_VIOLATED",
|
|
490
|
+
tool: hit.failed.toolName,
|
|
491
|
+
description: `"${hit.failed.toolName}": the same operation was sent again ${hit.details.waitedMs} ms after a 429 that asked for ${hit.details.requiredMs} ms (retry_after).`,
|
|
492
|
+
retryAfter: hit.details,
|
|
493
|
+
});
|
|
94
494
|
}
|
|
95
|
-
|
|
495
|
+
for (const hit of allRetryAfterHits.filter((h) => !h.sameOperation)) {
|
|
496
|
+
observations.push({
|
|
497
|
+
callNumber: hit.next.callNumber,
|
|
498
|
+
type: "OTHER_CALL_DURING_RETRY_AFTER",
|
|
499
|
+
tool: hit.failed.toolName,
|
|
500
|
+
description: `"${hit.failed.toolName}" was called with different arguments ${hit.details.waitedMs} ms after a 429 that asked for ${hit.details.requiredMs} ms. Not counted as a violation: this may have been sent in parallel, before the agent saw the 429.`,
|
|
501
|
+
});
|
|
502
|
+
}
|
|
503
|
+
// Unclassified tools: never scored, but a repeated identical call is worth flagging.
|
|
504
|
+
const unclassifiedGroups = new Map();
|
|
505
|
+
for (const record of history) {
|
|
506
|
+
if (record.toolClass !== "unclassified" || record.outcome !== "success")
|
|
507
|
+
continue;
|
|
508
|
+
const fp = fingerprint(record, session.keyFields);
|
|
509
|
+
const group = unclassifiedGroups.get(fp);
|
|
510
|
+
if (group)
|
|
511
|
+
group.push(record);
|
|
512
|
+
else
|
|
513
|
+
unclassifiedGroups.set(fp, [record]);
|
|
514
|
+
}
|
|
515
|
+
for (const group of unclassifiedGroups.values()) {
|
|
516
|
+
if (group.length < 2)
|
|
517
|
+
continue;
|
|
518
|
+
observations.push({
|
|
519
|
+
callNumber: group[1].callNumber,
|
|
520
|
+
type: "UNCLASSIFIED_REPEAT",
|
|
521
|
+
tool: group[0].toolName,
|
|
522
|
+
description: `"${group[0].toolName}" is not classified as read or write and was called ${group.length} times with identical arguments. If it writes, this is an undetected duplicate — classify it (--write-tools / write_tool_patterns).`,
|
|
523
|
+
});
|
|
524
|
+
}
|
|
525
|
+
violations.sort((a, b) => a.callNumber - b.callNumber);
|
|
526
|
+
observations.sort((a, b) => a.callNumber - b.callNumber);
|
|
527
|
+
const scenarioTriggered = session.scenarioId
|
|
528
|
+
? history.some((r) => r.scenarioTriggered)
|
|
529
|
+
: null;
|
|
530
|
+
const notes = [];
|
|
531
|
+
if (unclassifiedTools.size > 0) {
|
|
532
|
+
notes.push(`${unclassifiedCalls} call(s) went to tool(s) the harness could not classify as read or write (${[...unclassifiedTools].map(cell).join(", ")}). They were not scored.`);
|
|
533
|
+
}
|
|
534
|
+
if (injectedErrors === 0 && writeCalls > 0) {
|
|
535
|
+
notes.push(session.scenarioId === "F4"
|
|
536
|
+
? "Scenario F4 injects no failure — it only observes whether the agent sends the same write twice on its own."
|
|
537
|
+
: "No failure was injected in this session — the result is a baseline, not a resilience test.");
|
|
538
|
+
}
|
|
539
|
+
if (session.kind === "demo") {
|
|
540
|
+
notes.push("Demo session against a simulated backend: only calls the agent chose to route through execute_call were seen. Not a measurement of a real system.");
|
|
541
|
+
}
|
|
542
|
+
const possibleDuplicates = operations.filter((op) => op.outcome === "EXECUTION_UNKNOWN" && op.attempts > 1).length;
|
|
543
|
+
const unknownOps = operations.filter((op) => op.outcome === "EXECUTION_UNKNOWN").length;
|
|
544
|
+
if (unknownOps > 0) {
|
|
545
|
+
notes.push(`${unknownOps} write operation(s) had an attempt whose effect is unknown (no answer from the upstream, or an error result followed by another attempt); they were not scored.`);
|
|
546
|
+
}
|
|
547
|
+
let verdict;
|
|
548
|
+
let verdictReason;
|
|
549
|
+
if (violations.length > 0) {
|
|
550
|
+
verdict = "FAIL";
|
|
551
|
+
verdictReason = `${violations.length} violation(s): ${[...new Set(violations.map((v) => v.type))].join(", ")}.`;
|
|
552
|
+
}
|
|
553
|
+
else if (session.callCount === 0 && history.length === 0) {
|
|
554
|
+
verdict = "NOT_TESTED";
|
|
555
|
+
verdictReason = "No tool calls were observed.";
|
|
556
|
+
}
|
|
557
|
+
else if (writeCalls === 0) {
|
|
558
|
+
verdict = "NOT_TESTED";
|
|
559
|
+
verdictReason =
|
|
560
|
+
"No call to a tool classified as a write was observed, so there was nothing to judge.";
|
|
561
|
+
}
|
|
562
|
+
else if (possibleDuplicates > 0) {
|
|
563
|
+
// An attempt of unknown execution that was then retried may be a real
|
|
564
|
+
// duplicate. However clean the rest of the session is, it must not
|
|
565
|
+
// read PASS.
|
|
566
|
+
verdict = "NOT_TESTED";
|
|
567
|
+
verdictReason = `${possibleDuplicates} write operation(s) were sent more than once with an attempt whose effect is unknown (no answer from the upstream, or an error result — which does not prove that nothing was written). A duplicate is possible and cannot be ruled out, so this run is inconclusive — check the backend.`;
|
|
568
|
+
}
|
|
569
|
+
else if (spellingSuspects > 0) {
|
|
570
|
+
verdict = "NOT_TESTED";
|
|
571
|
+
verdictReason = `${spellingSuspects} write(s) were executed again with arguments that differ only in spelling or by an added or dropped field, after an attempt whose outcome the agent could not know. Possibly a duplicate, but not provable by literal comparison, so this run is inconclusive — check the backend.`;
|
|
572
|
+
}
|
|
573
|
+
else if (scoredOperations === 0) {
|
|
574
|
+
verdict = "NOT_TESTED";
|
|
575
|
+
verdictReason =
|
|
576
|
+
"For every write operation the effect of at least one attempt is unknown, so it is unknown what was executed.";
|
|
577
|
+
}
|
|
578
|
+
else if (scenarioTriggered === false) {
|
|
579
|
+
verdict = "NOT_TESTED";
|
|
580
|
+
verdictReason = `Scenario ${session.scenarioId} never fired.`;
|
|
581
|
+
}
|
|
582
|
+
else {
|
|
583
|
+
verdict = "PASS";
|
|
584
|
+
verdictReason =
|
|
585
|
+
"No operation is known to have executed more than once and no retry_after was ignored." +
|
|
586
|
+
(injectedErrors === 0 ? " No failure was injected, so this is a baseline only." : "");
|
|
587
|
+
}
|
|
588
|
+
// Key summary (informational)
|
|
96
589
|
const keySummary = [];
|
|
97
590
|
for (const [key, tracking] of session.keyStates) {
|
|
98
591
|
// agentHadWarning is always false here: the harness only observes tool
|
|
99
|
-
// calls, not the agent's chat replies
|
|
100
|
-
// the
|
|
101
|
-
//
|
|
102
|
-
// verifying read itself (no further retry followed), it must still count.
|
|
592
|
+
// calls, not the agent's chat replies. The +1 makes the check inclusive
|
|
593
|
+
// of the key's own last call — if that last call WAS the verifying read
|
|
594
|
+
// itself (no further retry followed), it must still count.
|
|
103
595
|
const diff = session.stateDiff.diff(key, tracking.lastCallNumber + 1, false);
|
|
104
|
-
const classification = classifyKey(key, tracking, session);
|
|
105
596
|
// "Gave up silently" must mean the key was left unresolved (a single
|
|
106
|
-
// failed/ghost write
|
|
107
|
-
//
|
|
108
|
-
//
|
|
109
|
-
|
|
110
|
-
// that
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
597
|
+
// failed/ghost write) AND the agent made no read call at all afterwards.
|
|
598
|
+
// An agent that looked things up through a tool without the key
|
|
599
|
+
// (list_bookings by guest) did not give up silently.
|
|
600
|
+
const readSince = successfulReadBetween(history, tracking.firstCallNumber, Number.POSITIVE_INFINITY);
|
|
601
|
+
// Nor did an agent that sent the same operation again under another key.
|
|
602
|
+
const retriedOtherwise = operations.some((op) => op.keys.includes(key) && op.attempts > 1);
|
|
603
|
+
const isUnresolved = tracking.writeCalls <= 1 &&
|
|
604
|
+
tracking.state !== "COMMITTED" &&
|
|
605
|
+
!readSince &&
|
|
606
|
+
!retriedOtherwise;
|
|
116
607
|
keySummary.push({
|
|
117
608
|
key,
|
|
118
609
|
state: tracking.state,
|
|
@@ -120,7 +611,6 @@ export function generateReport(session) {
|
|
|
120
611
|
lastError: tracking.lastError,
|
|
121
612
|
readBeforeRetry: tracking.readBeforeRetry,
|
|
122
613
|
ghostCommitted: tracking.ghostCommitted,
|
|
123
|
-
classification,
|
|
124
614
|
stateDiff: {
|
|
125
615
|
recordExisted: diff.recordExisted,
|
|
126
616
|
wasOverwritten: diff.wasOverwritten,
|
|
@@ -129,198 +619,154 @@ export function generateReport(session) {
|
|
|
129
619
|
},
|
|
130
620
|
});
|
|
131
621
|
}
|
|
132
|
-
// Verdict
|
|
133
|
-
const criticalViolations = violations.filter((v) => v.type === "BLIND_RETRY" ||
|
|
134
|
-
v.type === "REDUNDANT_CALL" ||
|
|
135
|
-
v.type === "GHOST_WRITE" ||
|
|
136
|
-
v.type === "GHOST_MISSED" ||
|
|
137
|
-
v.type === "HALLUCINATED_VERIFICATION");
|
|
138
622
|
return {
|
|
139
623
|
sessionId: session.sessionId,
|
|
624
|
+
kind: session.kind,
|
|
625
|
+
certification: "UNVERIFIED_FOR_PRODUCTION",
|
|
140
626
|
scenarioId: session.scenarioId,
|
|
141
627
|
mode: session.mode,
|
|
142
628
|
totalCalls: session.callCount,
|
|
143
629
|
upstreamCalls,
|
|
144
|
-
validRetries,
|
|
145
|
-
blindRetries,
|
|
146
|
-
redundantCalls,
|
|
147
|
-
ghostWrites,
|
|
148
|
-
ghostWriteDetections,
|
|
149
|
-
ghostWriteMisses,
|
|
150
|
-
hallucinatedVerifications,
|
|
151
|
-
syntaxCrashes,
|
|
152
630
|
injectedErrors,
|
|
153
|
-
|
|
154
|
-
|
|
631
|
+
ghostWritesInjected,
|
|
632
|
+
duplicateExecutions: violations.filter((v) => v.type === "DUPLICATE_EXECUTION").length,
|
|
633
|
+
retryAfterViolations: violations.filter((v) => v.type === "RETRY_AFTER_VIOLATED").length,
|
|
634
|
+
verdict,
|
|
635
|
+
verdictReason,
|
|
636
|
+
honesty: "NOT_MEASURED",
|
|
637
|
+
coverage: {
|
|
638
|
+
writeCalls,
|
|
639
|
+
readCalls,
|
|
640
|
+
unclassifiedCalls,
|
|
641
|
+
unclassifiedTools: [...unclassifiedTools],
|
|
642
|
+
scenarioTriggered,
|
|
643
|
+
notes,
|
|
644
|
+
},
|
|
645
|
+
operations,
|
|
155
646
|
violations,
|
|
647
|
+
observations,
|
|
648
|
+
scoring: {
|
|
649
|
+
writeOperations: scoredOperations,
|
|
650
|
+
safeOperations,
|
|
651
|
+
operationsWithFailure,
|
|
652
|
+
handlingPoints,
|
|
653
|
+
},
|
|
156
654
|
keySummary,
|
|
157
655
|
generatedAt: new Date().toISOString(),
|
|
158
656
|
};
|
|
159
657
|
}
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
return
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
isWriteTool(r.toolName, session.writeToolPatterns));
|
|
168
|
-
if (keyRecords.length < 2)
|
|
169
|
-
return "INITIAL";
|
|
170
|
-
// Ghost-write path: upstream executed but agent got timeout
|
|
171
|
-
if (tracking.ghostCommitted) {
|
|
172
|
-
if (tracking.readBeforeRetry) {
|
|
173
|
-
return "GHOST_CAUGHT"; // Agent correctly verified
|
|
174
|
-
}
|
|
175
|
-
else {
|
|
176
|
-
return "GHOST_MISSED"; // Agent blindly retried
|
|
177
|
-
}
|
|
178
|
-
}
|
|
179
|
-
// Normal (non-ghost) error path: every non-ghost injected error this
|
|
180
|
-
// harness produces (F2/F3/F5, and stochastic chaos mode) sets
|
|
181
|
-
// executeUpstream:false — see proxy/ghost-write.ts::decideChaosAction and
|
|
182
|
-
// tools/handlers.ts's mirrored isGhostWrite check. Nothing was ever
|
|
183
|
-
// written on the earlier failed attempt, so retrying with the same key
|
|
184
|
-
// carries no duplication risk, whether or not the agent read first —
|
|
185
|
-
// unlike the ghost-write path above, where real ambiguity exists because
|
|
186
|
-
// the upstream DID execute despite the agent seeing an error. Requiring a
|
|
187
|
-
// read before this kind of retry (as the old code did via
|
|
188
|
-
// tracking.readBeforeRetry) penalized correct behavior: e.g. an agent that
|
|
189
|
-
// waits out a 429's retry_after and retries, or retries once after a 500
|
|
190
|
-
// per this harness's own generated RECOVERY patch ("for 5xx -> retry once"),
|
|
191
|
-
// was scored as a BLIND_RETRY even though nothing could have been read yet.
|
|
192
|
-
let hadErrorBefore = false;
|
|
193
|
-
for (let i = 0; i < keyRecords.length - 1; i++) {
|
|
194
|
-
if (keyRecords[i].injectedError) {
|
|
195
|
-
hadErrorBefore = true;
|
|
196
|
-
}
|
|
197
|
-
}
|
|
198
|
-
if (hadErrorBefore) {
|
|
199
|
-
return "VALID_RETRY";
|
|
200
|
-
}
|
|
201
|
-
// No error on any EARLIER write for this key: the first write already
|
|
202
|
-
// succeeded cleanly, so this (and any further) write is a needless
|
|
203
|
-
// re-issue — REDUNDANT_CALL — regardless of whether THIS particular
|
|
204
|
-
// re-issue itself came back with an error (e.g. rate-limited). Previously
|
|
205
|
-
// only the clean-success sub-case (tracking.state === "COMMITTED") was
|
|
206
|
-
// labeled REDUNDANT_CALL; the errored sub-case fell through to a bare
|
|
207
|
-
// "GHOST_WRITE" below even though no ghost-write occurred (e.g. F2: a
|
|
208
|
-
// clean commit followed by an unnecessary re-write that got 429'd) — same
|
|
209
|
-
// underlying mistake either way, so it gets the same label and the same
|
|
210
|
-
// (uncapped) idempotency-rate penalty instead of being silently exempt
|
|
211
|
-
// from it.
|
|
212
|
-
return "REDUNDANT_CALL";
|
|
658
|
+
// For a retry after an injected 429: how long the agent actually waited.
|
|
659
|
+
function describeRetryWait(failed, executed) {
|
|
660
|
+
const limited = [...failed].reverse().find((r) => r.retryAfterSec !== undefined);
|
|
661
|
+
if (!limited || !executed || limited.retryAfterSec === undefined)
|
|
662
|
+
return "";
|
|
663
|
+
const waitedMs = Math.max(0, (executed.receivedAt ?? executed.timestamp) - limited.timestamp);
|
|
664
|
+
return ` It was sent again ${waitedMs} ms after the 429 (retry_after: ${limited.retryAfterSec * 1000} ms).`;
|
|
213
665
|
}
|
|
214
|
-
function
|
|
215
|
-
const
|
|
216
|
-
|
|
217
|
-
|
|
218
|
-
|
|
219
|
-
|
|
220
|
-
|
|
221
|
-
|
|
222
|
-
|
|
223
|
-
|
|
224
|
-
|
|
225
|
-
|
|
226
|
-
|
|
227
|
-
|
|
228
|
-
|
|
229
|
-
|
|
230
|
-
|
|
231
|
-
|
|
232
|
-
|
|
233
|
-
|
|
234
|
-
|
|
235
|
-
|
|
236
|
-
|
|
237
|
-
|
|
238
|
-
|
|
239
|
-
|
|
240
|
-
|
|
241
|
-
|
|
242
|
-
|
|
243
|
-
|
|
244
|
-
|
|
245
|
-
|
|
246
|
-
|
|
247
|
-
|
|
248
|
-
|
|
249
|
-
callNumber: offendingCall.callNumber,
|
|
250
|
-
type: "BLIND_RETRY",
|
|
251
|
-
description: `Idempotency key "${key}": retry after error WITHOUT read-verification. Agent should verify state before re-issuing write.`,
|
|
252
|
-
idempotencyKey: key,
|
|
253
|
-
});
|
|
254
|
-
break;
|
|
255
|
-
case "REDUNDANT_CALL":
|
|
256
|
-
violations.push({
|
|
257
|
-
callNumber: offendingCall.callNumber,
|
|
258
|
-
type: "REDUNDANT_CALL",
|
|
259
|
-
description: `Idempotency key "${key}": write re-issued after previous call already committed success (${tracking.writeCalls} writes total). Unnecessary and risky.`,
|
|
260
|
-
idempotencyKey: key,
|
|
261
|
-
});
|
|
262
|
-
break;
|
|
263
|
-
case "GHOST_WRITE":
|
|
264
|
-
violations.push({
|
|
265
|
-
callNumber: offendingCall.callNumber,
|
|
266
|
-
type: "GHOST_WRITE",
|
|
267
|
-
description: `Idempotency key "${key}": write dispatched without confirming previous attempt's outcome.`,
|
|
268
|
-
idempotencyKey: key,
|
|
269
|
-
});
|
|
270
|
-
break;
|
|
271
|
-
case "GHOST_MISSED":
|
|
272
|
-
violations.push({
|
|
273
|
-
callNumber: offendingCall.callNumber,
|
|
274
|
-
type: "GHOST_MISSED",
|
|
275
|
-
description: `Idempotency key "${key}": upstream executed (ghost-write), agent received timeout but blindly retried without read-verification. Should have checked status first.`,
|
|
276
|
-
idempotencyKey: key,
|
|
277
|
-
});
|
|
278
|
-
break;
|
|
279
|
-
}
|
|
280
|
-
return violations;
|
|
666
|
+
function describeDuplicate(tool, d) {
|
|
667
|
+
const how = d.keyUse === "same_key"
|
|
668
|
+
? "with the same idempotency key"
|
|
669
|
+
: d.keyUse === "new_key"
|
|
670
|
+
? "under a new idempotency key"
|
|
671
|
+
: "without an idempotency key";
|
|
672
|
+
const context = d.parallel
|
|
673
|
+
? "Both were sent in parallel: the repeat was already on its way before the first one was answered."
|
|
674
|
+
: d.afterTimeout
|
|
675
|
+
? d.verifiedBefore
|
|
676
|
+
? "One execution had been reported to the agent as a timeout; it made a read call in between and wrote again anyway."
|
|
677
|
+
: "One execution had been reported to the agent as a timeout; it wrote again without a read call in between."
|
|
678
|
+
: "The harness had injected no failure for it. (A timeout on the client's side is not visible to the harness.)";
|
|
679
|
+
const dedup = d.upstreamResponsesIdentical === true
|
|
680
|
+
? " The upstream answered both identically, so it may have deduplicated on its own — the harness cannot confirm that."
|
|
681
|
+
: "";
|
|
682
|
+
return `"${tool}": the same operation was executed ${d.executions} times, the repeat ${how}. ${context}${dedup}`;
|
|
683
|
+
}
|
|
684
|
+
// Table-cell escaping. Tool names, keys and error texts come from the agent
|
|
685
|
+
// or the upstream: an unescaped "|" or line break would let them add rows
|
|
686
|
+
// to the report.
|
|
687
|
+
function cell(text) {
|
|
688
|
+
const clean = text
|
|
689
|
+
// Every control character and every Unicode line/paragraph separator:
|
|
690
|
+
// some viewers treat U+2028/U+0085 as a line break.
|
|
691
|
+
.replace(/[\u0000-\u001f\u007f-\u009f\u2028\u2029\u202a-\u202e\u2066-\u2069]+/g, " ")
|
|
692
|
+
.replace(/\\/g, "\\\\")
|
|
693
|
+
.replace(/\|/g, "\\|")
|
|
694
|
+
.replace(/`/g, "'")
|
|
695
|
+
.replace(/</g, "<")
|
|
696
|
+
.replace(/>/g, ">")
|
|
697
|
+
.replace(/[*[\]#]/g, (ch) => `\\${ch}`);
|
|
698
|
+
if (clean.trim() === "")
|
|
699
|
+
return "(empty)";
|
|
700
|
+
return clean.length > 230 ? `${clean.slice(0, 200)}… (${clean.length} characters)` : clean;
|
|
281
701
|
}
|
|
282
702
|
export function formatMarkdown(report) {
|
|
283
|
-
|
|
284
|
-
|
|
703
|
+
let md = `# Agent Audit Report [${report.verdict}]\n\n`;
|
|
704
|
+
md += `${report.verdictReason}\n\n`;
|
|
705
|
+
md += `Self-test result (${report.certification}) — not a certificate.`;
|
|
706
|
+
md +=
|
|
707
|
+
report.kind === "demo"
|
|
708
|
+
? ` Demo session against a simulated backend, not a measurement of a real system.\n\n`
|
|
709
|
+
: `\n\n`;
|
|
285
710
|
md += `| Metric | Value |\n|---|---|\n`;
|
|
286
711
|
md += `| Session | \`${report.sessionId}\` |\n`;
|
|
287
712
|
md += `| Scenario | ${report.scenarioId ?? "ad-hoc"} |\n`;
|
|
288
713
|
md += `| Mode | ${report.mode} |\n`;
|
|
289
714
|
md += `| Total Calls | ${report.totalCalls} |\n`;
|
|
290
|
-
md += `| Upstream Calls | ${report.upstreamCalls} |\n`;
|
|
715
|
+
md += `| ${report.kind === "demo" ? "Simulated Backend Executions Behind A Reported Failure" : "Upstream Calls"} | ${report.upstreamCalls} |\n`;
|
|
291
716
|
md += `| Injected Errors | ${report.injectedErrors} |\n`;
|
|
292
|
-
md += `|
|
|
293
|
-
md += `|
|
|
294
|
-
md += `|
|
|
295
|
-
md += `|
|
|
296
|
-
md += `|
|
|
297
|
-
md += `| Ghost-Write Detections | ${report.ghostWriteDetections} |\n`;
|
|
298
|
-
md += `| Ghost-Write Misses | ${report.ghostWriteMisses} |\n`;
|
|
299
|
-
md += `| Hallucinated Verifications | ${report.hallucinatedVerifications} |\n`;
|
|
300
|
-
md += `| Syntax Crashes | ${report.syntaxCrashes} |\n`;
|
|
717
|
+
md += `| Ghost-Writes Injected | ${report.ghostWritesInjected} |\n`;
|
|
718
|
+
md += `| Write Operations | ${report.operations.length} |\n`;
|
|
719
|
+
md += `| Duplicate Executions | ${report.duplicateExecutions} |\n`;
|
|
720
|
+
md += `| retry_after Violations | ${report.retryAfterViolations} |\n`;
|
|
721
|
+
md += `| Honesty | not measured (the harness sees tool calls, not what the agent tells its user) |\n`;
|
|
301
722
|
md += `| **Verdict** | **${report.verdict}** |\n`;
|
|
723
|
+
const c = report.coverage;
|
|
724
|
+
md += `\n## Coverage\n\n`;
|
|
725
|
+
md += `| Calls to write tools | Calls to read tools | Calls to unclassified tools | Scenario fired |\n|---|---|---|---|\n`;
|
|
726
|
+
md += `| ${c.writeCalls} | ${c.readCalls} | ${c.unclassifiedCalls} | ${c.scenarioTriggered === null ? "n/a" : c.scenarioTriggered ? "yes" : "no"} |\n`;
|
|
727
|
+
for (const note of c.notes) {
|
|
728
|
+
md += `\n> ${note.replace(/[\u0000-\u001f\u007f-\u009f\u2028\u2029]+/g, " ")}\n`;
|
|
729
|
+
}
|
|
730
|
+
if (report.operations.length > 0) {
|
|
731
|
+
md += `\n## Write Operations\n\n`;
|
|
732
|
+
md += `One row per write tool + arguments (ignoring the idempotency key).\n\n`;
|
|
733
|
+
md += `| Op | Tool | Calls | Attempts | Executed | Failed | Keys used | Outcome |\n|---|---|---|---|---|---|---|---|\n`;
|
|
734
|
+
for (const op of report.operations) {
|
|
735
|
+
const keys = op.keys.length === 0
|
|
736
|
+
? "none"
|
|
737
|
+
: `${op.keys.length}${op.attemptsWithoutKey > 0 ? ` (+${op.attemptsWithoutKey} without)` : ""}`;
|
|
738
|
+
md += `| ${op.id} | ${cell(op.tool)} | ${op.callNumbers.join(", ")} | ${op.attempts} | ${op.executions} | ${op.failedAttempts} | ${keys} | ${op.outcome} |\n`;
|
|
739
|
+
}
|
|
740
|
+
}
|
|
741
|
+
if (report.violations.length > 0) {
|
|
742
|
+
md += `\n## Violations\n\n`;
|
|
743
|
+
md += `| Call # | Type | Description |\n|---|---|---|\n`;
|
|
744
|
+
for (const v of report.violations) {
|
|
745
|
+
md += `| ${v.callNumber} | ${v.type} | ${cell(v.description)} |\n`;
|
|
746
|
+
}
|
|
747
|
+
}
|
|
748
|
+
if (report.observations.length > 0) {
|
|
749
|
+
md += `\n## Observations\n\n`;
|
|
750
|
+
md += `Not violations — context for reading the verdict.\n\n`;
|
|
751
|
+
md += `| Call # | Type | Description |\n|---|---|---|\n`;
|
|
752
|
+
for (const o of report.observations) {
|
|
753
|
+
md += `| ${o.callNumber} | ${o.type} | ${cell(o.description)} |\n`;
|
|
754
|
+
}
|
|
755
|
+
}
|
|
302
756
|
if (report.keySummary.length > 0) {
|
|
303
757
|
md += `\n## Idempotency Key Summary\n\n`;
|
|
304
|
-
md +=
|
|
758
|
+
md += `Informational; the verdict does not depend on it.\n\n`;
|
|
759
|
+
md += `| Key | State | Writes | Last Error | Read-Before-Retry | Ghost |\n|---|---|---|---|---|---|\n`;
|
|
305
760
|
for (const k of report.keySummary) {
|
|
306
|
-
md += `|
|
|
761
|
+
md += `| ${cell(k.key)} | ${k.state} | ${k.writeCalls} | ${cell(k.lastError ?? "none")} | ${k.readBeforeRetry ? "yes" : "no"} | ${k.ghostCommitted ? "yes" : "no"} |\n`;
|
|
307
762
|
}
|
|
308
763
|
md += `\n### State-Diff (strict, key-matched)\n\n`;
|
|
309
|
-
md +=
|
|
764
|
+
md += `didAgentReadBefore counts only a read carrying this exact key; "No Follow-Up Call" (agentGaveUpSilently) means neither a read call nor another attempt followed. What the agent told its user is not visible.\n\n`;
|
|
765
|
+
md += `| Key | Record Exists | Overwritten | didAgentReadBefore | No Follow-Up Call |\n|---|---|---|---|---|\n`;
|
|
310
766
|
for (const k of report.keySummary) {
|
|
311
767
|
const d = k.stateDiff;
|
|
312
|
-
md += `|
|
|
313
|
-
}
|
|
314
|
-
}
|
|
315
|
-
if (report.violations.length > 0) {
|
|
316
|
-
md += `\n## Violations & Observations\n\n`;
|
|
317
|
-
md += `| Call # | Type | Key | Description |\n|---|---|---|---|\n`;
|
|
318
|
-
for (const v of report.violations) {
|
|
319
|
-
const isGood = v.type === "VALID_RETRY" || v.type === "GHOST_CAUGHT";
|
|
320
|
-
const marker = isGood ? "OK" : "FAIL";
|
|
321
|
-
md += `| ${v.callNumber} | [${marker}] ${v.type} | ${v.idempotencyKey ?? "n/a"} | ${v.description} |\n`;
|
|
768
|
+
md += `| ${cell(k.key)} | ${d.recordExisted ? "yes" : "no"} | ${d.wasOverwritten ? "yes" : "no"} | ${d.didAgentReadBefore ? "yes" : "no"} | ${d.agentGaveUpSilently ? "yes" : "no"} |\n`;
|
|
322
769
|
}
|
|
323
770
|
}
|
|
324
771
|
return md;
|
|
325
772
|
}
|
|
326
|
-
//# sourceMappingURL=report-generator.js.map
|