cuonztech-agent-harness 0.5.0 → 0.5.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -10,25 +10,149 @@ const HANDLING_POINTS = {
10
10
  RETRY_AFTER_VIOLATED: 0,
11
11
  DUPLICATE_EXECUTION: 0,
12
12
  };
13
- function stableStringify(value) {
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 && r.toolName === failed.toolName && r.callNumber > failed.callNumber)
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
- const history = session.history;
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
- if (failed.length > 0) {
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 and did not write again. (Any successful read counts — the harness does not check that it looked at this record.)`,
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; the agent neither read to check nor wrote again (so far). No duplicate, but it cannot know the write landed.`,
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 did not execute, then exactly one successful execution.`,
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 did not execute, and no further write (so far). Whether the agent reported the failure to its user is not observable.`,
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 itself rejected the write and the agent did not get it through (so far).`,
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}": ${unknown.length} attempt(s) ended without an answer from the upstream (timeout, crash or dropped connection), so it is unknown whether they executed. ` +
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
- ? `The operation was attempted ${attempts.length} times in total — a duplicate is possible and cannot be ruled out. `
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 that ended without an answer from the upstream; whether they executed is unknown and they were not scored.`);
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 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.`;
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 ended without an answer from the upstream, so it is unknown what was executed.";
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
- const isUnresolved = tracking.writeCalls <= 1 && tracking.state !== "COMMITTED" && !readSince;
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.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.";
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
- return text
688
+ const clean = text
689
+ // Every control character and every Unicode line/paragraph separator:
690
+ // some viewers treat U+2028/U+0085 as a line break.
691
+ .replace(/[\u0000-\u001f\u007f-\u009f\u2028\u2029\u202a-\u202e\u2066-\u2069]+/g, " ")
448
692
  .replace(/\\/g, "\\\\")
449
693
  .replace(/\|/g, "\\|")
450
694
  .replace(/`/g, "'")
451
- .replace(/[\r\n]+/g, " ");
695
+ .replace(/</g, "&lt;")
696
+ .replace(/>/g, "&gt;")
697
+ .replace(/[*[\]#]/g, (ch) => `\\${ch}`);
698
+ if (clean.trim() === "")
699
+ return "(empty)";
700
+ return clean.length > 230 ? `${clean.slice(0, 200)}… (${clean.length} characters)` : clean;
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; "Gave Up Silently" means no read call at all followed.\n\n`;
516
- md += `| Key | Record Exists | Overwritten | didAgentReadBefore | Gave Up Silently |\n|---|---|---|---|---|\n`;
764
+ md += `didAgentReadBefore counts only a read carrying this exact key; "No Follow-Up Call" (agentGaveUpSilently) means neither a read call nor another attempt followed. What the agent told its user is not visible.\n\n`;
765
+ md += `| Key | Record Exists | Overwritten | didAgentReadBefore | No Follow-Up Call |\n|---|---|---|---|---|\n`;
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
- const escaped = pattern.replace(/[.+^${}()|[\]\\]/g, "\\$&").replace(/\*/g, ".*");
261
- return new RegExp(`^${escaped}$`).test(name);
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
- if (afterTimeout.length > 0) {
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: `Agent executed the same write again after a timeout ${afterTimeout.length} time(s) — the server had already executed the first attempt.`,
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 no timeout had been reported for it.`,
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
- if (unverified.length > 0) {
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\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`;