llm-output-guard 1.10.0 → 1.11.1
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 +5 -1
- package/dist/bin.cjs +91 -6
- package/dist/bin.cjs.map +1 -1
- package/dist/bin.js +91 -6
- package/dist/bin.js.map +1 -1
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -142,6 +142,10 @@ A turn carrying tool calls is judged by its **arguments**, never its prose —
|
|
|
142
142
|
that is the difference between an agent working through a list and an agent
|
|
143
143
|
stuck on one item.
|
|
144
144
|
|
|
145
|
+
**[Try it on a run →](https://edwinsatya.github.io/llm-output-guard/)** — the
|
|
146
|
+
playground has an *agent run* mode: pick a trace, see which turns form the
|
|
147
|
+
cycle. The healthy traps are the half worth looking at.
|
|
148
|
+
|
|
145
149
|
**[docs/agent-loops.md](docs/agent-loops.md)**
|
|
146
150
|
|
|
147
151
|
## The hard part is not catching loops
|
|
@@ -267,7 +271,7 @@ because only one of those is evidence.
|
|
|
267
271
|
- **Scores, not booleans.** Detectors report 0–1 and leave the threshold decision to you.
|
|
268
272
|
- **Abstains rather than guesses.** Samples too short to judge score 0.
|
|
269
273
|
- **Hand-rolled LZ77** rather than `node:zlib`, so the package stays runtime-agnostic.
|
|
270
|
-
- **Sub-millisecond**, 0.383 ms at 500 B and 1.014 ms at 32 KB, with one detector accounting for most of it. `npm run bench` reproduces
|
|
274
|
+
- **Sub-millisecond**, 0.383 ms at 500 B and 1.014 ms at 32 KB, with one detector accounting for most of it. A 12-turn agent run costs 0.075 ms. `npm run bench` reproduces both — **[docs/performance.md](docs/performance.md)**
|
|
271
275
|
- **Chinese, Japanese and Thai** are handled where they differ: `TAIL_LOOP` switches to character mode, `REPETITION` is blind and says so — **[docs/script-coverage.md](docs/script-coverage.md)**
|
|
272
276
|
|
|
273
277
|
## Stability
|
package/dist/bin.cjs
CHANGED
|
@@ -1033,6 +1033,16 @@ function extractTurn(value) {
|
|
|
1033
1033
|
return typeof value === "string" && value.length > 0 ? { text: value } : null;
|
|
1034
1034
|
}
|
|
1035
1035
|
const record = value;
|
|
1036
|
+
if (typeof record.role === "string" && record.role !== "assistant" && record.role !== "model") {
|
|
1037
|
+
return null;
|
|
1038
|
+
}
|
|
1039
|
+
const messageCalls = Array.isArray(record.tool_calls) ? record.tool_calls : Array.isArray(record.toolCalls) && typeof record.content === "string" ? record.toolCalls : null;
|
|
1040
|
+
if (messageCalls && (typeof record.content === "string" || record.content == null)) {
|
|
1041
|
+
return toTurn({ choices: [{ message: { ...record, tool_calls: messageCalls } }] });
|
|
1042
|
+
}
|
|
1043
|
+
if (typeof record.content === "string" && record.role != null) {
|
|
1044
|
+
return record.content.length > 0 ? { text: record.content } : null;
|
|
1045
|
+
}
|
|
1036
1046
|
if (Array.isArray(record.toolCalls)) {
|
|
1037
1047
|
const calls = record.toolCalls;
|
|
1038
1048
|
const native = calls.every(
|
|
@@ -1064,11 +1074,32 @@ function extractTurn(value) {
|
|
|
1064
1074
|
if (typeof record.text === "string") return { text: record.text };
|
|
1065
1075
|
return null;
|
|
1066
1076
|
}
|
|
1077
|
+
var TURN_KEYS = ["turns", "messages", "history", "conversation", "steps"];
|
|
1067
1078
|
function extractTurns(value) {
|
|
1068
|
-
const
|
|
1069
|
-
|
|
1070
|
-
|
|
1071
|
-
|
|
1079
|
+
const read = (list) => {
|
|
1080
|
+
if (!Array.isArray(list)) return null;
|
|
1081
|
+
const turns = list.map(extractTurn).filter((turn) => turn !== null);
|
|
1082
|
+
return turns.length > 0 ? turns : null;
|
|
1083
|
+
};
|
|
1084
|
+
if (Array.isArray(value)) return read(value);
|
|
1085
|
+
if (!value || typeof value !== "object") return null;
|
|
1086
|
+
const record = value;
|
|
1087
|
+
const named = (from) => {
|
|
1088
|
+
for (const key of TURN_KEYS) {
|
|
1089
|
+
const found = read(from[key]);
|
|
1090
|
+
if (found) return found;
|
|
1091
|
+
}
|
|
1092
|
+
return null;
|
|
1093
|
+
};
|
|
1094
|
+
const here = named(record);
|
|
1095
|
+
if (here) return here;
|
|
1096
|
+
for (const nested of Object.values(record)) {
|
|
1097
|
+
if (nested && typeof nested === "object" && !Array.isArray(nested)) {
|
|
1098
|
+
const found = named(nested);
|
|
1099
|
+
if (found) return found;
|
|
1100
|
+
}
|
|
1101
|
+
}
|
|
1102
|
+
return null;
|
|
1072
1103
|
}
|
|
1073
1104
|
function describe2(item) {
|
|
1074
1105
|
const { label, verdict } = item;
|
|
@@ -1086,6 +1117,12 @@ function runCheck(args) {
|
|
|
1086
1117
|
let presetName = "chat";
|
|
1087
1118
|
const presetAt = args.indexOf("--preset");
|
|
1088
1119
|
if (presetAt !== -1) {
|
|
1120
|
+
if (asTrace) {
|
|
1121
|
+
process.stderr.write(
|
|
1122
|
+
"--preset applies to responses, not runs. --trace scores AGENT_LOOP, which has one threshold: use --max-agent-loop.\n"
|
|
1123
|
+
);
|
|
1124
|
+
return 2;
|
|
1125
|
+
}
|
|
1089
1126
|
const name = args[presetAt + 1];
|
|
1090
1127
|
if (!name || !Object.hasOwn(presets, name)) {
|
|
1091
1128
|
process.stderr.write(
|
|
@@ -1097,8 +1134,41 @@ function runCheck(args) {
|
|
|
1097
1134
|
preset = presets[name];
|
|
1098
1135
|
presetName = name;
|
|
1099
1136
|
}
|
|
1137
|
+
const traceOptions = {};
|
|
1138
|
+
const numeric = [
|
|
1139
|
+
["--window", "window"],
|
|
1140
|
+
["--min-turns", "minTurns"],
|
|
1141
|
+
["--max-agent-loop", "maxAgentLoop"]
|
|
1142
|
+
];
|
|
1143
|
+
const VALUED = /* @__PURE__ */ new Set(["--preset", "--ignore-tools", ...numeric.map(([flag]) => flag)]);
|
|
1144
|
+
for (const [flag, key] of numeric) {
|
|
1145
|
+
const at = args.indexOf(flag);
|
|
1146
|
+
if (at === -1) continue;
|
|
1147
|
+
const value = Number(args[at + 1]);
|
|
1148
|
+
if (!Number.isFinite(value) || value < 0) {
|
|
1149
|
+
process.stderr.write(`${flag} expects a number, got: ${args[at + 1] ?? "(nothing)"}
|
|
1150
|
+
`);
|
|
1151
|
+
return 2;
|
|
1152
|
+
}
|
|
1153
|
+
traceOptions[key] = value;
|
|
1154
|
+
}
|
|
1155
|
+
const ignoreAt = args.indexOf("--ignore-tools");
|
|
1156
|
+
if (ignoreAt !== -1) {
|
|
1157
|
+
const names = (args[ignoreAt + 1] ?? "").split(",").map((n) => n.trim()).filter(Boolean);
|
|
1158
|
+
if (names.length === 0) {
|
|
1159
|
+
process.stderr.write("--ignore-tools expects a comma-separated list of tool names\n");
|
|
1160
|
+
return 2;
|
|
1161
|
+
}
|
|
1162
|
+
traceOptions.ignoreTools = names;
|
|
1163
|
+
}
|
|
1164
|
+
const usedTraceFlag = [...VALUED].find((f) => f !== "--preset" && args.includes(f));
|
|
1165
|
+
if (!asTrace && usedTraceFlag) {
|
|
1166
|
+
process.stderr.write(`${usedTraceFlag} only applies with --trace
|
|
1167
|
+
`);
|
|
1168
|
+
return 2;
|
|
1169
|
+
}
|
|
1100
1170
|
const files = args.filter(
|
|
1101
|
-
(arg, i) => !arg.startsWith("--") && args[i - 1]
|
|
1171
|
+
(arg, i) => !arg.startsWith("--") && !VALUED.has(args[i - 1] ?? "")
|
|
1102
1172
|
);
|
|
1103
1173
|
return (async () => {
|
|
1104
1174
|
const items = [];
|
|
@@ -1173,7 +1243,7 @@ function runCheck(args) {
|
|
|
1173
1243
|
}
|
|
1174
1244
|
const checked = asTrace ? traces.map(({ label, turns }) => ({
|
|
1175
1245
|
label: `${label} (${turns.length} turns)`,
|
|
1176
|
-
verdict: checkTrace(turns)
|
|
1246
|
+
verdict: checkTrace(turns, traceOptions)
|
|
1177
1247
|
})) : items.map(({ label, text }) => ({
|
|
1178
1248
|
label,
|
|
1179
1249
|
verdict: checkOutput(text, preset)
|
|
@@ -1209,6 +1279,13 @@ Options
|
|
|
1209
1279
|
--preset <name> chat | strictJson | longForm | lenient (default chat)
|
|
1210
1280
|
--jsonl read input as JSONL, one logged response per line
|
|
1211
1281
|
--trace read input as agent runs, one run per line
|
|
1282
|
+
|
|
1283
|
+
With --trace, scoring the run instead of the response:
|
|
1284
|
+
--ignore-tools <a,b> tools whose calls are not compared \u2014 a job poller,
|
|
1285
|
+
a sleep, a clock. By shape those are a loop
|
|
1286
|
+
--max-agent-loop <n> cycle-coverage threshold (default 0.4)
|
|
1287
|
+
--window <n> trailing turns inspected (default 12)
|
|
1288
|
+
--min-turns <n> turns required before judging at all (default 4)
|
|
1212
1289
|
--json emit a verdict per response as JSONL, for calibrate
|
|
1213
1290
|
--quiet no per-response output; the exit code is the answer
|
|
1214
1291
|
|
|
@@ -1231,6 +1308,14 @@ a native turn, or the raw envelope from any adapter this package ships:
|
|
|
1231
1308
|
[{"text":"Reading.","toolCalls":[{"name":"read_file","arguments":{"p":"a.ts"}}]}]
|
|
1232
1309
|
{"run":"job-14","turns":[{"choices":[{"message":{"content":"..."}}]}]}
|
|
1233
1310
|
|
|
1311
|
+
The turns are found under "turns", "messages", "history", "conversation" or
|
|
1312
|
+
"steps" -- at the top level or one object down -- so a request body or a logged
|
|
1313
|
+
run needs no reshaping. Only the model's own messages are read: system, user
|
|
1314
|
+
and tool messages are skipped rather than counted as turns.
|
|
1315
|
+
|
|
1316
|
+
{"turns":[{"role":"assistant","content":"\u2026","tool_calls":[\u2026]},
|
|
1317
|
+
{"role":"tool","content":"\u2026"}]}
|
|
1318
|
+
|
|
1234
1319
|
A file that parses whole as one array is read as a single run, so a run logged
|
|
1235
1320
|
as one pretty-printed document works without reshaping.
|
|
1236
1321
|
|