agentfences 0.8.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 +1 -1
- package/dist/client.d.ts +21 -1
- package/dist/client.js +78 -6
- package/dist/control.js +17 -3
- package/dist/core.d.ts +7 -5
- package/dist/core.js +6 -3
- package/dist/mcp.js +1 -1
- 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
|
|
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,48 @@ 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
|
+
const send = () => fetch(this.endpoint + path, {
|
|
98
|
+
method: "POST", headers: this.headers, body: json, signal: AbortSignal.timeout(this.timeoutMs),
|
|
99
|
+
});
|
|
37
100
|
let resp;
|
|
38
101
|
try {
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
}
|
|
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
|
+
}
|
|
42
111
|
}
|
|
43
112
|
catch (e) {
|
|
113
|
+
this.breaker.record(false);
|
|
44
114
|
return { network_error: String(e) };
|
|
45
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"));
|
|
46
118
|
const text = await resp.text().catch(() => "");
|
|
47
119
|
let body = {}, isJson = false;
|
|
48
120
|
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,12 @@ 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;
|
|
56
58
|
}
|
|
57
59
|
/** Call once at startup, before any governed function runs. */
|
|
58
60
|
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;
|
|
@@ -182,9 +185,9 @@ export async function checkpoint(response, extra = {}) {
|
|
|
182
185
|
precheck.releaseFinished(run);
|
|
183
186
|
run.tokensUsed += stepTokens;
|
|
184
187
|
run.iterations += 1;
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
|
|
188
|
+
// A step_id per step: a retried checkpoint is counted once on servers that support it
|
|
189
|
+
const resp = client ? await client.checkpoint(run.runId, { cost_delta_usd: stepCost, tokens_used: stepTokens,
|
|
190
|
+
iterations: run.iterations, duration_ms: run.durationMs, step_id: randomUUID().replaceAll("-", "") }) : null;
|
|
188
191
|
const result = decide(run, resp);
|
|
189
192
|
// onBreach "hold": wait here for someone to raise the limit. Only a breach the server decided: an unreachable one can't be raised
|
|
190
193
|
if (result.breachType && control.HOLDABLE.has(result.breachType) && control.holds(run) && resp && !resp.network_error) {
|
package/dist/mcp.js
CHANGED
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.0";
|
package/dist/version.js
CHANGED
|
@@ -1,2 +1,2 @@
|
|
|
1
1
|
/** The one place the version lives; sent to Fences on every request. Keep in step with package.json. */
|
|
2
|
-
export const VERSION = "0.
|
|
2
|
+
export const VERSION = "0.9.0";
|
package/package.json
CHANGED