switchroom 0.18.17 → 0.18.18
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/dist/agent-scheduler/index.js +13 -0
- package/dist/auth-broker/index.js +13 -0
- package/dist/cli/notion-write-pretool.mjs +13 -0
- package/dist/cli/switchroom.js +605 -479
- package/dist/host-control/main.js +17 -1
- package/dist/vault/approvals/kernel-server.js +13 -0
- package/dist/vault/broker/server.js +13 -0
- package/package.json +1 -1
- package/telegram-plugin/bridge/bridge.ts +7 -1
- package/telegram-plugin/dist/bridge/bridge.js +26 -1
- package/telegram-plugin/dist/gateway/gateway.js +1401 -431
- package/telegram-plugin/dist/server.js +26 -1
- package/telegram-plugin/fleet-fallback-resume.ts +26 -3
- package/telegram-plugin/gateway/approval-hold.ts +49 -0
- package/telegram-plugin/gateway/bridge-dead-watchdog.ts +61 -18
- package/telegram-plugin/gateway/gateway.ts +362 -71
- package/telegram-plugin/gateway/linear-activity.ts +20 -4
- package/telegram-plugin/gateway/premium-recovery-wiring.ts +122 -0
- package/telegram-plugin/gateway/session-model-file.ts +103 -0
- package/telegram-plugin/gateway/tier-downgrade-wiring.ts +121 -0
- package/telegram-plugin/gateway/unhandled-rejection-policy.ts +14 -1
- package/telegram-plugin/llm-error-present.ts +436 -0
- package/telegram-plugin/operator-events.ts +7 -1
- package/telegram-plugin/permission-title.ts +172 -10
- package/telegram-plugin/premium-recovery.ts +101 -0
- package/telegram-plugin/raw-error-scrub.ts +73 -0
- package/telegram-plugin/retry-api-call.ts +8 -2
- package/telegram-plugin/send-gate-degraded.test.ts +152 -1
- package/telegram-plugin/send-gate-observability.test.ts +140 -0
- package/telegram-plugin/send-gate-observability.ts +65 -20
- package/telegram-plugin/send-gate.test.ts +143 -1
- package/telegram-plugin/send-gate.ts +212 -19
- package/telegram-plugin/session-tail.ts +16 -0
- package/telegram-plugin/shared/local-time.ts +69 -0
- package/telegram-plugin/tests/approval-hold-harness.ts +6 -6
- package/telegram-plugin/tests/approval-hold-outcome.test.ts +10 -2
- package/telegram-plugin/tests/bridge-dead-watchdog.test.ts +61 -0
- package/telegram-plugin/tests/fleet-fallback-resume.test.ts +39 -0
- package/telegram-plugin/tests/flood-windows-persistence.test.ts +3 -2
- package/telegram-plugin/tests/linear-create-issue.test.ts +30 -2
- package/telegram-plugin/tests/llm-error-present.test.ts +380 -0
- package/telegram-plugin/tests/permission-title.test.ts +167 -4
- package/telegram-plugin/tests/premium-recovery-wiring.test.ts +150 -0
- package/telegram-plugin/tests/premium-recovery.test.ts +165 -0
- package/telegram-plugin/tests/reaction-gate-routing.test.ts +6 -1
- package/telegram-plugin/tests/retry-api-call.test.ts +21 -0
- package/telegram-plugin/tests/tier-downgrade-wiring.test.ts +165 -0
- package/telegram-plugin/tests/tier-downgrade.test.ts +141 -0
- package/telegram-plugin/tests/unhandled-rejection-policy.test.ts +27 -1
- package/telegram-plugin/tests/worker-activity-feed.test.ts +5 -2
- package/telegram-plugin/tests/worker-feed-coalesce.test.ts +492 -0
- package/telegram-plugin/tier-downgrade.ts +198 -0
- package/telegram-plugin/tool-activity-summary.ts +99 -0
- package/telegram-plugin/worker-activity-feed.ts +509 -409
|
@@ -0,0 +1,436 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* llm-error-present.ts — humanized, cross-surface-deduped presentation of an
|
|
3
|
+
* LLM/API error, so a raw `b'{"type":"error",…}'` line never reaches a user.
|
|
4
|
+
*
|
|
5
|
+
* THE PROBLEM this closes
|
|
6
|
+
* -----------------------
|
|
7
|
+
* One Anthropic error JSONL line fans out to THREE independent render surfaces,
|
|
8
|
+
* none consulting each other, each historically printing the raw `detail`
|
|
9
|
+
* string with its `b'{…}'` byte-blob attached:
|
|
10
|
+
* 1. the agent turn-end "done" card (tool-activity-summary result block),
|
|
11
|
+
* 2. the operator-event "🚦 Rate limited" card (raw `detail`), and
|
|
12
|
+
* 3. the reply/answer passthrough (the synthetic-assistant error text relayed
|
|
13
|
+
* as the turn's answer).
|
|
14
|
+
*
|
|
15
|
+
* This module owns the ONE clean rendering + the ONE dedup authority the three
|
|
16
|
+
* surfaces consult, so a fan-out produces EXACTLY ONE user-facing message with
|
|
17
|
+
* NO raw JSON. It reuses the existing classifiers rather than re-deriving them:
|
|
18
|
+
* `classify429Detail` (throttle-tier.ts), `detectModelUnavailable` /
|
|
19
|
+
* `parseResetTime` (model-unavailable.ts), `isLitellmProxyLocal429` /
|
|
20
|
+
* `isTransientUpstreamSignal` (model-unavailable.ts), `classifyClaudeError`
|
|
21
|
+
* (operator-events.ts). `coreText` is ALWAYS built from a per-kind template —
|
|
22
|
+
* never from the raw `detail` — and `stripRawErrorBytes` is the belt-and-braces
|
|
23
|
+
* scrub so even the enrichment `reason` can carry no JSON.
|
|
24
|
+
*
|
|
25
|
+
* Pure module: no IPC, no bot, no FS. The only mutable state is the
|
|
26
|
+
* `ErrorPresenceGate` singleton (an in-memory dedup ledger), driven by an
|
|
27
|
+
* injectable `now`.
|
|
28
|
+
*/
|
|
29
|
+
|
|
30
|
+
import {
|
|
31
|
+
detectModelUnavailable,
|
|
32
|
+
isLitellmProxyLocal429,
|
|
33
|
+
parseResetTime,
|
|
34
|
+
} from './model-unavailable.js'
|
|
35
|
+
import { classify429Detail } from './throttle-tier.js'
|
|
36
|
+
import { classifyClaudeError } from './operator-events.js'
|
|
37
|
+
import type { InlineKeyboardMarkup } from './operator-events.js'
|
|
38
|
+
import { stripRawErrorBytes, extractRequestId } from './raw-error-scrub.js'
|
|
39
|
+
import { fmtLocalClock, tzAbbrev } from './shared/local-time.js'
|
|
40
|
+
|
|
41
|
+
export { stripRawErrorBytes, extractRequestId } from './raw-error-scrub.js'
|
|
42
|
+
|
|
43
|
+
// ─── Types ──────────────────────────────────────────────────────────────────
|
|
44
|
+
|
|
45
|
+
export type LlmErrorKind =
|
|
46
|
+
| 'rate_limit'
|
|
47
|
+
| 'overload_529'
|
|
48
|
+
| 'quota_wall'
|
|
49
|
+
| 'auth'
|
|
50
|
+
| 'transient'
|
|
51
|
+
| 'unknown'
|
|
52
|
+
|
|
53
|
+
export type LlmErrorSource = 'anthropic' | 'litellm-local' | 'network'
|
|
54
|
+
|
|
55
|
+
export interface ParsedLlmError {
|
|
56
|
+
kind: LlmErrorKind
|
|
57
|
+
/** Human, JSON-STRIPPED one-liner built from a per-kind template. Never raw. */
|
|
58
|
+
coreText: string
|
|
59
|
+
/** Parsed reset instant, when the source carried one. */
|
|
60
|
+
resetAt?: Date
|
|
61
|
+
/** Parsed retry-after window in ms, when the source carried one. */
|
|
62
|
+
retryAfterMs?: number
|
|
63
|
+
/** Resolved model id, when the source named one. */
|
|
64
|
+
model?: string
|
|
65
|
+
/** Anthropic `request_id`, when present — the strongest dedup key. */
|
|
66
|
+
requestId?: string
|
|
67
|
+
source: LlmErrorSource
|
|
68
|
+
/** True when the harness is still retrying this error internally (mid-retry). */
|
|
69
|
+
autoRetrying: boolean
|
|
70
|
+
/** True when the failure is final (NOT an in-flight retry). */
|
|
71
|
+
terminal: boolean
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
/** Optional retry-state annotations Claude Code stamps on a retried error line. */
|
|
75
|
+
export interface LlmErrorRetryState {
|
|
76
|
+
retryAttempt: number | null
|
|
77
|
+
maxRetries: number | null
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
// ─── model extraction ────────────────────────────────────────────────────────
|
|
81
|
+
|
|
82
|
+
/** Pull a resolved model id (`claude-…`, `sr-…`) out of a raw string, or undefined. */
|
|
83
|
+
function extractModel(raw: string): string | undefined {
|
|
84
|
+
const m = raw.match(/["']?model["']?\s*[=:]\s*["']?((?:claude|sr)[A-Za-z0-9._-]+)/i)
|
|
85
|
+
return m ? m[1] : undefined
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
// ─── Parser ──────────────────────────────────────────────────────────────────
|
|
89
|
+
|
|
90
|
+
const TRANSIENT_KINDS: ReadonlySet<LlmErrorKind> = new Set<LlmErrorKind>([
|
|
91
|
+
'rate_limit',
|
|
92
|
+
'overload_529',
|
|
93
|
+
'transient',
|
|
94
|
+
])
|
|
95
|
+
|
|
96
|
+
/** The always-actionable kinds — NEVER silenced, even inside a collapse window. */
|
|
97
|
+
export function isActionableKind(kind: LlmErrorKind): boolean {
|
|
98
|
+
return kind === 'auth' || kind === 'quota_wall'
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
/**
|
|
102
|
+
* Parse a raw error string (a session-tail `detail`, an isApiErrorMessage
|
|
103
|
+
* text, or a raw stderr line) into a structured, JSON-free ParsedLlmError.
|
|
104
|
+
* Reuses the existing wording classifiers; maps their kinds into this union.
|
|
105
|
+
*/
|
|
106
|
+
export function parseLlmError(
|
|
107
|
+
raw: string,
|
|
108
|
+
retryState?: LlmErrorRetryState,
|
|
109
|
+
): ParsedLlmError {
|
|
110
|
+
const text = typeof raw === 'string' ? raw : ''
|
|
111
|
+
const requestId = extractRequestId(text)
|
|
112
|
+
const model = extractModel(text)
|
|
113
|
+
|
|
114
|
+
// Reset / retry-after best-effort (Anthropic + relative wordings).
|
|
115
|
+
const resetAt = parseResetTime(text)
|
|
116
|
+
const retryAfterMs =
|
|
117
|
+
resetAt != null ? Math.max(0, resetAt.getTime() - Date.now()) : undefined
|
|
118
|
+
|
|
119
|
+
const { kind, source } = classifyKindAndSource(text)
|
|
120
|
+
|
|
121
|
+
// Retry / terminal semantics. Only the transient family can be mid-retry;
|
|
122
|
+
// auth / quota_wall / model_unavailable are terminal by construction.
|
|
123
|
+
let autoRetrying = false
|
|
124
|
+
let terminal = true
|
|
125
|
+
if (TRANSIENT_KINDS.has(kind)) {
|
|
126
|
+
const { retryAttempt, maxRetries } = retryState ?? { retryAttempt: null, maxRetries: null }
|
|
127
|
+
if (retryAttempt != null && maxRetries != null) {
|
|
128
|
+
autoRetrying = retryAttempt < maxRetries
|
|
129
|
+
terminal = retryAttempt >= maxRetries
|
|
130
|
+
} else {
|
|
131
|
+
// No retry annotation: treat as a surfaced (terminal) failure — Claude
|
|
132
|
+
// writes the user-facing error shape only after its own retries are done.
|
|
133
|
+
autoRetrying = false
|
|
134
|
+
terminal = true
|
|
135
|
+
}
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
return {
|
|
139
|
+
kind,
|
|
140
|
+
coreText: buildCoreText(kind, source),
|
|
141
|
+
...(resetAt != null ? { resetAt } : {}),
|
|
142
|
+
...(retryAfterMs != null ? { retryAfterMs } : {}),
|
|
143
|
+
...(model != null ? { model } : {}),
|
|
144
|
+
...(requestId != null ? { requestId } : {}),
|
|
145
|
+
source,
|
|
146
|
+
autoRetrying,
|
|
147
|
+
terminal,
|
|
148
|
+
}
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
function classifyKindAndSource(text: string): { kind: LlmErrorKind; source: LlmErrorSource } {
|
|
152
|
+
const lower = text.toLowerCase()
|
|
153
|
+
|
|
154
|
+
// 1. Auth — always terminal, always actionable.
|
|
155
|
+
const claudeKind = classifyClaudeError({ message: text, type: text })
|
|
156
|
+
if (claudeKind === 'credentials-expired' || claudeKind === 'credentials-invalid') {
|
|
157
|
+
return { kind: 'auth', source: 'anthropic' }
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
// 2. LiteLLM-proxy-LOCAL 429 — the proxy's own limiter, never Anthropic.
|
|
161
|
+
// Checked before the generic quota/overload matchers because some LiteLLM
|
|
162
|
+
// bodies contain the word "limit" that a quota matcher could seize on.
|
|
163
|
+
if (isLitellmProxyLocal429(text)) {
|
|
164
|
+
return { kind: 'rate_limit', source: 'litellm-local' }
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
// 3. Model-unavailable detector owns quota-wall / overload / network wording.
|
|
168
|
+
const mu = detectModelUnavailable(text)
|
|
169
|
+
if (mu != null) {
|
|
170
|
+
if (mu.kind === 'quota_exhausted') return { kind: 'quota_wall', source: 'anthropic' }
|
|
171
|
+
if (mu.kind === 'network') return { kind: 'transient', source: 'network' }
|
|
172
|
+
// mu.kind === 'overload' | 'rate_limited' — split 529 overload from a 429.
|
|
173
|
+
if (
|
|
174
|
+
lower.includes('529') ||
|
|
175
|
+
lower.includes('overloaded')
|
|
176
|
+
) {
|
|
177
|
+
return { kind: 'overload_529', source: 'anthropic' }
|
|
178
|
+
}
|
|
179
|
+
// A 429-family transient. Three-way classify picks the source nuance; all
|
|
180
|
+
// land on the calm rate_limit kind here.
|
|
181
|
+
const c = classify429Detail(text)
|
|
182
|
+
return {
|
|
183
|
+
kind: 'rate_limit',
|
|
184
|
+
source: c === 'litellm-local' ? 'litellm-local' : 'anthropic',
|
|
185
|
+
}
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
// 4. Credit balance is a quota-family wall (actionable).
|
|
189
|
+
if (claudeKind === 'credit-exhausted') {
|
|
190
|
+
return { kind: 'quota_wall', source: 'anthropic' }
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
// 5. Bare rate-limit classification from the operator taxonomy.
|
|
194
|
+
if (claudeKind === 'rate-limited') {
|
|
195
|
+
return { kind: 'rate_limit', source: 'anthropic' }
|
|
196
|
+
}
|
|
197
|
+
if (claudeKind === 'unknown-5xx') {
|
|
198
|
+
return { kind: 'overload_529', source: 'anthropic' }
|
|
199
|
+
}
|
|
200
|
+
|
|
201
|
+
return { kind: 'unknown', source: 'anthropic' }
|
|
202
|
+
}
|
|
203
|
+
|
|
204
|
+
function buildCoreText(kind: LlmErrorKind, source: LlmErrorSource): string {
|
|
205
|
+
switch (kind) {
|
|
206
|
+
case 'rate_limit':
|
|
207
|
+
return source === 'litellm-local'
|
|
208
|
+
? 'Hit the local proxy rate limit — retrying automatically.'
|
|
209
|
+
: 'Rate limited by Anthropic — retrying automatically.'
|
|
210
|
+
case 'overload_529':
|
|
211
|
+
return 'Anthropic is overloaded (529) — retrying automatically.'
|
|
212
|
+
case 'quota_wall':
|
|
213
|
+
return 'Usage limit reached on this Claude subscription.'
|
|
214
|
+
case 'auth':
|
|
215
|
+
return 'Claude login needs re-authentication.'
|
|
216
|
+
case 'transient':
|
|
217
|
+
return source === 'network'
|
|
218
|
+
? "Couldn't reach Anthropic (network) — retrying automatically."
|
|
219
|
+
: 'A temporary upstream hiccup — retrying automatically.'
|
|
220
|
+
case 'unknown':
|
|
221
|
+
return 'The model returned an error.'
|
|
222
|
+
}
|
|
223
|
+
}
|
|
224
|
+
|
|
225
|
+
// ─── Rendering ───────────────────────────────────────────────────────────────
|
|
226
|
+
|
|
227
|
+
export interface RenderedLlmError {
|
|
228
|
+
text: string
|
|
229
|
+
keyboard?: InlineKeyboardMarkup
|
|
230
|
+
}
|
|
231
|
+
|
|
232
|
+
/**
|
|
233
|
+
* Format a reset instant in the operator's local tz plus a relative tail, e.g.
|
|
234
|
+
* `clears ~4:52pm AEST (~in 38m)`. Returns '' when there is no reset to show.
|
|
235
|
+
*/
|
|
236
|
+
export function formatResetLocal(resetAt: Date | undefined, tz: string, now: Date = new Date()): string {
|
|
237
|
+
if (resetAt == null) return ''
|
|
238
|
+
const ms = resetAt.getTime()
|
|
239
|
+
if (!Number.isFinite(ms)) return ''
|
|
240
|
+
const clock = fmtLocalClock(ms, tz)
|
|
241
|
+
const abbrev = tzAbbrev(ms, tz)
|
|
242
|
+
const rel = formatRelativeTail(ms - now.getTime())
|
|
243
|
+
return rel ? `clears ~${clock} ${abbrev} (${rel})` : `clears ~${clock} ${abbrev}`
|
|
244
|
+
}
|
|
245
|
+
|
|
246
|
+
function formatRelativeTail(deltaMs: number): string {
|
|
247
|
+
if (deltaMs <= 0) return '~now'
|
|
248
|
+
const totalMin = Math.round(deltaMs / 60_000)
|
|
249
|
+
if (totalMin < 1) return '~in <1m'
|
|
250
|
+
if (totalMin < 60) return `~in ${totalMin}m`
|
|
251
|
+
const hours = Math.floor(totalMin / 60)
|
|
252
|
+
const mins = totalMin % 60
|
|
253
|
+
if (hours < 24) return mins > 0 ? `~in ${hours}h ${mins}m` : `~in ${hours}h`
|
|
254
|
+
const days = Math.floor(hours / 24)
|
|
255
|
+
const remH = hours % 24
|
|
256
|
+
return remH > 0 ? `~in ${days}d ${remH}h` : `~in ${days}d`
|
|
257
|
+
}
|
|
258
|
+
|
|
259
|
+
/**
|
|
260
|
+
* Render ONE clean card for a parsed LLM error. Action buttons for the
|
|
261
|
+
* actionable kinds (auth → Reauth, quota_wall → Wait/switch). `agent` is used
|
|
262
|
+
* in the callback_data (URL-encoded) and the headline; `tz` localizes the reset.
|
|
263
|
+
*/
|
|
264
|
+
export function renderLlmError(
|
|
265
|
+
parsed: ParsedLlmError,
|
|
266
|
+
agent: string,
|
|
267
|
+
tz: string,
|
|
268
|
+
now: Date = new Date(),
|
|
269
|
+
): RenderedLlmError {
|
|
270
|
+
const safeAgent = escapeAgent(agent)
|
|
271
|
+
const emoji = kindEmoji(parsed.kind)
|
|
272
|
+
const lines: string[] = [`${emoji} ${parsed.coreText} (**${safeAgent}**)`]
|
|
273
|
+
|
|
274
|
+
const resetLine = formatResetLocal(parsed.resetAt, tz, now)
|
|
275
|
+
if (resetLine) lines.push(`_${resetLine}_`)
|
|
276
|
+
|
|
277
|
+
if (parsed.model) lines.push(`_model: ${escapeAgent(parsed.model)}_`)
|
|
278
|
+
|
|
279
|
+
const text = lines.join('\n')
|
|
280
|
+
|
|
281
|
+
switch (parsed.kind) {
|
|
282
|
+
case 'auth':
|
|
283
|
+
return {
|
|
284
|
+
text,
|
|
285
|
+
keyboard: {
|
|
286
|
+
inline_keyboard: [
|
|
287
|
+
[
|
|
288
|
+
{ text: '🔐 Reauth now', callback_data: `op:reauth:${encodeURIComponent(agent)}` },
|
|
289
|
+
{ text: '❌ Dismiss', callback_data: `op:dismiss:${encodeURIComponent(agent)}` },
|
|
290
|
+
],
|
|
291
|
+
],
|
|
292
|
+
},
|
|
293
|
+
}
|
|
294
|
+
case 'quota_wall':
|
|
295
|
+
return {
|
|
296
|
+
text,
|
|
297
|
+
keyboard: {
|
|
298
|
+
inline_keyboard: [
|
|
299
|
+
[{ text: '⏳ Wait', callback_data: `op:dismiss:${encodeURIComponent(agent)}` }],
|
|
300
|
+
],
|
|
301
|
+
},
|
|
302
|
+
}
|
|
303
|
+
default:
|
|
304
|
+
return { text }
|
|
305
|
+
}
|
|
306
|
+
}
|
|
307
|
+
|
|
308
|
+
function kindEmoji(kind: LlmErrorKind): string {
|
|
309
|
+
switch (kind) {
|
|
310
|
+
case 'rate_limit':
|
|
311
|
+
return '🚦'
|
|
312
|
+
case 'overload_529':
|
|
313
|
+
return '🔥'
|
|
314
|
+
case 'quota_wall':
|
|
315
|
+
return '⚠️'
|
|
316
|
+
case 'auth':
|
|
317
|
+
return '🔑'
|
|
318
|
+
case 'transient':
|
|
319
|
+
return '🌐'
|
|
320
|
+
case 'unknown':
|
|
321
|
+
return '⚠️'
|
|
322
|
+
}
|
|
323
|
+
}
|
|
324
|
+
|
|
325
|
+
/** Minimal markdown-safe agent rendering (mirrors operator-events escapeMarkdown intent). */
|
|
326
|
+
function escapeAgent(s: string): string {
|
|
327
|
+
return s.replace(/([_*`\[\]])/g, '\\$1')
|
|
328
|
+
}
|
|
329
|
+
|
|
330
|
+
// ─── Cross-surface dedup: ErrorPresenceGate ──────────────────────────────────
|
|
331
|
+
|
|
332
|
+
/**
|
|
333
|
+
* How long ONE terminal error owns the "already surfaced" claim across all
|
|
334
|
+
* three surfaces. Distinct from — and additional to — the 5-minute per-kind
|
|
335
|
+
* `shouldEmitOperatorEvent` cooldown (operator-events.ts): that debounces an
|
|
336
|
+
* error STORM on one surface; this collapses ONE error's FAN-OUT across
|
|
337
|
+
* surfaces within a single turn.
|
|
338
|
+
*/
|
|
339
|
+
export const ERROR_COLLAPSE_WINDOW_MS = 60_000
|
|
340
|
+
|
|
341
|
+
/**
|
|
342
|
+
* A tiny in-memory dedup ledger. The FIRST surface to `claim(key)` within the
|
|
343
|
+
* collapse window wins (returns true) and renders; every later surface loses
|
|
344
|
+
* (returns false) and suppresses. Key preference: the Anthropic `request_id`
|
|
345
|
+
* when present (exact), else a `${kind}:${agent}:${windowBucket}` coarse key so
|
|
346
|
+
* two distinct-but-unlabelled errors of the same kind within 60s still collapse.
|
|
347
|
+
*/
|
|
348
|
+
export class ErrorPresenceGate {
|
|
349
|
+
private readonly claims = new Map<string, number>()
|
|
350
|
+
|
|
351
|
+
/** Build the dedup key for a parsed error + agent. */
|
|
352
|
+
keyFor(parsed: Pick<ParsedLlmError, 'kind' | 'requestId'>, agent: string, now: number): string {
|
|
353
|
+
if (parsed.requestId) return `rid:${parsed.requestId}`
|
|
354
|
+
const bucket = Math.floor(now / ERROR_COLLAPSE_WINDOW_MS)
|
|
355
|
+
return `${parsed.kind}:${agent}:${bucket}`
|
|
356
|
+
}
|
|
357
|
+
|
|
358
|
+
/**
|
|
359
|
+
* Attempt to claim ownership of `key`. Returns true for the first caller
|
|
360
|
+
* within the window, false for every subsequent caller. Expired claims are
|
|
361
|
+
* pruned lazily on each call.
|
|
362
|
+
*/
|
|
363
|
+
claim(key: string, now: number = Date.now()): boolean {
|
|
364
|
+
this.prune(now)
|
|
365
|
+
const existing = this.claims.get(key)
|
|
366
|
+
if (existing != null && now - existing < ERROR_COLLAPSE_WINDOW_MS) {
|
|
367
|
+
return false
|
|
368
|
+
}
|
|
369
|
+
this.claims.set(key, now)
|
|
370
|
+
return true
|
|
371
|
+
}
|
|
372
|
+
|
|
373
|
+
/** True when `key` is currently claimed (does NOT claim). */
|
|
374
|
+
isClaimed(key: string, now: number = Date.now()): boolean {
|
|
375
|
+
const existing = this.claims.get(key)
|
|
376
|
+
return existing != null && now - existing < ERROR_COLLAPSE_WINDOW_MS
|
|
377
|
+
}
|
|
378
|
+
|
|
379
|
+
private prune(now: number): void {
|
|
380
|
+
for (const [k, at] of this.claims) {
|
|
381
|
+
if (now - at >= ERROR_COLLAPSE_WINDOW_MS) this.claims.delete(k)
|
|
382
|
+
}
|
|
383
|
+
}
|
|
384
|
+
|
|
385
|
+
/** Test-only: forget every claim. */
|
|
386
|
+
reset(): void {
|
|
387
|
+
this.claims.clear()
|
|
388
|
+
}
|
|
389
|
+
}
|
|
390
|
+
|
|
391
|
+
/** The process-wide gate the three surfaces consult. */
|
|
392
|
+
export const errorPresenceGate = new ErrorPresenceGate()
|
|
393
|
+
|
|
394
|
+
/**
|
|
395
|
+
* The single decision a surface makes before rendering a parsed LLM error.
|
|
396
|
+
* Returns:
|
|
397
|
+
* - 'render' — this surface owns the humanized card, render it.
|
|
398
|
+
* - 'suppress' — another surface already owns it (dedup) OR it is a
|
|
399
|
+
* transient error the harness is still auto-retrying — stay silent.
|
|
400
|
+
*
|
|
401
|
+
* ACTIONABLE kinds (auth / quota_wall) ALWAYS return 'render' — they are never
|
|
402
|
+
* deduped away and never silenced mid-retry, because the operator must always
|
|
403
|
+
* see (and be able to act on) a login/quota wall.
|
|
404
|
+
*/
|
|
405
|
+
export function decideErrorSurface(
|
|
406
|
+
parsed: ParsedLlmError,
|
|
407
|
+
agent: string,
|
|
408
|
+
opts: { claim: boolean; now?: number; gate?: ErrorPresenceGate } = { claim: true },
|
|
409
|
+
): 'render' | 'suppress' {
|
|
410
|
+
const now = opts.now ?? Date.now()
|
|
411
|
+
const gate = opts.gate ?? errorPresenceGate
|
|
412
|
+
|
|
413
|
+
if (isActionableKind(parsed.kind)) {
|
|
414
|
+
// Always render — but still record the claim so a redundant transient
|
|
415
|
+
// surface for the same key stays collapsed.
|
|
416
|
+
if (opts.claim) gate.claim(gate.keyFor(parsed, agent, now), now)
|
|
417
|
+
return 'render'
|
|
418
|
+
}
|
|
419
|
+
|
|
420
|
+
// Auto-retry silence: a transient error still being retried internally is
|
|
421
|
+
// NOT surfaced on ANY surface until it goes terminal. NOTE: in the current
|
|
422
|
+
// production wiring the operator surface reaches here only AFTER session-tail
|
|
423
|
+
// (readNew → `errEvent.terminal || !errEvent.transient`) has already dropped
|
|
424
|
+
// in-flight transients, and the gateway calls `parseLlmError(detail)` with no
|
|
425
|
+
// retryState (the raw retry annotations don't survive the IPC hop) — so a
|
|
426
|
+
// production `parsed.autoRetrying` is always false here. This branch is the
|
|
427
|
+
// module's own guarantee for ANY caller that DOES pass retryState (unit-tested
|
|
428
|
+
// as such); prod silence is owned upstream at session-tail, not re-derived here.
|
|
429
|
+
if (parsed.autoRetrying && !parsed.terminal) return 'suppress'
|
|
430
|
+
|
|
431
|
+
const key = gate.keyFor(parsed, agent, now)
|
|
432
|
+
if (opts.claim) {
|
|
433
|
+
return gate.claim(key, now) ? 'render' : 'suppress'
|
|
434
|
+
}
|
|
435
|
+
return gate.isClaimed(key, now) ? 'suppress' : 'render'
|
|
436
|
+
}
|
|
@@ -13,6 +13,7 @@
|
|
|
13
13
|
*/
|
|
14
14
|
|
|
15
15
|
import { escapeMarkdown } from './format.js'
|
|
16
|
+
import { stripRawErrorBytes } from './raw-error-scrub.js'
|
|
16
17
|
|
|
17
18
|
// ─── Taxonomy ────────────────────────────────────────────────────────────────
|
|
18
19
|
|
|
@@ -216,7 +217,12 @@ export interface RenderResult {
|
|
|
216
217
|
*/
|
|
217
218
|
export function renderOperatorEvent(ev: OperatorEvent): RenderResult {
|
|
218
219
|
const agent = escapeMarkdown(ev.agent)
|
|
219
|
-
|
|
220
|
+
// #llm-error-surfacing: NEVER let a raw API-error byte-blob (`· b'{…}'`,
|
|
221
|
+
// trailing `{"type":"error"…}` JSON, `API Error:` prefix) reach a user. A
|
|
222
|
+
// clean human detail passes through unchanged; only smuggled raw bytes are
|
|
223
|
+
// stripped. This is the belt-and-braces scrub for every card kind — the
|
|
224
|
+
// rate-limited card in particular used to relay the raw synthetic-error text.
|
|
225
|
+
const detail = escapeMarkdown(stripRawErrorBytes(ev.detail))
|
|
220
226
|
|
|
221
227
|
switch (ev.kind) {
|
|
222
228
|
case 'credentials-expired':
|
|
@@ -20,7 +20,7 @@
|
|
|
20
20
|
import { basename } from "node:path";
|
|
21
21
|
import { escapeMarkdown } from "./card-format.js";
|
|
22
22
|
import { prettyMcpServer, type ScopeOption } from "./permission-rule.js";
|
|
23
|
-
import { redact } from "./secret-detect/redact.js";
|
|
23
|
+
import { redact, REDACTED_MARKER } from "./secret-detect/redact.js";
|
|
24
24
|
|
|
25
25
|
const COMMAND_TITLE_MAX = 48;
|
|
26
26
|
const DESCRIPTION_LINE_MAX = 240;
|
|
@@ -35,6 +35,55 @@ const ARG_SUMMARY_MAX_KEYS = 4; // how many payload keys to surface on the card
|
|
|
35
35
|
const ARG_VALUE_MAX = 40; // per-value truncation in the arg-summary line
|
|
36
36
|
const ARG_SUMMARY_LINE_MAX = 180; // total cap for the arg-summary line
|
|
37
37
|
|
|
38
|
+
/**
|
|
39
|
+
* Input keys that are routing / formatting / id noise, never operator-
|
|
40
|
+
* meaningful context. Stripped when synthesizing the fallback `context:`
|
|
41
|
+
* line (#3167) so it surfaces the substance (the reply text, the command,
|
|
42
|
+
* the query) and not `chat_id` / `format` / `disable_notification` chrome.
|
|
43
|
+
* `reason`/`why` are here too because their presence takes the `why:` path —
|
|
44
|
+
* they never reach the synthesizer.
|
|
45
|
+
*/
|
|
46
|
+
const CONTEXT_NOISE_KEYS = new Set([
|
|
47
|
+
"reason",
|
|
48
|
+
"why",
|
|
49
|
+
"chat_id",
|
|
50
|
+
"message_id",
|
|
51
|
+
"message_thread_id",
|
|
52
|
+
"thread_id",
|
|
53
|
+
"origin_turn_id",
|
|
54
|
+
"reply_to",
|
|
55
|
+
"quote",
|
|
56
|
+
"quote_text",
|
|
57
|
+
"format",
|
|
58
|
+
"parse_mode",
|
|
59
|
+
"disable_web_page_preview",
|
|
60
|
+
"disable_notification",
|
|
61
|
+
"protect_content",
|
|
62
|
+
"single_use",
|
|
63
|
+
"ack_text",
|
|
64
|
+
"inline_keyboard",
|
|
65
|
+
"file_id",
|
|
66
|
+
]);
|
|
67
|
+
|
|
68
|
+
/**
|
|
69
|
+
* Input keys whose VALUE is credential-shaped by name — hard-masked whole,
|
|
70
|
+
* never partially revealed (#3167 review). The token-shape detectors in
|
|
71
|
+
* `redact()` need a random-looking value to fire, so a low-entropy password
|
|
72
|
+
* or a short secret under one of these keys would otherwise slip through the
|
|
73
|
+
* synthesized `context:` line. Matching the key is the reliable signal.
|
|
74
|
+
*/
|
|
75
|
+
const SENSITIVE_VALUE_KEY_RE =
|
|
76
|
+
/token|secret|password|passwd|key|auth|dsn|conn|url|credential|cookie|session/i;
|
|
77
|
+
|
|
78
|
+
/**
|
|
79
|
+
* Credential-bearing DSN schemes that `redactUrls()` (inside `redact()`) does
|
|
80
|
+
* NOT cover — it only handles http(s)/ws(s)/ftp. A `postgres://user:pass@host`
|
|
81
|
+
* DATABASE_URL is a real vault-key shape here, so mask the whole DSN when its
|
|
82
|
+
* authority carries a `user:pass@` credential (#3167 review).
|
|
83
|
+
*/
|
|
84
|
+
const NON_HTTP_DSN_RE =
|
|
85
|
+
/\b(?:postgres(?:ql)?|mysql|mariadb|redis|rediss|mongodb(?:\+srv)?|amqp|amqps):\/\/\S*@\S+/gi;
|
|
86
|
+
|
|
38
87
|
/**
|
|
39
88
|
* Human verb-phrases for switchroom-managed MCP tools. The raw
|
|
40
89
|
* `mcp__<server>__<tool>` name is operator-hostile. Phrases are written
|
|
@@ -141,15 +190,23 @@ export function formatPermissionCardBody(opts: {
|
|
|
141
190
|
// static schema description (#2469).
|
|
142
191
|
const callerReason = callerSuppliedReason(opts.inputPreview);
|
|
143
192
|
const rawWhy = (callerReason ?? "").replace(/\s+/g, " ").trim();
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
193
|
+
if (rawWhy.length > 0) {
|
|
194
|
+
const truncatedWhy =
|
|
195
|
+
rawWhy.length > DESCRIPTION_LINE_MAX
|
|
196
|
+
? rawWhy.slice(0, DESCRIPTION_LINE_MAX - 1) + "…"
|
|
197
|
+
: rawWhy;
|
|
198
|
+
lines.push(`why: _${escapeTgHtml(truncatedWhy)}_`);
|
|
199
|
+
} else {
|
|
200
|
+
// No caller reason. Many tools (reply, react, edit_message, …) carry no
|
|
201
|
+
// `reason`/`why` argument at all, so a bare "why: not provided" gives the
|
|
202
|
+
// operator nothing to decide on — the post-rollout permission storm filled
|
|
203
|
+
// the chat with contentless cards (#3167). Fall back to an honest,
|
|
204
|
+
// redaction-safe `context:` line synthesized from the tool + its salient
|
|
205
|
+
// input, so the card always carries something meaningful. The distinct
|
|
206
|
+
// label keeps the agent's omission of a rationale visible (it is NOT a
|
|
207
|
+
// fabricated "why"), while still surfacing what the tool is about to do.
|
|
208
|
+
lines.push(`context: _${escapeTgHtml(synthesizeContext(opts.toolName, opts.inputPreview))}_`);
|
|
209
|
+
}
|
|
153
210
|
|
|
154
211
|
// Third line (REST-wrapper MCP writes only): a redaction-safe summary of
|
|
155
212
|
// the payload so the operator can see WHAT is being sent, not just the
|
|
@@ -597,6 +654,111 @@ function callerSuppliedReason(inputPreview: string | undefined): string | null {
|
|
|
597
654
|
return null;
|
|
598
655
|
}
|
|
599
656
|
|
|
657
|
+
/**
|
|
658
|
+
* Honest, redaction-safe fallback for the card's rationale line when the
|
|
659
|
+
* caller supplied no `reason`/`why` (#3167). Synthesizes context from a
|
|
660
|
+
* summary of the tool's salient input fields — the reply text, the command,
|
|
661
|
+
* the search query — so the operator always has something to decide on
|
|
662
|
+
* instead of a contentless "not provided". Every value passes through
|
|
663
|
+
* `redact()`; ids / routing / formatting keys are stripped. Falls back to
|
|
664
|
+
* the natural action phrase when the input exposes nothing salient.
|
|
665
|
+
* Deterministic (no model in the loop): same input → same line. Exported
|
|
666
|
+
* for unit testing.
|
|
667
|
+
*/
|
|
668
|
+
export function synthesizeContext(
|
|
669
|
+
toolName: string,
|
|
670
|
+
inputPreview: string | undefined,
|
|
671
|
+
): string {
|
|
672
|
+
const input = parseInput(inputPreview);
|
|
673
|
+
const summary = input ? salientInputSummary(input) : null;
|
|
674
|
+
// Prefer the salient input summary (the reply text, the query, the diff
|
|
675
|
+
// target) — the substance the title's verb-phrase does NOT already carry.
|
|
676
|
+
// When the input exposes nothing meaningful, fall back to the natural
|
|
677
|
+
// action so the line still names the tool rather than reading empty.
|
|
678
|
+
const base = summary ?? naturalAction(toolName, inputPreview);
|
|
679
|
+
// Acceptance criteria (#3167) call for "tool + summarized input +
|
|
680
|
+
// originating turn". When the call carries a forum-topic origin turn,
|
|
681
|
+
// append a compact reference so the operator can tie the card to the turn
|
|
682
|
+
// that spawned it. Kept short — it's a routing id, not prose.
|
|
683
|
+
const turnRef = input ? readString(input, "origin_turn_id") : null;
|
|
684
|
+
return turnRef ? `${base} · turn ${shortTurnRef(turnRef)}` : base;
|
|
685
|
+
}
|
|
686
|
+
|
|
687
|
+
/** Compact display form of an origin-turn id — the tail is the distinguishing
|
|
688
|
+
* part; a full opaque id would swamp the line. */
|
|
689
|
+
function shortTurnRef(turnId: string): string {
|
|
690
|
+
const t = turnId.trim();
|
|
691
|
+
return t.length <= 10 ? t : `…${t.slice(-8)}`;
|
|
692
|
+
}
|
|
693
|
+
|
|
694
|
+
/**
|
|
695
|
+
* A compact, redaction-safe summary of the operator-meaningful scalar fields
|
|
696
|
+
* of a tool input — used to build the synthesized `context:` line (#3167).
|
|
697
|
+
* Skips {@link CONTEXT_NOISE_KEYS} (ids/routing/formatting), surfaces up to
|
|
698
|
+
* {@link ARG_SUMMARY_MAX_KEYS} scalar `key: value` pairs (each redacted +
|
|
699
|
+
* truncated), and renders nested objects/arrays as the bare key name (no
|
|
700
|
+
* value dump — avoids leaking PII/secrets and oversized blobs). Returns null
|
|
701
|
+
* when nothing meaningful remains, so the caller falls back to the bare
|
|
702
|
+
* action phrase.
|
|
703
|
+
*
|
|
704
|
+
* Free-text fields (e.g. a reply `text`) surface as content — a conscious
|
|
705
|
+
* choice (#3167 review, LOW-1): the operator NEEDS to see *what* is being
|
|
706
|
+
* sent to judge the card. Exposure is bounded to {@link ARG_VALUE_MAX} chars
|
|
707
|
+
* and every value is redaction-passed via {@link redactSalientValue}, so a
|
|
708
|
+
* card can never leak more than the outbound reply already does in history
|
|
709
|
+
* (both use the same `redact()` chokepoint). We do not mask free text to a
|
|
710
|
+
* placeholder, which would gut the card's usefulness.
|
|
711
|
+
*/
|
|
712
|
+
function salientInputSummary(input: Record<string, unknown>): string | null {
|
|
713
|
+
const parts: string[] = [];
|
|
714
|
+
for (const [key, value] of Object.entries(input)) {
|
|
715
|
+
if (CONTEXT_NOISE_KEYS.has(key)) continue;
|
|
716
|
+
if (value == null) continue;
|
|
717
|
+
if (parts.length >= ARG_SUMMARY_MAX_KEYS) {
|
|
718
|
+
parts.push("…");
|
|
719
|
+
break;
|
|
720
|
+
}
|
|
721
|
+
if (typeof value === "object") {
|
|
722
|
+
parts.push(key); // nested object/array → key name only, never dumped
|
|
723
|
+
continue;
|
|
724
|
+
}
|
|
725
|
+
const shown = truncate(redactSalientValue(key, String(value)), ARG_VALUE_MAX);
|
|
726
|
+
if (shown.length === 0) continue;
|
|
727
|
+
parts.push(`${key}: ${shown}`);
|
|
728
|
+
}
|
|
729
|
+
if (parts.length === 0) return null;
|
|
730
|
+
const joined = parts.join(", ");
|
|
731
|
+
return joined.length > ARG_SUMMARY_LINE_MAX
|
|
732
|
+
? joined.slice(0, ARG_SUMMARY_LINE_MAX - 1) + "…"
|
|
733
|
+
: joined;
|
|
734
|
+
}
|
|
735
|
+
|
|
736
|
+
/**
|
|
737
|
+
* Redact a single salient value for display on the synthesized `context:`
|
|
738
|
+
* line, given its input KEY (#3167 review). Three layers, defense-in-depth:
|
|
739
|
+
*
|
|
740
|
+
* 1. Hard-mask whole when the key is credential-shaped
|
|
741
|
+
* ({@link SENSITIVE_VALUE_KEY_RE}) — the token-shape detectors need a
|
|
742
|
+
* random-looking value, so a low-entropy password / short secret under a
|
|
743
|
+
* `token`/`password`/`api_key` key would otherwise leak. The key name is
|
|
744
|
+
* the reliable signal.
|
|
745
|
+
* 2. Mask non-http DSN credentials ({@link NON_HTTP_DSN_RE}) that
|
|
746
|
+
* `redactUrls()` misses (postgres://user:pass@host, mysql://, redis://…).
|
|
747
|
+
* 3. Run `redact()` WITH the key restored as `key=value` context, so the
|
|
748
|
+
* contextual detectors (`kv_entropy` / `env_key_value`) that require the
|
|
749
|
+
* `key[:=]value` shape in one string can fire — then strip the synthetic
|
|
750
|
+
* `key=` prefix. Redacting the BARE value (the pre-review bug) blinded
|
|
751
|
+
* those detectors, since `redact()` deliberately excludes the
|
|
752
|
+
* low-precision `generic_high_entropy` fallback.
|
|
753
|
+
*/
|
|
754
|
+
function redactSalientValue(key: string, value: string): string {
|
|
755
|
+
if (SENSITIVE_VALUE_KEY_RE.test(key)) return REDACTED_MARKER;
|
|
756
|
+
const dsnScrubbed = value.replace(NON_HTTP_DSN_RE, REDACTED_MARKER);
|
|
757
|
+
const scrubbed = redact(`${key}=${dsnScrubbed}`);
|
|
758
|
+
const prefix = `${key}=`;
|
|
759
|
+
return scrubbed.startsWith(prefix) ? scrubbed.slice(prefix.length) : scrubbed;
|
|
760
|
+
}
|
|
761
|
+
|
|
600
762
|
/**
|
|
601
763
|
* Regex-based fallback to extract a `reason` or `why` value from a raw
|
|
602
764
|
* (possibly truncated / invalid-JSON) inputPreview string. Mirrors
|