bare-agent 0.26.2 → 0.27.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), 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. `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.27.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,26 @@ 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`. `pause_turn` (a *resumable* server-tool state), `refusal` and `context_exceeded` are surfaced but deliberately **not** treated as truncations.
392
+
383
393
  **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
394
 
395
+ **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.
396
+
397
+ > 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.
398
+
399
+ **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.
400
+
401
+ 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.
402
+
403
+ `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.
404
+
405
+ > **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.
406
+
385
407
  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
408
 
387
409
  **`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 +753,26 @@ new OpenAI({ apiKey, model: 'gpt-4o-mini', baseUrl: 'https://api.openai.com/v1'
731
753
  // Anthropic
732
754
  new Anthropic({ apiKey, model: 'claude-haiku-4-5-20251001' })
733
755
 
756
+ // Anthropic + TRANSCRIPT CACHING (BA-1, v0.27+) — if you run a TOOL LOOP, turn this on.
757
+ // Anthropic does NOT auto-cache, so without it your loop re-buys its entire growing transcript at
758
+ // FULL input price every single round. Measured on claude-sonnet-5 with a ~15k-token tool-result
759
+ // transcript: $0.0753 -> $0.0110 per round, 6.8x cheaper in steady state (round 1 pays a 1.25x cache
760
+ // write, once). Opt-in because it changes the wire format; also settable per call via
761
+ // loop.run(msgs, tools, { cacheMessages: true }).
762
+ new Anthropic({ apiKey, model: 'claude-sonnet-5', cacheMessages: true })
763
+ //
764
+ // Two things worth knowing before you rely on it:
765
+ // 1. Caching pays for RE-SENDING, not for GROWING. The 6.8x is what a STABLE prefix buys — the
766
+ // transcript re-sent round after round. A round that appends large NEW content (another whole-file
767
+ // read) writes those tokens at 1.25x; no breakpoint makes a token you've never sent before cheap.
768
+ // Caching is necessary, not sufficient — it compounds with retrieval that stops re-reading files.
769
+ // 2. A destructive `trim`/stash fold that rewrites the transcript PREFIX INVALIDATES the cache (the
770
+ // prefix IS the cache key). Keep the head stable, or you re-pay the write premium every round.
771
+ //
772
+ // `cacheSystem` is a different, weaker knob: Anthropic's minimum cacheable prefix is 1024-4096 tokens
773
+ // (model-dependent) and a typical system persona is a few hundred — so on its own it silently caches
774
+ // NOTHING. The transcript is where a tool loop's tokens actually live.
775
+
734
776
  // Gemini (native generateContent — needed for prompt-cache token tiers; the OpenAI-compat endpoint drops them)
735
777
  new Gemini({ apiKey, model: 'gemini-2.5-flash', baseUrl: 'https://generativelanguage.googleapis.com/v1beta' })
736
778
 
@@ -1191,17 +1233,20 @@ Mobile tools follow the observe-act pattern: action tools auto-return a fresh sn
1191
1233
 
1192
1234
  ### Recipe 8b: Loop + Shell Tools (cross-platform primitives)
1193
1235
 
1194
- `createShellTools()` returns three pure-Node tools that work identically on linux, macOS, and Windows — no external binaries, no platform detection.
1236
+ `createShellTools()` returns five pure-Node tools that work identically on linux, macOS, and Windows — no external binaries, no platform detection.
1195
1237
 
1196
1238
  | Tool | Purpose |
1197
1239
  |---|---|
1198
1240
  | `shell_read` | Read a file (utf8, 256KB cap) or list a directory (tab-separated). `~` expands to home. |
1241
+ | `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
1242
  | `shell_grep` | JavaScript regex search across files. Walks directories, skips binary files, returns `{hits: [{file, line, text}], truncated, fileCount}`. |
1200
1243
  | `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
1244
  | `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
1245
 
1203
1246
  **Zero baked-in allowlist.** The library ships the primitives; gating is bareguard's job via the standard `wireGate(gate)` wiring.
1204
1247
 
1248
+ > **⚠️ `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.
1249
+
1205
1250
  > **⚠️ `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
1251
 
1207
1252
  ```javascript
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "bare-agent",
3
- "version": "0.26.2",
3
+ "version": "0.27.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;
@@ -128,6 +142,17 @@ export class Loop {
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`.)
131
156
  * @throws {Error} `[Loop] Tool is missing a name` — when a tool has no name or a non-string name.
132
157
  * @throws {Error} `[Loop] Tool "X" is missing an execute() function` — when execute is not a function.
133
158
  * @throws {Error} `[Loop] Tool "X" has invalid parameters` — when parameters is not an object.
@@ -180,6 +205,11 @@ export class Loop {
180
205
  metrics: RunMetrics;
181
206
  temperatureDropped?: boolean;
182
207
  }>;
208
+ /**
209
+ * Request a clean stop. The current tool call finishes; the loop then exits at the next boundary and
210
+ * `run()` resolves with `error: null` and the last text the model produced (BA-5) — a deliberate stop
211
+ * is not a fault, and callers should not need a `stoppedByBound` flag to un-lie the return value.
212
+ */
183
213
  stop(): void;
184
214
  }
185
215
  /**
@@ -239,6 +269,16 @@ export function estimateCost(model: string | null, usage: Usage | null): number
239
269
  * gate.record (via wireGate). `event.kind` discriminates the source: `'turn'` for a main-loop round,
240
270
  * `'summarize'` for an out-of-band `ctx.summarize` call (R-C6). Both count against the budget.
241
271
  * @property {Function} [onToolResult]
272
+ * @property {number} [maxIdenticalToolErrors] - BA-12 safety net (default 3). Short-circuit the run when a
273
+ * tool's `execute` throws this many times IN A ROW for a BYTE-IDENTICAL call (same tool + same args). A
274
+ * tool error is deliberately fed back to the model so it can recover — that is the point of the feedback
275
+ * loop — but a model re-issuing the SAME impossible call verbatim can never succeed, and spins to the
276
+ * budget cap with no progress (observed live: `claude-sonnet-5` retried a rejected write 8/8 times).
277
+ * Deliberately the NARROWEST guard: any tool call that SUCCEEDS, or the same tool called with DIFFERENT
278
+ * arguments, resets the streak — a model adapting its input in response to an error is genuinely
279
+ * recovering and is never penalised. Returns cleanly with `error: 'stuck:<tool>'` (mirrors the deny/halt
280
+ * returns; never throws even under `throwOnError`; transcript sealed; the model's text preserved).
281
+ * `0`/`Infinity` disables (restores pre-BA-12 behavior: errors are advisory forever).
242
282
  * @property {number} [maxConsecutiveDenials] - BA-11 safety net (default 3). Short-circuit the run when
243
283
  * `policy` denies this many tool calls IN A ROW with no allowed call in between — a governance deny is
244
284
  * not a recoverable tool error, so a model that keeps retrying variants of a denied action would
package/src/loop.js CHANGED
@@ -1,6 +1,7 @@
1
1
  'use strict';
2
2
 
3
3
  const { ToolError, HaltError } = require('./errors');
4
+ const { isTruncated } = 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
  }
@@ -331,6 +352,17 @@ class Loop {
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`.)
334
366
  * @throws {Error} `[Loop] Tool is missing a name` — when a tool has no name or a non-string name.
335
367
  * @throws {Error} `[Loop] Tool "X" is missing an execute() function` — when execute is not a function.
336
368
  * @throws {Error} `[Loop] Tool "X" has invalid parameters` — when parameters is not an object.
@@ -387,6 +419,14 @@ class Loop {
387
419
 
388
420
  let lastUsage = { inputTokens: 0, outputTokens: 0 };
389
421
  let totalCost = 0;
422
+ // BA-5: the most recent NON-EMPTY assistant text this run produced. Every bound that can end a run —
423
+ // governance halt, deny-streak, provider error, caller stop, hard round limit — returns this instead of
424
+ // substituting `text: ''`. In a ralph-style outer loop (`while red and under-cap: run the worker`) a bound
425
+ // firing is NORMAL termination, not an exception, and the worker's own account of what it did and ruled
426
+ // out is the only channel from attempt N to attempt N+1 — dropping it silently deletes the loop's ratchet.
427
+ // The caller decides what a partial result is worth; the library must not decide it is worth nothing.
428
+ // Stays '' when the model never produced text (nothing to preserve — we never invent a placeholder).
429
+ let lastText = '';
390
430
  // BA-10: sticky across rounds — true if ANY round's `temperature` was dropped by the model (400,
391
431
  // unsupported/deprecated) and retried without it. Surfaced on the result so an upstream receipt
392
432
  // (recurse's refineLeaf) can report the EFFECTIVE temperature rather than the ignored request.
@@ -394,6 +434,39 @@ class Loop {
394
434
  // BA-11: consecutive policy-deny counter (reset by any tool call that PASSES policy). When it reaches
395
435
  // this.maxConsecutiveDenials the run short-circuits cleanly — see the deny block below.
396
436
  let consecutiveDenials = 0;
437
+ // BA-12: a policy DENY is not the only way a model can spin. A tool whose `execute` keeps THROWING is
438
+ // fed the error back as a tool result (deliberately — that's how a model recovers from a bad path), but
439
+ // a model that re-issues the BYTE-IDENTICAL call against an error that cannot be recovered from will
440
+ // spin to the budget cap with zero progress. We count only IDENTICAL repeats of a FAILING call
441
+ // (same tool + same args), which is the narrowest guard that catches the observed spin: a model that
442
+ // VARIES its arguments in response to an error is genuinely recovering and must never be penalised.
443
+ let identicalErrors = 0;
444
+ /** @type {string|null} */
445
+ let lastErrorFingerprint = null;
446
+ // BA-12: record a FAILING tool call and short-circuit if it is the Nth byte-identical repeat. Shared by
447
+ // the two ways a call can fail with an error fed back to the model: `execute` threw, OR the tool name is
448
+ // unknown (a hallucinated tool). Both feed an error result the model can spin on identically, so both
449
+ // must count — an unknown-tool spin is the same budget burn as a throwing-tool spin. Only a DIFFERENT
450
+ // tool/args (recovery) or a SUCCESS resets the streak. Returns the clean stuck-result to return, or null
451
+ // to continue. Args are fingerprinted defensively: an unstringifiable payload never matches, so the
452
+ // guard degrades to off rather than throwing inside the failure path.
453
+ const recordToolFailure = (/** @type {ToolCall} */ tc) => {
454
+ let fingerprint = null;
455
+ try { fingerprint = `${tc.name}:${JSON.stringify(tc.arguments)}`; } catch { fingerprint = null; }
456
+ if (fingerprint !== null && fingerprint === lastErrorFingerprint) identicalErrors += 1;
457
+ else { identicalErrors = 1; lastErrorFingerprint = fingerprint; }
458
+ if (this.maxIdenticalToolErrors > 0 && Number.isFinite(this.maxIdenticalToolErrors)
459
+ && identicalErrors >= this.maxIdenticalToolErrors) {
460
+ const stuckTag = `stuck:${tc.name}`;
461
+ // Same clean exit as the deny-streak and halt paths: seal the transcript, never throw (even under
462
+ // throwOnError), and preserve the model's text (BA-5) so a bounded attempt still teaches its successor.
463
+ sealDanglingToolCalls(msgs, `[halted:${stuckTag}]`);
464
+ this._reportError('stuck', new Error(`tool "${tc.name}" failed ${identicalErrors} times with identical arguments`), { rule: stuckTag, attempts: identicalErrors });
465
+ this._safeEmit({ type: 'loop:done', data: { text: lastText, stuck: true, rule: stuckTag, cost: totalCost } });
466
+ return { text: lastText, toolCalls: [], usage: lastUsage, cost: totalCost, error: stuckTag, msgs, metrics: finalizeMetrics(), ...(temperatureDropped && { temperatureDropped: true }) };
467
+ }
468
+ return null;
469
+ };
397
470
 
398
471
  // The meter (Feature 3): bareagent is the canonical run counter. Accumulates across rounds and is
399
472
  // returned as `result.metrics`. `tokens` is CUMULATIVE over all four tiers (fixes the last-round-only
@@ -617,10 +690,16 @@ class Loop {
617
690
  } catch (err) {
618
691
  this._reportError('provider', err, { round });
619
692
  if (this.throwOnError) throw err;
620
- return { text: '', toolCalls: [], usage: lastUsage, cost: totalCost, error: err.message, msgs, metrics: finalizeMetrics() };
693
+ // BA-5: a mid-run provider failure must not erase the work of the rounds that succeeded.
694
+ return { text: lastText, toolCalls: [], usage: lastUsage, cost: totalCost, error: err.message, msgs, metrics: finalizeMetrics(), ...(temperatureDropped && { temperatureDropped: true }) };
621
695
  }
622
696
 
623
697
  lastUsage = result.usage || lastUsage;
698
+ // BA-5: capture the text BEFORE anything downstream can halt (onLlmResult forwards this round's spend
699
+ // to the gate, which is exactly where a budget cap trips — the text of the round that tripped the cap
700
+ // is the text most worth keeping). A tool-call-only round carries no text, so hold the last non-empty
701
+ // one rather than letting a silent round erase an earlier account.
702
+ if (typeof result.text === 'string' && result.text.trim() !== '') lastText = result.text;
624
703
  if (result.temperatureDropped) temperatureDropped = true;
625
704
  // Publish the latest measured usage to ctx (non-enumerable, fail-open) so a transcript-bound seam —
626
705
  // e.g. F2 stash auto-compaction — can read EXACT provider-counted `inputTokens` to gauge context
@@ -666,12 +745,40 @@ class Loop {
666
745
  }
667
746
  }
668
747
 
748
+ // BA-6: the API CUT THIS ROUND OFF at the output cap. It is NOT a finished turn, and it must not
749
+ // reach the "no tool calls ⇒ final answer" rule below — that rule is what laundered a truncation
750
+ // into a clean `error: null` completion, indistinguishable from a model that chose to stop.
751
+ //
752
+ // Placed AFTER metering (the tokens were really spent — the gate must see them) and BEFORE tool
753
+ // execution, which is the load-bearing half: a truncated round's tool calls were cut off
754
+ // mid-generation, so their arguments are missing keys. Executing one is exactly how BA-4 emptied a
755
+ // 1789-line file (`shell_write` reached the fs with no `content`). A COMPLETE call always arrives
756
+ // tagged 'tool_use', never 'max_tokens' (measured on the real API, poc/ba6-stop-reason-mapping.mjs),
757
+ // so refusing here discards nothing legitimate and closes the data-loss path for EVERY tool.
758
+ //
759
+ // We report; the caller decides. No auto-retry at a bigger cap: that doubles spend against a budget
760
+ // the gate is enforcing, and the right recovery (raise the cap? split the task? shorten it?) is the
761
+ // caller's call, not the library's. `lastText` (BA-5) preserves the partial work either way.
762
+ if (isTruncated(result.stopReason)) {
763
+ const dropped = (result.toolCalls || []).length;
764
+ // Seal the transcript with the partial text only. Deliberately NOT the tool_calls: pushing a call
765
+ // we refuse to execute would orphan it (a tool_call with no tool_result is a wire-invalid
766
+ // transcript on Anthropic). Empty text pushes nothing — a bare empty assistant turn is also invalid.
767
+ if (typeof result.text === 'string' && result.text.trim() !== '') {
768
+ msgs.push({ role: 'assistant', content: result.text });
769
+ }
770
+ this._safeEmit({ type: 'loop:done', data: { text: lastText, truncated: true, droppedToolCalls: dropped, cost: totalCost } });
771
+ return { text: lastText, toolCalls: [], usage: lastUsage, cost: totalCost, error: 'truncated:max_tokens', msgs, metrics: finalizeMetrics(), ...(temperatureDropped && { temperatureDropped: true }) };
772
+ }
773
+
669
774
  // No tool calls — LLM gave a final text response
670
775
  if (!result.toolCalls || result.toolCalls.length === 0) {
671
776
  this._safeEmit({ type: 'loop:text', data: { text: result.text } });
672
777
  this._safeCall('onText', this.onText, result.text);
673
778
  this._safeEmit({ type: 'loop:done', data: { text: result.text, usage: lastUsage, cost: totalCost } });
674
- msgs.push({ role: 'assistant', content: result.text });
779
+ // BA-7: the final turn carries its native blocks too — a caller that replays this transcript
780
+ // into a fresh run (or persists it) gets a faithful one rather than a silently lossy copy.
781
+ msgs.push({ role: 'assistant', content: result.text, ...(result.providerBlocks && { providerBlocks: result.providerBlocks }) });
675
782
  // RT-2 F2: residual harvest of the surviving window (incl. this final answer) on clean completion.
676
783
  // `trim` only harvests EVICTED turns; without this, the never-evicted tail would diverge from an
677
784
  // end-of-task batch. The trimmer's idempotent key means it never re-writes what eviction harvested.
@@ -693,6 +800,12 @@ class Loop {
693
800
  type: 'function',
694
801
  function: { name: tc.name, arguments: JSON.stringify(tc.arguments) },
695
802
  })),
803
+ // BA-7: carry provider-native blocks (Anthropic `thinking`/`redacted_thinking`) the normalized
804
+ // shape can't express. THIS is the turn the API contract is about — thinking blocks must be
805
+ // echoed back unchanged, signature included, when continuing a tool-use conversation. Opaque
806
+ // to the Loop: it never reads them, it only refuses to lose them. Providers that send none add
807
+ // nothing, so the message stays byte-identical to today.
808
+ ...(result.providerBlocks && { providerBlocks: result.providerBlocks }),
696
809
  });
