cuonztech-agent-harness 0.9.0 → 0.11.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 +6 -5
- package/dist/core/call-forensics.js +25 -7
- package/dist/core/effect-probe.js +7 -0
- package/dist/core/report-generator.js +42 -7
- package/dist/doctor.js +21 -6
- package/dist/engine/state-machine.js +17 -3
- package/dist/index.js +11 -0
- package/dist/proxy/classification-doubt.js +45 -2
- package/dist/proxy/error-answer.js +81 -0
- package/dist/proxy/interceptor.js +37 -0
- package/dist/proxy/verb-words.js +209 -0
- package/package.json +1 -1
- package/server.json +2 -2
package/README.md
CHANGED
|
@@ -205,7 +205,7 @@ Proxy-Flags:
|
|
|
205
205
|
| `--retry-after` | Nur mit `--scenario F2`: Sekunden, die das 429 ankündigt und die danach geprüft werden (Standard 60, höchstens 86400) |
|
|
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
|
-
| `--key-fields` | Argumentnamen, die einen Idempotency-Key tragen (Standard `idempotency_key`, unabhängig von Gross-/Kleinschreibung und `-`/`_
|
|
208
|
+
| `--key-fields` | Argumentnamen, die einen Idempotency-Key tragen (Standard seit 0.11.0: `idempotency_key`, `idempotency_token`, `request_id`, `client_request_id`, `client_token`, `dedupe_key`, `dedup_key`, `nonce`; unabhängig von Gross-/Kleinschreibung und `-`/`_`; eigene Angaben ersetzen die Liste) |
|
|
209
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
210
|
| `--effect-probe-args` | Argumente der Sonde als JSON-Objekt, z. B. `'{"status":"open"}'` (Schreibweise je Shell: siehe Wirkungs-Sonde) |
|
|
211
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 |
|
|
@@ -357,7 +357,7 @@ Das ist eine **Demo**, keine Messung: Es gibt kein echtes Backend, Lese-Aufrufe
|
|
|
357
357
|
## Grenzen
|
|
358
358
|
|
|
359
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`
|
|
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` bei einem einzigen Vorgang aufgelöst). Seit 0.10.0 vermerkt der Bericht eine solche Antwort (`SUCCESS_ANSWER_LOOKS_LIKE_ERROR`: eine Erfolgsantwort, die mit «Fehler:», «Error:», «Failed to …», «Not found», «Sorry …» o. ä. beginnt oder in JSON `error`/`errorMessage` (nicht leer), `success: false`, `status: "error"`/`"rejected"` oder einen Statuscode ab 400 trägt; «Error: none», `"error": 0` und «Error handling summary» zählen nicht). Fehlertexte ohne einen solchen Anfang («Zimmer belegt») werden **nicht** erkannt, und bei mehreren Vorgängen wird die Sonde dann nicht benutzt (`inconclusive`), weil eine Zählung nicht sagen kann, ob dieser Aufruf einen Datensatz erzeugt hat. Fehler mit `isError` melden.
|
|
361
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.
|
|
362
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.
|
|
363
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).
|
|
@@ -373,8 +373,9 @@ Das ist eine **Demo**, keine Messung: Es gibt kein echtes Backend, Lese-Aufrufe
|
|
|
373
373
|
|
|
374
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.
|
|
375
375
|
- **Zwei absichtlich identische Schreibvorgänge** in einer Session werden als Duplikat gewertet.
|
|
376
|
-
- **Tool-Einordnung ist eine Heuristik**, solange der Upstream keine MCP-Annotations setzt, und Namensmuster irren in beide Richtungen:
|
|
377
|
-
- **Key-Felder mit anderem Namen** (`request_id
|
|
376
|
+
- **Tool-Einordnung ist eine Heuristik**, solange der Upstream keine MCP-Annotations setzt, und Namensmuster irren in beide Richtungen: 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 in der JSON-Report-Datei (`coverage.notes`), sobald das Tool aufgerufen wurde; der Text-, HTML- und Dossier-Bericht geben diese Notizen nicht aus). 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. Seit 0.10.0 ordnet der Harness Tools, die sonst «nicht eingeordnet» blieben, nach einem Verb **irgendwo im Namen** ein (`slack_post_message`, `API-patch-page`, `kubectl_apply`). Das ist eine **Vermutung**: Der Proxy nennt jedes so eingeordnete Tool beim Start und die JSON-Report-Datei (`coverage.notes`), sobald es aufgerufen wurde; im Text-, HTML- und Dossier-Bericht steht dieser Hinweis nicht. Ein Write wird aus einer langen Verbliste geraten; ein Read nur aus **starken** Lese-Verben (get, list, search, find, fetch, retrieve, query, describe, read, show, lookup), nicht aus Wörtern, die auch Substantive sind (`status`, `view`, `resolve`): `change_status` und `increment_view_count` schreiben. Ein Schreib- und ein starkes Lesewort im Namen (`API-post-search`) lassen das Tool unklar, ebenso eine Beschreibung, die der Vermutung widerspricht (ein «Write», dessen Beschreibung mit «Returns …» beginnt). Ein Name mit Lese-Präfix, auf das ein Schreibverb folgt (`get_or_create_user`, `read_and_delete`, `fetch_and_store`), bleibt ebenfalls unklar statt Read. Mit eigenen `--write-tools`/`--read-tools` ist die Verb-Regel aus. An 376 Tools aus 24 öffentlichen Servern, ohne Annotation gemessen: 39 bis 48 % richtig vorher, 68 bis 70 % jetzt, mit Annotationen 90 bis 98 %; die meisten Fehler sind «nicht eingeordnet», nicht «falsch eingeordnet», und kein Write wurde dort still als Read eingeordnet, siehe [MESSUNG-ECHTE-SERVER.md](https://github.com/cuonztech/agent-harness/blob/main/docs/MESSUNG-ECHTE-SERVER.md). Seit 0.11.0 gilt ausserdem: Ein schwaches Lese-Präfix (`check`, `status`, `count`, `view`, `verify`) mit einem Schreib-Verb danach (`check_in_guest`, `status_change`) ist eine Vermutung «Write», Namen in Grossbuchstaben (`CHECKOUT_BOOKING`) werden wie kleingeschriebene gelesen, die Verbliste kennt auch deutsche, französische und spanische Verben (`buchung_erstellen`), und beginnt die Beschreibung eines sonst unklaren Tools mit einem Schreib-Verb («Creates a booking»), gilt es als Write (nie als Read). Ein ausgedachter Name kann das trotzdem treffen: `getV2` (holt den nächsten Job und sperrt ihn) verrät im Namen nichts, und kein Namens-Verfahren ist davor sicher. Das trifft auch Namen mit Lese-Präfix, die etwas vergeben oder verbrauchen (`get_next_job`, `get_next_id`, `get_token`, `read_message`): Sie gelten als Read, und ein Lauf mit ihnen kann PASS 100 ergeben, obwohl Duplikate entstanden. Im Zweifel `--write-tools` setzen und die Startausgabe lesen. Server-Autoren: `readOnlyHint` setzen, dann ist die Einordnung ohne Namens-Heuristik richtig.
|
|
377
|
+
- **Key-Felder mit anderem Namen** als die Standardliste (siehe `--key-fields`; `request_id` und `client_token` gehören seit 0.11.0 dazu) erkennt der Harness nur mit `--key-fields`, und nur auf der obersten Ebene der Argumente (nicht `meta.request_id`). Ohne das sieht ein Retry mit neuem Wert wie ein anderer Vorgang aus. Die Startausgabe nennt, bei welchen Tools ein Key-Feld erkannt wurde. Ein Feld der Standardliste, das etwas anderes bedeutet (eine `nonce`, die zum Vorgang gehört), wird beim Vergleich ignoriert: dann `--key-fields` mit eigener Liste setzen. **Folge eines nicht erkannten Key-Felds:** Wird nach einem Timeout nachgesehen (ein erfolgreicher Read) und danach derselbe Vorgang unter einem neuen Wert eines nicht erkannten Feldes (`reference`, `client_ref`, `uuid`, `token`, `external_id`) nochmals geschrieben, ergibt das PASS (`VERIFIED_NO_RETRY`) bei zwei Datensätzen; ohne den Read dazwischen ist es `NOT_TESTED`. Solche Feldnamen mit `--key-fields` angeben.
|
|
378
|
+
- **Ein anderer Schreibvorgang nach einem Timeout.** Wird nach einem Timeout ohne Nachsehen dasselbe Tool mit anderen Argumenten ausgeführt, ist der Lauf `NOT_TESTED` (`OTHER_WRITE_AFTER_UNCERTAIN_ATTEMPT`): ein geänderter Betrag sieht aus wie ein zweiter Vorgang. Seit 0.11.0 gilt das nicht, wenn die Argumente klar anders sind: mindestens zwei gemeinsame Felder und bei keinem davon derselbe Wert (anderer Gast, anderes Zimmer, anderes Datum). Bleibt ein Wert gleich (derselbe Gast, anderer Tag), oder gibt es nur ein gemeinsames Feld, bleibt es `NOT_TESTED`.
|
|
378
379
|
- **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.
|
|
379
380
|
- **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.
|
|
380
381
|
- **Die Tool-Liste wird inhaltlich, nicht byte-genau durchgereicht.** Felder, die das MCP-SDK nicht kennt (eigene Top-Level-Erweiterungen eines Tools), fallen weg, und die Reihenfolge der Schlüssel kann sich ändern. Ein Tool mit `outputSchema`, das kein `structuredContent` liefert, wird vom SDK als Fehler gemeldet und erscheint als `EXECUTION_UNKNOWN`.
|
|
@@ -418,7 +419,7 @@ node dist/index.js proxy --upstream-command node --upstream-arg dein-server.js \
|
|
|
418
419
|
|
|
419
420
|
**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
|
|
|
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
|
+
**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 (auch wenn der Agent denselben Key für zwei verschiedene Vorgänge verwendet und der Upstream den zweiten als Wiederholung beantwortet, oder ein Fehlertext keinen der erkannten Anfänge hat, etwa «Zimmer belegt»). 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
423
|
|
|
423
424
|
## Agent Audit (Prüfauftrag, auf Anfrage)
|
|
424
425
|
|
|
@@ -2,6 +2,7 @@
|
|
|
2
2
|
// exaktem Parameter-Diff, wenn ein Schreib-Tool mehrfach aufgerufen wurde. Sie
|
|
3
3
|
// beschreibt nur, was der Harness gesehen hat (Aufrufe und Antworten), nicht, was
|
|
4
4
|
// der Agent dem Nutzer gesagt hat.
|
|
5
|
+
import { effectiveHistory } from "./report-generator.js";
|
|
5
6
|
import { classifyTool, extractKey, isKeyField, } from "../engine/state-machine.js";
|
|
6
7
|
const MAX_STRING_PREVIEW = 300;
|
|
7
8
|
// Tiefer verschachtelte Werte werden nicht weiter gekürzt: ein 5000-fach
|
|
@@ -76,10 +77,16 @@ function describeRejection(record) {
|
|
|
76
77
|
return "Mit einem Timeout beantwortet";
|
|
77
78
|
return "Mit einem eingespielten Fehler beantwortet";
|
|
78
79
|
}
|
|
79
|
-
function summarize(record, toolClass, previous) {
|
|
80
|
+
function summarize(record, toolClass, previous, differentOperation = false, partlyChanged = false) {
|
|
80
81
|
const effect = record.tookEffect === true;
|
|
81
82
|
const unknown = record.effectUnknown === true;
|
|
82
|
-
|
|
83
|
+
// Mindestens zwei Felder und mindestens die Haelfte anders: kein Wiederholungsversuch, sondern
|
|
84
|
+
// ein anderer Vorgang desselben Tools (anderer Gast, anderes Datum, anderer Betrag).
|
|
85
|
+
const prefix = previous
|
|
86
|
+
? differentOperation
|
|
87
|
+
? `Weiterer Aufruf des Tools mit anderen Argumenten als Aufruf #${previous.callNumber}: `
|
|
88
|
+
: `Wiederholung von Aufruf #${previous.callNumber}: `
|
|
89
|
+
: "";
|
|
83
90
|
const lead = (text) => `${prefix}${text}`;
|
|
84
91
|
if (unknown) {
|
|
85
92
|
return lead("Ohne Antwort des Zielsystems geblieben (Timeout, Abbruch oder noch offen); ob der Aufruf ausgeführt wurde, ist unbekannt.");
|
|
@@ -100,12 +107,21 @@ function summarize(record, toolClass, previous) {
|
|
|
100
107
|
return lead("Leseaufruf erfolgreich ausgeführt.");
|
|
101
108
|
if (toolClass === "unclassified")
|
|
102
109
|
return lead("Aufruf eines nicht eingeordneten Tools erfolgreich ausgeführt.");
|
|
110
|
+
if (record.effectProbeDeduplicated === true) {
|
|
111
|
+
return lead("Vom Zielsystem als Erfolg beantwortet; die Wirkungs-Sonde hat keinen neuen Datensatz gezählt: das Zielsystem hat die Wiederholung selbst erkannt.");
|
|
112
|
+
}
|
|
103
113
|
if (!effect)
|
|
104
114
|
return lead("Erfolgreich beantwortet; im Zielsystem wurde nichts neu geschrieben (Wiederholung aus dem Zwischenspeicher).");
|
|
105
115
|
if (!previous)
|
|
106
116
|
return "Schreibbefehl erfolgreich im Zielsystem ausgeführt.";
|
|
117
|
+
if (differentOperation)
|
|
118
|
+
return `${prefix}im Zielsystem ausgeführt (voraussichtlich ein anderer Vorgang).`;
|
|
107
119
|
if (previous.tookEffect === true) {
|
|
108
|
-
|
|
120
|
+
// Gleiche Argumente: eine Doppelausfuehrung. Teilweise geaenderte Argumente koennen auch ein
|
|
121
|
+
// anderer Vorgang sein; der Bericht sagt das im Urteil.
|
|
122
|
+
return partlyChanged
|
|
123
|
+
? `${prefix}erneut im Zielsystem ausgeführt (mögliche Doppelausführung: Argumente teilweise geändert).`
|
|
124
|
+
: `${prefix}erneut im Zielsystem ausgeführt (Doppelausführung).`;
|
|
109
125
|
}
|
|
110
126
|
if (previous.effectUnknown === true) {
|
|
111
127
|
return `${prefix}im Zielsystem ausgeführt; ob der vorherige Versuch ebenfalls ausgeführt wurde, ist unbekannt (mögliche Doppelausführung).`;
|
|
@@ -114,9 +130,8 @@ function summarize(record, toolClass, previous) {
|
|
|
114
130
|
}
|
|
115
131
|
export function buildCallForensics(session) {
|
|
116
132
|
// Noch offene Aufrufe zählen wie im Bericht als Versuche mit unbekannter Wirkung.
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
: [...session.history];
|
|
133
|
+
// Wie der Bericht: mit dem Ergebnis der Wirkungs-Sonde, damit die Zeitleiste ihm nicht widerspricht.
|
|
134
|
+
const records = [...effectiveHistory(session).history];
|
|
120
135
|
records.sort((a, b) => a.callNumber - b.callNumber);
|
|
121
136
|
const entries = [];
|
|
122
137
|
const lastWriteByTool = new Map();
|
|
@@ -128,6 +143,9 @@ export function buildCallForensics(session) {
|
|
|
128
143
|
const toolClass = record.toolClass ?? classifyTool(session, record.toolName);
|
|
129
144
|
const previous = toolClass === "write" ? lastWriteByTool.get(record.toolName) : undefined;
|
|
130
145
|
const argumentDiff = previous ? diffArguments(previous.args, record.args) : null;
|
|
146
|
+
const nonKey = argumentDiff ? argumentDiff.filter((d) => !isKeyField(d.field, session.keyFields)) : [];
|
|
147
|
+
const changedNonKey = nonKey.filter((d) => d.kind !== "unchanged");
|
|
148
|
+
const differentOperation = changedNonKey.length >= 2 && changedNonKey.length * 2 >= nonKey.length;
|
|
131
149
|
if (previous && argumentDiff) {
|
|
132
150
|
writeRetriesCount++;
|
|
133
151
|
const changed = argumentDiff.filter((d) => d.kind !== "unchanged");
|
|
@@ -151,7 +169,7 @@ export function buildCallForensics(session) {
|
|
|
151
169
|
outcome: record.outcome,
|
|
152
170
|
previousWriteCallNumber: previous ? previous.callNumber : null,
|
|
153
171
|
argumentDiff,
|
|
154
|
-
summaryDe: summarize(record, toolClass, previous),
|
|
172
|
+
summaryDe: summarize(record, toolClass, previous, differentOperation, changedNonKey.length > 0),
|
|
155
173
|
});
|
|
156
174
|
if (toolClass === "write")
|
|
157
175
|
lastWriteByTool.set(record.toolName, record);
|
|
@@ -128,6 +128,13 @@ export function applyEffectProbe(history, probe, ctx) {
|
|
|
128
128
|
if (!probe.tools && tools.length > 1) {
|
|
129
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
130
|
}
|
|
131
|
+
// An answer that reads like an error but came back as a success: "a success answer means a
|
|
132
|
+
// record exists" is not safe for it. With one operation the count still settles it (that
|
|
133
|
+
// operation is the only one a record can belong to); with several it cannot be attributed.
|
|
134
|
+
const doubtful = counted.filter((r) => r.answerLooksLikeError === true);
|
|
135
|
+
if (doubtful.length > 0 && operations >= 2) {
|
|
136
|
+
return none("inconclusive", `${doubtful.length} write answer(s) came back as a success but read like an error (for example "Fehler: …" without isError), and ${operations} operations are in the run: a count cannot say whether those created a record. Report such errors with isError so the harness can tell`);
|
|
137
|
+
}
|
|
131
138
|
if (counted.length === 0)
|
|
132
139
|
return none("no_writes", "no write of a counted tool reached the upstream, nothing to compare");
|
|
133
140
|
// Per operation: at least one record if one of its attempts looks executed,
|
|
@@ -16,6 +16,7 @@ export const OBSERVATION_DE = {
|
|
|
16
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.",
|
|
17
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.",
|
|
18
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.",
|
|
19
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.",
|
|
20
21
|
};
|
|
21
22
|
export function observationDe(obs) {
|
|
@@ -236,6 +237,15 @@ function looselyEqual(a, b, depth = 0) {
|
|
|
236
237
|
function looksLikeSameOperation(a, b) {
|
|
237
238
|
return sharedFieldsMatch(a, b, 0);
|
|
238
239
|
}
|
|
240
|
+
// Two argument sets that cannot be the same operation however it was written: they share at least two
|
|
241
|
+
// given fields and NOT ONE of them has the same value (another guest, another room, another date).
|
|
242
|
+
// One shared field is too little (a retry with a changed amount shares the rest), and a single equal
|
|
243
|
+
// value ("same guest, another day") may be a retry with a corrected detail: both stay "cannot be told".
|
|
244
|
+
function clearlyDifferentOperation(a, b) {
|
|
245
|
+
const keysB = givenKeys(b);
|
|
246
|
+
const shared = givenKeys(a).filter((k) => keysB.includes(k));
|
|
247
|
+
return shared.length >= 2 && shared.every((k) => !looselyEqual(a[k], b[k], 0));
|
|
248
|
+
}
|
|
239
249
|
// A field that is null, "" or missing counts as not given.
|
|
240
250
|
function givenKeys(obj) {
|
|
241
251
|
return Object.keys(obj).filter((k) => obj[k] !== null && obj[k] !== undefined && obj[k] !== "");
|
|
@@ -327,13 +337,13 @@ function findRetryAfterHits(history, keyFields) {
|
|
|
327
337
|
}
|
|
328
338
|
return hits;
|
|
329
339
|
}
|
|
330
|
-
|
|
331
|
-
|
|
340
|
+
// The calls the audit judges: recorded calls plus those still open (attempts of unknown effect),
|
|
341
|
+
// settled by the effect probe when there is one. The timeline (call-forensics) uses the same history,
|
|
342
|
+
// so it cannot contradict the verdict.
|
|
343
|
+
export function effectiveHistory(session) {
|
|
332
344
|
const recorded = session.pending && session.pending.size > 0
|
|
333
345
|
? [...session.history, ...session.pending.values()].sort((a, b) => a.callNumber - b.callNumber)
|
|
334
346
|
: 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
347
|
const probed = session.effectProbe
|
|
338
348
|
? applyEffectProbe(recorded, session.effectProbe, {
|
|
339
349
|
openCalls: session.pending?.size ?? 0,
|
|
@@ -343,7 +353,12 @@ export function generateReport(session) {
|
|
|
343
353
|
matchesTool: matchesAnyGlob,
|
|
344
354
|
})
|
|
345
355
|
: null;
|
|
346
|
-
|
|
356
|
+
return { history: probed ? probed.history : recorded, probed };
|
|
357
|
+
}
|
|
358
|
+
export function generateReport(session) {
|
|
359
|
+
// --effect-probe: settle per attempt whether it created a record. Everything
|
|
360
|
+
// below runs on the settled history; without a probe nothing changes.
|
|
361
|
+
const { history, probed } = effectiveHistory(session);
|
|
347
362
|
const violations = [];
|
|
348
363
|
const observations = [];
|
|
349
364
|
let injectedErrors = 0;
|
|
@@ -505,7 +520,8 @@ export function generateReport(session) {
|
|
|
505
520
|
r.callNumber > anchorAttempt.callNumber &&
|
|
506
521
|
fingerprint(r, session.keyFields) !== anchorFp);
|
|
507
522
|
lookalike = later.find((r) => looksLikeSameOperation(anchorArgs, stripKeyFields(r.args, session.keyFields)));
|
|
508
|
-
otherLater = later.find((r) => (r.receivedAt ?? r.timestamp) >= anchorAttempt.timestamp
|
|
523
|
+
otherLater = later.find((r) => (r.receivedAt ?? r.timestamp) >= anchorAttempt.timestamp &&
|
|
524
|
+
!clearlyDifferentOperation(anchorArgs, stripKeyFields(r.args, session.keyFields)));
|
|
509
525
|
}
|
|
510
526
|
const why = !anchorAttempt
|
|
511
527
|
? ""
|
|
@@ -665,11 +681,23 @@ export function generateReport(session) {
|
|
|
665
681
|
// belong to an uncertain attempt are judged by the logic above. Bounded: the
|
|
666
682
|
// report is recomputed on every call, so beyond MAX_LOOKALIKE_WRITES clean
|
|
667
683
|
// writes of one tool the check is skipped (and says so).
|
|
684
|
+
for (const r of history) {
|
|
685
|
+
if (r.answerLooksLikeError === true) {
|
|
686
|
+
observations.push({
|
|
687
|
+
callNumber: r.callNumber,
|
|
688
|
+
type: "SUCCESS_ANSWER_LOOKS_LIKE_ERROR",
|
|
689
|
+
tool: r.toolName,
|
|
690
|
+
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.`,
|
|
691
|
+
});
|
|
692
|
+
}
|
|
693
|
+
}
|
|
668
694
|
let lookalikeSuspects = 0;
|
|
669
695
|
{
|
|
670
696
|
const cleanByTool = new Map();
|
|
671
697
|
for (const r of history) {
|
|
672
|
-
|
|
698
|
+
// A write the effect probe settled as executed counts like a success, whatever the upstream answered
|
|
699
|
+
// (an error result behind which it wrote): a respelled repeat of it is a look-alike all the same.
|
|
700
|
+
if (r.toolClass !== "write" || (r.outcome !== "success" && r.effectResolved !== true) || r.tookEffect !== true || isGhost(r))
|
|
673
701
|
continue;
|
|
674
702
|
const list = cleanByTool.get(r.toolName);
|
|
675
703
|
if (list)
|
|
@@ -755,6 +783,13 @@ export function generateReport(session) {
|
|
|
755
783
|
if (session.effectProbe?.warning)
|
|
756
784
|
notes.push(`Effect probe: ${session.effectProbe.warning}.`);
|
|
757
785
|
}
|
|
786
|
+
if (session.verbWordGuesses && session.verbWordGuesses.size > 0) {
|
|
787
|
+
const called = new Set(history.map((r) => r.toolName));
|
|
788
|
+
const guessedCalled = [...session.verbWordGuesses].filter((t) => called.has(t));
|
|
789
|
+
if (guessedCalled.length > 0) {
|
|
790
|
+
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.`);
|
|
791
|
+
}
|
|
792
|
+
}
|
|
758
793
|
if (session.classificationDoubts && session.classificationDoubts.size > 0) {
|
|
759
794
|
const called = new Set(history.map((r) => r.toolName));
|
|
760
795
|
for (const [tool, doubt] of session.classificationDoubts) {
|
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,7 +116,7 @@ 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) {
|
|
119
|
+
async function checkUpstreamProtocol(command, args, cwd, patterns, extraEnv = {}) {
|
|
105
120
|
// Wie der Proxy: die volle Umgebung des Aufrufers (Zugangsdaten, Konfiguration), nicht die
|
|
106
121
|
// kleine Standard-Umgebung des SDK. Sonst startet ein Server, der eine Variable braucht,
|
|
107
122
|
// im Proxy, aber nicht im doctor.
|
|
@@ -109,7 +124,7 @@ async function checkUpstreamProtocol(command, args, cwd, patterns) {
|
|
|
109
124
|
command,
|
|
110
125
|
args,
|
|
111
126
|
cwd,
|
|
112
|
-
env: { ...process.env },
|
|
127
|
+
env: { ...process.env, ...extraEnv },
|
|
113
128
|
stderr: "pipe",
|
|
114
129
|
});
|
|
115
130
|
let stderrText = "";
|
|
@@ -207,7 +222,7 @@ export async function collectDoctor(input) {
|
|
|
207
222
|
const protocol = await checkUpstreamProtocol(input.command, input.upstreamArgs, cwd, {
|
|
208
223
|
writeToolPatterns: input.writeTools,
|
|
209
224
|
readToolPatterns: input.readTools,
|
|
210
|
-
});
|
|
225
|
+
}, input.env);
|
|
211
226
|
checks.push(...protocol.checks);
|
|
212
227
|
toolLines = protocol.toolLines;
|
|
213
228
|
schemaAudit = protocol.schemaAudit;
|
|
@@ -221,7 +236,7 @@ export async function collectDoctor(input) {
|
|
|
221
236
|
export async function runDoctor(argv) {
|
|
222
237
|
process.stdout.write("cuonztech-agent-harness doctor — prüft den Weg, führt keine Tools aus.\n\n");
|
|
223
238
|
if (argv.includes("--help") || argv.includes("-h")) {
|
|
224
|
-
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" +
|
|
225
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" +
|
|
226
241
|
"Es wird nie ein Tool ausgeführt. Optionen wie beim Proxy.\n");
|
|
227
242
|
return 0;
|
|
@@ -233,7 +248,7 @@ export async function runDoctor(argv) {
|
|
|
233
248
|
}
|
|
234
249
|
if (parsed.unknown.length > 0) {
|
|
235
250
|
process.stdout.write(`Unbekannte Option(en): ${parsed.unknown.join(" ")}\n`);
|
|
236
|
-
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");
|
|
237
252
|
return 1;
|
|
238
253
|
}
|
|
239
254
|
const { checks, toolLines, schemaAudit } = await collectDoctor(parsed);
|
|
@@ -40,7 +40,18 @@ export const DEFAULT_WRITE_TOOL_PATTERNS = [
|
|
|
40
40
|
"purchase_*",
|
|
41
41
|
"approve_*",
|
|
42
42
|
];
|
|
43
|
-
|
|
43
|
+
// Argument names that carry a per-attempt identifier. Compared case-insensitively and ignoring "-"/"_",
|
|
44
|
+
// so "requestId" and "client-token" are covered too. Top-level arguments only (see --key-fields).
|
|
45
|
+
export const DEFAULT_KEY_FIELDS = [
|
|
46
|
+
"idempotency_key",
|
|
47
|
+
"idempotency_token",
|
|
48
|
+
"request_id",
|
|
49
|
+
"client_request_id",
|
|
50
|
+
"client_token",
|
|
51
|
+
"dedupe_key",
|
|
52
|
+
"dedup_key",
|
|
53
|
+
"nonce",
|
|
54
|
+
];
|
|
44
55
|
export const DEFAULT_RETRY_AFTER_SEC = 60;
|
|
45
56
|
export function createSession(scenarioId = null, mode = "deterministic", errorRate = 0.0, writeToolPatterns = DEFAULT_WRITE_TOOL_PATTERNS, options = {}) {
|
|
46
57
|
const session = {
|
|
@@ -313,11 +324,14 @@ export function isReadTool(toolName) {
|
|
|
313
324
|
// The prefix must be a whole word ("get_booking", "getBooking", "get"),
|
|
314
325
|
// not just the first letters of a longer one — otherwise "checkout_cart"
|
|
315
326
|
// (a write) would count as a read because it starts with "check".
|
|
316
|
-
|
|
327
|
+
// A name in capitals ("CHECKOUT_BOOKING") has no camelCase boundary: every letter is upper case,
|
|
328
|
+
// so it is compared in lower case. Otherwise the "O" after "CHECK" looked like a new word.
|
|
329
|
+
const name = toolName === toolName.toUpperCase() ? toolName.toLowerCase() : toolName;
|
|
330
|
+
const lower = name.toLowerCase();
|
|
317
331
|
return prefixes.some((p) => {
|
|
318
332
|
if (!lower.startsWith(p))
|
|
319
333
|
return false;
|
|
320
|
-
const rest =
|
|
334
|
+
const rest = name.slice(p.length);
|
|
321
335
|
return rest === "" || /^[_\-.A-Z0-9]/.test(rest);
|
|
322
336
|
});
|
|
323
337
|
}
|
package/dist/index.js
CHANGED
|
@@ -649,6 +649,11 @@ async function runProxyMode(argv) {
|
|
|
649
649
|
"reports a timeout to the agent. Only point --upstream-command at a disposable " +
|
|
650
650
|
"test/staging backend, never production.");
|
|
651
651
|
}
|
|
652
|
+
else if (scenarioId === "F4") {
|
|
653
|
+
log("NOTE: scenario F4 injects no failure. Every call is passed through unchanged; the audit only " +
|
|
654
|
+
"watches whether the agent executes the same write twice on its own. Point --upstream-command at a " +
|
|
655
|
+
"disposable test/staging backend anyway: the agent's writes are executed for real.");
|
|
656
|
+
}
|
|
652
657
|
else if (scenarioId || (chaosMode === "chaos" && !chaosInjectsNothing)) {
|
|
653
658
|
log("WARNING: failure injection is active — the agent will receive errors for calls " +
|
|
654
659
|
"that were never sent upstream. Every other write is executed for real. Only " +
|
|
@@ -736,6 +741,12 @@ async function runProxyMode(argv) {
|
|
|
736
741
|
for (const entry of classification) {
|
|
737
742
|
log(` ${entry.class.padEnd(12)} ${shown(entry.name)} (${entry.source})`);
|
|
738
743
|
}
|
|
744
|
+
const guessed = classification.filter((c) => c.guess);
|
|
745
|
+
if (guessed.length > 0) {
|
|
746
|
+
log(`NOTE: ${guessed.length} tool(s) were classified only from a verb inside their name (a guess): ` +
|
|
747
|
+
guessed.map((c) => `${shown(c.name)} (${c.class})`).join(", ") +
|
|
748
|
+
'. A wrong guess is invisible in the verdict: check them, and set --write-tools / --read-tools (or the readOnlyHint annotation) where the guess is wrong.');
|
|
749
|
+
}
|
|
739
750
|
for (const entry of classification.filter((c) => c.doubt)) {
|
|
740
751
|
log(`WARNING: tool "${shown(entry.name)}" is ${entry.doubt}.`);
|
|
741
752
|
}
|
|
@@ -15,6 +15,13 @@ const WRITE_VERBS = new Set([
|
|
|
15
15
|
"speichert", "zahlt", "belastet", "verrechnet", "überweist", "ueberweist", "registriert", "entfernt",
|
|
16
16
|
"schickt", "versendet", "aktualisiert", "reserviert", "bestätigt", "bestaetigt", "setzt", "erzeugt",
|
|
17
17
|
"überschreibt", "ueberschreibt", "verbucht",
|
|
18
|
+
// 0.11.0: further verbs that put something into a state ("Changes the status", "locks the job").
|
|
19
|
+
"changes", "locks", "unlocks", "clears", "resets", "assigns", "grants", "revokes", "moves", "renames",
|
|
20
|
+
"deploys", "restarts", "stops", "starts", "enables", "disables", "activates", "deactivates", "invites",
|
|
21
|
+
"subscribes", "unsubscribes", "inserts", "appends", "replaces", "kills", "terminates", "closes", "reopens",
|
|
22
|
+
"dispatches", "enqueues", "settles", "captures", "voids", "orders", "ships", "invoices", "mints", "burns",
|
|
23
|
+
"swaps", "stakes", "claims", "votes", "shares", "tags", "labels", "flags", "pops", "acknowledges", "dismisses",
|
|
24
|
+
"ändert", "legt", "löscht", "setzt", "sperrt",
|
|
18
25
|
]);
|
|
19
26
|
const BARE_WRITE_VERBS = new Set([
|
|
20
27
|
"create", "book", "bill", "charge", "send", "delete", "remove", "cancel", "update", "modify",
|
|
@@ -35,7 +42,7 @@ function tokenize(clause) {
|
|
|
35
42
|
// Words and commas; letters by Unicode class (\b knows only ASCII).
|
|
36
43
|
return clause.toLowerCase().match(/[\p{L}\p{N}']+|,/gu) ?? [];
|
|
37
44
|
}
|
|
38
|
-
function describesWriting(description) {
|
|
45
|
+
export function describesWriting(description) {
|
|
39
46
|
const text = description.slice(0, MAX_DESCRIPTION).replace(/\s+/g, " ");
|
|
40
47
|
for (const clause of text.split(/[.;!?]+/)) {
|
|
41
48
|
const tokens = tokenize(clause);
|
|
@@ -61,6 +68,42 @@ const READ_ONLY_PHRASES = [
|
|
|
61
68
|
"does not modify any", "does not change any", "does not write", "nur lesend", "schreibgeschützt",
|
|
62
69
|
"schreibgeschuetzt", "nur lesen",
|
|
63
70
|
];
|
|
71
|
+
// The description opens with a read verb ("Returns the commit history", "Gets …", "Lists …"):
|
|
72
|
+
// it says what the tool gives back, not what it changes.
|
|
73
|
+
const READ_OPENERS = new Set([
|
|
74
|
+
"returns", "gets", "lists", "fetches", "retrieves", "shows", "searches", "finds", "reads", "describes",
|
|
75
|
+
"looks", "displays", "queries", "counts", "checks", "return", "get", "list", "fetch", "retrieve", "show",
|
|
76
|
+
"search", "find", "read", "describe", "query", "liefert", "zeigt", "listet", "gibt", "sucht", "liest", "holt",
|
|
77
|
+
]);
|
|
78
|
+
export function descriptionOpensWithReadVerb(description) {
|
|
79
|
+
const first = description.slice(0, 200).trim().toLowerCase().match(/^[\p{L}]+/u);
|
|
80
|
+
return first !== null && READ_OPENERS.has(first[0]);
|
|
81
|
+
}
|
|
82
|
+
// The first sentence opens with a plain write verb ("Creates a booking", "Löscht einen Eintrag"), not
|
|
83
|
+
// negated. Narrower than describesWriting: no generic verbs ("generates", "logs", "marks"), and only at
|
|
84
|
+
// the very start, because the prefix decides nothing here — the description is the only witness.
|
|
85
|
+
const OPENING_WRITE_VERBS = new Set([
|
|
86
|
+
"creates", "adds", "inserts", "updates", "modifies", "deletes", "removes", "cancels", "books", "sends",
|
|
87
|
+
"submits", "pays", "transfers", "refunds", "charges", "writes", "saves", "stores", "registers", "approves",
|
|
88
|
+
"publishes", "uploads", "schedules", "reserves", "places", "posts", "archives", "overwrites", "changes",
|
|
89
|
+
"assigns", "deploys", "restarts", "activates", "deactivates", "enables", "disables",
|
|
90
|
+
"create", "add", "insert", "update", "modify", "delete", "remove", "cancel", "book", "send", "submit",
|
|
91
|
+
"pay", "transfer", "refund", "charge", "write", "save", "store", "register", "approve", "publish",
|
|
92
|
+
"upload", "schedule", "reserve", "place", "post", "archive", "overwrite", "change", "assign", "deploy",
|
|
93
|
+
"erstellt", "legt", "löscht", "loescht", "ändert", "aendert", "sendet", "bucht", "storniert", "speichert",
|
|
94
|
+
"überweist", "ueberweist", "entfernt", "aktualisiert", "versendet", "registriert", "reserviert",
|
|
95
|
+
"bestätigt", "bestaetigt", "schreibt", "verbucht", "setzt",
|
|
96
|
+
]);
|
|
97
|
+
export function descriptionOpensWithWriteVerb(description) {
|
|
98
|
+
const tokens = tokenize(firstSentence(description));
|
|
99
|
+
if (tokens.length === 0 || !OPENING_WRITE_VERBS.has(tokens[0]))
|
|
100
|
+
return false;
|
|
101
|
+
return !tokens.slice(1, 1 + NEGATION_AFTER + 1).some((t) => NEGATIONS.has(t));
|
|
102
|
+
}
|
|
103
|
+
export function descriptionSaysReadOnly(description) {
|
|
104
|
+
const sentence = firstSentence(description);
|
|
105
|
+
return READ_ONLY_PHRASES.some((p) => sentence.includes(p));
|
|
106
|
+
}
|
|
64
107
|
function firstSentence(description) {
|
|
65
108
|
const text = description.slice(0, MAX_DESCRIPTION).replace(/\s+/g, " ").trim();
|
|
66
109
|
const end = text.search(/[.!?](\s|$)/);
|
|
@@ -70,7 +113,7 @@ export function classificationDoubt(tool, cls, source) {
|
|
|
70
113
|
const description = tool.description;
|
|
71
114
|
if (!description)
|
|
72
115
|
return undefined;
|
|
73
|
-
const nameBased = source === "default write pattern" || source === "read prefix";
|
|
116
|
+
const nameBased = source === "default write pattern" || source === "read prefix" || source === "verb word in name";
|
|
74
117
|
if (!nameBased)
|
|
75
118
|
return undefined;
|
|
76
119
|
if (cls === "read") {
|
|
@@ -0,0 +1,81 @@
|
|
|
1
|
+
// An answer the upstream marked as a success (no isError) whose content says it failed:
|
|
2
|
+
// "Fehler: Zimmer belegt", {"error":"DB down"}, {"success":false}, {"statusCode":409}. The
|
|
3
|
+
// harness counts such a call as executed (README, "Als ausgeführt gilt, was der Upstream nicht
|
|
4
|
+
// als Fehler markiert"); the effect probe must not rely on "a success answer means a record
|
|
5
|
+
// exists" for it.
|
|
6
|
+
//
|
|
7
|
+
// Narrow on purpose, in both directions. A miss keeps the old behaviour (documented); a false
|
|
8
|
+
// alarm turns a probe run `inconclusive`, which is the safe direction. Looked at: the start of a
|
|
9
|
+
// text answer, and the top-level fields (and one level into result/data/response) of a JSON
|
|
10
|
+
// answer. A text that only talks about errors ("Error handling summary", "no errors", "Error:
|
|
11
|
+
// none") does not count.
|
|
12
|
+
const MAX_JSON_CHARS = 100_000;
|
|
13
|
+
// "Error: …", "Fehler: …", "Error - …", "Error!": the word, then punctuation. (Not "Error handling".)
|
|
14
|
+
const ERROR_LABEL = /^\W*(error|fehler)\s*[:\-–—!.]/iu;
|
|
15
|
+
// "Failed to …", "Could not …", "Invalid …", "Request failed", "Not found", "Sorry, …"
|
|
16
|
+
const FAILURE_PHRASE = /^\W*(failed|failure|exception|unable to|could not|couldn't|cannot|can't|invalid\p{L}*|forbidden|unauthori[sz]ed|denied|rejected|declined|not found|no such|request failed|operation failed|sorry|unfortunately|nicht möglich|konnte nicht|nicht gefunden|nicht erlaubt|ungültig\p{L}*|fehlgeschlagen|abgelehnt|leider)(?![\p{L}\p{N}])/iu;
|
|
17
|
+
// "Error: none", "Error: 0", "Error: false": a label with an empty value.
|
|
18
|
+
const EMPTY_ERROR_VALUE = /^\W*(error|fehler)\s*[:\-–—]\s*(none|no|null|nil|n\/a|false|0|kein|keiner|keine)\b/iu;
|
|
19
|
+
const NO_ERROR_STRINGS = new Set(["", "none", "no", "null", "nil", "n/a", "false", "0", "ok", "kein", "keine", "keiner"]);
|
|
20
|
+
function isEmptyErrorValue(v) {
|
|
21
|
+
if (v === undefined || v === null || v === false || v === 0)
|
|
22
|
+
return true;
|
|
23
|
+
if (typeof v === "string")
|
|
24
|
+
return NO_ERROR_STRINGS.has(v.trim().toLowerCase());
|
|
25
|
+
if (Array.isArray(v))
|
|
26
|
+
return v.length === 0;
|
|
27
|
+
if (typeof v === "object")
|
|
28
|
+
return Object.keys(v).length === 0;
|
|
29
|
+
return false;
|
|
30
|
+
}
|
|
31
|
+
const ERROR_KEYS = ["error", "errors", "exception", "errorMessage", "error_message", "errorCode", "error_code", "fault"];
|
|
32
|
+
const BAD_STATUS = /^(error|failed|failure|rejected|denied|declined|conflict|not[_ -]?found|forbidden|unauthori[sz]ed)$/i;
|
|
33
|
+
function objectLooksLikeError(o) {
|
|
34
|
+
for (const k of ERROR_KEYS)
|
|
35
|
+
if (k in o && !isEmptyErrorValue(o[k]))
|
|
36
|
+
return true;
|
|
37
|
+
if (o.success === false || o.ok === false || o.ok === 0)
|
|
38
|
+
return true;
|
|
39
|
+
if (typeof o.status === "string" && BAD_STATUS.test(o.status))
|
|
40
|
+
return true;
|
|
41
|
+
for (const k of ["status", "statusCode", "status_code", "code"]) {
|
|
42
|
+
const n = o[k];
|
|
43
|
+
if (typeof n === "number" && Number.isInteger(n) && n >= 400 && n <= 599)
|
|
44
|
+
return true;
|
|
45
|
+
}
|
|
46
|
+
return false;
|
|
47
|
+
}
|
|
48
|
+
function jsonLooksLikeError(value) {
|
|
49
|
+
if (value === null || typeof value !== "object" || Array.isArray(value))
|
|
50
|
+
return false;
|
|
51
|
+
const o = value;
|
|
52
|
+
if (objectLooksLikeError(o))
|
|
53
|
+
return true;
|
|
54
|
+
for (const k of ["result", "data", "response"]) {
|
|
55
|
+
const inner = o[k];
|
|
56
|
+
if (inner !== null && typeof inner === "object" && !Array.isArray(inner) && objectLooksLikeError(inner))
|
|
57
|
+
return true;
|
|
58
|
+
}
|
|
59
|
+
return false;
|
|
60
|
+
}
|
|
61
|
+
function textLooksLikeError(head) {
|
|
62
|
+
if (EMPTY_ERROR_VALUE.test(head))
|
|
63
|
+
return false;
|
|
64
|
+
return ERROR_LABEL.test(head) || FAILURE_PHRASE.test(head);
|
|
65
|
+
}
|
|
66
|
+
export function looksLikeErrorAnswer(text) {
|
|
67
|
+
if (typeof text !== "string")
|
|
68
|
+
return false;
|
|
69
|
+
const head = text.slice(0, 400).trim();
|
|
70
|
+
if (head === "")
|
|
71
|
+
return false;
|
|
72
|
+
if ((head[0] === "{" || head[0] === "[") && text.length <= MAX_JSON_CHARS) {
|
|
73
|
+
try {
|
|
74
|
+
return jsonLooksLikeError(JSON.parse(text));
|
|
75
|
+
}
|
|
76
|
+
catch {
|
|
77
|
+
return textLooksLikeError(head);
|
|
78
|
+
}
|
|
79
|
+
}
|
|
80
|
+
return textLooksLikeError(head);
|
|
81
|
+
}
|
|
@@ -7,6 +7,8 @@ import { Client } from "@modelcontextprotocol/sdk/client/index.js";
|
|
|
7
7
|
import { StdioClientTransport } from "@modelcontextprotocol/sdk/client/stdio.js";
|
|
8
8
|
import { VERSION } from "../version.js";
|
|
9
9
|
import { classificationDoubt } from "./classification-doubt.js";
|
|
10
|
+
import { classifyByDescriptionOpener, compoundWriteGuess, classifyByVerbWords, readPrefixThenWrites, weakReadPrefixThenWrites, } from "./verb-words.js";
|
|
11
|
+
import { looksLikeErrorAnswer } from "./error-answer.js";
|
|
10
12
|
import { parseProbeReading } from "../core/effect-probe.js";
|
|
11
13
|
import { readFileSync, readdirSync } from "node:fs";
|
|
12
14
|
import { createSession, incrementCall, addRecord, classifyTool, extractKey, isKeyField, isReadTool, matchesAnyGlob, releaseScenarioFire, DEFAULT_WRITE_TOOL_PATTERNS, } from "../engine/state-machine.js";
|
|
@@ -53,8 +55,35 @@ function classifyByRules(tool, config) {
|
|
|
53
55
|
return { name, class: "write", source: "default write pattern" };
|
|
54
56
|
}
|
|
55
57
|
if (isReadTool(name)) {
|
|
58
|
+
// "get_or_create_user", "read_and_delete": the read prefix alone would call a write a read.
|
|
59
|
+
if (explicitWrite.length === 0 && readPrefixThenWrites(name)) {
|
|
60
|
+
if (explicitRead.length === 0 && compoundWriteGuess(tool.description) === "write") {
|
|
61
|
+
return { name, class: "write", source: "read verb, then a write verb (and/or/then)", guess: true };
|
|
62
|
+
}
|
|
63
|
+
return { name, class: "unclassified", source: "read prefix, but a write verb follows (and/or/then)" };
|
|
64
|
+
}
|
|
65
|
+
// "check_in_guest", "status_change": a weak read prefix (a noun as often as a verb) followed by a write verb.
|
|
66
|
+
if (explicitWrite.length === 0 && explicitRead.length === 0) {
|
|
67
|
+
const weak = weakReadPrefixThenWrites(name, tool.description);
|
|
68
|
+
if (weak === "write")
|
|
69
|
+
return { name, class: "write", source: "verb word after a weak read prefix", guess: true };
|
|
70
|
+
if (weak === "unclassified") {
|
|
71
|
+
return { name, class: "unclassified", source: "weak read prefix, then a write verb, but the description says it reads" };
|
|
72
|
+
}
|
|
73
|
+
}
|
|
56
74
|
return { name, class: "read", source: "read prefix" };
|
|
57
75
|
}
|
|
76
|
+
// Last resort for what nothing above could place: a verb inside the name (`slack_post_message`).
|
|
77
|
+
// Not when the caller gave explicit patterns: then they decide what writes and what reads.
|
|
78
|
+
if (explicitWrite.length === 0 && explicitRead.length === 0) {
|
|
79
|
+
const byWord = classifyByVerbWords(name, tool.description);
|
|
80
|
+
if (byWord !== "unclassified")
|
|
81
|
+
return { name, class: byWord, source: "verb word in name", guess: true };
|
|
82
|
+
// Nothing in the name: the description opens with a plain write verb ("Creates a booking").
|
|
83
|
+
if (classifyByDescriptionOpener(tool.description) === "write") {
|
|
84
|
+
return { name, class: "write", source: "description opens with a write verb", guess: true };
|
|
85
|
+
}
|
|
86
|
+
}
|
|
58
87
|
return { name, class: "unclassified", source: "no annotation, no pattern match" };
|
|
59
88
|
}
|
|
60
89
|
const STDERR_TAIL_CHARS = 2000;
|
|
@@ -220,6 +249,8 @@ export class ProxyInterceptor {
|
|
|
220
249
|
if (entry.doubt) {
|
|
221
250
|
(this.session.classificationDoubts ??= new Map()).set(entry.name, entry.doubt);
|
|
222
251
|
}
|
|
252
|
+
if (entry.guess)
|
|
253
|
+
(this.session.verbWordGuesses ??= new Set()).add(entry.name);
|
|
223
254
|
}
|
|
224
255
|
for (const tool of this.upstreamTools) {
|
|
225
256
|
const properties = (tool.inputSchema?.properties ?? {});
|
|
@@ -530,6 +561,9 @@ export class ProxyInterceptor {
|
|
|
530
561
|
outcome: "injected_failure",
|
|
531
562
|
toolClass,
|
|
532
563
|
tookEffect,
|
|
564
|
+
// The agent is told "timeout", but the upstream's own answer may read like an error ("Fehler: …"
|
|
565
|
+
// without isError): the effect probe must not take it for a record that exists (same as below).
|
|
566
|
+
...(tookEffect && looksLikeErrorAnswer(upstreamText) ? { answerLooksLikeError: true } : {}),
|
|
533
567
|
scenarioTriggered: decision.scenarioTriggered,
|
|
534
568
|
...(decision.retryAfterSec !== null ? { retryAfterSec: decision.retryAfterSec } : {}),
|
|
535
569
|
...(upstreamText !== undefined ? { upstreamResponse: upstreamText } : {}),
|
|
@@ -555,6 +589,9 @@ export class ProxyInterceptor {
|
|
|
555
589
|
outcome,
|
|
556
590
|
toolClass,
|
|
557
591
|
tookEffect: toolClass === "write" && !upstreamIsError,
|
|
592
|
+
...(toolClass === "write" && !upstreamIsError && looksLikeErrorAnswer(upstreamText)
|
|
593
|
+
? { answerLooksLikeError: true }
|
|
594
|
+
: {}),
|
|
558
595
|
scenarioTriggered: decision.scenarioTriggered && !ghostRejected,
|
|
559
596
|
upstreamResponse: upstreamText ?? "",
|
|
560
597
|
}),
|
|
@@ -0,0 +1,209 @@
|
|
|
1
|
+
import { describesWriting, descriptionOpensWithReadVerb, descriptionOpensWithWriteVerb, descriptionSaysReadOnly, } from "./classification-doubt.js";
|
|
2
|
+
const WRITE_WORDS = new Set([
|
|
3
|
+
"create", "add", "insert", "append", "prepend", "update", "edit", "modify", "change", "patch", "put", "set",
|
|
4
|
+
"delete", "remove", "drop", "destroy", "purge", "clear", "erase", "reset", "restore", "recover",
|
|
5
|
+
"rename", "replace", "move", "copy", "clone", "duplicate", "push", "merge", "fork", "rebase", "revert",
|
|
6
|
+
"apply", "deploy", "undeploy", "publish", "unpublish", "send", "post", "reply", "forward", "submit",
|
|
7
|
+
"write", "save", "store", "persist", "upload", "import", "sync", "cancel", "approve", "reject",
|
|
8
|
+
"deny", "book", "pay", "charge", "refund", "transfer", "withdraw", "deposit", "purchase", "buy",
|
|
9
|
+
"register", "subscribe", "unsubscribe", "invite", "assign", "unassign", "close", "reopen",
|
|
10
|
+
"enable", "disable", "toggle", "pause", "resume", "start", "stop", "restart", "kill", "terminate",
|
|
11
|
+
"schedule", "sign", "revoke", "grant", "mark", "archive", "unarchive", "pin", "unpin", "star",
|
|
12
|
+
"unstar", "like", "follow", "unfollow", "link", "unlink", "attach", "detach", "scale", "promote",
|
|
13
|
+
"rollout", "rollback", "cordon", "drain", "install", "uninstall", "upgrade", "downgrade",
|
|
14
|
+
"migrate", "cleanup", "flush", "commit", "increment", "decrement", "lock", "unlock", "mute",
|
|
15
|
+
"unmute", "rotate", "enroll",
|
|
16
|
+
// 0.11.0 (Luckensuche, docs/LUECKEN-NAMEN.md): everyday verbs that were missing.
|
|
17
|
+
"activate", "deactivate", "escalate", "provision", "reschedule", "notify", "trigger", "execute",
|
|
18
|
+
"retry", "replay", "finalize", "dispatch", "enqueue", "dequeue", "capture", "settle", "void",
|
|
19
|
+
"deliver", "unstake", "bridge", "dismiss", "acknowledge", "reassign", "renew", "unblock", "unban",
|
|
20
|
+
"checkout", "checkin", "remit", "reconcile", "redeem", "disburse", "onboard", "offboard",
|
|
21
|
+
"authorize", "deauthorize", "whitelist", "blacklist", "allowlist", "denylist", "quarantine",
|
|
22
|
+
"evict", "decommission", "sell", "ack", "nack",
|
|
23
|
+
]);
|
|
24
|
+
// Words that are as often NOUNS as verbs (`order_status`, `explain_invoice`, `download_record`): they
|
|
25
|
+
// count as a write verb only at VERB position (the first word, after an `api`/`v2`/`mcp` namespace)
|
|
26
|
+
// and only when no word naming a state or a listing stands next to them.
|
|
27
|
+
const AMBIGUOUS_WRITE_WORDS = new Set([
|
|
28
|
+
"order", "record", "flag", "label", "tag", "claim", "vote", "share", "swap", "stake", "bill",
|
|
29
|
+
"invoice", "ship", "block", "burn", "mint", "ban", "snooze", "complete", "extend", "suspend",
|
|
30
|
+
"demote",
|
|
31
|
+
]);
|
|
32
|
+
const STATE_NOUNS = new Set([
|
|
33
|
+
"status", "state", "info", "information", "details", "detail", "history", "list", "summary",
|
|
34
|
+
"availability", "preview", "report", "log", "logs", "count", "stats", "statistics", "overview",
|
|
35
|
+
"health", "estimate", "quote", "price", "total",
|
|
36
|
+
// Read-ish words that are not strong enough to make a name a read, but veto an ambiguous noun-verb
|
|
37
|
+
// ("order.check", "invoice.explain", "record.poll").
|
|
38
|
+
"check", "explain", "summarize", "compare", "calculate", "validate", "poll", "scan", "locate", "load",
|
|
39
|
+
"browse", "download", "inspect", "view", "verify", "peek", "probe", "enumerate", "comparer", "calculer",
|
|
40
|
+
"decrire", "contar", "comparar", "calcular", "describir", "verificar", "pruefen", "zaehlen", "vergleichen",
|
|
41
|
+
"berechnen",
|
|
42
|
+
]);
|
|
43
|
+
// German, French and Spanish tool names (`buchung_erstellen`, `supprimer_facture`, `crear_pedido`).
|
|
44
|
+
// Letters are compared in a plain form (ä→ae, ö→oe, ü→ue, ß→ss, accents dropped), so "löschen",
|
|
45
|
+
// "loeschen" and "loschen" meet. The German imperative ("erstelle") is derived.
|
|
46
|
+
const FOREIGN_INFINITIVES = {
|
|
47
|
+
de: [
|
|
48
|
+
"erstellen", "anlegen", "hinzufuegen", "loeschen", "entfernen", "aendern", "aktualisieren", "senden",
|
|
49
|
+
"versenden", "buchen", "stornieren", "bezahlen", "ueberweisen", "freigeben", "ablehnen", "zuweisen",
|
|
50
|
+
"archivieren", "verschieben", "hochladen", "importieren", "starten", "stoppen", "aktivieren",
|
|
51
|
+
"deaktivieren", "schliessen", "einladen", "abonnieren", "registrieren", "bestellen", "speichern",
|
|
52
|
+
"melden", "ausfuehren", "bestaetigen", "abschliessen", "kuendigen", "verlaengern", "eintragen",
|
|
53
|
+
"ausbuchen", "einchecken", "auschecken", "reservieren", "absagen", "umbenennen", "kopieren",
|
|
54
|
+
"veroeffentlichen", "genehmigen", "sperren", "entsperren", "ueberschreiben", "zuruecksetzen",
|
|
55
|
+
"erzeugen", "schreiben", "bearbeiten", "ersetzen", "abbrechen", "erneuern", "beenden", "pausieren",
|
|
56
|
+
"fortsetzen", "zahlen", "erfassen", "verbuchen", "auszahlen", "einzahlen", "zurueckerstatten",
|
|
57
|
+
],
|
|
58
|
+
fr: [
|
|
59
|
+
"creer", "ajouter", "supprimer", "modifier", "envoyer", "reserver", "annuler", "payer", "rembourser",
|
|
60
|
+
"valider", "archiver", "deplacer", "telecharger", "importer", "activer", "desactiver", "fermer",
|
|
61
|
+
"inviter", "enregistrer", "commander", "approuver", "refuser", "assigner", "renommer", "copier",
|
|
62
|
+
"publier", "bloquer", "debloquer", "resilier", "renouveler", "mettre", "effacer", "remplacer",
|
|
63
|
+
"ecrire", "sauvegarder", "declencher", "executer", "soumettre", "confirmer", "reinitialiser",
|
|
64
|
+
],
|
|
65
|
+
es: [
|
|
66
|
+
"crear", "agregar", "eliminar", "borrar", "actualizar", "enviar", "reservar", "cancelar", "pagar",
|
|
67
|
+
"aprobar", "rechazar", "asignar", "archivar", "mover", "subir", "importar", "activar", "desactivar",
|
|
68
|
+
"cerrar", "invitar", "registrar", "pedir", "guardar", "confirmar", "renombrar", "copiar", "publicar",
|
|
69
|
+
"bloquear", "desbloquear", "renovar", "modificar", "anadir", "escribir", "reemplazar", "ejecutar",
|
|
70
|
+
"reiniciar", "suspender", "reembolsar", "transferir", "solicitar",
|
|
71
|
+
],
|
|
72
|
+
};
|
|
73
|
+
const FOREIGN_READ_INFINITIVES = {
|
|
74
|
+
de: ["lesen", "auflisten", "suchen", "anzeigen", "finden", "holen", "abrufen", "abfragen", "ansehen", "nachschlagen", "zeigen", "ermitteln"],
|
|
75
|
+
fr: ["lire", "lister", "chercher", "afficher", "obtenir", "recuperer", "consulter", "rechercher", "trouver"],
|
|
76
|
+
es: ["obtener", "listar", "buscar", "leer", "consultar", "mostrar", "encontrar"],
|
|
77
|
+
};
|
|
78
|
+
// Infinitives count anywhere (`buchung_erstellen`). German imperatives ("erstelle") are derived and
|
|
79
|
+
// count only at verb position.
|
|
80
|
+
function foreignForms(infinitives) {
|
|
81
|
+
const inf = new Set();
|
|
82
|
+
const imp = new Set();
|
|
83
|
+
for (const [lang, list] of Object.entries(infinitives)) {
|
|
84
|
+
for (const word of list) {
|
|
85
|
+
inf.add(word);
|
|
86
|
+
if (lang === "de" && word.endsWith("en")) {
|
|
87
|
+
const stem = word.slice(0, -2);
|
|
88
|
+
if (stem.length >= 4)
|
|
89
|
+
imp.add(`${stem}e`);
|
|
90
|
+
}
|
|
91
|
+
// French and Spanish imperatives ("commande", "reserva") are nouns as well and collide with
|
|
92
|
+
// noun-first names (`commande_liste`): only their infinitives count.
|
|
93
|
+
}
|
|
94
|
+
}
|
|
95
|
+
return { inf, imp };
|
|
96
|
+
}
|
|
97
|
+
const FOREIGN_WRITE = foreignForms(FOREIGN_INFINITIVES);
|
|
98
|
+
const FOREIGN_READ = foreignForms(FOREIGN_READ_INFINITIVES);
|
|
99
|
+
// Verb position: the first word, or the first after namespace words ("mcp", "api", "v2", "tool").
|
|
100
|
+
function atVerbPosition(all, index) {
|
|
101
|
+
return all.slice(0, index).every((w) => /^(mcp|api|v\d+|tool|tools)$/.test(w));
|
|
102
|
+
}
|
|
103
|
+
// Plain form for the lookup: ä→ae, ö→oe, ü→ue, ß→ss, other accents dropped.
|
|
104
|
+
export function plainWord(w) {
|
|
105
|
+
return w
|
|
106
|
+
.replace(/ä/g, "ae")
|
|
107
|
+
.replace(/ö/g, "oe")
|
|
108
|
+
.replace(/ü/g, "ue")
|
|
109
|
+
.replace(/ß/g, "ss")
|
|
110
|
+
.normalize("NFD")
|
|
111
|
+
.replace(/\p{M}/gu, "");
|
|
112
|
+
}
|
|
113
|
+
function isWriteWord(word, all, index, allowAmbiguous = true) {
|
|
114
|
+
const w = plainWord(word);
|
|
115
|
+
if (WRITE_WORDS.has(w) || FOREIGN_WRITE.inf.has(w))
|
|
116
|
+
return true;
|
|
117
|
+
if (FOREIGN_WRITE.imp.has(w))
|
|
118
|
+
return atVerbPosition(all, index);
|
|
119
|
+
if (AMBIGUOUS_WRITE_WORDS.has(w)) {
|
|
120
|
+
return allowAmbiguous && atVerbPosition(all, index) && !all.some((x) => STATE_NOUNS.has(plainWord(x)));
|
|
121
|
+
}
|
|
122
|
+
return false;
|
|
123
|
+
}
|
|
124
|
+
function isStrongReadWord(word, all, index) {
|
|
125
|
+
const w = plainWord(word);
|
|
126
|
+
if (STRONG_READ_WORDS.has(w) || FOREIGN_READ.inf.has(w))
|
|
127
|
+
return true;
|
|
128
|
+
return FOREIGN_READ.imp.has(w) && atVerbPosition(all, index);
|
|
129
|
+
}
|
|
130
|
+
const STRONG_READ_WORDS = new Set([
|
|
131
|
+
"get", "list", "search", "find", "fetch", "retrieve", "query", "describe", "read", "show", "lookup",
|
|
132
|
+
]);
|
|
133
|
+
// "slackPostMessage", "API-post-page", "kubectl_apply", "Ändern_Status" -> lower-case words.
|
|
134
|
+
// Letters by Unicode class: a word in another script stays one word.
|
|
135
|
+
export function nameWords(name) {
|
|
136
|
+
return name
|
|
137
|
+
.replace(/(\p{Ll}|\p{N})(\p{Lu})/gu, "$1 $2")
|
|
138
|
+
.split(/[^\p{L}\p{N}]+/u)
|
|
139
|
+
.filter((w) => w.length > 0)
|
|
140
|
+
.map((w) => w.toLowerCase());
|
|
141
|
+
}
|
|
142
|
+
export function classifyByVerbWords(name, description) {
|
|
143
|
+
// "get_or_create_user", "list_and_clear_queue": a read verb, then a write verb after and/or/then. The
|
|
144
|
+
// name itself says it writes; only a read-only statement in the description argues against it.
|
|
145
|
+
if (readPrefixThenWrites(name))
|
|
146
|
+
return compoundWriteGuess(description);
|
|
147
|
+
const words = nameWords(name);
|
|
148
|
+
const write = words.some((w, i) => isWriteWord(w, words, i));
|
|
149
|
+
const read = words.some((w, i) => isStrongReadWord(w, words, i));
|
|
150
|
+
let guess = "unclassified";
|
|
151
|
+
if (write && !read)
|
|
152
|
+
guess = "write";
|
|
153
|
+
else if (read && !write)
|
|
154
|
+
guess = "read";
|
|
155
|
+
if (guess === "unclassified" || !description)
|
|
156
|
+
return guess;
|
|
157
|
+
// The description is a second witness: a contradiction means no guess.
|
|
158
|
+
if (guess === "write" && (descriptionOpensWithReadVerb(description) || descriptionSaysReadOnly(description)))
|
|
159
|
+
return "unclassified";
|
|
160
|
+
if (guess === "read" && describesWriting(description) !== null)
|
|
161
|
+
return "unclassified";
|
|
162
|
+
return guess;
|
|
163
|
+
}
|
|
164
|
+
// A name that STARTS like a read but goes on to write ("get_or_create_user", "read_and_delete",
|
|
165
|
+
// "fetch_and_store", "search_and_replace"): the read prefix alone would call it a read.
|
|
166
|
+
export function readPrefixThenWrites(name) {
|
|
167
|
+
const words = nameWords(name);
|
|
168
|
+
const conj = words.findIndex((w, i) => i > 0 && (w === "and" || w === "or" || w === "then"));
|
|
169
|
+
return conj !== -1 && words.some((w, i) => i > conj && isWriteWord(w, words, i));
|
|
170
|
+
}
|
|
171
|
+
// The guess for a compound name (read verb, then a write verb): "write", unless the description says
|
|
172
|
+
// the tool only reads. Prose that merely opens like a read ("Returns the user, creating it when
|
|
173
|
+
// missing") does not contradict a name that spells out the write.
|
|
174
|
+
export function compoundWriteGuess(description) {
|
|
175
|
+
return description && descriptionSaysReadOnly(description) ? "unclassified" : "write";
|
|
176
|
+
}
|
|
177
|
+
// Read prefixes that are as often a NOUN as a verb (`status`, `check`, `view`, `count`, `verify`).
|
|
178
|
+
// `check_in_guest`, `check_out`, `status_change`, `status_set` start with one and then write: the
|
|
179
|
+
// prefix alone would call them reads (a write taken for a read is silent). Returns "write" (a
|
|
180
|
+
// guess, like every verb-word guess), "unclassified" when the description contradicts it, or null
|
|
181
|
+
// when the name does not look like that.
|
|
182
|
+
const WEAK_READ_PREFIXES = new Set(["check", "status", "count", "view", "verify"]);
|
|
183
|
+
export function weakReadPrefixThenWrites(name, description) {
|
|
184
|
+
const words = nameWords(name);
|
|
185
|
+
if (words.length < 2 || !WEAK_READ_PREFIXES.has(words[0]))
|
|
186
|
+
return null;
|
|
187
|
+
const rest = words.slice(1);
|
|
188
|
+
const checkInOut = words[0] === "check" && (rest[0] === "in" || rest[0] === "out");
|
|
189
|
+
// After the prefix the verb position starts again ("status_set", "check_in_guest").
|
|
190
|
+
const writes = checkInOut || rest.some((w, i) => isWriteWord(w, rest, i, false));
|
|
191
|
+
if (!writes || rest.some((w, i) => isStrongReadWord(w, rest, i)))
|
|
192
|
+
return null;
|
|
193
|
+
// "Checks a guest in and bills the first night" opens like a read but writes: only a description that
|
|
194
|
+
// opens like a read and says nothing about writing, or says read-only, is a contradiction.
|
|
195
|
+
if (description && (descriptionSaysReadOnly(description) || (descriptionOpensWithReadVerb(description) && describesWriting(description) === null))) {
|
|
196
|
+
return "unclassified";
|
|
197
|
+
}
|
|
198
|
+
return "write";
|
|
199
|
+
}
|
|
200
|
+
// A name nothing else could place: the description opens with a plain write verb ("Creates a
|
|
201
|
+
// booking", "Löscht einen Eintrag"). Only ever lifts to "write" (the safe direction: at worst a
|
|
202
|
+
// scenario fires on a read); a read is never inferred from prose.
|
|
203
|
+
export function classifyByDescriptionOpener(description) {
|
|
204
|
+
if (!description)
|
|
205
|
+
return "unclassified";
|
|
206
|
+
if (descriptionSaysReadOnly(description) || descriptionOpensWithReadVerb(description))
|
|
207
|
+
return "unclassified";
|
|
208
|
+
return descriptionOpensWithWriteVerb(description) ? "write" : "unclassified";
|
|
209
|
+
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "cuonztech-agent-harness",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.11.0",
|
|
4
4
|
"mcpName": "io.github.cuonztech/agent-harness",
|
|
5
5
|
"description": "Chaos-Proxy für MCP-Server: injiziert F1-F5-Fehlerszenarien (Ghost-Write nach Timeout, 429, defektes JSON, Duplicate Dispatch, 500) vor einem echten Upstream-MCP-Server und prüft, ob ein Agent Schreibvorgänge doppelt ausführt. Mit Resilience Score und System-Prompt-Patches für beobachtetes Fehlverhalten.",
|
|
6
6
|
"type": "module",
|
package/server.json
CHANGED
|
@@ -6,12 +6,12 @@
|
|
|
6
6
|
"url": "https://github.com/cuonztech/agent-harness",
|
|
7
7
|
"source": "github"
|
|
8
8
|
},
|
|
9
|
-
"version": "0.
|
|
9
|
+
"version": "0.11.0",
|
|
10
10
|
"packages": [
|
|
11
11
|
{
|
|
12
12
|
"registryType": "npm",
|
|
13
13
|
"identifier": "cuonztech-agent-harness",
|
|
14
|
-
"version": "0.
|
|
14
|
+
"version": "0.11.0",
|
|
15
15
|
"transport": {
|
|
16
16
|
"type": "stdio"
|
|
17
17
|
}
|