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.
Files changed (78) hide show
  1. package/README.md +260 -151
  2. package/dist/benchmark/cta.js +45 -31
  3. package/dist/benchmark/runner.js +105 -87
  4. package/dist/benchmark/score.js +115 -60
  5. package/dist/core/report-generator.js +704 -258
  6. package/dist/core/state-engine.js +1 -2
  7. package/dist/engine/state-diff.js +4 -2
  8. package/dist/engine/state-machine.js +164 -35
  9. package/dist/hardening/patch-generator.js +58 -48
  10. package/dist/index.js +737 -148
  11. package/dist/proxy/gateway.js +71 -65
  12. package/dist/proxy/ghost-write.js +37 -10
  13. package/dist/proxy/interceptor.js +367 -84
  14. package/dist/scenarios/f1-f5.js +34 -27
  15. package/dist/scenarios/types.js +0 -1
  16. package/dist/server/server.js +70 -18
  17. package/dist/server/stdio.js +0 -1
  18. package/dist/tools/handlers.js +80 -43
  19. package/dist/tools/schemas.js +27 -11
  20. package/dist/version.js +0 -1
  21. package/package.json +4 -3
  22. package/dist/benchmark/cta.d.ts +0 -13
  23. package/dist/benchmark/cta.d.ts.map +0 -1
  24. package/dist/benchmark/cta.js.map +0 -1
  25. package/dist/benchmark/runner.d.ts +0 -19
  26. package/dist/benchmark/runner.d.ts.map +0 -1
  27. package/dist/benchmark/runner.js.map +0 -1
  28. package/dist/benchmark/score.d.ts +0 -26
  29. package/dist/benchmark/score.d.ts.map +0 -1
  30. package/dist/benchmark/score.js.map +0 -1
  31. package/dist/core/report-generator.d.ts +0 -47
  32. package/dist/core/report-generator.d.ts.map +0 -1
  33. package/dist/core/report-generator.js.map +0 -1
  34. package/dist/core/state-engine.d.ts +0 -2
  35. package/dist/core/state-engine.d.ts.map +0 -1
  36. package/dist/core/state-engine.js.map +0 -1
  37. package/dist/engine/state-diff.d.ts +0 -33
  38. package/dist/engine/state-diff.d.ts.map +0 -1
  39. package/dist/engine/state-diff.js.map +0 -1
  40. package/dist/engine/state-machine.d.ts +0 -46
  41. package/dist/engine/state-machine.d.ts.map +0 -1
  42. package/dist/engine/state-machine.js.map +0 -1
  43. package/dist/hardening/patch-generator.d.ts +0 -12
  44. package/dist/hardening/patch-generator.d.ts.map +0 -1
  45. package/dist/hardening/patch-generator.js.map +0 -1
  46. package/dist/index.d.ts +0 -3
  47. package/dist/index.d.ts.map +0 -1
  48. package/dist/index.js.map +0 -1
  49. package/dist/proxy/gateway.d.ts +0 -4
  50. package/dist/proxy/gateway.d.ts.map +0 -1
  51. package/dist/proxy/gateway.js.map +0 -1
  52. package/dist/proxy/ghost-write.d.ts +0 -17
  53. package/dist/proxy/ghost-write.d.ts.map +0 -1
  54. package/dist/proxy/ghost-write.js.map +0 -1
  55. package/dist/proxy/interceptor.d.ts +0 -33
  56. package/dist/proxy/interceptor.d.ts.map +0 -1
  57. package/dist/proxy/interceptor.js.map +0 -1
  58. package/dist/scenarios/f1-f5.d.ts +0 -9
  59. package/dist/scenarios/f1-f5.d.ts.map +0 -1
  60. package/dist/scenarios/f1-f5.js.map +0 -1
  61. package/dist/scenarios/types.d.ts +0 -21
  62. package/dist/scenarios/types.d.ts.map +0 -1
  63. package/dist/scenarios/types.js.map +0 -1
  64. package/dist/server/server.d.ts +0 -3
  65. package/dist/server/server.d.ts.map +0 -1
  66. package/dist/server/server.js.map +0 -1
  67. package/dist/server/stdio.d.ts +0 -7
  68. package/dist/server/stdio.d.ts.map +0 -1
  69. package/dist/server/stdio.js.map +0 -1
  70. package/dist/tools/handlers.d.ts +0 -93
  71. package/dist/tools/handlers.d.ts.map +0 -1
  72. package/dist/tools/handlers.js.map +0 -1
  73. package/dist/tools/schemas.d.ts +0 -38
  74. package/dist/tools/schemas.d.ts.map +0 -1
  75. package/dist/tools/schemas.js.map +0 -1
  76. package/dist/version.d.ts +0 -2
  77. package/dist/version.d.ts.map +0 -1
  78. package/dist/version.js.map +0 -1
