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 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
- checkpoint(runId, body) { return this.post(`/api/runs/${runId}/checkpoint`, body); }
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
- resp = await fetch(this.endpoint + path, {
40
- method: "POST", headers: this.headers, body: JSON.stringify(payload), signal: AbortSignal.timeout(this.timeoutMs),
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
- client = new FencesClient(options.apiKey, options.endpoint ?? "http://localhost:8000");
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
- control.adopt(run, resp.limits ?? {}, control.LIMITS, true);
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
- const resp = client ? await client.checkpoint(run.runId, {
186
- cost_delta_usd: stepCost, tokens_used: stepTokens, iterations: run.iterations, duration_ms: run.durationMs
187
- }) : null;
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, true);
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
@@ -34,7 +34,7 @@ export function wrap(client, options = {}) {
34
34
  if (result?.isError)
35
35
  throw new MCPToolError(result);
36
36
  return result;
37
- });
37
+ }, Object.keys(args).join(", "));
38
38
  }
39
39
  catch (e) {
40
40
  if (e instanceof MCPToolError)
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
- /** An `execute_tool {name}` span; args is a short summary of the call's arguments. */
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
- /** An `execute_tool {name}` span; args is a short summary of the call's arguments. */
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": args.length <= MAX_ARGS ? args : args.slice(0, MAX_ARGS - 1) + "…",
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
- export declare function callAsync<R>(run: Run, name: string, summary: string, cost: (() => number) | null, needsApproval: boolean, call: () => Promise<R>): Promise<R>;
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
- export async function callAsync(run, name, summary, cost, needsApproval, call) {
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.8.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.8.0";
2
+ export const VERSION = "0.9.1";
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "agentfences",
3
- "version": "0.8.0",
3
+ "version": "0.9.1",
4
4
  "description": "On-call for AI agents: limits your agents can't raise, cost tracking, a decision trail, and a stop button",
5
5
  "license": "MIT",
6
6
  "author": "Bhavik Sardar",