cuonztech-agent-harness 0.7.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.
@@ -0,0 +1,217 @@
1
+ // ---------------------------------------------------------------- counting
2
+ function walk(value, path) {
3
+ let cur = value;
4
+ for (const part of path.split(".").filter((p) => p !== "")) {
5
+ if (cur === null || typeof cur !== "object" || Array.isArray(cur))
6
+ return undefined;
7
+ cur = cur[part];
8
+ }
9
+ return cur;
10
+ }
11
+ function countOf(value) {
12
+ if (Array.isArray(value))
13
+ return value.length;
14
+ if (typeof value === "number" && Number.isFinite(value) && value >= 0)
15
+ return value;
16
+ if (value !== null && typeof value === "object")
17
+ return Object.keys(value).length;
18
+ return null;
19
+ }
20
+ const PAGING_KEYS = [
21
+ "total", "total_count", "totalCount", "count", "next", "nextCursor", "next_cursor", "nextPage",
22
+ "next_page", "has_more", "hasMore", "more", "limit", "page_size", "pageSize", "offset", "cursor",
23
+ ];
24
+ function pagingHint(container, countedKey) {
25
+ if (container === null || typeof container !== "object" || Array.isArray(container))
26
+ return undefined;
27
+ const keys = Object.keys(container).filter((k) => k !== countedKey && PAGING_KEYS.includes(k));
28
+ if (keys.length === 0)
29
+ return undefined;
30
+ return `the probe's answer carries ${keys.join(", ")} next to the counted list: if it is paginated or capped, a full list under-counts. Count a total instead (--effect-count <path to the total>)`;
31
+ }
32
+ function errorText(result) {
33
+ const first = result.content?.find((c) => c.type === "text");
34
+ const text = typeof first?.text === "string" ? first.text.replace(/\s+/g, " ").trim() : "";
35
+ return text === "" ? "" : `: "${text.length > 200 ? text.slice(0, 200) + "…" : text}"`;
36
+ }
37
+ // What the upstream's answer says about "how many records". Throws with a
38
+ // message that says what to do.
39
+ export function parseProbeReading(result, countPath) {
40
+ if (result.isError) {
41
+ throw new Error(`the probe tool answered with an error result${errorText(result)} (does it need arguments? see --effect-probe-args)`);
42
+ }
43
+ let data = result.structuredContent;
44
+ if (data === undefined) {
45
+ const first = result.content?.find((c) => c.type === "text");
46
+ const text = typeof first?.text === "string" ? first.text : "";
47
+ try {
48
+ data = JSON.parse(text);
49
+ }
50
+ catch {
51
+ throw new Error("the probe tool's answer is not JSON (use a tool that returns JSON)");
52
+ }
53
+ }
54
+ if (countPath !== undefined) {
55
+ const picked = countPath === "" ? data : walk(data, countPath);
56
+ const n = countOf(picked);
57
+ if (n === null)
58
+ throw new Error(`--effect-count "${countPath}" does not lead to an array, a number or an object in the answer`);
59
+ const parts = countPath.split(".").filter((p) => p !== "");
60
+ const last = parts.length > 0 ? parts[parts.length - 1] : null;
61
+ const parent = parts.length > 1 ? walk(data, parts.slice(0, -1).join(".")) : data;
62
+ const warning = Array.isArray(picked) ? pagingHint(parent, last) : undefined;
63
+ return { count: n, ...(warning ? { warning } : {}) };
64
+ }
65
+ if (Array.isArray(data))
66
+ return { count: data.length };
67
+ if (typeof data === "number") {
68
+ const n = countOf(data);
69
+ if (n === null)
70
+ throw new Error("the probe's answer is a number that is not a count (negative or not finite)");
71
+ return { count: n };
72
+ }
73
+ if (data !== null && typeof data === "object") {
74
+ const obj = data;
75
+ const arrays = Object.entries(obj).filter(([, v]) => Array.isArray(v));
76
+ if (arrays.length === 1) {
77
+ const warning = pagingHint(obj, arrays[0][0]);
78
+ return { count: arrays[0][1].length, ...(warning ? { warning } : {}) };
79
+ }
80
+ for (const key of ["count", "total", "total_count", "totalCount", "n"]) {
81
+ const n = typeof obj[key] === "number" ? countOf(obj[key]) : null;
82
+ if (n !== null)
83
+ return { count: n };
84
+ }
85
+ }
86
+ throw new Error("cannot tell what to count in the probe's answer: pass --effect-count <path>");
87
+ }
88
+ export function parseProbeCount(result, countPath) {
89
+ return parseProbeReading(result, countPath).count;
90
+ }
91
+ // An attempt the harness saw come back as a success, or run behind an injected
92
+ // timeout: it counts as certain to have created a record (see the assumptions).
93
+ const looksExecuted = (r) => r.outcome === "success" || r.tookEffect === true;
94
+ // Returns the history with the probe's findings applied (copies; the session's
95
+ // own records are never changed) plus a summary for the report. Without a usable
96
+ // reading the history is returned as it is.
97
+ export function applyEffectProbe(history, probe, ctx) {
98
+ const { fingerprint } = ctx;
99
+ const writes = history.filter((r) => r.toolClass === "write" && ctx.mayHaveExecuted(r));
100
+ const tools = [...new Set(writes.map((r) => r.toolName))];
101
+ const counted = probe.tools
102
+ ? writes.filter((r) => ctx.matchesTool(r.toolName, probe.tools ?? []))
103
+ : writes;
104
+ const operations = new Set(counted.map(fingerprint)).size;
105
+ const delta = probe.before !== null && probe.after !== null ? probe.after - probe.before : null;
106
+ const base = {
107
+ tool: probe.tool,
108
+ before: probe.before,
109
+ after: probe.after,
110
+ delta,
111
+ attempts: counted.length,
112
+ operations,
113
+ };
114
+ const none = (conclusion, note) => ({
115
+ history,
116
+ summary: { ...base, conclusion, note },
117
+ });
118
+ if (probe.error !== null)
119
+ return none("inconclusive", `the probe could not be read: ${probe.error}`);
120
+ if (delta === null)
121
+ return none("inconclusive", "no reading of the probe is available");
122
+ if (ctx.openCalls > 0) {
123
+ return none("inconclusive", `${ctx.openCalls} call(s) were still open at the upstream when the probe was read`);
124
+ }
125
+ if (probe.readAtCalls !== ctx.recordedCalls) {
126
+ return none("inconclusive", `the last reading of the probe is older than the latest call (${probe.readAtCalls} of ${ctx.recordedCalls} calls); a new reading is on its way`);
127
+ }
128
+ if (!probe.tools && tools.length > 1) {
129
+ return none("inconclusive", `${tools.length} different write tools reached the upstream (${tools.join(", ")}): a count cannot say which of them created which record. Name the tool(s) the probe counts with --effect-tools`);
130
+ }
131
+ if (counted.length === 0)
132
+ return none("no_writes", "no write of a counted tool reached the upstream, nothing to compare");
133
+ // Per operation: at least one record if one of its attempts looks executed,
134
+ // none otherwise; at most as many as it had attempts that may have run.
135
+ const byOperation = new Map();
136
+ for (const r of counted) {
137
+ const fp = fingerprint(r);
138
+ const list = byOperation.get(fp);
139
+ if (list)
140
+ list.push(r);
141
+ else
142
+ byOperation.set(fp, [r]);
143
+ }
144
+ let lo = 0;
145
+ for (const list of byOperation.values())
146
+ lo += list.some(looksExecuted) ? 1 : 0;
147
+ const hi = counted.length;
148
+ if (delta < lo || delta > hi) {
149
+ return none("inconclusive", `the probe counted ${delta} new record(s), but the ${counted.length} write(s) in ${operations} distinct operation(s) that reached the upstream imply between ${lo} and ${hi}: that contradicts the assumptions (the tool you probe does not count what these writes create, writes that create no record are in the run, or someone else wrote meanwhile). Not used.`);
150
+ }
151
+ const copies = new Map();
152
+ const mutate = (r) => {
153
+ let c = copies.get(r.callNumber);
154
+ if (!c) {
155
+ c = { ...r };
156
+ copies.set(r.callNumber, c);
157
+ }
158
+ return c;
159
+ };
160
+ const resolved = () => history.map((r) => copies.get(r.callNumber) ?? r);
161
+ if (delta === hi) {
162
+ // Every attempt created its record, including the ones whose outcome was open.
163
+ for (const r of counted) {
164
+ if (r.effectUnknown || r.tookEffect !== true) {
165
+ const c = mutate(r);
166
+ c.tookEffect = true;
167
+ c.effectUnknown = false;
168
+ c.effectResolved = true;
169
+ }
170
+ }
171
+ return {
172
+ history: resolved(),
173
+ summary: {
174
+ ...base,
175
+ conclusion: "confirmed",
176
+ note: `the probe counted ${delta} new record(s) for ${counted.length} write(s): every one created a record`,
177
+ },
178
+ };
179
+ }
180
+ if (delta === lo) {
181
+ // The minimum: every operation with an attempt that looks executed has exactly
182
+ // one record, every other operation none. Nothing was created twice.
183
+ for (const list of byOperation.values()) {
184
+ const holder = list.find(looksExecuted);
185
+ for (const r of list) {
186
+ if (r === holder) {
187
+ if (r.effectUnknown || r.tookEffect !== true) {
188
+ const c = mutate(r);
189
+ c.tookEffect = true;
190
+ c.effectUnknown = false;
191
+ c.effectResolved = true;
192
+ }
193
+ }
194
+ else if (r.tookEffect === true || r.effectUnknown || r.outcome === "upstream_error") {
195
+ const c = mutate(r);
196
+ // Seen as executed by the harness, but no new record: the upstream
197
+ // recognised the repeat. (An attempt that only ended in an error is
198
+ // simply resolved as "did not write".)
199
+ if (r.tookEffect === true && holder !== undefined)
200
+ c.effectProbeDeduplicated = true;
201
+ c.tookEffect = false;
202
+ c.effectUnknown = false;
203
+ c.effectResolved = true;
204
+ }
205
+ }
206
+ }
207
+ return {
208
+ history: resolved(),
209
+ summary: {
210
+ ...base,
211
+ conclusion: "deduplicated",
212
+ note: `the probe counted ${delta} new record(s) for ${counted.length} write(s) in ${operations} operation(s): repeated or failed writes created no new record`,
213
+ },
214
+ };
215
+ }
216
+ return none("partial", `the probe counted ${delta} new record(s) for ${counted.length} write(s) in ${operations} operation(s) (at least ${lo}, at most ${hi}): some repeats created a record and some did not, and a count cannot say which operation created which. The audit runs on the executions it saw`);
217
+ }
@@ -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,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
- function successfulReadBetween(history, afterCall, beforeCall) {
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 history = session.pending && session.pending.size > 0
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" && r.upstreamExecuted && !r.effectUnknown);
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
- if (!successfulReadBetween(history, uncertain.callNumber, otherLater.callNumber)) {
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${successfulReadBetween(history, uncertain.callNumber, otherLater.callNumber) ? "" : ", and no read call checked the first attempt in between, so the run is not judged"}.`,
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
- " (Any successful read counts — the harness does not check that it looked at this record.)" +
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
- const transport = new StdioClientTransport({ command, args, cwd, stderr: "pipe" });
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
- ? "Alle Tools eindeutig als Lese- oder Schreib-Tool eingeordnet"
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.map((c) => ` ${c.class.padEnd(12)} ${c.name} (${c.source})`),
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
  }