llm-output-guard 1.7.0 → 1.9.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.
Files changed (60) hide show
  1. package/README.md +52 -5
  2. package/dist/{adapter-options-DNkMNS92.d.ts → adapter-options-B0H8fka3.d.ts} +1 -1
  3. package/dist/{adapter-options-B-fgYd--.d.cts → adapter-options-DwlCHZ06.d.cts} +1 -1
  4. package/dist/agent.cjs +192 -0
  5. package/dist/agent.cjs.map +1 -0
  6. package/dist/agent.d.cts +241 -0
  7. package/dist/agent.d.ts +241 -0
  8. package/dist/agent.js +140 -0
  9. package/dist/agent.js.map +1 -0
  10. package/dist/ai-sdk.cjs +27 -20
  11. package/dist/ai-sdk.cjs.map +1 -1
  12. package/dist/ai-sdk.d.cts +4 -3
  13. package/dist/ai-sdk.d.ts +4 -3
  14. package/dist/ai-sdk.js +3 -2
  15. package/dist/ai-sdk.js.map +1 -1
  16. package/dist/anthropic.cjs +43 -25
  17. package/dist/anthropic.cjs.map +1 -1
  18. package/dist/anthropic.d.cts +4 -3
  19. package/dist/anthropic.d.ts +4 -3
  20. package/dist/anthropic.js +4 -3
  21. package/dist/anthropic.js.map +1 -1
  22. package/dist/bin.cjs +27 -20
  23. package/dist/bin.cjs.map +1 -1
  24. package/dist/bin.js +27 -20
  25. package/dist/bin.js.map +1 -1
  26. package/dist/check-B7lLF8a8.d.cts +30 -0
  27. package/dist/check-DiKuVvZM.d.ts +30 -0
  28. package/dist/{chunk-XJDWUHOP.js → chunk-5RDXIKYO.js} +21 -9
  29. package/dist/chunk-5RDXIKYO.js.map +1 -0
  30. package/dist/{chunk-CKOCNNFY.js → chunk-PCEGLE2B.js} +30 -185
  31. package/dist/chunk-PCEGLE2B.js.map +1 -0
  32. package/dist/chunk-RSTPJ57Q.js +167 -0
  33. package/dist/chunk-RSTPJ57Q.js.map +1 -0
  34. package/dist/{chunk-6XVGQCC2.js → chunk-TMTDOMZV.js} +3 -3
  35. package/dist/{chunk-6XVGQCC2.js.map → chunk-TMTDOMZV.js.map} +1 -1
  36. package/dist/google.cjs +955 -0
  37. package/dist/google.cjs.map +1 -0
  38. package/dist/google.d.cts +62 -0
  39. package/dist/google.d.ts +62 -0
  40. package/dist/google.js +80 -0
  41. package/dist/google.js.map +1 -0
  42. package/dist/index.cjs +27 -20
  43. package/dist/index.cjs.map +1 -1
  44. package/dist/index.d.cts +5 -30
  45. package/dist/index.d.ts +5 -30
  46. package/dist/index.js +2 -1
  47. package/dist/index.js.map +1 -1
  48. package/dist/openai.cjs +43 -25
  49. package/dist/openai.cjs.map +1 -1
  50. package/dist/openai.d.cts +4 -3
  51. package/dist/openai.d.ts +4 -3
  52. package/dist/openai.js +4 -3
  53. package/dist/openai.js.map +1 -1
  54. package/dist/stream--__c1Xrj.d.cts +130 -0
  55. package/dist/stream-DFuVMN_h.d.ts +130 -0
  56. package/dist/{stream-BYy6vuGy.d.cts → types-CxKV_wpA.d.cts} +8 -129
  57. package/dist/{stream-BYy6vuGy.d.ts → types-CxKV_wpA.d.ts} +8 -129
  58. package/package.json +32 -1
  59. package/dist/chunk-CKOCNNFY.js.map +0 -1
  60. package/dist/chunk-XJDWUHOP.js.map +0 -1
