@agentguard-run/burn 0.2.5 → 0.2.6
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/CHANGELOG.md +10 -0
- package/README.md +48 -17
- package/dist/src/calibrate.js +2 -3
- package/dist/src/cli.js +14 -3
- package/dist/src/conformance.d.ts +5 -2
- package/dist/src/conformance.js +30 -17
- package/dist/src/defaults.d.ts +5 -4
- package/dist/src/defaults.js +7 -6
- package/dist/src/detectors/evaluate.d.ts +4 -5
- package/dist/src/detectors/evaluate.js +13 -11
- package/dist/src/gateway.js +2 -8
- package/dist/src/history/claude-transcript.d.ts +20 -2
- package/dist/src/history/claude-transcript.js +56 -15
- package/dist/src/hook/pre-tool-use.d.ts +13 -9
- package/dist/src/hook/pre-tool-use.js +55 -31
- package/dist/src/insights/blocks.d.ts +61 -0
- package/dist/src/insights/blocks.js +243 -0
- package/dist/src/insights/transcript.d.ts +3 -1
- package/dist/src/insights/transcript.js +14 -1
- package/dist/src/insights/types.d.ts +7 -0
- package/dist/src/policy.d.ts +4 -0
- package/dist/src/policy.js +57 -0
- package/dist/src/replay/render.js +4 -2
- package/dist/src/replay/simulate.d.ts +5 -0
- package/dist/src/replay/simulate.js +23 -8
- package/dist/src/state/reservations.d.ts +2 -0
- package/dist/src/state/reservations.js +5 -1
- package/dist/src/state/spawn-window.d.ts +10 -0
- package/dist/src/state/spawn-window.js +25 -0
- package/dist/src/types.d.ts +2 -1
- package/fixtures/usage-dedup-session/subagents/agent-synthetic-first.jsonl +5 -0
- package/fixtures/usage-dedup-session/subagents/agent-synthetic-second.jsonl +4 -0
- package/fixtures/usage-dedup-session.jsonl +4 -0
- package/package.json +1 -1
package/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,15 @@
|
|
|
1
1
|
# Changelog
|
|
2
2
|
|
|
3
|
+
## 0.2.6 (2026-09-19)
|
|
4
|
+
|
|
5
|
+
- Fix inflated enforcement usage: repeated assistant content blocks and copied child histories now share the same provider response identity as `why`. Persist only usage deltas; rebuild old counters once without replacing their receipt chain head.
|
|
6
|
+
- Fix lifetime fan-out blocking: lifetime count is WARN only, the stop ceiling uses 120 active minutes, and spawn rate defaults to enforcement at 16 proposals within 15 active minutes. Depth above 2 still stops. Atomic reservations use both active windows, including parallel proposals.
|
|
7
|
+
- Fill missing policy fields from defaults with one notice. Preserve explicit settings, including older advisory spawn-rate policies, and bind the normalized policy into new receipt digests.
|
|
8
|
+
- Keep sustained thresholds at WARN 3.5B and STOP 5B. They now fire later because duplicate usage no longer advances the counters. Calibration uses corrected usage and excludes bursts rather than long lifetime spawn counts.
|
|
9
|
+
- Add `blocks [session|all]` with signed receipt context and measured child tokens and list-price economics, separating forks and fresh agents. Reuse the local attribution parser and existing verified pricing table; no transcript contents enter receipts.
|
|
10
|
+
- Keep hook WARN and STOP card text, layout and branding unchanged.
|
|
11
|
+
- Tests: 150 passing by default, one opt-in stress test skipped. The separate 240-process stress run admits exactly 40, denies 200, signs 240 receipts and has no lock failures. All 14 conformance checks pass.
|
|
12
|
+
|
|
3
13
|
## 0.2.5 (2026-09-18)
|
|
4
14
|
|
|
5
15
|
- Attribute session usage from measured deltas: fixed-prefix context, cached history, tool output, repeated Read results and conversation. Use bytes only to split a measured increment, with mixed intervals counted as shared.
|
package/README.md
CHANGED
|
@@ -56,23 +56,54 @@ Merge these fields into the existing policy. All existing thresholds and modes r
|
|
|
56
56
|
|
|
57
57
|
## Two safety planes
|
|
58
58
|
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
59
|
+
Structural limits use active time rather than the age of the session. The
|
|
60
|
+
lifetime spawn count warns at 24 but cannot stop a session on its own. The
|
|
61
|
+
fan-out ceiling permits 40 spawns within the last 120 active minutes and
|
|
62
|
+
stops the next proposal. The separate spawn-rate rule warns at 8 and stops
|
|
63
|
+
candidate 16 within 15 active minutes. Depth above 2 still stops.
|
|
64
|
+
|
|
65
|
+
Economic limits warn at 3.5B and stop at 5B measured tokens per session.
|
|
66
|
+
Repeated Claude assistant content blocks share one provider response id.
|
|
67
|
+
Their usage and copied child history count once, including later increases
|
|
68
|
+
to a response's recorded usage. Old session counters rebuild once from the
|
|
69
|
+
parent and its available child transcripts, retaining the signed chain head.
|
|
70
|
+
The rebuild places events chronologically. A child usage record discovered
|
|
71
|
+
late on a later hook enters the current active-minute bucket, while session
|
|
72
|
+
and bucket totals remain conserved.
|
|
73
|
+
The same numeric sustained thresholds now fire later because inflated usage
|
|
74
|
+
has been removed. Earlier replay figures in the changelog used the old counts.
|
|
75
|
+
|
|
76
|
+
Existing policy files keep explicit settings. Missing fields are filled in
|
|
77
|
+
memory, with a one-time notice. In particular, an old explicit
|
|
78
|
+
`spawnRate.enforce: false` remains advisory until you change it. New policies
|
|
79
|
+
default to `true`; `fanout.windowActiveMinutes` defaults to 120. Run calibration
|
|
80
|
+
only when you want it to write a new shadow policy, or set `AGENTGUARD_HOME`
|
|
81
|
+
to a scratch directory to review fitted values first.
|
|
82
|
+
|
|
83
|
+
## Price a recorded block
|
|
84
|
+
|
|
85
|
+
```sh
|
|
86
|
+
agentguard-burn blocks
|
|
87
|
+
agentguard-burn blocks SESSION_ID
|
|
88
|
+
agentguard-burn blocks all
|
|
89
|
+
```
|
|
90
|
+
|
|
91
|
+
Each WARN or STOP receipt gets one row with its time, session, detector
|
|
92
|
+
reasons and spawn ordinal. The row includes the session's available child
|
|
93
|
+
count, median and maximum tokens, and median and maximum API list-price
|
|
94
|
+
dollars per child. Forks and fresh agents are reported separately. The
|
|
95
|
+
report reuses the attribution parser and excludes copied provider responses.
|
|
96
|
+
It reads recorded usage only and makes no network calls.
|
|
97
|
+
|
|
98
|
+
These are list-price equivalents, not a bill or a claim of money saved.
|
|
99
|
+
Economics describe the currently available child transcripts of that session,
|
|
100
|
+
not only the children completed before the receipt. Missing child history or
|
|
101
|
+
unknown model pricing stays unavailable. Unknown cache lifetimes produce a
|
|
102
|
+
price range. A fork is identified by its transcript's fork metadata. Codex child discovery
|
|
103
|
+
requires an explicit parent thread id; unknown inherited-history boundaries
|
|
104
|
+
stay unavailable. Use `blocks all json` for structured output. Session
|
|
105
|
+
attribution uses local receipt identifiers without copying transcript content
|
|
106
|
+
into the ledger.
|
|
76
107
|
|
|
77
108
|
Cache-read ratio was about 98% in the original calibration, healthy and pathological alike. It
|
|
78
109
|
is shown as an explanation and never used to decide.
|
package/dist/src/calibrate.js
CHANGED
|
@@ -23,7 +23,6 @@
|
|
|
23
23
|
Object.defineProperty(exports, "__esModule", { value: true });
|
|
24
24
|
exports.calibrate = calibrate;
|
|
25
25
|
const defaults_1 = require("./defaults");
|
|
26
|
-
const IMMUTABLE_MAX_SPAWNS = 40;
|
|
27
26
|
const IMMUTABLE_MAX_TOKENS = 6_000_000_000;
|
|
28
27
|
/**
|
|
29
28
|
* Sessions below this are not evidence about what a working session costs.
|
|
@@ -69,10 +68,10 @@ function calibrate(sessions, base = defaults_1.DEFAULT_THRESHOLDS) {
|
|
|
69
68
|
if (trivial > 0) {
|
|
70
69
|
notes.push(`${trivial} short session(s) under ${MIN_WORKING_SESSION_TOKENS / 1e6}M tokens ignored; they are not evidence about working-session cost.`);
|
|
71
70
|
}
|
|
72
|
-
const healthy = working.filter((s) => s.
|
|
71
|
+
const healthy = working.filter((s) => !s.fanoutStop && !s.spawnRateStop && s.totalTokens <= IMMUTABLE_MAX_TOKENS);
|
|
73
72
|
const excluded = working.length - healthy.length;
|
|
74
73
|
if (excluded > 0) {
|
|
75
|
-
notes.push(`${excluded} session(s) excluded as already pathological (
|
|
74
|
+
notes.push(`${excluded} session(s) excluded as already pathological (fan-out, depth or spawn-rate STOP, or >6B tokens). They must not define normal.`);
|
|
76
75
|
}
|
|
77
76
|
const tokens = winsorise(healthy.map((s) => s.totalTokens)).sort((a, b) => a - b);
|
|
78
77
|
const rawWarn = quantile(tokens, 0.9);
|
package/dist/src/cli.js
CHANGED
|
@@ -38,6 +38,7 @@ const sessions_1 = require("./insights/sessions");
|
|
|
38
38
|
const transcript_1 = require("./insights/transcript");
|
|
39
39
|
const live_1 = require("./insights/live");
|
|
40
40
|
const render_2 = require("./insights/render");
|
|
41
|
+
const blocks_1 = require("./insights/blocks");
|
|
41
42
|
const HOME = process.env.AGENTGUARD_HOME ?? (0, node_path_1.join)((0, node_os_1.homedir)(), '.agentguard');
|
|
42
43
|
const PROXY_HOSTS = ['ollama', 'vllm', 'lm-studio', 'openai-compatible'];
|
|
43
44
|
function readStdinJson() {
|
|
@@ -81,6 +82,12 @@ async function main(argv) {
|
|
|
81
82
|
};
|
|
82
83
|
const has = (name) => rest.includes(name);
|
|
83
84
|
switch (command) {
|
|
85
|
+
case 'blocks': {
|
|
86
|
+
const session = rest.find(value => value !== 'json' && value !== '--json');
|
|
87
|
+
const report = (0, blocks_1.blocksReport)(HOME, session, { rates: (0, live_1.readPricing)(HOME, (0, pre_tool_use_1.loadPolicy)(HOME)) });
|
|
88
|
+
process.stdout.write((rest.includes('json') || has('--json') ? JSON.stringify(report, null, 2) : (0, blocks_1.renderBlocks)(report)) + '\n');
|
|
89
|
+
return 0;
|
|
90
|
+
}
|
|
84
91
|
case 'why': {
|
|
85
92
|
const location = (0, sessions_1.selectInsightTranscript)(rest.find(value => value !== 'json'));
|
|
86
93
|
const transcript = (0, transcript_1.readInsightSession)(location.path, { host: location.host, sessionId: location.sessionId });
|
|
@@ -142,7 +149,8 @@ async function main(argv) {
|
|
|
142
149
|
`Calibrated from ${result.sessionsUsed} of ${result.sessionsTotal} sessions (${result.sessionsExcluded} excluded as pathological).`,
|
|
143
150
|
` raw p90 ${(result.rawWarnP90 / 1e9).toFixed(2)}B raw p99 ${(result.rawStopP99 / 1e9).toFixed(2)}B`,
|
|
144
151
|
` sustained WARN ${(t.sustained.warnTokens / 1e9).toFixed(2)}B STOP ${(t.sustained.stopTokens / 1e9).toFixed(2)}B`,
|
|
145
|
-
` fan-out WARN ${t.fanout.warn}
|
|
152
|
+
` fan-out lifetime WARN ${t.fanout.warn}; STOP above ${t.fanout.stop} spawns in ${t.fanout.windowActiveMinutes ?? 120} active minutes; depth above ${t.fanout.maxDepth}`,
|
|
153
|
+
` spawn-rate WARN ${t.spawnRate?.warn ?? 8}; STOP ${t.spawnRate?.stop ?? 16} in ${t.spawnRate?.windowActiveMinutes ?? 15} active minutes; ${t.spawnRate?.enforce === false ? 'shadow only' : 'enforcing in enforce mode'}`,
|
|
146
154
|
` burn-debt ${t.burnDebt.enabled ? `ON, baseline ${Math.round(t.burnDebt.baselinePerActiveMinute / 1e6)}M/active-min` : 'off'}`,
|
|
147
155
|
...result.notes.map((n) => ` note: ${n}`),
|
|
148
156
|
`Policy written to ${(0, node_path_1.join)(HOME, 'burn-policy.json')} in SHADOW mode.`,
|
|
@@ -157,7 +165,9 @@ async function main(argv) {
|
|
|
157
165
|
const override = (0, override_1.readOverride)(HOME);
|
|
158
166
|
process.stdout.write([
|
|
159
167
|
`mode: ${policy.mode}${policy.mode === 'enforce' ? ' (the next STOP blocks)' : ' (recording only; nothing is blocked)'}`,
|
|
160
|
-
`thresholds: fan-out ${policy.thresholds.fanout.warn}
|
|
168
|
+
`thresholds: fan-out lifetime WARN ${policy.thresholds.fanout.warn}; STOP above ${policy.thresholds.fanout.stop} spawns in ${policy.thresholds.fanout.windowActiveMinutes ?? 120} active minutes; depth above ${policy.thresholds.fanout.maxDepth}`,
|
|
169
|
+
`spawn-rate: WARN ${policy.thresholds.spawnRate?.warn ?? 8}; STOP ${policy.thresholds.spawnRate?.stop ?? 16} in ${policy.thresholds.spawnRate?.windowActiveMinutes ?? 15} active minutes; ${policy.thresholds.spawnRate?.enforce === false ? 'shadow only' : 'enforcing in enforce mode'}`,
|
|
170
|
+
`sustained: ${(policy.thresholds.sustained.warnTokens / 1e9).toFixed(1)}B/${(policy.thresholds.sustained.stopTokens / 1e9).toFixed(1)}B local-compute warn at ${lc?.warnConcurrent ?? 4} concurrent${lc?.stopConcurrent ? `, stop at ${lc.stopConcurrent}` : ' (no stop set)'}`,
|
|
161
171
|
policy.calibration ? `calibrated from ${policy.calibration.sessionsSampled} sessions` : 'using shipped defaults (run: agentguard-burn calibrate)',
|
|
162
172
|
`shadow observation: ${e.decisions} decisions over ${e.days.toFixed(1)} days`,
|
|
163
173
|
` would have warned: ${e.warns} would have blocked: ${e.wouldBlock}`,
|
|
@@ -292,7 +302,8 @@ async function main(argv) {
|
|
|
292
302
|
return 0;
|
|
293
303
|
}
|
|
294
304
|
default:
|
|
295
|
-
process.stdout.write('agentguard-burn <why|rewrites|pace|statusline|pricing|replay|calibrate|status|init|enforce|shadow|resume|proxy|conformance>\n' +
|
|
305
|
+
process.stdout.write('agentguard-burn <blocks|why|rewrites|pace|statusline|pricing|replay|calibrate|status|init|enforce|shadow|resume|proxy|conformance>\n' +
|
|
306
|
+
' blocks [session|all] stored WARN/STOP receipts and that session\'s child usage\n' +
|
|
296
307
|
' why [session] token attribution and API list-price equivalents\n' +
|
|
297
308
|
' rewrites [session|all] full-prefix cache writes by cause\n' +
|
|
298
309
|
' pace [session] ten-minute pace and next-hour projection\n' +
|
|
@@ -1,10 +1,13 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* Conformance: the same failure, through every door, stops at the same step.
|
|
3
3
|
*
|
|
4
|
-
*
|
|
4
|
+
* Fixtures, fitted on real sessions:
|
|
5
5
|
*
|
|
6
6
|
* the storm 42 candidate spawns. First WARN must be spawn 24, first STOP
|
|
7
|
-
* must be spawn 41. Every spawn-capable
|
|
7
|
+
* must be spawn 41 with spawn-rate advisory. Every spawn-capable
|
|
8
|
+
* adapter replays it.
|
|
9
|
+
* rate default policy warns at candidate 8 and stops candidate 16.
|
|
10
|
+
* Pending proposals count even before a transcript sees them.
|
|
8
11
|
* the grind model calls of 250M tokens each. First WARN must be the call
|
|
9
12
|
* after 3.5B, first STOP the call after 5B. Every usage-capable
|
|
10
13
|
* adapter replays it.
|
package/dist/src/conformance.js
CHANGED
|
@@ -2,10 +2,13 @@
|
|
|
2
2
|
/**
|
|
3
3
|
* Conformance: the same failure, through every door, stops at the same step.
|
|
4
4
|
*
|
|
5
|
-
*
|
|
5
|
+
* Fixtures, fitted on real sessions:
|
|
6
6
|
*
|
|
7
7
|
* the storm 42 candidate spawns. First WARN must be spawn 24, first STOP
|
|
8
|
-
* must be spawn 41. Every spawn-capable
|
|
8
|
+
* must be spawn 41 with spawn-rate advisory. Every spawn-capable
|
|
9
|
+
* adapter replays it.
|
|
10
|
+
* rate default policy warns at candidate 8 and stops candidate 16.
|
|
11
|
+
* Pending proposals count even before a transcript sees them.
|
|
9
12
|
* the grind model calls of 250M tokens each. First WARN must be the call
|
|
10
13
|
* after 3.5B, first STOP the call after 5B. Every usage-capable
|
|
11
14
|
* adapter replays it.
|
|
@@ -35,11 +38,11 @@ const STOP_AT = defaults_1.DEFAULT_THRESHOLDS.fanout.stop + 1; // 41
|
|
|
35
38
|
const CALL_TOKENS = 250_000_000;
|
|
36
39
|
const GRIND_WARN_CALL = Math.floor(defaults_1.DEFAULT_THRESHOLDS.sustained.warnTokens / CALL_TOKENS) + 1; // 15
|
|
37
40
|
const GRIND_STOP_CALL = Math.floor(defaults_1.DEFAULT_THRESHOLDS.sustained.stopTokens / CALL_TOKENS) + 1; // 21
|
|
38
|
-
function freshHome() {
|
|
41
|
+
function freshHome(enforceRate = false) {
|
|
39
42
|
const home = (0, node_fs_1.mkdtempSync)((0, node_path_1.join)((0, node_os_1.tmpdir)(), 'agb-conf-'));
|
|
40
43
|
// These fixtures share a home to inspect all receipts. Test only the
|
|
41
44
|
// session boundaries here; account concurrency has its own integration tests.
|
|
42
|
-
const policy = { mode: 'enforce', thresholds: { ...defaults_1.DEFAULT_THRESHOLDS, account: { ...defaults_1.DEFAULT_THRESHOLDS.account, warnConcurrentSessions: Number.MAX_SAFE_INTEGER } } };
|
|
45
|
+
const policy = { mode: 'enforce', thresholds: { ...defaults_1.DEFAULT_THRESHOLDS, spawnRate: { ...defaults_1.DEFAULT_THRESHOLDS.spawnRate, enforce: enforceRate }, account: { ...defaults_1.DEFAULT_THRESHOLDS.account, warnConcurrentSessions: Number.MAX_SAFE_INTEGER } } };
|
|
43
46
|
(0, node_fs_1.writeFileSync)((0, node_path_1.join)(home, 'burn-policy.json'), JSON.stringify(policy));
|
|
44
47
|
return home;
|
|
45
48
|
}
|
|
@@ -52,6 +55,7 @@ function boundaries(steps) {
|
|
|
52
55
|
// Hook adapters only expose the first finding's summary. Fan-out is evaluated
|
|
53
56
|
// first in the core, so when it fires it is the one in the message.
|
|
54
57
|
const FANOUT_WARN = /agent spawns this session/;
|
|
58
|
+
const RATE_WARN = /spawns in the last 15 active minutes/;
|
|
55
59
|
function stepFromMessage(denied, message, warnPattern) {
|
|
56
60
|
if (denied)
|
|
57
61
|
return 'STOP';
|
|
@@ -60,34 +64,36 @@ function stepFromMessage(denied, message, warnPattern) {
|
|
|
60
64
|
return warnPattern.test(message) ? 'WARN' : 'WARN:other';
|
|
61
65
|
}
|
|
62
66
|
// ---- storms --------------------------------------------------------------
|
|
63
|
-
function stormRaw(home) {
|
|
64
|
-
const burn = (0, raw_api_1.createRawApiGuard)({ sessionId: 'storm-raw'
|
|
67
|
+
function stormRaw(home, rate = false, startObserved = true) {
|
|
68
|
+
const burn = (0, raw_api_1.createRawApiGuard)({ sessionId: `${rate ? 'rate' : 'storm'}-raw${startObserved ? '' : '-pending'}`, home });
|
|
65
69
|
const steps = [];
|
|
66
70
|
for (let i = 1; i <= STORM; i++) {
|
|
67
71
|
const lease = burn.beforeSpawn({ parentDepth: 0 });
|
|
68
72
|
const d = lease.decision;
|
|
69
|
-
const fanout = d.report.findings.find((f) => f.detector === 'fanout');
|
|
73
|
+
const fanout = d.report.findings.find((f) => f.detector === (rate ? 'spawn_rate' : 'fanout'));
|
|
70
74
|
steps.push(d.blocked ? 'STOP' : fanout?.verdict === 'WARN' ? 'WARN' : d.verdict === 'WARN' ? 'WARN:other' : 'OK');
|
|
71
|
-
if (!d.blocked)
|
|
75
|
+
if (!d.blocked && startObserved)
|
|
72
76
|
lease.started();
|
|
73
77
|
}
|
|
74
78
|
return boundaries(steps);
|
|
75
79
|
}
|
|
76
|
-
function stormCursor(home) {
|
|
80
|
+
function stormCursor(home, rate = false) {
|
|
81
|
+
const sessionId = rate ? 'rate-cursor' : 'storm-cursor';
|
|
77
82
|
const gateway = new gateway_1.Gateway(home);
|
|
78
83
|
const steps = [];
|
|
79
84
|
for (let i = 1; i <= STORM; i++) {
|
|
80
|
-
const out = (0, cursor_1.handleCursorHook)({ hook_event_name: 'subagentStart', conversation_id:
|
|
81
|
-
steps.push(stepFromMessage(out.permission === 'deny', out.agent_message, FANOUT_WARN));
|
|
85
|
+
const out = (0, cursor_1.handleCursorHook)({ hook_event_name: 'subagentStart', conversation_id: sessionId, subagent_id: `sub-${i}`, parent_conversation_id: sessionId }, gateway);
|
|
86
|
+
steps.push(stepFromMessage(out.permission === 'deny', out.agent_message, rate ? RATE_WARN : FANOUT_WARN));
|
|
82
87
|
}
|
|
83
88
|
return boundaries(steps);
|
|
84
89
|
}
|
|
85
|
-
function stormCodex(home) {
|
|
90
|
+
function stormCodex(home, rate = false) {
|
|
91
|
+
const sessionId = rate ? 'rate-codex' : 'storm-codex';
|
|
86
92
|
const gateway = new gateway_1.Gateway(home);
|
|
87
93
|
const steps = [];
|
|
88
94
|
for (let i = 1; i <= STORM; i++) {
|
|
89
|
-
const out = (0, codex_1.handleCodexHook)({ hook_event_name: 'PreToolUse', session_id:
|
|
90
|
-
steps.push(stepFromMessage(out.hookSpecificOutput?.permissionDecision === 'deny', out.systemMessage, FANOUT_WARN));
|
|
95
|
+
const out = (0, codex_1.handleCodexHook)({ hook_event_name: 'PreToolUse', session_id: sessionId, tool_name: 'spawn_agent', tool_use_id: `call-${i}` }, gateway);
|
|
96
|
+
steps.push(stepFromMessage(out.hookSpecificOutput?.permissionDecision === 'deny', out.systemMessage, rate ? RATE_WARN : FANOUT_WARN));
|
|
91
97
|
}
|
|
92
98
|
return boundaries(steps);
|
|
93
99
|
}
|
|
@@ -221,17 +227,23 @@ async function composite(home) {
|
|
|
221
227
|
// ---- runner --------------------------------------------------------------
|
|
222
228
|
async function runConformance() {
|
|
223
229
|
const home = freshHome();
|
|
230
|
+
const rateHome = freshHome(true);
|
|
224
231
|
const checks = [];
|
|
225
232
|
const expectStorm = (name, b) => checks.push({
|
|
226
233
|
name,
|
|
227
234
|
ok: b.firstWarn === WARN_AT && b.firstStop === STOP_AT,
|
|
228
235
|
detail: `fan-out WARN at ${b.firstWarn} (want ${WARN_AT}), STOP at ${b.firstStop} (want ${STOP_AT})${b.firstAnyWarn !== null && b.firstAnyWarn < WARN_AT ? `; advisory spawn-rate WARN at ${b.firstAnyWarn}` : ''}`,
|
|
229
236
|
});
|
|
237
|
+
const expectRate = (name, b) => checks.push({ name, ok: b.firstWarn === defaults_1.DEFAULT_THRESHOLDS.spawnRate.warn && b.firstStop === defaults_1.DEFAULT_THRESHOLDS.spawnRate.stop, detail: `default spawn-rate WARN at ${b.firstWarn} (want 8), STOP at ${b.firstStop} (want 16)` });
|
|
230
238
|
const expectGrind = (name, b) => checks.push({ name, ok: b.firstWarn === GRIND_WARN_CALL && b.firstStop === GRIND_STOP_CALL, detail: `first WARN call ${b.firstWarn} (want ${GRIND_WARN_CALL}), first STOP call ${b.firstStop} (want ${GRIND_STOP_CALL})` });
|
|
231
239
|
try {
|
|
232
240
|
expectStorm('storm · raw middleware', stormRaw(home));
|
|
233
241
|
expectStorm('storm · cursor subagentStart', stormCursor(home));
|
|
234
242
|
expectStorm('storm · codex PreToolUse', stormCodex(home));
|
|
243
|
+
expectRate('rate · raw middleware', stormRaw(rateHome, true));
|
|
244
|
+
expectRate('rate · cursor subagentStart', stormCursor(rateHome, true));
|
|
245
|
+
expectRate('rate · codex PreToolUse', stormCodex(rateHome, true));
|
|
246
|
+
expectRate('rate · pending only, no observed spawns', stormRaw(rateHome, true, false));
|
|
235
247
|
checks.push({ name: 'depth · raw middleware', ok: depthRaw(home), detail: 'depth 1 and 2 admitted, depth 3 denied' });
|
|
236
248
|
expectGrind('grind · raw middleware', grindRaw(home));
|
|
237
249
|
const p = await grindProxy(home);
|
|
@@ -241,17 +253,18 @@ async function runConformance() {
|
|
|
241
253
|
const c = await composite(home);
|
|
242
254
|
checks.push({ name: 'composite · middleware tree + proxy usage, one session', ok: c.ok, detail: c.detail });
|
|
243
255
|
// Receipts: every spawn decision above was signed; verify the chain tail.
|
|
244
|
-
const receipts = require('node:fs')
|
|
245
|
-
.readFileSync((0, node_path_1.join)(
|
|
256
|
+
const receipts = [home, rateHome].flatMap((directory) => require('node:fs')
|
|
257
|
+
.readFileSync((0, node_path_1.join)(directory, 'receipts.ndjson'), 'utf8')
|
|
246
258
|
.split('\n')
|
|
247
259
|
.filter(Boolean)
|
|
248
|
-
.map((l) => JSON.parse(l));
|
|
260
|
+
.map((l) => JSON.parse(l)));
|
|
249
261
|
const allValid = receipts.every((r) => (0, receipt_1.verifyReceipt)(r));
|
|
250
262
|
const contentFree = !receipts.some((r) => JSON.stringify(r.payload).match(/transcript|prompt|\/Users\/|content/i));
|
|
251
263
|
checks.push({ name: `receipts · ${receipts.length} signed, all verify, content-free`, ok: allValid && contentFree && receipts.length > 0, detail: `${receipts.length} receipts, verify=${allValid}, content-free=${contentFree}` });
|
|
252
264
|
}
|
|
253
265
|
finally {
|
|
254
266
|
(0, node_fs_1.rmSync)(home, { recursive: true, force: true });
|
|
267
|
+
(0, node_fs_1.rmSync)(rateHome, { recursive: true, force: true });
|
|
255
268
|
}
|
|
256
269
|
const ok = checks.every((c) => c.ok);
|
|
257
270
|
const text = [
|
package/dist/src/defaults.d.ts
CHANGED
|
@@ -5,10 +5,11 @@ import type { Policy, Thresholds } from './types';
|
|
|
5
5
|
* Originally fitted against 412 real Claude Code sessions. Replayed on the
|
|
6
6
|
* author's machine on 2026-09-18: 453 sessions, 662 spawns, 36.30B recorded tokens;
|
|
7
7
|
* 5 STOP, 3 WARN, 445 clean. The recorded post-STOP tail is 10.80B (30%).
|
|
8
|
-
*
|
|
9
|
-
*
|
|
10
|
-
*
|
|
11
|
-
*
|
|
8
|
+
* That historical snapshot used inflated repeated streaming usage counts and
|
|
9
|
+
* a lifetime fan-out ceiling. Version 0.2.6 deduplicates usage and limits fan-out
|
|
10
|
+
* to 120 active minutes. Lifetime 24 warns; above 40 in the window stops.
|
|
11
|
+
* Spawn rate 8/16 over 15 active minutes warns/stops by default. Sustained
|
|
12
|
+
* thresholds remain 3.5B/5B pending review of deduplicated calibration.
|
|
12
13
|
* This is a dated replay snapshot and an upper bound, not labelled evidence
|
|
13
14
|
* of false-positive rates or guaranteed savings.
|
|
14
15
|
*
|
package/dist/src/defaults.js
CHANGED
|
@@ -7,10 +7,11 @@ exports.SHADOW_MIN_DAYS = exports.SHADOW_MIN_DECISIONS = exports.ACTIVE_GAP_CAP_
|
|
|
7
7
|
* Originally fitted against 412 real Claude Code sessions. Replayed on the
|
|
8
8
|
* author's machine on 2026-09-18: 453 sessions, 662 spawns, 36.30B recorded tokens;
|
|
9
9
|
* 5 STOP, 3 WARN, 445 clean. The recorded post-STOP tail is 10.80B (30%).
|
|
10
|
-
*
|
|
11
|
-
*
|
|
12
|
-
*
|
|
13
|
-
*
|
|
10
|
+
* That historical snapshot used inflated repeated streaming usage counts and
|
|
11
|
+
* a lifetime fan-out ceiling. Version 0.2.6 deduplicates usage and limits fan-out
|
|
12
|
+
* to 120 active minutes. Lifetime 24 warns; above 40 in the window stops.
|
|
13
|
+
* Spawn rate 8/16 over 15 active minutes warns/stops by default. Sustained
|
|
14
|
+
* thresholds remain 3.5B/5B pending review of deduplicated calibration.
|
|
14
15
|
* This is a dated replay snapshot and an upper bound, not labelled evidence
|
|
15
16
|
* of false-positive rates or guaranteed savings.
|
|
16
17
|
*
|
|
@@ -24,7 +25,7 @@ exports.SHADOW_MIN_DAYS = exports.SHADOW_MIN_DECISIONS = exports.ACTIVE_GAP_CAP_
|
|
|
24
25
|
* absent from these thresholds.
|
|
25
26
|
*/
|
|
26
27
|
exports.DEFAULT_THRESHOLDS = {
|
|
27
|
-
fanout: { warn: 24, stop: 40, maxDepth: 2 },
|
|
28
|
+
fanout: { warn: 24, stop: 40, maxDepth: 2, windowActiveMinutes: 120 },
|
|
28
29
|
sustained: { warnTokens: 3_500_000_000, stopTokens: 5_000_000_000 },
|
|
29
30
|
burnDebt: {
|
|
30
31
|
baselinePerActiveMinute: 0,
|
|
@@ -33,7 +34,7 @@ exports.DEFAULT_THRESHOLDS = {
|
|
|
33
34
|
stopDebt: 1_500_000_000,
|
|
34
35
|
enabled: false,
|
|
35
36
|
},
|
|
36
|
-
spawnRate: { windowActiveMinutes: 15, warn: 8, stop: 16, enforce:
|
|
37
|
+
spawnRate: { windowActiveMinutes: 15, warn: 8, stop: 16, enforce: true },
|
|
37
38
|
duplicate: { warnReaders: 3 },
|
|
38
39
|
account: {
|
|
39
40
|
stopTokens: null,
|
|
@@ -5,15 +5,14 @@
|
|
|
5
5
|
* failure modes:
|
|
6
6
|
*
|
|
7
7
|
* structural - fan-out. Many agents, each re-sending context. The 190-spawn
|
|
8
|
-
* session. Caught by
|
|
8
|
+
* session. Caught by spawn windows over active time.
|
|
9
9
|
* economic - sustained burn. Few agents, long session, enormous total. The
|
|
10
10
|
* 9.15B session with only 26 spawns, which a spawn cap cannot
|
|
11
11
|
* see. Caught by a cumulative token ceiling and burn debt.
|
|
12
12
|
*
|
|
13
|
-
* Verdict is the maximum severity across findings.
|
|
14
|
-
*
|
|
15
|
-
*
|
|
16
|
-
* safety tool uninstalled.
|
|
13
|
+
* Verdict is the maximum severity across findings. Lifetime fan-out and
|
|
14
|
+
* duplicate work are advisory; only a recent burst, depth or economic
|
|
15
|
+
* threshold can stop new work. Spawn rate can be made advisory by policy.
|
|
17
16
|
*/
|
|
18
17
|
import type { BurnReport, SessionState, Thresholds } from '../types';
|
|
19
18
|
/**
|
|
@@ -6,20 +6,20 @@
|
|
|
6
6
|
* failure modes:
|
|
7
7
|
*
|
|
8
8
|
* structural - fan-out. Many agents, each re-sending context. The 190-spawn
|
|
9
|
-
* session. Caught by
|
|
9
|
+
* session. Caught by spawn windows over active time.
|
|
10
10
|
* economic - sustained burn. Few agents, long session, enormous total. The
|
|
11
11
|
* 9.15B session with only 26 spawns, which a spawn cap cannot
|
|
12
12
|
* see. Caught by a cumulative token ceiling and burn debt.
|
|
13
13
|
*
|
|
14
|
-
* Verdict is the maximum severity across findings.
|
|
15
|
-
*
|
|
16
|
-
*
|
|
17
|
-
* safety tool uninstalled.
|
|
14
|
+
* Verdict is the maximum severity across findings. Lifetime fan-out and
|
|
15
|
+
* duplicate work are advisory; only a recent burst, depth or economic
|
|
16
|
+
* threshold can stop new work. Spawn rate can be made advisory by policy.
|
|
18
17
|
*/
|
|
19
18
|
Object.defineProperty(exports, "__esModule", { value: true });
|
|
20
19
|
exports.evaluate = evaluate;
|
|
21
20
|
exports.fmt = fmt;
|
|
22
21
|
const account_1 = require("../state/account");
|
|
22
|
+
const defaults_1 = require("../defaults");
|
|
23
23
|
const session_1 = require("../state/session");
|
|
24
24
|
const RANK = { OK: 0, WARN: 1, STOP: 2 };
|
|
25
25
|
function worst(a, b) {
|
|
@@ -36,12 +36,14 @@ function evaluate(state, thresholds, proposedSpawnDepth = null, account) {
|
|
|
36
36
|
// ---- structural plane: fan-out --------------------------------------
|
|
37
37
|
// Count the proposal itself. "Allow through 40, deny candidate 41."
|
|
38
38
|
const effectiveSpawns = state.spawnCount + (proposedSpawnDepth !== null ? 1 : 0);
|
|
39
|
-
|
|
39
|
+
const fanoutWindow = thresholds.fanout.windowActiveMinutes ?? defaults_1.DEFAULT_THRESHOLDS.fanout.windowActiveMinutes;
|
|
40
|
+
const recentFanout = (0, session_1.windowSum)(state.spawnsByActiveMinute, state.activeMinutes, fanoutWindow) + (proposedSpawnDepth !== null ? 1 : 0);
|
|
41
|
+
if (recentFanout > thresholds.fanout.stop) {
|
|
40
42
|
findings.push({
|
|
41
43
|
detector: 'fanout',
|
|
42
44
|
verdict: 'STOP',
|
|
43
|
-
summary: `Spawn ${
|
|
44
|
-
observed:
|
|
45
|
+
summary: `Spawn ${recentFanout} would exceed the fan-out ceiling of ${thresholds.fanout.stop}.`,
|
|
46
|
+
observed: recentFanout,
|
|
45
47
|
threshold: thresholds.fanout.stop,
|
|
46
48
|
});
|
|
47
49
|
verdict = worst(verdict, 'STOP');
|
|
@@ -110,8 +112,8 @@ function evaluate(state, thresholds, proposedSpawnDepth = null, account) {
|
|
|
110
112
|
verdict = worst(verdict, 'WARN');
|
|
111
113
|
}
|
|
112
114
|
}
|
|
113
|
-
// ----
|
|
114
|
-
const recentSpawns = (0, session_1.windowSum)(state.spawnsByActiveMinute, state.activeMinutes, thresholds.spawnRate.windowActiveMinutes);
|
|
115
|
+
// ---- structural plane: spawn rate over active time -----------------
|
|
116
|
+
const recentSpawns = (0, session_1.windowSum)(state.spawnsByActiveMinute, state.activeMinutes, thresholds.spawnRate.windowActiveMinutes) + (proposedSpawnDepth !== null ? 1 : 0);
|
|
115
117
|
if (recentSpawns >= thresholds.spawnRate.stop) {
|
|
116
118
|
const severity = thresholds.spawnRate.enforce ? 'STOP' : 'WARN';
|
|
117
119
|
findings.push({
|
|
@@ -199,7 +201,7 @@ function prescribe(state, findings, t) {
|
|
|
199
201
|
out.push(`Let the ${Math.min(state.spawnCount, 4)} most useful running agents finish; do not replace them.`);
|
|
200
202
|
}
|
|
201
203
|
else if (has('fanout')) {
|
|
202
|
-
out.push(`You are at ${state.spawnCount} spawns; ${t.fanout.stop - state.
|
|
204
|
+
out.push(`You are at ${state.spawnCount} spawns; ${Math.max(0, t.fanout.stop - (0, session_1.windowSum)(state.spawnsByActiveMinute, state.activeMinutes, t.fanout.windowActiveMinutes ?? defaults_1.DEFAULT_THRESHOLDS.fanout.windowActiveMinutes))} remain before the ceiling. Plan for it.`);
|
|
203
205
|
}
|
|
204
206
|
if (state.maxDepth >= 2) {
|
|
205
207
|
out.push('Agents are spawning agents. Make the root orchestrator the only process allowed to spawn.');
|
package/dist/src/gateway.js
CHANGED
|
@@ -41,6 +41,7 @@ const pre_tool_use_1 = require("./hook/pre-tool-use");
|
|
|
41
41
|
const override_1 = require("./override");
|
|
42
42
|
const receipt_1 = require("./receipt");
|
|
43
43
|
const reservations_1 = require("./state/reservations");
|
|
44
|
+
const spawn_window_1 = require("./state/spawn-window");
|
|
44
45
|
const account_1 = require("./state/account");
|
|
45
46
|
const session_1 = require("./state/session");
|
|
46
47
|
const MAX_SEEN_EVENTS = 4000;
|
|
@@ -217,14 +218,7 @@ class Gateway {
|
|
|
217
218
|
}
|
|
218
219
|
const live = new Map(meta.liveSpawns);
|
|
219
220
|
const proposedDepth = event.proposedDepth ?? (event.issuerId !== undefined && live.has(event.issuerId) ? live.get(event.issuerId) + 1 : 1);
|
|
220
|
-
const report = (0,
|
|
221
|
-
const reservation = tx.reserve({
|
|
222
|
-
sessionId: event.sessionId,
|
|
223
|
-
toolUseId: event.spawnId,
|
|
224
|
-
observedSpawns: state.spawnCount,
|
|
225
|
-
ceiling: policy.thresholds.fanout.stop,
|
|
226
|
-
now: event.at,
|
|
227
|
-
});
|
|
221
|
+
const { report, reservation } = (0, spawn_window_1.evaluateSpawnReservation)(tx, state, policy.thresholds, proposedDepth, event.spawnId, event.at, { sessions: (0, account_1.readAccountSessions)(this.home, state, event.at), now: event.at });
|
|
228
222
|
const wouldBlock = report.verdict === 'STOP' || !reservation.allowed;
|
|
229
223
|
const verdict = wouldBlock ? 'STOP' : report.verdict;
|
|
230
224
|
// Session-scope STOPs need a session we trust. Fan-out is session scope.
|
|
@@ -12,6 +12,8 @@
|
|
|
12
12
|
* truncated or replaced file resets the cursor rather than reading garbage.
|
|
13
13
|
*/
|
|
14
14
|
import type { BurnEvent } from '../types';
|
|
15
|
+
/** Component maxima for one provider response, never transcript contents. */
|
|
16
|
+
export type UsageSnapshot = [input: number, output: number, cacheCreation: number, cacheRead: number];
|
|
15
17
|
export interface ReaderCursor {
|
|
16
18
|
/** Byte just after the last complete newline we processed. */
|
|
17
19
|
offset: number;
|
|
@@ -20,6 +22,11 @@ export interface ReaderCursor {
|
|
|
20
22
|
malformedLines: number;
|
|
21
23
|
/** Resolved depth per line uuid, for spawn-depth attribution. */
|
|
22
24
|
depthByUuid: Map<string, number>;
|
|
25
|
+
/** Optional and additive so callers with pre-0.2.6 cursors still load. */
|
|
26
|
+
usageVersion?: 1;
|
|
27
|
+
usageByMessage?: Map<string, UsageSnapshot>;
|
|
28
|
+
/** Child file cursors share usageByMessage with their parent. */
|
|
29
|
+
children?: Map<string, ReaderCursor>;
|
|
23
30
|
}
|
|
24
31
|
export declare function newCursor(): ReaderCursor;
|
|
25
32
|
interface RawLine {
|
|
@@ -28,6 +35,7 @@ interface RawLine {
|
|
|
28
35
|
parentUuid?: string;
|
|
29
36
|
isSidechain?: boolean;
|
|
30
37
|
message?: {
|
|
38
|
+
id?: unknown;
|
|
31
39
|
usage?: Record<string, number | undefined>;
|
|
32
40
|
content?: unknown;
|
|
33
41
|
};
|
|
@@ -39,8 +47,18 @@ export declare function normaliseLine(raw: RawLine, cursor: ReaderCursor): BurnE
|
|
|
39
47
|
* and advances the cursor. O(new bytes), never O(file size) after the first
|
|
40
48
|
* read.
|
|
41
49
|
*/
|
|
42
|
-
export
|
|
43
|
-
/**
|
|
50
|
+
export interface ReadOptions {
|
|
51
|
+
/** Legacy-state rebuilds must not replace totals after an unreadable file. */
|
|
52
|
+
throwOnOpenError?: boolean;
|
|
53
|
+
}
|
|
54
|
+
export declare function readIncremental(path: string, cursor: ReaderCursor, options?: ReadOptions): BurnEvent[];
|
|
55
|
+
/**
|
|
56
|
+
* Fold the parent's tool events and its stored children's usage into one
|
|
57
|
+
* session. Child histories can contain copied parent tool calls, so only their
|
|
58
|
+
* usage deltas contribute here. The parent's observed spawn count stays intact.
|
|
59
|
+
*/
|
|
60
|
+
export declare function readSessionIncremental(path: string, cursor: ReaderCursor, options?: ReadOptions): BurnEvent[];
|
|
61
|
+
/** Convenience for replay and tests: read a session and its own stored children. */
|
|
44
62
|
export declare function readAll(path: string): {
|
|
45
63
|
events: BurnEvent[];
|
|
46
64
|
cursor: ReaderCursor;
|