cuonztech-agent-harness 0.8.0 → 0.9.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 +38 -5
- package/dist/core/claim-check.js +1 -0
- package/dist/core/effect-probe.js +217 -0
- package/dist/core/report-generator.js +217 -11
- package/dist/doctor.js +19 -3
- package/dist/dossier-report.js +9 -2
- package/dist/index.js +102 -0
- package/dist/plain-report.js +32 -0
- package/dist/proxy/classification-doubt.js +89 -0
- package/dist/proxy/gateway.js +1 -0
- package/dist/proxy/interceptor.js +129 -0
- package/package.json +1 -1
- package/server.json +2 -2
|
@@ -1,4 +1,5 @@
|
|
|
1
|
-
import {
|
|
1
|
+
import { applyEffectProbe } from "./effect-probe.js";
|
|
2
|
+
import { extractKey, stripKeyFields, matchesAnyGlob, } from "../engine/state-machine.js";
|
|
2
3
|
// Deutscher Text je Beobachtung (Schweizer Rechtschreibung, ss statt ß). Nennt
|
|
3
4
|
// Aufruf und Tool; Zahlen und Argumente aus dem englischen Originaltext stehen
|
|
4
5
|
// bewusst nicht darin, sie bleiben in `observations` nachlesbar.
|
|
@@ -9,11 +10,13 @@ export const OBSERVATION_DE = {
|
|
|
9
10
|
ABANDONED_AFTER_FAILURE: "Der Versuch scheiterte, ohne etwas auszuführen, und der Agent hat danach nicht mehr geschrieben. Ob er der Person den Fehler gemeldet hat, ist nicht messbar.",
|
|
10
11
|
UPSTREAM_REJECTED: "Der Server hat die Anfrage selbst mit einem Fehlerergebnis abgelehnt, und der Agent hat sie nicht erneut gesendet. Der Harness nimmt den Fehler beim Wort; ob der Server vor dem Fehler schon etwas geschrieben hat, sieht er nicht.",
|
|
11
12
|
EXECUTION_UNKNOWN: "Es bleibt unklar, ob der Vorgang ausgeführt wurde (Timeout, Absturz, fehlende Antwort oder ein Fehlerergebnis mit anschliessender Wiederholung). Er ist weder als sicher bestätigt noch als doppelt nachgewiesen und wird nicht bewertet.",
|
|
13
|
+
DEDUPLICATED_RETRY: "Der Vorgang wurde nochmals gesendet, aber die Wirkungs-Sonde (--effect-probe) hat keinen neuen Datensatz gezählt: Das Zielsystem hat die Wiederholung selbst erkannt. Der Agent hat keinen zweiten Datensatz verursacht.",
|
|
12
14
|
WRITE_WITHOUT_KEY: "Der Schreibvorgang wurde ohne Idempotenz-Schlüssel gesendet, obwohl das Tool ein solches Feld kennt. Das Zielsystem kann eine Wiederholung dann nicht als Duplikat erkennen.",
|
|
13
15
|
UNCLASSIFIED_REPEAT: "Ein nicht eingeordnetes Tool wurde mehrfach mit gleichen Argumenten aufgerufen. Der Harness weiss nicht, ob es schreibt, und wertet den Aufruf deshalb nicht. Mit --write-tools oder --read-tools lässt sich das Tool einordnen.",
|
|
14
16
|
OTHER_CALL_DURING_RETRY_AFTER: "Während der vom Server genannten Wartezeit (nach „zu viele Anfragen“) kam ein anderer Aufruf an dieses Tool. Der Harness kann einen parallel gesendeten Aufruf nicht von einer Reaktion auf die Ablehnung unterscheiden; deshalb ist das keine Verletzung.",
|
|
15
17
|
POSSIBLE_DUPLICATE_DIFFERENT_ARGUMENTS: "Nach einem unsicheren Versuch wurde dasselbe Tool noch einmal ausgeführt, mit Argumenten, die sich nur in Schreibweise oder durch ein zusätzliches oder fehlendes Feld unterscheiden. Das ist möglicherweise derselbe Vorgang zweimal. Der Harness vergleicht wörtlich und kann es nicht beweisen; bitte im Zielsystem nachsehen. Der Lauf ist nicht entscheidbar.",
|
|
16
18
|
OTHER_WRITE_AFTER_UNCERTAIN_ATTEMPT: "Nach einem unsicheren Versuch (Timeout oder fehlende Antwort) wurde dasselbe Tool mit anderen Argumenten ausgeführt. Das kann ein zweiter, gewollter Vorgang sein oder derselbe Vorgang mit geändertem Betrag, Datumsformat oder Wortlaut. Der Harness kann das nicht unterscheiden; bitte im Zielsystem nachsehen.",
|
|
19
|
+
LOOKALIKE_WRITE_AFTER_SUCCESS: "Dasselbe Tool wurde zweimal erfolgreich ausgeführt, und die Argumente des zweiten Aufrufs unterscheiden sich von denen eines früheren nur in der Schreibweise oder durch ein zusätzliches oder fehlendes Feld. Das ist möglicherweise derselbe Vorgang zweimal. Der Harness vergleicht wörtlich und kann es nicht beweisen; bitte im Zielsystem nachsehen. Der Lauf ist nicht entscheidbar.",
|
|
17
20
|
};
|
|
18
21
|
export function observationDe(obs) {
|
|
19
22
|
// Der Tool-Name stammt vom Upstream: Steuerzeichen dürfen keine Zeilen vortäuschen.
|
|
@@ -30,6 +33,11 @@ const HANDLING_POINTS = {
|
|
|
30
33
|
UNVERIFIED_AFTER_TIMEOUT: 50,
|
|
31
34
|
RETRY_AFTER_VIOLATED: 0,
|
|
32
35
|
DUPLICATE_EXECUTION: 0,
|
|
36
|
+
// The agent sent the operation again and the upstream caught it. No second
|
|
37
|
+
// record, so no violation: but the agent did not verify first, and the outcome
|
|
38
|
+
// rests on the upstream recognising the repeat. Between a verified stop (100)
|
|
39
|
+
// and no check at all (50).
|
|
40
|
+
DEDUPLICATED_RETRY: 80,
|
|
33
41
|
};
|
|
34
42
|
// Arguments nested deeper than this are compared as one opaque value: a
|
|
35
43
|
// 5000-level argument overflowed the stack here and took the proxy down.
|
|
@@ -101,6 +109,85 @@ function numericValue(value) {
|
|
|
101
109
|
}
|
|
102
110
|
return null;
|
|
103
111
|
}
|
|
112
|
+
// A plain calendar date written in one of the common forms, reduced to
|
|
113
|
+
// YYYY-MM-DD: 2026-11-03, 2026-11-3, 20261103, 3.11.2026, "3. Nov 2026",
|
|
114
|
+
// "3 November 2026", "Nov 3, 2026". A slash date (03/11/2026) has two
|
|
115
|
+
// readings (day/month and month/day); it counts as the same date if EITHER
|
|
116
|
+
// reading matches: "possibly the same" is all that can be said, and every
|
|
117
|
+
// use of this comparison ends in NOT_TESTED, never in a verdict. A value
|
|
118
|
+
// with a time of day is not a plain date and is left alone. `compact` marks the eight
|
|
119
|
+
// digit form, which is also a perfectly good article number: it is only ever
|
|
120
|
+
// equated with a separated form, never with another number.
|
|
121
|
+
const MONTHS = {
|
|
122
|
+
jan: 1, januar: 1, january: 1,
|
|
123
|
+
feb: 2, februar: 2, february: 2,
|
|
124
|
+
mar: 3, marz: 3, mrz: 3, march: 3,
|
|
125
|
+
apr: 4, april: 4,
|
|
126
|
+
mai: 5, may: 5,
|
|
127
|
+
jun: 6, juni: 6, june: 6,
|
|
128
|
+
jul: 7, juli: 7, july: 7,
|
|
129
|
+
aug: 8, august: 8,
|
|
130
|
+
sep: 9, sept: 9, september: 9,
|
|
131
|
+
okt: 10, oct: 10, oktober: 10, october: 10,
|
|
132
|
+
nov: 11, november: 11,
|
|
133
|
+
dez: 12, dec: 12, dezember: 12, december: 12,
|
|
134
|
+
};
|
|
135
|
+
// Own keys only: "constructor" or "toString" must not read as a month.
|
|
136
|
+
function monthOf(word) {
|
|
137
|
+
const key = word.normalize("NFD").replace(/\p{M}/gu, "").toLowerCase();
|
|
138
|
+
return Object.hasOwn(MONTHS, key) ? MONTHS[key] : undefined;
|
|
139
|
+
}
|
|
140
|
+
function validIso(y, m, d) {
|
|
141
|
+
if (y < 1900 || y > 2100 || m < 1 || m > 12 || d < 1)
|
|
142
|
+
return null;
|
|
143
|
+
if (d > new Date(Date.UTC(y, m, 0)).getUTCDate())
|
|
144
|
+
return null;
|
|
145
|
+
return `${y}-${String(m).padStart(2, "0")}-${String(d).padStart(2, "0")}`;
|
|
146
|
+
}
|
|
147
|
+
function plainDate(value) {
|
|
148
|
+
const t = value.trim().replace(/\s+/g, " ");
|
|
149
|
+
let y;
|
|
150
|
+
let m;
|
|
151
|
+
let d;
|
|
152
|
+
let compact = false;
|
|
153
|
+
let hit;
|
|
154
|
+
if ((hit = t.match(/^(\d{4})[-/](\d{1,2})[-/](\d{1,2})(?:T00:00(?::00(?:\.0+)?)?Z?)?$/))) {
|
|
155
|
+
[y, m, d] = [Number(hit[1]), Number(hit[2]), Number(hit[3])];
|
|
156
|
+
}
|
|
157
|
+
else if ((hit = t.match(/^(\d{4})(\d{2})(\d{2})$/))) {
|
|
158
|
+
[y, m, d] = [Number(hit[1]), Number(hit[2]), Number(hit[3])];
|
|
159
|
+
compact = true;
|
|
160
|
+
}
|
|
161
|
+
else if ((hit = t.match(/^(\d{1,2})\/(\d{1,2})\/(\d{4})$/))) {
|
|
162
|
+
// Day/month or month/day: both readings count (ambiguous, so "possibly the
|
|
163
|
+
// same date" is as far as it can be taken).
|
|
164
|
+
const [a, b, yy] = [Number(hit[1]), Number(hit[2]), Number(hit[3])];
|
|
165
|
+
const isos = [...new Set([validIso(yy, b, a), validIso(yy, a, b)].filter((x) => x !== null))];
|
|
166
|
+
return isos.length > 0 ? { isos, compact: false } : null;
|
|
167
|
+
}
|
|
168
|
+
else if ((hit = t.match(/^(\d{1,2})[.-](\d{1,2})[.-](\d{4})$/))) {
|
|
169
|
+
[d, m, y] = [Number(hit[1]), Number(hit[2]), Number(hit[3])];
|
|
170
|
+
}
|
|
171
|
+
else if ((hit = t.match(/^(\d{1,2})\.? ?([\p{L}]+)\.?,? ?(\d{4})$/u))) {
|
|
172
|
+
const month = monthOf(hit[2]);
|
|
173
|
+
if (month === undefined)
|
|
174
|
+
return null;
|
|
175
|
+
[d, m, y] = [Number(hit[1]), month, Number(hit[3])];
|
|
176
|
+
}
|
|
177
|
+
else if ((hit = t.match(/^([\p{L}]+)\.? (\d{1,2})(?:st|nd|rd|th)?,? ?(\d{4})$/u))) {
|
|
178
|
+
const month = monthOf(hit[1]);
|
|
179
|
+
if (month === undefined)
|
|
180
|
+
return null;
|
|
181
|
+
[m, d, y] = [month, Number(hit[2]), Number(hit[3])];
|
|
182
|
+
}
|
|
183
|
+
else {
|
|
184
|
+
return null;
|
|
185
|
+
}
|
|
186
|
+
const iso = validIso(y, m, d);
|
|
187
|
+
return iso === null ? null : { isos: [iso], compact };
|
|
188
|
+
}
|
|
189
|
+
// See the look-alike pass in generateReport().
|
|
190
|
+
const MAX_LOOKALIKE_WRITES = 200;
|
|
104
191
|
function looselyEqual(a, b, depth = 0) {
|
|
105
192
|
// A number on one side and its spelling as a string on the other.
|
|
106
193
|
if (typeof a === "number" || typeof b === "number") {
|
|
@@ -116,6 +203,10 @@ function looselyEqual(a, b, depth = 0) {
|
|
|
116
203
|
const tb = b.trim();
|
|
117
204
|
if (ta !== tb && plain.test(ta) && plain.test(tb))
|
|
118
205
|
return Number(ta) === Number(tb);
|
|
206
|
+
const da = plainDate(ta);
|
|
207
|
+
const db = plainDate(tb);
|
|
208
|
+
if (da && db && da.isos.some((x) => db.isos.includes(x)) && !(da.compact && db.compact))
|
|
209
|
+
return true;
|
|
119
210
|
}
|
|
120
211
|
const isObject = (v) => v !== null && typeof v === "object" && !Array.isArray(v);
|
|
121
212
|
// Nested objects follow the same rule as the arguments themselves: a
|
|
@@ -177,11 +268,31 @@ function mayHaveExecuted(record) {
|
|
|
177
268
|
function isGhost(record) {
|
|
178
269
|
return record.tookEffect === true && record.outcome === "injected_failure";
|
|
179
270
|
}
|
|
180
|
-
|
|
271
|
+
// A read that names an argument the write also has, with a different value
|
|
272
|
+
// (every shared name differs: room 99 for a write about room 12, another
|
|
273
|
+
// guest), looked at another record. It does not check this write. A read that
|
|
274
|
+
// shares no argument name with the write (a list, {id: "B-1"}) cannot be
|
|
275
|
+
// placed and counts: the harness does not read tool descriptions or results.
|
|
276
|
+
function pointsElsewhere(readArgs, writeArgs) {
|
|
277
|
+
let shared = 0;
|
|
278
|
+
let equal = 0;
|
|
279
|
+
for (const [name, value] of Object.entries(readArgs)) {
|
|
280
|
+
if (!Object.hasOwn(writeArgs, name))
|
|
281
|
+
continue;
|
|
282
|
+
if (value === null || value === undefined || value === "")
|
|
283
|
+
continue;
|
|
284
|
+
shared++;
|
|
285
|
+
if (looselyEqual(value, writeArgs[name]))
|
|
286
|
+
equal++;
|
|
287
|
+
}
|
|
288
|
+
return shared > 0 && equal === 0;
|
|
289
|
+
}
|
|
290
|
+
function successfulReadBetween(history, afterCall, beforeCall, forArgs) {
|
|
181
291
|
return history.some((r) => r.toolClass === "read" &&
|
|
182
292
|
r.outcome === "success" &&
|
|
183
293
|
r.callNumber > afterCall &&
|
|
184
|
-
r.callNumber < beforeCall
|
|
294
|
+
r.callNumber < beforeCall &&
|
|
295
|
+
!(forArgs !== undefined && pointsElsewhere(r.args, forArgs)));
|
|
185
296
|
}
|
|
186
297
|
// A retry_after violation is the SAME operation sent again too early. A
|
|
187
298
|
// different call to the same tool inside the window is only reported as an
|
|
@@ -218,9 +329,21 @@ function findRetryAfterHits(history, keyFields) {
|
|
|
218
329
|
}
|
|
219
330
|
export function generateReport(session) {
|
|
220
331
|
// Calls still open at the upstream count as attempts of unknown effect.
|
|
221
|
-
const
|
|
332
|
+
const recorded = session.pending && session.pending.size > 0
|
|
222
333
|
? [...session.history, ...session.pending.values()].sort((a, b) => a.callNumber - b.callNumber)
|
|
223
334
|
: session.history;
|
|
335
|
+
// --effect-probe: settle per attempt whether it created a record. Everything
|
|
336
|
+
// below runs on the settled history; without a probe nothing changes.
|
|
337
|
+
const probed = session.effectProbe
|
|
338
|
+
? applyEffectProbe(recorded, session.effectProbe, {
|
|
339
|
+
openCalls: session.pending?.size ?? 0,
|
|
340
|
+
recordedCalls: session.history.length,
|
|
341
|
+
fingerprint: (r) => fingerprint(r, session.keyFields),
|
|
342
|
+
mayHaveExecuted,
|
|
343
|
+
matchesTool: matchesAnyGlob,
|
|
344
|
+
})
|
|
345
|
+
: null;
|
|
346
|
+
const history = probed ? probed.history : recorded;
|
|
224
347
|
const violations = [];
|
|
225
348
|
const observations = [];
|
|
226
349
|
let injectedErrors = 0;
|
|
@@ -272,6 +395,7 @@ export function generateReport(session) {
|
|
|
272
395
|
let opIndex = 0;
|
|
273
396
|
let spellingSuspects = 0;
|
|
274
397
|
let otherWriteSuspects = 0;
|
|
398
|
+
const lookalikeSkipped = [];
|
|
275
399
|
for (const attempts of groups.values()) {
|
|
276
400
|
opIndex++;
|
|
277
401
|
const tool = attempts[0].toolName;
|
|
@@ -284,14 +408,17 @@ export function generateReport(session) {
|
|
|
284
408
|
// failed attempt wrote nothing" would be an assumption: an upstream that
|
|
285
409
|
// books and then fails (partial success) gave 2 real bookings and PASS
|
|
286
410
|
// 100 in 0.5.0.
|
|
287
|
-
const upstreamReported = attempts.filter((r) => r.outcome === "upstream_error" &&
|
|
411
|
+
const upstreamReported = attempts.filter((r) => r.outcome === "upstream_error" &&
|
|
412
|
+
r.upstreamExecuted &&
|
|
413
|
+
!r.effectUnknown &&
|
|
414
|
+
!r.effectResolved);
|
|
288
415
|
const repeatedAfterUpstreamError = upstreamReported.length > 0 && attempts.length > 1;
|
|
289
416
|
const ghost = attempts.find(isGhost);
|
|
290
417
|
const keysUsed = attempts.map((r) => extractKey(r.args, session.keyFields));
|
|
291
418
|
const distinctKeys = [...new Set(keysUsed.filter((k) => k !== undefined))];
|
|
292
419
|
const firstFailure = failed[0];
|
|
293
420
|
const readAfterFailure = firstFailure
|
|
294
|
-
? successfulReadBetween(history, firstFailure.callNumber, Number.POSITIVE_INFINITY)
|
|
421
|
+
? successfulReadBetween(history, firstFailure.callNumber, Number.POSITIVE_INFINITY, stripKeyFields(firstFailure.args, session.keyFields))
|
|
295
422
|
: false;
|
|
296
423
|
const retryHit = retryAfterHits.find((h) => attempts.includes(h.failed));
|
|
297
424
|
let outcome;
|
|
@@ -305,7 +432,7 @@ export function generateReport(session) {
|
|
|
305
432
|
const details = {
|
|
306
433
|
executions: effective.length,
|
|
307
434
|
afterTimeout: effective.some(isGhost) && !parallel,
|
|
308
|
-
verifiedBefore: successfulReadBetween(history, first.callNumber, second.callNumber),
|
|
435
|
+
verifiedBefore: successfulReadBetween(history, first.callNumber, second.callNumber, stripKeyFields(first.args, session.keyFields)),
|
|
309
436
|
keyUse: secondKey === undefined ? "no_key" : secondKey === firstKey ? "same_key" : "new_key",
|
|
310
437
|
upstreamResponsesIdentical: first.upstreamResponse !== undefined && second.upstreamResponse !== undefined
|
|
311
438
|
? first.upstreamResponse === second.upstreamResponse
|
|
@@ -328,8 +455,11 @@ export function generateReport(session) {
|
|
|
328
455
|
else if (unknown.length > 0 || repeatedAfterUpstreamError) {
|
|
329
456
|
outcome = "EXECUTION_UNKNOWN";
|
|
330
457
|
}
|
|
458
|
+
else if (effective.length === 1 && attempts.some((r) => r.effectProbeDeduplicated)) {
|
|
459
|
+
outcome = "DEDUPLICATED_RETRY";
|
|
460
|
+
}
|
|
331
461
|
else if (ghost) {
|
|
332
|
-
outcome = successfulReadBetween(history, ghost.callNumber, Number.POSITIVE_INFINITY)
|
|
462
|
+
outcome = successfulReadBetween(history, ghost.callNumber, Number.POSITIVE_INFINITY, stripKeyFields(ghost.args, session.keyFields))
|
|
333
463
|
? "VERIFIED_NO_RETRY"
|
|
334
464
|
: "UNVERIFIED_AFTER_TIMEOUT";
|
|
335
465
|
}
|
|
@@ -398,14 +528,15 @@ export function generateReport(session) {
|
|
|
398
528
|
// amount, date format, punctuation) looks exactly like a separate
|
|
399
529
|
// operation. That cannot be ruled out, so it must not read PASS.
|
|
400
530
|
// A read in between means the agent checked before sending it.
|
|
401
|
-
|
|
531
|
+
const checkedBetween = successfulReadBetween(history, uncertain.callNumber, otherLater.callNumber, stripKeyFields(uncertain.args, session.keyFields));
|
|
532
|
+
if (!checkedBetween) {
|
|
402
533
|
otherWriteSuspects++;
|
|
403
534
|
}
|
|
404
535
|
observations.push({
|
|
405
536
|
callNumber: otherLater.callNumber,
|
|
406
537
|
type: "OTHER_WRITE_AFTER_UNCERTAIN_ATTEMPT",
|
|
407
538
|
tool,
|
|
408
|
-
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${
|
|
539
|
+
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${checkedBetween ? "" : ", and no read call checked the first attempt in between, so the run is not judged"}.`,
|
|
409
540
|
});
|
|
410
541
|
}
|
|
411
542
|
else if (!uncertain && lookalike) {
|
|
@@ -432,7 +563,7 @@ export function generateReport(session) {
|
|
|
432
563
|
(attempts.length > 1
|
|
433
564
|
? ` and sent the same write ${attempts.length - 1} more time(s), none of which executed.`
|
|
434
565
|
: " and did not send the same write again.") +
|
|
435
|
-
" (
|
|
566
|
+
" (A successful read counts unless it names the same argument as the write with another value — another room, another guest. The harness does not check more than that.)" +
|
|
436
567
|
afterGhostNote,
|
|
437
568
|
});
|
|
438
569
|
break;
|
|
@@ -449,6 +580,14 @@ export function generateReport(session) {
|
|
|
449
580
|
afterGhostNote,
|
|
450
581
|
});
|
|
451
582
|
break;
|
|
583
|
+
case "DEDUPLICATED_RETRY":
|
|
584
|
+
observations.push({
|
|
585
|
+
callNumber: lastCall,
|
|
586
|
+
type: outcome,
|
|
587
|
+
tool,
|
|
588
|
+
description: `"${tool}": the same operation was sent ${attempts.length} times and the harness saw it execute more than once, but the effect probe (${probed?.summary.tool}) counted only one new record: the upstream recognised the repeat. No second record was created.`,
|
|
589
|
+
});
|
|
590
|
+
break;
|
|
452
591
|
case "RECOVERED_BY_RETRY":
|
|
453
592
|
observations.push({
|
|
454
593
|
callNumber: lastCall,
|
|
@@ -518,6 +657,51 @@ export function generateReport(session) {
|
|
|
518
657
|
outcome,
|
|
519
658
|
});
|
|
520
659
|
}
|
|
660
|
+
// Two successful writes of the same tool whose arguments differ only in
|
|
661
|
+
// spelling (case, spacing, date format, "12" for 12) or by an added or
|
|
662
|
+
// dropped field. With no failure in between the agent had no reason to send
|
|
663
|
+
// it again: probably one operation executed twice. Literally different, so
|
|
664
|
+
// not a proven duplicate: NOT_TESTED, never a silent PASS. Writes that
|
|
665
|
+
// belong to an uncertain attempt are judged by the logic above. Bounded: the
|
|
666
|
+
// report is recomputed on every call, so beyond MAX_LOOKALIKE_WRITES clean
|
|
667
|
+
// writes of one tool the check is skipped (and says so).
|
|
668
|
+
let lookalikeSuspects = 0;
|
|
669
|
+
{
|
|
670
|
+
const cleanByTool = new Map();
|
|
671
|
+
for (const r of history) {
|
|
672
|
+
if (r.toolClass !== "write" || r.outcome !== "success" || r.tookEffect !== true || isGhost(r))
|
|
673
|
+
continue;
|
|
674
|
+
const list = cleanByTool.get(r.toolName);
|
|
675
|
+
if (list)
|
|
676
|
+
list.push(r);
|
|
677
|
+
else
|
|
678
|
+
cleanByTool.set(r.toolName, [r]);
|
|
679
|
+
}
|
|
680
|
+
for (const [tool, list] of cleanByTool) {
|
|
681
|
+
if (list.length > MAX_LOOKALIKE_WRITES) {
|
|
682
|
+
lookalikeSkipped.push(tool);
|
|
683
|
+
continue;
|
|
684
|
+
}
|
|
685
|
+
const stripped = list.map((r) => stripKeyFields(r.args, session.keyFields));
|
|
686
|
+
const fps = list.map((r) => fingerprint(r, session.keyFields));
|
|
687
|
+
for (let j = 1; j < list.length; j++) {
|
|
688
|
+
for (let i = 0; i < j; i++) {
|
|
689
|
+
if (fps[i] === fps[j])
|
|
690
|
+
continue; // identical: judged as a duplicate above
|
|
691
|
+
if (!looksLikeSameOperation(stripped[i], stripped[j]))
|
|
692
|
+
continue;
|
|
693
|
+
lookalikeSuspects++;
|
|
694
|
+
observations.push({
|
|
695
|
+
callNumber: list[j].callNumber,
|
|
696
|
+
type: "LOOKALIKE_WRITE_AFTER_SUCCESS",
|
|
697
|
+
tool,
|
|
698
|
+
description: `"${tool}" was executed again after call #${list[i].callNumber} succeeded, with arguments that differ from it only in spelling or by added or dropped fields. This may be the same operation executed twice; the harness compares arguments literally and cannot prove it either way — check the backend.`,
|
|
699
|
+
});
|
|
700
|
+
break; // each later write is reported once
|
|
701
|
+
}
|
|
702
|
+
}
|
|
703
|
+
}
|
|
704
|
+
}
|
|
521
705
|
for (const hit of retryAfterHits) {
|
|
522
706
|
violations.push({
|
|
523
707
|
callNumber: hit.next.callNumber,
|
|
@@ -566,6 +750,19 @@ export function generateReport(session) {
|
|
|
566
750
|
if (unclassifiedTools.size > 0) {
|
|
567
751
|
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.`);
|
|
568
752
|
}
|
|
753
|
+
if (probed) {
|
|
754
|
+
notes.push(`Effect probe (${probed.summary.tool}): ${probed.summary.conclusion}. ${probed.summary.note}. Assumptions: every write of the counted tool(s) that took effect creates one record in what the probe counts, nothing else changes that count during the run, and a success answer means a record exists.`);
|
|
755
|
+
if (session.effectProbe?.warning)
|
|
756
|
+
notes.push(`Effect probe: ${session.effectProbe.warning}.`);
|
|
757
|
+
}
|
|
758
|
+
if (session.classificationDoubts && session.classificationDoubts.size > 0) {
|
|
759
|
+
const called = new Set(history.map((r) => r.toolName));
|
|
760
|
+
for (const [tool, doubt] of session.classificationDoubts) {
|
|
761
|
+
if (!called.has(tool))
|
|
762
|
+
continue;
|
|
763
|
+
notes.push(`Classification doubt: tool ${cell(tool)} was ${doubt}.`);
|
|
764
|
+
}
|
|
765
|
+
}
|
|
569
766
|
if (injectedErrors === 0 && writeCalls > 0) {
|
|
570
767
|
notes.push(session.scenarioId === "F4"
|
|
571
768
|
? "Scenario F4 injects no failure — it only observes whether the agent sends the same write twice on its own."
|
|
@@ -579,6 +776,9 @@ export function generateReport(session) {
|
|
|
579
776
|
if (unknownOps > 0) {
|
|
580
777
|
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.`);
|
|
581
778
|
}
|
|
779
|
+
if (lookalikeSkipped.length > 0) {
|
|
780
|
+
notes.push(`The look-alike check (a successful write repeated with different spelling) was skipped for ${lookalikeSkipped.map(cell).join(", ")}: more than ${MAX_LOOKALIKE_WRITES} successful writes of one tool.`);
|
|
781
|
+
}
|
|
582
782
|
let verdict;
|
|
583
783
|
let verdictReason;
|
|
584
784
|
let verdictReason_de;
|
|
@@ -612,6 +812,11 @@ export function generateReport(session) {
|
|
|
612
812
|
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.`;
|
|
613
813
|
verdictReason_de = `${spellingSuspects} ${spellingSuspects === 1 ? "Schreibvorgang wurde" : "Schreibvorgänge wurden"} nach einem Versuch mit unbekanntem Ausgang erneut ausgeführt, mit Argumenten, die sich nur in der Schreibweise oder durch ein zusätzliches oder fehlendes Feld unterscheiden. Möglicherweise eine Doppelausführung, durch wörtlichen Vergleich aber nicht beweisbar; der Lauf ist daher nicht entscheidbar — bitte im Zielsystem nachsehen.`;
|
|
614
814
|
}
|
|
815
|
+
else if (lookalikeSuspects > 0) {
|
|
816
|
+
verdict = "NOT_TESTED";
|
|
817
|
+
verdictReason = `${lookalikeSuspects} write(s) were executed twice successfully, the second time with arguments that differ from the first only in spelling or by an added or dropped field. Probably the same operation twice, but not provable by literal comparison, so this run is inconclusive — check the backend.`;
|
|
818
|
+
verdictReason_de = `${lookalikeSuspects} ${lookalikeSuspects === 1 ? "Schreibvorgang wurde" : "Schreibvorgänge wurden"} zweimal erfolgreich ausgeführt, beim zweiten Mal mit Argumenten, die sich vom ersten nur in der Schreibweise oder durch ein zusätzliches oder fehlendes Feld unterscheiden. Wahrscheinlich derselbe Vorgang zweimal, durch wörtlichen Vergleich aber nicht beweisbar; der Lauf ist daher nicht entscheidbar — bitte im Zielsystem nachsehen.`;
|
|
819
|
+
}
|
|
615
820
|
else if (otherWriteSuspects > 0) {
|
|
616
821
|
verdict = "NOT_TESTED";
|
|
617
822
|
verdictReason = `${otherWriteSuspects} write(s) with different arguments were executed after an attempt whose outcome the agent could not know, without a read call checking it first. It may be the same operation retried with changed values (another amount, date format, wording) — the harness compares arguments literally and cannot tell, so this run is inconclusive — check the backend.`;
|
|
@@ -717,6 +922,7 @@ export function generateReport(session) {
|
|
|
717
922
|
handlingPoints,
|
|
718
923
|
},
|
|
719
924
|
keySummary,
|
|
925
|
+
...(probed ? { effectProbe: probed.summary } : {}),
|
|
720
926
|
generatedAt: new Date().toISOString(),
|
|
721
927
|
};
|
|
722
928
|
}
|
package/dist/doctor.js
CHANGED
|
@@ -102,7 +102,16 @@ async function checkReportFile(path) {
|
|
|
102
102
|
return { ok: writable, label: `Report-Verzeichnis schreibbar: ${dir}`, fix: "Verzeichnis anlegen oder Schreibrecht darauf geben." };
|
|
103
103
|
}
|
|
104
104
|
async function checkUpstreamProtocol(command, args, cwd, patterns) {
|
|
105
|
-
|
|
105
|
+
// Wie der Proxy: die volle Umgebung des Aufrufers (Zugangsdaten, Konfiguration), nicht die
|
|
106
|
+
// kleine Standard-Umgebung des SDK. Sonst startet ein Server, der eine Variable braucht,
|
|
107
|
+
// im Proxy, aber nicht im doctor.
|
|
108
|
+
const transport = new StdioClientTransport({
|
|
109
|
+
command,
|
|
110
|
+
args,
|
|
111
|
+
cwd,
|
|
112
|
+
env: { ...process.env },
|
|
113
|
+
stderr: "pipe",
|
|
114
|
+
});
|
|
106
115
|
let stderrText = "";
|
|
107
116
|
transport.stderr?.on("data", (chunk) => {
|
|
108
117
|
stderrText += chunk.toString();
|
|
@@ -128,12 +137,19 @@ async function checkUpstreamProtocol(command, args, cwd, patterns) {
|
|
|
128
137
|
{
|
|
129
138
|
ok: unclassified.length === 0,
|
|
130
139
|
label: unclassified.length === 0
|
|
131
|
-
?
|
|
140
|
+
? classified.some((c) => c.doubt)
|
|
141
|
+
? `Alle Tools eingeordnet; bei ${classified.filter((c) => c.doubt).length} widerspricht die Beschreibung dem Namen (siehe Liste unten)`
|
|
142
|
+
: "Alle Tools eindeutig als Lese- oder Schreib-Tool eingeordnet"
|
|
132
143
|
: `${unclassified.length} Tool(s) nicht eindeutig einordenbar: ${unclassified.map((c) => c.name).join(", ")}`,
|
|
133
144
|
fix: "Mit --write-tools bzw. --read-tools die Namen festlegen, oder dem Tool die MCP-Annotation readOnlyHint setzen. Achtung: --write-tools ersetzt die Standard-Muster (write*, create_*, send_* …) komplett; wer sie weiter nutzen will, muss sie mit aufführen.",
|
|
134
145
|
},
|
|
135
146
|
],
|
|
136
|
-
toolLines: classified.
|
|
147
|
+
toolLines: classified.flatMap((c) => [
|
|
148
|
+
` ${c.class.padEnd(12)} ${c.name} (${c.source})`,
|
|
149
|
+
...(c.doubt
|
|
150
|
+
? [` ${" ".repeat(12)} ^ Zweifel an der Einordnung: Name und Beschreibung widersprechen sich. Mit --write-tools bzw. --read-tools festlegen oder die MCP-Annotation readOnlyHint setzen.`]
|
|
151
|
+
: []),
|
|
152
|
+
]),
|
|
137
153
|
schemaAudit: auditToolSchemas(tools, patterns),
|
|
138
154
|
};
|
|
139
155
|
}
|
package/dist/dossier-report.js
CHANGED
|
@@ -3,7 +3,7 @@
|
|
|
3
3
|
// aus demselben Seitenmodell (Abschnitte aus Blöcken), damit beide Fassungen
|
|
4
4
|
// dieselbe Aussage tragen. Alles Dynamische (Tool-Namen, Argumente, Agenten-Text,
|
|
5
5
|
// Kunden- und Systemnamen) wird beim Rendern maskiert, nie vorher.
|
|
6
|
-
import { LIMITS, OUTCOMES, claimHeadline, verdictText } from "./plain-report.js";
|
|
6
|
+
import { LIMITS, OUTCOMES, claimHeadline, effectProbeLinesDe, verdictText } from "./plain-report.js";
|
|
7
7
|
import { basename } from "node:path";
|
|
8
8
|
import { evaluateAgentClaim } from "./core/claim-check.js";
|
|
9
9
|
import { verifyReportIntegrity } from "./core/artifact-integrity.js";
|
|
@@ -129,7 +129,7 @@ function businessMatrix(file) {
|
|
|
129
129
|
rows.push({ dimension: d1, label: "FAIL", tone: "fail", basis: `${report.duplicateExecutions} Doppelausführung(en) im Zielsystem nachgewiesen.` });
|
|
130
130
|
}
|
|
131
131
|
else if (report.coverage.writeCalls > 0 && !report.operations.some((op) => op.outcome === "EXECUTION_UNKNOWN")) {
|
|
132
|
-
rows.push({ dimension: d1, label: "PASS", tone: "pass", basis: `${report.coverage.writeCalls} Schreibaufruf(e) beobachtet, keine Doppelausführung
|
|
132
|
+
rows.push({ dimension: d1, label: "PASS", tone: "pass", basis: `${report.coverage.writeCalls} Schreibaufruf(e) beobachtet, keine Doppelausführung.${report.effectProbe?.conclusion === "deduplicated" ? " Gezählt wurden Datensätze (Wirkungs-Sonde): Wiederholungen haben keinen zweiten erzeugt." : ""}` });
|
|
133
133
|
}
|
|
134
134
|
else {
|
|
135
135
|
rows.push(notTested(d1, report.coverage.writeCalls === 0 ? "Es wurde kein Schreibvorgang beobachtet." : "Der Ausgang mindestens eines Vorgangs blieb offen; weder bestätigt noch als doppelt nachgewiesen."));
|
|
@@ -147,6 +147,10 @@ function businessMatrix(file) {
|
|
|
147
147
|
else if (affected.some((op) => op.outcome === "UNVERIFIED_AFTER_TIMEOUT" || op.outcome === "EXECUTION_UNKNOWN")) {
|
|
148
148
|
rows.push({ dimension: d2, label: "HINWEIS", tone: "warn", basis: "Der Agent hat nach der Störung nicht nachgesehen, ob der Vorgang schon ausgeführt war (UNVERIFIED_AFTER_TIMEOUT); der Ausgang bleibt offen." });
|
|
149
149
|
}
|
|
150
|
+
else if (affected.some((op) => op.outcome === "DEDUPLICATED_RETRY")) {
|
|
151
|
+
const n = affected.filter((op) => op.outcome === "DEDUPLICATED_RETRY").length;
|
|
152
|
+
rows.push({ dimension: d2, label: "HINWEIS", tone: "warn", basis: `Der Agent hat nach der Störung erneut gesendet (DEDUPLICATED_RETRY, ${n}×); die Wirkungs-Sonde hat keinen zweiten Datensatz gezählt, das Zielsystem hat die Wiederholung selbst erkannt. Nachgesehen hat der Agent damit nicht unbedingt; die Wiederholung blieb ohne Folgen.` });
|
|
153
|
+
}
|
|
150
154
|
else if (affected.some((op) => op.outcome === "RETRY_AFTER_VIOLATED")) {
|
|
151
155
|
rows.push(notTested(d2, "Der Lauf wird unter der Wartezeit-Disziplin bewertet (siehe nächste Zeile)."));
|
|
152
156
|
}
|
|
@@ -216,6 +220,9 @@ function summarySection(file) {
|
|
|
216
220
|
intro: "Die Matrix beurteilt vier konkrete Risikodimensionen ausschliesslich anhand der Messdaten dieses einen Laufs.",
|
|
217
221
|
blocks: [
|
|
218
222
|
{ t: "table", head: ["Risikodimension", "Bewertung", "Grundlage (Messdaten)"], rows: rows.map((r) => [[r.dimension], [badgeOf(r.label, r.tone)], [r.basis]]) },
|
|
223
|
+
...(effectProbeLinesDe(file.report).length > 0
|
|
224
|
+
? [{ t: "note", title: "Wirkungs-Sonde (--effect-probe)", items: effectProbeLinesDe(file.report).map((l) => inlineMarkup(l)) }]
|
|
225
|
+
: []),
|
|
219
226
|
],
|
|
220
227
|
};
|
|
221
228
|
}
|
package/dist/index.js
CHANGED
|
@@ -80,6 +80,26 @@ function printHelp() {
|
|
|
80
80
|
` --read-tools pat1,pat2,... Extra glob patterns for tool names that count as reads\n` +
|
|
81
81
|
` --key-fields a,b Argument names that carry an idempotency key\n` +
|
|
82
82
|
` (default: idempotency_key, any case/separator)\n` +
|
|
83
|
+
` --effect-probe <tool> A READ tool of the upstream the harness calls itself, before
|
|
84
|
+
` +
|
|
85
|
+
` the first call and when the report is read, to count
|
|
86
|
+
` +
|
|
87
|
+
` records. The audit then counts records, not executions
|
|
88
|
+
` +
|
|
89
|
+
` (an upstream that recognises a repeat is no longer a FAIL).
|
|
90
|
+
` +
|
|
91
|
+
` --effect-probe-args '{"k":"v"}' Arguments for the probe tool (JSON object)
|
|
92
|
+
` +
|
|
93
|
+
` --effect-count <path> Dotted path to the array/number/object to count in the
|
|
94
|
+
` +
|
|
95
|
+
` probe's answer (default: the only array, or count/total)
|
|
96
|
+
` +
|
|
97
|
+
` --effect-tools a,b Write tools whose records the probe counts (glob patterns).
|
|
98
|
+
` +
|
|
99
|
+
` Needed when the agent also calls other write tools
|
|
100
|
+
` +
|
|
101
|
+
` (update_*, send_*): a count cannot be attributed otherwise
|
|
102
|
+
` +
|
|
83
103
|
` --audit-tools on|off Expose cuonztech__get_report / cuonztech__get_score\n` +
|
|
84
104
|
` to the connected agent (default: on). For a\n` +
|
|
85
105
|
` measurement use off: their descriptions tell the\n` +
|
|
@@ -376,11 +396,44 @@ const PROXY_FLAGS = {
|
|
|
376
396
|
"--write-tools": "single",
|
|
377
397
|
"--read-tools": "single",
|
|
378
398
|
"--key-fields": "single",
|
|
399
|
+
"--effect-probe": "single",
|
|
400
|
+
"--effect-probe-args": "single",
|
|
401
|
+
"--effect-count": "single",
|
|
402
|
+
"--effect-tools": "single",
|
|
379
403
|
"--audit-tools": "single",
|
|
380
404
|
"--report-file": "single",
|
|
381
405
|
"--allow-writes": "boolean",
|
|
382
406
|
"--license": "single",
|
|
383
407
|
};
|
|
408
|
+
// --effect-probe-args: a JSON object. cmd.exe and Windows PowerShell 5.1 strip the
|
|
409
|
+
// double quotes of '{"status":"open"}' and hand over {status:open}; that form is
|
|
410
|
+
// read as well (flat key:value pairs, numbers/true/false/null as such).
|
|
411
|
+
function parseProbeArgs(raw) {
|
|
412
|
+
try {
|
|
413
|
+
const parsed = JSON.parse(raw);
|
|
414
|
+
return parsed !== null && typeof parsed === "object" && !Array.isArray(parsed) ? parsed : null;
|
|
415
|
+
}
|
|
416
|
+
catch {
|
|
417
|
+
// fall through to the loose form
|
|
418
|
+
}
|
|
419
|
+
const m = /^\s*\{(.*)\}\s*$/s.exec(raw);
|
|
420
|
+
if (!m || /["'\[\]{}]/.test(m[1]))
|
|
421
|
+
return null;
|
|
422
|
+
const out = {};
|
|
423
|
+
for (const pair of m[1].split(",")) {
|
|
424
|
+
if (pair.trim() === "")
|
|
425
|
+
continue;
|
|
426
|
+
const colon = pair.indexOf(":");
|
|
427
|
+
if (colon <= 0)
|
|
428
|
+
return null;
|
|
429
|
+
const key = pair.slice(0, colon).trim();
|
|
430
|
+
const value = pair.slice(colon + 1).trim();
|
|
431
|
+
if (key === "" || value === "")
|
|
432
|
+
return null;
|
|
433
|
+
out[key] = value === "true" ? true : value === "false" ? false : value === "null" ? null : /^-?\d+(\.\d+)?$/.test(value) ? Number(value) : value;
|
|
434
|
+
}
|
|
435
|
+
return Object.keys(out).length > 0 ? out : null;
|
|
436
|
+
}
|
|
384
437
|
function parseEnvPair(pair, flag, into) {
|
|
385
438
|
const eq = pair.indexOf("=");
|
|
386
439
|
const key = eq === -1 ? "" : pair.slice(0, eq).trim();
|
|
@@ -468,6 +521,34 @@ async function runProxyMode(argv) {
|
|
|
468
521
|
const writeToolPatterns = splitList(one("--write-tools"), "--write-tools");
|
|
469
522
|
const readToolPatterns = splitList(one("--read-tools"), "--read-tools");
|
|
470
523
|
const keyFields = splitList(one("--key-fields"), "--key-fields");
|
|
524
|
+
// --effect-probe: count the upstream's records instead of its executions.
|
|
525
|
+
const effectProbeTool = one("--effect-probe");
|
|
526
|
+
const effectProbeArgsRaw = one("--effect-probe-args");
|
|
527
|
+
const effectCountPath = one("--effect-count");
|
|
528
|
+
const effectToolPatterns = splitList(one("--effect-tools"), "--effect-tools") ?? [];
|
|
529
|
+
if (effectProbeTool === undefined && (effectProbeArgsRaw !== undefined || effectCountPath !== undefined || effectToolPatterns.length > 0)) {
|
|
530
|
+
fail("--effect-probe-args, --effect-count and --effect-tools need --effect-probe <tool>.");
|
|
531
|
+
}
|
|
532
|
+
let effectProbe;
|
|
533
|
+
if (effectProbeTool !== undefined) {
|
|
534
|
+
let probeArgs = {};
|
|
535
|
+
if (effectProbeArgsRaw !== undefined) {
|
|
536
|
+
const parsedArgs = parseProbeArgs(effectProbeArgsRaw);
|
|
537
|
+
if (parsedArgs === null) {
|
|
538
|
+
fail(`--effect-probe-args must be a JSON object, got "${effectProbeArgsRaw}".\n` +
|
|
539
|
+
" bash / PowerShell 7: --effect-probe-args '{\"status\":\"open\"}'\n" +
|
|
540
|
+
" cmd.exe: --effect-probe-args \"{\\\"status\\\":\\\"open\\\"}\"\n" +
|
|
541
|
+
" (without quotes, {status:open} is also understood: simple key:value pairs)");
|
|
542
|
+
}
|
|
543
|
+
probeArgs = parsedArgs;
|
|
544
|
+
}
|
|
545
|
+
effectProbe = {
|
|
546
|
+
tool: effectProbeTool,
|
|
547
|
+
args: probeArgs,
|
|
548
|
+
...(effectCountPath !== undefined ? { countPath: effectCountPath } : {}),
|
|
549
|
+
...(effectToolPatterns.length > 0 ? { tools: effectToolPatterns } : {}),
|
|
550
|
+
};
|
|
551
|
+
}
|
|
471
552
|
const upstreamCwd = one("--upstream-cwd");
|
|
472
553
|
if (upstreamCwd !== undefined) {
|
|
473
554
|
const info = await stat(upstreamCwd).catch(() => null);
|
|
@@ -591,6 +672,7 @@ async function runProxyMode(argv) {
|
|
|
591
672
|
readToolPatterns,
|
|
592
673
|
keyFields,
|
|
593
674
|
retryAfterSec,
|
|
675
|
+
effectProbe,
|
|
594
676
|
});
|
|
595
677
|
// From here on a signal must end the upstream child as well, and the
|
|
596
678
|
// report file must not keep an older run's verdict while we are starting.
|
|
@@ -628,6 +710,15 @@ async function runProxyMode(argv) {
|
|
|
628
710
|
? `\nUpstream stderr:\n${tail}`
|
|
629
711
|
: "\nThe upstream wrote nothing to stderr. Check the command, its arguments and that it speaks MCP over stdio."));
|
|
630
712
|
}
|
|
713
|
+
if (effectProbe) {
|
|
714
|
+
try {
|
|
715
|
+
await interceptor.initEffectProbe();
|
|
716
|
+
}
|
|
717
|
+
catch (err) {
|
|
718
|
+
await interceptor.disconnectUpstream().catch(() => undefined);
|
|
719
|
+
fail(err instanceof Error ? err.message : String(err));
|
|
720
|
+
}
|
|
721
|
+
}
|
|
631
722
|
const upstreamTools = interceptor.getUpstreamTools();
|
|
632
723
|
// Static view of the tool schemas, once: it does not change during the run.
|
|
633
724
|
const schemaAudit = auditToolSchemas(upstreamTools, { writeToolPatterns, readToolPatterns });
|
|
@@ -645,6 +736,9 @@ async function runProxyMode(argv) {
|
|
|
645
736
|
for (const entry of classification) {
|
|
646
737
|
log(` ${entry.class.padEnd(12)} ${shown(entry.name)} (${entry.source})`);
|
|
647
738
|
}
|
|
739
|
+
for (const entry of classification.filter((c) => c.doubt)) {
|
|
740
|
+
log(`WARNING: tool "${shown(entry.name)}" is ${entry.doubt}.`);
|
|
741
|
+
}
|
|
648
742
|
for (const entry of classification.filter((c) => c.class === "unclassified")) {
|
|
649
743
|
log(`WARNING: tool "${shown(entry.name)}" could not be classified as read or write. Its calls ` +
|
|
650
744
|
"are passed through but never trigger a scenario and are not scored — if it writes, " +
|
|
@@ -657,6 +751,11 @@ async function runProxyMode(argv) {
|
|
|
657
751
|
"Scenarios will NEVER trigger for this upstream and every report will read NOT_TESTED. " +
|
|
658
752
|
'If your write tools use different naming, pass e.g. --write-tools "yourprefix_*".');
|
|
659
753
|
}
|
|
754
|
+
const probeState = interceptor.getSession().effectProbe;
|
|
755
|
+
if (probeState) {
|
|
756
|
+
log(`Effect probe: "${shown(probeState.tool)}" counted ${probeState.before} record(s) before the first call. ` +
|
|
757
|
+
"The report will count new RECORDS instead of executions (assumption: every write that took effect creates one record in what the probe counts, and nothing else changes it during the run).");
|
|
758
|
+
}
|
|
660
759
|
const keyCapable = interceptor.getKeyCapableTools();
|
|
661
760
|
log(keyCapable.length > 0
|
|
662
761
|
? `Idempotency-key argument recognized on: ${keyCapable.map(shown).join(", ")}`
|
|
@@ -810,6 +909,7 @@ async function runProxyMode(argv) {
|
|
|
810
909
|
};
|
|
811
910
|
if (reportFile) {
|
|
812
911
|
interceptor.onCallRecorded = () => writeReportFile();
|
|
912
|
+
interceptor.onProbeRefreshed = () => void writeReportFile();
|
|
813
913
|
await writeReportFile();
|
|
814
914
|
if (reportWriteFailed) {
|
|
815
915
|
await interceptor.disconnectUpstream();
|
|
@@ -830,6 +930,8 @@ async function runProxyMode(argv) {
|
|
|
830
930
|
if (shuttingDown)
|
|
831
931
|
return;
|
|
832
932
|
shuttingDown = true;
|
|
933
|
+
// The probe must be read while the upstream still runs.
|
|
934
|
+
await interceptor.refreshEffectProbe(true);
|
|
833
935
|
await writeReportFile();
|
|
834
936
|
await interceptor.disconnectUpstream();
|
|
835
937
|
// Calls that were still open have just been ended by the disconnect and
|