threadwire 0.1.5 → 0.1.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/README.md +5 -3
- package/docs/card-10520-plan.md +45 -0
- package/docs/container-runtime.md +3 -2
- package/docs/delegated-result-protocol.md +161 -0
- package/docs/evidence-artifacts.md +106 -0
- package/package.json +1 -1
- package/scripts/verify-package.js +7 -1
- package/src/cli.js +250 -29
- package/src/context-budget-metrics.js +180 -0
- package/src/delegated-result-admission.js +377 -0
- package/src/evidence-store.js +1472 -0
- package/src/notice-queue.js +22 -7
- package/src/providers/opencode.js +42 -18
- package/src/relay.js +6 -6
- package/src/run-worker.js +158 -40
- package/src/telegram-ingress/command.js +16 -0
- package/src/telegram-ingress/config.js +89 -11
- package/src/telegram-ingress/core.js +162 -90
- package/src/telegram-ingress/http.js +17 -1
- package/src/telegram-webhook.js +16 -0
- package/src/types.js +2 -2
- package/src/worker-control.js +294 -0
- package/src/hermes-protocol.js +0 -126
package/src/cli.js
CHANGED
|
@@ -1,18 +1,20 @@
|
|
|
1
1
|
// @ts-check
|
|
2
2
|
|
|
3
3
|
import {readFile} from "node:fs/promises"
|
|
4
|
-
import {
|
|
4
|
+
import {randomUUID} from "node:crypto"
|
|
5
|
+
import {isAbsolute, normalize, resolve} from "node:path"
|
|
5
6
|
import {stdin, stderr, stdout} from "node:process"
|
|
6
7
|
import {createFetchTransport} from "./notifiers/fetch-transport.js"
|
|
7
8
|
import {createTelegramSender, parseTelegramTarget} from "./notifiers/telegram.js"
|
|
8
|
-
import {NoticeQueue} from "./notice-queue.js"
|
|
9
9
|
import {createProvider, PROVIDERS} from "./providers/index.js"
|
|
10
|
-
import {Relay} from "./relay.js"
|
|
11
10
|
import {runWorker} from "./run-worker.js"
|
|
12
11
|
import {ActivityLog} from "./activity-log.js"
|
|
13
|
-
import {
|
|
14
|
-
import {buildProviderEnvironment, parseTelegramRequestTimeoutMs, resolveFileBackedSettings} from "./telegram-ingress/config.js"
|
|
12
|
+
import {DelegatedResultAdmission, validateContinuationHandle} from "./delegated-result-admission.js"
|
|
13
|
+
import {buildProviderEnvironment, collectEvidenceRedactions, parseTelegramRequestTimeoutMs, resolveFileBackedSettings} from "./telegram-ingress/config.js"
|
|
15
14
|
import {resolveWorkspaceProfile} from "./workspace-profile.js"
|
|
15
|
+
import {WorkerControl} from "./worker-control.js"
|
|
16
|
+
import {EvidenceStore} from "./evidence-store.js"
|
|
17
|
+
import {ContextBudgetMetrics} from "./context-budget-metrics.js"
|
|
16
18
|
|
|
17
19
|
const HELP = `Usage: threadwire run --provider <codex|claude|opencode> --target telegram:<chat-id> | telegram:<chat-id>:<thread-id>
|
|
18
20
|
[--process-number <positive-integer>] [--cwd <directory>]
|
|
@@ -20,9 +22,12 @@ const HELP = `Usage: threadwire run --provider <codex|claude|opencode> --target
|
|
|
20
22
|
[--tool-messages] [--max-output-length <positive-integer>]
|
|
21
23
|
[--resume-session <provider-session-id>] [--activity-log <local-jsonl-path>]
|
|
22
24
|
[--prompt <text> | --prompt-file <path> | stdin]
|
|
23
|
-
[-- <provider arguments...>]
|
|
25
|
+
[-- <provider arguments...>]
|
|
26
|
+
threadwire evidence read --handle <opaque-handle>
|
|
27
|
+
(--bytes <offset>:<limit> | --lines <start>:<limit> | --query <literal> --context-bytes <limit>)`
|
|
24
28
|
|
|
25
29
|
/** @typedef {{provider: string, target: string, cwd: string, toolMessages: boolean, workspaceProfile?: string, prompt?: string, promptFile?: string, processNumber?: number, maxOutputLength?: number, resumeSession?: string, activityLog?: string, providerArguments: string[]}} ParsedArguments */
|
|
30
|
+
/** @typedef {{evidenceRead: true, request: unknown}} EvidenceParsedArguments */
|
|
26
31
|
/**
|
|
27
32
|
* @typedef {{
|
|
28
33
|
* env?: NodeJS.ProcessEnv,
|
|
@@ -31,6 +36,9 @@ const HELP = `Usage: threadwire run --provider <codex|claude|opencode> --target
|
|
|
31
36
|
* errorOutput?: NodeJS.WritableStream,
|
|
32
37
|
* transportFactory?: typeof createFetchTransport,
|
|
33
38
|
* workerRunner?: typeof runWorker,
|
|
39
|
+
* evidenceStore?: EvidenceStore,
|
|
40
|
+
* evidenceOwnerScope?: {destinationId: string, runId: string},
|
|
41
|
+
* workerControlOptions?: Pick<ConstructorParameters<typeof WorkerControl>[0], "queueOptions">,
|
|
34
42
|
* workspaceProfileOperations?: import("./workspace-profile.js").WorkspaceProfileOperations
|
|
35
43
|
* }} MainDependencies
|
|
36
44
|
*/
|
|
@@ -82,6 +90,46 @@ export function parseArguments(arguments_) {
|
|
|
82
90
|
return /** @type {ParsedArguments} */ (parsed)
|
|
83
91
|
}
|
|
84
92
|
|
|
93
|
+
/** @param {string[]} arguments_ @returns {EvidenceParsedArguments} */
|
|
94
|
+
function parseEvidenceArguments(arguments_) {
|
|
95
|
+
if (arguments_[1] !== "read") throw new Error(HELP)
|
|
96
|
+
/** @type {Record<string, string>} */
|
|
97
|
+
const options = {}
|
|
98
|
+
for (let index = 2; index < arguments_.length; index += 2) {
|
|
99
|
+
const name = arguments_[index]
|
|
100
|
+
const value = arguments_[index + 1]
|
|
101
|
+
if (!name || !value || !["--handle", "--bytes", "--lines", "--query", "--context-bytes"].includes(name)) throw new Error(HELP)
|
|
102
|
+
if (options[name] !== undefined) throw new Error(`Duplicate evidence option: ${name}`)
|
|
103
|
+
options[name] = value
|
|
104
|
+
}
|
|
105
|
+
const handle = options["--handle"]
|
|
106
|
+
if (!handle) throw new Error("--handle is required")
|
|
107
|
+
const selectors = ["--bytes", "--lines", "--query"].filter((name) => options[name] !== undefined)
|
|
108
|
+
if (selectors.length !== 1) throw new Error("Exactly one bounded evidence selector is required")
|
|
109
|
+
let request
|
|
110
|
+
if (options["--bytes"]) request = {handle, bytes: rangePair(options["--bytes"], true)}
|
|
111
|
+
else if (options["--lines"]) request = {handle, lines: rangePair(options["--lines"], false)}
|
|
112
|
+
else {
|
|
113
|
+
const context = options["--context-bytes"]
|
|
114
|
+
if (!context) throw new Error("--context-bytes is required with --query")
|
|
115
|
+
request = {handle, query: {literal: options["--query"], limit: 1, contextBytes: positiveInteger(context, "--context-bytes")}}
|
|
116
|
+
}
|
|
117
|
+
if (options["--query"] === undefined && options["--context-bytes"] !== undefined) throw new Error("--context-bytes requires --query")
|
|
118
|
+
return {evidenceRead: true, request}
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
/** @param {string} value @param {boolean} zeroStart */
|
|
122
|
+
function rangePair(value, zeroStart) {
|
|
123
|
+
const match = /^(\d+):(\d+)$/u.exec(value)
|
|
124
|
+
if (!match) throw new Error("Evidence range must be <start>:<limit>")
|
|
125
|
+
const start = Number(match[1])
|
|
126
|
+
const limit = Number(match[2])
|
|
127
|
+
if (!Number.isSafeInteger(start) || (!zeroStart && start < 1) || (zeroStart && start < 0) || !Number.isSafeInteger(limit) || limit < 1) {
|
|
128
|
+
throw new Error("Evidence range must contain bounded safe integers")
|
|
129
|
+
}
|
|
130
|
+
return zeroStart ? {offset: start, limit} : {start, limit}
|
|
131
|
+
}
|
|
132
|
+
|
|
85
133
|
/**
|
|
86
134
|
* @param {string[]} arguments_
|
|
87
135
|
* @param {MainDependencies} [dependencies]
|
|
@@ -93,11 +141,24 @@ export async function main(arguments_, dependencies = {}) {
|
|
|
93
141
|
let environment = sourceEnvironment
|
|
94
142
|
const validateOnly = environment.THREADWIRE_VALIDATE_ONLY === "1"
|
|
95
143
|
try {
|
|
96
|
-
const parsed = parseArguments(arguments_)
|
|
144
|
+
const parsed = arguments_[0] === "evidence" ? parseEvidenceArguments(arguments_) : parseArguments(arguments_)
|
|
97
145
|
if ("help" in parsed) {
|
|
98
146
|
if (!validateOnly) output.write(`${HELP}\n`)
|
|
99
147
|
return 0
|
|
100
148
|
}
|
|
149
|
+
if ("evidenceRead" in parsed) {
|
|
150
|
+
const root = evidenceRoot(sourceEnvironment.THREADWIRE_EVIDENCE_ROOT)
|
|
151
|
+
if (!root) throw new Error("THREADWIRE_EVIDENCE_ROOT is required")
|
|
152
|
+
if (validateOnly) return 0
|
|
153
|
+
const store = await EvidenceStore.open({root})
|
|
154
|
+
try {
|
|
155
|
+
const result = await store.readBearer(parsed.request)
|
|
156
|
+
output.write(`${JSON.stringify(result)}\n`)
|
|
157
|
+
return 0
|
|
158
|
+
} finally {
|
|
159
|
+
await store.close()
|
|
160
|
+
}
|
|
161
|
+
}
|
|
101
162
|
const target = parseTelegramTarget(parsed.target)
|
|
102
163
|
const resolvedWorkspace = parsed.workspaceProfile === undefined
|
|
103
164
|
? undefined
|
|
@@ -106,35 +167,57 @@ export async function main(arguments_, dependencies = {}) {
|
|
|
106
167
|
dependencies.workspaceProfileOperations
|
|
107
168
|
)
|
|
108
169
|
if (validateOnly) return 0
|
|
109
|
-
|
|
110
|
-
const
|
|
170
|
+
const metrics = new ContextBudgetMetrics()
|
|
171
|
+
const admission = new DelegatedResultAdmission({output, metrics})
|
|
111
172
|
let terminalExitCode = 2
|
|
173
|
+
/** @type {unknown} */
|
|
174
|
+
let evidenceError
|
|
112
175
|
/** @type {ActivityLog | undefined} */
|
|
113
176
|
let activity
|
|
177
|
+
/** @type {Awaited<ReturnType<EvidenceStore["createArtifact"]>> | undefined} */
|
|
178
|
+
let evidence
|
|
179
|
+
/** @type {EvidenceStore | undefined} */
|
|
180
|
+
let ownedEvidenceStore
|
|
181
|
+
let evidencePayloadBytes = 0
|
|
114
182
|
try {
|
|
183
|
+
environment = await resolveFileBackedSettings(sourceEnvironment, ["THREADWIRE_TELEGRAM_BOT_TOKEN"])
|
|
115
184
|
const prompt = await readPrompt(parsed, dependencies.input ?? stdin)
|
|
185
|
+
const configuredEvidenceRoot = evidenceRoot(sourceEnvironment.THREADWIRE_EVIDENCE_ROOT)
|
|
186
|
+
if (dependencies.evidenceStore === undefined && configuredEvidenceRoot !== undefined) {
|
|
187
|
+
ownedEvidenceStore = await EvidenceStore.open({root: configuredEvidenceRoot})
|
|
188
|
+
}
|
|
189
|
+
const evidenceStore = dependencies.evidenceStore ?? ownedEvidenceStore
|
|
190
|
+
if (evidenceStore !== undefined) {
|
|
191
|
+
const evidenceOwner = dependencies.evidenceOwnerScope ?? evidenceStore.createOwnerScope({
|
|
192
|
+
destinationId: `${target.chatId}:${target.threadId ?? "dm"}`,
|
|
193
|
+
runId: randomUUID()
|
|
194
|
+
})
|
|
195
|
+
evidence = await evidenceStore.createArtifact(evidenceOwner, {
|
|
196
|
+
contentType: "text/plain; charset=utf-8",
|
|
197
|
+
redactions: await collectEvidenceRedactions(environment)
|
|
198
|
+
})
|
|
199
|
+
}
|
|
200
|
+
if (evidence !== undefined) {
|
|
201
|
+
const promptEvidence = `prompt\n${prompt}\nprovider-stream\n`
|
|
202
|
+
await evidence.append("prompt", promptEvidence)
|
|
203
|
+
evidencePayloadBytes += Buffer.byteLength(promptEvidence, "utf8")
|
|
204
|
+
}
|
|
116
205
|
const providerEnvironment = buildProviderEnvironment(environment)
|
|
117
206
|
const provider = createProvider(parsed.provider, parsed.providerArguments, prompt, parsed.resumeSession, providerEnvironment)
|
|
207
|
+
if (parsed.resumeSession !== undefined) admission.setContinuationHandle(parsed.resumeSession)
|
|
118
208
|
if (parsed.workspaceProfile !== undefined) {
|
|
119
209
|
if (resolvedWorkspace === undefined) throw new Error("Workspace profile resolution failed")
|
|
120
|
-
hermes.write({
|
|
121
|
-
type: "execution_environment",
|
|
122
|
-
profile: resolvedWorkspace.profile,
|
|
123
|
-
environment: "container",
|
|
124
|
-
repositoryPath: resolvedWorkspace.cwd,
|
|
125
|
-
revision: resolvedWorkspace.revision,
|
|
126
|
-
sourceIdentity: resolvedWorkspace.sourceIdentity
|
|
127
|
-
})
|
|
128
210
|
}
|
|
129
211
|
const token = environment.THREADWIRE_TELEGRAM_BOT_TOKEN
|
|
130
212
|
if (!token) throw new Error("THREADWIRE_TELEGRAM_BOT_TOKEN is required")
|
|
131
213
|
const transport = (dependencies.transportFactory ?? createFetchTransport)(token, undefined, parseTelegramRequestTimeoutMs(environment))
|
|
132
|
-
const
|
|
133
|
-
|
|
134
|
-
queue,
|
|
214
|
+
const control = new WorkerControl({
|
|
215
|
+
sender: createTelegramSender(target, transport),
|
|
135
216
|
processNumber: parsed.processNumber ?? process.pid,
|
|
136
217
|
toolMessages: parsed.toolMessages,
|
|
137
|
-
...(
|
|
218
|
+
...(dependencies.workerControlOptions ?? {}),
|
|
219
|
+
...(parsed.maxOutputLength === undefined ? {} : {maxOutputLength: parsed.maxOutputLength}),
|
|
220
|
+
metrics
|
|
138
221
|
})
|
|
139
222
|
activity = parsed.activityLog === undefined ? undefined : new ActivityLog(parsed.activityLog)
|
|
140
223
|
if (activity && resolvedWorkspace) {
|
|
@@ -152,30 +235,121 @@ export async function main(arguments_, dependencies = {}) {
|
|
|
152
235
|
environment: providerEnvironment,
|
|
153
236
|
parse: provider.parse,
|
|
154
237
|
onEvent: (event) => {
|
|
155
|
-
|
|
156
|
-
|
|
238
|
+
validateNormalizedWorkerEvent(event)
|
|
239
|
+
if (event.type !== "text-delta") {
|
|
240
|
+
metrics.recordRejected(`${event.type}_progress`, Buffer.byteLength(JSON.stringify(event), "utf8"))
|
|
241
|
+
}
|
|
242
|
+
acceptAdmissionEvent(admission, event)
|
|
243
|
+
return control.accept(event)
|
|
157
244
|
},
|
|
158
245
|
onSpawn: (pid) => {
|
|
159
246
|
if (activity && pid !== undefined) activity.recordStarted(provider.name, pid)
|
|
160
247
|
},
|
|
161
248
|
onRecord: (record) => {
|
|
249
|
+
metrics.recordParsedProviderRecord("provider_stdout")
|
|
162
250
|
const id = provider.sessionId(record)
|
|
163
|
-
if (id !== undefined)
|
|
251
|
+
if (id !== undefined) {
|
|
252
|
+
admission.setContinuationHandle(id)
|
|
253
|
+
activity?.recordSession(provider.name, id)
|
|
254
|
+
}
|
|
255
|
+
},
|
|
256
|
+
onStdoutChunk: (chunk) => {
|
|
257
|
+
metrics.recordRawChildChunk("provider_stdout", chunk.length)
|
|
258
|
+
metrics.recordRejected("provider_stream", chunk.length)
|
|
259
|
+
if (evidence === undefined) return undefined
|
|
260
|
+
return evidence.append("provider-stdout", chunk).then(() => { evidencePayloadBytes += chunk.length })
|
|
261
|
+
},
|
|
262
|
+
onStderrChunk: (chunk) => {
|
|
263
|
+
metrics.recordRawChildChunk("provider_stderr", chunk.length)
|
|
264
|
+
metrics.recordRejected("stderr", chunk.length)
|
|
265
|
+
if (evidence === undefined) return undefined
|
|
266
|
+
return evidence.append("provider-stderr", chunk).then(() => { evidencePayloadBytes += chunk.length })
|
|
164
267
|
}
|
|
165
268
|
})
|
|
166
|
-
await
|
|
269
|
+
await control.close()
|
|
167
270
|
terminalExitCode = exitCode
|
|
168
|
-
return exitCode
|
|
169
271
|
} finally {
|
|
170
|
-
|
|
272
|
+
if (evidence !== undefined) {
|
|
273
|
+
try {
|
|
274
|
+
admission.addArtifactHandle(evidence.handle)
|
|
275
|
+
const terminal = {state: /** @type {"completed" | "failed"} */ (terminalExitCode === 0 ? "completed" : "failed"), exitCode: terminalExitCode}
|
|
276
|
+
metrics.recordAdmission(admission.preview(terminal))
|
|
277
|
+
const projectedArtifactBytes = metrics.projectSelfInclusiveArtifact(evidencePayloadBytes, 1)
|
|
278
|
+
await evidence.append("context-metrics", `${JSON.stringify(metrics.snapshot())}\n`)
|
|
279
|
+
const artifact = await evidence.finalize()
|
|
280
|
+
assertArtifactProjection(artifact.bytes, projectedArtifactBytes)
|
|
281
|
+
} catch (error) {
|
|
282
|
+
await evidence.abort()
|
|
283
|
+
admission.removeArtifactHandle(evidence.handle)
|
|
284
|
+
metrics.clearArtifact()
|
|
285
|
+
terminalExitCode = 2
|
|
286
|
+
evidenceError = error
|
|
287
|
+
}
|
|
288
|
+
}
|
|
289
|
+
admission.complete({state: terminalExitCode === 0 ? "completed" : "failed", exitCode: terminalExitCode})
|
|
290
|
+
errorOutput.write(`threadwire-context-metrics ${JSON.stringify(metrics.conciseDiagnostic())}\n`)
|
|
171
291
|
activity?.close()
|
|
292
|
+
await ownedEvidenceStore?.close()
|
|
172
293
|
}
|
|
294
|
+
if (evidenceError !== undefined) throw evidenceError
|
|
295
|
+
return terminalExitCode
|
|
173
296
|
} catch (error) {
|
|
174
297
|
errorOutput.write(`threadwire: ${safeMessage(error)}\n`)
|
|
175
298
|
return 2
|
|
176
299
|
}
|
|
177
300
|
}
|
|
178
301
|
|
|
302
|
+
/** @param {string | undefined} value */
|
|
303
|
+
function evidenceRoot(value) {
|
|
304
|
+
if (value === undefined) return undefined
|
|
305
|
+
if (!isAbsolute(value) || normalize(value) !== value) throw new Error("THREADWIRE_EVIDENCE_ROOT must be a normalized absolute path")
|
|
306
|
+
return value
|
|
307
|
+
}
|
|
308
|
+
|
|
309
|
+
/** @param {number} actual @param {number} projected */
|
|
310
|
+
function assertArtifactProjection(actual, projected) {
|
|
311
|
+
if (actual !== projected) throw new Error("Context metrics artifact projection mismatch")
|
|
312
|
+
}
|
|
313
|
+
|
|
314
|
+
/** @param {DelegatedResultAdmission} admission @param {import("./types.js").WorkerEvent} event */
|
|
315
|
+
function acceptAdmissionEvent(admission, event) {
|
|
316
|
+
if (event.type === "text-delta") admission.acceptConclusionEvent(event)
|
|
317
|
+
}
|
|
318
|
+
|
|
319
|
+
/** @param {unknown} event */
|
|
320
|
+
function validateNormalizedWorkerEvent(event) {
|
|
321
|
+
if (!isPlainObject(event) || typeof event.type !== "string") {
|
|
322
|
+
throw new Error("invalid normalized worker event")
|
|
323
|
+
}
|
|
324
|
+
if (event.type === "text-delta") {
|
|
325
|
+
if (!hasExactEnumerableFields(event, ["type", "text", "streamId"]) || typeof event.text !== "string" || typeof event.streamId !== "string") {
|
|
326
|
+
throw new Error("invalid normalized worker event")
|
|
327
|
+
}
|
|
328
|
+
return
|
|
329
|
+
}
|
|
330
|
+
if (event.type === "lifecycle") {
|
|
331
|
+
if (
|
|
332
|
+
!hasExactEnumerableFields(event, ["type", "phase", "summary"])
|
|
333
|
+
|| (event.phase !== "started" && event.phase !== "completed" && event.phase !== "failed")
|
|
334
|
+
|| typeof event.summary !== "string"
|
|
335
|
+
) throw new Error("invalid normalized worker event")
|
|
336
|
+
return
|
|
337
|
+
}
|
|
338
|
+
if (event.type === "diagnostic") {
|
|
339
|
+
if (
|
|
340
|
+
!hasExactEnumerableFields(event, ["type", "level", "summary"])
|
|
341
|
+
|| (event.level !== "warning" && event.level !== "error")
|
|
342
|
+
|| typeof event.summary !== "string"
|
|
343
|
+
) throw new Error("invalid normalized worker event")
|
|
344
|
+
return
|
|
345
|
+
}
|
|
346
|
+
if (event.type === "tool") {
|
|
347
|
+
if (!isToolEventShape(event)) throw new Error("invalid normalized worker event")
|
|
348
|
+
return
|
|
349
|
+
}
|
|
350
|
+
throw new Error("Worker emitted an unknown normalized event kind")
|
|
351
|
+
}
|
|
352
|
+
|
|
179
353
|
/** @param {ParsedArguments} parsed @param {NodeJS.ReadableStream & {isTTY?: boolean}} input */
|
|
180
354
|
async function readPrompt(parsed, input) {
|
|
181
355
|
let prompt
|
|
@@ -207,6 +381,53 @@ function positiveInteger(value, option) {
|
|
|
207
381
|
|
|
208
382
|
/** @param {string} value */
|
|
209
383
|
function sessionId(value) {
|
|
210
|
-
|
|
211
|
-
|
|
384
|
+
try {
|
|
385
|
+
return validateContinuationHandle(value)
|
|
386
|
+
} catch {
|
|
387
|
+
throw new Error("--resume-session must be a valid provider session ID")
|
|
388
|
+
}
|
|
389
|
+
}
|
|
390
|
+
|
|
391
|
+
/** @param {Record<string, unknown>} event */
|
|
392
|
+
function isToolEventShape(event) {
|
|
393
|
+
const allowed = event.phase === "finished"
|
|
394
|
+
? ["type", "phase", "name", "key", "detail", "output"]
|
|
395
|
+
: ["type", "phase", "name", "key", "detail"]
|
|
396
|
+
if (
|
|
397
|
+
!hasOnlyAllowedEnumerableFields(event, allowed, ["type", "phase", "name", "key"])
|
|
398
|
+
|| (event.phase !== "started" && event.phase !== "finished")
|
|
399
|
+
|| typeof event.name !== "string"
|
|
400
|
+
|| typeof event.key !== "string"
|
|
401
|
+
|| (event.detail !== undefined && typeof event.detail !== "string")
|
|
402
|
+
|| (event.output !== undefined && typeof event.output !== "string")
|
|
403
|
+
) return false
|
|
404
|
+
if (event.phase === "started" && event.output !== undefined) return false
|
|
405
|
+
return true
|
|
406
|
+
}
|
|
407
|
+
|
|
408
|
+
/** @param {unknown} value @returns {value is Record<string, unknown>} */
|
|
409
|
+
function isPlainObject(value) {
|
|
410
|
+
if (value === null || typeof value !== "object" || Array.isArray(value)) return false
|
|
411
|
+
const prototype = Object.getPrototypeOf(value)
|
|
412
|
+
return prototype === Object.prototype || prototype === null
|
|
413
|
+
}
|
|
414
|
+
|
|
415
|
+
/** @param {Record<string, unknown>} value @param {string[]} expected */
|
|
416
|
+
function hasExactEnumerableFields(value, expected) {
|
|
417
|
+
const fields = Reflect.ownKeys(value)
|
|
418
|
+
return fields.length === expected.length && fields.every((field) => (
|
|
419
|
+
typeof field === "string"
|
|
420
|
+
&& expected.includes(field)
|
|
421
|
+
&& Object.prototype.propertyIsEnumerable.call(value, field)
|
|
422
|
+
))
|
|
423
|
+
}
|
|
424
|
+
|
|
425
|
+
/** @param {Record<string, unknown>} value @param {string[]} allowed @param {string[]} required */
|
|
426
|
+
function hasOnlyAllowedEnumerableFields(value, allowed, required) {
|
|
427
|
+
const fields = Reflect.ownKeys(value)
|
|
428
|
+
return required.every((field) => Object.prototype.propertyIsEnumerable.call(value, field)) && fields.every((field) => (
|
|
429
|
+
typeof field === "string"
|
|
430
|
+
&& allowed.includes(field)
|
|
431
|
+
&& Object.prototype.propertyIsEnumerable.call(value, field)
|
|
432
|
+
))
|
|
212
433
|
}
|
|
@@ -0,0 +1,180 @@
|
|
|
1
|
+
// @ts-check
|
|
2
|
+
|
|
3
|
+
export const METRIC_COUNTER_LIMIT = 2_147_483_647
|
|
4
|
+
export const CONTEXT_GROWTH_METADATA_ALLOWANCE_BYTES = 256
|
|
5
|
+
|
|
6
|
+
const RAW_CLASSES = ["provider_stdout", "provider_stderr"]
|
|
7
|
+
const REJECTED_CLASSES = ["provider_stream", "stderr", "lifecycle_progress", "diagnostic_progress", "tool_progress"]
|
|
8
|
+
const PROGRESS_CLASSES = ["assistant", "lifecycle", "diagnostic", "tool"]
|
|
9
|
+
const ENVELOPE_FIELDS = [
|
|
10
|
+
"version", "type", "state", "exitCode", "conclusion", "blocker", "decisionRequest",
|
|
11
|
+
"continuationHandle", "artifactHandles", "references", "validationSummary"
|
|
12
|
+
]
|
|
13
|
+
|
|
14
|
+
export class ContextBudgetMetrics {
|
|
15
|
+
constructor() {
|
|
16
|
+
this.streamBytes = 0
|
|
17
|
+
this.streamChunks = 0
|
|
18
|
+
this.streamBytesByClass = counters(RAW_CLASSES)
|
|
19
|
+
this.streamChunksByClass = counters(RAW_CLASSES)
|
|
20
|
+
this.parsedRecords = 0
|
|
21
|
+
this.parsedRecordsByClass = counters(RAW_CLASSES)
|
|
22
|
+
this.rejectedBytes = counters(REJECTED_CLASSES)
|
|
23
|
+
this.rejectedEvents = counters(REJECTED_CLASSES)
|
|
24
|
+
this.progress = progressCounters()
|
|
25
|
+
this.artifactBytes = 0
|
|
26
|
+
this.artifactHandles = 0
|
|
27
|
+
this.admittedBytes = 0
|
|
28
|
+
this.admittedTokens = 0
|
|
29
|
+
this.admittedEnvelopeFields = counters(ENVELOPE_FIELDS)
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
/** @param {string} metricClass @param {number} bytes */
|
|
33
|
+
recordRawChildChunk(metricClass, bytes) {
|
|
34
|
+
addClass(this.streamBytesByClass, metricClass, bytes)
|
|
35
|
+
addClass(this.streamChunksByClass, metricClass, 1)
|
|
36
|
+
this.streamBytes = saturatingAdd(this.streamBytes, bytes)
|
|
37
|
+
this.streamChunks = saturatingAdd(this.streamChunks, 1)
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
/** Backward-compatible name for callers outside the package. @param {string} metricClass @param {number} bytes */
|
|
41
|
+
recordRawChildEvent(metricClass, bytes) {
|
|
42
|
+
this.recordRawChildChunk(metricClass, bytes)
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
/** @param {string} metricClass @param {number} [records] */
|
|
46
|
+
recordParsedProviderRecord(metricClass, records = 1) {
|
|
47
|
+
addClass(this.parsedRecordsByClass, metricClass, records)
|
|
48
|
+
this.parsedRecords = saturatingAdd(this.parsedRecords, records)
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
/** @param {string} metricClass @param {number} bytes */
|
|
52
|
+
recordRejected(metricClass, bytes) {
|
|
53
|
+
addClass(this.rejectedBytes, metricClass, bytes)
|
|
54
|
+
addClass(this.rejectedEvents, metricClass, 1)
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
/** @param {string} metricClass @param {number} bytes */
|
|
58
|
+
recordProgress(metricClass, bytes, outcome = "delivered") {
|
|
59
|
+
if (!Object.prototype.hasOwnProperty.call(this.progress, outcome)) throw new Error("Invalid context progress outcome")
|
|
60
|
+
const target = this.progress[outcome]
|
|
61
|
+
if (target === undefined) throw new Error("Invalid context progress outcome")
|
|
62
|
+
addClass(target.bytesByClass, metricClass, bytes)
|
|
63
|
+
addClass(target.eventsByClass, metricClass, 1)
|
|
64
|
+
target.bytes = saturatingAdd(target.bytes, bytes)
|
|
65
|
+
target.events = saturatingAdd(target.events, 1)
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
/** @param {number} bytes @param {number} handles */
|
|
69
|
+
recordArtifact(bytes, handles) {
|
|
70
|
+
this.artifactBytes = saturatingAdd(this.artifactBytes, bytes)
|
|
71
|
+
this.artifactHandles = saturatingAdd(this.artifactHandles, handles)
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
/**
|
|
75
|
+
* Set the final logical payload size for an artifact containing this snapshot.
|
|
76
|
+
* The returned fixed point includes the snapshot's own newline-terminated bytes.
|
|
77
|
+
* @param {number} bytesBeforeMetrics @param {number} handles
|
|
78
|
+
*/
|
|
79
|
+
projectSelfInclusiveArtifact(bytesBeforeMetrics, handles) {
|
|
80
|
+
const base = bounded(bytesBeforeMetrics)
|
|
81
|
+
this.artifactHandles = bounded(handles)
|
|
82
|
+
this.artifactBytes = base
|
|
83
|
+
for (let iteration = 0; iteration < 16; iteration += 1) {
|
|
84
|
+
const projected = saturatingAdd(base, Buffer.byteLength(`${JSON.stringify(this.snapshot())}\n`, "utf8"))
|
|
85
|
+
if (projected === this.artifactBytes) return projected
|
|
86
|
+
this.artifactBytes = projected
|
|
87
|
+
}
|
|
88
|
+
throw new Error("Context metrics artifact projection did not converge")
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
clearArtifact() {
|
|
92
|
+
this.artifactBytes = 0
|
|
93
|
+
this.artifactHandles = 0
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
/** @param {Record<string, unknown>} envelope */
|
|
97
|
+
recordAdmission(envelope) {
|
|
98
|
+
const serialized = `${JSON.stringify(envelope)}\n`
|
|
99
|
+
this.admittedBytes = bounded(Buffer.byteLength(serialized, "utf8"))
|
|
100
|
+
this.admittedTokens = bounded(Math.ceil(this.admittedBytes / 4))
|
|
101
|
+
for (const field of ENVELOPE_FIELDS) {
|
|
102
|
+
if (!Object.prototype.hasOwnProperty.call(envelope, field)) continue
|
|
103
|
+
this.admittedEnvelopeFields[field] = bounded(Buffer.byteLength(JSON.stringify(envelope[field]), "utf8"))
|
|
104
|
+
}
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
snapshot() {
|
|
108
|
+
return {
|
|
109
|
+
version: 1,
|
|
110
|
+
units: {bytes: "utf8", tokens: "estimated_ceiling_bytes_div_4", events: "count"},
|
|
111
|
+
rawChild: {
|
|
112
|
+
streamBytes: this.streamBytes,
|
|
113
|
+
streamChunks: this.streamChunks,
|
|
114
|
+
streamBytesByClass: {...this.streamBytesByClass},
|
|
115
|
+
streamChunksByClass: {...this.streamChunksByClass},
|
|
116
|
+
parsedRecords: this.parsedRecords,
|
|
117
|
+
parsedRecordsByClass: {...this.parsedRecordsByClass}
|
|
118
|
+
},
|
|
119
|
+
admitted: {bytes: this.admittedBytes, estimatedTokens: this.admittedTokens},
|
|
120
|
+
rejected: {bytesByClass: {...this.rejectedBytes}, eventsByClass: {...this.rejectedEvents}},
|
|
121
|
+
artifacts: {bytes: this.artifactBytes, handles: this.artifactHandles},
|
|
122
|
+
progress: Object.fromEntries(Object.entries(this.progress).map(([outcome, values]) => [
|
|
123
|
+
outcome,
|
|
124
|
+
{bytes: values.bytes, events: values.events, bytesByClass: {...values.bytesByClass}, eventsByClass: {...values.eventsByClass}}
|
|
125
|
+
])),
|
|
126
|
+
admittedEnvelopeFields: {...this.admittedEnvelopeFields},
|
|
127
|
+
parentContextDeltaBytes: this.admittedBytes
|
|
128
|
+
}
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
conciseDiagnostic() {
|
|
132
|
+
return {
|
|
133
|
+
version: 1,
|
|
134
|
+
rawBytes: this.streamBytes,
|
|
135
|
+
rawChunks: this.streamChunks,
|
|
136
|
+
parsedRecords: this.parsedRecords,
|
|
137
|
+
admittedBytes: this.admittedBytes,
|
|
138
|
+
rejectedBytes: sumCounters(this.rejectedBytes),
|
|
139
|
+
artifactBytes: this.artifactBytes,
|
|
140
|
+
artifactHandles: this.artifactHandles,
|
|
141
|
+
progressEvents: this.progress.delivered?.events ?? 0,
|
|
142
|
+
parentContextDeltaBytes: this.admittedBytes
|
|
143
|
+
}
|
|
144
|
+
}
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
/** @returns {Record<string, {bytes: number, events: number, bytesByClass: Record<string, number>, eventsByClass: Record<string, number>}>} */
|
|
148
|
+
function progressCounters() {
|
|
149
|
+
return Object.fromEntries(["attempted", "suppressed", "coalesced", "delivered"].map((outcome) => [
|
|
150
|
+
outcome,
|
|
151
|
+
{bytes: 0, events: 0, bytesByClass: counters(PROGRESS_CLASSES), eventsByClass: counters(PROGRESS_CLASSES)}
|
|
152
|
+
]))
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
/** @param {string[]} classes */
|
|
156
|
+
function counters(classes) {
|
|
157
|
+
return Object.fromEntries(classes.map((metricClass) => [metricClass, 0]))
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
/** @param {Record<string, number>} target @param {string} metricClass @param {number} increment */
|
|
161
|
+
function addClass(target, metricClass, increment) {
|
|
162
|
+
if (!Object.prototype.hasOwnProperty.call(target, metricClass)) throw new Error("Invalid context metric class")
|
|
163
|
+
target[metricClass] = saturatingAdd(target[metricClass] ?? 0, increment)
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
/** @param {number} current @param {number} increment */
|
|
167
|
+
function saturatingAdd(current, increment) {
|
|
168
|
+
return Math.min(METRIC_COUNTER_LIMIT, current + bounded(increment))
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
/** @param {number} value */
|
|
172
|
+
function bounded(value) {
|
|
173
|
+
if (!Number.isSafeInteger(value) || value < 0) throw new Error("Invalid context metric counter")
|
|
174
|
+
return Math.min(value, METRIC_COUNTER_LIMIT)
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
/** @param {Record<string, number>} values */
|
|
178
|
+
function sumCounters(values) {
|
|
179
|
+
return Object.values(values).reduce((total, value) => saturatingAdd(total, value), 0)
|
|
180
|
+
}
|