@osama.ansar/agent-loopguard 0.1.0 → 0.2.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +23 -0
- package/README.md +190 -18
- package/dist/index.cjs +840 -119
- package/dist/index.d.cts +259 -9
- package/dist/index.d.ts +259 -9
- package/dist/index.js +839 -120
- package/package.json +2 -1
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(
|
|
87
|
+
recorded(totals) {
|
|
78
88
|
return {
|
|
79
|
-
totalTokens:
|
|
80
|
-
inputTokens:
|
|
81
|
-
outputTokens:
|
|
82
|
-
costUsd:
|
|
83
|
-
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
|
-
|
|
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.
|
|
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 &&
|
|
648
|
+
} else if (this.streak > 0 && textKey === this.streakKey) {
|
|
510
649
|
this.streak += 1;
|
|
511
650
|
} else {
|
|
512
651
|
this.streak = 1;
|
|
513
|
-
this.
|
|
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 =
|
|
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:
|
|
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.
|
|
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
|
-
|
|
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.
|
|
553
|
-
this.
|
|
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 =
|
|
794
|
+
detect(ring) {
|
|
795
|
+
const n = ring.sigs.length;
|
|
575
796
|
for (let period = 2; period <= this.maxPeriod; period += 1) {
|
|
576
|
-
|
|
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 =
|
|
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:
|
|
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.
|
|
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
|
-
|
|
603
|
-
|
|
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.
|
|
621
|
-
|
|
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
|
-
|
|
636
|
-
|
|
637
|
-
|
|
638
|
-
|
|
639
|
-
|
|
640
|
-
|
|
641
|
-
|
|
642
|
-
|
|
643
|
-
|
|
644
|
-
|
|
645
|
-
|
|
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
|
-
|
|
941
|
+
return { threshold: policy?.repeatThreshold ?? this.threshold, identicalResults: this.requireIdenticalResults };
|
|
648
942
|
}
|
|
649
|
-
|
|
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
|
-
|
|
656
|
-
|
|
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.
|
|
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.
|
|
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
|
|
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 = [
|
|
712
|
-
|
|
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,
|
|
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)
|
|
740
|
-
|
|
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:
|
|
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
|
-
|
|
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
|
|
803
|
-
|
|
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;
|