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