bare-agent 0.26.2 → 0.27.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/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