package/README.md CHANGED
@@ -91,8 +91,9 @@ const client = withOutputGuard(new OpenAI(), { ...presets.chat, onDegenerate: 'a
91
91
  ```
92
92
 
93
93
  Adapters for the **OpenAI SDK** (both `chat.completions` and `responses`),
94
- **Anthropic**, and the **Vercel AI SDK** — plus anything speaking OpenAI's
95
- protocol: Groq, Together, OpenRouter, Fireworks, vLLM, Ollama.
94
+ **Anthropic**, **Google Gemini** and the **Vercel AI SDK** — plus anything
95
+ speaking OpenAI's protocol: Groq, Together, OpenRouter, Fireworks, vLLM,
96
+ Ollama.
96
97
 
97
98
  On a stream this **cancels the HTTP request** the moment a loop is detectable, so
98
99
  you stop paying for the rest of it. Two switches worth knowing:
@@ -102,6 +103,37 @@ replays it instead of answering is caught.
102
103
 
103
104
  **[docs/adapters.md](docs/adapters.md)** · **[docs/streaming.md](docs/streaming.md)**
104
105
 
106
+ ## Agents fail across turns, not inside one
107
+
108
+ An agent that calls `read_file` on the same path six turns running has produced
109
+ six healthy responses. Every detector above scores each of them **0.000**, and
110
+ is right to — nothing is wrong with any *response*. What is wrong is the
111
+ sequence.
112
+
113
+ ```ts
114
+ import { createAgentGuard } from 'llm-output-guard/agent';
115
+
116
+ const guard = createAgentGuard();
117
+
118
+ while (!done) {
119
+ const response = await model.step();
120
+ const verdict = guard.observe({ text: response.text, toolCalls: response.toolCalls });
121
+ if (!verdict.ok) break; // the run is circling; stop paying for it
122
+ }
123
+ ```
124
+
125
+ `AGENT_LOOP` reads one axis nothing else here reads. It looks for an **exact**
126
+ repeating cycle of turns, which is what keeps it off the shapes that dominate
127
+ healthy agent traffic: twenty reads of twenty different files, an identical
128
+ preamble on every turn, edit/test/edit/test, pagination, a retry. Every healthy
129
+ trace in the corpus scores **0.000**; the weakest degenerate one scores 0.455.
130
+
131
+ A turn carrying tool calls is judged by its **arguments**, never its prose —
132
+ that is the difference between an agent working through a list and an agent
133
+ stuck on one item.
134
+
135
+ **[docs/agent-loops.md](docs/agent-loops.md)**
136
+
105
137
  ## The hard part is not catching loops
106
138
 
107
139
  A miss is annoying. **A false positive is worse** — a healthy response gets
@@ -227,9 +259,11 @@ What semver means here specifically. These rules bind from **1.0.0** onward, and
227
259
  the public surface was frozen export by export in that release.
228
260
 
229
261
  **The public API is** everything exported from `llm-output-guard`, plus
230
- `outputGuard` / `OutputGuardOptions` / `DegenerateAction` from `./ai-sdk` and
231
- `withOutputGuard` / `OutputGuardOptions` / `DegenerateAction` from `./openai` and
232
- `./anthropic`. Each subpath is its own contract, so an option added to one is not
262
+ `outputGuard` / `OutputGuardOptions` / `DegenerateAction` from `./ai-sdk`,
263
+ `withOutputGuard` / `OutputGuardOptions` / `DegenerateAction` from `./openai`,
264
+ `./anthropic` and `./google`, and `checkTrace` / `assertTrace` /
265
+ `createAgentGuard` / `agentLoopScore` / `agentLoopDetail` from `./agent`. Each
266
+ subpath is its own contract, so an option added to one is not
233
267
  a promise about the others. Anything else is internal and may move in any
234
268
  release. The list is asserted in `test/surface.test.ts`, so an export cannot join
235
269
  it by accident.
@@ -243,6 +277,7 @@ interesting case, so it gets a rule of its own:
243
277
  | Adding a new detector that runs by default | **major** |
244
278
  | Adding a new *option*, defaulted so nothing changes | minor |
245
279
  | Adding a new detector that is opt-in | minor |
280
+ | Adding a member to an exported union (`ReasonCode`) | minor |
246
281
  | Making an existing detector strictly more accurate on its own axis | minor |
247
282
  | Docs, internals, performance, fixing a detector that returned a wrong score | patch |
248
283
 
@@ -251,6 +286,16 @@ responses get discarded and retried, which is a larger event than a signature
251
286
  change and invisible until your traffic hits it. A number in `presets.chat` is
252
287
  part of the contract in the same way a function name is.
253
288
 
289
+ **A new union member is a minor, and the reasoning is the inverse of the row
290
+ above.** Adding `AGENT_LOOP` to `ReasonCode` cannot change what any existing
291
+ call returns — no response is judged differently and no threshold moves. What it
292
+ can do is fail a consumer's *build*, where a bare `Record<ReasonCode, T>` is now
293
+ missing a key or an exhaustive `switch` has lost its `never`. That breakage is
294
+ loud, immediate, and one line to fix, which makes it the cheap kind: the
295
+ expensive kind is the threshold change above, which builds fine and quietly
296
+ discards different responses. Use `Partial<Record<ReasonCode, T>>` if you want
297
+ to be immune — it is what this package's own CLI does.
298
+
254
299
  **Not covered:** the exact scores a detector returns (only their direction and
255
300
  the thresholds acting on them), the fixture corpus, `message` strings in
256
301
  `Reason`, or the human-readable `calibrate` report. `--json` output *is* covered.
@@ -275,6 +320,8 @@ re-released as 0.5.0. The rule it broke is the one in the table above.
275
320
  - `openai`'s `responses.stream()` helper is not wrapped; `create({ stream: true })` is.
276
321
  - Truncation from a missing full stop is weak evidence, scored 0.55 and deliberately left below the defaults. Lower `maxTruncation` to ~0.5 to catch it, and expect false positives.
277
322
  - A JSON array of repeated identical records reads as a loop and fails from three records up. Set `redundancyScope: 'jsonValues'`.
323
+ - `AGENT_LOOP` needs an *exact* cycle. An agent circling without repeating — `build`, read a file, `build`, read another, `build` — is **not** detected: the only signal that reads it scores that 0.444 against a healthy edit/test rhythm's 0.375, a margin too small to ship. Measured, rejected, and pinned by a test.
324
+ - A tool whose job is to be called repeatedly with identical arguments (polling, sleeping) is indistinguishable from a loop by shape. Name it in `ignoreTools`.
278
325
  - Thresholds are calibrated on the bundled corpus. Yours will differ — and the word and character thresholds need calibrating **separately**.
279
326
 
280
327
  ## License
@@ -1,4 +1,4 @@
1
- import { V as Verdict } from './stream-BYy6vuGy.js';
1
+ import { V as Verdict } from './types-CxKV_wpA.js';
2
2
 
3
3
  /**
4
4
  * The option surface every provider adapter shares.
@@ -1,4 +1,4 @@
1
- import { V as Verdict } from './stream-BYy6vuGy.cjs';
1
+ import { V as Verdict } from './types-CxKV_wpA.cjs';
2
2
 
3
3
  /**
4
4
  * The option surface every provider adapter shares.
package/dist/agent.cjs ADDED
@@ -0,0 +1,192 @@
1
+ 'use strict';
2
+
3
+ // src/internal/tokenize.ts
4
+ function clamp01(n) {
5
+ if (Number.isNaN(n)) return 0;
6
+ return n < 0 ? 0 : n > 1 ? 1 : n;
7
+ }
8
+
9
+ // src/internal/periodicity.ts
10
+ function periodicDetail(tail, { maxPeriod, minRepeats, minSample = minRepeats * 2 }) {
11
+ const none = { score: 0, period: 0, repeats: 0 };
12
+ if (tail.length < minSample) return none;
13
+ let best = none;
14
+ const periodCap = Math.min(maxPeriod, Math.floor(tail.length / minRepeats));
15
+ for (let p = 1; p <= periodCap; p++) {
16
+ const block = tail.slice(tail.length - p);
17
+ let repeats = 1;
18
+ let cursor = tail.length - p;
19
+ while (cursor - p >= 0) {
20
+ let same = true;
21
+ for (let k = 0; k < p; k++) {
22
+ if (tail[cursor - p + k] !== block[k]) {
23
+ same = false;
24
+ break;
25
+ }
26
+ }
27
+ if (!same) break;
28
+ repeats++;
29
+ cursor -= p;
30
+ }
31
+ if (repeats >= minRepeats) {
32
+ const score = clamp01(repeats * p / tail.length);
33
+ if (score > best.score) best = { score, period: p, repeats };
34
+ }
35
+ }
36
+ return best;
37
+ }
38
+
39
+ // src/internal/turn-fingerprint.ts
40
+ var NAME_SEP = "\0";
41
+ var CALL_SEP = "";
42
+ function canonicalArguments(args) {
43
+ if (args === null || args === void 0) return "";
44
+ if (typeof args === "string") {
45
+ const trimmed = args.trim();
46
+ if (trimmed === "") return "";
47
+ try {
48
+ return canonicalArguments(JSON.parse(trimmed));
49
+ } catch {
50
+ return trimmed;
51
+ }
52
+ }
53
+ try {
54
+ return stringifySorted(args, /* @__PURE__ */ new WeakSet());
55
+ } catch {
56
+ return "";
57
+ }
58
+ }
59
+ function stringifySorted(value, seen) {
60
+ if (value === null || typeof value !== "object") return JSON.stringify(value) ?? "null";
61
+ if (seen.has(value)) return '"[circular]"';
62
+ seen.add(value);
63
+ try {
64
+ if (Array.isArray(value)) {
65
+ return `[${value.map((item) => stringifySorted(item, seen)).join(",")}]`;
66
+ }
67
+ const entries = Object.entries(value).filter(([, v]) => v !== void 0).sort(([a], [b]) => a < b ? -1 : a > b ? 1 : 0).map(([k, v]) => `${JSON.stringify(k)}:${stringifySorted(v, seen)}`);
68
+ return `{${entries.join(",")}}`;
69
+ } finally {
70
+ seen.delete(value);
71
+ }
72
+ }
73
+ function normaliseText(text) {
74
+ return text.toLowerCase().replace(/\s+/g, " ").trim();
75
+ }
76
+ var callFingerprint = (call) => `${call.name ?? ""}${NAME_SEP}${canonicalArguments(call.arguments)}`;
77
+ function fingerprintTurn(turn, ignoreTools) {
78
+ const issued = turn.toolCalls ?? [];
79
+ if (issued.length > 0) {
80
+ const kept = issued.filter((call) => !(call.name != null && ignoreTools.has(call.name)));
81
+ if (kept.length === 0) return null;
82
+ return kept.map(callFingerprint).sort().join(CALL_SEP);
83
+ }
84
+ const text = normaliseText(turn.text ?? "");
85
+ return text === "" ? null : `text${NAME_SEP}${text}`;
86
+ }
87
+
88
+ // src/detectors/agent-loop.ts
89
+ var AGENT_LOOP_DEFAULTS = {
90
+ window: 12,
91
+ minTurns: 4,
92
+ minRepeats: 3,
93
+ maxPeriod: 4
94
+ };
95
+ function agentLoopDetail(turns, options = {}) {
96
+ const { window, minTurns, minRepeats, maxPeriod } = { ...AGENT_LOOP_DEFAULTS, ...options };
97
+ const ignore = new Set(options.ignoreTools ?? []);
98
+ const none = { score: 0, period: 0, repeats: 0, measured: 0, cycle: [] };
99
+ if (!Array.isArray(turns)) return none;
100
+ const fingerprints = [];
101
+ const labels = [];
102
+ for (const turn of turns) {
103
+ if (turn == null || typeof turn !== "object") continue;
104
+ const fp = fingerprintTurn(turn, ignore);
105
+ if (fp === null) continue;
106
+ fingerprints.push(fp);
107
+ labels.push(labelTurn(turn, ignore));
108
+ }
109
+ const size = Math.max(1, window);
110
+ const tail = fingerprints.slice(-size);
111
+ if (tail.length < minTurns) return { ...none, measured: tail.length };
112
+ const detail = periodicDetail(tail, { maxPeriod, minRepeats, minSample: minTurns });
113
+ const cycle = detail.period > 0 ? labels.slice(-detail.period) : [];
114
+ return { ...detail, measured: tail.length, cycle };
115
+ }
116
+ function labelTurn(turn, ignore) {
117
+ const kept = (turn.toolCalls ?? []).filter(
118
+ (call) => !(call.name != null && ignore.has(call.name))
119
+ );
120
+ if (kept.length === 0) return "prose";
121
+ return kept.map((call) => call.name ?? "(unnamed)").join("+");
122
+ }
123
+ function agentLoopScore(turns, options = {}) {
124
+ return agentLoopDetail(turns, options).score;
125
+ }
126
+
127
+ // src/check.ts
128
+ var DegenerateOutputError = class extends Error {
129
+ verdict;
130
+ /** Marks this as safe to retry against another provider. */
131
+ retryable = true;
132
+ constructor(verdict) {
133
+ super(`Degenerate LLM output: ${verdict.reasons.map((r) => r.code).join(", ")}`);
134
+ this.name = "DegenerateOutputError";
135
+ this.verdict = verdict;
136
+ }
137
+ };
138
+
139
+ // src/agent.ts
140
+ var MAX_AGENT_LOOP = 0.4;
141
+ function checkTrace(turns, options = {}) {
142
+ const threshold = options.maxAgentLoop === void 0 ? MAX_AGENT_LOOP : options.maxAgentLoop;
143
+ const reasons = [];
144
+ const scores = {};
145
+ if (threshold == null) return { ok: true, reasons, scores };
146
+ const detail = agentLoopDetail(turns, options);
147
+ scores.AGENT_LOOP = detail.score;
148
+ if (detail.score > threshold) {
149
+ const block = detail.cycle.join(" -> ");
150
+ reasons.push({
151
+ code: "AGENT_LOOP",
152
+ score: detail.score,
153
+ threshold,
154
+ message: `Agent repeated the same ${detail.period}-turn cycle ${detail.repeats} times (${Math.round(detail.score * 100)}% of the last ${detail.measured} turns): ${block}.`
155
+ });
156
+ }
157
+ return { ok: reasons.length === 0, reasons, scores };
158
+ }
159
+ function assertTrace(turns, options = {}) {
160
+ const verdict = checkTrace(turns, options);
161
+ if (!verdict.ok) throw new DegenerateOutputError(verdict);
162
+ return turns;
163
+ }
164
+ function createAgentGuard(options = {}) {
165
+ const window = Math.max(1, options.window ?? AGENT_LOOP_DEFAULTS.window);
166
+ const ignore = new Set(options.ignoreTools ?? []);
167
+ let retained = [];
168
+ return {
169
+ observe(turn) {
170
+ if (turn != null && typeof turn === "object" && fingerprintTurn(turn, ignore) !== null) {
171
+ retained.push(turn);
172
+ if (retained.length > window) retained = retained.slice(-window);
173
+ }
174
+ return checkTrace(retained, options);
175
+ },
176
+ reset() {
177
+ retained = [];
178
+ },
179
+ get size() {
180
+ return retained.length;
181
+ }
182
+ };
183
+ }
184
+
185
+ exports.DegenerateOutputError = DegenerateOutputError;
186
+ exports.agentLoopDetail = agentLoopDetail;
187
+ exports.agentLoopScore = agentLoopScore;
188
+ exports.assertTrace = assertTrace;
189
+ exports.checkTrace = checkTrace;
190
+ exports.createAgentGuard = createAgentGuard;
191
+ //# sourceMappingURL=agent.cjs.map
192
+ //# sourceMappingURL=agent.cjs.map
@@ -0,0 +1 @@
1
+ {"version":3,"sources":["../src/internal/tokenize.ts","../src/internal/periodicity.ts","../src/internal/turn-fingerprint.ts","../src/detectors/agent-loop.ts","../src/check.ts","../src/agent.ts"],"names":[],"mappings":"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","file":"agent.cjs","sourcesContent":["import type { TokenMode } from '../types.js';\n\n/**\n * Word tokenizer for scripts that separate words -- with spaces, punctuation,\n * or anything else that is not a letter or digit. Latin, Cyrillic, Greek,\n * Hangul, Arabic, Devanagari and friends all tokenize correctly here.\n *\n * It does *not* work for Han, Kana or Thai. Those write without inter-word\n * spaces, so a whole punctuation-delimited clause matches as one token, and a\n * loop with no punctuation inside it matches as one token for the entire\n * response. See {@link nonSpacedRatio} for how that case is detected and\n * `tailLoopScore` for what runs instead.\n */\nexport function words(text: string): string[] {\n return text.toLowerCase().match(/[\\p{L}\\p{N}']+/gu) ?? [];\n}\n\n/** Character tokens, whitespace dropped. The fallback where `words` cannot see. */\nexport function chars(text: string): string[] {\n return [...text.replace(/\\s+/g, '')];\n}\n\n/** Scripts that do not put spaces between words. */\nconst NON_SPACED = /[\\p{Script=Han}\\p{Script=Hiragana}\\p{Script=Katakana}\\p{Script=Thai}]/gu;\n\n/**\n * Share of a span written in a script `words()` cannot tokenize, 0..1.\n *\n * The denominator counts marks as well as letters on purpose.\n * `\\p{Script=Thai}` matches Thai vowel and tone marks, which are `\\p{M}` and\n * not `\\p{L}` -- so counting `\\p{L}` underneath returned ratios above 1 for\n * Thai and made any cutoff meaningless there. Numerator and denominator have\n * to count the same set.\n */\nexport function nonSpacedRatio(text: string): number {\n const total = text.match(/[\\p{L}\\p{M}]/gu)?.length ?? 0;\n if (total === 0) return 0;\n return (text.match(NON_SPACED)?.length ?? 0) / total;\n}\n\n/**\n * Which tokenizer suits this span.\n *\n * Decide it from the span a detector actually reads, never from the whole\n * response. A reply that answers in English and then loops in Chinese measures\n * 0.35 overall and 1.00 across its tail: judging the tail by the whole\n * response's ratio puts the tail detector in word mode on text that has no\n * words in it, which is the exact failure this dispatch exists to prevent.\n */\nexport function tokenModeOf(text: string, cutoff = 0.5): TokenMode {\n return nonSpacedRatio(text) >= cutoff ? 'char' : 'word';\n}\n\n/** Clamp a raw signal into the 0..1 suspicion range. */\nexport function clamp01(n: number): number {\n if (Number.isNaN(n)) return 0;\n return n < 0 ? 0 : n > 1 ? 1 : n;\n}\n\n/** Short, safe excerpt for messages. Never leaks a full response into logs. */\nexport function excerpt(text: string, max = 80): string {\n const flat = text.replace(/\\s+/g, ' ').trim();\n return flat.length <= max ? flat : flat.slice(0, max) + '\\u2026';\n}\n","import { clamp01 } from './tokenize.js';\n\nexport interface PeriodicityOptions {\n /** Longest loop period to look for, in tokens. */\n maxPeriod: number;\n /** A block must repeat at least this many times to count as a loop. */\n minRepeats: number;\n /**\n * Tokens required before the search will judge at all. Defaults to\n * `minRepeats * 2`.\n *\n * Callers whose tokens are expensive want a lower floor than callers whose\n * tokens are characters. An agent turn is a whole model call, so five\n * identical ones is a great deal of evidence; five identical characters is\n * none. The default preserves the character and word behaviour exactly.\n */\n minSample?: number;\n}\n\nexport interface PeriodicityResult {\n /** Fraction of the inspected window covered by the repeating block, 0..1. */\n score: number;\n /** Length of the repeating block, in tokens. 0 when nothing repeated. */\n period: number;\n /** How many times it repeated. 0 when nothing repeated. */\n repeats: number;\n}\n\n/**\n * Largest share of `tail` covered by a block repeating to its end, with the\n * block that produced it.\n *\n * Tokenizer-agnostic on purpose. Word tokens, characters and agent-turn\n * fingerprints are three different granularities asking one question -- does\n * this sequence end in an exact cycle -- and a second copy of this search is\n * how two of them quietly stop agreeing. What differs per caller is the\n * tokens, the period cap and the floor, which are the arguments.\n *\n * Requires *exact* periodicity, which is what makes it safe on structured\n * output: repeated key scaffolding, a repeated preamble or a repeated tool name\n * never produce it unless the whole block repeats.\n */\nexport function periodicDetail(\n tail: readonly string[],\n { maxPeriod, minRepeats, minSample = minRepeats * 2 }: PeriodicityOptions,\n): PeriodicityResult {\n const none: PeriodicityResult = { score: 0, period: 0, repeats: 0 };\n if (tail.length < minSample) return none;\n\n let best = none;\n const periodCap = Math.min(maxPeriod, Math.floor(tail.length / minRepeats));\n for (let p = 1; p <= periodCap; p++) {\n const block = tail.slice(tail.length - p);\n let repeats = 1;\n let cursor = tail.length - p;\n while (cursor - p >= 0) {\n let same = true;\n for (let k = 0; k < p; k++) {\n if (tail[cursor - p + k] !== block[k]) { same = false; break; }\n }\n if (!same) break;\n repeats++;\n cursor -= p;\n }\n if (repeats >= minRepeats) {\n const score = clamp01((repeats * p) / tail.length);\n if (score > best.score) best = { score, period: p, repeats };\n }\n }\n return best;\n}\n\n/** {@link periodicDetail} without the block, for callers that only want the score. */\nexport function periodicCoverage(\n tail: readonly string[],\n options: PeriodicityOptions,\n): number {\n return periodicDetail(tail, options).score;\n}\n","/**\n * What makes two agent turns the same turn.\n *\n * ## This module is INTERNAL. It is not public API, at 1.0 or after.\n *\n * What is public is the behaviour: which traces `AGENT_LOOP` reads as a cycle.\n *\n * ## The rule, and the trap it exists to avoid\n *\n * **A turn that carries tool calls is fingerprinted by the calls alone. Its\n * text is never read.** This follows the rule `tool-calls.ts` already\n * established for a single response -- the presence of tool calls means the\n * text is a preamble rather than the answer -- and here it is what separates\n * the two shapes that matter:\n *\n * \"Let me check the next file.\" read_file { path: \"src/a.ts\" }\n * \"Let me check the next file.\" read_file { path: \"src/b.ts\" }\n * \"Let me check the next file.\" read_file { path: \"src/c.ts\" }\n *\n * Every word of prose is identical and the agent is working perfectly. A\n * fingerprint that included the preamble would call this a total collapse. The\n * arguments are where progress lives, so the arguments are what is measured.\n *\n * A turn with no tool calls has only its prose, so prose is what it gets.\n */\nimport type { AgentToolCall, AgentTurn } from '../agent-types.js';\n\n/*\n * Field separators. Control characters rather than punctuation, because a tool\n * name or an argument value may legitimately contain any printable character --\n * and a separator that can appear inside a field is one that lets two different\n * turns fingerprint identically. `JSON.stringify` escapes both of these inside\n * any string value it emits, so neither can be forged from the argument side.\n */\nconst NAME_SEP = '\\u0000';\nconst CALL_SEP = '\\u0001';\n\n/**\n * Arguments as a canonical string, so two calls differing only in key order\n * fingerprint identically.\n *\n * Providers do not promise key order and models do not produce it consistently:\n * the same intended call arrives as `{query, limit}` on one turn and\n * `{limit, query}` on the next. A raw `JSON.stringify` reads those as two\n * distinct calls, which is how a six-turn loop scores zero.\n *\n * A JSON **string** is parsed before canonicalising, because OpenAI sends\n * `function.arguments` that way while Anthropic sends an object -- the same\n * split `argumentsToText` handles. An unparseable string is used verbatim\n * rather than discarded: it is still a stable identity for the call, which is\n * all a fingerprint needs.\n */\nexport function canonicalArguments(args: unknown): string {\n if (args === null || args === undefined) return '';\n if (typeof args === 'string') {\n const trimmed = args.trim();\n if (trimmed === '') return '';\n try {\n return canonicalArguments(JSON.parse(trimmed) as unknown);\n } catch {\n return trimmed;\n }\n }\n try {\n return stringifySorted(args, new WeakSet());\n } catch {\n // A BigInt, or something else JSON cannot carry. Neither is a shape a\n // provider sends, and neither is evidence of a loop.\n return '';\n }\n}\n\n/**\n * `JSON.stringify` with object keys sorted at every depth.\n *\n * Array order is preserved -- it is meaningful in a way key order is not.\n * Cycles are cut rather than thrown on, so a caller who hands us a live object\n * graph gets a fingerprint instead of an exception.\n */\nfunction stringifySorted(value: unknown, seen: WeakSet<object>): string {\n if (value === null || typeof value !== 'object') return JSON.stringify(value) ?? 'null';\n if (seen.has(value)) return '\"[circular]\"';\n seen.add(value);\n try {\n if (Array.isArray(value)) {\n return `[${value.map((item) => stringifySorted(item, seen)).join(',')}]`;\n }\n const entries = Object.entries(value as Record<string, unknown>)\n .filter(([, v]) => v !== undefined)\n .sort(([a], [b]) => (a < b ? -1 : a > b ? 1 : 0))\n .map(([k, v]) => `${JSON.stringify(k)}:${stringifySorted(v, seen)}`);\n return `{${entries.join(',')}}`;\n } finally {\n seen.delete(value);\n }\n}\n\n/**\n * Prose reduced to what a repeat survives.\n *\n * Case and whitespace drift between otherwise identical restatements -- a\n * trailing newline, a doubled space, a capital that did not come back. None of\n * those is progress, and requiring exact equality through them is how an\n * obvious text loop scores zero.\n */\nexport function normaliseText(text: string): string {\n return text.toLowerCase().replace(/\\s+/g, ' ').trim();\n}\n\nconst callFingerprint = (call: AgentToolCall): string =>\n `${call.name ?? ''}${NAME_SEP}${canonicalArguments(call.arguments)}`;\n\n/**\n * One turn as a single comparable token, or `null` when there is nothing to\n * compare.\n *\n * `null` is the abstain case and it is dropped from the sequence rather than\n * counted: a turn with no text and no calls says nothing about whether the\n * agent is advancing, and a run of them would otherwise read as a perfect\n * cycle. Same reasoning as `checkPreamble` returning `null` on the no-text\n * case.\n *\n * `ignoreTools` drops calls to named tools before fingerprinting. A turn whose\n * calls are all ignored has no fingerprint at all -- it does not fall through\n * to its preamble, which would reintroduce the identical-preamble trap on\n * exactly the traces a caller reached for this option to fix.\n */\nexport function fingerprintTurn(\n turn: AgentTurn,\n ignoreTools: ReadonlySet<string>,\n): string | null {\n const issued = turn.toolCalls ?? [];\n\n if (issued.length > 0) {\n const kept = issued.filter((call) => !(call.name != null && ignoreTools.has(call.name)));\n if (kept.length === 0) return null;\n /*\n * Sorted, because calls issued together are issued in parallel and their\n * order carries no progress. A model that asks for the same two lookups in\n * the other order next turn has done the same turn twice, and comparing\n * them positionally would score that loop at zero.\n */\n return kept.map(callFingerprint).sort().join(CALL_SEP);\n }\n\n const text = normaliseText(turn.text ?? '');\n return text === '' ? null : `text${NAME_SEP}${text}`;\n}\n","/**\n * The failure that every other detector in this package scores 0.000 on.\n *\n * An agent calls `read_file` on the same path six turns running, or edits one\n * file and runs one test set back and forth until the budget is gone. Each\n * individual response is fluent, correctly formed, the right length and calls a\n * tool that exists with arguments that match its schema. `checkOutput` is right\n * to pass every one of them: nothing is wrong with any *response*. What is\n * wrong is the sequence.\n *\n * So this reads one axis nothing else here reads -- across turns -- and it\n * reads it the same way `TAIL_LOOP` reads a response: by looking for exact\n * periodicity in a trailing window. The tokens are turn fingerprints instead of\n * words, and `internal/periodicity.ts` is literally the same search.\n *\n * ## Exact periodicity, and what it costs\n *\n * The block must repeat *exactly*. That is what makes the detector safe on the\n * shapes that dominate healthy agent traffic -- twenty reads of twenty\n * different files, an edit/test rhythm that edits a different file each time, a\n * preamble the model reuses word for word. None of those produce an exact\n * repeat of the whole block, and all of them would fool a similarity measure.\n *\n * It also means a genuinely stuck agent that varies is **not caught**. An agent\n * returning to one failing call between other work -- build, read, build, list,\n * build -- has no exact cycle, and the shape it does have is\n * indistinguishable from a healthy edit/test rhythm. That gap is measured\n * rather than assumed; see `docs/agent-loops.md`.\n */\nimport type { AgentCheckOptions, AgentTurn } from '../agent-types.js';\nimport { periodicDetail } from '../internal/periodicity.js';\nimport { fingerprintTurn } from '../internal/turn-fingerprint.js';\n\nexport interface AgentLoopResult {\n /** Share of the inspected window covered by the repeating block, 0..1. */\n score: number;\n /** Turns in the repeating block. 0 when nothing repeated. */\n period: number;\n /** How many times it repeated. 0 when nothing repeated. */\n repeats: number;\n /**\n * How many turns were actually measured -- the window, after turns with\n * nothing to compare were dropped. `score` is a share of this.\n */\n measured: number;\n /**\n * The repeating block, one readable label per turn: the tool names a turn\n * called, or `prose` for a turn that called none. Empty when nothing\n * repeated.\n *\n * For messages and logs only. It names what is looping, which is the first\n * thing anyone debugging a stuck agent wants and the one thing a score\n * cannot say.\n */\n cycle: readonly string[];\n}\n\nexport const AGENT_LOOP_DEFAULTS: Required<\n Pick<AgentCheckOptions, 'window' | 'minTurns' | 'minRepeats' | 'maxPeriod'>\n> = {\n window: 12,\n minTurns: 4,\n minRepeats: 3,\n maxPeriod: 4,\n};\n\n/**\n * Whether a trace ends in a cycle, and which one.\n *\n * Pure and synchronous like everything else here: the same turns always produce\n * the same result, and nothing is retained between calls.\n */\nexport function agentLoopDetail(\n turns: readonly AgentTurn[],\n options: AgentCheckOptions = {},\n): AgentLoopResult {\n const { window, minTurns, minRepeats, maxPeriod } = { ...AGENT_LOOP_DEFAULTS, ...options };\n const ignore = new Set(options.ignoreTools ?? []);\n\n const none: AgentLoopResult = { score: 0, period: 0, repeats: 0, measured: 0, cycle: [] };\n if (!Array.isArray(turns)) return none;\n\n /*\n * Fingerprint first, then window. Doing it the other way round would let a\n * run of unmeasurable turns -- empty ones, or ones whose only calls are\n * ignored -- push real turns out of the window and shrink the sample on\n * exactly the traces that need it most.\n */\n const fingerprints: string[] = [];\n const labels: string[] = [];\n for (const turn of turns) {\n if (turn == null || typeof turn !== 'object') continue;\n const fp = fingerprintTurn(turn, ignore);\n if (fp === null) continue;\n fingerprints.push(fp);\n labels.push(labelTurn(turn, ignore));\n }\n\n const size = Math.max(1, window);\n const tail = fingerprints.slice(-size);\n if (tail.length < minTurns) return { ...none, measured: tail.length };\n\n const detail = periodicDetail(tail, { maxPeriod, minRepeats, minSample: minTurns });\n const cycle = detail.period > 0 ? labels.slice(-detail.period) : [];\n return { ...detail, measured: tail.length, cycle };\n}\n\n/** What to call this turn in a message. Never used for comparison. */\nfunction labelTurn(turn: AgentTurn, ignore: ReadonlySet<string>): string {\n const kept = (turn.toolCalls ?? []).filter(\n (call) => !(call.name != null && ignore.has(call.name)),\n );\n if (kept.length === 0) return 'prose';\n return kept.map((call) => call.name ?? '(unnamed)').join('+');\n}\n\n/** {@link agentLoopDetail} without the block, for callers that only want the score. */\nexport function agentLoopScore(\n turns: readonly AgentTurn[],\n options: AgentCheckOptions = {},\n): number {\n return agentLoopDetail(turns, options).score;\n}\n","import type { CheckOptions, Reason, ReasonCode, TokenMode, Verdict } from './types.js';\nimport { repetitionScore, tailLoopDetail } from './detectors/repetition.js';\nimport { compressibilityScore } from './detectors/compressibility.js';\nimport { emptinessScore, shortnessScore } from './detectors/emptiness.js';\nimport { truncationScore } from './detectors/truncation.js';\nimport { jsonScore } from './detectors/json.js';\nimport { languageMismatchScore } from './detectors/language.js';\nimport { scriptMismatchScore } from './detectors/script.js';\nimport { promptEchoDetail } from './detectors/prompt-echo.js';\nimport { excerpt } from './internal/tokenize.js';\nimport { redundancySpans } from './internal/json-scope.js';\n\nconst DEFAULTS: Required<\n Pick<CheckOptions,\n 'minLength' | 'maxRepetition' | 'maxTailLoop' | 'maxCompressibility' |\n 'maxTruncation' | 'expectJson' | 'allowJsonFence' | 'maxLangMismatch' | 'ngram' |\n 'maxCharTailLoop' | 'nonSpacedCutoff' | 'redundancyScope' | 'maxScriptMismatch' |\n 'maxPromptEcho'>\n> = {\n minLength: 1,\n maxRepetition: 0.35,\n maxTailLoop: 0.5,\n maxCharTailLoop: 0.7,\n nonSpacedCutoff: 0.5,\n maxCompressibility: 0.75,\n maxTruncation: null as unknown as number,\n expectJson: false,\n allowJsonFence: true,\n maxLangMismatch: 0.6,\n maxScriptMismatch: 0.5,\n maxPromptEcho: 0.6,\n ngram: 3,\n redundancyScope: 'document',\n};\n\n/**\n * Runs every enabled detector and returns a structured verdict.\n *\n * Pure and synchronous: no network, no clock, no randomness. The same input\n * always produces the same verdict, which is what makes it safe to put on a\n * hot path and easy to unit test.\n *\n * Every detector runs even after one fails, so `reasons` shows the full picture\n * rather than whichever check happened to be ordered first.\n *\n * Never throws. A `null`, `undefined`, or otherwise non-string input is a\n * verdict (`EMPTY`), not an exception -- see the guard below for why.\n */\nexport function checkOutput(\n text: string | null | undefined,\n options: CheckOptions = {},\n): Verdict {\n const opts = { ...DEFAULTS, ...options };\n const reasons: Reason[] = [];\n const scores: Partial<Record<ReasonCode, number>> = {};\n const modes: Partial<Record<ReasonCode, TokenMode>> = {};\n let parsedJson: unknown;\n\n const add = (\n code: ReasonCode,\n score: number,\n threshold: number,\n message: string,\n mode?: TokenMode,\n ) => {\n scores[code] = score;\n if (mode) modes[code] = mode;\n if (score > threshold) reasons.push({ code, score, threshold, message, ...(mode && { mode }) });\n };\n\n /*\n * A caller who has `undefined` where the text should be is in exactly the\n * situation this package exists for: the request \"succeeded\" and produced\n * nothing. Types do not stop it -- an SDK whose field is optional, a JSON\n * envelope that shaped differently than documented, a `.content[0].text`\n * that was never there. Throwing a TypeError here would be the worst\n * possible answer, because it is not a DegenerateOutputError and so slips\n * straight through the very retry predicate the README recommends.\n */\n if (typeof text !== 'string') {\n scores.EMPTY = 1;\n reasons.push({\n code: 'EMPTY',\n score: 1,\n threshold: 0.5,\n message: `Response was ${text === null ? 'null' : typeof text}, not a string.`,\n });\n return { ok: false, reasons, scores };\n }\n\n const empty = emptinessScore(text);\n add('EMPTY', empty, 0.5, 'Response contains no usable content.');\n\n // Once the response is empty, the remaining content signals are noise.\n if (empty >= 1) {\n return { ok: false, reasons, scores };\n }\n\n if (opts.minLength > 0) {\n add(\n 'TOO_SHORT',\n shortnessScore(text, opts.minLength),\n 0,\n `Response is ${text.trim().length} chars, below the ${opts.minLength} minimum.`,\n );\n }\n\n /*\n * The spans the redundancy detectors read. One span -- the whole response --\n * unless `redundancyScope` says otherwise and the payload parses. See\n * `internal/json-scope.ts` for why a JSON array needs a different span.\n */\n const spans = redundancySpans(text, opts.redundancyScope);\n\n if (opts.maxRepetition != null) {\n // The worst span, not the average: a loop confined to one array element is\n // still a loop, and averaging is what hides it.\n const s = Math.max(...spans.map((span) => repetitionScore(span, { n: opts.ngram })));\n add('REPETITION', s, opts.maxRepetition,\n `${Math.round(s * 100)}% of ${opts.ngram}-grams are duplicates.`);\n }\n\n /*\n * The tail detector picks its own tokenizer from its own tail, so the\n * threshold has to be picked the same way -- `maxTailLoop` and\n * `maxCharTailLoop` describe different distributions and are not\n * interchangeable. Either can be null independently, which is what disabling\n * one mode looks like.\n */\n {\n // The mode travels with the span that produced the score, because the\n // threshold is chosen by it -- reporting the worst score against another\n // span's tokenizer would compare a number to the wrong distribution.\n let worst = { score: -1, mode: 'word' as TokenMode };\n for (const span of spans) {\n const detail = tailLoopDetail(span, { nonSpacedCutoff: opts.nonSpacedCutoff });\n if (detail.score > worst.score) worst = detail;\n }\n const { score, mode } = worst;\n const threshold = mode === 'char' ? opts.maxCharTailLoop : opts.maxTailLoop;\n if (threshold != null) {\n add('TAIL_LOOP', score, threshold,\n `Response ends in a repeating block covering ${Math.round(score * 100)}% of the tail.`,\n mode);\n }\n }\n\n if (opts.maxCompressibility != null) {\n const s = compressibilityScore(text);\n add('LOW_ENTROPY', s, opts.maxCompressibility,\n 'Response is far more compressible than natural language.');\n }\n\n if (opts.maxTruncation != null || opts.finishReason) {\n const s = truncationScore(text, { finishReason: opts.finishReason });\n add('TRUNCATED', s, opts.maxTruncation ?? 0.75,\n `Response appears cut off near: \"${excerpt(text.trim().slice(-60), 60)}\"`);\n }\n\n if (opts.expectJson) {\n const result = jsonScore(text, {\n allowFence: opts.allowJsonFence,\n requiredKeys: opts.requiredKeys,\n schema: opts.schema,\n });\n parsedJson = result.value;\n\n /*\n * One code for three ways of failing the same contract: the caller asked\n * for a payload of a given shape and did not get one. A schema mismatch\n * wants exactly the handling `INVALID_JSON` already gets -- retry, or fall\n * through to another provider -- so giving it a code of its own would widen\n * a frozen union and split existing handling for no gain.\n */\n add('INVALID_JSON', result.score, 0,\n result.reason === 'missing-keys'\n ? `JSON is missing required keys: ${result.missingKeys?.join(', ')}.`\n : result.reason === 'schema'\n ? `JSON does not match the schema: ${result.issues?.join('; ')}.`\n : 'Response is not parseable JSON.');\n }\n\n /*\n * Script before language, because where both fire the script answer is the\n * one worth reading: it measured characters, and the other guessed from\n * twenty function words. They keep separate codes rather than sharing\n * `LANG_MISMATCH` for the same reason `TAIL_LOOP` reports its mode -- a share\n * of letters and a relative share of function-word hits are different\n * distributions, and one histogram holding both describes neither.\n */\n if (opts.expectScript) {\n const wanted = Array.isArray(opts.expectScript) ? opts.expectScript : [opts.expectScript];\n const s = scriptMismatchScore(text, opts.expectScript);\n add('SCRIPT_MISMATCH', s, opts.maxScriptMismatch,\n `${Math.round(s * 100)}% of letters are not in ${wanted.join(' or ')}.`);\n }\n\n /*\n * Last, because it is the only detector that reads something other than the\n * response, and the only one whose usefulness depends on what the caller\n * asked the model to do. See `detectors/prompt-echo.ts` for the task types\n * it must not be pointed at.\n *\n * The mode is reported for the same reason `TAIL_LOOP` reports it, but a\n * single threshold serves both: this measures the share of output copied\n * verbatim, which is 1.000 for a full echo and 0.000 for a healthy answer in\n * either tokenizer. `TAIL_LOOP` needed two because periodicity has a\n * different base rate per script; a copy either happened or it did not.\n */\n if (opts.prompt) {\n const { score, mode } = promptEchoDetail(text, opts.prompt, {\n nonSpacedCutoff: opts.nonSpacedCutoff,\n });\n add('PROMPT_ECHO', score, opts.maxPromptEcho,\n `${Math.round(score * 100)}% of the response is copied from the prompt.`, mode);\n }\n\n if (opts.expectLang) {\n const s = languageMismatchScore(text, opts.expectLang);\n add('LANG_MISMATCH', s, opts.maxLangMismatch,\n `Response does not look like '${opts.expectLang}'.`);\n }\n\n return {\n ok: reasons.length === 0,\n reasons,\n scores,\n ...(Object.keys(modes).length > 0 && { modes }),\n json: parsedJson,\n };\n}\n\n/** Error thrown by {@link assertOutput}, carrying the full verdict. */\nexport class DegenerateOutputError extends Error {\n readonly verdict: Verdict;\n /** Marks this as safe to retry against another provider. */\n readonly retryable = true;\n\n constructor(verdict: Verdict) {\n super(`Degenerate LLM output: ${verdict.reasons.map((r) => r.code).join(', ')}`);\n this.name = 'DegenerateOutputError';\n this.verdict = verdict;\n }\n}\n\n/**\n * Throwing wrapper, for dropping straight into an existing retry or fallback\n * chain that already keys off thrown errors.\n */\nexport function assertOutput(\n text: string | null | undefined,\n options: CheckOptions = {},\n): string {\n const verdict = checkOutput(text, options);\n if (!verdict.ok) throw new DegenerateOutputError(verdict);\n // Unreachable for non-strings: those score EMPTY 1 and throw above.\n return text as string;\n}\n","/**\n * Degeneration across turns, for agent runs.\n *\n * Everything else in this package judges one response. This judges a\n * **sequence** of them, because that is where an agent fails: each response is\n * healthy on its own and the run as a whole is going nowhere. `checkOutput`\n * scores every turn of a six-turn loop at 0.000 and is right to.\n *\n * ## What this does not do\n *\n * It does not check the turns themselves. A turn that loops *inside* itself is\n * `checkOutput`'s job and the adapters already do it, so running both here\n * would report the same failure twice under two codes. Use them together: the\n * adapter guards each response, this guards the run.\n *\n * ## Stateful, still deterministic\n *\n * `checkTrace` is pure -- hand it the turns, get a verdict. `createAgentGuard`\n * is the same function with the turns retained for you, which is what an agent\n * loop actually wants. Neither reads a clock, a network or a random source, and\n * the guard retains only what the window can reach, so a thousand-turn run\n * holds twelve turns.\n */\nimport type { Reason, ReasonCode, Verdict } from './types.js';\nimport type { AgentCheckOptions, AgentTurn } from './agent-types.js';\nimport { agentLoopDetail, AGENT_LOOP_DEFAULTS } from './detectors/agent-loop.js';\nimport { fingerprintTurn } from './internal/turn-fingerprint.js';\nimport { DegenerateOutputError } from './check.js';\n\nexport type { AgentCheckOptions, AgentToolCall, AgentTurn } from './agent-types.js';\nexport type { AgentLoopResult } from './detectors/agent-loop.js';\nexport { agentLoopDetail, agentLoopScore } from './detectors/agent-loop.js';\nexport { DegenerateOutputError } from './check.js';\n\n/** Default cycle-coverage threshold. See `AgentCheckOptions.maxAgentLoop`. */\nconst MAX_AGENT_LOOP = 0.4;\n\n/**\n * Whether an agent run has stopped advancing.\n *\n * Returns the same {@link Verdict} shape the rest of the package returns, so it\n * drops into the same handling, the same `onVerdict` sink and the same\n * calibration pipeline. `scores.AGENT_LOOP` is present whenever the detector\n * ran, including when it passed -- log it and you will know your real loop rate.\n *\n * Never throws. A trace that is not an array, or holds entries that are not\n * turns, produces a passing verdict rather than an exception: this sits in an\n * agent loop, and a guard that can crash the loop it guards is worse than the\n * failure it was added to catch.\n */\nexport function checkTrace(\n turns: readonly AgentTurn[],\n options: AgentCheckOptions = {},\n): Verdict {\n const threshold = options.maxAgentLoop === undefined ? MAX_AGENT_LOOP : options.maxAgentLoop;\n const reasons: Reason[] = [];\n const scores: Partial<Record<ReasonCode, number>> = {};\n\n if (threshold == null) return { ok: true, reasons, scores };\n\n const detail = agentLoopDetail(turns, options);\n scores.AGENT_LOOP = detail.score;\n\n if (detail.score > threshold) {\n const block = detail.cycle.join(' -> ');\n reasons.push({\n code: 'AGENT_LOOP',\n score: detail.score,\n threshold,\n message:\n `Agent repeated the same ${detail.period}-turn cycle ${detail.repeats} times ` +\n `(${Math.round(detail.score * 100)}% of the last ${detail.measured} turns): ${block}.`,\n });\n }\n\n return { ok: reasons.length === 0, reasons, scores };\n}\n\n/**\n * Throwing wrapper, for an agent loop that already breaks on a thrown error.\n *\n * Throws the same {@link DegenerateOutputError} the single-response side\n * throws, carrying the trace verdict -- so one `catch` handles both a\n * degenerate response and a degenerate run.\n */\nexport function assertTrace(\n turns: readonly AgentTurn[],\n options: AgentCheckOptions = {},\n): readonly AgentTurn[] {\n const verdict = checkTrace(turns, options);\n if (!verdict.ok) throw new DegenerateOutputError(verdict);\n return turns;\n}\n\nexport interface AgentGuard {\n /**\n * Record a turn and judge the run so far.\n *\n * Call it once per model response, with whatever that response produced.\n * The returned verdict covers the **run**, not the turn.\n */\n observe(turn: AgentTurn): Verdict;\n /** Forget every turn recorded so far. For reusing a guard across runs. */\n reset(): void;\n /** How many turns are currently retained. Never exceeds `window`. */\n readonly size: number;\n}\n\n/**\n * A {@link checkTrace} that remembers the turns for you.\n *\n * ```ts\n * const guard = createAgentGuard();\n * while (!done) {\n * const response = await model.step();\n * const verdict = guard.observe(toTurn(response));\n * if (!verdict.ok) break; // the run is circling; stop paying for it\n * }\n * ```\n *\n * Only turns the detector can compare are retained, and only as many as the\n * window can reach. That bound is deliberate rather than an optimisation:\n * retaining raw turns instead would let a run of empty ones push real turns out\n * of the window, shrinking the sample on exactly the traces that need it most.\n */\nexport function createAgentGuard(options: AgentCheckOptions = {}): AgentGuard {\n const window = Math.max(1, options.window ?? AGENT_LOOP_DEFAULTS.window);\n const ignore = new Set(options.ignoreTools ?? []);\n let retained: AgentTurn[] = [];\n\n return {\n observe(turn: AgentTurn): Verdict {\n if (turn != null && typeof turn === 'object' && fingerprintTurn(turn, ignore) !== null) {\n retained.push(turn);\n if (retained.length > window) retained = retained.slice(-window);\n }\n return checkTrace(retained, options);\n },\n reset() {\n retained = [];\n },\n get size() {\n return retained.length;\n },\n };\n}\n"]}
@@ -0,0 +1,241 @@
1
+ import { V as Verdict } from './types-CxKV_wpA.cjs';
2
+ export { D as DegenerateOutputError } from './check-B7lLF8a8.cjs';
3
+
4
+ /**
5
+ * The shapes `./agent` reads.
6
+ *
7
+ * Structurally typed and provider-neutral, for the same reason the adapters
8
+ * are: a trace is assembled by the caller from whatever their framework hands
9
+ * back, and requiring a provider's own type here would make this subpath
10
+ * depend on a provider.
11
+ */
12
+ /** One tool call in a turn. */
13
+ interface AgentToolCall {
14
+ /**
15
+ * The tool's name. OpenAI spells it `function.name`, Anthropic and the AI SDK
16
+ * spell it `name`, and the caller maps whichever they have.
17
+ *
18
+ * Optional because a streamed call can arrive before its name does. A call
19
+ * with no name still fingerprints -- by its arguments -- rather than being
20
+ * dropped, since two nameless calls with identical arguments are still the
21
+ * same call twice.
22
+ */
23
+ name?: string | null;
24
+ /**
25
+ * The arguments, in any shape a provider sends: a parsed object, or the raw
26
+ * JSON string OpenAI uses. Both canonicalise to the same fingerprint, so a
27
+ * trace assembled from mixed sources still compares.
28
+ */
29
+ arguments?: unknown;
30
+ }
31
+ /** One model response in an agent run. */
32
+ interface AgentTurn {
33
+ /** The prose the model produced, if any. */
34
+ text?: string | null;
35
+ /**
36
+ * The tool calls the model issued, if any.
37
+ *
38
+ * **A turn carrying tool calls is judged by them alone**, and its `text` is
39
+ * read as a preamble and ignored -- the same rule the single-response
40
+ * adapters already apply. See `internal/turn-fingerprint.ts` for the trap
41
+ * that rule exists to avoid.
42
+ */
43
+ toolCalls?: readonly AgentToolCall[] | null;
44
+ }
45
+ interface AgentCheckOptions {
46
+ /**
47
+ * How many trailing turns to inspect. Default 12.
48
+ *
49
+ * The same idea as `TAIL_LOOP`'s tail: what matters is whether the agent is
50
+ * stuck *now*, not whether it repeated itself twenty turns ago and recovered.
51
+ * A trace shorter than the window is measured whole.
52
+ *
53
+ * It also bounds the longest cycle that can be found, at `window / minRepeats`
54
+ * turns -- 4 at the defaults. Raise both to catch longer orbits, and expect
55
+ * a loop at the very end of a long trace to score lower, because the score is
56
+ * coverage of the window rather than a count.
57
+ */
58
+ window?: number;
59
+ /**
60
+ * Turns required before this will judge at all. Default 4.
61
+ *
62
+ * Below it the detector abstains, which is the rule everywhere else in this
63
+ * package. Two identical turns is a retry, and three is a short poll; neither
64
+ * is evidence that an agent has stopped advancing.
65
+ */
66
+ minTurns?: number;
67
+ /** How many times a block must repeat to count as a cycle. Default 3. */
68
+ minRepeats?: number;
69
+ /**
70
+ * Longest cycle to look for, in turns. Default 4.
71
+ *
72
+ * Capped by `window / minRepeats` regardless, since a block cannot repeat
73
+ * three times inside a window that does not hold it three times.
74
+ */
75
+ maxPeriod?: number;
76
+ /**
77
+ * Cycle-coverage threshold. Set null to disable. Default 0.4.
78
+ *
79
+ * Measured on this repo's agent corpus: every healthy trace scores **0.000**,
80
+ * including the traps built to look like loops, and the weakest degenerate
81
+ * trace scores 0.455. The default sits in that gap, nearer the healthy side
82
+ * because nothing healthy approaches it.
83
+ */
84
+ maxAgentLoop?: number | null;
85
+ /**
86
+ * Tools whose calls are dropped before fingerprinting. Empty by default.
87
+ *
88
+ * The escape hatch for a tool whose whole job is to be called repeatedly with
89
+ * identical arguments -- polling a job, sleeping, reading a clock. By shape
90
+ * those are indistinguishable from a loop, so naming them is the only honest
91
+ * way to separate them, in the same way `PROMPT_ECHO` cannot be pointed at a
92
+ * translate endpoint.
93
+ *
94
+ * A turn whose calls are *all* ignored drops out of the trace entirely rather
95
+ * than falling back to its preamble text.
96
+ */
97
+ ignoreTools?: readonly string[];
98
+ }
99
+
100
+ /**
101
+ * The failure that every other detector in this package scores 0.000 on.
102
+ *
103
+ * An agent calls `read_file` on the same path six turns running, or edits one
104
+ * file and runs one test set back and forth until the budget is gone. Each
105
+ * individual response is fluent, correctly formed, the right length and calls a
106
+ * tool that exists with arguments that match its schema. `checkOutput` is right
107
+ * to pass every one of them: nothing is wrong with any *response*. What is
108
+ * wrong is the sequence.
109
+ *
110
+ * So this reads one axis nothing else here reads -- across turns -- and it
111
+ * reads it the same way `TAIL_LOOP` reads a response: by looking for exact
112
+ * periodicity in a trailing window. The tokens are turn fingerprints instead of
113
+ * words, and `internal/periodicity.ts` is literally the same search.
114
+ *
115
+ * ## Exact periodicity, and what it costs
116
+ *
117
+ * The block must repeat *exactly*. That is what makes the detector safe on the
118
+ * shapes that dominate healthy agent traffic -- twenty reads of twenty
119
+ * different files, an edit/test rhythm that edits a different file each time, a
120
+ * preamble the model reuses word for word. None of those produce an exact
121
+ * repeat of the whole block, and all of them would fool a similarity measure.
122
+ *
123
+ * It also means a genuinely stuck agent that varies is **not caught**. An agent
124
+ * returning to one failing call between other work -- build, read, build, list,
125
+ * build -- has no exact cycle, and the shape it does have is
126
+ * indistinguishable from a healthy edit/test rhythm. That gap is measured
127
+ * rather than assumed; see `docs/agent-loops.md`.
128
+ */
129
+
130
+ interface AgentLoopResult {
131
+ /** Share of the inspected window covered by the repeating block, 0..1. */
132
+ score: number;
133
+ /** Turns in the repeating block. 0 when nothing repeated. */
134
+ period: number;
135
+ /** How many times it repeated. 0 when nothing repeated. */
136
+ repeats: number;
137
+ /**
138
+ * How many turns were actually measured -- the window, after turns with
139
+ * nothing to compare were dropped. `score` is a share of this.
140
+ */
141
+ measured: number;
142
+ /**
143
+ * The repeating block, one readable label per turn: the tool names a turn
144
+ * called, or `prose` for a turn that called none. Empty when nothing
145
+ * repeated.
146
+ *
147
+ * For messages and logs only. It names what is looping, which is the first
148
+ * thing anyone debugging a stuck agent wants and the one thing a score
149
+ * cannot say.
150
+ */
151
+ cycle: readonly string[];
152
+ }
153
+ /**
154
+ * Whether a trace ends in a cycle, and which one.
155
+ *
156
+ * Pure and synchronous like everything else here: the same turns always produce
157
+ * the same result, and nothing is retained between calls.
158
+ */
159
+ declare function agentLoopDetail(turns: readonly AgentTurn[], options?: AgentCheckOptions): AgentLoopResult;
160
+ /** {@link agentLoopDetail} without the block, for callers that only want the score. */
161
+ declare function agentLoopScore(turns: readonly AgentTurn[], options?: AgentCheckOptions): number;
162
+
163
+ /**
164
+ * Degeneration across turns, for agent runs.
165
+ *
166
+ * Everything else in this package judges one response. This judges a
167
+ * **sequence** of them, because that is where an agent fails: each response is
168
+ * healthy on its own and the run as a whole is going nowhere. `checkOutput`
169
+ * scores every turn of a six-turn loop at 0.000 and is right to.
170
+ *
171
+ * ## What this does not do
172
+ *
173
+ * It does not check the turns themselves. A turn that loops *inside* itself is
174
+ * `checkOutput`'s job and the adapters already do it, so running both here
175
+ * would report the same failure twice under two codes. Use them together: the
176
+ * adapter guards each response, this guards the run.
177
+ *
178
+ * ## Stateful, still deterministic
179
+ *
180
+ * `checkTrace` is pure -- hand it the turns, get a verdict. `createAgentGuard`
181
+ * is the same function with the turns retained for you, which is what an agent
182
+ * loop actually wants. Neither reads a clock, a network or a random source, and
183
+ * the guard retains only what the window can reach, so a thousand-turn run
184
+ * holds twelve turns.
185
+ */
186
+
187
+ /**
188
+ * Whether an agent run has stopped advancing.
189
+ *
190
+ * Returns the same {@link Verdict} shape the rest of the package returns, so it
191
+ * drops into the same handling, the same `onVerdict` sink and the same
192
+ * calibration pipeline. `scores.AGENT_LOOP` is present whenever the detector
193
+ * ran, including when it passed -- log it and you will know your real loop rate.
194
+ *
195
+ * Never throws. A trace that is not an array, or holds entries that are not
196
+ * turns, produces a passing verdict rather than an exception: this sits in an
197
+ * agent loop, and a guard that can crash the loop it guards is worse than the
198
+ * failure it was added to catch.
199
+ */
200
+ declare function checkTrace(turns: readonly AgentTurn[], options?: AgentCheckOptions): Verdict;
201
+ /**
202
+ * Throwing wrapper, for an agent loop that already breaks on a thrown error.
203
+ *
204
+ * Throws the same {@link DegenerateOutputError} the single-response side
205
+ * throws, carrying the trace verdict -- so one `catch` handles both a
206
+ * degenerate response and a degenerate run.
207
+ */
208
+ declare function assertTrace(turns: readonly AgentTurn[], options?: AgentCheckOptions): readonly AgentTurn[];
209
+ interface AgentGuard {
210
+ /**
211
+ * Record a turn and judge the run so far.
212
+ *
213
+ * Call it once per model response, with whatever that response produced.
214
+ * The returned verdict covers the **run**, not the turn.
215
+ */
216
+ observe(turn: AgentTurn): Verdict;
217
+ /** Forget every turn recorded so far. For reusing a guard across runs. */
218
+ reset(): void;
219
+ /** How many turns are currently retained. Never exceeds `window`. */
220
+ readonly size: number;
221
+ }
222
+ /**
223
+ * A {@link checkTrace} that remembers the turns for you.
224
+ *
225
+ * ```ts
226
+ * const guard = createAgentGuard();
227
+ * while (!done) {
228
+ * const response = await model.step();
229
+ * const verdict = guard.observe(toTurn(response));
230
+ * if (!verdict.ok) break; // the run is circling; stop paying for it
231
+ * }
232
+ * ```
233
+ *
234
+ * Only turns the detector can compare are retained, and only as many as the
235
+ * window can reach. That bound is deliberate rather than an optimisation:
236
+ * retaining raw turns instead would let a run of empty ones push real turns out
237
+ * of the window, shrinking the sample on exactly the traces that need it most.
238
+ */
239
+ declare function createAgentGuard(options?: AgentCheckOptions): AgentGuard;
240
+
241
+ export { type AgentCheckOptions, type AgentGuard, type AgentLoopResult, type AgentToolCall, type AgentTurn, agentLoopDetail, agentLoopScore, assertTrace, checkTrace, createAgentGuard };