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 CHANGED
@@ -119,10 +119,16 @@ console.log(result.count, result.matchedIds); // a code-derived count + the id
119
119
 
120
120
  > **⚠️ Cost is open by design — wire a cap.** `recurse()` adds no intrinsic total-work limit. On the model-driven default a node can spawn up to ~100 children per level, each recursing to `maxDepth` (default 3), so **token / $ spend compounds and is bounded only by your gate** — not by recurse. Run it **with bareguard** (`ctx.policy`, which enforces depth/budget/call caps) **or with some token/USD cap** for any non-trivial or untrusted task; ungoverned, a weak model that over-decomposes *will* burn tokens. For a hard local brake without a gate, set `maxDepth: 1` (flat, no nesting). The forced modes (`mode:'fanout'` / `'partition'`) are bounded by a deterministic count + concurrency cap; the open path is the model-driven default.
121
121
 
122
- **Govern — one gate over both axes.** `wireGate(gate)` routes every LLM + tool call through one bareguard policy + audit + budget. Denied tools never reach the model; halts (turn / budget / content caps) exit cleanly. A plain deny stays advisory (the model can pivot to an allowed tool), but the Loop short-circuits a *spin* — `maxConsecutiveDenials` consecutive denials of the same action (default 3) stop the run with `error:'denied:<tool>'` instead of burning the budget to the cap; under `recurse` that surfaces as `{ incomplete, blocker:'governance-deny' }`. `require('bare-agent/bareguard')`
122
+ **Govern — one gate over both axes.** `wireGate(gate)` routes every LLM + tool call through one bareguard policy + audit + budget. Denied tools never reach the model; halts (turn / budget / content caps) exit cleanly. A plain deny stays advisory (the model can pivot to an allowed tool), but the Loop short-circuits a *spin* — `maxConsecutiveDenials` consecutive denials of the same action (default 3) stop the run with `error:'denied:<tool>'` instead of burning the budget to the cap; under `recurse` that surfaces as `{ incomplete, blocker:'governance-deny' }`. The same bound covers a tool that keeps *failing*: `maxIdenticalToolErrors` (default 3) stops a model re-sending a byte-identical call that cannot succeed, with `error:'stuck:<tool>'`. `require('bare-agent/bareguard')`
123
123
 
124
124
  **Providers:** OpenAI-compatible (OpenAI, OpenRouter, Groq, vLLM, LM Studio), Anthropic, Gemini (native), Ollama, CLIPipe, Fallback — or bring your own (one `generate` method). All return the same shape; swap freely. Usage including prompt-cache tiers is normalized, so `result.metrics` reports honest cumulative tokens + cost — and `null`, never a silent `0`, for a model it couldn't price. A model that rejects a non-default `temperature` (e.g. `claude-sonnet-5`, OpenAI o1/gpt-5-class return a `400`) is handled gracefully — the provider drops the param and retries once rather than failing the call, surfacing `temperatureDropped` so a caller can report the effective value.
125
125
 
126
+ **The run tells you the truth about what happened on the wire.** Every provider reports **why** generation ended (`stopReason`, normalized across all of them and surfaced on **every** `Loop.run()` return), so a round the API **cut off at the token cap** can no longer masquerade as a finished answer: it returns `error: 'truncated:max_tokens'` with the partial text preserved, instead of a silent `error: null` (BA-6). Its tool calls are **refused, never executed** — a *complete* tool call always arrives tagged `tool_use`, so one riding a truncated round was cut off mid-generation with arguments missing, which is exactly how a truncated `shell_write` can zero a file. The same honesty now covers **every** non-clean terminal signal (BA-13): a safety `refusal` returns `error: 'refusal'` and a blown context window returns `error: 'context_exceeded'` (both were previously laundered into an empty success), while a resumable `pause_turn` makes the loop resume rather than terminate. `error` is the sole success signal; a bound firing preserves the model's work rather than discarding it (BA-5).
127
+
128
+ **Thinking blocks survive the round-trip.** `claude-sonnet-5` and Opus 4.7+ run adaptive thinking **by default** — they return `thinking` blocks whether or not you ask — and Anthropic's contract is that those blocks come back **unchanged, signature included**, when a tool-use conversation continues. bare-agent used to drop every one, silently (the API returns 200 either way). They now ride the transcript on `Message.providerBlocks` and are replayed verbatim, with no flag to set. `new Anthropic({ thinking })` is a separate opt-in for pinning the mode or reaching `display`/`effort` — it does *not* enable thinking, which is already on. **This is a protocol/data-loss fix, not a capability one: a head-to-head with thinking fully preserved produced no better outcomes, and we say so rather than sell it (BA-7).**
129
+
130
+ **Anthropic tool loops: turn on transcript caching.** Anthropic does **not** auto-cache, so by default a tool loop re-buys its entire growing transcript at full input price *every round*. `new Anthropic({ cacheMessages: true })` rolls a cache breakpoint forward each round — measured **$0.0753 → $0.0110 per round (6.8× cheaper)** in steady state on `claude-sonnet-5`; the 1.25× cache write is paid once. Caching pays for **re-sending, not growing** (fresh content still costs full price to write), and a destructive `trim` fold that rewrites the transcript *prefix* invalidates it — fold the middle, keep the head.
131
+
126
132
  **Tools:** Any function is a tool — REST, MCP, CLI, shell. Built-in web + mobile (optional).
