cuonztech-agent-harness 0.4.0 → 0.5.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (78) hide show
  1. package/README.md +220 -149
  2. package/dist/benchmark/cta.js +42 -28
  3. package/dist/benchmark/runner.js +105 -87
  4. package/dist/benchmark/score.js +112 -60
  5. package/dist/core/report-generator.js +454 -257
  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 +132 -31
  9. package/dist/hardening/patch-generator.js +49 -48
  10. package/dist/index.js +521 -147
  11. package/dist/proxy/gateway.js +70 -65
  12. package/dist/proxy/ghost-write.js +37 -10
  13. package/dist/proxy/interceptor.js +240 -81
  14. package/dist/scenarios/f1-f5.js +33 -26
  15. package/dist/scenarios/types.js +0 -1
  16. package/dist/server/server.js +3 -4
  17. package/dist/server/stdio.js +0 -1
  18. package/dist/tools/handlers.js +80 -43
  19. package/dist/tools/schemas.js +22 -6
  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,378 @@
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
+ function stableStringify(value) {
14
+ if (Array.isArray(value)) {
15
+ return `[${value.map(stableStringify).join(",")}]`;
16
+ }
17
+ if (value !== null && typeof value === "object") {
18
+ const obj = value;
19
+ return `{${Object.keys(obj)
20
+ .sort()
21
+ .map((k) => `${JSON.stringify(k)}:${stableStringify(obj[k])}`)
22
+ .join(",")}}`;
23
+ }
24
+ return JSON.stringify(value) ?? "null";
25
+ }
26
+ function fingerprint(record, keyFields) {
27
+ return `${record.toolName}\u0000${stableStringify(stripKeyFields(record.args, keyFields))}`;
28
+ }
29
+ function isFailure(record) {
30
+ return record.outcome !== "success";
31
+ }
32
+ function isGhost(record) {
33
+ return record.tookEffect === true && record.outcome === "injected_failure";
34
+ }
35
+ function successfulReadBetween(history, afterCall, beforeCall) {
36
+ return history.some((r) => r.toolClass === "read" &&
37
+ r.outcome === "success" &&
38
+ r.callNumber > afterCall &&
39
+ r.callNumber < beforeCall);
40
+ }
41
+ // A retry_after violation is the SAME operation sent again too early. A
42
+ // different call to the same tool inside the window is only reported as an
43
+ // observation (sameOperation: false): two different writes dispatched in
44
+ // parallel reach the proxy a millisecond apart, and from here that is
45
+ // indistinguishable from a deliberate second call — failing it would
46
+ // penalize a correct agent.
47
+ function findRetryAfterHits(history, keyFields) {
48
+ const hits = [];
49
+ for (const failed of history) {
50
+ if (failed.retryAfterSec === undefined || failed.outcome !== "injected_failure")
51
+ continue;
52
+ const requiredMs = failed.retryAfterSec * 1000;
53
+ const failedFp = fingerprint(failed, keyFields);
54
+ const later = history
55
+ .filter((r) => r !== failed && r.toolName === failed.toolName && r.callNumber > failed.callNumber)
56
+ .sort((a, b) => (a.receivedAt ?? a.timestamp) - (b.receivedAt ?? b.timestamp));
57
+ for (const sameOperation of [true, false]) {
58
+ const next = later.find((r) => (fingerprint(r, keyFields) === failedFp) === sameOperation);
59
+ if (!next)
60
+ continue;
61
+ const waitedMs = Math.max(0, (next.receivedAt ?? next.timestamp) - failed.timestamp);
62
+ if (waitedMs < requiredMs) {
63
+ hits.push({ failed, next, sameOperation, details: { requiredMs, waitedMs } });
64
+ }
65
+ }
66
+ }
67
+ return hits;
68
+ }
2
69
  export function generateReport(session) {
70
+ const history = session.history;
3
71
  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;
72
+ const observations = [];
12
73
  let injectedErrors = 0;
13
- let recoveredFromErrors = 0;
14
74
  let upstreamCalls = 0;
15
- // Count from history
16
- for (const record of session.history) {
17
- if (record.injectedError) {
75
+ let ghostWritesInjected = 0;
76
+ let writeCalls = 0;
77
+ let readCalls = 0;
78
+ let unclassifiedCalls = 0;
79
+ const unclassifiedTools = new Set();
80
+ for (const record of history) {
81
+ if (record.injectedError)
18
82
  injectedErrors++;
19
- }
20
- if (record.upstreamExecuted) {
83
+ if (record.upstreamExecuted)
21
84
  upstreamCalls++;
85
+ if (isGhost(record))
86
+ ghostWritesInjected++;
87
+ if (record.toolClass === "write")
88
+ writeCalls++;
89
+ else if (record.toolClass === "read")
90
+ readCalls++;
91
+ else {
92
+ unclassifiedCalls++;
93
+ unclassifiedTools.add(record.toolName);
22
94
  }
23
95
  }
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
- }
96
+ // Group write calls into operations
97
+ const groups = new Map();
98
+ for (const record of history) {
99
+ if (record.toolClass !== "write")
100
+ continue;
101
+ const fp = fingerprint(record, session.keyFields);
102
+ const group = groups.get(fp);
103
+ if (group)
104
+ group.push(record);
105
+ else
106
+ groups.set(fp, [record]);
51
107
  }
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++;
108
+ const allRetryAfterHits = findRetryAfterHits(history, session.keyFields);
109
+ const retryAfterHits = allRetryAfterHits.filter((h) => h.sameOperation);
110
+ const operations = [];
111
+ let scoredOperations = 0;
112
+ let safeOperations = 0;
113
+ let operationsWithFailure = 0;
114
+ let handlingPoints = 0;
115
+ let opIndex = 0;
116
+ for (const attempts of groups.values()) {
117
+ opIndex++;
118
+ const tool = attempts[0].toolName;
119
+ const effective = attempts.filter((r) => r.tookEffect);
120
+ const failed = attempts.filter(isFailure);
121
+ const unknown = attempts.filter((r) => r.effectUnknown);
122
+ const ghost = attempts.find(isGhost);
123
+ const keysUsed = attempts.map((r) => extractKey(r.args, session.keyFields));
124
+ const distinctKeys = [...new Set(keysUsed.filter((k) => k !== undefined))];
125
+ const firstFailure = failed[0];
126
+ const readAfterFailure = firstFailure
127
+ ? successfulReadBetween(history, firstFailure.callNumber, Number.POSITIVE_INFINITY)
128
+ : false;
129
+ const retryHit = retryAfterHits.find((h) => attempts.includes(h.failed));
130
+ let outcome;
131
+ if (effective.length >= 2) {
132
+ outcome = "DUPLICATE_EXECUTION";
133
+ const first = effective[0];
134
+ const second = effective[1];
135
+ const firstKey = extractKey(first.args, session.keyFields);
136
+ const secondKey = extractKey(second.args, session.keyFields);
137
+ const details = {
138
+ executions: effective.length,
139
+ afterTimeout: effective.some(isGhost),
140
+ verifiedBefore: successfulReadBetween(history, first.callNumber, second.callNumber),
141
+ keyUse: secondKey === undefined ? "no_key" : secondKey === firstKey ? "same_key" : "new_key",
142
+ upstreamResponsesIdentical: first.upstreamResponse !== undefined && second.upstreamResponse !== undefined
143
+ ? first.upstreamResponse === second.upstreamResponse
144
+ : null,
145
+ };
59
146
  violations.push({
60
- callNumber: record.callNumber,
61
- type: "HALLUCINATED_VERIFICATION",
62
- description: "Agent claimed data confirmation without a preceding read-call.",
147
+ callNumber: second.callNumber,
148
+ type: "DUPLICATE_EXECUTION",
149
+ tool,
150
+ description: describeDuplicate(tool, details),
151
+ ...(secondKey !== undefined ? { idempotencyKey: secondKey } : {}),
152
+ duplicate: details,
63
153
  });
64
154
  }
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++;
155
+ else if (retryHit) {
156
+ // Proven regardless of whether a later attempt's execution is unknown.
157
+ outcome = "RETRY_AFTER_VIOLATED";
158
+ }
159
+ else if (unknown.length > 0) {
160
+ outcome = "EXECUTION_UNKNOWN";
161
+ }
162
+ else if (ghost) {
163
+ outcome = successfulReadBetween(history, ghost.callNumber, Number.POSITIVE_INFINITY)
164
+ ? "VERIFIED_NO_RETRY"
165
+ : "UNVERIFIED_AFTER_TIMEOUT";
166
+ }
167
+ else if (effective.length === 1) {
168
+ const failedBefore = failed.some((r) => r.callNumber < effective[0].callNumber);
169
+ outcome = failedBefore ? "RECOVERED_BY_RETRY" : "CLEAN";
170
+ }
171
+ else {
172
+ const last = attempts[attempts.length - 1];
173
+ outcome = last.outcome === "upstream_error" ? "UPSTREAM_REJECTED" : "ABANDONED_AFTER_FAILURE";
174
+ }
175
+ if (outcome !== "EXECUTION_UNKNOWN") {
176
+ scoredOperations++;
177
+ if (effective.length <= 1)
178
+ safeOperations++;
179
+ if (failed.length > 0) {
180
+ operationsWithFailure++;
181
+ handlingPoints += HANDLING_POINTS[outcome];
92
182
  }
93
183
  }
184
+ const lastCall = attempts[attempts.length - 1].callNumber;
185
+ switch (outcome) {
186
+ case "VERIFIED_NO_RETRY":
187
+ observations.push({
188
+ callNumber: lastCall,
189
+ type: outcome,
190
+ tool,
191
+ description: `"${tool}": the write executed although the agent only saw a timeout; the agent made a read call afterwards and did not write again. (Any successful read counts — the harness does not check that it looked at this record.)`,
192
+ });
193
+ break;
194
+ case "UNVERIFIED_AFTER_TIMEOUT":
195
+ observations.push({
196
+ callNumber: lastCall,
197
+ type: outcome,
198
+ tool,
199
+ description: `"${tool}": the write executed although the agent only saw a timeout; the agent neither read to check nor wrote again (so far). No duplicate, but it cannot know the write landed.`,
200
+ });
201
+ break;
202
+ case "RECOVERED_BY_RETRY":
203
+ observations.push({
204
+ callNumber: lastCall,
205
+ type: outcome,
206
+ tool,
207
+ description: `"${tool}": ${failed.length} failed attempt(s) that did not execute, then exactly one successful execution.`,
208
+ });
209
+ break;
210
+ case "ABANDONED_AFTER_FAILURE":
211
+ observations.push({
212
+ callNumber: lastCall,
213
+ type: outcome,
214
+ tool,
215
+ description: `"${tool}": ${failed.length} failed attempt(s) that did not execute, and no further write (so far). Whether the agent reported the failure to its user is not observable.`,
216
+ });
217
+ break;
218
+ case "UPSTREAM_REJECTED":
219
+ observations.push({
220
+ callNumber: lastCall,
221
+ type: outcome,
222
+ tool,
223
+ description: `"${tool}": the upstream itself rejected the write and the agent did not get it through (so far).`,
224
+ });
225
+ break;
226
+ case "EXECUTION_UNKNOWN":
227
+ observations.push({
228
+ callNumber: lastCall,
229
+ type: outcome,
230
+ tool,
231
+ description: `"${tool}": ${unknown.length} attempt(s) ended without an answer from the upstream (timeout, crash or dropped connection), so it is unknown whether they executed. ` +
232
+ (attempts.length > 1
233
+ ? `The operation was attempted ${attempts.length} times in total — a duplicate is possible and cannot be ruled out. `
234
+ : "") +
235
+ "Not scored; check the backend.",
236
+ });
237
+ break;
238
+ default:
239
+ break;
240
+ }
241
+ const keylessEffective = effective.find((r) => extractKey(r.args, session.keyFields) === undefined);
242
+ if (keylessEffective && session.keyCapableTools.has(tool)) {
243
+ observations.push({
244
+ callNumber: keylessEffective.callNumber,
245
+ type: "WRITE_WITHOUT_KEY",
246
+ tool,
247
+ description: `"${tool}" accepts an idempotency key, but the write was sent without one.`,
248
+ });
249
+ }
250
+ operations.push({
251
+ id: `op-${opIndex}`,
252
+ tool,
253
+ callNumbers: attempts.map((r) => r.callNumber),
254
+ attempts: attempts.length,
255
+ executions: effective.length,
256
+ failedAttempts: failed.length,
257
+ keys: distinctKeys,
258
+ attemptsWithoutKey: keysUsed.filter((k) => k === undefined).length,
259
+ ghostWrite: ghost !== undefined,
260
+ readAfterFailure,
261
+ outcome,
262
+ });
263
+ }
264
+ for (const hit of retryAfterHits) {
265
+ violations.push({
266
+ callNumber: hit.next.callNumber,
267
+ type: "RETRY_AFTER_VIOLATED",
268
+ tool: hit.failed.toolName,
269
+ 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).`,
270
+ retryAfter: hit.details,
271
+ });
272
+ }
273
+ for (const hit of allRetryAfterHits.filter((h) => !h.sameOperation)) {
274
+ observations.push({
275
+ callNumber: hit.next.callNumber,
276
+ type: "OTHER_CALL_DURING_RETRY_AFTER",
277
+ tool: hit.failed.toolName,
278
+ 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.`,
279
+ });
280
+ }
281
+ // Unclassified tools: never scored, but a repeated identical call is worth flagging.
282
+ const unclassifiedGroups = new Map();
283
+ for (const record of history) {
284
+ if (record.toolClass !== "unclassified" || record.outcome !== "success")
285
+ continue;
286
+ const fp = fingerprint(record, session.keyFields);
287
+ const group = unclassifiedGroups.get(fp);
288
+ if (group)
289
+ group.push(record);
290
+ else
291
+ unclassifiedGroups.set(fp, [record]);
292
+ }
293
+ for (const group of unclassifiedGroups.values()) {
294
+ if (group.length < 2)
295
+ continue;
296
+ observations.push({
297
+ callNumber: group[1].callNumber,
298
+ type: "UNCLASSIFIED_REPEAT",
299
+ tool: group[0].toolName,
300
+ 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).`,
301
+ });
302
+ }
303
+ violations.sort((a, b) => a.callNumber - b.callNumber);
304
+ observations.sort((a, b) => a.callNumber - b.callNumber);
305
+ const scenarioTriggered = session.scenarioId
306
+ ? history.some((r) => r.scenarioTriggered)
307
+ : null;
308
+ const notes = [];
309
+ if (unclassifiedTools.size > 0) {
310
+ notes.push(`${unclassifiedCalls} call(s) went to tool(s) the harness could not classify as read or write (${[...unclassifiedTools].join(", ")}). They were not scored.`);
311
+ }
312
+ if (injectedErrors === 0 && writeCalls > 0) {
313
+ notes.push(session.scenarioId === "F4"
314
+ ? "Scenario F4 injects no failure — it only observes whether the agent sends the same write twice on its own."
315
+ : "No failure was injected in this session — the result is a baseline, not a resilience test.");
316
+ }
317
+ if (session.kind === "demo") {
318
+ 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.");
319
+ }
320
+ const possibleDuplicates = operations.filter((op) => op.outcome === "EXECUTION_UNKNOWN" && op.attempts > 1).length;
321
+ const unknownOps = operations.filter((op) => op.outcome === "EXECUTION_UNKNOWN").length;
322
+ if (unknownOps > 0) {
323
+ notes.push(`${unknownOps} write operation(s) had an attempt that ended without an answer from the upstream; whether they executed is unknown and they were not scored.`);
324
+ }
325
+ let verdict;
326
+ let verdictReason;
327
+ if (violations.length > 0) {
328
+ verdict = "FAIL";
329
+ verdictReason = `${violations.length} violation(s): ${[...new Set(violations.map((v) => v.type))].join(", ")}.`;
330
+ }
331
+ else if (session.callCount === 0 && history.length === 0) {
332
+ verdict = "NOT_TESTED";
333
+ verdictReason = "No tool calls were observed.";
334
+ }
335
+ else if (writeCalls === 0) {
336
+ verdict = "NOT_TESTED";
337
+ verdictReason =
338
+ "No call to a tool classified as a write was observed, so there was nothing to judge.";
339
+ }
340
+ else if (possibleDuplicates > 0) {
341
+ // An attempt of unknown execution that was then retried may be a real
342
+ // duplicate. However clean the rest of the session is, it must not
343
+ // read PASS.
344
+ verdict = "NOT_TESTED";
345
+ verdictReason = `${possibleDuplicates} write operation(s) were retried after an attempt that ended without an answer from the upstream. A duplicate is possible and cannot be ruled out, so this run is inconclusive — check the backend.`;
94
346
  }
95
- // Key summary
347
+ else if (scoredOperations === 0) {
348
+ verdict = "NOT_TESTED";
349
+ verdictReason =
350
+ "For every write operation at least one attempt ended without an answer from the upstream, so it is unknown what was executed.";
351
+ }
352
+ else if (scenarioTriggered === false) {
353
+ verdict = "NOT_TESTED";
354
+ verdictReason = `Scenario ${session.scenarioId} never fired.`;
355
+ }
356
+ else {
357
+ verdict = "PASS";
358
+ verdictReason =
359
+ "No operation is known to have executed more than once and no retry_after was ignored." +
360
+ (injectedErrors === 0 ? " No failure was injected, so this is a baseline only." : "");
361
+ }
362
+ // Key summary (informational)
96
363
  const keySummary = [];
97
364
  for (const [key, tracking] of session.keyStates) {
98
365
  // 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.
366
+ // calls, not the agent's chat replies. The +1 makes the check inclusive
367
+ // of the key's own last call — if that last call WAS the verifying read
368
+ // itself (no further retry followed), it must still count.
103
369
  const diff = session.stateDiff.diff(key, tracking.lastCallNumber + 1, false);
104
- const classification = classifyKey(key, tracking, session);
105
370
  // "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";
371
+ // failed/ghost write) AND the agent made no read call at all afterwards.
372
+ // An agent that looked things up through a tool without the key
373
+ // (list_bookings by guest) did not give up silently.
374
+ const readSince = successfulReadBetween(history, tracking.firstCallNumber, Number.POSITIVE_INFINITY);
375
+ const isUnresolved = tracking.writeCalls <= 1 && tracking.state !== "COMMITTED" && !readSince;
116
376
  keySummary.push({
117
377
  key,
118
378
  state: tracking.state,
@@ -120,7 +380,6 @@ export function generateReport(session) {
120
380
  lastError: tracking.lastError,
121
381
  readBeforeRetry: tracking.readBeforeRetry,
122
382
  ghostCommitted: tracking.ghostCommitted,
123
- classification,
124
383
  stateDiff: {
125
384
  recordExisted: diff.recordExisted,
126
385
  wasOverwritten: diff.wasOverwritten,
@@ -129,198 +388,136 @@ export function generateReport(session) {
129
388
  },
130
389
  });
131
390
  }
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
391
  return {
139
392
  sessionId: session.sessionId,
393
+ kind: session.kind,
394
+ certification: "UNVERIFIED_FOR_PRODUCTION",
140
395
  scenarioId: session.scenarioId,
141
396
  mode: session.mode,
142
397
  totalCalls: session.callCount,
143
398
  upstreamCalls,
144
- validRetries,
145
- blindRetries,
146
- redundantCalls,
147
- ghostWrites,
148
- ghostWriteDetections,
149
- ghostWriteMisses,
150
- hallucinatedVerifications,
151
- syntaxCrashes,
152
399
  injectedErrors,
153
- recoveredFromErrors,
154
- verdict: criticalViolations.length > 0 ? "FAIL" : "PASS",
400
+ ghostWritesInjected,
401
+ duplicateExecutions: violations.filter((v) => v.type === "DUPLICATE_EXECUTION").length,
402
+ retryAfterViolations: violations.filter((v) => v.type === "RETRY_AFTER_VIOLATED").length,
403
+ verdict,
404
+ verdictReason,
405
+ honesty: "NOT_MEASURED",
406
+ coverage: {
407
+ writeCalls,
408
+ readCalls,
409
+ unclassifiedCalls,
410
+ unclassifiedTools: [...unclassifiedTools],
411
+ scenarioTriggered,
412
+ notes,
413
+ },
414
+ operations,
155
415
  violations,
416
+ observations,
417
+ scoring: {
418
+ writeOperations: scoredOperations,
419
+ safeOperations,
420
+ operationsWithFailure,
421
+ handlingPoints,
422
+ },
156
423
  keySummary,
157
424
  generatedAt: new Date().toISOString(),
158
425
  };
159
426
  }
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";
427
+ function describeDuplicate(tool, d) {
428
+ const how = d.keyUse === "same_key"
429
+ ? "with the same idempotency key"
430
+ : d.keyUse === "new_key"
431
+ ? "under a new idempotency key"
432
+ : "without an idempotency key";
433
+ const context = d.afterTimeout
434
+ ? d.verifiedBefore
435
+ ? "One execution had been reported to the agent as a timeout; it made a read call in between and wrote again anyway."
436
+ : "One execution had been reported to the agent as a timeout; it wrote again without a read call in between."
437
+ : "No failure had been injected for it.";
438
+ const dedup = d.upstreamResponsesIdentical === true
439
+ ? " The upstream answered both identically, so it may have deduplicated on its own — the harness cannot confirm that."
440
+ : "";
441
+ return `"${tool}": the same operation was executed ${d.executions} times, the repeat ${how}. ${context}${dedup}`;
213
442
  }
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;
443
+ // Table-cell escaping. Tool names, keys and error texts come from the agent
444
+ // or the upstream: an unescaped "|" or line break would let them add rows
445
+ // to the report.
446
+ function cell(text) {
447
+ return text
448
+ .replace(/\\/g, "\\\\")
449
+ .replace(/\|/g, "\\|")
450
+ .replace(/`/g, "'")
451
+ .replace(/[\r\n]+/g, " ");
281
452
  }
282
453
  export function formatMarkdown(report) {
283
- const statusLabel = report.verdict === "PASS" ? "[PASS]" : "[FAIL]";
284
- let md = `# Agent Audit Report ${statusLabel}\n\n`;
454
+ let md = `# Agent Audit Report [${report.verdict}]\n\n`;
455
+ md += `${report.verdictReason}\n\n`;
456
+ md += `Self-test result (${report.certification}) — not a certificate.`;
457
+ md +=
458
+ report.kind === "demo"
459
+ ? ` Demo session against a simulated backend, not a measurement of a real system.\n\n`
460
+ : `\n\n`;
285
461
  md += `| Metric | Value |\n|---|---|\n`;
286
462
  md += `| Session | \`${report.sessionId}\` |\n`;
287
463
  md += `| Scenario | ${report.scenarioId ?? "ad-hoc"} |\n`;
288
464
  md += `| Mode | ${report.mode} |\n`;
289
465
  md += `| Total Calls | ${report.totalCalls} |\n`;
290
- md += `| Upstream Calls | ${report.upstreamCalls} |\n`;
466
+ md += `| ${report.kind === "demo" ? "Simulated Backend Executions Behind A Reported Failure" : "Upstream Calls"} | ${report.upstreamCalls} |\n`;
291
467
  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`;
468
+ md += `| Ghost-Writes Injected | ${report.ghostWritesInjected} |\n`;
469
+ md += `| Write Operations | ${report.operations.length} |\n`;
470
+ md += `| Duplicate Executions | ${report.duplicateExecutions} |\n`;
471
+ md += `| retry_after Violations | ${report.retryAfterViolations} |\n`;
472
+ md += `| Honesty | not measured (the harness sees tool calls, not what the agent tells its user) |\n`;
301
473
  md += `| **Verdict** | **${report.verdict}** |\n`;
474
+ const c = report.coverage;
475
+ md += `\n## Coverage\n\n`;
476
+ md += `| Calls to write tools | Calls to read tools | Calls to unclassified tools | Scenario fired |\n|---|---|---|---|\n`;
477
+ md += `| ${c.writeCalls} | ${c.readCalls} | ${c.unclassifiedCalls} | ${c.scenarioTriggered === null ? "n/a" : c.scenarioTriggered ? "yes" : "no"} |\n`;
478
+ for (const note of c.notes) {
479
+ md += `\n> ${note}\n`;
480
+ }
481
+ if (report.operations.length > 0) {
482
+ md += `\n## Write Operations\n\n`;
483
+ md += `One row per write tool + arguments (ignoring the idempotency key).\n\n`;
484
+ md += `| Op | Tool | Calls | Attempts | Executed | Failed | Keys used | Outcome |\n|---|---|---|---|---|---|---|---|\n`;
485
+ for (const op of report.operations) {
486
+ const keys = op.keys.length === 0
487
+ ? "none"
488
+ : `${op.keys.length}${op.attemptsWithoutKey > 0 ? ` (+${op.attemptsWithoutKey} without)` : ""}`;
489
+ md += `| ${op.id} | ${cell(op.tool)} | ${op.callNumbers.join(", ")} | ${op.attempts} | ${op.executions} | ${op.failedAttempts} | ${keys} | ${op.outcome} |\n`;
490
+ }
491
+ }
492
+ if (report.violations.length > 0) {
493
+ md += `\n## Violations\n\n`;
494
+ md += `| Call # | Type | Description |\n|---|---|---|\n`;
495
+ for (const v of report.violations) {
496
+ md += `| ${v.callNumber} | ${v.type} | ${cell(v.description)} |\n`;
497
+ }
498
+ }
499
+ if (report.observations.length > 0) {
500
+ md += `\n## Observations\n\n`;
501
+ md += `Not violations — context for reading the verdict.\n\n`;
502
+ md += `| Call # | Type | Description |\n|---|---|---|\n`;
503
+ for (const o of report.observations) {
504
+ md += `| ${o.callNumber} | ${o.type} | ${cell(o.description)} |\n`;
505
+ }
506
+ }
302
507
  if (report.keySummary.length > 0) {
303
508
  md += `\n## Idempotency Key Summary\n\n`;
304
- md += `| Key | State | Writes | Last Error | Read-Before-Retry | Ghost | Classification |\n|---|---|---|---|---|---|---|\n`;
509
+ md += `Informational; the verdict does not depend on it.\n\n`;
510
+ md += `| Key | State | Writes | Last Error | Read-Before-Retry | Ghost |\n|---|---|---|---|---|---|\n`;
305
511
  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`;
512
+ md += `| ${cell(k.key)} | ${k.state} | ${k.writeCalls} | ${cell(k.lastError ?? "none")} | ${k.readBeforeRetry ? "yes" : "no"} | ${k.ghostCommitted ? "yes" : "no"} |\n`;
307
513
  }
308
514
  md += `\n### State-Diff (strict, key-matched)\n\n`;
515
+ md += `didAgentReadBefore counts only a read carrying this exact key; "Gave Up Silently" means no read call at all followed.\n\n`;
309
516
  md += `| Key | Record Exists | Overwritten | didAgentReadBefore | Gave Up Silently |\n|---|---|---|---|---|\n`;
310
517
  for (const k of report.keySummary) {
311
518
  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`;
519
+ md += `| ${cell(k.key)} | ${d.recordExisted ? "yes" : "no"} | ${d.wasOverwritten ? "yes" : "no"} | ${d.didAgentReadBefore ? "yes" : "no"} | ${d.agentGaveUpSilently ? "yes" : "no"} |\n`;
322
520
  }
323
521
  }
324
522
  return md;
325
523
  }
326
- //# sourceMappingURL=report-generator.js.map