bare-agent 0.26.2 → 0.28.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/README.md +7 -1
- package/bareagent.context.md +55 -2
- package/package.json +1 -1
- package/src/loop.d.ts +51 -2
- package/src/loop.js +231 -18
- package/src/provider-anthropic.d.ts +32 -2
- package/src/provider-anthropic.js +106 -4
- package/src/provider-gemini.js +10 -0
- package/src/provider-ollama.d.ts +1 -1
- package/src/provider-ollama.js +32 -9
- package/src/provider-openai.js +14 -5
- package/src/provider-stop-reason.d.ts +34 -0
- package/src/provider-stop-reason.js +200 -0
- package/src/recurse-retrieval.d.ts +1 -1
- package/src/recurse-retrieval.js +2 -2
- package/src/recurse-synthesize.js +11 -2
- package/src/recurse.js +1 -1
- package/tools/shell.d.ts +13 -2
- package/tools/shell.js +28 -8
- package/types/index.d.ts +46 -0
package/src/loop.js
CHANGED
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
'use strict';
|
|
2
2
|
|
|
3
3
|
const { ToolError, HaltError } = require('./errors');
|
|
4
|
+
const { classifyStopReason } = require('./provider-stop-reason');
|
|
4
5
|
|
|
5
6
|
/** @typedef {import('../types').Provider} Provider */
|
|
6
7
|
/** @typedef {import('../types').Message} Message */
|
|
@@ -49,6 +50,16 @@ const { ToolError, HaltError } = require('./errors');
|
|
|
49
50
|
* gate.record (via wireGate). `event.kind` discriminates the source: `'turn'` for a main-loop round,
|
|
50
51
|
* `'summarize'` for an out-of-band `ctx.summarize` call (R-C6). Both count against the budget.
|
|
51
52
|
* @property {Function} [onToolResult]
|
|
53
|
+
* @property {number} [maxIdenticalToolErrors] - BA-12 safety net (default 3). Short-circuit the run when a
|
|
54
|
+
* tool's `execute` throws this many times IN A ROW for a BYTE-IDENTICAL call (same tool + same args). A
|
|
55
|
+
* tool error is deliberately fed back to the model so it can recover — that is the point of the feedback
|
|
56
|
+
* loop — but a model re-issuing the SAME impossible call verbatim can never succeed, and spins to the
|
|
57
|
+
* budget cap with no progress (observed live: `claude-sonnet-5` retried a rejected write 8/8 times).
|
|
58
|
+
* Deliberately the NARROWEST guard: any tool call that SUCCEEDS, or the same tool called with DIFFERENT
|
|
59
|
+
* arguments, resets the streak — a model adapting its input in response to an error is genuinely
|
|
60
|
+
* recovering and is never penalised. Returns cleanly with `error: 'stuck:<tool>'` (mirrors the deny/halt
|
|
61
|
+
* returns; never throws even under `throwOnError`; transcript sealed; the model's text preserved).
|
|
62
|
+
* `0`/`Infinity` disables (restores pre-BA-12 behavior: errors are advisory forever).
|
|
52
63
|
* @property {number} [maxConsecutiveDenials] - BA-11 safety net (default 3). Short-circuit the run when
|
|
53
64
|
* `policy` denies this many tool calls IN A ROW with no allowed call in between — a governance deny is
|
|
54
65
|
* not a recoverable tool error, so a model that keeps retrying variants of a denied action would
|
|
@@ -101,13 +112,17 @@ const HARD_ROUND_LIMIT = 100;
|
|
|
101
112
|
|
|
102
113
|
// Walk the assistant tool_calls in the last assistant message and append a
|
|
103
114
|
// synthetic `role:'tool'` reply for every tool_call_id that has no matching
|
|
104
|
-
// reply.
|
|
105
|
-
//
|
|
115
|
+
// reply. Keeps msgs a valid OpenAI transcript when the loop exits between
|
|
116
|
+
// pushing assistant.tool_calls and finishing the per-tool loop.
|
|
117
|
+
//
|
|
118
|
+
// `marker` is the literal reply text, because the exit it seals is not always a halt: a caller-initiated
|
|
119
|
+
// stop() is sealed too, and stamping `[halted:…]` on it would tell a resumed model it was cut off by
|
|
120
|
+
// governance when it wasn't (and would false-positive any consumer grepping msgs for `[halted:`).
|
|
106
121
|
/**
|
|
107
122
|
* @param {Message[]} msgs
|
|
108
|
-
* @param {string}
|
|
123
|
+
* @param {string} marker - Literal content for each synthetic reply, e.g. `[halted:budget.maxCostUsd]`.
|
|
109
124
|
*/
|
|
110
|
-
function sealDanglingToolCalls(msgs,
|
|
125
|
+
function sealDanglingToolCalls(msgs, marker) {
|
|
111
126
|
for (let i = msgs.length - 1; i >= 0; i--) {
|
|
112
127
|
const m = msgs[i];
|
|
113
128
|
if (m.role !== 'assistant' || !Array.isArray(m.tool_calls)) continue;
|
|
@@ -117,7 +132,7 @@ function sealDanglingToolCalls(msgs, rule) {
|
|
|
117
132
|
}
|
|
118
133
|
for (const tc of m.tool_calls) {
|
|
119
134
|
if (!seen.has(tc.id)) {
|
|
120
|
-
msgs.push({ role: 'tool', tool_call_id: tc.id, content:
|
|
135
|
+
msgs.push({ role: 'tool', tool_call_id: tc.id, content: marker });
|
|
121
136
|
}
|
|
122
137
|
}
|
|
123
138
|
return;
|
|
@@ -243,6 +258,12 @@ class Loop {
|
|
|
243
258
|
throw new Error('[Loop] options.maxConsecutiveDenials must be a non-negative number (0 or Infinity disables)');
|
|
244
259
|
}
|
|
245
260
|
this.maxConsecutiveDenials = options.maxConsecutiveDenials != null ? options.maxConsecutiveDenials : 3;
|
|
261
|
+
// BA-12 identical-tool-error spin guard. Same shape as BA-11: default 3, 0/Infinity disables.
|
|
262
|
+
if (options.maxIdenticalToolErrors != null
|
|
263
|
+
&& (typeof options.maxIdenticalToolErrors !== 'number' || options.maxIdenticalToolErrors < 0 || Number.isNaN(options.maxIdenticalToolErrors))) {
|
|
264
|
+
throw new Error('[Loop] options.maxIdenticalToolErrors must be a non-negative number (0 or Infinity disables)');
|
|
265
|
+
}
|
|
266
|
+
this.maxIdenticalToolErrors = options.maxIdenticalToolErrors != null ? options.maxIdenticalToolErrors : 3;
|
|
246
267
|
if (options.assemble != null && typeof options.assemble !== 'function') {
|
|
247
268
|
throw new Error('[Loop] options.assemble must be a function (msgs, info) => msgs');
|
|
248
269
|
}
|
|
@@ -325,12 +346,30 @@ class Loop {
|
|
|
325
346
|
* thunk is re-evaluated each round (D4/eval-assist F2) so a tool set that grows mid-run — e.g. a skill
|
|
326
347
|
* unlocking its tools — is offered on the next round; a static array is resolved once at wire time.
|
|
327
348
|
* @param {Record<string, any>} [options={}] - Per-run overrides (system, temperature, ctx, etc.).
|
|
328
|
-
* @returns {Promise<{text: string, toolCalls: ToolCall[], usage: Usage, cost: number, error: string|null, msgs: Message[], metrics: RunMetrics, temperatureDropped?: boolean}>}
|
|
349
|
+
* @returns {Promise<{text: string, toolCalls: ToolCall[], usage: Usage, cost: number, error: string|null, stopReason: string|null, msgs: Message[], metrics: RunMetrics, temperatureDropped?: boolean}>}
|
|
329
350
|
* On halt the returned `error` is `halt:<rule>` (or `halt:unknown` if the
|
|
330
351
|
* thrown HaltError carried no `rule`), and `msgs` is sanitized so any
|
|
331
352
|
* dangling assistant `tool_calls` from the halted round are paired with
|
|
332
353
|
* synthetic `[halted]` tool replies — safe to feed back into another
|
|
333
354
|
* provider call without violating OpenAI's tool-call/tool-result pairing.
|
|
355
|
+
*
|
|
356
|
+
* BA-5 — a bound that fires PRESERVES the model's work. Every terminating path (governance halt,
|
|
357
|
+
* deny-streak, provider error under `throwOnError:false`, `stop()`, the hard round limit) returns the
|
|
358
|
+
* last non-empty assistant text in `text` rather than substituting `''`. A bound firing is normal
|
|
359
|
+
* termination for a bounded attempt, and that text is the only channel from attempt N to attempt N+1 —
|
|
360
|
+
* the caller decides what a partial result is worth. `error` remains the sole success signal: a
|
|
361
|
+
* non-empty `text` NEVER means the run converged, so never infer success from it. `text` stays `''`
|
|
362
|
+
* when the model genuinely produced none (no placeholder is invented).
|
|
363
|
+
*
|
|
364
|
+
* A caller-initiated `stop()` returns `error: null` — a deliberate stop is not a fault. (It previously
|
|
365
|
+
* fell through to the hard-round-limit return and reported that safety warning as its `error`.)
|
|
366
|
+
*
|
|
367
|
+
* BA-13 — `stopReason` (the round's NEUTRAL stop reason) is surfaced on EVERY return, and non-clean
|
|
368
|
+
* terminal rounds are error-tagged instead of laundered into `error: null`: a safety `refusal` returns
|
|
369
|
+
* `error: 'refusal'` and a `context_exceeded` returns `error: 'context_exceeded'`, both with partial
|
|
370
|
+
* text preserved (BA-5). BEHAVIOR CHANGE: a refused round that previously returned `error: null` with
|
|
371
|
+
* empty text now returns `error: 'refusal'` — the point of the fix. `pause_turn` is NOT terminal: the
|
|
372
|
+
* loop resumes (bounded by the hard round limit / gate). `error` stays the sole success signal.
|
|
334
373
|
* @throws {Error} `[Loop] Tool is missing a name` — when a tool has no name or a non-string name.
|
|
335
374
|
* @throws {Error} `[Loop] Tool "X" is missing an execute() function` — when execute is not a function.
|
|
336
375
|
* @throws {Error} `[Loop] Tool "X" has invalid parameters` — when parameters is not an object.
|
|
@@ -387,6 +426,20 @@ class Loop {
|
|
|
387
426
|
|
|
388
427
|
let lastUsage = { inputTokens: 0, outputTokens: 0 };
|
|
389
428
|
let totalCost = 0;
|
|
429
|
+
// BA-5: the most recent NON-EMPTY assistant text this run produced. Every bound that can end a run —
|
|
430
|
+
// governance halt, deny-streak, provider error, caller stop, hard round limit — returns this instead of
|
|
431
|
+
// substituting `text: ''`. In a ralph-style outer loop (`while red and under-cap: run the worker`) a bound
|
|
432
|
+
// firing is NORMAL termination, not an exception, and the worker's own account of what it did and ruled
|
|
433
|
+
// out is the only channel from attempt N to attempt N+1 — dropping it silently deletes the loop's ratchet.
|
|
434
|
+
// The caller decides what a partial result is worth; the library must not decide it is worth nothing.
|
|
435
|
+
// Stays '' when the model never produced text (nothing to preserve — we never invent a placeholder).
|
|
436
|
+
let lastText = '';
|
|
437
|
+
// BA-13: the NEUTRAL stop reason of the most recent completed round (post-provider-normalization).
|
|
438
|
+
// Surfaced on EVERY return so a caller can branch on WHY a run ended, not just its `error` tag — the
|
|
439
|
+
// load-bearing companion to the classifier (a terminal `error` says "not a clean finish"; `stopReason`
|
|
440
|
+
// says which kind). Stays null until the first round completes, and across a provider error / a
|
|
441
|
+
// pre-round stop() it holds the last round's value (or null if none ran).
|
|
442
|
+
let lastStopReason = null;
|
|
390
443
|
// BA-10: sticky across rounds — true if ANY round's `temperature` was dropped by the model (400,
|
|
391
444
|
// unsupported/deprecated) and retried without it. Surfaced on the result so an upstream receipt
|
|
392
445
|
// (recurse's refineLeaf) can report the EFFECTIVE temperature rather than the ignored request.
|
|
@@ -394,6 +447,39 @@ class Loop {
|
|
|
394
447
|
// BA-11: consecutive policy-deny counter (reset by any tool call that PASSES policy). When it reaches
|
|
395
448
|
// this.maxConsecutiveDenials the run short-circuits cleanly — see the deny block below.
|
|
396
449
|
let consecutiveDenials = 0;
|
|
450
|
+
// BA-12: a policy DENY is not the only way a model can spin. A tool whose `execute` keeps THROWING is
|
|
451
|
+
// fed the error back as a tool result (deliberately — that's how a model recovers from a bad path), but
|
|
452
|
+
// a model that re-issues the BYTE-IDENTICAL call against an error that cannot be recovered from will
|
|
453
|
+
// spin to the budget cap with zero progress. We count only IDENTICAL repeats of a FAILING call
|
|
454
|
+
// (same tool + same args), which is the narrowest guard that catches the observed spin: a model that
|
|
455
|
+
// VARIES its arguments in response to an error is genuinely recovering and must never be penalised.
|
|
456
|
+
let identicalErrors = 0;
|
|
457
|
+
/** @type {string|null} */
|
|
458
|
+
let lastErrorFingerprint = null;
|
|
459
|
+
// BA-12: record a FAILING tool call and short-circuit if it is the Nth byte-identical repeat. Shared by
|
|
460
|
+
// the two ways a call can fail with an error fed back to the model: `execute` threw, OR the tool name is
|
|
461
|
+
// unknown (a hallucinated tool). Both feed an error result the model can spin on identically, so both
|
|
462
|
+
// must count — an unknown-tool spin is the same budget burn as a throwing-tool spin. Only a DIFFERENT
|
|
463
|
+
// tool/args (recovery) or a SUCCESS resets the streak. Returns the clean stuck-result to return, or null
|
|
464
|
+
// to continue. Args are fingerprinted defensively: an unstringifiable payload never matches, so the
|
|
465
|
+
// guard degrades to off rather than throwing inside the failure path.
|
|
466
|
+
const recordToolFailure = (/** @type {ToolCall} */ tc) => {
|
|
467
|
+
let fingerprint = null;
|
|
468
|
+
try { fingerprint = `${tc.name}:${JSON.stringify(tc.arguments)}`; } catch { fingerprint = null; }
|
|
469
|
+
if (fingerprint !== null && fingerprint === lastErrorFingerprint) identicalErrors += 1;
|
|
470
|
+
else { identicalErrors = 1; lastErrorFingerprint = fingerprint; }
|
|
471
|
+
if (this.maxIdenticalToolErrors > 0 && Number.isFinite(this.maxIdenticalToolErrors)
|
|
472
|
+
&& identicalErrors >= this.maxIdenticalToolErrors) {
|
|
473
|
+
const stuckTag = `stuck:${tc.name}`;
|
|
474
|
+
// Same clean exit as the deny-streak and halt paths: seal the transcript, never throw (even under
|
|
475
|
+
// throwOnError), and preserve the model's text (BA-5) so a bounded attempt still teaches its successor.
|
|
476
|
+
sealDanglingToolCalls(msgs, `[halted:${stuckTag}]`);
|
|
477
|
+
this._reportError('stuck', new Error(`tool "${tc.name}" failed ${identicalErrors} times with identical arguments`), { rule: stuckTag, attempts: identicalErrors });
|
|
478
|
+
this._safeEmit({ type: 'loop:done', data: { text: lastText, stuck: true, rule: stuckTag, cost: totalCost } });
|
|
479
|
+
return { text: lastText, toolCalls: [], usage: lastUsage, cost: totalCost, error: stuckTag, stopReason: lastStopReason, msgs, metrics: finalizeMetrics(), ...(temperatureDropped && { temperatureDropped: true }) };
|
|
480
|
+
}
|
|
481
|
+
return null;
|
|
482
|
+
};
|
|
397
483
|
|
|
398
484
|
// The meter (Feature 3): bareagent is the canonical run counter. Accumulates across rounds and is
|
|
399
485
|
// returned as `result.metrics`. `tokens` is CUMULATIVE over all four tiers (fixes the last-round-only
|
|
@@ -617,10 +703,19 @@ class Loop {
|
|
|
617
703
|
} catch (err) {
|
|
618
704
|
this._reportError('provider', err, { round });
|
|
619
705
|
if (this.throwOnError) throw err;
|
|
620
|
-
|
|
706
|
+
// BA-5: a mid-run provider failure must not erase the work of the rounds that succeeded.
|
|
707
|
+
return { text: lastText, toolCalls: [], usage: lastUsage, cost: totalCost, error: err.message, stopReason: lastStopReason, msgs, metrics: finalizeMetrics(), ...(temperatureDropped && { temperatureDropped: true }) };
|
|
621
708
|
}
|
|
622
709
|
|
|
623
710
|
lastUsage = result.usage || lastUsage;
|
|
711
|
+
// BA-5: capture the text BEFORE anything downstream can halt (onLlmResult forwards this round's spend
|
|
712
|
+
// to the gate, which is exactly where a budget cap trips — the text of the round that tripped the cap
|
|
713
|
+
// is the text most worth keeping). A tool-call-only round carries no text, so hold the last non-empty
|
|
714
|
+
// one rather than letting a silent round erase an earlier account.
|
|
715
|
+
if (typeof result.text === 'string' && result.text.trim() !== '') lastText = result.text;
|
|
716
|
+
// BA-13: capture this round's neutral stop reason for surfacing on the run's return (every exit
|
|
717
|
+
// path reads lastStopReason). Non-string / absent ⇒ null (the provider's pre-BA-6 degrade).
|
|
718
|
+
lastStopReason = typeof result.stopReason === 'string' ? result.stopReason : null;
|
|
624
719
|
if (result.temperatureDropped) temperatureDropped = true;
|
|
625
720
|
// Publish the latest measured usage to ctx (non-enumerable, fail-open) so a transcript-bound seam —
|
|
626
721
|
// e.g. F2 stash auto-compaction — can read EXACT provider-counted `inputTokens` to gauge context
|
|
@@ -666,12 +761,67 @@ class Loop {
|
|
|
666
761
|
}
|
|
667
762
|
}
|
|
668
763
|
|
|
764
|
+
// BA-13: classify this round's terminal signal against the neutral stop-reason vocabulary. BA-6
|
|
765
|
+
// short-circuited exactly one non-clean reason (max_tokens); this gate is the general form. It sits
|
|
766
|
+
// AFTER metering (the tokens were really spent — the gate must see them) and BEFORE tool execution,
|
|
767
|
+
// which is the load-bearing half: a non-final round's tool calls were cut off mid-generation, so
|
|
768
|
+
// their arguments are missing keys. Executing one is exactly how BA-4 emptied a 1789-line file
|
|
769
|
+
// (`shell_write` reached the fs with no `content`). A COMPLETE call always arrives tagged 'tool_use',
|
|
770
|
+
// never a truncation/refusal (measured on the real API, poc/ba6-stop-reason-mapping.mjs), so refusing
|
|
771
|
+
// the tool calls of ANY non-clean terminal round discards nothing legitimate and closes the data-loss
|
|
772
|
+
// path for EVERY tool — the BA-4 protocol-layer closure applied uniformly (see classifyStopReason).
|
|
773
|
+
//
|
|
774
|
+
// We report; the caller decides. No auto-retry: that doubles spend against the gate's budget, and the
|
|
775
|
+
// right recovery (raise the cap? re-gate the refusal? shorten the context?) is the caller's, not the
|
|
776
|
+
// library's. `lastText` (BA-5) preserves the partial work on every terminal leg.
|
|
777
|
+
const terminal = classifyStopReason(result.stopReason);
|
|
778
|
+
if (terminal === 'resume') {
|
|
779
|
+
// BA-13: `pause_turn` — a RESUMABLE server-side tool pause (the API's server-tool loop hit its
|
|
780
|
+
// per-turn iteration cap mid-turn). NOT a finish, NOT an error. Resuming REQUIRES re-sending the
|
|
781
|
+
// paused assistant turn: the provider detects the trailing server-tool block and continues where
|
|
782
|
+
// it left off (the documented Anthropic pause_turn protocol — "re-send the user message and
|
|
783
|
+
// assistant response"). A bare `continue` WITHOUT appending re-sends byte-identical input, so the
|
|
784
|
+
// server restarts the turn from scratch and pauses again, spinning to HARD_ROUND_LIMIT (100 paid
|
|
785
|
+
// calls) instead of resuming. So append the assistant turn — its partial text plus the
|
|
786
|
+
// provider-native server-tool/thinking blocks (which the Anthropic provider replays via
|
|
787
|
+
// providerBlocks) — BEFORE continuing, exactly as the tool-execution path pushes its assistant
|
|
788
|
+
// turn. Only push when there is something to carry: an empty assistant turn (no text, no blocks)
|
|
789
|
+
// is wire-invalid, and a pause with no partial output cannot be advanced anyway — HARD_ROUND_LIMIT
|
|
790
|
+
// (or the gate) bounds that pathological case. No client tool_calls are pushed: a server pause
|
|
791
|
+
// does not carry an unpaired client call, and pushing one would orphan it (wire-invalid) — the
|
|
792
|
+
// same BA-4 refusal principle as the other non-clean terminal legs.
|
|
793
|
+
const hasText = typeof result.text === 'string' && result.text.trim() !== '';
|
|
794
|
+
if (hasText || result.providerBlocks) {
|
|
795
|
+
msgs.push({ role: 'assistant', content: result.text || null, ...(result.providerBlocks && { providerBlocks: result.providerBlocks }) });
|
|
796
|
+
}
|
|
797
|
+
this._safeEmit({ type: 'loop:resume', data: { round, stopReason: lastStopReason } });
|
|
798
|
+
continue;
|
|
799
|
+
}
|
|
800
|
+
if (terminal) {
|
|
801
|
+
// terminal ∈ {'truncated','refusal','context_exceeded'} — a non-clean terminal round. Error-tag it
|
|
802
|
+
// (NOT just surface stopReason): `recurse`'s worker path and the bareloop adopter both branch on
|
|
803
|
+
// `error`, and 0.27.0's "error is the sole success signal" invariant must stay true — an
|
|
804
|
+
// `error:null` + `stopReason:'refusal'` would re-breed BA-6 for these legs.
|
|
805
|
+
const errorTag = terminal === 'truncated' ? 'truncated:max_tokens' : terminal;
|
|
806
|
+
const dropped = (result.toolCalls || []).length;
|
|
807
|
+
// Seal the transcript with the partial text only. Deliberately NOT the tool_calls: pushing a call
|
|
808
|
+
// we refuse to execute would orphan it (a tool_call with no tool_result is a wire-invalid
|
|
809
|
+
// transcript on Anthropic). Empty text pushes nothing — a bare empty assistant turn is also invalid.
|
|
810
|
+
if (typeof result.text === 'string' && result.text.trim() !== '') {
|
|
811
|
+
msgs.push({ role: 'assistant', content: result.text });
|
|
812
|
+
}
|
|
813
|
+
this._safeEmit({ type: 'loop:done', data: { text: lastText, ...(terminal === 'truncated' && { truncated: true }), terminal, stopReason: lastStopReason, droppedToolCalls: dropped, cost: totalCost } });
|
|
814
|
+
return { text: lastText, toolCalls: [], usage: lastUsage, cost: totalCost, error: errorTag, stopReason: lastStopReason, msgs, metrics: finalizeMetrics(), ...(temperatureDropped && { temperatureDropped: true }) };
|
|
815
|
+
}
|
|
816
|
+
|
|
669
817
|
// No tool calls — LLM gave a final text response
|
|
670
818
|
if (!result.toolCalls || result.toolCalls.length === 0) {
|
|
671
819
|
this._safeEmit({ type: 'loop:text', data: { text: result.text } });
|
|
672
820
|
this._safeCall('onText', this.onText, result.text);
|
|
673
821
|
this._safeEmit({ type: 'loop:done', data: { text: result.text, usage: lastUsage, cost: totalCost } });
|
|
674
|
-
|
|
822
|
+
// BA-7: the final turn carries its native blocks too — a caller that replays this transcript
|
|
823
|
+
// into a fresh run (or persists it) gets a faithful one rather than a silently lossy copy.
|
|
824
|
+
msgs.push({ role: 'assistant', content: result.text, ...(result.providerBlocks && { providerBlocks: result.providerBlocks }) });
|
|
675
825
|
// RT-2 F2: residual harvest of the surviving window (incl. this final answer) on clean completion.
|
|
676
826
|
// `trim` only harvests EVICTED turns; without this, the never-evicted tail would diverge from an
|
|
677
827
|
// end-of-task batch. The trimmer's idempotent key means it never re-writes what eviction harvested.
|
|
@@ -681,7 +831,7 @@ class Loop {
|
|
|
681
831
|
try { await flush(msgs, ctx); }
|
|
682
832
|
catch (err) { if (err instanceof HaltError) throw err; this._reportError('trim-flush', err, { round }); }
|
|
683
833
|
}
|
|
684
|
-
return { text: result.text, toolCalls: [], usage: lastUsage, cost: totalCost, error: null, msgs, metrics: finalizeMetrics(), ...(temperatureDropped && { temperatureDropped: true }) };
|
|
834
|
+
return { text: result.text, toolCalls: [], usage: lastUsage, cost: totalCost, error: null, stopReason: lastStopReason, msgs, metrics: finalizeMetrics(), ...(temperatureDropped && { temperatureDropped: true }) };
|
|
685
835
|
}
|
|
686
836
|
|
|
687
837
|
// Execute tool calls
|
|
@@ -693,6 +843,12 @@ class Loop {
|
|
|
693
843
|
type: 'function',
|
|
694
844
|
function: { name: tc.name, arguments: JSON.stringify(tc.arguments) },
|
|
695
845
|
})),
|
|
846
|
+
// BA-7: carry provider-native blocks (Anthropic `thinking`/`redacted_thinking`) the normalized
|
|
847
|
+
// shape can't express. THIS is the turn the API contract is about — thinking blocks must be
|
|
848
|
+
// echoed back unchanged, signature included, when continuing a tool-use conversation. Opaque
|
|
849
|
+
// to the Loop: it never reads them, it only refuses to lose them. Providers that send none add
|
|
850
|
+
// nothing, so the message stays byte-identical to today.
|
|
851
|
+
...(result.providerBlocks && { providerBlocks: result.providerBlocks }),
|
|
696
852
|
});
|
|
697
853
|
|
|
698
854
|
for (const tc of result.toolCalls) {
|
|
@@ -708,6 +864,11 @@ class Loop {
|
|
|
708
864
|
const errMsg = `[Loop] Unknown tool: ${tc.name}`;
|
|
709
865
|
msgs.push({ role: 'tool', tool_call_id: tc.id, content: errMsg });
|
|
710
866
|
this._safeEmit({ type: 'loop:tool_result', data: { tool: tc.name, error: errMsg } });
|
|
867
|
+
// BA-12: an unknown-tool error is fed back like any tool error, and a weak model can spin on it
|
|
868
|
+
// verbatim just the same (it hallucinates the same missing tool every round). Count it so the
|
|
869
|
+
// spin guard catches it too — otherwise the run burns to the hard round limit / budget cap.
|
|
870
|
+
const stuck = recordToolFailure(tc);
|
|
871
|
+
if (stuck) return stuck;
|
|
711
872
|
continue;
|
|
712
873
|
}
|
|
713
874
|
|
|
@@ -776,10 +937,10 @@ class Loop {
|
|
|
776
937
|
// Pair any still-dangling tool_calls from this round so the returned transcript stays
|
|
777
938
|
// provider-valid (same seal the halt path uses), then exit cleanly — no throw even under
|
|
778
939
|
// throwOnError, mirroring the governance-halt contract.
|
|
779
|
-
sealDanglingToolCalls(msgs, denyTag);
|
|
940
|
+
sealDanglingToolCalls(msgs, `[halted:${denyTag}]`);
|
|
780
941
|
this._reportError('denied', new Error(`policy denied ${consecutiveDenials} consecutive tool calls (${tc.name})`), { rule: denyTag, denials: consecutiveDenials });
|
|
781
|
-
this._safeEmit({ type: 'loop:done', data: { text:
|
|
782
|
-
return { text:
|
|
942
|
+
this._safeEmit({ type: 'loop:done', data: { text: lastText, denied: true, rule: denyTag, cost: totalCost } });
|
|
943
|
+
return { text: lastText, toolCalls: [], usage: lastUsage, cost: totalCost, error: denyTag, stopReason: lastStopReason, msgs, metrics: finalizeMetrics(), ...(temperatureDropped && { temperatureDropped: true }) };
|
|
783
944
|
}
|
|
784
945
|
continue;
|
|
785
946
|
}
|
|
@@ -798,6 +959,11 @@ class Loop {
|
|
|
798
959
|
const content = typeof toolResult === 'string' ? toolResult : JSON.stringify(toolResult);
|
|
799
960
|
msgs.push({ role: 'tool', tool_call_id: tc.id, content });
|
|
800
961
|
this._safeEmit({ type: 'loop:tool_result', data: { tool: tc.name, result: content } });
|
|
962
|
+
// BA-12: a call that SUCCEEDED is progress — clear the identical-failure streak. Without this, a
|
|
963
|
+
// tool that fails, is recovered from, then fails identically much later would accumulate across
|
|
964
|
+
// unrelated stretches of the run and short-circuit a healthy loop.
|
|
965
|
+
identicalErrors = 0;
|
|
966
|
+
lastErrorFingerprint = null;
|
|
801
967
|
} catch (err) {
|
|
802
968
|
// A HaltError from a tool body is a deliberate governance exit, not a tool failure — re-throw it
|
|
803
969
|
// like every other seam (the outer catch pairs dangling tool_calls + returns halt cleanly). Ordinary
|
|
@@ -807,6 +973,12 @@ class Loop {
|
|
|
807
973
|
const errMsg = `[Loop] Tool error: ${toolError.message}`;
|
|
808
974
|
msgs.push({ role: 'tool', tool_call_id: tc.id, content: errMsg });
|
|
809
975
|
this._safeEmit({ type: 'loop:tool_result', data: { tool: tc.name, error: errMsg } });
|
|
976
|
+
|
|
977
|
+
// BA-12: `execute` threw. Count this failing call and short-circuit on the Nth identical repeat —
|
|
978
|
+
// see recordToolFailure for the full rationale (only a byte-identical repeat counts; a model that
|
|
979
|
+
// adapts its args, or a success, resets the streak).
|
|
980
|
+
const stuck = recordToolFailure(tc);
|
|
981
|
+
if (stuck) return stuck;
|
|
810
982
|
}
|
|
811
983
|
|
|
812
984
|
// BA1: forward tool result/error to gate.record (via wireGate) with ctx in
|
|
@@ -837,19 +1009,55 @@ class Loop {
|
|
|
837
1009
|
// synthetic `[halted]` replies so the returned msgs is a valid
|
|
838
1010
|
// OpenAI-shaped transcript — consumers can feed it back into another
|
|
839
1011
|
// provider call without tripping the tool-call/tool-result pairing.
|
|
840
|
-
sealDanglingToolCalls(msgs, rule);
|
|
1012
|
+
sealDanglingToolCalls(msgs, `[halted:${rule}]`);
|
|
841
1013
|
this._reportError('halt', err, { rule, reason: err.decision?.reason ?? null });
|
|
842
|
-
|
|
843
|
-
|
|
1014
|
+
// BA-5: the rule tag survives on `error`; so does the work. A halt is how a bounded attempt is
|
|
1015
|
+
// SUPPOSED to end — the caller reads `error` to know it was bounded and `text` to learn from it.
|
|
1016
|
+
this._safeEmit({ type: 'loop:done', data: { text: lastText, halted: true, rule, cost: totalCost } });
|
|
1017
|
+
return { text: lastText, toolCalls: [], usage: lastUsage, cost: totalCost, error: `halt:${rule}`, stopReason: lastStopReason, msgs, metrics: finalizeMetrics(), ...(temperatureDropped && { temperatureDropped: true }) };
|
|
844
1018
|
}
|
|
845
1019
|
throw err;
|
|
846
1020
|
}
|
|
847
1021
|
|
|
1022
|
+
// BA-5 / BA-3: a caller-initiated stop() breaks the round loop and lands here. It is NOT a fault, and it
|
|
1023
|
+
// is not the hard limit — reporting it as either (which is what fall-through did: it returned the
|
|
1024
|
+
// safety-limit warning below, indistinguishable from a runaway) forces every caller to keep a
|
|
1025
|
+
// `stoppedByBound` flag to un-lie the return value. A deliberate stop returns error:null + the work.
|
|
1026
|
+
if (this._stopped) {
|
|
1027
|
+
// stop() can land mid-round, between an assistant tool_calls message and its results — pair the
|
|
1028
|
+
// stragglers so the returned transcript stays provider-valid (the same seal the halt path applies).
|
|
1029
|
+
sealDanglingToolCalls(msgs, '[stopped]');
|
|
1030
|
+
// RT-2 F2 residual harvest, same as the clean-completion path: a stop is a DELIBERATE end (error:null,
|
|
1031
|
+
// transcript final), so the surviving window must be harvested or a stopped run silently loses every
|
|
1032
|
+
// never-evicted turn that an identical naturally-ending run would have kept. (A governance halt is
|
|
1033
|
+
// deliberately NOT flushed — that is an abort, not an end.) Fail-open / HaltError per the trim contract.
|
|
1034
|
+
const flushOnStop = this.trim && /** @type {any} */ (this.trim).flush;
|
|
1035
|
+
if (typeof flushOnStop === 'function') {
|
|
1036
|
+
try {
|
|
1037
|
+
await flushOnStop(msgs, ctx);
|
|
1038
|
+
} catch (err) {
|
|
1039
|
+
// We are PAST the outer HaltError handler here, so a governance halt raised during the harvest
|
|
1040
|
+
// (e.g. a write-gate deny) cannot be re-thrown — that would escape run() as an exception and break
|
|
1041
|
+
// the contract that a HaltError is always a clean return. Convert it in place, as the outer handler
|
|
1042
|
+
// would have.
|
|
1043
|
+
if (err instanceof HaltError) {
|
|
1044
|
+
const rule = err.rule || 'unknown';
|
|
1045
|
+
this._reportError('halt', err, { rule, reason: err.decision?.reason ?? null });
|
|
1046
|
+
this._safeEmit({ type: 'loop:done', data: { text: lastText, halted: true, rule, cost: totalCost } });
|
|
1047
|
+
return { text: lastText, toolCalls: [], usage: lastUsage, cost: totalCost, error: `halt:${rule}`, stopReason: lastStopReason, msgs, metrics: finalizeMetrics(), ...(temperatureDropped && { temperatureDropped: true }) };
|
|
1048
|
+
}
|
|
1049
|
+
this._reportError('trim-flush', err, { phase: 'stop' });
|
|
1050
|
+
}
|
|
1051
|
+
}
|
|
1052
|
+
this._safeEmit({ type: 'loop:done', data: { text: lastText, stopped: true, cost: totalCost } });
|
|
1053
|
+
return { text: lastText, toolCalls: [], usage: lastUsage, cost: totalCost, error: null, stopReason: lastStopReason, msgs, metrics: finalizeMetrics(), ...(temperatureDropped && { temperatureDropped: true }) };
|
|
1054
|
+
}
|
|
1055
|
+
|
|
848
1056
|
// Hard safety limit — should never fire under normal usage; bareguard's
|
|
849
1057
|
// limits.maxTurns (or the LLM's natural completion) ends the loop first.
|
|
850
1058
|
const warning = `[Loop] hit internal safety limit of ${HARD_ROUND_LIMIT} rounds. Wire bareguard for proper governance — see bare-agent/bareguard.`;
|
|
851
|
-
this._safeEmit({ type: 'loop:done', data: { text:
|
|
852
|
-
return { text:
|
|
1059
|
+
this._safeEmit({ type: 'loop:done', data: { text: lastText, warning, cost: totalCost } });
|
|
1060
|
+
return { text: lastText, toolCalls: [], usage: lastUsage, cost: totalCost, error: warning, stopReason: lastStopReason, msgs, metrics: finalizeMetrics(), ...(temperatureDropped && { temperatureDropped: true }) };
|
|
853
1061
|
}
|
|
854
1062
|
|
|
855
1063
|
/**
|
|
@@ -922,7 +1130,7 @@ class Loop {
|
|
|
922
1130
|
* @param {string} text - User message.
|
|
923
1131
|
* @param {ToolDef[]} [tools=[]] - Tool definitions.
|
|
924
1132
|
* @param {Record<string, any>} [options={}] - Per-run overrides.
|
|
925
|
-
* @returns {Promise<{text: string, toolCalls: ToolCall[], usage: Usage, cost: number, error: string|null, msgs: Message[], metrics: RunMetrics, temperatureDropped?: boolean}>}
|
|
1133
|
+
* @returns {Promise<{text: string, toolCalls: ToolCall[], usage: Usage, cost: number, error: string|null, stopReason: string|null, msgs: Message[], metrics: RunMetrics, temperatureDropped?: boolean}>}
|
|
926
1134
|
*/
|
|
927
1135
|
async chat(text, tools = [], options = {}) {
|
|
928
1136
|
this._history.push({ role: 'user', content: text });
|
|
@@ -934,6 +1142,11 @@ class Loop {
|
|
|
934
1142
|
return result;
|
|
935
1143
|
}
|
|
936
1144
|
|
|
1145
|
+
/**
|
|
1146
|
+
* Request a clean stop. The current tool call finishes; the loop then exits at the next boundary and
|
|
1147
|
+
* `run()` resolves with `error: null` and the last text the model produced (BA-5) — a deliberate stop
|
|
1148
|
+
* is not a fault, and callers should not need a `stoppedByBound` flag to un-lie the return value.
|
|
1149
|
+
*/
|
|
937
1150
|
stop() {
|
|
938
1151
|
this._stopped = true;
|
|
939
1152
|
}
|
|
@@ -15,9 +15,19 @@ export type AnthropicOptions = {
|
|
|
15
15
|
*/
|
|
16
16
|
baseUrl?: string | undefined;
|
|
17
17
|
/**
|
|
18
|
-
* - Opt-in prompt caching: send the system prompt with a `cache_control` breakpoint so Anthropic caches it. Anthropic does NOT auto-cache, so without this its cache tiers are always 0. Overridable per call via `generate(..., { cacheSystem })`.
|
|
18
|
+
* - Opt-in prompt caching: send the system prompt with a `cache_control` breakpoint so Anthropic caches it. Anthropic does NOT auto-cache, so without this its cache tiers are always 0. Overridable per call via `generate(..., { cacheSystem })`. NOTE: on its own this rarely helps a tool loop — Anthropic's minimum cacheable prefix is 1024–4096 tokens (model-dependent) and a typical system persona is a few hundred, so it silently never caches. The transcript is where a tool loop's tokens actually live — see `cacheMessages`.
|
|
19
19
|
*/
|
|
20
20
|
cacheSystem?: boolean | undefined;
|
|
21
|
+
/**
|
|
22
|
+
* - Opt-in TRANSCRIPT caching (BA-1): roll a `cache_control` breakpoint onto the last content block of the last message, so Anthropic caches the whole conversation prefix and the loop stops re-buying it at full price every round. In a tool loop the transcript IS the tool results (file bodies from `shell_read`) and it always ENDS on one, which `_toAnthropicMessage` rebuilds from scratch — so no caller-side seam (`assemble` included) can reach it, and this has to live in the provider. Measured on `claude-sonnet-5` with a ~15k-token tool-result transcript (`poc/ba1-message-caching.mjs`): steady state **$0.0753 → $0.0110 per round, 6.8x cheaper**; round 1 pays a 1.25x cache WRITE once. Off by default — it changes the wire format, so adopters opt in. Overridable per call via `generate(..., { cacheMessages })`. **Interaction:** a destructive `trim`/stash fold that rewrites the transcript PREFIX invalidates the cache (the prefix is the cache key), so a fold must keep the head stable or you re-pay the write premium every round for nothing.
|
|
23
|
+
*/
|
|
24
|
+
cacheMessages?: boolean | undefined;
|
|
25
|
+
/**
|
|
26
|
+
* - Opt-in extended thinking (BA-7), forwarded to `body.thinking` VERBATIM and unvalidated — e.g. `{ type: 'adaptive' }`, or `{ type: 'adaptive', display: 'summarized' }` to surface the reasoning (the default `display` is `'omitted'`). Deliberately opaque: this parameter has already broken once (`budget_tokens` was removed and now 400s on `claude-sonnet-5` / Opus 4.7+), and a library that reshapes it would need a release every time the API moves. Overridable per call via `generate(..., { thinking })`; pass `null` there to suppress an instance default.
|
|
27
|
+
*
|
|
28
|
+
* **MEASURED CAVEAT — this option does not "turn thinking on".** On `claude-sonnet-5` adaptive thinking is ALREADY the default: sending this changed the observed thinking rate not at all (2/10 rounds with it vs 3/10 without — `poc/ba7-adaptive-default.mjs`). Its real use is pinning the mode and reaching `display`/`effort`. The change that mattered is that thinking blocks are now PRESERVED and replayed (see `Message.providerBlocks`), which happens whether or not you ever set this.
|
|
29
|
+
*/
|
|
30
|
+
thinking?: any;
|
|
21
31
|
/**
|
|
22
32
|
* - Attach the full upstream response to `err.body` on HTTP errors (off by default to avoid leaking unexpected fields through error logs; `err.message` still carries the API error).
|
|
23
33
|
*/
|
|
@@ -31,7 +41,11 @@ export type AnthropicOptions = {
|
|
|
31
41
|
* @property {string} [apiKey] - Anthropic API key (required).
|
|
32
42
|
* @property {string} [model='claude-haiku-4-5-20251001'] - Model ID.
|
|
33
43
|
* @property {string} [baseUrl='https://api.anthropic.com/v1'] - API base (override for proxies/gateways; the request posts to `${baseUrl}/messages`).
|
|
34
|
-
* @property {boolean} [cacheSystem=false] - Opt-in prompt caching: send the system prompt with a `cache_control` breakpoint so Anthropic caches it. Anthropic does NOT auto-cache, so without this its cache tiers are always 0. Overridable per call via `generate(..., { cacheSystem })`.
|
|
44
|
+
* @property {boolean} [cacheSystem=false] - Opt-in prompt caching: send the system prompt with a `cache_control` breakpoint so Anthropic caches it. Anthropic does NOT auto-cache, so without this its cache tiers are always 0. Overridable per call via `generate(..., { cacheSystem })`. NOTE: on its own this rarely helps a tool loop — Anthropic's minimum cacheable prefix is 1024–4096 tokens (model-dependent) and a typical system persona is a few hundred, so it silently never caches. The transcript is where a tool loop's tokens actually live — see `cacheMessages`.
|
|
45
|
+
* @property {boolean} [cacheMessages=false] - Opt-in TRANSCRIPT caching (BA-1): roll a `cache_control` breakpoint onto the last content block of the last message, so Anthropic caches the whole conversation prefix and the loop stops re-buying it at full price every round. In a tool loop the transcript IS the tool results (file bodies from `shell_read`) and it always ENDS on one, which `_toAnthropicMessage` rebuilds from scratch — so no caller-side seam (`assemble` included) can reach it, and this has to live in the provider. Measured on `claude-sonnet-5` with a ~15k-token tool-result transcript (`poc/ba1-message-caching.mjs`): steady state **$0.0753 → $0.0110 per round, 6.8x cheaper**; round 1 pays a 1.25x cache WRITE once. Off by default — it changes the wire format, so adopters opt in. Overridable per call via `generate(..., { cacheMessages })`. **Interaction:** a destructive `trim`/stash fold that rewrites the transcript PREFIX invalidates the cache (the prefix is the cache key), so a fold must keep the head stable or you re-pay the write premium every round for nothing.
|
|
46
|
+
* @property {any} [thinking] - Opt-in extended thinking (BA-7), forwarded to `body.thinking` VERBATIM and unvalidated — e.g. `{ type: 'adaptive' }`, or `{ type: 'adaptive', display: 'summarized' }` to surface the reasoning (the default `display` is `'omitted'`). Deliberately opaque: this parameter has already broken once (`budget_tokens` was removed and now 400s on `claude-sonnet-5` / Opus 4.7+), and a library that reshapes it would need a release every time the API moves. Overridable per call via `generate(..., { thinking })`; pass `null` there to suppress an instance default.
|
|
47
|
+
*
|
|
48
|
+
* **MEASURED CAVEAT — this option does not "turn thinking on".** On `claude-sonnet-5` adaptive thinking is ALREADY the default: sending this changed the observed thinking rate not at all (2/10 rounds with it vs 3/10 without — `poc/ba7-adaptive-default.mjs`). Its real use is pinning the mode and reaching `display`/`effort`. The change that mattered is that thinking blocks are now PRESERVED and replayed (see `Message.providerBlocks`), which happens whether or not you ever set this.
|
|
35
49
|
* @property {boolean} [exposeErrorBody=false] - Attach the full upstream response to `err.body` on HTTP errors (off by default to avoid leaking unexpected fields through error logs; `err.message` still carries the API error).
|
|
36
50
|
*/
|
|
37
51
|
export class AnthropicProvider {
|
|
@@ -44,6 +58,8 @@ export class AnthropicProvider {
|
|
|
44
58
|
model: string;
|
|
45
59
|
baseUrl: string;
|
|
46
60
|
cacheSystem: boolean;
|
|
61
|
+
cacheMessages: boolean;
|
|
62
|
+
thinking: any;
|
|
47
63
|
exposeErrorBody: boolean;
|
|
48
64
|
/**
|
|
49
65
|
* Generate a response from the Anthropic API.
|
|
@@ -57,6 +73,20 @@ export class AnthropicProvider {
|
|
|
57
73
|
/** One-time warning that this model rejected `temperature` and the request was retried without it (BA-10). */
|
|
58
74
|
_warnTemperatureDropped(): void;
|
|
59
75
|
_warnedTempDropped: boolean | undefined;
|
|
76
|
+
/**
|
|
77
|
+
* BA-7: the provider-native blocks to replay at the FRONT of an assistant turn's content, or `[]`.
|
|
78
|
+
*
|
|
79
|
+
* Only ever returns blocks this model itself produced. The tag carries `this.model` (the CONFIGURED
|
|
80
|
+
* id, not the response's resolved one — those can differ by date suffix, and a mismatch there would
|
|
81
|
+
* silently disable preservation, which is the very bug class BA-7 exists to close).
|
|
82
|
+
*
|
|
83
|
+
* Front, because Anthropic requires `thinking` to lead the content array — the verbatim order we
|
|
84
|
+
* measured a successful round-trip on (`poc/ba7-thinking-contract.mjs`, R3).
|
|
85
|
+
*
|
|
86
|
+
* @param {Message} msg
|
|
87
|
+
* @returns {any[]}
|
|
88
|
+
*/
|
|
89
|
+
_nativeBlocks(msg: Message): any[];
|
|
60
90
|
/**
|
|
61
91
|
* @param {Message} msg
|
|
62
92
|
* @returns {any}
|