bare-agent 0.46.6 → 0.48.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.
@@ -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.43.0 | Node.js >= 18 | zero required deps (`bareguard >=0.9.0 <0.16.0` optional peer for governance) | Apache 2.0
4
+ > v0.43.0 | Node.js >= 18 | zero required deps (`bareguard >=0.19.0 <1.0.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/archive/usage-guide.md)
7
7
 
@@ -19,7 +19,7 @@ Eight entry points:
19
19
  - `require('bare-agent/providers')` — OpenAI, Anthropic, Ollama, CLIPipe, Fallback (the canonical short names; `*Provider` aliases — `OpenAIProvider`, `AnthropicProvider`, etc. — are also exported and match the class names, so either destructure works, v0.12.1+)
20
20
  - `require('bare-agent/stores')` — SQLite (FTS5), JsonFile
21
21
  - `require('bare-agent/transports')` — JsonlTransport
22
- - `require('bare-agent/tools')` — createBrowsingTools, createMobileTools, createShellTools, createSpawnTool, createDeferTool, spawnChild, readDeferQueue, liteCtxMcpBridgeConfig
22
+ - `require('bare-agent/tools')` — createBrowsingTools, createMobileTools, createShellTools, resolveToolPath, createSpawnTool, createDeferTool, spawnChild, readDeferQueue, liteCtxMcpBridgeConfig
23
23
  - `require('bare-agent/mcp')` — createMCPBridge (returns `tools` + `metaTools`), discoverServers, buildMetaTools
24
24
  - `require('bare-agent/bareguard')` — wireGate (one-line bareguard Gate integration), defaultActionTranslator
25
25
 
@@ -509,7 +509,18 @@ The blocks now ride the transcript on `Message.providerBlocks` (`{provider, mode
509
509
 
510
510
  Legacy `wrapTool` / `wrapTools` are retained as deprecation shims (one-shot console warning, removal in 1.0). Migration: replace `wrapTools(tools)` at `loop.run()` with `filterTools(tools)` once upfront + `onLlmResult` / `onToolResult` on `new Loop({...})` to pick up LLM-cost recording and `_ctx` threading.
511
511
 
512
- **`actionTranslator` for bash/fs primitive activation (v0.10.1+).** Bareguard's `bashCheck` / `fsCheck` / `netCheck` only fire when `action.type === 'bash'` / `'read'` / `'write'` / `'fetch'`. The default action shape is `{type: toolName, args, _ctx}` which matches `tools.denylist` / `tools.allowlist` but does NOT activate those primitives. Adopters who want both pass `wireGate(gate, { actionTranslator })`. Since bareguard 0.4.1+, the primitives read fields from either flat (`action.cmd`) or nested (`action.args.cmd` / `.command`) shapes, so you can pass args through verbatim:
512
+ **`actionTranslator` for bash/fs primitive activation — DEFAULT since bareguard >=0.19.0 (the "C2" fix).** Bareguard's `bashCheck` / `fsCheck` only fire when `action.type === 'bash'` / `'read'` / `'write'` / `'edit'`. Through 0.15, `wireGate`'s default action shape was `{type: toolName, args, _ctx}` — this matched `tools.denylist` / `tools.allowlist` but never activated `fs`/`bash`, so every adopter had to hand-write the translator below. **As of bareagent's current peerDependency floor (bareguard >=0.19.0), the DEFAULT translator does this mapping for you** — no custom `actionTranslator` needed for the six `createShellTools` primitives:
513
+
514
+ - `shell_read` / `shell_grep` → `{type:'read', tool, path: resolveToolPath(args.path), args, _ctx}`
515
+ - `shell_write` → `{type:'write', tool, path: resolveToolPath(args.path), args, _ctx}`
516
+ - `shell_edit` → `{type:'edit', tool, path: resolveToolPath(args.path), args, _ctx}`
517
+ - `shell_run` → `{type:'bash', tool, cmd: args.argv.join(' '), args, _ctx}`
518
+ - `shell_exec` → `{type:'bash', tool, cmd: args.command, args, _ctx}`
519
+ - every other tool → `{type: toolName, tool: toolName, args, _ctx}`
520
+
521
+ `tool` carries the literal bareagent tool name on **every** branch — bareguard 0.19's action identity for `tools.allowlist`/`tools.denylist` is `action.tool ?? action.type`, so a tool-name allowlist (`tools.allowlist: ['shell_read']`) keeps matching even though `type` is now `'read'`/`'write'`/`'bash'`, not the tool name. **`filterTools` deliberately does NOT use this translator** — it probes tool IDENTITY ONLY (`{type: name, tool: name}`). No real `args` exist at discovery time, so probing the translated shape would mean `path: undefined`; measured on a real 0.19 Gate, a path-less `read`/`write`/`edit` action SKIPS the fs scope check entirely (not a deny), which would silently OFFER a shell file tool with NO scope configured at all — and bareguard has flagged that behavior may tighten to a deny in a future release, which would instead silently HIDE a properly-scoped tool. Neither reading is safe to build `filterTools` on, so it only reflects `tools.allowlist`/`tools.denylist`; scope enforcement stays entirely at `policy`, on the real call, where it denies loudly. `onLlmResult` still always uses `{type:'llm'}` regardless of the translator (so budget rules match without translator collusion). `resolveToolPath` canonicalizes `~`/relative paths before the gate sees them, exactly as the manual translator below used to; a malformed or missing path is handled so the translator **never crashes and never silently allows** — see `src/bareguard-adapter.js`'s `safeToolPath` for the exact contract: ANY missing or unresolvable path — absent `path` key, non-string, empty, or an unresolvable `~` — normalizes to `''` (bareguard denies via `fs.invalidPath` regardless of scope config). A real call is never silently allowed.
522
+
523
+ **Requires bareguard >=0.19.0.** 0.15 and earlier ignore `action.tool` entirely, so this exact shape combined with a tool-name allowlist would deny every shell file action — that's WHY the peerDependency floor moved, not a runtime version check. Override with `wireGate(gate, { actionTranslator })` if you need a different shape or are pinned to an older bareguard:
513
524
 
514
525
  ```javascript
515
526
  const { policy, onToolResult } = wireGate(gate, {
@@ -524,7 +535,9 @@ const { policy, onToolResult } = wireGate(gate, {
524
535
  });
525
536
  ```
526
537
 
527
- `onLlmResult` always uses `{type:'llm'}` regardless of the translator (so budget rules match without translator collusion). `defaultActionTranslator` is exported for composition. **A tool is NOT auto-gated by the fs/bash primitives without this translator** — e.g. `shell_write` runs the write but `fs.writeScope` only enforces once `shell_write` → `{type:'write', path}`; the default `{type:'shell_write'}` matches `tools.allow/denylist` only. Verified live: with the translator, `gate.check` ALLOWs `shell_run ["ls","/tmp"]` and DENYs `shell_read /etc/passwd` (`[deny: fs.readScope]`), and an out-of-scope `shell_write` is denied **before** `execute` (nothing touches disk).
538
+ Verified live against a real 0.19 Gate: `gate.check` ALLOWs `shell_run ["ls","/tmp"]` and DENYs `shell_read /etc/passwd` (`[deny: fs.readScope]`), and an out-of-scope `shell_write` is denied **before** `execute` (nothing touches disk) — with zero custom translator. `defaultActionTranslator` is exported for composition.
539
+
540
+ > **fs.readScope/writeScope now deny by default when unset (bareguard 0.19).** Before, an unconfigured `fs` primitive that never activated was silently a no-op; now that the default translator activates it, a Gate with NO `fs.readScope` configured denies every `shell_read`/`shell_grep` (`fs.readScope.unset`), and likewise `fs.writeScope.unset` for `shell_write`/`shell_edit`. If you want the shell file tools usable at all, set both scopes explicitly — see `examples/with-bareguard.mjs`. Note this denial is a `policy`-time fact only: `filterTools` (identity-only, see above) still OFFERS the shell file tools to the model even with no scope configured — the deny happens loudly on the real call, not by hiding the tool at discovery time.
528
541
 
529
542
  **Bounding tool rounds — use `limits.maxToolRounds` (bareguard 0.4.2+), not doubled `maxTurns`.** `limits.maxTurns` ticks on every `gate.record` (LLM + tool), so an "N LLM-tool round" cap is `maxTurns: N*2`. `limits.maxToolRounds: N` ticks only on non-`llm` records and gives the natural semantic — pairs cleanly with our split `onLlmResult` / `onToolResult` (the LLM side writes `{type:'llm'}` records which the counter skips). Halt severity, same shape as `maxTurns`, rebuilt from audit on cold-start.
530
543
 
@@ -1414,6 +1427,12 @@ Mobile tools follow the observe-act pattern: action tools auto-return a fresh sn
1414
1427
 
1415
1428
  **Zero baked-in allowlist.** The library ships the primitives; gating is bareguard's job via the standard `wireGate(gate)` wiring.
1416
1429
 
1430
+ > **Path canonicalization + symlink refusal, together.** bareguard's own resolved-path (symlink) containment check is planned for an upcoming release, not yet published; until then the gate matches paths lexically, so a symlinked parent dir is NOT contained even when the link sits inside an allowed scope. bare-agent canonicalizes the path (`~` + resolve, via `resolveToolPath`) before the gate check and opens that same path — this closes a *different* gap (the gate judging a `~`/relative path in a different form than the one the tool opens), not the lexical-match gap above. `noFollowSymlinks` refuses a symlinked final path *component* at open time. Together they narrow but do not close the check-then-open window, and neither covers hardlinks.
1431
+ >
1432
+ > **Opt-in symlink refusal:** `createShellTools({ noFollowSymlinks: true })` makes `shell_read`, `shell_write`, `shell_edit`, and `shell_grep` refuse to open a path whose **final** path component is a symlink — a file, a dir, or dangling — instead of silently following it. Refusal is a **thrown** error with `err.code === 'ELOOP'` (feeds the Loop's `maxIdenticalToolErrors` guard like any other tool error), and for `shell_write` the link's target is never created or touched. Scope is the final component **only** — a symlinked *parent* directory is not covered (per the canonicalization note above), and neither is the `cwd` option of `shell_run`/`shell_exec` — it only gets `~` expansion, never this flag or `resolveToolPath`'s full canonicalization, so a symlinked `cwd` is always followed. `shell_grep` refuses only a symlinked **root** path loudly, via the same atomic open every other tool uses (open-then-fstat, not a look-then-open `lstat`); a file that turns into a symlink partway through the walk is skipped silently, the same as any other read error. The check is atomic (`O_NOFOLLOW`) on linux/macOS; on Windows, which has no `O_NOFOLLOW`-equivalent open flag, it falls back to a non-atomic `lstat`-then-open check — a symlink planted in that gap slips through. A directory listing (`shell_read` on a dir, `shell_grep`'s root when it's a dir) additionally rechecks the path's `dev`+`ino` against the open handle right after listing — Node has no `fdopendir`, so the listing itself still goes by path, and this narrows (does not close) the window where a directory swapped in between the open and the listing would report a different directory's names; a swap-and-swap-back inside that narrow window still passes undetected. Default `false` — omitting the option is byte-identical to the pre-existing behavior.
1433
+ >
1434
+ > **`resolveToolPath(p)`** — exported alongside `createShellTools` from `bare-agent/tools`. THE canonicalizer every shell file tool applies internally before opening: expand `~`/`~/…` via `os.homedir()` (throws rather than silently degrading `~/x` to `/x` when no home directory can be determined), then `path.resolve` against `process.cwd()`. Idempotent; rejects a non-string/empty path (thrown, naming only the type/category of the bad value, never the value itself) — every file tool routes through it, so `shell_read`/`shell_grep` with `""` now error instead of silently reading/searching `process.cwd()`. Call it in your `actionTranslator` on `args.path` before returning the gated action, so the string the gate judges is the exact string the tool opens.
1435
+
1417
1436
  > **⚠️ `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.
1418
1437
 
1419
1438
  > **`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`).
@@ -18,16 +18,30 @@
18
18
 
19
19
  import { Gate } from 'bareguard';
20
20
  import { createRequire } from 'node:module';
21
+ import fs from 'node:fs';
22
+ import os from 'node:os';
23
+ import path from 'node:path';
21
24
  const require = createRequire(import.meta.url);
22
25
  const { Loop, wireGate } = require('bare-agent');
23
26
  const { OpenAI } = require('bare-agent/providers');
24
27
  const { createShellTools } = require('bare-agent/tools');
25
28
 
26
29
  // 1. Build the gate. Every primitive is optional with sensible defaults.
30
+ // fs.readScope/fs.writeScope are both set: on bareguard >=0.19 (bareagent's peerDependency
31
+ // floor), a shell file action denies by default when its scope is unset (fs.readScope.unset /
32
+ // fs.writeScope.unset) — leaving writeScope out here would silently deny every shell_write /
33
+ // shell_edit call, not leave the primitive dead like on older bareguard.
34
+ // readScope is deliberately WIDER than writeScope here — read is fine anywhere under /tmp,
35
+ // but writes are narrowed to one demo subdir, to show the two scopes are independent (a
36
+ // folder listed only in readScope is NOT writable, and vice versa). The demo dir is created
37
+ // below since fs.writeScope doesn't need the path to pre-exist, but the example's own
38
+ // shell_write call does.
39
+ const writeDir = path.join(os.tmpdir(), 'bare-agent-demo');
40
+ fs.mkdirSync(writeDir, { recursive: true });
27
41
  const gate = new Gate({
28
42
  budget: { maxCostUsd: 0.10 }, // hard USD cap
29
43
  limits: { maxTurns: 20 }, // safety net on think/act cycles
30
- fs: { readScope: ['/tmp', '~/'] }, // shell_read / shell_grep allowed roots
44
+ fs: { readScope: ['/tmp'], writeScope: [writeDir] }, // read: anywhere under /tmp; write: one narrower demo dir
31
45
  bash: { allow: ['ls', 'cat', 'echo', 'pwd'] }, // argv[0] allowlist for shell_run
32
46
  audit: { path: './bareagent-audit.jsonl' },
33
47
  // Required by bareguard: any ask/halt event flows through here.
@@ -45,29 +59,16 @@ const gate = new Gate({
45
59
  });
46
60
  await gate.init();
47
61
 
48
- // 2. Wire the gate. The DEFAULT translator emits `{ type: <toolName> }` — which matches bareguard's
49
- // `tools.allowlist`/`tools.denylist` (they read `action.type`) but does NOT activate the `bash`/`fs`/`net`
50
- // primitives: those fire only on `action.type ∈ {bash, read, write, edit}` and read `action.cmd`/`action.path`.
51
- // So to make the `bash.allow` + `fs.readScope` config above actually enforce, we MUST translate the shell
52
- // tools into those primitive shapes — otherwise the caps are silently dead (relayfact F7/BA-3).
53
- const actionTranslator = (toolName, args, ctx) => {
54
- switch (toolName) {
55
- // shell_run is argv (no shell); bareguard's bash.allow matches `cmd.startsWith(prefix)`, so join argv[0..].
56
- case 'shell_run': return { type: 'bash', cmd: (args?.argv || []).join(' '), args, _ctx: ctx ?? null };
57
- case 'shell_exec': return { type: 'bash', cmd: args?.command, args, _ctx: ctx ?? null };
58
- // shell_read / shell_grep are reads — gate them through fs.readScope.
59
- case 'shell_read':
60
- case 'shell_grep': return { type: 'read', path: args?.path, args, _ctx: ctx ?? null };
61
- // shell_write is a write — gate it through fs.writeScope (add writeScope to the Gate config to enforce).
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 };
65
- default: return { type: toolName, args, _ctx: ctx ?? null };
66
- }
67
- };
62
+ // 2. Wire the gate. As of bareguard >=0.19 (bareagent's peerDependency floor), the DEFAULT
63
+ // actionTranslator already maps the six `createShellTools` primitives into bareguard's `fs`/`bash`
64
+ // shapes (shell_read/shell_grep → {type:'read',...}, shell_write → {type:'write',...}, shell_edit →
65
+ // {type:'edit',...}, shell_run/shell_exec → {type:'bash',...}), canonicalizing the path with
66
+ // `resolveToolPath` itself — so the `bash.allow` + `fs.readScope`/`fs.writeScope` config above just
67
+ // works with no custom actionTranslator (this used to be a required hand-written override — the "C2"
68
+ // gap, relayfact F7/BA-3 — see src/bareguard-adapter.js for the exact shape and bareguard-version note).
68
69
  // onToolResult + onLlmResult are the current wiring (wrapTools is deprecated — it loses _ctx and never sees
69
70
  // LLM cost, so the budget cap can't cover token-only rounds). policy gates pre-call; the result hooks record.
70
- const { policy, onToolResult, onLlmResult } = wireGate(gate, { actionTranslator });
71
+ const { policy, onToolResult, onLlmResult } = wireGate(gate);
71
72
 
72
73
  // 3. Standard bareagent setup.
73
74
  const provider = new OpenAI({
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "bare-agent",
3
- "version": "0.46.6",
3
+ "version": "0.48.0",
4
4
  "files": [
5
5
  "index.js",
6
6
  "index.d.ts",
@@ -83,7 +83,7 @@
83
83
  "cron-parser": "^4.9.0"
84
84
  },
85
85
  "peerDependencies": {
86
- "bareguard": ">=0.9.0 <1.0.0",
86
+ "bareguard": ">=0.19.0 <1.0.0",
87
87
  "better-sqlite3": ">=9.0.0"
88
88
  },
89
89
  "peerDependenciesMeta": {
@@ -95,7 +95,7 @@
95
95
  }
96
96
  },
97
97
  "scripts": {
98
- "test": "node --test test/**/*.test.js",
98
+ "test": "node --test test/**/*.test.js test/**/*.test.mjs",
99
99
  "test:unit": "node --test \"test/!(integration*|*mcp*|spawn*).test.js\"",
100
100
  "typecheck": "tsc --noEmit",
101
101
  "prebuild:types": "node scripts/clean-types.js",
@@ -107,7 +107,7 @@
107
107
  },
108
108
  "devDependencies": {
109
109
  "@types/node": "^22.19.19",
110
- "bareguard": ">=0.9.0 <1.0.0",
110
+ "bareguard": ">=0.19.0 <1.0.0",
111
111
  "litectx": "^0.26.0",
112
112
  "typescript": "^5.7.0"
113
113
  }
package/primitives.json CHANGED
@@ -141,9 +141,9 @@
141
141
  "category": "tools",
142
142
  "when": "you want to give an agent shell/file tools (read, grep, write, edit, run, exec) — cross-platform, pure Node, zero deps",
143
143
  "import": "import { createShellTools } from 'bare-agent/tools'",
144
- "signature": "createShellTools() => {tools: ToolDef[]}",
145
- "fails": "never throws at creation; gating is the caller's via Loop({ policy }) and fs.writeScope, and shell_edit refuses a non-unique anchor as a tool result (file untouched).",
146
- "example": "const { tools } = createShellTools();\nconst loop = new Loop({ provider, tools, policy });"
144
+ "signature": "createShellTools(options?: {noFollowSymlinks?: boolean}) => {tools: ToolDef[]}",
145
+ "fails": "never throws at creation; gating is the caller's via Loop({ policy }) and fs.writeScope, shell_edit refuses a non-unique anchor as a tool result (file untouched), and with noFollowSymlinks:true the four file tools throw ELOOP on a symlinked final path component.",
146
+ "example": "const { tools } = createShellTools();\nconst loop = new Loop({ provider, tools, policy });\nconst { tools: safeTools } = createShellTools({ noFollowSymlinks: true });"
147
147
  },
148
148
  {
149
149
  "name": "createSpawnTool",
@@ -361,10 +361,19 @@
361
361
  "fails": "skips empty spans and never fabricates; a provider HaltError propagates clean. Each pass forwards usage via onLlmResult; a fact counts once via ctx.recordMemoryOp.",
362
362
  "example": "const { facts } = await remember(spans, { provider, store });\nconsole.log(`consolidated ${facts.length} durable facts`);"
363
363
  },
364
+ {
365
+ "name": "resolveToolPath",
366
+ "category": "tools",
367
+ "when": "you gate shell file tools — canonicalize the tool's path once with this, put it in the action you pass to `gate.check`, so the gate judges exactly the path the tool opens",
368
+ "import": "import { resolveToolPath } from 'bare-agent/tools'",
369
+ "signature": "resolveToolPath(p: string) => string",
370
+ "fails": "throws on a non-string or empty path, and on a `~` path when no home directory can be determined — never turns `~/x` into `/x` or an empty path into cwd",
371
+ "example": "const resolved = resolveToolPath('~/notes.txt');"
372
+ },
364
373
  {
365
374
  "name": "Retry",
366
375
  "category": "resilience",
367
- "when": "you want backoff-with-jitter around a flaky async call (a provider request, a plan step) — the retry seam Loop and runPlan wrap providers with",
376
+ "when": "you want backoff-with-jitter around a flaky async call (a provider request, a plan step) — the retry seam that Loop and runPlan wrap around providers",
368
377
  "import": "import { Retry } from 'bare-agent'",
369
378
  "signature": "new Retry(options?: RetryOptions)",
370
379
  "fails": "rethrows the last error once attempts are exhausted; by default only transient errors (429/5xx/ECONNRESET/ETIMEDOUT) are retried.",
@@ -89,7 +89,15 @@ export type Annotation = {
89
89
  * - `onToolResult` — callback for `new Loop({ onToolResult })`. Forwards every
90
90
  * tool.execute result to gate.record with ctx in scope.
91
91
  * - `filterTools` — async (tools) => filtered. Drops tools denied by gate.allows
92
- * so the LLM never sees them. No audit, no record. Bulk-only:
92
+ * so the LLM never sees them. No audit, no record. Judges tool
93
+ * IDENTITY ONLY (`{type: name, tool: name}`) — never the
94
+ * `actionTranslator`'s real per-type shape (an `fs`/`bash` primitive
95
+ * needs a real path/cmd that doesn't exist yet at discovery time; a
96
+ * path-less probe's meaning is version-fragile — see the function doc
97
+ * below). So filterTools reflects ONLY `tools.allowlist`/`tools.denylist`;
98
+ * per-call scope (`fs.readScope`/`writeScope`, `bash.allow`) is enforced
99
+ * by `policy` on every real call — an unset scope denies loudly there,
100
+ * never silently hidden or silently offered at filter time. Bulk-only:
93
101
  * when MCP tools are exposed via `mcp_discover`+`mcp_invoke`
94
102
  * meta-tools, filterTools cannot drop the inner names (they
95
103
  * are not in the tool list). Gate those via bareguard's
@@ -111,13 +119,17 @@ export type Annotation = {
111
119
  * "[deny: <rule>] <toolName> denied" when bareguard omits a reason. Halt bypasses
112
120
  * this (HaltError doesn't reach the LLM).
113
121
  * @param {Function} [options.actionTranslator] - (toolName, args, ctx) => action.
114
- * Builds the action object passed to `gate.check` and `gate.record`. Default:
115
- * `{ type: toolName, args, _ctx: ctx }`. Override when bareguard's primitives
116
- * need a specific shape — e.g. `bashCheck` requires `{type:'bash', cmd:...}`,
117
- * `fsCheck` requires `{type:'read'|'write'|'edit', path:...}`. The default shape
118
- * matches `tools.denylist` / `tools.allowlist` (which read `action.type`) but
119
- * does NOT activate `bash`/`fs`/`net` primitives — those need their own
120
- * `action.type` value. Adopters using those primitives must translate.
122
+ * Builds the action object passed to `gate.check` and `gate.record`. Default (requires
123
+ * bareguard >=0.19.0): the six `createShellTools` primitives map to bareguard's `fs`/`bash`
124
+ * shapes — `shell_read`/`shell_grep` → `{type:'read', tool, path: resolveToolPath(args.path), args, _ctx}`,
125
+ * `shell_write` → `{type:'write', ...}`, `shell_edit` → `{type:'edit', ...}`, `shell_run` →
126
+ * `{type:'bash', tool, cmd: args.argv.join(' '), args, _ctx}`, `shell_exec` → `{type:'bash', tool,
127
+ * cmd: args.command, args, _ctx}`; every other tool → `{type: toolName, tool: toolName, args, _ctx}`.
128
+ * `tool` always carries the literal bareagent tool name — bareguard's action identity for
129
+ * `tools.allowlist`/`tools.denylist` is `action.tool ?? action.type`, so a tool-name allowlist
130
+ * (`tools.allowlist:['shell_read']`) keeps matching even though `type` is now `'read'`. This
131
+ * activates `fs`/`bash` scoping automatically (`fs.readScope`/`fs.writeScope` deny by default
132
+ * when unset — see CHANGELOG) — override when a different shape is needed.
121
133
  * @returns {{policy: Function, onLlmResult: Function, onToolResult: Function, filterTools: Function, wrapTool: Function, wrapTools: Function}}
122
134
  * @when you have a bareguard Gate and want to wire it into a Loop as the policy + metering chokepoint in one line
123
135
  * @fails never throws; a gate halt surfaces as a HaltError the Loop catches and exits cleanly, and a deny is an advisory string fed back to the model.
@@ -156,9 +168,22 @@ export function wireGate(gate: Gate, options?: {
156
168
  * @param {Ctx} ctx
157
169
  */
158
170
  export function defaultActionTranslator(toolName: string, args: any, ctx: Ctx): {
171
+ tool: string;
172
+ args: any;
173
+ _ctx: any;
174
+ type: string;
175
+ path: string;
176
+ } | {
177
+ tool: string;
178
+ args: any;
179
+ _ctx: any;
159
180
  type: string;
181
+ cmd: any;
182
+ } | {
183
+ tool: string;
160
184
  args: any;
161
185
  _ctx: any;
186
+ type: string;
162
187
  };
163
188
  /**
164
189
  * @typedef {object} AnnotationLimits
@@ -1,6 +1,7 @@
1
1
  'use strict';
2
2
 
3
3
  const { HaltError } = require('./errors');
4
+ const { resolveToolPath } = require('../tools/shell');
4
5
 
5
6
  /** @typedef {import('../types').Ctx} Ctx */
6
7
  /** @typedef {import('../types').ToolDef} ToolDef */
@@ -56,7 +57,15 @@ let warnedWrap = false;
56
57
  * - `onToolResult` — callback for `new Loop({ onToolResult })`. Forwards every
57
58
  * tool.execute result to gate.record with ctx in scope.
58
59
  * - `filterTools` — async (tools) => filtered. Drops tools denied by gate.allows
59
- * so the LLM never sees them. No audit, no record. Bulk-only:
60
+ * so the LLM never sees them. No audit, no record. Judges tool
61
+ * IDENTITY ONLY (`{type: name, tool: name}`) — never the
62
+ * `actionTranslator`'s real per-type shape (an `fs`/`bash` primitive
63
+ * needs a real path/cmd that doesn't exist yet at discovery time; a
64
+ * path-less probe's meaning is version-fragile — see the function doc
65
+ * below). So filterTools reflects ONLY `tools.allowlist`/`tools.denylist`;
66
+ * per-call scope (`fs.readScope`/`writeScope`, `bash.allow`) is enforced
67
+ * by `policy` on every real call — an unset scope denies loudly there,
68
+ * never silently hidden or silently offered at filter time. Bulk-only:
60
69
  * when MCP tools are exposed via `mcp_discover`+`mcp_invoke`
61
70
  * meta-tools, filterTools cannot drop the inner names (they
62
71
  * are not in the tool list). Gate those via bareguard's
@@ -78,13 +87,17 @@ let warnedWrap = false;
78
87
  * "[deny: <rule>] <toolName> denied" when bareguard omits a reason. Halt bypasses
79
88
  * this (HaltError doesn't reach the LLM).
80
89
  * @param {Function} [options.actionTranslator] - (toolName, args, ctx) => action.
81
- * Builds the action object passed to `gate.check` and `gate.record`. Default:
82
- * `{ type: toolName, args, _ctx: ctx }`. Override when bareguard's primitives
83
- * need a specific shape — e.g. `bashCheck` requires `{type:'bash', cmd:...}`,
84
- * `fsCheck` requires `{type:'read'|'write'|'edit', path:...}`. The default shape
85
- * matches `tools.denylist` / `tools.allowlist` (which read `action.type`) but
86
- * does NOT activate `bash`/`fs`/`net` primitives — those need their own
87
- * `action.type` value. Adopters using those primitives must translate.
90
+ * Builds the action object passed to `gate.check` and `gate.record`. Default (requires
91
+ * bareguard >=0.19.0): the six `createShellTools` primitives map to bareguard's `fs`/`bash`
92
+ * shapes — `shell_read`/`shell_grep` → `{type:'read', tool, path: resolveToolPath(args.path), args, _ctx}`,
93
+ * `shell_write` → `{type:'write', ...}`, `shell_edit` → `{type:'edit', ...}`, `shell_run` →
94
+ * `{type:'bash', tool, cmd: args.argv.join(' '), args, _ctx}`, `shell_exec` → `{type:'bash', tool,
95
+ * cmd: args.command, args, _ctx}`; every other tool → `{type: toolName, tool: toolName, args, _ctx}`.
96
+ * `tool` always carries the literal bareagent tool name — bareguard's action identity for
97
+ * `tools.allowlist`/`tools.denylist` is `action.tool ?? action.type`, so a tool-name allowlist
98
+ * (`tools.allowlist:['shell_read']`) keeps matching even though `type` is now `'read'`. This
99
+ * activates `fs`/`bash` scoping automatically (`fs.readScope`/`fs.writeScope` deny by default
100
+ * when unset — see CHANGELOG) — override when a different shape is needed.
88
101
  * @returns {{policy: Function, onLlmResult: Function, onToolResult: Function, filterTools: Function, wrapTool: Function, wrapTools: Function}}
89
102
  * @when you have a bareguard Gate and want to wire it into a Loop as the policy + metering chokepoint in one line
90
103
  * @fails never throws; a gate halt surfaces as a HaltError the Loop catches and exits cleanly, and a deny is an advisory string fed back to the model.
@@ -214,10 +227,33 @@ function wireGate(gate, options = {}) {
214
227
  // Bind to the Gate so `this` stays correct (bareguard's allows reads
215
228
  // this._initialized) — extracting the method unbound would crash.
216
229
  const allows = gate.allows.bind(gate);
230
+ // DELIBERATELY NOT `translate(t.name, undefined, undefined)`. No real `args` exist
231
+ // yet at discovery time, so a path/cmd-bearing actionTranslator (e.g. the default,
232
+ // which maps shell_read/write/edit to `fs` primitive shapes) would probe with
233
+ // `path:undefined` — and what that means is a bareguard-VERSION-DEPENDENT fact, not
234
+ // a stable contract: measured on bareguard 0.19.0, a path-less `read`/`write`/`edit`
235
+ // action SKIPS the fs scope check entirely ("not a file action", not "scope unset"),
236
+ // so `allows()` returns true even with NO fs.readScope/writeScope configured at all —
237
+ // silently OFFERING a tool that every real call to it would then be denied by `policy`
238
+ // (fs.readScope.unset/fs.writeScope.unset). bareguard has flagged this as likely to
239
+ // tighten in a future release (a path-less file action denying instead of skipping),
240
+ // which would flip the failure mode to silently HIDING the tool even when scope IS
241
+ // configured. Neither reading is something this adapter should guess at, and there is
242
+ // no bareguard API to ask "is fs.readScope configured" directly — so filterTools probes
243
+ // IDENTITY ONLY (`{type: name, tool: name}`, never the translated per-type shape). That
244
+ // only reflects `tools.allowlist`/`tools.denylist` (bareguard's identity for both is
245
+ // `action.tool ?? action.type` — see `defaultActionTranslator`'s header comment), which
246
+ // is exactly the class of rule filterTools can safely pre-evaluate with no real args.
247
+ // Scope enforcement (`fs.readScope`/`writeScope`, `bash.allow`) stays where it has real
248
+ // data to judge: `policy`, on every actual call, denying loudly when scope is unset —
249
+ // never silently hidden at discovery time. See test/integration-bareguard-real.test.js
250
+ // for the live-0.19 verification of both allowlist/denylist filtering and the
251
+ // no-fs-config "still offered, denied on the real call" behavior.
252
+ //
217
253
  // Parallel: gate.allows is config-driven and pure, so concurrent calls are
218
254
  // safe. Matters for large MCP catalogs (50+ tools) where sequential awaits
219
255
  // were noticeable overhead on every startup.
220
- const verdicts = await Promise.all(tools.map(t => allows(t.name)));
256
+ const verdicts = await Promise.all(tools.map(t => allows({ type: t.name, tool: t.name })));
221
257
  return tools.filter((_, i) => verdicts[i]);
222
258
  };
223
259
 
@@ -284,18 +320,65 @@ function defaultFormatDeny(decision, toolName) {
284
320
  return decision.reason ? `${tag} ${decision.reason}` : `${tag} ${toolName} denied`;
285
321
  }
286
322
 
287
- // Canonical action shape: tool name as type, args nested, ctx tagged. Matches
288
- // bareguard's `tools.denylist`/`tools.allowlist` (which read `action.type`) but
289
- // does NOT activate `bash`/`fs`/`net` primitives — those require `action.type`
290
- // to be `bash`/`read`/`write`/etc. and read fields like `action.cmd` /
291
- // `action.path` at the top level. Override via `wireGate(gate, { actionTranslator })`.
323
+ // Requires bareguard >=0.19.0 (bareagent's own peerDependency floor). Maps the six
324
+ // `createShellTools` primitives (tools/shell.js) to bareguard's `fs`/`bash` action
325
+ // shapes, so `fs.readScope`/`fs.writeScope`/`bash.allow` activate WITHOUT a caller
326
+ // writing their own translator (the "C2" fix — this used to be a documented gap
327
+ // every adopter had to close by hand, see examples/with-bareguard.mjs). Every other
328
+ // tool still gets the pre-0.19 tool-named shape. `tool` is set on every branch —
329
+ // bareguard 0.19's action identity for `tools.allowlist`/`tools.denylist` is
330
+ // `action.tool ?? action.type` (tool exclusive when present), so a tool-name
331
+ // allowlist keeps matching even though `type` is now `'read'`/`'write'`/`'bash'`.
332
+ // Override via `wireGate(gate, { actionTranslator })` when a different shape is needed
333
+ // (e.g. pre-0.19 bareguard, which ignores `action.tool` entirely and would deny every
334
+ // shell file action under this shape — hence the peerDependency floor, not a runtime check).
292
335
  /**
293
336
  * @param {string} toolName
294
337
  * @param {any} args
295
338
  * @param {Ctx} ctx
296
339
  */
297
340
  function defaultActionTranslator(toolName, args, ctx) {
298
- return { type: toolName, args, _ctx: ctx ?? null };
341
+ const tagged = { tool: toolName, args, _ctx: ctx ?? null };
342
+ if (toolName === 'shell_read' || toolName === 'shell_grep') {
343
+ return { type: 'read', path: safeToolPath(args), ...tagged };
344
+ }
345
+ if (toolName === 'shell_write') {
346
+ return { type: 'write', path: safeToolPath(args), ...tagged };
347
+ }
348
+ if (toolName === 'shell_edit') {
349
+ return { type: 'edit', path: safeToolPath(args), ...tagged };
350
+ }
351
+ if (toolName === 'shell_run') {
352
+ return { type: 'bash', cmd: Array.isArray(args?.argv) ? args.argv.join(' ') : undefined, ...tagged };
353
+ }
354
+ if (toolName === 'shell_exec') {
355
+ return { type: 'bash', cmd: args?.command, ...tagged };
356
+ }
357
+ return { type: toolName, ...tagged };
358
+ }
359
+
360
+ // Canonicalize a shell file tool's `path` arg for bareguard's fs primitive, without
361
+ // ever letting a bad or missing path crash the translator or launder into a silent
362
+ // allow. `filterTools` no longer probes this translator at all (it judges tool
363
+ // IDENTITY ONLY — see filterTools above), so there is no remaining "discovery-time,
364
+ // no real args yet" case to special-case here: every call into this function is a
365
+ // REAL per-call translation, and `args` missing entirely, missing the `path` key,
366
+ // or carrying a non-string/empty/unresolvable-`~` path are ALL malformed-or-absent
367
+ // paths that must deny, never skip-then-allow. `resolveToolPath` itself throws on
368
+ // anything that isn't a non-empty string (missing `args`/`args.path` reads as
369
+ // `undefined`, which fails that same check) — so a single try/catch covers every
370
+ // case uniformly, always normalizing to `''`, which `fs.invalidPath` reliably denies
371
+ // on a real bareguard 0.19 Gate whether or not `fs.*Scope` is configured. (Previously
372
+ // this returned `undefined` — not `''` — for a missing `path` key, which hit
373
+ // bareguard's fs primitive's "not a file action, skip scope entirely" branch and
374
+ // silently ALLOWED a real call with no path at all; closed here.)
375
+ /** @param {any} args */
376
+ function safeToolPath(args) {
377
+ try {
378
+ return resolveToolPath(args?.path);
379
+ } catch {
380
+ return '';
381
+ }
299
382
  }
300
383
 
301
384
  // ── judge → gate.annotate mapping (BA-20) ─────────────────────────────────────
package/src/loop.d.ts CHANGED
@@ -167,7 +167,7 @@ export class Loop {
167
167
  * thunk is re-evaluated each round (D4/eval-assist F2) so a tool set that grows mid-run — e.g. a skill
168
168
  * unlocking its tools — is offered on the next round; a static array is resolved once at wire time.
169
169
  * @param {Record<string, any>} [options={}] - Per-run overrides (system, temperature, ctx, etc.).
170
- * @returns {Promise<{text: string, toolCalls: ToolCall[], usage: Usage, cost: number, error: string|null, stopReason: string|null, model: string|null, msgs: Message[], metrics: RunMetrics, malformedToolCall?: {name: string|undefined, error: string}, temperatureDropped?: boolean}>}
170
+ * @returns {Promise<{text: string, toolCalls: ToolCall[], usage: Usage, cost: number, error: string|null, stopReason: string|null, model: string|null, msgs: Message[], metrics: RunMetrics, malformedToolCall?: {name: string|undefined, error: string, rawArguments?: string, rawTruncated?: true}, temperatureDropped?: boolean}>}
171
171
  * On halt the returned `error` is `halt:<rule>` (or `halt:unknown` if the
172
172
  * thrown HaltError carried no `rule`), and `msgs` is sanitized so any
173
173
  * dangling assistant `tool_calls` from the halted round are paired with
@@ -208,6 +208,8 @@ export class Loop {
208
208
  malformedToolCall?: {
209
209
  name: string | undefined;
210
210
  error: string;
211
+ rawArguments?: string;
212
+ rawTruncated?: true;
211
213
  };
212
214
  temperatureDropped?: boolean;
213
215
  }>;
@@ -238,7 +240,7 @@ export class Loop {
238
240
  * @param {string} text - User message.
239
241
  * @param {ToolDef[]} [tools=[]] - Tool definitions.
240
242
  * @param {Record<string, any>} [options={}] - Per-run overrides.
241
- * @returns {Promise<{text: string, toolCalls: ToolCall[], usage: Usage, cost: number, error: string|null, stopReason: string|null, model: string|null, msgs: Message[], metrics: RunMetrics, malformedToolCall?: {name: string|undefined, error: string}, temperatureDropped?: boolean}>}
243
+ * @returns {Promise<{text: string, toolCalls: ToolCall[], usage: Usage, cost: number, error: string|null, stopReason: string|null, model: string|null, msgs: Message[], metrics: RunMetrics, malformedToolCall?: {name: string|undefined, error: string, rawArguments?: string, rawTruncated?: true}, temperatureDropped?: boolean}>}
242
244
  */
243
245
  chat(text: string, tools?: ToolDef[], options?: Record<string, any>): Promise<{
244
246
  text: string;
@@ -253,6 +255,8 @@ export class Loop {
253
255
  malformedToolCall?: {
254
256
  name: string | undefined;
255
257
  error: string;
258
+ rawArguments?: string;
259
+ rawTruncated?: true;
256
260
  };
257
261
  temperatureDropped?: boolean;
258
262
  }>;
package/src/loop.js CHANGED
@@ -455,7 +455,7 @@ class Loop {
455
455
  * thunk is re-evaluated each round (D4/eval-assist F2) so a tool set that grows mid-run — e.g. a skill
456
456
  * unlocking its tools — is offered on the next round; a static array is resolved once at wire time.
457
457
  * @param {Record<string, any>} [options={}] - Per-run overrides (system, temperature, ctx, etc.).
458
- * @returns {Promise<{text: string, toolCalls: ToolCall[], usage: Usage, cost: number, error: string|null, stopReason: string|null, model: string|null, msgs: Message[], metrics: RunMetrics, malformedToolCall?: {name: string|undefined, error: string}, temperatureDropped?: boolean}>}
458
+ * @returns {Promise<{text: string, toolCalls: ToolCall[], usage: Usage, cost: number, error: string|null, stopReason: string|null, model: string|null, msgs: Message[], metrics: RunMetrics, malformedToolCall?: {name: string|undefined, error: string, rawArguments?: string, rawTruncated?: true}, temperatureDropped?: boolean}>}
459
459
  * On halt the returned `error` is `halt:<rule>` (or `halt:unknown` if the
460
460
  * thrown HaltError carried no `rule`), and `msgs` is sanitized so any
461
461
  * dangling assistant `tool_calls` from the halted round are paired with
@@ -1328,7 +1328,7 @@ class Loop {
1328
1328
  * @param {string} text - User message.
1329
1329
  * @param {ToolDef[]} [tools=[]] - Tool definitions.
1330
1330
  * @param {Record<string, any>} [options={}] - Per-run overrides.
1331
- * @returns {Promise<{text: string, toolCalls: ToolCall[], usage: Usage, cost: number, error: string|null, stopReason: string|null, model: string|null, msgs: Message[], metrics: RunMetrics, malformedToolCall?: {name: string|undefined, error: string}, temperatureDropped?: boolean}>}
1331
+ * @returns {Promise<{text: string, toolCalls: ToolCall[], usage: Usage, cost: number, error: string|null, stopReason: string|null, model: string|null, msgs: Message[], metrics: RunMetrics, malformedToolCall?: {name: string|undefined, error: string, rawArguments?: string, rawTruncated?: true}, temperatureDropped?: boolean}>}
1332
1332
  */
1333
1333
  async chat(text, tools = [], options = {}) {
1334
1334
  this._history.push({ role: 'user', content: text });
@@ -13,6 +13,13 @@ export type OllamaOptions = {
13
13
  * - BA-19: TOTAL call-duration deadline in ms, beside `timeoutMs`. The idle bound resets on any socket activity, so a response that trickles a byte forever never trips it and hangs for hours. This is an absolute, non-resetting ceiling; on trip, `generate()` rejects with a TERMINAL `TimeoutError` (`code: 'EDEADLINE'`, `context.bound: 'deadline'`, `retryable: false`). DISABLED by default; `0`/`Infinity` disable. Overridable per call via `generate(..., { deadlineMs })`. (A cold large-model load is a legitimate long single call — leave this disabled or set it generously for such models.)
14
14
  */
15
15
  deadlineMs?: number | undefined;
16
+ /**
17
+ * - BA-27 follow-up: when a tool call's string-shaped
18
+ * `function.arguments` JSON fails to parse, also attach the raw string (verbatim, capped at 500
19
+ * chars, `rawTruncated:true` when longer) to `malformedToolCall.rawArguments`. Off by default,
20
+ * independent of `exposeErrorBody`. See `src/provider-toolcalls.js`.
21
+ */
22
+ exposeMalformedArgs?: boolean | undefined;
16
23
  };
17
24
  /** @typedef {import('../types').Message} Message */
18
25
  /** @typedef {import('../types').ToolDef} ToolDef */
@@ -24,6 +31,10 @@ export type OllamaOptions = {
24
31
  * @property {boolean} [exposeErrorBody=false]
25
32
  * @property {number} [timeoutMs=600000] - BA-18: request/idle timeout in ms. Bounds a silent or never-answering socket on inactivity so `generate()` rejects with a retryable `TimeoutError` (`code: 'ETIMEDOUT'`, `context.bound: 'idle'`) instead of hanging until the OS TCP timeout. `0`/`Infinity` disables it. Overridable per call via `generate(..., { timeoutMs })`. (A local Ollama that is loading a large model cold can be slow to first byte — raise this or disable it for very large local models.)
26
33
  * @property {number} [deadlineMs=0] - BA-19: TOTAL call-duration deadline in ms, beside `timeoutMs`. The idle bound resets on any socket activity, so a response that trickles a byte forever never trips it and hangs for hours. This is an absolute, non-resetting ceiling; on trip, `generate()` rejects with a TERMINAL `TimeoutError` (`code: 'EDEADLINE'`, `context.bound: 'deadline'`, `retryable: false`). DISABLED by default; `0`/`Infinity` disable. Overridable per call via `generate(..., { deadlineMs })`. (A cold large-model load is a legitimate long single call — leave this disabled or set it generously for such models.)
34
+ * @property {boolean} [exposeMalformedArgs=false] - BA-27 follow-up: when a tool call's string-shaped
35
+ * `function.arguments` JSON fails to parse, also attach the raw string (verbatim, capped at 500
36
+ * chars, `rawTruncated:true` when longer) to `malformedToolCall.rawArguments`. Off by default,
37
+ * independent of `exposeErrorBody`. See `src/provider-toolcalls.js`.
27
38
  */
28
39
  export class OllamaProvider {
29
40
  /**
@@ -39,6 +50,7 @@ export class OllamaProvider {
39
50
  exposeErrorBody: boolean;
40
51
  timeoutMs: number | undefined;
41
52
  deadlineMs: number | undefined;
53
+ exposeMalformedArgs: boolean;
42
54
  /**
43
55
  * Generate a response from a local Ollama instance.
44
56
  * @param {Message[]} messages - Conversation messages.
@@ -22,6 +22,10 @@ const OLLAMA_USAGE_KEYS = ['prompt_eval_count', 'eval_count'];
22
22
  * @property {boolean} [exposeErrorBody=false]
23
23
  * @property {number} [timeoutMs=600000] - BA-18: request/idle timeout in ms. Bounds a silent or never-answering socket on inactivity so `generate()` rejects with a retryable `TimeoutError` (`code: 'ETIMEDOUT'`, `context.bound: 'idle'`) instead of hanging until the OS TCP timeout. `0`/`Infinity` disables it. Overridable per call via `generate(..., { timeoutMs })`. (A local Ollama that is loading a large model cold can be slow to first byte — raise this or disable it for very large local models.)
24
24
  * @property {number} [deadlineMs=0] - BA-19: TOTAL call-duration deadline in ms, beside `timeoutMs`. The idle bound resets on any socket activity, so a response that trickles a byte forever never trips it and hangs for hours. This is an absolute, non-resetting ceiling; on trip, `generate()` rejects with a TERMINAL `TimeoutError` (`code: 'EDEADLINE'`, `context.bound: 'deadline'`, `retryable: false`). DISABLED by default; `0`/`Infinity` disable. Overridable per call via `generate(..., { deadlineMs })`. (A cold large-model load is a legitimate long single call — leave this disabled or set it generously for such models.)
25
+ * @property {boolean} [exposeMalformedArgs=false] - BA-27 follow-up: when a tool call's string-shaped
26
+ * `function.arguments` JSON fails to parse, also attach the raw string (verbatim, capped at 500
27
+ * chars, `rawTruncated:true` when longer) to `malformedToolCall.rawArguments`. Off by default,
28
+ * independent of `exposeErrorBody`. See `src/provider-toolcalls.js`.
25
29
  */
26
30
 
27
31
  class OllamaProvider {
@@ -41,6 +45,8 @@ class OllamaProvider {
41
45
  this.timeoutMs = options.timeoutMs;
42
46
  // BA-19: total call-duration deadline (ms). Resolved at call time (default 0 = disabled).
43
47
  this.deadlineMs = options.deadlineMs;
48
+ // BA-27 follow-up: expose the raw malformed tool-call arguments string. Off by default.
49
+ this.exposeMalformedArgs = options.exposeMalformedArgs === true;
44
50
  }
45
51
 
46
52
  /**
@@ -100,7 +106,7 @@ class OllamaProvider {
100
106
  arguments: typeof tc.function.arguments === 'string'
101
107
  ? JSON.parse(tc.function.arguments)
102
108
  : tc.function.arguments,
103
- }));
109
+ }), { exposeMalformedArgs: this.exposeMalformedArgs });
104
110
 
105
111
  return {
106
112
  text: msg.content || '',
@@ -38,6 +38,14 @@ export type OpenAIOptions = {
38
38
  * self-hosted / proxy endpoints). No model-name sniffing — the caller declares the dialect.
39
39
  */
40
40
  legacyMaxTokens?: boolean | undefined;
41
+ /**
42
+ * - BA-27 follow-up: when a tool call's `function.arguments`
43
+ * JSON fails to parse, also attach the raw string (verbatim, capped at 500 chars, `rawTruncated:true`
44
+ * when longer) to `malformedToolCall.rawArguments`. Off by default, independent of `exposeErrorBody` —
45
+ * the raw blob is model-generated, not an upstream error body, but the same "don't leak an unbounded
46
+ * unexpected string into logs by default" reasoning applies. See `src/provider-toolcalls.js`.
47
+ */
48
+ exposeMalformedArgs?: boolean | undefined;
41
49
  };
42
50
  /**
43
51
  * @typedef {object} OpenAIOptions
@@ -64,6 +72,11 @@ export type OpenAIOptions = {
64
72
  * OpenAI GPT-5 models 400 on `max_tokens` ("Unsupported parameter … Use 'max_completion_tokens'").
65
73
  * Set `true` for an OpenAI-compatible server that only understands the legacy key (e.g. some
66
74
  * self-hosted / proxy endpoints). No model-name sniffing — the caller declares the dialect.
75
+ * @property {boolean} [exposeMalformedArgs=false] - BA-27 follow-up: when a tool call's `function.arguments`
76
+ * JSON fails to parse, also attach the raw string (verbatim, capped at 500 chars, `rawTruncated:true`
77
+ * when longer) to `malformedToolCall.rawArguments`. Off by default, independent of `exposeErrorBody` —
78
+ * the raw blob is model-generated, not an upstream error body, but the same "don't leak an unbounded
79
+ * unexpected string into logs by default" reasoning applies. See `src/provider-toolcalls.js`.
67
80
  */
68
81
  export class OpenAIProvider {
69
82
  /**
@@ -81,6 +94,7 @@ export class OpenAIProvider {
81
94
  timeoutMs: number | undefined;
82
95
  deadlineMs: number | undefined;
83
96
  legacyMaxTokens: boolean;
97
+ exposeMalformedArgs: boolean;
84
98
  /**
85
99
  * Generate a response from the OpenAI API.
86
100
  * @param {Message[]} messages - Conversation messages.