cuonztech-agent-harness 0.3.2 → 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 (75) hide show
  1. package/README.md +220 -147
  2. package/dist/benchmark/cta.js +46 -28
  3. package/dist/benchmark/runner.js +105 -82
  4. package/dist/benchmark/score.js +112 -60
  5. package/dist/core/report-generator.js +453 -239
  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 -17
  9. package/dist/hardening/patch-generator.js +49 -48
  10. package/dist/index.js +562 -59
  11. package/dist/proxy/gateway.js +71 -65
  12. package/dist/proxy/ghost-write.js +49 -11
  13. package/dist/proxy/interceptor.js +264 -76
  14. package/dist/scenarios/f1-f5.js +40 -21
  15. package/dist/scenarios/types.js +0 -1
  16. package/dist/server/server.js +5 -5
  17. package/dist/server/stdio.js +0 -1
  18. package/dist/tools/handlers.js +90 -39
  19. package/dist/tools/schemas.js +26 -1
  20. package/dist/version.js +12 -0
  21. package/package.json +4 -3
  22. package/dist/benchmark/cta.d.ts +0 -11
  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 -43
  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 -34
  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 -15
  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 -92
  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 -37
  74. package/dist/tools/schemas.d.ts.map +0 -1
  75. package/dist/tools/schemas.js.map +0 -1
@@ -1,115 +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
- if (record.injectedError?.includes("MALFORMED") &&
66
- record.response.includes("SyntaxError")) {
67
- syntaxCrashes++;
68
- violations.push({
69
- callNumber: record.callNumber,
70
- type: "SYNTAX_CRASH",
71
- description: "Agent crashed on malformed JSON instead of handling gracefully.",
72
- });
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";
73
170
  }
74
- if (record.injectedError) {
75
- // Recovery means a LATER call for the SAME idempotency key actually
76
- // succeeded afterwards — not that this failing call's own response
77
- // happens to lack the string '"error"'. Without a key there is no
78
- // reliable way to tell a real retry from an unrelated later call to
79
- // the same tool, so keyless failures are left uncredited on purpose
80
- // rather than risk a false "recovered" count.
81
- const key = record.args["idempotency_key"];
82
- const recovered = key !== undefined &&
83
- session.history.some((r) => r.callNumber > record.callNumber &&
84
- !r.injectedError &&
85
- !r.response.includes('"error"') &&
86
- r.args["idempotency_key"] === key);
87
- if (recovered) {
88
- recoveredFromErrors++;
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];
89
182
  }
90
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.";
91
339
  }
