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 +7 -1
- package/bareagent.context.md +47 -2
- package/package.json +1 -1
- package/src/loop.d.ts +40 -0
- package/src/loop.js +185 -15
- package/src/provider-anthropic.d.ts +32 -2
- package/src/provider-anthropic.js +106 -4
- package/src/provider-gemini.js +10 -0
- package/src/provider-ollama.d.ts +1 -1
- package/src/provider-ollama.js +32 -9
- package/src/provider-openai.js +14 -5
- package/src/provider-stop-reason.d.ts +26 -0
- package/src/provider-stop-reason.js +148 -0
- package/src/recurse-synthesize.js +11 -2
- package/tools/shell.d.ts +13 -2
- package/tools/shell.js +28 -8
- package/types/index.d.ts +46 -0
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).
|
package/bareagent.context.md
CHANGED
|
@@ -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.
|
|
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
|
|
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
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.
|
|
105
|
-
//
|
|
115
|
+
// reply. Keeps msgs a valid OpenAI transcript when the loop exits between
|
|
116
|
+
// pushing assistant.tool_calls and finishing the per-tool loop.
|
|
117
|
+
//
|
|
118
|
+
// `marker` is the literal reply text, because the exit it seals is not always a halt: a caller-initiated
|
|
119
|
+
// stop() is sealed too, and stamping `[halted:…]` on it would tell a resumed model it was cut off by
|
|
120
|
+
// governance when it wasn't (and would false-positive any consumer grepping msgs for `[halted:`).
|
|
106
121
|
/**
|
|
107
122
|
* @param {Message[]} msgs
|
|
108
|
-
* @param {string}
|
|
123
|
+
* @param {string} marker - Literal content for each synthetic reply, e.g. `[halted:budget.maxCostUsd]`.
|
|
109
124
|
*/
|
|
110
|
-
function sealDanglingToolCalls(msgs,
|
|
125
|
+
function sealDanglingToolCalls(msgs, marker) {
|
|
111
126
|
for (let i = msgs.length - 1; i >= 0; i--) {
|
|
112
127
|
const m = msgs[i];
|
|
113
128
|
if (m.role !== 'assistant' || !Array.isArray(m.tool_calls)) continue;
|
|
@@ -117,7 +132,7 @@ function sealDanglingToolCalls(msgs, rule) {
|
|
|
117
132
|
}
|
|
118
133
|
for (const tc of m.tool_calls) {
|
|
119
134
|
if (!seen.has(tc.id)) {
|
|
120
|
-
msgs.push({ role: 'tool', tool_call_id: tc.id, content:
|
|
135
|
+
msgs.push({ role: 'tool', tool_call_id: tc.id, content: marker });
|
|
121
136
|
}
|
|
122
137
|
}
|
|
123
138
|
return;
|
|
@@ -243,6 +258,12 @@ class Loop {
|
|
|
243
258
|
throw new Error('[Loop] options.maxConsecutiveDenials must be a non-negative number (0 or Infinity disables)');
|
|
244
259
|
}
|
|
245
260
|
this.maxConsecutiveDenials = options.maxConsecutiveDenials != null ? options.maxConsecutiveDenials : 3;
|
|
261
|
+
// BA-12 identical-tool-error spin guard. Same shape as BA-11: default 3, 0/Infinity disables.
|
|
262
|
+
if (options.maxIdenticalToolErrors != null
|
|
263
|
+
&& (typeof options.maxIdenticalToolErrors !== 'number' || options.maxIdenticalToolErrors < 0 || Number.isNaN(options.maxIdenticalToolErrors))) {
|
|
264
|
+
throw new Error('[Loop] options.maxIdenticalToolErrors must be a non-negative number (0 or Infinity disables)');
|
|
265
|
+
}
|
|
266
|
+
this.maxIdenticalToolErrors = options.maxIdenticalToolErrors != null ? options.maxIdenticalToolErrors : 3;
|
|
246
267
|
if (options.assemble != null && typeof options.assemble !== 'function') {
|
|
247
268
|
throw new Error('[Loop] options.assemble must be a function (msgs, info) => msgs');
|
|
248
269
|
}
|
|
@@ -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
|
-
|
|
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
|
-
|
|
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:
|
|
782
|
-
return { text:
|
|
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
|
-
|
|
843
|
-
|
|
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:
|
|
852
|
-
return { text:
|
|
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
|
}
|