@@ -1,118 +1,609 @@
1
- import { isWriteTool } from "../engine/state-machine.js";
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
- let validRetries = 0;
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
- // Count from history
16
- for (const record of session.history) {
17
- if (record.injectedError) {
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
- // Analyze each idempotency key via state machine
25
- for (const [key, tracking] of session.keyStates) {
26
- const classification = classifyKey(key, tracking, session);
27
- const keyViolations = getKeyViolations(key, tracking, session, classification);
28
- for (const v of keyViolations) {
29
- violations.push(v);
30
- switch (v.type) {
31
- case "VALID_RETRY":
32
- validRetries++;
33
- break;
34
- case "BLIND_RETRY":
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
- // Scan for non-key violations
53
- for (const record of session.history) {
54
- if (record.response.includes("confirmed") &&
55
- !session.history.some((r) => r.callNumber < record.callNumber &&
56
- r.toolName.startsWith("read") &&
57
- r.toolName.includes(record.toolName.replace("write", "")))) {
58
- hallucinatedVerifications++;
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: record.callNumber,
61
- type: "HALLUCINATED_VERIFICATION",
62
- description: "Agent claimed data confirmation without a preceding read-call.",
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
- // syntaxCrashes is intentionally left at 0 here, always: an MCP server
66
- // only ever sees the tool calls an agent makes, never the agent's own
67
- // internal exceptions, so "did the agent crash with a SyntaxError" is
68
- // structurally unobservable over this protocol. The removed check used
69
- // to look for the literal string "SyntaxError" inside response.includes()
70
- // — data the harness itself never produces (F3's malformed body is
71
- // `{"result": "ok", "data": {broken`, no such substring), so it could
72
- // never fire from a real run. The field/type stay wired through
73
- // score.ts/patch-generator.ts as always-0 infrastructure for a future
74
- // real signal (e.g. transcript-based analysis in a real benchmark mode),
75
- // rather than keeping a condition that can only ever be fed synthetic
76
- // test data and never a genuine harness-generated response.
77
- if (record.injectedError) {
78
- // Recovery means a LATER call for the SAME idempotency key actually
79
- // succeeded afterwards — not that this failing call's own response
80
- // happens to lack the string '"error"'. Without a key there is no
81
- // reliable way to tell a real retry from an unrelated later call to
82
- // the same tool, so keyless failures are left uncredited on purpose
83
- // rather than risk a false "recovered" count.
84
- const key = record.args["idempotency_key"];
85
- const recovered = key !== undefined &&
86
- session.history.some((r) => r.callNumber > record.callNumber &&
87
- !r.injectedError &&
88
- !r.response.includes('"error"') &&
89
- r.args["idempotency_key"] === key);
90
- if (recovered) {
91
- recoveredFromErrors++;
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
- // Key summary
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, so it has no channel to confirm
100
- // the agent actually warned the user before giving up. The +1 makes the
101
- // check inclusive of the key's own last call — if that last call WAS the
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, nothing since) — NOT merely "no exact-key read
107
- // happened". Every other classification (GHOST_CAUGHT, VALID_RETRY,
108
- // REDUNDANT_CALL, even BLIND_RETRY/GHOST_MISSED) means the agent DID take
109
- // further action on the key, just not necessarily a strict-key read —
110
- // that is already captured by readBeforeRetry/didAgentReadBefore and the
111
- // violation counters above. Without this gate, agentGaveUpSilently would
112
- // fire true on almost every key (any realistic agent that checks status
113
- // via a domain param instead of repeating idempotency_key), which is
114
- // exactly backwards for an "excellent behavior" GHOST_CAUGHT case.
115
- const isUnresolved = classification === "GHOST_WRITE" || classification === "WRITE_ONLY";
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
- recoveredFromErrors,
154
- verdict: criticalViolations.length > 0 ? "FAIL" : "PASS",
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
- function classifyKey(_key, tracking, session) {
161
- if (tracking.writeCalls <= 1) {
162
- if (tracking.ghostCommitted)
163
- return "GHOST_WRITE";
164
- return tracking.state === "COMMITTED" ? "INITIAL" : "WRITE_ONLY";
165
- }
166
- const keyRecords = session.history.filter((r) => r.args["idempotency_key"] === _key &&
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 getKeyViolations(key, tracking, session, classification) {
215
- const violations = [];
216
- // Clean cases — no violation
217
- if (classification === "VALID_RETRY" ||
218
- classification === "INITIAL" ||
219
- classification === "WRITE_ONLY") {
220
- if (classification === "VALID_RETRY") {
221
- violations.push({
222
- callNumber: tracking.lastCallNumber,
223
- type: "VALID_RETRY",
224
- description: `Idempotency key "${key}": correct retry after error with read-verification. Good agent behavior.`,
225
- idempotencyKey: key,
226
- });
227
- }
228
- return violations;
229
- }
230
- // GHOST_CAUGHT: agent detected ghost-write correctly — report as positive
231
- if (classification === "GHOST_CAUGHT") {
232
- violations.push({
233
- callNumber: tracking.lastCallNumber,
234
- type: "GHOST_CAUGHT",
235
- description: `Idempotency key "${key}": agent correctly detected ghost-write via read-before-retry. Excellent behavior.`,
236
- idempotencyKey: key,
237
- });
238
- return violations;
239
- }
240
- // Find offending call
241
- const keyWriteRecords = session.history.filter((r) => r.args["idempotency_key"] === key &&
242
- isWriteTool(r.toolName, session.writeToolPatterns));
243
- const offendingCall = keyWriteRecords.length >= 2 ? keyWriteRecords[1] : null;
244
- if (!offendingCall)
245
- return violations;
246
- switch (classification) {
247
- case "BLIND_RETRY":
248
- violations.push({
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, "&lt;")
696
+ .replace(/>/g, "&gt;")
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
- const statusLabel = report.verdict === "PASS" ? "[PASS]" : "[FAIL]";
284
- let md = `# Agent Audit Report ${statusLabel}\n\n`;
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 += `| Recovered | ${report.recoveredFromErrors} |\n`;
293
- md += `| Valid Retries | ${report.validRetries} |\n`;
294
- md += `| Blind Retries | ${report.blindRetries} |\n`;
295
- md += `| Redundant Calls | ${report.redundantCalls} |\n`;
296
- md += `| Ghost Writes | ${report.ghostWrites} |\n`;
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 += `| Key | State | Writes | Last Error | Read-Before-Retry | Ghost | Classification |\n|---|---|---|---|---|---|---|\n`;
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 += `| \`${k.key}\` | ${k.state} | ${k.writeCalls} | ${k.lastError ?? "none"} | ${k.readBeforeRetry ? "yes" : "no"} | ${k.ghostCommitted ? "yes" : "no"} | ${k.classification} |\n`;
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 += `| Key | Record Exists | Overwritten | didAgentReadBefore | Gave Up Silently |\n|---|---|---|---|---|\n`;
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 += `| \`${k.key}\` | ${d.recordExisted ? "yes" : "no"} | ${d.wasOverwritten ? "yes" : "no"} | ${d.didAgentReadBefore ? "yes" : "no"} | ${d.agentGaveUpSilently ? "yes" : "no"} |\n`;
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