cuonztech-agent-harness 0.8.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 +38 -5
- package/dist/core/call-forensics.js +25 -7
- package/dist/core/claim-check.js +1 -0
- package/dist/core/effect-probe.js +224 -0
- package/dist/core/report-generator.js +244 -13
- package/dist/doctor.js +39 -8
- package/dist/dossier-report.js +9 -2
- package/dist/index.js +113 -0
- package/dist/plain-report.js +32 -0
- package/dist/proxy/classification-doubt.js +104 -0
- package/dist/proxy/error-answer.js +81 -0
- package/dist/proxy/gateway.js +1 -0
- package/dist/proxy/interceptor.js +147 -0
- package/dist/proxy/verb-words.js +53 -0
- package/package.json +1 -1
- package/server.json +2 -2
package/dist/index.js
CHANGED
|
@@ -80,6 +80,26 @@ function printHelp() {
|
|
|
80
80
|
` --read-tools pat1,pat2,... Extra glob patterns for tool names that count as reads\n` +
|
|
81
81
|
` --key-fields a,b Argument names that carry an idempotency key\n` +
|
|
82
82
|
` (default: idempotency_key, any case/separator)\n` +
|
|
83
|
+
` --effect-probe <tool> A READ tool of the upstream the harness calls itself, before
|
|
84
|
+
` +
|
|
85
|
+
` the first call and when the report is read, to count
|
|
86
|
+
` +
|
|
87
|
+
` records. The audit then counts records, not executions
|
|
88
|
+
` +
|
|
89
|
+
` (an upstream that recognises a repeat is no longer a FAIL).
|
|
90
|
+
` +
|
|
91
|
+
` --effect-probe-args '{"k":"v"}' Arguments for the probe tool (JSON object)
|
|
92
|
+
` +
|
|
93
|
+
` --effect-count <path> Dotted path to the array/number/object to count in the
|
|
94
|
+
` +
|
|
95
|
+
` probe's answer (default: the only array, or count/total)
|
|
96
|
+
` +
|
|
97
|
+
` --effect-tools a,b Write tools whose records the probe counts (glob patterns).
|
|
98
|
+
` +
|
|
99
|
+
` Needed when the agent also calls other write tools
|
|
100
|
+
` +
|
|
101
|
+
` (update_*, send_*): a count cannot be attributed otherwise
|
|
102
|
+
` +
|
|
83
103
|
` --audit-tools on|off Expose cuonztech__get_report / cuonztech__get_score\n` +
|
|
84
104
|
` to the connected agent (default: on). For a\n` +
|
|
85
105
|
` measurement use off: their descriptions tell the\n` +
|
|
@@ -376,11 +396,44 @@ const PROXY_FLAGS = {
|
|
|
376
396
|
"--write-tools": "single",
|
|
377
397
|
"--read-tools": "single",
|
|
378
398
|
"--key-fields": "single",
|
|
399
|
+
"--effect-probe": "single",
|
|
400
|
+
"--effect-probe-args": "single",
|
|
401
|
+
"--effect-count": "single",
|
|
402
|
+
"--effect-tools": "single",
|
|
379
403
|
"--audit-tools": "single",
|
|
380
404
|
"--report-file": "single",
|
|
381
405
|
"--allow-writes": "boolean",
|
|
382
406
|
"--license": "single",
|
|
383
407
|
};
|
|
408
|
+
// --effect-probe-args: a JSON object. cmd.exe and Windows PowerShell 5.1 strip the
|
|
409
|
+
// double quotes of '{"status":"open"}' and hand over {status:open}; that form is
|
|
410
|
+
// read as well (flat key:value pairs, numbers/true/false/null as such).
|
|
411
|
+
function parseProbeArgs(raw) {
|
|
412
|
+
try {
|
|
413
|
+
const parsed = JSON.parse(raw);
|
|
414
|
+
return parsed !== null && typeof parsed === "object" && !Array.isArray(parsed) ? parsed : null;
|
|
415
|
+
}
|
|
416
|
+
catch {
|
|
417
|
+
// fall through to the loose form
|
|
418
|
+
}
|
|
419
|
+
const m = /^\s*\{(.*)\}\s*$/s.exec(raw);
|
|
420
|
+
if (!m || /["'\[\]{}]/.test(m[1]))
|
|
421
|
+
return null;
|
|
422
|
+
const out = {};
|
|
423
|
+
for (const pair of m[1].split(",")) {
|
|
424
|
+
if (pair.trim() === "")
|
|
425
|
+
continue;
|
|
426
|
+
const colon = pair.indexOf(":");
|
|
427
|
+
if (colon <= 0)
|
|
428
|
+
return null;
|
|
429
|
+
const key = pair.slice(0, colon).trim();
|
|
430
|
+
const value = pair.slice(colon + 1).trim();
|
|
431
|
+
if (key === "" || value === "")
|
|
432
|
+
return null;
|
|
433
|
+
out[key] = value === "true" ? true : value === "false" ? false : value === "null" ? null : /^-?\d+(\.\d+)?$/.test(value) ? Number(value) : value;
|
|
434
|
+
}
|
|
435
|
+
return Object.keys(out).length > 0 ? out : null;
|
|
436
|
+
}
|
|
384
437
|
function parseEnvPair(pair, flag, into) {
|
|
385
438
|
const eq = pair.indexOf("=");
|
|
386
439
|
const key = eq === -1 ? "" : pair.slice(0, eq).trim();
|
|
@@ -468,6 +521,34 @@ async function runProxyMode(argv) {
|
|
|
468
521
|
const writeToolPatterns = splitList(one("--write-tools"), "--write-tools");
|
|
469
522
|
const readToolPatterns = splitList(one("--read-tools"), "--read-tools");
|
|
470
523
|
const keyFields = splitList(one("--key-fields"), "--key-fields");
|
|
524
|
+
// --effect-probe: count the upstream's records instead of its executions.
|
|
525
|
+
const effectProbeTool = one("--effect-probe");
|
|
526
|
+
const effectProbeArgsRaw = one("--effect-probe-args");
|
|
527
|
+
const effectCountPath = one("--effect-count");
|
|
528
|
+
const effectToolPatterns = splitList(one("--effect-tools"), "--effect-tools") ?? [];
|
|
529
|
+
if (effectProbeTool === undefined && (effectProbeArgsRaw !== undefined || effectCountPath !== undefined || effectToolPatterns.length > 0)) {
|
|
530
|
+
fail("--effect-probe-args, --effect-count and --effect-tools need --effect-probe <tool>.");
|
|
531
|
+
}
|
|
532
|
+
let effectProbe;
|
|
533
|
+
if (effectProbeTool !== undefined) {
|
|
534
|
+
let probeArgs = {};
|
|
535
|
+
if (effectProbeArgsRaw !== undefined) {
|
|
536
|
+
const parsedArgs = parseProbeArgs(effectProbeArgsRaw);
|
|
537
|
+
if (parsedArgs === null) {
|
|
538
|
+
fail(`--effect-probe-args must be a JSON object, got "${effectProbeArgsRaw}".\n` +
|
|
539
|
+
" bash / PowerShell 7: --effect-probe-args '{\"status\":\"open\"}'\n" +
|
|
540
|
+
" cmd.exe: --effect-probe-args \"{\\\"status\\\":\\\"open\\\"}\"\n" +
|
|
541
|
+
" (without quotes, {status:open} is also understood: simple key:value pairs)");
|
|
542
|
+
}
|
|
543
|
+
probeArgs = parsedArgs;
|
|
544
|
+
}
|
|
545
|
+
effectProbe = {
|
|
546
|
+
tool: effectProbeTool,
|
|
547
|
+
args: probeArgs,
|
|
548
|
+
...(effectCountPath !== undefined ? { countPath: effectCountPath } : {}),
|
|
549
|
+
...(effectToolPatterns.length > 0 ? { tools: effectToolPatterns } : {}),
|
|
550
|
+
};
|
|
551
|
+
}
|
|
471
552
|
const upstreamCwd = one("--upstream-cwd");
|
|
472
553
|
if (upstreamCwd !== undefined) {
|
|
473
554
|
const info = await stat(upstreamCwd).catch(() => null);
|
|
@@ -568,6 +649,11 @@ async function runProxyMode(argv) {
|
|
|
568
649
|
"reports a timeout to the agent. Only point --upstream-command at a disposable " +
|
|
569
650
|
"test/staging backend, never production.");
|
|
570
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
|
+
}
|
|
571
657
|
else if (scenarioId || (chaosMode === "chaos" && !chaosInjectsNothing)) {
|
|
572
658
|
log("WARNING: failure injection is active — the agent will receive errors for calls " +
|
|
573
659
|
"that were never sent upstream. Every other write is executed for real. Only " +
|
|
@@ -591,6 +677,7 @@ async function runProxyMode(argv) {
|
|
|
591
677
|
readToolPatterns,
|
|
592
678
|
keyFields,
|
|
593
679
|
retryAfterSec,
|
|
680
|
+
effectProbe,
|
|
594
681
|
});
|
|
595
682
|
// From here on a signal must end the upstream child as well, and the
|
|
596
683
|
// report file must not keep an older run's verdict while we are starting.
|
|
@@ -628,6 +715,15 @@ async function runProxyMode(argv) {
|
|
|
628
715
|
? `\nUpstream stderr:\n${tail}`
|
|
629
716
|
: "\nThe upstream wrote nothing to stderr. Check the command, its arguments and that it speaks MCP over stdio."));
|
|
630
717
|
}
|
|
718
|
+
if (effectProbe) {
|
|
719
|
+
try {
|
|
720
|
+
await interceptor.initEffectProbe();
|
|
721
|
+
}
|
|
722
|
+
catch (err) {
|
|
723
|
+
await interceptor.disconnectUpstream().catch(() => undefined);
|
|
724
|
+
fail(err instanceof Error ? err.message : String(err));
|
|
725
|
+
}
|
|
726
|
+
}
|
|
631
727
|
const upstreamTools = interceptor.getUpstreamTools();
|
|
632
728
|
// Static view of the tool schemas, once: it does not change during the run.
|
|
633
729
|
const schemaAudit = auditToolSchemas(upstreamTools, { writeToolPatterns, readToolPatterns });
|
|
@@ -645,6 +741,15 @@ async function runProxyMode(argv) {
|
|
|
645
741
|
for (const entry of classification) {
|
|
646
742
|
log(` ${entry.class.padEnd(12)} ${shown(entry.name)} (${entry.source})`);
|
|
647
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
|
+
}
|
|
750
|
+
for (const entry of classification.filter((c) => c.doubt)) {
|
|
751
|
+
log(`WARNING: tool "${shown(entry.name)}" is ${entry.doubt}.`);
|
|
752
|
+
}
|
|
648
753
|
for (const entry of classification.filter((c) => c.class === "unclassified")) {
|
|
649
754
|
log(`WARNING: tool "${shown(entry.name)}" could not be classified as read or write. Its calls ` +
|
|
650
755
|
"are passed through but never trigger a scenario and are not scored — if it writes, " +
|
|
@@ -657,6 +762,11 @@ async function runProxyMode(argv) {
|
|
|
657
762
|
"Scenarios will NEVER trigger for this upstream and every report will read NOT_TESTED. " +
|
|
658
763
|
'If your write tools use different naming, pass e.g. --write-tools "yourprefix_*".');
|
|
659
764
|
}
|
|
765
|
+
const probeState = interceptor.getSession().effectProbe;
|
|
766
|
+
if (probeState) {
|
|
767
|
+
log(`Effect probe: "${shown(probeState.tool)}" counted ${probeState.before} record(s) before the first call. ` +
|
|
768
|
+
"The report will count new RECORDS instead of executions (assumption: every write that took effect creates one record in what the probe counts, and nothing else changes it during the run).");
|
|
769
|
+
}
|
|
660
770
|
const keyCapable = interceptor.getKeyCapableTools();
|
|
661
771
|
log(keyCapable.length > 0
|
|
662
772
|
? `Idempotency-key argument recognized on: ${keyCapable.map(shown).join(", ")}`
|
|
@@ -810,6 +920,7 @@ async function runProxyMode(argv) {
|
|
|
810
920
|
};
|
|
811
921
|
if (reportFile) {
|
|
812
922
|
interceptor.onCallRecorded = () => writeReportFile();
|
|
923
|
+
interceptor.onProbeRefreshed = () => void writeReportFile();
|
|
813
924
|
await writeReportFile();
|
|
814
925
|
if (reportWriteFailed) {
|
|
815
926
|
await interceptor.disconnectUpstream();
|
|
@@ -830,6 +941,8 @@ async function runProxyMode(argv) {
|
|
|
830
941
|
if (shuttingDown)
|
|
831
942
|
return;
|
|
832
943
|
shuttingDown = true;
|
|
944
|
+
// The probe must be read while the upstream still runs.
|
|
945
|
+
await interceptor.refreshEffectProbe(true);
|
|
833
946
|
await writeReportFile();
|
|
834
947
|
await interceptor.disconnectUpstream();
|
|
835
948
|
// Calls that were still open have just been ended by the disconnect and
|
package/dist/plain-report.js
CHANGED
|
@@ -82,6 +82,10 @@ export const OUTCOMES = {
|
|
|
82
82
|
text: "Es bleibt unklar, ob der Vorgang ausgeführt wurde. Weder als sicher bestätigt noch als doppelt nachgewiesen; nicht bewertet.",
|
|
83
83
|
},
|
|
84
84
|
DUPLICATE_EXECUTION: { good: false, text: "Der Vorgang wurde mehrfach ausgeführt." },
|
|
85
|
+
DEDUPLICATED_RETRY: {
|
|
86
|
+
good: true,
|
|
87
|
+
text: "Der Vorgang wurde nochmals gesendet, das Zielsystem hat die Wiederholung aber selbst erkannt: Die Wirkungs-Sonde hat keinen zweiten Datensatz gezählt.",
|
|
88
|
+
},
|
|
85
89
|
RETRY_AFTER_VIOLATED: { good: false, text: "Der Agent hat zu früh erneut gesendet, vor Ablauf der angegebenen Wartezeit." },
|
|
86
90
|
};
|
|
87
91
|
export const LIMITS = [
|
|
@@ -171,6 +175,30 @@ function claimSection(claim) {
|
|
|
171
175
|
lines.push("");
|
|
172
176
|
return lines;
|
|
173
177
|
}
|
|
178
|
+
const PROBE_CONCLUSION_DE = {
|
|
179
|
+
confirmed: "bestätigt: Jeder Schreibversuch hat einen Datensatz erzeugt.",
|
|
180
|
+
deduplicated: "dedupliziert: Wiederholte oder gescheiterte Schreibvorgänge haben keinen neuen Datensatz erzeugt. Die Bewertung rechnet mit Datensätzen statt mit Ausführungen.",
|
|
181
|
+
partial: "teilweise: Einige Wiederholungen haben einen Datensatz erzeugt, andere nicht. Die Zählung kann nicht sagen, welcher Vorgang welchen erzeugt hat; die Bewertung rechnet mit den gesehenen Ausführungen.",
|
|
182
|
+
inconclusive: "nicht verwertbar: Die Bewertung rechnet mit den gesehenen Ausführungen.",
|
|
183
|
+
no_writes: "kein Schreibvorgang erreichte das Zielsystem, es gab nichts zu vergleichen.",
|
|
184
|
+
};
|
|
185
|
+
// Wirkungs-Sonde (--effect-probe): was gezählt wurde und was daraus folgt. Ohne
|
|
186
|
+
// Sonde leer.
|
|
187
|
+
export function effectProbeLinesDe(report) {
|
|
188
|
+
const p = report?.effectProbe;
|
|
189
|
+
if (!p)
|
|
190
|
+
return [];
|
|
191
|
+
const count = p.before === null || p.after === null ? "Zählung nicht verfügbar" : `Datensätze vorher ${p.before}, nachher ${p.after}, neu ${p.delta}`;
|
|
192
|
+
const lines = [
|
|
193
|
+
`Der Harness hat das Lese-Tool \`${p.tool.replace(/[`\r\n]/g, "?")}\` selbst aufgerufen und Datensätze gezählt: ${count}. ${p.attempts} Schreibversuch(e) in ${p.operations} Vorgang/Vorgängen erreichten das Zielsystem.`,
|
|
194
|
+
`Schluss: ${PROBE_CONCLUSION_DE[p.conclusion]}`,
|
|
195
|
+
];
|
|
196
|
+
if (p.conclusion === "inconclusive" || p.conclusion === "partial") {
|
|
197
|
+
lines.push(`Begründung (Originaltext): ${p.note.replace(/[\r\n]+/g, " ")}`);
|
|
198
|
+
}
|
|
199
|
+
lines.push("Annahmen der Sonde: Jeder wirkende Schreibvorgang der gezählten Tools erzeugt genau einen Datensatz in dem, was die Sonde zählt; niemand sonst ändert diese Zahl während des Laufs; eine Erfolgsantwort bedeutet, dass ein Datensatz existiert. Sie sagt nichts über die Absicht des Agenten.");
|
|
200
|
+
return lines;
|
|
201
|
+
}
|
|
174
202
|
export function renderMarkdown(file, options = {}) {
|
|
175
203
|
const pro = options.pro ?? true;
|
|
176
204
|
const { headline, explanation } = verdictText(file);
|
|
@@ -209,6 +237,10 @@ export function renderMarkdown(file, options = {}) {
|
|
|
209
237
|
}
|
|
210
238
|
lines.push("");
|
|
211
239
|
}
|
|
240
|
+
const probeLines = effectProbeLinesDe(report);
|
|
241
|
+
if (probeLines.length > 0) {
|
|
242
|
+
lines.push("## Wirkungs-Sonde", "", ...probeLines.map((l) => `- ${l}`), "");
|
|
243
|
+
}
|
|
212
244
|
if (file.score) {
|
|
213
245
|
lines.push("## Kennzahl", "");
|
|
214
246
|
if (file.score.overall === null || file.score.overall === undefined) {
|
|
@@ -0,0 +1,104 @@
|
|
|
1
|
+
const MAX_DESCRIPTION = 600;
|
|
2
|
+
// A verb counts only where a verb stands in a description: at the start of a
|
|
3
|
+
// clause ("Books a room"), or after a comma or a conjunction ("... and bills the
|
|
4
|
+
// first night", ", or creates one"). The same words elsewhere are mostly nouns
|
|
5
|
+
// ("Lists issues", "Returns charges", "the booking that updates the view").
|
|
6
|
+
// Third person singular present (German: present); the bare form only at the
|
|
7
|
+
// start ("Create a booking").
|
|
8
|
+
const WRITE_VERBS = new Set([
|
|
9
|
+
"creates", "books", "bills", "charges", "sends", "deletes", "removes", "cancels", "updates",
|
|
10
|
+
"modifies", "writes", "inserts", "submits", "pays", "transfers", "refunds", "stores", "saves",
|
|
11
|
+
"persists", "reserves", "registers", "approves", "issues", "withdraws", "deposits", "purchases",
|
|
12
|
+
"places", "confirms", "schedules", "posts", "adds", "executes", "triggers", "publishes", "logs",
|
|
13
|
+
"sets", "notifies", "emails", "uploads", "generates", "archives", "overwrites", "increments", "marks",
|
|
14
|
+
"erstellt", "bucht", "sendet", "löscht", "loescht", "storniert", "ändert", "aendert", "schreibt",
|
|
15
|
+
"speichert", "zahlt", "belastet", "verrechnet", "überweist", "ueberweist", "registriert", "entfernt",
|
|
16
|
+
"schickt", "versendet", "aktualisiert", "reserviert", "bestätigt", "bestaetigt", "setzt", "erzeugt",
|
|
17
|
+
"überschreibt", "ueberschreibt", "verbucht",
|
|
18
|
+
]);
|
|
19
|
+
const BARE_WRITE_VERBS = new Set([
|
|
20
|
+
"create", "book", "bill", "charge", "send", "delete", "remove", "cancel", "update", "modify",
|
|
21
|
+
"write", "insert", "submit", "pay", "transfer", "refund", "store", "save", "persist", "reserve",
|
|
22
|
+
"register", "approve", "issue", "withdraw", "deposit", "purchase", "place", "confirm", "schedule",
|
|
23
|
+
"post", "add", "execute", "trigger", "publish", "log", "set", "notify", "email", "upload", "generate",
|
|
24
|
+
"archive", "overwrite", "increment", "mark",
|
|
25
|
+
]);
|
|
26
|
+
const CONJUNCTIONS = new Set(["and", "or", "then", "also", "und", "oder", "dann", "sowie", "danach", "plus"]);
|
|
27
|
+
// A verb inside a negated clause says the opposite ("Never writes or sends anything",
|
|
28
|
+
// "Sendet nichts, löscht nichts").
|
|
29
|
+
const NEGATIONS = new Set([
|
|
30
|
+
"not", "never", "no", "without", "doesn't", "don't", "won't", "nothing", "nobody",
|
|
31
|
+
"nicht", "kein", "keine", "keinen", "ohne", "nie", "niemals", "nichts",
|
|
32
|
+
]);
|
|
33
|
+
const NEGATION_AFTER = 3; // "bucht nichts", "does nothing"
|
|
34
|
+
function tokenize(clause) {
|
|
35
|
+
// Words and commas; letters by Unicode class (\b knows only ASCII).
|
|
36
|
+
return clause.toLowerCase().match(/[\p{L}\p{N}']+|,/gu) ?? [];
|
|
37
|
+
}
|
|
38
|
+
export function describesWriting(description) {
|
|
39
|
+
const text = description.slice(0, MAX_DESCRIPTION).replace(/\s+/g, " ");
|
|
40
|
+
for (const clause of text.split(/[.;!?]+/)) {
|
|
41
|
+
const tokens = tokenize(clause);
|
|
42
|
+
for (let i = 0; i < tokens.length; i++) {
|
|
43
|
+
const word = tokens[i];
|
|
44
|
+
const prev = i > 0 ? tokens[i - 1] : null;
|
|
45
|
+
const inVerbPosition = prev === null || prev === "," || CONJUNCTIONS.has(prev);
|
|
46
|
+
const isVerb = inVerbPosition && (WRITE_VERBS.has(word) || (i === 0 && BARE_WRITE_VERBS.has(word)));
|
|
47
|
+
if (!isVerb)
|
|
48
|
+
continue;
|
|
49
|
+
const negated = tokens.slice(0, i).some((t) => NEGATIONS.has(t)) ||
|
|
50
|
+
tokens.slice(i + 1, i + 1 + NEGATION_AFTER).some((t) => NEGATIONS.has(t));
|
|
51
|
+
if (!negated)
|
|
52
|
+
return word;
|
|
53
|
+
}
|
|
54
|
+
}
|
|
55
|
+
return null;
|
|
56
|
+
}
|
|
57
|
+
// Unambiguous statements only, and only in the FIRST sentence ("The id field is
|
|
58
|
+
// read-only" in a later sentence is about a parameter, not about the tool).
|
|
59
|
+
const READ_ONLY_PHRASES = [
|
|
60
|
+
"read-only", "read only", "readonly", "only reads", "no side effect", "no side-effect",
|
|
61
|
+
"does not modify any", "does not change any", "does not write", "nur lesend", "schreibgeschützt",
|
|
62
|
+
"schreibgeschuetzt", "nur lesen",
|
|
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
|
+
}
|
|
79
|
+
function firstSentence(description) {
|
|
80
|
+
const text = description.slice(0, MAX_DESCRIPTION).replace(/\s+/g, " ").trim();
|
|
81
|
+
const end = text.search(/[.!?](\s|$)/);
|
|
82
|
+
return (end === -1 ? text : text.slice(0, end + 1)).toLowerCase();
|
|
83
|
+
}
|
|
84
|
+
export function classificationDoubt(tool, cls, source) {
|
|
85
|
+
const description = tool.description;
|
|
86
|
+
if (!description)
|
|
87
|
+
return undefined;
|
|
88
|
+
const nameBased = source === "default write pattern" || source === "read prefix" || source === "verb word in name";
|
|
89
|
+
if (!nameBased)
|
|
90
|
+
return undefined;
|
|
91
|
+
if (cls === "read") {
|
|
92
|
+
const verb = describesWriting(description);
|
|
93
|
+
return verb
|
|
94
|
+
? `taken for a read by its name, but its description says it "${verb}" — if it writes, duplicates through it stay invisible (set --write-tools or the readOnlyHint annotation)`
|
|
95
|
+
: undefined;
|
|
96
|
+
}
|
|
97
|
+
if (cls === "write") {
|
|
98
|
+
const first = firstSentence(description);
|
|
99
|
+
if (READ_ONLY_PHRASES.some((p) => first.includes(p))) {
|
|
100
|
+
return "taken for a write by its name, but its description says it only reads — calls to it may be treated as writes (set --read-tools or the readOnlyHint annotation)";
|
|
101
|
+
}
|
|
102
|
+
}
|
|
103
|
+
return undefined;
|
|
104
|
+
}
|
|
@@ -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
|
+
}
|
package/dist/proxy/gateway.js
CHANGED
|
@@ -86,6 +86,7 @@ export function createProxyServer(interceptor, options = {}) {
|
|
|
86
86
|
isError: true,
|
|
87
87
|
};
|
|
88
88
|
}
|
|
89
|
+
await interceptor.refreshEffectProbe(true);
|
|
89
90
|
const withSession = { session_id: interceptor.sessionId, format };
|
|
90
91
|
if (name === `${AUDIT_TOOL_PREFIX}get_report`) {
|
|
91
92
|
return handleGetReport(withSession, { persist: false });
|
|
@@ -6,6 +6,10 @@
|
|
|
6
6
|
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
|
+
import { classificationDoubt } from "./classification-doubt.js";
|
|
10
|
+
import { classifyByVerbWords, readPrefixThenWrites } from "./verb-words.js";
|
|
11
|
+
import { looksLikeErrorAnswer } from "./error-answer.js";
|
|
12
|
+
import { parseProbeReading } from "../core/effect-probe.js";
|
|
9
13
|
import { readFileSync, readdirSync } from "node:fs";
|
|
10
14
|
import { createSession, incrementCall, addRecord, classifyTool, extractKey, isKeyField, isReadTool, matchesAnyGlob, releaseScenarioFire, DEFAULT_WRITE_TOOL_PATTERNS, } from "../engine/state-machine.js";
|
|
11
15
|
import { decideChaosAction, handleGhostWrite, buildCallRecord, } from "./ghost-write.js";
|
|
@@ -17,6 +21,11 @@ function sleep(ms) {
|
|
|
17
21
|
// Anything left over stays "unclassified" — and is reported as such instead
|
|
18
22
|
// of being quietly treated as a harmless read.
|
|
19
23
|
export function classifyUpstreamTool(tool, config) {
|
|
24
|
+
const result = classifyByRules(tool, config);
|
|
25
|
+
const doubt = classificationDoubt(tool, result.class, result.source);
|
|
26
|
+
return doubt ? { ...result, doubt } : result;
|
|
27
|
+
}
|
|
28
|
+
function classifyByRules(tool, config) {
|
|
20
29
|
const name = tool.name;
|
|
21
30
|
const explicitWrite = config.writeToolPatterns ?? [];
|
|
22
31
|
const explicitRead = config.readToolPatterns ?? [];
|
|
@@ -46,11 +55,23 @@ export function classifyUpstreamTool(tool, config) {
|
|
|
46
55
|
return { name, class: "write", source: "default write pattern" };
|
|
47
56
|
}
|
|
48
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
|
+
}
|
|
49
62
|
return { name, class: "read", source: "read prefix" };
|
|
50
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
|
+
}
|
|
51
71
|
return { name, class: "unclassified", source: "no annotation, no pattern match" };
|
|
52
72
|
}
|
|
53
73
|
const STDERR_TAIL_CHARS = 2000;
|
|
74
|
+
const PROBE_TIMEOUT_MS = 15_000;
|
|
54
75
|
const MAX_TOOL_LIST_PAGES = 1000;
|
|
55
76
|
// Linux only (/proc). [ppid, starttime] of a process, or null if it is gone.
|
|
56
77
|
function procStat(pid) {
|
|
@@ -123,6 +144,11 @@ export class ProxyInterceptor {
|
|
|
123
144
|
onCallRecorded = null;
|
|
124
145
|
// Called once if the upstream connection drops while the proxy is running.
|
|
125
146
|
onUpstreamLost = null;
|
|
147
|
+
// --effect-probe: called when a background reading of the probe has landed,
|
|
148
|
+
// so the report file can be written again with it.
|
|
149
|
+
onProbeRefreshed = null;
|
|
150
|
+
probeTimer = null;
|
|
151
|
+
probeInFlight = null;
|
|
126
152
|
constructor(config) {
|
|
127
153
|
this.config = config;
|
|
128
154
|
this.session = createSession(config.scenarioId ?? null, config.mode ?? "deterministic", config.errorRate ?? 0.0, config.writeToolPatterns, {
|
|
@@ -204,6 +230,11 @@ export class ProxyInterceptor {
|
|
|
204
230
|
this.toolClassification = this.upstreamTools.map((tool) => classifyUpstreamTool(tool, this.config));
|
|
205
231
|
for (const entry of this.toolClassification) {
|
|
206
232
|
this.session.toolClassOverrides.set(entry.name, entry.class);
|
|
233
|
+
if (entry.doubt) {
|
|
234
|
+
(this.session.classificationDoubts ??= new Map()).set(entry.name, entry.doubt);
|
|
235
|
+
}
|
|
236
|
+
if (entry.guess)
|
|
237
|
+
(this.session.verbWordGuesses ??= new Set()).add(entry.name);
|
|
207
238
|
}
|
|
208
239
|
for (const tool of this.upstreamTools) {
|
|
209
240
|
const properties = (tool.inputSchema?.properties ?? {});
|
|
@@ -227,6 +258,118 @@ export class ProxyInterceptor {
|
|
|
227
258
|
this.upstreamTransport = null;
|
|
228
259
|
killSurvivors(descendants);
|
|
229
260
|
}
|
|
261
|
+
// --effect-probe: take the baseline reading. Called once after connect, before
|
|
262
|
+
// the first agent call. Throws with a message that says what to fix.
|
|
263
|
+
async initEffectProbe() {
|
|
264
|
+
const cfg = this.config.effectProbe;
|
|
265
|
+
if (!cfg)
|
|
266
|
+
return;
|
|
267
|
+
const entry = this.toolClassification.find((t) => t.name === cfg.tool);
|
|
268
|
+
if (!entry) {
|
|
269
|
+
throw new Error(`--effect-probe "${cfg.tool}": the upstream has no such tool.`);
|
|
270
|
+
}
|
|
271
|
+
if (entry.class !== "read") {
|
|
272
|
+
throw new Error(`--effect-probe "${cfg.tool}": the probe must be a READ tool, but it is taken for ${entry.class === "write" ? "a write" : "a tool that is neither read nor write"} (${entry.source}). The harness calls it itself, repeatedly: a write would change what it measures. Pick a read tool, or classify it with --read-tools.`);
|
|
273
|
+
}
|
|
274
|
+
let reading;
|
|
275
|
+
try {
|
|
276
|
+
reading = await this.readProbe();
|
|
277
|
+
}
|
|
278
|
+
catch (err) {
|
|
279
|
+
throw new Error(`--effect-probe "${cfg.tool}": ${err instanceof Error ? err.message : String(err)}`);
|
|
280
|
+
}
|
|
281
|
+
this.session.effectProbe = {
|
|
282
|
+
...cfg,
|
|
283
|
+
before: reading.count,
|
|
284
|
+
after: reading.count,
|
|
285
|
+
error: null,
|
|
286
|
+
readAtCalls: 0,
|
|
287
|
+
...(reading.warning ? { warning: reading.warning } : {}),
|
|
288
|
+
};
|
|
289
|
+
}
|
|
290
|
+
// The probe is read OFF the answer path: after a call a trailing, debounced
|
|
291
|
+
// reading is scheduled, and the report file is written again when it lands.
|
|
292
|
+
// The agent never waits for it, and its timing (retry_after) is not touched.
|
|
293
|
+
// After an error the next scheduled reading waits 10 s.
|
|
294
|
+
scheduleEffectProbeRefresh() {
|
|
295
|
+
const probe = this.session.effectProbe;
|
|
296
|
+
if (!probe || this.probeTimer || this.closing)
|
|
297
|
+
return;
|
|
298
|
+
const wait = probe.error !== null ? 10_000 : 400;
|
|
299
|
+
this.probeTimer = setTimeout(() => {
|
|
300
|
+
this.probeTimer = null;
|
|
301
|
+
void this.refreshEffectProbe().then(() => {
|
|
302
|
+
this.onProbeRefreshed?.();
|
|
303
|
+
// Calls were still open (or arrived meanwhile): the reading is behind.
|
|
304
|
+
if (probe.readAtCalls !== this.session.history.length)
|
|
305
|
+
this.scheduleEffectProbeRefresh();
|
|
306
|
+
});
|
|
307
|
+
}, wait);
|
|
308
|
+
this.probeTimer.unref?.();
|
|
309
|
+
}
|
|
310
|
+
// Latest reading. `force` (a report is being read, the proxy is ending) waits
|
|
311
|
+
// for a reading even right after an error. One reading at a time; a second
|
|
312
|
+
// caller waits for the one in flight and uses it.
|
|
313
|
+
async refreshEffectProbe(force = false) {
|
|
314
|
+
const probe = this.session.effectProbe;
|
|
315
|
+
if (!probe)
|
|
316
|
+
return;
|
|
317
|
+
if (this.probeInFlight)
|
|
318
|
+
await this.probeInFlight;
|
|
319
|
+
const calls = this.session.history.length;
|
|
320
|
+
if (calls === probe.readAtCalls && probe.error === null)
|
|
321
|
+
return;
|
|
322
|
+
if (!force && probe.error !== null && probe.lastAttemptAt !== undefined && Date.now() - probe.lastAttemptAt < 10_000)
|
|
323
|
+
return;
|
|
324
|
+
// Calls still open: a record may be half-written. Leave the old reading;
|
|
325
|
+
// the report treats open calls as "inconclusive".
|
|
326
|
+
if ((this.session.pending?.size ?? 0) > 0)
|
|
327
|
+
return;
|
|
328
|
+
this.probeInFlight = (async () => {
|
|
329
|
+
probe.lastAttemptAt = Date.now();
|
|
330
|
+
try {
|
|
331
|
+
const reading = await this.readProbe();
|
|
332
|
+
probe.after = reading.count;
|
|
333
|
+
probe.error = null;
|
|
334
|
+
if (reading.warning)
|
|
335
|
+
probe.warning = reading.warning;
|
|
336
|
+
else
|
|
337
|
+
delete probe.warning;
|
|
338
|
+
}
|
|
339
|
+
catch (err) {
|
|
340
|
+
probe.error = err instanceof Error ? err.message : String(err);
|
|
341
|
+
}
|
|
342
|
+
// The history length BEFORE the reading: calls that arrived during it
|
|
343
|
+
// make the reading older than the history, and the report says so.
|
|
344
|
+
probe.readAtCalls = calls;
|
|
345
|
+
})();
|
|
346
|
+
try {
|
|
347
|
+
await this.probeInFlight;
|
|
348
|
+
}
|
|
349
|
+
finally {
|
|
350
|
+
this.probeInFlight = null;
|
|
351
|
+
}
|
|
352
|
+
}
|
|
353
|
+
async readProbe() {
|
|
354
|
+
const cfg = this.config.effectProbe;
|
|
355
|
+
if (!cfg)
|
|
356
|
+
throw new Error("no probe configured");
|
|
357
|
+
if (!this.upstreamClient || !this.upstreamAlive) {
|
|
358
|
+
throw new Error("the upstream is not running any more");
|
|
359
|
+
}
|
|
360
|
+
let result;
|
|
361
|
+
try {
|
|
362
|
+
result = await this.upstreamClient.callTool({ name: cfg.tool, arguments: cfg.args }, undefined, { timeout: PROBE_TIMEOUT_MS });
|
|
363
|
+
}
|
|
364
|
+
catch (err) {
|
|
365
|
+
const message = err instanceof Error ? err.message : String(err);
|
|
366
|
+
if (/timed out/i.test(message)) {
|
|
367
|
+
throw new Error(`the probe tool did not answer within ${PROBE_TIMEOUT_MS / 1000} s (use a faster read tool, or narrow it with --effect-probe-args)`);
|
|
368
|
+
}
|
|
369
|
+
throw new Error(`the probe call failed: ${message}`);
|
|
370
|
+
}
|
|
371
|
+
return parseProbeReading(result, cfg.countPath);
|
|
372
|
+
}
|
|
230
373
|
getUpstreamTools() {
|
|
231
374
|
return this.upstreamTools;
|
|
232
375
|
}
|
|
@@ -255,6 +398,7 @@ export class ProxyInterceptor {
|
|
|
255
398
|
catch {
|
|
256
399
|
// Reporting must never change what the agent gets back.
|
|
257
400
|
}
|
|
401
|
+
this.scheduleEffectProbeRefresh();
|
|
258
402
|
return result;
|
|
259
403
|
}
|
|
260
404
|
async handleToolCall(toolName, args, signal) {
|
|
@@ -426,6 +570,9 @@ export class ProxyInterceptor {
|
|
|
426
570
|
outcome,
|
|
427
571
|
toolClass,
|
|
428
572
|
tookEffect: toolClass === "write" && !upstreamIsError,
|
|
573
|
+
...(toolClass === "write" && !upstreamIsError && looksLikeErrorAnswer(upstreamText)
|
|
574
|
+
? { answerLooksLikeError: true }
|
|
575
|
+
: {}),
|
|
429
576
|
scenarioTriggered: decision.scenarioTriggered && !ghostRejected,
|
|
430
577
|
upstreamResponse: upstreamText ?? "",
|
|
431
578
|
}),
|