cuonztech-agent-harness 0.3.2 → 0.5.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 +220 -147
- package/dist/benchmark/cta.js +46 -28
- package/dist/benchmark/runner.js +105 -82
- package/dist/benchmark/score.js +112 -60
- package/dist/core/report-generator.js +453 -239
- package/dist/core/state-engine.js +1 -2
- package/dist/engine/state-diff.js +4 -2
- package/dist/engine/state-machine.js +164 -17
- package/dist/hardening/patch-generator.js +49 -48
- package/dist/index.js +562 -59
- package/dist/proxy/gateway.js +71 -65
- package/dist/proxy/ghost-write.js +49 -11
- package/dist/proxy/interceptor.js +264 -76
- package/dist/scenarios/f1-f5.js +40 -21
- package/dist/scenarios/types.js +0 -1
- package/dist/server/server.js +5 -5
- package/dist/server/stdio.js +0 -1
- package/dist/tools/handlers.js +90 -39
- package/dist/tools/schemas.js +26 -1
- package/dist/version.js +12 -0
- package/package.json +4 -3
- package/dist/benchmark/cta.d.ts +0 -11
- package/dist/benchmark/cta.d.ts.map +0 -1
- package/dist/benchmark/cta.js.map +0 -1
- package/dist/benchmark/runner.d.ts +0 -19
- package/dist/benchmark/runner.d.ts.map +0 -1
- package/dist/benchmark/runner.js.map +0 -1
- package/dist/benchmark/score.d.ts +0 -26
- package/dist/benchmark/score.d.ts.map +0 -1
- package/dist/benchmark/score.js.map +0 -1
- package/dist/core/report-generator.d.ts +0 -47
- package/dist/core/report-generator.d.ts.map +0 -1
- package/dist/core/report-generator.js.map +0 -1
- package/dist/core/state-engine.d.ts +0 -2
- package/dist/core/state-engine.d.ts.map +0 -1
- package/dist/core/state-engine.js.map +0 -1
- package/dist/engine/state-diff.d.ts +0 -33
- package/dist/engine/state-diff.d.ts.map +0 -1
- package/dist/engine/state-diff.js.map +0 -1
- package/dist/engine/state-machine.d.ts +0 -43
- package/dist/engine/state-machine.d.ts.map +0 -1
- package/dist/engine/state-machine.js.map +0 -1
- package/dist/hardening/patch-generator.d.ts +0 -12
- package/dist/hardening/patch-generator.d.ts.map +0 -1
- package/dist/hardening/patch-generator.js.map +0 -1
- package/dist/index.d.ts +0 -3
- package/dist/index.d.ts.map +0 -1
- package/dist/index.js.map +0 -1
- package/dist/proxy/gateway.d.ts +0 -4
- package/dist/proxy/gateway.d.ts.map +0 -1
- package/dist/proxy/gateway.js.map +0 -1
- package/dist/proxy/ghost-write.d.ts +0 -17
- package/dist/proxy/ghost-write.d.ts.map +0 -1
- package/dist/proxy/ghost-write.js.map +0 -1
- package/dist/proxy/interceptor.d.ts +0 -34
- package/dist/proxy/interceptor.d.ts.map +0 -1
- package/dist/proxy/interceptor.js.map +0 -1
- package/dist/scenarios/f1-f5.d.ts +0 -9
- package/dist/scenarios/f1-f5.d.ts.map +0 -1
- package/dist/scenarios/f1-f5.js.map +0 -1
- package/dist/scenarios/types.d.ts +0 -15
- package/dist/scenarios/types.d.ts.map +0 -1
- package/dist/scenarios/types.js.map +0 -1
- package/dist/server/server.d.ts +0 -3
- package/dist/server/server.d.ts.map +0 -1
- package/dist/server/server.js.map +0 -1
- package/dist/server/stdio.d.ts +0 -7
- package/dist/server/stdio.d.ts.map +0 -1
- package/dist/server/stdio.js.map +0 -1
- package/dist/tools/handlers.d.ts +0 -92
- package/dist/tools/handlers.d.ts.map +0 -1
- package/dist/tools/handlers.js.map +0 -1
- package/dist/tools/schemas.d.ts +0 -37
- package/dist/tools/schemas.d.ts.map +0 -1
- package/dist/tools/schemas.js.map +0 -1
|
@@ -1,3 +1,2 @@
|
|
|
1
1
|
// Re-export from new engine module for backward compatibility
|
|
2
|
-
export { createSession, getSession, incrementCall, addRecord, resetSession, deleteSession, listSessions, isReadTool, isWriteTool, markGhostCommitted, } from "../engine/state-machine.js";
|
|
3
|
-
//# sourceMappingURL=state-engine.js.map
|
|
2
|
+
export { createSession, getSession, incrementCall, addRecord, resetSession, deleteSession, listSessions, isReadTool, isWriteTool, classifyTool, extractKey, stripKeyFields, reserveScenarioFire, releaseScenarioFire, markGhostCommitted, } from "../engine/state-machine.js";
|
|
@@ -42,7 +42,10 @@ export class StateDiffStore {
|
|
|
42
42
|
recordExisted: record !== undefined,
|
|
43
43
|
valueBeforeRetry: record?.value ?? null,
|
|
44
44
|
valueAfterRetry: record?.value ?? null,
|
|
45
|
-
|
|
45
|
+
// A key that was never written cannot have been overwritten:
|
|
46
|
+
// `record?.overwrittenAt !== null` alone is true for a missing record
|
|
47
|
+
// (undefined !== null).
|
|
48
|
+
wasOverwritten: record !== undefined && record.overwrittenAt !== null,
|
|
46
49
|
agentCheckedBeforeRetry: agentRead,
|
|
47
50
|
agentGaveUpSilently: !agentRead && !agentHadWarning,
|
|
48
51
|
};
|
|
@@ -52,4 +55,3 @@ export class StateDiffStore {
|
|
|
52
55
|
this.readLog = [];
|
|
53
56
|
}
|
|
54
57
|
}
|
|
55
|
-
//# sourceMappingURL=state-diff.js.map
|
|
@@ -1,7 +1,48 @@
|
|
|
1
1
|
import { randomUUID } from "node:crypto";
|
|
2
2
|
import { StateDiffStore } from "./state-diff.js";
|
|
3
3
|
const sessions = new Map();
|
|
4
|
-
|
|
4
|
+
// Upper bound on sessions kept in memory: a long-running server whose
|
|
5
|
+
// clients never call delete_session would otherwise grow without limit.
|
|
6
|
+
// The oldest session is dropped first.
|
|
7
|
+
const MAX_SESSIONS = 500;
|
|
8
|
+
// Heuristic only: a list of common write-verb prefixes can never be
|
|
9
|
+
// complete. Tools that match nothing here (and no read prefix) are reported
|
|
10
|
+
// as "unclassified" rather than silently treated as harmless — see
|
|
11
|
+
// classifyTool(). Callers with unusual naming pass their own patterns
|
|
12
|
+
// (--write-tools in proxy mode, start_session's write_tool_patterns); in
|
|
13
|
+
// proxy mode MCP tool annotations (readOnlyHint) take precedence over this
|
|
14
|
+
// list.
|
|
15
|
+
export const DEFAULT_WRITE_TOOL_PATTERNS = [
|
|
16
|
+
"write*",
|
|
17
|
+
"create_*",
|
|
18
|
+
"send_*",
|
|
19
|
+
"submit_*",
|
|
20
|
+
"post_*",
|
|
21
|
+
"update_*",
|
|
22
|
+
"insert_*",
|
|
23
|
+
"book_*",
|
|
24
|
+
"pay_*",
|
|
25
|
+
"charge_*",
|
|
26
|
+
"cancel_*",
|
|
27
|
+
"delete_*",
|
|
28
|
+
"confirm_*",
|
|
29
|
+
"place_*",
|
|
30
|
+
"add_*",
|
|
31
|
+
"register_*",
|
|
32
|
+
"schedule_*",
|
|
33
|
+
"transfer_*",
|
|
34
|
+
"refund_*",
|
|
35
|
+
"reserve_*",
|
|
36
|
+
"issue_*",
|
|
37
|
+
"remove_*",
|
|
38
|
+
"withdraw_*",
|
|
39
|
+
"deposit_*",
|
|
40
|
+
"purchase_*",
|
|
41
|
+
"approve_*",
|
|
42
|
+
];
|
|
43
|
+
export const DEFAULT_KEY_FIELDS = ["idempotency_key"];
|
|
44
|
+
export const DEFAULT_RETRY_AFTER_SEC = 60;
|
|
45
|
+
export function createSession(scenarioId = null, mode = "deterministic", errorRate = 0.0, writeToolPatterns = DEFAULT_WRITE_TOOL_PATTERNS, options = {}) {
|
|
5
46
|
const session = {
|
|
6
47
|
sessionId: randomUUID(),
|
|
7
48
|
callCount: 0,
|
|
@@ -12,8 +53,30 @@ export function createSession(scenarioId = null, mode = "deterministic", errorRa
|
|
|
12
53
|
keyStates: new Map(),
|
|
13
54
|
createdAt: Date.now(),
|
|
14
55
|
stateDiff: new StateDiffStore(),
|
|
56
|
+
writeToolPatterns: writeToolPatterns.length > 0 ? writeToolPatterns : DEFAULT_WRITE_TOOL_PATTERNS,
|
|
57
|
+
readToolPatterns: options.readToolPatterns ?? [],
|
|
58
|
+
toolClassOverrides: new Map(),
|
|
59
|
+
keyFields: options.keyFields && options.keyFields.length > 0
|
|
60
|
+
? options.keyFields
|
|
61
|
+
: DEFAULT_KEY_FIELDS,
|
|
62
|
+
keyCapableTools: new Set(),
|
|
63
|
+
retryAfterSec: options.retryAfterSec ?? DEFAULT_RETRY_AFTER_SEC,
|
|
64
|
+
scenarioFireCount: 0,
|
|
65
|
+
knownToolsOnly: false,
|
|
66
|
+
kind: "demo",
|
|
15
67
|
};
|
|
16
68
|
sessions.set(session.sessionId, session);
|
|
69
|
+
if (sessions.size > MAX_SESSIONS) {
|
|
70
|
+
let oldest = null;
|
|
71
|
+
for (const s of sessions.values()) {
|
|
72
|
+
if (s.sessionId === session.sessionId)
|
|
73
|
+
continue;
|
|
74
|
+
if (!oldest || s.createdAt < oldest.createdAt)
|
|
75
|
+
oldest = s;
|
|
76
|
+
}
|
|
77
|
+
if (oldest)
|
|
78
|
+
sessions.delete(oldest.sessionId);
|
|
79
|
+
}
|
|
17
80
|
return session;
|
|
18
81
|
}
|
|
19
82
|
export function getSession(sessionId) {
|
|
@@ -26,30 +89,86 @@ export function incrementCall(sessionId) {
|
|
|
26
89
|
session.callCount += 1;
|
|
27
90
|
return session.callCount;
|
|
28
91
|
}
|
|
92
|
+
function normalizeFieldName(name) {
|
|
93
|
+
return name.toLowerCase().replace(/[-_]/g, "");
|
|
94
|
+
}
|
|
95
|
+
export function isKeyField(name, keyFields) {
|
|
96
|
+
const normalized = normalizeFieldName(name);
|
|
97
|
+
return keyFields.some((f) => normalizeFieldName(f) === normalized);
|
|
98
|
+
}
|
|
99
|
+
// The idempotency key a call carries, if any. An empty string counts as
|
|
100
|
+
// "no key".
|
|
101
|
+
export function extractKey(args, keyFields = DEFAULT_KEY_FIELDS) {
|
|
102
|
+
for (const [name, value] of Object.entries(args)) {
|
|
103
|
+
if (!isKeyField(name, keyFields))
|
|
104
|
+
continue;
|
|
105
|
+
if (typeof value === "string" && value.length > 0)
|
|
106
|
+
return value;
|
|
107
|
+
if (typeof value === "number")
|
|
108
|
+
return String(value);
|
|
109
|
+
}
|
|
110
|
+
return undefined;
|
|
111
|
+
}
|
|
112
|
+
export function stripKeyFields(args, keyFields = DEFAULT_KEY_FIELDS) {
|
|
113
|
+
const rest = {};
|
|
114
|
+
for (const [name, value] of Object.entries(args)) {
|
|
115
|
+
if (!isKeyField(name, keyFields))
|
|
116
|
+
rest[name] = value;
|
|
117
|
+
}
|
|
118
|
+
return rest;
|
|
119
|
+
}
|
|
120
|
+
export function classifyTool(session, toolName) {
|
|
121
|
+
const override = session.toolClassOverrides.get(toolName);
|
|
122
|
+
if (override)
|
|
123
|
+
return override;
|
|
124
|
+
if (session.knownToolsOnly)
|
|
125
|
+
return "unclassified";
|
|
126
|
+
if (matchesAnyGlob(toolName, session.writeToolPatterns))
|
|
127
|
+
return "write";
|
|
128
|
+
if (isReadTool(toolName) || matchesAnyGlob(toolName, session.readToolPatterns)) {
|
|
129
|
+
return "read";
|
|
130
|
+
}
|
|
131
|
+
return "unclassified";
|
|
132
|
+
}
|
|
133
|
+
// Marks that the scenario fired on one more write. Deliberately synchronous:
|
|
134
|
+
// the caller must not await anything between receiving a call and deciding,
|
|
135
|
+
// or two parallel writes would both see "the scenario has not fired yet".
|
|
136
|
+
export function reserveScenarioFire(session) {
|
|
137
|
+
session.scenarioFireCount += 1;
|
|
138
|
+
}
|
|
139
|
+
// Gives a reserved fire back: the scenario was meant for this call but could
|
|
140
|
+
// not be applied (the upstream call failed or was rejected), so the next
|
|
141
|
+
// write gets it instead.
|
|
142
|
+
export function releaseScenarioFire(session) {
|
|
143
|
+
session.scenarioFireCount = Math.max(0, session.scenarioFireCount - 1);
|
|
144
|
+
}
|
|
29
145
|
export function addRecord(sessionId, record) {
|
|
30
146
|
const session = sessions.get(sessionId);
|
|
31
147
|
if (!session)
|
|
32
148
|
throw new Error(`Session ${sessionId} not found`);
|
|
149
|
+
record.upstreamExecuted = Boolean(record.upstreamExecuted);
|
|
150
|
+
record.outcome ??= record.injectedError !== null ? "injected_failure" : "success";
|
|
151
|
+
record.toolClass ??= classifyTool(session, record.toolName);
|
|
152
|
+
record.tookEffect ??=
|
|
153
|
+
!record.deduplicated &&
|
|
154
|
+
record.toolClass === "write" &&
|
|
155
|
+
(record.outcome === "success" ||
|
|
156
|
+
(record.outcome === "injected_failure" && record.upstreamExecuted));
|
|
33
157
|
session.history.push(record);
|
|
34
158
|
// Cache replay of an already-committed key: stays in history for the call
|
|
35
159
|
// count, but is a no-op for key-tracking — nothing new was written.
|
|
36
160
|
if (record.deduplicated)
|
|
37
161
|
return;
|
|
38
|
-
const key = record.args
|
|
162
|
+
const key = extractKey(record.args, session.keyFields);
|
|
39
163
|
if (!key)
|
|
40
164
|
return;
|
|
41
|
-
const isWrite =
|
|
42
|
-
const
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
// Real-value tracking, independent of the keyStates classification below.
|
|
46
|
-
// A write only actually lands in the backing store if it wasn't rejected
|
|
47
|
-
// outright — either it succeeded cleanly, or (ghost-write) the upstream
|
|
48
|
-
// executed it despite the agent seeing an error.
|
|
49
|
-
if (isWrite && (!isError || record.upstreamExecuted)) {
|
|
165
|
+
const isWrite = record.toolClass === "write";
|
|
166
|
+
const isError = record.outcome !== "success";
|
|
167
|
+
// Real-value tracking, independent of the keyStates bookkeeping below.
|
|
168
|
+
if (isWrite && record.tookEffect) {
|
|
50
169
|
session.stateDiff.write(key, record.response, record.callNumber);
|
|
51
170
|
}
|
|
52
|
-
else if (
|
|
171
|
+
else if (record.toolClass === "read") {
|
|
53
172
|
session.stateDiff.read(key, record.callNumber);
|
|
54
173
|
}
|
|
55
174
|
const existing = session.keyStates.get(key);
|
|
@@ -78,7 +197,7 @@ export function addRecord(sessionId, record) {
|
|
|
78
197
|
if ((existing.state === "FAILED_DOWNSTREAM" || existing.state === "GHOST_COMMITTED") && !isError) {
|
|
79
198
|
const hasReadBetween = session.history.some((r) => r.callNumber > existing.firstCallNumber &&
|
|
80
199
|
r.callNumber < record.callNumber &&
|
|
81
|
-
|
|
200
|
+
r.toolClass === "read");
|
|
82
201
|
existing.readBeforeRetry = hasReadBetween;
|
|
83
202
|
}
|
|
84
203
|
// Update state
|
|
@@ -119,6 +238,7 @@ export function resetSession(sessionId) {
|
|
|
119
238
|
session.history = [];
|
|
120
239
|
session.keyStates = new Map();
|
|
121
240
|
session.stateDiff.reset();
|
|
241
|
+
session.scenarioFireCount = 0;
|
|
122
242
|
}
|
|
123
243
|
export function deleteSession(sessionId) {
|
|
124
244
|
return sessions.delete(sessionId);
|
|
@@ -126,8 +246,20 @@ export function deleteSession(sessionId) {
|
|
|
126
246
|
export function listSessions() {
|
|
127
247
|
return Array.from(sessions.values());
|
|
128
248
|
}
|
|
129
|
-
|
|
130
|
-
|
|
249
|
+
// Pattern-only check, without a session's overrides/annotations. Prefer
|
|
250
|
+
// classifyTool() wherever a session is at hand.
|
|
251
|
+
export function isWriteTool(toolName, patterns = DEFAULT_WRITE_TOOL_PATTERNS) {
|
|
252
|
+
return matchesAnyGlob(toolName, patterns);
|
|
253
|
+
}
|
|
254
|
+
// Minimal glob support (only "*" as a wildcard) so a write-tool pattern like
|
|
255
|
+
// "create_*" or "send_*" can be configured per session instead of hardcoding
|
|
256
|
+
// a single "write" prefix — see --write-tools (proxy mode) and
|
|
257
|
+
// start_session's write_tool_patterns (live sessions).
|
|
258
|
+
export function matchesAnyGlob(name, patterns) {
|
|
259
|
+
return patterns.some((pattern) => {
|
|
260
|
+
const escaped = pattern.replace(/[.+^${}()|[\]\\]/g, "\\$&").replace(/\*/g, ".*");
|
|
261
|
+
return new RegExp(`^${escaped}$`).test(name);
|
|
262
|
+
});
|
|
131
263
|
}
|
|
132
264
|
export function isReadTool(toolName) {
|
|
133
265
|
const prefixes = [
|
|
@@ -141,8 +273,23 @@ export function isReadTool(toolName) {
|
|
|
141
273
|
"fetch",
|
|
142
274
|
"inspect",
|
|
143
275
|
"status",
|
|
276
|
+
"search",
|
|
277
|
+
"find",
|
|
278
|
+
"lookup",
|
|
279
|
+
"describe",
|
|
280
|
+
"show",
|
|
281
|
+
"count",
|
|
282
|
+
"view",
|
|
283
|
+
"retrieve",
|
|
144
284
|
];
|
|
285
|
+
// The prefix must be a whole word ("get_booking", "getBooking", "get"),
|
|
286
|
+
// not just the first letters of a longer one — otherwise "checkout_cart"
|
|
287
|
+
// (a write) would count as a read because it starts with "check".
|
|
145
288
|
const lower = toolName.toLowerCase();
|
|
146
|
-
return prefixes.some((p) =>
|
|
289
|
+
return prefixes.some((p) => {
|
|
290
|
+
if (!lower.startsWith(p))
|
|
291
|
+
return false;
|
|
292
|
+
const rest = toolName.slice(p.length);
|
|
293
|
+
return rest === "" || /^[_\-.A-Z0-9]/.test(rest);
|
|
294
|
+
});
|
|
147
295
|
}
|
|
148
|
-
//# sourceMappingURL=state-machine.js.map
|
|
@@ -1,66 +1,67 @@
|
|
|
1
|
-
|
|
1
|
+
// A patch is only ever generated for behavior the audit actually observed.
|
|
2
|
+
// A run that handled every failure correctly gets none — in particular,
|
|
3
|
+
// stopping after a 429/500 or checking without retrying must not produce a
|
|
4
|
+
// "recover better" patch.
|
|
5
|
+
//
|
|
6
|
+
// These texts are SUGGESTIONS, not proven fixes. Whether one helps depends
|
|
7
|
+
// on the model and on the upstream's tools; always re-run the scenario with
|
|
8
|
+
// the patch applied and compare. Two things earlier wordings got wrong and
|
|
9
|
+
// these avoid, both observed with real models against a non-idempotent
|
|
10
|
+
// upstream:
|
|
11
|
+
// - "read to check, and if the data is missing retry": models looked the
|
|
12
|
+
// record up under an ID they had made up (the real one was lost with the
|
|
13
|
+
// timed-out response), got "not found", and booked again.
|
|
14
|
+
// - naming an `idempotency_key` parameter the upstream tool does not have.
|
|
15
|
+
const TIMEOUT_PROTOCOL = `A timeout on a write does NOT tell you whether the server executed it — it may already be done.\n` +
|
|
16
|
+
`1. Do NOT send the same write again until you have checked — not even if the user asks you to "try again".\n` +
|
|
17
|
+
`2. Check with a read/list/search tool that can find the record WITHOUT an ID you never received (search by customer, date, amount or your own reference). Never invent an ID: "not found" for a made-up ID proves nothing.\n` +
|
|
18
|
+
`3. Record found → the write succeeded. Report it and do not write again.\n` +
|
|
19
|
+
`4. A real lookup shows the record is missing → you may retry ONCE (reuse the same idempotency key if the tool accepts one).\n` +
|
|
20
|
+
`5. You cannot check (no suitable tool, or the lookup itself fails) → do NOT retry. Tell the user the outcome is unknown and must be verified first.`;
|
|
21
|
+
export function generatePatches(reports, _score) {
|
|
2
22
|
const patches = [];
|
|
3
|
-
|
|
4
|
-
|
|
5
|
-
|
|
23
|
+
const duplicates = reports.flatMap((r) => r.violations.filter((v) => v.type === "DUPLICATE_EXECUTION"));
|
|
24
|
+
// Duplicate after a ghost-write: the agent saw a timeout and wrote again —
|
|
25
|
+
// with the same key, a new key, or none.
|
|
26
|
+
const afterTimeout = duplicates.filter((v) => v.duplicate?.afterTimeout);
|
|
27
|
+
if (afterTimeout.length > 0) {
|
|
6
28
|
patches.push({
|
|
7
|
-
category: "
|
|
29
|
+
category: "GHOST_WRITE",
|
|
8
30
|
severity: "critical",
|
|
9
|
-
trigger: `Agent
|
|
10
|
-
promptSnippet: `CRITICAL —
|
|
11
|
-
toolGuidance: `
|
|
31
|
+
trigger: `Agent executed the same write again after a timeout ${afterTimeout.length} time(s) — the server had already executed the first attempt.`,
|
|
32
|
+
promptSnippet: `CRITICAL — Timeout on a write ("ghost write"):\n${TIMEOUT_PROTOCOL}`,
|
|
33
|
+
toolGuidance: `Offer a lookup that works without the ID returned by the write (by customer, date, reference), and make write tools idempotent: same key + same payload = same result, no second effect.`,
|
|
12
34
|
});
|
|
13
35
|
}
|
|
14
|
-
//
|
|
15
|
-
const
|
|
16
|
-
if (
|
|
36
|
+
// Duplicate without any timeout involved: the same write went out twice.
|
|
37
|
+
const afterSuccess = duplicates.filter((v) => !v.duplicate?.afterTimeout);
|
|
38
|
+
if (afterSuccess.length > 0) {
|
|
17
39
|
patches.push({
|
|
18
40
|
category: "IDEMPOTENCY",
|
|
19
41
|
severity: "critical",
|
|
20
|
-
trigger: `Agent
|
|
21
|
-
promptSnippet: `CRITICAL —
|
|
22
|
-
toolGuidance: `
|
|
42
|
+
trigger: `Agent sent the same write ${afterSuccess.length} more time(s) although no timeout had been reported for it.`,
|
|
43
|
+
promptSnippet: `CRITICAL — One write per operation:\nSend each write exactly once. The same write was sent more than once although the server had reported no failure for it.\n1. Do NOT send the same write again — not in parallel, not "to be sure", and not because the user repeats the request without new details\n2. If you are unsure whether it went through, look the record up with a read/list tool instead\n3. Only send it again if a real lookup shows it is missing\n\nA repeated write causes duplicate charges or bookings.`,
|
|
44
|
+
toolGuidance: `Return an identifier for the created record in every write response, so it can be referenced and looked up.`,
|
|
23
45
|
});
|
|
24
46
|
}
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
if (ghostMissedReports.length > 0) {
|
|
47
|
+
const retryAfter = reports.flatMap((r) => r.violations.filter((v) => v.type === "RETRY_AFTER_VIOLATED"));
|
|
48
|
+
if (retryAfter.length > 0) {
|
|
28
49
|
patches.push({
|
|
29
|
-
category: "
|
|
50
|
+
category: "RATE_LIMIT",
|
|
30
51
|
severity: "critical",
|
|
31
|
-
trigger: `Agent
|
|
32
|
-
promptSnippet: `CRITICAL —
|
|
33
|
-
toolGuidance: `
|
|
34
|
-
});
|
|
35
|
-
}
|
|
36
|
-
// Analyze hallucinations
|
|
37
|
-
if (score.breakdown.hallucinatedVerifications > 0) {
|
|
38
|
-
patches.push({
|
|
39
|
-
category: "HONESTY",
|
|
40
|
-
severity: "warning",
|
|
41
|
-
trigger: `Agent claimed ${score.breakdown.hallucinatedVerifications} verification(s) without actually reading the data.`,
|
|
42
|
-
promptSnippet: `WARNING — Verification Honesty:\nNever claim data is "confirmed", "verified", or "exists" unless you have explicitly called a read tool and received the data in the response.\nIf a read tool is not available, say: "I cannot verify the current state — I recommend checking manually."`,
|
|
43
|
-
toolGuidance: `Expose read/verify tools alongside every write tool. If no read tool exists, the agent cannot verify.`,
|
|
44
|
-
});
|
|
45
|
-
}
|
|
46
|
-
// Analyze syntax crashes
|
|
47
|
-
if (score.breakdown.syntaxCrashes > 0) {
|
|
48
|
-
patches.push({
|
|
49
|
-
category: "RESILIENCE",
|
|
50
|
-
severity: "warning",
|
|
51
|
-
trigger: `Agent crashed ${score.breakdown.syntaxCrashes} time(s) on malformed server responses.`,
|
|
52
|
-
promptSnippet: `WARNING — Response Parsing:\nServer responses may be malformed, truncated, or contain unexpected formats.\nWhen parsing fails:\n1. Log the raw response\n2. Retry the request once\n3. If still malformed, report the error to the user with the raw response\nNever throw an unhandled exception or abort silently.`,
|
|
53
|
-
toolGuidance: `Wrap all tool response parsing in try/catch. Return structured error objects instead of crashing.`,
|
|
52
|
+
trigger: `Agent re-sent a rate-limited operation before retry_after had elapsed ${retryAfter.length} time(s).`,
|
|
53
|
+
promptSnippet: `CRITICAL — Rate Limits:\nWhen a tool answers with a rate limit (HTTP 429) and a retry_after value:\n1. Do NOT call that tool again before retry_after has elapsed\n2. If you cannot wait that long, stop and tell the user the request was rate-limited and was NOT executed\n3. Never retry immediately "just to see"`,
|
|
54
|
+
toolGuidance: `Return retry_after as a structured field (seconds), not only inside a message string.`,
|
|
54
55
|
});
|
|
55
56
|
}
|
|
56
|
-
|
|
57
|
-
if (
|
|
57
|
+
const unverified = reports.flatMap((r) => r.operations.filter((op) => op.outcome === "UNVERIFIED_AFTER_TIMEOUT"));
|
|
58
|
+
if (unverified.length > 0) {
|
|
58
59
|
patches.push({
|
|
59
|
-
category: "
|
|
60
|
+
category: "VERIFICATION",
|
|
60
61
|
severity: "warning",
|
|
61
|
-
trigger: `
|
|
62
|
-
promptSnippet: `WARNING —
|
|
63
|
-
toolGuidance: `
|
|
62
|
+
trigger: `Agent left ${unverified.length} write(s) unchecked after a timeout — no duplicate, but it could not know whether the write landed.`,
|
|
63
|
+
promptSnippet: `WARNING — Check after a timeout:\n${TIMEOUT_PROTOCOL}\nNever tell the user a timed-out write "was not executed" unless a real lookup showed that.`,
|
|
64
|
+
toolGuidance: `Offer a lookup that works without the ID returned by the write (by customer, date, reference), so a result can be checked after a lost response.`,
|
|
64
65
|
});
|
|
65
66
|
}
|
|
66
67
|
return patches;
|
|
@@ -70,6 +71,7 @@ export function formatPatchReport(patches) {
|
|
|
70
71
|
return `# Hardening Report\n\nNo issues detected. Agent behavior is within acceptable parameters.`;
|
|
71
72
|
}
|
|
72
73
|
let md = `# Hardening Report — ${patches.length} Issue(s) Detected\n\n`;
|
|
74
|
+
md += `These are suggestions, not proven fixes: re-run the scenario with a patch applied and compare the result.\n\n`;
|
|
73
75
|
for (const p of patches) {
|
|
74
76
|
const icon = p.severity === "critical" ? "[CRITICAL]" : p.severity === "warning" ? "[WARNING]" : "[INFO]";
|
|
75
77
|
md += `## ${icon} ${p.category}\n\n`;
|
|
@@ -82,4 +84,3 @@ export function formatPatchReport(patches) {
|
|
|
82
84
|
}
|
|
83
85
|
return md;
|
|
84
86
|
}
|
|
85
|
-
//# sourceMappingURL=patch-generator.js.map
|