bare-agent 0.47.0 → 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 +15 -4
- package/examples/with-bareguard.mjs +24 -30
- package/package.json +3 -3
- 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/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
|
|
|
@@ -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,9 +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.
|
|
528
539
|
|
|
529
|
-
> **
|
|
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.
|
|
530
541
|
|
|
531
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.
|
|
532
543
|
|
|
@@ -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
|
-
const { createShellTools
|
|
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,36 +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
|
-
// path you hand it WITHOUT canonicalizing it, so a `~`-prefixed or relative path would be judged
|
|
56
|
-
// against `fs.readScope`/`fs.writeScope` in a different form than the one the tool actually opens —
|
|
57
|
-
// gate string and opened string must be the SAME string. `resolveToolPath` is the one canonicalizer
|
|
58
|
-
// (expand `~` via os.homedir(), then path.resolve) every shell file tool already applies internally
|
|
59
|
-
// before opening; calling it here too means the gate sees exactly what gets opened.
|
|
60
|
-
const actionTranslator = (toolName, args, ctx) => {
|
|
61
|
-
switch (toolName) {
|
|
62
|
-
// shell_run is argv (no shell); bareguard's bash.allow matches `cmd.startsWith(prefix)`, so join argv[0..].
|
|
63
|
-
case 'shell_run': return { type: 'bash', cmd: (args?.argv || []).join(' '), args, _ctx: ctx ?? null };
|
|
64
|
-
case 'shell_exec': return { type: 'bash', cmd: args?.command, args, _ctx: ctx ?? null };
|
|
65
|
-
// shell_read / shell_grep are reads — gate them through fs.readScope, on the canonicalized path.
|
|
66
|
-
case 'shell_read':
|
|
67
|
-
case 'shell_grep': return { type: 'read', path: resolveToolPath(args?.path), args, _ctx: ctx ?? null };
|
|
68
|
-
// shell_write is a write — gate it through fs.writeScope (add writeScope to the Gate config to enforce).
|
|
69
|
-
case 'shell_write': return { type: 'write', path: resolveToolPath(args?.path), args, _ctx: ctx ?? null };
|
|
70
|
-
// shell_edit is an anchored edit — bareguard gates {type:'edit'} by the SAME fs.writeScope as write.
|
|
71
|
-
case 'shell_edit': return { type: 'edit', path: resolveToolPath(args?.path), args, _ctx: ctx ?? null };
|
|
72
|
-
default: return { type: toolName, args, _ctx: ctx ?? null };
|
|
73
|
-
}
|
|
74
|
-
};
|
|
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).
|
|
75
69
|
// onToolResult + onLlmResult are the current wiring (wrapTools is deprecated — it loses _ctx and never sees
|
|
76
70
|
// LLM cost, so the budget cap can't cover token-only rounds). policy gates pre-call; the result hooks record.
|
|
77
|
-
const { policy, onToolResult, onLlmResult } = wireGate(gate
|
|
71
|
+
const { policy, onToolResult, onLlmResult } = wireGate(gate);
|
|
78
72
|
|
|
79
73
|
// 3. Standard bareagent setup.
|
|
80
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": {
|
|
@@ -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
|
}
|
|
@@ -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.
|
package/src/provider-openai.js
CHANGED
|
@@ -72,6 +72,11 @@ function isLoopbackHost(hostname) {
|
|
|
72
72
|
* OpenAI GPT-5 models 400 on `max_tokens` ("Unsupported parameter … Use 'max_completion_tokens'").
|
|
73
73
|
* Set `true` for an OpenAI-compatible server that only understands the legacy key (e.g. some
|
|
74
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`.
|
|
75
80
|
*/
|
|
76
81
|
|
|
77
82
|
class OpenAIProvider {
|
|
@@ -93,6 +98,8 @@ class OpenAIProvider {
|
|
|
93
98
|
this.deadlineMs = options.deadlineMs;
|
|
94
99
|
// BA-24 (fwdloop): use the legacy `max_tokens` key. Default false ⇒ `max_completion_tokens` (GPT-5-safe).
|
|
95
100
|
this.legacyMaxTokens = options.legacyMaxTokens === true;
|
|
101
|
+
// BA-27 follow-up: expose the raw malformed tool-call arguments string. Off by default.
|
|
102
|
+
this.exposeMalformedArgs = options.exposeMalformedArgs === true;
|
|
96
103
|
}
|
|
97
104
|
|
|
98
105
|
/**
|
|
@@ -161,7 +168,7 @@ class OpenAIProvider {
|
|
|
161
168
|
id: tc.id,
|
|
162
169
|
name: tc.function.name,
|
|
163
170
|
arguments: JSON.parse(tc.function.arguments),
|
|
164
|
-
}));
|
|
171
|
+
}), { exposeMalformedArgs: this.exposeMalformedArgs });
|
|
165
172
|
|
|
166
173
|
return {
|
|
167
174
|
text: msg.content || '',
|
|
@@ -1,5 +1,4 @@
|
|
|
1
1
|
export type ToolCall = import("../types").ToolCall;
|
|
2
|
-
/** @typedef {import('../types').ToolCall} ToolCall */
|
|
3
2
|
/**
|
|
4
3
|
* BA-27 — parse a round's raw tool calls into the neutral {@link ToolCall} shape WITHOUT throwing on
|
|
5
4
|
* malformed arguments.
|
|
@@ -16,14 +15,27 @@ export type ToolCall = import("../types").ToolCall;
|
|
|
16
15
|
* All-or-nothing (mirrors BA-4's refusal to execute a truncated round's calls): a partial set risks
|
|
17
16
|
* running half a decomposed intent, so one bad call voids the whole round's calls.
|
|
18
17
|
*
|
|
18
|
+
* `opts.exposeMalformedArgs` (default off, mirrors `exposeErrorBody`): when on, the marker also
|
|
19
|
+
* carries `rawArguments` — the raw `function.arguments` VERBATIM, capped at 500 chars (`rawTruncated:
|
|
20
|
+
* true` added only when it was longer). Non-string raw arguments (e.g. Ollama's well-formed object
|
|
21
|
+
* case reaching this catch some other way) are never exposed — only a string is a model-generated
|
|
22
|
+
* blob worth surfacing for debugging; an object was already structurally valid before whatever else
|
|
23
|
+
* threw. Off by default for the same reason as `exposeErrorBody`: an arbitrary model-generated string
|
|
24
|
+
* landing in a log/audit row unbounded is its own leak surface.
|
|
25
|
+
*
|
|
19
26
|
* @param {any[]} rawToolCalls - provider-native tool-call entries (may be undefined/empty)
|
|
20
27
|
* @param {(tc: any) => ToolCall} mapOne - maps one raw entry to a ToolCall; MAY throw on bad arguments
|
|
21
|
-
* @
|
|
28
|
+
* @param {{ exposeMalformedArgs?: boolean }} [opts]
|
|
29
|
+
* @returns {{ toolCalls: ToolCall[], malformedToolCall?: { name: string|undefined, error: string, rawArguments?: string, rawTruncated?: true } }}
|
|
22
30
|
*/
|
|
23
|
-
export function parseToolCalls(rawToolCalls: any[], mapOne: (tc: any) => ToolCall
|
|
31
|
+
export function parseToolCalls(rawToolCalls: any[], mapOne: (tc: any) => ToolCall, opts?: {
|
|
32
|
+
exposeMalformedArgs?: boolean;
|
|
33
|
+
}): {
|
|
24
34
|
toolCalls: ToolCall[];
|
|
25
35
|
malformedToolCall?: {
|
|
26
36
|
name: string | undefined;
|
|
27
37
|
error: string;
|
|
38
|
+
rawArguments?: string;
|
|
39
|
+
rawTruncated?: true;
|
|
28
40
|
};
|
|
29
41
|
};
|
|
@@ -2,6 +2,11 @@
|
|
|
2
2
|
|
|
3
3
|
/** @typedef {import('../types').ToolCall} ToolCall */
|
|
4
4
|
|
|
5
|
+
// Raw-arguments exposure cap. Matches the sibling opt-in error-body exposure pattern
|
|
6
|
+
// (`exposeErrorBody`) elsewhere in the providers: off by default, and even when on, bounded —
|
|
7
|
+
// an arbitrarily long model-generated string reaching a log/receipt unbounded is its own risk.
|
|
8
|
+
const RAW_ARGUMENTS_MAX_CHARS = 500;
|
|
9
|
+
|
|
5
10
|
/**
|
|
6
11
|
* BA-27 — parse a round's raw tool calls into the neutral {@link ToolCall} shape WITHOUT throwing on
|
|
7
12
|
* malformed arguments.
|
|
@@ -18,11 +23,20 @@
|
|
|
18
23
|
* All-or-nothing (mirrors BA-4's refusal to execute a truncated round's calls): a partial set risks
|
|
19
24
|
* running half a decomposed intent, so one bad call voids the whole round's calls.
|
|
20
25
|
*
|
|
26
|
+
* `opts.exposeMalformedArgs` (default off, mirrors `exposeErrorBody`): when on, the marker also
|
|
27
|
+
* carries `rawArguments` — the raw `function.arguments` VERBATIM, capped at 500 chars (`rawTruncated:
|
|
28
|
+
* true` added only when it was longer). Non-string raw arguments (e.g. Ollama's well-formed object
|
|
29
|
+
* case reaching this catch some other way) are never exposed — only a string is a model-generated
|
|
30
|
+
* blob worth surfacing for debugging; an object was already structurally valid before whatever else
|
|
31
|
+
* threw. Off by default for the same reason as `exposeErrorBody`: an arbitrary model-generated string
|
|
32
|
+
* landing in a log/audit row unbounded is its own leak surface.
|
|
33
|
+
*
|
|
21
34
|
* @param {any[]} rawToolCalls - provider-native tool-call entries (may be undefined/empty)
|
|
22
35
|
* @param {(tc: any) => ToolCall} mapOne - maps one raw entry to a ToolCall; MAY throw on bad arguments
|
|
23
|
-
* @
|
|
36
|
+
* @param {{ exposeMalformedArgs?: boolean }} [opts]
|
|
37
|
+
* @returns {{ toolCalls: ToolCall[], malformedToolCall?: { name: string|undefined, error: string, rawArguments?: string, rawTruncated?: true } }}
|
|
24
38
|
*/
|
|
25
|
-
function parseToolCalls(rawToolCalls, mapOne) {
|
|
39
|
+
function parseToolCalls(rawToolCalls, mapOne, opts = {}) {
|
|
26
40
|
const raw = rawToolCalls || [];
|
|
27
41
|
/** @type {ToolCall[]} */
|
|
28
42
|
const toolCalls = [];
|
|
@@ -30,13 +44,19 @@ function parseToolCalls(rawToolCalls, mapOne) {
|
|
|
30
44
|
try {
|
|
31
45
|
toolCalls.push(mapOne(tc));
|
|
32
46
|
} catch (e) {
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
error: e instanceof Error ? e.message : String(e),
|
|
38
|
-
},
|
|
47
|
+
/** @type {{ name: string|undefined, error: string, rawArguments?: string, rawTruncated?: true }} */
|
|
48
|
+
const malformedToolCall = {
|
|
49
|
+
name: tc && tc.function ? tc.function.name : undefined,
|
|
50
|
+
error: e instanceof Error ? e.message : String(e),
|
|
39
51
|
};
|
|
52
|
+
if (opts.exposeMalformedArgs) {
|
|
53
|
+
const rawArgs = tc && tc.function ? tc.function.arguments : undefined;
|
|
54
|
+
if (typeof rawArgs === 'string') {
|
|
55
|
+
malformedToolCall.rawArguments = rawArgs.slice(0, RAW_ARGUMENTS_MAX_CHARS);
|
|
56
|
+
if (rawArgs.length > RAW_ARGUMENTS_MAX_CHARS) malformedToolCall.rawTruncated = true;
|
|
57
|
+
}
|
|
58
|
+
}
|
|
59
|
+
return { toolCalls: [], malformedToolCall };
|
|
40
60
|
}
|
|
41
61
|
}
|
|
42
62
|
return { toolCalls };
|
package/types/index.d.ts
CHANGED
|
@@ -142,8 +142,13 @@ export interface GenerateResult {
|
|
|
142
142
|
* tool call" and retries; `usage`/`model` still flow so the round is metered. The JSON is NEVER
|
|
143
143
|
* repaired. Absent on a clean round. OpenAI-compatible + Ollama string-arguments only; Anthropic
|
|
144
144
|
* arrives pre-parsed and cannot hit this.
|
|
145
|
+
*
|
|
146
|
+
* `rawArguments`/`rawTruncated` (BA-27 follow-up) are present only when the provider was
|
|
147
|
+
* constructed with `exposeMalformedArgs: true` (default off, mirrors `exposeErrorBody`) AND the
|
|
148
|
+
* raw `function.arguments` was a string: the raw value verbatim, capped at 500 chars, with
|
|
149
|
+
* `rawTruncated: true` added only when it was longer.
|
|
145
150
|
*/
|
|
146
|
-
malformedToolCall?: { name: string | undefined; error: string };
|
|
151
|
+
malformedToolCall?: { name: string | undefined; error: string; rawArguments?: string; rawTruncated?: true };
|
|
147
152
|
/**
|
|
148
153
|
* BA-7 — provider-native content blocks the normalized `{text, toolCalls}` shape cannot express
|
|
149
154
|
* (Anthropic `thinking` / `redacted_thinking`), captured opaquely so the Loop can put them on the
|