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