agentfences 0.3.0 → 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 +20 -1
- package/dist/client.d.ts +3 -0
- package/dist/client.js +3 -0
- package/dist/control.d.ts +43 -0
- package/dist/control.js +162 -0
- package/dist/core.d.ts +7 -0
- package/dist/core.js +25 -5
- package/dist/index.d.ts +2 -0
- package/dist/index.js +1 -0
- package/dist/version.d.ts +1 -1
- package/dist/version.js +1 -1
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -59,6 +59,25 @@ init({ apiKey: process.env.FENCES_API_KEY, endpoint: "https://your-fences-host",
|
|
|
59
59
|
|
|
60
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`; a quarantined agent throws `AgentQuarantined` before any of its code runs. In serverless handlers, `await flush()` before returning so queued decisions and spans are sent.
|
|
61
61
|
|
|
62
|
+
## Live control (from 0.4.0)
|
|
63
|
+
|
|
64
|
+
From version 0.4.0, which isn't on npm yet: in cloud mode a heartbeat (every 15 seconds, `init({ heartbeatS })`) lets whoever is on call act on a running agent from the dashboard:
|
|
65
|
+
|
|
66
|
+
- **Stop:** the next `checkpoint()` returns `breachType: "stopped_by_user"`, so the agent wraps up gracefully.
|
|
67
|
+
- **Pause and resume:** a paused run waits inside its next `checkpoint()` until it's resumed; after `pauseTimeoutS` (default one hour) it returns `breachType: "paused"`.
|
|
68
|
+
- **Limits** changed in the dashboard apply to the run immediately. **Quarantine** makes the governed call throw `AgentQuarantined` before any agent code runs.
|
|
69
|
+
|
|
70
|
+
An agent can ask a person before doing something risky:
|
|
71
|
+
|
|
72
|
+
```ts
|
|
73
|
+
import { requestApproval } from "agentfences";
|
|
74
|
+
|
|
75
|
+
const approval = await requestApproval("Refund $240 to order 1182?", { amountUsd: 240, timeoutS: 600 });
|
|
76
|
+
if (!approval.granted) return `I didn't issue the refund: ${approval.note}`;
|
|
77
|
+
```
|
|
78
|
+
|
|
79
|
+
An approved amount raises the run's budget. It never hangs or throws: in local mode, outside a run, or on a server without approvals it's denied right away, and with no answer it's denied after `timeoutS`. The heartbeat's timer never keeps your process alive on its own.
|
|
80
|
+
|
|
62
81
|
## Not yet in the TypeScript SDK
|
|
63
82
|
|
|
64
|
-
The Python SDK also
|
|
83
|
+
The Python SDK also records OpenAI/Anthropic calls automatically (`instrument`) and integrates with LangGraph and the OpenAI Agents SDK. Everything here follows the same server protocol ([PROTOCOL.md](../PROTOCOL.md)).
|
package/dist/client.d.ts
CHANGED
|
@@ -9,6 +9,9 @@ export declare class FencesClient {
|
|
|
9
9
|
logDecision(runId: string, body: Record<string, unknown>): Promise<Reply>;
|
|
10
10
|
endRun(runId: string, body: Record<string, unknown>): Promise<Reply>;
|
|
11
11
|
logSpans(runId: string, spans: unknown[]): Promise<Reply>;
|
|
12
|
+
/** Report live runs; the reply may carry commands for them. */
|
|
13
|
+
heartbeat(runs: unknown[]): Promise<Reply>;
|
|
14
|
+
requestApproval(runId: string, body: Record<string, unknown>): Promise<Reply>;
|
|
12
15
|
/**
|
|
13
16
|
* optional: an endpoint older servers may not have. A missing route returns
|
|
14
17
|
* { unsupported: true } and nothing throws, not even a rejected key.
|
package/dist/client.js
CHANGED
|
@@ -17,6 +17,9 @@ export class FencesClient {
|
|
|
17
17
|
logDecision(runId, body) { return this.post(`/api/runs/${runId}/decisions`, body); }
|
|
18
18
|
endRun(runId, body) { return this.post(`/api/runs/${runId}/end`, body); }
|
|
19
19
|
logSpans(runId, spans) { return this.post(`/api/runs/${runId}/spans`, { spans }, true); }
|
|
20
|
+
/** Report live runs; the reply may carry commands for them. */
|
|
21
|
+
heartbeat(runs) { return this.post("/api/heartbeat", { runs }, true); }
|
|
22
|
+
requestApproval(runId, body) { return this.post(`/api/runs/${runId}/approvals`, body, true); }
|
|
20
23
|
/**
|
|
21
24
|
* optional: an endpoint older servers may not have. A missing route returns
|
|
22
25
|
* { unsupported: true } and nothing throws, not even a rejected key.
|
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
import type { FencesClient, Reply } from "./client.js";
|
|
2
|
+
import type { Run } from "./core.js";
|
|
3
|
+
export interface Approval {
|
|
4
|
+
granted: boolean;
|
|
5
|
+
/** Who answered, as the dashboard reports it. */
|
|
6
|
+
by?: string | null;
|
|
7
|
+
/** Their note, or why no one could answer. */
|
|
8
|
+
note?: string | null;
|
|
9
|
+
/** The amount approved, if one was asked for. */
|
|
10
|
+
amountUsd?: number | null;
|
|
11
|
+
}
|
|
12
|
+
/** Live-control state a run carries. */
|
|
13
|
+
export interface Control {
|
|
14
|
+
stopped: boolean;
|
|
15
|
+
paused: boolean;
|
|
16
|
+
pauseExpired: boolean;
|
|
17
|
+
/** approval id -> the answer, or null while waiting */
|
|
18
|
+
approvals: Map<string, Approval | null>;
|
|
19
|
+
awaiting: number;
|
|
20
|
+
}
|
|
21
|
+
export declare const newControl: () => Control;
|
|
22
|
+
export declare let pauseTimeoutMs: number;
|
|
23
|
+
export declare function configure(c: FencesClient | null, heartbeatS: number, pauseTimeoutS: number): void;
|
|
24
|
+
export declare function register(run: Run): void;
|
|
25
|
+
export declare function unregister(run: Run): void;
|
|
26
|
+
/** Apply the commands addressed to this run (from a heartbeat or a checkpoint reply). Each is safe to apply twice. */
|
|
27
|
+
export declare function apply(run: Run, commands: Reply[] | undefined): void;
|
|
28
|
+
/** Hold the agent at its checkpoint while its run is paused, up to pauseTimeoutS. */
|
|
29
|
+
export declare function waitIfPaused(run: Run): Promise<void>;
|
|
30
|
+
/**
|
|
31
|
+
* Ask a person, through the Fences dashboard, to approve something, and wait for the answer:
|
|
32
|
+
*
|
|
33
|
+
* const approval = await requestApproval("Refund $240 to order 1182?", { amountUsd: 240 });
|
|
34
|
+
* if (!approval.granted) return `I didn't issue the refund: ${approval.note}`;
|
|
35
|
+
*
|
|
36
|
+
* If an amount is approved, the run's budget goes up by that much. Returns denied right away in
|
|
37
|
+
* local mode, outside a run, or when the server can't take requests, and denied after timeoutS
|
|
38
|
+
* (default 600) with no answer: it never hangs and never throws.
|
|
39
|
+
*/
|
|
40
|
+
export declare function requestApproval(reason: string, options?: {
|
|
41
|
+
amountUsd?: number;
|
|
42
|
+
timeoutS?: number;
|
|
43
|
+
}): Promise<Approval>;
|
package/dist/control.js
ADDED
|
@@ -0,0 +1,162 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Live control from the Fences dashboard: a heartbeat that reports live runs and brings back
|
|
3
|
+
* commands (stop, pause, resume, limits, approval answers), pausing at checkpoints, and
|
|
4
|
+
* approval requests. Cloud mode only; a server without these endpoints gets one warning and
|
|
5
|
+
* runs carry on as before. Same behaviour as the Python SDK (PROTOCOL.md, "Live control").
|
|
6
|
+
*/
|
|
7
|
+
import { randomUUID } from "node:crypto";
|
|
8
|
+
const FAST_MS = 2000; // heartbeat interval while a run is paused or waiting for an approval
|
|
9
|
+
export const newControl = () => ({ stopped: false, paused: false, pauseExpired: false, approvals: new Map(), awaiting: 0 });
|
|
10
|
+
const live = new Map(); // run id -> runs in progress
|
|
11
|
+
let client = null;
|
|
12
|
+
let intervalMs = 15_000;
|
|
13
|
+
export let pauseTimeoutMs = 3_600_000;
|
|
14
|
+
const supported = { heartbeat: true, approvals: true };
|
|
15
|
+
let timer = null;
|
|
16
|
+
let beating = false;
|
|
17
|
+
export function configure(c, heartbeatS, pauseTimeoutS) {
|
|
18
|
+
client = c;
|
|
19
|
+
intervalMs = heartbeatS * 1000;
|
|
20
|
+
pauseTimeoutMs = pauseTimeoutS * 1000;
|
|
21
|
+
supported.heartbeat = supported.approvals = true;
|
|
22
|
+
schedule(0); // a new client or interval takes effect now, not after the old wait
|
|
23
|
+
}
|
|
24
|
+
export function register(run) {
|
|
25
|
+
live.set(run.runId, run);
|
|
26
|
+
if (!timer)
|
|
27
|
+
schedule(nextDelay());
|
|
28
|
+
}
|
|
29
|
+
export function unregister(run) {
|
|
30
|
+
live.delete(run.runId);
|
|
31
|
+
}
|
|
32
|
+
function unsupported(kind, what, consequence) {
|
|
33
|
+
if (!supported[kind])
|
|
34
|
+
return;
|
|
35
|
+
supported[kind] = false;
|
|
36
|
+
console.warn(`agentfences: this Fences server doesn't support ${what} yet, so ${consequence}`);
|
|
37
|
+
}
|
|
38
|
+
// ── Commands ──────────────────────────────────────────────────────────────────
|
|
39
|
+
const LIMITS = { budget_usd: "budgetUsd", max_iterations: "maxIterations", max_duration_ms: "maxDurationMs", max_tokens: "maxTokens" };
|
|
40
|
+
/** Apply the commands addressed to this run (from a heartbeat or a checkpoint reply). Each is safe to apply twice. */
|
|
41
|
+
export function apply(run, commands) {
|
|
42
|
+
for (const c of commands ?? []) {
|
|
43
|
+
if (c.run_id !== undefined && c.run_id !== run.runId)
|
|
44
|
+
continue;
|
|
45
|
+
const ctl = run.control;
|
|
46
|
+
if (c.type === "stop") {
|
|
47
|
+
ctl.stopped = true;
|
|
48
|
+
ctl.paused = false;
|
|
49
|
+
}
|
|
50
|
+
else if (c.type === "pause") {
|
|
51
|
+
ctl.paused = true;
|
|
52
|
+
schedule(0);
|
|
53
|
+
}
|
|
54
|
+
else if (c.type === "resume") {
|
|
55
|
+
ctl.paused = false;
|
|
56
|
+
}
|
|
57
|
+
else if (c.type === "limits") {
|
|
58
|
+
for (const [wire, field] of Object.entries(LIMITS)) {
|
|
59
|
+
if (c[wire] !== undefined && c[wire] !== null)
|
|
60
|
+
run[field] = c[wire]; // eslint-disable-line @typescript-eslint/no-explicit-any
|
|
61
|
+
}
|
|
62
|
+
}
|
|
63
|
+
else if (c.type === "approval" && ctl.approvals.has(c.approval_id)) {
|
|
64
|
+
ctl.approvals.set(c.approval_id, { granted: !!c.granted, by: c.by ?? null, note: c.note ?? null, amountUsd: c.amount_usd ?? null });
|
|
65
|
+
}
|
|
66
|
+
}
|
|
67
|
+
}
|
|
68
|
+
const busy = () => [...live.values()].some((r) => r.control.paused || r.control.awaiting > 0);
|
|
69
|
+
const nextDelay = () => (busy() ? Math.min(FAST_MS, intervalMs) : intervalMs); // faster while busy, never slower
|
|
70
|
+
function schedule(delayMs) {
|
|
71
|
+
if (timer)
|
|
72
|
+
clearTimeout(timer);
|
|
73
|
+
timer = null;
|
|
74
|
+
if (!client || !supported.heartbeat || !live.size)
|
|
75
|
+
return;
|
|
76
|
+
timer = setTimeout(beat, delayMs);
|
|
77
|
+
timer.unref(); // the heartbeat never keeps the process alive on its own
|
|
78
|
+
}
|
|
79
|
+
async function beat() {
|
|
80
|
+
timer = null;
|
|
81
|
+
if (beating || !client || !live.size)
|
|
82
|
+
return schedule(nextDelay());
|
|
83
|
+
beating = true;
|
|
84
|
+
try {
|
|
85
|
+
const runs = [...live.values()];
|
|
86
|
+
const resp = await client.heartbeat(runs.map((r) => ({ run_id: r.runId, iterations: r.iterations, spent_usd: r.costUsd,
|
|
87
|
+
tokens_used: r.tokensUsed, paused: r.control.paused })));
|
|
88
|
+
if (resp.unsupported) {
|
|
89
|
+
unsupported("heartbeat", "the heartbeat", "a stop from the dashboard applies at the next checkpoint and pause isn't available");
|
|
90
|
+
return;
|
|
91
|
+
}
|
|
92
|
+
for (const run of runs)
|
|
93
|
+
apply(run, resp.commands?.filter((c) => c.run_id === run.runId));
|
|
94
|
+
}
|
|
95
|
+
catch { /* a failed heartbeat is retried next time */ }
|
|
96
|
+
finally {
|
|
97
|
+
beating = false;
|
|
98
|
+
}
|
|
99
|
+
schedule(nextDelay());
|
|
100
|
+
}
|
|
101
|
+
// ── Pausing ───────────────────────────────────────────────────────────────────
|
|
102
|
+
/** Hold the agent at its checkpoint while its run is paused, up to pauseTimeoutS. */
|
|
103
|
+
export async function waitIfPaused(run) {
|
|
104
|
+
const deadline = Date.now() + pauseTimeoutMs;
|
|
105
|
+
while (run.control.paused) {
|
|
106
|
+
if (Date.now() >= deadline) {
|
|
107
|
+
run.control.paused = false;
|
|
108
|
+
run.control.pauseExpired = true;
|
|
109
|
+
return;
|
|
110
|
+
}
|
|
111
|
+
await new Promise((ok) => setTimeout(ok, 200));
|
|
112
|
+
}
|
|
113
|
+
}
|
|
114
|
+
// ── Approvals ─────────────────────────────────────────────────────────────────
|
|
115
|
+
/**
|
|
116
|
+
* Ask a person, through the Fences dashboard, to approve something, and wait for the answer:
|
|
117
|
+
*
|
|
118
|
+
* const approval = await requestApproval("Refund $240 to order 1182?", { amountUsd: 240 });
|
|
119
|
+
* if (!approval.granted) return `I didn't issue the refund: ${approval.note}`;
|
|
120
|
+
*
|
|
121
|
+
* If an amount is approved, the run's budget goes up by that much. Returns denied right away in
|
|
122
|
+
* local mode, outside a run, or when the server can't take requests, and denied after timeoutS
|
|
123
|
+
* (default 600) with no answer: it never hangs and never throws.
|
|
124
|
+
*/
|
|
125
|
+
export async function requestApproval(reason, options = {}) {
|
|
126
|
+
const { getActiveRun, recordSpan } = await import("./core.js");
|
|
127
|
+
const run = getActiveRun();
|
|
128
|
+
const timeoutS = options.timeoutS ?? 600, amountUsd = options.amountUsd ?? null;
|
|
129
|
+
if (!run)
|
|
130
|
+
return { granted: false, note: "requestApproval() was called outside a governed run" };
|
|
131
|
+
if (!client)
|
|
132
|
+
return { granted: false, note: "Local mode: there is no one to approve this request" };
|
|
133
|
+
if (!supported.approvals)
|
|
134
|
+
return { granted: false, note: "This Fences server doesn't support approvals" };
|
|
135
|
+
const approvalId = randomUUID(), started = performance.now();
|
|
136
|
+
// timeout_s lets the server expire the request when we stop waiting
|
|
137
|
+
const resp = await client.requestApproval(run.runId, { approval_id: approvalId, reason, amount_usd: amountUsd, timeout_s: timeoutS });
|
|
138
|
+
if (resp.unsupported) {
|
|
139
|
+
unsupported("approvals", "approval requests", "they are denied");
|
|
140
|
+
return { granted: false, note: "This Fences server doesn't support approvals" };
|
|
141
|
+
}
|
|
142
|
+
if (resp.network_error)
|
|
143
|
+
return { granted: false, note: "Couldn't reach Fences to ask for approval" };
|
|
144
|
+
run.control.approvals.set(approvalId, null);
|
|
145
|
+
run.control.awaiting += 1;
|
|
146
|
+
schedule(0); // poll fast while someone decides
|
|
147
|
+
const deadline = Date.now() + timeoutS * 1000;
|
|
148
|
+
while (run.control.approvals.get(approvalId) === null && Date.now() < deadline) {
|
|
149
|
+
await new Promise((ok) => setTimeout(ok, 100));
|
|
150
|
+
}
|
|
151
|
+
run.control.awaiting -= 1;
|
|
152
|
+
const answer = run.control.approvals.get(approvalId) ?? { granted: false, note: "No answer before the timeout" };
|
|
153
|
+
run.control.approvals.delete(approvalId);
|
|
154
|
+
if (answer.granted && answer.amountUsd && run.budgetUsd !== null)
|
|
155
|
+
run.budgetUsd += answer.amountUsd; // an unlimited run stays unlimited
|
|
156
|
+
recordSpan("fences.approval", performance.now() - started, {
|
|
157
|
+
"fences.approval.id": approvalId, "fences.approval.reason": reason, "fences.approval.amount_usd": amountUsd,
|
|
158
|
+
"fences.approval.granted": answer.granted, "fences.approval.by": answer.by, "fences.approval.note": answer.note,
|
|
159
|
+
"fences.approval.granted_usd": answer.amountUsd,
|
|
160
|
+
});
|
|
161
|
+
return answer;
|
|
162
|
+
}
|
package/dist/core.d.ts
CHANGED
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import { FencesClient } from "./client.js";
|
|
2
|
+
import * as control from "./control.js";
|
|
2
3
|
import * as events from "./events.js";
|
|
3
4
|
import { type CustomPrice } from "./pricing.js";
|
|
4
5
|
export interface CheckpointResult {
|
|
@@ -55,6 +56,8 @@ export declare class Run {
|
|
|
55
56
|
lastBreach: string | null;
|
|
56
57
|
exception?: events.ExceptionInfo;
|
|
57
58
|
warned: boolean;
|
|
59
|
+
/** Live control from the dashboard (see control.ts). */
|
|
60
|
+
readonly control: control.Control;
|
|
58
61
|
constructor(agentName: string, budgetUsd: number | null, maxIterations: number, maxDurationMs: number, maxTokens: number, context: Record<string, string>);
|
|
59
62
|
get durationMs(): number;
|
|
60
63
|
}
|
|
@@ -77,6 +80,10 @@ export interface InitOptions {
|
|
|
77
80
|
release?: string;
|
|
78
81
|
/** Sees every outgoing event and returns it (changed or not), or null to drop it. */
|
|
79
82
|
redact?: events.RedactHook;
|
|
83
|
+
/** Seconds between heartbeats reporting live runs (cloud mode). Default 15. */
|
|
84
|
+
heartbeatS?: number;
|
|
85
|
+
/** How long a paused run waits at its checkpoint before stopping with breach "paused". Default 3600. */
|
|
86
|
+
pauseTimeoutS?: number;
|
|
80
87
|
}
|
|
81
88
|
/** Call once at startup, before any governed function runs. */
|
|
82
89
|
export declare function init(options?: InitOptions): void;
|
package/dist/core.js
CHANGED
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import { AsyncLocalStorage } from "node:async_hooks";
|
|
2
2
|
import { randomUUID } from "node:crypto";
|
|
3
3
|
import { FencesClient } from "./client.js";
|
|
4
|
+
import * as control from "./control.js";
|
|
4
5
|
import { AgentQuarantined, FencesStop } from "./errors.js";
|
|
5
6
|
import * as events from "./events.js";
|
|
6
7
|
import { costOf, setCustomPrices } from "./pricing.js";
|
|
@@ -57,6 +58,8 @@ export class Run {
|
|
|
57
58
|
lastBreach = null;
|
|
58
59
|
exception;
|
|
59
60
|
warned = false;
|
|
61
|
+
/** Live control from the dashboard (see control.ts). */
|
|
62
|
+
control = control.newControl();
|
|
60
63
|
constructor(agentName, budgetUsd, maxIterations, maxDurationMs, maxTokens, context) {
|
|
61
64
|
this.agentName = agentName;
|
|
62
65
|
this.budgetUsd = budgetUsd;
|
|
@@ -83,15 +86,17 @@ export function init(options = {}) {
|
|
|
83
86
|
failClosed = !!options.failClosed;
|
|
84
87
|
if (localOnly) {
|
|
85
88
|
client = null;
|
|
86
|
-
return;
|
|
87
89
|
}
|
|
88
|
-
if (!options.apiKey) {
|
|
90
|
+
else if (!options.apiKey) {
|
|
89
91
|
throw new Error("apiKey is required unless localOnly is set. Use init({ localOnly: true }) for local usage.");
|
|
90
92
|
}
|
|
91
|
-
|
|
93
|
+
else {
|
|
94
|
+
client = new FencesClient(options.apiKey, options.endpoint ?? "http://localhost:8000");
|
|
95
|
+
}
|
|
96
|
+
control.configure(client, options.heartbeatS ?? 15, options.pauseTimeoutS ?? 3600);
|
|
92
97
|
}
|
|
93
98
|
/** @internal For tests: point the SDK at a fake server client. */
|
|
94
|
-
export function _setClient(c) { client = c; }
|
|
99
|
+
export function _setClient(c) { client = c; control.configure(c, 15, 3600); }
|
|
95
100
|
function requireInit() {
|
|
96
101
|
if (!localOnly && !client) {
|
|
97
102
|
throw new Error("Call agentfences.init() before using Fences. For local usage: init({ localOnly: true })");
|
|
@@ -118,6 +123,7 @@ export function governed(fn, options = {}) {
|
|
|
118
123
|
return async (...args) => {
|
|
119
124
|
requireInit();
|
|
120
125
|
const run = await startRun(name, options);
|
|
126
|
+
control.register(run);
|
|
121
127
|
let error, failed = false;
|
|
122
128
|
try {
|
|
123
129
|
return await store.run(run, () => fn(...args));
|
|
@@ -179,6 +185,7 @@ function noBudget(agentName, how) {
|
|
|
179
185
|
events.warnOnce(`${agentName}:no_budget`, `${agentName} has no budget, so its spend isn't capped. ${how}`);
|
|
180
186
|
}
|
|
181
187
|
async function endRun(run, status, error) {
|
|
188
|
+
control.unregister(run);
|
|
182
189
|
if (!client)
|
|
183
190
|
return;
|
|
184
191
|
// The run must end even if redaction drops its error details
|
|
@@ -214,7 +221,12 @@ export async function checkpoint(response, extra = {}) {
|
|
|
214
221
|
const resp = client ? await client.checkpoint(run.runId, {
|
|
215
222
|
cost_delta_usd: stepCost, tokens_used: stepTokens, iterations: run.iterations, duration_ms: run.durationMs
|
|
216
223
|
}) : null;
|
|
217
|
-
|
|
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;
|
|
218
230
|
}
|
|
219
231
|
function localBreach(run) {
|
|
220
232
|
// Limits trip only once exceeded, same rule as the server; round away float drift (0.02*5 != 0.10)
|
|
@@ -240,6 +252,7 @@ function decide(run, resp) {
|
|
|
240
252
|
run.lastBreach = localBreach(run);
|
|
241
253
|
}
|
|
242
254
|
else {
|
|
255
|
+
control.apply(run, resp.commands);
|
|
243
256
|
// The server is authoritative: adopt its totals and (on a breach) its current limits
|
|
244
257
|
run.costUsd = resp.spent_usd ?? run.costUsd;
|
|
245
258
|
run.iterations = resp.iterations ?? run.iterations;
|
|
@@ -251,6 +264,13 @@ function decide(run, resp) {
|
|
|
251
264
|
// A fresh breach, or a 409 because the run is still fenced from an earlier one
|
|
252
265
|
run.lastBreach = resp.ok ? null : (resp.breach ?? run.lastBreach ?? "limit_reached");
|
|
253
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";
|
|
254
274
|
return run.lastBreach ? breachResult(run.lastBreach, run) : okResult(run.costUsd);
|
|
255
275
|
}
|
|
256
276
|
// ── Decision trail and spans ──────────────────────────────────────────────────
|
package/dist/index.d.ts
CHANGED
|
@@ -3,6 +3,8 @@ export type { CheckpointResult, InitOptions, Limits, Extra, Span } from "./core.
|
|
|
3
3
|
export { context } from "./events.js";
|
|
4
4
|
export type { RedactHook, Event } from "./events.js";
|
|
5
5
|
export { tool } from "./tools.js";
|
|
6
|
+
export { requestApproval } from "./control.js";
|
|
7
|
+
export type { Approval } from "./control.js";
|
|
6
8
|
export { priceOf } from "./pricing.js";
|
|
7
9
|
export type { CustomPrice } from "./pricing.js";
|
|
8
10
|
export { FencesError, FencesAuthError, AgentQuarantined, FencesStop } from "./errors.js";
|
package/dist/index.js
CHANGED
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
export { init, governed, checkpoint, logDecision, flush, getActiveRun, Run } from "./core.js";
|
|
2
2
|
export { context } from "./events.js";
|
|
3
3
|
export { tool } from "./tools.js";
|
|
4
|
+
export { requestApproval } from "./control.js";
|
|
4
5
|
export { priceOf } from "./pricing.js";
|
|
5
6
|
export { FencesError, FencesAuthError, AgentQuarantined, FencesStop } from "./errors.js";
|
|
6
7
|
export { VERSION } from "./version.js";
|
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.4.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.4.0";
|
package/package.json
CHANGED