agentfences 0.0.0-stage → 0.4.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 +82 -2
- package/dist/client.d.ts +20 -0
- package/dist/client.js +62 -0
- package/dist/control.d.ts +43 -0
- package/dist/control.js +162 -0
- package/dist/core.d.ts +130 -0
- package/dist/core.js +362 -0
- package/dist/errors.d.ts +22 -0
- package/dist/errors.js +33 -0
- package/dist/events.d.ts +38 -0
- package/dist/events.js +102 -0
- package/dist/index.d.ts +11 -0
- package/dist/index.js +7 -0
- package/dist/prices.generated.d.ts +11 -0
- package/dist/prices.generated.js +3 -0
- package/dist/pricing.d.ts +30 -0
- package/dist/pricing.js +103 -0
- package/dist/spans.d.ts +11 -0
- package/dist/spans.js +35 -0
- package/dist/tools.d.ts +15 -0
- package/dist/tools.js +44 -0
- package/dist/version.d.ts +2 -0
- package/dist/version.js +2 -0
- package/package.json +22 -4
package/dist/core.js
ADDED
|
@@ -0,0 +1,362 @@
|
|
|
1
|
+
import { AsyncLocalStorage } from "node:async_hooks";
|
|
2
|
+
import { randomUUID } from "node:crypto";
|
|
3
|
+
import { FencesClient } from "./client.js";
|
|
4
|
+
import * as control from "./control.js";
|
|
5
|
+
import { AgentQuarantined, FencesStop } from "./errors.js";
|
|
6
|
+
import * as events from "./events.js";
|
|
7
|
+
import { costOf, setCustomPrices } from "./pricing.js";
|
|
8
|
+
const SUMMARY = "I'll summarize what I found so far.";
|
|
9
|
+
const WRAP_UP = "Stop your current task immediately and summarize what you have found or completed so far.";
|
|
10
|
+
const money = (x) => `$${x.toFixed(4)}`;
|
|
11
|
+
const count = (n) => n.toLocaleString("en-US");
|
|
12
|
+
function breachResult(type, run) {
|
|
13
|
+
const spent = run.costUsd, limit = run.budgetUsd ?? 0;
|
|
14
|
+
const text = {
|
|
15
|
+
budget_exceeded: [`I've reached my budget limit (spent ${money(spent)} of ${money(limit)}). ${SUMMARY}`,
|
|
16
|
+
`You have reached your budget limit (${money(spent)} of ${money(limit)} spent). Stop your current task immediately ` +
|
|
17
|
+
"and provide a clear summary of what you have found or completed so far. Tell the user you stopped due to budget and what you accomplished."],
|
|
18
|
+
iteration_limit: [`I've reached my iteration limit (${run.iterations} steps). ${SUMMARY}`,
|
|
19
|
+
`You have reached your iteration limit (${run.iterations} steps). ${WRAP_UP} Tell the user you stopped due to the iteration limit.`],
|
|
20
|
+
time_limit: [`I've reached my time limit (${run.durationMs}ms). ${SUMMARY}`,
|
|
21
|
+
`You have reached your time limit. ${WRAP_UP} Tell the user you stopped due to the time limit.`],
|
|
22
|
+
token_limit: [`I've reached my token limit (${count(run.tokensUsed)} tokens). ${SUMMARY}`,
|
|
23
|
+
`You have reached your token limit (${count(run.tokensUsed)} tokens used). ${WRAP_UP} Tell the user you stopped due to the token limit.`],
|
|
24
|
+
stopped_by_user: [`I was stopped from the Fences dashboard. ${SUMMARY}`,
|
|
25
|
+
`A person stopped this run from the Fences dashboard. ${WRAP_UP} Tell the user you were stopped by an operator.`],
|
|
26
|
+
key_daily_budget: [`My daily spending cap has been reached. ${SUMMARY}`,
|
|
27
|
+
`Your daily spending cap, across all of your runs, has been reached. ${WRAP_UP} Tell the user you stopped because the daily cap was reached.`],
|
|
28
|
+
key_monthly_budget: [`My monthly spending cap has been reached. ${SUMMARY}`,
|
|
29
|
+
`Your monthly spending cap, across all of your runs, has been reached. ${WRAP_UP} Tell the user you stopped because the monthly cap was reached.`],
|
|
30
|
+
fences_unreachable: [`I can't reach Fences to confirm I'm within my limits, so I'm stopping to be safe. ${SUMMARY}`,
|
|
31
|
+
`Your governance service can't be reached and you are configured to stop when that happens. ${WRAP_UP}`],
|
|
32
|
+
paused: [`I was paused from the Fences dashboard and not resumed in time. ${SUMMARY}`,
|
|
33
|
+
`A person paused this run from the Fences dashboard and it was not resumed in time. Stop your current task and summarize ` +
|
|
34
|
+
"what you have found or completed so far. Tell the user the run was paused by an operator."],
|
|
35
|
+
};
|
|
36
|
+
const [message, systemPrompt] = text[type] ??
|
|
37
|
+
["Governance limit reached.", "A governance limit has been reached. Summarize what you have done so far."];
|
|
38
|
+
return { ok: false, breached: true, breachType: type, message, systemPrompt, spentUsd: run.costUsd };
|
|
39
|
+
}
|
|
40
|
+
const okResult = (spentUsd = 0) => ({ ok: true, breached: false, breachType: null, message: "", systemPrompt: "", spentUsd });
|
|
41
|
+
const DEFAULTS = { maxIterations: 100, maxDurationMs: 300_000, maxTokens: 0 };
|
|
42
|
+
export const MAX_EVENTS_PER_RUN = 1000;
|
|
43
|
+
export class Run {
|
|
44
|
+
agentName;
|
|
45
|
+
budgetUsd;
|
|
46
|
+
maxIterations;
|
|
47
|
+
maxDurationMs;
|
|
48
|
+
maxTokens;
|
|
49
|
+
context;
|
|
50
|
+
runId = randomUUID();
|
|
51
|
+
costUsd = 0;
|
|
52
|
+
iterations = 0;
|
|
53
|
+
tokensUsed = 0;
|
|
54
|
+
startedAt = Date.now();
|
|
55
|
+
decisions = [];
|
|
56
|
+
/** Tool and model calls as OpenTelemetry-shaped spans, newest 1,000 kept. */
|
|
57
|
+
events = [];
|
|
58
|
+
lastBreach = null;
|
|
59
|
+
exception;
|
|
60
|
+
warned = false;
|
|
61
|
+
/** Live control from the dashboard (see control.ts). */
|
|
62
|
+
control = control.newControl();
|
|
63
|
+
constructor(agentName, budgetUsd, maxIterations, maxDurationMs, maxTokens, context) {
|
|
64
|
+
this.agentName = agentName;
|
|
65
|
+
this.budgetUsd = budgetUsd;
|
|
66
|
+
this.maxIterations = maxIterations;
|
|
67
|
+
this.maxDurationMs = maxDurationMs;
|
|
68
|
+
this.maxTokens = maxTokens;
|
|
69
|
+
this.context = context;
|
|
70
|
+
}
|
|
71
|
+
get durationMs() { return Date.now() - this.startedAt; }
|
|
72
|
+
}
|
|
73
|
+
const store = new AsyncLocalStorage();
|
|
74
|
+
/** The run in progress in this async context, if any. */
|
|
75
|
+
export function getActiveRun() {
|
|
76
|
+
return store.getStore() ?? null;
|
|
77
|
+
}
|
|
78
|
+
let client = null;
|
|
79
|
+
let localOnly = false;
|
|
80
|
+
let failClosed = false;
|
|
81
|
+
/** Call once at startup, before any governed function runs. */
|
|
82
|
+
export function init(options = {}) {
|
|
83
|
+
setCustomPrices(options.prices);
|
|
84
|
+
events.configure(options.environment, options.release, options.redact);
|
|
85
|
+
localOnly = !!options.localOnly;
|
|
86
|
+
failClosed = !!options.failClosed;
|
|
87
|
+
if (localOnly) {
|
|
88
|
+
client = null;
|
|
89
|
+
}
|
|
90
|
+
else if (!options.apiKey) {
|
|
91
|
+
throw new Error("apiKey is required unless localOnly is set. Use init({ localOnly: true }) for local usage.");
|
|
92
|
+
}
|
|
93
|
+
else {
|
|
94
|
+
client = new FencesClient(options.apiKey, options.endpoint ?? "http://localhost:8000");
|
|
95
|
+
}
|
|
96
|
+
control.configure(client, options.heartbeatS ?? 15, options.pauseTimeoutS ?? 3600);
|
|
97
|
+
}
|
|
98
|
+
/** @internal For tests: point the SDK at a fake server client. */
|
|
99
|
+
export function _setClient(c) { client = c; control.configure(c, 15, 3600); }
|
|
100
|
+
function requireInit() {
|
|
101
|
+
if (!localOnly && !client) {
|
|
102
|
+
throw new Error("Call agentfences.init() before using Fences. For local usage: init({ localOnly: true })");
|
|
103
|
+
}
|
|
104
|
+
}
|
|
105
|
+
function warnUnreachable(run, err) {
|
|
106
|
+
if (run.warned)
|
|
107
|
+
return;
|
|
108
|
+
run.warned = true;
|
|
109
|
+
console.warn(`agentfences: Fences backend unreachable for run ${run.runId} (${err}); enforcing limits locally`);
|
|
110
|
+
}
|
|
111
|
+
// ── governed ──────────────────────────────────────────────────────────────────
|
|
112
|
+
/**
|
|
113
|
+
* Make each call of fn a governed run with these limits:
|
|
114
|
+
*
|
|
115
|
+
* const research = governed(async (q: string) => { ... }, { budgetUsd: 0.5, maxIterations: 20 });
|
|
116
|
+
*
|
|
117
|
+
* Every limit is optional. On Fences Cloud, a limit left out comes from the agent's page in
|
|
118
|
+
* the dashboard. The run is named after the function (or `name`). checkpoint() never throws:
|
|
119
|
+
* a crossed limit comes back as a breached result, so the agent can wrap up gracefully.
|
|
120
|
+
*/
|
|
121
|
+
export function governed(fn, options = {}) {
|
|
122
|
+
const name = options.name ?? (fn.name || "agent");
|
|
123
|
+
return async (...args) => {
|
|
124
|
+
requireInit();
|
|
125
|
+
const run = await startRun(name, options);
|
|
126
|
+
control.register(run);
|
|
127
|
+
let error, failed = false;
|
|
128
|
+
try {
|
|
129
|
+
return await store.run(run, () => fn(...args));
|
|
130
|
+
}
|
|
131
|
+
catch (e) {
|
|
132
|
+
[error, failed] = [e, true];
|
|
133
|
+
throw e;
|
|
134
|
+
}
|
|
135
|
+
finally {
|
|
136
|
+
await endRun(run, ...outcome(run, failed, error));
|
|
137
|
+
}
|
|
138
|
+
};
|
|
139
|
+
}
|
|
140
|
+
function outcome(run, failed, error) {
|
|
141
|
+
if (!failed)
|
|
142
|
+
return [run.lastBreach ? "breached" : "success", null];
|
|
143
|
+
if (error instanceof FencesStop)
|
|
144
|
+
return ["breached", null]; // a framework integration halted the agent
|
|
145
|
+
run.exception = events.exceptionInfo(error);
|
|
146
|
+
const e = run.exception;
|
|
147
|
+
return ["error", e.message ? `${e.type}: ${e.message}` : e.type];
|
|
148
|
+
}
|
|
149
|
+
const LIMIT_KEYS = { budget_usd: "budgetUsd", max_iterations: "maxIterations", max_duration_ms: "maxDurationMs", max_tokens: "maxTokens" };
|
|
150
|
+
async function startRun(agentName, limits) {
|
|
151
|
+
const run = new Run(agentName, limits.budgetUsd ?? null, limits.maxIterations ?? DEFAULTS.maxIterations, limits.maxDurationMs ?? DEFAULTS.maxDurationMs, limits.maxTokens ?? DEFAULTS.maxTokens, events.currentContext());
|
|
152
|
+
if (!client) {
|
|
153
|
+
if (run.budgetUsd === null)
|
|
154
|
+
noBudget(agentName, "Set one with governed(fn, { budgetUsd: ... }).");
|
|
155
|
+
return run;
|
|
156
|
+
}
|
|
157
|
+
// The run must start even if redaction drops its context: limits depend on it
|
|
158
|
+
const sent = Object.keys(run.context).length
|
|
159
|
+
? events.redact({ type: "run_start", agent_name: agentName, context: { ...run.context } }) : null;
|
|
160
|
+
// Limits left out in code go as null: the server fills them from the agent's dashboard settings
|
|
161
|
+
const body = { run_id: run.runId, agent_name: agentName, budget_usd: limits.budgetUsd ?? null,
|
|
162
|
+
max_iterations: limits.maxIterations ?? null, max_duration_ms: limits.maxDurationMs ?? null, max_tokens: limits.maxTokens ?? null };
|
|
163
|
+
if (sent?.context)
|
|
164
|
+
body.context = sent.context;
|
|
165
|
+
const resp = await client.startRun(body);
|
|
166
|
+
if (resp.quarantined)
|
|
167
|
+
throw new AgentQuarantined(agentName, resp.detail);
|
|
168
|
+
// The server caps limits at the agent's ceilings: enforce the same numbers if it becomes unreachable later
|
|
169
|
+
for (const [wire, field] of Object.entries(LIMIT_KEYS)) {
|
|
170
|
+
const v = resp.limits?.[wire];
|
|
171
|
+
if (typeof v === "number" || (wire === "budget_usd" && v === null))
|
|
172
|
+
run[field] = v; // eslint-disable-line @typescript-eslint/no-explicit-any
|
|
173
|
+
}
|
|
174
|
+
for (const n of resp.notices ?? []) {
|
|
175
|
+
events.warnOnce(`${agentName}:${n.code}`, `${n.message ?? ""}${n.url ? ` ${n.url}` : ""}`);
|
|
176
|
+
}
|
|
177
|
+
if (!("limits" in resp) && run.budgetUsd === null && !resp.network_error) {
|
|
178
|
+
noBudget(agentName, "Set one on its page in the Fences dashboard.");
|
|
179
|
+
}
|
|
180
|
+
if (resp.network_error)
|
|
181
|
+
warnUnreachable(run, resp.network_error);
|
|
182
|
+
return run;
|
|
183
|
+
}
|
|
184
|
+
function noBudget(agentName, how) {
|
|
185
|
+
events.warnOnce(`${agentName}:no_budget`, `${agentName} has no budget, so its spend isn't capped. ${how}`);
|
|
186
|
+
}
|
|
187
|
+
async function endRun(run, status, error) {
|
|
188
|
+
control.unregister(run);
|
|
189
|
+
if (!client)
|
|
190
|
+
return;
|
|
191
|
+
// The run must end even if redaction drops its error details
|
|
192
|
+
const sent = error ? events.redact({ type: "run_end", status, error, exception: run.exception }) : null;
|
|
193
|
+
const body = { status, error: sent?.error ?? null };
|
|
194
|
+
if (sent?.exception)
|
|
195
|
+
body.exception = sent.exception;
|
|
196
|
+
await client.endRun(run.runId, body);
|
|
197
|
+
}
|
|
198
|
+
/**
|
|
199
|
+
* Record one step, then check every limit. Pass the model's response and Fences works out
|
|
200
|
+
* the cost from its model and token usage:
|
|
201
|
+
*
|
|
202
|
+
* const response = await openai.chat.completions.create({ ... });
|
|
203
|
+
* const result = await checkpoint(response);
|
|
204
|
+
* if (result.breached) return result.message;
|
|
205
|
+
*
|
|
206
|
+
* Without a response: checkpoint(null, { costDeltaUsd: 0.02, tokensUsed: 450 }).
|
|
207
|
+
* Never throws for a breach. Outside a governed run it does nothing.
|
|
208
|
+
*/
|
|
209
|
+
export async function checkpoint(response, extra = {}) {
|
|
210
|
+
const run = getActiveRun();
|
|
211
|
+
if (!run)
|
|
212
|
+
return okResult();
|
|
213
|
+
if (typeof response === "number") {
|
|
214
|
+
throw new TypeError("checkpoint() takes the model's response first; pass a cost as checkpoint(null, { costDeltaUsd })");
|
|
215
|
+
}
|
|
216
|
+
const [cost, tokens] = response === undefined || response === null ? [0, 0] : costOf(response);
|
|
217
|
+
const stepCost = cost + (extra.costDeltaUsd ?? 0), stepTokens = tokens + (extra.tokensUsed ?? 0);
|
|
218
|
+
run.costUsd += stepCost;
|
|
219
|
+
run.tokensUsed += stepTokens;
|
|
220
|
+
run.iterations += 1;
|
|
221
|
+
const resp = client ? await client.checkpoint(run.runId, {
|
|
222
|
+
cost_delta_usd: stepCost, tokens_used: stepTokens, iterations: run.iterations, duration_ms: run.durationMs
|
|
223
|
+
}) : null;
|
|
224
|
+
const result = decide(run, resp);
|
|
225
|
+
if (run.control.paused && !result.breached) { // paused from the dashboard: wait here until resumed
|
|
226
|
+
await control.waitIfPaused(run);
|
|
227
|
+
return finalResult(run);
|
|
228
|
+
}
|
|
229
|
+
return result;
|
|
230
|
+
}
|
|
231
|
+
function localBreach(run) {
|
|
232
|
+
// Limits trip only once exceeded, same rule as the server; round away float drift (0.02*5 != 0.10)
|
|
233
|
+
if (run.budgetUsd !== null && Math.round(run.costUsd * 1e9) / 1e9 > run.budgetUsd)
|
|
234
|
+
return "budget_exceeded";
|
|
235
|
+
if (run.iterations > run.maxIterations)
|
|
236
|
+
return "iteration_limit";
|
|
237
|
+
if (run.durationMs > run.maxDurationMs)
|
|
238
|
+
return "time_limit";
|
|
239
|
+
if (run.maxTokens > 0 && run.tokensUsed > run.maxTokens)
|
|
240
|
+
return "token_limit";
|
|
241
|
+
return null;
|
|
242
|
+
}
|
|
243
|
+
function decide(run, resp) {
|
|
244
|
+
if (!resp || resp.network_error) {
|
|
245
|
+
if (resp) {
|
|
246
|
+
warnUnreachable(run, resp.network_error);
|
|
247
|
+
if (failClosed) {
|
|
248
|
+
run.lastBreach = "fences_unreachable";
|
|
249
|
+
return breachResult(run.lastBreach, run);
|
|
250
|
+
}
|
|
251
|
+
}
|
|
252
|
+
run.lastBreach = localBreach(run);
|
|
253
|
+
}
|
|
254
|
+
else {
|
|
255
|
+
control.apply(run, resp.commands);
|
|
256
|
+
// The server is authoritative: adopt its totals and (on a breach) its current limits
|
|
257
|
+
run.costUsd = resp.spent_usd ?? run.costUsd;
|
|
258
|
+
run.iterations = resp.iterations ?? run.iterations;
|
|
259
|
+
run.tokensUsed = resp.tokens_used ?? run.tokensUsed;
|
|
260
|
+
for (const [wire, field] of Object.entries(LIMIT_KEYS)) {
|
|
261
|
+
if (wire in resp)
|
|
262
|
+
run[field] = resp[wire]; // eslint-disable-line @typescript-eslint/no-explicit-any
|
|
263
|
+
}
|
|
264
|
+
// A fresh breach, or a 409 because the run is still fenced from an earlier one
|
|
265
|
+
run.lastBreach = resp.ok ? null : (resp.breach ?? run.lastBreach ?? "limit_reached");
|
|
266
|
+
}
|
|
267
|
+
return finalResult(run);
|
|
268
|
+
}
|
|
269
|
+
function finalResult(run) {
|
|
270
|
+
if (run.control.stopped)
|
|
271
|
+
run.lastBreach = "stopped_by_user"; // a stop from the dashboard sticks, whatever a later reply says
|
|
272
|
+
else if (run.control.pauseExpired)
|
|
273
|
+
run.lastBreach = "paused";
|
|
274
|
+
return run.lastBreach ? breachResult(run.lastBreach, run) : okResult(run.costUsd);
|
|
275
|
+
}
|
|
276
|
+
// ── Decision trail and spans ──────────────────────────────────────────────────
|
|
277
|
+
const SPAN_BATCH = 50;
|
|
278
|
+
const outbox = [];
|
|
279
|
+
let draining = null;
|
|
280
|
+
let spansSupported = true;
|
|
281
|
+
function enqueue(item) {
|
|
282
|
+
outbox.push(item);
|
|
283
|
+
draining ??= drain().finally(() => { draining = null; });
|
|
284
|
+
}
|
|
285
|
+
/** Decisions and spans go to the server in the background, in order; consecutive spans of a run go together. */
|
|
286
|
+
async function drain() {
|
|
287
|
+
while (outbox.length) {
|
|
288
|
+
const item = outbox.shift();
|
|
289
|
+
const c = client;
|
|
290
|
+
if (!c)
|
|
291
|
+
continue;
|
|
292
|
+
try {
|
|
293
|
+
if (item.kind === "decision") {
|
|
294
|
+
await c.logDecision(item.runId, item.body);
|
|
295
|
+
continue;
|
|
296
|
+
}
|
|
297
|
+
const batch = [item.span];
|
|
298
|
+
while (outbox[0]?.kind === "span" && outbox[0].runId === item.runId && batch.length < SPAN_BATCH) {
|
|
299
|
+
batch.push(outbox.shift().span);
|
|
300
|
+
}
|
|
301
|
+
if (spansSupported && (await c.logSpans(item.runId, batch)).unsupported) {
|
|
302
|
+
spansSupported = false;
|
|
303
|
+
console.warn("agentfences: this Fences server doesn't accept tool and model call spans yet; " +
|
|
304
|
+
"they're kept on the run locally but not sent");
|
|
305
|
+
}
|
|
306
|
+
}
|
|
307
|
+
catch { /* never let delivery break the agent */ }
|
|
308
|
+
}
|
|
309
|
+
}
|
|
310
|
+
/** Wait up to timeoutMs for queued decisions and spans to reach the server. Call before a serverless handler returns. */
|
|
311
|
+
export async function flush(timeoutMs = 5000) {
|
|
312
|
+
if (!draining)
|
|
313
|
+
return;
|
|
314
|
+
await Promise.race([draining, new Promise((r) => setTimeout(r, timeoutMs).unref())]);
|
|
315
|
+
}
|
|
316
|
+
/** Record the agent's reasoning at this step. Does nothing outside a governed run. */
|
|
317
|
+
export function logDecision(reasoning, action = null) {
|
|
318
|
+
const run = getActiveRun();
|
|
319
|
+
if (!run)
|
|
320
|
+
return;
|
|
321
|
+
run.decisions.push({ timestamp: Date.now() / 1000, iteration: run.iterations, reasoning, action });
|
|
322
|
+
if (!client)
|
|
323
|
+
return;
|
|
324
|
+
const sent = events.redact({ type: "decision", reasoning, action });
|
|
325
|
+
if (sent && typeof sent.reasoning === "string" && sent.reasoning) {
|
|
326
|
+
enqueue({ kind: "decision", runId: run.runId,
|
|
327
|
+
body: { iteration: run.iterations, reasoning: sent.reasoning, action: sent.action ?? null } });
|
|
328
|
+
}
|
|
329
|
+
}
|
|
330
|
+
/**
|
|
331
|
+
* Add a tool or model call to the active run as an OpenTelemetry-shaped span (GenAI
|
|
332
|
+
* semantic conventions): kept on the run, and sent through the redact hook in cloud mode.
|
|
333
|
+
*/
|
|
334
|
+
export function recordSpan(name, elapsedMs, attributes, error) {
|
|
335
|
+
const run = getActiveRun();
|
|
336
|
+
if (!run)
|
|
337
|
+
return;
|
|
338
|
+
const end = Date.now() / 1000;
|
|
339
|
+
const attrs = {};
|
|
340
|
+
for (const [k, v] of Object.entries(attributes))
|
|
341
|
+
if (v !== null && v !== undefined)
|
|
342
|
+
attrs[k] = v;
|
|
343
|
+
attrs["fences.iteration"] = run.iterations;
|
|
344
|
+
if (error !== undefined) {
|
|
345
|
+
attrs["error.type"] = events.errorType(error);
|
|
346
|
+
attrs["fences.error.message"] = events.errorText(error);
|
|
347
|
+
const status = error?.status; // the OpenAI and Anthropic JS SDKs put the HTTP status here
|
|
348
|
+
if (typeof status === "number")
|
|
349
|
+
attrs["http.response.status_code"] = status;
|
|
350
|
+
}
|
|
351
|
+
const span = { name, start_ts: end - elapsedMs / 1000, end_ts: end, status: error === undefined ? "ok" : "error", attributes: attrs };
|
|
352
|
+
run.events.push(span);
|
|
353
|
+
if (run.events.length > MAX_EVENTS_PER_RUN)
|
|
354
|
+
run.events.shift(); // a runaway agent can't eat memory
|
|
355
|
+
if (!client || !spansSupported)
|
|
356
|
+
return;
|
|
357
|
+
const sent = events.redact({ type: "span", ...span });
|
|
358
|
+
if (sent) {
|
|
359
|
+
const { type: _, ...redacted } = sent; // the type key is for the redact hook only
|
|
360
|
+
enqueue({ kind: "span", runId: run.runId, span: redacted });
|
|
361
|
+
}
|
|
362
|
+
}
|
package/dist/errors.d.ts
ADDED
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
import type { CheckpointResult } from "./core.js";
|
|
2
|
+
export declare class FencesError extends Error {
|
|
3
|
+
constructor(message: string);
|
|
4
|
+
}
|
|
5
|
+
/** The Fences server rejected the API key. Thrown so a bad key is noticed, not silently ignored. */
|
|
6
|
+
export declare class FencesAuthError extends FencesError {
|
|
7
|
+
}
|
|
8
|
+
/** A governed function was called for an agent quarantined in the dashboard. No agent code ran. */
|
|
9
|
+
export declare class AgentQuarantined extends FencesError {
|
|
10
|
+
readonly agentName: string;
|
|
11
|
+
constructor(agentName: string, detail?: string);
|
|
12
|
+
}
|
|
13
|
+
/**
|
|
14
|
+
* Thrown by framework integrations when a limit is crossed, because a framework callback
|
|
15
|
+
* can't hand a result back to the agent. Carries the same fields as a breached CheckpointResult.
|
|
16
|
+
*/
|
|
17
|
+
export declare class FencesStop extends FencesError {
|
|
18
|
+
readonly result: CheckpointResult;
|
|
19
|
+
readonly breachType: string | null;
|
|
20
|
+
readonly systemPrompt: string;
|
|
21
|
+
constructor(result: CheckpointResult);
|
|
22
|
+
}
|
package/dist/errors.js
ADDED
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
export class FencesError extends Error {
|
|
2
|
+
constructor(message) {
|
|
3
|
+
super(message);
|
|
4
|
+
this.name = new.target.name;
|
|
5
|
+
}
|
|
6
|
+
}
|
|
7
|
+
/** The Fences server rejected the API key. Thrown so a bad key is noticed, not silently ignored. */
|
|
8
|
+
export class FencesAuthError extends FencesError {
|
|
9
|
+
}
|
|
10
|
+
/** A governed function was called for an agent quarantined in the dashboard. No agent code ran. */
|
|
11
|
+
export class AgentQuarantined extends FencesError {
|
|
12
|
+
agentName;
|
|
13
|
+
constructor(agentName, detail) {
|
|
14
|
+
super(`Agent '${agentName}' is quarantined in Fences, so this run was not started` +
|
|
15
|
+
(detail && detail !== "quarantined" ? `: ${detail}` : ""));
|
|
16
|
+
this.agentName = agentName;
|
|
17
|
+
}
|
|
18
|
+
}
|
|
19
|
+
/**
|
|
20
|
+
* Thrown by framework integrations when a limit is crossed, because a framework callback
|
|
21
|
+
* can't hand a result back to the agent. Carries the same fields as a breached CheckpointResult.
|
|
22
|
+
*/
|
|
23
|
+
export class FencesStop extends FencesError {
|
|
24
|
+
result;
|
|
25
|
+
breachType;
|
|
26
|
+
systemPrompt;
|
|
27
|
+
constructor(result) {
|
|
28
|
+
super(result.message);
|
|
29
|
+
this.result = result;
|
|
30
|
+
this.breachType = result.breachType;
|
|
31
|
+
this.systemPrompt = result.systemPrompt;
|
|
32
|
+
}
|
|
33
|
+
}
|
package/dist/events.d.ts
ADDED
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
export declare const MAX_CONTEXT_KEYS = 32;
|
|
2
|
+
export declare const MAX_CONTEXT_VALUE = 256;
|
|
3
|
+
export declare const MAX_MESSAGE = 1000;
|
|
4
|
+
export declare const MAX_FRAMES = 15;
|
|
5
|
+
export type Event = {
|
|
6
|
+
type: string;
|
|
7
|
+
[key: string]: unknown;
|
|
8
|
+
};
|
|
9
|
+
export type RedactHook = (event: Event) => Event | null | undefined;
|
|
10
|
+
/** Log a warning once per key per process. */
|
|
11
|
+
export declare function warnOnce(key: string, message: string): void;
|
|
12
|
+
export declare function configure(environment?: string, release?: string, redact?: RedactHook): void;
|
|
13
|
+
/**
|
|
14
|
+
* Tag every run started inside fn, e.g. with the end user or session it serves:
|
|
15
|
+
*
|
|
16
|
+
* await context({ user_id: "u_123", session_id: "s_9" }, () => supportAgent(question))
|
|
17
|
+
*
|
|
18
|
+
* Calls nest; inner values win. Values are stored as strings (up to 256 characters,
|
|
19
|
+
* 32 keys); null and undefined leave a key out.
|
|
20
|
+
*/
|
|
21
|
+
export declare function context<T>(values: Record<string, unknown>, fn: () => T): T;
|
|
22
|
+
export declare function currentContext(): Record<string, string>;
|
|
23
|
+
/** `Type: message`, or just the type when there's no message. */
|
|
24
|
+
export declare function errorText(e: unknown): string;
|
|
25
|
+
export declare function errorType(e: unknown): string;
|
|
26
|
+
export interface ExceptionInfo {
|
|
27
|
+
type: string;
|
|
28
|
+
message: string;
|
|
29
|
+
stack: string[];
|
|
30
|
+
}
|
|
31
|
+
/** Type, message and the last frames of the stack (innermost last), for the run's error report. */
|
|
32
|
+
export declare function exceptionInfo(e: unknown): ExceptionInfo;
|
|
33
|
+
/**
|
|
34
|
+
* Pass an outgoing event through the user's redact hook. Returns the event to send, or
|
|
35
|
+
* null to drop it. If the hook throws or returns something else, the event is dropped:
|
|
36
|
+
* never send unredacted data, and never crash the agent.
|
|
37
|
+
*/
|
|
38
|
+
export declare function redact(event: Event): Event | null;
|
package/dist/events.js
ADDED
|
@@ -0,0 +1,102 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* What a run carries besides numbers: its context (environment, release, user, session),
|
|
3
|
+
* the error that ended it, and the redact hook every outgoing event passes through.
|
|
4
|
+
*/
|
|
5
|
+
import { AsyncLocalStorage } from "node:async_hooks";
|
|
6
|
+
export const MAX_CONTEXT_KEYS = 32;
|
|
7
|
+
export const MAX_CONTEXT_VALUE = 256;
|
|
8
|
+
export const MAX_MESSAGE = 1000;
|
|
9
|
+
export const MAX_FRAMES = 15;
|
|
10
|
+
const processContext = {}; // environment / release, from init()
|
|
11
|
+
const contextStore = new AsyncLocalStorage();
|
|
12
|
+
let redactHook;
|
|
13
|
+
let redactFailed = false;
|
|
14
|
+
const warned = new Set();
|
|
15
|
+
/** Log a warning once per key per process. */
|
|
16
|
+
export function warnOnce(key, message) {
|
|
17
|
+
if (warned.has(key))
|
|
18
|
+
return;
|
|
19
|
+
warned.add(key);
|
|
20
|
+
console.warn(`agentfences: ${message}`);
|
|
21
|
+
}
|
|
22
|
+
export function configure(environment, release, redact) {
|
|
23
|
+
for (const k of Object.keys(processContext))
|
|
24
|
+
delete processContext[k];
|
|
25
|
+
if (environment)
|
|
26
|
+
processContext.environment = environment.slice(0, MAX_CONTEXT_VALUE);
|
|
27
|
+
if (release)
|
|
28
|
+
processContext.release = release.slice(0, MAX_CONTEXT_VALUE);
|
|
29
|
+
redactHook = redact;
|
|
30
|
+
redactFailed = false;
|
|
31
|
+
}
|
|
32
|
+
/**
|
|
33
|
+
* Tag every run started inside fn, e.g. with the end user or session it serves:
|
|
34
|
+
*
|
|
35
|
+
* await context({ user_id: "u_123", session_id: "s_9" }, () => supportAgent(question))
|
|
36
|
+
*
|
|
37
|
+
* Calls nest; inner values win. Values are stored as strings (up to 256 characters,
|
|
38
|
+
* 32 keys); null and undefined leave a key out.
|
|
39
|
+
*/
|
|
40
|
+
export function context(values, fn) {
|
|
41
|
+
const merged = { ...(contextStore.getStore() ?? {}) };
|
|
42
|
+
for (const [k, v] of Object.entries(values)) {
|
|
43
|
+
if (v !== null && v !== undefined)
|
|
44
|
+
merged[k] = String(v).slice(0, MAX_CONTEXT_VALUE);
|
|
45
|
+
}
|
|
46
|
+
if (Object.keys(merged).length > MAX_CONTEXT_KEYS) {
|
|
47
|
+
throw new RangeError(`agentfences.context() takes at most ${MAX_CONTEXT_KEYS} keys`);
|
|
48
|
+
}
|
|
49
|
+
return contextStore.run(merged, fn);
|
|
50
|
+
}
|
|
51
|
+
export function currentContext() {
|
|
52
|
+
return { ...processContext, ...(contextStore.getStore() ?? {}) };
|
|
53
|
+
}
|
|
54
|
+
/** `Type: message`, or just the type when there's no message. */
|
|
55
|
+
export function errorText(e) {
|
|
56
|
+
const type = errorType(e);
|
|
57
|
+
const message = e instanceof Error ? e.message : e === undefined ? "" : String(e);
|
|
58
|
+
return (message ? `${type}: ${message}` : type).slice(0, MAX_MESSAGE);
|
|
59
|
+
}
|
|
60
|
+
export function errorType(e) {
|
|
61
|
+
if (e instanceof Error)
|
|
62
|
+
return e.name && e.name !== "Error" ? e.name : e.constructor.name;
|
|
63
|
+
return typeof e;
|
|
64
|
+
}
|
|
65
|
+
/** Type, message and the last frames of the stack (innermost last), for the run's error report. */
|
|
66
|
+
export function exceptionInfo(e) {
|
|
67
|
+
const frames = e instanceof Error && e.stack
|
|
68
|
+
? e.stack.split("\n").slice(1).map((l) => l.trim()).filter((l) => l.startsWith("at ")).map((l) => {
|
|
69
|
+
const m = l.match(/^at (?:(.+?) \()?(.+?):(\d+):\d+\)?$/); // "at fn (file:line:col)" or "at file:line:col"
|
|
70
|
+
return m ? `${m[2]}:${m[3]} in ${m[1] ?? "<anonymous>"}` : l.slice(3);
|
|
71
|
+
})
|
|
72
|
+
: [];
|
|
73
|
+
return {
|
|
74
|
+
type: errorType(e),
|
|
75
|
+
message: (e instanceof Error ? e.message : String(e)).slice(0, MAX_MESSAGE),
|
|
76
|
+
stack: frames.reverse().slice(-MAX_FRAMES),
|
|
77
|
+
};
|
|
78
|
+
}
|
|
79
|
+
/**
|
|
80
|
+
* Pass an outgoing event through the user's redact hook. Returns the event to send, or
|
|
81
|
+
* null to drop it. If the hook throws or returns something else, the event is dropped:
|
|
82
|
+
* never send unredacted data, and never crash the agent.
|
|
83
|
+
*/
|
|
84
|
+
export function redact(event) {
|
|
85
|
+
if (!redactHook)
|
|
86
|
+
return event;
|
|
87
|
+
try {
|
|
88
|
+
const out = redactHook(structuredClone(event));
|
|
89
|
+
if (out === null || out === undefined)
|
|
90
|
+
return null;
|
|
91
|
+
if (typeof out !== "object" || Array.isArray(out))
|
|
92
|
+
throw new TypeError(`returned ${typeof out}, not an object or null`);
|
|
93
|
+
return out;
|
|
94
|
+
}
|
|
95
|
+
catch (e) {
|
|
96
|
+
if (!redactFailed) {
|
|
97
|
+
redactFailed = true;
|
|
98
|
+
console.warn(`agentfences: redact hook failed (${errorText(e)}); dropping events it fails on`);
|
|
99
|
+
}
|
|
100
|
+
return null;
|
|
101
|
+
}
|
|
102
|
+
}
|
package/dist/index.d.ts
ADDED
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
export { init, governed, checkpoint, logDecision, flush, getActiveRun, Run } from "./core.js";
|
|
2
|
+
export type { CheckpointResult, InitOptions, Limits, Extra, Span } from "./core.js";
|
|
3
|
+
export { context } from "./events.js";
|
|
4
|
+
export type { RedactHook, Event } from "./events.js";
|
|
5
|
+
export { tool } from "./tools.js";
|
|
6
|
+
export { requestApproval } from "./control.js";
|
|
7
|
+
export type { Approval } from "./control.js";
|
|
8
|
+
export { priceOf } from "./pricing.js";
|
|
9
|
+
export type { CustomPrice } from "./pricing.js";
|
|
10
|
+
export { FencesError, FencesAuthError, AgentQuarantined, FencesStop } from "./errors.js";
|
|
11
|
+
export { VERSION } from "./version.js";
|
package/dist/index.js
ADDED
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
export { init, governed, checkpoint, logDecision, flush, getActiveRun, Run } from "./core.js";
|
|
2
|
+
export { context } from "./events.js";
|
|
3
|
+
export { tool } from "./tools.js";
|
|
4
|
+
export { requestApproval } from "./control.js";
|
|
5
|
+
export { priceOf } from "./pricing.js";
|
|
6
|
+
export { FencesError, FencesAuthError, AgentQuarantined, FencesStop } from "./errors.js";
|
|
7
|
+
export { VERSION } from "./version.js";
|