cadet-agent 0.20.2 → 0.24.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 +46 -6
- package/package.json +35 -35
- package/src/cli.mjs +373 -33
- package/src/harness/archive.mjs +242 -0
- package/src/harness/budget.mjs +298 -0
- package/src/harness/context.mjs +229 -0
- package/src/harness/hook.mjs +147 -0
- package/src/harness/index.mjs +60 -0
- package/src/harness/ledger.mjs +309 -0
- package/src/harness/policy.mjs +359 -0
- package/src/harness/redaction.mjs +133 -0
- package/src/harness/routing.mjs +153 -0
- package/src/harness/state.mjs +646 -0
- package/src/harness/util.mjs +149 -0
- package/src/harness/verification.mjs +490 -0
- package/src/install.mjs +92 -154
|
@@ -0,0 +1,242 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Cadet-Agent archive safety.
|
|
3
|
+
*
|
|
4
|
+
* A hardened, zero-dependency ZIP reader/extractor with explicit containment and
|
|
5
|
+
* resource limits. Rejects absolute paths, traversal after canonicalization,
|
|
6
|
+
* paths outside the target directory, oversized filenames, too many files,
|
|
7
|
+
* oversized compressed/uncompressed totals, and excessive compression ratios.
|
|
8
|
+
* Validates central-directory and local-header bounds before allocating, and
|
|
9
|
+
* verifies CRC-32 when present.
|
|
10
|
+
*
|
|
11
|
+
* Contract: docs/core/HarnessContract.md §9 (archive safety).
|
|
12
|
+
*/
|
|
13
|
+
|
|
14
|
+
import { mkdirSync, writeFileSync } from 'node:fs';
|
|
15
|
+
import { join, normalize, resolve, sep } from 'node:path';
|
|
16
|
+
import { inflateRawSync } from 'node:zlib';
|
|
17
|
+
|
|
18
|
+
export const SIG_EOCD = 0x06054b50;
|
|
19
|
+
export const SIG_CD = 0x02014b50;
|
|
20
|
+
export const SIG_LFH = 0x04034b50;
|
|
21
|
+
|
|
22
|
+
const MIB = 1024 * 1024;
|
|
23
|
+
|
|
24
|
+
/** Default archive limits (mirrors policy DEFAULT_ARCHIVE_LIMITS). */
|
|
25
|
+
export const DEFAULT_ARCHIVE_LIMITS = Object.freeze({
|
|
26
|
+
maxCompressedBytes: 25 * MIB,
|
|
27
|
+
maxDecompressedBytes: 100 * MIB,
|
|
28
|
+
maxFiles: 2000,
|
|
29
|
+
maxFilenameBytes: 240,
|
|
30
|
+
maxCompressionRatio: 100,
|
|
31
|
+
});
|
|
32
|
+
|
|
33
|
+
export class ArchiveError extends Error {
|
|
34
|
+
constructor(message, code = 'archive-error') {
|
|
35
|
+
super(message);
|
|
36
|
+
this.name = 'ArchiveError';
|
|
37
|
+
this.code = code;
|
|
38
|
+
}
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
function read32(buf, off) { return buf.readUInt32LE(off); }
|
|
42
|
+
function read16(buf, off) { return buf.readUInt16LE(off); }
|
|
43
|
+
|
|
44
|
+
/** CRC-32 (IEEE) table, computed once. */
|
|
45
|
+
const CRC_TABLE = (() => {
|
|
46
|
+
const table = new Uint32Array(256);
|
|
47
|
+
for (let n = 0; n < 256; n++) {
|
|
48
|
+
let c = n;
|
|
49
|
+
for (let k = 0; k < 8; k++) c = c & 1 ? 0xEDB88320 ^ (c >>> 1) : c >>> 1;
|
|
50
|
+
table[n] = c >>> 0;
|
|
51
|
+
}
|
|
52
|
+
return table;
|
|
53
|
+
})();
|
|
54
|
+
|
|
55
|
+
export function crc32(buf) {
|
|
56
|
+
let crc = 0xFFFFFFFF;
|
|
57
|
+
for (let i = 0; i < buf.length; i++) crc = CRC_TABLE[(crc ^ buf[i]) & 0xFF] ^ (crc >>> 8);
|
|
58
|
+
return (crc ^ 0xFFFFFFFF) >>> 0;
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
export function findEocd(buf) {
|
|
62
|
+
if (buf.length < 22) throw new ArchiveError('Not a valid ZIP file: too short', 'not-a-zip');
|
|
63
|
+
const maxStart = Math.max(0, buf.length - 65535 - 22);
|
|
64
|
+
for (let i = buf.length - 22; i >= maxStart; i--) {
|
|
65
|
+
if (read32(buf, i) === SIG_EOCD) return i;
|
|
66
|
+
}
|
|
67
|
+
throw new ArchiveError('Not a valid ZIP file: EOCD signature not found', 'not-a-zip');
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
/**
|
|
71
|
+
* Validate that a resolved output path stays inside the target directory.
|
|
72
|
+
* Rejects absolute paths and any traversal that escapes after canonicalization.
|
|
73
|
+
*/
|
|
74
|
+
export function assertContained(targetDir, entryName) {
|
|
75
|
+
const raw = entryName.replace(/\\/g, '/');
|
|
76
|
+
if (raw.startsWith('/') || /^[a-zA-Z]:\//.test(raw)) {
|
|
77
|
+
throw new ArchiveError(`archive entry uses an absolute path: ${entryName}`, 'absolute-path');
|
|
78
|
+
}
|
|
79
|
+
const resolvedRoot = resolve(targetDir);
|
|
80
|
+
const resolved = resolve(join(targetDir, raw));
|
|
81
|
+
if (resolved !== resolvedRoot && !resolved.startsWith(resolvedRoot + sep)) {
|
|
82
|
+
throw new ArchiveError(`archive entry escapes the target directory: ${entryName}`, 'traversal');
|
|
83
|
+
}
|
|
84
|
+
return resolved;
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
function validateEntry(entry, limits, running) {
|
|
88
|
+
const nameBytes = Buffer.byteLength(entry.filename, 'utf-8');
|
|
89
|
+
if (nameBytes > limits.maxFilenameBytes) {
|
|
90
|
+
throw new ArchiveError(`archive entry filename exceeds ${limits.maxFilenameBytes} bytes: ${entry.filename.slice(0, 60)}`, 'filename-too-long');
|
|
91
|
+
}
|
|
92
|
+
if (entry.uncompressedSize > limits.maxDecompressedBytes || running.uncompressed + entry.uncompressedSize > limits.maxDecompressedBytes) {
|
|
93
|
+
throw new ArchiveError(`decompressed size exceeds ${limits.maxDecompressedBytes} bytes`, 'decompressed-too-large');
|
|
94
|
+
}
|
|
95
|
+
if (entry.compressedSize > limits.maxCompressedBytes || running.compressed + entry.compressedSize > limits.maxCompressedBytes) {
|
|
96
|
+
throw new ArchiveError(`compressed size exceeds ${limits.maxCompressedBytes} bytes`, 'compressed-too-large');
|
|
97
|
+
}
|
|
98
|
+
if (entry.uncompressedSize > 0 && entry.compressedSize > 0) {
|
|
99
|
+
const ratio = entry.uncompressedSize / entry.compressedSize;
|
|
100
|
+
if (ratio > limits.maxCompressionRatio) {
|
|
101
|
+
throw new ArchiveError(`compression ratio ${ratio.toFixed(1)}:1 exceeds ${limits.maxCompressionRatio}:1 for ${entry.filename}`, 'compression-ratio');
|
|
102
|
+
}
|
|
103
|
+
}
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
/**
|
|
107
|
+
* Iterate central-directory entries with bounds checking. Directory entries are
|
|
108
|
+
* yielded too (`isDirectory: true`) so callers can decide what to do, but the
|
|
109
|
+
* default extractor skips them.
|
|
110
|
+
*/
|
|
111
|
+
export function* readEntries(buf, limits = DEFAULT_ARCHIVE_LIMITS) {
|
|
112
|
+
const eocdOff = findEocd(buf);
|
|
113
|
+
const cdSize = read32(buf, eocdOff + 12);
|
|
114
|
+
const cdOff = read32(buf, eocdOff + 16);
|
|
115
|
+
const totalEntries = read16(buf, eocdOff + 10);
|
|
116
|
+
|
|
117
|
+
if (cdOff === 0xFFFFFFFF || cdSize === 0xFFFFFFFF) {
|
|
118
|
+
throw new ArchiveError('ZIP64 format is not supported', 'zip64');
|
|
119
|
+
}
|
|
120
|
+
if (cdOff + cdSize > buf.length) {
|
|
121
|
+
throw new ArchiveError('central directory extends past the end of the archive', 'malformed');
|
|
122
|
+
}
|
|
123
|
+
if (totalEntries > limits.maxFiles) {
|
|
124
|
+
throw new ArchiveError(`archive declares ${totalEntries} files, over the limit of ${limits.maxFiles}`, 'too-many-files');
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
const running = { compressed: 0, uncompressed: 0, files: 0 };
|
|
128
|
+
let off = cdOff;
|
|
129
|
+
const end = cdOff + cdSize;
|
|
130
|
+
while (off + 46 <= end) {
|
|
131
|
+
if (read32(buf, off) !== SIG_CD) {
|
|
132
|
+
throw new ArchiveError('malformed central directory entry', 'malformed');
|
|
133
|
+
}
|
|
134
|
+
const method = read16(buf, off + 10);
|
|
135
|
+
const crc = read32(buf, off + 16);
|
|
136
|
+
const compressedSize = read32(buf, off + 20);
|
|
137
|
+
const uncompressedSize = read32(buf, off + 24);
|
|
138
|
+
const filenameLen = read16(buf, off + 28);
|
|
139
|
+
const extraLen = read16(buf, off + 30);
|
|
140
|
+
const commentLen = read16(buf, off + 32);
|
|
141
|
+
const localHeaderOff = read32(buf, off + 42);
|
|
142
|
+
if (off + 46 + filenameLen + extraLen + commentLen > end) {
|
|
143
|
+
throw new ArchiveError('central directory entry extends past the directory', 'malformed');
|
|
144
|
+
}
|
|
145
|
+
const filename = buf.toString('utf-8', off + 46, off + 46 + filenameLen).replace(/\\/g, '/');
|
|
146
|
+
if (localHeaderOff + 30 > buf.length) {
|
|
147
|
+
throw new ArchiveError(`local header offset out of bounds for ${filename}`, 'malformed');
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
const isDirectory = filename.endsWith('/');
|
|
151
|
+
const entry = { filename, method, compressedSize, uncompressedSize, localHeaderOff, crc, isDirectory };
|
|
152
|
+
if (!isDirectory) {
|
|
153
|
+
running.files++;
|
|
154
|
+
if (running.files > limits.maxFiles) {
|
|
155
|
+
throw new ArchiveError(`archive contains more than ${limits.maxFiles} files`, 'too-many-files');
|
|
156
|
+
}
|
|
157
|
+
validateEntry(entry, limits, running);
|
|
158
|
+
running.compressed += compressedSize;
|
|
159
|
+
running.uncompressed += uncompressedSize;
|
|
160
|
+
}
|
|
161
|
+
yield entry;
|
|
162
|
+
off += 46 + filenameLen + extraLen + commentLen;
|
|
163
|
+
}
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
/** Decompress one entry's payload, validating the local header and CRC. */
|
|
167
|
+
export function readEntryData(buf, entry) {
|
|
168
|
+
const { localHeaderOff } = entry;
|
|
169
|
+
if (read32(buf, localHeaderOff) !== SIG_LFH) {
|
|
170
|
+
throw new ArchiveError(`local file header signature missing for ${entry.filename}`, 'malformed');
|
|
171
|
+
}
|
|
172
|
+
const lfhFilenameLen = read16(buf, localHeaderOff + 26);
|
|
173
|
+
const lfhExtraLen = read16(buf, localHeaderOff + 28);
|
|
174
|
+
const dataStart = localHeaderOff + 30 + lfhFilenameLen + lfhExtraLen;
|
|
175
|
+
if (dataStart + entry.compressedSize > buf.length) {
|
|
176
|
+
throw new ArchiveError(`entry data extends past the end of the archive: ${entry.filename}`, 'malformed');
|
|
177
|
+
}
|
|
178
|
+
const compressed = buf.subarray(dataStart, dataStart + entry.compressedSize);
|
|
179
|
+
|
|
180
|
+
let data;
|
|
181
|
+
if (entry.method === 0) {
|
|
182
|
+
data = compressed;
|
|
183
|
+
} else if (entry.method === 8) {
|
|
184
|
+
try {
|
|
185
|
+
data = inflateRawSync(compressed);
|
|
186
|
+
} catch (err) {
|
|
187
|
+
throw new ArchiveError(`failed to inflate ${entry.filename}: ${err.message}`, 'inflate-failed');
|
|
188
|
+
}
|
|
189
|
+
} else {
|
|
190
|
+
throw new ArchiveError(
|
|
191
|
+
`unsupported compression method ${entry.method} for ${entry.filename}. ` +
|
|
192
|
+
'Download cadet-agent.zip manually from https://github.com/naishtech/cadet-agent/releases/latest',
|
|
193
|
+
'unsupported-method'
|
|
194
|
+
);
|
|
195
|
+
}
|
|
196
|
+
|
|
197
|
+
if (data.length !== entry.uncompressedSize) {
|
|
198
|
+
throw new ArchiveError(`size mismatch for ${entry.filename} (expected ${entry.uncompressedSize}, got ${data.length})`, 'size-mismatch');
|
|
199
|
+
}
|
|
200
|
+
// Verify CRC when the archive provides one (0 means "not recorded").
|
|
201
|
+
if (entry.crc !== 0 && crc32(data) !== entry.crc) {
|
|
202
|
+
throw new ArchiveError(`CRC mismatch for ${entry.filename}`, 'crc-mismatch');
|
|
203
|
+
}
|
|
204
|
+
return data;
|
|
205
|
+
}
|
|
206
|
+
|
|
207
|
+
/**
|
|
208
|
+
* Extract a ZIP into targetDir with full containment and limit enforcement.
|
|
209
|
+
* `filter` may return `{ skip: true }` to omit an entry. Returns extracted paths.
|
|
210
|
+
*/
|
|
211
|
+
export function extractArchive(buf, targetDir, { limits = DEFAULT_ARCHIVE_LIMITS, filter = null } = {}) {
|
|
212
|
+
if (buf.length > limits.maxCompressedBytes) {
|
|
213
|
+
throw new ArchiveError(`archive is ${buf.length} bytes, over the compressed limit of ${limits.maxCompressedBytes}`, 'compressed-too-large');
|
|
214
|
+
}
|
|
215
|
+
const extracted = [];
|
|
216
|
+
const skipped = [];
|
|
217
|
+
for (const entry of readEntries(buf, limits)) {
|
|
218
|
+
if (entry.isDirectory) continue;
|
|
219
|
+
if (filter) {
|
|
220
|
+
const decision = filter(entry);
|
|
221
|
+
if (decision === false || decision?.skip) { skipped.push(entry.filename); continue; }
|
|
222
|
+
}
|
|
223
|
+
const outPath = assertContained(targetDir, entry.filename);
|
|
224
|
+
const data = readEntryData(buf, entry);
|
|
225
|
+
mkdirSync(join(outPath, '..'), { recursive: true });
|
|
226
|
+
writeFileSync(outPath, data);
|
|
227
|
+
extracted.push(outPath);
|
|
228
|
+
}
|
|
229
|
+
return { extracted, skipped, targetDir: normalize(targetDir) };
|
|
230
|
+
}
|
|
231
|
+
|
|
232
|
+
/** Read a single entry's bytes without extracting (used for the manifest). */
|
|
233
|
+
export function readArchiveEntry(buf, name, limits = DEFAULT_ARCHIVE_LIMITS) {
|
|
234
|
+
const normalized = name.replace(/\\/g, '/');
|
|
235
|
+
for (const entry of readEntries(buf, limits)) {
|
|
236
|
+
if (entry.isDirectory) continue;
|
|
237
|
+
if (entry.filename.replace(/^\.\//, '') === normalized.replace(/^\.\//, '')) {
|
|
238
|
+
return readEntryData(buf, entry);
|
|
239
|
+
}
|
|
240
|
+
}
|
|
241
|
+
return null;
|
|
242
|
+
}
|
|
@@ -0,0 +1,298 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Cadet-Agent harness budget accounting.
|
|
3
|
+
*
|
|
4
|
+
* Deterministic counters over the resolved policy. Consumed by the ledger,
|
|
5
|
+
* verification runner, CLI, and installer. A budget result is machine-readable
|
|
6
|
+
* and can never be confused with success: `ok === false` for warnings and stops.
|
|
7
|
+
*
|
|
8
|
+
* Contract: docs/core/HarnessContract.md §3 (budgets) and §4 (estimation).
|
|
9
|
+
*/
|
|
10
|
+
|
|
11
|
+
import { budgetForScope, warnThreshold, PolicyError } from './policy.mjs';
|
|
12
|
+
|
|
13
|
+
export const BUDGET_RESULTS = Object.freeze(['ok', 'warning', 'exhausted']);
|
|
14
|
+
|
|
15
|
+
const COUNTER_TO_BUDGET = Object.freeze({
|
|
16
|
+
contextTokens: 'maxContextTokens',
|
|
17
|
+
outputTokens: 'maxOutputTokens',
|
|
18
|
+
toolCalls: 'maxToolCalls',
|
|
19
|
+
retries: 'maxTotalRetries',
|
|
20
|
+
wallClockMs: 'maxWallClockMs',
|
|
21
|
+
estimatedCostUsd: 'maxEstimatedCostUsd',
|
|
22
|
+
downloadedBytes: 'maxDownloadedBytes',
|
|
23
|
+
decompressedBytes: 'maxDecompressedBytes',
|
|
24
|
+
archiveFiles: 'maxArchiveFiles',
|
|
25
|
+
});
|
|
26
|
+
|
|
27
|
+
/** Per-something counters that are tracked separately from the run totals. */
|
|
28
|
+
function emptyCounters() {
|
|
29
|
+
return {
|
|
30
|
+
contextTokens: 0,
|
|
31
|
+
outputTokens: 0,
|
|
32
|
+
toolCalls: 0,
|
|
33
|
+
retries: 0,
|
|
34
|
+
wallClockMs: 0,
|
|
35
|
+
estimatedCostUsd: 0,
|
|
36
|
+
downloadedBytes: 0,
|
|
37
|
+
decompressedBytes: 0,
|
|
38
|
+
archiveFiles: 0,
|
|
39
|
+
};
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
function requireNonNegative(name, value) {
|
|
43
|
+
if (typeof value !== 'number' || !Number.isFinite(value) || value < 0) {
|
|
44
|
+
throw new PolicyError(`Budget counter "${name}" must be a non-negative finite number (got ${value}).`);
|
|
45
|
+
}
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
/**
|
|
49
|
+
* A budget tracker for one scope (a run or a story).
|
|
50
|
+
* Immutable reads: `snapshot()` is a frozen copy.
|
|
51
|
+
*/
|
|
52
|
+
export class BudgetTracker {
|
|
53
|
+
constructor(policy, scope = 'perRun') {
|
|
54
|
+
this.scope = scope;
|
|
55
|
+
this.limits = budgetForScope(policy, scope);
|
|
56
|
+
const caps = budgetForScope(policy, 'perRun');
|
|
57
|
+
this.caps = caps; // hard safety ceilings, applied on top of scope overrides
|
|
58
|
+
this.counters = emptyCounters();
|
|
59
|
+
this.events = [];
|
|
60
|
+
// Counters whose value could not be measured. An unmeasurable cost budget
|
|
61
|
+
// must never be treated as satisfied (contract §4).
|
|
62
|
+
this.unknown = new Set();
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
/** Mark a counter as unmeasurable (e.g. provider cost with no rate card). */
|
|
66
|
+
markUnknown(name, reason = 'value not measurable') {
|
|
67
|
+
if (!(name in this.counters)) {
|
|
68
|
+
throw new PolicyError(`Unknown budget counter "${name}".`);
|
|
69
|
+
}
|
|
70
|
+
this.unknown.add(name);
|
|
71
|
+
this.events.push({ type: 'budget-unknown', counter: name, reason, at: new Date().toISOString() });
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
/**
|
|
75
|
+
* Add to a counter. Throws on unknown counter or invalid value rather than
|
|
76
|
+
* silently recording a wrong number.
|
|
77
|
+
*/
|
|
78
|
+
add(name, amount, meta = {}) {
|
|
79
|
+
if (!(name in this.counters)) {
|
|
80
|
+
throw new PolicyError(`Unknown budget counter "${name}".`);
|
|
81
|
+
}
|
|
82
|
+
requireNonNegative(name, amount);
|
|
83
|
+
this.counters[name] += amount;
|
|
84
|
+
if (meta.reason) {
|
|
85
|
+
this.events.push({ type: 'budget-add', counter: name, amount, reason: meta.reason, at: new Date().toISOString() });
|
|
86
|
+
}
|
|
87
|
+
return this.evaluate(name);
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
set(name, value) {
|
|
91
|
+
if (!(name in this.counters)) {
|
|
92
|
+
throw new PolicyError(`Unknown budget counter "${name}".`);
|
|
93
|
+
}
|
|
94
|
+
requireNonNegative(name, value);
|
|
95
|
+
this.counters[name] = value;
|
|
96
|
+
return this.evaluate(name);
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
/** Evaluate one counter (or all) against its budget. */
|
|
100
|
+
evaluate(name) {
|
|
101
|
+
const names = name ? [name] : Object.keys(this.counters);
|
|
102
|
+
const checks = names.map((n) => this.check(n));
|
|
103
|
+
const worst = checks.reduce((acc, c) => (c.status === 'exhausted' ? c : acc.status === 'exhausted' ? acc : c.status === 'warning' ? c : acc), { status: 'ok' });
|
|
104
|
+
return {
|
|
105
|
+
status: worst.status,
|
|
106
|
+
ok: worst.status === 'ok',
|
|
107
|
+
exhausted: worst.status === 'exhausted',
|
|
108
|
+
checks,
|
|
109
|
+
};
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
check(name) {
|
|
113
|
+
const budgetKey = COUNTER_TO_BUDGET[name];
|
|
114
|
+
const limit = this.limits[budgetKey];
|
|
115
|
+
const cap = this.caps[budgetKey] || limit;
|
|
116
|
+
const used = this.counters[name];
|
|
117
|
+
const hard = Math.min(limit?.hard ?? Infinity, cap?.hard ?? Infinity);
|
|
118
|
+
const warn = limit ? warnThreshold(limit) : null;
|
|
119
|
+
let status = 'ok';
|
|
120
|
+
if (hard !== Infinity && used >= hard) status = 'exhausted';
|
|
121
|
+
else if (warn !== null && used >= warn) status = 'warning';
|
|
122
|
+
return {
|
|
123
|
+
counter: name,
|
|
124
|
+
budget: budgetKey,
|
|
125
|
+
used,
|
|
126
|
+
hard: hard === Infinity ? null : hard,
|
|
127
|
+
warn,
|
|
128
|
+
remaining: hard === Infinity ? null : Math.max(0, hard - used),
|
|
129
|
+
fraction: hard && hard !== Infinity ? used / hard : 0,
|
|
130
|
+
status,
|
|
131
|
+
};
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
/** Per-step retry check; `maxRetriesPerStep` is separate from the run total. */
|
|
135
|
+
checkStepRetries(stepRetries) {
|
|
136
|
+
const per = this.limits.maxRetriesPerStep?.hard ?? Infinity;
|
|
137
|
+
const total = this.check('retries');
|
|
138
|
+
let status = 'ok';
|
|
139
|
+
if (stepRetries >= per) status = 'exhausted';
|
|
140
|
+
else if (stepRetries + 1 >= per) status = 'warning';
|
|
141
|
+
if (total.status === 'exhausted') status = 'exhausted';
|
|
142
|
+
else if (status === 'ok' && total.status === 'warning') status = 'warning';
|
|
143
|
+
return {
|
|
144
|
+
counter: 'retriesPerStep',
|
|
145
|
+
used: stepRetries,
|
|
146
|
+
hard: per === Infinity ? null : per,
|
|
147
|
+
remaining: per === Infinity ? null : Math.max(0, per - stepRetries),
|
|
148
|
+
status,
|
|
149
|
+
};
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
snapshot() {
|
|
153
|
+
const checks = Object.keys(this.counters).map((n) => this.check(n));
|
|
154
|
+
return Object.freeze({
|
|
155
|
+
scope: this.scope,
|
|
156
|
+
counters: { ...this.counters },
|
|
157
|
+
checks,
|
|
158
|
+
events: [...this.events],
|
|
159
|
+
});
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
/** Machine-readable final result. `ok` is true only when every budget is within limits. */
|
|
163
|
+
result() {
|
|
164
|
+
const evaluation = this.evaluate();
|
|
165
|
+
const unknownCost = this.unknown.has('estimatedCostUsd');
|
|
166
|
+
return {
|
|
167
|
+
status: evaluation.status,
|
|
168
|
+
ok: evaluation.status === 'ok' && !unknownCost,
|
|
169
|
+
exhausted: evaluation.status === 'exhausted',
|
|
170
|
+
checks: evaluation.checks,
|
|
171
|
+
counters: { ...this.counters },
|
|
172
|
+
unknown: [...this.unknown],
|
|
173
|
+
costUnmeasurable: unknownCost,
|
|
174
|
+
};
|
|
175
|
+
}
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
/**
|
|
179
|
+
* A hard-stop result. Always `ok: false` — callers must never treat this as success.
|
|
180
|
+
*/
|
|
181
|
+
export function budgetExhaustedResult(reason, detail = {}) {
|
|
182
|
+
return {
|
|
183
|
+
status: 'exhausted',
|
|
184
|
+
ok: false,
|
|
185
|
+
exhausted: true,
|
|
186
|
+
exitCode: 3,
|
|
187
|
+
code: 'budget-exhausted',
|
|
188
|
+
reason,
|
|
189
|
+
...detail,
|
|
190
|
+
};
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
/** Compute a consumption report row for display (no secrets involved). */
|
|
194
|
+
export function budgetReport(tracker) {
|
|
195
|
+
return tracker.result().checks.map((c) => ({
|
|
196
|
+
budget: c.budget,
|
|
197
|
+
used: c.used,
|
|
198
|
+
hard: c.hard,
|
|
199
|
+
remaining: c.remaining,
|
|
200
|
+
overage: c.hard !== null && c.used > c.hard,
|
|
201
|
+
status: c.status,
|
|
202
|
+
}));
|
|
203
|
+
}
|
|
204
|
+
|
|
205
|
+
/**
|
|
206
|
+
* Evaluate all tracked counters for a hard stop. Returns
|
|
207
|
+
* `{ exhausted, reason, checks }`. A hard stop must block the operation, not
|
|
208
|
+
* merely record a warning.
|
|
209
|
+
*/
|
|
210
|
+
export function evaluateHardStop(tracker) {
|
|
211
|
+
const result = tracker.result();
|
|
212
|
+
if (result.exhausted) {
|
|
213
|
+
const first = result.checks.find((c) => c.status === 'exhausted');
|
|
214
|
+
return {
|
|
215
|
+
exhausted: true,
|
|
216
|
+
blocked: true,
|
|
217
|
+
reason: first ? `${first.budget} budget exhausted (used ${first.used} of ${first.hard})` : 'budget exhausted',
|
|
218
|
+
checks: result.checks,
|
|
219
|
+
};
|
|
220
|
+
}
|
|
221
|
+
if (result.costUnmeasurable) {
|
|
222
|
+
const costCheck = result.checks.find((c) => c.counter === 'estimatedCostUsd');
|
|
223
|
+
// Only block when a cost budget is actually configured.
|
|
224
|
+
if (costCheck && costCheck.hard !== null) {
|
|
225
|
+
return {
|
|
226
|
+
exhausted: false,
|
|
227
|
+
blocked: true,
|
|
228
|
+
reason: 'provider cost is unmeasurable (no rate card); the cost budget cannot be confirmed',
|
|
229
|
+
checks: result.checks,
|
|
230
|
+
};
|
|
231
|
+
}
|
|
232
|
+
}
|
|
233
|
+
return { exhausted: false, blocked: false, reason: null, checks: result.checks };
|
|
234
|
+
}
|
|
235
|
+
|
|
236
|
+
/**
|
|
237
|
+
* Estimate tokens from UTF-8 byte length (contract §4).
|
|
238
|
+
* Marked as an estimate so it can never be mistaken for provider telemetry.
|
|
239
|
+
*/
|
|
240
|
+
export function estimateTokens(textOrBytes, policy) {
|
|
241
|
+
const bytes = typeof textOrBytes === 'number'
|
|
242
|
+
? textOrBytes
|
|
243
|
+
: Buffer.byteLength(String(textOrBytes ?? ''), 'utf-8');
|
|
244
|
+
const per = policy?.estimation?.bytesPerToken || 3;
|
|
245
|
+
return {
|
|
246
|
+
tokens: Math.ceil(bytes / per),
|
|
247
|
+
bytes,
|
|
248
|
+
source: 'estimate',
|
|
249
|
+
confidence: 'low',
|
|
250
|
+
};
|
|
251
|
+
}
|
|
252
|
+
|
|
253
|
+
/**
|
|
254
|
+
* Estimate provider cost from tokens and a rate card.
|
|
255
|
+
* Returns `{ known: false }` when no rate card exists — USD is never invented.
|
|
256
|
+
*/
|
|
257
|
+
export function estimateCost(inputTokens, outputTokens, policy) {
|
|
258
|
+
const model = policy?.model;
|
|
259
|
+
const card = model && policy?.estimation?.rateCards?.[model];
|
|
260
|
+
if (!card) {
|
|
261
|
+
return { known: false, usd: null, source: 'unknown', model: model || null };
|
|
262
|
+
}
|
|
263
|
+
const usd = inputTokens * (card.inputRate ?? 0) + outputTokens * (card.outputRate ?? 0);
|
|
264
|
+
return {
|
|
265
|
+
known: true,
|
|
266
|
+
usd: Math.round(usd * 10000) / 10000,
|
|
267
|
+
source: 'estimate',
|
|
268
|
+
model,
|
|
269
|
+
rateCardId: card.id || model,
|
|
270
|
+
effectiveDate: card.effectiveDate || null,
|
|
271
|
+
};
|
|
272
|
+
}
|
|
273
|
+
|
|
274
|
+
/** Normalize provider or estimated usage. Unknown is `unknown`, never zero. */
|
|
275
|
+
export function normalizeUsage(usage, policy) {
|
|
276
|
+
if (!usage || (usage.inputTokens === undefined && usage.outputTokens === undefined && usage.bytes === undefined)) {
|
|
277
|
+
return { source: 'unknown', inputTokens: null, outputTokens: null, confidence: 'unknown' };
|
|
278
|
+
}
|
|
279
|
+
if (usage.source === 'provider') {
|
|
280
|
+
return {
|
|
281
|
+
source: 'provider',
|
|
282
|
+
inputTokens: usage.inputTokens ?? 0,
|
|
283
|
+
outputTokens: usage.outputTokens ?? 0,
|
|
284
|
+
cachedTokens: usage.cachedTokens ?? null,
|
|
285
|
+
confidence: 'high',
|
|
286
|
+
};
|
|
287
|
+
}
|
|
288
|
+
const est = estimateTokens(usage.bytes ?? 0, policy);
|
|
289
|
+
return {
|
|
290
|
+
source: 'estimate',
|
|
291
|
+
inputTokens: usage.inputTokens ?? est.tokens,
|
|
292
|
+
outputTokens: usage.outputTokens ?? 0,
|
|
293
|
+
cachedTokens: null,
|
|
294
|
+
confidence: est.confidence,
|
|
295
|
+
};
|
|
296
|
+
}
|
|
297
|
+
|
|
298
|
+
export { COUNTER_TO_BUDGET };
|