@osama.ansar/agent-loopguard 0.1.0 → 0.2.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/CHANGELOG.md CHANGED
@@ -4,6 +4,29 @@ All notable changes to this project are documented here. The format follows
4
4
  [Keep a Changelog](https://keepachangelog.com/en/1.1.0/) and the project uses
5
5
  [Semantic Versioning](https://semver.org/).
6
6
 
7
+ ## [0.2.0] - 2026-10-14
8
+
9
+ Backwards compatible for code that uses the package. See Notes for the type-level changes and the one run-time edge case.
10
+
11
+ ### Added
12
+
13
+ - `guard.preflight(call)` (also accepts an array of parallel calls): asks whether running a tool call now would immediately trip a rule, without recording anything or changing the guard. Judges budget and step limits that are already used up, per-tool call caps, repeated identical calls and oscillation, in the same priority order as `check`; returns `{ allow: true, warnings? }` or `{ allow: false, reason, code, detail, evidence, feedback }`. Rules that need a result, text, finish reason or usage, and result-aware repeat rules (`requireIdenticalResults`, `polling` tools), are not judged before a call runs.
14
+ - Model-facing `feedback` on every fault verdict from a built-in detector and on every `preflight` denial: fixed, deterministic plain-English text per code that names the tool but never repeats arguments, results or model text. New `feedback` option to override it, `feedbackFor(verdict, phase?)` for the default text, and a `feedback` field on custom stops.
15
+ - Save and resume: `guard.toJSON()` (so `JSON.stringify(guard)` works) and `guard.snapshot({ redact? })` return a versioned, JSON-safe, size-bounded `GuardSnapshot`; `createGuard.fromJSON(snapshot, options?, { allowLimitChanges? })` (also exported as `restoreGuard`) rebuilds a guard that gives identical verdicts. Options are fingerprinted, not stored: restoring under different options throws a `TypeError`. Malformed or hostile snapshots throw a `TypeError` at restore time.
16
+ - Per-tool policies: `tools: { [name]: { polling, maxCalls, repeatThreshold, ignore } }`. `maxCalls` adds the stop reason `'tool-limit'` with code `TOOL_CALL_CAP`.
17
+ - Oscillation evidence now lists the tool names of the cycle (`evidence.tools`); `summary().detectors.toolLimits` shows calls against each cap (present only when a cap is set).
18
+ - New exported types: `Preflight`, `PreflightAllow`, `PreflightDeny`, `PreflightCall`, `PreflightStopVerdict`, `ToolPolicy`, `ToolLimitEvidence`, `ToolLimitVerdict`, `FeedbackPhase`, `GuardSnapshot`, `GuardSnapshotVersion`, `GuardOptionsFingerprint`, `SnapshotOptions`, `RestoreOptions`, `RepeatedToolCallSnapshot`, `OscillationSnapshot`, `NoProgressSnapshot`.
19
+ - `examples/preflight-and-feedback.ts` (`npm run example:preflight`), run in CI.
20
+
21
+ ### Notes
22
+
23
+ - **Widened unions.** `StopReason` gains `'tool-limit'`, `BuiltinStopCode` gains `'TOOL_CALL_CAP'`, and `StopVerdict` gains `ToolLimitVerdict`. A TypeScript `switch` over `verdict.reason` or `verdict.code` that has no `default` and relies on exhaustiveness will stop compiling until you add a case. At run time the new reason only appears if you set a `maxCalls`.
24
+ - **`Guard` has three new methods** (`preflight`, `toJSON`, `snapshot`). Code that implements the `Guard` interface itself (a test double, say) needs them.
25
+ - **Memory is now bounded, with one visible effect.** The no-progress detector remembers the newest 1024 distinct tool outcomes instead of all of them, so in a run with more than 1024 distinct outcomes an outcome older than that counts as new again. The oscillation detector keeps `maxPeriod` calls and one counter per period instead of a 4096-call window; the results are the same except that the reported cycle count is no longer capped by that window. Signatures are compared as 28-character hashes instead of raw strings (a collision between different values is possible in principle, not a practical concern).
26
+ - **A restored guard starts with an empty `history`**, and custom detectors see only the steps recorded since the restore; `summary().steps` and every detector keep counting across it. `summary().lastVerdict` is `null` until the first `check`.
27
+ - A custom verdict has no `feedback` unless its detector (or the `feedback` option) supplies one; the package has nothing sensible to say for rules it does not know.
28
+ - Tested offline only: preflight, feedback and snapshots have not been tried against real provider output beyond the existing AI SDK integration test.
29
+
7
30
  ## [0.1.0] - 2026-10-14
8
31
 
9
32
  Published on npm as `@osama.ansar/agent-loopguard` (the unscoped name was too close to an existing package).
package/README.md CHANGED
@@ -1,6 +1,6 @@
1
1
  # agent-loopguard
2
2
 
3
- <!-- Add after the first publish: [![npm](https://img.shields.io/npm/v/@osama.ansar/agent-loopguard)](https://www.npmjs.com/package/@osama.ansar/agent-loopguard) -->
3
+ [![npm](https://img.shields.io/npm/v/@osama.ansar/agent-loopguard)](https://www.npmjs.com/package/@osama.ansar/agent-loopguard) [![CI](https://github.com/OsamaAnsar/agent-loopguard/actions/workflows/ci.yml/badge.svg)](https://github.com/OsamaAnsar/agent-loopguard/actions/workflows/ci.yml)
4
4
 
5
5
  Agent loops fail in a few boring, expensive ways. The model calls the same tool with the same arguments again and again. Two calls ping-pong (`search`, `read`, `search`, `read`). The model repeats itself while nothing new comes back. A provider ends a final, text-only step with a finish reason the loop does not recognise as "done", so some frameworks re-send the same request until the step limit. Or the run quietly burns through a token, time or cost budget.
6
6
 
@@ -48,13 +48,14 @@ Every detector can be tuned or switched off with `false`. All defaults are liste
48
48
  | Detector (option) | Stops when | Defaults |
49
49
  | --- | --- | --- |
50
50
  | `maxSteps` | the number of steps recorded reaches the limit | `50` |
51
- | `repeatedToolCall` | the same tool is called with canonically-equal arguments N times in a row | `threshold: 3`, `requireIdenticalResults: false`, `ignoreErrors: false` |
51
+ | `tools.<name>.maxCalls` | a tool is called more often than its cap allows (the call after the Nth) | no caps unless you set them |
52
+ | `repeatedToolCall` | the same tool is called with canonically-equal arguments N times in a row | `threshold: 3`, `requireIdenticalResults: false`, `ignoreErrors: false`; per tool: `repeatThreshold`, `polling` |
52
53
  | `oscillation` | the tail of the tool-call sequence is a cycle of 2..`maxPeriod` calls repeated `repeats` times | `maxPeriod: 3`, `repeats: 3` |
53
54
  | `noProgress` | N consecutive steps have the same normalised text and no new tool outcome | `threshold: 3` |
54
55
  | `finishReason` | the finish reason is terminal, or is an unfamiliar one under the `otherPolicy` | `terminal: ['stop', 'length', 'content-filter', 'error']`, `otherPolicy: 'stop-if-text-only'` |
55
56
  | `budget` | a cumulative token, cost or duration total reaches its limit (and warns before that, if you set `warnAt`) | no limits unless you set them |
56
57
 
57
- Plus a `custom` hook for your own rules (below).
58
+ Plus per-tool policies (`tools`) and a `custom` hook for your own rules (both below). Three more things sit around the detectors: [`preflight`](#check-a-call-before-it-runs) asks whether a call would trip a rule before you run it, [feedback](#tell-the-model-what-went-wrong) gives every fault a short message that is safe to send to the model, and [`toJSON` / `fromJSON`](#save-and-resume-a-run) carry a run into another process.
58
59
 
59
60
  ## Examples
60
61
 
@@ -134,7 +135,121 @@ const guard = createGuard({
134
135
  });
135
136
  ```
136
137
 
137
- A custom detector receives the full history (the current step last) and runs after the built-in ones. Return `undefined` or `{ stop: false }` to continue. Its verdicts have `reason: 'custom'`. If it throws, the error propagates out of `guard.check`.
138
+ A custom detector receives the full history (the current step last) and runs after the built-in ones. Return `undefined` or `{ stop: false }` to continue. Its verdicts have `reason: 'custom'`. If it throws, the error propagates out of `guard.check`. A custom detector can also return `feedback: '...'` (text for the model); without it, a custom verdict has no `feedback`.
139
+
140
+ ### Per-tool policies
141
+
142
+ ```ts
143
+ import { createGuard } from '@osama.ansar/agent-loopguard';
144
+
145
+ const guard = createGuard({
146
+ tools: {
147
+ search: { maxCalls: 8 }, // at most 8 searches in a run
148
+ job_status: { polling: { maxIdenticalResults: 6 } }, // same args are fine while the result changes
149
+ deploy: { repeatThreshold: 2 }, // two identical deploys in a row is already too many
150
+ think: { ignore: true }, // bookkeeping: never counts as a loop or as progress
151
+ rm: { maxCalls: 0 }, // forbidden outright
152
+ },
153
+ });
154
+ ```
155
+
156
+ | Policy field | Meaning |
157
+ | --- | --- |
158
+ | `maxCalls` | An integer >= 0: the most calls to this tool in one run. `maxCalls: 3` allows three; the fourth stops the run with reason `tool-limit`, code `TOOL_CALL_CAP` (and `preflight` refuses it before it runs). `0` forbids the tool. |
159
+ | `repeatThreshold` | An integer >= 2 replacing the global `repeatedToolCall.threshold` for this tool. |
160
+ | `polling` | `true` (or `{ maxIdenticalResults?: number }`): identical arguments are legitimate while the results keep changing. The repeated-call rule applies to this tool only when the result is identical too, after `maxIdenticalResults` identical outcomes in a row (default: the tool's `repeatThreshold`, else the global threshold, so raise it for jobs that stay "pending" for a while). Calls without a result compare equal. |
161
+ | `ignore` | `true` leaves the tool out of repeat, oscillation and no-progress detection and out of `maxCalls`. Takes precedence over the other fields. |
162
+
163
+ Tool names that are not listed follow the global options; names that are never called do no harm. An option the policy does not know (`maxcalls`, a typo) throws a `TypeError` from `createGuard`, so a misspelt cap cannot quietly do nothing. `repeatThreshold` and `polling` have no effect when `repeatedToolCall` is `false`; `maxCalls` and `ignore` still work.
164
+
165
+ ### Check a call before it runs
166
+
167
+ ```ts
168
+ import { createGuard } from '@osama.ansar/agent-loopguard';
169
+
170
+ const guard = createGuard({ tools: { search: { maxCalls: 5 } } });
171
+
172
+ const executed = [];
173
+ for (const call of modelToolCalls) { // [{ name, args }]
174
+ const pre = guard.preflight(call);
175
+ if (!pre.allow) {
176
+ toolMessages.push({ toolCallId: call.id, content: pre.feedback }); // tell the model; do not run the tool
177
+ continue;
178
+ }
179
+ executed.push({ name: call.name, args: call.args, result: await runTool(call) });
180
+ }
181
+
182
+ // After the step, record what really happened, exactly as before.
183
+ const verdict = guard.check({ text, toolCalls: executed, finishReason, usage });
184
+ ```
185
+
186
+ `guard.preflight(call)` answers "if I run this call now, would it immediately trip a rule?". It records nothing and changes nothing, so you can ask as often as you like. It returns `{ allow: true, warnings? }` or `{ allow: false, reason, code, detail, evidence, feedback }`, using the same `reason` and `code` as a verdict. Pass an array to ask about parallel calls in one turn (`guard.preflight([a, b])`); an array is judged as one step, whereas asking about calls one at a time cannot see the calls of the same turn, which are not recorded yet.
187
+
188
+ What it can and cannot judge, because a call that has not run yet has no result, text, finish reason or usage:
189
+
190
+ | Rule | In `preflight`? |
191
+ | --- | --- |
192
+ | `budget` | Yes, when a limit is already reached (the totals so far; the wall clock for a duration limit). Budget warnings come back on an allowed call. |
193
+ | `maxSteps` | Yes, when the limit is already reached. |
194
+ | `tools.<name>.maxCalls` | Yes: the call after the Nth is refused. |
195
+ | `repeatedToolCall` | Yes, for the default rule: the call that would be the Nth identical one in a row. **No** for result-aware rules (`requireIdenticalResults`, `polling` tools): they cannot be judged before the result is known, so `preflight` never refuses on them (it may be right to run the call: the result might be new). `check` still stops them once the identical outcomes are in. |
196
+ | `oscillation` | Yes: the call that would complete the cycle. |
197
+ | `noProgress`, `finishReason`, `custom` | No. They need the finished step (or your code), so only `check` runs them. |
198
+
199
+ A tool with `ignore: true` skips the call-pattern rules in `preflight` too, but `budget` and `maxSteps` still apply to it. When several rules would refuse, the first in this order wins: budget, max steps, tool limit, repeated call, oscillation. `preflight` says nothing about a guard whose last verdict was already a stop; acting on that is up to you.
200
+
201
+ A refused call never ran, so leave it out of the step you pass to `check`. The step still counts towards `maxSteps`, and if the model keeps asking for the refused call, `maxSteps` ends the run (and `noProgress`, if its text repeats too). Recording a refused call as if it had run would make the repeat rule fire on the refusal itself.
202
+
203
+ ### Tell the model what went wrong
204
+
205
+ ```ts
206
+ const verdict = guard.check(step);
207
+ if (verdict.stop && verdict.feedback) {
208
+ messages.push({ role: 'user', content: verdict.feedback }); // let the model change course or wrap up
209
+ }
210
+ ```
211
+
212
+ Every stop verdict that is a fault (`isFault(verdict)`) has a `feedback` string: a few plain sentences, different for each `code`, the same every time, written to be pasted into the model's next turn or a tool result. For example, for `REPEATED_TOOL_CALL`:
213
+
214
+ > You have called `search` 3 times in a row with the same arguments. Repeating it is unlikely to give you anything new. Try a different tool or different arguments, or answer with what you have.
215
+
216
+ Those words are the whole message: it is built from fixed text, counts and limits, and the tool **name**. It never repeats arguments, results, model text or provider strings, because those can hold secrets or personal data and this text is meant to be sent to a model (and ends up in logs and transcripts). `explain()` and `verdict.detail` are the developer's view and do show an argument preview. A tool name is only used if it looks like an ordinary identifier (letters, digits, `_ . : / -`, up to 64 characters); the model chooses names, so anything else is replaced by "that tool". A normal `stop` finish has no `feedback`, and neither does a custom verdict unless the detector supplies one.
217
+
218
+ ```ts
219
+ import { createGuard, feedbackFor } from '@osama.ansar/agent-loopguard';
220
+
221
+ const guard = createGuard({
222
+ // Return a string to replace the text, or undefined to keep the default.
223
+ feedback: (verdict, { phase }) =>
224
+ verdict.code === 'MAX_STEPS' ? 'Out of steps. Summarise what you found.' : undefined,
225
+ });
226
+ // feedbackFor(verdict) returns the default text for any verdict (or undefined when there is none).
227
+ ```
228
+
229
+ `phase` is `'check'` for a verdict from `check` and `'preflight'` for a refused call, whose default wording differs ("That call was not run: ..."). `feedback` is called only for faults; if it throws, the error propagates out of `check` / `preflight`. Keeping your own text free of secrets is up to you.
230
+
231
+ ### Save and resume a run
232
+
233
+ ```ts
234
+ import { createGuard } from '@osama.ansar/agent-loopguard';
235
+
236
+ const options = { maxSteps: 40, tools: { search: { maxCalls: 8 } } };
237
+
238
+ // In the first process: JSON.stringify(guard) is guard.toJSON().
239
+ await store.set(`run:${runId}`, JSON.stringify(guard));
240
+
241
+ // Later, in another process: pass the same options.
242
+ const guard = createGuard.fromJSON(JSON.parse(await store.get(`run:${runId}`)), options);
243
+ ```
244
+
245
+ The restored guard goes on exactly where the original stopped: given the same steps it returns the same verdicts and the same summaries (this is checked by a differential test that cuts random runs at random points, through `structuredClone` and through a JSON round trip). `restoreGuard` is the same function as `createGuard.fromJSON`.
246
+
247
+ - **What a snapshot holds.** Version number, step and call counters, running totals, the wall-clock time used so far (only when there is a duration budget), tool names, hashes of recent calls and outcomes, and a few previews of at most 80 characters of recent arguments and text, kept so that evidence and messages read the same after a restore. **What it does not hold:** raw arguments, results, errors, model text, the step history, the options, the last verdict. Hashes are not cryptographic; a short, guessable value could be recovered by hashing candidates, so treat a snapshot as run data, not as public. `guard.snapshot({ redact: true })` replaces the previews with `[redacted]`: stop decisions stay the same, but evidence for calls made before the snapshot says `[redacted]`.
248
+ - **Size.** Bounded by the options, not by the length of the run: the oscillation detector keeps `maxPeriod` calls, the no-progress detector the newest 1024 distinct outcomes (about 31 bytes each), the repeat detector one run. Measured with default options: 831 bytes for a fresh guard, and 33,088 bytes after 1,500 or 6,000 steps that each had a new 2 KB argument, a 3 KB result and 500 characters of text (it stops growing once 1024 outcomes are remembered); raising `oscillation.maxPeriod` to 50 made it 39,292 bytes. Tool names over 256 characters are cut in the snapshot.
249
+ - **Options are not stored; they are fingerprinted.** Functions cannot be saved, so you pass the options again. The snapshot records one fingerprint per group of options (`maxSteps`, `repeatedToolCall`, `oscillation`, `noProgress`, `finishReason`, `budget`, `tools`, and the number of `custom` detectors), and restoring under different options throws a `TypeError` that names the groups, instead of silently behaving differently. Not fingerprinted: `now`, `feedback` and what your custom detectors do. To give a run that hit its limit more room, pass `{ allowLimitChanges: true }` as the third argument: then `maxSteps` and `budget` may differ (and only those).
250
+ - **History.** The raw steps are not in a snapshot, so a restored guard starts with an empty `history`, and custom detectors see only the steps recorded since the restore. `summary().steps` and the detectors keep counting across the restore. `summary().lastVerdict` is `null` until the first `check`.
251
+ - **Wall-clock budgets.** The time used so far is stored as a duration, so the time between saving and restoring is not counted.
252
+ - **Bad snapshots.** An unknown version, a wrong shape, an impossible number or a hostile object (throwing getter, revoked proxy, `__proto__` keys) throws a `TypeError` from `fromJSON`, never a crash later from `check`. Unknown extra keys are ignored.
138
253
 
139
254
  ## What it does not do
140
255
 
@@ -144,23 +259,27 @@ A custom detector receives the full history (the current step last) and runs aft
144
259
  - **It does not run your agent.** `runAgentLoop` is a ten-line convenience, not a framework.
145
260
  - **It does not cancel in-flight work.** A verdict is advice; stopping is up to the caller (`runAgentLoop` and `toStopWhen` do it for you at step boundaries).
146
261
  - **Defaults are not benchmarked.** Thresholds such as "3 in a row" are conventions, not measured optima. Check them against your own traces.
147
- - **History is kept in memory** for the life of the guard (`reset()` clears it). It is cheap for a normal run but is not designed for unbounded sessions.
262
+ - **History is kept in memory** for the life of the guard (`reset()` clears it). It is cheap for a normal run but is not designed for unbounded sessions. The detectors themselves keep bounded state; only `guard.history` (the raw steps) grows.
263
+ - **`preflight` is not a full check.** It cannot see results, text, finish reasons or usage, so it never replaces `check`; see the table above for exactly what it judges. It does not cancel or wrap your tool calls either: it only answers, and you decide.
264
+ - **A snapshot is not a transcript.** It cannot rebuild `history`, and it does not store your options (only their fingerprints).
148
265
 
149
266
  ## How detection works
150
267
 
151
268
  Steps are reduced to comparable facts once, when `check` is called. Anything unusable (a non-string finish reason, `NaN` token counts, a throwing getter) is ignored rather than thrown on.
152
269
 
153
- **Canonical form.** Arguments and results are turned into a deterministic string so equal values compare equal: object keys are sorted; properties whose value is `undefined` are omitted (like JSON); array order is kept; `NaN`, `Infinity` and bigint (`10n`) stay distinct from strings and numbers that print alike; `-0` equals `0`; Dates are written as ISO strings; Maps and Sets are sorted by their entries; objects with `toJSON` (URL, ...) are written by calling it; a reference back to an ancestor is written `[Circular:<depth>]`; nesting deeper than 64 levels and inputs of more than 200,000 values are cut off with a marker (so two huge values that agree on everything before the cut compare equal). If reading a value throws (hostile getters, revoked proxies), it gets a unique placeholder and is never equal to anything. Canonical forms longer than 256 characters are compared by length plus a 28-hex-character non-cryptographic hash; this is for equality checks, not security.
270
+ **Canonical form.** Arguments and results are turned into a deterministic string so equal values compare equal: object keys are sorted; properties whose value is `undefined` are omitted (like JSON); array order is kept; `NaN`, `Infinity` and bigint (`10n`) stay distinct from strings and numbers that print alike; `-0` equals `0`; Dates are written as ISO strings; Maps and Sets are sorted by their entries; objects with `toJSON` (URL, ...) are written by calling it; a reference back to an ancestor is written `[Circular:<depth>]`; nesting deeper than 64 levels and inputs of more than 200,000 values are cut off with a marker (so two huge values that agree on everything before the cut compare equal). If reading a value throws (hostile getters, revoked proxies), it gets a unique placeholder and is never equal to anything. Equality is decided on a fixed-length hash of the canonical form (two independent 53-bit hashes, 28 hex characters; for forms longer than 256 characters the length is folded in first), so detector state and snapshots hold hashes, not raw values. The hash is non-cryptographic and meant for equality checks, not security.
154
271
 
155
- **Call sequence.** "A call" is one entry of `toolCalls`. `repeatedToolCall` and `oscillation` read the flattened sequence of calls across steps, in order. Parallel calls in one step count one by one; steps with no tool calls neither extend nor break a run. A call's signature is its tool name plus the canonical arguments. Its outcome signature adds the canonical result or error (or, if neither was supplied, is just the call).
272
+ **Call sequence.** "A call" is one entry of `toolCalls`. `repeatedToolCall` and `oscillation` read the flattened sequence of calls across steps, in order (calls of a tool with `ignore: true` are removed from it first, so they neither extend nor break a run). Parallel calls in one step count one by one; steps with no tool calls neither extend nor break a run. A call's signature is its tool name plus the canonical arguments. Its outcome signature adds the canonical result or error (or, if neither was supplied, is just the call).
156
273
 
157
274
  **`maxSteps`.** Stops when `limit` steps have been recorded. With `maxSteps: 3` the third `check` stops.
158
275
 
159
- **`repeatedToolCall`.** Stops when the run of identical signatures at the end of the sequence reaches `threshold` (exactly N stops, N-1 does not). With `requireIdenticalResults`, identical means identical outcome signature, so polling a job whose status keeps changing is not a repeat. With `ignoreErrors`, calls with a non-null `error` are removed from the sequence before counting. A run that reaches the threshold in the middle of a step still counts if a later call in the same step ends it.
276
+ **`tools.<name>.maxCalls`.** Counts every call to the tool in the run (across steps, whatever the arguments or results) and stops on the first call beyond the cap: with `maxCalls: 3` the fourth call stops, with `maxCalls: 0` the first does. With parallel calls in one step, the first call over the cap is the one reported. Ignored tools are never counted. It keeps stopping on every later step that calls the tool, like the other rules.
277
+
278
+ **`repeatedToolCall`.** Stops when the run of identical signatures at the end of the sequence reaches `threshold` (exactly N stops, N-1 does not). With `requireIdenticalResults`, identical means identical outcome signature, so polling a job whose status keeps changing is not a repeat. With `ignoreErrors`, calls with a non-null `error` are removed from the sequence before counting. A run that reaches the threshold in the middle of a step still counts if a later call in the same step ends it. A tool's `repeatThreshold` replaces the threshold for runs of that tool; a `polling` tool compares outcomes like `requireIdenticalResults` does, with its own threshold; the evidence reports the threshold and `identicalResults` that applied.
160
279
 
161
280
  **`oscillation`.** For each period `p` from 2 to `maxPeriod`, looks at the end of the call-signature sequence: it stops when the last `p` calls repeat for `repeats` full cycles in a row (`a b a b a b` is period 2, three cycles). A cycle made of one distinct call (`a a a a a a`) is not an oscillation; that is `repeatedToolCall`'s case. Results are not considered.
162
281
 
163
- **`noProgress`.** A step is *stalled* when none of its tool outcomes is new in this run (a step without tool calls is always stalled). It stops after `threshold` consecutive stalled steps that all have the same normalised text (lower-cased, whitespace collapsed; an empty string counts as text, so tool-only steps are judged on outcomes alone). A new outcome, or different text, restarts the count. Because outcomes include the result, the same call returning a new result is progress.
282
+ **`noProgress`.** A step is *stalled* when none of its tool outcomes is new in this run (a step without tool calls is always stalled). It stops after `threshold` consecutive stalled steps that all have the same normalised text (lower-cased, whitespace collapsed; an empty string counts as text, so tool-only steps are judged on outcomes alone). A new outcome, or different text, restarts the count. Because outcomes include the result, the same call returning a new result is progress. Calls of `ignore` tools are not outcomes, so a step that only calls them is stalled. The detector remembers the newest 1024 distinct outcomes; in a run with more than that, an outcome older than the window counts as new again (this bounds memory and snapshot size).
164
283
 
165
284
  **`finishReason`.** Finish reasons are normalised (trimmed, lower-cased, `_` becomes `-`). Then: a reason in `terminal` stops, even if tool calls are present; `tool-calls` continues; a missing reason says nothing; anything else (`other`, `unknown`, any unfamiliar string) follows `otherPolicy`: `'stop'` stops, `'continue'` continues, `'stop-if-text-only'` (default) stops when the step has non-blank text and no tool calls.
166
285
 
@@ -168,7 +287,7 @@ Why the default exists: some frameworks only treat `stop` as "finished". If a pr
168
287
 
169
288
  **`budget`.** Totals are summed from the steps: input, output and total tokens (total falls back to input plus output when a step gives none), `costUsd`, and `durationMs`. Numbers that are not finite and non-negative are ignored. A limit is *spent when reached* (`value >= limit`): the guard runs after a step has happened, so waiting for "exceeded" would always overshoot by a step. `maxDurationMs` compares against the larger of the summed `durationMs` values and the wall-clock time since the guard was created or reset (read from the injectable `now()`, so tests can control it). With `warnAt` (a ratio between 0 and 1, per limit or shared), a step that is at or past that fraction but below the limit returns `{ stop: false, warnings: [...] }`; the warning repeats on every step while it applies.
170
289
 
171
- **Priority.** All detectors see every step (so their state stays consistent), and the first stop wins in this order: finish reason, budget, max steps, repeated tool call, oscillation, no progress, custom. Detectors overlap: a perfectly repeating `a b a b a` with identical results and empty text is reported by `noProgress` a step before `oscillation` would fire.
290
+ **Priority.** All detectors see every step (so their state stays consistent), and the first stop wins in this order: finish reason, budget, max steps, tool limit, repeated tool call, oscillation, no progress, custom. The tool limit sits after the step and budget limits (all explicit caps) and before the pattern detectors, because a cap you set is a harder rule than a heuristic. `preflight` uses the same order for the rules it can judge. Detectors overlap: a perfectly repeating `a b a b a` with identical results and empty text is reported by `noProgress` a step before `oscillation` would fire.
172
291
 
173
292
  ## API reference
174
293
 
@@ -186,19 +305,34 @@ interface GuardOptions {
186
305
  maxCostUsd?: BudgetLimit; maxDurationMs?: BudgetLimit;
187
306
  warnAt?: number; // default warning ratio
188
307
  } | false; // BudgetLimit = number | { limit: number; warnAt?: number }
308
+ tools?: Record<string, ToolPolicy>; // per-tool rules, below
189
309
  custom?: CustomDetector | readonly CustomDetector[];
310
+ feedback?: (verdict: StopVerdict, info: { phase: 'check' | 'preflight' }) => string | undefined;
190
311
  now?: () => number; // default Date.now
191
312
  }
192
313
 
314
+ interface ToolPolicy {
315
+ polling?: boolean | { maxIdenticalResults?: number }; // integer >= 2
316
+ maxCalls?: number; // integer >= 0
317
+ repeatThreshold?: number; // integer >= 2
318
+ ignore?: boolean;
319
+ }
320
+
193
321
  interface Guard {
194
322
  check(step: GuardStep): Verdict;
323
+ preflight(call: PreflightCall | readonly PreflightCall[]): Preflight; // would this call trip a rule? changes nothing
195
324
  reset(): void; // forget the run, keep the options
196
325
  summary(): GuardSummary; // counts, totals, per-detector state; JSON-serialisable
197
- readonly history: readonly GuardStep[]; // frozen shallow copy
326
+ toJSON(): GuardSnapshot; // save the run; JSON.stringify(guard) uses it
327
+ snapshot(options?: { redact?: boolean }): GuardSnapshot;
328
+ readonly history: readonly GuardStep[]; // frozen shallow copy (empty after a restore)
198
329
  }
330
+
331
+ // Rebuild a guard from a snapshot. `snapshot` may be the result of JSON.parse; `restoreGuard` is the same function.
332
+ createGuard.fromJSON(snapshot: unknown, options?: GuardOptions, restore?: { allowLimitChanges?: boolean }): Guard;
199
333
  ```
200
334
 
201
- Invalid options (a threshold below 2, a non-positive limit, a `warnAt` outside 0 to 1, ...) throw a `TypeError` from `createGuard`. `check` does not throw on odd step data.
335
+ Invalid options (a threshold below 2, a non-positive limit, a `warnAt` outside 0 to 1, a misspelt tool policy, ...) throw a `TypeError` from `createGuard`. `check` and `preflight` do not throw on odd step or call data.
202
336
 
203
337
  `summary()` contains nothing time-dependent: durations are the summed `durationMs` values, never wall-clock readings.
204
338
 
@@ -220,9 +354,9 @@ interface GuardStep {
220
354
  ```ts
221
355
  type Verdict =
222
356
  | { stop: false; warnings?: GuardWarning[] }
223
- | { stop: true; reason: StopReason; code: string; detail: string; evidence: /* depends on reason */; warnings?: GuardWarning[] };
357
+ | { stop: true; reason: StopReason; code: string; detail: string; evidence: /* depends on reason */; feedback?: string; warnings?: GuardWarning[] };
224
358
 
225
- type StopReason = 'max-steps' | 'repeated-tool-call' | 'oscillation' | 'no-progress' | 'finish-reason' | 'budget' | 'custom';
359
+ type StopReason = 'max-steps' | 'tool-limit' | 'repeated-tool-call' | 'oscillation' | 'no-progress' | 'finish-reason' | 'budget' | 'custom';
226
360
  ```
227
361
 
228
362
  `verdict.reason` narrows `verdict.evidence` in TypeScript. Verdicts are plain JSON.
@@ -230,20 +364,50 @@ type StopReason = 'max-steps' | 'repeated-tool-call' | 'oscillation' | 'no-progr
230
364
  | `reason` | `code` | `evidence` |
231
365
  | --- | --- | --- |
232
366
  | `max-steps` | `MAX_STEPS` | `{ steps, limit }` |
367
+ | `tool-limit` | `TOOL_CALL_CAP` | `{ tool, calls, limit }` |
233
368
  | `repeated-tool-call` | `REPEATED_TOOL_CALL` | `{ tool, argsPreview, count, threshold, stepIndexes, identicalResults }` |
234
- | `oscillation` | `OSCILLATION` | `{ period, repeats, threshold, cycle }` |
369
+ | `oscillation` | `OSCILLATION` | `{ period, repeats, threshold, cycle, tools }` |
235
370
  | `no-progress` | `NO_PROGRESS` | `{ steps, threshold, textPreview }` |
236
371
  | `finish-reason` | `FINISH_TERMINAL`, `FINISH_OTHER`, `FINISH_OTHER_TEXT_ONLY` | `{ finishReason, normalized, policy, hasText, toolCallCount }` |
237
372
  | `budget` | `BUDGET_TOTAL_TOKENS`, `BUDGET_INPUT_TOKENS`, `BUDGET_OUTPUT_TOKENS`, `BUDGET_COST_USD`, `BUDGET_DURATION_MS` | `{ metric, value, limit }` |
238
373
  | `custom` | your `code` (default `CUSTOM`) | whatever you return |
239
374
 
240
- A warning is `{ code: 'BUDGET_WARNING', metric, value, limit, ratio, detail }`.
375
+ A warning is `{ code: 'BUDGET_WARNING', metric, value, limit, ratio, detail }`. `feedback` is present on every fault verdict from a built-in detector (see [Tell the model what went wrong](#tell-the-model-what-went-wrong)).
241
376
 
242
377
  ### Helpers
243
378
 
244
379
  - `explain(verdict): string`: readable text for logs, including a short hint and any warnings.
245
380
  - `isFault(verdict): verdict is StopVerdict`: true for a stop that is not just the model finishing normally with `stop`.
381
+ - `feedbackFor(verdict, phase?: 'check' | 'preflight'): string | undefined`: the default model-facing text for a verdict (what `verdict.feedback` holds unless you override it). `undefined` for a normal `stop` finish and for a custom verdict that brings no text.
246
382
  - `canonicalize(value): string` and `stableHash(value): string`: the comparison primitives, exported because they are useful on their own.
383
+ - `restoreGuard(snapshot, options?, { allowLimitChanges? }?): Guard`: the same function as `createGuard.fromJSON`.
384
+
385
+ ### `Preflight`
386
+
387
+ ```ts
388
+ type Preflight =
389
+ | { allow: true; warnings?: GuardWarning[] }
390
+ | { allow: false; reason: 'max-steps' | 'budget' | 'tool-limit' | 'repeated-tool-call' | 'oscillation';
391
+ code: string; detail: string; evidence: /* as for the same reason in a verdict */; feedback: string };
392
+ ```
393
+
394
+ `reason` narrows `evidence` as it does for a verdict. `detail` is for developers and may quote an argument preview; `feedback` is for the model and never does. The evidence of a refused repeat or cycle is what `check` would report for the same call (the counts include the call that was refused). See [Check a call before it runs](#check-a-call-before-it-runs) for what is judged.
395
+
396
+ ### `GuardSnapshot`
397
+
398
+ ```ts
399
+ interface GuardSnapshot {
400
+ version: 1;
401
+ steps: number; toolCalls: number; failedToolCalls: number;
402
+ totals: GuardTotals;
403
+ wallClockMs: number | null; // only with a duration budget
404
+ options: { maxSteps: string; repeatedToolCall: string; oscillation: string; noProgress: string;
405
+ finishReason: string; budget: string; tools: string; custom: string }; // fingerprints
406
+ detectors: { repeatedToolCall; oscillation; noProgress; finishReason; toolLimits }; // each null when that detector is off
407
+ }
408
+ ```
409
+
410
+ Plain JSON-safe data. The shape of each detector's state is exported as a type (`RepeatedToolCallSnapshot`, `OscillationSnapshot`, `NoProgressSnapshot`) but is an implementation detail: treat a snapshot as opaque and only pass it to `createGuard.fromJSON`. See [Save and resume a run](#save-and-resume-a-run).
247
411
 
248
412
  ### Adapters
249
413
 
@@ -254,9 +418,16 @@ A warning is `{ code: 'BUDGET_WARNING', metric, value, limit, ratio, detail }`.
254
418
 
255
419
  ## Related work
256
420
 
257
- [`agent-loop-guard`](https://www.npmjs.com/package/agent-loop-guard) (by Nextbridge) is a similar, independent library, and a good one to look at. Going by its 0.2.0 README, it wraps each tool call in `beforeCall()` / `afterCall()`, so it can block a call before it runs, and it offers per-tool call limits, saved and resumable state, and a text report you can paste into the model's context.
421
+ [`agent-loop-guard`](https://www.npmjs.com/package/agent-loop-guard) (by Nextbridge) is a similar, independent library, and a good one to look at. Going by its 0.2.0 README, it wraps each tool call in `beforeCall()` / `afterCall()` (with `run()` to wrap your executor and `afterError()` for thrown errors), and offers a call budget with per-tool ceilings and a wall-clock limit, windowed repeat detection for repeats that are not consecutive, early warnings before a limit is hit, `toJSON()` / `fromJSON()` state, a `report()` text you can paste into the model's context, and blocked decisions that carry a suggestion phrased for the model.
422
+
423
+ Since 0.2.0 this package covers some of the same ground: `preflight` (a check before a call runs), per-tool call caps, save and resume, and model-facing `feedback`. What is still different, as far as the two READMEs say:
424
+
425
+ - **Shape.** Here you pass in each finished step to `check`. `preflight` is a read-only question about a call that has not run; there is no `beforeCall` / `afterCall` pairing and no `run()` wrapper, and `preflight` cannot judge rules that need the result (see above). In the other library the pair is the whole API.
426
+ - **What this one has that its README does not describe.** A finish-reason policy (for example, ending a run when a text-only step finishes with an unfamiliar reason), token, cost and duration budgets driven by the usage numbers you report (its README describes call-count and wall-clock budgets), a no-progress rule on repeated text, per-tool `polling` and `ignore` policies, a custom-detector hook, a `stopWhen` adapter for the Vercel AI SDK that is tested against the real SDK, and CommonJS as well as ESM builds (its README says it is ESM-only).
427
+ - **What the other one has that this one does not.** `run()`, `report()`, windowed repeat detection (here only consecutive repeats and short cycles are detected), early warnings before a repeat, stagnation or call-budget limit is reached (here only budgets warn), cycle detection that compares results as well as calls (here `oscillation` ignores results), custom signature functions, and an `onDecision` hook.
428
+ - **Saved state.** In both, options are not stored. Here a snapshot also carries a fingerprint of the options and restoring under different ones throws; it is bounded by the options rather than by the run (it remembers only the newest 1024 distinct outcomes), and it holds hashes plus a few short previews. Its README says its snapshot grows with the number of distinct calls in a run.
258
429
 
259
- This package takes a different shape: you pass in each finished step, and it adds a finish-reason policy (for example, ending a run when a text-only step finishes with an unfamiliar reason), budgets driven by the token, cost and duration numbers you report, and a `stopWhen` adapter for the Vercel AI SDK that is tested against the real SDK. Both have zero runtime dependencies. Pick whichever fits how your loop is written; nothing stops you from using the other one's ideas.
430
+ Both have zero runtime dependencies. Pick whichever fits how your loop is written; nothing stops you from using the other one's ideas.
260
431
 
261
432
  ## Development
262
433
 
@@ -267,6 +438,7 @@ npm run lint
267
438
  npm test # npm run test:coverage for coverage
268
439
  npm run build
269
440
  npm run example:loop
441
+ npm run example:preflight # preflight, feedback, per-tool policies and save/resume, offline
270
442
  npm run example:ai-sdk # Node >= 22 (needs the `ai` dev dependency)
271
443
  ```
272
444