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 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 (const c of commands ?? []) {
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 ?? 500) >= 500))
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 })), batch);
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 ?? 500) >= 500)
207
- acks = [...batch, ...acks]; // at least once; a 4xx won't get better
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
- let approvalId = needsApproval ? await approve(run, name, summary, amount) : null;
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
- const guardId = randomUUID();
61
- resp = await client.guard(run.runId, { guard_id: guardId, phase: "check", tool: name, arguments: sent.arguments ?? null,
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.5.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.5.0";
2
+ export const VERSION = "0.6.0";
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "agentfences",
3
- "version": "0.5.0",
3
+ "version": "0.6.0",
4
4
  "description": "On-call for AI agents: limits your agents can't raise, cost tracking, a decision trail, and a stop button",
5
5
  "license": "MIT",
6
6
  "author": "Bhavik Sardar",