cuonztech-agent-harness 0.9.0 → 0.10.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +3 -3
- package/dist/core/call-forensics.js +25 -7
- package/dist/core/effect-probe.js +7 -0
- package/dist/core/report-generator.js +31 -6
- package/dist/doctor.js +21 -6
- package/dist/index.js +11 -0
- package/dist/proxy/classification-doubt.js +17 -2
- package/dist/proxy/error-answer.js +81 -0
- package/dist/proxy/interceptor.js +18 -0
- package/dist/proxy/verb-words.js +53 -0
- package/package.json +1 -1
- package/server.json +2 -2
package/README.md
CHANGED
|
@@ -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,7 +373,7 @@ 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: `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.
|
|
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. 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 der Bericht, sobald es aufgerufen wurde. 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). Ein ausgedachter Name kann das trotzdem treffen: kein Namens-Verfahren ist davor sicher. Server-Autoren: `readOnlyHint` setzen, dann ist die Einordnung ohne Namens-Heuristik richtig.
|
|
377
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.
|
|
378
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.
|
|
379
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.
|
|
@@ -418,7 +418,7 @@ node dist/index.js proxy --upstream-command node --upstream-arg dein-server.js \
|
|
|
418
418
|
|
|
419
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
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.
|
|
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 (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
422
|
|
|
423
423
|
## Agent Audit (Prüfauftrag, auf Anfrage)
|
|
424
424
|
|
|
@@ -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) {
|
|
@@ -327,13 +328,13 @@ function findRetryAfterHits(history, keyFields) {
|
|
|
327
328
|
}
|
|
328
329
|
return hits;
|
|
329
330
|
}
|
|
330
|
-
|
|
331
|
-
|
|
331
|
+
// The calls the audit judges: recorded calls plus those still open (attempts of unknown effect),
|
|
332
|
+
// settled by the effect probe when there is one. The timeline (call-forensics) uses the same history,
|
|
333
|
+
// so it cannot contradict the verdict.
|
|
334
|
+
export function effectiveHistory(session) {
|
|
332
335
|
const recorded = session.pending && session.pending.size > 0
|
|
333
336
|
? [...session.history, ...session.pending.values()].sort((a, b) => a.callNumber - b.callNumber)
|
|
334
337
|
: 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
338
|
const probed = session.effectProbe
|
|
338
339
|
? applyEffectProbe(recorded, session.effectProbe, {
|
|
339
340
|
openCalls: session.pending?.size ?? 0,
|
|
@@ -343,7 +344,12 @@ export function generateReport(session) {
|
|
|
343
344
|
matchesTool: matchesAnyGlob,
|
|
344
345
|
})
|
|
345
346
|
: null;
|
|
346
|
-
|
|
347
|
+
return { history: probed ? probed.history : recorded, probed };
|
|
348
|
+
}
|
|
349
|
+
export function generateReport(session) {
|
|
350
|
+
// --effect-probe: settle per attempt whether it created a record. Everything
|
|
351
|
+
// below runs on the settled history; without a probe nothing changes.
|
|
352
|
+
const { history, probed } = effectiveHistory(session);
|
|
347
353
|
const violations = [];
|
|
348
354
|
const observations = [];
|
|
349
355
|
let injectedErrors = 0;
|
|
@@ -665,11 +671,23 @@ export function generateReport(session) {
|
|
|
665
671
|
// belong to an uncertain attempt are judged by the logic above. Bounded: the
|
|
666
672
|
// report is recomputed on every call, so beyond MAX_LOOKALIKE_WRITES clean
|
|
667
673
|
// writes of one tool the check is skipped (and says so).
|
|
674
|
+
for (const r of history) {
|
|
675
|
+
if (r.answerLooksLikeError === true) {
|
|
676
|
+
observations.push({
|
|
677
|
+
callNumber: r.callNumber,
|
|
678
|
+
type: "SUCCESS_ANSWER_LOOKS_LIKE_ERROR",
|
|
679
|
+
tool: r.toolName,
|
|
680
|
+
description: `"${r.toolName}" answered as a success (no isError), but the content reads like an error. Counted as executed. If the upstream reports failures only in text, a correct retry can look like a duplicate; report errors with isError.`,
|
|
681
|
+
});
|
|
682
|
+
}
|
|
683
|
+
}
|
|
668
684
|
let lookalikeSuspects = 0;
|
|
669
685
|
{
|
|
670
686
|
const cleanByTool = new Map();
|
|
671
687
|
for (const r of history) {
|
|
672
|
-
|
|
688
|
+
// A write the effect probe settled as executed counts like a success, whatever the upstream answered
|
|
689
|
+
// (an error result behind which it wrote): a respelled repeat of it is a look-alike all the same.
|
|
690
|
+
if (r.toolClass !== "write" || (r.outcome !== "success" && r.effectResolved !== true) || r.tookEffect !== true || isGhost(r))
|
|
673
691
|
continue;
|
|
674
692
|
const list = cleanByTool.get(r.toolName);
|
|
675
693
|
if (list)
|
|
@@ -755,6 +773,13 @@ export function generateReport(session) {
|
|
|
755
773
|
if (session.effectProbe?.warning)
|
|
756
774
|
notes.push(`Effect probe: ${session.effectProbe.warning}.`);
|
|
757
775
|
}
|
|
776
|
+
if (session.verbWordGuesses && session.verbWordGuesses.size > 0) {
|
|
777
|
+
const called = new Set(history.map((r) => r.toolName));
|
|
778
|
+
const guessedCalled = [...session.verbWordGuesses].filter((t) => called.has(t));
|
|
779
|
+
if (guessedCalled.length > 0) {
|
|
780
|
+
notes.push(`Classification by name only (a guess): ${guessedCalled.map(cell).join(", ")} ${guessedCalled.length === 1 ? "was" : "were"} classified from a verb inside the name, not from an annotation or a pattern. If a guess is wrong, duplicates through that tool are not seen as such; check with --write-tools / --read-tools.`);
|
|
781
|
+
}
|
|
782
|
+
}
|
|
758
783
|
if (session.classificationDoubts && session.classificationDoubts.size > 0) {
|
|
759
784
|
const called = new Set(history.map((r) => r.toolName));
|
|
760
785
|
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);
|
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
|
}
|
|
@@ -35,7 +35,7 @@ function tokenize(clause) {
|
|
|
35
35
|
// Words and commas; letters by Unicode class (\b knows only ASCII).
|
|
36
36
|
return clause.toLowerCase().match(/[\p{L}\p{N}']+|,/gu) ?? [];
|
|
37
37
|
}
|
|
38
|
-
function describesWriting(description) {
|
|
38
|
+
export function describesWriting(description) {
|
|
39
39
|
const text = description.slice(0, MAX_DESCRIPTION).replace(/\s+/g, " ");
|
|
40
40
|
for (const clause of text.split(/[.;!?]+/)) {
|
|
41
41
|
const tokens = tokenize(clause);
|
|
@@ -61,6 +61,21 @@ const READ_ONLY_PHRASES = [
|
|
|
61
61
|
"does not modify any", "does not change any", "does not write", "nur lesend", "schreibgeschützt",
|
|
62
62
|
"schreibgeschuetzt", "nur lesen",
|
|
63
63
|
];
|
|
64
|
+
// The description opens with a read verb ("Returns the commit history", "Gets …", "Lists …"):
|
|
65
|
+
// it says what the tool gives back, not what it changes.
|
|
66
|
+
const READ_OPENERS = new Set([
|
|
67
|
+
"returns", "gets", "lists", "fetches", "retrieves", "shows", "searches", "finds", "reads", "describes",
|
|
68
|
+
"looks", "displays", "queries", "counts", "checks", "return", "get", "list", "fetch", "retrieve", "show",
|
|
69
|
+
"search", "find", "read", "describe", "query", "liefert", "zeigt", "listet", "gibt", "sucht", "liest", "holt",
|
|
70
|
+
]);
|
|
71
|
+
export function descriptionOpensWithReadVerb(description) {
|
|
72
|
+
const first = description.slice(0, 200).trim().toLowerCase().match(/^[\p{L}]+/u);
|
|
73
|
+
return first !== null && READ_OPENERS.has(first[0]);
|
|
74
|
+
}
|
|
75
|
+
export function descriptionSaysReadOnly(description) {
|
|
76
|
+
const sentence = firstSentence(description);
|
|
77
|
+
return READ_ONLY_PHRASES.some((p) => sentence.includes(p));
|
|
78
|
+
}
|
|
64
79
|
function firstSentence(description) {
|
|
65
80
|
const text = description.slice(0, MAX_DESCRIPTION).replace(/\s+/g, " ").trim();
|
|
66
81
|
const end = text.search(/[.!?](\s|$)/);
|
|
@@ -70,7 +85,7 @@ export function classificationDoubt(tool, cls, source) {
|
|
|
70
85
|
const description = tool.description;
|
|
71
86
|
if (!description)
|
|
72
87
|
return undefined;
|
|
73
|
-
const nameBased = source === "default write pattern" || source === "read prefix";
|
|
88
|
+
const nameBased = source === "default write pattern" || source === "read prefix" || source === "verb word in name";
|
|
74
89
|
if (!nameBased)
|
|
75
90
|
return undefined;
|
|
76
91
|
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 { classifyByVerbWords, readPrefixThenWrites } 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,19 @@ 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
|
+
return { name, class: "unclassified", source: "read prefix, but a write verb follows (and/or/then)" };
|
|
61
|
+
}
|
|
56
62
|
return { name, class: "read", source: "read prefix" };
|
|
57
63
|
}
|
|
64
|
+
// Last resort for what nothing above could place: a verb inside the name (`slack_post_message`).
|
|
65
|
+
// Not when the caller gave explicit patterns: then they decide what writes and what reads.
|
|
66
|
+
if (explicitWrite.length === 0 && explicitRead.length === 0) {
|
|
67
|
+
const byWord = classifyByVerbWords(name, tool.description);
|
|
68
|
+
if (byWord !== "unclassified")
|
|
69
|
+
return { name, class: byWord, source: "verb word in name", guess: true };
|
|
70
|
+
}
|
|
58
71
|
return { name, class: "unclassified", source: "no annotation, no pattern match" };
|
|
59
72
|
}
|
|
60
73
|
const STDERR_TAIL_CHARS = 2000;
|
|
@@ -220,6 +233,8 @@ export class ProxyInterceptor {
|
|
|
220
233
|
if (entry.doubt) {
|
|
221
234
|
(this.session.classificationDoubts ??= new Map()).set(entry.name, entry.doubt);
|
|
222
235
|
}
|
|
236
|
+
if (entry.guess)
|
|
237
|
+
(this.session.verbWordGuesses ??= new Set()).add(entry.name);
|
|
223
238
|
}
|
|
224
239
|
for (const tool of this.upstreamTools) {
|
|
225
240
|
const properties = (tool.inputSchema?.properties ?? {});
|
|
@@ -555,6 +570,9 @@ export class ProxyInterceptor {
|
|
|
555
570
|
outcome,
|
|
556
571
|
toolClass,
|
|
557
572
|
tookEffect: toolClass === "write" && !upstreamIsError,
|
|
573
|
+
...(toolClass === "write" && !upstreamIsError && looksLikeErrorAnswer(upstreamText)
|
|
574
|
+
? { answerLooksLikeError: true }
|
|
575
|
+
: {}),
|
|
558
576
|
scenarioTriggered: decision.scenarioTriggered && !ghostRejected,
|
|
559
577
|
upstreamResponse: upstreamText ?? "",
|
|
560
578
|
}),
|
|
@@ -0,0 +1,53 @@
|
|
|
1
|
+
import { describesWriting, descriptionOpensWithReadVerb, 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
|
+
]);
|
|
17
|
+
const STRONG_READ_WORDS = new Set([
|
|
18
|
+
"get", "list", "search", "find", "fetch", "retrieve", "query", "describe", "read", "show", "lookup",
|
|
19
|
+
]);
|
|
20
|
+
// "slackPostMessage", "API-post-page", "kubectl_apply", "Ändern_Status" -> lower-case words.
|
|
21
|
+
// Letters by Unicode class: a word in another script stays one word.
|
|
22
|
+
export function nameWords(name) {
|
|
23
|
+
return name
|
|
24
|
+
.replace(/(\p{Ll}|\p{N})(\p{Lu})/gu, "$1 $2")
|
|
25
|
+
.split(/[^\p{L}\p{N}]+/u)
|
|
26
|
+
.filter((w) => w.length > 0)
|
|
27
|
+
.map((w) => w.toLowerCase());
|
|
28
|
+
}
|
|
29
|
+
export function classifyByVerbWords(name, description) {
|
|
30
|
+
const words = nameWords(name);
|
|
31
|
+
const write = words.some((w) => WRITE_WORDS.has(w));
|
|
32
|
+
const read = words.some((w) => STRONG_READ_WORDS.has(w));
|
|
33
|
+
let guess = "unclassified";
|
|
34
|
+
if (write && !read)
|
|
35
|
+
guess = "write";
|
|
36
|
+
else if (read && !write)
|
|
37
|
+
guess = "read";
|
|
38
|
+
if (guess === "unclassified" || !description)
|
|
39
|
+
return guess;
|
|
40
|
+
// The description is a second witness: a contradiction means no guess.
|
|
41
|
+
if (guess === "write" && (descriptionOpensWithReadVerb(description) || descriptionSaysReadOnly(description)))
|
|
42
|
+
return "unclassified";
|
|
43
|
+
if (guess === "read" && describesWriting(description) !== null)
|
|
44
|
+
return "unclassified";
|
|
45
|
+
return guess;
|
|
46
|
+
}
|
|
47
|
+
// A name that STARTS like a read but goes on to write ("get_or_create_user", "read_and_delete",
|
|
48
|
+
// "fetch_and_store", "search_and_replace"): the read prefix alone would call it a read.
|
|
49
|
+
export function readPrefixThenWrites(name) {
|
|
50
|
+
const words = nameWords(name);
|
|
51
|
+
const conj = words.findIndex((w, i) => i > 0 && (w === "and" || w === "or" || w === "then"));
|
|
52
|
+
return conj !== -1 && words.slice(conj + 1).some((w) => WRITE_WORDS.has(w));
|
|
53
|
+
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "cuonztech-agent-harness",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.10.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.10.0",
|
|
10
10
|
"packages": [
|
|
11
11
|
{
|
|
12
12
|
"registryType": "npm",
|
|
13
13
|
"identifier": "cuonztech-agent-harness",
|
|
14
|
-
"version": "0.
|
|
14
|
+
"version": "0.10.0",
|
|
15
15
|
"transport": {
|
|
16
16
|
"type": "stdio"
|
|
17
17
|
}
|