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.
- package/README.md +220 -147
- package/dist/benchmark/cta.js +46 -28
- package/dist/benchmark/runner.js +105 -82
- package/dist/benchmark/score.js +112 -60
- package/dist/core/report-generator.js +453 -239
- package/dist/core/state-engine.js +1 -2
- package/dist/engine/state-diff.js +4 -2
- package/dist/engine/state-machine.js +164 -17
- package/dist/hardening/patch-generator.js +49 -48
- package/dist/index.js +562 -59
- package/dist/proxy/gateway.js +71 -65
- package/dist/proxy/ghost-write.js +49 -11
- package/dist/proxy/interceptor.js +264 -76
- package/dist/scenarios/f1-f5.js +40 -21
- package/dist/scenarios/types.js +0 -1
- package/dist/server/server.js +5 -5
- package/dist/server/stdio.js +0 -1
- package/dist/tools/handlers.js +90 -39
- package/dist/tools/schemas.js +26 -1
- package/dist/version.js +12 -0
- package/package.json +4 -3
- package/dist/benchmark/cta.d.ts +0 -11
- package/dist/benchmark/cta.d.ts.map +0 -1
- package/dist/benchmark/cta.js.map +0 -1
- package/dist/benchmark/runner.d.ts +0 -19
- package/dist/benchmark/runner.d.ts.map +0 -1
- package/dist/benchmark/runner.js.map +0 -1
- package/dist/benchmark/score.d.ts +0 -26
- package/dist/benchmark/score.d.ts.map +0 -1
- package/dist/benchmark/score.js.map +0 -1
- package/dist/core/report-generator.d.ts +0 -47
- package/dist/core/report-generator.d.ts.map +0 -1
- package/dist/core/report-generator.js.map +0 -1
- package/dist/core/state-engine.d.ts +0 -2
- package/dist/core/state-engine.d.ts.map +0 -1
- package/dist/core/state-engine.js.map +0 -1
- package/dist/engine/state-diff.d.ts +0 -33
- package/dist/engine/state-diff.d.ts.map +0 -1
- package/dist/engine/state-diff.js.map +0 -1
- package/dist/engine/state-machine.d.ts +0 -43
- package/dist/engine/state-machine.d.ts.map +0 -1
- package/dist/engine/state-machine.js.map +0 -1
- package/dist/hardening/patch-generator.d.ts +0 -12
- package/dist/hardening/patch-generator.d.ts.map +0 -1
- package/dist/hardening/patch-generator.js.map +0 -1
- package/dist/index.d.ts +0 -3
- package/dist/index.d.ts.map +0 -1
- package/dist/index.js.map +0 -1
- package/dist/proxy/gateway.d.ts +0 -4
- package/dist/proxy/gateway.d.ts.map +0 -1
- package/dist/proxy/gateway.js.map +0 -1
- package/dist/proxy/ghost-write.d.ts +0 -17
- package/dist/proxy/ghost-write.d.ts.map +0 -1
- package/dist/proxy/ghost-write.js.map +0 -1
- package/dist/proxy/interceptor.d.ts +0 -34
- package/dist/proxy/interceptor.d.ts.map +0 -1
- package/dist/proxy/interceptor.js.map +0 -1
- package/dist/scenarios/f1-f5.d.ts +0 -9
- package/dist/scenarios/f1-f5.d.ts.map +0 -1
- package/dist/scenarios/f1-f5.js.map +0 -1
- package/dist/scenarios/types.d.ts +0 -15
- package/dist/scenarios/types.d.ts.map +0 -1
- package/dist/scenarios/types.js.map +0 -1
- package/dist/server/server.d.ts +0 -3
- package/dist/server/server.d.ts.map +0 -1
- package/dist/server/server.js.map +0 -1
- package/dist/server/stdio.d.ts +0 -7
- package/dist/server/stdio.d.ts.map +0 -1
- package/dist/server/stdio.js.map +0 -1
- package/dist/tools/handlers.d.ts +0 -92
- package/dist/tools/handlers.d.ts.map +0 -1
- package/dist/tools/handlers.js.map +0 -1
- package/dist/tools/schemas.d.ts +0 -37
- package/dist/tools/schemas.d.ts.map +0 -1
- package/dist/tools/schemas.js.map +0 -1
|
@@ -1,115 +1,378 @@
|
|
|
1
|
-
import {
|
|
1
|
+
import { extractKey, stripKeyFields, } from "../engine/state-machine.js";
|
|
2
|
+
const HANDLING_POINTS = {
|
|
3
|
+
CLEAN: 100,
|
|
4
|
+
VERIFIED_NO_RETRY: 100,
|
|
5
|
+
RECOVERED_BY_RETRY: 100,
|
|
6
|
+
ABANDONED_AFTER_FAILURE: 100,
|
|
7
|
+
UPSTREAM_REJECTED: 100,
|
|
8
|
+
EXECUTION_UNKNOWN: 0, // never used: such operations are not scored
|
|
9
|
+
UNVERIFIED_AFTER_TIMEOUT: 50,
|
|
10
|
+
RETRY_AFTER_VIOLATED: 0,
|
|
11
|
+
DUPLICATE_EXECUTION: 0,
|
|
12
|
+
};
|
|
13
|
+
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
|
-
|
|
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
|
-
|
|
16
|
-
|
|
17
|
-
|
|
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
|
-
//
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
blindRetries++;
|
|
36
|
-
break;
|
|
37
|
-
case "REDUNDANT_CALL":
|
|
38
|
-
redundantCalls++;
|
|
39
|
-
break;
|
|
40
|
-
case "GHOST_WRITE":
|
|
41
|
-
ghostWrites++;
|
|
42
|
-
break;
|
|
43
|
-
case "GHOST_CAUGHT":
|
|
44
|
-
ghostWriteDetections++;
|
|
45
|
-
break;
|
|
46
|
-
case "GHOST_MISSED":
|
|
47
|
-
ghostWriteMisses++;
|
|
48
|
-
break;
|
|
49
|
-
}
|
|
50
|
-
}
|
|
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
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
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:
|
|
61
|
-
type: "
|
|
62
|
-
|
|
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 (
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
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
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
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
|
-
|
|
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
|
|
97
|
-
// the
|
|
98
|
-
//
|
|
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
|
|
104
|
-
//
|
|
105
|
-
//
|
|
106
|
-
|
|
107
|
-
|
|
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
|
-
|
|
151
|
-
|
|
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
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
const
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
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
|
-
|
|
199
|
-
|
|
200
|
-
|
|
201
|
-
|
|
202
|
-
|
|
203
|
-
|
|
204
|
-
|
|
205
|
-
|
|
206
|
-
|
|
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
|
-
|
|
267
|
-
|
|
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 += `|
|
|
276
|
-
md += `|
|
|
277
|
-
md += `|
|
|
278
|
-
md += `|
|
|
279
|
-
md += `|
|
|
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 +=
|
|
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 += `|
|
|
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 += `|
|
|
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
|