127
133
 
128
134
  **Cross-language:** Run as a subprocess; talk JSONL over stdin/stdout from Python, Go, Rust, Ruby, or Java. Wrappers in [`contrib/`](contrib/README.md).
@@ -1,7 +1,7 @@
1
1
  # bareagent — Integration Guide
2
2
 
3
3
  > For AI assistants and developers wiring bareagent into a project.
4
- > v0.26.2 | Node.js >= 18 | zero required deps (`bareguard >=0.9.0 <0.13.0` optional peer for governance) | Apache 2.0
4
+ > v0.28.0 | Node.js >= 18 | zero required deps (`bareguard >=0.9.0 <0.13.0` optional peer for governance) | Apache 2.0
5
5
  >
6
6
  > Full human guide with composition examples, design philosophy, and recipes: [Usage Guide](docs/02-features/usage-guide.md)
7
7
 
@@ -183,6 +183,8 @@ Skills are operator-registered `{ name, description, instructions, tools }` bund
183
183
 
184
184
  The shipped reference skill is **stash** — compaction-first context hygiene. Its `trim` wires into `Loop({ trim })` and folds finished sub-tasks out of the live transcript (restorable), keeping long runs under budget. Pass a litectx instance as `ctx` for lossless verbatim parking + the `ctx.summarize` lossy path; set `compaction.ceilingTokens` to enable automatic token-pressure folding.
185
185
 
186
+ > **⚠️ Interaction with `cacheMessages` (BA-1).** Prompt caching is a **prefix match**: the transcript prefix *is* the cache key. A fold that rewrites the **head** of the transcript invalidates the cache, so the next round re-pays the 1.25× cache-write premium on the whole thing — you can end up paying *more* than with no caching at all. If you run both, **keep the head stable** (fold the MIDDLE, which is what `compaction.keepHeadTurns` is for) so the cached prefix survives the fold. The two features are complementary, but only in that order.
187
+
186
188
  ```javascript
187
189
  const { Loop, SkillRegistry, createStashSkill } = require('bare-agent');
188
190
  const { Anthropic } = require('bare-agent/providers');
@@ -373,6 +375,8 @@ const loop = new Loop({
373
375
  const result = await loop.run(messages, tools, { ctx: { userId: 42 } });
374
376
  if (result.error?.startsWith('halt:')) {
375
377
  // budget / turn cap / gate terminated — handled cleanly, no [HALT:] reached the LLM (BA2)
378
+ // result.text still holds whatever the model produced before the bound fired (BA-5) — feed it
379
+ // forward to the next attempt. `error` is the success signal; non-empty text ≠ converged.
376
380
  }
377
381
  ```
378
382
 
