agentfences 0.7.0 → 0.9.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
@@ -30,7 +30,7 @@ const research = governed(async (question: string) => {
30
30
 
31
31
  Pass the model's response to `checkpoint()` and Fences works out what the call cost from its model and token usage, including cache reads and writes. It understands results from the OpenAI and Anthropic SDKs, the Vercel AI SDK (`generateText`), Gemini and LangChain.js, or any object with the same usage fields. Prices come from a built-in table of about 290 models (the same one as the Python SDK).
32
32
 
33
- - Unknown model: its tokens are counted and Fences warns once. Add prices in USD per 1M tokens: `init({ localOnly: true, prices: { "my-finetune": { input: 2, output: 6 } } })`.
33
+ - Unknown model: its tokens are counted and Fences warns once. Add prices in USD per 1M tokens: `init({ localOnly: true, prices: { "my-finetune": { input: 2, output: 6 } } })`. Both prices are required; an invalid one throws at `init()`.
34
34
  - Extra costs, or no response at all: `checkpoint(response, { costDeltaUsd: 0.01 })` or `checkpoint(null, { costDeltaUsd: 0.02, tokensUsed: 450 })`.
35
35
 
36
36
  ## Limits
@@ -47,7 +47,7 @@ const webSearch = tool(async ({ query }: { query: string }) => search(query));
47
47
  await context({ user_id: "u_123", session_id: "s_9" }, () => research(question)); // tags every run inside
48
48
  ```
49
49
 
50
- - Tool and model calls are recorded as OpenTelemetry-shaped spans (`execute_tool webSearch`, `gen_ai.*` attributes), on `getActiveRun().events`.
50
+ - Tool and model calls are recorded as OpenTelemetry-shaped spans (`execute_tool webSearch`, `gen_ai.*` attributes), on `getActiveRun().events`. A tool call carries its argument names (`query, limit`), not their values; `init({ toolArguments: "full" })` records the values as JSON (300 characters at most), `"none"` neither (from 0.9.0). A guarded tool's check and an approval request still send the values.
51
51
  - When a governed run throws, Fences records the error's type, message and stack with the run; the error still reaches your code.
52
52
  - `init({ redact: (event) => ... })` sees everything before it leaves the process and can change or drop it.
53
53
 
@@ -57,7 +57,7 @@ await context({ user_id: "u_123", session_id: "s_9" }, () => research(question))
57
57
  init({ apiKey: process.env.FENCES_API_KEY, endpoint: "https://your-fences-host", environment: "prod", release: "v1.4.2" });
58
58
  ```
59
59
 
60
- The server is authoritative for limits and sees spend across processes. If it can't be reached, limits are enforced locally; pass `failClosed: true` to stop instead. A rejected key throws `FencesAuthError`; a quarantined agent throws `AgentQuarantined` before any of its code runs. In serverless handlers, `await flush()` before returning so queued decisions and spans are sent.
60
+ The server is authoritative for limits and sees spend across processes. If it can't be reached, limits are enforced locally; pass `failClosed: true` to stop instead. A rejected key throws `FencesAuthError` when the run starts, and a key revoked during a run ends it at its next checkpoint (`fences_unreachable`); a quarantined agent throws `AgentQuarantined` before any of its code runs. A cost that isn't a finite number of zero or more throws instead of being counted. In serverless handlers, `await flush()` before returning so queued decisions and spans are sent.
61
61
 
62
62
  ## Live control (from 0.4.0)
63
63
 
@@ -117,6 +117,23 @@ mcp.wrap(client, { guard: ["send_email"], costUsd: { refund: (args) => args.amou
117
117
 
118
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
119
 
120
+ ## Model calls (from 0.8.0)
121
+
122
+ `instrument(client)` records every OpenAI and Anthropic call made inside a governed run (model, tokens, cost, latency, errors) and checks it against the budget **before** it's sent:
123
+
124
+ ```ts
125
+ import OpenAI from "openai";
126
+ import { instrument } from "agentfences";
127
+
128
+ const openai = instrument(new OpenAI()); // one call covers every client of that SDK
129
+ ```
130
+
131
+ - If a call's worst case could take the run past its budget, it rejects with `FencesStop` (`budget_exceeded`) and never reaches the provider. The worst case is the request's input, estimated high, plus its output cap: `max_tokens` (or `max_completion_tokens`, `max_output_tokens`) if set, otherwise the model's whole output limit, so **set `max_tokens` on calls in runs with small budgets**.
132
+ - Each call's worst case is held while it's in flight, so parallel calls can't overspend together. A failed call is released; a finished one is replaced by its actual cost, and the next `checkpoint()` releases it. With `onBreach: "hold"`, a call that doesn't fit waits for someone to raise the budget.
133
+ - Calls are recorded, not counted as steps: keep calling `checkpoint(response)`, which adds the cost and checks the other limits.
134
+ - The SDK's own promise comes back, with `.withResponse()` and `.asResponse()`. After `.asResponse()` the call is recorded without usage, and its worst case stays held until the next checkpoint.
135
+ - OpenAI: `chat.completions.create` and `responses.create`, including OpenAI-compatible clients. Anthropic: `messages.create`. Streams are recorded when they start; pass the final usage to `checkpoint()`.
136
+
120
137
  ## Not yet in the TypeScript SDK
121
138
 
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)).
139
+ The Python SDK also integrates with LangGraph and the OpenAI Agents SDK. Model calls made without `instrument(client)` are checked afterwards, by `checkpoint()`, so a run can go over its budget by one such call. Everything here follows the same server protocol ([PROTOCOL.md](../PROTOCOL.md)).
@@ -0,0 +1,24 @@
1
+ export interface Approval {
2
+ granted: boolean;
3
+ /** Who answered, as the dashboard reports it. */
4
+ by?: string | null;
5
+ /** Their note, or why no one could answer. */
6
+ note?: string | null;
7
+ /** The amount approved, if one was asked for. */
8
+ amountUsd?: number | null;
9
+ approvalId?: string | null;
10
+ }
11
+ /**
12
+ * Ask a person, through the Fences dashboard, to approve something, and wait for the answer:
13
+ *
14
+ * const approval = await requestApproval("Refund $240 to order 1182?", { amountUsd: 240 });
15
+ * if (!approval.granted) return `I didn't issue the refund: ${approval.note}`;
16
+ *
17
+ * If an amount is approved, the run's budget goes up by that much. Returns denied right away in
18
+ * local mode, outside a run, or when the server can't take requests, and denied after timeoutS
19
+ * (default 600) with no answer: it never hangs and never throws.
20
+ */
21
+ export declare function requestApproval(reason: string, options?: {
22
+ amountUsd?: number;
23
+ timeoutS?: number;
24
+ }): Promise<Approval>;
@@ -0,0 +1,57 @@
1
+ /** Approval requests: ask a person, through the Fences dashboard, and wait for the answer. */
2
+ import { randomUUID } from "node:crypto";
3
+ import * as control from "./control.js";
4
+ import { recordSpan } from "./core.js";
5
+ import { getActiveRun } from "./run.js";
6
+ /**
7
+ * Ask a person, through the Fences dashboard, to approve something, and wait for the answer:
8
+ *
9
+ * const approval = await requestApproval("Refund $240 to order 1182?", { amountUsd: 240 });
10
+ * if (!approval.granted) return `I didn't issue the refund: ${approval.note}`;
11
+ *
12
+ * If an amount is approved, the run's budget goes up by that much. Returns denied right away in
13
+ * local mode, outside a run, or when the server can't take requests, and denied after timeoutS
14
+ * (default 600) with no answer: it never hangs and never throws.
15
+ */
16
+ export async function requestApproval(reason, options = {}) {
17
+ const run = getActiveRun(), client = control._client();
18
+ const timeoutS = options.timeoutS ?? 600, amountUsd = options.amountUsd ?? null;
19
+ if (!run)
20
+ return { granted: false, note: "requestApproval() was called outside a governed run" };
21
+ if (!client)
22
+ return { granted: false, note: "Local mode: there is no one to approve this request" };
23
+ if (!control.supported.approvals)
24
+ return { granted: false, note: "This Fences server doesn't support approvals" };
25
+ const approvalId = randomUUID(), started = performance.now();
26
+ // timeout_s lets the server expire the request when we stop waiting
27
+ const resp = await client.requestApproval(run.runId, { approval_id: approvalId, reason, amount_usd: amountUsd, timeout_s: timeoutS });
28
+ if (resp.unsupported) {
29
+ control.unsupported("approvals", "approval requests", "they are denied");
30
+ return { granted: false, note: "This Fences server doesn't support approvals" };
31
+ }
32
+ if (resp.network_error)
33
+ return { granted: false, note: "Couldn't reach Fences to ask for approval" };
34
+ if (resp.ok === false)
35
+ return { granted: false, note: `Fences didn't accept the approval request (HTTP ${resp.status})` }; // no one will be asked
36
+ run.control.approvals.set(approvalId, null);
37
+ run.control.awaiting += 1;
38
+ control.schedule(0); // poll fast while someone decides
39
+ const deadline = Date.now() + timeoutS * 1000;
40
+ while (run.control.approvals.get(approvalId) === null && Date.now() < deadline) {
41
+ await new Promise((ok) => setTimeout(ok, 100));
42
+ }
43
+ run.control.awaiting -= 1;
44
+ const c = run.control.approvals.get(approvalId); // the dashboard's approval command, or null: no answer in time
45
+ run.control.approvals.delete(approvalId);
46
+ const answer = c
47
+ ? { granted: !!c.granted, by: c.by ?? null, note: c.note ?? null, amountUsd: c.amount_usd ?? null, approvalId }
48
+ : { granted: false, note: "No answer before the timeout", approvalId };
49
+ if (answer.granted && answer.amountUsd && run.budgetUsd !== null)
50
+ run.budgetUsd += answer.amountUsd; // an unlimited run stays unlimited
51
+ recordSpan("fences.approval", performance.now() - started, {
52
+ "fences.approval.id": approvalId, "fences.approval.reason": reason, "fences.approval.amount_usd": amountUsd,
53
+ "fences.approval.granted": answer.granted, "fences.approval.by": answer.by, "fences.approval.note": answer.note,
54
+ "fences.approval.granted_usd": answer.amountUsd,
55
+ });
56
+ return answer;
57
+ }
package/dist/client.d.ts CHANGED
@@ -1,10 +1,28 @@
1
1
  export type Reply = Record<string, any>;
2
+ export declare const FAILURES_TO_OPEN = 3;
3
+ /**
4
+ * A circuit breaker: after FAILURES_TO_OPEN failed calls in a row (no connection, a timeout, a 5xx,
5
+ * or a 429 on anything but the heartbeat), skip calls for about 30 seconds, then let one through to test the server. While it's open
6
+ * a call fails at once instead of after the timeout: checkpoints are enforced locally and guarded
7
+ * tools are blocked, without adding seconds to every step.
8
+ */
9
+ export declare class Breaker {
10
+ failures: number;
11
+ openUntil: number;
12
+ private probing;
13
+ allow(): boolean;
14
+ record(ok: boolean): void;
15
+ }
2
16
  export declare class FencesClient {
3
17
  readonly endpoint: string;
4
18
  private readonly timeoutMs;
5
19
  private readonly headers;
20
+ readonly breaker: Breaker;
21
+ /** The server echoed a step_id: it counts a repeated step once, so retrying a checkpoint is safe. */
22
+ dedupesSteps: boolean;
6
23
  constructor(apiKey: string, endpoint: string, timeoutMs?: number);
7
24
  startRun(body: Record<string, unknown>): Promise<Reply>;
25
+ /** body.step_id, if set, lets the server count the step once however often it's sent. */
8
26
  checkpoint(runId: string, body: Record<string, unknown>): Promise<Reply>;
9
27
  logDecision(runId: string, body: Record<string, unknown>): Promise<Reply>;
10
28
  endRun(runId: string, body: Record<string, unknown>): Promise<Reply>;
@@ -15,8 +33,12 @@ export declare class FencesClient {
15
33
  guard(runId: string, body: Record<string, unknown>): Promise<Reply>;
16
34
  requestApproval(runId: string, body: Record<string, unknown>): Promise<Reply>;
17
35
  /**
18
- * optional: an endpoint older servers may not have. A missing route returns
19
- * { unsupported: true } and nothing throws, not even a rejected key.
36
+ * optional: an endpoint older servers may not have. A missing route returns { unsupported: true },
37
+ * and any other error { ok: false, status }.
38
+ * start: a rejected key throws FencesAuthError, before any agent code runs. Later in a run it
39
+ * returns { network_error, auth_rejected: true }: throwing there would replace the agent's own result or error.
40
+ * retry: a call that's safe to repeat goes once more after a connection error (not after a timeout,
41
+ * which would double the wait).
20
42
  */
21
- post(path: string, payload: unknown, optional?: boolean): Promise<Reply>;
43
+ post(path: string, payload: unknown, optional?: boolean, start?: boolean, retry?: boolean): Promise<Reply>;
22
44
  }
package/dist/client.js CHANGED
@@ -1,10 +1,55 @@
1
- /** HTTP calls to a Fences server, as specified in PROTOCOL.md. Uses the fetch built into Node 18+. */
1
+ /**
2
+ * HTTP calls to a Fences server, as specified in PROTOCOL.md, with the fetch built into Node 18+.
3
+ * Nothing throws except a rejected key at run start.
4
+ */
2
5
  import { FencesAuthError } from "./errors.js";
3
6
  import { VERSION } from "./version.js";
7
+ export const FAILURES_TO_OPEN = 3; // failed calls in a row before the breaker opens
8
+ const OPEN_MS = 30_000; // how long calls are skipped once it's open (with jitter), before one goes through to test
9
+ /**
10
+ * A circuit breaker: after FAILURES_TO_OPEN failed calls in a row (no connection, a timeout, a 5xx,
11
+ * or a 429 on anything but the heartbeat), skip calls for about 30 seconds, then let one through to test the server. While it's open
12
+ * a call fails at once instead of after the timeout: checkpoints are enforced locally and guarded
13
+ * tools are blocked, without adding seconds to every step.
14
+ */
15
+ export class Breaker {
16
+ failures = 0;
17
+ openUntil = 0;
18
+ probing = false;
19
+ allow() {
20
+ if (this.failures < FAILURES_TO_OPEN)
21
+ return true;
22
+ if (this.probing || Date.now() < this.openUntil)
23
+ return false;
24
+ this.probing = true; // one test call at a time
25
+ return true;
26
+ }
27
+ record(ok) {
28
+ const wasOpen = this.failures >= FAILURES_TO_OPEN;
29
+ this.probing = false;
30
+ if (ok) {
31
+ this.failures = 0;
32
+ if (wasOpen)
33
+ console.warn("agentfences: Fences is reachable again");
34
+ return;
35
+ }
36
+ this.failures += 1;
37
+ if (this.failures < FAILURES_TO_OPEN)
38
+ return;
39
+ this.openUntil = Date.now() + OPEN_MS * (0.75 + Math.random() / 2); // jitter: SDKs cut off together don't all come back at once
40
+ if (!wasOpen) {
41
+ console.warn(`agentfences: Fences failed ${this.failures} calls in a row; skipping calls for about ${OPEN_MS / 1000}s ` +
42
+ "(limits enforced locally, guarded tools blocked)");
43
+ }
44
+ }
45
+ }
4
46
  export class FencesClient {
5
47
  endpoint;
6
48
  timeoutMs;
7
49
  headers;
50
+ breaker = new Breaker();
51
+ /** The server echoed a step_id: it counts a repeated step once, so retrying a checkpoint is safe. */
52
+ dedupesSteps = false;
8
53
  constructor(apiKey, endpoint, timeoutMs = 3000) {
9
54
  this.endpoint = endpoint;
10
55
  this.timeoutMs = timeoutMs;
@@ -12,8 +57,14 @@ export class FencesClient {
12
57
  // The server uses the SDK version to warn about old SDKs and to skip commands they don't understand
13
58
  this.headers = { "Content-Type": "application/json", "X-API-Key": apiKey, "X-Fences-SDK": `js/${VERSION}` };
14
59
  }
15
- startRun(body) { return this.post("/api/runs/start", body); }
16
- checkpoint(runId, body) { return this.post(`/api/runs/${runId}/checkpoint`, body); }
60
+ startRun(body) { return this.post("/api/runs/start", body, false, true); }
61
+ /** body.step_id, if set, lets the server count the step once however often it's sent. */
62
+ async checkpoint(runId, body) {
63
+ const resp = await this.post(`/api/runs/${runId}/checkpoint`, body, false, false, !!body.step_id && this.dedupesSteps);
64
+ if (body.step_id && resp.step_id === body.step_id)
65
+ this.dedupesSteps = true;
66
+ return resp;
67
+ }
17
68
  logDecision(runId, body) { return this.post(`/api/runs/${runId}/decisions`, body); }
18
69
  endRun(runId, body) { return this.post(`/api/runs/${runId}/end`, body); }
19
70
  logSpans(runId, spans) { return this.post(`/api/runs/${runId}/spans`, { spans }, true); }
@@ -22,28 +73,54 @@ export class FencesClient {
22
73
  return this.post("/api/heartbeat", acks.length ? { runs, acks } : { runs }, true);
23
74
  }
24
75
  /** Check a guarded tool call before it runs ({ phase: "check" }), or settle it after ({ phase: "settle" }). */
25
- guard(runId, body) { return this.post(`/api/runs/${runId}/guard`, body, true); }
76
+ guard(runId, body) { return this.post(`/api/runs/${runId}/guard`, body, true, false, true); } // guard_id: safe to repeat
26
77
  requestApproval(runId, body) { return this.post(`/api/runs/${runId}/approvals`, body, true); }
27
78
  /**
28
- * optional: an endpoint older servers may not have. A missing route returns
29
- * { unsupported: true } and nothing throws, not even a rejected key.
79
+ * optional: an endpoint older servers may not have. A missing route returns { unsupported: true },
80
+ * and any other error { ok: false, status }.
81
+ * start: a rejected key throws FencesAuthError, before any agent code runs. Later in a run it
82
+ * returns { network_error, auth_rejected: true }: throwing there would replace the agent's own result or error.
83
+ * retry: a call that's safe to repeat goes once more after a connection error (not after a timeout,
84
+ * which would double the wait).
30
85
  */
31
- async post(path, payload, optional = false) {
86
+ async post(path, payload, optional = false, start = false, retry = false) {
87
+ if (!this.breaker.allow())
88
+ return { network_error: "Fences unreachable: calls paused after repeated failures" };
89
+ let json;
90
+ try {
91
+ json = JSON.stringify(payload);
92
+ }
93
+ catch (e) { // a payload JSON can't encode: not the server's fault
94
+ this.breaker.record(true);
95
+ return { network_error: String(e) };
96
+ }
97
+ const send = () => fetch(this.endpoint + path, {
98
+ method: "POST", headers: this.headers, body: json, signal: AbortSignal.timeout(this.timeoutMs),
99
+ });
32
100
  let resp;
33
101
  try {
34
- resp = await fetch(this.endpoint + path, {
35
- method: "POST", headers: this.headers, body: JSON.stringify(payload), signal: AbortSignal.timeout(this.timeoutMs),
36
- });
102
+ try {
103
+ resp = await send();
104
+ }
105
+ catch (e) {
106
+ if (!retry || e?.name === "TimeoutError")
107
+ throw e;
108
+ await new Promise((ok) => setTimeout(ok, 100 + Math.random() * 200)); // a blip: once more, after a moment
109
+ resp = await send();
110
+ }
37
111
  }
38
112
  catch (e) {
113
+ this.breaker.record(false);
39
114
  return { network_error: String(e) };
40
115
  }
116
+ // A 429 on the heartbeat is its own budget, not an outage: it mustn't cut off checkpoints
117
+ this.breaker.record(resp.status < 500 && (resp.status !== 429 || path === "/api/heartbeat"));
41
118
  const text = await resp.text().catch(() => "");
42
119
  let body = {}, isJson = false;
43
120
  try {
44
121
  const parsed = JSON.parse(text);
45
- if (parsed && typeof parsed === "object")
46
- [body, isJson] = [parsed, true];
122
+ if (parsed && typeof parsed === "object" && !Array.isArray(parsed))
123
+ [body, isJson] = [parsed, true]; // a list isn't a Fences reply
47
124
  }
48
125
  catch { /* not JSON: the status code still decides */ }
49
126
  const detail = typeof body.detail === "string" ? body.detail : "";
@@ -53,8 +130,11 @@ export class FencesClient {
53
130
  }
54
131
  return resp.ok ? body : { ok: false, status: resp.status };
55
132
  }
56
- if (resp.status === 401 || resp.status === 403)
57
- throw new FencesAuthError(`Fences API key rejected: ${text}`);
133
+ if (resp.status === 401 || resp.status === 403) {
134
+ if (start)
135
+ throw new FencesAuthError(`Fences API key rejected: ${detail || text.slice(0, 200)}`);
136
+ return { network_error: `API key rejected (HTTP ${resp.status})`, auth_rejected: true };
137
+ }
58
138
  if (resp.status === 423)
59
139
  return { ok: false, quarantined: true, detail }; // the agent is quarantined: the run must not start
60
140
  if (resp.status === 409)
package/dist/control.d.ts CHANGED
@@ -1,15 +1,12 @@
1
+ /**
2
+ * Live control from the Fences dashboard: a heartbeat that reports live runs and brings back
3
+ * commands (stop, pause, resume, quarantine, limits, approval answers) and acknowledges them,
4
+ * containment hooks, pausing at checkpoints, waiting at a limit, and cancelling a run stuck after
5
+ * a stop. Cloud mode only; a server without these endpoints gets one warning and runs carry
6
+ * on as before. Same behaviour as the Python SDK (PROTOCOL.md, "Live control").
7
+ */
1
8
  import type { FencesClient, Reply } from "./client.js";
2
- import type { Run } from "./core.js";
3
- export interface Approval {
4
- granted: boolean;
5
- /** Who answered, as the dashboard reports it. */
6
- by?: string | null;
7
- /** Their note, or why no one could answer. */
8
- note?: string | null;
9
- /** The amount approved, if one was asked for. */
10
- amountUsd?: number | null;
11
- approvalId?: string | null;
12
- }
9
+ import type { Run } from "./run.js";
13
10
  /** What onStop and onQuarantine receive. */
14
11
  export interface ControlEvent {
15
12
  type: "stop" | "quarantine";
@@ -36,13 +33,15 @@ export interface Control {
36
33
  quarantined: boolean;
37
34
  /** While waiting at a limit: "breach" or "budget". */
38
35
  held: "breach" | "budget" | null;
39
- /** approval id -> the answer, or null while waiting */
40
- approvals: Map<string, Approval | null>;
36
+ /** approval id -> the dashboard's answer (an approval command), or null while waiting */
37
+ approvals: Map<string, Reply | null>;
41
38
  awaiting: number;
42
39
  /** command ids already acknowledged */
43
40
  acked: Set<string>;
44
41
  }
45
42
  export declare const newControl: () => Control;
43
+ /** @internal */
44
+ export declare const _client: () => FencesClient | null;
46
45
  export declare let pauseTimeoutMs: number;
47
46
  export declare const supported: {
48
47
  heartbeat: boolean;
@@ -60,6 +59,19 @@ export declare function configure(c: FencesClient | null, o: ControlOptions): vo
60
59
  export declare function register(run: Run): void;
61
60
  export declare function unregister(run: Run): void;
62
61
  export declare function unsupported(kind: keyof typeof supported, what: string, consequence: string): void;
62
+ export declare const LIMITS: {
63
+ readonly budget_usd: "budgetUsd";
64
+ readonly max_iterations: "maxIterations";
65
+ readonly max_duration_ms: "maxDurationMs";
66
+ readonly max_tokens: "maxTokens";
67
+ };
68
+ export declare const TOTALS: {
69
+ readonly spent_usd: "costUsd";
70
+ readonly iterations: "iterations";
71
+ readonly tokens_used: "tokensUsed";
72
+ };
73
+ /** Set the run's fields from a server reply: numbers only, so a null or odd value can't break local enforcement. nullBudget: a null budget_usd means unlimited spend. */
74
+ export declare function adopt(run: Run, reply: Reply, fields: Record<string, string>, nullBudget?: boolean): void;
63
75
  /** Apply the commands addressed to this run (from a heartbeat or a checkpoint reply), and acknowledge them. Each is safe to apply twice. */
64
76
  export declare function apply(run: Run, commands: Reply[] | undefined): void;
65
77
  /**
@@ -68,6 +80,8 @@ export declare function apply(run: Run, commands: Reply[] | undefined): void;
68
80
  * its containment, and says so.
69
81
  */
70
82
  export declare function drain(timeoutMs?: number): Promise<void>;
83
+ /** @internal Send a heartbeat after delayMs (0: now), replacing the one scheduled. */
84
+ export declare function schedule(delayMs: number): void;
71
85
  /** Breaches a person can lift by raising a limit, so onBreach "hold" waits at them. */
72
86
  export declare const HOLDABLE: Set<string>;
73
87
  export declare const BUDGET_BREACHES: Set<string>;
@@ -79,17 +93,3 @@ export declare function startHold(run: Run, kind: "breach" | "budget"): void;
79
93
  export declare function endHold(run: Run): "released" | "stopped" | "timeout";
80
94
  /** Hold the agent at its checkpoint while its run is paused, up to pauseTimeoutS. */
81
95
  export declare function waitIfPaused(run: Run): Promise<void>;
82
- /**
83
- * Ask a person, through the Fences dashboard, to approve something, and wait for the answer:
84
- *
85
- * const approval = await requestApproval("Refund $240 to order 1182?", { amountUsd: 240 });
86
- * if (!approval.granted) return `I didn't issue the refund: ${approval.note}`;
87
- *
88
- * If an amount is approved, the run's budget goes up by that much. Returns denied right away in
89
- * local mode, outside a run, or when the server can't take requests, and denied after timeoutS
90
- * (default 600) with no answer: it never hangs and never throws.
91
- */
92
- export declare function requestApproval(reason: string, options?: {
93
- amountUsd?: number;
94
- timeoutS?: number;
95
- }): Promise<Approval>;
package/dist/control.js CHANGED
@@ -1,21 +1,16 @@
1
- /**
2
- * Live control from the Fences dashboard: a heartbeat that reports live runs and brings back
3
- * commands (stop, pause, resume, quarantine, limits, approval answers) and acknowledges them,
4
- * containment hooks, pausing at checkpoints, cancelling a run stuck after a stop, and approval
5
- * requests. Cloud mode only; a server without these endpoints gets one warning and runs carry
6
- * on as before. Same behaviour as the Python SDK (PROTOCOL.md, "Live control").
7
- */
8
- import { randomUUID } from "node:crypto";
9
1
  /** Heartbeat statuses whose acks go again with the next heartbeat: rate limited, or a server error. */
10
2
  const retryable = (status) => status === undefined || status === 429 || status >= 500;
11
3
  const FAST_MS = 2000; // heartbeat interval while a run is paused, waiting for an approval, or stopping
12
4
  const HOOK_TIMEOUT_MS = 30_000; // a hook still running after this is reported as timed out
5
+ const MAX_BACKOFF_MS = 60_000; // longest wait between heartbeats after failures (jittered)
13
6
  export const newControl = () => ({
14
7
  stopped: false, stoppedAt: 0, stopSeen: false, cancelled: false, onCancel: null, paused: false, pauseExpired: false,
15
8
  quarantined: false, held: null, approvals: new Map(), awaiting: 0, acked: new Set(),
16
9
  });
17
10
  const live = new Map(); // run id -> runs in progress
18
11
  let client = null;
12
+ /** @internal */
13
+ export const _client = () => client;
19
14
  let intervalMs = 15_000;
20
15
  export let pauseTimeoutMs = 3_600_000;
21
16
  let stopGraceMs = 30_000;
@@ -23,6 +18,7 @@ const hooks = { stop: null, quarantine: null };
23
18
  export const supported = { heartbeat: true, approvals: true, guard: true };
24
19
  let timer = null;
25
20
  let beating = false;
21
+ const backoff = { failed: 0, nextTry: 0 }; // heartbeats failed in a row, and when the next may go
26
22
  let acks = []; // acknowledgements waiting for the next heartbeat
27
23
  const hooked = new Set(); // quarantine ids whose hook has run in this process
28
24
  const runningHooks = new Set();
@@ -34,6 +30,7 @@ export function configure(c, o) {
34
30
  hooks.stop = o.onStop ?? null;
35
31
  hooks.quarantine = o.onQuarantine ?? null;
36
32
  supported.heartbeat = supported.approvals = supported.guard = true;
33
+ Object.assign(backoff, { failed: 0, nextTry: 0 });
37
34
  schedule(0); // a new client or interval takes effect now, not after the old wait
38
35
  }
39
36
  export function register(run) {
@@ -51,7 +48,17 @@ export function unsupported(kind, what, consequence) {
51
48
  console.warn(`agentfences: this Fences server doesn't support ${what} yet, so ${consequence}`);
52
49
  }
53
50
  // ── Commands ──────────────────────────────────────────────────────────────────
54
- const LIMITS = { budget_usd: "budgetUsd", max_iterations: "maxIterations", max_duration_ms: "maxDurationMs", max_tokens: "maxTokens" };
51
+ export const LIMITS = { budget_usd: "budgetUsd", max_iterations: "maxIterations", max_duration_ms: "maxDurationMs", max_tokens: "maxTokens" };
52
+ export const TOTALS = { spent_usd: "costUsd", iterations: "iterations", tokens_used: "tokensUsed" };
53
+ /** Set the run's fields from a server reply: numbers only, so a null or odd value can't break local enforcement. nullBudget: a null budget_usd means unlimited spend. */
54
+ export function adopt(run, reply, fields, nullBudget = false) {
55
+ for (const [wire, field] of Object.entries(fields)) {
56
+ const v = reply[wire];
57
+ if ((typeof v === "number" && !Number.isNaN(v)) || (nullBudget && wire === "budget_usd" && v === null)) {
58
+ run[field] = v; // eslint-disable-line @typescript-eslint/no-explicit-any
59
+ }
60
+ }
61
+ }
55
62
  /** Apply the commands addressed to this run (from a heartbeat or a checkpoint reply), and acknowledge them. Each is safe to apply twice. */
56
63
  export function apply(run, commands) {
57
64
  // Limits first: a resume in the same reply may release a call held for budget, which re-checks against them at once
@@ -69,8 +76,10 @@ export function apply(run, commands) {
69
76
  ctl.paused = ctl.quarantined = false;
70
77
  }
71
78
  else if (c.type === "pause" || c.type === "quarantine") { // a quarantined agent's runs hold at their next checkpoint
72
- ctl.paused = true;
73
- schedule(0);
79
+ if (!ctl.paused) { // only a change wakes the heartbeat: a quarantine is re-sent on every reply while it lasts
80
+ ctl.paused = true;
81
+ schedule(0);
82
+ }
74
83
  if (c.type === "quarantine") {
75
84
  ctl.quarantined = true; // guarded tools are blocked here too, even if the server can't be reached
76
85
  hook("quarantine", run, c);
@@ -80,15 +89,11 @@ export function apply(run, commands) {
80
89
  ctl.paused = ctl.quarantined = false; // releasing a quarantine resumes its runs
81
90
  }
82
91
  else if (c.type === "limits") {
83
- for (const [wire, field] of Object.entries(LIMITS)) {
84
- if (c[wire] !== undefined && c[wire] !== null)
85
- run[field] = c[wire]; // eslint-disable-line @typescript-eslint/no-explicit-any
86
- }
92
+ adopt(run, c, LIMITS);
87
93
  }
88
94
  else if (c.type === "approval") {
89
- if (ctl.approvals.has(c.approval_id)) {
90
- ctl.approvals.set(c.approval_id, { granted: !!c.granted, by: c.by ?? null, note: c.note ?? null, amountUsd: c.amount_usd ?? null });
91
- }
95
+ if (ctl.approvals.has(c.approval_id))
96
+ ctl.approvals.set(c.approval_id, c); // approvals.ts turns it into the answer
92
97
  }
93
98
  else {
94
99
  continue; // unknown: ignored, and not acknowledged
@@ -174,7 +179,8 @@ if (typeof process?.on === "function") { // edge runtimes have no process events
174
179
  const unseenStop = (r) => r.control.stopped && !r.control.stopSeen && !r.control.cancelled && r.control.onCancel !== null;
175
180
  const busy = () => [...live.values()].some((r) => r.control.paused || r.control.awaiting > 0 || unseenStop(r));
176
181
  const nextDelay = () => (busy() ? Math.min(FAST_MS, intervalMs, stopGraceMs ?? FAST_MS) : intervalMs); // faster while busy, never slower
177
- function schedule(delayMs) {
182
+ /** @internal Send a heartbeat after delayMs (0: now), replacing the one scheduled. */
183
+ export function schedule(delayMs) {
178
184
  if (timer)
179
185
  clearTimeout(timer);
180
186
  timer = null;
@@ -183,6 +189,13 @@ function schedule(delayMs) {
183
189
  timer = setTimeout(beat, delayMs);
184
190
  timer.unref(); // the heartbeat never keeps the process alive on its own
185
191
  }
192
+ /** After a failed heartbeat: wait longer before the next, doubling from the shortest interval in use, with jitter,
193
+ * so SDKs coming back from the same outage don't all beat at once. */
194
+ function backOff() {
195
+ backoff.failed += 1;
196
+ const wait = Math.min(Math.min(FAST_MS, intervalMs) * 2 ** (backoff.failed - 1), MAX_BACKOFF_MS);
197
+ backoff.nextTry = Date.now() + wait * (0.5 + Math.random() / 2);
198
+ }
186
199
  /** Halt governed calls that haven't reached a checkpoint within stopGraceS of a stop. */
187
200
  function cancelOverdue() {
188
201
  if (stopGraceMs === null)
@@ -196,8 +209,8 @@ function cancelOverdue() {
196
209
  }
197
210
  async function beat() {
198
211
  timer = null;
199
- cancelOverdue();
200
- if (beating || !client || !(live.size || acks.length))
212
+ cancelOverdue(); // local, so it keeps its schedule while heartbeats back off
213
+ if (beating || !client || !(live.size || acks.length) || Date.now() < backoff.nextTry)
201
214
  return schedule(nextDelay());
202
215
  beating = true;
203
216
  const runs = [...live.values()], batch = acks;
@@ -207,20 +220,24 @@ async function beat() {
207
220
  tokens_used: r.tokensUsed, paused: r.control.paused, stopped: r.control.stopped,
208
221
  ...(r.control.held ? { held: r.control.held } : {}) })), batch);
209
222
  if (resp.network_error || (resp.ok === false && !resp.unsupported)) {
210
- if (resp.network_error || retryable(resp.status))
223
+ if (resp.network_error || retryable(resp.status)) {
211
224
  acks = [...batch, ...acks]; // at least once; any other 4xx won't get better
225
+ backOff();
226
+ }
212
227
  }
213
228
  else if (resp.unsupported) {
214
229
  unsupported("heartbeat", "the heartbeat", "a stop from the dashboard applies at the next checkpoint and pause isn't available");
215
230
  return;
216
231
  }
217
232
  else {
233
+ Object.assign(backoff, { failed: 0, nextTry: 0 });
218
234
  for (const run of runs)
219
235
  apply(run, resp.commands?.filter((c) => c.run_id === run.runId));
220
236
  }
221
237
  }
222
238
  catch {
223
239
  acks = [...batch, ...acks]; // a failed heartbeat is retried next time
240
+ backOff();
224
241
  }
225
242
  finally {
226
243
  beating = false;
@@ -266,53 +283,3 @@ export async function waitIfPaused(run) {
266
283
  await new Promise((ok) => setTimeout(ok, 200));
267
284
  }
268
285
  }
269
- // ── Approvals ─────────────────────────────────────────────────────────────────
270
- /**
271
- * Ask a person, through the Fences dashboard, to approve something, and wait for the answer:
272
- *
273
- * const approval = await requestApproval("Refund $240 to order 1182?", { amountUsd: 240 });
274
- * if (!approval.granted) return `I didn't issue the refund: ${approval.note}`;
275
- *
276
- * If an amount is approved, the run's budget goes up by that much. Returns denied right away in
277
- * local mode, outside a run, or when the server can't take requests, and denied after timeoutS
278
- * (default 600) with no answer: it never hangs and never throws.
279
- */
280
- export async function requestApproval(reason, options = {}) {
281
- const { getActiveRun, recordSpan } = await import("./core.js");
282
- const run = getActiveRun();
283
- const timeoutS = options.timeoutS ?? 600, amountUsd = options.amountUsd ?? null;
284
- if (!run)
285
- return { granted: false, note: "requestApproval() was called outside a governed run" };
286
- if (!client)
287
- return { granted: false, note: "Local mode: there is no one to approve this request" };
288
- if (!supported.approvals)
289
- return { granted: false, note: "This Fences server doesn't support approvals" };
290
- const approvalId = randomUUID(), started = performance.now();
291
- // timeout_s lets the server expire the request when we stop waiting
292
- const resp = await client.requestApproval(run.runId, { approval_id: approvalId, reason, amount_usd: amountUsd, timeout_s: timeoutS });
293
- if (resp.unsupported) {
294
- unsupported("approvals", "approval requests", "they are denied");
295
- return { granted: false, note: "This Fences server doesn't support approvals" };
296
- }
297
- if (resp.network_error)
298
- return { granted: false, note: "Couldn't reach Fences to ask for approval" };
299
- run.control.approvals.set(approvalId, null);
300
- run.control.awaiting += 1;
301
- schedule(0); // poll fast while someone decides
302
- const deadline = Date.now() + timeoutS * 1000;
303
- while (run.control.approvals.get(approvalId) === null && Date.now() < deadline) {
304
- await new Promise((ok) => setTimeout(ok, 100));
305
- }
306
- run.control.awaiting -= 1;
307
- const answer = run.control.approvals.get(approvalId) ?? { granted: false, note: "No answer before the timeout" };
308
- answer.approvalId = approvalId;
309
- run.control.approvals.delete(approvalId);
310
- if (answer.granted && answer.amountUsd && run.budgetUsd !== null)
311
- run.budgetUsd += answer.amountUsd; // an unlimited run stays unlimited
312
- recordSpan("fences.approval", performance.now() - started, {
313
- "fences.approval.id": approvalId, "fences.approval.reason": reason, "fences.approval.amount_usd": amountUsd,
314
- "fences.approval.granted": answer.granted, "fences.approval.by": answer.by, "fences.approval.note": answer.note,
315
- "fences.approval.granted_usd": answer.amountUsd,
316
- });
317
- return answer;
318
- }