switchroom 0.18.13 → 0.18.15

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.
Files changed (47) hide show
  1. package/dist/agent-scheduler/index.js +49 -9
  2. package/dist/auth-broker/index.js +152 -46
  3. package/dist/cli/autoaccept-poll.js +23 -0
  4. package/dist/cli/drive-write-pretool.mjs +24 -1
  5. package/dist/cli/foreground-hog-pretool.mjs +264 -0
  6. package/dist/cli/notion-write-pretool.mjs +0 -1
  7. package/dist/cli/switchroom.js +1185 -1072
  8. package/dist/host-control/main.js +53 -52
  9. package/dist/vault/approvals/kernel-server.js +16 -13
  10. package/dist/vault/broker/server.js +672 -669
  11. package/package.json +1 -1
  12. package/profiles/coding/CLAUDE.md.hbs +2 -0
  13. package/profiles/default/CLAUDE.md.hbs +2 -0
  14. package/skills/switchroom-architecture/telegram.md +0 -1
  15. package/telegram-plugin/auth-snapshot-format.ts +37 -5
  16. package/telegram-plugin/auto-fallback-fleet.ts +29 -1
  17. package/telegram-plugin/bridge/bridge.ts +2 -0
  18. package/telegram-plugin/dist/bridge/bridge.js +23 -0
  19. package/telegram-plugin/dist/gateway/gateway.js +765 -67
  20. package/telegram-plugin/dist/server.js +24 -1
  21. package/telegram-plugin/gateway/auth-broker-client.ts +1 -0
  22. package/telegram-plugin/gateway/auth-command.ts +14 -0
  23. package/telegram-plugin/gateway/forward-origin.ts +235 -0
  24. package/telegram-plugin/gateway/gateway.ts +270 -10
  25. package/telegram-plugin/gateway/throttle-tier-wiring.ts +268 -0
  26. package/telegram-plugin/history.ts +55 -6
  27. package/telegram-plugin/model-unavailable.ts +234 -2
  28. package/telegram-plugin/render/rich-render.ts +40 -32
  29. package/telegram-plugin/runtime-metrics.ts +31 -0
  30. package/telegram-plugin/session-tail.ts +14 -2
  31. package/telegram-plugin/stream-controller.ts +3 -2
  32. package/telegram-plugin/tests/auto-fallback-fleet.test.ts +72 -0
  33. package/telegram-plugin/tests/forward-origin.test.ts +309 -0
  34. package/telegram-plugin/tests/history.test.ts +157 -0
  35. package/telegram-plugin/tests/model-unavailable.test.ts +187 -0
  36. package/telegram-plugin/tests/operator-events-session-tail.test.ts +55 -0
  37. package/telegram-plugin/tests/render/render-outbound-chunks.test.ts +6 -4
  38. package/telegram-plugin/tests/render/rich-render.test.ts +41 -22
  39. package/telegram-plugin/tests/runtime-metrics.test.ts +24 -0
  40. package/telegram-plugin/tests/single-mode-stream-reply.test.ts +5 -3
  41. package/telegram-plugin/tests/status-accent.test.ts +5 -3
  42. package/telegram-plugin/tests/stream-controller-chunk-cap.test.ts +20 -20
  43. package/telegram-plugin/tests/stream-reply-handler.test.ts +5 -2
  44. package/telegram-plugin/tests/throttle-tier-wiring.test.ts +290 -0
  45. package/telegram-plugin/tests/throttle-tier.test.ts +454 -0
  46. package/telegram-plugin/throttle-tier.ts +323 -0
  47. package/telegram-plugin/uat/scenarios/jtbd-rich-formatting-render-dm.test.ts +8 -7