@@ -380,8 +384,34 @@ if (result.error?.startsWith('halt:')) {
380
384
 
381
385
  Halt-severity decisions exit the loop cleanly via a typed `HaltError` — full mechanics (sealed `msgs`, `halt:<rule>` error token, `loop:done{halted:true}` event, `throwOnError:true` interaction, `halt:unknown` coalesce) are in the **Halt decisions throw `HaltError`** paragraph below. Short version: check `result.error?.startsWith('halt:')` after the run.
382
386
 
387
+ **A bound that fires PRESERVES the model's work (BA-5, v0.27+).** Every terminating path — governance halt, the deny-spin short-circuit, a provider error under `throwOnError:false`, `loop.stop()`, and the internal hard round limit — returns the **last non-empty assistant text** in `result.text` instead of `''`. If you drive bare-agent from an outer retry loop (`while not-done and under-cap: run the worker`), a bound firing is **normal termination**, not an exception, and that text is your only channel from attempt N to attempt N+1 — before this, a bounded attempt taught its successor nothing. Two rules: **`error` is the sole success signal** (a non-empty `text` never means the run converged — always branch on `error`), and `text` is `''` when the model genuinely produced none (no placeholder is invented, so you can trust emptiness). Under `recurse` this is what finally populates `best` on an incomplete node. Relatedly, **`loop.stop()` now returns `error: null`** — a deliberate stop is not a fault; it previously fell through to the hard-round-limit return and reported that safety warning as its error, indistinguishable from a runaway.
388
+
389
+ **A TRUNCATED round is reported, never laundered into a finish (BA-6, v0.27+).** Every provider now surfaces `GenerateResult.stopReason` — normalized across all five to one neutral vocabulary (`end_turn`, `max_tokens`, `tool_use`, `stop_sequence`, `refusal`, `pause_turn`, `context_exceeded`; `null` when the provider doesn't report one, e.g. CLIPipe). Before this, **no** provider read its finish-reason field, so a round the API **cut off at the output cap** hit the Loop's *"no tool calls ⇒ final answer"* rule and came back as a **clean finish with `error: null`** — a truncation indistinguishable from a model that chose to stop. It now returns **`result.error === 'truncated:max_tokens'`** with the partial text preserved (BA-5). Check `result.error === null` for success, as always. Note the **default `maxTokens` is 4096** and is deliberately unchanged: raising it silently lifts every adopter's ceiling and bill, which is the same class of sin as the silent truncation. If you run a reasoning model on hard tasks, **raise it yourself** — `loop.run(msgs, tools, { maxTokens: 16000 })` (it's a `run()` option, forwarded to the provider), and now you'll get a loud error instead of a quiet empty answer when you don't.
390
+
391
+ **A truncated round's tool calls are REFUSED, and this is load-bearing.** A **complete** tool call always arrives tagged `tool_use`, **never** `max_tokens` (measured on the real API). So a tool call riding a truncated round was **cut off mid-generation with arguments missing** — which is exactly how a `shell_write` whose `content` never arrived emptied a 1789-line file (BA-4). The Loop therefore never executes the tool calls of a `max_tokens` round: it returns `truncated:max_tokens` instead. Refusing discards nothing legitimate, and it closes that data-loss path for **every** tool you grant, not just `shell_write`.
392
+
393
+ **Every non-clean terminal stop reason is error-tagged, and `stopReason` rides every return (BA-13, v0.28+).** BA-6 fixed exactly one stop reason (`max_tokens`); the others still laundered a non-finish into a clean `error: null`. They no longer do:
394
+ - `refusal` → **`result.error === 'refusal'`** (partial text preserved). A safety refusal is **not** an empty success. `RECITATION` fires on entirely benign prompts, so this is reachable on ordinary runs.
395
+ - `context_exceeded` → **`result.error === 'context_exceeded'`** (ran out of context window — a spend/limit story, distinct from the output cap).
396
+ - `pause_turn` → the Loop **resumes** the turn (a resumable server-tool pause is neither terminal nor an error). Resuming appends the paused assistant turn (its partial text + provider-native server-tool blocks) and re-requests — the documented Anthropic pause_turn protocol, so the server continues where it left off rather than restarting. A pause that never progresses is caught by the same `HARD_ROUND_LIMIT` / gate `maxTurns` bounds as any non-advancing loop.
397
+ - The tool calls of a `refusal` / `context_exceeded` round are **refused, not executed** — the same BA-4 closure as `max_tokens`.
398
+
399
+ And **`result.stopReason`** (the neutral value, or `null`) is now present on **every** `Loop.run()` return, not only the clean-finish path — branch on it when you need *why* a run ended, not just *whether* it succeeded. **`error` stays the sole success signal** (`result.error === null`), and this is deliberately error-tagged rather than "surface `stopReason` only": `recurse` and any consumer following that invariant branch on `error`, so a refused worker now propagates an honest `{ incomplete }` up the tree with **zero recurse changes**. **Behavior change to note:** a refused round that previously returned `error: null` + empty text now returns `error: 'refusal'` — the point of the fix, but branch on `error`, not emptiness.
400
+
383
401
  **Deny-spin short-circuit (`maxConsecutiveDenials`, default 3, v0.25+).** A *non-halt* deny (a `policy` verdict that isn't `true` — e.g. a `humanChannel: deny`, an allowlist miss, a `content`/`fs.writeScope` block) is **advisory**: it's fed back to the model as a tool result so the model can pivot to a different allowed tool. But a model that keeps retrying the *same* denied action would otherwise spin every round until your `budget.maxCostUsd` finally halts it — burning the whole cap with no progress (this bit a coding agent whose write kept tripping `content.askPatterns`). The Loop now counts **consecutive** denials (any allowed call resets the streak, preserving the pivot) and short-circuits at `maxConsecutiveDenials` with `result.error === 'denied:<tool>'` (a clean return, transcript sealed — never a throw). Check `result.error?.startsWith('denied:')` to distinguish a governance block from a completed run; set `maxConsecutiveDenials: 0` (or `Infinity`) on `new Loop({...})` to restore the pure-advisory behavior. Under `recurse`, a short-circuited worker returns a **labeled** `{ incomplete: true, blocker: 'governance-deny' }` (and `receipts.blocker`) so you can widen scope / re-gate / escalate rather than read it as a model failure.
384
402
 
403
+ **Stuck-tool short-circuit (`maxIdenticalToolErrors`, default 3).** The error-side mirror of the deny guard, for the case where the *tool itself* keeps rejecting. A tool error is fed back to the model **on purpose** — that's how it learns the path was wrong and adapts — so the guard fires only on a **byte-identical** repeat: same tool name, same `JSON.stringify(arguments)`, N times in a row. Then the model isn't recovering, it's re-sending a call that cannot succeed, and the Loop returns `result.error === 'stuck:<tool>'` (clean, transcript sealed). This includes a repeated call to a tool that **doesn't exist** — a model that hallucinates the same unknown tool every round gets `[Loop] Unknown tool` fed back and can spin on it exactly as hard as on a throwing tool, so it counts toward the same guard. **Any successful tool call resets the streak**, and a model that *varies* its arguments (or the hallucinated name) never trips it. Counting *any* consecutive tool error instead was tried and rejected — it kills a model legitimately recovering from an `ENOENT` by trying a different path. Set `maxIdenticalToolErrors: 0` (or `Infinity`) on `new Loop({...})` to disable.
404
+
405
+ > Note the division of labour with **BA-6**: if the tool call was *cut off* by the output cap, you get `truncated:max_tokens` and the tool is **never executed at all** — that path is closed upstream. `stuck:` covers the residual case: a fully-formed call on an untruncated round that fails for its own reasons — the tool executes and rejects it (bad path, failed validation), or the tool name doesn't exist at all — repeated byte-identically.
406
+
407
+ **Thinking blocks are preserved and replayed (BA-7).** On `claude-sonnet-5` (and Opus 4.7+) **adaptive thinking is the default** — the API returns `thinking` blocks on a good fraction of rounds **whether or not you ask for them** (measured: ~3/10; sending `thinking:{type:'adaptive'}` explicitly changed the rate not at all). Anthropic's contract is that those blocks are echoed back **unchanged, `signature` included**, when a tool-use conversation continues. Before 0.27 bare-agent dropped every one of them, silently — and because the API returns **200** either way, nothing ever surfaced.
408
+
409
+ The blocks now ride the transcript on `Message.providerBlocks` (`{provider, model, blocks}`) and the Anthropic provider replays them at the front of the assistant turn. **You get this for free — no flag.** Two things to know if you touch the transcript yourself: the blocks are **opaque** (keep the bytes; don't rebuild them from parsed fields — a `redacted_thinking` block won't survive it), and they are **tagged with the model that signed them** — a signature is model-bound, so swapping models mid-transcript drops them rather than sending a signature the new model will reject. If you persist and replay transcripts, keep `providerBlocks` intact through your serializer.
410
+
411
+ `AnthropicProvider({ thinking })` is a separate, opt-in knob, forwarded to `body.thinking` verbatim (e.g. `{type:'adaptive', display:'summarized'}` to surface the reasoning; the default `display` is `'omitted'`). **It does not "turn thinking on"** — it's already on. Use it to pin the mode or reach `display`/`effort`. It is passed through unvalidated on purpose: `budget_tokens` was removed from the API and now **400s** on sonnet-5/Opus 4.7+, so a library that reshaped this parameter would need a release every time Anthropic moved.
412
+
413
+ > **Honesty note, and we mean it.** This is a **protocol/data-loss fix, not a capability fix.** A head-to-head with thinking fully preserved vs. stock bare-agent produced **indistinguishable** outcomes. Do not adopt 0.27 expecting better reasoning — you will not get it, and we have the measurement.
414
+
385
415
  Legacy `wrapTool` / `wrapTools` are retained as deprecation shims (one-shot console warning, removal in 1.0). Migration: replace `wrapTools(tools)` at `loop.run()` with `filterTools(tools)` once upfront + `onLlmResult` / `onToolResult` on `new Loop({...})` to pick up LLM-cost recording and `_ctx` threading.
386
416
 
387
417
  **`actionTranslator` for bash/fs primitive activation (v0.10.1+).** Bareguard's `bashCheck` / `fsCheck` / `netCheck` only fire when `action.type === 'bash'` / `'read'` / `'write'` / `'fetch'`. The default action shape is `{type: toolName, args, _ctx}` which matches `tools.denylist` / `tools.allowlist` but does NOT activate those primitives. Adopters who want both pass `wireGate(gate, { actionTranslator })`. Since bareguard 0.4.1+, the primitives read fields from either flat (`action.cmd`) or nested (`action.args.cmd` / `.command`) shapes, so you can pass args through verbatim:
@@ -731,6 +761,26 @@ new OpenAI({ apiKey, model: 'gpt-4o-mini', baseUrl: 'https://api.openai.com/v1'
731
761
  // Anthropic
732
762
  new Anthropic({ apiKey, model: 'claude-haiku-4-5-20251001' })
733
763
 
764
+ // Anthropic + TRANSCRIPT CACHING (BA-1, v0.27+) — if you run a TOOL LOOP, turn this on.
765
+ // Anthropic does NOT auto-cache, so without it your loop re-buys its entire growing transcript at
766
+ // FULL input price every single round. Measured on claude-sonnet-5 with a ~15k-token tool-result
767
+ // transcript: $0.0753 -> $0.0110 per round, 6.8x cheaper in steady state (round 1 pays a 1.25x cache
768
+ // write, once). Opt-in because it changes the wire format; also settable per call via
769
+ // loop.run(msgs, tools, { cacheMessages: true }).
770
+ new Anthropic({ apiKey, model: 'claude-sonnet-5', cacheMessages: true })
771
+ //
772
+ // Two things worth knowing before you rely on it:
773
+ // 1. Caching pays for RE-SENDING, not for GROWING. The 6.8x is what a STABLE prefix buys — the
774
+ // transcript re-sent round after round. A round that appends large NEW content (another whole-file
775
+ // read) writes those tokens at 1.25x; no breakpoint makes a token you've never sent before cheap.
776
+ // Caching is necessary, not sufficient — it compounds with retrieval that stops re-reading files.
777
+ // 2. A destructive `trim`/stash fold that rewrites the transcript PREFIX INVALIDATES the cache (the
778
+ // prefix IS the cache key). Keep the head stable, or you re-pay the write premium every round.
779
+ //
780
+ // `cacheSystem` is a different, weaker knob: Anthropic's minimum cacheable prefix is 1024-4096 tokens
781
+ // (model-dependent) and a typical system persona is a few hundred — so on its own it silently caches
782
+ // NOTHING. The transcript is where a tool loop's tokens actually live.
783
+
734
784
  // Gemini (native generateContent — needed for prompt-cache token tiers; the OpenAI-compat endpoint drops them)
735
785
  new Gemini({ apiKey, model: 'gemini-2.5-flash', baseUrl: 'https://generativelanguage.googleapis.com/v1beta' })
736
786
 
@@ -1191,17 +1241,20 @@ Mobile tools follow the observe-act pattern: action tools auto-return a fresh sn
1191
1241
 
1192
1242
  ### Recipe 8b: Loop + Shell Tools (cross-platform primitives)
1193
1243
 
1194
- `createShellTools()` returns three pure-Node tools that work identically on linux, macOS, and Windows — no external binaries, no platform detection.
1244
+ `createShellTools()` returns five pure-Node tools that work identically on linux, macOS, and Windows — no external binaries, no platform detection.
1195
1245
 
1196
1246
  | Tool | Purpose |
1197
1247
  |---|---|
1198
1248
  | `shell_read` | Read a file (utf8, 256KB cap) or list a directory (tab-separated). `~` expands to home. |
1249
+ | `shell_write` | Write (or `append:true`) UTF-8 text to a file, creating parent dirs. 5MB cap. No shell, so it gates cleanly through `fs.writeScope` once translated to `{type:'write'}`. **`content` is REQUIRED** — see the truncation guard below. |
1199
1250
  | `shell_grep` | JavaScript regex search across files. Walks directories, skips binary files, returns `{hits: [{file, line, text}], truncated, fileCount}`. |
1200
1251
  | `shell_run` | Run a command with an **argv array** via `child_process.execFile` (no shell, no metacharacter interpretation). Returns `{stdout, stderr, code, timedOut}`. **Use this when you need a policy allowlist.** |
1201
1252
  | `shell_exec` | Run a raw shell command string via `/bin/sh -c` (or `cmd.exe`). Returns the same shape. **Shell metacharacters are interpreted — naive allowlists are bypassable.** Use only when you genuinely need shell features (pipes, redirects, globs). |
1202
1253
 
1203
1254
  **Zero baked-in allowlist.** The library ships the primitives; gating is bareguard's job via the standard `wireGate(gate)` wiring.
1204
1255
 
1256
+ > **⚠️ `shell_write` requires `content` — and a gate cannot cover for it (v0.27+).** `content` used to default to `''`, so a tool call that OMITTED it silently overwrote the target with **zero bytes** and returned `"wrote 0 bytes to <path>"` as success. That is the ordinary shape of a model hitting its **output-token cap** mid-generation on a long file — observed live emptying a 1789-line source file. **No policy can catch it:** a 0-byte write is a *legal* write, and bareguard's `fs` primitive judges `{type:'write', path}` without inspecting the body (the gate correctly `allow`s it). `shell_write` now **rejects** an absent, `null`, or non-string `content` and leaves the file byte-identical; the error tells the model to retry with the full content. An explicit `content: ""` still empties the file — that one is deliberate.
1257
+
1205
1258
  > **⚠️ `shell_exec` injection caveat.** `"ls"` passes a base-command allowlist like `args.command.split(/\s+/)[0]`, but so does `"ls;rm -rf /tmp/x"` — the shell runs both. **A base-command allowlist is NOT safe for `shell_exec`.** For policy-gated use, prefer `shell_run({argv})` and allow-list on `args.argv[0]` — there is no shell in that path, so metacharacters are just literal argument bytes. Use `shell_exec` only when the agent needs pipes/redirects/globs, and gate it at a higher level (human approval, narrow intent).
1206
1259
 
1207
1260
  ```javascript
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "bare-agent",
3
- "version": "0.26.2",
3
+ "version": "0.28.0",
4
4
  "files": [
5
5
  "index.js",
6
6
  "index.d.ts",
package/src/loop.d.ts CHANGED
@@ -52,6 +52,19 @@ export type LoopOptions = {
52
52
  */
53
53
  onLlmResult?: Function | undefined;
54
54
  onToolResult?: Function | undefined;
55
+ /**
56
+ * - BA-12 safety net (default 3). Short-circuit the run when a
57
+ * tool's `execute` throws this many times IN A ROW for a BYTE-IDENTICAL call (same tool + same args). A
58
+ * tool error is deliberately fed back to the model so it can recover — that is the point of the feedback
59
+ * loop — but a model re-issuing the SAME impossible call verbatim can never succeed, and spins to the
60
+ * budget cap with no progress (observed live: `claude-sonnet-5` retried a rejected write 8/8 times).
61
+ * Deliberately the NARROWEST guard: any tool call that SUCCEEDS, or the same tool called with DIFFERENT
62
+ * arguments, resets the streak — a model adapting its input in response to an error is genuinely
63
+ * recovering and is never penalised. Returns cleanly with `error: 'stuck:<tool>'` (mirrors the deny/halt
64
+ * returns; never throws even under `throwOnError`; transcript sealed; the model's text preserved).
65
+ * `0`/`Infinity` disables (restores pre-BA-12 behavior: errors are advisory forever).
66
+ */
67
+ maxIdenticalToolErrors?: number | undefined;
55
68
  /**
56
69
  * - BA-11 safety net (default 3). Short-circuit the run when
57
70
  * `policy` denies this many tool calls IN A ROW with no allowed call in between — a governance deny is
@@ -90,6 +103,7 @@ export class Loop {
90
103
  store: import("../types").Store | null;
91
104
  policy: Function | null;
92
105
  maxConsecutiveDenials: number;
106
+ maxIdenticalToolErrors: number;
93
107
  assemble: Function | null;
94
108
  trim: Function | null;
95
109
  onLlmResult: Function | null;
@@ -122,12 +136,30 @@ export class Loop {
122
136
  * thunk is re-evaluated each round (D4/eval-assist F2) so a tool set that grows mid-run — e.g. a skill
123
137
  * unlocking its tools — is offered on the next round; a static array is resolved once at wire time.
124
138
  * @param {Record<string, any>} [options={}] - Per-run overrides (system, temperature, ctx, etc.).
125
- * @returns {Promise<{text: string, toolCalls: ToolCall[], usage: Usage, cost: number, error: string|null, msgs: Message[], metrics: RunMetrics, temperatureDropped?: boolean}>}
139
+ * @returns {Promise<{text: string, toolCalls: ToolCall[], usage: Usage, cost: number, error: string|null, stopReason: string|null, msgs: Message[], metrics: RunMetrics, temperatureDropped?: boolean}>}
126
140
  * On halt the returned `error` is `halt:<rule>` (or `halt:unknown` if the
127
141
  * thrown HaltError carried no `rule`), and `msgs` is sanitized so any
128
142
  * dangling assistant `tool_calls` from the halted round are paired with
129
143
  * synthetic `[halted]` tool replies — safe to feed back into another
130
144
  * provider call without violating OpenAI's tool-call/tool-result pairing.
145
+ *
146
+ * BA-5 — a bound that fires PRESERVES the model's work. Every terminating path (governance halt,
147
+ * deny-streak, provider error under `throwOnError:false`, `stop()`, the hard round limit) returns the
148
+ * last non-empty assistant text in `text` rather than substituting `''`. A bound firing is normal
149
+ * termination for a bounded attempt, and that text is the only channel from attempt N to attempt N+1 —
150
+ * the caller decides what a partial result is worth. `error` remains the sole success signal: a
151
+ * non-empty `text` NEVER means the run converged, so never infer success from it. `text` stays `''`
152
+ * when the model genuinely produced none (no placeholder is invented).
153
+ *
154
+ * A caller-initiated `stop()` returns `error: null` — a deliberate stop is not a fault. (It previously
155
+ * fell through to the hard-round-limit return and reported that safety warning as its `error`.)
156
+ *
157
+ * BA-13 — `stopReason` (the round's NEUTRAL stop reason) is surfaced on EVERY return, and non-clean
158
+ * terminal rounds are error-tagged instead of laundered into `error: null`: a safety `refusal` returns
159
+ * `error: 'refusal'` and a `context_exceeded` returns `error: 'context_exceeded'`, both with partial
160
+ * text preserved (BA-5). BEHAVIOR CHANGE: a refused round that previously returned `error: null` with
161
+ * empty text now returns `error: 'refusal'` — the point of the fix. `pause_turn` is NOT terminal: the
162
+ * loop resumes (bounded by the hard round limit / gate). `error` stays the sole success signal.
131
163
  * @throws {Error} `[Loop] Tool is missing a name` — when a tool has no name or a non-string name.
132
164
  * @throws {Error} `[Loop] Tool "X" is missing an execute() function` — when execute is not a function.
133
165
  * @throws {Error} `[Loop] Tool "X" has invalid parameters` — when parameters is not an object.
@@ -138,6 +170,7 @@ export class Loop {
138
170
  usage: Usage;
139
171
  cost: number;
140
172
  error: string | null;
173
+ stopReason: string | null;
141
174
  msgs: Message[];
142
175
  metrics: RunMetrics;
143
176
  temperatureDropped?: boolean;
@@ -168,7 +201,7 @@ export class Loop {
168
201
  * @param {string} text - User message.
169
202
  * @param {ToolDef[]} [tools=[]] - Tool definitions.
170
203
  * @param {Record<string, any>} [options={}] - Per-run overrides.
171
- * @returns {Promise<{text: string, toolCalls: ToolCall[], usage: Usage, cost: number, error: string|null, msgs: Message[], metrics: RunMetrics, temperatureDropped?: boolean}>}
204
+ * @returns {Promise<{text: string, toolCalls: ToolCall[], usage: Usage, cost: number, error: string|null, stopReason: string|null, msgs: Message[], metrics: RunMetrics, temperatureDropped?: boolean}>}
172
205
  */
173
206
  chat(text: string, tools?: ToolDef[], options?: Record<string, any>): Promise<{
174
207
  text: string;
@@ -176,10 +209,16 @@ export class Loop {
176
209
  usage: Usage;
177
210
  cost: number;
178
211
  error: string | null;
212
+ stopReason: string | null;
179
213
  msgs: Message[];
180
214
  metrics: RunMetrics;
181
215
  temperatureDropped?: boolean;
182
216
  }>;
217
+ /**
218
+ * Request a clean stop. The current tool call finishes; the loop then exits at the next boundary and
219
+ * `run()` resolves with `error: null` and the last text the model produced (BA-5) — a deliberate stop
220
+ * is not a fault, and callers should not need a `stoppedByBound` flag to un-lie the return value.
221
+ */
183
222
  stop(): void;
184
223
  }
185
224
  /**
@@ -239,6 +278,16 @@ export function estimateCost(model: string | null, usage: Usage | null): number
239
278
  * gate.record (via wireGate). `event.kind` discriminates the source: `'turn'` for a main-loop round,
240
279
  * `'summarize'` for an out-of-band `ctx.summarize` call (R-C6). Both count against the budget.
241
280
  * @property {Function} [onToolResult]
281
+ * @property {number} [maxIdenticalToolErrors] - BA-12 safety net (default 3). Short-circuit the run when a
282
+ * tool's `execute` throws this many times IN A ROW for a BYTE-IDENTICAL call (same tool + same args). A
283
+ * tool error is deliberately fed back to the model so it can recover — that is the point of the feedback
284
+ * loop — but a model re-issuing the SAME impossible call verbatim can never succeed, and spins to the
285
+ * budget cap with no progress (observed live: `claude-sonnet-5` retried a rejected write 8/8 times).
286
+ * Deliberately the NARROWEST guard: any tool call that SUCCEEDS, or the same tool called with DIFFERENT
287
+ * arguments, resets the streak — a model adapting its input in response to an error is genuinely
288
+ * recovering and is never penalised. Returns cleanly with `error: 'stuck:<tool>'` (mirrors the deny/halt
289
+ * returns; never throws even under `throwOnError`; transcript sealed; the model's text preserved).
290
+ * `0`/`Infinity` disables (restores pre-BA-12 behavior: errors are advisory forever).
242
291
  * @property {number} [maxConsecutiveDenials] - BA-11 safety net (default 3). Short-circuit the run when
243
292
  * `policy` denies this many tool calls IN A ROW with no allowed call in between — a governance deny is
244
293
  * not a recoverable tool error, so a model that keeps retrying variants of a denied action would