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