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.
- package/bareagent.context.md +23 -4
- package/examples/with-bareguard.mjs +23 -22
- package/package.json +4 -4
- package/primitives.json +13 -4
- package/src/bareguard-adapter.d.ts +33 -8
- package/src/bareguard-adapter.js +98 -15
- package/src/loop.d.ts +6 -2
- package/src/loop.js +2 -2
- package/src/provider-ollama.d.ts +12 -0
- package/src/provider-ollama.js +7 -1
- package/src/provider-openai.d.ts +14 -0
- package/src/provider-openai.js +8 -1
- package/src/provider-toolcalls.d.ts +15 -3
- package/src/provider-toolcalls.js +28 -8
- package/src/retry.d.ts +1 -1
- package/src/retry.js +1 -1
- package/src/tools.d.ts +2 -1
- package/src/tools.js +2 -1
- package/tools/shell.d.ts +136 -6
- package/tools/shell.js +406 -59
- package/types/index.d.ts +6 -1
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.43.0 | Node.js >= 18 | zero required deps (`bareguard >=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
|
|
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
|
-
|
|
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',
|
|
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.
|
|
49
|
-
//
|
|
50
|
-
//
|
|
51
|
-
//
|
|
52
|
-
//
|
|
53
|
-
|
|
54
|
-
|
|
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
|
|
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.
|
|
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.
|
|
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.
|
|
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,
|
|
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
|
|
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.
|
|
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
|
-
*
|
|
116
|
-
*
|
|
117
|
-
* `
|
|
118
|
-
*
|
|
119
|
-
*
|
|
120
|
-
* `
|
|
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
|
package/src/bareguard-adapter.js
CHANGED
|
@@ -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.
|
|
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
|
-
*
|
|
83
|
-
*
|
|
84
|
-
* `
|
|
85
|
-
*
|
|
86
|
-
*
|
|
87
|
-
* `
|
|
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
|
-
//
|
|
288
|
-
// bareguard's `
|
|
289
|
-
//
|
|
290
|
-
//
|
|
291
|
-
//
|
|
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
|
-
|
|
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 });
|
package/src/provider-ollama.d.ts
CHANGED
|
@@ -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.
|
package/src/provider-ollama.js
CHANGED
|
@@ -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 || '',
|
package/src/provider-openai.d.ts
CHANGED
|
@@ -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.
|