@celestea/runtime 2.7.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/LICENSE +21 -0
- package/README.md +106 -0
- package/dist/agent-config.d.ts +18 -0
- package/dist/agent-config.js +31 -0
- package/dist/autowake.d.ts +141 -0
- package/dist/autowake.js +262 -0
- package/dist/compact/index.d.ts +13 -0
- package/dist/compact/index.js +13 -0
- package/dist/compact/plan.d.ts +51 -0
- package/dist/compact/plan.js +98 -0
- package/dist/compact/rewrite.d.ts +23 -0
- package/dist/compact/rewrite.js +79 -0
- package/dist/compact/run.d.ts +44 -0
- package/dist/compact/run.js +59 -0
- package/dist/compact/summarize.d.ts +30 -0
- package/dist/compact/summarize.js +70 -0
- package/dist/compact/transcript.d.ts +35 -0
- package/dist/compact/transcript.js +88 -0
- package/dist/compose.d.ts +117 -0
- package/dist/compose.js +191 -0
- package/dist/errors.d.ts +25 -0
- package/dist/errors.js +34 -0
- package/dist/frames.d.ts +46 -0
- package/dist/frames.js +62 -0
- package/dist/gen.d.ts +86 -0
- package/dist/gen.js +129 -0
- package/dist/host/engine-session.d.ts +117 -0
- package/dist/host/engine-session.js +109 -0
- package/dist/host/index.d.ts +39 -0
- package/dist/host/index.js +39 -0
- package/dist/host/provider-target.d.ts +113 -0
- package/dist/host/provider-target.js +116 -0
- package/dist/inbox-checkpoint.d.ts +18 -0
- package/dist/inbox-checkpoint.js +37 -0
- package/dist/inbox.d.ts +94 -0
- package/dist/inbox.js +139 -0
- package/dist/index.d.ts +71 -0
- package/dist/index.js +71 -0
- package/dist/ledger-io.d.ts +27 -0
- package/dist/ledger-io.js +74 -0
- package/dist/ledger-llm.d.ts +48 -0
- package/dist/ledger-llm.js +115 -0
- package/dist/ledger-query.d.ts +91 -0
- package/dist/ledger-query.js +153 -0
- package/dist/ledger.d.ts +271 -0
- package/dist/ledger.js +444 -0
- package/dist/pricing.d.ts +100 -0
- package/dist/pricing.js +167 -0
- package/dist/profile.d.ts +26 -0
- package/dist/profile.js +39 -0
- package/dist/recovery.d.ts +56 -0
- package/dist/recovery.js +91 -0
- package/dist/retention.d.ts +49 -0
- package/dist/retention.js +119 -0
- package/dist/runtime.d.ts +197 -0
- package/dist/runtime.js +347 -0
- package/dist/sanitize.d.ts +35 -0
- package/dist/sanitize.js +36 -0
- package/dist/session-binding.d.ts +36 -0
- package/dist/session-binding.js +33 -0
- package/dist/session-registry.d.ts +238 -0
- package/dist/session-registry.js +388 -0
- package/dist/status.d.ts +279 -0
- package/dist/status.js +411 -0
- package/dist/tokens.d.ts +25 -0
- package/dist/tokens.js +25 -0
- package/dist/turn-runner.d.ts +169 -0
- package/dist/turn-runner.js +242 -0
- package/dist/usage.d.ts +64 -0
- package/dist/usage.js +88 -0
- package/dist/watchdog-mount.d.ts +79 -0
- package/dist/watchdog-mount.js +120 -0
- package/dist/worker-wiring.d.ts +74 -0
- package/dist/worker-wiring.js +107 -0
- package/package.json +31 -0
package/dist/status.d.ts
ADDED
|
@@ -0,0 +1,279 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Statusline tracker — port of `celestea_studio/src/main.rs:253-330` (W218/W263).
|
|
3
|
+
*
|
|
4
|
+
* Two live counters, fed by the turn sink:
|
|
5
|
+
* - `steps` one per tool CALL (its `tool_result` closes that step, so a
|
|
6
|
+
* result never doubles the count — W263 semantics, equal to the
|
|
7
|
+
* frontend's per-turn tool counter);
|
|
8
|
+
* - `rate` the `tokens_per_sec` estimate over text/thinking deltas: the
|
|
9
|
+
* 5s sliding-window rate (W754) while the window still carries
|
|
10
|
+
* output, else the mean over the turn's ACTIVE intervals (W763).
|
|
11
|
+
*
|
|
12
|
+
* W754 rate semantics (the window half): the rate averages over ACTIVE intervals
|
|
13
|
+
* only. Two deltas farther apart than `GAP_MS` (1s) bound a no-flow break (long
|
|
14
|
+
* tool call, rate limit stall, idle tail of a finished turn), and that break is
|
|
15
|
+
* NOT part of the denominator — a wall-clock span over the whole window would
|
|
16
|
+
* otherwise dilute the rate towards zero. The denominator is the sum of the
|
|
17
|
+
* per-interval spans, each floored at `MIN_ACTIVE_MS` (1s) so a lone burst
|
|
18
|
+
* cannot report a silly rate.
|
|
19
|
+
*
|
|
20
|
+
* W763 turn semantics (the fallback half): the window answer is the responsive
|
|
21
|
+
* one (an observed stream read 570 -> 1003 -> 760 tok/s), but an EMPTY window —
|
|
22
|
+
* a stall longer than 5s, or the plain end of a turn — used to report 0 and stay
|
|
23
|
+
* there, which the operator rejected. So the window rate is reported only while
|
|
24
|
+
* it is positive; otherwise the rate is the mean over the whole TURN's active
|
|
25
|
+
* intervals ([turnRate]), a stable positive number until the next `beginTurn()`.
|
|
26
|
+
* 0 therefore means exactly one thing: this turn has produced no delta yet (TTFT).
|
|
27
|
+
*
|
|
28
|
+
* Unit note: what is computed here is CHARACTERS per second, not tokens. The
|
|
29
|
+
* field has always been named `tokens_per_sec` (frozen contract) and the
|
|
30
|
+
* frontend renders it as an approximate ~1:1 token rate, so the unit semantics
|
|
31
|
+
* are deliberately unchanged by W754 — only the denominator changed.
|
|
32
|
+
*
|
|
33
|
+
* W755 context-usage口径 (aligned with the DSH host's `contextPressure`):
|
|
34
|
+
* 1. a provider usage frame has been seen -> `used` = that request's REAL
|
|
35
|
+
* `prompt_tokens` (input + cache, never output) PLUS the model-visible
|
|
36
|
+
* growth measured since the sample, so the number answers for the NEXT
|
|
37
|
+
* request instead of the last one (`projected:true`);
|
|
38
|
+
* 2. no frame yet, but the loop hands back its own assembly -> `used` = the
|
|
39
|
+
* token estimate of THAT request (system + trimmed history + tool
|
|
40
|
+
* schemas), never of the raw session log (`assembled_estimate`);
|
|
41
|
+
* 3. neither -> `used` = 0 / `method:"none"`: the UI shows "unknown" rather
|
|
42
|
+
* than a made-up ratio.
|
|
43
|
+
* The session log's CHARACTER count is never reported as a token count again
|
|
44
|
+
* ([estimatedContextChars] survives as an internal/debug helper only), and a
|
|
45
|
+
* missing context window is reported as `window:0, ratio:0` — the 1,000,000
|
|
46
|
+
* display fallback never enters a ratio (W755, Fix A/B/C).
|
|
47
|
+
*
|
|
48
|
+
* W766: branch 2 reads a MEMOIZED estimate ([AssembledContext]): the runtime
|
|
49
|
+
* derives it once per log state, so an idle session's 2s status tick is a lookup
|
|
50
|
+
* instead of an O(bytes) re-estimate. No口径 value changes — the same function
|
|
51
|
+
* produces the same number, just not on every tick.
|
|
52
|
+
*
|
|
53
|
+
* `now()` is injectable so the rate window is testable without sleeping.
|
|
54
|
+
*/
|
|
55
|
+
import type { ModelRequest, SessionEvent, Statusline, TurnOutcome } from "@celestea/core";
|
|
56
|
+
import { type UsageAccounting } from "./usage.js";
|
|
57
|
+
/** W218: sliding-window length for `tokens_per_sec`. */
|
|
58
|
+
export declare const RATE_WINDOW_MS = 5000;
|
|
59
|
+
/** W754: deltas farther apart than this bound a no-flow break — excluded from the rate denominator. */
|
|
60
|
+
export declare const GAP_MS = 1000;
|
|
61
|
+
/** W754: minimum duration credited to one activity interval (the old "1s floor" spirit, without counting stalls). */
|
|
62
|
+
export declare const MIN_ACTIVE_MS = 1000;
|
|
63
|
+
/** W218: cadence of the SSE status "progress" events during a turn. */
|
|
64
|
+
export declare const STATUS_TICK_MS = 2000;
|
|
65
|
+
/** One output delta: when it landed and how many chars it carried. */
|
|
66
|
+
export interface RateSample {
|
|
67
|
+
at: number;
|
|
68
|
+
chars: number;
|
|
69
|
+
}
|
|
70
|
+
export declare class StatusTracker {
|
|
71
|
+
private steps;
|
|
72
|
+
private samples;
|
|
73
|
+
/** W763: the turn's COMPRESSED activity intervals (grows with stoppages, never with deltas). */
|
|
74
|
+
private segments;
|
|
75
|
+
private readonly now;
|
|
76
|
+
constructor(now?: () => number);
|
|
77
|
+
/** New-turn baseline: clear the step counter, the rate window and the turn's intervals (W763). */
|
|
78
|
+
beginTurn(): void;
|
|
79
|
+
/** Record one step (one tool call). */
|
|
80
|
+
addStep(): void;
|
|
81
|
+
/** Record one output delta (text/thinking) into the rate window and the turn's intervals (W763). */
|
|
82
|
+
addChars(chars: number): void;
|
|
83
|
+
/**
|
|
84
|
+
* W763: activity intervals held for the current turn. Diagnostic/test hook —
|
|
85
|
+
* an uninterrupted stream is ONE segment no matter how many deltas it carries.
|
|
86
|
+
*/
|
|
87
|
+
get turnSegmentCount(): number;
|
|
88
|
+
/** Steps recorded in the current turn. */
|
|
89
|
+
get stepCount(): number;
|
|
90
|
+
/**
|
|
91
|
+
* Chars-per-second estimate (W754 + W763): the responsive window rate while
|
|
92
|
+
* the 5s window still carries output, otherwise the turn's active-interval
|
|
93
|
+
* mean. 0 only while this turn has not produced a single delta (TTFT).
|
|
94
|
+
*/
|
|
95
|
+
rate(): number;
|
|
96
|
+
private trim;
|
|
97
|
+
}
|
|
98
|
+
/**
|
|
99
|
+
* W754: total ACTIVE milliseconds covered by the samples (the rate denominator).
|
|
100
|
+
*
|
|
101
|
+
* Samples are walked in time order; every adjacent pair closer than `GAP_MS`
|
|
102
|
+
* stays in the same activity interval, a wider step closes the interval and
|
|
103
|
+
* opens a new one (the break itself contributes nothing). The final interval is
|
|
104
|
+
* extended to `now` only while the stream still looks alive (`now` within
|
|
105
|
+
* `GAP_MS` of the last delta), so a stall does not keep inflating the
|
|
106
|
+
* denominator. Each interval is credited at least `MIN_ACTIVE_MS`.
|
|
107
|
+
*/
|
|
108
|
+
export declare function activeSpanMs(samples: readonly RateSample[], now: number): number;
|
|
109
|
+
/**
|
|
110
|
+
* W763: one COMPRESSED activity interval of the current turn. Consecutive deltas
|
|
111
|
+
* closer than `GAP_MS` collapse into a single record, so this list grows with
|
|
112
|
+
* the number of stoppages — never with the number of deltas (10k deltas without
|
|
113
|
+
* a pause is ONE segment), which is what keeps the whole turn in O(pauses)
|
|
114
|
+
* memory instead of O(deltas).
|
|
115
|
+
*/
|
|
116
|
+
export interface TurnSegment {
|
|
117
|
+
/** Wall clock of the interval's first delta (ms). */
|
|
118
|
+
start: number;
|
|
119
|
+
/** Wall clock of the interval's last delta (ms) — the segment's live edge. */
|
|
120
|
+
at: number;
|
|
121
|
+
/** Characters carried by every delta of this interval. */
|
|
122
|
+
chars: number;
|
|
123
|
+
}
|
|
124
|
+
/** W763: fold one delta into the turn's segments (O(1); allocates only when a pause opens a new interval). */
|
|
125
|
+
export declare function pushTurnDelta(segments: TurnSegment[], at: number, chars: number): void;
|
|
126
|
+
/**
|
|
127
|
+
* W763: Σ ACTIVE ms over the turn's segments — the same flooring rule as
|
|
128
|
+
* [activeSpanMs] (every interval ≥ `MIN_ACTIVE_MS`) and the same guard that only
|
|
129
|
+
* extends the OPEN interval while the stream is still alive, so neither a stall
|
|
130
|
+
* nor a finished turn can inflate the denominator.
|
|
131
|
+
*/
|
|
132
|
+
export declare function turnSpanMs(segments: readonly TurnSegment[], now: number): number;
|
|
133
|
+
/**
|
|
134
|
+
* W763: chars/s averaged over the turn's ACTIVE intervals — the "有流时段均值" the
|
|
135
|
+
* operator asked for. 0 iff the turn has produced no delta at all yet.
|
|
136
|
+
*/
|
|
137
|
+
export declare function turnRate(segments: readonly TurnSegment[], now: number): number;
|
|
138
|
+
/** Factory form (ARCHITECTURE.md §6.1). */
|
|
139
|
+
export declare function createStatusTracker(now?: () => number): StatusTracker;
|
|
140
|
+
/**
|
|
141
|
+
* W755: which source backs `context_usage.window`.
|
|
142
|
+
* - `profile` the session's profile declared a real capacity -> `ratio` is real;
|
|
143
|
+
* - `fallback` the profile declares none (0 = trimming off), so the only number
|
|
144
|
+
* left is the contract's DISPLAY default (`CONTEXT_WINDOW_FALLBACK`)
|
|
145
|
+
* owned by the frontend — `window` stays 0 and no ratio is drawn;
|
|
146
|
+
* - `unknown` the configured value is not a usable number at all.
|
|
147
|
+
*/
|
|
148
|
+
export type ContextWindowSource = "profile" | "fallback" | "unknown";
|
|
149
|
+
/**
|
|
150
|
+
* W755 (Fix B): the forward-looking correction of the provider's prompt sample.
|
|
151
|
+
*
|
|
152
|
+
* DSH computes `projectedTokens = pressureTokens + surfaceTokens - sampledSurfaceTokens`
|
|
153
|
+
* so that occupancy answers for the NEXT request rather than the last one
|
|
154
|
+
* (`dsh-token-meter/lib/types/usage-projection.js:178-187`). Same shape here:
|
|
155
|
+
* remember the model-visible estimate observed when a NEW `prompt_tokens` value
|
|
156
|
+
* arrived, and add whatever the visible surface has grown by since.
|
|
157
|
+
*
|
|
158
|
+
* Only growth counts: a shrinking surface (a trim/compaction) never pulls the
|
|
159
|
+
* number BELOW the provider's own measurement — that anchor is a fact.
|
|
160
|
+
*
|
|
161
|
+
* The state is PER SESSION (one instance lives on the session's [StatusView]),
|
|
162
|
+
* never a module singleton and never a timer. A fresh instance per call just
|
|
163
|
+
* degrades to "no projection", which is correct, only lagging.
|
|
164
|
+
*/
|
|
165
|
+
export declare class ContextPressure {
|
|
166
|
+
private sampledPrompt;
|
|
167
|
+
private assembledAtSample;
|
|
168
|
+
/**
|
|
169
|
+
* Note the (real prompt, visible estimate) pair of this observation. A prompt
|
|
170
|
+
* value already sampled keeps its original anchor, which is exactly what makes
|
|
171
|
+
* the surface growth measurable; a new value re-anchors.
|
|
172
|
+
*/
|
|
173
|
+
observe(promptTokens: number, assembledTokens: number | null): void;
|
|
174
|
+
/**
|
|
175
|
+
* `used` for this observation: the real prompt plus the visible growth since
|
|
176
|
+
* it was sampled. Without either half of the anchor it is just the prompt
|
|
177
|
+
* (`projected:false` — nothing forward-looking was measurable).
|
|
178
|
+
*/
|
|
179
|
+
project(promptTokens: number, assembledTokens: number | null): {
|
|
180
|
+
used: number;
|
|
181
|
+
projected: boolean;
|
|
182
|
+
};
|
|
183
|
+
}
|
|
184
|
+
/** Everything the statusline needs, so a turn task can snapshot it without a request path. */
|
|
185
|
+
export interface StatusView {
|
|
186
|
+
model: string;
|
|
187
|
+
reasoning_effort: string | null;
|
|
188
|
+
status: StatusTracker;
|
|
189
|
+
usage: UsageAccounting;
|
|
190
|
+
/** Live profile context window (0 = trimming off -> contract display default). */
|
|
191
|
+
context_window: number;
|
|
192
|
+
/** The current session log's events (a rebind swaps the producer, not the view). */
|
|
193
|
+
events: () => readonly SessionEvent[];
|
|
194
|
+
/**
|
|
195
|
+
* W755 (Fix A): the model-visible request the NEXT step would build — the
|
|
196
|
+
* loop's OWN assembly (system + trimmed history + tool schemas) — or null when
|
|
197
|
+
* the mounted loop cannot snapshot (test doubles). This — not the raw log —
|
|
198
|
+
* is what the context usage falls back to.
|
|
199
|
+
*
|
|
200
|
+
* W766: the estimate travels WITH the request ([AssembledContext]), because the
|
|
201
|
+
* runtime memoizes both on the log state that produced them. A tick that
|
|
202
|
+
* re-reads an unchanged log therefore costs one lookup, not another O(bytes)
|
|
203
|
+
* walk of the messages.
|
|
204
|
+
*/
|
|
205
|
+
assembled: () => AssembledContext | null;
|
|
206
|
+
/**
|
|
207
|
+
* W755 (Fix B): the per-session projection state. MUST be the same instance on
|
|
208
|
+
* every call for one session (a fresh one per call silently disables the
|
|
209
|
+
* forward-looking correction).
|
|
210
|
+
*/
|
|
211
|
+
pressure: ContextPressure;
|
|
212
|
+
}
|
|
213
|
+
/**
|
|
214
|
+
* W766: a model-visible request TOGETHER with its token estimate.
|
|
215
|
+
*
|
|
216
|
+
* The two are one value on purpose: [estimatedContextTokens] is O(total bytes),
|
|
217
|
+
* the runtime memoizes the assembly on the session log's state, and a statusline
|
|
218
|
+
* tick must not re-walk a request it was already handed. Bundling the estimate
|
|
219
|
+
* with the request gives it the SAME lifetime and the SAME invalidation key as
|
|
220
|
+
* what it describes — there is no second cache to keep in sync
|
|
221
|
+
* (`Runtime.assembledContext()`).
|
|
222
|
+
*/
|
|
223
|
+
export interface AssembledContext {
|
|
224
|
+
request: ModelRequest;
|
|
225
|
+
/** [estimatedContextTokens] of `request`, computed once per assembly. */
|
|
226
|
+
tokens: number;
|
|
227
|
+
}
|
|
228
|
+
/**
|
|
229
|
+
* W755 (Fix A): token estimate of a MODEL-VISIBLE request — the very estimator
|
|
230
|
+
* the loop's trim budget already uses (`packages/agent-loop/src/context-trim.ts`),
|
|
231
|
+
* reused rather than re-derived, so the two can never disagree: system text +
|
|
232
|
+
* every message (content + structural overhead) + the tool schemas.
|
|
233
|
+
*
|
|
234
|
+
* W766: callers on a hot path should obtain the value through a memoized
|
|
235
|
+
* [AssembledContext] instead of calling this again on an unchanged request.
|
|
236
|
+
*/
|
|
237
|
+
export declare function estimatedContextTokens(request: ModelRequest): number;
|
|
238
|
+
/**
|
|
239
|
+
* W766: pair a request with its estimate — for callers that build a view by hand
|
|
240
|
+
* (tests, the bench) and therefore have no runtime cache to read it from.
|
|
241
|
+
*/
|
|
242
|
+
export declare function assembledContextOf(request: ModelRequest): AssembledContext;
|
|
243
|
+
/** W263: used/window ratio, clamped to [0,1], rounded to 4 decimals. */
|
|
244
|
+
export declare function ratio4(used: number, window: number): number;
|
|
245
|
+
/**
|
|
246
|
+
* W218 character volume of the session log (user/assistant text + tool call
|
|
247
|
+
* id/name/args + tool result value/error). Thinking deltas and turn markers
|
|
248
|
+
* carry no model-visible history and are free.
|
|
249
|
+
*
|
|
250
|
+
* W755: THIS IS A CHARACTER COUNT, NOT A TOKEN COUNT, and it is no longer part
|
|
251
|
+
* of any reported口径 — `contextUsage` never reports it (the old
|
|
252
|
+
* `session_event_chars` method divided it by a TOKEN window, which over-reported
|
|
253
|
+
* by ~4x for latin text and inflated further for CJK). Kept only as an internal
|
|
254
|
+
* / debug scale reference (and as the regression anchor pinning the fix).
|
|
255
|
+
*/
|
|
256
|
+
export declare function estimatedContextChars(events: readonly SessionEvent[]): number;
|
|
257
|
+
/**
|
|
258
|
+
* W755: the context usage, in the DSH口径 — real provider prompt first, else the
|
|
259
|
+
* model-visible assembly's token estimate, else an honest "unknown" (never the
|
|
260
|
+
* session log's character count; see [estimatedContextChars]).
|
|
261
|
+
*/
|
|
262
|
+
export declare function contextUsage(view: StatusView): Statusline["context_usage"];
|
|
263
|
+
/**
|
|
264
|
+
* W755: the statusline of a session with NO live generation yet (the studio
|
|
265
|
+
* adapter's "empty one"). Nothing is measurable there — no provider frame, no
|
|
266
|
+
* engine assembly, no events — so it reports the honest `method:"none"` branch
|
|
267
|
+
* (`used:0`) instead of a made-up ratio. A FRESH [ContextPressure] is correct
|
|
268
|
+
* here: with no usage frame no prompt sample is ever observed through it.
|
|
269
|
+
*/
|
|
270
|
+
export declare function coldStatusline(input: {
|
|
271
|
+
model: string;
|
|
272
|
+
reasoning_effort: string | null;
|
|
273
|
+
context_window: number;
|
|
274
|
+
now?: () => number;
|
|
275
|
+
}): Statusline;
|
|
276
|
+
/** `statusline_of` — the frozen `/api/status` payload. */
|
|
277
|
+
export declare function statuslineOf(view: StatusView): Statusline;
|
|
278
|
+
/** Terminal outcome -> SSE status phase (the frozen vocabulary). */
|
|
279
|
+
export declare function outcomePhaseOf(outcome: TurnOutcome): string;
|
package/dist/status.js
ADDED
|
@@ -0,0 +1,411 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Statusline tracker — port of `celestea_studio/src/main.rs:253-330` (W218/W263).
|
|
3
|
+
*
|
|
4
|
+
* Two live counters, fed by the turn sink:
|
|
5
|
+
* - `steps` one per tool CALL (its `tool_result` closes that step, so a
|
|
6
|
+
* result never doubles the count — W263 semantics, equal to the
|
|
7
|
+
* frontend's per-turn tool counter);
|
|
8
|
+
* - `rate` the `tokens_per_sec` estimate over text/thinking deltas: the
|
|
9
|
+
* 5s sliding-window rate (W754) while the window still carries
|
|
10
|
+
* output, else the mean over the turn's ACTIVE intervals (W763).
|
|
11
|
+
*
|
|
12
|
+
* W754 rate semantics (the window half): the rate averages over ACTIVE intervals
|
|
13
|
+
* only. Two deltas farther apart than `GAP_MS` (1s) bound a no-flow break (long
|
|
14
|
+
* tool call, rate limit stall, idle tail of a finished turn), and that break is
|
|
15
|
+
* NOT part of the denominator — a wall-clock span over the whole window would
|
|
16
|
+
* otherwise dilute the rate towards zero. The denominator is the sum of the
|
|
17
|
+
* per-interval spans, each floored at `MIN_ACTIVE_MS` (1s) so a lone burst
|
|
18
|
+
* cannot report a silly rate.
|
|
19
|
+
*
|
|
20
|
+
* W763 turn semantics (the fallback half): the window answer is the responsive
|
|
21
|
+
* one (an observed stream read 570 -> 1003 -> 760 tok/s), but an EMPTY window —
|
|
22
|
+
* a stall longer than 5s, or the plain end of a turn — used to report 0 and stay
|
|
23
|
+
* there, which the operator rejected. So the window rate is reported only while
|
|
24
|
+
* it is positive; otherwise the rate is the mean over the whole TURN's active
|
|
25
|
+
* intervals ([turnRate]), a stable positive number until the next `beginTurn()`.
|
|
26
|
+
* 0 therefore means exactly one thing: this turn has produced no delta yet (TTFT).
|
|
27
|
+
*
|
|
28
|
+
* Unit note: what is computed here is CHARACTERS per second, not tokens. The
|
|
29
|
+
* field has always been named `tokens_per_sec` (frozen contract) and the
|
|
30
|
+
* frontend renders it as an approximate ~1:1 token rate, so the unit semantics
|
|
31
|
+
* are deliberately unchanged by W754 — only the denominator changed.
|
|
32
|
+
*
|
|
33
|
+
* W755 context-usage口径 (aligned with the DSH host's `contextPressure`):
|
|
34
|
+
* 1. a provider usage frame has been seen -> `used` = that request's REAL
|
|
35
|
+
* `prompt_tokens` (input + cache, never output) PLUS the model-visible
|
|
36
|
+
* growth measured since the sample, so the number answers for the NEXT
|
|
37
|
+
* request instead of the last one (`projected:true`);
|
|
38
|
+
* 2. no frame yet, but the loop hands back its own assembly -> `used` = the
|
|
39
|
+
* token estimate of THAT request (system + trimmed history + tool
|
|
40
|
+
* schemas), never of the raw session log (`assembled_estimate`);
|
|
41
|
+
* 3. neither -> `used` = 0 / `method:"none"`: the UI shows "unknown" rather
|
|
42
|
+
* than a made-up ratio.
|
|
43
|
+
* The session log's CHARACTER count is never reported as a token count again
|
|
44
|
+
* ([estimatedContextChars] survives as an internal/debug helper only), and a
|
|
45
|
+
* missing context window is reported as `window:0, ratio:0` — the 1,000,000
|
|
46
|
+
* display fallback never enters a ratio (W755, Fix A/B/C).
|
|
47
|
+
*
|
|
48
|
+
* W766: branch 2 reads a MEMOIZED estimate ([AssembledContext]): the runtime
|
|
49
|
+
* derives it once per log state, so an idle session's 2s status tick is a lookup
|
|
50
|
+
* instead of an O(bytes) re-estimate. No口径 value changes — the same function
|
|
51
|
+
* produces the same number, just not on every tick.
|
|
52
|
+
*
|
|
53
|
+
* `now()` is injectable so the rate window is testable without sleeping.
|
|
54
|
+
*/
|
|
55
|
+
import { estimateMessagesTokens, estimateTokens } from "@celestea/agent-loop";
|
|
56
|
+
import { createUsageTracker, usageStatus } from "./usage.js";
|
|
57
|
+
/** W218: sliding-window length for `tokens_per_sec`. */
|
|
58
|
+
export const RATE_WINDOW_MS = 5_000;
|
|
59
|
+
/** W754: deltas farther apart than this bound a no-flow break — excluded from the rate denominator. */
|
|
60
|
+
export const GAP_MS = 1_000;
|
|
61
|
+
/** W754: minimum duration credited to one activity interval (the old "1s floor" spirit, without counting stalls). */
|
|
62
|
+
export const MIN_ACTIVE_MS = 1_000;
|
|
63
|
+
/** W218: cadence of the SSE status "progress" events during a turn. */
|
|
64
|
+
export const STATUS_TICK_MS = 2_000;
|
|
65
|
+
export class StatusTracker {
|
|
66
|
+
steps = 0;
|
|
67
|
+
samples = [];
|
|
68
|
+
/** W763: the turn's COMPRESSED activity intervals (grows with stoppages, never with deltas). */
|
|
69
|
+
segments = [];
|
|
70
|
+
now;
|
|
71
|
+
constructor(now = Date.now) {
|
|
72
|
+
this.now = now;
|
|
73
|
+
}
|
|
74
|
+
/** New-turn baseline: clear the step counter, the rate window and the turn's intervals (W763). */
|
|
75
|
+
beginTurn() {
|
|
76
|
+
this.steps = 0;
|
|
77
|
+
this.samples = [];
|
|
78
|
+
this.segments = [];
|
|
79
|
+
}
|
|
80
|
+
/** Record one step (one tool call). */
|
|
81
|
+
addStep() {
|
|
82
|
+
this.steps += 1;
|
|
83
|
+
}
|
|
84
|
+
/** Record one output delta (text/thinking) into the rate window and the turn's intervals (W763). */
|
|
85
|
+
addChars(chars) {
|
|
86
|
+
const at = this.now();
|
|
87
|
+
const n = Math.max(0, Math.trunc(chars));
|
|
88
|
+
this.samples.push({ at, chars: n });
|
|
89
|
+
this.trim(at);
|
|
90
|
+
pushTurnDelta(this.segments, at, n);
|
|
91
|
+
}
|
|
92
|
+
/**
|
|
93
|
+
* W763: activity intervals held for the current turn. Diagnostic/test hook —
|
|
94
|
+
* an uninterrupted stream is ONE segment no matter how many deltas it carries.
|
|
95
|
+
*/
|
|
96
|
+
get turnSegmentCount() {
|
|
97
|
+
return this.segments.length;
|
|
98
|
+
}
|
|
99
|
+
/** Steps recorded in the current turn. */
|
|
100
|
+
get stepCount() {
|
|
101
|
+
return this.steps;
|
|
102
|
+
}
|
|
103
|
+
/**
|
|
104
|
+
* Chars-per-second estimate (W754 + W763): the responsive window rate while
|
|
105
|
+
* the 5s window still carries output, otherwise the turn's active-interval
|
|
106
|
+
* mean. 0 only while this turn has not produced a single delta (TTFT).
|
|
107
|
+
*/
|
|
108
|
+
rate() {
|
|
109
|
+
const now = this.now();
|
|
110
|
+
this.trim(now);
|
|
111
|
+
const window = windowRateOf(this.samples, now);
|
|
112
|
+
return window > 0 ? window : turnRate(this.segments, now);
|
|
113
|
+
}
|
|
114
|
+
trim(now) {
|
|
115
|
+
while (this.samples.length > 0 && now - (this.samples[0]?.at ?? now) > RATE_WINDOW_MS) {
|
|
116
|
+
this.samples.shift();
|
|
117
|
+
}
|
|
118
|
+
}
|
|
119
|
+
}
|
|
120
|
+
/**
|
|
121
|
+
* W754: total ACTIVE milliseconds covered by the samples (the rate denominator).
|
|
122
|
+
*
|
|
123
|
+
* Samples are walked in time order; every adjacent pair closer than `GAP_MS`
|
|
124
|
+
* stays in the same activity interval, a wider step closes the interval and
|
|
125
|
+
* opens a new one (the break itself contributes nothing). The final interval is
|
|
126
|
+
* extended to `now` only while the stream still looks alive (`now` within
|
|
127
|
+
* `GAP_MS` of the last delta), so a stall does not keep inflating the
|
|
128
|
+
* denominator. Each interval is credited at least `MIN_ACTIVE_MS`.
|
|
129
|
+
*/
|
|
130
|
+
export function activeSpanMs(samples, now) {
|
|
131
|
+
const first = samples[0];
|
|
132
|
+
if (first === undefined)
|
|
133
|
+
return 0;
|
|
134
|
+
let span = 0;
|
|
135
|
+
let segStart = first.at;
|
|
136
|
+
let prev = first.at;
|
|
137
|
+
for (let i = 1; i < samples.length; i += 1) {
|
|
138
|
+
const at = samples[i]?.at ?? prev;
|
|
139
|
+
if (at - prev > GAP_MS) {
|
|
140
|
+
span += Math.max(prev - segStart, MIN_ACTIVE_MS);
|
|
141
|
+
segStart = at;
|
|
142
|
+
}
|
|
143
|
+
prev = at;
|
|
144
|
+
}
|
|
145
|
+
const tail = now - prev;
|
|
146
|
+
const end = tail >= 0 && tail <= GAP_MS ? now : prev;
|
|
147
|
+
return span + Math.max(end - segStart, MIN_ACTIVE_MS);
|
|
148
|
+
}
|
|
149
|
+
/** W763: fold one delta into the turn's segments (O(1); allocates only when a pause opens a new interval). */
|
|
150
|
+
export function pushTurnDelta(segments, at, chars) {
|
|
151
|
+
const last = segments[segments.length - 1];
|
|
152
|
+
if (last === undefined || at - last.at > GAP_MS) {
|
|
153
|
+
segments.push({ start: at, at, chars });
|
|
154
|
+
return;
|
|
155
|
+
}
|
|
156
|
+
last.at = at;
|
|
157
|
+
last.chars += chars;
|
|
158
|
+
}
|
|
159
|
+
/**
|
|
160
|
+
* W763: Σ ACTIVE ms over the turn's segments — the same flooring rule as
|
|
161
|
+
* [activeSpanMs] (every interval ≥ `MIN_ACTIVE_MS`) and the same guard that only
|
|
162
|
+
* extends the OPEN interval while the stream is still alive, so neither a stall
|
|
163
|
+
* nor a finished turn can inflate the denominator.
|
|
164
|
+
*/
|
|
165
|
+
export function turnSpanMs(segments, now) {
|
|
166
|
+
let ms = 0;
|
|
167
|
+
for (let i = 0; i < segments.length; i += 1) {
|
|
168
|
+
const seg = segments[i];
|
|
169
|
+
if (seg === undefined)
|
|
170
|
+
continue;
|
|
171
|
+
const tail = now - seg.at;
|
|
172
|
+
const open = i === segments.length - 1 && tail >= 0 && tail <= GAP_MS;
|
|
173
|
+
ms += Math.max((open ? now : seg.at) - seg.start, MIN_ACTIVE_MS);
|
|
174
|
+
}
|
|
175
|
+
return ms;
|
|
176
|
+
}
|
|
177
|
+
/**
|
|
178
|
+
* W763: chars/s averaged over the turn's ACTIVE intervals — the "有流时段均值" the
|
|
179
|
+
* operator asked for. 0 iff the turn has produced no delta at all yet.
|
|
180
|
+
*/
|
|
181
|
+
export function turnRate(segments, now) {
|
|
182
|
+
if (segments.length === 0)
|
|
183
|
+
return 0;
|
|
184
|
+
let chars = 0;
|
|
185
|
+
for (const seg of segments)
|
|
186
|
+
chars += seg.chars;
|
|
187
|
+
return (chars / turnSpanMs(segments, now)) * 1_000;
|
|
188
|
+
}
|
|
189
|
+
/** W754 window half: the 5s-window rate, 0 when the window holds no char-carrying sample. */
|
|
190
|
+
function windowRateOf(samples, now) {
|
|
191
|
+
let chars = 0;
|
|
192
|
+
for (const s of samples)
|
|
193
|
+
chars += s.chars;
|
|
194
|
+
if (chars === 0)
|
|
195
|
+
return 0;
|
|
196
|
+
return (chars / activeSpanMs(samples, now)) * 1_000;
|
|
197
|
+
}
|
|
198
|
+
/** Factory form (ARCHITECTURE.md §6.1). */
|
|
199
|
+
export function createStatusTracker(now = Date.now) {
|
|
200
|
+
return new StatusTracker(now);
|
|
201
|
+
}
|
|
202
|
+
/**
|
|
203
|
+
* W755 (Fix B): the forward-looking correction of the provider's prompt sample.
|
|
204
|
+
*
|
|
205
|
+
* DSH computes `projectedTokens = pressureTokens + surfaceTokens - sampledSurfaceTokens`
|
|
206
|
+
* so that occupancy answers for the NEXT request rather than the last one
|
|
207
|
+
* (`dsh-token-meter/lib/types/usage-projection.js:178-187`). Same shape here:
|
|
208
|
+
* remember the model-visible estimate observed when a NEW `prompt_tokens` value
|
|
209
|
+
* arrived, and add whatever the visible surface has grown by since.
|
|
210
|
+
*
|
|
211
|
+
* Only growth counts: a shrinking surface (a trim/compaction) never pulls the
|
|
212
|
+
* number BELOW the provider's own measurement — that anchor is a fact.
|
|
213
|
+
*
|
|
214
|
+
* The state is PER SESSION (one instance lives on the session's [StatusView]),
|
|
215
|
+
* never a module singleton and never a timer. A fresh instance per call just
|
|
216
|
+
* degrades to "no projection", which is correct, only lagging.
|
|
217
|
+
*/
|
|
218
|
+
export class ContextPressure {
|
|
219
|
+
sampledPrompt = 0;
|
|
220
|
+
assembledAtSample = null;
|
|
221
|
+
/**
|
|
222
|
+
* Note the (real prompt, visible estimate) pair of this observation. A prompt
|
|
223
|
+
* value already sampled keeps its original anchor, which is exactly what makes
|
|
224
|
+
* the surface growth measurable; a new value re-anchors.
|
|
225
|
+
*/
|
|
226
|
+
observe(promptTokens, assembledTokens) {
|
|
227
|
+
if (!(promptTokens > 0))
|
|
228
|
+
return;
|
|
229
|
+
if (promptTokens === this.sampledPrompt && this.assembledAtSample !== null)
|
|
230
|
+
return;
|
|
231
|
+
this.sampledPrompt = promptTokens;
|
|
232
|
+
this.assembledAtSample = assembledTokens;
|
|
233
|
+
}
|
|
234
|
+
/**
|
|
235
|
+
* `used` for this observation: the real prompt plus the visible growth since
|
|
236
|
+
* it was sampled. Without either half of the anchor it is just the prompt
|
|
237
|
+
* (`projected:false` — nothing forward-looking was measurable).
|
|
238
|
+
*/
|
|
239
|
+
project(promptTokens, assembledTokens) {
|
|
240
|
+
if (!(promptTokens > 0))
|
|
241
|
+
return { used: 0, projected: false };
|
|
242
|
+
if (assembledTokens === null || this.assembledAtSample === null) {
|
|
243
|
+
return { used: promptTokens, projected: false };
|
|
244
|
+
}
|
|
245
|
+
const growth = Math.max(0, assembledTokens - this.assembledAtSample);
|
|
246
|
+
return { used: promptTokens + growth, projected: growth > 0 };
|
|
247
|
+
}
|
|
248
|
+
}
|
|
249
|
+
/**
|
|
250
|
+
* W755 (Fix A): token estimate of a MODEL-VISIBLE request — the very estimator
|
|
251
|
+
* the loop's trim budget already uses (`packages/agent-loop/src/context-trim.ts`),
|
|
252
|
+
* reused rather than re-derived, so the two can never disagree: system text +
|
|
253
|
+
* every message (content + structural overhead) + the tool schemas.
|
|
254
|
+
*
|
|
255
|
+
* W766: callers on a hot path should obtain the value through a memoized
|
|
256
|
+
* [AssembledContext] instead of calling this again on an unchanged request.
|
|
257
|
+
*/
|
|
258
|
+
export function estimatedContextTokens(request) {
|
|
259
|
+
const system = request.system === null ? 0 : estimateTokens(request.system);
|
|
260
|
+
return system + estimateMessagesTokens(request.messages) + estimateTokens(JSON.stringify(request.tools));
|
|
261
|
+
}
|
|
262
|
+
/**
|
|
263
|
+
* W766: pair a request with its estimate — for callers that build a view by hand
|
|
264
|
+
* (tests, the bench) and therefore have no runtime cache to read it from.
|
|
265
|
+
*/
|
|
266
|
+
export function assembledContextOf(request) {
|
|
267
|
+
return { request, tokens: estimatedContextTokens(request) };
|
|
268
|
+
}
|
|
269
|
+
/** W263: used/window ratio, clamped to [0,1], rounded to 4 decimals. */
|
|
270
|
+
export function ratio4(used, window) {
|
|
271
|
+
if (window <= 0)
|
|
272
|
+
return 0;
|
|
273
|
+
const r = used / window;
|
|
274
|
+
return Math.round(Math.min(1, Math.max(0, r)) * 10_000) / 10_000;
|
|
275
|
+
}
|
|
276
|
+
/**
|
|
277
|
+
* W218 character volume of the session log (user/assistant text + tool call
|
|
278
|
+
* id/name/args + tool result value/error). Thinking deltas and turn markers
|
|
279
|
+
* carry no model-visible history and are free.
|
|
280
|
+
*
|
|
281
|
+
* W755: THIS IS A CHARACTER COUNT, NOT A TOKEN COUNT, and it is no longer part
|
|
282
|
+
* of any reported口径 — `contextUsage` never reports it (the old
|
|
283
|
+
* `session_event_chars` method divided it by a TOKEN window, which over-reported
|
|
284
|
+
* by ~4x for latin text and inflated further for CJK). Kept only as an internal
|
|
285
|
+
* / debug scale reference (and as the regression anchor pinning the fix).
|
|
286
|
+
*/
|
|
287
|
+
export function estimatedContextChars(events) {
|
|
288
|
+
let total = 0;
|
|
289
|
+
for (const ev of events) {
|
|
290
|
+
switch (ev.type) {
|
|
291
|
+
case "user_message":
|
|
292
|
+
case "assistant_message":
|
|
293
|
+
total += ev.text.length;
|
|
294
|
+
break;
|
|
295
|
+
case "tool_call":
|
|
296
|
+
total += ev.id.length + ev.name.length + jsonLen(ev.args);
|
|
297
|
+
break;
|
|
298
|
+
case "tool_result":
|
|
299
|
+
total += ev.id.length + jsonLen(ev.value) + (ev.error?.length ?? 0);
|
|
300
|
+
break;
|
|
301
|
+
default:
|
|
302
|
+
break;
|
|
303
|
+
}
|
|
304
|
+
}
|
|
305
|
+
return total;
|
|
306
|
+
}
|
|
307
|
+
/**
|
|
308
|
+
* W755: the context usage, in the DSH口径 — real provider prompt first, else the
|
|
309
|
+
* model-visible assembly's token estimate, else an honest "unknown" (never the
|
|
310
|
+
* session log's character count; see [estimatedContextChars]).
|
|
311
|
+
*/
|
|
312
|
+
export function contextUsage(view) {
|
|
313
|
+
const { window, source } = contextWindowOf(view.context_window);
|
|
314
|
+
const assembled = assembledTokensOf(view);
|
|
315
|
+
const prompt = view.usage.latest().prompt_tokens;
|
|
316
|
+
if (prompt > 0) {
|
|
317
|
+
view.pressure.observe(prompt, assembled);
|
|
318
|
+
const { used, projected } = view.pressure.project(prompt, assembled);
|
|
319
|
+
return {
|
|
320
|
+
used,
|
|
321
|
+
window,
|
|
322
|
+
ratio: ratio4(used, window),
|
|
323
|
+
estimated: false,
|
|
324
|
+
method: "usage_prompt_tokens",
|
|
325
|
+
projected,
|
|
326
|
+
window_source: source,
|
|
327
|
+
};
|
|
328
|
+
}
|
|
329
|
+
if (assembled !== null) {
|
|
330
|
+
return {
|
|
331
|
+
used: assembled,
|
|
332
|
+
window,
|
|
333
|
+
ratio: ratio4(assembled, window),
|
|
334
|
+
estimated: true,
|
|
335
|
+
method: "assembled_estimate",
|
|
336
|
+
projected: false,
|
|
337
|
+
window_source: source,
|
|
338
|
+
};
|
|
339
|
+
}
|
|
340
|
+
return { used: 0, window, ratio: 0, estimated: true, method: "none", projected: false, window_source: source };
|
|
341
|
+
}
|
|
342
|
+
/**
|
|
343
|
+
* W755 (Fix C): the honest window. A missing capacity is NOT a 1,000,000-token
|
|
344
|
+
* window — the contract's display default is for the UI's text only and never
|
|
345
|
+
* enters a ratio (DSH refuses to render the ring at all without a capacity).
|
|
346
|
+
*/
|
|
347
|
+
function contextWindowOf(configured) {
|
|
348
|
+
if (Number.isFinite(configured) && configured > 0)
|
|
349
|
+
return { window: Math.trunc(configured), source: "profile" };
|
|
350
|
+
if (configured === 0)
|
|
351
|
+
return { window: 0, source: "fallback" };
|
|
352
|
+
return { window: 0, source: "unknown" };
|
|
353
|
+
}
|
|
354
|
+
/** The token estimate of the loop's assembly, or null when it cannot snapshot. */
|
|
355
|
+
function assembledTokensOf(view) {
|
|
356
|
+
let assembled = null;
|
|
357
|
+
try {
|
|
358
|
+
assembled = view.assembled();
|
|
359
|
+
}
|
|
360
|
+
catch {
|
|
361
|
+
// A statusline read must never fail the endpoint: no snapshot is a valid
|
|
362
|
+
// answer (method "none"), an exception is not.
|
|
363
|
+
assembled = null;
|
|
364
|
+
}
|
|
365
|
+
// W766: read the estimate the assembly carries; never re-estimate here.
|
|
366
|
+
return assembled === null ? null : assembled.tokens;
|
|
367
|
+
}
|
|
368
|
+
/**
|
|
369
|
+
* W755: the statusline of a session with NO live generation yet (the studio
|
|
370
|
+
* adapter's "empty one"). Nothing is measurable there — no provider frame, no
|
|
371
|
+
* engine assembly, no events — so it reports the honest `method:"none"` branch
|
|
372
|
+
* (`used:0`) instead of a made-up ratio. A FRESH [ContextPressure] is correct
|
|
373
|
+
* here: with no usage frame no prompt sample is ever observed through it.
|
|
374
|
+
*/
|
|
375
|
+
export function coldStatusline(input) {
|
|
376
|
+
return statuslineOf({
|
|
377
|
+
model: input.model,
|
|
378
|
+
reasoning_effort: input.reasoning_effort,
|
|
379
|
+
status: createStatusTracker(input.now ?? Date.now),
|
|
380
|
+
usage: createUsageTracker(),
|
|
381
|
+
context_window: input.context_window,
|
|
382
|
+
events: () => [],
|
|
383
|
+
assembled: () => null,
|
|
384
|
+
pressure: new ContextPressure(),
|
|
385
|
+
});
|
|
386
|
+
}
|
|
387
|
+
/** `statusline_of` — the frozen `/api/status` payload. */
|
|
388
|
+
export function statuslineOf(view) {
|
|
389
|
+
return {
|
|
390
|
+
model: view.model,
|
|
391
|
+
reasoning_effort: view.reasoning_effort,
|
|
392
|
+
steps: view.status.stepCount,
|
|
393
|
+
tokens_per_sec: Math.round(view.status.rate() * 100) / 100,
|
|
394
|
+
context_usage: contextUsage(view),
|
|
395
|
+
usage: usageStatus(view.usage),
|
|
396
|
+
};
|
|
397
|
+
}
|
|
398
|
+
function jsonLen(value) {
|
|
399
|
+
if (value === null || value === undefined)
|
|
400
|
+
return 0;
|
|
401
|
+
try {
|
|
402
|
+
return JSON.stringify(value)?.length ?? 0;
|
|
403
|
+
}
|
|
404
|
+
catch {
|
|
405
|
+
return 0;
|
|
406
|
+
}
|
|
407
|
+
}
|
|
408
|
+
/** Terminal outcome -> SSE status phase (the frozen vocabulary). */
|
|
409
|
+
export function outcomePhaseOf(outcome) {
|
|
410
|
+
return typeof outcome === "string" ? outcome : "error";
|
|
411
|
+
}
|