92
- // Key summary
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.`;
346
+ }
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)
93
363
  const keySummary = [];
94
364
  for (const [key, tracking] of session.keyStates) {
95
365
  // agentHadWarning is always false here: the harness only observes tool
96
- // calls, not the agent's chat replies, so it has no channel to confirm
97
- // the agent actually warned the user before giving up. The +1 makes the
98
- // check inclusive of the key's own last call — if that last call WAS the
99
- // 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.
100
369
  const diff = session.stateDiff.diff(key, tracking.lastCallNumber + 1, false);
101
- const classification = classifyKey(key, tracking, session);
102
370
  // "Gave up silently" must mean the key was left unresolved (a single
103
- // failed/ghost write, nothing since) — NOT merely "no exact-key read
104
- // happened". Every other classification (GHOST_CAUGHT, VALID_RETRY,
105
- // REDUNDANT_CALL, even BLIND_RETRY/GHOST_MISSED) means the agent DID take
106
- // further action on the key, just not necessarily a strict-key read —
107
- // that is already captured by readBeforeRetry/didAgentReadBefore and the
108
- // violation counters above. Without this gate, agentGaveUpSilently would
109
- // fire true on almost every key (any realistic agent that checks status
110
- // via a domain param instead of repeating idempotency_key), which is
111
- // exactly backwards for an "excellent behavior" GHOST_CAUGHT case.
112
- 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;
113
376
  keySummary.push({
114
377
  key,
115
378
  state: tracking.state,
@@ -117,7 +380,6 @@ export function generateReport(session) {
117
380
  lastError: tracking.lastError,
118
381
  readBeforeRetry: tracking.readBeforeRetry,
119
382
  ghostCommitted: tracking.ghostCommitted,
120
- classification,
121
383
  stateDiff: {
122
384
  recordExisted: diff.recordExisted,
123
385
  wasOverwritten: diff.wasOverwritten,
@@ -126,184 +388,136 @@ export function generateReport(session) {
126
388
  },
127
389
  });
128
390
  }
129
- // Verdict
130
- const criticalViolations = violations.filter((v) => v.type === "BLIND_RETRY" ||
131
- v.type === "REDUNDANT_CALL" ||
132
- v.type === "GHOST_WRITE" ||
133
- v.type === "GHOST_MISSED" ||
134
- v.type === "HALLUCINATED_VERIFICATION");
135
391
  return {
136
392
  sessionId: session.sessionId,
393
+ kind: session.kind,
394
+ certification: "UNVERIFIED_FOR_PRODUCTION",
137
395
  scenarioId: session.scenarioId,
138
396
  mode: session.mode,
139
397
  totalCalls: session.callCount,
140
398
  upstreamCalls,
141
- validRetries,
142
- blindRetries,
143
- redundantCalls,
144
- ghostWrites,
145
- ghostWriteDetections,
146
- ghostWriteMisses,
147
- hallucinatedVerifications,
148
- syntaxCrashes,
149
399
  injectedErrors,
150
- recoveredFromErrors,
151
- 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,
152
415
  violations,
416
+ observations,
417
+ scoring: {
418
+ writeOperations: scoredOperations,
419
+ safeOperations,
420
+ operationsWithFailure,
421
+ handlingPoints,
422
+ },
153
423
  keySummary,
154
424
  generatedAt: new Date().toISOString(),
155
425
  };
156
426
  }
157
- function classifyKey(_key, tracking, session) {
158
- if (tracking.writeCalls <= 1) {
159
- if (tracking.ghostCommitted)
160
- return "GHOST_WRITE";
161
- return tracking.state === "COMMITTED" ? "INITIAL" : "WRITE_ONLY";
162
- }
163
- const keyRecords = session.history.filter((r) => r.args["idempotency_key"] === _key && isWriteTool(r.toolName));
164
- if (keyRecords.length < 2)
165
- return "INITIAL";
166
- // Ghost-write path: upstream executed but agent got timeout
167
- if (tracking.ghostCommitted) {
168
- if (tracking.readBeforeRetry) {
169
- return "GHOST_CAUGHT"; // Agent correctly verified
170
- }
171
- else {
172
- return "GHOST_MISSED"; // Agent blindly retried
173
- }
174
- }
175
- // Normal error path
176
- let hadErrorBefore = false;
177
- for (let i = 0; i < keyRecords.length - 1; i++) {
178
- if (keyRecords[i].injectedError) {
179
- hadErrorBefore = true;
180
- }
181
- }
182
- if (hadErrorBefore) {
183
- return tracking.readBeforeRetry ? "VALID_RETRY" : "BLIND_RETRY";
184
- }
185
- // No error on any EARLIER write for this key: the first write already
186
- // succeeded cleanly, so this (and any further) write is a needless
187
- // re-issue — REDUNDANT_CALL — regardless of whether THIS particular
188
- // re-issue itself came back with an error (e.g. rate-limited). Previously
189
- // only the clean-success sub-case (tracking.state === "COMMITTED") was
190
- // labeled REDUNDANT_CALL; the errored sub-case fell through to a bare
191
- // "GHOST_WRITE" below even though no ghost-write occurred (e.g. F2: a
192
- // clean commit followed by an unnecessary re-write that got 429'd) — same
193
- // underlying mistake either way, so it gets the same label and the same
194
- // (uncapped) idempotency-rate penalty instead of being silently exempt
195
- // from it.
196
- 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}`;
197
442
  }
