@osama.ansar/agent-loopguard 0.0.0-stage → 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/dist/index.cjs ADDED
@@ -0,0 +1,932 @@
1
+ 'use strict';
2
+
3
+ // src/detectors/budget.ts
4
+ var SPEC = [
5
+ { option: "maxTotalTokens", metric: "totalTokens", code: "BUDGET_TOTAL_TOKENS", label: "total tokens" },
6
+ { option: "maxInputTokens", metric: "inputTokens", code: "BUDGET_INPUT_TOKENS", label: "input tokens" },
7
+ { option: "maxOutputTokens", metric: "outputTokens", code: "BUDGET_OUTPUT_TOKENS", label: "output tokens" },
8
+ { option: "maxCostUsd", metric: "costUsd", code: "BUDGET_COST_USD", label: "cost (USD)" },
9
+ { option: "maxDurationMs", metric: "durationMs", code: "BUDGET_DURATION_MS", label: "duration (ms)" }
10
+ ];
11
+ function checkRatio(name, ratio) {
12
+ if (typeof ratio !== "number" || !(ratio > 0 && ratio < 1)) {
13
+ throw new TypeError(`agent-loopguard: ${name} must be a number strictly between 0 and 1, got ${String(ratio)}`);
14
+ }
15
+ return ratio;
16
+ }
17
+ var BudgetDetector = class {
18
+ constructor(options, now) {
19
+ this.now = now;
20
+ const defaultWarn = options.warnAt === void 0 ? void 0 : checkRatio("budget.warnAt", options.warnAt);
21
+ const limits = [];
22
+ for (const spec of SPEC) {
23
+ const raw = options[spec.option];
24
+ if (raw === void 0) continue;
25
+ const limit = typeof raw === "number" ? raw : raw.limit;
26
+ if (typeof limit !== "number" || !(limit > 0) || !Number.isFinite(limit)) {
27
+ throw new TypeError(`agent-loopguard: budget.${spec.option} must be a positive finite number, got ${String(limit)}`);
28
+ }
29
+ const ownWarn = typeof raw === "number" ? void 0 : raw.warnAt;
30
+ const warnAt = ownWarn === void 0 ? defaultWarn : checkRatio(`budget.${spec.option}.warnAt`, ownWarn);
31
+ limits.push({ metric: spec.metric, limit, warnAt, code: spec.code, label: spec.label });
32
+ }
33
+ this.limits = limits;
34
+ this.startedAt = now();
35
+ }
36
+ now;
37
+ limits;
38
+ startedAt;
39
+ lastUsed = zeroUsed();
40
+ observe(_record, ctx) {
41
+ const recorded = this.recorded(ctx);
42
+ this.lastUsed = recorded;
43
+ const used = this.withWallClock(recorded);
44
+ let stop;
45
+ const warnings = [];
46
+ for (const { metric, limit, warnAt, code, label } of this.limits) {
47
+ const value = used[metric];
48
+ if (value >= limit) {
49
+ if (stop === void 0) {
50
+ const evidence = { metric, value, limit };
51
+ stop = {
52
+ stop: true,
53
+ reason: "budget",
54
+ code,
55
+ detail: `Budget exhausted: ${label} reached ${value} of the ${limit} allowed.`,
56
+ evidence
57
+ };
58
+ }
59
+ } else if (warnAt !== void 0 && value >= warnAt * limit) {
60
+ const ratio = value / limit;
61
+ warnings.push({
62
+ code: "BUDGET_WARNING",
63
+ metric,
64
+ value,
65
+ limit,
66
+ ratio,
67
+ detail: `Budget warning: ${label} is at ${Math.round(ratio * 100)}% of the ${limit} allowed (${value} used).`
68
+ });
69
+ }
70
+ }
71
+ if (stop === void 0 && warnings.length === 0) return void 0;
72
+ const result = {};
73
+ if (stop !== void 0) result.stop = stop;
74
+ if (warnings.length > 0) result.warnings = warnings;
75
+ return result;
76
+ }
77
+ recorded(ctx) {
78
+ return {
79
+ totalTokens: ctx.totals.totalTokens,
80
+ inputTokens: ctx.totals.inputTokens,
81
+ outputTokens: ctx.totals.outputTokens,
82
+ costUsd: ctx.totals.costUsd,
83
+ durationMs: ctx.totals.durationMs
84
+ };
85
+ }
86
+ withWallClock(recorded) {
87
+ if (!this.limits.some((l) => l.metric === "durationMs")) return recorded;
88
+ const elapsed = this.now() - this.startedAt;
89
+ if (!Number.isFinite(elapsed) || elapsed <= recorded.durationMs) return recorded;
90
+ return { ...recorded, durationMs: elapsed };
91
+ }
92
+ state() {
93
+ const limits = {};
94
+ for (const l of this.limits) limits[l.metric] = l.limit;
95
+ return { limits, used: { ...this.lastUsed } };
96
+ }
97
+ reset() {
98
+ this.startedAt = this.now();
99
+ this.lastUsed = zeroUsed();
100
+ }
101
+ };
102
+ function zeroUsed() {
103
+ return { totalTokens: 0, inputTokens: 0, outputTokens: 0, costUsd: 0, durationMs: 0 };
104
+ }
105
+
106
+ // src/canonical.ts
107
+ var MAX_DEPTH = 64;
108
+ var MAX_NODES = 2e5;
109
+ var fallbackCounter = 0;
110
+ function canonicalize(value) {
111
+ try {
112
+ const out = [];
113
+ write(value, { nodes: 0, stack: [] }, out);
114
+ return out.join("");
115
+ } catch {
116
+ fallbackCounter += 1;
117
+ return `[Uncanonicalizable#${fallbackCounter}]`;
118
+ }
119
+ }
120
+ function write(value, walk, out) {
121
+ walk.nodes += 1;
122
+ if (walk.nodes > MAX_NODES) {
123
+ out.push("[Truncated]");
124
+ return;
125
+ }
126
+ switch (typeof value) {
127
+ case "undefined":
128
+ out.push("undefined");
129
+ return;
130
+ case "boolean":
131
+ out.push(value ? "true" : "false");
132
+ return;
133
+ case "number":
134
+ if (Number.isNaN(value)) out.push("NaN");
135
+ else if (Object.is(value, -0)) out.push("0");
136
+ else out.push(String(value));
137
+ return;
138
+ case "bigint":
139
+ out.push(`${value.toString()}n`);
140
+ return;
141
+ case "string":
142
+ out.push(JSON.stringify(value));
143
+ return;
144
+ case "symbol":
145
+ out.push(`Symbol(${String(value.description)})`);
146
+ return;
147
+ case "function":
148
+ out.push(`[Function ${String(value.name)}]`);
149
+ return;
150
+ }
151
+ if (value === null) {
152
+ out.push("null");
153
+ return;
154
+ }
155
+ const obj = value;
156
+ const ancestor = walk.stack.indexOf(obj);
157
+ if (ancestor !== -1) {
158
+ out.push(`[Circular:${ancestor}]`);
159
+ return;
160
+ }
161
+ if (walk.stack.length >= MAX_DEPTH) {
162
+ out.push("[MaxDepth]");
163
+ return;
164
+ }
165
+ walk.stack.push(obj);
166
+ try {
167
+ writeObject(obj, walk, out);
168
+ } finally {
169
+ walk.stack.pop();
170
+ }
171
+ }
172
+ function writeObject(obj, walk, out) {
173
+ if (obj instanceof Date) {
174
+ const time = obj.getTime();
175
+ out.push(Number.isNaN(time) ? "Date(invalid)" : `Date(${obj.toISOString()})`);
176
+ return;
177
+ }
178
+ if (obj instanceof RegExp) {
179
+ out.push(`RegExp(${String(obj)})`);
180
+ return;
181
+ }
182
+ if (Array.isArray(obj)) {
183
+ out.push("[");
184
+ for (let i = 0; i < obj.length; i += 1) {
185
+ if (i > 0) out.push(",");
186
+ write(obj[i], walk, out);
187
+ }
188
+ out.push("]");
189
+ return;
190
+ }
191
+ if (obj instanceof Map) {
192
+ const entries = [];
193
+ for (const [k, v] of obj) {
194
+ const part = [];
195
+ write(k, walk, part);
196
+ part.push("=>");
197
+ write(v, walk, part);
198
+ entries.push(part.join(""));
199
+ }
200
+ out.push(`Map{${entries.sort().join(",")}}`);
201
+ return;
202
+ }
203
+ if (obj instanceof Set) {
204
+ const items = [];
205
+ for (const v of obj) {
206
+ const part = [];
207
+ write(v, walk, part);
208
+ items.push(part.join(""));
209
+ }
210
+ out.push(`Set{${items.sort().join(",")}}`);
211
+ return;
212
+ }
213
+ if (ArrayBuffer.isView(obj)) {
214
+ const bytes = new Uint8Array(obj.buffer, obj.byteOffset, obj.byteLength);
215
+ out.push(`${constructorName(obj)}<${Array.prototype.join.call(bytes, ",")}>`);
216
+ return;
217
+ }
218
+ if (obj instanceof ArrayBuffer) {
219
+ out.push(`ArrayBuffer<${Array.prototype.join.call(new Uint8Array(obj), ",")}>`);
220
+ return;
221
+ }
222
+ if (obj instanceof Error) {
223
+ out.push(`Error(${String(obj.name)}: ${String(obj.message)})`);
224
+ return;
225
+ }
226
+ const toJSON = obj.toJSON;
227
+ if (typeof toJSON === "function") {
228
+ const json = toJSON.call(obj);
229
+ out.push("toJSON:");
230
+ write(json, walk, out);
231
+ return;
232
+ }
233
+ const proto = Object.getPrototypeOf(obj);
234
+ if (proto !== Object.prototype && proto !== null) out.push(constructorName(obj));
235
+ out.push("{");
236
+ let first = true;
237
+ for (const key of Object.keys(obj).sort()) {
238
+ let v;
239
+ try {
240
+ v = obj[key];
241
+ } catch {
242
+ v = "[Unreadable]";
243
+ }
244
+ if (v === void 0) continue;
245
+ if (!first) out.push(",");
246
+ first = false;
247
+ out.push(JSON.stringify(key), ":");
248
+ write(v, walk, out);
249
+ }
250
+ out.push("}");
251
+ }
252
+ function constructorName(obj) {
253
+ const ctor = obj.constructor;
254
+ return typeof ctor?.name === "string" && ctor.name !== "" ? ctor.name : "Object";
255
+ }
256
+ function cyrb53(input, seed) {
257
+ let h1 = 3735928559 ^ seed;
258
+ let h2 = 1103547991 ^ seed;
259
+ for (let i = 0; i < input.length; i += 1) {
260
+ const ch = input.charCodeAt(i);
261
+ h1 = Math.imul(h1 ^ ch, 2654435761);
262
+ h2 = Math.imul(h2 ^ ch, 1597334677);
263
+ }
264
+ h1 = Math.imul(h1 ^ h1 >>> 16, 2246822507) ^ Math.imul(h2 ^ h2 >>> 13, 3266489909);
265
+ h2 = Math.imul(h2 ^ h2 >>> 16, 2246822507) ^ Math.imul(h1 ^ h1 >>> 13, 3266489909);
266
+ return 4294967296 * (2097151 & h2) + (h1 >>> 0);
267
+ }
268
+ function stableHash(value) {
269
+ return hashString(canonicalize(value));
270
+ }
271
+ function hashString(text) {
272
+ const a = cyrb53(text, 0).toString(16).padStart(14, "0");
273
+ const b = cyrb53(text, 2654435769).toString(16).padStart(14, "0");
274
+ return a + b;
275
+ }
276
+ var INLINE_LIMIT = 256;
277
+ var PREVIEW_LIMIT = 80;
278
+ function describe(value) {
279
+ const canonical = canonicalize(value);
280
+ return {
281
+ signature: canonical.length <= INLINE_LIMIT ? canonical : `#${canonical.length}:${hashString(canonical)}`,
282
+ preview: truncate(canonical, PREVIEW_LIMIT)
283
+ };
284
+ }
285
+ function truncate(text, max) {
286
+ return text.length <= max ? text : `${text.slice(0, max - 1)}\u2026`;
287
+ }
288
+
289
+ // src/record.ts
290
+ function normalizeFinishReason(reason) {
291
+ return reason.trim().toLowerCase().replace(/_/g, "-");
292
+ }
293
+ function normalizeText(text) {
294
+ return text.replace(/\s+/g, " ").trim().toLowerCase();
295
+ }
296
+ function num(value) {
297
+ return typeof value === "number" && Number.isFinite(value) && value >= 0 ? value : void 0;
298
+ }
299
+ function isObject(value) {
300
+ return typeof value === "object" && value !== null;
301
+ }
302
+ function get(obj, key) {
303
+ try {
304
+ return obj[key];
305
+ } catch {
306
+ return void 0;
307
+ }
308
+ }
309
+ function isArray(value) {
310
+ try {
311
+ return Array.isArray(value);
312
+ } catch {
313
+ return false;
314
+ }
315
+ }
316
+ function buildCall(raw) {
317
+ if (!isObject(raw)) return void 0;
318
+ const rawName = get(raw, "name");
319
+ const name = typeof rawName === "string" ? rawName : describe(rawName).signature;
320
+ const args = describe(get(raw, "args"));
321
+ const error = get(raw, "error");
322
+ const result = get(raw, "result");
323
+ const failed = error !== void 0 && error !== null;
324
+ const callSig = `${JSON.stringify(name)}(${args.signature})`;
325
+ let resultSig;
326
+ if (failed) resultSig = `error:${describe(error).signature}`;
327
+ else if (result !== void 0) resultSig = `ok:${describe(result).signature}`;
328
+ return {
329
+ name,
330
+ label: `${name}(${args.preview})`,
331
+ callSig,
332
+ resultSig,
333
+ // With no result supplied the call itself stands in as "the evidence".
334
+ outcomeSig: resultSig === void 0 ? callSig : `${callSig}=>${resultSig}`,
335
+ failed,
336
+ argsPreview: args.preview
337
+ };
338
+ }
339
+ function buildCalls(step) {
340
+ const calls = [];
341
+ try {
342
+ const raw = get(step, "toolCalls");
343
+ if (!isArray(raw)) return calls;
344
+ for (const item of raw) {
345
+ const call = buildCall(item);
346
+ if (call !== void 0) calls.push(call);
347
+ }
348
+ } catch {
349
+ }
350
+ return calls;
351
+ }
352
+ function buildRecord(step, index) {
353
+ const s = isObject(step) ? step : {};
354
+ const rawUsage = get(s, "usage");
355
+ const usage = isObject(rawUsage) ? rawUsage : {};
356
+ const inputTokens = num(get(usage, "inputTokens"));
357
+ const outputTokens = num(get(usage, "outputTokens"));
358
+ let totalTokens = num(get(usage, "totalTokens"));
359
+ if (totalTokens === void 0 && (inputTokens !== void 0 || outputTokens !== void 0)) {
360
+ totalTokens = (inputTokens ?? 0) + (outputTokens ?? 0);
361
+ }
362
+ const rawFinish = get(s, "finishReason");
363
+ const finishReason = typeof rawFinish === "string" && rawFinish.trim() !== "" ? normalizeFinishReason(rawFinish) : void 0;
364
+ const rawText = get(s, "text");
365
+ return {
366
+ index,
367
+ calls: buildCalls(s),
368
+ text: typeof rawText === "string" ? normalizeText(rawText) : "",
369
+ finishReason,
370
+ rawFinishReason: typeof rawFinish === "string" && finishReason !== void 0 ? rawFinish : void 0,
371
+ inputTokens,
372
+ outputTokens,
373
+ totalTokens,
374
+ costUsd: num(get(usage, "costUsd")),
375
+ durationMs: num(get(s, "durationMs"))
376
+ };
377
+ }
378
+
379
+ // src/detectors/finishReason.ts
380
+ var DEFAULT_TERMINAL_FINISH_REASONS = ["stop", "length", "content-filter", "error"];
381
+ var OTHER_POLICIES = ["stop", "continue", "stop-if-text-only"];
382
+ var FinishReasonDetector = class {
383
+ terminal;
384
+ otherPolicy;
385
+ last = null;
386
+ constructor(options = {}) {
387
+ this.terminal = new Set((options.terminal ?? DEFAULT_TERMINAL_FINISH_REASONS).map(normalizeFinishReason));
388
+ const policy = options.otherPolicy ?? "stop-if-text-only";
389
+ if (!OTHER_POLICIES.includes(policy)) {
390
+ throw new TypeError(
391
+ `agent-loopguard: finishReason.otherPolicy must be one of ${OTHER_POLICIES.join(", ")}, got ${String(policy)}`
392
+ );
393
+ }
394
+ this.otherPolicy = policy;
395
+ }
396
+ observe(record) {
397
+ const normalized = record.finishReason;
398
+ if (normalized === void 0) return void 0;
399
+ this.last = normalized;
400
+ const raw = record.rawFinishReason ?? normalized;
401
+ const hasText = record.text !== "";
402
+ const toolCallCount = record.calls.length;
403
+ const evidence = (policy) => ({
404
+ finishReason: raw,
405
+ normalized,
406
+ policy,
407
+ hasText,
408
+ toolCallCount
409
+ });
410
+ if (this.terminal.has(normalized)) {
411
+ return {
412
+ stop: {
413
+ stop: true,
414
+ reason: "finish-reason",
415
+ code: "FINISH_TERMINAL",
416
+ detail: `The model finished with "${raw}", which ends the run.`,
417
+ evidence: evidence("terminal")
418
+ }
419
+ };
420
+ }
421
+ if (normalized === "tool-calls") return void 0;
422
+ if (this.otherPolicy === "stop") {
423
+ return {
424
+ stop: {
425
+ stop: true,
426
+ reason: "finish-reason",
427
+ code: "FINISH_OTHER",
428
+ detail: `The model finished with the unfamiliar reason "${raw}" and the policy is to stop on those.`,
429
+ evidence: evidence("other-stop")
430
+ }
431
+ };
432
+ }
433
+ if (this.otherPolicy === "stop-if-text-only" && hasText && toolCallCount === 0) {
434
+ return {
435
+ stop: {
436
+ stop: true,
437
+ reason: "finish-reason",
438
+ code: "FINISH_OTHER_TEXT_ONLY",
439
+ detail: `The model returned text with no tool calls and the unfamiliar finish reason "${raw}"; re-sending would likely repeat the same answer.`,
440
+ evidence: evidence("other-text-only")
441
+ }
442
+ };
443
+ }
444
+ return void 0;
445
+ }
446
+ state() {
447
+ return { lastFinishReason: this.last };
448
+ }
449
+ reset() {
450
+ this.last = null;
451
+ }
452
+ };
453
+
454
+ // src/detectors/types.ts
455
+ function assertInteger(name, value, min) {
456
+ if (typeof value !== "number" || !Number.isInteger(value) || value < min) {
457
+ throw new TypeError(`agent-loopguard: ${name} must be an integer >= ${min}, got ${String(value)}`);
458
+ }
459
+ return value;
460
+ }
461
+
462
+ // src/detectors/maxSteps.ts
463
+ var MaxStepsDetector = class {
464
+ limit;
465
+ steps = 0;
466
+ constructor(limit) {
467
+ this.limit = assertInteger("maxSteps", limit, 1);
468
+ }
469
+ observe(_record) {
470
+ this.steps += 1;
471
+ if (this.steps < this.limit) return void 0;
472
+ return {
473
+ stop: {
474
+ stop: true,
475
+ reason: "max-steps",
476
+ code: "MAX_STEPS",
477
+ detail: `Reached the limit of ${this.limit} steps.`,
478
+ evidence: { steps: this.steps, limit: this.limit }
479
+ }
480
+ };
481
+ }
482
+ remaining() {
483
+ return Math.max(0, this.limit - this.steps);
484
+ }
485
+ reset() {
486
+ this.steps = 0;
487
+ }
488
+ };
489
+
490
+ // src/detectors/noProgress.ts
491
+ var NoProgressDetector = class {
492
+ threshold;
493
+ seen = /* @__PURE__ */ new Set();
494
+ streak = 0;
495
+ streakText = "";
496
+ constructor(options = {}) {
497
+ this.threshold = assertInteger("noProgress.threshold", options.threshold ?? 3, 2);
498
+ }
499
+ observe(record) {
500
+ let hasNew = false;
501
+ for (const call of record.calls) {
502
+ if (!this.seen.has(call.outcomeSig)) {
503
+ hasNew = true;
504
+ this.seen.add(call.outcomeSig);
505
+ }
506
+ }
507
+ if (hasNew) {
508
+ this.streak = 0;
509
+ } else if (this.streak > 0 && record.text === this.streakText) {
510
+ this.streak += 1;
511
+ } else {
512
+ this.streak = 1;
513
+ this.streakText = record.text;
514
+ }
515
+ if (this.streak < this.threshold) return void 0;
516
+ const preview = truncate(this.streakText, 80);
517
+ return {
518
+ stop: {
519
+ stop: true,
520
+ reason: "no-progress",
521
+ code: "NO_PROGRESS",
522
+ detail: this.streakText === "" ? `No progress: ${this.streak} consecutive steps produced no new tool results and no text.` : `No progress: ${this.streak} consecutive steps repeated the same text and produced no new tool results ("${preview}").`,
523
+ evidence: { steps: this.streak, threshold: this.threshold, textPreview: preview }
524
+ }
525
+ };
526
+ }
527
+ state() {
528
+ return { threshold: this.threshold, streak: this.streak };
529
+ }
530
+ reset() {
531
+ this.seen.clear();
532
+ this.streak = 0;
533
+ this.streakText = "";
534
+ }
535
+ };
536
+
537
+ // src/detectors/oscillation.ts
538
+ var WINDOW = 4096;
539
+ var OscillationDetector = class {
540
+ maxPeriod;
541
+ repeats;
542
+ sigs = [];
543
+ labels = [];
544
+ current = null;
545
+ constructor(options = {}) {
546
+ this.maxPeriod = assertInteger("oscillation.maxPeriod", options.maxPeriod ?? 3, 2);
547
+ this.repeats = assertInteger("oscillation.repeats", options.repeats ?? 3, 2);
548
+ }
549
+ observe(record) {
550
+ let found;
551
+ for (const call of record.calls) {
552
+ this.sigs.push(call.callSig);
553
+ this.labels.push(call.label);
554
+ if (this.sigs.length > WINDOW) {
555
+ this.sigs.splice(0, WINDOW / 2);
556
+ this.labels.splice(0, WINDOW / 2);
557
+ }
558
+ found = this.detect() ?? found;
559
+ }
560
+ this.current = found ? { period: found.period, repeats: found.repeats } : null;
561
+ if (!found) return void 0;
562
+ return {
563
+ stop: {
564
+ stop: true,
565
+ reason: "oscillation",
566
+ code: "OSCILLATION",
567
+ detail: `The agent is oscillating: a cycle of ${found.period} calls (${found.cycle.join(" -> ")}) repeated ${found.repeats} times in a row.`,
568
+ evidence: found
569
+ }
570
+ };
571
+ }
572
+ /** Look for a repeating cycle at the very end of the sequence. */
573
+ detect() {
574
+ const n = this.sigs.length;
575
+ for (let period = 2; period <= this.maxPeriod; period += 1) {
576
+ if (n < period * this.repeats) continue;
577
+ let matching = 0;
578
+ for (let i = n - 1; i >= period && this.sigs[i] === this.sigs[i - period]; i -= 1) matching += 1;
579
+ const cycles = Math.floor(matching / period) + 1;
580
+ if (cycles < this.repeats) continue;
581
+ const cycle = this.sigs.slice(n - period);
582
+ if (new Set(cycle).size < 2) continue;
583
+ return {
584
+ period,
585
+ repeats: cycles,
586
+ threshold: this.repeats,
587
+ cycle: this.labels.slice(n - period)
588
+ };
589
+ }
590
+ return void 0;
591
+ }
592
+ state() {
593
+ return { maxPeriod: this.maxPeriod, repeats: this.repeats, current: this.current };
594
+ }
595
+ reset() {
596
+ this.sigs = [];
597
+ this.labels = [];
598
+ this.current = null;
599
+ }
600
+ };
601
+ var RepeatedToolCallDetector = class {
602
+ threshold;
603
+ requireIdenticalResults;
604
+ ignoreErrors;
605
+ runKey;
606
+ runTool = "";
607
+ runPreview = "";
608
+ runLength = 0;
609
+ runSteps = [];
610
+ longest = 0;
611
+ constructor(options = {}) {
612
+ this.threshold = assertInteger("repeatedToolCall.threshold", options.threshold ?? 3, 2);
613
+ this.requireIdenticalResults = options.requireIdenticalResults ?? false;
614
+ this.ignoreErrors = options.ignoreErrors ?? false;
615
+ }
616
+ observe(record) {
617
+ let hit;
618
+ for (const call of record.calls) {
619
+ if (this.ignoreErrors && call.failed) continue;
620
+ this.extendRun(call, record.index);
621
+ if (this.runLength >= this.threshold) hit = this.evidence();
622
+ }
623
+ if (hit === void 0) return void 0;
624
+ const results = hit.identicalResults ? " and identical results" : "";
625
+ return {
626
+ stop: {
627
+ stop: true,
628
+ reason: "repeated-tool-call",
629
+ code: "REPEATED_TOOL_CALL",
630
+ detail: `Tool "${hit.tool}" was called ${hit.count} times in a row with identical arguments${results}: ${hit.tool}(${hit.argsPreview}).`,
631
+ evidence: hit
632
+ }
633
+ };
634
+ }
635
+ extendRun(call, stepIndex) {
636
+ const key = this.requireIdenticalResults ? call.outcomeSig : call.callSig;
637
+ if (key === this.runKey) {
638
+ this.runLength += 1;
639
+ if (this.runSteps[this.runSteps.length - 1] !== stepIndex) this.runSteps.push(stepIndex);
640
+ } else {
641
+ this.runKey = key;
642
+ this.runTool = call.name;
643
+ this.runPreview = call.argsPreview;
644
+ this.runLength = 1;
645
+ this.runSteps = [stepIndex];
646
+ }
647
+ if (this.runLength > this.longest) this.longest = this.runLength;
648
+ }
649
+ evidence() {
650
+ return {
651
+ tool: this.runTool,
652
+ argsPreview: this.runPreview,
653
+ count: this.runLength,
654
+ threshold: this.threshold,
655
+ stepIndexes: this.runSteps.slice(-10),
656
+ identicalResults: this.requireIdenticalResults
657
+ };
658
+ }
659
+ state() {
660
+ return {
661
+ threshold: this.threshold,
662
+ currentRun: this.runLength > 0 ? { tool: this.runTool, length: this.runLength } : null,
663
+ longestRun: this.longest
664
+ };
665
+ }
666
+ reset() {
667
+ this.runKey = void 0;
668
+ this.runTool = "";
669
+ this.runPreview = "";
670
+ this.runLength = 0;
671
+ this.runSteps = [];
672
+ this.longest = 0;
673
+ }
674
+ };
675
+
676
+ // src/guard.ts
677
+ var DEFAULT_MAX_STEPS = 50;
678
+ var CustomRunner = class {
679
+ constructor(fn) {
680
+ this.fn = fn;
681
+ }
682
+ fn;
683
+ observe(_record, ctx) {
684
+ const out = this.fn(ctx.history, ctx.step);
685
+ if (out === void 0 || out === null || out.stop !== true) return void 0;
686
+ const stop = {
687
+ stop: true,
688
+ reason: "custom",
689
+ code: typeof out.code === "string" && out.code !== "" ? out.code : "CUSTOM",
690
+ detail: typeof out.detail === "string" ? out.detail : "Stopped by a custom detector.",
691
+ evidence: out.evidence === void 0 ? null : out.evidence
692
+ };
693
+ return { stop };
694
+ }
695
+ reset() {
696
+ }
697
+ };
698
+ function zeroTotals() {
699
+ return { inputTokens: 0, outputTokens: 0, totalTokens: 0, costUsd: 0, durationMs: 0 };
700
+ }
701
+ function createGuard(options = {}) {
702
+ const now = options.now ?? Date.now;
703
+ const maxSteps = options.maxSteps === false ? void 0 : new MaxStepsDetector(options.maxSteps ?? DEFAULT_MAX_STEPS);
704
+ const repeated = options.repeatedToolCall === false ? void 0 : new RepeatedToolCallDetector(options.repeatedToolCall);
705
+ const oscillation = options.oscillation === false ? void 0 : new OscillationDetector(options.oscillation);
706
+ const noProgress = options.noProgress === false ? void 0 : new NoProgressDetector(options.noProgress);
707
+ const finish = options.finishReason === false ? void 0 : new FinishReasonDetector(options.finishReason);
708
+ const budget = options.budget === void 0 || options.budget === false ? void 0 : new BudgetDetector(options.budget, now);
709
+ const customFns = options.custom === void 0 ? [] : typeof options.custom === "function" ? [options.custom] : options.custom;
710
+ const custom = customFns.map((fn) => new CustomRunner(fn));
711
+ const optional = [finish, budget, maxSteps, repeated, oscillation, noProgress, ...custom];
712
+ const detectors = optional.filter((d) => d !== void 0);
713
+ let history = [];
714
+ let totals = zeroTotals();
715
+ let toolCalls = 0;
716
+ let failedToolCalls = 0;
717
+ let lastVerdict = null;
718
+ function check(step) {
719
+ const safe = typeof step === "object" && step !== null ? step : {};
720
+ const record = buildRecord(safe, history.length);
721
+ history.push(safe);
722
+ totals.inputTokens += record.inputTokens ?? 0;
723
+ totals.outputTokens += record.outputTokens ?? 0;
724
+ totals.totalTokens += record.totalTokens ?? 0;
725
+ totals.costUsd += record.costUsd ?? 0;
726
+ totals.durationMs += record.durationMs ?? 0;
727
+ toolCalls += record.calls.length;
728
+ failedToolCalls += record.calls.filter((c) => c.failed).length;
729
+ const ctx = { totals, step: safe, history };
730
+ let stop;
731
+ const warnings = [];
732
+ for (const detector of detectors) {
733
+ const result = detector.observe(record, ctx);
734
+ if (result === void 0) continue;
735
+ if (result.stop !== void 0 && stop === void 0) stop = result.stop;
736
+ if (result.warnings !== void 0) warnings.push(...result.warnings);
737
+ }
738
+ let verdict;
739
+ if (stop !== void 0) verdict = warnings.length > 0 ? { ...stop, warnings } : stop;
740
+ else verdict = warnings.length > 0 ? { stop: false, warnings } : { stop: false };
741
+ lastVerdict = verdict;
742
+ return verdict;
743
+ }
744
+ function reset() {
745
+ history = [];
746
+ totals = zeroTotals();
747
+ toolCalls = 0;
748
+ failedToolCalls = 0;
749
+ lastVerdict = null;
750
+ for (const detector of detectors) detector.reset();
751
+ }
752
+ function summary() {
753
+ return {
754
+ steps: history.length,
755
+ toolCalls,
756
+ failedToolCalls,
757
+ totals: { ...totals },
758
+ detectors: {
759
+ maxSteps: maxSteps ? { limit: maxSteps.limit, remaining: maxSteps.remaining() } : null,
760
+ repeatedToolCall: repeated ? repeated.state() : null,
761
+ oscillation: oscillation ? oscillation.state() : null,
762
+ noProgress: noProgress ? noProgress.state() : null,
763
+ finishReason: finish ? finish.state() : null,
764
+ budget: budget ? budget.state() : null
765
+ },
766
+ lastVerdict
767
+ };
768
+ }
769
+ return {
770
+ check,
771
+ reset,
772
+ summary,
773
+ get history() {
774
+ return Object.freeze([...history]);
775
+ }
776
+ };
777
+ }
778
+
779
+ // src/explain.ts
780
+ function hint(verdict) {
781
+ switch (verdict.reason) {
782
+ case "max-steps":
783
+ return "Raise maxSteps if the task really needs more steps; otherwise find out why the agent is not converging.";
784
+ case "repeated-tool-call":
785
+ return "The model keeps issuing the same call. Check that the tool result gives it something to act on, and that the prompt says what to do when a tool fails.";
786
+ case "oscillation":
787
+ return "The model is flipping between the same calls. Look for two tools whose results send the model back to each other.";
788
+ case "no-progress":
789
+ return "Nothing new is coming back and the model is not saying anything new. Retrying the same request will not help.";
790
+ case "finish-reason":
791
+ return verdict.evidence.normalized === "stop" ? "This is the normal end of a run, not a fault." : 'The provider ended this step in a way the loop does not treat as "keep going". Inspect the raw response.';
792
+ case "budget":
793
+ return "Raise the limit, shorten the context, or split the task.";
794
+ case "custom":
795
+ return "Stopped by a custom detector.";
796
+ }
797
+ }
798
+ function isFault(verdict) {
799
+ if (!verdict.stop) return false;
800
+ return !(verdict.reason === "finish-reason" && verdict.evidence.normalized === "stop");
801
+ }
802
+ function explain(verdict) {
803
+ const lines = [];
804
+ if (verdict.stop) {
805
+ lines.push(`Stop [${verdict.code}] ${verdict.detail}`);
806
+ lines.push(`Hint: ${hint(verdict)}`);
807
+ } else {
808
+ lines.push("Continue.");
809
+ }
810
+ for (const warning of verdict.warnings ?? []) lines.push(`Warning [${warning.code}] ${warning.detail}`);
811
+ return lines.join("\n");
812
+ }
813
+
814
+ // src/adapters/aiSdk.ts
815
+ function isRecord(value) {
816
+ return typeof value === "object" && value !== null;
817
+ }
818
+ function toGuardStep(step) {
819
+ const results = /* @__PURE__ */ new Map();
820
+ const errors = /* @__PURE__ */ new Map();
821
+ const positionalResults = [];
822
+ for (const r of step.toolResults ?? []) {
823
+ if (typeof r.toolCallId === "string") results.set(r.toolCallId, "output" in r ? r.output : r.result);
824
+ else positionalResults.push(r);
825
+ }
826
+ for (const part of step.content ?? []) {
827
+ const p = part;
828
+ if (isRecord(p) && p["type"] === "tool-error" && typeof p["toolCallId"] === "string") {
829
+ errors.set(p["toolCallId"], p["error"] ?? "tool-error");
830
+ }
831
+ }
832
+ const toolCalls = (step.toolCalls ?? []).map((call, i) => {
833
+ const out = { name: call.toolName, args: "input" in call ? call.input : call.args };
834
+ const id = call.toolCallId;
835
+ if (typeof id === "string") {
836
+ if (results.has(id)) out.result = results.get(id);
837
+ if (errors.has(id)) out.error = errors.get(id);
838
+ } else {
839
+ const positional = positionalResults[i];
840
+ if (positional !== void 0) out.result = "output" in positional ? positional.output : positional.result;
841
+ }
842
+ return out;
843
+ });
844
+ const guardStep = { toolCalls };
845
+ if (step.text !== void 0) guardStep.text = step.text;
846
+ if (step.finishReason !== void 0) guardStep.finishReason = step.finishReason;
847
+ if (step.usage !== void 0) guardStep.usage = { ...step.usage };
848
+ const duration = step.performance?.stepTimeMs;
849
+ if (typeof duration === "number") guardStep.durationMs = duration;
850
+ return guardStep;
851
+ }
852
+ function toStopWhen(guardFactory = {}, hooks = {}) {
853
+ const makeGuard = typeof guardFactory === "function" ? guardFactory : () => createGuard(guardFactory);
854
+ const converted = /* @__PURE__ */ new WeakMap();
855
+ return ({ steps }) => {
856
+ const guard = makeGuard();
857
+ let stopped;
858
+ for (const step of steps) {
859
+ let guardStep = converted.get(step);
860
+ if (guardStep === void 0) {
861
+ guardStep = toGuardStep(step);
862
+ converted.set(step, guardStep);
863
+ }
864
+ const verdict = guard.check(guardStep);
865
+ stopped = verdict.stop ? verdict : void 0;
866
+ }
867
+ if (stopped === void 0) return false;
868
+ hooks.onStop?.(stopped);
869
+ return true;
870
+ };
871
+ }
872
+
873
+ // src/adapters/loop.ts
874
+ function isFinal(step) {
875
+ if (step.done !== void 0) return step.done;
876
+ return !Array.isArray(step.toolCalls) || step.toolCalls.length === 0;
877
+ }
878
+ async function runAgentLoop(options) {
879
+ const guard = options.guard ?? createGuard();
880
+ const { signal } = options;
881
+ const steps = [];
882
+ let verdict = { stop: false };
883
+ for (let index = 0; ; index += 1) {
884
+ if (signal?.aborted) return { steps, verdict, completed: false, aborted: true };
885
+ let result;
886
+ try {
887
+ result = await options.step(index);
888
+ } catch (error) {
889
+ if (signal?.aborted) return { steps, verdict, completed: false, aborted: true };
890
+ throw error;
891
+ }
892
+ steps.push(result);
893
+ verdict = guard.check(result);
894
+ options.onVerdict?.(verdict, result, index);
895
+ if (verdict.stop) {
896
+ return { steps, verdict, completed: !isFault(verdict), aborted: false };
897
+ }
898
+ if (isFinal(result)) return { steps, verdict, completed: true, aborted: false };
899
+ }
900
+ }
901
+
902
+ // src/adapters/iterable.ts
903
+ function withGuard(source, guard) {
904
+ let verdict;
905
+ async function* run() {
906
+ for await (const step of source) {
907
+ verdict = guard.check(step);
908
+ yield step;
909
+ if (verdict.stop) return;
910
+ }
911
+ }
912
+ return {
913
+ [Symbol.asyncIterator]() {
914
+ verdict = void 0;
915
+ return run();
916
+ },
917
+ get verdict() {
918
+ return verdict;
919
+ }
920
+ };
921
+ }
922
+
923
+ exports.DEFAULT_MAX_STEPS = DEFAULT_MAX_STEPS;
924
+ exports.canonicalize = canonicalize;
925
+ exports.createGuard = createGuard;
926
+ exports.explain = explain;
927
+ exports.isFault = isFault;
928
+ exports.runAgentLoop = runAgentLoop;
929
+ exports.stableHash = stableHash;
930
+ exports.toGuardStep = toGuardStep;
931
+ exports.toStopWhen = toStopWhen;
932
+ exports.withGuard = withGuard;