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 +21 -4
- package/dist/approvals.d.ts +24 -0
- package/dist/approvals.js +57 -0
- package/dist/client.d.ts +25 -3
- package/dist/client.js +94 -14
- package/dist/control.d.ts +27 -27
- package/dist/control.js +39 -72
- package/dist/core.d.ts +13 -74
- package/dist/core.js +40 -145
- package/dist/index.d.ts +4 -2
- package/dist/index.js +2 -1
- package/dist/instrument.d.ts +12 -0
- package/dist/instrument.js +124 -0
- package/dist/mcp.js +3 -3
- package/dist/outbox.d.ts +23 -0
- package/dist/outbox.js +35 -0
- package/dist/precheck.d.ts +33 -0
- package/dist/precheck.js +136 -0
- package/dist/pricing.d.ts +1 -0
- package/dist/pricing.js +17 -4
- package/dist/results.d.ts +16 -0
- package/dist/results.js +33 -0
- package/dist/run.d.ts +79 -0
- package/dist/run.js +56 -0
- package/dist/spans.d.ts +11 -1
- package/dist/spans.js +16 -2
- package/dist/tools.d.ts +4 -1
- package/dist/tools.js +23 -12
- package/dist/version.d.ts +1 -1
- package/dist/version.js +1 -1
- package/package.json +28 -6
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
|
|
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
|
|
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
|
-
*
|
|
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
|
-
/**
|
|
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
|
-
|
|
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
|
-
*
|
|
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
|
-
|
|
35
|
-
|
|
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
|
-
|
|
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 "./
|
|
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,
|
|
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
|
|
73
|
-
|
|
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
|
-
|
|
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,
|
|
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
|
-
|
|
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
|
-
}
|