bare-agent 0.27.0 → 0.29.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 +2 -1
- package/bareagent.context.md +16 -3
- package/examples/with-bareguard.mjs +2 -0
- package/package.json +1 -1
- package/src/loop.d.ts +11 -2
- package/src/loop.js +70 -27
- package/src/provider-stop-reason.d.ts +8 -0
- package/src/provider-stop-reason.js +54 -2
- package/src/recurse-retrieval.d.ts +1 -1
- package/src/recurse-retrieval.js +2 -2
- package/src/recurse.js +1 -1
- package/tools/shell.d.ts +39 -0
- package/tools/shell.js +129 -6
package/README.md
CHANGED
|
@@ -123,7 +123,7 @@ console.log(result.count, result.matchedIds); // a code-derived count + the id
|
|
|
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).
|
|
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
127
|
|
|
128
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
129
|
|
|
@@ -161,6 +161,7 @@ const { policy, onLlmResult, onToolResult, filterTools } = wireGate(gate, {
|
|
|
161
161
|
if (toolName === 'shell_exec') return { type: 'bash', args, _ctx: ctx };
|
|
162
162
|
if (toolName === 'shell_read') return { type: 'read', args, _ctx: ctx };
|
|
163
163
|
if (toolName === 'shell_write') return { type: 'write', args, _ctx: ctx }; // gate by fs.writeScope
|
|
164
|
+
if (toolName === 'shell_edit') return { type: 'edit', args, _ctx: ctx }; // same fs.writeScope as write
|
|
164
165
|
return defaultActionTranslator(toolName, args, ctx);
|
|
165
166
|
},
|
|
166
167
|
});
|
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.29.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
|
|
|
@@ -65,7 +65,8 @@ Eight entry points:
|
|
|
65
65
|
| Assess website privacy risk | createBrowsingTools + Loop (requires `npm install wearehere`) |
|
|
66
66
|
| Control Android/iOS devices | createMobileTools + Loop |
|
|
67
67
|
| Control mobile (token-efficient, disk-based) | `baremobile` CLI session — snapshots to `.baremobile/*.yml` |
|
|
68
|
-
| Read/write files, list directories, run shell commands, grep | createShellTools (shell_read/grep/**write**/run/exec) + Loop({ policy }) — gate `shell_write` via `fs.writeScope` with an actionTranslator |
|
|
68
|
+
| Read/write files, list directories, run shell commands, grep | createShellTools (shell_read/grep/**write**/**edit**/run/exec) + Loop({ policy }) — gate `shell_write`/`shell_edit` via `fs.writeScope` with an actionTranslator |
|
|
69
|
+
| Change one span of a big file without re-emitting the whole thing | `shell_edit({path, oldText, newText})` — anchored exact-once replace; the surgical alternative to whole-file `shell_write` (BA-13). Gate it as `{type:'edit'}` |
|
|
69
70
|
| Auto-discover MCP servers from IDE configs | createMCPBridge |
|
|
70
71
|
| Gate MCP tools with allow/deny lists | createMCPBridge + `.mcp-bridge.json` |
|
|
71
72
|
| Gate every tool call with one policy hook | `wireGate(gate).policy` → `Loop({ policy })` |
|
|
@@ -388,7 +389,15 @@ Halt-severity decisions exit the loop cleanly via a typed `HaltError` — full m
|
|
|
388
389
|
|
|
389
390
|
**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
|
|
|
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
|
+
**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`.
|
|
393
|
+
|
|
394
|
+
**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:
|
|
395
|
+
- `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.
|
|
396
|
+
- `context_exceeded` → **`result.error === 'context_exceeded'`** (ran out of context window — a spend/limit story, distinct from the output cap).
|
|
397
|
+
- `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.
|
|
398
|
+
- The tool calls of a `refusal` / `context_exceeded` round are **refused, not executed** — the same BA-4 closure as `max_tokens`.
|
|
399
|
+
|
|
400
|
+
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.
|
|
392
401
|
|
|
393
402
|
**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.
|
|
394
403
|
|
|
@@ -415,6 +424,7 @@ const { policy, onToolResult } = wireGate(gate, {
|
|
|
415
424
|
if (toolName === 'shell_run') return { type: 'bash', args, _ctx: ctx }; // reads args.argv → joins to cmd
|
|
416
425
|
if (toolName === 'shell_read') return { type: 'read', args, _ctx: ctx }; // reads args.path
|
|
417
426
|
if (toolName === 'shell_write') return { type: 'write', args, _ctx: ctx }; // gate by fs.writeScope (reads args.path)
|
|
427
|
+
if (toolName === 'shell_edit') return { type: 'edit', args, _ctx: ctx }; // same fs.writeScope as write (reads args.path)
|
|
418
428
|
return { type: toolName, args, _ctx: ctx }; // fall through to defaultActionTranslator
|
|
419
429
|
},
|
|
420
430
|
});
|
|
@@ -1239,6 +1249,7 @@ Mobile tools follow the observe-act pattern: action tools auto-return a fresh sn
|
|
|
1239
1249
|
|---|---|
|
|
1240
1250
|
| `shell_read` | Read a file (utf8, 256KB cap) or list a directory (tab-separated). `~` expands to home. |
|
|
1241
1251
|
| `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. |
|
|
1252
|
+
| `shell_edit` | Anchored exact-string replace — the **surgical** alternative to whole-file `shell_write` (BA-13). `{path, oldText, newText}`: `oldText` must occur **exactly once** (quote surrounding lines to be unique); `newText` is spliced in **verbatim** (`""` deletes). Returns a compact `edited <path>: 1 replacement` receipt, never the body. 0 or 2+ matches → a refusal **returned as the tool result** (the model re-anchors; file untouched). Gates through `fs.writeScope` translated to `{type:'edit'}` — see the note below. |
|
|
1242
1253
|
| `shell_grep` | JavaScript regex search across files. Walks directories, skips binary files, returns `{hits: [{file, line, text}], truncated, fileCount}`. |
|
|
1243
1254
|
| `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.** |
|
|
1244
1255
|
| `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). |
|
|
@@ -1247,6 +1258,8 @@ Mobile tools follow the observe-act pattern: action tools auto-return a fresh sn
|
|
|
1247
1258
|
|
|
1248
1259
|
> **⚠️ `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
1260
|
|
|
1261
|
+
> **`shell_edit` — the surgical write (BA-13).** Changing one line of an 800-line file with `shell_write` forces the model to re-emit **all 800 lines** as tool-call JSON: an output-token tax ∝ file size (output is the expensive token class), paid on every revision, and the maximal broken-tree surface (a truncated rewrite mangles the lines it never meant to touch — the BA-4/BA-6 class). `shell_edit({path, oldText, newText})` emits only the anchor + replacement. Semantics worth knowing: `oldText` must match **exactly once** (a 0/2+ match is a refusal *returned as the tool result*, so the loop continues and the model widens the anchor — not a throw, so a repeated-identical miss is bounded by maxTurns/budget, not the spin guard); missing/empty `oldText` or missing/non-string `newText` **throw** (BA-4 guards; `newText:""` is a legal deletion); the write is **atomic** (sibling temp + rename, mode preserved) so an fs failure never leaves a partial file; and `newText` is a **literal splice** (a `$&`/`$1` in it lands verbatim, unlike `String.replace`). Gate it exactly like `shell_write` but as `{type:'edit'}` — **bareguard gates `edit` by the same `fs.writeScope` as `write` with zero config** (its FS primitive's `FS_TYPES` already includes `edit`).
|
|
1262
|
+
|
|
1250
1263
|
> **⚠️ `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).
|
|
1251
1264
|
|
|
1252
1265
|
```javascript
|
|
@@ -60,6 +60,8 @@ const actionTranslator = (toolName, args, ctx) => {
|
|
|
60
60
|
case 'shell_grep': return { type: 'read', path: args?.path, args, _ctx: ctx ?? null };
|
|
61
61
|
// shell_write is a write — gate it through fs.writeScope (add writeScope to the Gate config to enforce).
|
|
62
62
|
case 'shell_write': return { type: 'write', path: args?.path, args, _ctx: ctx ?? null };
|
|
63
|
+
// shell_edit is an anchored edit — bareguard gates {type:'edit'} by the SAME fs.writeScope as write.
|
|
64
|
+
case 'shell_edit': return { type: 'edit', path: args?.path, args, _ctx: ctx ?? null };
|
|
63
65
|
default: return { type: toolName, args, _ctx: ctx ?? null };
|
|
64
66
|
}
|
|
65
67
|
};
|
package/package.json
CHANGED
package/src/loop.d.ts
CHANGED
|
@@ -136,7 +136,7 @@ export class Loop {
|
|
|
136
136
|
* thunk is re-evaluated each round (D4/eval-assist F2) so a tool set that grows mid-run — e.g. a skill
|
|
137
137
|
* unlocking its tools — is offered on the next round; a static array is resolved once at wire time.
|
|
138
138
|
* @param {Record<string, any>} [options={}] - Per-run overrides (system, temperature, ctx, etc.).
|
|
139
|
-
* @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}>}
|
|
140
140
|
* On halt the returned `error` is `halt:<rule>` (or `halt:unknown` if the
|
|
141
141
|
* thrown HaltError carried no `rule`), and `msgs` is sanitized so any
|
|
142
142
|
* dangling assistant `tool_calls` from the halted round are paired with
|
|
@@ -153,6 +153,13 @@ export class Loop {
|
|
|
153
153
|
*
|
|
154
154
|
* A caller-initiated `stop()` returns `error: null` — a deliberate stop is not a fault. (It previously
|
|
155
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.
|
|
156
163
|
* @throws {Error} `[Loop] Tool is missing a name` — when a tool has no name or a non-string name.
|
|
157
164
|
* @throws {Error} `[Loop] Tool "X" is missing an execute() function` — when execute is not a function.
|
|
158
165
|
* @throws {Error} `[Loop] Tool "X" has invalid parameters` — when parameters is not an object.
|
|
@@ -163,6 +170,7 @@ export class Loop {
|
|
|
163
170
|
usage: Usage;
|
|
164
171
|
cost: number;
|
|
165
172
|
error: string | null;
|
|
173
|
+
stopReason: string | null;
|
|
166
174
|
msgs: Message[];
|
|
167
175
|
metrics: RunMetrics;
|
|
168
176
|
temperatureDropped?: boolean;
|
|
@@ -193,7 +201,7 @@ export class Loop {
|
|
|
193
201
|
* @param {string} text - User message.
|
|
194
202
|
* @param {ToolDef[]} [tools=[]] - Tool definitions.
|
|
195
203
|
* @param {Record<string, any>} [options={}] - Per-run overrides.
|
|
196
|
-
* @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}>}
|
|
197
205
|
*/
|
|
198
206
|
chat(text: string, tools?: ToolDef[], options?: Record<string, any>): Promise<{
|
|
199
207
|
text: string;
|
|
@@ -201,6 +209,7 @@ export class Loop {
|
|
|
201
209
|
usage: Usage;
|
|
202
210
|
cost: number;
|
|
203
211
|
error: string | null;
|
|
212
|
+
stopReason: string | null;
|
|
204
213
|
msgs: Message[];
|
|
205
214
|
metrics: RunMetrics;
|
|
206
215
|
temperatureDropped?: boolean;
|
package/src/loop.js
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
'use strict';
|
|
2
2
|
|
|
3
3
|
const { ToolError, HaltError } = require('./errors');
|
|
4
|
-
const {
|
|
4
|
+
const { classifyStopReason } = require('./provider-stop-reason');
|
|
5
5
|
|
|
6
6
|
/** @typedef {import('../types').Provider} Provider */
|
|
7
7
|
/** @typedef {import('../types').Message} Message */
|
|
@@ -346,7 +346,7 @@ class Loop {
|
|
|
346
346
|
* thunk is re-evaluated each round (D4/eval-assist F2) so a tool set that grows mid-run — e.g. a skill
|
|
347
347
|
* unlocking its tools — is offered on the next round; a static array is resolved once at wire time.
|
|
348
348
|
* @param {Record<string, any>} [options={}] - Per-run overrides (system, temperature, ctx, etc.).
|
|
349
|
-
* @returns {Promise<{text: string, toolCalls: ToolCall[], usage: Usage, cost: number, error: string|null, msgs: Message[], metrics: RunMetrics, temperatureDropped?: boolean}>}
|
|
349
|
+
* @returns {Promise<{text: string, toolCalls: ToolCall[], usage: Usage, cost: number, error: string|null, stopReason: string|null, msgs: Message[], metrics: RunMetrics, temperatureDropped?: boolean}>}
|
|
350
350
|
* On halt the returned `error` is `halt:<rule>` (or `halt:unknown` if the
|
|
351
351
|
* thrown HaltError carried no `rule`), and `msgs` is sanitized so any
|
|
352
352
|
* dangling assistant `tool_calls` from the halted round are paired with
|
|
@@ -363,6 +363,13 @@ class Loop {
|
|
|
363
363
|
*
|
|
364
364
|
* A caller-initiated `stop()` returns `error: null` — a deliberate stop is not a fault. (It previously
|
|
365
365
|
* fell through to the hard-round-limit return and reported that safety warning as its `error`.)
|
|
366
|
+
*
|
|
367
|
+
* BA-13 — `stopReason` (the round's NEUTRAL stop reason) is surfaced on EVERY return, and non-clean
|
|
368
|
+
* terminal rounds are error-tagged instead of laundered into `error: null`: a safety `refusal` returns
|
|
369
|
+
* `error: 'refusal'` and a `context_exceeded` returns `error: 'context_exceeded'`, both with partial
|
|
370
|
+
* text preserved (BA-5). BEHAVIOR CHANGE: a refused round that previously returned `error: null` with
|
|
371
|
+
* empty text now returns `error: 'refusal'` — the point of the fix. `pause_turn` is NOT terminal: the
|
|
372
|
+
* loop resumes (bounded by the hard round limit / gate). `error` stays the sole success signal.
|
|
366
373
|
* @throws {Error} `[Loop] Tool is missing a name` — when a tool has no name or a non-string name.
|
|
367
374
|
* @throws {Error} `[Loop] Tool "X" is missing an execute() function` — when execute is not a function.
|
|
368
375
|
* @throws {Error} `[Loop] Tool "X" has invalid parameters` — when parameters is not an object.
|
|
@@ -427,6 +434,12 @@ class Loop {
|
|
|
427
434
|
// The caller decides what a partial result is worth; the library must not decide it is worth nothing.
|
|
428
435
|
// Stays '' when the model never produced text (nothing to preserve — we never invent a placeholder).
|
|
429
436
|
let lastText = '';
|
|
437
|
+
// BA-13: the NEUTRAL stop reason of the most recent completed round (post-provider-normalization).
|
|
438
|
+
// Surfaced on EVERY return so a caller can branch on WHY a run ended, not just its `error` tag — the
|
|
439
|
+
// load-bearing companion to the classifier (a terminal `error` says "not a clean finish"; `stopReason`
|
|
440
|
+
// says which kind). Stays null until the first round completes, and across a provider error / a
|
|
441
|
+
// pre-round stop() it holds the last round's value (or null if none ran).
|
|
442
|
+
let lastStopReason = null;
|
|
430
443
|
// BA-10: sticky across rounds — true if ANY round's `temperature` was dropped by the model (400,
|
|
431
444
|
// unsupported/deprecated) and retried without it. Surfaced on the result so an upstream receipt
|
|
432
445
|
// (recurse's refineLeaf) can report the EFFECTIVE temperature rather than the ignored request.
|
|
@@ -463,7 +476,7 @@ class Loop {
|
|
|
463
476
|
sealDanglingToolCalls(msgs, `[halted:${stuckTag}]`);
|
|
464
477
|
this._reportError('stuck', new Error(`tool "${tc.name}" failed ${identicalErrors} times with identical arguments`), { rule: stuckTag, attempts: identicalErrors });
|
|
465
478
|
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 }) };
|
|
479
|
+
return { text: lastText, toolCalls: [], usage: lastUsage, cost: totalCost, error: stuckTag, stopReason: lastStopReason, msgs, metrics: finalizeMetrics(), ...(temperatureDropped && { temperatureDropped: true }) };
|
|
467
480
|
}
|
|
468
481
|
return null;
|
|
469
482
|
};
|
|
@@ -691,7 +704,7 @@ class Loop {
|
|
|
691
704
|
this._reportError('provider', err, { round });
|
|
692
705
|
if (this.throwOnError) throw err;
|
|
693
706
|
// 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 }) };
|
|
707
|
+
return { text: lastText, toolCalls: [], usage: lastUsage, cost: totalCost, error: err.message, stopReason: lastStopReason, msgs, metrics: finalizeMetrics(), ...(temperatureDropped && { temperatureDropped: true }) };
|
|
695
708
|
}
|
|
696
709
|
|
|
697
710
|
lastUsage = result.usage || lastUsage;
|
|
@@ -700,6 +713,9 @@ class Loop {
|
|
|
700
713
|
// is the text most worth keeping). A tool-call-only round carries no text, so hold the last non-empty
|
|
701
714
|
// one rather than letting a silent round erase an earlier account.
|
|
702
715
|
if (typeof result.text === 'string' && result.text.trim() !== '') lastText = result.text;
|
|
716
|
+
// BA-13: capture this round's neutral stop reason for surfacing on the run's return (every exit
|
|
717
|
+
// path reads lastStopReason). Non-string / absent ⇒ null (the provider's pre-BA-6 degrade).
|
|
718
|
+
lastStopReason = typeof result.stopReason === 'string' ? result.stopReason : null;
|
|
703
719
|
if (result.temperatureDropped) temperatureDropped = true;
|
|
704
720
|
// Publish the latest measured usage to ctx (non-enumerable, fail-open) so a transcript-bound seam —
|
|
705
721
|
// e.g. F2 stash auto-compaction — can read EXACT provider-counted `inputTokens` to gauge context
|
|
@@ -745,21 +761,48 @@ class Loop {
|
|
|
745
761
|
}
|
|
746
762
|
}
|
|
747
763
|
|
|
748
|
-
// BA-
|
|
749
|
-
//
|
|
750
|
-
//
|
|
751
|
-
//
|
|
752
|
-
//
|
|
753
|
-
//
|
|
754
|
-
//
|
|
755
|
-
//
|
|
756
|
-
//
|
|
757
|
-
// so refusing here discards nothing legitimate and closes the data-loss path for EVERY tool.
|
|
764
|
+
// BA-13: classify this round's terminal signal against the neutral stop-reason vocabulary. BA-6
|
|
765
|
+
// short-circuited exactly one non-clean reason (max_tokens); this gate is the general form. It sits
|
|
766
|
+
// AFTER metering (the tokens were really spent — the gate must see them) and BEFORE tool execution,
|
|
767
|
+
// which is the load-bearing half: a non-final round's tool calls were cut off mid-generation, so
|
|
768
|
+
// their arguments are missing keys. Executing one is exactly how BA-4 emptied a 1789-line file
|
|
769
|
+
// (`shell_write` reached the fs with no `content`). A COMPLETE call always arrives tagged 'tool_use',
|
|
770
|
+
// never a truncation/refusal (measured on the real API, poc/ba6-stop-reason-mapping.mjs), so refusing
|
|
771
|
+
// the tool calls of ANY non-clean terminal round discards nothing legitimate and closes the data-loss
|
|
772
|
+
// path for EVERY tool — the BA-4 protocol-layer closure applied uniformly (see classifyStopReason).
|
|
758
773
|
//
|
|
759
|
-
// We report; the caller decides. No auto-retry
|
|
760
|
-
//
|
|
761
|
-
//
|
|
762
|
-
|
|
774
|
+
// We report; the caller decides. No auto-retry: that doubles spend against the gate's budget, and the
|
|
775
|
+
// right recovery (raise the cap? re-gate the refusal? shorten the context?) is the caller's, not the
|
|
776
|
+
// library's. `lastText` (BA-5) preserves the partial work on every terminal leg.
|
|
777
|
+
const terminal = classifyStopReason(result.stopReason);
|
|
778
|
+
if (terminal === 'resume') {
|
|
779
|
+
// BA-13: `pause_turn` — a RESUMABLE server-side tool pause (the API's server-tool loop hit its
|
|
780
|
+
// per-turn iteration cap mid-turn). NOT a finish, NOT an error. Resuming REQUIRES re-sending the
|
|
781
|
+
// paused assistant turn: the provider detects the trailing server-tool block and continues where
|
|
782
|
+
// it left off (the documented Anthropic pause_turn protocol — "re-send the user message and
|
|
783
|
+
// assistant response"). A bare `continue` WITHOUT appending re-sends byte-identical input, so the
|
|
784
|
+
// server restarts the turn from scratch and pauses again, spinning to HARD_ROUND_LIMIT (100 paid
|
|
785
|
+
// calls) instead of resuming. So append the assistant turn — its partial text plus the
|
|
786
|
+
// provider-native server-tool/thinking blocks (which the Anthropic provider replays via
|
|
787
|
+
// providerBlocks) — BEFORE continuing, exactly as the tool-execution path pushes its assistant
|
|
788
|
+
// turn. Only push when there is something to carry: an empty assistant turn (no text, no blocks)
|
|
789
|
+
// is wire-invalid, and a pause with no partial output cannot be advanced anyway — HARD_ROUND_LIMIT
|
|
790
|
+
// (or the gate) bounds that pathological case. No client tool_calls are pushed: a server pause
|
|
791
|
+
// does not carry an unpaired client call, and pushing one would orphan it (wire-invalid) — the
|
|
792
|
+
// same BA-4 refusal principle as the other non-clean terminal legs.
|
|
793
|
+
const hasText = typeof result.text === 'string' && result.text.trim() !== '';
|
|
794
|
+
if (hasText || result.providerBlocks) {
|
|
795
|
+
msgs.push({ role: 'assistant', content: result.text || null, ...(result.providerBlocks && { providerBlocks: result.providerBlocks }) });
|
|
796
|
+
}
|
|
797
|
+
this._safeEmit({ type: 'loop:resume', data: { round, stopReason: lastStopReason } });
|
|
798
|
+
continue;
|
|
799
|
+
}
|
|
800
|
+
if (terminal) {
|
|
801
|
+
// terminal ∈ {'truncated','refusal','context_exceeded'} — a non-clean terminal round. Error-tag it
|
|
802
|
+
// (NOT just surface stopReason): `recurse`'s worker path and the bareloop adopter both branch on
|
|
803
|
+
// `error`, and 0.27.0's "error is the sole success signal" invariant must stay true — an
|
|
804
|
+
// `error:null` + `stopReason:'refusal'` would re-breed BA-6 for these legs.
|
|
805
|
+
const errorTag = terminal === 'truncated' ? 'truncated:max_tokens' : terminal;
|
|
763
806
|
const dropped = (result.toolCalls || []).length;
|
|
764
807
|
// Seal the transcript with the partial text only. Deliberately NOT the tool_calls: pushing a call
|
|
765
808
|
// we refuse to execute would orphan it (a tool_call with no tool_result is a wire-invalid
|
|
@@ -767,8 +810,8 @@ class Loop {
|
|
|
767
810
|
if (typeof result.text === 'string' && result.text.trim() !== '') {
|
|
768
811
|
msgs.push({ role: 'assistant', content: result.text });
|
|
769
812
|
}
|
|
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:
|
|
813
|
+
this._safeEmit({ type: 'loop:done', data: { text: lastText, ...(terminal === 'truncated' && { truncated: true }), terminal, stopReason: lastStopReason, droppedToolCalls: dropped, cost: totalCost } });
|
|
814
|
+
return { text: lastText, toolCalls: [], usage: lastUsage, cost: totalCost, error: errorTag, stopReason: lastStopReason, msgs, metrics: finalizeMetrics(), ...(temperatureDropped && { temperatureDropped: true }) };
|
|
772
815
|
}
|
|
773
816
|
|
|
774
817
|
// No tool calls — LLM gave a final text response
|
|
@@ -788,7 +831,7 @@ class Loop {
|
|
|
788
831
|
try { await flush(msgs, ctx); }
|
|
789
832
|
catch (err) { if (err instanceof HaltError) throw err; this._reportError('trim-flush', err, { round }); }
|
|
790
833
|
}
|
|
791
|
-
return { text: result.text, toolCalls: [], usage: lastUsage, cost: totalCost, error: null, msgs, metrics: finalizeMetrics(), ...(temperatureDropped && { temperatureDropped: true }) };
|
|
834
|
+
return { text: result.text, toolCalls: [], usage: lastUsage, cost: totalCost, error: null, stopReason: lastStopReason, msgs, metrics: finalizeMetrics(), ...(temperatureDropped && { temperatureDropped: true }) };
|
|
792
835
|
}
|
|
793
836
|
|
|
794
837
|
// Execute tool calls
|
|
@@ -897,7 +940,7 @@ class Loop {
|
|
|
897
940
|
sealDanglingToolCalls(msgs, `[halted:${denyTag}]`);
|
|
898
941
|
this._reportError('denied', new Error(`policy denied ${consecutiveDenials} consecutive tool calls (${tc.name})`), { rule: denyTag, denials: consecutiveDenials });
|
|
899
942
|
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 }) };
|
|
943
|
+
return { text: lastText, toolCalls: [], usage: lastUsage, cost: totalCost, error: denyTag, stopReason: lastStopReason, msgs, metrics: finalizeMetrics(), ...(temperatureDropped && { temperatureDropped: true }) };
|
|
901
944
|
}
|
|
902
945
|
continue;
|
|
903
946
|
}
|
|
@@ -971,7 +1014,7 @@ class Loop {
|
|
|
971
1014
|
// BA-5: the rule tag survives on `error`; so does the work. A halt is how a bounded attempt is
|
|
972
1015
|
// SUPPOSED to end — the caller reads `error` to know it was bounded and `text` to learn from it.
|
|
973
1016
|
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 }) };
|
|
1017
|
+
return { text: lastText, toolCalls: [], usage: lastUsage, cost: totalCost, error: `halt:${rule}`, stopReason: lastStopReason, msgs, metrics: finalizeMetrics(), ...(temperatureDropped && { temperatureDropped: true }) };
|
|
975
1018
|
}
|
|
976
1019
|
throw err;
|
|
977
1020
|
}
|
|
@@ -1001,20 +1044,20 @@ class Loop {
|
|
|
1001
1044
|
const rule = err.rule || 'unknown';
|
|
1002
1045
|
this._reportError('halt', err, { rule, reason: err.decision?.reason ?? null });
|
|
1003
1046
|
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 }) };
|
|
1047
|
+
return { text: lastText, toolCalls: [], usage: lastUsage, cost: totalCost, error: `halt:${rule}`, stopReason: lastStopReason, msgs, metrics: finalizeMetrics(), ...(temperatureDropped && { temperatureDropped: true }) };
|
|
1005
1048
|
}
|
|
1006
1049
|
this._reportError('trim-flush', err, { phase: 'stop' });
|
|
1007
1050
|
}
|
|
1008
1051
|
}
|
|
1009
1052
|
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 }) };
|
|
1053
|
+
return { text: lastText, toolCalls: [], usage: lastUsage, cost: totalCost, error: null, stopReason: lastStopReason, msgs, metrics: finalizeMetrics(), ...(temperatureDropped && { temperatureDropped: true }) };
|
|
1011
1054
|
}
|
|
1012
1055
|
|
|
1013
1056
|
// Hard safety limit — should never fire under normal usage; bareguard's
|
|
1014
1057
|
// limits.maxTurns (or the LLM's natural completion) ends the loop first.
|
|
1015
1058
|
const warning = `[Loop] hit internal safety limit of ${HARD_ROUND_LIMIT} rounds. Wire bareguard for proper governance — see bare-agent/bareguard.`;
|
|
1016
1059
|
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 }) };
|
|
1060
|
+
return { text: lastText, toolCalls: [], usage: lastUsage, cost: totalCost, error: warning, stopReason: lastStopReason, msgs, metrics: finalizeMetrics(), ...(temperatureDropped && { temperatureDropped: true }) };
|
|
1018
1061
|
}
|
|
1019
1062
|
|
|
1020
1063
|
/**
|
|
@@ -1087,7 +1130,7 @@ class Loop {
|
|
|
1087
1130
|
* @param {string} text - User message.
|
|
1088
1131
|
* @param {ToolDef[]} [tools=[]] - Tool definitions.
|
|
1089
1132
|
* @param {Record<string, any>} [options={}] - Per-run overrides.
|
|
1090
|
-
* @returns {Promise<{text: string, toolCalls: ToolCall[], usage: Usage, cost: number, error: string|null, msgs: Message[], metrics: RunMetrics, temperatureDropped?: boolean}>}
|
|
1133
|
+
* @returns {Promise<{text: string, toolCalls: ToolCall[], usage: Usage, cost: number, error: string|null, stopReason: string|null, msgs: Message[], metrics: RunMetrics, temperatureDropped?: boolean}>}
|
|
1091
1134
|
*/
|
|
1092
1135
|
async chat(text, tools = [], options = {}) {
|
|
1093
1136
|
this._history.push({ role: 'user', content: text });
|
|
@@ -20,7 +20,15 @@ export function normalizeStopReason(raw: string | null | undefined, provider: "a
|
|
|
20
20
|
* folded in here — `pause_turn` in particular is a RESUMABLE state, and erroring on it would break
|
|
21
21
|
* server-side tool flows that are working exactly as designed.
|
|
22
22
|
*
|
|
23
|
+
* Retained for back-compat (it was the Loop's original BA-6 gate). The Loop now routes through
|
|
24
|
+
* {@link classifyStopReason} instead — `isTruncated(x)` is exactly `classifyStopReason(x) === 'truncated'`.
|
|
25
|
+
*
|
|
23
26
|
* @param {string|null|undefined} stopReason - a NEUTRAL value (post-{@link normalizeStopReason}).
|
|
24
27
|
* @returns {boolean}
|
|
25
28
|
*/
|
|
26
29
|
export function isTruncated(stopReason: string | null | undefined): boolean;
|
|
30
|
+
/**
|
|
31
|
+
* @param {string|null|undefined} stopReason - a NEUTRAL value (post-{@link normalizeStopReason}).
|
|
32
|
+
* @returns {'truncated'|'refusal'|'context_exceeded'|'resume'|null}
|
|
33
|
+
*/
|
|
34
|
+
export function classifyStopReason(stopReason: string | null | undefined): "truncated" | "refusal" | "context_exceeded" | "resume" | null;
|
|
@@ -111,7 +111,10 @@ function normalizeStopReason(raw, provider, ctx = {}) {
|
|
|
111
111
|
if (!table) return raw;
|
|
112
112
|
// An unrecognized-but-present value passes through: the caller can still SEE it, and the Loop only
|
|
113
113
|
// acts on the known vocabulary — so a new upstream value can never be mistaken for a truncation.
|
|
114
|
-
|
|
114
|
+
// Own-property only (mirror of classifyStopReason): `raw` is a provider/proxy-supplied field, so a
|
|
115
|
+
// value like 'toString'/'constructor' would otherwise resolve `table[raw]` to an inherited
|
|
116
|
+
// Object.prototype function (truthy) and be returned in place of the verbatim string.
|
|
117
|
+
const mapped = Object.prototype.hasOwnProperty.call(table, raw) ? table[raw] : raw;
|
|
115
118
|
|
|
116
119
|
// GEMINI AND OLLAMA HAVE NO `tool_use` FINISH REASON (both measured live: Gemini returns
|
|
117
120
|
// `finishReason: STOP` and Ollama `done_reason: 'stop'` on a round that emitted a complete function
|
|
@@ -138,6 +141,9 @@ function normalizeStopReason(raw, provider, ctx = {}) {
|
|
|
138
141
|
* folded in here — `pause_turn` in particular is a RESUMABLE state, and erroring on it would break
|
|
139
142
|
* server-side tool flows that are working exactly as designed.
|
|
140
143
|
*
|
|
144
|
+
* Retained for back-compat (it was the Loop's original BA-6 gate). The Loop now routes through
|
|
145
|
+
* {@link classifyStopReason} instead — `isTruncated(x)` is exactly `classifyStopReason(x) === 'truncated'`.
|
|
146
|
+
*
|
|
141
147
|
* @param {string|null|undefined} stopReason - a NEUTRAL value (post-{@link normalizeStopReason}).
|
|
142
148
|
* @returns {boolean}
|
|
143
149
|
*/
|
|
@@ -145,4 +151,50 @@ function isTruncated(stopReason) {
|
|
|
145
151
|
return stopReason === 'max_tokens';
|
|
146
152
|
}
|
|
147
153
|
|
|
148
|
-
|
|
154
|
+
/**
|
|
155
|
+
* BA-13 — classify a round's NEUTRAL stop reason into the terminal ACTION the Loop must take.
|
|
156
|
+
*
|
|
157
|
+
* BA-6 short-circuited exactly one non-clean stop reason (`max_tokens`). Every OTHER non-clean reason
|
|
158
|
+
* — `refusal`, `context_exceeded`, `pause_turn` — fell through the Loop's "no tool calls ⇒ final
|
|
159
|
+
* answer" rule and was laundered into a clean `error: null` empty success (the BA-4/5/6/7 bug class:
|
|
160
|
+
* an under-modeled boundary round rounding optimistically toward "done"). A `RECITATION` refusal fires
|
|
161
|
+
* on entirely BENIGN prompts, so this was reachable on ordinary runs, and it propagated up `recurse`'s
|
|
162
|
+
* agent tree as a converged sub-task.
|
|
163
|
+
*
|
|
164
|
+
* One table with an EXPLICIT pass-through default replaces the single `if (isTruncated)` — the BA-7
|
|
165
|
+
* lesson ("don't parse-key on a closed set") applied to termination: BA-6 added one leg, BA-13 adds
|
|
166
|
+
* two terminals plus one resume, and the NEXT new stop reason degrades to pass-through (status quo)
|
|
167
|
+
* rather than re-breeding the bug.
|
|
168
|
+
*
|
|
169
|
+
* 'truncated' `max_tokens` — cut off at the output cap (BA-6). Loop returns `error:'truncated:max_tokens'`.
|
|
170
|
+
* 'refusal' declined on safety grounds. Loop returns `error:'refusal'` + partial text.
|
|
171
|
+
* 'context_exceeded' ran out of context window. Loop returns `error:'context_exceeded'` + partial text.
|
|
172
|
+
* 'resume' `pause_turn` — a RESUMABLE server-tool pause. NOT terminal, NOT an error: the
|
|
173
|
+
* Loop CONTINUES the round loop (bounded by HARD_ROUND_LIMIT / the gate's maxTurns).
|
|
174
|
+
* null pass-through: `end_turn` / `stop_sequence` / `tool_use` / an unrecognized value /
|
|
175
|
+
* absent. The Loop's existing tool-exec / final-answer logic runs unchanged.
|
|
176
|
+
*
|
|
177
|
+
* NB: `tool_use` is already derived by {@link normalizeStopReason} from what the round carried, so it
|
|
178
|
+
* needs no row here — a round that stopped to call a complete tool passes through to tool execution.
|
|
179
|
+
*/
|
|
180
|
+
const TERMINAL_ACTIONS = /** @type {Record<string, 'truncated'|'refusal'|'context_exceeded'|'resume'>} */ ({
|
|
181
|
+
max_tokens: 'truncated',
|
|
182
|
+
refusal: 'refusal',
|
|
183
|
+
context_exceeded: 'context_exceeded',
|
|
184
|
+
pause_turn: 'resume',
|
|
185
|
+
});
|
|
186
|
+
/**
|
|
187
|
+
* @param {string|null|undefined} stopReason - a NEUTRAL value (post-{@link normalizeStopReason}).
|
|
188
|
+
* @returns {'truncated'|'refusal'|'context_exceeded'|'resume'|null}
|
|
189
|
+
*/
|
|
190
|
+
function classifyStopReason(stopReason) {
|
|
191
|
+
if (typeof stopReason !== 'string') return null;
|
|
192
|
+
// Own-property only: `normalizeStopReason` passes an unrecognized value through verbatim, so a
|
|
193
|
+
// provider/proxy emitting stop_reason:'toString'/'constructor'/etc. would otherwise resolve to an
|
|
194
|
+
// inherited Object.prototype function (truthy) and be mistaken for a terminal action.
|
|
195
|
+
return Object.prototype.hasOwnProperty.call(TERMINAL_ACTIONS, stopReason)
|
|
196
|
+
? TERMINAL_ACTIONS[stopReason]
|
|
197
|
+
: null;
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
module.exports = { normalizeStopReason, isTruncated, classifyStopReason };
|
|
@@ -143,7 +143,7 @@ export function buildScanTool(corpus: Slice[] | (() => Promise<Slice[]>), opts:
|
|
|
143
143
|
/**
|
|
144
144
|
* Build a SCAN slice-source backed by a litectx-RESIDENT corpus (facts/episodes the agent already accrued) —
|
|
145
145
|
* the §10-step-7 deferral un-blocked by litectx 0.26's `enumerate` verb (spec:
|
|
146
|
-
* docs/01-product/
|
|
146
|
+
* docs/01-product/prd.md). Returns the generic async slice-source recurse's scan reads: a
|
|
147
147
|
* `() => Promise<Slice[]>` that pages through EVERY row of `kind` via `enumerate` (exhaustive — the rank-free
|
|
148
148
|
* read `recall` structurally cannot do) and maps each to `{id: item.path, text: item.body}`. recurse stays
|
|
149
149
|
* litectx-agnostic: it depends on this source SHAPE, never on litectx (same stance as `remember`'s Store
|
package/src/recurse-retrieval.js
CHANGED
|
@@ -16,7 +16,7 @@
|
|
|
16
16
|
//
|
|
17
17
|
// THE CORPUS FOR SCAN IS A GENERIC ARRAY SLICE-SOURCE (`opts.corpus`), NOT litectx: litectx has no exhaustive,
|
|
18
18
|
// rank-free enumerate verb today (every read is FTS-gated). The "corpus that already LIVES in litectx" case
|
|
19
|
-
// waits on the litectx `enumerate` verb (docs/01-product/
|
|
19
|
+
// waits on the litectx `enumerate` verb (docs/01-product/prd.md) and drops in behind this
|
|
20
20
|
// same slice-source socket with ZERO recurse changes — the same backend-agnostic stance as `remember`'s Store
|
|
21
21
|
// socket. Composes AROUND a Loop; NEVER imported by loop.js.
|
|
22
22
|
|
|
@@ -326,7 +326,7 @@ const ENUM_PAGE = 200;
|
|
|
326
326
|
/**
|
|
327
327
|
* Build a SCAN slice-source backed by a litectx-RESIDENT corpus (facts/episodes the agent already accrued) —
|
|
328
328
|
* the §10-step-7 deferral un-blocked by litectx 0.26's `enumerate` verb (spec:
|
|
329
|
-
* docs/01-product/
|
|
329
|
+
* docs/01-product/prd.md). Returns the generic async slice-source recurse's scan reads: a
|
|
330
330
|
* `() => Promise<Slice[]>` that pages through EVERY row of `kind` via `enumerate` (exhaustive — the rank-free
|
|
331
331
|
* read `recall` structurally cannot do) and maps each to `{id: item.path, text: item.body}`. recurse stays
|
|
332
332
|
* litectx-agnostic: it depends on this source SHAPE, never on litectx (same stance as `remember`'s Store
|
package/src/recurse.js
CHANGED
|
@@ -708,7 +708,7 @@ async function recurseRefineLeaf(task, ctx, opts, state) {
|
|
|
708
708
|
* never folded into the count as a zero; a governance HaltError mid-scan → clean incomplete.
|
|
709
709
|
*
|
|
710
710
|
* The corpus is the generic array slice-source `opts.corpus`. Absent it, scan has nothing to read — litectx's
|
|
711
|
-
* resident-corpus enumerate path is deferred (docs/01-product/
|
|
711
|
+
* resident-corpus enumerate path is deferred (docs/01-product/prd.md) — so we return an
|
|
712
712
|
* honest incomplete, never a fabricated zero.
|
|
713
713
|
* @param {string} task
|
|
714
714
|
* @param {RecurseCtx} ctx
|
package/tools/shell.d.ts
CHANGED
|
@@ -5,6 +5,7 @@ declare namespace _exports {
|
|
|
5
5
|
export { createShellTools };
|
|
6
6
|
export { _grepCore };
|
|
7
7
|
export { writeFile as _writeFile };
|
|
8
|
+
export { editFile as _editFile };
|
|
8
9
|
}
|
|
9
10
|
export = _exports;
|
|
10
11
|
type GrepArgs = {
|
|
@@ -97,3 +98,41 @@ declare function writeFile({ path: rawPath, content, append, maxBytes }: {
|
|
|
97
98
|
append?: boolean;
|
|
98
99
|
maxBytes?: number;
|
|
99
100
|
}): Promise<string>;
|
|
101
|
+
/**
|
|
102
|
+
* Anchored exact-string replace (BA-13) — the surgical counterpart to the whole-file `shell_write`.
|
|
103
|
+
* Changing one line of an 800-line file with `shell_write` forces the model to EMIT all 800 lines as
|
|
104
|
+
* tool-call JSON: an output-token tax ∝ file size (output is the expensive token class), paid on every
|
|
105
|
+
* revision, and the maximal broken-tree surface (a truncated rewrite mangles the 799 lines it never meant
|
|
106
|
+
* to touch — the BA-4/BA-6 truncation class). `shell_edit` emits only the anchor and its replacement.
|
|
107
|
+
*
|
|
108
|
+
* TWO error classes, deliberately split:
|
|
109
|
+
* - ANCHOR failures (`oldText` matches 0 or 2+ times) RETURN a refusal string as a normal tool RESULT —
|
|
110
|
+
* the loop continues and the model re-anchors, and the refusal names the count so the retry is a DISTINCT
|
|
111
|
+
* call. (Tradeoff, chosen with eyes open: a result does NOT feed the Loop's `maxIdenticalToolErrors` spin
|
|
112
|
+
* guard, so a model that repeats the byte-identical wrong anchor is bounded only by maxTurns/budget, not
|
|
113
|
+
* short-circuited. A widened anchor is a different call and recovers naturally; the exact-repeat spin is
|
|
114
|
+
* the rare degenerate case. This matches the ask's "refusal, not a throw" contract.)
|
|
115
|
+
* - fs-layer errors (missing file, a directory) and BA-4 param-guard violations THROW at the tool boundary.
|
|
116
|
+
*
|
|
117
|
+
* BA-4 param guards (guarded from birth this time — cf. `shell_write` zeroing files on an absent arg):
|
|
118
|
+
* `oldText` a required NON-EMPTY string, `newText` a required string — both THROW when absent/wrong-type (an
|
|
119
|
+
* absent param is the truncated-call signature, never a silent default). Explicit `newText:""` is a legal
|
|
120
|
+
* deletion; an absent `newText` is not.
|
|
121
|
+
*
|
|
122
|
+
* ATOMIC: read → splice in memory → write a sibling temp (same filesystem, so `rename` is atomic) carrying
|
|
123
|
+
* the original's mode → rename over the original. Any throw before the rename leaves the original
|
|
124
|
+
* byte-identical and cleans the temp up, so a reader never sees a partial file, and an edit can't silently
|
|
125
|
+
* drop the executable bit.
|
|
126
|
+
*
|
|
127
|
+
* LITERAL splice, NOT `String.replace`: `.replace(oldText, newText)` interprets `$&`/`$1`/`` $` `` patterns in
|
|
128
|
+
* `newText` and would corrupt any edit whose replacement contains a `$`. We index + slice, so every byte of
|
|
129
|
+
* `newText` lands verbatim.
|
|
130
|
+
* @param {{path: string, oldText: string, newText: string, maxBytes?: number}} args
|
|
131
|
+
* @returns {Promise<string>}
|
|
132
|
+
*/
|
|
133
|
+
declare function editFile({ path: rawPath, oldText, newText, maxBytes }: {
|
|
134
|
+
path: string;
|
|
135
|
+
oldText: string;
|
|
136
|
+
newText: string;
|
|
137
|
+
maxBytes?: number;
|
|
138
|
+
}): Promise<string>;
|
package/tools/shell.js
CHANGED
|
@@ -7,6 +7,7 @@
|
|
|
7
7
|
* shell_read — read a file or list a directory
|
|
8
8
|
* shell_grep — regex search across files (JS regex, no grep/rg/findstr)
|
|
9
9
|
* shell_write — write/overwrite (or append to) a file, creating parent dirs (no shell)
|
|
10
|
+
* shell_edit — anchored exact-string replace: change one span without rewriting the whole file
|
|
10
11
|
* shell_run — run a command via an argv array (no shell, allowlist-friendly on argv[0])
|
|
11
12
|
* shell_exec — run a raw shell command with timeout + max buffer
|
|
12
13
|
*
|
|
@@ -16,11 +17,13 @@
|
|
|
16
17
|
* GATING WITH bareguard's fs/bash PRIMITIVES: these tools carry tool-named actions by default
|
|
17
18
|
* (`{ type:'shell_write' }`), which match `tools.allowlist`/`tools.denylist` but do NOT activate the
|
|
18
19
|
* `fs`/`bash` primitives — those need `action.type ∈ {read,write,edit,bash}` with `action.path`/`action.cmd`.
|
|
19
|
-
* To gate `shell_write` by `fs.writeScope` (so a write outside the allowed root is denied BEFORE it
|
|
20
|
-
* disk), translate it at the gate — see `examples/with-bareguard.mjs` for the `wireGate(gate, { actionTranslator })`
|
|
21
|
-
* mapping (`shell_write` → `{ type:'write', path }`, `
|
|
22
|
-
* `shell_run`/`shell_exec` → `{ type:'bash', cmd }`).
|
|
23
|
-
*
|
|
20
|
+
* To gate `shell_write`/`shell_edit` by `fs.writeScope` (so a write outside the allowed root is denied BEFORE it
|
|
21
|
+
* touches disk), translate it at the gate — see `examples/with-bareguard.mjs` for the `wireGate(gate, { actionTranslator })`
|
|
22
|
+
* mapping (`shell_write` → `{ type:'write', path }`, `shell_edit` → `{ type:'edit', path }`, `shell_read`/`shell_grep`
|
|
23
|
+
* → `{ type:'read', path }`, `shell_run`/`shell_exec` → `{ type:'bash', cmd }`). bareguard gates `edit` by
|
|
24
|
+
* `fs.writeScope` identically to `write` (its FS primitive's `FS_TYPES` includes `edit`), so a consumer that
|
|
25
|
+
* fences `write` gets `edit` fenced by the same scope with ZERO extra config. A write/edit tool alone is NOT
|
|
26
|
+
* auto-gated — validated by poc/ba2-write-tool-gate.mjs (without the translator the out-of-scope write leaks).
|
|
24
27
|
*
|
|
25
28
|
* CAVEAT (applies to read AND write scopes): bareguard's `fs` primitive matches paths LEXICALLY (no
|
|
26
29
|
* `realpath`/symlink resolution), so a symlink that lives INSIDE the allowed scope but points OUTSIDE it is
|
|
@@ -33,6 +36,7 @@
|
|
|
33
36
|
|
|
34
37
|
const fs = require('node:fs/promises');
|
|
35
38
|
const path = require('node:path');
|
|
39
|
+
const crypto = require('node:crypto');
|
|
36
40
|
const { exec, execFile } = require('node:child_process');
|
|
37
41
|
const { Worker } = require('node:worker_threads');
|
|
38
42
|
|
|
@@ -127,6 +131,101 @@ async function writeFile({ path: rawPath, content, append = false, maxBytes }) {
|
|
|
127
131
|
return `${append ? 'appended' : 'wrote'} ${bytes} bytes to ${resolved}`;
|
|
128
132
|
}
|
|
129
133
|
|
|
134
|
+
/**
|
|
135
|
+
* Anchored exact-string replace (BA-13) — the surgical counterpart to the whole-file `shell_write`.
|
|
136
|
+
* Changing one line of an 800-line file with `shell_write` forces the model to EMIT all 800 lines as
|
|
137
|
+
* tool-call JSON: an output-token tax ∝ file size (output is the expensive token class), paid on every
|
|
138
|
+
* revision, and the maximal broken-tree surface (a truncated rewrite mangles the 799 lines it never meant
|
|
139
|
+
* to touch — the BA-4/BA-6 truncation class). `shell_edit` emits only the anchor and its replacement.
|
|
140
|
+
*
|
|
141
|
+
* TWO error classes, deliberately split:
|
|
142
|
+
* - ANCHOR failures (`oldText` matches 0 or 2+ times) RETURN a refusal string as a normal tool RESULT —
|
|
143
|
+
* the loop continues and the model re-anchors, and the refusal names the count so the retry is a DISTINCT
|
|
144
|
+
* call. (Tradeoff, chosen with eyes open: a result does NOT feed the Loop's `maxIdenticalToolErrors` spin
|
|
145
|
+
* guard, so a model that repeats the byte-identical wrong anchor is bounded only by maxTurns/budget, not
|
|
146
|
+
* short-circuited. A widened anchor is a different call and recovers naturally; the exact-repeat spin is
|
|
147
|
+
* the rare degenerate case. This matches the ask's "refusal, not a throw" contract.)
|
|
148
|
+
* - fs-layer errors (missing file, a directory) and BA-4 param-guard violations THROW at the tool boundary.
|
|
149
|
+
*
|
|
150
|
+
* BA-4 param guards (guarded from birth this time — cf. `shell_write` zeroing files on an absent arg):
|
|
151
|
+
* `oldText` a required NON-EMPTY string, `newText` a required string — both THROW when absent/wrong-type (an
|
|
152
|
+
* absent param is the truncated-call signature, never a silent default). Explicit `newText:""` is a legal
|
|
153
|
+
* deletion; an absent `newText` is not.
|
|
154
|
+
*
|
|
155
|
+
* ATOMIC: read → splice in memory → write a sibling temp (same filesystem, so `rename` is atomic) carrying
|
|
156
|
+
* the original's mode → rename over the original. Any throw before the rename leaves the original
|
|
157
|
+
* byte-identical and cleans the temp up, so a reader never sees a partial file, and an edit can't silently
|
|
158
|
+
* drop the executable bit.
|
|
159
|
+
*
|
|
160
|
+
* LITERAL splice, NOT `String.replace`: `.replace(oldText, newText)` interprets `$&`/`$1`/`` $` `` patterns in
|
|
161
|
+
* `newText` and would corrupt any edit whose replacement contains a `$`. We index + slice, so every byte of
|
|
162
|
+
* `newText` lands verbatim.
|
|
163
|
+
* @param {{path: string, oldText: string, newText: string, maxBytes?: number}} args
|
|
164
|
+
* @returns {Promise<string>}
|
|
165
|
+
*/
|
|
166
|
+
async function editFile({ path: rawPath, oldText, newText, maxBytes }) {
|
|
167
|
+
if (typeof rawPath !== 'string' || rawPath.length === 0) {
|
|
168
|
+
throw new Error('shell_edit requires a non-empty "path" string');
|
|
169
|
+
}
|
|
170
|
+
if (typeof oldText !== 'string' || oldText.length === 0) {
|
|
171
|
+
throw new Error(
|
|
172
|
+
'shell_edit requires a non-empty "oldText" string to anchor the edit — refusing to edit, the file is unchanged. '
|
|
173
|
+
+ `Got ${oldText === undefined ? 'no oldText argument' : oldText === '' ? 'an empty string' : `oldText of type ${oldText === null ? 'null' : typeof oldText}`}.`,
|
|
174
|
+
);
|
|
175
|
+
}
|
|
176
|
+
if (typeof newText !== 'string') {
|
|
177
|
+
throw new Error(
|
|
178
|
+
'shell_edit requires a "newText" string (pass newText:"" to delete the anchored text) — refusing to edit, the file is unchanged. '
|
|
179
|
+
+ `Got ${newText === undefined ? 'no newText argument' : `newText of type ${newText === null ? 'null' : typeof newText}`}`
|
|
180
|
+
+ '. If your output was cut short, retry with the full newText.',
|
|
181
|
+
);
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
const resolved = path.resolve(expandHome(rawPath));
|
|
185
|
+
// fs-layer errors (ENOENT for a missing file, EISDIR for a directory) throw — same surface as shell_read.
|
|
186
|
+
const content = await fs.readFile(resolved, 'utf8');
|
|
187
|
+
|
|
188
|
+
// Literal, non-overlapping occurrence count (split on a string does no regex interpretation).
|
|
189
|
+
const occurrences = content.split(oldText).length - 1;
|
|
190
|
+
if (occurrences === 0) {
|
|
191
|
+
return `shell_edit: oldText not found in ${resolved} — no change made. Quote the exact text to replace `
|
|
192
|
+
+ `(check whitespace and indentation), or read the file to re-anchor.`;
|
|
193
|
+
}
|
|
194
|
+
if (occurrences > 1) {
|
|
195
|
+
return `shell_edit: oldText occurs ${occurrences}× in ${resolved} — the anchor must match exactly once. `
|
|
196
|
+
+ `Widen it with surrounding lines so it is unique. No change made.`;
|
|
197
|
+
}
|
|
198
|
+
|
|
199
|
+
const idx = content.indexOf(oldText);
|
|
200
|
+
const patched = content.slice(0, idx) + newText + content.slice(idx + oldText.length);
|
|
201
|
+
|
|
202
|
+
const cap = maxBytes || DEFAULT_WRITE_MAX_BYTES;
|
|
203
|
+
const bytes = Buffer.byteLength(patched, 'utf8');
|
|
204
|
+
if (bytes > cap) {
|
|
205
|
+
throw new Error(`shell_edit result is ${bytes} bytes, over the ${cap}-byte cap (pass maxBytes to raise it)`);
|
|
206
|
+
}
|
|
207
|
+
|
|
208
|
+
// Atomic replace: a sibling temp (same dir → same filesystem → rename is atomic) with the original's mode.
|
|
209
|
+
const stat = await fs.stat(resolved);
|
|
210
|
+
const tmp = `${resolved}.shell_edit-${crypto.randomBytes(9).toString('hex')}.tmp`;
|
|
211
|
+
try {
|
|
212
|
+
// flag 'wx' (O_CREAT|O_EXCL) — never follow or clobber a pre-planted file/symlink at the temp path; a
|
|
213
|
+
// colliding name fails the write instead. Create owner-only (0o600) so the patched body — which may hold
|
|
214
|
+
// a secret from a sensitive source file — is never briefly world-readable in the window before chmod sets
|
|
215
|
+
// the original's real mode. (This temp pattern is new to shell_edit, so it carries its own hardening.)
|
|
216
|
+
await fs.writeFile(tmp, patched, { encoding: 'utf8', flag: 'wx', mode: 0o600 });
|
|
217
|
+
await fs.chmod(tmp, stat.mode & 0o777);
|
|
218
|
+
await fs.rename(tmp, resolved);
|
|
219
|
+
} catch (err) {
|
|
220
|
+
await fs.rm(tmp, { force: true }).catch(() => {});
|
|
221
|
+
throw err;
|
|
222
|
+
}
|
|
223
|
+
|
|
224
|
+
const removed = oldText.split('\n').length - 1;
|
|
225
|
+
const added = newText.split('\n').length - 1;
|
|
226
|
+
return `edited ${resolved}: 1 replacement (-${removed}/+${added} lines)`;
|
|
227
|
+
}
|
|
228
|
+
|
|
130
229
|
// Probe the first 1KB for NUL bytes to skip binary files in grep walks.
|
|
131
230
|
/** @param {string} filePath */
|
|
132
231
|
async function isProbablyText(filePath) {
|
|
@@ -453,6 +552,30 @@ function createShellTools() {
|
|
|
453
552
|
execute: async (/** @type {{path: string, content?: string, append?: boolean, maxBytes?: number}} */ args) =>
|
|
454
553
|
writeFile(/** @type {any} */ (args)),
|
|
455
554
|
},
|
|
555
|
+
{
|
|
556
|
+
name: 'shell_edit',
|
|
557
|
+
description: 'Replace an exact, unique text span in a file — the surgical alternative to shell_write, which ' +
|
|
558
|
+
'rewrites the ENTIRE file. Give oldText (the exact text to replace — it must occur EXACTLY ONCE, so quote ' +
|
|
559
|
+
'enough surrounding lines to be unique) and newText (its replacement; pass "" to delete). Matched literally ' +
|
|
560
|
+
'(no regex; whitespace and indentation are significant). Returns a compact "edited <path>: 1 replacement" ' +
|
|
561
|
+
'receipt, never the file body. If oldText matches 0 or 2+ times the file is left unchanged and the reason is ' +
|
|
562
|
+
'returned so you can re-anchor. The file must already exist. No shell — gate by path with an fs.writeScope ' +
|
|
563
|
+
'policy (translate to {type:"edit"}).',
|
|
564
|
+
parameters: {
|
|
565
|
+
type: 'object',
|
|
566
|
+
properties: {
|
|
567
|
+
path: { type: 'string', description: 'File to edit. ~ expands to home. The file must already exist.' },
|
|
568
|
+
oldText: { type: 'string', description: 'Exact text to find — must occur exactly once. Quote surrounding lines to disambiguate. Matched literally (no regex); whitespace and indentation are significant.' },
|
|
569
|
+
newText: { type: 'string', description: 'Replacement text, inserted verbatim (a literal splice — $ is not special). Pass "" to delete the anchored text. Required — a call without it is REJECTED, not treated as a deletion.' },
|
|
570
|
+
maxBytes: { type: 'integer', description: 'Reject if the resulting file would exceed this many bytes (default 5242880).' },
|
|
571
|
+
},
|
|
572
|
+
required: ['path', 'oldText', 'newText'],
|
|
573
|
+
},
|
|
574
|
+
// The args are model-authored and UNTRUSTED — oldText/newText may be absent (an output-token-capped
|
|
575
|
+
// generation), so the boundary type stays loose and editFile enforces the BA-4 contract at runtime.
|
|
576
|
+
execute: async (/** @type {{path: string, oldText?: string, newText?: string, maxBytes?: number}} */ args) =>
|
|
577
|
+
editFile(/** @type {any} */ (args)),
|
|
578
|
+
},
|
|
456
579
|
{
|
|
457
580
|
name: 'shell_run',
|
|
458
581
|
description: 'Run a command with an argv array (no shell, no interpolation) and return {stdout, stderr, code, timedOut}. Use this when a policy allowlist needs to match on argv[0] — no shell metacharacter injection is possible. Default timeout 30s, max output 1MB.',
|
|
@@ -493,4 +616,4 @@ function createShellTools() {
|
|
|
493
616
|
return { tools };
|
|
494
617
|
}
|
|
495
618
|
|
|
496
|
-
module.exports = { createShellTools, _grepCore, _writeFile: writeFile };
|
|
619
|
+
module.exports = { createShellTools, _grepCore, _writeFile: writeFile, _editFile: editFile };
|