697
810
 
698
811
  for (const tc of result.toolCalls) {
@@ -708,6 +821,11 @@ class Loop {
708
821
  const errMsg = `[Loop] Unknown tool: ${tc.name}`;
709
822
  msgs.push({ role: 'tool', tool_call_id: tc.id, content: errMsg });
710
823
  this._safeEmit({ type: 'loop:tool_result', data: { tool: tc.name, error: errMsg } });
824
+ // BA-12: an unknown-tool error is fed back like any tool error, and a weak model can spin on it
825
+ // verbatim just the same (it hallucinates the same missing tool every round). Count it so the
826
+ // spin guard catches it too — otherwise the run burns to the hard round limit / budget cap.
827
+ const stuck = recordToolFailure(tc);
828
+ if (stuck) return stuck;
711
829
  continue;
712
830
  }
713
831
 
@@ -776,10 +894,10 @@ class Loop {
776
894
  // Pair any still-dangling tool_calls from this round so the returned transcript stays
777
895
  // provider-valid (same seal the halt path uses), then exit cleanly — no throw even under
778
896
  // throwOnError, mirroring the governance-halt contract.
779
- sealDanglingToolCalls(msgs, denyTag);
897
+ sealDanglingToolCalls(msgs, `[halted:${denyTag}]`);
780
898
  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() };
899
+ this._safeEmit({ type: 'loop:done', data: { text: lastText, denied: true, rule: denyTag, cost: totalCost } });
900
+ return { text: lastText, toolCalls: [], usage: lastUsage, cost: totalCost, error: denyTag, msgs, metrics: finalizeMetrics(), ...(temperatureDropped && { temperatureDropped: true }) };
783
901
  }
