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/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. Halt-path only — keeps msgs a valid OpenAI transcript when the loop
105
- // exits between pushing assistant.tool_calls and finishing the per-tool loop.
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} rule
123
+ * @param {string} marker - Literal content for each synthetic reply, e.g. `[halted:budget.maxCostUsd]`.
109
124
  */
110
- function sealDanglingToolCalls(msgs, rule) {
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: `[halted:${rule}]` });
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
- return { text: '', toolCalls: [], usage: lastUsage, cost: totalCost, error: err.message, msgs, metrics: finalizeMetrics() };
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
- msgs.push({ role: 'assistant', content: result.text });
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: '', denied: true, rule: denyTag, cost: totalCost } });
782
- return { text: '', toolCalls: [], usage: lastUsage, cost: totalCost, error: denyTag, msgs, metrics: finalizeMetrics() };
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
- this._safeEmit({ type: 'loop:done', data: { text: '', halted: true, rule, cost: totalCost } });
843
- return { text: '', toolCalls: [], usage: lastUsage, cost: totalCost, error: `halt:${rule}`, msgs, metrics: finalizeMetrics() };
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: '', warning, cost: totalCost } });
852
- return { text: '', toolCalls: [], usage: lastUsage, cost: totalCost, error: warning, msgs, metrics: finalizeMetrics() };
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}