@osama.ansar/agent-loopguard 0.1.0 → 0.2.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/index.js CHANGED
@@ -36,8 +36,18 @@ var BudgetDetector = class {
36
36
  startedAt;
37
37
  lastUsed = zeroUsed();
38
38
  observe(_record, ctx) {
39
- const recorded = this.recorded(ctx);
39
+ const recorded = this.recorded(ctx.totals);
40
40
  this.lastUsed = recorded;
41
+ return this.evaluate(recorded);
42
+ }
43
+ /**
44
+ * Where the totals stand right now, without recording anything: the stop verdict if a limit has
45
+ * already been reached, and any warnings. Reads the clock when a duration limit exists.
46
+ */
47
+ peek(totals) {
48
+ return this.evaluate(this.recorded(totals));
49
+ }
50
+ evaluate(recorded) {
41
51
  const used = this.withWallClock(recorded);
42
52
  let stop;
43
53
  const warnings = [];
@@ -72,13 +82,13 @@ var BudgetDetector = class {
72
82
  if (warnings.length > 0) result.warnings = warnings;
73
83
  return result;
74
84
  }
75
- recorded(ctx) {
85
+ recorded(totals) {
76
86
  return {
77
- totalTokens: ctx.totals.totalTokens,
78
- inputTokens: ctx.totals.inputTokens,
79
- outputTokens: ctx.totals.outputTokens,
80
- costUsd: ctx.totals.costUsd,
81
- durationMs: ctx.totals.durationMs
87
+ totalTokens: totals.totalTokens,
88
+ inputTokens: totals.inputTokens,
89
+ outputTokens: totals.outputTokens,
90
+ costUsd: totals.costUsd,
91
+ durationMs: totals.durationMs
82
92
  };
83
93
  }
84
94
  withWallClock(recorded) {
@@ -87,6 +97,21 @@ var BudgetDetector = class {
87
97
  if (!Number.isFinite(elapsed) || elapsed <= recorded.durationMs) return recorded;
88
98
  return { ...recorded, durationMs: elapsed };
89
99
  }
100
+ /** The resolved limits, for the snapshot fingerprint. */
101
+ config() {
102
+ return this.limits.map(({ metric, limit, warnAt }) => ({ metric, limit, warnAt: warnAt ?? null }));
103
+ }
104
+ /** Wall-clock time since the run started, or `null` when no duration limit makes it matter. */
105
+ elapsedMs() {
106
+ if (!this.limits.some((l) => l.metric === "durationMs")) return null;
107
+ const elapsed = this.now() - this.startedAt;
108
+ return Number.isFinite(elapsed) && elapsed > 0 ? elapsed : 0;
109
+ }
110
+ /** Continue a run whose totals were `totals` and which had been running for `elapsedMs`. */
111
+ restore(totals, elapsedMs) {
112
+ this.lastUsed = this.recorded(totals);
113
+ this.startedAt = this.now() - (elapsedMs ?? 0);
114
+ }
90
115
  state() {
91
116
  const limits = {};
92
117
  for (const l of this.limits) limits[l.metric] = l.limit;
@@ -319,17 +344,17 @@ function buildCall(raw) {
319
344
  const error = get(raw, "error");
320
345
  const result = get(raw, "result");
321
346
  const failed = error !== void 0 && error !== null;
322
- const callSig = `${JSON.stringify(name)}(${args.signature})`;
347
+ const callSig = hashString(`${JSON.stringify(name)}(${args.signature})`);
323
348
  let resultSig;
324
- if (failed) resultSig = `error:${describe(error).signature}`;
325
- else if (result !== void 0) resultSig = `ok:${describe(result).signature}`;
349
+ if (failed) resultSig = hashString(`error:${describe(error).signature}`);
350
+ else if (result !== void 0) resultSig = hashString(`ok:${describe(result).signature}`);
326
351
  return {
327
352
  name,
328
353
  label: `${name}(${args.preview})`,
329
354
  callSig,
330
355
  resultSig,
331
356
  // With no result supplied the call itself stands in as "the evidence".
332
- outcomeSig: resultSig === void 0 ? callSig : `${callSig}=>${resultSig}`,
357
+ outcomeSig: resultSig === void 0 ? callSig : hashString(`${callSig}=>${resultSig}`),
333
358
  failed,
334
359
  argsPreview: args.preview
335
360
  };
@@ -347,6 +372,19 @@ function buildCalls(step) {
347
372
  }
348
373
  return calls;
349
374
  }
375
+ function buildPendingCalls(input) {
376
+ const raw = isArray(input) ? input : [input];
377
+ const calls = [];
378
+ try {
379
+ for (const item of raw) {
380
+ if (!isObject(item)) continue;
381
+ const call = buildCall({ name: get(item, "name"), args: get(item, "args") });
382
+ if (call !== void 0) calls.push(call);
383
+ }
384
+ } catch {
385
+ }
386
+ return calls;
387
+ }
350
388
  function buildRecord(step, index) {
351
389
  const s = isObject(step) ? step : {};
352
390
  const rawUsage = get(s, "usage");
@@ -374,6 +412,67 @@ function buildRecord(step, index) {
374
412
  };
375
413
  }
376
414
 
415
+ // src/snapshot.ts
416
+ var SNAPSHOT_VERSION = 1;
417
+ var MAX_NAME_LENGTH = 256;
418
+ var MAX_LABEL_LENGTH = 1024;
419
+ var MAX_PREVIEW_LENGTH = 128;
420
+ var MAX_FINISH_REASON_LENGTH = 256;
421
+ var REDACTED = "[redacted]";
422
+ var HASH = /^[0-9a-f]{28}$/;
423
+ function fail(path, problem) {
424
+ throw new TypeError(`agent-loopguard: invalid snapshot: ${path} ${problem}`);
425
+ }
426
+ function clip(text, max) {
427
+ return text.length <= max ? text : text.slice(0, max);
428
+ }
429
+ function fingerprint(config) {
430
+ return hashString(canonicalize(config));
431
+ }
432
+ function asRecord(value, path) {
433
+ if (typeof value !== "object" || value === null || Array.isArray(value)) fail(path, "must be an object");
434
+ return value;
435
+ }
436
+ function field(obj, key, path) {
437
+ if (!Object.prototype.hasOwnProperty.call(obj, key)) fail(`${path}.${key}`, "is missing");
438
+ return obj[key];
439
+ }
440
+ function asInt(value, path) {
441
+ if (typeof value !== "number" || !Number.isSafeInteger(value) || value < 0) {
442
+ fail(path, `must be a non-negative integer, got ${String(value)}`);
443
+ }
444
+ return value;
445
+ }
446
+ function asAmount(value, path) {
447
+ if (typeof value !== "number" || !Number.isFinite(value) || value < 0) {
448
+ fail(path, `must be a finite number >= 0, got ${String(value)}`);
449
+ }
450
+ return value;
451
+ }
452
+ function asString(value, path, maxLength) {
453
+ if (typeof value !== "string") fail(path, "must be a string");
454
+ if (value.length > maxLength) fail(path, `must be at most ${maxLength} characters`);
455
+ return value;
456
+ }
457
+ function asHash(value, path) {
458
+ if (typeof value !== "string" || !HASH.test(value)) fail(path, "must be a 28-character hex hash");
459
+ return value;
460
+ }
461
+ function asList(value, path, maxLength) {
462
+ if (!Array.isArray(value)) fail(path, "must be an array");
463
+ if (value.length > maxLength) fail(path, `must have at most ${maxLength} entries`);
464
+ return Array.from(value);
465
+ }
466
+ function asNullable(value, read) {
467
+ return value === null ? null : read(value);
468
+ }
469
+ function section(detectors, key, enabled) {
470
+ const value = field(detectors, key, "detectors");
471
+ if (enabled && value === null) fail(`detectors.${key}`, "is null, but that detector is enabled in the options");
472
+ if (!enabled && value !== null) fail(`detectors.${key}`, "has state, but that detector is disabled in the options");
473
+ return value;
474
+ }
475
+
377
476
  // src/detectors/finishReason.ts
378
477
  var DEFAULT_TERMINAL_FINISH_REASONS = ["stop", "length", "content-filter", "error"];
379
478
  var OTHER_POLICIES = ["stop", "continue", "stop-if-text-only"];
@@ -444,6 +543,21 @@ var FinishReasonDetector = class {
444
543
  state() {
445
544
  return { lastFinishReason: this.last };
446
545
  }
546
+ /** The resolved options, for the snapshot fingerprint. */
547
+ config() {
548
+ return { terminal: [...this.terminal].sort(), otherPolicy: this.otherPolicy };
549
+ }
550
+ snapshot() {
551
+ return { lastFinishReason: this.last === null ? null : clip(this.last, MAX_FINISH_REASON_LENGTH) };
552
+ }
553
+ restore(raw) {
554
+ const path = "detectors.finishReason";
555
+ const state = asRecord(raw, path);
556
+ this.last = asNullable(
557
+ field(state, "lastFinishReason", path),
558
+ (v) => asString(v, `${path}.lastFinishReason`, MAX_FINISH_REASON_LENGTH)
559
+ );
560
+ }
447
561
  reset() {
448
562
  this.last = null;
449
563
  }
@@ -477,6 +591,25 @@ var MaxStepsDetector = class {
477
591
  }
478
592
  };
479
593
  }
594
+ /** What a call made now would run into: the verdict if the limit has already been reached. */
595
+ peek() {
596
+ if (this.steps < this.limit) return void 0;
597
+ return {
598
+ stop: true,
599
+ reason: "max-steps",
600
+ code: "MAX_STEPS",
601
+ detail: `The limit of ${this.limit} steps has already been reached.`,
602
+ evidence: { steps: this.steps, limit: this.limit }
603
+ };
604
+ }
605
+ /** The resolved options, for the snapshot fingerprint. */
606
+ config() {
607
+ return { limit: this.limit };
608
+ }
609
+ /** Continue from a run that had already recorded `steps` steps. */
610
+ restore(steps) {
611
+ this.steps = steps;
612
+ }
480
613
  remaining() {
481
614
  return Math.max(0, this.limit - this.steps);
482
615
  }
@@ -486,11 +619,16 @@ var MaxStepsDetector = class {
486
619
  };
487
620
 
488
621
  // src/detectors/noProgress.ts
622
+ var MAX_REMEMBERED_OUTCOMES = 1024;
623
+ var PREVIEW_LIMIT2 = 80;
489
624
  var NoProgressDetector = class {
490
625
  threshold;
491
626
  seen = /* @__PURE__ */ new Set();
492
627
  streak = 0;
493
- streakText = "";
628
+ /** Hash of the normalised text the current streak shares. */
629
+ streakKey = "";
630
+ /** Shortened text of the current streak, for messages. Empty exactly when the text is empty. */
631
+ streakPreview = "";
494
632
  constructor(options = {}) {
495
633
  this.threshold = assertInteger("noProgress.threshold", options.threshold ?? 3, 2);
496
634
  }
@@ -499,61 +637,100 @@ var NoProgressDetector = class {
499
637
  for (const call of record.calls) {
500
638
  if (!this.seen.has(call.outcomeSig)) {
501
639
  hasNew = true;
502
- this.seen.add(call.outcomeSig);
640
+ this.remember(call.outcomeSig);
503
641
  }
504
642
  }
643
+ const textKey = hashString(record.text);
505
644
  if (hasNew) {
506
645
  this.streak = 0;
507
- } else if (this.streak > 0 && record.text === this.streakText) {
646
+ } else if (this.streak > 0 && textKey === this.streakKey) {
508
647
  this.streak += 1;
509
648
  } else {
510
649
  this.streak = 1;
511
- this.streakText = record.text;
650
+ this.streakKey = textKey;
651
+ this.streakPreview = truncate(record.text, PREVIEW_LIMIT2);
512
652
  }
513
653
  if (this.streak < this.threshold) return void 0;
514
- const preview = truncate(this.streakText, 80);
654
+ const preview = this.streakPreview;
515
655
  return {
516
656
  stop: {
517
657
  stop: true,
518
658
  reason: "no-progress",
519
659
  code: "NO_PROGRESS",
520
- 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}").`,
660
+ detail: preview === "" ? `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}").`,
521
661
  evidence: { steps: this.streak, threshold: this.threshold, textPreview: preview }
522
662
  }
523
663
  };
524
664
  }
665
+ remember(outcome) {
666
+ this.seen.add(outcome);
667
+ if (this.seen.size > MAX_REMEMBERED_OUTCOMES) {
668
+ for (const oldest of this.seen) {
669
+ this.seen.delete(oldest);
670
+ break;
671
+ }
672
+ }
673
+ }
674
+ /** The resolved options, for the snapshot fingerprint. */
675
+ config() {
676
+ return { threshold: this.threshold };
677
+ }
678
+ snapshot(redact) {
679
+ const idle = this.streak === 0;
680
+ const preview = redact && this.streakPreview !== "" ? REDACTED : clip(this.streakPreview, MAX_PREVIEW_LENGTH);
681
+ return {
682
+ seen: [...this.seen],
683
+ streak: this.streak,
684
+ textKey: idle ? null : this.streakKey,
685
+ textPreview: idle ? "" : preview
686
+ };
687
+ }
688
+ restore(raw) {
689
+ const path = "detectors.noProgress";
690
+ const state = asRecord(raw, path);
691
+ const seen = asList(field(state, "seen", path), `${path}.seen`, MAX_REMEMBERED_OUTCOMES).map(
692
+ (v, i) => asHash(v, `${path}.seen[${i}]`)
693
+ );
694
+ const streak = asInt(field(state, "streak", path), `${path}.streak`);
695
+ const textKey = asNullable(field(state, "textKey", path), (v) => asHash(v, `${path}.textKey`));
696
+ const textPreview = asString(field(state, "textPreview", path), `${path}.textPreview`, MAX_PREVIEW_LENGTH);
697
+ const outcomes = new Set(seen);
698
+ if (outcomes.size !== seen.length) fail(`${path}.seen`, "must not repeat an outcome");
699
+ if (streak === 0 ? textKey !== null || textPreview !== "" : textKey === null) {
700
+ fail(path, "has a streak and a text that disagree");
701
+ }
702
+ this.seen = outcomes;
703
+ this.streak = streak;
704
+ this.streakKey = textKey ?? "";
705
+ this.streakPreview = textPreview;
706
+ }
525
707
  state() {
526
708
  return { threshold: this.threshold, streak: this.streak };
527
709
  }
528
710
  reset() {
529
711
  this.seen.clear();
530
712
  this.streak = 0;
531
- this.streakText = "";
713
+ this.streakKey = "";
714
+ this.streakPreview = "";
532
715
  }
533
716
  };
534
717
 
535
718
  // src/detectors/oscillation.ts
536
- var WINDOW = 4096;
537
719
  var OscillationDetector = class {
538
720
  maxPeriod;
539
721
  repeats;
540
- sigs = [];
541
- labels = [];
722
+ ring;
542
723
  current = null;
543
724
  constructor(options = {}) {
544
725
  this.maxPeriod = assertInteger("oscillation.maxPeriod", options.maxPeriod ?? 3, 2);
545
726
  this.repeats = assertInteger("oscillation.repeats", options.repeats ?? 3, 2);
727
+ this.ring = this.emptyRing();
546
728
  }
547
729
  observe(record) {
548
730
  let found;
549
731
  for (const call of record.calls) {
550
- this.sigs.push(call.callSig);
551
- this.labels.push(call.label);
552
- if (this.sigs.length > WINDOW) {
553
- this.sigs.splice(0, WINDOW / 2);
554
- this.labels.splice(0, WINDOW / 2);
555
- }
556
- found = this.detect() ?? found;
732
+ this.push(this.ring, call);
733
+ found = this.detect(this.ring) ?? found;
557
734
  }
558
735
  this.current = found ? { period: found.period, repeats: found.repeats } : null;
559
736
  if (!found) return void 0;
@@ -567,56 +744,153 @@ var OscillationDetector = class {
567
744
  }
568
745
  };
569
746
  }
747
+ /**
748
+ * What `observe` would report if these calls (not yet made) were the next ones, without recording
749
+ * them. Results are not part of the rule, so nothing is lost by not having them yet.
750
+ */
751
+ peek(calls) {
752
+ const ring = {
753
+ sigs: [...this.ring.sigs],
754
+ labels: [...this.ring.labels],
755
+ names: [...this.ring.names],
756
+ matches: [...this.ring.matches]
757
+ };
758
+ let found;
759
+ for (const call of calls) {
760
+ this.push(ring, call);
761
+ found = this.detect(ring) ?? found;
762
+ }
763
+ if (found === void 0) return void 0;
764
+ return {
765
+ stop: true,
766
+ reason: "oscillation",
767
+ code: "OSCILLATION",
768
+ detail: `Calling now would complete a cycle of ${found.period} calls (${found.cycle.join(" -> ")}) repeated ${found.repeats} times in a row.`,
769
+ evidence: found
770
+ };
771
+ }
772
+ emptyRing() {
773
+ return { sigs: [], labels: [], names: [], matches: new Array(this.maxPeriod - 1).fill(0) };
774
+ }
775
+ push(ring, call) {
776
+ const n = ring.sigs.length;
777
+ for (let period = 2; period <= this.maxPeriod; period += 1) {
778
+ const slot = period - 2;
779
+ const matched = n >= period && call.callSig === ring.sigs[n - period];
780
+ ring.matches[slot] = matched ? ring.matches[slot] + 1 : 0;
781
+ }
782
+ ring.sigs.push(call.callSig);
783
+ ring.labels.push(call.label);
784
+ ring.names.push(call.name);
785
+ if (n >= this.maxPeriod) {
786
+ ring.sigs.shift();
787
+ ring.labels.shift();
788
+ ring.names.shift();
789
+ }
790
+ }
570
791
  /** Look for a repeating cycle at the very end of the sequence. */
571
- detect() {
572
- const n = this.sigs.length;
792
+ detect(ring) {
793
+ const n = ring.sigs.length;
573
794
  for (let period = 2; period <= this.maxPeriod; period += 1) {
574
- if (n < period * this.repeats) continue;
575
- let matching = 0;
576
- for (let i = n - 1; i >= period && this.sigs[i] === this.sigs[i - period]; i -= 1) matching += 1;
577
- const cycles = Math.floor(matching / period) + 1;
795
+ const cycles = Math.floor(ring.matches[period - 2] / period) + 1;
578
796
  if (cycles < this.repeats) continue;
579
- const cycle = this.sigs.slice(n - period);
797
+ const cycle = ring.sigs.slice(n - period);
580
798
  if (new Set(cycle).size < 2) continue;
581
799
  return {
582
800
  period,
583
801
  repeats: cycles,
584
802
  threshold: this.repeats,
585
- cycle: this.labels.slice(n - period)
803
+ cycle: ring.labels.slice(n - period),
804
+ tools: ring.names.slice(n - period)
586
805
  };
587
806
  }
588
807
  return void 0;
589
808
  }
809
+ /** The resolved options, for the snapshot fingerprint. */
810
+ config() {
811
+ return { maxPeriod: this.maxPeriod, repeats: this.repeats };
812
+ }
813
+ snapshot(redact) {
814
+ const ring = this.ring;
815
+ return {
816
+ sigs: [...ring.sigs],
817
+ labels: ring.labels.map(
818
+ (label, i) => redact ? `${ring.names[i]}(${REDACTED})` : clip(label, MAX_LABEL_LENGTH)
819
+ ),
820
+ names: ring.names.map((name) => clip(name, MAX_NAME_LENGTH)),
821
+ matches: [...ring.matches],
822
+ current: this.current === null ? null : { ...this.current }
823
+ };
824
+ }
825
+ restore(raw) {
826
+ const path = "detectors.oscillation";
827
+ const state = asRecord(raw, path);
828
+ const sigs = asList(field(state, "sigs", path), `${path}.sigs`, this.maxPeriod).map(
829
+ (v, i) => asHash(v, `${path}.sigs[${i}]`)
830
+ );
831
+ const labels = asList(field(state, "labels", path), `${path}.labels`, this.maxPeriod).map(
832
+ (v, i) => asString(v, `${path}.labels[${i}]`, MAX_LABEL_LENGTH)
833
+ );
834
+ const names = asList(field(state, "names", path), `${path}.names`, this.maxPeriod).map(
835
+ (v, i) => asString(v, `${path}.names[${i}]`, MAX_NAME_LENGTH)
836
+ );
837
+ const matches = asList(field(state, "matches", path), `${path}.matches`, this.maxPeriod - 1).map(
838
+ (v, i) => asInt(v, `${path}.matches[${i}]`)
839
+ );
840
+ const current = asNullable(field(state, "current", path), (v) => {
841
+ const where = `${path}.current`;
842
+ const c = asRecord(v, where);
843
+ const period = asInt(field(c, "period", where), `${where}.period`);
844
+ const repeats = asInt(field(c, "repeats", where), `${where}.repeats`);
845
+ if (period < 2 || period > this.maxPeriod) fail(`${where}.period`, "must be between 2 and maxPeriod");
846
+ return { period, repeats };
847
+ });
848
+ if (labels.length !== sigs.length || names.length !== sigs.length) fail(path, "has rings of different lengths");
849
+ if (matches.length !== this.maxPeriod - 1) {
850
+ fail(`${path}.matches`, `must have exactly ${this.maxPeriod - 1} entries`);
851
+ }
852
+ if (matches.some((m, i) => m > 0 && sigs.length < i + 2)) {
853
+ fail(`${path}.matches`, "counts calls that are not in the ring");
854
+ }
855
+ this.ring = { sigs, labels, names, matches };
856
+ this.current = current;
857
+ }
590
858
  state() {
591
859
  return { maxPeriod: this.maxPeriod, repeats: this.repeats, current: this.current };
592
860
  }
593
861
  reset() {
594
- this.sigs = [];
595
- this.labels = [];
862
+ this.ring = this.emptyRing();
596
863
  this.current = null;
597
864
  }
598
865
  };
866
+
867
+ // src/detectors/repeatedToolCall.ts
868
+ var MAX_STEP_INDEXES = 10;
869
+ function emptyRun() {
870
+ return { key: void 0, tool: "", preview: "", length: 0, steps: [], threshold: 0, identicalResults: false };
871
+ }
599
872
  var RepeatedToolCallDetector = class {
600
- threshold;
601
- requireIdenticalResults;
602
- ignoreErrors;
603
- runKey;
604
- runTool = "";
605
- runPreview = "";
606
- runLength = 0;
607
- runSteps = [];
608
- longest = 0;
609
- constructor(options = {}) {
873
+ constructor(options = {}, policies) {
874
+ this.policies = policies;
610
875
  this.threshold = assertInteger("repeatedToolCall.threshold", options.threshold ?? 3, 2);
611
876
  this.requireIdenticalResults = options.requireIdenticalResults ?? false;
612
877
  this.ignoreErrors = options.ignoreErrors ?? false;
613
878
  }
879
+ policies;
880
+ threshold;
881
+ requireIdenticalResults;
882
+ ignoreErrors;
883
+ run = emptyRun();
884
+ longest = 0;
614
885
  observe(record) {
615
886
  let hit;
616
887
  for (const call of record.calls) {
617
888
  if (this.ignoreErrors && call.failed) continue;
618
- this.extendRun(call, record.index);
619
- if (this.runLength >= this.threshold) hit = this.evidence();
889
+ const rule = this.ruleFor(call.name);
890
+ const key = rule.identicalResults ? call.outcomeSig : call.callSig;
891
+ this.run = advance(this.run, key, call, record.index, rule);
892
+ if (this.run.length > this.longest) this.longest = this.run.length;
893
+ if (this.run.length >= this.run.threshold) hit = evidenceOf(this.run);
620
894
  }
621
895
  if (hit === void 0) return void 0;
622
896
  const results = hit.identicalResults ? " and identical results" : "";
@@ -630,46 +904,388 @@ var RepeatedToolCallDetector = class {
630
904
  }
631
905
  };
632
906
  }
633
- extendRun(call, stepIndex) {
634
- const key = this.requireIdenticalResults ? call.outcomeSig : call.callSig;
635
- if (key === this.runKey) {
636
- this.runLength += 1;
637
- if (this.runSteps[this.runSteps.length - 1] !== stepIndex) this.runSteps.push(stepIndex);
638
- } else {
639
- this.runKey = key;
640
- this.runTool = call.name;
641
- this.runPreview = call.argsPreview;
642
- this.runLength = 1;
643
- this.runSteps = [stepIndex];
907
+ /**
908
+ * What `observe` would report if these calls (not yet made, so without results) were the next
909
+ * ones, without recording them. `stepIndex` is the index the step would get.
910
+ *
911
+ * A rule that needs results to call two calls identical (`requireIdenticalResults`, a `polling`
912
+ * tool) cannot be judged before the call runs, so such a call never trips it here; it counts as
913
+ * a run of one.
914
+ */
915
+ peek(calls, stepIndex) {
916
+ let run = { ...this.run, steps: [...this.run.steps] };
917
+ let hit;
918
+ for (const [i, call] of calls.entries()) {
919
+ const rule = this.ruleFor(call.name);
920
+ const key = rule.identicalResults ? `pending#${i}` : call.callSig;
921
+ run = advance(run, key, call, stepIndex, rule);
922
+ if (run.length >= run.threshold) hit = evidenceOf(run);
923
+ }
924
+ if (hit === void 0) return void 0;
925
+ return {
926
+ stop: true,
927
+ reason: "repeated-tool-call",
928
+ code: "REPEATED_TOOL_CALL",
929
+ detail: `Calling tool "${hit.tool}" now would be call ${hit.count} in a row with identical arguments (the limit is ${hit.threshold}): ${hit.tool}(${hit.argsPreview}).`,
930
+ evidence: hit
931
+ };
932
+ }
933
+ /** The threshold and result-matching mode that apply to calls of `tool`. */
934
+ ruleFor(tool) {
935
+ const policy = this.policies?.get(tool);
936
+ if (policy?.polling === true) {
937
+ return { threshold: policy.pollingThreshold ?? policy.repeatThreshold ?? this.threshold, identicalResults: true };
644
938
  }
645
- if (this.runLength > this.longest) this.longest = this.runLength;
939
+ return { threshold: policy?.repeatThreshold ?? this.threshold, identicalResults: this.requireIdenticalResults };
646
940
  }
647
- evidence() {
941
+ /** The resolved options, for the snapshot fingerprint. */
942
+ config() {
648
943
  return {
649
- tool: this.runTool,
650
- argsPreview: this.runPreview,
651
- count: this.runLength,
652
944
  threshold: this.threshold,
653
- stepIndexes: this.runSteps.slice(-10),
654
- identicalResults: this.requireIdenticalResults
945
+ requireIdenticalResults: this.requireIdenticalResults,
946
+ ignoreErrors: this.ignoreErrors
655
947
  };
656
948
  }
949
+ snapshot(redact) {
950
+ const run = this.run;
951
+ return {
952
+ key: run.length === 0 ? null : run.key ?? null,
953
+ tool: clip(run.tool, MAX_NAME_LENGTH),
954
+ preview: redact && run.preview !== "" ? REDACTED : clip(run.preview, MAX_PREVIEW_LENGTH),
955
+ length: run.length,
956
+ steps: [...run.steps],
957
+ longest: this.longest
958
+ };
959
+ }
960
+ restore(raw) {
961
+ const path = "detectors.repeatedToolCall";
962
+ const state = asRecord(raw, path);
963
+ const key = asNullable(field(state, "key", path), (v) => asHash(v, `${path}.key`));
964
+ const tool = asString(field(state, "tool", path), `${path}.tool`, MAX_NAME_LENGTH);
965
+ const preview = asString(field(state, "preview", path), `${path}.preview`, MAX_PREVIEW_LENGTH);
966
+ const length = asInt(field(state, "length", path), `${path}.length`);
967
+ const steps = asList(field(state, "steps", path), `${path}.steps`, MAX_STEP_INDEXES).map(
968
+ (v, i) => asInt(v, `${path}.steps[${i}]`)
969
+ );
970
+ const longest = asInt(field(state, "longest", path), `${path}.longest`);
971
+ if (length === 0 ? key !== null || steps.length !== 0 : key === null || steps.length === 0 || steps.length > length) {
972
+ fail(path, "has a run whose key, length and steps disagree");
973
+ }
974
+ if (steps.some((v, i) => i > 0 && v <= steps[i - 1])) {
975
+ fail(`${path}.steps`, "must be strictly increasing");
976
+ }
977
+ if (longest < length) fail(`${path}.longest`, "must be at least the length of the current run");
978
+ this.run = length === 0 || key === null ? emptyRun() : { key, tool, preview, length, steps, ...this.ruleFor(tool) };
979
+ this.longest = longest;
980
+ }
657
981
  state() {
658
982
  return {
659
983
  threshold: this.threshold,
660
- currentRun: this.runLength > 0 ? { tool: this.runTool, length: this.runLength } : null,
984
+ currentRun: this.run.length > 0 ? { tool: this.run.tool, length: this.run.length } : null,
661
985
  longestRun: this.longest
662
986
  };
663
987
  }
664
988
  reset() {
665
- this.runKey = void 0;
666
- this.runTool = "";
667
- this.runPreview = "";
668
- this.runLength = 0;
669
- this.runSteps = [];
989
+ this.run = emptyRun();
670
990
  this.longest = 0;
671
991
  }
672
992
  };
993
+ function advance(run, key, call, stepIndex, rule) {
994
+ if (key !== run.key) {
995
+ return { key, tool: call.name, preview: call.argsPreview, length: 1, steps: [stepIndex], ...rule };
996
+ }
997
+ run.length += 1;
998
+ if (run.steps[run.steps.length - 1] !== stepIndex) {
999
+ run.steps.push(stepIndex);
1000
+ if (run.steps.length > MAX_STEP_INDEXES) run.steps.shift();
1001
+ }
1002
+ return run;
1003
+ }
1004
+ function evidenceOf(run) {
1005
+ return {
1006
+ tool: run.tool,
1007
+ argsPreview: run.preview,
1008
+ count: run.length,
1009
+ threshold: run.threshold,
1010
+ stepIndexes: [...run.steps],
1011
+ identicalResults: run.identicalResults
1012
+ };
1013
+ }
1014
+
1015
+ // src/detectors/toolLimit.ts
1016
+ var ToolLimitDetector = class {
1017
+ limits = /* @__PURE__ */ new Map();
1018
+ counts = /* @__PURE__ */ new Map();
1019
+ constructor(policies) {
1020
+ for (const { tool, limit } of policies.capped) this.limits.set(tool, limit);
1021
+ }
1022
+ observe(record) {
1023
+ let evidence;
1024
+ for (const call of record.calls) {
1025
+ const limit = this.limits.get(call.name);
1026
+ if (limit === void 0) continue;
1027
+ const calls = (this.counts.get(call.name) ?? 0) + 1;
1028
+ this.counts.set(call.name, calls);
1029
+ if (calls > limit && evidence === void 0) evidence = { tool: call.name, calls, limit };
1030
+ }
1031
+ if (evidence === void 0) return void 0;
1032
+ const stop = {
1033
+ stop: true,
1034
+ reason: "tool-limit",
1035
+ code: "TOOL_CALL_CAP",
1036
+ detail: `Tool "${evidence.tool}" was called ${evidence.calls} times; its limit is ${evidence.limit}.`,
1037
+ evidence
1038
+ };
1039
+ return { stop };
1040
+ }
1041
+ /**
1042
+ * What `observe` would report if these calls ran next, without counting them. `calls` in the
1043
+ * evidence is the number the first over-limit call would be.
1044
+ */
1045
+ peek(calls) {
1046
+ const pending = /* @__PURE__ */ new Map();
1047
+ for (const call of calls) {
1048
+ const limit = this.limits.get(call.name);
1049
+ if (limit === void 0) continue;
1050
+ const n = (pending.get(call.name) ?? this.counts.get(call.name) ?? 0) + 1;
1051
+ pending.set(call.name, n);
1052
+ if (n > limit) {
1053
+ return {
1054
+ stop: true,
1055
+ reason: "tool-limit",
1056
+ code: "TOOL_CALL_CAP",
1057
+ detail: `Calling tool "${call.name}" now would be call ${n}; its limit is ${limit}.`,
1058
+ evidence: { tool: call.name, calls: n, limit }
1059
+ };
1060
+ }
1061
+ }
1062
+ return void 0;
1063
+ }
1064
+ snapshot() {
1065
+ return [...this.limits.keys()].map((tool) => ({ tool, calls: this.counts.get(tool) ?? 0 }));
1066
+ }
1067
+ restore(raw) {
1068
+ const path = "detectors.toolLimits";
1069
+ const tools = [...this.limits.keys()];
1070
+ const entries = asList(raw, path, tools.length);
1071
+ if (entries.length !== tools.length) fail(path, `must have one entry for each of the ${tools.length} capped tools`);
1072
+ const counts = /* @__PURE__ */ new Map();
1073
+ entries.forEach((entry, i) => {
1074
+ const where = `${path}[${i}]`;
1075
+ const record = asRecord(entry, where);
1076
+ const tool = asString(field(record, "tool", where), `${where}.tool`, MAX_NAME_LENGTH);
1077
+ if (tool !== tools[i]) fail(`${where}.tool`, "is not the tool capped at that position in the options");
1078
+ counts.set(tool, asInt(field(record, "calls", where), `${where}.calls`));
1079
+ });
1080
+ this.counts.clear();
1081
+ for (const [tool, calls] of counts) this.counts.set(tool, calls);
1082
+ }
1083
+ state() {
1084
+ return [...this.limits].map(([tool, limit]) => ({ tool, limit, calls: this.counts.get(tool) ?? 0 }));
1085
+ }
1086
+ reset() {
1087
+ this.counts.clear();
1088
+ }
1089
+ };
1090
+
1091
+ // src/explain.ts
1092
+ function hint(verdict) {
1093
+ switch (verdict.reason) {
1094
+ case "max-steps":
1095
+ return "Raise maxSteps if the task really needs more steps; otherwise find out why the agent is not converging.";
1096
+ case "tool-limit":
1097
+ return "The model called a tool more often than its maxCalls allows. Raise the limit if the task needs it, or look at why the model keeps going back to this tool.";
1098
+ case "repeated-tool-call":
1099
+ 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.";
1100
+ case "oscillation":
1101
+ return "The model is flipping between the same calls. Look for two tools whose results send the model back to each other.";
1102
+ case "no-progress":
1103
+ return "Nothing new is coming back and the model is not saying anything new. Retrying the same request will not help.";
1104
+ case "finish-reason":
1105
+ 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.';
1106
+ case "budget":
1107
+ return "Raise the limit, shorten the context, or split the task.";
1108
+ case "custom":
1109
+ return "Stopped by a custom detector.";
1110
+ }
1111
+ }
1112
+ function isFault(verdict) {
1113
+ if (!verdict.stop) return false;
1114
+ return !(verdict.reason === "finish-reason" && verdict.evidence.normalized === "stop");
1115
+ }
1116
+ function explain(verdict) {
1117
+ const lines = [];
1118
+ if (verdict.stop) {
1119
+ lines.push(`Stop [${verdict.code}] ${verdict.detail}`);
1120
+ lines.push(`Hint: ${hint(verdict)}`);
1121
+ } else {
1122
+ lines.push("Continue.");
1123
+ }
1124
+ for (const warning of verdict.warnings ?? []) lines.push(`Warning [${warning.code}] ${warning.detail}`);
1125
+ return lines.join("\n");
1126
+ }
1127
+
1128
+ // src/feedback.ts
1129
+ var SAFE_TOOL_NAME = /^[A-Za-z0-9_.:/-]{1,64}$/;
1130
+ function isSafeName(name) {
1131
+ return typeof name === "string" && SAFE_TOOL_NAME.test(name);
1132
+ }
1133
+ function toolRef(name) {
1134
+ return isSafeName(name) ? `\`${name}\`` : "that tool";
1135
+ }
1136
+ function times(n) {
1137
+ return `${n} time${n === 1 ? "" : "s"}`;
1138
+ }
1139
+ function toolList(names) {
1140
+ const unique = [...new Set(names)];
1141
+ if (unique.length === 0 || !unique.every(isSafeName)) return "the same few tools";
1142
+ const refs = unique.map((n) => `\`${n}\``);
1143
+ return refs.length === 1 ? refs[0] : `${refs.slice(0, -1).join(", ")} and ${refs[refs.length - 1]}`;
1144
+ }
1145
+ var BUDGET_LABEL = {
1146
+ totalTokens: "token",
1147
+ inputTokens: "input token",
1148
+ outputTokens: "output token",
1149
+ costUsd: "cost",
1150
+ durationMs: "time"
1151
+ };
1152
+ var ELSE = "Try a different tool or different arguments, or answer with what you have.";
1153
+ function feedbackFor(verdict, phase = "check") {
1154
+ const before = phase === "preflight";
1155
+ switch (verdict.reason) {
1156
+ case "max-steps":
1157
+ return before ? "That call was not run because the step limit for this task has been reached. Do not call any more tools; answer with what you have so far." : "You have reached the limit on how many steps this task may take. Do not call any more tools; answer with what you have so far.";
1158
+ case "tool-limit": {
1159
+ const { tool, calls, limit } = verdict.evidence;
1160
+ if (limit === 0) {
1161
+ return before ? `That call was not run: you may not use ${toolRef(tool)} in this task. Use a different tool, or answer with what you have.` : `You may not use ${toolRef(tool)} in this task. Use a different tool, or answer with what you have.`;
1162
+ }
1163
+ return before ? `That call was not run: you have already used ${toolRef(tool)} ${times(limit)}, the most allowed for it in this task. Use a different tool, or answer with what you have.` : `You have called ${toolRef(tool)} ${times(calls)}, more than the ${limit} allowed for it in this task. Do not call it again; use a different tool, or answer with what you have.`;
1164
+ }
1165
+ case "repeated-tool-call": {
1166
+ const { tool, count, identicalResults } = verdict.evidence;
1167
+ const same = identicalResults ? " and got the same result each time" : "";
1168
+ if (before) {
1169
+ return `That call was not run: you have already called ${toolRef(tool)} ${times(count - 1)} in a row with the same arguments${same}, and repeating it is unlikely to give you anything new. ${ELSE}`;
1170
+ }
1171
+ return `You have called ${toolRef(tool)} ${times(count)} in a row with the same arguments${same}. Repeating it is unlikely to give you anything new. ${ELSE}`;
1172
+ }
1173
+ case "oscillation": {
1174
+ const list = toolList(verdict.evidence.tools);
1175
+ return before ? `That call was not run because it would continue a cycle you are stuck in (${list}). Try a different approach, or answer with what you have.` : `You are going round in a cycle of calls (${list}) without getting anywhere. Stop repeating it: try a different approach, or answer with what you have.`;
1176
+ }
1177
+ case "no-progress":
1178
+ return `Your last ${verdict.evidence.steps} steps added nothing new: no new tool results and the same text. Do something different, such as using another tool or approach, or answer with what you have.`;
1179
+ case "finish-reason":
1180
+ return finishReasonFeedback(verdict);
1181
+ case "budget": {
1182
+ const label = BUDGET_LABEL[verdict.evidence.metric];
1183
+ return before ? `That call was not run because the ${label} budget for this task has been used up. Answer with what you have.` : `The ${label} budget for this task has been used up. Do not start any new work; answer with what you have.`;
1184
+ }
1185
+ case "custom":
1186
+ return verdict.feedback;
1187
+ }
1188
+ }
1189
+ function finishReasonFeedback(verdict) {
1190
+ if (verdict.code === "FINISH_OTHER") {
1191
+ return "Your last step ended without a clear signal that you were finished. If you are done, give your final answer; otherwise continue with a tool call.";
1192
+ }
1193
+ if (verdict.code === "FINISH_OTHER_TEXT_ONLY") {
1194
+ return "Your last step was a text answer with no clear signal that you were finished. If that was your final answer, you are done; otherwise call a tool to continue.";
1195
+ }
1196
+ switch (verdict.evidence.normalized) {
1197
+ case "stop":
1198
+ return void 0;
1199
+ // the model finished normally: nothing went wrong
1200
+ case "length":
1201
+ return "Your last response was cut off because it reached the length limit. Continue more briefly, or give a short answer with what you have.";
1202
+ case "content-filter":
1203
+ return "Your last response was blocked by a content filter. Rephrase it or take a different approach.";
1204
+ case "error":
1205
+ return "The last step ended with an error from the provider. Try again, or answer with what you have.";
1206
+ default:
1207
+ return "The last step ended in a way that ends this run. Give your final answer with what you have.";
1208
+ }
1209
+ }
1210
+
1211
+ // src/toolPolicy.ts
1212
+ var POLICY_KEYS = ["polling", "maxCalls", "repeatThreshold", "ignore"];
1213
+ function isPlainObject(value) {
1214
+ return typeof value === "object" && value !== null && !Array.isArray(value);
1215
+ }
1216
+ function resolve(tool, raw) {
1217
+ const where = `tools[${JSON.stringify(tool)}]`;
1218
+ if (!isPlainObject(raw)) throw new TypeError(`agent-loopguard: ${where} must be an object, got ${String(raw)}`);
1219
+ for (const key of Object.keys(raw)) {
1220
+ if (!POLICY_KEYS.includes(key)) {
1221
+ throw new TypeError(`agent-loopguard: ${where} has an unknown option "${key}" (known: ${POLICY_KEYS.join(", ")})`);
1222
+ }
1223
+ }
1224
+ const ignore = raw["ignore"];
1225
+ if (ignore !== void 0 && typeof ignore !== "boolean") {
1226
+ throw new TypeError(`agent-loopguard: ${where}.ignore must be a boolean, got ${String(ignore)}`);
1227
+ }
1228
+ let polling = false;
1229
+ let pollingThreshold;
1230
+ const rawPolling = raw["polling"];
1231
+ if (typeof rawPolling === "boolean") {
1232
+ polling = rawPolling;
1233
+ } else if (rawPolling !== void 0) {
1234
+ if (!isPlainObject(rawPolling)) {
1235
+ throw new TypeError(`agent-loopguard: ${where}.polling must be a boolean or an object, got ${String(rawPolling)}`);
1236
+ }
1237
+ for (const key of Object.keys(rawPolling)) {
1238
+ if (key !== "maxIdenticalResults") {
1239
+ throw new TypeError(`agent-loopguard: ${where}.polling has an unknown option "${key}" (known: maxIdenticalResults)`);
1240
+ }
1241
+ }
1242
+ polling = true;
1243
+ const limit = rawPolling["maxIdenticalResults"];
1244
+ if (limit !== void 0) pollingThreshold = assertInteger(`${where}.polling.maxIdenticalResults`, limit, 2);
1245
+ }
1246
+ const repeat = raw["repeatThreshold"];
1247
+ const repeatThreshold = repeat === void 0 ? void 0 : assertInteger(`${where}.repeatThreshold`, repeat, 2);
1248
+ const cap = raw["maxCalls"];
1249
+ const maxCalls = cap === void 0 ? void 0 : assertInteger(`${where}.maxCalls`, cap, 0);
1250
+ return {
1251
+ ignore: ignore === true,
1252
+ polling,
1253
+ pollingThreshold,
1254
+ repeatThreshold,
1255
+ maxCalls: ignore === true ? void 0 : maxCalls
1256
+ };
1257
+ }
1258
+ var ToolPolicies = class {
1259
+ byName = /* @__PURE__ */ new Map();
1260
+ /** Tools that have a call cap, in the order they were configured. */
1261
+ capped;
1262
+ hasIgnored;
1263
+ constructor(tools) {
1264
+ if (tools !== void 0 && !isPlainObject(tools)) {
1265
+ throw new TypeError(`agent-loopguard: tools must be an object keyed by tool name, got ${String(tools)}`);
1266
+ }
1267
+ const capped = [];
1268
+ let hasIgnored = false;
1269
+ for (const [tool, raw] of Object.entries(tools ?? {})) {
1270
+ const policy = resolve(tool, raw);
1271
+ this.byName.set(tool, policy);
1272
+ if (policy.maxCalls !== void 0) capped.push({ tool, limit: policy.maxCalls });
1273
+ if (policy.ignore) hasIgnored = true;
1274
+ }
1275
+ this.capped = capped;
1276
+ this.hasIgnored = hasIgnored;
1277
+ }
1278
+ get(name) {
1279
+ return this.byName.get(name);
1280
+ }
1281
+ isIgnored(name) {
1282
+ return this.byName.get(name)?.ignore === true;
1283
+ }
1284
+ /** Plain, order-independent description of the resolved rules (used for the snapshot fingerprint). */
1285
+ config() {
1286
+ return [...this.byName].sort(([a], [b]) => a < b ? -1 : a > b ? 1 : 0);
1287
+ }
1288
+ };
673
1289
 
674
1290
  // src/guard.ts
675
1291
  var DEFAULT_MAX_STEPS = 50;
@@ -688,6 +1304,7 @@ var CustomRunner = class {
688
1304
  detail: typeof out.detail === "string" ? out.detail : "Stopped by a custom detector.",
689
1305
  evidence: out.evidence === void 0 ? null : out.evidence
690
1306
  };
1307
+ if (typeof out.feedback === "string" && out.feedback !== "") stop.feedback = out.feedback;
691
1308
  return { stop };
692
1309
  }
693
1310
  reset() {
@@ -696,26 +1313,45 @@ var CustomRunner = class {
696
1313
  function zeroTotals() {
697
1314
  return { inputTokens: 0, outputTokens: 0, totalTokens: 0, costUsd: 0, durationMs: 0 };
698
1315
  }
699
- function createGuard(options = {}) {
1316
+ function buildGuard(options, restored) {
700
1317
  const now = options.now ?? Date.now;
1318
+ const policies = new ToolPolicies(options.tools);
701
1319
  const maxSteps = options.maxSteps === false ? void 0 : new MaxStepsDetector(options.maxSteps ?? DEFAULT_MAX_STEPS);
702
- const repeated = options.repeatedToolCall === false ? void 0 : new RepeatedToolCallDetector(options.repeatedToolCall);
1320
+ const repeated = options.repeatedToolCall === false ? void 0 : new RepeatedToolCallDetector(options.repeatedToolCall, policies);
703
1321
  const oscillation = options.oscillation === false ? void 0 : new OscillationDetector(options.oscillation);
704
1322
  const noProgress = options.noProgress === false ? void 0 : new NoProgressDetector(options.noProgress);
705
1323
  const finish = options.finishReason === false ? void 0 : new FinishReasonDetector(options.finishReason);
1324
+ const toolLimit = policies.capped.length > 0 ? new ToolLimitDetector(policies) : void 0;
706
1325
  const budget = options.budget === void 0 || options.budget === false ? void 0 : new BudgetDetector(options.budget, now);
707
1326
  const customFns = options.custom === void 0 ? [] : typeof options.custom === "function" ? [options.custom] : options.custom;
708
1327
  const custom = customFns.map((fn) => new CustomRunner(fn));
709
- const optional = [finish, budget, maxSteps, repeated, oscillation, noProgress, ...custom];
710
- const detectors = optional.filter((d) => d !== void 0);
1328
+ const optional = [
1329
+ { detector: finish, scoped: false },
1330
+ { detector: budget, scoped: false },
1331
+ { detector: maxSteps, scoped: false },
1332
+ { detector: toolLimit, scoped: true },
1333
+ { detector: repeated, scoped: true },
1334
+ { detector: oscillation, scoped: true },
1335
+ { detector: noProgress, scoped: true },
1336
+ ...custom.map((detector) => ({ detector, scoped: false }))
1337
+ ];
1338
+ const detectors = optional.filter((d) => d.detector !== void 0);
711
1339
  let history = [];
1340
+ let stepCount = 0;
712
1341
  let totals = zeroTotals();
713
1342
  let toolCalls = 0;
714
1343
  let failedToolCalls = 0;
715
1344
  let lastVerdict = null;
1345
+ function withFeedback(stop) {
1346
+ if (!isFault(stop)) return stop;
1347
+ const override = options.feedback?.(stop, { phase: "check" });
1348
+ const text = typeof override === "string" ? override : feedbackFor(stop, "check");
1349
+ return text === void 0 ? stop : { ...stop, feedback: text };
1350
+ }
716
1351
  function check(step) {
717
1352
  const safe = typeof step === "object" && step !== null ? step : {};
718
- const record = buildRecord(safe, history.length);
1353
+ const record = buildRecord(safe, stepCount);
1354
+ stepCount += 1;
719
1355
  history.push(safe);
720
1356
  totals.inputTokens += record.inputTokens ?? 0;
721
1357
  totals.outputTokens += record.outputTokens ?? 0;
@@ -724,32 +1360,52 @@ function createGuard(options = {}) {
724
1360
  totals.durationMs += record.durationMs ?? 0;
725
1361
  toolCalls += record.calls.length;
726
1362
  failedToolCalls += record.calls.filter((c) => c.failed).length;
1363
+ const scopedRecord = policies.hasIgnored ? { ...record, calls: record.calls.filter((c) => !policies.isIgnored(c.name)) } : record;
727
1364
  const ctx = { totals, step: safe, history };
728
1365
  let stop;
729
1366
  const warnings = [];
730
- for (const detector of detectors) {
731
- const result = detector.observe(record, ctx);
1367
+ for (const { detector, scoped } of detectors) {
1368
+ const result = detector.observe(scoped ? scopedRecord : record, ctx);
732
1369
  if (result === void 0) continue;
733
1370
  if (result.stop !== void 0 && stop === void 0) stop = result.stop;
734
1371
  if (result.warnings !== void 0) warnings.push(...result.warnings);
735
1372
  }
736
1373
  let verdict;
737
- if (stop !== void 0) verdict = warnings.length > 0 ? { ...stop, warnings } : stop;
738
- else verdict = warnings.length > 0 ? { stop: false, warnings } : { stop: false };
1374
+ if (stop !== void 0) {
1375
+ const full = withFeedback(stop);
1376
+ verdict = warnings.length > 0 ? { ...full, warnings } : full;
1377
+ } else {
1378
+ verdict = warnings.length > 0 ? { stop: false, warnings } : { stop: false };
1379
+ }
739
1380
  lastVerdict = verdict;
740
1381
  return verdict;
741
1382
  }
1383
+ function preflight(input) {
1384
+ const pending = buildPendingCalls(input);
1385
+ const scoped = policies.hasIgnored ? pending.filter((c) => !policies.isIgnored(c.name)) : pending;
1386
+ const budgetNow = budget?.peek(totals);
1387
+ const found = budgetNow?.stop ?? maxSteps?.peek() ?? toolLimit?.peek(scoped) ?? repeated?.peek(scoped, stepCount) ?? oscillation?.peek(scoped);
1388
+ if (found === void 0) {
1389
+ const warnings = budgetNow?.warnings;
1390
+ return warnings === void 0 ? { allow: true } : { allow: true, warnings };
1391
+ }
1392
+ const override = options.feedback?.(found, { phase: "preflight" });
1393
+ const feedback = typeof override === "string" ? override : feedbackFor(found, "preflight");
1394
+ const { stop: _stop, feedback: _feedback, warnings: _warnings, ...rest } = found;
1395
+ return { allow: false, ...rest, feedback };
1396
+ }
742
1397
  function reset() {
743
1398
  history = [];
1399
+ stepCount = 0;
744
1400
  totals = zeroTotals();
745
1401
  toolCalls = 0;
746
1402
  failedToolCalls = 0;
747
1403
  lastVerdict = null;
748
- for (const detector of detectors) detector.reset();
1404
+ for (const { detector } of detectors) detector.reset();
749
1405
  }
750
1406
  function summary() {
751
1407
  return {
752
- steps: history.length,
1408
+ steps: stepCount,
753
1409
  toolCalls,
754
1410
  failedToolCalls,
755
1411
  totals: { ...totals },
@@ -759,55 +1415,118 @@ function createGuard(options = {}) {
759
1415
  oscillation: oscillation ? oscillation.state() : null,
760
1416
  noProgress: noProgress ? noProgress.state() : null,
761
1417
  finishReason: finish ? finish.state() : null,
762
- budget: budget ? budget.state() : null
1418
+ budget: budget ? budget.state() : null,
1419
+ ...toolLimit ? { toolLimits: toolLimit.state() } : {}
763
1420
  },
764
1421
  lastVerdict
765
1422
  };
766
1423
  }
1424
+ function fingerprints() {
1425
+ return {
1426
+ maxSteps: fingerprint(maxSteps ? maxSteps.config() : false),
1427
+ repeatedToolCall: fingerprint(repeated ? repeated.config() : false),
1428
+ oscillation: fingerprint(oscillation ? oscillation.config() : false),
1429
+ noProgress: fingerprint(noProgress ? noProgress.config() : false),
1430
+ finishReason: fingerprint(finish ? finish.config() : false),
1431
+ budget: fingerprint(budget ? budget.config() : false),
1432
+ tools: fingerprint(policies.config()),
1433
+ custom: fingerprint(customFns.length)
1434
+ };
1435
+ }
1436
+ function snapshot(snapshotOptions) {
1437
+ const redact = typeof snapshotOptions === "object" && snapshotOptions !== null && snapshotOptions.redact === true;
1438
+ return {
1439
+ version: SNAPSHOT_VERSION,
1440
+ steps: stepCount,
1441
+ toolCalls,
1442
+ failedToolCalls,
1443
+ totals: { ...totals },
1444
+ wallClockMs: budget ? budget.elapsedMs() : null,
1445
+ options: fingerprints(),
1446
+ detectors: {
1447
+ repeatedToolCall: repeated ? repeated.snapshot(redact) : null,
1448
+ oscillation: oscillation ? oscillation.snapshot(redact) : null,
1449
+ noProgress: noProgress ? noProgress.snapshot(redact) : null,
1450
+ finishReason: finish ? finish.snapshot() : null,
1451
+ toolLimits: toolLimit ? toolLimit.snapshot() : null
1452
+ }
1453
+ };
1454
+ }
1455
+ function applySnapshot(raw, allowLimitChanges) {
1456
+ const top = asRecord(raw, "snapshot");
1457
+ const version = field(top, "version", "snapshot");
1458
+ if (version !== SNAPSHOT_VERSION) {
1459
+ const shown = typeof version === "number" || typeof version === "string" ? JSON.stringify(version) : typeof version;
1460
+ fail("snapshot.version", `is ${shown}, but this version of agent-loopguard reads version ${SNAPSHOT_VERSION}`);
1461
+ }
1462
+ const steps = asInt(field(top, "steps", "snapshot"), "snapshot.steps");
1463
+ const calls = asInt(field(top, "toolCalls", "snapshot"), "snapshot.toolCalls");
1464
+ const failed = asInt(field(top, "failedToolCalls", "snapshot"), "snapshot.failedToolCalls");
1465
+ if (failed > calls) fail("snapshot.failedToolCalls", "cannot exceed toolCalls");
1466
+ const rawTotals = asRecord(field(top, "totals", "snapshot"), "snapshot.totals");
1467
+ const restoredTotals = {
1468
+ inputTokens: asAmount(field(rawTotals, "inputTokens", "snapshot.totals"), "snapshot.totals.inputTokens"),
1469
+ outputTokens: asAmount(field(rawTotals, "outputTokens", "snapshot.totals"), "snapshot.totals.outputTokens"),
1470
+ totalTokens: asAmount(field(rawTotals, "totalTokens", "snapshot.totals"), "snapshot.totals.totalTokens"),
1471
+ costUsd: asAmount(field(rawTotals, "costUsd", "snapshot.totals"), "snapshot.totals.costUsd"),
1472
+ durationMs: asAmount(field(rawTotals, "durationMs", "snapshot.totals"), "snapshot.totals.durationMs")
1473
+ };
1474
+ const wallClockMs = asNullable(field(top, "wallClockMs", "snapshot"), (v) => asAmount(v, "snapshot.wallClockMs"));
1475
+ const stored = asRecord(field(top, "options", "snapshot"), "snapshot.options");
1476
+ const current = fingerprints();
1477
+ const relaxed = allowLimitChanges ? ["maxSteps", "budget"] : [];
1478
+ const changed = Object.keys(current).filter(
1479
+ (key) => !relaxed.includes(key) && asString(field(stored, key, "snapshot.options"), `snapshot.options.${key}`, 64) !== current[key]
1480
+ );
1481
+ if (changed.length > 0) {
1482
+ throw new TypeError(
1483
+ `agent-loopguard: this snapshot was taken with different options (${changed.join(", ")}); restore it with the options it was taken with`
1484
+ );
1485
+ }
1486
+ const states = asRecord(field(top, "detectors", "snapshot"), "snapshot.detectors");
1487
+ const load = (key, detector) => {
1488
+ const state = section(states, key, detector !== void 0);
1489
+ detector?.restore(state);
1490
+ };
1491
+ load("repeatedToolCall", repeated);
1492
+ load("oscillation", oscillation);
1493
+ load("noProgress", noProgress);
1494
+ load("finishReason", finish);
1495
+ load("toolLimits", toolLimit);
1496
+ stepCount = steps;
1497
+ toolCalls = calls;
1498
+ failedToolCalls = failed;
1499
+ totals = restoredTotals;
1500
+ maxSteps?.restore(steps);
1501
+ budget?.restore(restoredTotals, wallClockMs);
1502
+ }
1503
+ if (restored !== void 0) {
1504
+ try {
1505
+ applySnapshot(restored.snapshot, restored.allowLimitChanges);
1506
+ } catch (error) {
1507
+ if (error instanceof TypeError) throw error;
1508
+ throw new TypeError("agent-loopguard: invalid snapshot: it could not be read");
1509
+ }
1510
+ }
767
1511
  return {
768
1512
  check,
1513
+ preflight,
769
1514
  reset,
1515
+ toJSON: () => snapshot(),
1516
+ snapshot,
770
1517
  summary,
771
1518
  get history() {
772
1519
  return Object.freeze([...history]);
773
1520
  }
774
1521
  };
775
1522
  }
776
-
777
- // src/explain.ts
778
- function hint(verdict) {
779
- switch (verdict.reason) {
780
- case "max-steps":
781
- return "Raise maxSteps if the task really needs more steps; otherwise find out why the agent is not converging.";
782
- case "repeated-tool-call":
783
- 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.";
784
- case "oscillation":
785
- return "The model is flipping between the same calls. Look for two tools whose results send the model back to each other.";
786
- case "no-progress":
787
- return "Nothing new is coming back and the model is not saying anything new. Retrying the same request will not help.";
788
- case "finish-reason":
789
- 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.';
790
- case "budget":
791
- return "Raise the limit, shorten the context, or split the task.";
792
- case "custom":
793
- return "Stopped by a custom detector.";
794
- }
795
- }
796
- function isFault(verdict) {
797
- if (!verdict.stop) return false;
798
- return !(verdict.reason === "finish-reason" && verdict.evidence.normalized === "stop");
1523
+ function createGuard(options = {}) {
1524
+ return buildGuard(options, void 0);
799
1525
  }
800
- function explain(verdict) {
801
- const lines = [];
802
- if (verdict.stop) {
803
- lines.push(`Stop [${verdict.code}] ${verdict.detail}`);
804
- lines.push(`Hint: ${hint(verdict)}`);
805
- } else {
806
- lines.push("Continue.");
807
- }
808
- for (const warning of verdict.warnings ?? []) lines.push(`Warning [${warning.code}] ${warning.detail}`);
809
- return lines.join("\n");
1526
+ function restoreGuard(snapshot, options = {}, restoreOptions = {}) {
1527
+ return buildGuard(options, { snapshot, allowLimitChanges: restoreOptions?.allowLimitChanges === true });
810
1528
  }
1529
+ createGuard.fromJSON = restoreGuard;
811
1530
 
812
1531
  // src/adapters/aiSdk.ts
813
1532
  function isRecord(value) {
@@ -918,4 +1637,4 @@ function withGuard(source, guard) {
918
1637
  };
919
1638
  }
920
1639
 
921
- export { DEFAULT_MAX_STEPS, canonicalize, createGuard, explain, isFault, runAgentLoop, stableHash, toGuardStep, toStopWhen, withGuard };
1640
+ export { DEFAULT_MAX_STEPS, canonicalize, createGuard, explain, feedbackFor, isFault, restoreGuard, runAgentLoop, stableHash, toGuardStep, toStopWhen, withGuard };