784
902
  continue;
785
903
  }
@@ -798,6 +916,11 @@ class Loop {
798
916
  const content = typeof toolResult === 'string' ? toolResult : JSON.stringify(toolResult);
799
917
  msgs.push({ role: 'tool', tool_call_id: tc.id, content });
800
918
  this._safeEmit({ type: 'loop:tool_result', data: { tool: tc.name, result: content } });
919
+ // BA-12: a call that SUCCEEDED is progress — clear the identical-failure streak. Without this, a
920
+ // tool that fails, is recovered from, then fails identically much later would accumulate across
921
+ // unrelated stretches of the run and short-circuit a healthy loop.
922
+ identicalErrors = 0;
923
+ lastErrorFingerprint = null;
801
924
  } catch (err) {
802
925
  // A HaltError from a tool body is a deliberate governance exit, not a tool failure — re-throw it
803
926
  // like every other seam (the outer catch pairs dangling tool_calls + returns halt cleanly). Ordinary
@@ -807,6 +930,12 @@ class Loop {
807
930
  const errMsg = `[Loop] Tool error: ${toolError.message}`;
808
931
  msgs.push({ role: 'tool', tool_call_id: tc.id, content: errMsg });
809
932
  this._safeEmit({ type: 'loop:tool_result', data: { tool: tc.name, error: errMsg } });
933
+
934
+ // BA-12: `execute` threw. Count this failing call and short-circuit on the Nth identical repeat —
935
+ // see recordToolFailure for the full rationale (only a byte-identical repeat counts; a model that
936
+ // adapts its args, or a success, resets the streak).
937
+ const stuck = recordToolFailure(tc);
938
+ if (stuck) return stuck;
810
939
  }
811
940
 
812
941
  // BA1: forward tool result/error to gate.record (via wireGate) with ctx in
@@ -837,19 +966,55 @@ class Loop {
837
966
  // synthetic `[halted]` replies so the returned msgs is a valid
838
967
  // OpenAI-shaped transcript — consumers can feed it back into another
839
968
  // provider call without tripping the tool-call/tool-result pairing.
840
- sealDanglingToolCalls(msgs, rule);
969
+ sealDanglingToolCalls(msgs, `[halted:${rule}]`);
841
970
  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() };
971
+ // BA-5: the rule tag survives on `error`; so does the work. A halt is how a bounded attempt is
972
+ // SUPPOSED to end the caller reads `error` to know it was bounded and `text` to learn from it.
973
+ this._safeEmit({ type: 'loop:done', data: { text: lastText, halted: true, rule, cost: totalCost } });
974
+ return { text: lastText, toolCalls: [], usage: lastUsage, cost: totalCost, error: `halt:${rule}`, msgs, metrics: finalizeMetrics(), ...(temperatureDropped && { temperatureDropped: true }) };
844
975
  }