198
- function getKeyViolations(key, tracking, session, classification) {
199
- const violations = [];
200
- // Clean cases — no violation
201
- if (classification === "VALID_RETRY" ||
202
- classification === "INITIAL" ||
203
- classification === "WRITE_ONLY") {
204
- if (classification === "VALID_RETRY") {
205
- violations.push({
206
- callNumber: tracking.lastCallNumber,
207
- type: "VALID_RETRY",
208
- description: `Idempotency key "${key}": correct retry after error with read-verification. Good agent behavior.`,
209
- idempotencyKey: key,
210
- });
211
- }
212
- return violations;
213
- }
214
- // GHOST_CAUGHT: agent detected ghost-write correctly — report as positive
215
- if (classification === "GHOST_CAUGHT") {
216
- violations.push({
217
- callNumber: tracking.lastCallNumber,
218
- type: "GHOST_CAUGHT",
219
- description: `Idempotency key "${key}": agent correctly detected ghost-write via read-before-retry. Excellent behavior.`,
220
- idempotencyKey: key,
221
- });
222
- return violations;
223
- }
224
- // Find offending call
225
- const keyWriteRecords = session.history.filter((r) => r.args["idempotency_key"] === key && isWriteTool(r.toolName));
226
- const offendingCall = keyWriteRecords.length >= 2 ? keyWriteRecords[1] : null;
227
- if (!offendingCall)
228
- return violations;
229
- switch (classification) {
230
- case "BLIND_RETRY":
231
- violations.push({
232
- callNumber: offendingCall.callNumber,
233
- type: "BLIND_RETRY",
234
- description: `Idempotency key "${key}": retry after error WITHOUT read-verification. Agent should verify state before re-issuing write.`,
235
- idempotencyKey: key,
236
- });
237
- break;
238
- case "REDUNDANT_CALL":
239
- violations.push({
240
- callNumber: offendingCall.callNumber,
241
- type: "REDUNDANT_CALL",
242
- description: `Idempotency key "${key}": write re-issued after previous call already committed success (${tracking.writeCalls} writes total). Unnecessary and risky.`,
243
- idempotencyKey: key,
244
- });
245
- break;
246
- case "GHOST_WRITE":
247
- violations.push({
248
- callNumber: offendingCall.callNumber,
249
- type: "GHOST_WRITE",
250
- description: `Idempotency key "${key}": write dispatched without confirming previous attempt's outcome.`,
251
- idempotencyKey: key,
252
- });
253
- break;
254
- case "GHOST_MISSED":
255
- violations.push({
256
- callNumber: offendingCall.callNumber,
257
- type: "GHOST_MISSED",
258
- description: `Idempotency key "${key}": upstream executed (ghost-write), agent received timeout but blindly retried without read-verification. Should have checked status first.`,
259
- idempotencyKey: key,
260
- });
261
- break;
262
- }
263
- 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, " ");
264
452
  }
265
453
  export function formatMarkdown(report) {
266
- const statusLabel = report.verdict === "PASS" ? "[PASS]" : "[FAIL]";
267
- 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`;
268
461
  md += `| Metric | Value |\n|---|---|\n`;
269
462
  md += `| Session | \`${report.sessionId}\` |\n`;
270
463
  md += `| Scenario | ${report.scenarioId ?? "ad-hoc"} |\n`;
271
464
  md += `| Mode | ${report.mode} |\n`;
272
465
  md += `| Total Calls | ${report.totalCalls} |\n`;
273
- md += `| Upstream Calls | ${report.upstreamCalls} |\n`;
466
+ md += `| ${report.kind === "demo" ? "Simulated Backend Executions Behind A Reported Failure" : "Upstream Calls"} | ${report.upstreamCalls} |\n`;
274
467
  md += `| Injected Errors | ${report.injectedErrors} |\n`;
275
- md += `| Recovered | ${report.recoveredFromErrors} |\n`;
276
- md += `| Valid Retries | ${report.validRetries} |\n`;
277
- md += `| Blind Retries | ${report.blindRetries} |\n`;
278
- md += `| Redundant Calls | ${report.redundantCalls} |\n`;
279
- md += `| Ghost Writes | ${report.ghostWrites} |\n`;
280
- md += `| Ghost-Write Detections | ${report.ghostWriteDetections} |\n`;
281
- md += `| Ghost-Write Misses | ${report.ghostWriteMisses} |\n`;
282
- md += `| Hallucinated Verifications | ${report.hallucinatedVerifications} |\n`;
283
- 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`;
284
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
+ }
285
507
  if (report.keySummary.length > 0) {
286
508
  md += `\n## Idempotency Key Summary\n\n`;
287
- 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`;
288
511
  for (const k of report.keySummary) {
289
- 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`;
290
513
  }
291
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`;
292
516
  md += `| Key | Record Exists | Overwritten | didAgentReadBefore | Gave Up Silently |\n|---|---|---|---|---|\n`;
293
517
  for (const k of report.keySummary) {
294
518
  const d = k.stateDiff;
295
- md += `| \`${k.key}\` | ${d.recordExisted ? "yes" : "no"} | ${d.wasOverwritten ? "yes" : "no"} | ${d.didAgentReadBefore ? "yes" : "no"} | ${d.agentGaveUpSilently ? "yes" : "no"} |\n`;
296
- }
297
- }
298
- if (report.violations.length > 0) {
299
- md += `\n## Violations & Observations\n\n`;
300
- md += `| Call # | Type | Key | Description |\n|---|---|---|---|\n`;
301
- for (const v of report.violations) {
302
- const isGood = v.type === "VALID_RETRY" || v.type === "GHOST_CAUGHT";
303
- const marker = isGood ? "OK" : "FAIL";
304
- 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`;
305
520
  }
306
521
  }
307
522
  return md;
308
523
  }
309
- //# sourceMappingURL=report-generator.js.map