@junghanacs/entwurf 0.16.1 → 0.17.1
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/AGENTS.md +4 -2
- package/CHANGELOG.md +212 -0
- package/VERIFY.md +8 -1
- package/docs/acp-backend-rail.md +25 -14
- package/mcp/entwurf-bridge/dist/pi-extensions/lib/acp/acp-client.js +1 -1
- package/mcp/entwurf-bridge/dist/pi-extensions/lib/acp/backend-adapter.js +15 -0
- package/package.json +4 -4
- package/pi-extensions/lib/acp/acp-client.ts +57 -4
- package/pi-extensions/lib/acp/backend-adapter.ts +59 -0
- package/pi-extensions/lib/acp/backend.ts +453 -11
- package/pi-extensions/lib/acp/event-mapper.ts +43 -6
- package/run.sh +21 -0
- package/scripts/check-acp-stop-reason.ts +8 -2
- package/scripts/check-acp-usage-accounting.ts +1074 -0
- package/scripts/check-gate-qualification.ts +1 -0
- package/scripts/lib/pi-record-discovery.ts +47 -0
- package/scripts/mutants/acp-stream-hooks.json +4 -2
- package/scripts/mutants/acp-usage-accounting.json +181 -0
- package/scripts/smoke-acp-bundled-mcp-live.ts +2 -2
- package/scripts/smoke-acp-cortex-live.ts +2 -2
- package/scripts/smoke-acp-raw-turn-live.ts +1 -1
- package/scripts/smoke-acp-socket-citizen-live.ts +2 -2
- package/scripts/smoke-acp-v2-send-live.ts +2 -2
- package/scripts/smoke-entwurf-v2-matrix-live.ts +60 -10
- package/scripts/smoke-mux-lifecycle-live.ts +46 -2
|
@@ -46,7 +46,12 @@ import { Readable, Writable } from "node:stream";
|
|
|
46
46
|
import { ndJsonStream, PROTOCOL_VERSION } from "@agentclientprotocol/sdk";
|
|
47
47
|
import type { Api, AssistantMessage, Context, Model, SimpleStreamOptions } from "@earendil-works/pi-ai";
|
|
48
48
|
import { createAssistantMessageEventStream } from "@earendil-works/pi-ai";
|
|
49
|
-
import {
|
|
49
|
+
import {
|
|
50
|
+
type AcpClientHandlers,
|
|
51
|
+
type AcpConnectionLike,
|
|
52
|
+
type AcpPromptResponse,
|
|
53
|
+
connectAcpClient,
|
|
54
|
+
} from "./acp-client.js";
|
|
50
55
|
import { prependNewPromptAugment } from "./augment.js";
|
|
51
56
|
import { type AcpBackendAdapter, resolveAcpBackendAdapter } from "./backend-adapter.js";
|
|
52
57
|
import {
|
|
@@ -104,6 +109,97 @@ const SET_MODEL_TIMEOUT_MS = 30_000;
|
|
|
104
109
|
// to process-group teardown, so an abort always returns promptly.
|
|
105
110
|
const ABORT_CANCEL_GRACE_MS = 5_000;
|
|
106
111
|
|
|
112
|
+
/** Tokens below which a re-billed prefix is breakpoint granularity, not news.
|
|
113
|
+
* This matches pi's token threshold for a native cache-miss notice, which also
|
|
114
|
+
* considers a separate $0.10 cost threshold (read at pi-coding-agent
|
|
115
|
+
* `dist/modes/interactive/interactive-mode.js:3130`). */
|
|
116
|
+
const CACHE_MISS_NOTICE_FLOOR_TOKENS = 20_000;
|
|
117
|
+
|
|
118
|
+
/** ONE turn's ACCOUNTING totals: the SUM over that turn's API round trips, as
|
|
119
|
+
* ACP reports them. Carried on `usage.acp`, never on pi's four `Usage` fields —
|
|
120
|
+
* those mean one REQUEST's prompt shape to readers that would misread a sum
|
|
121
|
+
* (see `sealTurnUsage`). This is the vendor's own arithmetic, relayed. */
|
|
122
|
+
interface AcpTurnAccounting {
|
|
123
|
+
input: number;
|
|
124
|
+
output: number;
|
|
125
|
+
cacheRead: number;
|
|
126
|
+
cacheWrite: number;
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
function finiteOrZero(value: unknown): number {
|
|
130
|
+
return typeof value === "number" && Number.isFinite(value) ? value : 0;
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
/**
|
|
134
|
+
* ONE turn's ACCOUNTING totals, preferring the widest scope the backend offers.
|
|
135
|
+
*
|
|
136
|
+
* `_meta.quota.model_usage` comes from `result.modelUsage` and the vendor calls it
|
|
137
|
+
* "the accounting-grade figure per the SDK" — it also counts Task subagents,
|
|
138
|
+
* sidechains, and INTERNAL CALLS SUCH AS COMPACTION, so its rows "can total more
|
|
139
|
+
* than `token_count`" and are "the fuller picture, not a decomposition of it"
|
|
140
|
+
* (read at claude-agent-acp 0.73.0 `dist/acp-agent.js:5728-5748`). The narrower
|
|
141
|
+
* `PromptResponse.usage` (== `quota.token_count`) is the MAIN AGENT LOOP only.
|
|
142
|
+
*
|
|
143
|
+
* The wider one is the right numerator because the denominator already has that
|
|
144
|
+
* scope: `usage.cost.total` is the diff of the backend's running total, which
|
|
145
|
+
* includes those internal calls. Pairing a main-loop token sum with an
|
|
146
|
+
* all-inclusive cost understates the cache-effect badge exactly when compaction
|
|
147
|
+
* ran — and compaction is a live path again (#94). Rows are summed because a
|
|
148
|
+
* session may in principle report more than one model; with GLG's single-model
|
|
149
|
+
* rule there is exactly one.
|
|
150
|
+
*
|
|
151
|
+
* Falls back to `usage` when the sidecar is absent: `_meta` is a standard ACP
|
|
152
|
+
* extension slot whose contents a client may not assume, and `quota` is not in
|
|
153
|
+
* claude-agent-acp's exported types (#96 carries the re-measure-on-bump duty).
|
|
154
|
+
*/
|
|
155
|
+
function readTurnAccounting(promptResult: AcpPromptResponse): AcpTurnAccounting | undefined {
|
|
156
|
+
const rows = promptResult?._meta?.quota?.model_usage;
|
|
157
|
+
if (Array.isArray(rows) && rows.length > 0) {
|
|
158
|
+
const total = { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 };
|
|
159
|
+
for (const row of rows) {
|
|
160
|
+
const t = row?.token_count;
|
|
161
|
+
if (!t) continue;
|
|
162
|
+
total.input += finiteOrZero(t.inputTokens);
|
|
163
|
+
total.output += finiteOrZero(t.outputTokens);
|
|
164
|
+
total.cacheRead += finiteOrZero(t.cachedInputTokens);
|
|
165
|
+
total.cacheWrite += finiteOrZero(t.cachedWriteTokens);
|
|
166
|
+
}
|
|
167
|
+
return total;
|
|
168
|
+
}
|
|
169
|
+
const wire = promptResult?.usage;
|
|
170
|
+
if (!wire) return undefined;
|
|
171
|
+
return {
|
|
172
|
+
input: finiteOrZero(wire.inputTokens),
|
|
173
|
+
output: finiteOrZero(wire.outputTokens),
|
|
174
|
+
cacheRead: finiteOrZero(wire.cachedReadTokens),
|
|
175
|
+
cacheWrite: finiteOrZero(wire.cachedWriteTokens),
|
|
176
|
+
};
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
/**
|
|
180
|
+
* The MAIN AGENT LOOP's token totals (`PromptResponse.usage` ==
|
|
181
|
+
* `quota.token_count`). Same four fields as `readTurnAccounting`, different
|
|
182
|
+
* scope: this excludes Task subagents, sidechains, and internal calls such
|
|
183
|
+
* as compaction. The cache-miss bound's recurrence is a MAIN-LOOP identity
|
|
184
|
+
* — occupancy is main-context occupancy — so its IO terms come from here,
|
|
185
|
+
* never from the wide rows. Reporting still uses `readTurnAccounting`.
|
|
186
|
+
*/
|
|
187
|
+
function readMainLoopAccounting(promptResult: AcpPromptResponse): AcpTurnAccounting | undefined {
|
|
188
|
+
const wire = promptResult?.usage;
|
|
189
|
+
if (!wire) return undefined;
|
|
190
|
+
return {
|
|
191
|
+
input: finiteOrZero(wire.inputTokens),
|
|
192
|
+
output: finiteOrZero(wire.outputTokens),
|
|
193
|
+
cacheRead: finiteOrZero(wire.cachedReadTokens),
|
|
194
|
+
cacheWrite: finiteOrZero(wire.cachedWriteTokens),
|
|
195
|
+
};
|
|
196
|
+
}
|
|
197
|
+
|
|
198
|
+
/** 191,971 → "192k". Keeps the notice inside the 80-char fragment cap. */
|
|
199
|
+
function formatTokenCount(tokens: number): string {
|
|
200
|
+
return tokens >= 1000 ? `${Math.round(tokens / 1000)}k` : String(Math.round(tokens));
|
|
201
|
+
}
|
|
202
|
+
|
|
107
203
|
type StdioChild = ChildProcessByStdio<Writable, Readable, Readable>;
|
|
108
204
|
|
|
109
205
|
// ---------------------------------------------------------------------------
|
|
@@ -245,6 +341,72 @@ interface BridgeSession {
|
|
|
245
341
|
childEnd: ChildEndLatch;
|
|
246
342
|
/** Set while a prompt is in flight so a child death can close it (awaitAcpPromptTurn). */
|
|
247
343
|
notifyChildGone?: (err: Error) => void;
|
|
344
|
+
/**
|
|
345
|
+
* The SDK's last-observed SESSION-CUMULATIVE cost, in USD — the baseline the
|
|
346
|
+
* NEXT turn's cost is differenced against — authoritative for this rail in
|
|
347
|
+
* the sense that the backend's own estimate outranks any local recompute,
|
|
348
|
+
* not in the sense of a billing statement (#93).
|
|
349
|
+
*
|
|
350
|
+
* BRIDGE-SESSION SCOPED, and that scope is the invariant. It must track the
|
|
351
|
+
* live child, because the cumulative it measures is that child's own running
|
|
352
|
+
* total and restarts at zero with a new one: a rebuilt session (config-signature
|
|
353
|
+
* drift) gets a fresh object and therefore a fresh baseline, which is exactly
|
|
354
|
+
* right. Hoisting this to the pi session would keep a dead child's total as the
|
|
355
|
+
* baseline and make the next real turn's diff enormous or negative.
|
|
356
|
+
*
|
|
357
|
+
* `undefined` = no cumulative observed yet, which is NOT the same as 0: a turn
|
|
358
|
+
* that reports no cost must HOLD this value, not zero it, or the amount it
|
|
359
|
+
* carried would be double-counted by the next diff.
|
|
360
|
+
*/
|
|
361
|
+
sdkCumulativeCostUsd?: number;
|
|
362
|
+
/**
|
|
363
|
+
* The last observed context OCCUPANCY (`usage_update.used`), carried forward
|
|
364
|
+
* across a turn whose notification never arrived (#93).
|
|
365
|
+
*
|
|
366
|
+
* Without this, such a turn carries accounting at `usage.acp` but has zero
|
|
367
|
+
* `totalTokens` and zero pi-native usage fields. pi's
|
|
368
|
+
* `calculateContextTokens` (read at pi-coding-agent
|
|
369
|
+
* `dist/core/compaction/compaction.js:86-88`) then returns 0, making
|
|
370
|
+
* auto-compaction replace the vendor observation with its local estimate
|
|
371
|
+
* (read at pi-coding-agent `dist/core/agent-session.js:1696-1699`). Carrying
|
|
372
|
+
* the last measurement preserves the last known occupancy. Assigned, never
|
|
373
|
+
* summed.
|
|
374
|
+
*/
|
|
375
|
+
contextOccupancyTokens?: number;
|
|
376
|
+
/**
|
|
377
|
+
* The PREVIOUS turn's MAIN-LOOP `Σinput + Σoutput` (`PromptResponse.usage`,
|
|
378
|
+
* never the wide `model_usage` sum) and the wall-clock ms at which that
|
|
379
|
+
* turn SEALED. The idle gap is then measured from that seal to THIS turn's
|
|
380
|
+
* START (`turnStartedAtMs`), never to its seal, so a turn's own duration can
|
|
381
|
+
* never inflate the gap it reports. Together with `contextOccupancyTokens`,
|
|
382
|
+
* they are the whole input to the cache-miss bound in `sealTurnUsage` — no
|
|
383
|
+
* per-round-trip collection.
|
|
384
|
+
*
|
|
385
|
+
* Why these two and nothing else: within one turn Claude Code's cache
|
|
386
|
+
* breakpoints make `cacheRead_i = cacheRead_(i-1) + cacheWrite_(i-1)`
|
|
387
|
+
* (re-measured 2026-09-02 across the WHOLE ACP overlay corpus, not one ledger:
|
|
388
|
+
* 2,398 of 2,410 adjacent pairs hold — 99.50% — over 30+ session transcripts
|
|
389
|
+
* under `~/.pi/agent/claude-config-overlay/projects` spanning 2026-05 to
|
|
390
|
+
* 2026-09. On the incident ledger alone it is 101 of 102, and that single break
|
|
391
|
+
* IS the miss: cacheRead 0 against a predicted 195,177 at 2026-09-01T21:15:09Z.
|
|
392
|
+
* Of the 12 corpus-wide breaks, 10 fall BELOW prediction — each one a real miss
|
|
393
|
+
* or partial re-read — and the 2 that exceed it do so by 4,357 and 3,283 tokens,
|
|
394
|
+
* both far under the notice floor, so neither could lift a quiet turn over the
|
|
395
|
+
* threshold on its own). Telescoping that identity gives
|
|
396
|
+
*
|
|
397
|
+
* cacheRead_first = used_end − ΣcacheWrite − (input_last + output_last)
|
|
398
|
+
*
|
|
399
|
+
* and the same identity on the previous turn gives what THIS turn's first
|
|
400
|
+
* request would have read had the cache still been warm:
|
|
401
|
+
*
|
|
402
|
+
* expected = used_prev − (input_last' + output_last')
|
|
403
|
+
*
|
|
404
|
+
* Only the LAST request's input+output is unknown at turn scope, and it is
|
|
405
|
+
* bounded by the turn's own sums — so both quantities come out as PROVEN
|
|
406
|
+
* INTERVALS, never estimates. Assigned, never summed.
|
|
407
|
+
*/
|
|
408
|
+
priorTurnInputOutputSum?: number;
|
|
409
|
+
priorTurnSealedAtMs?: number;
|
|
248
410
|
/**
|
|
249
411
|
* This TURN owns reporting the child's end, so the next turn must not also
|
|
250
412
|
* announce it. Raised at the top of a failure path — BEFORE the bounded
|
|
@@ -610,7 +772,7 @@ async function awaitAcpPromptTurn(
|
|
|
610
772
|
session: BridgeSession,
|
|
611
773
|
promptArgs: { sessionId: string; prompt: AcpTextBlock[] },
|
|
612
774
|
opts: { signal?: AbortSignal; graceMs: number },
|
|
613
|
-
): Promise<
|
|
775
|
+
): Promise<AcpPromptResponse> {
|
|
614
776
|
let rejectLifecycle: ((err: Error) => void) | undefined;
|
|
615
777
|
const lifecycle = new Promise<never>((_, reject) => {
|
|
616
778
|
rejectLifecycle = reject;
|
|
@@ -680,9 +842,12 @@ export type AcpStopVerdict = {
|
|
|
680
842
|
/**
|
|
681
843
|
* ACP prompt stopReason → pi verdict.
|
|
682
844
|
*
|
|
683
|
-
* The ACP terminal set is closed (`@agentclientprotocol/sdk` 1.
|
|
684
|
-
* `schema/types.gen`): end_turn | max_tokens | max_turn_requests |
|
|
685
|
-
* cancelled
|
|
845
|
+
* The ACP terminal set is closed (`@agentclientprotocol/sdk` 1.4.0
|
|
846
|
+
* `dist/schema/types.gen.d.ts:3001`): end_turn | max_tokens | max_turn_requests |
|
|
847
|
+
* refusal | cancelled — no `| string` arm. 1.4.0 also ships an OPEN union at
|
|
848
|
+
* `dist/v2/schema/types.gen.d.ts:3607`, but that is behind the `./experimental/v2`
|
|
849
|
+
* export and entwurf imports the bare specifier, which resolves to the closed v1
|
|
850
|
+
* surface. Only three of those are successful or benign ends. The previous
|
|
686
851
|
* implementation returned a bare StopReason with `default: "stop"`, which turned
|
|
687
852
|
* `refusal`, `max_turn_requests`, any future member, AND a missing reason into a
|
|
688
853
|
* clean successful turn — pi then rendered a silently truncated answer as if the
|
|
@@ -957,6 +1122,14 @@ export function streamAcpTurn(
|
|
|
957
1122
|
deps: AcpTurnDeps,
|
|
958
1123
|
): ReturnType<typeof createAssistantMessageEventStream> {
|
|
959
1124
|
const stream = createAssistantMessageEventStream();
|
|
1125
|
+
// When THIS turn began. The idle gap that expires a prompt cache is the time
|
|
1126
|
+
// between turns, so it must be measured to a turn's START — measuring to its
|
|
1127
|
+
// seal would fold this turn's own duration into the gap and call a 58-minute
|
|
1128
|
+
// pause plus a 4-minute turn "62m idle", which crosses the 1h TTL in the
|
|
1129
|
+
// report while nothing crossed it in fact. pi's native detector uses message
|
|
1130
|
+
// timestamps for the same reason (read at pi-coding-agent
|
|
1131
|
+
// `dist/core/cache-stats.js:14-37`, `idleMs`).
|
|
1132
|
+
const turnStartedAtMs = Date.now();
|
|
960
1133
|
const state: AcpPiStreamState = createAcpStreamState(stream, {
|
|
961
1134
|
api: "entwurf",
|
|
962
1135
|
provider: "entwurf",
|
|
@@ -983,13 +1156,258 @@ export function streamAcpTurn(
|
|
|
983
1156
|
};
|
|
984
1157
|
}
|
|
985
1158
|
|
|
1159
|
+
/**
|
|
1160
|
+
* Seal this turn's ACCOUNTING — the #93 authority boundary.
|
|
1161
|
+
*
|
|
1162
|
+
* Runs ONLY for a backend whose adapter declares `sealsTurnAccounting`. Flag
|
|
1163
|
+
* absence means that backend's usage semantics were never measured, so nothing
|
|
1164
|
+
* is sealed and its emitted usage is whatever the common mapper produced —
|
|
1165
|
+
* unchanged, not guessed at.
|
|
1166
|
+
*
|
|
1167
|
+
* Two INDEPENDENT axes, because they arrive on different wires:
|
|
1168
|
+
*
|
|
1169
|
+
* tokens the turn aggregate, relayed from PromptResponse on `usage.acp`.
|
|
1170
|
+
* cost an ADJACENT DIFF of the backend's own running session total, which
|
|
1171
|
+
* arrives on `usage_update` and is captured raw by the mapper.
|
|
1172
|
+
*
|
|
1173
|
+
* The diff is what makes the session sum reproduce the backend's own running
|
|
1174
|
+
* total exactly — with no residue, which is what the gate measures. Read that
|
|
1175
|
+
* exactness for what it is: agreement with the BACKEND'S CUMULATIVE ESTIMATE,
|
|
1176
|
+
* never with an Anthropic invoice. The vendor calls `total_cost_usd` a
|
|
1177
|
+
* "Cumulative estimated cost" and "An estimate, not a billing statement"
|
|
1178
|
+
* (claude-agent-sdk `sdk.d.ts:4884`), and entwurf has measured no live
|
|
1179
|
+
* comparison against a bill.
|
|
1180
|
+
*
|
|
1181
|
+
* We prefer that estimate anyway for a STRUCTURAL reason, not because it is
|
|
1182
|
+
* more precise in general: it is computed UPSTREAM of a lossy flattening we
|
|
1183
|
+
* cannot undo. The observed cache-writes are entirely 1h, ACP carries no 1h
|
|
1184
|
+
* field, and pi prices a write with no `cacheWrite1h` at the 5m rate (read at
|
|
1185
|
+
* pi-coding-agent `dist/bundle/chunks/bedrock-converse-stream.js`,
|
|
1186
|
+
* `calculateCost`) — a local recompute measured $1.66 low on one live
|
|
1187
|
+
* ledger. Nothing here calls calculateCost: any price table we could apply
|
|
1188
|
+
* runs DOWNSTREAM of that flattening, so it would be a second, wronger
|
|
1189
|
+
* source.
|
|
1190
|
+
*/
|
|
1191
|
+
function sealTurnUsage(adapter: AcpBackendAdapter, session: BridgeSession, promptResult: AcpPromptResponse): void {
|
|
1192
|
+
if (!adapter.sealsTurnAccounting) return;
|
|
1193
|
+
|
|
1194
|
+
// --- the turn's ACCOUNTING aggregate, verbatim ------------------------
|
|
1195
|
+
// ACP reports one number set per turn and it is the SUM OVER THAT TURN'S API
|
|
1196
|
+
// ROUND TRIPS. It is carried on its OWN key, never on pi's four, because
|
|
1197
|
+
// those four mean ONE REQUEST's prompt shape to two readers that never go
|
|
1198
|
+
// through `calculateContextTokens` and so cannot be rescued by an honest
|
|
1199
|
+
// `totalTokens` (isContextOverflow, read at pi-ai
|
|
1200
|
+
// `dist/utils/overflow.js:132-145`; cache-stats.detectMiss, read at
|
|
1201
|
+
// pi-coding-agent `dist/core/cache-stats.js:14-37`). Projecting the
|
|
1202
|
+
// aggregate onto them compacted a live 223,516-token session on a 1,000,000
|
|
1203
|
+
// window (measured 2026-09-01).
|
|
1204
|
+
//
|
|
1205
|
+
// Nothing is computed here: these are the vendor's own four numbers, put
|
|
1206
|
+
// somewhere they cannot be mistaken for a per-request reading. pi stores an
|
|
1207
|
+
// assistant message as JSON and reads it back the same way (read at
|
|
1208
|
+
// pi-coding-agent `dist/core/session-manager.js:768-777` appendMessage,
|
|
1209
|
+
// `:98` parseSessionEntries), so a key pi does not know survives a resume.
|
|
1210
|
+
const aggregate = readTurnAccounting(promptResult);
|
|
1211
|
+
const mainLoop = readMainLoopAccounting(promptResult);
|
|
1212
|
+
if (aggregate) {
|
|
1213
|
+
(state.output.usage as unknown as { acp?: AcpTurnAccounting }).acp = aggregate;
|
|
1214
|
+
}
|
|
1215
|
+
|
|
1216
|
+
// --- token axis: DELIBERATELY UNWRITTEN ------------------------------
|
|
1217
|
+
// The four token fields stay at their zero initialisation. ACP's only token
|
|
1218
|
+
// carrier is `PromptResponse.usage`, and that is the SUM OVER THIS TURN'S
|
|
1219
|
+
// API ROUND TRIPS — measured 2026-09-02 on one 21-round-trip turn whose
|
|
1220
|
+
// overlay rows summed to exactly the four numbers ACP reported
|
|
1221
|
+
// (cacheRead 4,185,084) while the context it occupied was 223,516.
|
|
1222
|
+
//
|
|
1223
|
+
// pi reads these four as ONE REQUEST's prompt shape, in two places that do
|
|
1224
|
+
// NOT go through `calculateContextTokens` and so cannot be rescued by the
|
|
1225
|
+
// honest `totalTokens` written below:
|
|
1226
|
+
//
|
|
1227
|
+
// isContextOverflow `input + cacheRead > contextWindow` (read at pi-ai
|
|
1228
|
+
// `dist/utils/overflow.js:132-145`). The aggregate made
|
|
1229
|
+
// that true at 223k of a 1M window and compacted a live
|
|
1230
|
+
// session — the defect this seal now refuses to feed.
|
|
1231
|
+
// detectMiss `input + cacheRead + cacheWrite` vs the previous
|
|
1232
|
+
// request (read at pi-coding-agent
|
|
1233
|
+
// `dist/core/cache-stats.js:14-37`). Under the aggregate
|
|
1234
|
+
// it invented two phantom misses and SILENCED the real
|
|
1235
|
+
// 195,177-token one after a 401-minute idle gap.
|
|
1236
|
+
//
|
|
1237
|
+
// Writing zeros is not a placeholder for a better number we could compute:
|
|
1238
|
+
// the per-request partition is genuinely absent from the wire. The vendor
|
|
1239
|
+
// builds it in `lastAssistantUsage` and sends only its scalar sum (read at
|
|
1240
|
+
// claude-agent-acp 0.73.0 `dist/acp-agent.js:3273-3297`) — #96.
|
|
1241
|
+
//
|
|
1242
|
+
// But silence is NOT the resting state. A cache miss the operator never sees
|
|
1243
|
+
// is a false reading, not a modest one: a session can run for hours believing
|
|
1244
|
+
// its badge while a full prefix rewrite has already been paid for. So the
|
|
1245
|
+
// aggregate rides its own key above, and the bound below reports the rewrite.
|
|
1246
|
+
|
|
1247
|
+
// --- context occupancy (NOT a turn total — see the field's note) -----
|
|
1248
|
+
// Refresh from THIS turn's notification when there was one, then assign the
|
|
1249
|
+
// session's value UNCONDITIONALLY. The assignment is not conditional on the
|
|
1250
|
+
// notification having been missing: on this path the seal — not the mapper —
|
|
1251
|
+
// is the authority for what pi reads as occupancy, and when a fresh value did
|
|
1252
|
+
// arrive the two agree by construction.
|
|
1253
|
+
//
|
|
1254
|
+
// Why the field must be right rather than merely non-zero: pi's auto-compaction
|
|
1255
|
+
// takes `calculateContextTokens(message.usage)` at face value and falls back to
|
|
1256
|
+
// its own estimate ONLY when the message errored or that value is exactly 0
|
|
1257
|
+
// (read at pi-coding-agent `dist/core/agent-session.js:1696-1697`). A turn
|
|
1258
|
+
// aggregate written here would be non-zero and therefore never corrected — the
|
|
1259
|
+
// session would read as nearly empty all the way to an overflow.
|
|
1260
|
+
const priorOccupancy = session.contextOccupancyTokens;
|
|
1261
|
+
const priorIoSum = session.priorTurnInputOutputSum;
|
|
1262
|
+
const priorSealedAtMs = session.priorTurnSealedAtMs;
|
|
1263
|
+
|
|
1264
|
+
const occupancy = state.observedContextOccupancyTokens;
|
|
1265
|
+
if (typeof occupancy === "number") session.contextOccupancyTokens = occupancy;
|
|
1266
|
+
if (typeof session.contextOccupancyTokens === "number") {
|
|
1267
|
+
state.output.usage.totalTokens = session.contextOccupancyTokens;
|
|
1268
|
+
}
|
|
1269
|
+
|
|
1270
|
+
// --- cache-miss bound (the rewrite the operator must not miss) -------
|
|
1271
|
+
// Both quantities below are PROVEN INTERVALS derived from numbers already in
|
|
1272
|
+
// hand, not estimates. See `priorTurnInputOutputSum` for the identity and its
|
|
1273
|
+
// telescoping. Only the LAST request's input+output is unknown at turn scope,
|
|
1274
|
+
// and each turn's own sums bound it:
|
|
1275
|
+
//
|
|
1276
|
+
// cacheRead_first ≤ used_end − ΣcacheWrite (upper)
|
|
1277
|
+
// expected ≥ used_prev − (Σinput' + Σoutput') (lower)
|
|
1278
|
+
// miss ≥ expected_lower − cacheRead_first_upper
|
|
1279
|
+
//
|
|
1280
|
+
// The interval is proven only while the within-turn recurrence holds, and that
|
|
1281
|
+
// recurrence is an OBSERVED property of Claude Code's breakpoint placement
|
|
1282
|
+
// (measured 2026-09-02 corpus-wide: 2,398 of 2,410 adjacent pairs, 99.50%; see
|
|
1283
|
+
// `priorTurnInputOutputSum` for the full count and the break characterisation),
|
|
1284
|
+
// not a guarantee. If it breaks mid-turn — a 1h TTL expiring
|
|
1285
|
+
// between round trips, or the child compacting itself — `used_end − ΣcacheWrite`
|
|
1286
|
+
// goes negative and the `max(0, …)` below assumes cacheRead_first = 0. The
|
|
1287
|
+
// notice is still TRUE (a rewrite did happen) but its N is then a floor of a
|
|
1288
|
+
// weaker kind, not a telescoped bound. #96 carries that limit.
|
|
1289
|
+
//
|
|
1290
|
+
// A negative or small bound proves nothing was re-billed beyond breakpoint
|
|
1291
|
+
// granularity, so nothing is said. The floor is the TOKEN half of pi's own
|
|
1292
|
+
// native cache-miss display policy, which suppresses a notice only when the
|
|
1293
|
+
// miss is under 20,000 tokens AND under $0.10 (read at pi-coding-agent
|
|
1294
|
+
// `dist/modes/interactive/interactive-mode.js:3130`). The ACP rail borrows
|
|
1295
|
+
// that familiar token threshold alone and claims no part of the cost half:
|
|
1296
|
+
// this bound is a token interval, and pricing it locally is the very
|
|
1297
|
+
// downstream reprice the cost axis above refuses.
|
|
1298
|
+
// The re-billed size can never exceed what this turn's MAIN LOOP actually
|
|
1299
|
+
// WROTE: a re-billed prefix is paid for as cache creation. Clamping to
|
|
1300
|
+
// `mainLoop.cacheWrite` (not the wide aggregate) is what keeps the notice
|
|
1301
|
+
// a statement about money that changed hands on the prefix rather than
|
|
1302
|
+
// about the recurrence holding, or about an internal compaction write.
|
|
1303
|
+
//
|
|
1304
|
+
// Without it a context SHRINK reads as a giant miss. Worked counterexample
|
|
1305
|
+
// (gpt-5.6-sol, 2026-09-02): prior occupancy 200,000, prior IO 1,000, this
|
|
1306
|
+
// turn occupancy 20,000 with cacheWrite 1,000 — organic compaction, nothing
|
|
1307
|
+
// re-billed — yields 200,000 − 1,000 − 19,000 = 180,000 and would announce
|
|
1308
|
+
// "cache miss ≥180k" over a turn that wrote 1,000 tokens. The clamp answers
|
|
1309
|
+
// 1,000, which is under the floor, so nothing is said. On the real incident
|
|
1310
|
+
// the clamp does not bind: 191,971 ≤ cacheWrite 221,084.
|
|
1311
|
+
//
|
|
1312
|
+
// Both prior marks are read from ONE turn and written from ONE turn. Mixing
|
|
1313
|
+
// an older occupancy with a newer IO sum puts two turns in one equation and
|
|
1314
|
+
// can skew the bound HIGH, so the update below is all-or-nothing.
|
|
1315
|
+
//
|
|
1316
|
+
// The same all-or-nothing applies to SCOPE. Occupancy is main-context;
|
|
1317
|
+
// the recurrence is a main-loop identity. `aggregate.cacheWrite` is the
|
|
1318
|
+
// WIDE accounting figure (Task subagents, sidechains, internal compaction).
|
|
1319
|
+
// A large unrelated wide write shrinks `max(0, occupancy − cacheWrite)`
|
|
1320
|
+
// (less is subtracted) AND raises the `min(…, cacheWrite)` ceiling — both
|
|
1321
|
+
// paths push the bound UP, so a warm main prefix can announce a miss and
|
|
1322
|
+
// attach this turn's all-inclusive dollar figure to it. The IO terms
|
|
1323
|
+
// therefore come from `mainLoop`, never from `aggregate`.
|
|
1324
|
+
const derivable =
|
|
1325
|
+
mainLoop !== undefined &&
|
|
1326
|
+
typeof occupancy === "number" &&
|
|
1327
|
+
typeof priorOccupancy === "number" &&
|
|
1328
|
+
typeof priorIoSum === "number";
|
|
1329
|
+
const cacheWriteForBound = mainLoop !== undefined ? mainLoop.cacheWrite : 0;
|
|
1330
|
+
const rawBound = derivable
|
|
1331
|
+
? (priorOccupancy as number) - (priorIoSum as number) - Math.max(0, (occupancy as number) - cacheWriteForBound)
|
|
1332
|
+
: undefined;
|
|
1333
|
+
const missLowerBound = rawBound === undefined ? undefined : Math.min(rawBound, cacheWriteForBound);
|
|
1334
|
+
|
|
1335
|
+
if (mainLoop && typeof occupancy === "number") {
|
|
1336
|
+
session.priorTurnInputOutputSum = mainLoop.input + mainLoop.output;
|
|
1337
|
+
session.priorTurnSealedAtMs = Date.now();
|
|
1338
|
+
}
|
|
1339
|
+
|
|
1340
|
+
// --- cost axis ------------------------------------------------------
|
|
1341
|
+
// ALWAYS assigned on this path, including the 0 cases: the mapper may have
|
|
1342
|
+
// written the running cumulative into this field, and leaving it would put a
|
|
1343
|
+
// session total on a dashboard that sums per-turn costs — the whole defect.
|
|
1344
|
+
const observed = state.observedSessionCostUsd;
|
|
1345
|
+
let turnCostUsd: number | undefined;
|
|
1346
|
+
if (typeof observed !== "number") {
|
|
1347
|
+
// No cumulative arrived this turn. HOLD the baseline: this turn's real
|
|
1348
|
+
// amount is still inside the backend's running total and the next
|
|
1349
|
+
// adjacent diff absorbs it. Attributed to the wrong turn, exact in the
|
|
1350
|
+
// session sum. Zeroing the baseline here would double-count it instead.
|
|
1351
|
+
state.output.usage.cost.total = 0;
|
|
1352
|
+
} else {
|
|
1353
|
+
const diff = observed - (session.sdkCumulativeCostUsd ?? 0);
|
|
1354
|
+
if (diff < 0) {
|
|
1355
|
+
// The backend's running total went BACKWARDS. TWO receipts, and the gap
|
|
1356
|
+
// between them is exactly why the notice below names a MECHANISM and
|
|
1357
|
+
// never a cause: claude-agent-acp's `conversation_reset` handler only
|
|
1358
|
+
// switches the SDK to a fresh conversation and touches no cost at all
|
|
1359
|
+
// (read at 0.73.0 `dist/acp-agent.js:3675-3682`), while claude-agent-sdk
|
|
1360
|
+
// separately documents that "a mid-session /clear resets the running
|
|
1361
|
+
// total" (read at 0.3.257 `sdk.d.ts:4884`). A reset therefore PLAUSIBLY
|
|
1362
|
+
// explains a backwards total, but nothing here has MEASURED that it did,
|
|
1363
|
+
// and asserting the cause would be the same unmeasured claim this lane
|
|
1364
|
+
// exists to end.
|
|
1365
|
+
// Rebaseline and attribute 0 — but SAY SO. Silently absorbing it would
|
|
1366
|
+
// hide the one observation that can settle what a reset does to the
|
|
1367
|
+
// total, which is precisely the "no silent misaccounting" this lane owes.
|
|
1368
|
+
const detail = `${(session.sdkCumulativeCostUsd ?? 0).toFixed(6)} → ${observed.toFixed(6)} USD`;
|
|
1369
|
+
session.sdkCumulativeCostUsd = observed;
|
|
1370
|
+
state.output.usage.cost.total = 0;
|
|
1371
|
+
// Kept under the notice fragment cap (80) so the two numbers survive
|
|
1372
|
+
// verbatim — a truncated diagnostic is not evidence.
|
|
1373
|
+
pushAcpLifecycleNotice(state, `cost baseline reset (${detail}) — this turn attributed $0`);
|
|
1374
|
+
console.error(
|
|
1375
|
+
`entwurf: ACP backend reported a DECREASING session cost (${detail}). Rebaselined; this turn is ` +
|
|
1376
|
+
`attributed $0. The cause is NOT measured here. The one documented mechanism is a mid-session ` +
|
|
1377
|
+
`/clear, which claude-agent-sdk (sdk.d.ts:4884) says resets the running total; the adapter's ` +
|
|
1378
|
+
`conversation_reset event is NOT it — that handler switches conversation and touches no cost. ` +
|
|
1379
|
+
`The session total from here on is measured against the new baseline.`,
|
|
1380
|
+
);
|
|
1381
|
+
} else {
|
|
1382
|
+
session.sdkCumulativeCostUsd = observed;
|
|
1383
|
+
state.output.usage.cost.total = diff;
|
|
1384
|
+
turnCostUsd = diff;
|
|
1385
|
+
}
|
|
1386
|
+
}
|
|
1387
|
+
|
|
1388
|
+
// --- report the rewrite ----------------------------------------------
|
|
1389
|
+
// Said LAST so it can quote what this turn actually cost. The cost is the
|
|
1390
|
+
// SDK's own adjacent diff, never a local repricing.
|
|
1391
|
+
if (typeof missLowerBound === "number" && missLowerBound >= CACHE_MISS_NOTICE_FLOOR_TOKENS) {
|
|
1392
|
+
// Only when there is an idle gap worth naming. A sub-minute one explains
|
|
1393
|
+
// nothing and "after 0m idle" reads as noise, so the clause is dropped —
|
|
1394
|
+
// the re-billed size and the cost still stand on their own.
|
|
1395
|
+
const idleMinutes =
|
|
1396
|
+
typeof priorSealedAtMs === "number" ? Math.round((turnStartedAtMs - priorSealedAtMs) / 60_000) : 0;
|
|
1397
|
+
const idle = idleMinutes >= 1 ? ` after ${idleMinutes}m idle` : "";
|
|
1398
|
+
const paid = typeof turnCostUsd === "number" ? ` — this turn $${turnCostUsd.toFixed(2)}` : "";
|
|
1399
|
+
pushAcpLifecycleNotice(state, `cache miss ≥${formatTokenCount(missLowerBound)} re-billed${idle}${paid}`);
|
|
1400
|
+
}
|
|
1401
|
+
}
|
|
1402
|
+
|
|
986
1403
|
/**
|
|
987
1404
|
* Seal the turn from the ACP prompt result. "Success" here means the RPC
|
|
988
1405
|
* returned, not that the turn ended well — a returned `refusal` /
|
|
989
1406
|
* `max_turn_requests` / unknown / absent reason is sealed as an error event,
|
|
990
1407
|
* never a `done`. `rawStopReason` carries the wire value out either way.
|
|
991
1408
|
*/
|
|
992
|
-
function finishSuccess(
|
|
1409
|
+
function finishSuccess(adapter: AcpBackendAdapter, session: BridgeSession, promptResult: AcpPromptResponse): void {
|
|
1410
|
+
sealTurnUsage(adapter, session, promptResult);
|
|
993
1411
|
finalizeAcpStreamState(state);
|
|
994
1412
|
const verdict = mapPromptStopReason(promptResult?.stopReason);
|
|
995
1413
|
if (verdict.rawStopReason !== undefined) state.output.rawStopReason = verdict.rawStopReason;
|
|
@@ -1200,7 +1618,7 @@ export function streamAcpTurn(
|
|
|
1200
1618
|
inFlightKeys.add(sessionKey);
|
|
1201
1619
|
try {
|
|
1202
1620
|
if (decision.path === "reuse" && existing) {
|
|
1203
|
-
await runReuseTurn(existing, ctxSigs);
|
|
1621
|
+
await runReuseTurn(existing, ctxSigs, adapter);
|
|
1204
1622
|
} else {
|
|
1205
1623
|
await runNewTurn(params, ctxSigs, engraving, config, adapter, nativeModelId);
|
|
1206
1624
|
}
|
|
@@ -1426,18 +1844,42 @@ export function streamAcpTurn(
|
|
|
1426
1844
|
|
|
1427
1845
|
session.activePromptHandler = undefined;
|
|
1428
1846
|
session.busy = false;
|
|
1429
|
-
finishSuccess(promptResult);
|
|
1430
1847
|
|
|
1431
1848
|
// Retain ONLY a long-lived process-scoped session that survived the turn
|
|
1432
1849
|
// alive and un-aborted. A turn-scoped one-shot (and any aborted/dead
|
|
1433
1850
|
// turn) tears down so its stdio handle cannot pin pi's exit (S2c hang).
|
|
1434
|
-
|
|
1851
|
+
const retain = params.lifecyclePolicy === "process-scoped" && !signal?.aborted && session.alive;
|
|
1852
|
+
|
|
1853
|
+
// DISCOVERABILITY BEFORE THE SEAL. `finishSuccess` ends the stream, and
|
|
1854
|
+
// ending the stream is what releases the caller — which may start the next
|
|
1855
|
+
// turn on that event. A completed process-scoped session that is not yet in
|
|
1856
|
+
// the map would send that turn down the NEW path: a second child, and with
|
|
1857
|
+
// it a FRESH cost baseline, so a long session's accounting would silently
|
|
1858
|
+
// reset at a turn boundary (#93).
|
|
1859
|
+
//
|
|
1860
|
+
// Two things make that window empty today — nothing here awaits, and the
|
|
1861
|
+
// turn's in-flight claim is not released until after this returns — but both
|
|
1862
|
+
// are invariants of surrounding code rather than of this ordering, and
|
|
1863
|
+
// neither is visible from this line. Registering first makes the guarantee
|
|
1864
|
+
// local: the session is discoverable before anything can act on the seal,
|
|
1865
|
+
// whatever the code around it later does.
|
|
1866
|
+
if (retain) {
|
|
1435
1867
|
bridgeSessions.set(sessionKey, session);
|
|
1436
1868
|
retainedChildren.add(spawned);
|
|
1437
1869
|
registerGlobalCleanup();
|
|
1438
1870
|
// unref so the retained stdio cannot pin pi's exit at resident
|
|
1439
1871
|
// shutdown — reuse is unaffected (unref ≠ destroy). GPT amber.
|
|
1440
1872
|
unrefRetainedChild(spawned);
|
|
1873
|
+
}
|
|
1874
|
+
|
|
1875
|
+
finishSuccess(adapter, session, promptResult);
|
|
1876
|
+
|
|
1877
|
+
if (retain) {
|
|
1878
|
+
// AFTER the seal, deliberately: persisting is bookkeeping about a turn
|
|
1879
|
+
// that already answered, so a failed record write must not convert an
|
|
1880
|
+
// answered turn into an error turn. If it throws, the catch below still
|
|
1881
|
+
// un-registers this session and tears its child down — the same cleanup
|
|
1882
|
+
// a sealing exception gets.
|
|
1441
1883
|
persistRecord(session, deps);
|
|
1442
1884
|
} else {
|
|
1443
1885
|
session.retiring = true;
|
|
@@ -1478,7 +1920,7 @@ export function streamAcpTurn(
|
|
|
1478
1920
|
}
|
|
1479
1921
|
|
|
1480
1922
|
// --- reuse: send only the latest user delta to the live ACP session
|
|
1481
|
-
async function runReuseTurn(session: BridgeSession, ctxSigs: string[]): Promise<void> {
|
|
1923
|
+
async function runReuseTurn(session: BridgeSession, ctxSigs: string[], adapter: AcpBackendAdapter): Promise<void> {
|
|
1482
1924
|
/** Same phase discipline as a new turn — reuse just has no bootstrap to lose. */
|
|
1483
1925
|
let phase: "pre-prompt" | "prompt" = "pre-prompt";
|
|
1484
1926
|
try {
|
|
@@ -1514,7 +1956,7 @@ export function streamAcpTurn(
|
|
|
1514
1956
|
// prefix-compat check sees the full prior history (GPT ④: store the
|
|
1515
1957
|
// ctxSigs from the START of this call, only after the turn succeeds).
|
|
1516
1958
|
session.contextMessageSignatures = ctxSigs;
|
|
1517
|
-
finishSuccess(promptResult);
|
|
1959
|
+
finishSuccess(adapter, session, promptResult);
|
|
1518
1960
|
persistRecord(session, deps);
|
|
1519
1961
|
} catch (err) {
|
|
1520
1962
|
const aborted = Boolean(signal?.aborted);
|
|
@@ -49,6 +49,22 @@ export type AcpPiStreamState = {
|
|
|
49
49
|
/** When false, tool/permission notices are suppressed (kept terse for smokes). */
|
|
50
50
|
showToolNotifications?: boolean;
|
|
51
51
|
observedTools?: Map<string, ObservedToolState>;
|
|
52
|
+
/**
|
|
53
|
+
* RAW, UNINTERPRETED observations from this turn's `usage_update` notifications
|
|
54
|
+
* — carried beside the pi message, never folded into it here.
|
|
55
|
+
*
|
|
56
|
+
* The mapper is COMMON layer and deliberately assigns no meaning to either
|
|
57
|
+
* number: what `cost.amount` is CUMULATIVE OVER, and whether that even holds
|
|
58
|
+
* for a non-claude backend, is a per-backend measurement. backend.ts seals them
|
|
59
|
+
* only for a backend whose adapter declares `sealsTurnAccounting` (#93).
|
|
60
|
+
*
|
|
61
|
+
* Last write wins, never a sum: both are latest session-level observations, and
|
|
62
|
+
* one turn can legitimately see several (claude emits one per `result` message,
|
|
63
|
+
* including a sub-agent's own — read at claude-agent-acp 0.73.0
|
|
64
|
+
* `dist/acp-agent.js:2918-2933`), each carrying that result's current values.
|
|
65
|
+
*/
|
|
66
|
+
observedSessionCostUsd?: number;
|
|
67
|
+
observedContextOccupancyTokens?: number;
|
|
52
68
|
};
|
|
53
69
|
|
|
54
70
|
/** A zeroed pi Usage block. */
|
|
@@ -328,13 +344,34 @@ export function applyAcpSessionUpdate(
|
|
|
328
344
|
break;
|
|
329
345
|
}
|
|
330
346
|
case "usage_update": {
|
|
331
|
-
//
|
|
332
|
-
//
|
|
333
|
-
//
|
|
334
|
-
//
|
|
335
|
-
|
|
347
|
+
// `used` is OCCUPANCY-shaped — the backend's post-turn context size, not
|
|
348
|
+
// the prompt response's turn aggregate. Claude sends `lastAssistantTotalUsage`
|
|
349
|
+
// as `used` (read at claude-agent-acp 0.73.0
|
|
350
|
+
// `dist/acp-agent.js:3290-3297`), after constructing that scalar from the
|
|
351
|
+
// latest assistant snapshot (`:3273-3289`). pi reads `usage.totalTokens` as
|
|
352
|
+
// exactly that occupancy (`calculateContextTokens(usage) = usage.totalTokens
|
|
353
|
+
// || input + output + cacheRead + cacheWrite`, read at pi-coding-agent
|
|
354
|
+
// `dist/core/compaction/compaction.js:86-88`). The assignment below is the
|
|
355
|
+
// raw observation; backend.ts seals the measured occupancy and relays the
|
|
356
|
+
// turn aggregate separately on `usage.acp`, never onto pi's four fields.
|
|
357
|
+
if (typeof update.used === "number") {
|
|
358
|
+
state.output.usage.totalTokens = update.used;
|
|
359
|
+
state.observedContextOccupancyTokens = update.used;
|
|
360
|
+
}
|
|
361
|
+
// `cost.amount` is a running SESSION total on the claude backend, so
|
|
362
|
+
// assigning it to a TURN field is wrong wherever an adapter has measured
|
|
363
|
+
// that (#93: pi sums per-message cost, which inflated a long session
|
|
364
|
+
// 10-18x). It stays here for a backend whose adapter has NOT measured its
|
|
365
|
+
// semantics — for those, this coarse assignment is the pre-#93 behaviour
|
|
366
|
+
// and changing it would be an unmeasured claim. backend.ts OVERWRITES
|
|
367
|
+
// this field authoritatively (from the baseline diff) for every turn of a
|
|
368
|
+
// backend that HAS sealsTurnAccounting, so the cumulative can never reach
|
|
369
|
+
// an operator's dashboard as a turn cost on that path.
|
|
336
370
|
const cost = update.cost as { amount?: unknown } | undefined;
|
|
337
|
-
if (typeof cost?.amount === "number")
|
|
371
|
+
if (typeof cost?.amount === "number") {
|
|
372
|
+
state.output.usage.cost.total = cost.amount;
|
|
373
|
+
state.observedSessionCostUsd = cost.amount;
|
|
374
|
+
}
|
|
338
375
|
break;
|
|
339
376
|
}
|
|
340
377
|
default:
|
package/run.sh
CHANGED
|
@@ -2700,6 +2700,24 @@ check_acp_event_mapper() {
|
|
|
2700
2700
|
run_ts scripts/check-acp-event-mapper.ts
|
|
2701
2701
|
}
|
|
2702
2702
|
|
|
2703
|
+
check_acp_usage_accounting() {
|
|
2704
|
+
# Deterministic gate for the ACP USAGE ACCOUNTING contract (#93). A long-lived
|
|
2705
|
+
# Claude ACP session's dashboard read 10-18x high on three live ledgers,
|
|
2706
|
+
# because the per-turn token partition was dropped at the type boundary while
|
|
2707
|
+
# the backend's RUNNING SESSION TOTAL was assigned to a per-turn cost field pi
|
|
2708
|
+
# then summed. Drives streamAcpTurn against a fake ACP child + connection whose
|
|
2709
|
+
# turns are scripted (usage_update notification + PromptResponse.usage) for
|
|
2710
|
+
# five cells: the four-way token partition reaches the pi message; per-turn
|
|
2711
|
+
# costs are adjacent diffs of the running total and sum back to it across a
|
|
2712
|
+
# reused session; a turn with no cost notification attributes $0 and HOLDS the
|
|
2713
|
+
# baseline; a decreasing total rebaselines, attributes $0 and tells the
|
|
2714
|
+
# operator; totalTokens stays CONTEXT OCCUPANCY (asserted through pi's own
|
|
2715
|
+
# calculateContextTokens) and carries forward; and cortex — no measured
|
|
2716
|
+
# extractor — keeps its pre-#93 output untouched.
|
|
2717
|
+
section "ACP usage accounting (turn partition + adjacent-diff cost)"
|
|
2718
|
+
run_ts scripts/check-acp-usage-accounting.ts
|
|
2719
|
+
}
|
|
2720
|
+
|
|
2703
2721
|
check_acp_stop_reason() {
|
|
2704
2722
|
# Deterministic gate for the ACP stop-reason contract. Drives every member of
|
|
2705
2723
|
# the closed ACP terminal set (end_turn / max_tokens / max_turn_requests /
|
|
@@ -6520,6 +6538,9 @@ case "$cmd" in
|
|
|
6520
6538
|
check-acp-event-mapper)
|
|
6521
6539
|
check_acp_event_mapper
|
|
6522
6540
|
;;
|
|
6541
|
+
check-acp-usage-accounting)
|
|
6542
|
+
check_acp_usage_accounting
|
|
6543
|
+
;;
|
|
6523
6544
|
check-acp-stop-reason)
|
|
6524
6545
|
check_acp_stop_reason
|
|
6525
6546
|
;;
|