bare-agent 0.26.0 → 0.27.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,148 @@
1
+ /**
2
+ * BA-6 — normalize each provider's native finish-reason field to one neutral vocabulary.
3
+ *
4
+ * Every provider tells you WHY generation ended. Before this, bare-agent read the field on none of
5
+ * them (`grep -rn 'stop_reason\|finish_reason\|done_reason' src/` → zero hits), so a round the API
6
+ * CUT OFF at the token cap was indistinguishable from one the model chose to end — the Loop's rule is
7
+ * "no tool calls ⇒ final answer", and a truncation has no tool calls, so it returned as a clean finish
8
+ * with `error: null`. A truncation was laundered into a completion.
9
+ *
10
+ * The neutral vocabulary (what the Loop is allowed to reason about):
11
+ *
12
+ * 'end_turn' the model finished of its own accord — the ONLY clean finish
13
+ * 'max_tokens' CUT OFF at the output cap. NOT a finish. Load-bearing (see below).
14
+ * 'tool_use' stopped to call a tool, and the call is COMPLETE
15
+ * 'stop_sequence' hit a caller-supplied stop string — a legitimate finish
16
+ * 'refusal' declined on safety grounds (Anthropic `refusal`, OpenAI `content_filter`, …)
17
+ * 'pause_turn' server-side tool loop paused; the caller is expected to RESUME, not to error
18
+ * 'context_exceeded' ran out of CONTEXT WINDOW (distinct from running out of output budget)
19
+ * null provider didn't say / we don't recognize it
20
+ *
21
+ * `null` is the safe default and it is deliberate: an unmapped or absent value reproduces the
22
+ * pre-BA-6 behavior exactly. A wrong guess therefore degrades to the status quo rather than inventing
23
+ * a false truncation error on a healthy run. Unknown-but-present values pass through verbatim (a
24
+ * caller can still see them) but the Loop only ever ACTS on the values above.
25
+ *
26
+ * ── Why 'max_tokens' vs 'tool_use' is the load-bearing distinction (measured, not assumed) ──
27
+ *
28
+ * `poc/ba6-stop-reason-mapping.mjs`, real API, claude-sonnet-5 + gpt-4o-mini:
29
+ *
30
+ * a COMPLETE tool call ALWAYS arrives tagged 'tool_use' — never 'max_tokens'.
31
+ *
32
+ * Anthropic returned `stop_reason: "tool_use"` with intact arguments even at a tight 1024-token cap;
33
+ * OpenAI refuses outright (HTTP 400) rather than emit a tool call it could not finish. Neither ever
34
+ * handed back a COMPLETE tool call tagged as truncated.
35
+ *
36
+ * The converse is the dangerous case, and it is not hypothetical — it is the BA-4 file-zeroing bug one
37
+ * layer up: a round tagged 'max_tokens' that CARRIES a tool call carries a tool call that was cut off
38
+ * mid-generation, whose arguments are missing keys. That is precisely how a `claude-haiku-4-5` worker
39
+ * emptied a 1789-line file — it hit the output cap mid-`shell_write`, the `content` argument never
40
+ * arrived, and the truncated call was executed as if whole. So the Loop must NEVER execute the tool
41
+ * calls of a 'max_tokens' round. Refusing costs nothing legitimate (complete calls come back
42
+ * 'tool_use') and closes the data-loss path at the protocol layer, for every tool, not just
43
+ * `shell_write`.
44
+ */
45
+
46
+ /** Anthropic `stop_reason` → neutral. */
47
+ const ANTHROPIC = {
48
+ end_turn: 'end_turn',
49
+ max_tokens: 'max_tokens',
50
+ tool_use: 'tool_use',
51
+ stop_sequence: 'stop_sequence',
52
+ refusal: 'refusal',
53
+ pause_turn: 'pause_turn',
54
+ model_context_window_exceeded: 'context_exceeded',
55
+ };
56
+
57
+ /** OpenAI (and OpenAI-compatible) `finish_reason` → neutral. */
58
+ const OPENAI = {
59
+ stop: 'end_turn',
60
+ length: 'max_tokens',
61
+ tool_calls: 'tool_use',
62
+ function_call: 'tool_use',
63
+ content_filter: 'refusal',
64
+ };
65
+
66
+ /**
67
+ * Gemini `finishReason` → neutral. Gemini does NOT tag a function call specially — a complete tool call
68
+ * comes back as `STOP` with a `functionCall` part (measured live) — so there is no `tool_use` row here
69
+ * by design. `normalizeStopReason` derives it from `hasToolCalls` instead; see the note there.
70
+ */
71
+ const GEMINI = {
72
+ STOP: 'end_turn',
73
+ MAX_TOKENS: 'max_tokens',
74
+ SAFETY: 'refusal',
75
+ RECITATION: 'refusal',
76
+ BLOCKLIST: 'refusal',
77
+ PROHIBITED_CONTENT: 'refusal',
78
+ SPII: 'refusal',
79
+ };
80
+
81
+ /**
82
+ * Ollama `done_reason` → neutral. `load`/`unload` are lifecycle values, not completions — they map to
83
+ * null (unknown) rather than being forced into the vocabulary.
84
+ */
85
+ const OLLAMA = {
86
+ stop: 'end_turn',
87
+ length: 'max_tokens',
88
+ };
89
+
90
+ const TABLES = {
91
+ anthropic: ANTHROPIC,
92
+ openai: OPENAI,
93
+ gemini: GEMINI,
94
+ ollama: OLLAMA,
95
+ };
96
+
97
+ /**
98
+ * Map a provider's native finish-reason value onto the neutral vocabulary.
99
+ *
100
+ * @param {string|null|undefined} raw - the provider's native value (`stop_reason` / `finish_reason` /
101
+ * `finishReason` / `done_reason`). Absent or non-string ⇒ `null` (pre-BA-6 behavior).
102
+ * @param {'anthropic'|'openai'|'gemini'|'ollama'} provider - which table to read.
103
+ * @param {{hasToolCalls?: boolean}} [ctx] - what the round actually CARRIED. See below: two providers
104
+ * cannot express "stopped to call a tool" in their finish-reason field at all, so the round's own
105
+ * content is the only place that fact exists.
106
+ * @returns {string|null} a neutral value, an unrecognized value passed through verbatim, or `null`.
107
+ */
108
+ function normalizeStopReason(raw, provider, ctx = {}) {
109
+ if (typeof raw !== 'string' || raw === '') return null;
110
+ const table = TABLES[provider];
111
+ if (!table) return raw;
112
+ // An unrecognized-but-present value passes through: the caller can still SEE it, and the Loop only
113
+ // acts on the known vocabulary — so a new upstream value can never be mistaken for a truncation.
114
+ const mapped = table[raw] || raw;
115
+
116
+ // GEMINI AND OLLAMA HAVE NO `tool_use` FINISH REASON (both measured live: Gemini returns
117
+ // `finishReason: STOP` and Ollama `done_reason: 'stop'` on a round that emitted a complete function
118
+ // call). Reported verbatim, a round that stopped TO CALL A TOOL would come back as `end_turn` — "the
119
+ // model finished of its own accord" — on 2 of 5 providers and `tool_use` on the other 3.
120
+ //
121
+ // That is the BA-6 defect class in miniature: a round that is NOT a finish, reporting as a finish.
122
+ // An adopter branching on `stopReason === 'end_turn'` would be right on Anthropic/OpenAI and wrong
123
+ // on Gemini/Ollama. So derive it from what the round CARRIED — the model did stop to call a tool,
124
+ // and that is a report, not an invention.
125
+ //
126
+ // Narrow on purpose: only ever promotes `end_turn` → `tool_use`. It cannot touch `max_tokens` (a
127
+ // truncated round carrying a half-generated call must stay TRUNCATED — that is BA-4's mechanism),
128
+ // nor `refusal`/`pause_turn`/`context_exceeded`, nor an unrecognized passthrough value.
129
+ if (mapped === 'end_turn' && ctx.hasToolCalls === true) return 'tool_use';
130
+ return mapped;
131
+ }
132
+
133
+ /**
134
+ * Did this round get CUT OFF at the output-token cap?
135
+ *
136
+ * The one predicate the Loop acts on. Deliberately narrow: `context_exceeded`, `refusal` and
137
+ * `pause_turn` are all "not a normal finish" but they are NOT output-cap truncations and must not be
138
+ * folded in here — `pause_turn` in particular is a RESUMABLE state, and erroring on it would break
139
+ * server-side tool flows that are working exactly as designed.
140
+ *
141
+ * @param {string|null|undefined} stopReason - a NEUTRAL value (post-{@link normalizeStopReason}).
142
+ * @returns {boolean}
143
+ */
144
+ function isTruncated(stopReason) {
145
+ return stopReason === 'max_tokens';
146
+ }
147
+
148
+ module.exports = { normalizeStopReason, isTruncated };
@@ -62,8 +62,17 @@ async function mergeReduce(task, results, opts) {
62
62
  if (typeof out.error === 'string' && out.error.startsWith('halt:')) {
63
63
  throw new HaltError('[synthesize] merge halted by governance', { rule: out.error.slice('halt:'.length) });
64
64
  }
65
- // A non-halt fault (e.g. provider error) is non-fatal here — fall back to the lossless concat rather than
66
- // losing the partials entirely. recurse still reports honest completeness via its own paths.
65
+ // A non-halt fault (e.g. provider error, deny short-circuit, the hard round limit) is non-fatal here — fall
66
+ // back to the LOSSLESS concat rather than losing the partials entirely. recurse still reports honest
67
+ // completeness via its own paths.
68
+ //
69
+ // Branch on `out.error`, NOT on the falsiness of `out.text` (BA-5). Since the Loop now preserves the text a
70
+ // bounded run produced, a faulted merge returns its PARTIAL, aborted prose — so `out.text || concat` would
71
+ // silently ship that fragment as the synthesized answer and every child result would be lost. Proven
72
+ // reachable: the merge Loop registers no tools, so a hallucinated tool call is fed back as
73
+ // `[Loop] Unknown tool` and the round loop CONTINUES — round 1 emits prose, round 2 dies, and the fallback
74
+ // never fires. `error` is the sole success signal; text-falsiness never was one.
75
+ if (out.error) return concatReduce(results);
67
76
  return out.text || concatReduce(results);
68
77
  }
