agentfences 0.5.0 → 0.6.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 +2 -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/tools.js +27 -7
- 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
|
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/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");
|
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.6.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.6.0";
|
package/package.json
CHANGED