@@ -0,0 +1,323 @@
1
+ /**
2
+ * throttle-tier.ts — the 429 throttle tier (pure decision + notice rendering).
3
+ *
4
+ * Operator-approved behavior spec: "retry in place under 5 min, else mark +
5
+ * failover, honest reset messaging."
6
+ *
7
+ * A terminal TRANSIENT 429 (a `rate_limit_error` whose wording explicitly
8
+ * negates the account-quota reading — see `transientUpstreamSignals` in
9
+ * model-unavailable.ts) used to take the fully calm path: no broker state, no
10
+ * account attribution, a generic "🚦 Rate limited" card. That is right for a
11
+ * few-second burst but wrong for the minutes-long per-account throttles
12
+ * Anthropic emits with a "resets 8:50am (TZ)" hint: the fleet has no memory
13
+ * that the account is throttled (cron fires walk straight into the same 429)
14
+ * and the operator can't tell a throttle from a wall.
15
+ *
16
+ * This module owns the DECISION and the NOTICE TEXT; the gateway wires the
17
+ * side effects (broker `mark-throttled`, the Telegram send, the delayed
18
+ * retry nudge):
19
+ *
20
+ * - reset parseable and ≤ threshold (default 5 min) → `throttle`: stay on
21
+ * the account, record `throttled_until` broker-side, notify once.
22
+ * - reset parseable and > threshold → `failover`: escalate to the existing
23
+ * mark-exhausted + fleet-failover machinery with the parsed reset as the
24
+ * mark expiry.
25
+ * - reset unparseable → `throttle` with a conservative now+60s wait.
26
+ *
27
+ * Wall wording ("You've hit your limit", out_of_credits) never reaches this
28
+ * module — session-tail classifies those `quota-exhausted` and the gateway's
29
+ * existing failover path handles them unchanged.
30
+ *
31
+ * PURE — no IPC, no bot, no clock except the injected `now`.
32
+ */
33
+
34
+ import { escapeMarkdown } from './card-format.js'
35
+ import { formatResetRelative } from './quota-check.js'
36
+ import {
37
+ isLitellmProxyLocal429,
38
+ parseLitellmLimitDetail,
39
+ parseResetTime,
40
+ } from './model-unavailable.js'
41
+ import type { RuntimeMetricEvent } from './runtime-metrics.js'
42
+
43
+ // ─── Account-scoped throttle wording ─────────────────────────────────────────
44
+
45
+ /**
46
+ * ACCOUNT-AFFIRMING subset of `transientUpstreamSignals` (model-unavailable.ts).
47
+ * The throttle tier takes ACCOUNT-level actions — broker `mark-throttled`,
48
+ * per-account notice, retry nudge — so it must key on wording that affirms the
49
+ * account's OWN rate limit ("would exceed your account's rate limit",
50
+ * "not your account…"), NOT the server-side phrasings ("Server is temporarily
51
+ * limiting requests (not your usage limit)", 529 overload wording). A
52
+ * server-wide condition recorded as an account throttle would bench the wrong
53
+ * thing and restart-nudge an agent straight back into the same server-side
54
+ * wall. Server-side transients keep the existing calm rate-limited path
55
+ * (Claude Code's internal retry) untouched.
56
+ */
57
+ export const accountScopedThrottleSignals = [
58
+ "would exceed your account's rate limit",
59
+ 'would exceed your account’s rate limit',
60
+ // Covers both "not your account" and "not your account's" (substring).
61
+ 'not your account',
62
+ ]
63
+
64
+ /** True when `text` carries an EXPLICIT account-affirming throttle marker.
65
+ * Never throws on weird input. */
66
+ export function isAccountScopedThrottle(text: string): boolean {
67
+ if (typeof text !== 'string' || text.length === 0) return false
68
+ const sample = text.length > 16_384 ? text.slice(0, 16_384) : text
69
+ const lower = sample.toLowerCase()
70
+ return accountScopedThrottleSignals.some((s) => lower.includes(s))
71
+ }
72
+
73
+ // ─── Three-way 429 classification ────────────────────────────────────────────
74
+
75
+ /**
76
+ * Where a terminal 429-family failure originated:
77
+ * - `account-scoped` — Anthropic throttled THIS account ("would exceed
78
+ * your account's rate limit"). Eligible for the throttle tier below
79
+ * (broker mark-throttled / failover).
80
+ * - `litellm-local` — the LiteLLM proxy's OWN limiter tripped
81
+ * (`tpm_limit`/`rpm_limit` cap, router cooldown — see
82
+ * `litellmProxyLocal429Signals` in model-unavailable.ts). The request
83
+ * never reached Anthropic; account state must not be touched.
84
+ * - `generic-transient` — everything else in the rate-limit family
85
+ * (server-side 429/529 wording, bare `rate_limit_error`). Calm path.
86
+ */
87
+ export type RateLimit429Classification =
88
+ | 'account-scoped'
89
+ | 'litellm-local'
90
+ | 'generic-transient'
91
+
92
+ /**
93
+ * Classify a terminal `rate-limited` operator event's detail text.
94
+ *
95
+ * TIE-BREAK (both wordings present): account-scoped wins, and only on its
96
+ * EXPLICIT wording. Rationale: LiteLLM never emits the account-affirming
97
+ * strings itself, so when they co-occur with LiteLLM wording the detail is a
98
+ * genuine upstream Anthropic account 429 that traversed (and was wrapped by)
99
+ * the proxy — e.g. the pass-through's "litellm.RateLimitError: …would exceed
100
+ * your account's rate limit…" exception mapping. Classifying that as
101
+ * proxy-local would drop the broker throttle mark and walk every retry
102
+ * straight back into the same account throttle. The reverse risk is nil: a
103
+ * purely proxy-local 429 (limiter fired BEFORE any upstream call) cannot
104
+ * contain Anthropic's account wording.
105
+ *
106
+ * BOUND: operator events forwarded over IPC carry `detail` truncated to
107
+ * 1000 chars (bridge.ts `sendOperatorEvent`'s `.slice(0, 1000)`;
108
+ * `OPERATOR_EVENT_DETAIL_MAX` in gateway/ipc-server.ts), so on that path the
109
+ * tie-break sees only the first 1000 chars of the body. In practice both
110
+ * wordings sit well inside that window (real Anthropic and LiteLLM bodies
111
+ * are <400 chars), and a truncated-away account marker would merely
112
+ * downgrade account-scoped → litellm-local/generic-transient — the calm
113
+ * path, never a wrong account mark.
114
+ *
115
+ * Never throws on weird input (both matchers are total).
116
+ */
117
+ export function classify429Detail(text: string): RateLimit429Classification {
118
+ if (isAccountScopedThrottle(text)) return 'account-scoped'
119
+ if (isLitellmProxyLocal429(text)) return 'litellm-local'
120
+ return 'generic-transient'
121
+ }
122
+
123
+ /**
124
+ * Build the `rate_limit_429_classified` runtime metric for one terminal
125
+ * rate-limited operator event — the instrumentation that lets an operator
126
+ * correlate Anthropic ACCOUNT 429s with fleet TPM (the prerequisite for
127
+ * enabling LiteLLM `tpm_limit` caps; see docs/auth.md § LiteLLM-proxy-local
128
+ * 429s). Pure builder so the payload shape is unit-testable; the gateway
129
+ * emits the result via emitRuntimeMetric (PostHog + JSONL dual sink).
130
+ *
131
+ * `action` is what the gateway decided for this event:
132
+ * - `throttle` / `failover` — the account-scoped throttle tier's decision
133
+ * - `calm` — the existing calm rate-limited path (litellm-local and
134
+ * generic-transient always land here; no broker mark, no failover)
135
+ *
136
+ * Reset detail is best-effort: the Anthropic-shaped `parseResetTime` first,
137
+ * then LiteLLM's own shapes ("Limit resets at: … UTC" / "Try again in Ns")
138
+ * via `parseLitellmLimitDetail`. Limit fields parse only from LiteLLM
139
+ * wording (Anthropic bodies carry no numeric limit).
140
+ */
141
+ export function build429ClassifiedMetric(opts: {
142
+ agent: string
143
+ detail: string
144
+ classification: RateLimit429Classification
145
+ action: 'throttle' | 'failover' | 'calm'
146
+ now: number
147
+ }): Extract<RuntimeMetricEvent, { kind: 'rate_limit_429_classified' }> {
148
+ const detail = typeof opts.detail === 'string' ? opts.detail : ''
149
+ const litellm = parseLitellmLimitDetail(detail, new Date(opts.now))
150
+ const anthropicResetMs = parseResetTime(detail, new Date(opts.now))?.getTime() ?? null
151
+ const resetAtMs = anthropicResetMs ?? litellm.resetAtMs
152
+ return {
153
+ kind: 'rate_limit_429_classified',
154
+ agent: opts.agent,
155
+ classification: opts.classification,
156
+ action: opts.action,
157
+ reset_at_ms: resetAtMs,
158
+ reset_in_ms: resetAtMs != null ? Math.max(0, resetAtMs - opts.now) : null,
159
+ limit_type: litellm.limitType,
160
+ limit: litellm.limit,
161
+ current_usage: litellm.currentUsage,
162
+ }
163
+ }
164
+
165
+ /**
166
+ * Default retry-in-place ceiling: a transient 429 whose reset is within this
167
+ * window waits on the account instead of failing the fleet over. Override
168
+ * with SWITCHROOM_THROTTLE_RETRY_IN_PLACE_MAX_MS.
169
+ */
170
+ export const THROTTLE_RETRY_IN_PLACE_MAX_MS_DEFAULT = 5 * 60_000
171
+
172
+ /**
173
+ * Wait applied when a transient 429 carries NO parseable reset. Anthropic's
174
+ * burst throttles clear in seconds; 60s is comfortably past the common case
175
+ * without benching the account for long on a misread.
176
+ */
177
+ export const THROTTLE_DEFAULT_WAIT_MS = 60_000
178
+
179
+ /** Resolve the retry-in-place threshold from env (ms), else the default. */
180
+ export function throttleRetryInPlaceMaxMs(
181
+ env: NodeJS.ProcessEnv = process.env,
182
+ ): number {
183
+ const raw = env.SWITCHROOM_THROTTLE_RETRY_IN_PLACE_MAX_MS
184
+ if (raw == null || raw === '') return THROTTLE_RETRY_IN_PLACE_MAX_MS_DEFAULT
185
+ const n = Number(raw)
186
+ return Number.isFinite(n) && n > 0 ? n : THROTTLE_RETRY_IN_PLACE_MAX_MS_DEFAULT
187
+ }
188
+
189
+ export type ThrottleTierDecision =
190
+ /** Not a transient per-account 429 — caller falls through to its
191
+ * existing handling (calm card / quota path). */
192
+ | { action: 'none' }
193
+ /** Stay on the account; record throttled_until broker-side; notify once. */
194
+ | { action: 'throttle'; throttledUntilMs: number; resetParsed: boolean }
195
+ /** Reset is beyond the retry-in-place threshold — escalate to the
196
+ * existing mark-exhausted + fleet-failover machinery. */
197
+ | { action: 'failover'; resetAtMs: number }
198
+
199
+ /**
200
+ * THE throttle-tier decision for a terminal `rate-limited` operator event.
201
+ * `detail` is the user-facing error text from the transcript (the source of
202
+ * both the transient-negation wording and the "resets …" prose).
203
+ */
204
+ export function decideThrottleTier(opts: {
205
+ detail: string
206
+ now: number
207
+ /** Retry-in-place ceiling (ms). Callers pass throttleRetryInPlaceMaxMs(). */
208
+ thresholdMs: number
209
+ }): ThrottleTierDecision {
210
+ const { detail, now, thresholdMs } = opts
211
+ // Account-affirming wording ONLY — the wider transient list includes
212
+ // server-side phrasings (529 / "server is temporarily limiting requests")
213
+ // for which an account-scoped throttle would be the wrong action.
214
+ if (!isAccountScopedThrottle(detail)) return { action: 'none' }
215
+ const resetAt = parseResetTime(detail, new Date(now))
216
+ const resetAtMs = resetAt?.getTime()
217
+ if (resetAtMs == null || !Number.isFinite(resetAtMs) || resetAtMs <= now) {
218
+ // No usable reset — wait the conservative default in place.
219
+ return {
220
+ action: 'throttle',
221
+ throttledUntilMs: now + THROTTLE_DEFAULT_WAIT_MS,
222
+ resetParsed: false,
223
+ }
224
+ }
225
+ if (resetAtMs - now <= thresholdMs) {
226
+ return { action: 'throttle', throttledUntilMs: resetAtMs, resetParsed: true }
227
+ }
228
+ return { action: 'failover', resetAtMs }
229
+ }
230
+
231
+ // ─── Per-account notice cooldown ─────────────────────────────────────────────
232
+
233
+ /**
234
+ * Cooldown for the lightweight throttle notice, PER ACCOUNT. A burst of
235
+ * transient 429s (session-tail forwards every terminal error line) must
236
+ * produce ONE notice, not a stream — same shape and rationale as the
237
+ * failure-notice / all-blocked cooldowns in auto-fallback-fleet.ts. Keyed by
238
+ * account (not agent): the throttle is account-scoped, so every agent riding
239
+ * the same throttled account shares one window.
240
+ */
241
+ export const THROTTLE_NOTICE_COOLDOWN_MS = 10 * 60_000
242
+
243
+ export interface ThrottleNoticeState {
244
+ /** account label → unix ms of the last notice sent for it. */
245
+ lastSentAtMsByAccount: Record<string, number>
246
+ }
247
+
248
+ export function evaluateThrottleNotice(
249
+ prev: ThrottleNoticeState,
250
+ account: string,
251
+ now: number,
252
+ cooldownMs: number = THROTTLE_NOTICE_COOLDOWN_MS,
253
+ ): { send: boolean; next: ThrottleNoticeState } {
254
+ const last = prev.lastSentAtMsByAccount[account] ?? 0
255
+ if (now - last >= cooldownMs) {
256
+ return {
257
+ send: true,
258
+ next: {
259
+ lastSentAtMsByAccount: {
260
+ ...prev.lastSentAtMsByAccount,
261
+ [account]: now,
262
+ },
263
+ },
264
+ }
265
+ }
266
+ return { send: false, next: prev }
267
+ }
268
+
269
+ // ─── Notice rendering ────────────────────────────────────────────────────────
270
+
271
+ /**
272
+ * The ONE lightweight operator notice for the throttle path. Deliberately not
273
+ * the ⚠️ model-unavailable card: nothing is exhausted and nothing failed
274
+ * over — the point is to say so, name the account, and name the reset.
275
+ */
276
+ export function renderThrottleNotice(opts: {
277
+ /** Account label from the broker's mark-throttled response; null when the
278
+ * broker was unreachable and the account could not be attributed. */
279
+ account: string | null
280
+ agent: string
281
+ throttledUntilMs: number
282
+ /** True when the reset came from parsed "resets …" prose (vs the 60s default). */
283
+ resetParsed: boolean
284
+ now?: Date
285
+ }): string {
286
+ const now = opts.now ?? new Date()
287
+ // "resets in 3m" — same countdown dialect /usage speaks.
288
+ const resetStr = formatResetRelative(new Date(opts.throttledUntilMs), now)
289
+ const acct = opts.account ? `\`${escapeMarkdown(opts.account)}\`` : 'the active account'
290
+ const lines = [
291
+ `🚦 **Rate-limited, staying put** — ${acct} hit a transient rate limit on **${escapeMarkdown(opts.agent)}**.`,
292
+ `This is a short throttle, not a quota wall — ${
293
+ opts.resetParsed ? resetStr : `no reset given, retrying in ~60s`
294
+ }.`,
295
+ `_Staying on ${acct}; no failover needed. The turn retries automatically after the reset._`,
296
+ ]
297
+ return lines.join('\n')
298
+ }
299
+
300
+ /**
301
+ * Notice for the broker's ESCALATION outcome: repeated transient 429s on one
302
+ * account were corroborated by a live probe as a genuine wall, so the broker
303
+ * ran the standard mark-exhausted + roll. The gateway that raised the
304
+ * mark-throttled announces it (the reactive-path doctrine — same reason the
305
+ * plain mark-exhausted path announces gateway-side rather than via
306
+ * `last_fleet_roll`), which also covers PINNED (non-fleet-active) accounts.
307
+ */
308
+ export function renderThrottleEscalationNotice(opts: {
309
+ account: string | null
310
+ agent: string
311
+ /** The account the fleet/agents rolled to; null = every fallback blocked. */
312
+ rolledTo: string | null
313
+ }): string {
314
+ const acct = opts.account ? `\`${escapeMarkdown(opts.account)}\`` : 'the active account'
315
+ const head =
316
+ `⛔️ **Rate limit was actually a wall** — repeated 429s on ${acct} ` +
317
+ `(trigger: **${escapeMarkdown(opts.agent)}**) were corroborated by a live quota probe.`
318
+ const tail = opts.rolledTo
319
+ ? `Marked exhausted and rolled to \`${escapeMarkdown(opts.rolledTo)}\`.`
320
+ : `Marked exhausted — no fallback account had quota (all blocked). ` +
321
+ `Use \`/auth add <label>\` to attach another subscription.`
322
+ return `${head}\n${tail}`
323
+ }
@@ -61,13 +61,14 @@ import { richRenderEnabled } from "../../render/rich-render.js";
61
61
  const AGENT = "test-harness";
