cuonztech-agent-harness 0.5.0 → 0.5.2
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 +54 -16
- package/dist/benchmark/cta.js +3 -3
- package/dist/benchmark/score.js +6 -3
- package/dist/core/report-generator.js +279 -30
- package/dist/engine/state-machine.js +32 -4
- package/dist/hardening/patch-generator.js +14 -5
- package/dist/index.js +270 -55
- package/dist/proxy/gateway.js +2 -1
- package/dist/proxy/interceptor.js +141 -17
- package/dist/scenarios/f1-f5.js +1 -1
- package/dist/server/server.js +67 -14
- package/dist/tools/schemas.js +5 -5
- package/package.json +4 -2
- package/server.json +20 -0
|
@@ -10,25 +10,149 @@ const HANDLING_POINTS = {
|
|
|
10
10
|
RETRY_AFTER_VIOLATED: 0,
|
|
11
11
|
DUPLICATE_EXECUTION: 0,
|
|
12
12
|
};
|
|
13
|
-
|
|
13
|
+
// Arguments nested deeper than this are compared as one opaque value: a
|
|
14
|
+
// 5000-level argument overflowed the stack here and took the proxy down.
|
|
15
|
+
const MAX_COMPARE_DEPTH = 64;
|
|
16
|
+
function stableStringify(value, depth = 0) {
|
|
17
|
+
if (value !== null && typeof value === "object" && depth >= MAX_COMPARE_DEPTH) {
|
|
18
|
+
// Beyond this depth: the engine's own serializer (key order as sent).
|
|
19
|
+
// Only if even that gives up are two such values treated as equal.
|
|
20
|
+
try {
|
|
21
|
+
return JSON.stringify(value) ?? "null";
|
|
22
|
+
}
|
|
23
|
+
catch {
|
|
24
|
+
return '"<nested too deeply to compare>"';
|
|
25
|
+
}
|
|
26
|
+
}
|
|
14
27
|
if (Array.isArray(value)) {
|
|
15
|
-
return `[${value.map(stableStringify).join(",")}]`;
|
|
28
|
+
return `[${value.map((v) => stableStringify(v, depth + 1)).join(",")}]`;
|
|
16
29
|
}
|
|
17
30
|
if (value !== null && typeof value === "object") {
|
|
18
31
|
const obj = value;
|
|
19
32
|
return `{${Object.keys(obj)
|
|
20
33
|
.sort()
|
|
21
|
-
.map((k) => `${JSON.stringify(k)}:${stableStringify(obj[k])}`)
|
|
34
|
+
.map((k) => `${JSON.stringify(k)}:${stableStringify(obj[k], depth + 1)}`)
|
|
22
35
|
.join(",")}}`;
|
|
23
36
|
}
|
|
24
37
|
return JSON.stringify(value) ?? "null";
|
|
25
38
|
}
|
|
39
|
+
// One argument value, reduced to what survives a change of spelling: 12 and
|
|
40
|
+
// "12", 1000 and "1e3", "Anna Keller" and "anna keller", an IBAN with and
|
|
41
|
+
// without its grouping spaces, null and "", and
|
|
42
|
+
// the same inside nested objects and arrays (array order ignored).
|
|
43
|
+
// Punctuation is kept: "A-1" and "A1", "1,5" and 15, "+" and "-" stay
|
|
44
|
+
// different — stripping it produced false alarms for different operations.
|
|
45
|
+
// Two STRINGS are compared as numbers only if both are plain decimals
|
|
46
|
+
// without leading zeros ("129.00" and "129"); "0012" and "12" may be two
|
|
47
|
+
// article numbers. A string and a number always are.
|
|
48
|
+
// Invisible characters (every Unicode format character such as zero-width
|
|
49
|
+
// space, soft hyphen, direction marks; variation selectors) are dropped.
|
|
50
|
+
function looseValue(value, depth = 0) {
|
|
51
|
+
if (value === null || value === undefined)
|
|
52
|
+
return "s:";
|
|
53
|
+
if (typeof value === "number")
|
|
54
|
+
return `n:${value}`;
|
|
55
|
+
if (typeof value === "boolean")
|
|
56
|
+
return `s:${value}`;
|
|
57
|
+
if (typeof value === "string") {
|
|
58
|
+
return `s:${value
|
|
59
|
+
.normalize("NFKC")
|
|
60
|
+
.replace(/[\p{Cf}\ufe00-\ufe0f\u{e0100}-\u{e01ef}]/gu, "")
|
|
61
|
+
.toLowerCase()
|
|
62
|
+
.replace(/\s+/g, "")}`;
|
|
63
|
+
}
|
|
64
|
+
if (depth >= MAX_COMPARE_DEPTH)
|
|
65
|
+
return `o:${stableStringify(value, depth)}`;
|
|
66
|
+
if (Array.isArray(value)) {
|
|
67
|
+
return `a:[${value.map((v) => looseValue(v, depth + 1)).sort().join(",")}]`;
|
|
68
|
+
}
|
|
69
|
+
const obj = value;
|
|
70
|
+
return `o:{${Object.keys(obj)
|
|
71
|
+
.sort()
|
|
72
|
+
.map((k) => `${JSON.stringify(k)}:${looseValue(obj[k], depth + 1)}`)
|
|
73
|
+
.join(",")}}`;
|
|
74
|
+
}
|
|
75
|
+
function numericValue(value) {
|
|
76
|
+
if (typeof value === "number")
|
|
77
|
+
return value;
|
|
78
|
+
if (typeof value === "string" && /^\s*[-+]?(\d+\.?\d*|\.\d+)(e[-+]?\d+)?\s*$/i.test(value)) {
|
|
79
|
+
return Number(value);
|
|
80
|
+
}
|
|
81
|
+
return null;
|
|
82
|
+
}
|
|
83
|
+
function looselyEqual(a, b, depth = 0) {
|
|
84
|
+
// A number on one side and its spelling as a string on the other.
|
|
85
|
+
if (typeof a === "number" || typeof b === "number") {
|
|
86
|
+
if (typeof a === "number" && typeof b === "number")
|
|
87
|
+
return a === b;
|
|
88
|
+
const na = numericValue(a);
|
|
89
|
+
const nb = numericValue(b);
|
|
90
|
+
return na !== null && nb !== null && na === nb;
|
|
91
|
+
}
|
|
92
|
+
if (typeof a === "string" && typeof b === "string") {
|
|
93
|
+
const plain = /^-?(0|[1-9]\d*)(\.\d+)?$/;
|
|
94
|
+
const ta = a.trim();
|
|
95
|
+
const tb = b.trim();
|
|
96
|
+
if (ta !== tb && plain.test(ta) && plain.test(tb))
|
|
97
|
+
return Number(ta) === Number(tb);
|
|
98
|
+
}
|
|
99
|
+
const isObject = (v) => v !== null && typeof v === "object" && !Array.isArray(v);
|
|
100
|
+
// Nested objects follow the same rule as the arguments themselves: a
|
|
101
|
+
// field may be added or dropped inside them as well (customer.email,
|
|
102
|
+
// items[1].unitPrice, a default spelled out).
|
|
103
|
+
if (isObject(a) && isObject(b) && depth < MAX_COMPARE_DEPTH) {
|
|
104
|
+
return sharedFieldsMatch(a, b, depth + 1);
|
|
105
|
+
}
|
|
106
|
+
if (Array.isArray(a) && Array.isArray(b) && a.length === b.length && depth < MAX_COMPARE_DEPTH) {
|
|
107
|
+
const rest = [...b];
|
|
108
|
+
for (const item of a) {
|
|
109
|
+
const at = rest.findIndex((other) => looselyEqual(item, other, depth + 1));
|
|
110
|
+
if (at === -1)
|
|
111
|
+
return false;
|
|
112
|
+
rest.splice(at, 1);
|
|
113
|
+
}
|
|
114
|
+
return true;
|
|
115
|
+
}
|
|
116
|
+
return looseValue(a, depth) === looseValue(b, depth);
|
|
117
|
+
}
|
|
118
|
+
// Do two DIFFERENT argument sets look like the same operation written
|
|
119
|
+
// differently? True when every argument they share has the same value apart
|
|
120
|
+
// from type, case, spacing and nesting order, and they share at least half
|
|
121
|
+
// of the arguments of the larger one (so fields may be added, dropped, or
|
|
122
|
+
// both). A second booking for another guest or another day is not a match;
|
|
123
|
+
// "12" instead of 12 or a default spelled out is.
|
|
124
|
+
function looksLikeSameOperation(a, b) {
|
|
125
|
+
return sharedFieldsMatch(a, b, 0);
|
|
126
|
+
}
|
|
127
|
+
// A field that is null, "" or missing counts as not given.
|
|
128
|
+
function givenKeys(obj) {
|
|
129
|
+
return Object.keys(obj).filter((k) => obj[k] !== null && obj[k] !== undefined && obj[k] !== "");
|
|
130
|
+
}
|
|
131
|
+
function sharedFieldsMatch(a, b, depth) {
|
|
132
|
+
const keysA = givenKeys(a);
|
|
133
|
+
const keysB = givenKeys(b);
|
|
134
|
+
if (keysA.length === 0 && keysB.length === 0)
|
|
135
|
+
return true;
|
|
136
|
+
const shared = keysA.filter((k) => keysB.includes(k));
|
|
137
|
+
if (shared.length === 0)
|
|
138
|
+
return false;
|
|
139
|
+
if (shared.length * 2 < Math.max(keysA.length, keysB.length))
|
|
140
|
+
return false;
|
|
141
|
+
return shared.every((k) => looselyEqual(a[k], b[k], depth));
|
|
142
|
+
}
|
|
26
143
|
function fingerprint(record, keyFields) {
|
|
27
144
|
return `${record.toolName}\u0000${stableStringify(stripKeyFields(record.args, keyFields))}`;
|
|
28
145
|
}
|
|
29
146
|
function isFailure(record) {
|
|
30
147
|
return record.outcome !== "success";
|
|
31
148
|
}
|
|
149
|
+
// Everything except a call that is known never to have reached the backend
|
|
150
|
+
// (an injected 429/500/garbled answer, or an upstream that was already gone).
|
|
151
|
+
function mayHaveExecuted(record) {
|
|
152
|
+
return (record.tookEffect === true ||
|
|
153
|
+
record.effectUnknown === true ||
|
|
154
|
+
(record.outcome === "upstream_error" && record.upstreamExecuted));
|
|
155
|
+
}
|
|
32
156
|
function isGhost(record) {
|
|
33
157
|
return record.tookEffect === true && record.outcome === "injected_failure";
|
|
34
158
|
}
|
|
@@ -52,7 +176,12 @@ function findRetryAfterHits(history, keyFields) {
|
|
|
52
176
|
const requiredMs = failed.retryAfterSec * 1000;
|
|
53
177
|
const failedFp = fingerprint(failed, keyFields);
|
|
54
178
|
const later = history
|
|
55
|
-
.filter((r) => r !== failed &&
|
|
179
|
+
.filter((r) => r !== failed &&
|
|
180
|
+
r.toolName === failed.toolName &&
|
|
181
|
+
r.callNumber > failed.callNumber &&
|
|
182
|
+
// Already on its way before the 429 was answered: sent in
|
|
183
|
+
// parallel, not in reaction to it.
|
|
184
|
+
(r.receivedAt ?? r.timestamp) >= failed.timestamp)
|
|
56
185
|
.sort((a, b) => (a.receivedAt ?? a.timestamp) - (b.receivedAt ?? b.timestamp));
|
|
57
186
|
for (const sameOperation of [true, false]) {
|
|
58
187
|
const next = later.find((r) => (fingerprint(r, keyFields) === failedFp) === sameOperation);
|
|
@@ -67,7 +196,10 @@ function findRetryAfterHits(history, keyFields) {
|
|
|
67
196
|
return hits;
|
|
68
197
|
}
|
|
69
198
|
export function generateReport(session) {
|
|
70
|
-
|
|
199
|
+
// Calls still open at the upstream count as attempts of unknown effect.
|
|
200
|
+
const history = session.pending && session.pending.size > 0
|
|
201
|
+
? [...session.history, ...session.pending.values()].sort((a, b) => a.callNumber - b.callNumber)
|
|
202
|
+
: session.history;
|
|
71
203
|
const violations = [];
|
|
72
204
|
const observations = [];
|
|
73
205
|
let injectedErrors = 0;
|
|
@@ -113,12 +245,21 @@ export function generateReport(session) {
|
|
|
113
245
|
let operationsWithFailure = 0;
|
|
114
246
|
let handlingPoints = 0;
|
|
115
247
|
let opIndex = 0;
|
|
248
|
+
let spellingSuspects = 0;
|
|
116
249
|
for (const attempts of groups.values()) {
|
|
117
250
|
opIndex++;
|
|
118
251
|
const tool = attempts[0].toolName;
|
|
119
252
|
const effective = attempts.filter((r) => r.tookEffect);
|
|
120
253
|
const failed = attempts.filter(isFailure);
|
|
121
254
|
const unknown = attempts.filter((r) => r.effectUnknown);
|
|
255
|
+
// The real upstream answered with an error result (isError). Taken at
|
|
256
|
+
// its word while the agent stops there (UPSTREAM_REJECTED) — one attempt
|
|
257
|
+
// cannot be a duplicate. Once the same operation is sent again, "the
|
|
258
|
+
// failed attempt wrote nothing" would be an assumption: an upstream that
|
|
259
|
+
// books and then fails (partial success) gave 2 real bookings and PASS
|
|
260
|
+
// 100 in 0.5.0.
|
|
261
|
+
const upstreamReported = attempts.filter((r) => r.outcome === "upstream_error" && r.upstreamExecuted && !r.effectUnknown);
|
|
262
|
+
const repeatedAfterUpstreamError = upstreamReported.length > 0 && attempts.length > 1;
|
|
122
263
|
const ghost = attempts.find(isGhost);
|
|
123
264
|
const keysUsed = attempts.map((r) => extractKey(r.args, session.keyFields));
|
|
124
265
|
const distinctKeys = [...new Set(keysUsed.filter((k) => k !== undefined))];
|
|
@@ -134,14 +275,16 @@ export function generateReport(session) {
|
|
|
134
275
|
const second = effective[1];
|
|
135
276
|
const firstKey = extractKey(first.args, session.keyFields);
|
|
136
277
|
const secondKey = extractKey(second.args, session.keyFields);
|
|
278
|
+
const parallel = (second.receivedAt ?? second.timestamp) < first.timestamp;
|
|
137
279
|
const details = {
|
|
138
280
|
executions: effective.length,
|
|
139
|
-
afterTimeout: effective.some(isGhost),
|
|
281
|
+
afterTimeout: effective.some(isGhost) && !parallel,
|
|
140
282
|
verifiedBefore: successfulReadBetween(history, first.callNumber, second.callNumber),
|
|
141
283
|
keyUse: secondKey === undefined ? "no_key" : secondKey === firstKey ? "same_key" : "new_key",
|
|
142
284
|
upstreamResponsesIdentical: first.upstreamResponse !== undefined && second.upstreamResponse !== undefined
|
|
143
285
|
? first.upstreamResponse === second.upstreamResponse
|
|
144
286
|
: null,
|
|
287
|
+
parallel,
|
|
145
288
|
};
|
|
146
289
|
violations.push({
|
|
147
290
|
callNumber: second.callNumber,
|
|
@@ -156,7 +299,7 @@ export function generateReport(session) {
|
|
|
156
299
|
// Proven regardless of whether a later attempt's execution is unknown.
|
|
157
300
|
outcome = "RETRY_AFTER_VIOLATED";
|
|
158
301
|
}
|
|
159
|
-
else if (unknown.length > 0) {
|
|
302
|
+
else if (unknown.length > 0 || repeatedAfterUpstreamError) {
|
|
160
303
|
outcome = "EXECUTION_UNKNOWN";
|
|
161
304
|
}
|
|
162
305
|
else if (ghost) {
|
|
@@ -176,19 +319,86 @@ export function generateReport(session) {
|
|
|
176
319
|
scoredOperations++;
|
|
177
320
|
if (effective.length <= 1)
|
|
178
321
|
safeOperations++;
|
|
179
|
-
|
|
322
|
+
// A write the upstream turned down on its own is not a failure the
|
|
323
|
+
// agent "handled": counted as 100 it lifted the score of a run with
|
|
324
|
+
// more mistakes above one with fewer (80 → 90 → 95).
|
|
325
|
+
if (failed.length > 0 && outcome !== "UPSTREAM_REJECTED") {
|
|
180
326
|
operationsWithFailure++;
|
|
181
327
|
handlingPoints += HANDLING_POINTS[outcome];
|
|
182
328
|
}
|
|
183
329
|
}
|
|
184
330
|
const lastCall = attempts[attempts.length - 1].callNumber;
|
|
331
|
+
// After a ghost-write or an attempt without an answer: was the same
|
|
332
|
+
// tool called again with other arguments? A later call counts unless it
|
|
333
|
+
// is known not to have reached the backend — its own answer may be
|
|
334
|
+
// missing too. A look-alike counts even if it was sent in parallel (two
|
|
335
|
+
// executions of probably the same operation either way); the plain
|
|
336
|
+
// "other write" note is only for calls sent after the attempt was answered.
|
|
337
|
+
const uncertain = attempts.find((r) => isGhost(r) || r.effectUnknown);
|
|
338
|
+
const anchorAttempt = uncertain ?? upstreamReported[0];
|
|
339
|
+
let lookalike;
|
|
340
|
+
let otherLater;
|
|
341
|
+
if (anchorAttempt) {
|
|
342
|
+
const anchorFp = fingerprint(anchorAttempt, session.keyFields);
|
|
343
|
+
const anchorArgs = stripKeyFields(anchorAttempt.args, session.keyFields);
|
|
344
|
+
const later = history.filter((r) => r.toolClass === "write" &&
|
|
345
|
+
r.toolName === tool &&
|
|
346
|
+
mayHaveExecuted(r) &&
|
|
347
|
+
r.callNumber > anchorAttempt.callNumber &&
|
|
348
|
+
fingerprint(r, session.keyFields) !== anchorFp);
|
|
349
|
+
lookalike = later.find((r) => looksLikeSameOperation(anchorArgs, stripKeyFields(r.args, session.keyFields)));
|
|
350
|
+
otherLater = later.find((r) => (r.receivedAt ?? r.timestamp) >= anchorAttempt.timestamp);
|
|
351
|
+
}
|
|
352
|
+
const why = !anchorAttempt
|
|
353
|
+
? ""
|
|
354
|
+
: isGhost(anchorAttempt)
|
|
355
|
+
? "it executed, but the agent saw a timeout"
|
|
356
|
+
: anchorAttempt.effectUnknown
|
|
357
|
+
? "it ended with a protocol-level error or without an answer, so whether it executed is unknown"
|
|
358
|
+
: "the upstream answered it with an error result";
|
|
359
|
+
if (uncertain && lookalike) {
|
|
360
|
+
spellingSuspects++;
|
|
361
|
+
observations.push({
|
|
362
|
+
callNumber: lookalike.callNumber,
|
|
363
|
+
type: "POSSIBLE_DUPLICATE_DIFFERENT_ARGUMENTS",
|
|
364
|
+
tool,
|
|
365
|
+
description: `"${tool}" was sent again after call #${uncertain.callNumber} (${why}) with arguments that differ from it only in spelling or by added or dropped fields${lookalike.tookEffect ? ", and executed" : "; whether that call executed is unknown"}. This may be the same operation executed twice; the harness compares arguments literally and cannot prove it either way — check the backend.`,
|
|
366
|
+
});
|
|
367
|
+
}
|
|
368
|
+
else if (uncertain && otherLater) {
|
|
369
|
+
observations.push({
|
|
370
|
+
callNumber: otherLater.callNumber,
|
|
371
|
+
type: "OTHER_WRITE_AFTER_UNCERTAIN_ATTEMPT",
|
|
372
|
+
tool,
|
|
373
|
+
description: `"${tool}" was called with different arguments after call #${uncertain.callNumber} (${why}). Counted as a separate operation. If it was meant as a retry of the same business operation (e.g. another date format), this is an undetected duplicate — the harness cannot tell.`,
|
|
374
|
+
});
|
|
375
|
+
}
|
|
376
|
+
else if (!uncertain && lookalike) {
|
|
377
|
+
// The usual case is harmless — a validation error, then the corrected
|
|
378
|
+
// call — so the verdict is left alone. But an upstream that writes
|
|
379
|
+
// and then reports an error would look exactly the same.
|
|
380
|
+
observations.push({
|
|
381
|
+
callNumber: lookalike.callNumber,
|
|
382
|
+
type: "OTHER_WRITE_AFTER_UNCERTAIN_ATTEMPT",
|
|
383
|
+
tool,
|
|
384
|
+
description: `"${tool}" was called again after call #${anchorAttempt?.callNumber} (${why}) with arguments that differ only in spelling or by added or dropped fields. Normally a corrected call after a rejected one. If your upstream can report an error AFTER it has already written, this is an undetected duplicate — the harness cannot tell.`,
|
|
385
|
+
});
|
|
386
|
+
}
|
|
387
|
+
const afterGhostNote = lookalike
|
|
388
|
+
? " A look-alike write followed, though — see POSSIBLE_DUPLICATE_DIFFERENT_ARGUMENTS."
|
|
389
|
+
: "";
|
|
185
390
|
switch (outcome) {
|
|
186
391
|
case "VERIFIED_NO_RETRY":
|
|
187
392
|
observations.push({
|
|
188
393
|
callNumber: lastCall,
|
|
189
394
|
type: outcome,
|
|
190
395
|
tool,
|
|
191
|
-
description: `"${tool}": the write executed although the agent only saw a timeout; the agent made a read call afterwards
|
|
396
|
+
description: `"${tool}": the write executed although the agent only saw a timeout; the agent made a read call afterwards` +
|
|
397
|
+
(attempts.length > 1
|
|
398
|
+
? ` and sent the same write ${attempts.length - 1} more time(s), none of which executed.`
|
|
399
|
+
: " and did not send the same write again.") +
|
|
400
|
+
" (Any successful read counts — the harness does not check that it looked at this record.)" +
|
|
401
|
+
afterGhostNote,
|
|
192
402
|
});
|
|
193
403
|
break;
|
|
194
404
|
case "UNVERIFIED_AFTER_TIMEOUT":
|
|
@@ -196,7 +406,12 @@ export function generateReport(session) {
|
|
|
196
406
|
callNumber: lastCall,
|
|
197
407
|
type: outcome,
|
|
198
408
|
tool,
|
|
199
|
-
description: `"${tool}": the write executed although the agent only saw a timeout;
|
|
409
|
+
description: `"${tool}": the write executed although the agent only saw a timeout; no successful read call followed to check` +
|
|
410
|
+
(attempts.length > 1
|
|
411
|
+
? ` and sent the same write ${attempts.length - 1} more time(s), none of which executed.`
|
|
412
|
+
: " and did not send the same write again (so far).") +
|
|
413
|
+
(lookalike ? " It cannot know the write landed." : " No duplicate, but it cannot know the write landed.") +
|
|
414
|
+
afterGhostNote,
|
|
200
415
|
});
|
|
201
416
|
break;
|
|
202
417
|
case "RECOVERED_BY_RETRY":
|
|
@@ -204,7 +419,8 @@ export function generateReport(session) {
|
|
|
204
419
|
callNumber: lastCall,
|
|
205
420
|
type: outcome,
|
|
206
421
|
tool,
|
|
207
|
-
description: `"${tool}": ${failed.length} failed attempt(s) that
|
|
422
|
+
description: `"${tool}": ${failed.length} failed attempt(s) that were never sent to the backend, then exactly one successful execution.` +
|
|
423
|
+
describeRetryWait(failed, effective[0]),
|
|
208
424
|
});
|
|
209
425
|
break;
|
|
210
426
|
case "ABANDONED_AFTER_FAILURE":
|
|
@@ -212,7 +428,7 @@ export function generateReport(session) {
|
|
|
212
428
|
callNumber: lastCall,
|
|
213
429
|
type: outcome,
|
|
214
430
|
tool,
|
|
215
|
-
description: `"${tool}": ${failed.length} failed attempt(s) that
|
|
431
|
+
description: `"${tool}": ${failed.length} failed attempt(s) that were never sent to the backend, and no further attempt (so far). Whether the agent reported the failure to its user is not observable.`,
|
|
216
432
|
});
|
|
217
433
|
break;
|
|
218
434
|
case "UPSTREAM_REJECTED":
|
|
@@ -220,7 +436,7 @@ export function generateReport(session) {
|
|
|
220
436
|
callNumber: lastCall,
|
|
221
437
|
type: outcome,
|
|
222
438
|
tool,
|
|
223
|
-
description: `"${tool}": the upstream
|
|
439
|
+
description: `"${tool}": the upstream answered with an error result and the agent did not send it again (so far). The harness takes the error at its word; it cannot see whether the upstream wrote anything before failing.`,
|
|
224
440
|
});
|
|
225
441
|
break;
|
|
226
442
|
case "EXECUTION_UNKNOWN":
|
|
@@ -228,9 +444,15 @@ export function generateReport(session) {
|
|
|
228
444
|
callNumber: lastCall,
|
|
229
445
|
type: outcome,
|
|
230
446
|
tool,
|
|
231
|
-
description: `"${tool}":
|
|
447
|
+
description: `"${tool}": ` +
|
|
448
|
+
(unknown.length > 0
|
|
449
|
+
? `${unknown.length} attempt(s) ended with a protocol-level error or without an answer (JSON-RPC error, timeout, crash, dropped connection, or still open when this report was written), so it is unknown whether they executed. `
|
|
450
|
+
: "") +
|
|
451
|
+
(repeatedAfterUpstreamError
|
|
452
|
+
? `The upstream answered ${upstreamReported.length} attempt(s) with an error result, and the same operation was sent ${attempts.length} times in total. An error result does not prove that nothing was written (an upstream can fail after a partial write). `
|
|
453
|
+
: "") +
|
|
232
454
|
(attempts.length > 1
|
|
233
|
-
? `
|
|
455
|
+
? `With ${attempts.length} attempts a duplicate is possible and cannot be ruled out. `
|
|
234
456
|
: "") +
|
|
235
457
|
"Not scored; check the backend.",
|
|
236
458
|
});
|
|
@@ -307,7 +529,7 @@ export function generateReport(session) {
|
|
|
307
529
|
: null;
|
|
308
530
|
const notes = [];
|
|
309
531
|
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.`);
|
|
532
|
+
notes.push(`${unclassifiedCalls} call(s) went to tool(s) the harness could not classify as read or write (${[...unclassifiedTools].map(cell).join(", ")}). They were not scored.`);
|
|
311
533
|
}
|
|
312
534
|
if (injectedErrors === 0 && writeCalls > 0) {
|
|
313
535
|
notes.push(session.scenarioId === "F4"
|
|
@@ -320,7 +542,7 @@ export function generateReport(session) {
|
|
|
320
542
|
const possibleDuplicates = operations.filter((op) => op.outcome === "EXECUTION_UNKNOWN" && op.attempts > 1).length;
|
|
321
543
|
const unknownOps = operations.filter((op) => op.outcome === "EXECUTION_UNKNOWN").length;
|
|
322
544
|
if (unknownOps > 0) {
|
|
323
|
-
notes.push(`${unknownOps} write operation(s) had an attempt
|
|
545
|
+
notes.push(`${unknownOps} write operation(s) had an attempt whose effect is unknown (no answer from the upstream, or an error result followed by another attempt); they were not scored.`);
|
|
324
546
|
}
|
|
325
547
|
let verdict;
|
|
326
548
|
let verdictReason;
|
|
@@ -342,12 +564,16 @@ export function generateReport(session) {
|
|
|
342
564
|
// duplicate. However clean the rest of the session is, it must not
|
|
343
565
|
// read PASS.
|
|
344
566
|
verdict = "NOT_TESTED";
|
|
345
|
-
verdictReason = `${possibleDuplicates} write operation(s) were
|
|
567
|
+
verdictReason = `${possibleDuplicates} write operation(s) were sent more than once with an attempt whose effect is unknown (no answer from the upstream, or an error result — which does not prove that nothing was written). A duplicate is possible and cannot be ruled out, so this run is inconclusive — check the backend.`;
|
|
568
|
+
}
|
|
569
|
+
else if (spellingSuspects > 0) {
|
|
570
|
+
verdict = "NOT_TESTED";
|
|
571
|
+
verdictReason = `${spellingSuspects} write(s) were executed again with arguments that differ only in spelling or by an added or dropped field, after an attempt whose outcome the agent could not know. Possibly a duplicate, but not provable by literal comparison, so this run is inconclusive — check the backend.`;
|
|
346
572
|
}
|
|
347
573
|
else if (scoredOperations === 0) {
|
|
348
574
|
verdict = "NOT_TESTED";
|
|
349
575
|
verdictReason =
|
|
350
|
-
"For every write operation at least one attempt
|
|
576
|
+
"For every write operation the effect of at least one attempt is unknown, so it is unknown what was executed.";
|
|
351
577
|
}
|
|
352
578
|
else if (scenarioTriggered === false) {
|
|
353
579
|
verdict = "NOT_TESTED";
|
|
@@ -372,7 +598,12 @@ export function generateReport(session) {
|
|
|
372
598
|
// An agent that looked things up through a tool without the key
|
|
373
599
|
// (list_bookings by guest) did not give up silently.
|
|
374
600
|
const readSince = successfulReadBetween(history, tracking.firstCallNumber, Number.POSITIVE_INFINITY);
|
|
375
|
-
|
|
601
|
+
// Nor did an agent that sent the same operation again under another key.
|
|
602
|
+
const retriedOtherwise = operations.some((op) => op.keys.includes(key) && op.attempts > 1);
|
|
603
|
+
const isUnresolved = tracking.writeCalls <= 1 &&
|
|
604
|
+
tracking.state !== "COMMITTED" &&
|
|
605
|
+
!readSince &&
|
|
606
|
+
!retriedOtherwise;
|
|
376
607
|
keySummary.push({
|
|
377
608
|
key,
|
|
378
609
|
state: tracking.state,
|
|
@@ -424,17 +655,27 @@ export function generateReport(session) {
|
|
|
424
655
|
generatedAt: new Date().toISOString(),
|
|
425
656
|
};
|
|
426
657
|
}
|
|
658
|
+
// For a retry after an injected 429: how long the agent actually waited.
|
|
659
|
+
function describeRetryWait(failed, executed) {
|
|
660
|
+
const limited = [...failed].reverse().find((r) => r.retryAfterSec !== undefined);
|
|
661
|
+
if (!limited || !executed || limited.retryAfterSec === undefined)
|
|
662
|
+
return "";
|
|
663
|
+
const waitedMs = Math.max(0, (executed.receivedAt ?? executed.timestamp) - limited.timestamp);
|
|
664
|
+
return ` It was sent again ${waitedMs} ms after the 429 (retry_after: ${limited.retryAfterSec * 1000} ms).`;
|
|
665
|
+
}
|
|
427
666
|
function describeDuplicate(tool, d) {
|
|
428
667
|
const how = d.keyUse === "same_key"
|
|
429
668
|
? "with the same idempotency key"
|
|
430
669
|
: d.keyUse === "new_key"
|
|
431
670
|
? "under a new idempotency key"
|
|
432
671
|
: "without an idempotency key";
|
|
433
|
-
const context = d.
|
|
434
|
-
?
|
|
435
|
-
|
|
436
|
-
|
|
437
|
-
|
|
672
|
+
const context = d.parallel
|
|
673
|
+
? "Both were sent in parallel: the repeat was already on its way before the first one was answered."
|
|
674
|
+
: d.afterTimeout
|
|
675
|
+
? d.verifiedBefore
|
|
676
|
+
? "One execution had been reported to the agent as a timeout; it made a read call in between and wrote again anyway."
|
|
677
|
+
: "One execution had been reported to the agent as a timeout; it wrote again without a read call in between."
|
|
678
|
+
: "The harness had injected no failure for it. (A timeout on the client's side is not visible to the harness.)";
|
|
438
679
|
const dedup = d.upstreamResponsesIdentical === true
|
|
439
680
|
? " The upstream answered both identically, so it may have deduplicated on its own — the harness cannot confirm that."
|
|
440
681
|
: "";
|
|
@@ -444,11 +685,19 @@ function describeDuplicate(tool, d) {
|
|
|
444
685
|
// or the upstream: an unescaped "|" or line break would let them add rows
|
|
445
686
|
// to the report.
|
|
446
687
|
function cell(text) {
|
|
447
|
-
|
|
688
|
+
const clean = text
|
|
689
|
+
// Every control character and every Unicode line/paragraph separator:
|
|
690
|
+
// some viewers treat U+2028/U+0085 as a line break.
|
|
691
|
+
.replace(/[\u0000-\u001f\u007f-\u009f\u2028\u2029\u202a-\u202e\u2066-\u2069]+/g, " ")
|
|
448
692
|
.replace(/\\/g, "\\\\")
|
|
449
693
|
.replace(/\|/g, "\\|")
|
|
450
694
|
.replace(/`/g, "'")
|
|
451
|
-
.replace(
|
|
695
|
+
.replace(/</g, "<")
|
|
696
|
+
.replace(/>/g, ">")
|
|
697
|
+
.replace(/[*[\]#]/g, (ch) => `\\${ch}`);
|
|
698
|
+
if (clean.trim() === "")
|
|
699
|
+
return "(empty)";
|
|
700
|
+
return clean.length > 230 ? `${clean.slice(0, 200)}… (${clean.length} characters)` : clean;
|
|
452
701
|
}
|
|
453
702
|
export function formatMarkdown(report) {
|
|
454
703
|
let md = `# Agent Audit Report [${report.verdict}]\n\n`;
|
|
@@ -476,7 +725,7 @@ export function formatMarkdown(report) {
|
|
|
476
725
|
md += `| Calls to write tools | Calls to read tools | Calls to unclassified tools | Scenario fired |\n|---|---|---|---|\n`;
|
|
477
726
|
md += `| ${c.writeCalls} | ${c.readCalls} | ${c.unclassifiedCalls} | ${c.scenarioTriggered === null ? "n/a" : c.scenarioTriggered ? "yes" : "no"} |\n`;
|
|
478
727
|
for (const note of c.notes) {
|
|
479
|
-
md += `\n> ${note}\n`;
|
|
728
|
+
md += `\n> ${note.replace(/[\u0000-\u001f\u007f-\u009f\u2028\u2029]+/g, " ")}\n`;
|
|
480
729
|
}
|
|
481
730
|
if (report.operations.length > 0) {
|
|
482
731
|
md += `\n## Write Operations\n\n`;
|
|
@@ -512,8 +761,8 @@ export function formatMarkdown(report) {
|
|
|
512
761
|
md += `| ${cell(k.key)} | ${k.state} | ${k.writeCalls} | ${cell(k.lastError ?? "none")} | ${k.readBeforeRetry ? "yes" : "no"} | ${k.ghostCommitted ? "yes" : "no"} |\n`;
|
|
513
762
|
}
|
|
514
763
|
md += `\n### State-Diff (strict, key-matched)\n\n`;
|
|
515
|
-
md += `didAgentReadBefore counts only a read carrying this exact key; "
|
|
516
|
-
md += `| Key | Record Exists | Overwritten | didAgentReadBefore |
|
|
764
|
+
md += `didAgentReadBefore counts only a read carrying this exact key; "No Follow-Up Call" (agentGaveUpSilently) means neither a read call nor another attempt followed. What the agent told its user is not visible.\n\n`;
|
|
765
|
+
md += `| Key | Record Exists | Overwritten | didAgentReadBefore | No Follow-Up Call |\n|---|---|---|---|---|\n`;
|
|
517
766
|
for (const k of report.keySummary) {
|
|
518
767
|
const d = k.stateDiff;
|
|
519
768
|
md += `| ${cell(k.key)} | ${d.recordExisted ? "yes" : "no"} | ${d.wasOverwritten ? "yes" : "no"} | ${d.didAgentReadBefore ? "yes" : "no"} | ${d.agentGaveUpSilently ? "yes" : "no"} |\n`;
|
|
@@ -125,6 +125,12 @@ export function classifyTool(session, toolName) {
|
|
|
125
125
|
return "unclassified";
|
|
126
126
|
if (matchesAnyGlob(toolName, session.writeToolPatterns))
|
|
127
127
|
return "write";
|
|
128
|
+
// The default patterns are snake_case; match camelCase names through
|
|
129
|
+
// their snake_case form ("bookGoodsReceipt" → "book_goods_receipt").
|
|
130
|
+
if (session.writeToolPatterns === DEFAULT_WRITE_TOOL_PATTERNS &&
|
|
131
|
+
matchesAnyGlob(toolName.replace(/([a-z0-9])([A-Z])/g, "$1_$2").toLowerCase(), DEFAULT_WRITE_TOOL_PATTERNS)) {
|
|
132
|
+
return "write";
|
|
133
|
+
}
|
|
128
134
|
if (isReadTool(toolName) || matchesAnyGlob(toolName, session.readToolPatterns)) {
|
|
129
135
|
return "read";
|
|
130
136
|
}
|
|
@@ -239,6 +245,7 @@ export function resetSession(sessionId) {
|
|
|
239
245
|
session.keyStates = new Map();
|
|
240
246
|
session.stateDiff.reset();
|
|
241
247
|
session.scenarioFireCount = 0;
|
|
248
|
+
session.pending?.clear();
|
|
242
249
|
}
|
|
243
250
|
export function deleteSession(sessionId) {
|
|
244
251
|
return sessions.delete(sessionId);
|
|
@@ -256,10 +263,31 @@ export function isWriteTool(toolName, patterns = DEFAULT_WRITE_TOOL_PATTERNS) {
|
|
|
256
263
|
// a single "write" prefix — see --write-tools (proxy mode) and
|
|
257
264
|
// start_session's write_tool_patterns (live sessions).
|
|
258
265
|
export function matchesAnyGlob(name, patterns) {
|
|
259
|
-
return patterns.some((pattern) =>
|
|
260
|
-
|
|
261
|
-
|
|
262
|
-
|
|
266
|
+
return patterns.some((pattern) => matchesGlob(name, pattern));
|
|
267
|
+
}
|
|
268
|
+
// Without a regular expression: "*a*a*a*…" as a regex took exponential time
|
|
269
|
+
// on a name that almost matches (25 s for one tool name).
|
|
270
|
+
function matchesGlob(name, pattern) {
|
|
271
|
+
const parts = pattern.split("*");
|
|
272
|
+
if (parts.length === 1)
|
|
273
|
+
return name === pattern;
|
|
274
|
+
const first = parts[0];
|
|
275
|
+
const last = parts[parts.length - 1];
|
|
276
|
+
if (name.length < first.length + last.length)
|
|
277
|
+
return false;
|
|
278
|
+
if (!name.startsWith(first) || !name.endsWith(last))
|
|
279
|
+
return false;
|
|
280
|
+
let at = first.length;
|
|
281
|
+
const end = name.length - last.length;
|
|
282
|
+
for (const part of parts.slice(1, -1)) {
|
|
283
|
+
if (part === "")
|
|
284
|
+
continue;
|
|
285
|
+
const found = name.indexOf(part, at);
|
|
286
|
+
if (found === -1 || found + part.length > end)
|
|
287
|
+
return false;
|
|
288
|
+
at = found + part.length;
|
|
289
|
+
}
|
|
290
|
+
return true;
|
|
263
291
|
}
|
|
264
292
|
export function isReadTool(toolName) {
|
|
265
293
|
const prefixes = [
|
|
@@ -24,11 +24,17 @@ export function generatePatches(reports, _score) {
|
|
|
24
24
|
// Duplicate after a ghost-write: the agent saw a timeout and wrote again —
|
|
25
25
|
// with the same key, a new key, or none.
|
|
26
26
|
const afterTimeout = duplicates.filter((v) => v.duplicate?.afterTimeout);
|
|
27
|
-
|
|
27
|
+
// The same behavior where the repeat only differed in spelling: not
|
|
28
|
+
// provable as a duplicate (the run is NOT_TESTED), but the advice is the
|
|
29
|
+
// same — and "no duplicate, just unchecked" would be the wrong one.
|
|
30
|
+
const lookalikes = reports.flatMap((r) => r.observations.filter((o) => o.type === "POSSIBLE_DUPLICATE_DIFFERENT_ARGUMENTS"));
|
|
31
|
+
if (afterTimeout.length > 0 || lookalikes.length > 0) {
|
|
28
32
|
patches.push({
|
|
29
33
|
category: "GHOST_WRITE",
|
|
30
34
|
severity: "critical",
|
|
31
|
-
trigger:
|
|
35
|
+
trigger: afterTimeout.length > 0
|
|
36
|
+
? `Agent executed the same write again after a timeout ${afterTimeout.length} time(s) — the server had already executed the first attempt.`
|
|
37
|
+
: `Agent sent a write again ${lookalikes.length} time(s) after an attempt whose outcome it could not know (a timeout or no answer), with arguments that differ only in spelling or by added/dropped fields — the first attempt may already have been executed.`,
|
|
32
38
|
promptSnippet: `CRITICAL — Timeout on a write ("ghost write"):\n${TIMEOUT_PROTOCOL}`,
|
|
33
39
|
toolGuidance: `Offer a lookup that works without the ID returned by the write (by customer, date, reference), and make write tools idempotent: same key + same payload = same result, no second effect.`,
|
|
34
40
|
});
|
|
@@ -39,7 +45,7 @@ export function generatePatches(reports, _score) {
|
|
|
39
45
|
patches.push({
|
|
40
46
|
category: "IDEMPOTENCY",
|
|
41
47
|
severity: "critical",
|
|
42
|
-
trigger: `Agent sent the same write ${afterSuccess.length} more time(s) although
|
|
48
|
+
trigger: `Agent sent the same write ${afterSuccess.length} more time(s) although the harness had reported no failure for it.`,
|
|
43
49
|
promptSnippet: `CRITICAL — One write per operation:\nSend each write exactly once. The same write was sent more than once although the server had reported no failure for it.\n1. Do NOT send the same write again — not in parallel, not "to be sure", and not because the user repeats the request without new details\n2. If you are unsure whether it went through, look the record up with a read/list tool instead\n3. Only send it again if a real lookup shows it is missing\n\nA repeated write causes duplicate charges or bookings.`,
|
|
44
50
|
toolGuidance: `Return an identifier for the created record in every write response, so it can be referenced and looked up.`,
|
|
45
51
|
});
|
|
@@ -55,7 +61,9 @@ export function generatePatches(reports, _score) {
|
|
|
55
61
|
});
|
|
56
62
|
}
|
|
57
63
|
const unverified = reports.flatMap((r) => r.operations.filter((op) => op.outcome === "UNVERIFIED_AFTER_TIMEOUT"));
|
|
58
|
-
|
|
64
|
+
// GHOST_WRITE already carries the same protocol: a second copy in the same
|
|
65
|
+
// result would only end up twice in the system prompt.
|
|
66
|
+
if (unverified.length > 0 && afterTimeout.length === 0 && lookalikes.length === 0) {
|
|
59
67
|
patches.push({
|
|
60
68
|
category: "VERIFICATION",
|
|
61
69
|
severity: "warning",
|
|
@@ -71,7 +79,8 @@ export function formatPatchReport(patches) {
|
|
|
71
79
|
return `# Hardening Report\n\nNo issues detected. Agent behavior is within acceptable parameters.`;
|
|
72
80
|
}
|
|
73
81
|
let md = `# Hardening Report — ${patches.length} Issue(s) Detected\n\n`;
|
|
74
|
-
md += `These are suggestions, not proven fixes: re-run the scenario with a patch applied and compare the result.\n
|
|
82
|
+
md += `These are suggestions, not proven fixes: re-run the scenario with a patch applied and compare the result.\n`;
|
|
83
|
+
md += `GHOST_WRITE and VERIFICATION (from different runs) contain the same timeout protocol — add it to a system prompt once, not twice.\n\n`;
|
|
75
84
|
for (const p of patches) {
|
|
76
85
|
const icon = p.severity === "critical" ? "[CRITICAL]" : p.severity === "warning" ? "[WARNING]" : "[INFO]";
|
|
77
86
|
md += `## ${icon} ${p.category}\n\n`;
|