agent-loop-guard 0.1.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/LICENSE +21 -0
- package/README.md +406 -0
- package/package.json +58 -0
- package/src/index.d.ts +76 -0
- package/src/index.js +2 -0
- package/src/loop-guard.js +383 -0
- package/src/signature.js +254 -0
package/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 Nextbridge
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
package/README.md
ADDED
|
@@ -0,0 +1,406 @@
|
|
|
1
|
+
# agent-loop-guard
|
|
2
|
+
|
|
3
|
+
[](https://www.npmjs.com/package/agent-loop-guard)
|
|
4
|
+
[](https://github.com/nextbridgehq/agent-loop-guard/actions/workflows/ci.yml)
|
|
5
|
+
[](LICENSE)
|
|
6
|
+
[](https://nodejs.org)
|
|
7
|
+

|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
Developed and open-sourced by [Nextbridge](https://www.nextbridge.com).
|
|
12
|
+
|
|
13
|
+
---
|
|
14
|
+
|
|
15
|
+
**LLM agent loop detection for JavaScript, TypeScript, and Node.js.** `agent-loop-guard` is a lightweight AI agent guard for autonomous agents: a decision layer that delivers tool-call loop prevention by watching for repeated tool calls, stagnant results, and multi-step cycles before an agent burns its budget. Cycle detection and agent safety checks run entirely in-process — no framework, model client, or tool executor required.
|
|
16
|
+
|
|
17
|
+
`agent-loop-guard` doesn't execute tools or wrap a model client — it's two function calls, `beforeCall()` and `afterCall()`, that you check around your own tool-execution code. Every blocked decision comes back with a concrete, actionable suggestion instead of just a boolean, so it drops into any Node.js agent loop in minutes.
|
|
18
|
+
|
|
19
|
+
---
|
|
20
|
+
|
|
21
|
+
## Contents
|
|
22
|
+
|
|
23
|
+
- [The Problem](#the-problem)
|
|
24
|
+
- [Quick Start](#quick-start)
|
|
25
|
+
- [Features](#features)
|
|
26
|
+
- [Why & Who It's For](#why--who-its-for)
|
|
27
|
+
- [Installation](#installation)
|
|
28
|
+
- [Usage](#usage)
|
|
29
|
+
- [Architecture](#architecture)
|
|
30
|
+
- [Configuration](#configuration)
|
|
31
|
+
- [Example output](#example-output)
|
|
32
|
+
- [Decision codes](#decision-codes)
|
|
33
|
+
- [Programmatic API](#programmatic-api)
|
|
34
|
+
- [TypeScript support](#typescript-support)
|
|
35
|
+
- [How it works](#how-it-works)
|
|
36
|
+
- [Limitations](#limitations)
|
|
37
|
+
- [Security](#security)
|
|
38
|
+
- [Troubleshooting](#troubleshooting)
|
|
39
|
+
- [FAQ](#faq)
|
|
40
|
+
- [Changelog](CHANGELOG.md)
|
|
41
|
+
- [Contributing](#contributing)
|
|
42
|
+
- [License](#license)
|
|
43
|
+
|
|
44
|
+
## The Problem
|
|
45
|
+
|
|
46
|
+
Modern AI agents can run in loops without realizing it:
|
|
47
|
+
|
|
48
|
+
```
|
|
49
|
+
Agent:
|
|
50
|
+
→ search("customer record")
|
|
51
|
+
→ search("customer record")
|
|
52
|
+
→ search("customer record")
|
|
53
|
+
→ search("customer record")
|
|
54
|
+
```
|
|
55
|
+
|
|
56
|
+
A simple `maxIterations` limit stops the runaway cost, but it doesn't explain:
|
|
57
|
+
|
|
58
|
+
- Why did the agent get stuck?
|
|
59
|
+
- Which tool call caused the loop?
|
|
60
|
+
- Did the result stop changing?
|
|
61
|
+
- What should the agent try next?
|
|
62
|
+
|
|
63
|
+
`agent-loop-guard` adds a decision layer around your existing tool-execution loop: it detects the pattern early and hands back structured, actionable feedback instead of only saying "stop."
|
|
64
|
+
|
|
65
|
+
## Quick Start
|
|
66
|
+
|
|
67
|
+
```bash
|
|
68
|
+
npm install agent-loop-guard
|
|
69
|
+
```
|
|
70
|
+
|
|
71
|
+
```js
|
|
72
|
+
import { LoopGuard } from "agent-loop-guard";
|
|
73
|
+
|
|
74
|
+
const guard = new LoopGuard();
|
|
75
|
+
const decision = guard.beforeCall("search", { query: "cats" });
|
|
76
|
+
|
|
77
|
+
if (!decision.allowed) {
|
|
78
|
+
console.log(decision.suggestionDetail);
|
|
79
|
+
}
|
|
80
|
+
```
|
|
81
|
+
|
|
82
|
+
See [Usage](#usage) for a full agent loop wired up with `beforeCall()` and `afterCall()`.
|
|
83
|
+
|
|
84
|
+
## Features
|
|
85
|
+
|
|
86
|
+
- **Call-budget enforcement** — a hard ceiling (`maxCalls`) on total tool calls per run, so a runaway loop can't spend unbounded time or API cost.
|
|
87
|
+
- **Repeated-call detection** — flags the same tool called with identical arguments `repeatThreshold` times in a row.
|
|
88
|
+
- **Consecutive stagnation detection** — flags the same call returning the same result `stagnationThreshold` times in a row.
|
|
89
|
+
- **Cycle detection** — detects when an agent alternates between the same sequences of calls and results (e.g. A B C A B C) without progressing.
|
|
90
|
+
- **Actionable agent feedback** — every blocked decision carries a programmatic `code`, a `suggestedAction` (`"stop"` or `"change_approach"`), and a plain-English `suggestionDetail` you can feed straight back into the agent's next prompt turn.
|
|
91
|
+
- **Deterministic, cycle-safe signatures** — call arguments and results are serialized using a stable stringifier that handles `Date`, typed arrays, `BigInt`, and circular references consistently. Signatures exceeding `maxSignatureLength` are hashed using SHA-256.
|
|
92
|
+
- **Framework-independent** — pure decision logic with no dependency on any particular agent runtime, model client, or tool-calling format.
|
|
93
|
+
- **Bounded signature memory** — `maxSignatureLength` collapses oversized signatures to a short hash before they're retained, so pathologically large arguments don't bloat guard state.
|
|
94
|
+
- **Zero runtime dependencies**, with TypeScript declarations for the [Programmatic API](#programmatic-api).
|
|
95
|
+
|
|
96
|
+
## Why & Who It's For
|
|
97
|
+
|
|
98
|
+
Agent harnesses that let a model call tools in a loop — Claude Code-style runtimes, custom agent frameworks, anything built around a `while` loop and a tool dispatcher — can get stuck without any of the individual steps looking obviously wrong. `agent-loop-guard` exists to catch that pattern early and hand back a next step, rather than letting the loop spin until a timeout or budget cutoff kills it.
|
|
99
|
+
|
|
100
|
+
Use it if you're building or operating a Node.js agent runtime and want tool-calling loop prevention and agent stagnation detection without adopting a specific agent framework; if you want Node.js agent reliability guardrails that are easy to unit test in isolation; or if you want blocked-call feedback that's already phrased for a model, not just a boolean.
|
|
101
|
+
|
|
102
|
+
**Why not just use max iterations?**
|
|
103
|
+
|
|
104
|
+
| Approach | Stops runaway loops | Explains cause | Framework independent |
|
|
105
|
+
| ------------------- | -------------------- | --------------- | ---------------------- |
|
|
106
|
+
| Max iterations | ✅ | ❌ | ✅ |
|
|
107
|
+
| Timeout | ✅ | ❌ | ✅ |
|
|
108
|
+
| `agent-loop-guard` | ✅ | ✅ | ✅ |
|
|
109
|
+
|
|
110
|
+
A hard limit protects your budget. `agent-loop-guard` also helps your agent understand what happened and choose a better next action.
|
|
111
|
+
|
|
112
|
+
## Installation
|
|
113
|
+
|
|
114
|
+
```bash
|
|
115
|
+
npm install agent-loop-guard
|
|
116
|
+
```
|
|
117
|
+
|
|
118
|
+
Requires **Node.js >= 18**. `agent-loop-guard` is ESM-only (`import`, not `require()`), matching its `package.json` `type: "module"` and `engines` field. Zero runtime dependencies.
|
|
119
|
+
|
|
120
|
+
**Using it from CommonJS:** `require("agent-loop-guard")` is intentionally unsupported — the package's `exports` map only declares an `import` condition, so `require()` fails with `ERR_PACKAGE_PATH_NOT_EXPORTED` regardless of Node version. A CommonJS file can still consume the package via dynamic `import()`, which works on all supported Node versions:
|
|
121
|
+
|
|
122
|
+
```js
|
|
123
|
+
// consumer.cjs
|
|
124
|
+
(async () => {
|
|
125
|
+
const { LoopGuard } = await import("agent-loop-guard");
|
|
126
|
+
const guard = new LoopGuard();
|
|
127
|
+
// ...
|
|
128
|
+
})();
|
|
129
|
+
```
|
|
130
|
+
|
|
131
|
+
## Usage
|
|
132
|
+
|
|
133
|
+
```js
|
|
134
|
+
import { LoopGuard } from "agent-loop-guard";
|
|
135
|
+
|
|
136
|
+
const guard = new LoopGuard({
|
|
137
|
+
maxCalls: 50,
|
|
138
|
+
repeatThreshold: 3,
|
|
139
|
+
stagnationThreshold: 2,
|
|
140
|
+
});
|
|
141
|
+
|
|
142
|
+
for (const step of agentSteps) {
|
|
143
|
+
const pre = guard.beforeCall(step.tool, step.args);
|
|
144
|
+
if (!pre.allowed) {
|
|
145
|
+
// budget exhausted, or this call would repeat too many times in a row
|
|
146
|
+
break;
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
const result = await callTool(step.tool, step.args);
|
|
150
|
+
|
|
151
|
+
const post = guard.afterCall(step.tool, step.args, result);
|
|
152
|
+
if (!post.allowed) {
|
|
153
|
+
// Budget exhausted, stagnant result, or repeated multi-step cycle.
|
|
154
|
+
break;
|
|
155
|
+
}
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
console.log(guard.summary());
|
|
159
|
+
```
|
|
160
|
+
|
|
161
|
+
Feed a blocked decision's `suggestionDetail` back into the conversation instead of aborting silently:
|
|
162
|
+
|
|
163
|
+
```js
|
|
164
|
+
if (!post.allowed) {
|
|
165
|
+
messages.push({
|
|
166
|
+
role: "user",
|
|
167
|
+
content: `System note: ${post.suggestionDetail}`,
|
|
168
|
+
});
|
|
169
|
+
}
|
|
170
|
+
```
|
|
171
|
+
|
|
172
|
+
## Architecture
|
|
173
|
+
|
|
174
|
+
``` text
|
|
175
|
+
Agent Runtime
|
|
176
|
+
|
|
|
177
|
+
v
|
|
178
|
+
beforeCall()
|
|
179
|
+
|
|
|
180
|
+
+---- allowed ----> Tool execution
|
|
181
|
+
|
|
|
182
|
+
+---- blocked ----> Guidance
|
|
183
|
+
|
|
|
184
|
+
v
|
|
185
|
+
Agent decides
|
|
186
|
+
|
|
187
|
+
Tool result
|
|
188
|
+
|
|
|
189
|
+
v
|
|
190
|
+
afterCall()
|
|
191
|
+
|
|
|
192
|
+
v
|
|
193
|
+
Loop detection
|
|
194
|
+
```
|
|
195
|
+
|
|
196
|
+
## Configuration
|
|
197
|
+
|
|
198
|
+
```js
|
|
199
|
+
new LoopGuard(options?)
|
|
200
|
+
```
|
|
201
|
+
|
|
202
|
+
| Option | Default | Description |
|
|
203
|
+
| --------------------- | -------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ |
|
|
204
|
+
| `maxCalls` | `50` | Hard ceiling on total tool calls in a session. |
|
|
205
|
+
| `repeatThreshold` | `3` | Consecutive identical calls (same tool + args) before blocking. Minimum `2`. |
|
|
206
|
+
| `stagnationThreshold` | `2` | Consecutive identical call+result pairs before flagging stagnation. Minimum `2`. |
|
|
207
|
+
| `maxSignatureLength` | `200000` | Signatures longer than this are hashed down before being retained in guard history. Values are still fully serialized first, so this bounds stored-signature size, not serialization time. |
|
|
208
|
+
| `maxCycleLength` | `0` | Max sequence length to track for cycle detection (0 = disabled). Maximum `1000` — see note below. |
|
|
209
|
+
| `cycleThreshold` | `2` | How many times a sequence must repeat to be flagged. Minimum `2`. |
|
|
210
|
+
| `signature` | `null` | Optional custom call signature function: `(toolName, args) => string`. |
|
|
211
|
+
| `resultSignature` | `null` | Optional custom result signature function: `(result) => string`. |
|
|
212
|
+
| `onDecision` | `null` | Optional observability hook: `(decision) => void`. Triggered on all `beforeCall` and `afterCall` decisions. |
|
|
213
|
+
|
|
214
|
+
`maxCalls` and `maxSignatureLength` must be positive integers. `maxCycleLength` must be `0` or an integer `≥ 2` and `≤ 1000`. Option thresholds (`repeatThreshold`, `stagnationThreshold`, `cycleThreshold`) must be integers `≥ 2`. Hooks can be functions or `null`. An unrecognized key or an out-of-range value throws a `TypeError` from the constructor.
|
|
215
|
+
|
|
216
|
+
`onDecision` is intended for observability only. If it throws, the error is ignored so logging or metrics failures do not crash the agent loop. To keep in-flight decisions stable, the same `LoopGuard` instance rejects `beforeCall()`, `afterCall()`, and `reset()` calls made from inside its own `onDecision` callback with a clear `Error`. A callback may still interact with a different `LoopGuard` instance.
|
|
217
|
+
|
|
218
|
+
> **Larger cycle windows cost more.** Cycle detection re-scans the retained call-history window on every `afterCall()`, at roughly `O(maxCycleLength² × cycleThreshold)` cost per call. Measured steady-state cost per call scales from ~0.03ms at `maxCycleLength: 10` to ~0.24ms at `maxCycleLength: 1000` to ~8ms at `maxCycleLength: 5000` — cost roughly quadruples as `maxCycleLength` doubles. `1000` is enforced as a hard ceiling to keep this bounded; pick the smallest `maxCycleLength` that covers the sequence lengths you actually expect to see in a stuck agent.
|
|
219
|
+
|
|
220
|
+
### Production payload guidance
|
|
221
|
+
|
|
222
|
+
`maxSignatureLength` limits the size of signatures retained in guard state, but the default serializer still has to inspect and serialize the full value before it can decide whether to hash it. Avoid passing unrestricted large or untrusted payloads directly as tool arguments or results. This is especially important for raw API responses, large buffers, deeply nested documents, user-controlled objects, or objects from unknown realms.
|
|
223
|
+
|
|
224
|
+
For production agents, prefer custom `signature` and `resultSignature` functions that select bounded, stable identifiers or fields that actually matter for loop detection:
|
|
225
|
+
|
|
226
|
+
```js
|
|
227
|
+
const guard = new LoopGuard({
|
|
228
|
+
signature(toolName, args) {
|
|
229
|
+
return JSON.stringify({
|
|
230
|
+
toolName,
|
|
231
|
+
accountId: args.accountId,
|
|
232
|
+
requestKind: args.requestKind,
|
|
233
|
+
pageToken: args.pageToken ?? null,
|
|
234
|
+
});
|
|
235
|
+
},
|
|
236
|
+
resultSignature(result) {
|
|
237
|
+
return JSON.stringify({
|
|
238
|
+
status: result.status,
|
|
239
|
+
nextPageToken: result.nextPageToken ?? null,
|
|
240
|
+
itemCount: Array.isArray(result.items) ? result.items.length : null,
|
|
241
|
+
});
|
|
242
|
+
},
|
|
243
|
+
});
|
|
244
|
+
```
|
|
245
|
+
|
|
246
|
+
Do not include timestamps, random IDs, raw response bodies, or full document contents unless those values are truly part of the loop identity. Also note that JavaScript `Proxy` traps may execute during default signature inspection because object keys and property descriptors must be read to reject ambiguous values safely.
|
|
247
|
+
|
|
248
|
+
## Example output
|
|
249
|
+
|
|
250
|
+
A blocked `beforeCall()` decision after three identical `read_file` calls in a row:
|
|
251
|
+
|
|
252
|
+
```js
|
|
253
|
+
{
|
|
254
|
+
allowed: false,
|
|
255
|
+
code: 'REPEATED_CALL',
|
|
256
|
+
reason: '"read_file" has been called with identical arguments 3 times in a row. The agent appears stuck in a loop.',
|
|
257
|
+
suggestedAction: 'change_approach',
|
|
258
|
+
suggestionDetail: 'Do not retry "read_file" with the same arguments. Either try different arguments, use a different tool to accomplish the same goal, or ask the user for clarification if the task is ambiguous.'
|
|
259
|
+
}
|
|
260
|
+
```
|
|
261
|
+
|
|
262
|
+
A clean `afterCall()` decision:
|
|
263
|
+
|
|
264
|
+
```js
|
|
265
|
+
{ allowed: true, code: 'OK', stagnant: false, reason: null, suggestedAction: null, suggestionDetail: null }
|
|
266
|
+
```
|
|
267
|
+
|
|
268
|
+
## Decision codes
|
|
269
|
+
|
|
270
|
+
Every decision returned by `beforeCall()` and `afterCall()` carries one of these `code` values:
|
|
271
|
+
|
|
272
|
+
| Code | Meaning | Returned from |
|
|
273
|
+
| --- | --- | --- |
|
|
274
|
+
| `OK` | The call is allowed — no loop pattern was detected. | `beforeCall()`, `afterCall()` |
|
|
275
|
+
| `BUDGET_EXHAUSTED` | The total call budget (`maxCalls`) has been used up for this run. | `beforeCall()`, `afterCall()` |
|
|
276
|
+
| `REPEATED_CALL` | The same tool has been called with identical arguments `repeatThreshold` times in a row. | `beforeCall()` |
|
|
277
|
+
| `STAGNANT_RESULT` | The same call has returned the same result `stagnationThreshold` times in a row — no progress is being made. | `afterCall()` |
|
|
278
|
+
| `CYCLE_DETECTED` | A repeating multi-step sequence of calls and results has repeated `cycleThreshold` times in a row. | `afterCall()` |
|
|
279
|
+
|
|
280
|
+
## Programmatic API
|
|
281
|
+
|
|
282
|
+
### `new LoopGuard(options?)`
|
|
283
|
+
|
|
284
|
+
Creates a guard instance. See [Configuration](#configuration) for the accepted options.
|
|
285
|
+
|
|
286
|
+
### `guard.beforeCall(toolName, args)` → `BeforeCallDecision`
|
|
287
|
+
|
|
288
|
+
Call before executing a tool. Checks the call budget, then whether this would be the Nth consecutive identical call.
|
|
289
|
+
|
|
290
|
+
### `guard.afterCall(toolName, args, result)` → `AfterCallDecision`
|
|
291
|
+
|
|
292
|
+
Call after executing a tool, passing its result. Records the call, updates counters, and checks for a repeating call+result stagnation or a repeating multi-step cycle pattern.
|
|
293
|
+
|
|
294
|
+
### `guard.summary()` → `LoopGuardSummary`
|
|
295
|
+
|
|
296
|
+
Returns `{ totalCalls, uniqueCallSignatures, budgetRemaining }` — useful for logging at the end of a run.
|
|
297
|
+
|
|
298
|
+
### `guard.reset()` → `void`
|
|
299
|
+
|
|
300
|
+
Clears all call history and counters so the same instance can be reused for a new run.
|
|
301
|
+
|
|
302
|
+
### `callSignature(toolName, args)`, `stableStringify(value)`, `hashSignature(input)`
|
|
303
|
+
|
|
304
|
+
The deterministic serialization primitives `LoopGuard` is built on, exported for advanced use — e.g. building a custom stagnation check outside of `LoopGuard` itself.
|
|
305
|
+
|
|
306
|
+
## TypeScript support
|
|
307
|
+
|
|
308
|
+
Type declarations ship in [`src/index.d.ts`](src/index.d.ts) and are resolved automatically via the package's `types` field — no separate `@types` package needed.
|
|
309
|
+
|
|
310
|
+
```ts
|
|
311
|
+
import type {
|
|
312
|
+
LoopGuardOptions,
|
|
313
|
+
GuardDecision,
|
|
314
|
+
SuggestedAction,
|
|
315
|
+
LoopGuardSummary,
|
|
316
|
+
} from "agent-loop-guard";
|
|
317
|
+
```
|
|
318
|
+
|
|
319
|
+
| Export | Kind | Notes |
|
|
320
|
+
| -------------------------------------------------------------------------- | --------- | -------------------------------------------------------------------- |
|
|
321
|
+
| `LoopGuard` | class | See [Programmatic API](#programmatic-api). |
|
|
322
|
+
| `callSignature`, `stableStringify`, `hashSignature` | functions | Serialization primitives; see [Programmatic API](#programmatic-api). |
|
|
323
|
+
| `LoopGuardOptions`, `GuardDecision`, `SuggestedAction`, `LoopGuardSummary` | types | Public shapes for the API above. |
|
|
324
|
+
|
|
325
|
+
> **Note:** call arguments and results follow different rules once serialized. Arguments passed to `beforeCall()` / `afterCall()` must fit the supported-value set below or the call throws a `TypeError`. Results passed to `afterCall()` are more forgiving — an unsupported result value (a function, `Map`, etc.) falls back to a stable per-guard identity signature instead of throwing.
|
|
326
|
+
|
|
327
|
+
**Supported values:** strings, booleans, numbers (`NaN`, `Infinity`, `-Infinity`, and `-0` are each encoded distinctly), `null`, `undefined`, `bigint`, plain objects and class instances (including non-enumerable own data properties), arrays (empty, sparse, and explicit-`undefined` elements are all distinguished), valid `Date` instances, typed arrays, `ArrayBuffer`, `URL`, `URLSearchParams`, and circular/self-referential structures.
|
|
328
|
+
|
|
329
|
+
**Rejected values** (throws `TypeError`): functions, symbols, symbol-keyed properties on ordinary objects (symbol-keyed state on supported built-ins is ignored), accessor properties — getters/setters are never invoked, even to reject them — `Map`, `Set`, `DataView`, invalid `Date` values, and nesting deeper than 100 levels. Unsupported native built-ins not explicitly listed as supported are also rejected.
|
|
330
|
+
|
|
331
|
+
## How it works
|
|
332
|
+
|
|
333
|
+
1. `beforeCall()` checks the call budget first, then whether the upcoming call would be the Nth consecutive identical call (same tool + arguments).
|
|
334
|
+
2. `afterCall()` records the call, computes a signature for the result, and checks whether the same call+result pair has now repeated `stagnationThreshold` times in a row, or if a longer sequence of call+result pairs has formed a cycle.
|
|
335
|
+
3. Both signatures come from `callSignature()`, a deterministic, cycle-safe serializer — identical inputs always produce identical signatures, regardless of key order or object identity.
|
|
336
|
+
4. A blocked decision from either method carries a `suggestedAction` and `suggestionDetail`, so the caller has a concrete next step rather than just a boolean.
|
|
337
|
+
5. `summary()` and `reset()` let you inspect and reuse a guard instance across runs without constructing a new one.
|
|
338
|
+
|
|
339
|
+
## Limitations
|
|
340
|
+
|
|
341
|
+
- In-memory, single-run state — nothing is persisted across process restarts, and a single `LoopGuard` instance isn't safe to share across concurrent runs unless you add that coordination yourself.
|
|
342
|
+
- It doesn't call tools, wrap a model client, or fix the loop for you (no retries, no backtracking) — it only tells you when to make that decision.
|
|
343
|
+
- Call arguments must serialize under the supported-value rules in [TypeScript support](#typescript-support); tool arguments that legitimately include functions, symbols, `Map`s, or `Set`s will throw rather than being silently approximated.
|
|
344
|
+
- Different user-defined classes with the same constructor name and identical own properties generate identical signatures. This is acceptable for most JSON-style tool arguments, but custom signature hooks can be used if stricter object-identity tracking is required.
|
|
345
|
+
|
|
346
|
+
## Security
|
|
347
|
+
|
|
348
|
+
`agent-loop-guard` has zero runtime dependencies and makes no network requests or file-system access of its own — it only inspects and serializes the tool names, arguments, and results you pass into `beforeCall()` / `afterCall()`. There is no telemetry and no external calls of any kind.
|
|
349
|
+
|
|
350
|
+
The main security-relevant surface is signature serialization itself: see [Production payload guidance](#production-payload-guidance) above for how to avoid passing unrestricted large or untrusted payloads directly as tool arguments or results, and the [rejected-values list](#typescript-support) for which value shapes are refused outright rather than serialized ambiguously.
|
|
351
|
+
|
|
352
|
+
To report a security issue, open an issue at the [repository's issue tracker](https://github.com/nextbridgehq/agent-loop-guard/issues).
|
|
353
|
+
|
|
354
|
+
## Troubleshooting
|
|
355
|
+
|
|
356
|
+
**`beforeCall()` or `afterCall()` throws a `TypeError` about an unsupported value.**
|
|
357
|
+
One of the tool arguments contains a function, symbol, `Map`, `Set`, `DataView`, or an accessor property (getter/setter). These are rejected rather than serialized ambiguously — see the rejected-values list in [TypeScript support](#typescript-support). This only applies to arguments; unsupported _results_ fall back to an identity signature instead of throwing.
|
|
358
|
+
|
|
359
|
+
**`repeatThreshold` isn't blocking a loop I can see happening.**
|
|
360
|
+
Repeats must be _consecutive_ — a single different call in between resets the counter. Check that the arguments are actually identical after serialization (e.g. object key order doesn't matter, but a differing timestamp or request ID in the arguments will).
|
|
361
|
+
|
|
362
|
+
**The guard's counts look wrong after a burst of calls.**
|
|
363
|
+
Call `guard.summary()` to inspect `totalCalls`, `uniqueCallSignatures`, and `budgetRemaining` directly, and confirm you're calling `afterCall()` — not just `beforeCall()` — for every executed call, since only `afterCall()` updates history.
|
|
364
|
+
|
|
365
|
+
## FAQ
|
|
366
|
+
|
|
367
|
+
**Does `agent-loop-guard` call my tools or my model for me?**
|
|
368
|
+
No — it's a pure decision layer. You call `beforeCall()` / `afterCall()` around your own tool-execution code.
|
|
369
|
+
|
|
370
|
+
**Does it work with LangChain, OpenAI, Claude, or other frameworks?**
|
|
371
|
+
Yes — it has no dependency on a specific agent runtime, model provider, or tool-calling format. Pass a tool name and its arguments; that's the whole contract.
|
|
372
|
+
|
|
373
|
+
**Does it try to fix a detected loop automatically?**
|
|
374
|
+
No — see [Limitations](#limitations). It flags the pattern and returns a suggestion; retrying with different arguments, switching tools, or asking the user is left to your agent's control loop.
|
|
375
|
+
|
|
376
|
+
## Contributing
|
|
377
|
+
|
|
378
|
+
Issues and pull requests are welcome.
|
|
379
|
+
|
|
380
|
+
1. Fork the repository.
|
|
381
|
+
2. Create a feature branch:
|
|
382
|
+
|
|
383
|
+
```bash
|
|
384
|
+
git checkout -b feature/my-change
|
|
385
|
+
```
|
|
386
|
+
|
|
387
|
+
3. Run checks:
|
|
388
|
+
|
|
389
|
+
```bash
|
|
390
|
+
npm run check
|
|
391
|
+
```
|
|
392
|
+
|
|
393
|
+
4. Open a pull request describing your change.
|
|
394
|
+
|
|
395
|
+
## License
|
|
396
|
+
|
|
397
|
+
[MIT](LICENSE) © [Nextbridge](https://www.nextbridge.com)
|
|
398
|
+
|
|
399
|
+
---
|
|
400
|
+
|
|
401
|
+
Built and maintained by **[Nextbridge](https://www.nextbridge.com)**
|
|
402
|
+
|
|
403
|
+
[GitHub](https://github.com/nextbridgehq/agent-loop-guard) · [npm](https://www.npmjs.com/package/agent-loop-guard) · [Issues](https://github.com/nextbridgehq/agent-loop-guard/issues) · [Changelog](CHANGELOG.md)
|
|
404
|
+
|
|
405
|
+
If this project helps you build safer AI agents, consider giving it a ⭐ on [GitHub](https://github.com/nextbridgehq/agent-loop-guard).
|
|
406
|
+
|
package/package.json
ADDED
|
@@ -0,0 +1,58 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "agent-loop-guard",
|
|
3
|
+
"version": "0.1.0",
|
|
4
|
+
"description": "Detect repeated, stagnant, and cyclic tool calls, and enforce call-budget limits for LLM agents",
|
|
5
|
+
"type": "module",
|
|
6
|
+
"main": "./src/index.js",
|
|
7
|
+
"exports": {
|
|
8
|
+
".": {
|
|
9
|
+
"types": "./src/index.d.ts",
|
|
10
|
+
"import": "./src/index.js"
|
|
11
|
+
}
|
|
12
|
+
},
|
|
13
|
+
"files": [
|
|
14
|
+
"src"
|
|
15
|
+
],
|
|
16
|
+
"scripts": {
|
|
17
|
+
"test": "node --test test/*.test.js",
|
|
18
|
+
"typecheck": "tsc --noEmit --strict --module NodeNext --moduleResolution NodeNext --target ES2022 test/consumer.ts",
|
|
19
|
+
"check": "npm run test && npm run typecheck",
|
|
20
|
+
"prepublishOnly": "npm run check"
|
|
21
|
+
},
|
|
22
|
+
"keywords": [
|
|
23
|
+
"llm",
|
|
24
|
+
"agent",
|
|
25
|
+
"loop-detection",
|
|
26
|
+
"tool-calling",
|
|
27
|
+
"ai-agents",
|
|
28
|
+
"autonomous-agents",
|
|
29
|
+
"cycle-detection",
|
|
30
|
+
"agent-safety",
|
|
31
|
+
"typescript",
|
|
32
|
+
"nodejs"
|
|
33
|
+
],
|
|
34
|
+
"license": "MIT",
|
|
35
|
+
"engines": {
|
|
36
|
+
"node": ">=18"
|
|
37
|
+
},
|
|
38
|
+
"directories": {
|
|
39
|
+
"test": "test"
|
|
40
|
+
},
|
|
41
|
+
"author": "Nextbridge",
|
|
42
|
+
"repository": {
|
|
43
|
+
"type": "git",
|
|
44
|
+
"url": "git+https://github.com/nextbridgehq/agent-loop-guard.git"
|
|
45
|
+
},
|
|
46
|
+
"bugs": {
|
|
47
|
+
"url": "https://github.com/nextbridgehq/agent-loop-guard/issues"
|
|
48
|
+
},
|
|
49
|
+
"homepage": "https://github.com/nextbridgehq/agent-loop-guard#readme",
|
|
50
|
+
"publishConfig": {
|
|
51
|
+
"access": "public"
|
|
52
|
+
},
|
|
53
|
+
"sideEffects": false,
|
|
54
|
+
"types": "src/index.d.ts",
|
|
55
|
+
"devDependencies": {
|
|
56
|
+
"typescript": "^7.0.2"
|
|
57
|
+
}
|
|
58
|
+
}
|
package/src/index.d.ts
ADDED
|
@@ -0,0 +1,76 @@
|
|
|
1
|
+
export function stableStringify(value: unknown): string;
|
|
2
|
+
export function hashSignature(input: string): string;
|
|
3
|
+
export function callSignature(name: string, args: unknown): string;
|
|
4
|
+
|
|
5
|
+
export type SuggestedAction = "stop" | "change_approach";
|
|
6
|
+
export type DecisionCode = "OK" | "BUDGET_EXHAUSTED" | "REPEATED_CALL" | "STAGNANT_RESULT" | "CYCLE_DETECTED";
|
|
7
|
+
|
|
8
|
+
export type BeforeCallDecision =
|
|
9
|
+
| {
|
|
10
|
+
allowed: true;
|
|
11
|
+
code: "OK";
|
|
12
|
+
reason: null;
|
|
13
|
+
suggestedAction: null;
|
|
14
|
+
suggestionDetail: null;
|
|
15
|
+
}
|
|
16
|
+
| {
|
|
17
|
+
allowed: false;
|
|
18
|
+
code: "BUDGET_EXHAUSTED" | "REPEATED_CALL";
|
|
19
|
+
reason: string;
|
|
20
|
+
suggestedAction: SuggestedAction;
|
|
21
|
+
suggestionDetail: string;
|
|
22
|
+
};
|
|
23
|
+
|
|
24
|
+
export type AfterCallDecision =
|
|
25
|
+
| {
|
|
26
|
+
allowed: true;
|
|
27
|
+
code: "OK";
|
|
28
|
+
stagnant: false;
|
|
29
|
+
reason: null;
|
|
30
|
+
suggestedAction: null;
|
|
31
|
+
suggestionDetail: null;
|
|
32
|
+
}
|
|
33
|
+
| {
|
|
34
|
+
allowed: false;
|
|
35
|
+
code: "STAGNANT_RESULT";
|
|
36
|
+
stagnant: true;
|
|
37
|
+
reason: string;
|
|
38
|
+
suggestedAction: "change_approach";
|
|
39
|
+
suggestionDetail: string;
|
|
40
|
+
}
|
|
41
|
+
| {
|
|
42
|
+
allowed: false;
|
|
43
|
+
code: "BUDGET_EXHAUSTED" | "CYCLE_DETECTED";
|
|
44
|
+
stagnant: false;
|
|
45
|
+
reason: string;
|
|
46
|
+
suggestedAction: SuggestedAction;
|
|
47
|
+
suggestionDetail: string;
|
|
48
|
+
};
|
|
49
|
+
|
|
50
|
+
export type GuardDecision = BeforeCallDecision | AfterCallDecision;
|
|
51
|
+
|
|
52
|
+
export interface LoopGuardOptions {
|
|
53
|
+
maxCalls?: number;
|
|
54
|
+
repeatThreshold?: number;
|
|
55
|
+
stagnationThreshold?: number;
|
|
56
|
+
maxSignatureLength?: number;
|
|
57
|
+
maxCycleLength?: number;
|
|
58
|
+
cycleThreshold?: number;
|
|
59
|
+
signature?: ((toolName: string, args: unknown) => string) | null;
|
|
60
|
+
resultSignature?: ((result: unknown) => string) | null;
|
|
61
|
+
onDecision?: ((decision: GuardDecision) => void) | null;
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
export interface LoopGuardSummary {
|
|
65
|
+
totalCalls: number;
|
|
66
|
+
uniqueCallSignatures: number;
|
|
67
|
+
budgetRemaining: number;
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
export class LoopGuard {
|
|
71
|
+
constructor(options?: LoopGuardOptions);
|
|
72
|
+
beforeCall(name: string, args: unknown): BeforeCallDecision;
|
|
73
|
+
afterCall(name: string, args: unknown, result: unknown): AfterCallDecision;
|
|
74
|
+
summary(): LoopGuardSummary;
|
|
75
|
+
reset(): void;
|
|
76
|
+
}
|
package/src/index.js
ADDED
|
@@ -0,0 +1,383 @@
|
|
|
1
|
+
import { callSignature, hashSignature } from "./signature.js";
|
|
2
|
+
|
|
3
|
+
const DEFAULT_OPTIONS = {
|
|
4
|
+
maxCalls: 50, // hard ceiling on total tool calls in a session
|
|
5
|
+
repeatThreshold: 3, // consecutive identical calls (same tool+args) before flagging
|
|
6
|
+
stagnationThreshold: 2, // identical (tool+args+result) pairs before flagging "no progress"
|
|
7
|
+
maxSignatureLength: 200_000, // signatures longer than this are hashed down before storage
|
|
8
|
+
maxCycleLength: 0, // max sequence length to track for cycle detection (0 = disabled, max 1000 — see MAX_CYCLE_LENGTH)
|
|
9
|
+
cycleThreshold: 2, // how many times a sequence must repeat to be flagged
|
|
10
|
+
signature: null, // optional custom call signature function
|
|
11
|
+
resultSignature: null, // optional custom result signature function
|
|
12
|
+
onDecision: null, // optional observability hook: (decision) => void
|
|
13
|
+
};
|
|
14
|
+
|
|
15
|
+
function validateToolName(toolName) {
|
|
16
|
+
if (typeof toolName !== "string" || toolName.length === 0) {
|
|
17
|
+
throw new TypeError("toolName must be a non-empty string.");
|
|
18
|
+
}
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
function sameCycleEntry(left, right) {
|
|
22
|
+
return (
|
|
23
|
+
left.callSignature === right.callSignature &&
|
|
24
|
+
left.resultSignature === right.resultSignature
|
|
25
|
+
);
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
const MIN_REPEAT_THRESHOLD = 2;
|
|
29
|
+
const MIN_STAGNATION_THRESHOLD = 2;
|
|
30
|
+
const MIN_CYCLE_THRESHOLD = 2;
|
|
31
|
+
|
|
32
|
+
// Cycle detection re-scans the retained call-history window on every afterCall(),
|
|
33
|
+
// at O(maxCycleLength^2 * cycleThreshold) cost. 1000 keeps steady-state cost per
|
|
34
|
+
// call under ~0.25ms (measured), which stays negligible even across an agent run
|
|
35
|
+
// of thousands of calls, while comfortably covering realistic loop lengths. See
|
|
36
|
+
// the README's Configuration section for the measured cost table.
|
|
37
|
+
const MAX_CYCLE_LENGTH = 1000;
|
|
38
|
+
|
|
39
|
+
/**
|
|
40
|
+
* LoopGuard tracks an agent's tool-calling history within a single run
|
|
41
|
+
* and flags patterns that indicate the agent is stuck: calling the same
|
|
42
|
+
* tool with the same args repeatedly, or getting the same result back
|
|
43
|
+
* without making progress.
|
|
44
|
+
*
|
|
45
|
+
* It does not call tools itself — it's a pure decision layer you check
|
|
46
|
+
* before and after each tool call.
|
|
47
|
+
*/
|
|
48
|
+
export class LoopGuard {
|
|
49
|
+
#deliveringDecision = false;
|
|
50
|
+
|
|
51
|
+
constructor(options = {}) {
|
|
52
|
+
validateOptions(options);
|
|
53
|
+
this.options = { ...DEFAULT_OPTIONS, ...options };
|
|
54
|
+
this.totalCalls = 0;
|
|
55
|
+
this.uniqueSignatures = new Set();
|
|
56
|
+
this.consecutiveRepeatCount = 0;
|
|
57
|
+
this.lastSignature = null;
|
|
58
|
+
this.lastResultCallSignature = null;
|
|
59
|
+
this.lastResultSignature = null;
|
|
60
|
+
this.consecutiveResultCount = 0;
|
|
61
|
+
this.unsupportedObjectIds = new WeakMap();
|
|
62
|
+
this.unsupportedSymbolIds = new Map();
|
|
63
|
+
this.nextUnsupportedId = 1;
|
|
64
|
+
this.callHistory = [];
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
#assertNotDeliveringDecision(methodName) {
|
|
68
|
+
if (this.#deliveringDecision) {
|
|
69
|
+
throw new Error(
|
|
70
|
+
`LoopGuard ${methodName}() cannot be called from the same instance while onDecision is running.`
|
|
71
|
+
);
|
|
72
|
+
}
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
#decision(result) {
|
|
76
|
+
if (typeof this.options.onDecision === "function") {
|
|
77
|
+
this.#deliveringDecision = true;
|
|
78
|
+
try {
|
|
79
|
+
this.options.onDecision(Object.freeze({ ...result }));
|
|
80
|
+
} catch (err) {
|
|
81
|
+
// Ignore observability errors to prevent crashing the agent loop
|
|
82
|
+
} finally {
|
|
83
|
+
this.#deliveringDecision = false;
|
|
84
|
+
}
|
|
85
|
+
}
|
|
86
|
+
return result;
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
/**
|
|
90
|
+
* Call this BEFORE executing a tool call. Returns whether the call
|
|
91
|
+
* should be allowed to proceed.
|
|
92
|
+
*/
|
|
93
|
+
beforeCall(toolName, args) {
|
|
94
|
+
this.#assertNotDeliveringDecision("beforeCall");
|
|
95
|
+
validateToolName(toolName);
|
|
96
|
+
|
|
97
|
+
if (this.totalCalls >= this.options.maxCalls) {
|
|
98
|
+
return this.#decision({
|
|
99
|
+
allowed: false,
|
|
100
|
+
code: "BUDGET_EXHAUSTED",
|
|
101
|
+
reason: `Call budget exhausted (${this.totalCalls}/${this.options.maxCalls} calls used).`,
|
|
102
|
+
suggestedAction: "stop",
|
|
103
|
+
suggestionDetail: "End the run and surface a summary to the user rather than continuing — the budget exists to prevent runaway cost/time, not to be silently raised.",
|
|
104
|
+
});
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
const signature = this.#boundedSignature(toolName, args);
|
|
108
|
+
|
|
109
|
+
if (signature === this.lastSignature) {
|
|
110
|
+
const projectedRepeats = this.consecutiveRepeatCount + 1;
|
|
111
|
+
if (projectedRepeats >= this.options.repeatThreshold) {
|
|
112
|
+
return this.#decision({
|
|
113
|
+
allowed: false,
|
|
114
|
+
code: "REPEATED_CALL",
|
|
115
|
+
reason: `"${toolName}" has been called with identical arguments ${projectedRepeats} times in a row. The agent appears stuck in a loop.`,
|
|
116
|
+
suggestedAction: "change_approach",
|
|
117
|
+
suggestionDetail: `Do not retry "${toolName}" with the same arguments. Either try different arguments, use a different tool to accomplish the same goal, or ask the user for clarification if the task is ambiguous.`,
|
|
118
|
+
});
|
|
119
|
+
}
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
return this.#decision({ allowed: true, code: "OK", reason: null, suggestedAction: null, suggestionDetail: null });
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
/**
|
|
126
|
+
* Call this AFTER executing a tool call, passing the result. Updates
|
|
127
|
+
* internal history and returns whether budget is exhausted, a stagnation
|
|
128
|
+
* pattern was detected (same call + same result repeating), or a
|
|
129
|
+
* multi-step cycle was detected.
|
|
130
|
+
*/
|
|
131
|
+
afterCall(toolName, args, result) {
|
|
132
|
+
this.#assertNotDeliveringDecision("afterCall");
|
|
133
|
+
validateToolName(toolName);
|
|
134
|
+
|
|
135
|
+
if (this.totalCalls >= this.options.maxCalls) {
|
|
136
|
+
return this.#decision({
|
|
137
|
+
allowed: false,
|
|
138
|
+
code: "BUDGET_EXHAUSTED",
|
|
139
|
+
stagnant: false,
|
|
140
|
+
reason: `Call budget exhausted (${this.options.maxCalls} calls used).`,
|
|
141
|
+
suggestedAction: "stop",
|
|
142
|
+
suggestionDetail: "End the run and surface a summary to the user rather than continuing — the budget exists to prevent runaway cost/time.",
|
|
143
|
+
});
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
this.totalCalls += 1;
|
|
147
|
+
|
|
148
|
+
const signature = this.#boundedSignature(toolName, args);
|
|
149
|
+
const resultSignature = this.#safeResultSignature(result);
|
|
150
|
+
|
|
151
|
+
if (signature === this.lastSignature) {
|
|
152
|
+
this.consecutiveRepeatCount += 1;
|
|
153
|
+
} else {
|
|
154
|
+
this.consecutiveRepeatCount = 1;
|
|
155
|
+
}
|
|
156
|
+
this.lastSignature = signature;
|
|
157
|
+
|
|
158
|
+
this.uniqueSignatures.add(signature);
|
|
159
|
+
|
|
160
|
+
const stagnantCount =
|
|
161
|
+
signature === this.lastResultCallSignature && resultSignature === this.lastResultSignature
|
|
162
|
+
? this.consecutiveResultCount + 1
|
|
163
|
+
: 1;
|
|
164
|
+
|
|
165
|
+
this.lastResultCallSignature = signature;
|
|
166
|
+
this.lastResultSignature = resultSignature;
|
|
167
|
+
this.consecutiveResultCount = stagnantCount;
|
|
168
|
+
|
|
169
|
+
if (this.options.maxCycleLength > 0) {
|
|
170
|
+
const entry = Object.freeze({
|
|
171
|
+
callSignature: signature,
|
|
172
|
+
resultSignature,
|
|
173
|
+
});
|
|
174
|
+
this.callHistory.push(entry);
|
|
175
|
+
const historyLimit = this.options.maxCycleLength * this.options.cycleThreshold;
|
|
176
|
+
if (this.callHistory.length > historyLimit) {
|
|
177
|
+
this.callHistory.shift();
|
|
178
|
+
}
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
if (stagnantCount >= this.options.stagnationThreshold) {
|
|
182
|
+
return this.#decision({
|
|
183
|
+
allowed: false,
|
|
184
|
+
code: "STAGNANT_RESULT",
|
|
185
|
+
stagnant: true,
|
|
186
|
+
reason: `"${toolName}" has returned the same result ${stagnantCount} times for the same arguments — the agent is not making progress and should change strategy.`,
|
|
187
|
+
suggestedAction: "change_approach",
|
|
188
|
+
suggestionDetail: `The last ${stagnantCount} calls to "${toolName}" with these arguments all returned the same result. Repeating it again won't help — try a different tool, different arguments, or escalate to the user with what's been tried so far.`,
|
|
189
|
+
});
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
if (this.options.maxCycleLength > 0) {
|
|
193
|
+
const maxDetectableLength = Math.min(
|
|
194
|
+
this.options.maxCycleLength,
|
|
195
|
+
Math.floor(this.callHistory.length / this.options.cycleThreshold)
|
|
196
|
+
);
|
|
197
|
+
|
|
198
|
+
for (let len = 2; len <= maxDetectableLength; len++) {
|
|
199
|
+
const cycleThreshold = this.options.cycleThreshold;
|
|
200
|
+
const sequence = this.callHistory.slice(this.callHistory.length - len);
|
|
201
|
+
|
|
202
|
+
const isLengthOnePattern = sequence.every(
|
|
203
|
+
(entry) => sameCycleEntry(entry, sequence[0])
|
|
204
|
+
);
|
|
205
|
+
if (isLengthOnePattern) {
|
|
206
|
+
continue;
|
|
207
|
+
}
|
|
208
|
+
|
|
209
|
+
let isCycle = true;
|
|
210
|
+
for (let i = 1; i < cycleThreshold; i++) {
|
|
211
|
+
const offset = this.callHistory.length - len * (i + 1);
|
|
212
|
+
for (let j = 0; j < len; j++) {
|
|
213
|
+
if (!sameCycleEntry(this.callHistory[offset + j], sequence[j])) {
|
|
214
|
+
isCycle = false;
|
|
215
|
+
break;
|
|
216
|
+
}
|
|
217
|
+
}
|
|
218
|
+
if (!isCycle) break;
|
|
219
|
+
}
|
|
220
|
+
if (isCycle) {
|
|
221
|
+
return this.#decision({
|
|
222
|
+
allowed: false,
|
|
223
|
+
code: "CYCLE_DETECTED",
|
|
224
|
+
stagnant: false,
|
|
225
|
+
reason: `A repeating sequence of tool calls (length ${len}) has been detected ${cycleThreshold} times in a row.`,
|
|
226
|
+
suggestedAction: "change_approach",
|
|
227
|
+
suggestionDetail: `The agent is alternating between the same set of tool calls without progressing. Review the recent steps and break the loop by taking a different action or escalating to the user.`,
|
|
228
|
+
});
|
|
229
|
+
}
|
|
230
|
+
}
|
|
231
|
+
}
|
|
232
|
+
|
|
233
|
+
return this.#decision({ allowed: true, code: "OK", stagnant: false, reason: null, suggestedAction: null, suggestionDetail: null });
|
|
234
|
+
}
|
|
235
|
+
|
|
236
|
+
/** Returns a simple summary, useful for logging at the end of a run. */
|
|
237
|
+
summary() {
|
|
238
|
+
return {
|
|
239
|
+
totalCalls: this.totalCalls,
|
|
240
|
+
uniqueCallSignatures: this.uniqueSignatures.size,
|
|
241
|
+
budgetRemaining: Math.max(0, this.options.maxCalls - this.totalCalls),
|
|
242
|
+
};
|
|
243
|
+
}
|
|
244
|
+
|
|
245
|
+
reset() {
|
|
246
|
+
this.#assertNotDeliveringDecision("reset");
|
|
247
|
+
this.totalCalls = 0;
|
|
248
|
+
this.uniqueSignatures = new Set();
|
|
249
|
+
this.consecutiveRepeatCount = 0;
|
|
250
|
+
this.lastSignature = null;
|
|
251
|
+
this.lastResultCallSignature = null;
|
|
252
|
+
this.lastResultSignature = null;
|
|
253
|
+
this.consecutiveResultCount = 0;
|
|
254
|
+
this.unsupportedObjectIds = new WeakMap();
|
|
255
|
+
this.unsupportedSymbolIds = new Map();
|
|
256
|
+
this.nextUnsupportedId = 1;
|
|
257
|
+
this.callHistory = [];
|
|
258
|
+
}
|
|
259
|
+
|
|
260
|
+
/**
|
|
261
|
+
* Builds a call signature and, if it exceeds maxSignatureLength,
|
|
262
|
+
* collapses it to a short hash so pathologically large arguments
|
|
263
|
+
* don't bloat retained history or comparisons.
|
|
264
|
+
*/
|
|
265
|
+
#boundedSignature(toolName, args) {
|
|
266
|
+
let signature;
|
|
267
|
+
if (typeof this.options.signature === "function") {
|
|
268
|
+
signature = this.options.signature(toolName, args);
|
|
269
|
+
if (typeof signature !== "string") {
|
|
270
|
+
throw new TypeError("Custom signature function must return a string.");
|
|
271
|
+
}
|
|
272
|
+
} else {
|
|
273
|
+
signature = callSignature(toolName, args);
|
|
274
|
+
}
|
|
275
|
+
|
|
276
|
+
if (signature.length <= this.options.maxSignatureLength) {
|
|
277
|
+
return signature;
|
|
278
|
+
}
|
|
279
|
+
const toolPreview = toolName.slice(0, 40);
|
|
280
|
+
return `$tool:${JSON.stringify(toolPreview)}:$hash:${hashSignature(signature)}:len${signature.length}`;
|
|
281
|
+
}
|
|
282
|
+
|
|
283
|
+
/**
|
|
284
|
+
* Computes a signature for a tool result, handling built-in serialization
|
|
285
|
+
* failures (unsupported values like functions) safely by returning an identity string.
|
|
286
|
+
* Note: if a custom resultSignature function is provided, errors from it will propagate.
|
|
287
|
+
*/
|
|
288
|
+
#safeResultSignature(result) {
|
|
289
|
+
let signature;
|
|
290
|
+
if (typeof this.options.resultSignature === "function") {
|
|
291
|
+
signature = this.options.resultSignature(result);
|
|
292
|
+
if (typeof signature !== "string") {
|
|
293
|
+
throw new TypeError("Custom resultSignature function must return a string.");
|
|
294
|
+
}
|
|
295
|
+
} else {
|
|
296
|
+
try {
|
|
297
|
+
signature = callSignature("result", result);
|
|
298
|
+
} catch {
|
|
299
|
+
return this.#unsupportedResultSignature(result);
|
|
300
|
+
}
|
|
301
|
+
}
|
|
302
|
+
|
|
303
|
+
if (signature.length <= this.options.maxSignatureLength) {
|
|
304
|
+
return signature;
|
|
305
|
+
}
|
|
306
|
+
return `$hash:${hashSignature(signature)}:len${signature.length}`;
|
|
307
|
+
}
|
|
308
|
+
|
|
309
|
+
#unsupportedResultSignature(result) {
|
|
310
|
+
if ((typeof result === "object" && result !== null) || typeof result === "function") {
|
|
311
|
+
let id = this.unsupportedObjectIds.get(result);
|
|
312
|
+
if (id === undefined) {
|
|
313
|
+
id = this.nextUnsupportedId++;
|
|
314
|
+
this.unsupportedObjectIds.set(result, id);
|
|
315
|
+
}
|
|
316
|
+
return `$unsupported:${typeof result}:id${id}`;
|
|
317
|
+
}
|
|
318
|
+
|
|
319
|
+
// typeof result === "symbol" — the only remaining case reachable here.
|
|
320
|
+
// stableStringify() only ever throws for object/function/symbol values,
|
|
321
|
+
// so every other primitive returns from the branch above without erroring.
|
|
322
|
+
let id = this.unsupportedSymbolIds.get(result);
|
|
323
|
+
if (id === undefined) {
|
|
324
|
+
id = this.nextUnsupportedId++;
|
|
325
|
+
this.unsupportedSymbolIds.set(result, id);
|
|
326
|
+
}
|
|
327
|
+
return `$unsupported:symbol:id${id}`;
|
|
328
|
+
}
|
|
329
|
+
}
|
|
330
|
+
|
|
331
|
+
function validateOptions(options) {
|
|
332
|
+
if (options === null || typeof options !== "object" || Array.isArray(options)) {
|
|
333
|
+
throw new TypeError("LoopGuard options must be an object.");
|
|
334
|
+
}
|
|
335
|
+
|
|
336
|
+
const ownOption = (key) =>
|
|
337
|
+
Object.hasOwn(options, key) ? options[key] : undefined;
|
|
338
|
+
|
|
339
|
+
for (const key of Object.keys(options)) {
|
|
340
|
+
if (!Object.hasOwn(DEFAULT_OPTIONS, key)) {
|
|
341
|
+
throw new TypeError(`Unknown LoopGuard option: ${key}.`);
|
|
342
|
+
}
|
|
343
|
+
if (key === "signature" || key === "resultSignature" || key === "onDecision") {
|
|
344
|
+
if (options[key] !== null && typeof options[key] !== "function") {
|
|
345
|
+
throw new TypeError(`LoopGuard option ${key} must be a function or null.`);
|
|
346
|
+
}
|
|
347
|
+
continue;
|
|
348
|
+
}
|
|
349
|
+
if (!Number.isInteger(options[key]) || options[key] < 0) {
|
|
350
|
+
throw new TypeError(`LoopGuard option ${key} must be a non-negative integer.`);
|
|
351
|
+
}
|
|
352
|
+
if (options[key] === 0 && key !== "maxCycleLength") {
|
|
353
|
+
throw new TypeError(`LoopGuard option ${key} must be a positive integer.`);
|
|
354
|
+
}
|
|
355
|
+
}
|
|
356
|
+
|
|
357
|
+
const repeatThreshold = ownOption("repeatThreshold");
|
|
358
|
+
const stagnationThreshold = ownOption("stagnationThreshold");
|
|
359
|
+
const cycleThreshold = ownOption("cycleThreshold");
|
|
360
|
+
const maxCycleLength = ownOption("maxCycleLength");
|
|
361
|
+
|
|
362
|
+
if (repeatThreshold !== undefined && repeatThreshold < MIN_REPEAT_THRESHOLD) {
|
|
363
|
+
throw new TypeError(`LoopGuard option repeatThreshold must be at least ${MIN_REPEAT_THRESHOLD}.`);
|
|
364
|
+
}
|
|
365
|
+
|
|
366
|
+
if (stagnationThreshold !== undefined && stagnationThreshold < MIN_STAGNATION_THRESHOLD) {
|
|
367
|
+
throw new TypeError(`LoopGuard option stagnationThreshold must be at least ${MIN_STAGNATION_THRESHOLD}.`);
|
|
368
|
+
}
|
|
369
|
+
|
|
370
|
+
if (cycleThreshold !== undefined && cycleThreshold < MIN_CYCLE_THRESHOLD) {
|
|
371
|
+
throw new TypeError(`LoopGuard option cycleThreshold must be at least ${MIN_CYCLE_THRESHOLD}.`);
|
|
372
|
+
}
|
|
373
|
+
|
|
374
|
+
if (maxCycleLength !== undefined && maxCycleLength !== 0 && maxCycleLength < 2) {
|
|
375
|
+
throw new TypeError(`LoopGuard option maxCycleLength must be 0 or at least 2.`);
|
|
376
|
+
}
|
|
377
|
+
|
|
378
|
+
if (maxCycleLength !== undefined && maxCycleLength > MAX_CYCLE_LENGTH) {
|
|
379
|
+
throw new TypeError(
|
|
380
|
+
`LoopGuard option maxCycleLength must not exceed ${MAX_CYCLE_LENGTH} (cycle detection cost scales roughly with maxCycleLength² × cycleThreshold per call — see the README's Configuration section).`
|
|
381
|
+
);
|
|
382
|
+
}
|
|
383
|
+
}
|
package/src/signature.js
ADDED
|
@@ -0,0 +1,254 @@
|
|
|
1
|
+
import { createHash } from "node:crypto";
|
|
2
|
+
import { types } from "node:util";
|
|
3
|
+
|
|
4
|
+
const MAX_SERIALIZATION_DEPTH = 100;
|
|
5
|
+
|
|
6
|
+
/**
|
|
7
|
+
* Produces a deterministic, cycle-safe signature for a tool call.
|
|
8
|
+
* Unsupported values are rejected rather than collapsed into an ambiguous value.
|
|
9
|
+
*/
|
|
10
|
+
export function callSignature(toolName, args) {
|
|
11
|
+
if (typeof toolName !== "string") {
|
|
12
|
+
throw new TypeError("toolName must be a string.");
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
return `${JSON.stringify(toolName)}::${stableStringify(args)}`;
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
const KNOWN_NATIVES = new Set([
|
|
19
|
+
"Object", "Array", "Date", "RegExp", "Map", "Set",
|
|
20
|
+
"Int8Array", "Uint8Array", "Uint8ClampedArray", "Int16Array", "Uint16Array",
|
|
21
|
+
"Int32Array", "Uint32Array", "Float32Array", "Float64Array", "BigInt64Array", "BigUint64Array",
|
|
22
|
+
"ArrayBuffer", "DataView"
|
|
23
|
+
]);
|
|
24
|
+
|
|
25
|
+
function trySerializeURL(value) {
|
|
26
|
+
try {
|
|
27
|
+
return URL.prototype.toString.call(value);
|
|
28
|
+
} catch {
|
|
29
|
+
return null;
|
|
30
|
+
}
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
function trySerializeURLSearchParams(value) {
|
|
34
|
+
try {
|
|
35
|
+
return URLSearchParams.prototype.toString.call(value);
|
|
36
|
+
} catch {
|
|
37
|
+
return null;
|
|
38
|
+
}
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
function findToStringTagDescriptor(prototype) {
|
|
42
|
+
let current = prototype;
|
|
43
|
+
|
|
44
|
+
while (current !== null) {
|
|
45
|
+
const descriptor = Object.getOwnPropertyDescriptor(
|
|
46
|
+
current,
|
|
47
|
+
Symbol.toStringTag
|
|
48
|
+
);
|
|
49
|
+
|
|
50
|
+
if (descriptor) {
|
|
51
|
+
return descriptor;
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
current = Object.getPrototypeOf(current);
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
return null;
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
export function stableStringify(value) {
|
|
61
|
+
const seen = new WeakMap();
|
|
62
|
+
let nextId = 1;
|
|
63
|
+
|
|
64
|
+
function encode(current, path, depth) {
|
|
65
|
+
if (depth > MAX_SERIALIZATION_DEPTH) {
|
|
66
|
+
throw new TypeError(
|
|
67
|
+
`Value at ${path} exceeds the maximum supported nesting depth of ${MAX_SERIALIZATION_DEPTH}.`
|
|
68
|
+
);
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
if (current === null) return "null";
|
|
72
|
+
|
|
73
|
+
switch (typeof current) {
|
|
74
|
+
case "undefined":
|
|
75
|
+
return '{"$type":"undefined"}';
|
|
76
|
+
case "string":
|
|
77
|
+
case "boolean":
|
|
78
|
+
return JSON.stringify(current);
|
|
79
|
+
case "number":
|
|
80
|
+
if (Number.isNaN(current)) return '{"$type":"number","value":"NaN"}';
|
|
81
|
+
if (current === Infinity) return '{"$type":"number","value":"Infinity"}';
|
|
82
|
+
if (current === -Infinity) return '{"$type":"number","value":"-Infinity"}';
|
|
83
|
+
if (Object.is(current, -0)) return '{"$type":"number","value":"-0"}';
|
|
84
|
+
return JSON.stringify(current);
|
|
85
|
+
case "bigint":
|
|
86
|
+
return `{"$type":"bigint","value":${JSON.stringify(current.toString())}}`;
|
|
87
|
+
case "function":
|
|
88
|
+
case "symbol":
|
|
89
|
+
throw new TypeError(`Unsupported ${typeof current} value at ${path}.`);
|
|
90
|
+
case "object":
|
|
91
|
+
break;
|
|
92
|
+
default:
|
|
93
|
+
throw new TypeError(`Unsupported value at ${path}.`);
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
const existingId = seen.get(current);
|
|
97
|
+
if (existingId !== undefined) return `{"$ref":${existingId}}`;
|
|
98
|
+
|
|
99
|
+
const id = nextId++;
|
|
100
|
+
seen.set(current, id);
|
|
101
|
+
|
|
102
|
+
const prototype = Object.getPrototypeOf(current);
|
|
103
|
+
|
|
104
|
+
let constructorName = "Object";
|
|
105
|
+
let constructorFunction = Object;
|
|
106
|
+
|
|
107
|
+
if (prototype !== null && prototype !== Object.prototype) {
|
|
108
|
+
const descriptor = Object.getOwnPropertyDescriptor(prototype, "constructor");
|
|
109
|
+
|
|
110
|
+
if (
|
|
111
|
+
!descriptor ||
|
|
112
|
+
!("value" in descriptor) ||
|
|
113
|
+
typeof descriptor.value !== "function"
|
|
114
|
+
) {
|
|
115
|
+
throw new TypeError(
|
|
116
|
+
`Unsupported object prototype at ${path}.`
|
|
117
|
+
);
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
constructorFunction = descriptor.value;
|
|
121
|
+
constructorName = constructorFunction.name;
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
if (!constructorName) {
|
|
125
|
+
throw new TypeError(`Unsupported object value at ${path}.`);
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
if (typeof constructorFunction === "function") {
|
|
129
|
+
const ctorStr = Function.prototype.toString.call(constructorFunction);
|
|
130
|
+
if (ctorStr.includes("[native code]")) {
|
|
131
|
+
if (!KNOWN_NATIVES.has(constructorName)) {
|
|
132
|
+
throw new TypeError(`Unsupported native built-in ${constructorName} at ${path}.`);
|
|
133
|
+
}
|
|
134
|
+
}
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
const ownKeys = Reflect.ownKeys(current);
|
|
138
|
+
const stringKeys = ownKeys.filter((key) => typeof key === "string");
|
|
139
|
+
|
|
140
|
+
function validateDataProperties(obj, keys, currentPath) {
|
|
141
|
+
for (const key of keys) {
|
|
142
|
+
const descriptor = Object.getOwnPropertyDescriptor(obj, key);
|
|
143
|
+
if (!descriptor || !("value" in descriptor)) {
|
|
144
|
+
throw new TypeError(`Unsupported accessor property at ${currentPath}.${key}.`);
|
|
145
|
+
}
|
|
146
|
+
}
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
const urlValue = trySerializeURL(current);
|
|
150
|
+
if (urlValue !== null) {
|
|
151
|
+
validateDataProperties(current, stringKeys, path);
|
|
152
|
+
return `{"$id":${id},"$type":"URL","value":${JSON.stringify(
|
|
153
|
+
urlValue
|
|
154
|
+
)},"properties":${encodeProperties(current, stringKeys, path, depth)}}`;
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
const paramsValue = trySerializeURLSearchParams(current);
|
|
158
|
+
if (paramsValue !== null) {
|
|
159
|
+
validateDataProperties(current, stringKeys, path);
|
|
160
|
+
return `{"$id":${id},"$type":"URLSearchParams","value":${JSON.stringify(
|
|
161
|
+
paramsValue
|
|
162
|
+
)},"properties":${encodeProperties(current, stringKeys, path, depth)}}`;
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
validateDataProperties(current, stringKeys, path);
|
|
166
|
+
|
|
167
|
+
if (types.isDate(current)) {
|
|
168
|
+
if (Number.isNaN(current.getTime())) {
|
|
169
|
+
throw new TypeError(`Invalid Date value at ${path}.`);
|
|
170
|
+
}
|
|
171
|
+
return `{"$id":${id},"$type":"Date","value":${JSON.stringify(current.toISOString())},"properties":${encodeProperties(current, stringKeys, path, depth)}}`;
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
if (types.isRegExp(current)) {
|
|
175
|
+
const extraKeys = stringKeys.filter((key) => key !== "lastIndex");
|
|
176
|
+
return `{"$id":${id},"$type":"RegExp","source":${JSON.stringify(current.source)},"flags":${JSON.stringify(current.flags)},"lastIndex":${current.lastIndex},"properties":${encodeProperties(current, extraKeys, path, depth)}}`;
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
if (types.isMap(current) || types.isSet(current)) {
|
|
180
|
+
throw new TypeError(`Unsupported ${constructorName} value at ${path}.`);
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
if (types.isDataView(current)) {
|
|
184
|
+
throw new TypeError(`Unsupported DataView value at ${path}.`);
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
if (types.isTypedArray(current)) {
|
|
188
|
+
const values = Array.from(current, (item, index) =>
|
|
189
|
+
encode(item, `${path}[${index}]`, depth + 1)
|
|
190
|
+
);
|
|
191
|
+
const indexes = new Set(Array.from({ length: current.length }, (_, index) => String(index)));
|
|
192
|
+
const extraKeys = stringKeys.filter((key) => !indexes.has(key));
|
|
193
|
+
return `{"$id":${id},"$type":${JSON.stringify(constructorName)},"values":[${values.join(",")}],"properties":${encodeProperties(current, extraKeys, path, depth)}}`;
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
if (types.isAnyArrayBuffer(current)) {
|
|
197
|
+
return `{"$id":${id},"$type":"ArrayBuffer","values":${JSON.stringify(Array.from(new Uint8Array(current)))},"properties":${encodeProperties(current, stringKeys, path, depth)}}`;
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
if (Array.isArray(current)) {
|
|
201
|
+
const values = Array.from({ length: current.length }, (_, index) => {
|
|
202
|
+
const descriptor = Object.getOwnPropertyDescriptor(current, String(index));
|
|
203
|
+
return descriptor
|
|
204
|
+
? encode(descriptor.value, `${path}[${index}]`, depth + 1)
|
|
205
|
+
: '{"$type":"array-hole"}';
|
|
206
|
+
});
|
|
207
|
+
const indexes = new Set(Array.from({ length: current.length }, (_, index) => String(index)));
|
|
208
|
+
const extraKeys = stringKeys.filter((key) => key !== "length" && !indexes.has(key));
|
|
209
|
+
return `{"$id":${id},"$type":"Array","values":[${values.join(",")}],"properties":${encodeProperties(current, extraKeys, path, depth)}}`;
|
|
210
|
+
}
|
|
211
|
+
|
|
212
|
+
const tagDescriptor = findToStringTagDescriptor(prototype);
|
|
213
|
+
if (tagDescriptor) {
|
|
214
|
+
if (!("value" in tagDescriptor)) {
|
|
215
|
+
throw new TypeError(
|
|
216
|
+
`Unsupported Symbol.toStringTag accessor at ${path}.`
|
|
217
|
+
);
|
|
218
|
+
}
|
|
219
|
+
|
|
220
|
+
throw new TypeError(
|
|
221
|
+
`Unsupported built-in ${String(tagDescriptor.value)} at ${path}.`
|
|
222
|
+
);
|
|
223
|
+
}
|
|
224
|
+
|
|
225
|
+
if (ownKeys.some((key) => typeof key === "symbol")) {
|
|
226
|
+
throw new TypeError(`Unsupported symbol-keyed property at ${path}.`);
|
|
227
|
+
}
|
|
228
|
+
|
|
229
|
+
const entries = [...stringKeys].sort().map((key) => {
|
|
230
|
+
const descriptor = Object.getOwnPropertyDescriptor(current, key);
|
|
231
|
+
return `${JSON.stringify(key)}:{"enumerable":${descriptor.enumerable},"value":${encode(descriptor.value, `${path}.${key}`, depth + 1)}}`;
|
|
232
|
+
});
|
|
233
|
+
return `{"$id":${id},"$type":${JSON.stringify(constructorName)},"values":{${entries.join(",")}}}`;
|
|
234
|
+
}
|
|
235
|
+
|
|
236
|
+
function encodeProperties(object, keys, path, depth) {
|
|
237
|
+
const entries = [...keys].sort().map((key) => {
|
|
238
|
+
const descriptor = Object.getOwnPropertyDescriptor(object, key);
|
|
239
|
+
return `${JSON.stringify(key)}:{"enumerable":${descriptor.enumerable},"value":${encode(descriptor.value, `${path}.${key}`, depth + 1)}}`;
|
|
240
|
+
});
|
|
241
|
+
return `{${entries.join(",")}}`;
|
|
242
|
+
}
|
|
243
|
+
|
|
244
|
+
return encode(value, "$", 0);
|
|
245
|
+
}
|
|
246
|
+
|
|
247
|
+
|
|
248
|
+
/**
|
|
249
|
+
* Uses SHA-256 to hash signatures that exceed the max length,
|
|
250
|
+
* providing collision-resistant determinism for large payloads.
|
|
251
|
+
*/
|
|
252
|
+
export function hashSignature(str) {
|
|
253
|
+
return createHash("sha256").update(str).digest("hex");
|
|
254
|
+
}
|