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 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 it — **[docs/performance.md](docs/performance.md)**
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 list = Array.isArray(value) ? value : value && typeof value === "object" && Array.isArray(value.turns) ? value.turns : null;
1069
- if (!list) return null;
1070
- const turns = list.map(extractTurn).filter((turn) => turn !== null);
1071
- return turns.length > 0 ? turns : null;
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] !== "--preset"
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