845
976
  throw err;
846
977
  }
847
978
 
979
+ // BA-5 / BA-3: a caller-initiated stop() breaks the round loop and lands here. It is NOT a fault, and it
980
+ // is not the hard limit — reporting it as either (which is what fall-through did: it returned the
981
+ // safety-limit warning below, indistinguishable from a runaway) forces every caller to keep a
982
+ // `stoppedByBound` flag to un-lie the return value. A deliberate stop returns error:null + the work.
983
+ if (this._stopped) {
984
+ // stop() can land mid-round, between an assistant tool_calls message and its results — pair the
985
+ // stragglers so the returned transcript stays provider-valid (the same seal the halt path applies).
986
+ sealDanglingToolCalls(msgs, '[stopped]');
987
+ // RT-2 F2 residual harvest, same as the clean-completion path: a stop is a DELIBERATE end (error:null,
988
+ // transcript final), so the surviving window must be harvested or a stopped run silently loses every
989
+ // never-evicted turn that an identical naturally-ending run would have kept. (A governance halt is
990
+ // deliberately NOT flushed — that is an abort, not an end.) Fail-open / HaltError per the trim contract.
991
+ const flushOnStop = this.trim && /** @type {any} */ (this.trim).flush;
992
+ if (typeof flushOnStop === 'function') {
993
+ try {
994
+ await flushOnStop(msgs, ctx);
995
+ } catch (err) {
996
+ // We are PAST the outer HaltError handler here, so a governance halt raised during the harvest
997
+ // (e.g. a write-gate deny) cannot be re-thrown — that would escape run() as an exception and break
998
+ // the contract that a HaltError is always a clean return. Convert it in place, as the outer handler
999
+ // would have.
1000
+ if (err instanceof HaltError) {
1001
+ const rule = err.rule || 'unknown';
1002
+ this._reportError('halt', err, { rule, reason: err.decision?.reason ?? null });
1003
+ this._safeEmit({ type: 'loop:done', data: { text: lastText, halted: true, rule, cost: totalCost } });
1004
+ return { text: lastText, toolCalls: [], usage: lastUsage, cost: totalCost, error: `halt:${rule}`, msgs, metrics: finalizeMetrics(), ...(temperatureDropped && { temperatureDropped: true }) };
1005
+ }
1006
+ this._reportError('trim-flush', err, { phase: 'stop' });
1007
+ }
1008
+ }
1009
+ this._safeEmit({ type: 'loop:done', data: { text: lastText, stopped: true, cost: totalCost } });
1010
+ return { text: lastText, toolCalls: [], usage: lastUsage, cost: totalCost, error: null, msgs, metrics: finalizeMetrics(), ...(temperatureDropped && { temperatureDropped: true }) };
1011
+ }
1012
+
848
1013
  // Hard safety limit — should never fire under normal usage; bareguard's
849
1014
  // limits.maxTurns (or the LLM's natural completion) ends the loop first.
850
1015
  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() };
1016
+ this._safeEmit({ type: 'loop:done', data: { text: lastText, warning, cost: totalCost } });
1017
+ return { text: lastText, toolCalls: [], usage: lastUsage, cost: totalCost, error: warning, msgs, metrics: finalizeMetrics(), ...(temperatureDropped && { temperatureDropped: true }) };
853
1018
  }
854
1019
 
855
1020
  /**
@@ -934,6 +1099,11 @@ class Loop {
934
1099
  return result;
935
1100
  }
936
1101
 
1102
+ /**
1103
+ * Request a clean stop. The current tool call finishes; the loop then exits at the next boundary and
1104
+ * `run()` resolves with `error: null` and the last text the model produced (BA-5) — a deliberate stop
1105
+ * is not a fault, and callers should not need a `stoppedByBound` flag to un-lie the return value.
1106
+ */
937
1107
  stop() {
938
1108
  this._stopped = true;
939
1109
  }