agentfences 0.5.0 → 0.7.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/README.md +12 -0
- package/dist/control.d.ts +11 -0
- package/dist/control.js +36 -6
- package/dist/core.d.ts +10 -0
- package/dist/core.js +24 -0
- package/dist/index.d.ts +1 -0
- package/dist/index.js +1 -0
- package/dist/mcp.d.ts +40 -0
- package/dist/mcp.js +46 -0
- package/dist/tools.d.ts +3 -0
- package/dist/tools.js +51 -26
- package/dist/version.d.ts +1 -1
- package/dist/version.js +1 -1
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -78,6 +78,8 @@ if (!approval.granted) return `I didn't issue the refund: ${approval.note}`;
|
|
|
78
78
|
|
|
79
79
|
An approved amount raises the run's budget. It never hangs or throws: in local mode, outside a run, or on a server without approvals it's denied right away, and with no answer it's denied after `timeoutS`. The heartbeat's timer never keeps your process alive on its own.
|
|
80
80
|
|
|
81
|
+
**Waiting at a limit** (from 0.6.0): with `init({ onBreach: "hold" })` (or `governed(fn, { onBreach: "hold" })`), a limit the server says is crossed makes `checkpoint()` wait instead of returning the breach, so whoever is on call can raise it from the dashboard and the agent carries on. A guarded tool denied for budget waits the same way. A stop ends the wait; after `pauseTimeoutS` the agent gets the breach it was held at. Cloud only. Don't hold an agent a person is waiting on.
|
|
82
|
+
|
|
81
83
|
**Containment hooks** (from 0.5.0) run in your process when a stop or quarantine arrives, to contain what Fences can't reach, such as a credential the agent holds:
|
|
82
84
|
|
|
83
85
|
```ts
|
|
@@ -105,6 +107,16 @@ const deploy = tool(deployImpl, { guard: true, timeoutS: 30 });
|
|
|
105
107
|
|
|
106
108
|
A guarded or timed tool always returns a promise. What each part guarantees: [ENFORCEMENT.md](../ENFORCEMENT.md).
|
|
107
109
|
|
|
110
|
+
**MCP tools** (from 0.7.0): wrap an MCP `Client` and every `callTool` is recorded, with no `tool()` per tool. The tools you name are guarded as above:
|
|
111
|
+
|
|
112
|
+
```ts
|
|
113
|
+
import { mcp } from "agentfences";
|
|
114
|
+
|
|
115
|
+
mcp.wrap(client, { guard: ["send_email"], costUsd: { refund: (args) => args.amount as number } });
|
|
116
|
+
```
|
|
117
|
+
|
|
118
|
+
`guard: true` guards every tool, and `approve: [...]` names tools a person approves first. A result with `isError` is recorded as a failed call and still comes back to the agent.
|
|
119
|
+
|
|
108
120
|
## Not yet in the TypeScript SDK
|
|
109
121
|
|
|
110
122
|
The Python SDK also records OpenAI/Anthropic calls automatically (`instrument`) and integrates with LangGraph and the OpenAI Agents SDK. Those are also how it checks a model call against the budget **before** it's sent, so the TypeScript SDK doesn't do that yet: a model call is checked afterwards, by `checkpoint()`, and a run can go over its budget by one call. Everything here follows the same server protocol ([PROTOCOL.md](../PROTOCOL.md)).
|
package/dist/control.d.ts
CHANGED
|
@@ -34,6 +34,8 @@ export interface Control {
|
|
|
34
34
|
paused: boolean;
|
|
35
35
|
pauseExpired: boolean;
|
|
36
36
|
quarantined: boolean;
|
|
37
|
+
/** While waiting at a limit: "breach" or "budget". */
|
|
38
|
+
held: "breach" | "budget" | null;
|
|
37
39
|
/** approval id -> the answer, or null while waiting */
|
|
38
40
|
approvals: Map<string, Approval | null>;
|
|
39
41
|
awaiting: number;
|
|
@@ -66,6 +68,15 @@ export declare function apply(run: Run, commands: Reply[] | undefined): void;
|
|
|
66
68
|
* its containment, and says so.
|
|
67
69
|
*/
|
|
68
70
|
export declare function drain(timeoutMs?: number): Promise<void>;
|
|
71
|
+
/** Breaches a person can lift by raising a limit, so onBreach "hold" waits at them. */
|
|
72
|
+
export declare const HOLDABLE: Set<string>;
|
|
73
|
+
export declare const BUDGET_BREACHES: Set<string>;
|
|
74
|
+
/** Whether this run waits at a limit: onBreach "hold", in cloud mode (someone can raise it), and not stopped. */
|
|
75
|
+
export declare const holds: (run: Run) => boolean;
|
|
76
|
+
/** Pause the run at a limit: "breach" (a checkpoint breached) or "budget" (a call refused for budget). */
|
|
77
|
+
export declare function startHold(run: Run, kind: "breach" | "budget"): void;
|
|
78
|
+
/** After waiting: "released" (an operator raised the limit), "stopped", or "timeout". */
|
|
79
|
+
export declare function endHold(run: Run): "released" | "stopped" | "timeout";
|
|
69
80
|
/** Hold the agent at its checkpoint while its run is paused, up to pauseTimeoutS. */
|
|
70
81
|
export declare function waitIfPaused(run: Run): Promise<void>;
|
|
71
82
|
/**
|
package/dist/control.js
CHANGED
|
@@ -6,11 +6,13 @@
|
|
|
6
6
|
* on as before. Same behaviour as the Python SDK (PROTOCOL.md, "Live control").
|
|
7
7
|
*/
|
|
8
8
|
import { randomUUID } from "node:crypto";
|
|
9
|
+
/** Heartbeat statuses whose acks go again with the next heartbeat: rate limited, or a server error. */
|
|
10
|
+
const retryable = (status) => status === undefined || status === 429 || status >= 500;
|
|
9
11
|
const FAST_MS = 2000; // heartbeat interval while a run is paused, waiting for an approval, or stopping
|
|
10
12
|
const HOOK_TIMEOUT_MS = 30_000; // a hook still running after this is reported as timed out
|
|
11
13
|
export const newControl = () => ({
|
|
12
14
|
stopped: false, stoppedAt: 0, stopSeen: false, cancelled: false, onCancel: null, paused: false, pauseExpired: false,
|
|
13
|
-
quarantined: false, approvals: new Map(), awaiting: 0, acked: new Set(),
|
|
15
|
+
quarantined: false, held: null, approvals: new Map(), awaiting: 0, acked: new Set(),
|
|
14
16
|
});
|
|
15
17
|
const live = new Map(); // run id -> runs in progress
|
|
16
18
|
let client = null;
|
|
@@ -52,7 +54,8 @@ export function unsupported(kind, what, consequence) {
|
|
|
52
54
|
const LIMITS = { budget_usd: "budgetUsd", max_iterations: "maxIterations", max_duration_ms: "maxDurationMs", max_tokens: "maxTokens" };
|
|
53
55
|
/** Apply the commands addressed to this run (from a heartbeat or a checkpoint reply), and acknowledge them. Each is safe to apply twice. */
|
|
54
56
|
export function apply(run, commands) {
|
|
55
|
-
for
|
|
57
|
+
// Limits first: a resume in the same reply may release a call held for budget, which re-checks against them at once
|
|
58
|
+
for (const c of [...(commands ?? [])].sort((a, b) => Number(a.type !== "limits") - Number(b.type !== "limits"))) {
|
|
56
59
|
if (c.run_id !== undefined && c.run_id !== run.runId)
|
|
57
60
|
continue;
|
|
58
61
|
const ctl = run.control;
|
|
@@ -153,7 +156,7 @@ async function sendAcks() {
|
|
|
153
156
|
const batch = acks;
|
|
154
157
|
acks = [];
|
|
155
158
|
const resp = await client.heartbeat([], batch).catch(() => ({ network_error: "heartbeat failed" }));
|
|
156
|
-
if (resp.network_error || (resp.ok === false && (resp.status
|
|
159
|
+
if (resp.network_error || (resp.ok === false && retryable(resp.status)))
|
|
157
160
|
acks = [...batch, ...acks];
|
|
158
161
|
}
|
|
159
162
|
// Node exits once nothing keeps it busy: beforeExit lets acks queued by a finished hook still go out.
|
|
@@ -201,10 +204,11 @@ async function beat() {
|
|
|
201
204
|
acks = [];
|
|
202
205
|
try {
|
|
203
206
|
const resp = await client.heartbeat(runs.map((r) => ({ run_id: r.runId, iterations: r.iterations, spent_usd: r.costUsd,
|
|
204
|
-
tokens_used: r.tokensUsed, paused: r.control.paused, stopped: r.control.stopped
|
|
207
|
+
tokens_used: r.tokensUsed, paused: r.control.paused, stopped: r.control.stopped,
|
|
208
|
+
...(r.control.held ? { held: r.control.held } : {}) })), batch);
|
|
205
209
|
if (resp.network_error || (resp.ok === false && !resp.unsupported)) {
|
|
206
|
-
if (resp.network_error || (resp.status
|
|
207
|
-
acks = [...batch, ...acks]; // at least once;
|
|
210
|
+
if (resp.network_error || retryable(resp.status))
|
|
211
|
+
acks = [...batch, ...acks]; // at least once; any other 4xx won't get better
|
|
208
212
|
}
|
|
209
213
|
else if (resp.unsupported) {
|
|
210
214
|
unsupported("heartbeat", "the heartbeat", "a stop from the dashboard applies at the next checkpoint and pause isn't available");
|
|
@@ -223,6 +227,32 @@ async function beat() {
|
|
|
223
227
|
}
|
|
224
228
|
schedule(nextDelay());
|
|
225
229
|
}
|
|
230
|
+
// ── Waiting at a limit ────────────────────────────────────────────────────────
|
|
231
|
+
/** Breaches a person can lift by raising a limit, so onBreach "hold" waits at them. */
|
|
232
|
+
export const HOLDABLE = new Set(["budget_exceeded", "iteration_limit", "time_limit", "token_limit", "key_daily_budget",
|
|
233
|
+
"key_monthly_budget", "limit_reached"]);
|
|
234
|
+
export const BUDGET_BREACHES = new Set(["budget_exceeded", "key_daily_budget", "key_monthly_budget"]);
|
|
235
|
+
/** Whether this run waits at a limit: onBreach "hold", in cloud mode (someone can raise it), and not stopped. */
|
|
236
|
+
export const holds = (run) => run.onBreach === "hold" && client !== null && !run.control.stopped;
|
|
237
|
+
/** Pause the run at a limit: "breach" (a checkpoint breached) or "budget" (a call refused for budget). */
|
|
238
|
+
export function startHold(run, kind) {
|
|
239
|
+
run.control.held = kind;
|
|
240
|
+
if (!run.control.paused) {
|
|
241
|
+
run.control.paused = true;
|
|
242
|
+
schedule(0);
|
|
243
|
+
}
|
|
244
|
+
}
|
|
245
|
+
/** After waiting: "released" (an operator raised the limit), "stopped", or "timeout". */
|
|
246
|
+
export function endHold(run) {
|
|
247
|
+
run.control.held = null;
|
|
248
|
+
if (run.control.stopped)
|
|
249
|
+
return "stopped";
|
|
250
|
+
if (run.control.pauseExpired) {
|
|
251
|
+
run.control.pauseExpired = false; // the run ends with the limit it was held at, not "paused"
|
|
252
|
+
return "timeout";
|
|
253
|
+
}
|
|
254
|
+
return "released";
|
|
255
|
+
}
|
|
226
256
|
// ── Pausing ───────────────────────────────────────────────────────────────────
|
|
227
257
|
/** Hold the agent at its checkpoint while its run is paused, up to pauseTimeoutS. */
|
|
228
258
|
export async function waitIfPaused(run) {
|
package/dist/core.d.ts
CHANGED
|
@@ -60,6 +60,8 @@ export declare class Run {
|
|
|
60
60
|
readonly control: control.Control;
|
|
61
61
|
/** Held by guarded tools that are running. */
|
|
62
62
|
reservedUsd: number;
|
|
63
|
+
/** "hold": wait at a limit for someone to raise it (see InitOptions.onBreach). */
|
|
64
|
+
onBreach: "hold" | null;
|
|
63
65
|
private readonly abort;
|
|
64
66
|
/**
|
|
65
67
|
* Aborts when Fences halts the run after a stop it didn't reach a checkpoint for (see
|
|
@@ -109,6 +111,13 @@ export interface InitOptions {
|
|
|
109
111
|
onStop?: control.Hook;
|
|
110
112
|
/** The same, when the agent is quarantined (once per quarantine, however many runs it reaches). */
|
|
111
113
|
onQuarantine?: control.Hook;
|
|
114
|
+
/**
|
|
115
|
+
* "hold" makes runs wait at a limit instead of ending: when the server says a limit is crossed,
|
|
116
|
+
* checkpoint() waits (as if paused) until someone raises it and resumes the run, then returns ok
|
|
117
|
+
* and the agent carries on. A guarded tool denied for budget waits the same way. A stop ends the
|
|
118
|
+
* wait, and after pauseTimeoutS the breach is returned as usual. Cloud only; override per agent in governed().
|
|
119
|
+
*/
|
|
120
|
+
onBreach?: "hold" | null;
|
|
112
121
|
}
|
|
113
122
|
/** Call once at startup, before any governed function runs. */
|
|
114
123
|
export declare function init(options?: InitOptions): void;
|
|
@@ -127,6 +136,7 @@ export declare const _client: () => FencesClient | null;
|
|
|
127
136
|
*/
|
|
128
137
|
export declare function governed<A extends unknown[], R>(fn: (...args: A) => R | Promise<R>, options?: Limits & {
|
|
129
138
|
name?: string;
|
|
139
|
+
onBreach?: "hold" | null;
|
|
130
140
|
}): (...args: A) => Promise<Awaited<R>>;
|
|
131
141
|
export interface Extra {
|
|
132
142
|
/** Extra spend for this step in USD, e.g. a paid tool call. Without a response, the step's whole cost. */
|
package/dist/core.js
CHANGED
|
@@ -62,6 +62,8 @@ export class Run {
|
|
|
62
62
|
control = control.newControl();
|
|
63
63
|
/** Held by guarded tools that are running. */
|
|
64
64
|
reservedUsd = 0;
|
|
65
|
+
/** "hold": wait at a limit for someone to raise it (see InitOptions.onBreach). */
|
|
66
|
+
onBreach = null;
|
|
65
67
|
abort = new AbortController();
|
|
66
68
|
/**
|
|
67
69
|
* Aborts when Fences halts the run after a stop it didn't reach a checkpoint for (see
|
|
@@ -89,12 +91,21 @@ export function getActiveRun() {
|
|
|
89
91
|
let client = null;
|
|
90
92
|
let localOnly = false;
|
|
91
93
|
let failClosed = false;
|
|
94
|
+
let defaultOnBreach = null;
|
|
95
|
+
function checkOnBreach(value) {
|
|
96
|
+
if (value === undefined || value === null)
|
|
97
|
+
return null;
|
|
98
|
+
if (value !== "hold")
|
|
99
|
+
throw new TypeError(`onBreach must be "hold" or null, not ${JSON.stringify(value)}`);
|
|
100
|
+
return value;
|
|
101
|
+
}
|
|
92
102
|
/** Call once at startup, before any governed function runs. */
|
|
93
103
|
export function init(options = {}) {
|
|
94
104
|
setCustomPrices(options.prices);
|
|
95
105
|
events.configure(options.environment, options.release, options.redact);
|
|
96
106
|
localOnly = !!options.localOnly;
|
|
97
107
|
failClosed = !!options.failClosed;
|
|
108
|
+
defaultOnBreach = checkOnBreach(options.onBreach);
|
|
98
109
|
if (localOnly) {
|
|
99
110
|
client = null;
|
|
100
111
|
}
|
|
@@ -137,9 +148,11 @@ function warnUnreachable(run, err) {
|
|
|
137
148
|
*/
|
|
138
149
|
export function governed(fn, options = {}) {
|
|
139
150
|
const name = options.name ?? (fn.name || "agent");
|
|
151
|
+
checkOnBreach(options.onBreach);
|
|
140
152
|
return async (...args) => {
|
|
141
153
|
requireInit();
|
|
142
154
|
const run = await startRun(name, options);
|
|
155
|
+
run.onBreach = checkOnBreach(options.onBreach) ?? defaultOnBreach;
|
|
143
156
|
// A stop the agent doesn't reach a checkpoint for within stopGraceS halts the call here
|
|
144
157
|
const halted = new Promise((_, reject) => {
|
|
145
158
|
run.control.onCancel = () => {
|
|
@@ -251,6 +264,17 @@ export async function checkpoint(response, extra = {}) {
|
|
|
251
264
|
cost_delta_usd: stepCost, tokens_used: stepTokens, iterations: run.iterations, duration_ms: run.durationMs
|
|
252
265
|
}) : null;
|
|
253
266
|
const result = decide(run, resp);
|
|
267
|
+
// onBreach "hold": wait here for someone to raise the limit. Only a breach the server decided: an unreachable one can't be raised
|
|
268
|
+
if (result.breachType && control.HOLDABLE.has(result.breachType) && control.holds(run) && resp && !resp.network_error) {
|
|
269
|
+
control.startHold(run, "breach");
|
|
270
|
+
await control.waitIfPaused(run);
|
|
271
|
+
const outcome = control.endHold(run);
|
|
272
|
+
if (outcome === "released") { // an operator raised the limit and resumed the run
|
|
273
|
+
run.lastBreach = null;
|
|
274
|
+
return okResult(run.costUsd);
|
|
275
|
+
}
|
|
276
|
+
return outcome === "stopped" ? finalResult(run) : result; // timed out: the limit it was held at
|
|
277
|
+
}
|
|
254
278
|
if (run.control.paused && !result.breached) { // paused from the dashboard: wait here until resumed
|
|
255
279
|
await control.waitIfPaused(run);
|
|
256
280
|
return finalResult(run);
|
package/dist/index.d.ts
CHANGED
|
@@ -10,3 +10,4 @@ export { priceOf } from "./pricing.js";
|
|
|
10
10
|
export type { CustomPrice } from "./pricing.js";
|
|
11
11
|
export { FencesError, FencesAuthError, AgentQuarantined, FencesStop, ToolBlocked } from "./errors.js";
|
|
12
12
|
export { VERSION } from "./version.js";
|
|
13
|
+
export * as mcp from "./mcp.js";
|
package/dist/index.js
CHANGED
package/dist/mcp.d.ts
ADDED
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
type Args = Record<string, unknown>;
|
|
2
|
+
interface CallToolClient {
|
|
3
|
+
callTool(params: {
|
|
4
|
+
name: string;
|
|
5
|
+
arguments?: Args;
|
|
6
|
+
}, ...rest: any[]): Promise<any>;
|
|
7
|
+
}
|
|
8
|
+
export interface WrapOptions {
|
|
9
|
+
/** Tool names to check with Fences before each call, or true for every tool. */
|
|
10
|
+
guard?: boolean | string[];
|
|
11
|
+
/** Per tool, what a call costs the run: a number or a function of its arguments. Such a tool is guarded. */
|
|
12
|
+
costUsd?: Record<string, number | ((args: Args) => number)>;
|
|
13
|
+
/** Tool names a person approves from the dashboard first. Such a tool is guarded. */
|
|
14
|
+
approve?: string[];
|
|
15
|
+
}
|
|
16
|
+
/** An MCP tool answered with isError: recorded as the call's error, the result still goes back. */
|
|
17
|
+
export declare class MCPToolError extends Error {
|
|
18
|
+
readonly result: {
|
|
19
|
+
content?: {
|
|
20
|
+
text?: string;
|
|
21
|
+
}[];
|
|
22
|
+
};
|
|
23
|
+
constructor(result: {
|
|
24
|
+
content?: {
|
|
25
|
+
text?: string;
|
|
26
|
+
}[];
|
|
27
|
+
});
|
|
28
|
+
}
|
|
29
|
+
/**
|
|
30
|
+
* Record every tool call an MCP client makes, and guard the ones you name:
|
|
31
|
+
*
|
|
32
|
+
* mcp.wrap(client, { guard: ["refund", "send_email"], costUsd: { refund: (a) => a.amount as number } });
|
|
33
|
+
*
|
|
34
|
+
* Patches client.callTool in place and returns the client. Each call inside a governed run
|
|
35
|
+
* becomes a tool span, with no tool() per tool. A result with isError counts as a failed call.
|
|
36
|
+
* Guarded tools go through the same check and settle as tool(fn, { guard: true }); a refused
|
|
37
|
+
* one throws ToolBlocked and never reaches the MCP server. Outside a governed run calls pass straight through.
|
|
38
|
+
*/
|
|
39
|
+
export declare function wrap<C extends CallToolClient>(client: C, options?: WrapOptions): C;
|
|
40
|
+
export {};
|
package/dist/mcp.js
ADDED
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
import { getActiveRun } from "./core.js";
|
|
2
|
+
import { argsSummary, callAsync } from "./tools.js";
|
|
3
|
+
/** An MCP tool answered with isError: recorded as the call's error, the result still goes back. */
|
|
4
|
+
export class MCPToolError extends Error {
|
|
5
|
+
result;
|
|
6
|
+
constructor(result) {
|
|
7
|
+
super((result.content ?? []).map((c) => c.text ?? "").join(" ").trim() || "isError");
|
|
8
|
+
this.result = result;
|
|
9
|
+
this.name = "MCPToolError";
|
|
10
|
+
}
|
|
11
|
+
}
|
|
12
|
+
/**
|
|
13
|
+
* Record every tool call an MCP client makes, and guard the ones you name:
|
|
14
|
+
*
|
|
15
|
+
* mcp.wrap(client, { guard: ["refund", "send_email"], costUsd: { refund: (a) => a.amount as number } });
|
|
16
|
+
*
|
|
17
|
+
* Patches client.callTool in place and returns the client. Each call inside a governed run
|
|
18
|
+
* becomes a tool span, with no tool() per tool. A result with isError counts as a failed call.
|
|
19
|
+
* Guarded tools go through the same check and settle as tool(fn, { guard: true }); a refused
|
|
20
|
+
* one throws ToolBlocked and never reaches the MCP server. Outside a governed run calls pass straight through.
|
|
21
|
+
*/
|
|
22
|
+
export function wrap(client, options = {}) {
|
|
23
|
+
const original = client.callTool.bind(client), costs = options.costUsd ?? {}, approve = new Set(options.approve);
|
|
24
|
+
const named = new Set(Array.isArray(options.guard) ? options.guard : []);
|
|
25
|
+
client.callTool = async (params, ...rest) => {
|
|
26
|
+
const run = getActiveRun();
|
|
27
|
+
if (!run)
|
|
28
|
+
return original(params, ...rest);
|
|
29
|
+
const { name } = params, args = params.arguments ?? {}, c = Object.hasOwn(costs, name) ? costs[name] : undefined;
|
|
30
|
+
const guarded = options.guard === true || named.has(name) || c !== undefined || approve.has(name);
|
|
31
|
+
try {
|
|
32
|
+
return await callAsync(run, name, argsSummary([args]), guarded ? () => Number((typeof c === "function" ? c(args) : c) ?? 0) : null, approve.has(name), async () => {
|
|
33
|
+
const result = await original(params, ...rest);
|
|
34
|
+
if (result?.isError)
|
|
35
|
+
throw new MCPToolError(result);
|
|
36
|
+
return result;
|
|
37
|
+
});
|
|
38
|
+
}
|
|
39
|
+
catch (e) {
|
|
40
|
+
if (e instanceof MCPToolError)
|
|
41
|
+
return e.result;
|
|
42
|
+
throw e;
|
|
43
|
+
}
|
|
44
|
+
};
|
|
45
|
+
return client;
|
|
46
|
+
}
|
package/dist/tools.d.ts
CHANGED
|
@@ -1,5 +1,8 @@
|
|
|
1
|
+
import { type Run } from "./core.js";
|
|
1
2
|
/** `{"query":"cats","limit":5}`: the arguments as compact JSON (one object argument on its own), capped. */
|
|
2
3
|
export declare function argsSummary(args: unknown[]): string;
|
|
4
|
+
/** Record an async tool call; cost is null for an unguarded call, else a function giving its amount. */
|
|
5
|
+
export declare function callAsync<R>(run: Run, name: string, summary: string, cost: (() => number) | null, needsApproval: boolean, call: () => Promise<R>): Promise<R>;
|
|
3
6
|
interface ToolFields<A extends unknown[]> {
|
|
4
7
|
name?: string;
|
|
5
8
|
/** Reject with a TimeoutError if the call takes longer. JS can't stop the tool's code; pass run.signal to its fetches. */
|
package/dist/tools.js
CHANGED
|
@@ -36,7 +36,7 @@ async function open(run, name, summary, amount, needsApproval) {
|
|
|
36
36
|
await control.waitIfPaused(run);
|
|
37
37
|
if (run.control.pauseExpired)
|
|
38
38
|
block(run, name, "the run was paused from the Fences dashboard and not resumed in time", "paused");
|
|
39
|
-
|
|
39
|
+
const approvalId = needsApproval ? await approve(run, name, summary, amount) : null;
|
|
40
40
|
if (run.control.stopped)
|
|
41
41
|
block(run, name, "the run was stopped from the Fences dashboard", "stopped_by_user");
|
|
42
42
|
const client = _client();
|
|
@@ -49,6 +49,27 @@ async function open(run, name, summary, amount, needsApproval) {
|
|
|
49
49
|
run.reservedUsd += amount;
|
|
50
50
|
return randomUUID();
|
|
51
51
|
}
|
|
52
|
+
const call = { guardId: randomUUID(), approvalId }; // one id per call: the server answers a re-check of a denied id afresh
|
|
53
|
+
for (;;) {
|
|
54
|
+
try {
|
|
55
|
+
return await check(run, client, name, summary, amount, call);
|
|
56
|
+
}
|
|
57
|
+
catch (e) {
|
|
58
|
+
if (!(e instanceof ToolBlocked && control.BUDGET_BREACHES.has(e.breachType ?? "") && control.holds(run)))
|
|
59
|
+
throw e;
|
|
60
|
+
control.startHold(run, "budget"); // onBreach "hold": wait for someone to raise the budget, then check again
|
|
61
|
+
await control.waitIfPaused(run);
|
|
62
|
+
const outcome = control.endHold(run);
|
|
63
|
+
if (outcome === "stopped")
|
|
64
|
+
block(run, name, "the run was stopped from the Fences dashboard", "stopped_by_user");
|
|
65
|
+
if (outcome !== "released")
|
|
66
|
+
throw e;
|
|
67
|
+
}
|
|
68
|
+
}
|
|
69
|
+
}
|
|
70
|
+
async function check(run, client, name, summary, amount, call) {
|
|
71
|
+
if (run.control.stopped)
|
|
72
|
+
block(run, name, "the run was stopped from the Fences dashboard", "stopped_by_user");
|
|
52
73
|
// Check against what this process already holds before asking the server, which can't see it; reserve until it answers
|
|
53
74
|
if (!fits(run, amount))
|
|
54
75
|
block(run, name, `$${amount.toFixed(2)} more could take the run past its $${run.budgetUsd.toFixed(2)} budget`, "budget_exceeded");
|
|
@@ -57,15 +78,14 @@ async function open(run, name, summary, amount, needsApproval) {
|
|
|
57
78
|
const sent = events.redact({ type: "guard", tool: name, arguments: summary }) ?? {}; // the check goes ahead without them
|
|
58
79
|
let resp = {}; // eslint-disable-line @typescript-eslint/no-explicit-any
|
|
59
80
|
for (let attempt = 0; attempt < 2; attempt++) { // once more after an approval the server asked for
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
amount_usd: amount, approval_id: approvalId });
|
|
81
|
+
resp = await client.guard(run.runId, { guard_id: call.guardId, phase: "check", tool: name, arguments: sent.arguments ?? null,
|
|
82
|
+
amount_usd: amount, approval_id: call.approvalId });
|
|
63
83
|
control.apply(run, resp.commands);
|
|
64
84
|
if (resp.allow === true)
|
|
65
|
-
return guardId;
|
|
66
|
-
if (!(resp.needs_approval && approvalId === null))
|
|
85
|
+
return call.guardId;
|
|
86
|
+
if (!(resp.needs_approval && call.approvalId === null))
|
|
67
87
|
break;
|
|
68
|
-
approvalId = await approve(run, name, summary, amount);
|
|
88
|
+
call.approvalId = await approve(run, name, summary, amount);
|
|
69
89
|
}
|
|
70
90
|
if (resp.unsupported) {
|
|
71
91
|
control.unsupported("guard", "guarded tools", "they are blocked");
|
|
@@ -88,6 +108,28 @@ async function settle(run, guardId, amount, error, failed) {
|
|
|
88
108
|
run.costUsd += charged;
|
|
89
109
|
await _client()?.guard(run.runId, { guard_id: guardId, phase: "settle", outcome, cost_usd: charged });
|
|
90
110
|
}
|
|
111
|
+
/** Record an async tool call; cost is null for an unguarded call, else a function giving its amount. */
|
|
112
|
+
export async function callAsync(run, name, summary, cost, needsApproval, call) {
|
|
113
|
+
const started = performance.now();
|
|
114
|
+
let gate = null;
|
|
115
|
+
try {
|
|
116
|
+
if (cost) {
|
|
117
|
+
const amount = cost();
|
|
118
|
+
gate = [await open(run, name, summary, amount, needsApproval), amount];
|
|
119
|
+
}
|
|
120
|
+
const out = await call();
|
|
121
|
+
recordToolCall(name, summary, started);
|
|
122
|
+
if (gate)
|
|
123
|
+
await settle(run, ...gate, undefined, false);
|
|
124
|
+
return out;
|
|
125
|
+
}
|
|
126
|
+
catch (e) {
|
|
127
|
+
recordToolCall(name, summary, started, e);
|
|
128
|
+
if (gate)
|
|
129
|
+
await settle(run, ...gate, e, true);
|
|
130
|
+
throw e;
|
|
131
|
+
}
|
|
132
|
+
}
|
|
91
133
|
function timed(p, timeoutS, name) {
|
|
92
134
|
if (timeoutS === undefined)
|
|
93
135
|
return p;
|
|
@@ -105,25 +147,8 @@ export function tool(fn, options = {}) {
|
|
|
105
147
|
const run = getActiveRun();
|
|
106
148
|
if (!run)
|
|
107
149
|
return timed(Promise.resolve().then(() => fn(...args)), options.timeoutS, name);
|
|
108
|
-
const
|
|
109
|
-
|
|
110
|
-
try {
|
|
111
|
-
if (guarded) {
|
|
112
|
-
const c = options.costUsd, amount = Number((typeof c === "function" ? c(...args) : c) ?? 0);
|
|
113
|
-
gate = [await open(run, name, summary, amount, !!options.approve), amount];
|
|
114
|
-
}
|
|
115
|
-
const out = await timed(Promise.resolve().then(() => fn(...args)), options.timeoutS, name);
|
|
116
|
-
recordToolCall(name, summary, started);
|
|
117
|
-
if (gate)
|
|
118
|
-
await settle(run, ...gate, undefined, false);
|
|
119
|
-
return out;
|
|
120
|
-
}
|
|
121
|
-
catch (e) {
|
|
122
|
-
recordToolCall(name, summary, started, e);
|
|
123
|
-
if (gate)
|
|
124
|
-
await settle(run, ...gate, e, true);
|
|
125
|
-
throw e;
|
|
126
|
-
}
|
|
150
|
+
const c = options.costUsd;
|
|
151
|
+
return callAsync(run, name, argsSummary(args), guarded ? () => Number((typeof c === "function" ? c(...args) : c) ?? 0) : null, !!options.approve, () => timed(Promise.resolve().then(() => fn(...args)), options.timeoutS, name));
|
|
127
152
|
};
|
|
128
153
|
}
|
|
129
154
|
return (...args) => {
|
package/dist/version.d.ts
CHANGED
|
@@ -1,2 +1,2 @@
|
|
|
1
1
|
/** The one place the version lives; sent to Fences on every request. Keep in step with package.json. */
|
|
2
|
-
export declare const VERSION = "0.
|
|
2
|
+
export declare const VERSION = "0.7.0";
|
package/dist/version.js
CHANGED
|
@@ -1,2 +1,2 @@
|
|
|
1
1
|
/** The one place the version lives; sent to Fences on every request. Keep in step with package.json. */
|
|
2
|
-
export const VERSION = "0.
|
|
2
|
+
export const VERSION = "0.7.0";
|
package/package.json
CHANGED