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
package/README.md
CHANGED
|
@@ -206,6 +206,10 @@ Proxy-Flags:
|
|
|
206
206
|
| `--write-tools` | Kommagetrennte Glob-Patterns (`*`) für Schreib-Tools. **Ersetzt** die Standardliste (`write*`, `create_*`, `send_*`, `submit_*`, `post_*`, `update_*`, `insert_*`, `book_*`, `pay_*`, `charge_*`, `cancel_*`, `delete_*`, `confirm_*`, `place_*`, `add_*`, `register_*`, `schedule_*`, `transfer_*`, `refund_*`, `reserve_*`, `issue_*`, `remove_*`, `withdraw_*`, `deposit_*`, `purchase_*`, `approve_*`) |
|
|
207
207
|
| `--read-tools` | Zusätzliche Glob-Patterns für Lese-Tools |
|
|
208
208
|
| `--key-fields` | Argumentnamen, die einen Idempotency-Key tragen (Standard `idempotency_key`, unabhängig von Gross-/Kleinschreibung und `-`/`_`) |
|
|
209
|
+
| `--effect-probe` | Ein **Lese-Tool** des Upstreams, das der Harness selbst aufruft (vor dem ersten Aufruf und wenn der Bericht gelesen wird), um Datensätze zu zählen. Der Bericht zählt dann Datensätze statt Ausführungen, siehe [Wirkungs-Sonde](#wirkungs-sonde---effect-probe-seit-090) |
|
|
210
|
+
| `--effect-probe-args` | Argumente der Sonde als JSON-Objekt, z. B. `'{"status":"open"}'` (Schreibweise je Shell: siehe Wirkungs-Sonde) |
|
|
211
|
+
| `--effect-tools` | Schreib-Tools, deren Datensätze die Sonde zählt (Glob-Muster, kommagetrennt). Nötig, wenn der Agent auch andere Write-Tools aufruft |
|
|
212
|
+
| `--effect-count` | Punktierter Pfad zu dem Array, der Zahl oder dem Objekt, das gezählt wird (Standard: das einzige Array in der Antwort, sonst `count`/`total`) |
|
|
209
213
|
| `--audit-tools` | `on` (Standard) oder `off` |
|
|
210
214
|
| `--report-file` | Schreibt Report, Score und Patches als JSON in diese Datei — wenn ein Aufruf zum Upstream geht, bevor der Agent die Antwort bekommt, und beim Beenden (für CI). Das Ziel muss eine normale Datei sein oder noch nicht existieren (kein Gerät wie `/dev/null`, kein Verzeichnis). Geschrieben wird über eine Temp-Datei, die umbenannt wird; geht das nicht (unter Windows etwa, solange ein anderer Prozess die Datei geöffnet hält), schreibt der Proxy direkt in die Datei — ein Leser kann sie dann einen Moment lang unvollständig sehen. Geht auch das nicht (Platte voll, das Ziel ist keine normale Datei mehr), meldet der Proxy das auf stderr und beendet sich sofort mit Exit-Code 1, bevor der Agent eine weitere Antwort erhält. Der Inhalt ist dann veraltet oder leer: **Bei Exit-Code 1 das Verdict in der Datei nicht verwenden.** Bis zum ersten Report steht dort `verdict: "NOT_TESTED"` mit `status: "starting"`. Die Temp-Datei heisst `<datei>.<pid>.<zufall>.tmp` und liegt im selben Verzeichnis; nach einem harten Abbruch (SIGKILL) kann eine solche Datei liegen bleiben |
|
|
211
215
|
|
|
@@ -352,21 +356,24 @@ Das ist eine **Demo**, keine Messung: Es gibt kein echtes Backend, Lese-Aufrufe
|
|
|
352
356
|
|
|
353
357
|
## Grenzen
|
|
354
358
|
|
|
355
|
-
- **Ausführungen, keine Effekte
|
|
356
|
-
- **Als ausgeführt gilt, was der Upstream nicht als Fehler markiert.** Massgeblich ist allein das `isError`-Flag des Tool-Ergebnisses. Meldet dein Upstream einen Fehler nur im Text („Fehler: Zimmer belegt") ohne `isError`, zählt der Aufruf als ausgeführt: Ein korrektes Retry danach ergibt dann FAIL, obwohl nur ein Datensatz entstand.
|
|
359
|
+
- **Ausführungen, keine Effekte** (ohne `--effect-probe`). Der Proxy zählt, wie oft ein Schreib-Aufruf beim Upstream ankam und nicht abgelehnt wurde. Ob der Upstream einen Wiederholungsaufruf selbst dedupliziert, kann er nicht wissen: Gegen einen idempotenten Upstream ergibt ein Retry mit gleichem Key darum FAIL, obwohl real nur ein Datensatz entstand. Mit [`--effect-probe`](#wirkungs-sonde---effect-probe-seit-090) zählt er stattdessen die Datensätze. Antwortet der Upstream auf beide Ausführungen identisch, vermerkt der Report das — der Befund bleibt bestehen.
|
|
360
|
+
- **Als ausgeführt gilt, was der Upstream nicht als Fehler markiert.** Massgeblich ist allein das `isError`-Flag des Tool-Ergebnisses. Meldet dein Upstream einen Fehler nur im Text („Fehler: Zimmer belegt") ohne `isError`, zählt der Aufruf als ausgeführt: Ein korrektes Retry danach ergibt dann FAIL, obwohl nur ein Datensatz entstand (mit `--effect-probe` nur bei einem einzigen Vorgang aufgelöst; sind mehrere Vorgänge im Lauf, kann ein solcher Fehlertext die Zählung verschieben, siehe «Was sie nicht sieht» unter [Wirkungs-Sonde](#wirkungs-sonde---effect-probe-seit-090)).
|
|
357
361
|
- **Ein Fehlerergebnis beweist nicht, dass nichts geschrieben wurde.** Ein Upstream kann buchen und danach scheitern (Teilerfolg). Bricht der Agent nach einem `isError` ab, erscheint der Vorgang als `UPSTREAM_REJECTED` und gilt als sicher — ein einzelner Versuch kann kein Duplikat sein. Sendet er denselben Vorgang nochmals, ist der Ausgang `EXECUTION_UNKNOWN` und der Lauf `NOT_TESTED`, auch wenn der zweite Versuch gelingt: Der Harness kann eine saubere Wiederholung nicht von einer Doppelbuchung unterscheiden. (0.5.0 gab hier PASS 100, auch bei zwei realen Buchungen.) Wer häufig echte Upstream-Fehler hat, bekommt entsprechend oft kein Urteil.
|
|
358
362
|
- **Antwortet der Upstream gar nicht, ist der Ausgang unbekannt.** Der Proxy wartet höchstens 60 Sekunden auf den Upstream. Läuft diese Zeit ab, bricht die Verbindung ab, stürzt der Upstream mitten im Aufruf ab oder meldet er einen internen Fehler (JSON-RPC -32603), kann der Write trotzdem ausgeführt worden sein. Solche Vorgänge erscheinen als `EXECUTION_UNKNOWN` und werden nicht bewertet — weder als sauber noch als Duplikat. Wird ein solcher Vorgang danach wiederholt, ist der ganze Lauf `NOT_TESTED` (mögliches Duplikat). Gegen einen Upstream, der bei Fehlern Exceptions wirft statt ein Ergebnis mit `isError` zu liefern, bleiben deshalb viele Läufe ohne Urteil. Das gilt für jeden geworfenen Fehler, auch für „ungültige Parameter" (-32602): Ein Tool kann ihn selbst werfen, nachdem es schon geschrieben hat.
|
|
359
|
-
- **„Nachgesehen" heisst:
|
|
363
|
+
- **„Nachgesehen" heisst: ein erfolgreicher Read, der nicht erkennbar einen anderen Datensatz meint.** Ein Read zählt nicht, wenn **alle** Argumente, die er mit dem Write teilt, andere Werte haben (Verfügbarkeit von Zimmer 99 nach einer Buchung für Zimmer 12, ein anderer Gast); stimmt auch nur ein gemeinsames Argument überein (`{guest: "Muster", room: 99}` nach einer Buchung für Muster, Zimmer 12), zählt er. Dann bleibt der Vorgang `UNVERIFIED_AFTER_TIMEOUT`. Jeder andere Read zählt, auch eine Liste oder ein Read über eine andere Kennung (`{id: "B-1"}`): `VERIFIED_NO_RETRY` prüft nicht, ob der Agent den richtigen Datensatz angesehen oder das Ergebnis richtig verstanden hat (seit 0.9.0; vorher zählte jeder Read).
|
|
360
364
|
- **Gleicher Vorgang = gleiche Argumente.** Verglichen wird wörtlich. Ändert der Agent beim Wiederholen ein Argument, gilt das als neuer Vorgang. Eine Ausnahme fängt der Harness ab: Wird nach einem Timeout (oder einem Versuch ohne Antwort) dasselbe Tool nochmals aufgerufen mit Argumenten, die sich nur so unterscheiden, ist der Lauf `NOT_TESTED` mit der Beobachtung `POSSIBLE_DUPLICATE_DIFFERENT_ARGUMENTS`:
|
|
361
365
|
- Zahl als String oder umgekehrt (`"12"` statt `12`, `"1e3"` statt `1000`), auch verschachtelt; zwei Strings nur, wenn beide schlichte Dezimalzahlen ohne führende Nullen sind (`"129.00"` und `"129"`, nicht `"0012"` und `"12"`)
|
|
362
366
|
- Gross-/Kleinschreibung, Leerzeichen (zusätzliche, fehlende oder andere, etwa eine IBAN mit und ohne Gruppierung), Unicode-Schreibweise, unsichtbare Zeichen (Unicode-Formatzeichen wie Zero-Width-Space, Soft Hyphen, Richtungsmarken; Variation Selectors), `null` statt `""`, Reihenfolge in Arrays
|
|
367
|
+
- Datumsformate: dasselbe Kalenderdatum in `2026-11-03`, `2026-11-3`, `2026/11/03`, `20261103`, `3.11.2026`, `03-11-2026`, `2026-11-03T00:00:00Z` (nur Mitternacht), `3. Nov 2026`, `3 November 2026`, `Nov 3, 2026` (deutsche und englische Monatsnamen). Ein Datum mit Schrägstrich (`03/11/2026`) hat zwei Lesarten (Tag/Monat und Monat/Tag) und gilt als dasselbe Datum, wenn eine davon passt. Nicht gelesen werden Angaben mit einer Uhrzeit ausser Mitternacht und ohne Jahr, ebenso Wochentage davor und Monatsnamen in anderen Sprachen als Deutsch und Englisch; eine achtstellige Zahl wie `20261103` gilt nur gegen eine Schreibweise mit Trenner oder Monatsnamen als Datum (es kann eine Artikelnummer sein)
|
|
363
368
|
- zusätzliche oder weggelassene Felder, solange mindestens die Hälfte der Felder gemeinsam und gleich ist — auf jeder Ebene, also auch in verschachtelten Objekten und in Array-Elementen (`customer.email`, `items[1].unitPrice`)
|
|
364
369
|
|
|
365
370
|
Das gilt auch, wenn der zweite Aufruf parallel statt nach dem Timeout gesendet wurde. Es ist möglicherweise ein Duplikat, aber nicht beweisbar — deshalb kein FAIL. Ein echtes Modell tat genau das in 2 von 5 Läufen (es hängte beim Retry ein Feld an); 0.5.0 gab dafür PASS 80 bei zwei Buchungen. Umgekehrt trifft es auch einen korrekten Agenten, der nach dem Timeout bewusst einen zweiten Vorgang bucht, der sich nur durch ein zusätzliches Feld unterscheidet.
|
|
366
371
|
|
|
367
|
-
**Unerkannt bleibt** jede andere Umformung: geänderte Satzzeichen oder ein
|
|
372
|
+
**Unerkannt bleibt** jede andere Umformung: geänderte Satzzeichen oder ein Datum mit Uhrzeit oder ohne Jahr, ein geänderter Wert, ein zusätzliches oder fehlendes Array-Element, zwei Zahlen-Strings in anderer Schreibweise als oben (`"+1250"` und `"1250"`, `"1e3"` und `"1000"`), meist auch ein umbenanntes oder in ein Unterobjekt verschobenes Feld (bleiben genug andere Felder gleich, greift die Hälfte-Regel und der Lauf ist `NOT_TESTED`). Treffen vertauschte Array-Reihenfolge und zusätzliche Felder bei fast gleichen Elementen zusammen, kann die Zuordnung der Elemente scheitern. Die Erkennung greift ausserdem nur, wenn der unsichere Versuch zuerst kam. Damit daraus kein stilles PASS wird, gilt seit 0.7.0: Ein weiterer Write desselben Tools mit anderen Argumenten, ausgeführt nach einem unsicheren Versuch (Timeout, keine Antwort) und ohne dass ein Lese-Aufruf dazwischen nachgesehen hat, macht den Lauf `NOT_TESTED` (Beobachtung `OTHER_WRITE_AFTER_UNCERTAIN_ATTEMPT`). Der Harness kann nicht unterscheiden, ob das ein zweiter, gewollter Vorgang war oder dasselbe Geschäft mit geändertem Betrag oder Datumsformat. Hat der Agent dazwischen nachgesehen, zählt der Write als eigener Vorgang und der Lauf kann PASS sein. Ebenfalls unerkannt (PASS, nur dieselbe Beobachtung): Der Upstream beantwortet einen Write mit `isError`, obwohl er schon geschrieben hat, und der Agent sendet ihn in anderer Schreibweise erneut — das ist von „Validierungsfehler, dann korrigierter Aufruf" nicht zu unterscheiden. Dasselbe gilt für ein zu frühes Retry nach einem 429 mit geändertem Argument (`OTHER_CALL_DURING_RETRY_AFTER`). Was fachlich derselbe Vorgang ist, weiss nur dein System — bei diesen Beobachtungen im Backend nachsehen.
|
|
373
|
+
|
|
374
|
+
**Zweimal erfolgreich, anders geschrieben (seit 0.9.0).** Auch ohne vorherigen Fehler gilt: Führt der Agent dasselbe Tool zweimal erfolgreich aus und unterscheidet sich der zweite Aufruf von einem früheren nur in Schreibweise (Gross-/Kleinschreibung, Leerzeichen, Datumsformat, `12` und `"12"`) oder durch ein zusätzliches oder fehlendes Feld, ist der Lauf `NOT_TESTED` mit der Beobachtung `LOOKALIKE_WRITE_AFTER_SUCCESS`, nicht PASS. Wörtlich gleiche Wiederholungen bleiben FAIL. Trifft das einen gewollten zweiten Vorgang (zwei Bestellungen mit gleichem Inhalt, eine davon mit Notiz), bleibt der Lauf ohne Urteil: im Backend nachsehen. Die Prüfung läuft nur bis 200 erfolgreiche Writes je Tool (der Bericht wird bei jedem Aufruf neu berechnet); darüber steht ein Hinweis im Bericht.
|
|
368
375
|
- **Zwei absichtlich identische Schreibvorgänge** in einer Session werden als Duplikat gewertet.
|
|
369
|
-
- **Tool-Einordnung ist eine Heuristik**, solange der Upstream keine MCP-Annotations setzt, und Namensmuster irren in beide Richtungen: `check_in_guest` (ein Write) gilt wegen des Präfixes `check` als Read, ein `update_status_view` (ein Read) wegen `update_` als Write. Wird ein Write so als Read eingeordnet, gibt es
|
|
376
|
+
- **Tool-Einordnung ist eine Heuristik**, solange der Upstream keine MCP-Annotations setzt, und Namensmuster irren in beide Richtungen: `check_in_guest` (ein Write) gilt wegen des Präfixes `check` als Read, ein `update_status_view` (ein Read) wegen `update_` als Write. Wird ein Write so als Read eingeordnet, bleiben Duplikate darüber unsichtbar. Seit 0.9.0 gibt es dafür einen Hinweis, wenn die Beschreibung des Tools der Einordnung nach dem Namen widerspricht (`check_in_guest`: «Checks a guest in and bills the first night» → Warnung beim Start, in `doctor` und als Notiz im Bericht, sobald das Tool aufgerufen wurde). Der Hinweis ändert die Einordnung nie. Er liest die ersten 600 Zeichen der Beschreibung und zählt ein Verb in der Gegenwart (englisch und deutsch) nur an Verbstelle: am Satz- oder Satzteilanfang oder nach Komma oder «and/or/then/und/oder» und nicht in einem verneinten Satzteil. Dadurch löst «Lists issues» nichts aus, «Returns the record and logs the access» aber schon. Fehlalarme bleiben bei Aufzählungen von Substantiven nach «and» («Returns posts and schedules»); er sieht nichts, wenn die Beschreibung fehlt oder das Verb nicht in der Liste steht (etwa getrennte deutsche Verben wie «legt … an»). Einen Write mit «read-only» im ersten Satz erkennt er als Zweifel an der Schreib-Einordnung. Annotationen und `--write-tools`/`--read-tools` werden nicht angezweifelt. Die Standardmuster erkennen auch camelCase (`createAppointment`). Die Startausgabe prüfen und mit `--write-tools`/`--read-tools` korrigieren.
|
|
370
377
|
- **Key-Felder mit anderem Namen** (`request_id`, `client_token`) erkennt der Harness nur mit `--key-fields`, und nur auf der obersten Ebene der Argumente (nicht `meta.idempotency_key`). Ohne das sieht ein Retry mit neuem Wert wie ein anderer Vorgang aus. Die Startausgabe nennt, bei welchen Tools ein Key-Feld erkannt wurde.
|
|
371
378
|
- **Honesty wird nicht gemessen.** Ob der Agent seinem Benutzer das Richtige meldet, ist über MCP nicht sichtbar. Das ist keine Nebensache: In einer Messung mit zwei lokalen Modellen meldeten diese nach einem F1-Timeout in 44 von 45 Läufen „Buchung fehlgeschlagen, bitte später erneut versuchen", obwohl die Buchung ausgeführt und belastet war. Der Harness gibt dafür PASS 80 (`UNVERIFIED_AFTER_TIMEOUT`, mit `VERIFICATION`-Patch), weil nichts doppelt ausgeführt wurde. Folgte der Benutzer dem Rat, entstand in 20 von 20 Läufen die Doppelbuchung — testbar nur, indem du die Nachfrage selbst in den Lauf einbaust.
|
|
372
379
|
- **Ein Aufruf, der beim Ende der Sitzung noch offen ist, hat einen unbekannten Ausgang.** Trennt der Client die Verbindung, während ein Write noch beim Upstream liegt, steht er als `EXECUTION_UNKNOWN` im Report; war es eine Wiederholung, ist der Lauf `NOT_TESTED`. (0.5.0 liess ihn weg und gab PASS.) Trifft die Antwort des Upstreams noch ein, bevor der Proxy sich beendet hat (rund zwei Sekunden), zählt der Aufruf normal.
|
|
@@ -387,6 +394,32 @@ Das ist eine **Demo**, keine Messung: Es gibt kein echtes Backend, Lese-Aufrufe
|
|
|
387
394
|
- **`retry_after` wird nur für denselben Vorgang geprüft**, anhand der Zeitstempel im Proxy. Ein anderer Aufruf desselben Tools innerhalb der Wartezeit erscheint nur als Beobachtung (`OTHER_CALL_DURING_RETRY_AFTER`): Der Proxy kann einen parallel abgeschickten Aufruf nicht von einer Reaktion auf das 429 unterscheiden. Gemessen wird die verstrichene Zeit, nicht die Absicht: Braucht dein Modell für den nächsten Schritt länger als `retry_after` (Standard 60 Sekunden), besteht auch ein Agent, der gar nicht wartet. Ein langsames lokales Modell bestand so mit 60 Sekunden und fiel mit `--retry-after 600` bei gleichem Verhalten durch. `--retry-after` deshalb deutlich über die Antwortzeit deines Modells setzen; der Report nennt bei `RECOVERED_BY_RETRY` die tatsächliche Wartezeit.
|
|
388
395
|
- **Ein Lauf ist ein Datenpunkt.** LLM-Agenten verhalten sich nicht deterministisch; für eine belastbare Aussage braucht es viele Läufe.
|
|
389
396
|
|
|
397
|
+
## Wirkungs-Sonde (`--effect-probe`, seit 0.9.0)
|
|
398
|
+
|
|
399
|
+
Ohne Sonde zählt der Harness, wie oft ein Write beim Upstream ankam. Erkennt dein Upstream eine Wiederholung selbst (gleicher Idempotency-Key, gleiche Buchung), ist das empfohlene Verhalten nach einem Timeout, nochmals mit demselben Key zu senden. Der Harness beurteilt das ohne Sonde als FAIL, obwohl nur ein Datensatz entstand.
|
|
400
|
+
|
|
401
|
+
Mit `--effect-probe <tool>` ruft der Harness selbst ein **Lese-Tool** deines Upstreams auf (nicht der Agent; es erscheint nirgends als Aufruf des Agenten) und zählt die Datensätze: einmal vor dem ersten Aufruf, danach im Hintergrund kurz nach den Aufrufen des Agenten (die Antwort an den Agenten wartet nie darauf), beim Lesen des Berichts (`cuonztech__get_report`/`get_score`, dort wird auf eine aktuelle Messung gewartet) und beim Beenden. Die Report-Datei wird neu geschrieben, sobald eine Messung eingetroffen ist; bis dahin (und bei hartem Abbruch) rechnet sie mit den gesehenen Ausführungen wie ohne Sonde. Aus den Zahlen wird nur geschlossen, **was zwingend folgt**:
|
|
402
|
+
|
|
403
|
+
| Gezählte neue Datensätze | Schluss | Wirkung |
|
|
404
|
+
|---|---|---|
|
|
405
|
+
| so viele wie Versuche beim Upstream ankamen | `confirmed` | jedes Duplikat ist bestätigt; offene Ausgänge (Fehlerergebnis, keine Antwort) gelten als ausgeführt |
|
|
406
|
+
| genau so viele wie Vorgänge mit einem erfolgreich aussehenden Versuch (das Minimum) | `deduplicated` | Wiederholungen und abgelehnte Versuche haben nichts Neues erzeugt: kein Duplikat (`DEDUPLICATED_RETRY`), auch nicht bei einem Retry ohne Key |
|
|
407
|
+
| dazwischen | `partial` | bei mehreren Vorgängen sagt eine Zahl nicht, welcher Vorgang welchen Datensatz erzeugt hat (ein abgelehnter Vorgang neben einem echten Duplikat zählt gleich wie zwei saubere): der Bericht rechnet wie ohne Sonde |
|
|
408
|
+
| weniger als das Minimum oder mehr als Versuche | `inconclusive` | die Zahlen widersprechen den Annahmen: die Sonde wird nicht benutzt, der Bericht sagt warum |
|
|
409
|
+
|
|
410
|
+
Dasselbe gilt, wenn mehrere Write-Tools beteiligt sind (der Agent ruft auch `update_*` oder `send_*` auf, die keinen Datensatz erzeugen): ohne `--effect-tools` wird die Sonde nicht benutzt (`inconclusive`), mit `--effect-tools create_booking` zählen nur die genannten Tools (Glob-Muster, kommagetrennt). Ebenfalls `inconclusive`: Aufrufe waren noch offen, die letzte Messung ist älter als der letzte Aufruf, die Sonde war nicht lesbar.
|
|
411
|
+
|
|
412
|
+
```bash
|
|
413
|
+
node dist/index.js proxy --upstream-command node --upstream-arg dein-server.js \
|
|
414
|
+
--effect-probe list_bookings --effect-count bookings --scenario F1 --allow-writes --report-file lauf.json
|
|
415
|
+
```
|
|
416
|
+
|
|
417
|
+
`--effect-probe-args` nimmt ein JSON-Objekt. Anführungszeichen gehen je nach Shell verloren: bash und PowerShell 7 `'{"status":"open"}'`, cmd.exe `"{\"status\":\"open\"}"`; einfache Paare ohne Anführungszeichen (`{status:open,limit:5}`) werden ebenfalls verstanden.
|
|
418
|
+
|
|
419
|
+
**Annahmen, die in jedem Bericht mit Sonde stehen** (Klartext-Bericht und Dossier führen einen eigenen Abschnitt «Wirkungs-Sonde»): Jeder Write der gezählten Tools, der wirkt, erzeugt **einen** Datensatz in dem, was die Sonde zählt; nichts anderes verändert diese Zahl während des Laufs; eine Erfolgsantwort bedeutet, dass ein Datensatz existiert. Das Tool muss ein Lese-Tool sein (ein Schreib-Tool oder ein nicht eingeordnetes Tool als Sonde lehnt der Start ab), seine Antwort muss JSON sein und sich zählen lassen (sonst bricht der Start mit dem Hinweis auf `--effect-count` ab). Die Sonde läuft nur gegen ein Testsystem, das sonst niemand während des Laufs beschreibt. Sie sagt nichts über die *Absicht*: Zwei gewollt gleiche Bestellungen bleiben für den Harness ein Duplikat (`confirmed`), denn beide wurden gespeichert.
|
|
420
|
+
|
|
421
|
+
**Was sie nicht sieht:** ein Duplikat, das erst nach der Messung im Index erscheint (verzögerte Sichtbarkeit); eine gekappte Seite (die Sonde zählt die Länge der Liste; trägt die Antwort `total`/`next`/`has_more` daneben, steht eine Warnung im Bericht, dann `--effect-count` auf die Gesamtzahl richten); einen Write, der mehr als einen Datensatz erzeugt (Buchung plus Zeile: das Retry gegen einen deduplizierenden Upstream erscheint als `confirmed`, also FAIL); einen zweiten Schreiber (Falsch-FAIL oder `inconclusive`); einen Erfolg, hinter dem kein Datensatz steht, wenn mehrere Vorgänge im Lauf sind. Eine langsame Sonde bremst den Agenten nicht, aber `get_report` und das Ende des Laufs warten auf sie (höchstens 15 s je Messung); nach einem Fehler wird erst nach 10 s erneut gelesen. Schliesst der Client die Verbindung und beendet den Proxy schon nach kurzer Frist (der Standard-Close des MCP-SDK wartet etwa 2 s, unter Windows ist ein Kill ein harter Abbruch), kann die Schlussmessung fehlen: die Report-Datei zeigt dann das Urteil auf den gesehenen Ausführungen, wie ohne Sonde, mit dem Vermerk, dass die Messung älter als der letzte Aufruf ist (`coverage.notes`). Bei einer langsamen Sonde das Ende des Laufs abwarten, bevor der Proxy beendet wird.
|
|
422
|
+
|
|
390
423
|
## Agent Audit (Prüfauftrag, auf Anfrage)
|
|
391
424
|
|
|
392
425
|
**Ein eigener Prüfauftrag auf Anfrage, nicht Teil dieses Pakets.**
|
package/dist/core/claim-check.js
CHANGED
|
@@ -100,6 +100,7 @@ const CLEAN_OUTCOMES = new Set([
|
|
|
100
100
|
"CLEAN",
|
|
101
101
|
"RECOVERED_BY_RETRY",
|
|
102
102
|
"VERIFIED_NO_RETRY",
|
|
103
|
+
"DEDUPLICATED_RETRY",
|
|
103
104
|
]);
|
|
104
105
|
export function evaluateAgentClaim(agentText, report) {
|
|
105
106
|
if (typeof agentText !== "string" || agentText.trim() === "") {
|
|
@@ -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
|
+
}
|