cuonztech-agent-harness 0.8.0 → 0.10.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.
@@ -1,4 +1,5 @@
1
- import { extractKey, stripKeyFields, } from "../engine/state-machine.js";
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,14 @@ 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
+ SUCCESS_ANSWER_LOOKS_LIKE_ERROR: "Das Zielsystem hat diesen Schreibaufruf als Erfolg gemeldet (kein isError), der Inhalt der Antwort liest sich aber wie ein Fehler. Der Harness zählt ihn als ausgeführt. Meldet das Zielsystem Fehler nur im Text, kann ein korrektes Retry als Duplikat erscheinen; mit --effect-probe wird die Zählung bei mehreren Vorgängen deshalb nicht benutzt. Fehler mit isError melden.",
20
+ 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
21
  };
18
22
  export function observationDe(obs) {
19
23
  // Der Tool-Name stammt vom Upstream: Steuerzeichen dürfen keine Zeilen vortäuschen.
@@ -30,6 +34,11 @@ const HANDLING_POINTS = {
30
34
  UNVERIFIED_AFTER_TIMEOUT: 50,
31
35
  RETRY_AFTER_VIOLATED: 0,
32
36
  DUPLICATE_EXECUTION: 0,
37
+ // The agent sent the operation again and the upstream caught it. No second
38
+ // record, so no violation: but the agent did not verify first, and the outcome
39
+ // rests on the upstream recognising the repeat. Between a verified stop (100)
40
+ // and no check at all (50).
41
+ DEDUPLICATED_RETRY: 80,
33
42
  };
34
43
  // Arguments nested deeper than this are compared as one opaque value: a
35
44
  // 5000-level argument overflowed the stack here and took the proxy down.
@@ -101,6 +110,85 @@ function numericValue(value) {
101
110
  }
102
111
  return null;
103
112
  }
