@code-yeongyu/senpi-agent-core 2026.9.16-2 → 2026.9.16-3

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.
@@ -1,178 +0,0 @@
1
- /**
2
- * Rate guard for an in-progress provider stream.
3
- *
4
- * Every other guard on a live stream is a SILENCE detector: the stream-start
5
- * bound stops applying once the first event arrived, and the inter-event idle
6
- * bound is re-armed by every event. A provider that keeps answering at ~2 tok/s
7
- * therefore trips nothing at all, while the session is unusable — the reported
8
- * `gpt-6-astra` symptom (#1739). This measures the RATE of streamed text and
9
- * thinking units so a trickle becomes a first-class, retryable failure instead
10
- * of a healthy-looking turn.
11
- *
12
- * Deliberately not a wall-clock turn budget: tool-using turns legitimately last
13
- * many minutes. Only time spent waiting on the provider counts, and time the
14
- * provider spends executing local work (Cursor's exec channel) is excluded.
15
- */
16
- /** Sustained floor in streamed units per second; `0` disables the watchdog. */
17
- export const DEFAULT_STREAM_THROUGHPUT_FLOOR_TOKENS_PER_SECOND = 8;
18
- /** Observation window; the verdict needs a full window of measured streaming. */
19
- export const DEFAULT_STREAM_THROUGHPUT_WINDOW_MS = 20_000;
20
- /** First-token jitter and a single long reasoning pause must not fire. */
21
- export const DEFAULT_STREAM_THROUGHPUT_GRACE_MS = 5_000;
22
- /**
23
- * Minimum streamed units inside the window before a rate is judged at all, so a
24
- * two-token heartbeat is never divided into a verdict.
25
- */
26
- export const STREAM_THROUGHPUT_MIN_UNITS = 16;
27
- /** Live rate needs this much measured streaming before it means anything. */
28
- const MIN_RATE_SAMPLE_MS = 1_000;
29
- /**
30
- * One unit approximates one token. Providers that emit a delta per token give
31
- * one unit per delta; gateways that batch several tokens into one delta are
32
- * measured by length instead of by event count, so batching cannot be mistaken
33
- * for a trickle.
34
- */
35
- export function estimateStreamedUnits(text) {
36
- if (!text)
37
- return 0;
38
- return Math.max(1, Math.ceil(text.length / 4));
39
- }
40
- function formatRate(value) {
41
- return Number.isInteger(value) ? String(value) : value.toFixed(1);
42
- }
43
- function formatWindowSeconds(windowMs) {
44
- const seconds = windowMs / 1000;
45
- return Number.isInteger(seconds) ? String(seconds) : seconds.toFixed(1);
46
- }
47
- /**
48
- * The wording is part of the contract: `packages/ai/src/utils/retry.ts`
49
- * classifies it as a retryable, throughput-degraded failure (distinct from the
50
- * silence stalls), and the session routes it straight to the fallback chain.
51
- */
52
- export function formatStreamThroughputDegradedMessage(tokensPerSecond, floorTokensPerSecond, windowMs) {
53
- return (`Provider stream throughput degraded: ${formatRate(tokensPerSecond)} tok/s over ` +
54
- `${formatWindowSeconds(windowMs)}s (floor ${formatRate(floorTokensPerSecond)} tok/s) ` +
55
- `(lower or disable with retry.provider.minThroughputTokensPerSecond in senpi settings; 0 disables)`);
56
- }
57
- export class StreamThroughputDegradedError extends Error {
58
- constructor(tokensPerSecond, floorTokensPerSecond, windowMs) {
59
- super(formatStreamThroughputDegradedMessage(tokensPerSecond, floorTokensPerSecond, windowMs));
60
- this.name = "StreamThroughputDegradedError";
61
- this.tokensPerSecond = tokensPerSecond;
62
- this.floorTokensPerSecond = floorTokensPerSecond;
63
- this.windowMs = windowMs;
64
- }
65
- }
66
- /**
67
- * Sliding-window counter of streamed units over the time actually spent
68
- * waiting on the provider. Shared by the watchdog and by the interactive
69
- * working status, so the rate a user sees is the rate that gets judged.
70
- */
71
- export class StreamRateMeter {
72
- constructor(windowMs, now = Date.now) {
73
- this.samples = [];
74
- this.unitsInWindow = 0;
75
- this.excludedMs = 0;
76
- this.windowMs = windowMs;
77
- this.now = now;
78
- }
79
- /** Wall clock minus the spans excluded from measurement; monotonic. */
80
- measuredNow() {
81
- return this.now() - this.excludedMs;
82
- }
83
- /** Measured time since the first stream event, or undefined before it. */
84
- measuredElapsedMs() {
85
- return this.originMs === undefined ? undefined : this.measuredNow() - this.originMs;
86
- }
87
- /** Anchor the measurement at the first stream event. Idempotent. */
88
- start() {
89
- if (this.originMs === undefined)
90
- this.originMs = this.measuredNow();
91
- }
92
- /** Drop a span of wall-clock time (provider-local tool work) from the measurement. */
93
- exclude(elapsedMs) {
94
- if (elapsedMs > 0)
95
- this.excludedMs += elapsedMs;
96
- }
97
- record(units) {
98
- if (units <= 0)
99
- return;
100
- this.start();
101
- const at = this.measuredNow();
102
- this.samples.push({ at, units });
103
- this.unitsInWindow += units;
104
- this.prune(at);
105
- }
106
- /** Streamed units inside the trailing window. */
107
- units() {
108
- this.prune(this.measuredNow());
109
- return this.unitsInWindow;
110
- }
111
- /**
112
- * Units per second over the trailing window, or undefined until enough
113
- * measured streaming exists for the number to mean anything.
114
- */
115
- ratePerSecond() {
116
- const elapsedMs = this.measuredElapsedMs();
117
- if (elapsedMs === undefined)
118
- return undefined;
119
- const spanMs = Math.min(this.windowMs, elapsedMs);
120
- if (spanMs < MIN_RATE_SAMPLE_MS)
121
- return undefined;
122
- const units = this.units();
123
- if (units <= 0)
124
- return 0;
125
- return units / (spanMs / 1000);
126
- }
127
- reset() {
128
- this.samples = [];
129
- this.unitsInWindow = 0;
130
- this.excludedMs = 0;
131
- this.originMs = undefined;
132
- }
133
- prune(at) {
134
- const cutoff = at - this.windowMs;
135
- let dropped = 0;
136
- while (dropped < this.samples.length && this.samples[dropped].at < cutoff) {
137
- this.unitsInWindow -= this.samples[dropped].units;
138
- dropped++;
139
- }
140
- if (dropped > 0)
141
- this.samples = this.samples.slice(dropped);
142
- }
143
- }
144
- /**
145
- * Returns undefined when the watchdog is disabled (a floor or window of `0`),
146
- * so the caller can skip the measurement entirely.
147
- */
148
- export function createStreamThroughputWatchdog(options, now = Date.now) {
149
- const floorTokensPerSecond = options?.floorTokensPerSecond ?? DEFAULT_STREAM_THROUGHPUT_FLOOR_TOKENS_PER_SECOND;
150
- const windowMs = options?.windowMs ?? DEFAULT_STREAM_THROUGHPUT_WINDOW_MS;
151
- const graceMs = Math.max(0, options?.graceMs ?? DEFAULT_STREAM_THROUGHPUT_GRACE_MS);
152
- if (!Number.isFinite(floorTokensPerSecond) || floorTokensPerSecond <= 0)
153
- return undefined;
154
- if (!Number.isFinite(windowMs) || windowMs <= 0)
155
- return undefined;
156
- const meter = new StreamRateMeter(windowMs, now);
157
- return {
158
- start: () => meter.start(),
159
- exclude: (elapsedMs) => meter.exclude(elapsedMs),
160
- ratePerSecond: () => meter.ratePerSecond(),
161
- record: (units) => {
162
- meter.record(units);
163
- const elapsedMs = meter.measuredElapsedMs();
164
- // Judge only on a full window of measured streaming that starts after
165
- // the grace period; anything earlier is jitter, not a sustained rate.
166
- if (elapsedMs === undefined || elapsedMs < graceMs + windowMs)
167
- return undefined;
168
- const unitsInWindow = meter.units();
169
- if (unitsInWindow < STREAM_THROUGHPUT_MIN_UNITS)
170
- return undefined;
171
- const tokensPerSecond = unitsInWindow / (windowMs / 1000);
172
- if (tokensPerSecond >= floorTokensPerSecond)
173
- return undefined;
174
- return new StreamThroughputDegradedError(tokensPerSecond, floorTokensPerSecond, windowMs);
175
- },
176
- };
177
- }
178
- //# sourceMappingURL=stream-throughput-watchdog.js.map
@@ -1 +0,0 @@
1
- {"version":3,"file":"stream-throughput-watchdog.js","sourceRoot":"","sources":["../src/stream-throughput-watchdog.ts"],"names":[],"mappings":"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","sourcesContent":["/**\n * Rate guard for an in-progress provider stream.\n *\n * Every other guard on a live stream is a SILENCE detector: the stream-start\n * bound stops applying once the first event arrived, and the inter-event idle\n * bound is re-armed by every event. A provider that keeps answering at ~2 tok/s\n * therefore trips nothing at all, while the session is unusable — the reported\n * `gpt-6-astra` symptom (#1739). This measures the RATE of streamed text and\n * thinking units so a trickle becomes a first-class, retryable failure instead\n * of a healthy-looking turn.\n *\n * Deliberately not a wall-clock turn budget: tool-using turns legitimately last\n * many minutes. Only time spent waiting on the provider counts, and time the\n * provider spends executing local work (Cursor's exec channel) is excluded.\n */\n\n/** Sustained floor in streamed units per second; `0` disables the watchdog. */\nexport const DEFAULT_STREAM_THROUGHPUT_FLOOR_TOKENS_PER_SECOND = 8;\n/** Observation window; the verdict needs a full window of measured streaming. */\nexport const DEFAULT_STREAM_THROUGHPUT_WINDOW_MS = 20_000;\n/** First-token jitter and a single long reasoning pause must not fire. */\nexport const DEFAULT_STREAM_THROUGHPUT_GRACE_MS = 5_000;\n/**\n * Minimum streamed units inside the window before a rate is judged at all, so a\n * two-token heartbeat is never divided into a verdict.\n */\nexport const STREAM_THROUGHPUT_MIN_UNITS = 16;\n/** Live rate needs this much measured streaming before it means anything. */\nconst MIN_RATE_SAMPLE_MS = 1_000;\n\nexport interface StreamThroughputOptions {\n\t/** Sustained floor in units per second; `0` or negative disables the watchdog. */\n\tfloorTokensPerSecond?: number;\n\t/** Observation window in milliseconds; `0` or negative disables the watchdog. */\n\twindowMs?: number;\n\t/** Milliseconds after the first stream event that are never measured. */\n\tgraceMs?: number;\n}\n\n/**\n * One unit approximates one token. Providers that emit a delta per token give\n * one unit per delta; gateways that batch several tokens into one delta are\n * measured by length instead of by event count, so batching cannot be mistaken\n * for a trickle.\n */\nexport function estimateStreamedUnits(text: string | undefined): number {\n\tif (!text) return 0;\n\treturn Math.max(1, Math.ceil(text.length / 4));\n}\n\nfunction formatRate(value: number): string {\n\treturn Number.isInteger(value) ? String(value) : value.toFixed(1);\n}\n\nfunction formatWindowSeconds(windowMs: number): string {\n\tconst seconds = windowMs / 1000;\n\treturn Number.isInteger(seconds) ? String(seconds) : seconds.toFixed(1);\n}\n\n/**\n * The wording is part of the contract: `packages/ai/src/utils/retry.ts`\n * classifies it as a retryable, throughput-degraded failure (distinct from the\n * silence stalls), and the session routes it straight to the fallback chain.\n */\nexport function formatStreamThroughputDegradedMessage(\n\ttokensPerSecond: number,\n\tfloorTokensPerSecond: number,\n\twindowMs: number,\n): string {\n\treturn (\n\t\t`Provider stream throughput degraded: ${formatRate(tokensPerSecond)} tok/s over ` +\n\t\t`${formatWindowSeconds(windowMs)}s (floor ${formatRate(floorTokensPerSecond)} tok/s) ` +\n\t\t`(lower or disable with retry.provider.minThroughputTokensPerSecond in senpi settings; 0 disables)`\n\t);\n}\n\nexport class StreamThroughputDegradedError extends Error {\n\treadonly tokensPerSecond: number;\n\treadonly floorTokensPerSecond: number;\n\treadonly windowMs: number;\n\n\tconstructor(tokensPerSecond: number, floorTokensPerSecond: number, windowMs: number) {\n\t\tsuper(formatStreamThroughputDegradedMessage(tokensPerSecond, floorTokensPerSecond, windowMs));\n\t\tthis.name = \"StreamThroughputDegradedError\";\n\t\tthis.tokensPerSecond = tokensPerSecond;\n\t\tthis.floorTokensPerSecond = floorTokensPerSecond;\n\t\tthis.windowMs = windowMs;\n\t}\n}\n\n/**\n * Sliding-window counter of streamed units over the time actually spent\n * waiting on the provider. Shared by the watchdog and by the interactive\n * working status, so the rate a user sees is the rate that gets judged.\n */\nexport class StreamRateMeter {\n\tprivate readonly windowMs: number;\n\tprivate readonly now: () => number;\n\tprivate samples: { at: number; units: number }[] = [];\n\tprivate unitsInWindow = 0;\n\tprivate excludedMs = 0;\n\tprivate originMs: number | undefined;\n\n\tconstructor(windowMs: number, now: () => number = Date.now) {\n\t\tthis.windowMs = windowMs;\n\t\tthis.now = now;\n\t}\n\n\t/** Wall clock minus the spans excluded from measurement; monotonic. */\n\tmeasuredNow(): number {\n\t\treturn this.now() - this.excludedMs;\n\t}\n\n\t/** Measured time since the first stream event, or undefined before it. */\n\tmeasuredElapsedMs(): number | undefined {\n\t\treturn this.originMs === undefined ? undefined : this.measuredNow() - this.originMs;\n\t}\n\n\t/** Anchor the measurement at the first stream event. Idempotent. */\n\tstart(): void {\n\t\tif (this.originMs === undefined) this.originMs = this.measuredNow();\n\t}\n\n\t/** Drop a span of wall-clock time (provider-local tool work) from the measurement. */\n\texclude(elapsedMs: number): void {\n\t\tif (elapsedMs > 0) this.excludedMs += elapsedMs;\n\t}\n\n\trecord(units: number): void {\n\t\tif (units <= 0) return;\n\t\tthis.start();\n\t\tconst at = this.measuredNow();\n\t\tthis.samples.push({ at, units });\n\t\tthis.unitsInWindow += units;\n\t\tthis.prune(at);\n\t}\n\n\t/** Streamed units inside the trailing window. */\n\tunits(): number {\n\t\tthis.prune(this.measuredNow());\n\t\treturn this.unitsInWindow;\n\t}\n\n\t/**\n\t * Units per second over the trailing window, or undefined until enough\n\t * measured streaming exists for the number to mean anything.\n\t */\n\tratePerSecond(): number | undefined {\n\t\tconst elapsedMs = this.measuredElapsedMs();\n\t\tif (elapsedMs === undefined) return undefined;\n\t\tconst spanMs = Math.min(this.windowMs, elapsedMs);\n\t\tif (spanMs < MIN_RATE_SAMPLE_MS) return undefined;\n\t\tconst units = this.units();\n\t\tif (units <= 0) return 0;\n\t\treturn units / (spanMs / 1000);\n\t}\n\n\treset(): void {\n\t\tthis.samples = [];\n\t\tthis.unitsInWindow = 0;\n\t\tthis.excludedMs = 0;\n\t\tthis.originMs = undefined;\n\t}\n\n\tprivate prune(at: number): void {\n\t\tconst cutoff = at - this.windowMs;\n\t\tlet dropped = 0;\n\t\twhile (dropped < this.samples.length && this.samples[dropped].at < cutoff) {\n\t\t\tthis.unitsInWindow -= this.samples[dropped].units;\n\t\t\tdropped++;\n\t\t}\n\t\tif (dropped > 0) this.samples = this.samples.slice(dropped);\n\t}\n}\n\nexport interface StreamThroughputWatchdog {\n\t/** Marks the first stream event; starts the grace clock. Idempotent. */\n\tstart(): void;\n\t/** Records streamed units and returns the verdict when the floor is breached. */\n\trecord(units: number): StreamThroughputDegradedError | undefined;\n\t/** Drops a span of wall-clock time (provider-local tool work) from the measurement. */\n\texclude(elapsedMs: number): void;\n\t/** Live rate over the window, or undefined while it is still meaningless. */\n\tratePerSecond(): number | undefined;\n}\n\n/**\n * Returns undefined when the watchdog is disabled (a floor or window of `0`),\n * so the caller can skip the measurement entirely.\n */\nexport function createStreamThroughputWatchdog(\n\toptions: StreamThroughputOptions | undefined,\n\tnow: () => number = Date.now,\n): StreamThroughputWatchdog | undefined {\n\tconst floorTokensPerSecond = options?.floorTokensPerSecond ?? DEFAULT_STREAM_THROUGHPUT_FLOOR_TOKENS_PER_SECOND;\n\tconst windowMs = options?.windowMs ?? DEFAULT_STREAM_THROUGHPUT_WINDOW_MS;\n\tconst graceMs = Math.max(0, options?.graceMs ?? DEFAULT_STREAM_THROUGHPUT_GRACE_MS);\n\tif (!Number.isFinite(floorTokensPerSecond) || floorTokensPerSecond <= 0) return undefined;\n\tif (!Number.isFinite(windowMs) || windowMs <= 0) return undefined;\n\n\tconst meter = new StreamRateMeter(windowMs, now);\n\treturn {\n\t\tstart: () => meter.start(),\n\t\texclude: (elapsedMs: number) => meter.exclude(elapsedMs),\n\t\tratePerSecond: () => meter.ratePerSecond(),\n\t\trecord: (units: number) => {\n\t\t\tmeter.record(units);\n\t\t\tconst elapsedMs = meter.measuredElapsedMs();\n\t\t\t// Judge only on a full window of measured streaming that starts after\n\t\t\t// the grace period; anything earlier is jitter, not a sustained rate.\n\t\t\tif (elapsedMs === undefined || elapsedMs < graceMs + windowMs) return undefined;\n\t\t\tconst unitsInWindow = meter.units();\n\t\t\tif (unitsInWindow < STREAM_THROUGHPUT_MIN_UNITS) return undefined;\n\t\t\tconst tokensPerSecond = unitsInWindow / (windowMs / 1000);\n\t\t\tif (tokensPerSecond >= floorTokensPerSecond) return undefined;\n\t\t\treturn new StreamThroughputDegradedError(tokensPerSecond, floorTokensPerSecond, windowMs);\n\t\t},\n\t};\n}\n"]}