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 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
+ }
@@ -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;
@@ -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 { allowed: false, decision: { action: "blocked", reason: "kill switch tripped" } };
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.1.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
  }