69
78
 
package/tools/shell.d.ts CHANGED
@@ -77,12 +77,23 @@ declare function _grepCore({ pattern, path: rawPath, recursive, maxMatches, flag
77
77
  * writes through the shell is impractical: redirection is a shell metachar that an argv/bash allowlist denies).
78
78
  * Creates parent directories. Caps size as a sanity ceiling. NO shell — so it gates cleanly through bareguard's
79
79
  * fs primitive when the adopter translates `shell_write` → `{ type:'write', path }` (see createShellTools doc).
80
- * @param {{path: string, content?: string, append?: boolean, maxBytes?: number}} args
80
+ *
81
+ * `content` is REQUIRED and must be a string (BA-4). It used to default to `''`, which made the ordinary
82
+ * failure mode of a long generation — the model hits its output-token cap and the tool call arrives with
83
+ * `content` absent — silently truncate the target to zero bytes and report `"wrote 0 bytes"` as SUCCESS.
84
+ * No policy can catch that: a 0-byte write is a legal write, and bareguard's fs primitive judges
85
+ * `{type:'write', path}` without ever inspecting the body. It is a missing precondition in the primitive,
86
+ * not a governance gap. An explicit `content: ''` still empties the file — the caller meant it.
87
+ * `content` is typed REQUIRED so the generated `.d.ts` states the real contract — a library caller that omits
88
+ * it is a type error, not a runtime surprise. The guard below still runs, because the tool-execute boundary
89
+ * feeds this UNTRUSTED model-authored args (that is the boundary BA-4 was breached at, and where types buy
90
+ * nothing).
91
+ * @param {{path: string, content: string, append?: boolean, maxBytes?: number}} args
81
92
  * @returns {Promise<string>}
82
93
  */
83
94
  declare function writeFile({ path: rawPath, content, append, maxBytes }: {
84
95
  path: string;
85
- content?: string;
96
+ content: string;
86
97
  append?: boolean;
87
98
  maxBytes?: number;
88
99
  }): Promise<string>;
package/tools/shell.js CHANGED
@@ -90,23 +90,40 @@ async function readEntry(rawPath, maxBytes) {
90
90
  * writes through the shell is impractical: redirection is a shell metachar that an argv/bash allowlist denies).
91
91
  * Creates parent directories. Caps size as a sanity ceiling. NO shell — so it gates cleanly through bareguard's
92
92
  * fs primitive when the adopter translates `shell_write` → `{ type:'write', path }` (see createShellTools doc).
93
- * @param {{path: string, content?: string, append?: boolean, maxBytes?: number}} args
93
+ *
94
+ * `content` is REQUIRED and must be a string (BA-4). It used to default to `''`, which made the ordinary
95
+ * failure mode of a long generation — the model hits its output-token cap and the tool call arrives with
96
+ * `content` absent — silently truncate the target to zero bytes and report `"wrote 0 bytes"` as SUCCESS.
97
+ * No policy can catch that: a 0-byte write is a legal write, and bareguard's fs primitive judges
98
+ * `{type:'write', path}` without ever inspecting the body. It is a missing precondition in the primitive,
99
+ * not a governance gap. An explicit `content: ''` still empties the file — the caller meant it.
100
+ * `content` is typed REQUIRED so the generated `.d.ts` states the real contract — a library caller that omits
101
+ * it is a type error, not a runtime surprise. The guard below still runs, because the tool-execute boundary
102
+ * feeds this UNTRUSTED model-authored args (that is the boundary BA-4 was breached at, and where types buy
103
+ * nothing).
104
+ * @param {{path: string, content: string, append?: boolean, maxBytes?: number}} args
94
105
  * @returns {Promise<string>}
95
106
  */
96
- async function writeFile({ path: rawPath, content = '', append = false, maxBytes }) {
107
+ async function writeFile({ path: rawPath, content, append = false, maxBytes }) {
97
108
  if (typeof rawPath !== 'string' || rawPath.length === 0) {
98
109
  throw new Error('shell_write requires a non-empty "path" string');
99
110
  }
100
- const text = content == null ? '' : String(content);
111
+ if (typeof content !== 'string') {
112
+ throw new Error(
113
+ 'shell_write requires a "content" string (pass content:"" to deliberately empty the file). '
114
+ + `Got ${content === undefined ? 'no content argument' : `content of type ${content === null ? 'null' : typeof content}`}`
115
+ + ' — refusing to write, the file is unchanged. If your output was cut short, retry with the full content.',
116
+ );
117
+ }
101
118
  const cap = maxBytes || DEFAULT_WRITE_MAX_BYTES;
102
- const bytes = Buffer.byteLength(text, 'utf8');
119
+ const bytes = Buffer.byteLength(content, 'utf8');
103
120
  if (bytes > cap) {
104
121
  throw new Error(`shell_write content is ${bytes} bytes, over the ${cap}-byte cap (pass maxBytes to raise it)`);
105
122
  }
106
123
  const resolved = path.resolve(expandHome(rawPath));
107
124
  await fs.mkdir(path.dirname(resolved), { recursive: true });
108
- if (append) await fs.appendFile(resolved, text, 'utf8');
109
- else await fs.writeFile(resolved, text, 'utf8');
125
+ if (append) await fs.appendFile(resolved, content, 'utf8');
126
+ else await fs.writeFile(resolved, content, 'utf8');
110
127
  return `${append ? 'appended' : 'wrote'} ${bytes} bytes to ${resolved}`;
111
128
  }
112
129
 
@@ -425,13 +442,16 @@ function createShellTools() {
425
442
  type: 'object',
426
443
  properties: {
427
444
  path: { type: 'string', description: 'Target file path. ~ expands to home. Parent dirs are created.' },
428
- content: { type: 'string', description: 'The full text to write (UTF-8).' },
445
+ content: { type: 'string', description: 'The full text to write (UTF-8). Required — a call without it is REJECTED, not treated as an empty write. Pass "" only to deliberately empty the file.' },
429
446
  append: { type: 'boolean', description: 'Append to the file instead of overwriting it (default false).' },
430
447
  maxBytes: { type: 'integer', description: 'Reject a write larger than this many bytes (default 5242880).' },
431
448
  },
432
449
  required: ['path', 'content'],
433
450
  },
434
- execute: async (/** @type {{path: string, content?: string, append?: boolean, maxBytes?: number}} */ args) => writeFile(args),
451
+ // The args are model-authored and UNTRUSTED `content` may be absent (an output-token-capped
452
+ // generation), so the boundary type stays loose and `writeFile` enforces the contract at runtime (BA-4).
453
+ execute: async (/** @type {{path: string, content?: string, append?: boolean, maxBytes?: number}} */ args) =>
454
+ writeFile(/** @type {any} */ (args)),
435
455
  },
436
456
  {
437
457
  name: 'shell_run',
package/types/index.d.ts CHANGED
@@ -80,12 +80,39 @@ export interface GenerateResult {
80
80
  usage: Usage;
81
81
  /** Model id the response was produced by; preferred over Provider.model for cost accounting. */
82
82
  model?: string | null;
83
+ /**
84
+ * Why generation ended, normalized across providers (BA-6). Before this, no provider read its native
85
+ * finish-reason field, so a round the API CUT OFF at the token cap was indistinguishable from one the
86
+ * model chose to end — and the Loop, whose rule is "no tool calls ⇒ final answer", returned the
87
+ * truncation as a clean finish with `error: null`.
88
+ *
89
+ * - `'end_turn'` — the model finished on its own. The only clean finish.
90
+ * - `'max_tokens'` — CUT OFF at the output cap. The Loop returns `error: 'truncated:max_tokens'`
91
+ * (preserving the partial text) and REFUSES to execute any tool call the round carries: a complete
92
+ * call always arrives as `'tool_use'`, so one riding a `'max_tokens'` round was cut off
93
+ * mid-generation with arguments missing — the BA-4 file-zeroing mechanism.
94
+ * - `'tool_use'` — stopped to call a tool, and the call is COMPLETE.
95
+ * - `'stop_sequence'` / `'refusal'` / `'pause_turn'` / `'context_exceeded'` — reported, not acted on.
96
+ * - `null` — the provider didn't report one (e.g. CLIPipe) or the value is unrecognized. Reproduces
97
+ * pre-BA-6 behavior exactly, so an unmapped provider degrades to the status quo.
98
+ */
99
+ stopReason?: string | null;
83
100
  /**
84
101
  * True when the requested `temperature` was rejected by the model (400, unsupported/deprecated) and
85
102
  * the request was retried without it (BA-10). The response was produced at the model's DEFAULT
86
103
  * temperature, not the one requested — callers reporting an effective temperature must honor this.
87
104
  */
88
105
  temperatureDropped?: boolean;
106
+ /**
107
+ * BA-7 — provider-native content blocks the normalized `{text, toolCalls}` shape cannot express
108
+ * (Anthropic `thinking` / `redacted_thinking`), captured opaquely so the Loop can put them on the
109
+ * transcript and the provider can replay them on the next round.
110
+ *
111
+ * Present only when the response actually carried such blocks — so a provider that returns none
112
+ * leaves both the result and the resulting message byte-identical to pre-BA-7. See `Message.providerBlocks`
113
+ * for the replay contract (the provider/model tag is enforced; a signature is model-bound).
114
+ */
115
+ providerBlocks?: { provider: string; model: string; blocks: any[] };
89
116
  /**
90
117
  * Authoritative per-call cost in USD, reported by the provider itself — e.g. CLIPipeProvider
91
118
  * `parse:'claude-json'` surfacing the claude CLI's own `total_cost_usd`, a real price with no local
@@ -102,6 +129,25 @@ export interface Message {
102
129
  content?: string | null;
103
130
  tool_calls?: any[];
104
131
  tool_call_id?: string;
132
+ /**
133
+ * BA-7 — provider-native content blocks that this OpenAI-shaped message cannot express, carried
134
+ * verbatim so they can be replayed to the provider that issued them.
135
+ *
136
+ * Today this is Anthropic `thinking` / `redacted_thinking`. Anthropic's contract is that such
137
+ * blocks are echoed back UNCHANGED (`signature` included) when continuing a tool-use conversation;
138
+ * before BA-7 there was no field on this type that could hold one, so they were silently dropped.
139
+ *
140
+ * OPAQUE by design — the Loop never reads `blocks`, and the provider re-emits their bytes rather
141
+ * than re-serializing a parsed shape (a `redacted_thinking` block cannot survive a round-trip
142
+ * through parsed fields). The `provider`/`model` tag is enforced on replay: a thinking signature is
143
+ * bound to the model that produced it, so a mismatch drops the blocks and degrades to the lossy
144
+ * pre-BA-7 request rather than risking a 400.
145
+ *
146
+ * The normalized `content` / `tool_calls` remain the source of truth: only blocks that have no
147
+ * normalized representation live here, so an `assemble`/`trim` seam that rewrites this message is
148
+ * never silently undone by a stale cached copy of its text.
149
+ */
150
+ providerBlocks?: { provider: string; model: string; blocks: any[] };
105
151
  [key: string]: any;
106
152
  }
107
153