agentfences 0.8.0 → 0.9.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +2 -2
- package/dist/client.d.ts +21 -1
- package/dist/client.js +83 -6
- package/dist/control.js +17 -3
- package/dist/core.d.ts +13 -5
- package/dist/core.js +33 -6
- package/dist/mcp.js +1 -1
- package/dist/run.d.ts +2 -0
- package/dist/run.js +2 -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 +16 -8
- package/dist/version.d.ts +1 -1
- package/dist/version.js +1 -1
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -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` 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.
|
|
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. `endpoint` must be an `https://` URL unless the server is on the same machine; `allowHttp: true` permits plain HTTP for a trusted private network (from 0.9.1). A server reply can raise a budget, but can't make a budget set in code unlimited. 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
|
|
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>;
|
|
@@ -19,6 +37,8 @@ export declare class FencesClient {
|
|
|
19
37
|
* and any other error { ok: false, status }.
|
|
20
38
|
* start: a rejected key throws FencesAuthError, before any agent code runs. Later in a run it
|
|
21
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).
|
|
22
42
|
*/
|
|
23
|
-
post(path: string, payload: unknown, optional?: boolean, start?: boolean): Promise<Reply>;
|
|
43
|
+
post(path: string, payload: unknown, optional?: boolean, start?: boolean, retry?: boolean): Promise<Reply>;
|
|
24
44
|
}
|
package/dist/client.js
CHANGED
|
@@ -4,10 +4,52 @@
|
|
|
4
4
|
*/
|
|
5
5
|
import { FencesAuthError } from "./errors.js";
|
|
6
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
|
+
}
|
|
7
46
|
export class FencesClient {
|
|
8
47
|
endpoint;
|
|
9
48
|
timeoutMs;
|
|
10
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;
|
|
11
53
|
constructor(apiKey, endpoint, timeoutMs = 3000) {
|
|
12
54
|
this.endpoint = endpoint;
|
|
13
55
|
this.timeoutMs = timeoutMs;
|
|
@@ -16,7 +58,13 @@ export class FencesClient {
|
|
|
16
58
|
this.headers = { "Content-Type": "application/json", "X-API-Key": apiKey, "X-Fences-SDK": `js/${VERSION}` };
|
|
17
59
|
}
|
|
18
60
|
startRun(body) { return this.post("/api/runs/start", body, false, true); }
|
|
19
|
-
|
|
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
|
+
}
|
|
20
68
|
logDecision(runId, body) { return this.post(`/api/runs/${runId}/decisions`, body); }
|
|
21
69
|
endRun(runId, body) { return this.post(`/api/runs/${runId}/end`, body); }
|
|
22
70
|
logSpans(runId, spans) { return this.post(`/api/runs/${runId}/spans`, { spans }, true); }
|
|
@@ -25,24 +73,53 @@ export class FencesClient {
|
|
|
25
73
|
return this.post("/api/heartbeat", acks.length ? { runs, acks } : { runs }, true);
|
|
26
74
|
}
|
|
27
75
|
/** Check a guarded tool call before it runs ({ phase: "check" }), or settle it after ({ phase: "settle" }). */
|
|
28
|
-
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
|
|
29
77
|
requestApproval(runId, body) { return this.post(`/api/runs/${runId}/approvals`, body, true); }
|
|
30
78
|
/**
|
|
31
79
|
* optional: an endpoint older servers may not have. A missing route returns { unsupported: true },
|
|
32
80
|
* and any other error { ok: false, status }.
|
|
33
81
|
* start: a rejected key throws FencesAuthError, before any agent code runs. Later in a run it
|
|
34
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).
|
|
35
85
|
*/
|
|
36
|
-
async post(path, payload, optional = false, start = 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
|
+
// Redirects aren't followed: one would carry the API key (and the request) to wherever it points
|
|
98
|
+
const send = () => fetch(this.endpoint + path, {
|
|
99
|
+
method: "POST", headers: this.headers, body: json, signal: AbortSignal.timeout(this.timeoutMs), redirect: "manual",
|
|
100
|
+
});
|
|
37
101
|
let resp;
|
|
38
102
|
try {
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
}
|
|
103
|
+
try {
|
|
104
|
+
resp = await send();
|
|
105
|
+
}
|
|
106
|
+
catch (e) {
|
|
107
|
+
if (!retry || e?.name === "TimeoutError")
|
|
108
|
+
throw e;
|
|
109
|
+
await new Promise((ok) => setTimeout(ok, 100 + Math.random() * 200)); // a blip: once more, after a moment
|
|
110
|
+
resp = await send();
|
|
111
|
+
}
|
|
42
112
|
}
|
|
43
113
|
catch (e) {
|
|
114
|
+
this.breaker.record(false);
|
|
44
115
|
return { network_error: String(e) };
|
|
45
116
|
}
|
|
117
|
+
// A 429 on the heartbeat is its own budget, not an outage: it mustn't cut off checkpoints
|
|
118
|
+
this.breaker.record(resp.status < 500 && (resp.status !== 429 || path === "/api/heartbeat"));
|
|
119
|
+
if (resp.type === "opaqueredirect" || (resp.status >= 300 && resp.status < 400)) { // not followed: treated as no answer
|
|
120
|
+
return { network_error: `Fences answered with a redirect (HTTP ${resp.status} to ${JSON.stringify(resp.headers.get("location") ?? "?")}), ` +
|
|
121
|
+
"which isn't followed; check the endpoint" };
|
|
122
|
+
}
|
|
46
123
|
const text = await resp.text().catch(() => "");
|
|
47
124
|
let body = {}, isJson = false;
|
|
48
125
|
try {
|
package/dist/control.js
CHANGED
|
@@ -2,6 +2,7 @@
|
|
|
2
2
|
const retryable = (status) => status === undefined || status === 429 || status >= 500;
|
|
3
3
|
const FAST_MS = 2000; // heartbeat interval while a run is paused, waiting for an approval, or stopping
|
|
4
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)
|
|
5
6
|
export const newControl = () => ({
|
|
6
7
|
stopped: false, stoppedAt: 0, stopSeen: false, cancelled: false, onCancel: null, paused: false, pauseExpired: false,
|
|
7
8
|
quarantined: false, held: null, approvals: new Map(), awaiting: 0, acked: new Set(),
|
|
@@ -17,6 +18,7 @@ const hooks = { stop: null, quarantine: null };
|
|
|
17
18
|
export const supported = { heartbeat: true, approvals: true, guard: true };
|
|
18
19
|
let timer = null;
|
|
19
20
|
let beating = false;
|
|
21
|
+
const backoff = { failed: 0, nextTry: 0 }; // heartbeats failed in a row, and when the next may go
|
|
20
22
|
let acks = []; // acknowledgements waiting for the next heartbeat
|
|
21
23
|
const hooked = new Set(); // quarantine ids whose hook has run in this process
|
|
22
24
|
const runningHooks = new Set();
|
|
@@ -28,6 +30,7 @@ export function configure(c, o) {
|
|
|
28
30
|
hooks.stop = o.onStop ?? null;
|
|
29
31
|
hooks.quarantine = o.onQuarantine ?? null;
|
|
30
32
|
supported.heartbeat = supported.approvals = supported.guard = true;
|
|
33
|
+
Object.assign(backoff, { failed: 0, nextTry: 0 });
|
|
31
34
|
schedule(0); // a new client or interval takes effect now, not after the old wait
|
|
32
35
|
}
|
|
33
36
|
export function register(run) {
|
|
@@ -186,6 +189,13 @@ export function schedule(delayMs) {
|
|
|
186
189
|
timer = setTimeout(beat, delayMs);
|
|
187
190
|
timer.unref(); // the heartbeat never keeps the process alive on its own
|
|
188
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
|
+
}
|
|
189
199
|
/** Halt governed calls that haven't reached a checkpoint within stopGraceS of a stop. */
|
|
190
200
|
function cancelOverdue() {
|
|
191
201
|
if (stopGraceMs === null)
|
|
@@ -199,8 +209,8 @@ function cancelOverdue() {
|
|
|
199
209
|
}
|
|
200
210
|
async function beat() {
|
|
201
211
|
timer = null;
|
|
202
|
-
cancelOverdue();
|
|
203
|
-
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)
|
|
204
214
|
return schedule(nextDelay());
|
|
205
215
|
beating = true;
|
|
206
216
|
const runs = [...live.values()], batch = acks;
|
|
@@ -210,20 +220,24 @@ async function beat() {
|
|
|
210
220
|
tokens_used: r.tokensUsed, paused: r.control.paused, stopped: r.control.stopped,
|
|
211
221
|
...(r.control.held ? { held: r.control.held } : {}) })), batch);
|
|
212
222
|
if (resp.network_error || (resp.ok === false && !resp.unsupported)) {
|
|
213
|
-
if (resp.network_error || retryable(resp.status))
|
|
223
|
+
if (resp.network_error || retryable(resp.status)) {
|
|
214
224
|
acks = [...batch, ...acks]; // at least once; any other 4xx won't get better
|
|
225
|
+
backOff();
|
|
226
|
+
}
|
|
215
227
|
}
|
|
216
228
|
else if (resp.unsupported) {
|
|
217
229
|
unsupported("heartbeat", "the heartbeat", "a stop from the dashboard applies at the next checkpoint and pause isn't available");
|
|
218
230
|
return;
|
|
219
231
|
}
|
|
220
232
|
else {
|
|
233
|
+
Object.assign(backoff, { failed: 0, nextTry: 0 });
|
|
221
234
|
for (const run of runs)
|
|
222
235
|
apply(run, resp.commands?.filter((c) => c.run_id === run.runId));
|
|
223
236
|
}
|
|
224
237
|
}
|
|
225
238
|
catch {
|
|
226
239
|
acks = [...batch, ...acks]; // a failed heartbeat is retried next time
|
|
240
|
+
backOff();
|
|
227
241
|
}
|
|
228
242
|
finally {
|
|
229
243
|
beating = false;
|
package/dist/core.d.ts
CHANGED
|
@@ -1,12 +1,8 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* init(), governed() and checkpoint(): starting and ending runs, checking every step against
|
|
3
|
-
* its limits (the server decides in cloud mode, the same rules apply here otherwise), and
|
|
4
|
-
* recording decisions and spans.
|
|
5
|
-
*/
|
|
6
1
|
import { FencesClient } from "./client.js";
|
|
7
2
|
import * as control from "./control.js";
|
|
8
3
|
import * as events from "./events.js";
|
|
9
4
|
import { type CustomPrice } from "./pricing.js";
|
|
5
|
+
import { type ToolArguments } from "./spans.js";
|
|
10
6
|
import { type CheckpointResult } from "./results.js";
|
|
11
7
|
import { Run, type Limits } from "./run.js";
|
|
12
8
|
export type { CheckpointResult } from "./results.js";
|
|
@@ -53,6 +49,18 @@ export interface InitOptions {
|
|
|
53
49
|
* wait, and after pauseTimeoutS the breach is returned as usual. Cloud only; override per agent in governed().
|
|
54
50
|
*/
|
|
55
51
|
onBreach?: "hold" | null;
|
|
52
|
+
/**
|
|
53
|
+
* What a recorded tool call carries of its arguments: "names" (the default: `query, limit`),
|
|
54
|
+
* "full" (the arguments as JSON, capped) or "none". A guarded tool's check and an approval request
|
|
55
|
+
* still send the values, which rules and approvers need; the redact hook can change or drop them.
|
|
56
|
+
*/
|
|
57
|
+
toolArguments?: ToolArguments;
|
|
58
|
+
/**
|
|
59
|
+
* endpoint must use https://, except on this machine (localhost, 127.0.0.1, ::1): over plain HTTP anyone
|
|
60
|
+
* on the network path could read the API key and rewrite the server's replies, limits included.
|
|
61
|
+
* true permits it, for a trusted private network.
|
|
62
|
+
*/
|
|
63
|
+
allowHttp?: boolean;
|
|
56
64
|
}
|
|
57
65
|
/** Call once at startup, before any governed function runs. */
|
|
58
66
|
export declare function init(options?: InitOptions): void;
|
package/dist/core.js
CHANGED
|
@@ -3,6 +3,7 @@
|
|
|
3
3
|
* its limits (the server decides in cloud mode, the same rules apply here otherwise), and
|
|
4
4
|
* recording decisions and spans.
|
|
5
5
|
*/
|
|
6
|
+
import { randomUUID } from "node:crypto";
|
|
6
7
|
import { FencesClient } from "./client.js";
|
|
7
8
|
import * as control from "./control.js";
|
|
8
9
|
import { AgentQuarantined, FencesStop } from "./errors.js";
|
|
@@ -10,6 +11,7 @@ import * as events from "./events.js";
|
|
|
10
11
|
import * as outbox from "./outbox.js";
|
|
11
12
|
import * as precheck from "./precheck.js";
|
|
12
13
|
import { costOf, setCustomPrices } from "./pricing.js";
|
|
14
|
+
import { setToolArguments } from "./spans.js";
|
|
13
15
|
import { breachResult, okResult } from "./results.js";
|
|
14
16
|
import { DEFAULTS, MAX_EVENTS_PER_RUN, Run, getActiveRun, store } from "./run.js";
|
|
15
17
|
export { Run, getActiveRun } from "./run.js";
|
|
@@ -26,6 +28,7 @@ function checkOnBreach(value) {
|
|
|
26
28
|
}
|
|
27
29
|
/** Call once at startup, before any governed function runs. */
|
|
28
30
|
export function init(options = {}) {
|
|
31
|
+
setToolArguments(options.toolArguments ?? "names");
|
|
29
32
|
setCustomPrices(options.prices);
|
|
30
33
|
events.configure(options.environment, options.release, options.redact);
|
|
31
34
|
localOnly = !!options.localOnly;
|
|
@@ -38,7 +41,9 @@ export function init(options = {}) {
|
|
|
38
41
|
throw new Error("apiKey is required unless localOnly is set. Use init({ localOnly: true }) for local usage.");
|
|
39
42
|
}
|
|
40
43
|
else {
|
|
41
|
-
|
|
44
|
+
const endpoint = options.endpoint ?? "http://localhost:8000";
|
|
45
|
+
checkEndpoint(endpoint, !!options.allowHttp);
|
|
46
|
+
client = new FencesClient(options.apiKey, endpoint);
|
|
42
47
|
}
|
|
43
48
|
control.configure(client, { heartbeatS: options.heartbeatS ?? 15, pauseTimeoutS: options.pauseTimeoutS ?? 3600,
|
|
44
49
|
stopGraceS: options.stopGraceS === undefined ? 30 : options.stopGraceS, onStop: options.onStop, onQuarantine: options.onQuarantine });
|
|
@@ -50,6 +55,26 @@ export function _setClient(c) {
|
|
|
50
55
|
}
|
|
51
56
|
/** @internal */
|
|
52
57
|
export const _client = () => client;
|
|
58
|
+
function checkEndpoint(endpoint, allowHttp) {
|
|
59
|
+
let url;
|
|
60
|
+
try {
|
|
61
|
+
url = new URL(endpoint);
|
|
62
|
+
}
|
|
63
|
+
catch {
|
|
64
|
+
throw new TypeError(`endpoint must be an https:// URL, not ${JSON.stringify(endpoint)}`);
|
|
65
|
+
}
|
|
66
|
+
if ((url.protocol !== "http:" && url.protocol !== "https:") || url.username || url.password || endpoint.includes("\\")) {
|
|
67
|
+
throw new TypeError(`endpoint must be an https:// URL with no username or backslash, not ${JSON.stringify(endpoint)}`);
|
|
68
|
+
}
|
|
69
|
+
if (url.protocol === "https:")
|
|
70
|
+
return;
|
|
71
|
+
const host = url.hostname; // parsed as fetch will: the host checked is the host connected to
|
|
72
|
+
const local = host === "localhost" || host === "127.0.0.1" || host === "[::1]";
|
|
73
|
+
if (!local && !allowHttp) {
|
|
74
|
+
throw new TypeError(`endpoint ${JSON.stringify(endpoint)} uses plain HTTP: the API key and the server's replies, limits ` +
|
|
75
|
+
"included, would cross the network unencrypted. Use https://, or pass allowHttp: true for a trusted private network.");
|
|
76
|
+
}
|
|
77
|
+
}
|
|
53
78
|
function requireInit() {
|
|
54
79
|
if (!localOnly && !client) {
|
|
55
80
|
throw new Error("Call agentfences.init() before using Fences. For local usage: init({ localOnly: true })");
|
|
@@ -113,6 +138,7 @@ function outcome(run, failed, error) {
|
|
|
113
138
|
}
|
|
114
139
|
async function startRun(agentName, limits) {
|
|
115
140
|
const run = new Run(agentName, limits.budgetUsd ?? null, limits.maxIterations ?? DEFAULTS.maxIterations, limits.maxDurationMs ?? DEFAULTS.maxDurationMs, limits.maxTokens ?? DEFAULTS.maxTokens, events.currentContext());
|
|
141
|
+
run.budgetInCode = limits.budgetUsd != null;
|
|
116
142
|
if (!client) {
|
|
117
143
|
if (run.budgetUsd === null)
|
|
118
144
|
noBudget(agentName, "Set one with governed(fn, { budgetUsd: ... }).");
|
|
@@ -130,7 +156,8 @@ async function startRun(agentName, limits) {
|
|
|
130
156
|
if (resp.quarantined)
|
|
131
157
|
throw new AgentQuarantined(agentName, resp.detail);
|
|
132
158
|
// The server caps limits at the agent's ceilings: enforce the same numbers if it becomes unreachable later
|
|
133
|
-
|
|
159
|
+
// A null budget (unlimited) is taken only for a run whose code set none: a reply can't lift a budget from the code
|
|
160
|
+
control.adopt(run, resp.limits ?? {}, control.LIMITS, limits.budgetUsd == null);
|
|
134
161
|
for (const n of resp.notices ?? []) {
|
|
135
162
|
events.warnOnce(`${agentName}:${n.code}`, `${n.message ?? ""}${n.url ? ` ${n.url}` : ""}`);
|
|
136
163
|
}
|
|
@@ -182,9 +209,9 @@ export async function checkpoint(response, extra = {}) {
|
|
|
182
209
|
precheck.releaseFinished(run);
|
|
183
210
|
run.tokensUsed += stepTokens;
|
|
184
211
|
run.iterations += 1;
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
|
|
212
|
+
// A step_id per step: a retried checkpoint is counted once on servers that support it
|
|
213
|
+
const resp = client ? await client.checkpoint(run.runId, { cost_delta_usd: stepCost, tokens_used: stepTokens,
|
|
214
|
+
iterations: run.iterations, duration_ms: run.durationMs, step_id: randomUUID().replaceAll("-", "") }) : null;
|
|
188
215
|
const result = decide(run, resp);
|
|
189
216
|
// onBreach "hold": wait here for someone to raise the limit. Only a breach the server decided: an unreachable one can't be raised
|
|
190
217
|
if (result.breachType && control.HOLDABLE.has(result.breachType) && control.holds(run) && resp && !resp.network_error) {
|
|
@@ -240,7 +267,7 @@ function decide(run, resp) {
|
|
|
240
267
|
control.apply(run, resp.commands);
|
|
241
268
|
// The server is authoritative: adopt its totals and (on a breach) its current limits
|
|
242
269
|
control.adopt(run, resp, control.TOTALS);
|
|
243
|
-
control.adopt(run, resp, control.LIMITS,
|
|
270
|
+
control.adopt(run, resp, control.LIMITS, !run.budgetInCode);
|
|
244
271
|
// A fresh breach, or a 409 because the run is still fenced from an earlier one
|
|
245
272
|
run.lastBreach = resp.ok ? null : (resp.breach ?? run.lastBreach ?? "limit_reached");
|
|
246
273
|
}
|
package/dist/mcp.js
CHANGED
package/dist/run.d.ts
CHANGED
|
@@ -60,6 +60,8 @@ export declare class Run {
|
|
|
60
60
|
reservedUsd: number;
|
|
61
61
|
/** Model calls' worst-case costs (see precheck.ts), released at the next checkpoint once they finish. */
|
|
62
62
|
holds: Hold[];
|
|
63
|
+
/** governed() set a budget: no server reply can make the run unlimited. */
|
|
64
|
+
budgetInCode: boolean;
|
|
63
65
|
/** "hold": wait at a limit for someone to raise it (see InitOptions.onBreach). */
|
|
64
66
|
onBreach: "hold" | null;
|
|
65
67
|
private readonly abort;
|
package/dist/run.js
CHANGED
|
@@ -28,6 +28,8 @@ export class Run {
|
|
|
28
28
|
reservedUsd = 0;
|
|
29
29
|
/** Model calls' worst-case costs (see precheck.ts), released at the next checkpoint once they finish. */
|
|
30
30
|
holds = [];
|
|
31
|
+
/** governed() set a budget: no server reply can make the run unlimited. */
|
|
32
|
+
budgetInCode = false;
|
|
31
33
|
/** "hold": wait at a limit for someone to raise it (see InitOptions.onBreach). */
|
|
32
34
|
onBreach = null;
|
|
33
35
|
abort = new AbortController();
|
package/dist/spans.d.ts
CHANGED
|
@@ -1,11 +1,21 @@
|
|
|
1
1
|
export declare const MAX_ARGS = 300;
|
|
2
|
+
export type ToolArguments = "names" | "full" | "none";
|
|
3
|
+
/** init({ toolArguments }): what tool call spans carry of the arguments. */
|
|
4
|
+
export declare let toolArguments: ToolArguments;
|
|
5
|
+
/** @internal */
|
|
6
|
+
export declare function setToolArguments(value: unknown): void;
|
|
2
7
|
/** A `chat {model}` span. started is a performance.now() reading; response carries the usage. */
|
|
3
8
|
export declare function recordLlmCall(provider: string, requestModel: string | undefined, started: number, response?: unknown, error?: unknown, opts?: {
|
|
4
9
|
stream?: boolean;
|
|
5
10
|
integration?: string;
|
|
6
11
|
}): void;
|
|
7
|
-
/**
|
|
12
|
+
/**
|
|
13
|
+
* An `execute_tool {name}` span. args is a short summary of the call's arguments and opts.argNames
|
|
14
|
+
* just their names: the span carries the names by default, the values only with
|
|
15
|
+
* init({ toolArguments: "full" }), and neither with "none".
|
|
16
|
+
*/
|
|
8
17
|
export declare function recordToolCall(name: string, args: string, started: number, error?: unknown, opts?: {
|
|
9
18
|
callId?: string;
|
|
10
19
|
integration?: string;
|
|
20
|
+
argNames?: string;
|
|
11
21
|
}): void;
|
package/dist/spans.js
CHANGED
|
@@ -5,6 +5,15 @@
|
|
|
5
5
|
import { recordSpan } from "./core.js";
|
|
6
6
|
import { costOf, readUsage } from "./pricing.js";
|
|
7
7
|
export const MAX_ARGS = 300; // characters of a tool call's argument summary
|
|
8
|
+
/** init({ toolArguments }): what tool call spans carry of the arguments. */
|
|
9
|
+
export let toolArguments = "names";
|
|
10
|
+
/** @internal */
|
|
11
|
+
export function setToolArguments(value) {
|
|
12
|
+
if (value !== "names" && value !== "full" && value !== "none") {
|
|
13
|
+
throw new TypeError(`toolArguments must be "names", "full" or "none", not ${JSON.stringify(value)}`);
|
|
14
|
+
}
|
|
15
|
+
toolArguments = value;
|
|
16
|
+
}
|
|
8
17
|
/** A `chat {model}` span. started is a performance.now() reading; response carries the usage. */
|
|
9
18
|
export function recordLlmCall(provider, requestModel, started, response, error, opts = {}) {
|
|
10
19
|
const attrs = { "gen_ai.operation.name": "chat", "gen_ai.provider.name": provider,
|
|
@@ -25,11 +34,16 @@ export function recordLlmCall(provider, requestModel, started, response, error,
|
|
|
25
34
|
const model = attrs["gen_ai.response.model"] || requestModel || "unknown";
|
|
26
35
|
recordSpan(`chat ${model}`, performance.now() - started, attrs, error);
|
|
27
36
|
}
|
|
28
|
-
/**
|
|
37
|
+
/**
|
|
38
|
+
* An `execute_tool {name}` span. args is a short summary of the call's arguments and opts.argNames
|
|
39
|
+
* just their names: the span carries the names by default, the values only with
|
|
40
|
+
* init({ toolArguments: "full" }), and neither with "none".
|
|
41
|
+
*/
|
|
29
42
|
export function recordToolCall(name, args, started, error, opts = {}) {
|
|
43
|
+
const sent = toolArguments === "full" ? args : toolArguments === "names" ? opts.argNames : undefined;
|
|
30
44
|
recordSpan(`execute_tool ${name}`, performance.now() - started, {
|
|
31
45
|
"gen_ai.operation.name": "execute_tool", "gen_ai.tool.name": name, "gen_ai.tool.call.id": opts.callId,
|
|
32
|
-
"gen_ai.tool.call.arguments":
|
|
46
|
+
"gen_ai.tool.call.arguments": sent === undefined || sent.length <= MAX_ARGS ? sent : sent.slice(0, MAX_ARGS - 1) + "…",
|
|
33
47
|
"fences.integration": opts.integration,
|
|
34
48
|
}, error);
|
|
35
49
|
}
|
package/dist/tools.d.ts
CHANGED
|
@@ -1,8 +1,11 @@
|
|
|
1
1
|
import { type Run } from "./core.js";
|
|
2
|
+
/** `query, limit` (one object argument: its keys), or `0, 1` (positions): what a tool call span carries by default. */
|
|
3
|
+
export declare function argNames(args: unknown[]): string;
|
|
2
4
|
/** `{"query":"cats","limit":5}`: the arguments as compact JSON (one object argument on its own), capped. */
|
|
3
5
|
export declare function argsSummary(args: unknown[]): string;
|
|
4
6
|
/** Record an async tool call; cost is null for an unguarded call, else a function giving its amount. */
|
|
5
|
-
|
|
7
|
+
/** summary (the values) goes to the guard and an approval request; the span gets names. */
|
|
8
|
+
export declare function callAsync<R>(run: Run, name: string, summary: string, cost: (() => number) | null, needsApproval: boolean, call: () => Promise<R>, names?: string): Promise<R>;
|
|
6
9
|
interface ToolFields<A extends unknown[]> {
|
|
7
10
|
name?: string;
|
|
8
11
|
/** Reject with a TimeoutError if the call takes longer. JS can't stop the tool's code; pass run.signal to its fetches. */
|
package/dist/tools.js
CHANGED
|
@@ -5,6 +5,13 @@ import { _client, checkAmount, getActiveRun, localBreach } from "./core.js";
|
|
|
5
5
|
import { ToolBlocked } from "./errors.js";
|
|
6
6
|
import * as events from "./events.js";
|
|
7
7
|
import { MAX_ARGS, recordToolCall } from "./spans.js";
|
|
8
|
+
/** `query, limit` (one object argument: its keys), or `0, 1` (positions): what a tool call span carries by default. */
|
|
9
|
+
export function argNames(args) {
|
|
10
|
+
const [only] = args;
|
|
11
|
+
if (args.length === 1 && only && typeof only === "object" && !Array.isArray(only))
|
|
12
|
+
return Object.keys(only).join(", ");
|
|
13
|
+
return args.map((_, i) => String(i)).join(", ");
|
|
14
|
+
}
|
|
8
15
|
/** `{"query":"cats","limit":5}`: the arguments as compact JSON (one object argument on its own), capped. */
|
|
9
16
|
export function argsSummary(args) {
|
|
10
17
|
let text;
|
|
@@ -112,7 +119,8 @@ async function settle(run, guardId, amount, error, failed) {
|
|
|
112
119
|
await _client()?.guard(run.runId, { guard_id: guardId, phase: "settle", outcome, cost_usd: charged });
|
|
113
120
|
}
|
|
114
121
|
/** Record an async tool call; cost is null for an unguarded call, else a function giving its amount. */
|
|
115
|
-
|
|
122
|
+
/** summary (the values) goes to the guard and an approval request; the span gets names. */
|
|
123
|
+
export async function callAsync(run, name, summary, cost, needsApproval, call, names) {
|
|
116
124
|
const started = performance.now();
|
|
117
125
|
let gate = null;
|
|
118
126
|
try {
|
|
@@ -121,13 +129,13 @@ export async function callAsync(run, name, summary, cost, needsApproval, call) {
|
|
|
121
129
|
gate = [await open(run, name, summary, amount, needsApproval), amount];
|
|
122
130
|
}
|
|
123
131
|
const out = await call();
|
|
124
|
-
recordToolCall(name, summary, started);
|
|
132
|
+
recordToolCall(name, summary, started, undefined, { argNames: names });
|
|
125
133
|
if (gate)
|
|
126
134
|
await settle(run, ...gate, undefined, false);
|
|
127
135
|
return out;
|
|
128
136
|
}
|
|
129
137
|
catch (e) {
|
|
130
|
-
recordToolCall(name, summary, started, e);
|
|
138
|
+
recordToolCall(name, summary, started, e, { argNames: names });
|
|
131
139
|
if (gate)
|
|
132
140
|
await settle(run, ...gate, e, true);
|
|
133
141
|
throw e;
|
|
@@ -151,25 +159,25 @@ export function tool(fn, options = {}) {
|
|
|
151
159
|
if (!run)
|
|
152
160
|
return timed(Promise.resolve().then(() => fn(...args)), options.timeoutS, name);
|
|
153
161
|
const c = options.costUsd;
|
|
154
|
-
return callAsync(run, name, argsSummary(args), guarded ? () => (typeof c === "function" ? c(...args) : c) ?? 0 : null, !!options.approve, () => timed(Promise.resolve().then(() => fn(...args)), options.timeoutS, name));
|
|
162
|
+
return callAsync(run, name, argsSummary(args), guarded ? () => (typeof c === "function" ? c(...args) : c) ?? 0 : null, !!options.approve, () => timed(Promise.resolve().then(() => fn(...args)), options.timeoutS, name), argNames(args));
|
|
155
163
|
};
|
|
156
164
|
}
|
|
157
165
|
return (...args) => {
|
|
158
166
|
if (!getActiveRun())
|
|
159
167
|
return fn(...args);
|
|
160
|
-
const summary = argsSummary(args), started = performance.now();
|
|
168
|
+
const summary = argsSummary(args), started = performance.now(), opts = { argNames: argNames(args) };
|
|
161
169
|
let out;
|
|
162
170
|
try {
|
|
163
171
|
out = fn(...args);
|
|
164
172
|
}
|
|
165
173
|
catch (e) {
|
|
166
|
-
recordToolCall(name, summary, started, e);
|
|
174
|
+
recordToolCall(name, summary, started, e, opts);
|
|
167
175
|
throw e;
|
|
168
176
|
}
|
|
169
177
|
if (out instanceof Promise) {
|
|
170
|
-
return out.then((v) => { recordToolCall(name, summary, started); return v; }, (e) => { recordToolCall(name, summary, started, e); throw e; });
|
|
178
|
+
return out.then((v) => { recordToolCall(name, summary, started, undefined, opts); return v; }, (e) => { recordToolCall(name, summary, started, e, opts); throw e; });
|
|
171
179
|
}
|
|
172
|
-
recordToolCall(name, summary, started);
|
|
180
|
+
recordToolCall(name, summary, started, undefined, opts);
|
|
173
181
|
return out;
|
|
174
182
|
};
|
|
175
183
|
}
|
package/dist/version.d.ts
CHANGED
|
@@ -1,2 +1,2 @@
|
|
|
1
1
|
/** The one place the version lives; sent to Fences on every request. Keep in step with package.json. */
|
|
2
|
-
export declare const VERSION = "0.
|
|
2
|
+
export declare const VERSION = "0.9.1";
|
package/dist/version.js
CHANGED
|
@@ -1,2 +1,2 @@
|
|
|
1
1
|
/** The one place the version lives; sent to Fences on every request. Keep in step with package.json. */
|
|
2
|
-
export const VERSION = "0.
|
|
2
|
+
export const VERSION = "0.9.1";
|
package/package.json
CHANGED