guardplane 0.1.0 → 0.2.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 +44 -1
- package/dist/burn-rate.d.ts +97 -0
- package/dist/burn-rate.js +214 -0
- package/dist/control-plane.d.ts +29 -0
- package/dist/control-plane.js +63 -5
- package/dist/index.d.ts +3 -1
- package/dist/index.js +1 -0
- package/package.json +5 -1
package/README.md
CHANGED
|
@@ -7,7 +7,7 @@
|
|
|
7
7
|
|
|
8
8
|
A minimal, framework-agnostic **control plane for AI agent fleets**: a signal plane, a confidence gate, and a kill switch. Zero runtime dependencies.
|
|
9
9
|
|
|
10
|
-
AI agents have started causing the production incidents they were built to resolve. In April 2026 a coding agent deleted a company's production database and its backups in about nine seconds. The pattern is not rare, and it has the same shape every time: an agent takes a real action on an incomplete view of the world, and nothing stands between its intent and the damage.
|
|
10
|
+
AI agents have started causing the production incidents they were built to resolve. In April 2026 a coding agent [deleted a company's production database and its backups](https://www.theregister.com/2026/04/27/cursoropus_agent_snuffs_out_pocketos/) in about nine seconds. The pattern is not rare, and it has the same shape every time: an agent takes a real action on an incomplete view of the world, and nothing stands between its intent and the damage.
|
|
11
11
|
|
|
12
12
|
This is a small reference implementation of the thing that stands in between. It is the companion to the write-up [**Who Operates the Operators?**](https://mkadri85.github.io/blog/agentic-sre-agent-fleets/).
|
|
13
13
|
|
|
@@ -148,6 +148,49 @@ The gate is the only interesting decision in the system, so it is worth stating
|
|
|
148
148
|
|
|
149
149
|
The default threshold is `0.8` and the default auto-allowed actions are `reroute` and `pause`. Both are configurable per plane.
|
|
150
150
|
|
|
151
|
+
## The burn-rate breaker (new in 0.2)
|
|
152
|
+
|
|
153
|
+
Per-action gates catch the bad call in front of you. They do not catch an
|
|
154
|
+
agent whose judgment is slowly degrading. The `BurnRateMonitor` alarms on the
|
|
155
|
+
derivative, not the level: each agent is compared to its OWN trailing
|
|
156
|
+
baseline, and when its current failure rate burns past that baseline (default
|
|
157
|
+
2x), the agent is latched into `propose_only` - it may keep producing, but its
|
|
158
|
+
outputs are proposals for a human, not actions.
|
|
159
|
+
|
|
160
|
+
```ts
|
|
161
|
+
import { controlPlane } from "guardplane";
|
|
162
|
+
|
|
163
|
+
const plane = controlPlane({
|
|
164
|
+
detectors: [],
|
|
165
|
+
actions,
|
|
166
|
+
onEscalate: (ctx) => notifyHuman(ctx),
|
|
167
|
+
burnRate: { tripRatio: 2, cooldownMs: 10 * 60_000 },
|
|
168
|
+
onDemote: ({ agentId, snapshot }) =>
|
|
169
|
+
page(`agent ${agentId} demoted: ${snapshot.reason}`),
|
|
170
|
+
});
|
|
171
|
+
|
|
172
|
+
const obs = plane.wrap("billing-agent").observe({ type: "tool_call", ok: false });
|
|
173
|
+
obs.mode; // "active" | "propose_only"
|
|
174
|
+
plane.fleet(); // one BurnSnapshot per agent + kill-switch state
|
|
175
|
+
```
|
|
176
|
+
|
|
177
|
+
Three guards keep a degrading agent from teaching the baseline that failure
|
|
178
|
+
is normal:
|
|
179
|
+
|
|
180
|
+
- **learning freeze** - while burn rate is elevated, the baseline stops
|
|
181
|
+
absorbing events, so an active incident cannot poison it
|
|
182
|
+
- **asymmetric learning** - the baseline learns improvement fast and
|
|
183
|
+
degradation slowly
|
|
184
|
+
- **absolute ceiling** - a level backstop (default 60% failure rate) that
|
|
185
|
+
catches the slow boil the ratio can never see, including during cold start
|
|
186
|
+
|
|
187
|
+
The design follows the multi-window burn-rate alerting practice from the
|
|
188
|
+
[Google SRE Workbook, ch. 5](https://sre.google/workbook/alerting-on-slos/),
|
|
189
|
+
pointed at agents instead of services. `BurnRateMonitor` is the kill switch's
|
|
190
|
+
softer sibling: `demote(agentId)` / `reset(agentId)` mirror `trip` / `reset`,
|
|
191
|
+
and `burnRateDetector(monitor)` adapts the latch into a standard `Detector`
|
|
192
|
+
for custom stacks.
|
|
193
|
+
|
|
151
194
|
## What this is, and is not
|
|
152
195
|
|
|
153
196
|
- It **is** a dependency-free skeleton you can read in one sitting and wire into an existing agent runtime in an afternoon.
|
|
@@ -0,0 +1,97 @@
|
|
|
1
|
+
import type { AgentEvent, AgentId, Detector } from "./types.js";
|
|
2
|
+
export type AgentMode = "active" | "propose_only";
|
|
3
|
+
/** One agent's burn-rate picture at a point in time. */
|
|
4
|
+
export interface BurnSnapshot {
|
|
5
|
+
agentId: AgentId;
|
|
6
|
+
/** Trailing baseline failure rate, 0..1 (EWMA over the agent's own history). */
|
|
7
|
+
baseline: number;
|
|
8
|
+
/** Failure rate over the current short window, 0..1. Null below minCurrentSamples. */
|
|
9
|
+
current: number | null;
|
|
10
|
+
/** current / max(baseline, baselineFloor). Null while still learning. */
|
|
11
|
+
burnRate: number | null;
|
|
12
|
+
samples: {
|
|
13
|
+
baseline: number;
|
|
14
|
+
current: number;
|
|
15
|
+
};
|
|
16
|
+
mode: AgentMode;
|
|
17
|
+
demotedAt?: number;
|
|
18
|
+
reason?: string;
|
|
19
|
+
}
|
|
20
|
+
export interface BurnRateOptions {
|
|
21
|
+
/** Burn ratio (current/baseline) that latches demotion. Default 2. */
|
|
22
|
+
tripRatio?: number;
|
|
23
|
+
/** Current-window length in ms. Default 60_000. */
|
|
24
|
+
currentWindowMs?: number;
|
|
25
|
+
/** Half-life of the trailing baseline EWMA, in ms. Default 30 minutes. */
|
|
26
|
+
baselineHalfLifeMs?: number;
|
|
27
|
+
/** Minimum events in the current window before a burn rate is computed. Default 8. */
|
|
28
|
+
minCurrentSamples?: number;
|
|
29
|
+
/** Minimum lifetime events before the baseline is trusted (cold start). Default 30. */
|
|
30
|
+
minBaselineSamples?: number;
|
|
31
|
+
/** Baseline is clamped up to this before dividing (avoids 0-baseline infinities). Default 0.02. */
|
|
32
|
+
baselineFloor?: number;
|
|
33
|
+
/** Level backstop: absolute current failure rate that trips regardless of ratio. Default 0.6. */
|
|
34
|
+
absoluteCeiling?: number;
|
|
35
|
+
/** Stop folding events into the baseline while burnRate exceeds this (poisoning guard). Default 1.25. */
|
|
36
|
+
freezeLearningAbove?: number;
|
|
37
|
+
/** Sustained-recovery ms before auto-promotion back to "active". Omit for manual-reset-only. */
|
|
38
|
+
cooldownMs?: number;
|
|
39
|
+
}
|
|
40
|
+
/**
|
|
41
|
+
* Per-agent trailing-baseline burn-rate breaker: alarm on the derivative, not
|
|
42
|
+
* the level. Each agent is compared to its OWN history; when its current
|
|
43
|
+
* failure rate burns past its baseline (or past an absolute ceiling), the
|
|
44
|
+
* agent is latched into "propose_only" until cooldown or manual reset.
|
|
45
|
+
*
|
|
46
|
+
* Poisoning guards, so a degrading agent cannot teach the baseline that
|
|
47
|
+
* failure is normal:
|
|
48
|
+
* - learning freezes while burn rate exceeds `freezeLearningAbove`
|
|
49
|
+
* - the baseline learns improvement fast and degradation slowly
|
|
50
|
+
* (asymmetric EWMA half-life)
|
|
51
|
+
* - `absoluteCeiling` is a level backstop that catches the slow boil the
|
|
52
|
+
* ratio can never see
|
|
53
|
+
*
|
|
54
|
+
* Clock discipline matches SignalPlane: learning uses event.ts, reads take
|
|
55
|
+
* `now` as a parameter. Never calls Date.now(). Zero dependencies, no Node
|
|
56
|
+
* APIs, runs in the browser.
|
|
57
|
+
*/
|
|
58
|
+
export declare class BurnRateMonitor {
|
|
59
|
+
private readonly agents;
|
|
60
|
+
private readonly demoteListeners;
|
|
61
|
+
private readonly promoteListeners;
|
|
62
|
+
private readonly tripRatio;
|
|
63
|
+
private readonly currentWindowMs;
|
|
64
|
+
private readonly baselineHalfLifeMs;
|
|
65
|
+
private readonly minCurrentSamples;
|
|
66
|
+
private readonly minBaselineSamples;
|
|
67
|
+
private readonly baselineFloor;
|
|
68
|
+
private readonly absoluteCeiling;
|
|
69
|
+
private readonly freezeLearningAbove;
|
|
70
|
+
private readonly cooldownMs?;
|
|
71
|
+
constructor(opts?: BurnRateOptions);
|
|
72
|
+
/** Feed one event. Uses event.ts as the clock; never Date.now(). */
|
|
73
|
+
record(event: AgentEvent): void;
|
|
74
|
+
/** Current picture for one agent. Pure read; pass the clock in. */
|
|
75
|
+
snapshot(agentId: AgentId, now: number): BurnSnapshot;
|
|
76
|
+
/** Fleet-level aggregated view: one snapshot per known agent. */
|
|
77
|
+
fleet(now: number): BurnSnapshot[];
|
|
78
|
+
mode(agentId: AgentId): AgentMode;
|
|
79
|
+
/** Manual demote, mirroring KillSwitch.trip. */
|
|
80
|
+
demote(agentId: AgentId, reason?: string, now?: number): void;
|
|
81
|
+
/** Manual reset back to active, mirroring KillSwitch.reset. */
|
|
82
|
+
reset(agentId: AgentId): void;
|
|
83
|
+
onDemote(fn: (snapshot: BurnSnapshot) => void): void;
|
|
84
|
+
onPromote(fn: (agentId: AgentId) => void): void;
|
|
85
|
+
private state;
|
|
86
|
+
private trim;
|
|
87
|
+
private rates;
|
|
88
|
+
private evaluate;
|
|
89
|
+
private latch;
|
|
90
|
+
private maybePromote;
|
|
91
|
+
}
|
|
92
|
+
/**
|
|
93
|
+
* Wraps a BurnRateMonitor as a standard Detector so it composes with the rest
|
|
94
|
+
* of the reasoning stack. Read-only view: feed the monitor via record();
|
|
95
|
+
* the adapter only reads the latch, keyed off the last event in the window.
|
|
96
|
+
*/
|
|
97
|
+
export declare function burnRateDetector(monitor: BurnRateMonitor): Detector;
|
|
@@ -0,0 +1,214 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Per-agent trailing-baseline burn-rate breaker: alarm on the derivative, not
|
|
3
|
+
* the level. Each agent is compared to its OWN history; when its current
|
|
4
|
+
* failure rate burns past its baseline (or past an absolute ceiling), the
|
|
5
|
+
* agent is latched into "propose_only" until cooldown or manual reset.
|
|
6
|
+
*
|
|
7
|
+
* Poisoning guards, so a degrading agent cannot teach the baseline that
|
|
8
|
+
* failure is normal:
|
|
9
|
+
* - learning freezes while burn rate exceeds `freezeLearningAbove`
|
|
10
|
+
* - the baseline learns improvement fast and degradation slowly
|
|
11
|
+
* (asymmetric EWMA half-life)
|
|
12
|
+
* - `absoluteCeiling` is a level backstop that catches the slow boil the
|
|
13
|
+
* ratio can never see
|
|
14
|
+
*
|
|
15
|
+
* Clock discipline matches SignalPlane: learning uses event.ts, reads take
|
|
16
|
+
* `now` as a parameter. Never calls Date.now(). Zero dependencies, no Node
|
|
17
|
+
* APIs, runs in the browser.
|
|
18
|
+
*/
|
|
19
|
+
export class BurnRateMonitor {
|
|
20
|
+
agents = new Map();
|
|
21
|
+
demoteListeners = [];
|
|
22
|
+
promoteListeners = [];
|
|
23
|
+
tripRatio;
|
|
24
|
+
currentWindowMs;
|
|
25
|
+
baselineHalfLifeMs;
|
|
26
|
+
minCurrentSamples;
|
|
27
|
+
minBaselineSamples;
|
|
28
|
+
baselineFloor;
|
|
29
|
+
absoluteCeiling;
|
|
30
|
+
freezeLearningAbove;
|
|
31
|
+
cooldownMs;
|
|
32
|
+
constructor(opts = {}) {
|
|
33
|
+
this.tripRatio = opts.tripRatio ?? 2;
|
|
34
|
+
this.currentWindowMs = opts.currentWindowMs ?? 60_000;
|
|
35
|
+
this.baselineHalfLifeMs = opts.baselineHalfLifeMs ?? 30 * 60_000;
|
|
36
|
+
this.minCurrentSamples = opts.minCurrentSamples ?? 8;
|
|
37
|
+
this.minBaselineSamples = opts.minBaselineSamples ?? 30;
|
|
38
|
+
this.baselineFloor = opts.baselineFloor ?? 0.02;
|
|
39
|
+
this.absoluteCeiling = opts.absoluteCeiling ?? 0.6;
|
|
40
|
+
this.freezeLearningAbove = opts.freezeLearningAbove ?? 1.25;
|
|
41
|
+
this.cooldownMs = opts.cooldownMs;
|
|
42
|
+
}
|
|
43
|
+
/** Feed one event. Uses event.ts as the clock; never Date.now(). */
|
|
44
|
+
record(event) {
|
|
45
|
+
const s = this.state(event.agentId);
|
|
46
|
+
const dt = s.baselineSamples === 0 ? this.baselineHalfLifeMs : Math.max(0, event.ts - s.lastTs);
|
|
47
|
+
s.lastTs = Math.max(s.lastTs, event.ts);
|
|
48
|
+
// current window first, so the freeze guard sees the incident forming
|
|
49
|
+
s.window.push({ ts: event.ts, ok: event.ok });
|
|
50
|
+
this.trim(s, event.ts);
|
|
51
|
+
const { current, burnRate } = this.rates(s);
|
|
52
|
+
const frozen = burnRate !== null && burnRate > this.freezeLearningAbove;
|
|
53
|
+
if (!frozen) {
|
|
54
|
+
const value = event.ok ? 0 : 1;
|
|
55
|
+
let alpha;
|
|
56
|
+
if (s.baselineSamples < this.minBaselineSamples) {
|
|
57
|
+
// warm-up: plain running mean, so early ordering cannot skew the start
|
|
58
|
+
alpha = 1 / (s.baselineSamples + 1);
|
|
59
|
+
}
|
|
60
|
+
else {
|
|
61
|
+
// asymmetric EWMA: improvements are learned fast, degradation slowly
|
|
62
|
+
const halfLife = value < s.baseline ? this.baselineHalfLifeMs / 6 : this.baselineHalfLifeMs;
|
|
63
|
+
alpha = 1 - Math.pow(0.5, dt / halfLife);
|
|
64
|
+
}
|
|
65
|
+
s.baseline = s.baseline + alpha * (value - s.baseline);
|
|
66
|
+
s.baselineSamples += 1;
|
|
67
|
+
}
|
|
68
|
+
this.evaluate(event.agentId, s, current, burnRate, event.ts);
|
|
69
|
+
}
|
|
70
|
+
/** Current picture for one agent. Pure read; pass the clock in. */
|
|
71
|
+
snapshot(agentId, now) {
|
|
72
|
+
const s = this.state(agentId);
|
|
73
|
+
this.trim(s, now);
|
|
74
|
+
const { current, burnRate } = this.rates(s);
|
|
75
|
+
this.maybePromote(agentId, s, burnRate, now);
|
|
76
|
+
return {
|
|
77
|
+
agentId,
|
|
78
|
+
baseline: s.baseline,
|
|
79
|
+
current,
|
|
80
|
+
burnRate,
|
|
81
|
+
samples: { baseline: s.baselineSamples, current: s.window.length },
|
|
82
|
+
mode: s.mode,
|
|
83
|
+
...(s.demotedAt !== undefined ? { demotedAt: s.demotedAt } : {}),
|
|
84
|
+
...(s.reason !== undefined ? { reason: s.reason } : {}),
|
|
85
|
+
};
|
|
86
|
+
}
|
|
87
|
+
/** Fleet-level aggregated view: one snapshot per known agent. */
|
|
88
|
+
fleet(now) {
|
|
89
|
+
return [...this.agents.keys()].map((id) => this.snapshot(id, now));
|
|
90
|
+
}
|
|
91
|
+
mode(agentId) {
|
|
92
|
+
return this.agents.get(agentId)?.mode ?? "active";
|
|
93
|
+
}
|
|
94
|
+
/** Manual demote, mirroring KillSwitch.trip. */
|
|
95
|
+
demote(agentId, reason = "manually demoted", now) {
|
|
96
|
+
const s = this.state(agentId);
|
|
97
|
+
if (s.mode === "propose_only")
|
|
98
|
+
return;
|
|
99
|
+
s.mode = "propose_only";
|
|
100
|
+
s.demotedAt = now ?? s.lastTs;
|
|
101
|
+
s.reason = reason;
|
|
102
|
+
const snap = this.snapshot(agentId, s.demotedAt);
|
|
103
|
+
for (const l of this.demoteListeners)
|
|
104
|
+
l(snap);
|
|
105
|
+
}
|
|
106
|
+
/** Manual reset back to active, mirroring KillSwitch.reset. */
|
|
107
|
+
reset(agentId) {
|
|
108
|
+
const s = this.state(agentId);
|
|
109
|
+
if (s.mode === "active")
|
|
110
|
+
return;
|
|
111
|
+
s.mode = "active";
|
|
112
|
+
delete s.demotedAt;
|
|
113
|
+
delete s.reason;
|
|
114
|
+
for (const l of this.promoteListeners)
|
|
115
|
+
l(agentId);
|
|
116
|
+
}
|
|
117
|
+
onDemote(fn) {
|
|
118
|
+
this.demoteListeners.push(fn);
|
|
119
|
+
}
|
|
120
|
+
onPromote(fn) {
|
|
121
|
+
this.promoteListeners.push(fn);
|
|
122
|
+
}
|
|
123
|
+
// ---- internals ----
|
|
124
|
+
state(agentId) {
|
|
125
|
+
let s = this.agents.get(agentId);
|
|
126
|
+
if (!s) {
|
|
127
|
+
s = { baseline: 0, baselineSamples: 0, lastTs: 0, window: [], mode: "active" };
|
|
128
|
+
this.agents.set(agentId, s);
|
|
129
|
+
}
|
|
130
|
+
return s;
|
|
131
|
+
}
|
|
132
|
+
trim(s, now) {
|
|
133
|
+
while (s.window.length > 0 && now - s.window[0].ts > this.currentWindowMs) {
|
|
134
|
+
s.window.shift();
|
|
135
|
+
}
|
|
136
|
+
}
|
|
137
|
+
rates(s) {
|
|
138
|
+
if (s.window.length < this.minCurrentSamples)
|
|
139
|
+
return { current: null, burnRate: null };
|
|
140
|
+
const failures = s.window.filter((e) => !e.ok).length;
|
|
141
|
+
const current = failures / s.window.length;
|
|
142
|
+
if (s.baselineSamples < this.minBaselineSamples)
|
|
143
|
+
return { current, burnRate: null };
|
|
144
|
+
const burnRate = current / Math.max(s.baseline, this.baselineFloor);
|
|
145
|
+
return { current, burnRate };
|
|
146
|
+
}
|
|
147
|
+
evaluate(agentId, s, current, burnRate, now) {
|
|
148
|
+
if (s.mode === "propose_only") {
|
|
149
|
+
this.maybePromote(agentId, s, burnRate, now);
|
|
150
|
+
return;
|
|
151
|
+
}
|
|
152
|
+
if (current === null)
|
|
153
|
+
return;
|
|
154
|
+
// level backstop applies even during baseline cold start
|
|
155
|
+
if (current >= this.absoluteCeiling) {
|
|
156
|
+
this.latch(agentId, s, now, `failure rate ${(current * 100).toFixed(0)}% >= ceiling ${(this.absoluteCeiling * 100).toFixed(0)}%`);
|
|
157
|
+
return;
|
|
158
|
+
}
|
|
159
|
+
if (burnRate !== null && burnRate >= this.tripRatio) {
|
|
160
|
+
this.latch(agentId, s, now, `burn rate ${burnRate.toFixed(1)}x own baseline (${(s.baseline * 100).toFixed(1)}% -> ${(current * 100).toFixed(0)}%)`);
|
|
161
|
+
}
|
|
162
|
+
}
|
|
163
|
+
latch(agentId, s, now, reason) {
|
|
164
|
+
s.mode = "propose_only";
|
|
165
|
+
s.demotedAt = now;
|
|
166
|
+
s.reason = reason;
|
|
167
|
+
const snap = {
|
|
168
|
+
agentId,
|
|
169
|
+
baseline: s.baseline,
|
|
170
|
+
current: this.rates(s).current,
|
|
171
|
+
burnRate: this.rates(s).burnRate,
|
|
172
|
+
samples: { baseline: s.baselineSamples, current: s.window.length },
|
|
173
|
+
mode: s.mode,
|
|
174
|
+
demotedAt: now,
|
|
175
|
+
reason,
|
|
176
|
+
};
|
|
177
|
+
for (const l of this.demoteListeners)
|
|
178
|
+
l(snap);
|
|
179
|
+
}
|
|
180
|
+
maybePromote(agentId, s, burnRate, now) {
|
|
181
|
+
if (s.mode !== "propose_only" || this.cooldownMs === undefined || s.demotedAt === undefined)
|
|
182
|
+
return;
|
|
183
|
+
if (now - s.demotedAt < this.cooldownMs)
|
|
184
|
+
return;
|
|
185
|
+
if (burnRate !== null && burnRate >= 1)
|
|
186
|
+
return; // still burning; wait
|
|
187
|
+
s.mode = "active";
|
|
188
|
+
delete s.demotedAt;
|
|
189
|
+
delete s.reason;
|
|
190
|
+
for (const l of this.promoteListeners)
|
|
191
|
+
l(agentId);
|
|
192
|
+
}
|
|
193
|
+
}
|
|
194
|
+
/**
|
|
195
|
+
* Wraps a BurnRateMonitor as a standard Detector so it composes with the rest
|
|
196
|
+
* of the reasoning stack. Read-only view: feed the monitor via record();
|
|
197
|
+
* the adapter only reads the latch, keyed off the last event in the window.
|
|
198
|
+
*/
|
|
199
|
+
export function burnRateDetector(monitor) {
|
|
200
|
+
return (events) => {
|
|
201
|
+
const last = events[events.length - 1];
|
|
202
|
+
if (!last)
|
|
203
|
+
return null;
|
|
204
|
+
const snap = monitor.snapshot(last.agentId, last.ts);
|
|
205
|
+
if (snap.mode === "propose_only") {
|
|
206
|
+
return {
|
|
207
|
+
status: "failing",
|
|
208
|
+
confidence: 0.9,
|
|
209
|
+
reason: snap.reason ?? "burn-rate breaker latched: propose-only",
|
|
210
|
+
};
|
|
211
|
+
}
|
|
212
|
+
return null;
|
|
213
|
+
};
|
|
214
|
+
}
|
package/dist/control-plane.d.ts
CHANGED
|
@@ -3,6 +3,7 @@ import { SignalPlane, type SignalSink } from "./signal-plane.js";
|
|
|
3
3
|
import { KillSwitch } from "./kill-switch.js";
|
|
4
4
|
import { type GatePolicy } from "./confidence-gate.js";
|
|
5
5
|
import type { Actions } from "./actions.js";
|
|
6
|
+
import { BurnRateMonitor, type AgentMode, type BurnRateOptions, type BurnSnapshot } from "./burn-rate.js";
|
|
6
7
|
/** Everything the control plane hands a human when it decides to escalate. */
|
|
7
8
|
export interface EscalationContext {
|
|
8
9
|
agentId: AgentId;
|
|
@@ -27,11 +28,35 @@ export interface ControlPlaneConfig {
|
|
|
27
28
|
now?: () => number;
|
|
28
29
|
/** Map a diagnosis to a proposed remediation. Default: pause. */
|
|
29
30
|
planRemediation?: (health: Health, agentId: AgentId) => Remediation;
|
|
31
|
+
/**
|
|
32
|
+
* Enable the baseline-relative burn-rate breaker: alarm on the derivative,
|
|
33
|
+
* not the level. Pass options, or a prebuilt monitor to share/inspect it.
|
|
34
|
+
*/
|
|
35
|
+
burnRate?: BurnRateOptions | BurnRateMonitor;
|
|
36
|
+
/** Called when the breaker latches an agent into propose-only. */
|
|
37
|
+
onDemote?: (ctx: {
|
|
38
|
+
agentId: AgentId;
|
|
39
|
+
snapshot: BurnSnapshot;
|
|
40
|
+
replay: AgentEvent[];
|
|
41
|
+
}) => void;
|
|
30
42
|
}
|
|
31
43
|
/** What `observe` tells the caller: may the agent proceed, and why. */
|
|
32
44
|
export interface Observation {
|
|
33
45
|
allowed: boolean;
|
|
34
46
|
decision: Decision;
|
|
47
|
+
/**
|
|
48
|
+
* "propose_only" while the burn-rate breaker has this agent demoted: the
|
|
49
|
+
* agent may keep producing, but its outputs are proposals for a human, not
|
|
50
|
+
* actions. "active" otherwise (and always, when burnRate is not configured).
|
|
51
|
+
*/
|
|
52
|
+
mode: AgentMode;
|
|
53
|
+
}
|
|
54
|
+
/** Fleet-level health view: burn snapshots plus kill-switch state per agent. */
|
|
55
|
+
export interface FleetView {
|
|
56
|
+
agents: Array<BurnSnapshot & {
|
|
57
|
+
killed: boolean;
|
|
58
|
+
}>;
|
|
59
|
+
generatedAt: number;
|
|
35
60
|
}
|
|
36
61
|
/** A per-agent handle onto the control plane. */
|
|
37
62
|
export interface WrappedAgent {
|
|
@@ -51,9 +76,13 @@ export declare class ControlPlane {
|
|
|
51
76
|
private readonly cfg;
|
|
52
77
|
readonly signals: SignalPlane;
|
|
53
78
|
readonly killSwitch: KillSwitch;
|
|
79
|
+
/** The burn-rate breaker, exposed like killSwitch. Undefined unless configured. */
|
|
80
|
+
readonly burnRate?: BurnRateMonitor;
|
|
54
81
|
private readonly gate;
|
|
55
82
|
private readonly now;
|
|
56
83
|
constructor(cfg: ControlPlaneConfig);
|
|
84
|
+
/** Fleet-level health view across every agent the plane has seen. */
|
|
85
|
+
fleet(): FleetView;
|
|
57
86
|
/** Get a handle for one agent in the fleet. */
|
|
58
87
|
wrap(agentId: AgentId): WrappedAgent;
|
|
59
88
|
private observe;
|
package/dist/control-plane.js
CHANGED
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import { SignalPlane } from "./signal-plane.js";
|
|
2
2
|
import { KillSwitch } from "./kill-switch.js";
|
|
3
3
|
import { confidenceGate } from "./confidence-gate.js";
|
|
4
|
+
import { BurnRateMonitor } from "./burn-rate.js";
|
|
4
5
|
/**
|
|
5
6
|
* The control plane. Wraps a fleet of agents so that every decision is recorded
|
|
6
7
|
* (signal plane), diagnosed (reasoning), gated (confidence gate), and either
|
|
@@ -10,6 +11,8 @@ export class ControlPlane {
|
|
|
10
11
|
cfg;
|
|
11
12
|
signals;
|
|
12
13
|
killSwitch;
|
|
14
|
+
/** The burn-rate breaker, exposed like killSwitch. Undefined unless configured. */
|
|
15
|
+
burnRate;
|
|
13
16
|
gate;
|
|
14
17
|
now;
|
|
15
18
|
constructor(cfg) {
|
|
@@ -18,6 +21,44 @@ export class ControlPlane {
|
|
|
18
21
|
this.killSwitch = new KillSwitch();
|
|
19
22
|
this.gate = confidenceGate(cfg.gate);
|
|
20
23
|
this.now = cfg.now ?? (() => Date.now());
|
|
24
|
+
if (cfg.burnRate) {
|
|
25
|
+
this.burnRate = cfg.burnRate instanceof BurnRateMonitor
|
|
26
|
+
? cfg.burnRate
|
|
27
|
+
: new BurnRateMonitor(cfg.burnRate);
|
|
28
|
+
if (cfg.onDemote) {
|
|
29
|
+
this.burnRate.onDemote((snapshot) => {
|
|
30
|
+
cfg.onDemote?.({ agentId: snapshot.agentId, snapshot, replay: this.signals.replay(snapshot.agentId) });
|
|
31
|
+
});
|
|
32
|
+
}
|
|
33
|
+
}
|
|
34
|
+
}
|
|
35
|
+
/** Fleet-level health view across every agent the plane has seen. */
|
|
36
|
+
fleet() {
|
|
37
|
+
const now = this.now();
|
|
38
|
+
const killed = (id) => this.killSwitch.isTripped(id);
|
|
39
|
+
if (this.burnRate) {
|
|
40
|
+
const seen = new Set();
|
|
41
|
+
const agents = this.burnRate.fleet(now).map((s) => {
|
|
42
|
+
seen.add(s.agentId);
|
|
43
|
+
return { ...s, killed: killed(s.agentId) };
|
|
44
|
+
});
|
|
45
|
+
for (const e of this.signals.all()) {
|
|
46
|
+
if (!seen.has(e.agentId)) {
|
|
47
|
+
seen.add(e.agentId);
|
|
48
|
+
agents.push({ ...this.burnRate.snapshot(e.agentId, now), killed: killed(e.agentId) });
|
|
49
|
+
}
|
|
50
|
+
}
|
|
51
|
+
return { agents, generatedAt: now };
|
|
52
|
+
}
|
|
53
|
+
const ids = [...new Set(this.signals.all().map((e) => e.agentId))];
|
|
54
|
+
return {
|
|
55
|
+
agents: ids.map((agentId) => ({
|
|
56
|
+
agentId, baseline: 0, current: null, burnRate: null,
|
|
57
|
+
samples: { baseline: 0, current: 0 }, mode: "active",
|
|
58
|
+
killed: killed(agentId),
|
|
59
|
+
})),
|
|
60
|
+
generatedAt: now,
|
|
61
|
+
};
|
|
21
62
|
}
|
|
22
63
|
/** Get a handle for one agent in the fleet. */
|
|
23
64
|
wrap(agentId) {
|
|
@@ -26,7 +67,11 @@ export class ControlPlane {
|
|
|
26
67
|
observe(agentId, raw) {
|
|
27
68
|
// 1. Kill switch: a hard stop that beats everything else.
|
|
28
69
|
if (this.killSwitch.isTripped(agentId)) {
|
|
29
|
-
return {
|
|
70
|
+
return {
|
|
71
|
+
allowed: false,
|
|
72
|
+
decision: { action: "blocked", reason: "kill switch tripped" },
|
|
73
|
+
mode: this.burnRate?.mode(agentId) ?? "active",
|
|
74
|
+
};
|
|
30
75
|
}
|
|
31
76
|
// 2. Signal plane: record the decision as one replayable event.
|
|
32
77
|
const event = {
|
|
@@ -39,12 +84,25 @@ export class ControlPlane {
|
|
|
39
84
|
detail: raw.detail,
|
|
40
85
|
};
|
|
41
86
|
this.signals.record(event);
|
|
87
|
+
this.burnRate?.record(event);
|
|
88
|
+
// 2b. Burn-rate breaker: while demoted, everything is a proposal.
|
|
89
|
+
if (this.burnRate && this.burnRate.mode(agentId) === "propose_only") {
|
|
90
|
+
const snap = this.burnRate.snapshot(agentId, event.ts);
|
|
91
|
+
return {
|
|
92
|
+
allowed: true,
|
|
93
|
+
mode: "propose_only",
|
|
94
|
+
decision: {
|
|
95
|
+
action: "escalate",
|
|
96
|
+
reason: snap.reason ?? "burn-rate breaker latched: propose-only until reset or cooldown",
|
|
97
|
+
},
|
|
98
|
+
};
|
|
99
|
+
}
|
|
42
100
|
// 3. Reasoning layer: is this agent actually broken?
|
|
43
101
|
const windowMs = this.cfg.windowMs ?? 60_000;
|
|
44
102
|
const window = this.signals.recent(agentId, windowMs, this.now());
|
|
45
103
|
const health = this.diagnose(window);
|
|
46
104
|
if (health.status === "healthy") {
|
|
47
|
-
return { allowed: true, decision: { action: "allow", reason: "healthy" } };
|
|
105
|
+
return { allowed: true, decision: { action: "allow", reason: "healthy" }, mode: "active" };
|
|
48
106
|
}
|
|
49
107
|
// 4. Confidence gate: act or escalate.
|
|
50
108
|
const remediation = (this.cfg.planRemediation ?? defaultPlan)(health, agentId);
|
|
@@ -53,15 +111,15 @@ export class ControlPlane {
|
|
|
53
111
|
if (decision.action === "auto_remediate" && decision.remediation) {
|
|
54
112
|
void this.apply(agentId, decision.remediation);
|
|
55
113
|
// a reroute lets the agent keep working; a pause/rollback stops this step
|
|
56
|
-
return { allowed: decision.remediation.kind === "reroute", decision };
|
|
114
|
+
return { allowed: decision.remediation.kind === "reroute", decision, mode: "active" };
|
|
57
115
|
}
|
|
58
116
|
if (decision.action === "escalate") {
|
|
59
117
|
// contain first, then hand the full replay to a human
|
|
60
118
|
void this.cfg.actions.pause(agentId, "containing before human review");
|
|
61
119
|
this.cfg.onEscalate({ agentId, health, decision, replay: this.signals.replay(agentId) });
|
|
62
|
-
return { allowed: false, decision };
|
|
120
|
+
return { allowed: false, decision, mode: "active" };
|
|
63
121
|
}
|
|
64
|
-
return { allowed: true, decision };
|
|
122
|
+
return { allowed: true, decision, mode: "active" };
|
|
65
123
|
}
|
|
66
124
|
diagnose(events) {
|
|
67
125
|
const findings = this.cfg.detectors
|
package/dist/index.d.ts
CHANGED
|
@@ -6,9 +6,11 @@ export type { KillScope } from "./kill-switch.js";
|
|
|
6
6
|
export { confidenceGate } from "./confidence-gate.js";
|
|
7
7
|
export type { GatePolicy } from "./confidence-gate.js";
|
|
8
8
|
export { loopDetector, costRunawayDetector, errorRateDetector, driftDetector } from "./detector.js";
|
|
9
|
+
export { BurnRateMonitor, burnRateDetector } from "./burn-rate.js";
|
|
10
|
+
export type { AgentMode, BurnRateOptions, BurnSnapshot } from "./burn-rate.js";
|
|
9
11
|
export type { Actions } from "./actions.js";
|
|
10
12
|
export { ControlPlane } from "./control-plane.js";
|
|
11
|
-
export type { ControlPlaneConfig, WrappedAgent, Observation, ObservableEvent, EscalationContext, } from "./control-plane.js";
|
|
13
|
+
export type { ControlPlaneConfig, WrappedAgent, Observation, ObservableEvent, EscalationContext, FleetView, } from "./control-plane.js";
|
|
12
14
|
import { ControlPlane, type ControlPlaneConfig } from "./control-plane.js";
|
|
13
15
|
/** Convenience factory. `const plane = controlPlane({ ... })`. */
|
|
14
16
|
export declare function controlPlane(cfg: ControlPlaneConfig): ControlPlane;
|
package/dist/index.js
CHANGED
|
@@ -2,6 +2,7 @@ export { SignalPlane } from "./signal-plane.js";
|
|
|
2
2
|
export { KillSwitch } from "./kill-switch.js";
|
|
3
3
|
export { confidenceGate } from "./confidence-gate.js";
|
|
4
4
|
export { loopDetector, costRunawayDetector, errorRateDetector, driftDetector } from "./detector.js";
|
|
5
|
+
export { BurnRateMonitor, burnRateDetector } from "./burn-rate.js";
|
|
5
6
|
export { ControlPlane } from "./control-plane.js";
|
|
6
7
|
import { ControlPlane } from "./control-plane.js";
|
|
7
8
|
/** Convenience factory. `const plane = controlPlane({ ... })`. */
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "guardplane",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.2.0",
|
|
4
4
|
"description": "A minimal, framework-agnostic control plane for AI agent fleets: signal plane, confidence gate, and kill switch.",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"license": "MIT",
|
|
@@ -43,5 +43,9 @@
|
|
|
43
43
|
"tsx": "^4.19.2",
|
|
44
44
|
"typescript": "^5.7.2",
|
|
45
45
|
"vitest": "^2.1.8"
|
|
46
|
+
},
|
|
47
|
+
"sideEffects": false,
|
|
48
|
+
"engines": {
|
|
49
|
+
"node": ">=18"
|
|
46
50
|
}
|
|
47
51
|
}
|