@stigmer/runner 3.5.0 → 3.5.2

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.
@@ -0,0 +1,303 @@
1
+ /**
2
+ * Model-call error unwrapping and classification, shared by every harness.
3
+ *
4
+ * Two problems this module solves (stigmer/stigmer#330):
5
+ *
6
+ * 1. LangChain wraps any error thrown inside a middleware-wrapped model call
7
+ * in `MiddlewareError`, so `err.constructor.name` reads "MiddlewareError"
8
+ * regardless of what actually failed. The wrapper preserves the original
9
+ * error on the standard `cause` property — `unwrapModelError` walks that
10
+ * chain back to the root SDK error (which carries `.status` and the
11
+ * provider's parsed body).
12
+ *
13
+ * 2. Raw provider error prose is wrong for the user in proxy mode: billing
14
+ * errors from the PLATFORM's provider account told customers to top up
15
+ * consoles they don't own. The cloud proxy rewrites those to a 503
16
+ * carrying PLATFORM_CAPACITY_SENTINEL (the contract lives in
17
+ * stigmer-cloud's PlatformProviderErrorClassifier and the DD at
18
+ * _projects/2026-08/20260801.02.provider-error-attribution/); this module
19
+ * is the runner-side half that recognizes the sentinel and, in direct
20
+ * (BYO-key) mode, attributes billing errors to the user's own account
21
+ * with actionable wording.
22
+ *
23
+ * Classification order is load-bearing: the sentinel check must precede
24
+ * status mapping, otherwise the proxy's 503 would classify as a retryable
25
+ * 5xx and waste Temporal retries against a dead platform account.
26
+ */
27
+
28
+ import type { LlmProvider } from "./llm-proxy.js";
29
+
30
+ /**
31
+ * Machine-readable code the cloud proxy embeds in rewritten platform-fault
32
+ * error messages. The message text is the contract carrier — it is the one
33
+ * field proven to survive provider SDK → LangChain → runner intact.
34
+ * Duplicated in stigmer-cloud's PlatformProviderErrorClassifier.SENTINEL_CODE;
35
+ * change both or neither.
36
+ */
37
+ export const PLATFORM_CAPACITY_SENTINEL = "STIGMER_PLATFORM_MODEL_CAPACITY";
38
+
39
+ export interface ClassifiedModelError {
40
+ /** Stable machine-readable code (doubles as the Temporal failure type). */
41
+ readonly code: string;
42
+ /** User-facing message; never raw provider prose in proxy mode. */
43
+ readonly message: string;
44
+ /** Temporal retry semantics: false = will not self-heal, fail fast. */
45
+ readonly retryable: boolean;
46
+ }
47
+
48
+ export interface ModelErrorContext {
49
+ /** True when model calls route through the Stigmer platform proxy. */
50
+ readonly proxyMode: boolean;
51
+ /** Provider, when the caller knows it — sharpens the message wording. */
52
+ readonly provider?: LlmProvider;
53
+ /** Model id, when the caller knows it. */
54
+ readonly modelId?: string;
55
+ /**
56
+ * True when the caller's catch can only see model-call failures (e.g. the
57
+ * call-llm activity). Enables the loose connection/timeout class-name
58
+ * heuristics ("Timeout"/"Connection" substrings, catching undici transport
59
+ * errors). Broad catch blocks (deep-agent, Cursor) must leave this false:
60
+ * a WorkspaceLockTimeoutError is not a model connection timeout, and only
61
+ * the SDKs' own APIConnection* class names are positive signal there.
62
+ */
63
+ readonly assumeModelCall?: boolean;
64
+ }
65
+
66
+ /**
67
+ * Walk the `cause` chain to the root error. LangChain's MiddlewareError (and
68
+ * anything else that chains causes) preserves the original SDK error there.
69
+ * Depth-capped so a pathological cause cycle cannot spin forever.
70
+ */
71
+ export function unwrapModelError(err: unknown): unknown {
72
+ let current = err;
73
+ for (let depth = 0; depth < 10; depth++) {
74
+ if (current instanceof Error && current.cause instanceof Error) {
75
+ current = current.cause;
76
+ } else {
77
+ return current;
78
+ }
79
+ }
80
+ return current;
81
+ }
82
+
83
+ /**
84
+ * Classify a model-call failure into a stable code with a user-facing
85
+ * message, or return undefined when there is no positive signal that the
86
+ * error came from a model call at all.
87
+ *
88
+ * The undefined arm matters for callers whose catch blocks see more than
89
+ * model errors (the deep-agent activity wraps tools, MCP, and workspace
90
+ * operations too): only positively-identified model errors get relabeled;
91
+ * everything else keeps its own identity.
92
+ */
93
+ export function classifyModelCallError(
94
+ err: unknown,
95
+ ctx: ModelErrorContext,
96
+ ): ClassifiedModelError | undefined {
97
+ const root = unwrapModelError(err);
98
+ const message = root instanceof Error ? root.message : String(root);
99
+
100
+ // 1. Platform sentinel — before status mapping (see module doc).
101
+ if (message.includes(PLATFORM_CAPACITY_SENTINEL)) {
102
+ return {
103
+ code: "LLM_PLATFORM_CAPACITY",
104
+ retryable: false,
105
+ message: platformCapacityMessage(ctx),
106
+ };
107
+ }
108
+
109
+ // 2. Provider billing prose. In direct mode this is the user's own
110
+ // account and the fix is theirs. In proxy mode these patterns should
111
+ // never appear (the proxy rewrites them), but a version-skewed proxy
112
+ // could still relay them — attribute to the platform, never tell a
113
+ // proxied customer to top up someone else's account.
114
+ if (isProviderBillingMessage(message)) {
115
+ if (ctx.proxyMode) {
116
+ return {
117
+ code: "LLM_PLATFORM_CAPACITY",
118
+ retryable: false,
119
+ message: platformCapacityMessage(ctx),
120
+ };
121
+ }
122
+ return {
123
+ code: "LLM_PROVIDER_BILLING",
124
+ retryable: false,
125
+ message:
126
+ `Your ${ctx.provider ? providerLabel(ctx) : "model provider"} account is out of credits or over quota. ` +
127
+ `Add credits to your provider account or switch to a different model. ` +
128
+ `Provider message: ${message}`,
129
+ };
130
+ }
131
+
132
+ // 3. HTTP status duck-typing. Both provider SDKs throw APIError subclasses
133
+ // exposing `.status`; duck-typing avoids importing either SDK's classes.
134
+ const status = typeof (root as { status?: unknown }).status === "number"
135
+ ? (root as { status: number }).status
136
+ : undefined;
137
+ if (status !== undefined) {
138
+ return classifyByStatus(status, message, ctx);
139
+ }
140
+
141
+ // 4. Connection/timeout heuristics on the root error's class name. Strict
142
+ // (SDK class names only) unless the caller vouches that every error it
143
+ // sees is a model-call error — see ModelErrorContext.assumeModelCall.
144
+ const errName = root instanceof Error ? root.constructor.name : "";
145
+ const isTimeout = errName.includes("APIConnectionTimeout")
146
+ || (ctx.assumeModelCall === true && errName.includes("Timeout"));
147
+ if (isTimeout) {
148
+ return {
149
+ code: "LLM_CONNECTION_TIMEOUT",
150
+ retryable: true,
151
+ message: `Connection timed out for ${modelLabel(ctx)}: ${message}`,
152
+ };
153
+ }
154
+ const isConnection = errName.includes("APIConnection")
155
+ || (ctx.assumeModelCall === true && errName.includes("Connection"));
156
+ if (isConnection) {
157
+ return {
158
+ code: "LLM_CONNECTION_ERROR",
159
+ retryable: true,
160
+ message: `Connection failed for ${modelLabel(ctx)}: ${message}`,
161
+ };
162
+ }
163
+
164
+ return undefined;
165
+ }
166
+
167
+ /**
168
+ * Retryability policy (unchanged from the original call-llm classifier):
169
+ * - 4xx (except 429): nonRetryable — client/config errors won't self-heal
170
+ * - 429: nonRetryable at the Temporal level — the SDK already retried
171
+ * internally with backoff; retrying on top causes duplicates
172
+ * - 5xx: retryable — transient provider outages
173
+ */
174
+ function classifyByStatus(
175
+ status: number,
176
+ rawMessage: string,
177
+ ctx: ModelErrorContext,
178
+ ): ClassifiedModelError {
179
+ const context = modelLabel(ctx);
180
+
181
+ switch (status) {
182
+ case 401:
183
+ return {
184
+ code: "LLM_AUTHENTICATION_ERROR",
185
+ retryable: false,
186
+ message: ctx.proxyMode
187
+ ? `The Stigmer platform rejected this model call (authentication, HTTP 401) for ${context}. ` +
188
+ `Your session token may have expired — retry the execution, and contact support if it persists.`
189
+ : `Authentication failed for ${context}. Check that your API key is valid and not expired.`,
190
+ };
191
+ case 403:
192
+ return {
193
+ code: "LLM_PERMISSION_DENIED",
194
+ retryable: false,
195
+ message: ctx.proxyMode
196
+ ? `The Stigmer platform denied this model call (authorization, HTTP 403) for ${context}. ` +
197
+ `Verify this execution is permitted to use the model, and contact support if it persists.`
198
+ : `Access denied for ${context}. Verify that your API key has permission to use this model.`,
199
+ };
200
+ case 404:
201
+ return {
202
+ code: "LLM_MODEL_NOT_FOUND",
203
+ retryable: false,
204
+ message: `Model not found: ${context}. Verify the model name is correct and available in your account.`,
205
+ };
206
+ case 400:
207
+ return {
208
+ code: "LLM_BAD_REQUEST",
209
+ retryable: false,
210
+ message: `Invalid request to ${context}: ${rawMessage}`,
211
+ };
212
+ case 422:
213
+ return {
214
+ code: "LLM_UNPROCESSABLE_REQUEST",
215
+ retryable: false,
216
+ message: `Unprocessable request to ${context}: ${rawMessage}`,
217
+ };
218
+ case 429:
219
+ return {
220
+ code: "LLM_RATE_LIMIT",
221
+ retryable: false,
222
+ message:
223
+ `Rate limit exceeded for ${context}. The provider's built-in retry was exhausted. ` +
224
+ `Try again later or reduce request frequency.`,
225
+ };
226
+ default:
227
+ if (status >= 500) {
228
+ return {
229
+ code: "LLM_PROVIDER_ERROR",
230
+ retryable: true,
231
+ message: `${providerSubject(ctx)} returned a server error (HTTP ${status}) for ${context}: ${rawMessage}`,
232
+ };
233
+ }
234
+ return {
235
+ code: "LLM_API_ERROR",
236
+ retryable: false,
237
+ message: `${providerSubject(ctx)} returned an API error (HTTP ${status}) for ${context}: ${rawMessage}`,
238
+ };
239
+ }
240
+ }
241
+
242
+ /**
243
+ * Describe an arbitrary activity failure for the execution's user-visible
244
+ * error field: model-call errors get classified codes and messages; anything
245
+ * else keeps the ROOT error's identity (never a wrapper's class name).
246
+ */
247
+ export function describeExecutionError(
248
+ err: unknown,
249
+ ctx: ModelErrorContext,
250
+ ): { errorType: string; errorMessage: string } {
251
+ const classified = classifyModelCallError(err, ctx);
252
+ if (classified) {
253
+ return { errorType: classified.code, errorMessage: classified.message };
254
+ }
255
+
256
+ const root = unwrapModelError(err);
257
+ return {
258
+ errorType: root instanceof Error ? root.constructor.name : "UnknownError",
259
+ errorMessage: root instanceof Error ? root.message : String(root),
260
+ };
261
+ }
262
+
263
+ /**
264
+ * Provider billing-exhaustion prose, matched against the raw message. Narrow
265
+ * by design: these phrases are pinned to real provider wordings (Anthropic's
266
+ * 400 billing prose; OpenAI's insufficient_quota / no-credits 429s). A miss
267
+ * falls through to status classification — never worse than today.
268
+ */
269
+ function isProviderBillingMessage(message: string): boolean {
270
+ const lower = message.toLowerCase();
271
+ return (
272
+ lower.includes("credit balance is too low")
273
+ || lower.includes("insufficient_quota")
274
+ || lower.includes("exceeded your current quota")
275
+ || lower.includes("no credits remaining")
276
+ );
277
+ }
278
+
279
+ function platformCapacityMessage(ctx: ModelErrorContext): string {
280
+ return (
281
+ `The Stigmer platform's model capacity for ${providerLabel(ctx)} is temporarily unavailable. ` +
282
+ `This is a platform-side issue — your organization's credits were not charged for this call. ` +
283
+ `Please retry shortly, or contact support if it persists. [code: ${PLATFORM_CAPACITY_SENTINEL}]`
284
+ );
285
+ }
286
+
287
+ function providerLabel(ctx: ModelErrorContext): string {
288
+ if (ctx.provider === "anthropic") return "Anthropic";
289
+ if (ctx.provider === "openai") return "OpenAI";
290
+ return "the model provider";
291
+ }
292
+
293
+ /** Sentence-initial form: "Anthropic" / "The model provider". */
294
+ function providerSubject(ctx: ModelErrorContext): string {
295
+ return ctx.provider ? providerLabel(ctx) : "The model provider";
296
+ }
297
+
298
+ function modelLabel(ctx: ModelErrorContext): string {
299
+ if (ctx.modelId && ctx.provider) return `model "${ctx.modelId}" (${providerLabel(ctx)})`;
300
+ if (ctx.modelId) return `model "${ctx.modelId}"`;
301
+ if (ctx.provider) return `the model (${providerLabel(ctx)})`;
302
+ return "the model";
303
+ }