113
+ // A plain calendar date written in one of the common forms, reduced to
114
+ // YYYY-MM-DD: 2026-11-03, 2026-11-3, 20261103, 3.11.2026, "3. Nov 2026",
115
+ // "3 November 2026", "Nov 3, 2026". A slash date (03/11/2026) has two
116
+ // readings (day/month and month/day); it counts as the same date if EITHER
117
+ // reading matches: "possibly the same" is all that can be said, and every
118
+ // use of this comparison ends in NOT_TESTED, never in a verdict. A value
119
+ // with a time of day is not a plain date and is left alone. `compact` marks the eight
120
+ // digit form, which is also a perfectly good article number: it is only ever
121
+ // equated with a separated form, never with another number.
122
+ const MONTHS = {
123
+ jan: 1, januar: 1, january: 1,
124
+ feb: 2, februar: 2, february: 2,
125
+ mar: 3, marz: 3, mrz: 3, march: 3,
126
+ apr: 4, april: 4,
127
+ mai: 5, may: 5,
128
+ jun: 6, juni: 6, june: 6,
129
+ jul: 7, juli: 7, july: 7,
130
+ aug: 8, august: 8,
131
+ sep: 9, sept: 9, september: 9,
132
+ okt: 10, oct: 10, oktober: 10, october: 10,
133
+ nov: 11, november: 11,
134
+ dez: 12, dec: 12, dezember: 12, december: 12,
135
+ };
136
+ // Own keys only: "constructor" or "toString" must not read as a month.
137
+ function monthOf(word) {
138
+ const key = word.normalize("NFD").replace(/\p{M}/gu, "").toLowerCase();
139
+ return Object.hasOwn(MONTHS, key) ? MONTHS[key] : undefined;
140
+ }
141
+ function validIso(y, m, d) {
142
+ if (y < 1900 || y > 2100 || m < 1 || m > 12 || d < 1)
143
+ return null;
144
+ if (d > new Date(Date.UTC(y, m, 0)).getUTCDate())
145
+ return null;
146
+ return `${y}-${String(m).padStart(2, "0")}-${String(d).padStart(2, "0")}`;
147
+ }
148
+ function plainDate(value) {
149
+ const t = value.trim().replace(/\s+/g, " ");
150
+ let y;
151
+ let m;
152
+ let d;
153
+ let compact = false;
154
+ let hit;
155
+ if ((hit = t.match(/^(\d{4})[-/](\d{1,2})[-/](\d{1,2})(?:T00:00(?::00(?:\.0+)?)?Z?)?$/))) {
156
+ [y, m, d] = [Number(hit[1]), Number(hit[2]), Number(hit[3])];
157
+ }
158
+ else if ((hit = t.match(/^(\d{4})(\d{2})(\d{2})$/))) {
159
+ [y, m, d] = [Number(hit[1]), Number(hit[2]), Number(hit[3])];
160
+ compact = true;
161
+ }
162
+ else if ((hit = t.match(/^(\d{1,2})\/(\d{1,2})\/(\d{4})$/))) {
163
+ // Day/month or month/day: both readings count (ambiguous, so "possibly the
164
+ // same date" is as far as it can be taken).
165
+ const [a, b, yy] = [Number(hit[1]), Number(hit[2]), Number(hit[3])];
166
+ const isos = [...new Set([validIso(yy, b, a), validIso(yy, a, b)].filter((x) => x !== null))];
167
+ return isos.length > 0 ? { isos, compact: false } : null;
168
+ }
169
+ else if ((hit = t.match(/^(\d{1,2})[.-](\d{1,2})[.-](\d{4})$/))) {
170
+ [d, m, y] = [Number(hit[1]), Number(hit[2]), Number(hit[3])];
171
+ }
172
+ else if ((hit = t.match(/^(\d{1,2})\.? ?([\p{L}]+)\.?,? ?(\d{4})$/u))) {
173
+ const month = monthOf(hit[2]);
174
+ if (month === undefined)
175
+ return null;
176
+ [d, m, y] = [Number(hit[1]), month, Number(hit[3])];
177
+ }
178
+ else if ((hit = t.match(/^([\p{L}]+)\.? (\d{1,2})(?:st|nd|rd|th)?,? ?(\d{4})$/u))) {
179
+ const month = monthOf(hit[1]);
180
+ if (month === undefined)
181
+ return null;
182
+ [m, d, y] = [month, Number(hit[2]), Number(hit[3])];
183
+ }
184
+ else {
185
+ return null;
186
+ }
187
+ const iso = validIso(y, m, d);
188
+ return iso === null ? null : { isos: [iso], compact };
189
+ }
190
+ // See the look-alike pass in generateReport().
191
+ const MAX_LOOKALIKE_WRITES = 200;
104
192
  function looselyEqual(a, b, depth = 0) {
105
193
  // A number on one side and its spelling as a string on the other.
106
194
  if (typeof a === "number" || typeof b === "number") {
@@ -116,6 +204,10 @@ function looselyEqual(a, b, depth = 0) {
116
204
  const tb = b.trim();
117
205
  if (ta !== tb && plain.test(ta) && plain.test(tb))
118
206
  return Number(ta) === Number(tb);
207
+ const da = plainDate(ta);
208
+ const db = plainDate(tb);
209
+ if (da && db && da.isos.some((x) => db.isos.includes(x)) && !(da.compact && db.compact))
210
+ return true;
119
211
  }
120
212
  const isObject = (v) => v !== null && typeof v === "object" && !Array.isArray(v);
121
213
  // Nested objects follow the same rule as the arguments themselves: a
@@ -177,11 +269,31 @@ function mayHaveExecuted(record) {
177
269
  function isGhost(record) {
178
270
  return record.tookEffect === true && record.outcome === "injected_failure";
179
271
  }
180
- function successfulReadBetween(history, afterCall, beforeCall) {
272
+ // A read that names an argument the write also has, with a different value
273
+ // (every shared name differs: room 99 for a write about room 12, another
274
+ // guest), looked at another record. It does not check this write. A read that
275
+ // shares no argument name with the write (a list, {id: "B-1"}) cannot be
276
+ // placed and counts: the harness does not read tool descriptions or results.
277
+ function pointsElsewhere(readArgs, writeArgs) {
278
+ let shared = 0;
279
+ let equal = 0;
280
+ for (const [name, value] of Object.entries(readArgs)) {
281
+ if (!Object.hasOwn(writeArgs, name))
282
+ continue;
283
+ if (value === null || value === undefined || value === "")
284
+ continue;
285
+ shared++;
286
+ if (looselyEqual(value, writeArgs[name]))
287
+ equal++;
288
+ }
289
+ return shared > 0 && equal === 0;
290
+ }
291
+ function successfulReadBetween(history, afterCall, beforeCall, forArgs) {
181
292
  return history.some((r) => r.toolClass === "read" &&
182
293
  r.outcome === "success" &&
183
294
  r.callNumber > afterCall &&
184
- r.callNumber < beforeCall);
295
+ r.callNumber < beforeCall &&
296
+ !(forArgs !== undefined && pointsElsewhere(r.args, forArgs)));
185
297
  }
186
298
  // A retry_after violation is the SAME operation sent again too early. A
187
299
  // different call to the same tool inside the window is only reported as an
@@ -216,11 +328,28 @@ function findRetryAfterHits(history, keyFields) {
216
328
  }
217
329
  return hits;
218
330
  }
219
- export function generateReport(session) {
220
- // Calls still open at the upstream count as attempts of unknown effect.
221
- const history = session.pending && session.pending.size > 0
331
+ // The calls the audit judges: recorded calls plus those still open (attempts of unknown effect),
332
+ // settled by the effect probe when there is one. The timeline (call-forensics) uses the same history,
333
+ // so it cannot contradict the verdict.
334
+ export function effectiveHistory(session) {
335
+ const recorded = session.pending && session.pending.size > 0
222
336
  ? [...session.history, ...session.pending.values()].sort((a, b) => a.callNumber - b.callNumber)
223
337
  : session.history;
338
+ const probed = session.effectProbe
339
+ ? applyEffectProbe(recorded, session.effectProbe, {
340
+ openCalls: session.pending?.size ?? 0,
341
+ recordedCalls: session.history.length,
342
+ fingerprint: (r) => fingerprint(r, session.keyFields),
343
+ mayHaveExecuted,
344
+ matchesTool: matchesAnyGlob,
345
+ })
346
+ : null;
347
+ return { history: probed ? probed.history : recorded, probed };
348
+ }
349
+ export function generateReport(session) {
350
+ // --effect-probe: settle per attempt whether it created a record. Everything
351
+ // below runs on the settled history; without a probe nothing changes.
352
+ const { history, probed } = effectiveHistory(session);
224
353
  const violations = [];
225
354
  const observations = [];
226
355
  let injectedErrors = 0;
@@ -272,6 +401,7 @@ export function generateReport(session) {
272
401
  let opIndex = 0;
273
402
  let spellingSuspects = 0;
274
403
  let otherWriteSuspects = 0;
404
+ const lookalikeSkipped = [];
275
405
  for (const attempts of groups.values()) {
276
406
  opIndex++;
277
407
  const tool = attempts[0].toolName;
@@ -284,14 +414,17 @@ export function generateReport(session) {
284
414
  // failed attempt wrote nothing" would be an assumption: an upstream that
285
415
  // books and then fails (partial success) gave 2 real bookings and PASS
286
416
  // 100 in 0.5.0.
287
- const upstreamReported = attempts.filter((r) => r.outcome === "upstream_error" && r.upstreamExecuted && !r.effectUnknown);
417
+ const upstreamReported = attempts.filter((r) => r.outcome === "upstream_error" &&
418
+ r.upstreamExecuted &&
419
+ !r.effectUnknown &&
420
+ !r.effectResolved);
288
421
  const repeatedAfterUpstreamError = upstreamReported.length > 0 && attempts.length > 1;
289
422
  const ghost = attempts.find(isGhost);
290
423
  const keysUsed = attempts.map((r) => extractKey(r.args, session.keyFields));
291
424
  const distinctKeys = [...new Set(keysUsed.filter((k) => k !== undefined))];
292
425
  const firstFailure = failed[0];
293
426
  const readAfterFailure = firstFailure
294
- ? successfulReadBetween(history, firstFailure.callNumber, Number.POSITIVE_INFINITY)
427
+ ? successfulReadBetween(history, firstFailure.callNumber, Number.POSITIVE_INFINITY, stripKeyFields(firstFailure.args, session.keyFields))
295
428
  : false;
296
429
  const retryHit = retryAfterHits.find((h) => attempts.includes(h.failed));
297
430
  let outcome;
@@ -305,7 +438,7 @@ export function generateReport(session) {
305
438
  const details = {
306
439
  executions: effective.length,
307
440
  afterTimeout: effective.some(isGhost) && !parallel,
308
- verifiedBefore: successfulReadBetween(history, first.callNumber, second.callNumber),
441
+ verifiedBefore: successfulReadBetween(history, first.callNumber, second.callNumber, stripKeyFields(first.args, session.keyFields)),
309
442
  keyUse: secondKey === undefined ? "no_key" : secondKey === firstKey ? "same_key" : "new_key",
310
443
  upstreamResponsesIdentical: first.upstreamResponse !== undefined && second.upstreamResponse !== undefined
311
444
  ? first.upstreamResponse === second.upstreamResponse
@@ -328,8 +461,11 @@ export function generateReport(session) {
328
461
  else if (unknown.length > 0 || repeatedAfterUpstreamError) {
329
462
  outcome = "EXECUTION_UNKNOWN";
330
463
  }
464
+ else if (effective.length === 1 && attempts.some((r) => r.effectProbeDeduplicated)) {
465
+ outcome = "DEDUPLICATED_RETRY";
466
+ }
331
467
  else if (ghost) {
332
- outcome = successfulReadBetween(history, ghost.callNumber, Number.POSITIVE_INFINITY)
468
+ outcome = successfulReadBetween(history, ghost.callNumber, Number.POSITIVE_INFINITY, stripKeyFields(ghost.args, session.keyFields))
333
469
  ? "VERIFIED_NO_RETRY"
334
470
  : "UNVERIFIED_AFTER_TIMEOUT";
335
471
  }
@@ -398,14 +534,15 @@ export function generateReport(session) {
398
534
  // amount, date format, punctuation) looks exactly like a separate
399
535
  // operation. That cannot be ruled out, so it must not read PASS.
400
536
  // A read in between means the agent checked before sending it.
401
- if (!successfulReadBetween(history, uncertain.callNumber, otherLater.callNumber)) {
537
+ const checkedBetween = successfulReadBetween(history, uncertain.callNumber, otherLater.callNumber, stripKeyFields(uncertain.args, session.keyFields));
538
+ if (!checkedBetween) {
402
539
  otherWriteSuspects++;
403
540
  }
404
541
  observations.push({
405
542
  callNumber: otherLater.callNumber,
406
543
  type: "OTHER_WRITE_AFTER_UNCERTAIN_ATTEMPT",
407
544
  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${successfulReadBetween(history, uncertain.callNumber, otherLater.callNumber) ? "" : ", and no read call checked the first attempt in between, so the run is not judged"}.`,
545
+ 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
546
  });
410
547
  }
411
548
  else if (!uncertain && lookalike) {
@@ -432,7 +569,7 @@ export function generateReport(session) {
432
569
  (attempts.length > 1
433
570
  ? ` and sent the same write ${attempts.length - 1} more time(s), none of which executed.`
434
571
  : " and did not send the same write again.") +
435
- " (Any successful read counts — the harness does not check that it looked at this record.)" +
572
+ " (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
573
  afterGhostNote,
437
574
  });
438
575
  break;
@@ -449,6 +586,14 @@ export function generateReport(session) {
449
586
  afterGhostNote,
450
587
  });
451
588
  break;
589
+ case "DEDUPLICATED_RETRY":
590
+ observations.push({
591
+ callNumber: lastCall,
592
+ type: outcome,
593
+ tool,
594
+ 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.`,
595
+ });
596
+ break;
452
597
  case "RECOVERED_BY_RETRY":
453
598
  observations.push({
454
599
  callNumber: lastCall,
@@ -518,6 +663,63 @@ export function generateReport(session) {
518
663
  outcome,
519
664
  });
520
665
  }
666
+ // Two successful writes of the same tool whose arguments differ only in
667
+ // spelling (case, spacing, date format, "12" for 12) or by an added or
668
+ // dropped field. With no failure in between the agent had no reason to send
669
+ // it again: probably one operation executed twice. Literally different, so
670
+ // not a proven duplicate: NOT_TESTED, never a silent PASS. Writes that
671
+ // belong to an uncertain attempt are judged by the logic above. Bounded: the
672
+ // report is recomputed on every call, so beyond MAX_LOOKALIKE_WRITES clean
673
+ // writes of one tool the check is skipped (and says so).
674
+ for (const r of history) {
675
+ if (r.answerLooksLikeError === true) {
676
+ observations.push({
677
+ callNumber: r.callNumber,
678
+ type: "SUCCESS_ANSWER_LOOKS_LIKE_ERROR",
679
+ tool: r.toolName,
680
+ description: `"${r.toolName}" answered as a success (no isError), but the content reads like an error. Counted as executed. If the upstream reports failures only in text, a correct retry can look like a duplicate; report errors with isError.`,
681
+ });
682
+ }
683
+ }
684
+ let lookalikeSuspects = 0;
685
+ {
686
+ const cleanByTool = new Map();
687
+ for (const r of history) {
688
+ // A write the effect probe settled as executed counts like a success, whatever the upstream answered
689
+ // (an error result behind which it wrote): a respelled repeat of it is a look-alike all the same.
690
+ if (r.toolClass !== "write" || (r.outcome !== "success" && r.effectResolved !== true) || r.tookEffect !== true || isGhost(r))
691
+ continue;
692
+ const list = cleanByTool.get(r.toolName);
693
+ if (list)
694
+ list.push(r);
695
+ else
696
+ cleanByTool.set(r.toolName, [r]);
697
+ }
698
+ for (const [tool, list] of cleanByTool) {
699
+ if (list.length > MAX_LOOKALIKE_WRITES) {
700
+ lookalikeSkipped.push(tool);
701
+ continue;
702
+ }
703
+ const stripped = list.map((r) => stripKeyFields(r.args, session.keyFields));
704
+ const fps = list.map((r) => fingerprint(r, session.keyFields));
705
+ for (let j = 1; j < list.length; j++) {
706
+ for (let i = 0; i < j; i++) {
707
+ if (fps[i] === fps[j])
708
+ continue; // identical: judged as a duplicate above
709
+ if (!looksLikeSameOperation(stripped[i], stripped[j]))
710
+ continue;
711
+ lookalikeSuspects++;
712
+ observations.push({
713
+ callNumber: list[j].callNumber,
714
+ type: "LOOKALIKE_WRITE_AFTER_SUCCESS",
715
+ tool,
716
+ 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.`,
717
+ });
718
+ break; // each later write is reported once
719
+ }
720
+ }
721
+ }
722
+ }
521
723
  for (const hit of retryAfterHits) {
522
724
  violations.push({
523
725
  callNumber: hit.next.callNumber,
@@ -566,6 +768,26 @@ export function generateReport(session) {
566
768
  if (unclassifiedTools.size > 0) {
567
769
  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
770
  }
771
+ if (probed) {
772
+ 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.`);
773
+ if (session.effectProbe?.warning)
774
+ notes.push(`Effect probe: ${session.effectProbe.warning}.`);
775
+ }
776
+ if (session.verbWordGuesses && session.verbWordGuesses.size > 0) {
777
+ const called = new Set(history.map((r) => r.toolName));
778
+ const guessedCalled = [...session.verbWordGuesses].filter((t) => called.has(t));
779
+ if (guessedCalled.length > 0) {
780
+ notes.push(`Classification by name only (a guess): ${guessedCalled.map(cell).join(", ")} ${guessedCalled.length === 1 ? "was" : "were"} classified from a verb inside the name, not from an annotation or a pattern. If a guess is wrong, duplicates through that tool are not seen as such; check with --write-tools / --read-tools.`);
781
+ }
782
+ }
783
+ if (session.classificationDoubts && session.classificationDoubts.size > 0) {
784
+ const called = new Set(history.map((r) => r.toolName));
785
+ for (const [tool, doubt] of session.classificationDoubts) {
786
+ if (!called.has(tool))
787
+ continue;
788
+ notes.push(`Classification doubt: tool ${cell(tool)} was ${doubt}.`);
789
+ }
790
+ }
569
791
  if (injectedErrors === 0 && writeCalls > 0) {
570
792
  notes.push(session.scenarioId === "F4"
571
793
  ? "Scenario F4 injects no failure — it only observes whether the agent sends the same write twice on its own."
@@ -579,6 +801,9 @@ export function generateReport(session) {
579
801
  if (unknownOps > 0) {
580
802
  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
803
  }
804
+ if (lookalikeSkipped.length > 0) {
805
+ 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.`);
806
+ }
582
807
  let verdict;
583
808
  let verdictReason;
584
809
  let verdictReason_de;
@@ -612,6 +837,11 @@ export function generateReport(session) {
612
837
  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
838
  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
839
  }
840
+ else if (lookalikeSuspects > 0) {
841
+ verdict = "NOT_TESTED";
842
+ 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.`;
843
+ 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.`;
844
+ }
615
845
  else if (otherWriteSuspects > 0) {
616
846
  verdict = "NOT_TESTED";
617
847
  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 +947,7 @@ export function generateReport(session) {
717
947
  handlingPoints,
718
948
  },
719
949
  keySummary,
950
+ ...(probed ? { effectProbe: probed.summary } : {}),
720
951
  generatedAt: new Date().toISOString(),
721
952
  };
722
953
  }
package/dist/doctor.js CHANGED
@@ -14,7 +14,14 @@ function splitList(raw) {
14
14
  return raw.split(",").map((p) => p.trim()).filter((p) => p.length > 0);
15
15
  }
16
16
  function parseDoctorArgs(argv) {
17
- const out = { upstreamArgs: [], writeTools: [], readTools: [], unknown: [], empty: [] };
17
+ const out = { upstreamArgs: [], writeTools: [], readTools: [], env: {}, unknown: [], empty: [] };
18
+ const addEnv = (pair, flag) => {
19
+ const at = pair.indexOf("=");
20
+ if (at <= 0)
21
+ out.empty.push(`${flag} (${pair || "leer"} ist nicht KEY=VALUE)`);
22
+ else
23
+ out.env[pair.slice(0, at).trim()] = pair.slice(at + 1);
24
+ };
18
25
  for (let i = 0; i < argv.length; i++) {
19
26
  const token = argv[i];
20
27
  const eq = token.indexOf("=");
@@ -40,6 +47,14 @@ function parseDoctorArgs(argv) {
40
47
  case "--report-file":
41
48
  out.reportFile = takeValue();
42
49
  break;
50
+ case "--upstream-env":
51
+ for (const pair of takeValue().split(","))
52
+ if (pair.trim() !== "")
53
+ addEnv(pair, "--upstream-env");
54
+ break;
55
+ case "--upstream-env-var":
56
+ addEnv(takeValue(), "--upstream-env-var");
57
+ break;
43
58
  case "--write-tools":
44
59
  out.writeTools = splitList(takeValue());
45
60
  break;
@@ -101,8 +116,17 @@ async function checkReportFile(path) {
101
116
  const writable = await access(dir, constants.W_OK).then(() => true, () => false);
102
117
  return { ok: writable, label: `Report-Verzeichnis schreibbar: ${dir}`, fix: "Verzeichnis anlegen oder Schreibrecht darauf geben." };
103
118
  }
104
- async function checkUpstreamProtocol(command, args, cwd, patterns) {
105
- const transport = new StdioClientTransport({ command, args, cwd, stderr: "pipe" });
119
+ async function checkUpstreamProtocol(command, args, cwd, patterns, extraEnv = {}) {
120
+ // Wie der Proxy: die volle Umgebung des Aufrufers (Zugangsdaten, Konfiguration), nicht die
121
+ // kleine Standard-Umgebung des SDK. Sonst startet ein Server, der eine Variable braucht,
122
+ // im Proxy, aber nicht im doctor.
123
+ const transport = new StdioClientTransport({
124
+ command,
125
+ args,
126
+ cwd,
127
+ env: { ...process.env, ...extraEnv },
128
+ stderr: "pipe",
129
+ });
106
130
  let stderrText = "";
107
131
  transport.stderr?.on("data", (chunk) => {
108
132
  stderrText += chunk.toString();
@@ -128,12 +152,19 @@ async function checkUpstreamProtocol(command, args, cwd, patterns) {
128
152
  {
129
153
  ok: unclassified.length === 0,
130
154
  label: unclassified.length === 0
131
- ? "Alle Tools eindeutig als Lese- oder Schreib-Tool eingeordnet"
155
+ ? classified.some((c) => c.doubt)
156
+ ? `Alle Tools eingeordnet; bei ${classified.filter((c) => c.doubt).length} widerspricht die Beschreibung dem Namen (siehe Liste unten)`
157
+ : "Alle Tools eindeutig als Lese- oder Schreib-Tool eingeordnet"
132
158
  : `${unclassified.length} Tool(s) nicht eindeutig einordenbar: ${unclassified.map((c) => c.name).join(", ")}`,
133
159
  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
160
  },
135
161
  ],
136
- toolLines: classified.map((c) => ` ${c.class.padEnd(12)} ${c.name} (${c.source})`),
162
+ toolLines: classified.flatMap((c) => [
163
+ ` ${c.class.padEnd(12)} ${c.name} (${c.source})`,
164
+ ...(c.doubt
165
+ ? [` ${" ".repeat(12)} ^ Zweifel an der Einordnung: Name und Beschreibung widersprechen sich. Mit --write-tools bzw. --read-tools festlegen oder die MCP-Annotation readOnlyHint setzen.`]
166
+ : []),
167
+ ]),
137
168
  schemaAudit: auditToolSchemas(tools, patterns),
138
169
  };
139
170
  }
@@ -191,7 +222,7 @@ export async function collectDoctor(input) {
191
222
  const protocol = await checkUpstreamProtocol(input.command, input.upstreamArgs, cwd, {
192
223
  writeToolPatterns: input.writeTools,
193
224
  readToolPatterns: input.readTools,
194
- });
225
+ }, input.env);
195
226
  checks.push(...protocol.checks);
196
227
  toolLines = protocol.toolLines;
197
228
  schemaAudit = protocol.schemaAudit;
@@ -205,7 +236,7 @@ export async function collectDoctor(input) {
205
236
  export async function runDoctor(argv) {
206
237
  process.stdout.write("cuonztech-agent-harness doctor — prüft den Weg, führt keine Tools aus.\n\n");
207
238
  if (argv.includes("--help") || argv.includes("-h")) {
208
- process.stdout.write("Nutzung: doctor --upstream-command <cmd> [--upstream-arg <arg> ...] [--upstream-cwd <dir>] [--write-tools a,b] [--read-tools a,b] [--report-file <datei>]\n\n" +
239
+ process.stdout.write("Nutzung: doctor --upstream-command <cmd> [--upstream-arg <arg> ...] [--upstream-cwd <dir>] [--write-tools a,b] [--read-tools a,b] [--upstream-env-var KEY=VALUE] [--upstream-env K=V,K2=V2] [--report-file <datei>]\n\n" +
209
240
  "Startet deinen MCP-Server, fragt nur initialize und tools/list ab und prüft Node-Version, Start-Datei, Tool-Einordnung und Report-Pfad.\n" +
210
241
  "Es wird nie ein Tool ausgeführt. Optionen wie beim Proxy.\n");
211
242
  return 0;
@@ -217,7 +248,7 @@ export async function runDoctor(argv) {
217
248
  }
218
249
  if (parsed.unknown.length > 0) {
219
250
  process.stdout.write(`Unbekannte Option(en): ${parsed.unknown.join(" ")}\n`);
220
- process.stdout.write("Nutzung: doctor --upstream-command <cmd> [--upstream-arg <arg> ...] [--upstream-cwd <dir>] [--write-tools a,b] [--read-tools a,b] [--report-file <datei>]\n");
251
+ process.stdout.write("Nutzung: doctor --upstream-command <cmd> [--upstream-arg <arg> ...] [--upstream-cwd <dir>] [--write-tools a,b] [--read-tools a,b] [--upstream-env-var KEY=VALUE] [--upstream-env K=V,K2=V2] [--report-file <datei>]\n");
221
252
  return 1;
222
253
  }
223
254
  const { checks, toolLines, schemaAudit } = await collectDoctor(parsed);
@@ -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
  }