cuonztech-agent-harness 0.10.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 +4 -3
- package/dist/core/report-generator.js +11 -1
- package/dist/engine/state-machine.js +17 -3
- package/dist/proxy/classification-doubt.js +28 -0
- package/dist/proxy/interceptor.js +20 -1
- package/dist/proxy/verb-words.js +160 -4
- 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 |
|
|
@@ -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`.
|
|
@@ -237,6 +237,15 @@ function looselyEqual(a, b, depth = 0) {
|
|
|
237
237
|
function looksLikeSameOperation(a, b) {
|
|
238
238
|
return sharedFieldsMatch(a, b, 0);
|
|
239
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
|
+
}
|
|
240
249
|
// A field that is null, "" or missing counts as not given.
|
|
241
250
|
function givenKeys(obj) {
|
|
242
251
|
return Object.keys(obj).filter((k) => obj[k] !== null && obj[k] !== undefined && obj[k] !== "");
|
|
@@ -511,7 +520,8 @@ export function generateReport(session) {
|
|
|
511
520
|
r.callNumber > anchorAttempt.callNumber &&
|
|
512
521
|
fingerprint(r, session.keyFields) !== anchorFp);
|
|
513
522
|
lookalike = later.find((r) => looksLikeSameOperation(anchorArgs, stripKeyFields(r.args, session.keyFields)));
|
|
514
|
-
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)));
|
|
515
525
|
}
|
|
516
526
|
const why = !anchorAttempt
|
|
517
527
|
? ""
|
|
@@ -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
|
}
|
|
@@ -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",
|
|
@@ -72,6 +79,27 @@ export function descriptionOpensWithReadVerb(description) {
|
|
|
72
79
|
const first = description.slice(0, 200).trim().toLowerCase().match(/^[\p{L}]+/u);
|
|
73
80
|
return first !== null && READ_OPENERS.has(first[0]);
|
|
74
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
|
+
}
|
|
75
103
|
export function descriptionSaysReadOnly(description) {
|
|
76
104
|
const sentence = firstSentence(description);
|
|
77
105
|
return READ_ONLY_PHRASES.some((p) => sentence.includes(p));
|
|
@@ -7,7 +7,7 @@ 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";
|
|
10
|
+
import { classifyByDescriptionOpener, compoundWriteGuess, classifyByVerbWords, readPrefixThenWrites, weakReadPrefixThenWrites, } from "./verb-words.js";
|
|
11
11
|
import { looksLikeErrorAnswer } from "./error-answer.js";
|
|
12
12
|
import { parseProbeReading } from "../core/effect-probe.js";
|
|
13
13
|
import { readFileSync, readdirSync } from "node:fs";
|
|
@@ -57,8 +57,20 @@ function classifyByRules(tool, config) {
|
|
|
57
57
|
if (isReadTool(name)) {
|
|
58
58
|
// "get_or_create_user", "read_and_delete": the read prefix alone would call a write a read.
|
|
59
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
|
+
}
|
|
60
63
|
return { name, class: "unclassified", source: "read prefix, but a write verb follows (and/or/then)" };
|
|
61
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
|
+
}
|
|
62
74
|
return { name, class: "read", source: "read prefix" };
|
|
63
75
|
}
|
|
64
76
|
// Last resort for what nothing above could place: a verb inside the name (`slack_post_message`).
|
|
@@ -67,6 +79,10 @@ function classifyByRules(tool, config) {
|
|
|
67
79
|
const byWord = classifyByVerbWords(name, tool.description);
|
|
68
80
|
if (byWord !== "unclassified")
|
|
69
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
|
+
}
|
|
70
86
|
}
|
|
71
87
|
return { name, class: "unclassified", source: "no annotation, no pattern match" };
|
|
72
88
|
}
|
|
@@ -545,6 +561,9 @@ export class ProxyInterceptor {
|
|
|
545
561
|
outcome: "injected_failure",
|
|
546
562
|
toolClass,
|
|
547
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 } : {}),
|
|
548
567
|
scenarioTriggered: decision.scenarioTriggered,
|
|
549
568
|
...(decision.retryAfterSec !== null ? { retryAfterSec: decision.retryAfterSec } : {}),
|
|
550
569
|
...(upstreamText !== undefined ? { upstreamResponse: upstreamText } : {}),
|
package/dist/proxy/verb-words.js
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { describesWriting, descriptionOpensWithReadVerb, descriptionSaysReadOnly } from "./classification-doubt.js";
|
|
1
|
+
import { describesWriting, descriptionOpensWithReadVerb, descriptionOpensWithWriteVerb, descriptionSaysReadOnly, } from "./classification-doubt.js";
|
|
2
2
|
const WRITE_WORDS = new Set([
|
|
3
3
|
"create", "add", "insert", "append", "prepend", "update", "edit", "modify", "change", "patch", "put", "set",
|
|
4
4
|
"delete", "remove", "drop", "destroy", "purge", "clear", "erase", "reset", "restore", "recover",
|
|
@@ -13,7 +13,120 @@ const WRITE_WORDS = new Set([
|
|
|
13
13
|
"rollout", "rollback", "cordon", "drain", "install", "uninstall", "upgrade", "downgrade",
|
|
14
14
|
"migrate", "cleanup", "flush", "commit", "increment", "decrement", "lock", "unlock", "mute",
|
|
15
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",
|
|
16
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
|
+
}
|
|
17
130
|
const STRONG_READ_WORDS = new Set([
|
|
18
131
|
"get", "list", "search", "find", "fetch", "retrieve", "query", "describe", "read", "show", "lookup",
|
|
19
132
|
]);
|
|
@@ -27,9 +140,13 @@ export function nameWords(name) {
|
|
|
27
140
|
.map((w) => w.toLowerCase());
|
|
28
141
|
}
|
|
29
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);
|
|
30
147
|
const words = nameWords(name);
|
|
31
|
-
const write = words.some((w) =>
|
|
32
|
-
const read = words.some((w) =>
|
|
148
|
+
const write = words.some((w, i) => isWriteWord(w, words, i));
|
|
149
|
+
const read = words.some((w, i) => isStrongReadWord(w, words, i));
|
|
33
150
|
let guess = "unclassified";
|
|
34
151
|
if (write && !read)
|
|
35
152
|
guess = "write";
|
|
@@ -49,5 +166,44 @@ export function classifyByVerbWords(name, description) {
|
|
|
49
166
|
export function readPrefixThenWrites(name) {
|
|
50
167
|
const words = nameWords(name);
|
|
51
168
|
const conj = words.findIndex((w, i) => i > 0 && (w === "and" || w === "or" || w === "then"));
|
|
52
|
-
return conj !== -1 && words.
|
|
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";
|
|
53
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
|
}
|