62
62
 
63
63
  // The rich-render wiring (parse -> IR -> renderSafe -> sendRichMessage) is
64
- // gated behind `SWITCHROOM_RICH_RENDER` (default OFF). The expandable /
65
- // collapsible round-trip proof below only runs when BOTH the driver creds AND
66
- // the flag are present the flag must be set in the gateway process under
67
- // test for the renderer to actually shape the outbound message, so gating the
68
- // scenario on the same flag keeps it honest (no false green when the wiring
69
- // isn't live). When the flag is off, this scenario self-skips green exactly
70
- // like the credential-less case.
64
+ // ON BY DEFAULT, with `SWITCHROOM_RICH_RENDER=0` as the escape hatch. The
65
+ // expandable / collapsible round-trip proof below only runs when the driver
66
+ // creds are present AND the renderer hasn't been killed off the same
67
+ // setting must hold in the gateway process under test for the renderer to
68
+ // actually shape the outbound message, so gating the scenario on the same
69
+ // check keeps it honest (no false green when the wiring isn't live). When
70
+ // the kill-switch is set, this scenario self-skips green exactly like the
71
+ // credential-less case.
71
72
  const RICH_RENDER_ON = richRenderEnabled();
72
73
 
73
74
  // The driver session is the load-bearing credential. Absent it, spinUp()