@blockrun/franklin 3.15.55 → 3.15.57

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.
@@ -6,14 +6,23 @@
6
6
  * - Auth errors (401) get special handling (token refresh, not retry)
7
7
  * - EPIPE/connection reset handled as network errors (retryable)
8
8
  */
9
- export type AgentErrorCategory = 'rate_limit' | 'payment' | 'network' | 'timeout' | 'context_limit' | 'overloaded' | 'server' | 'auth' | 'schema' | 'unknown';
9
+ export type AgentErrorCategory = 'rate_limit' | 'payment' | 'payment_rejected' | 'network' | 'timeout' | 'context_limit' | 'overloaded' | 'server' | 'auth' | 'schema' | 'unknown';
10
10
  export interface AgentErrorInfo {
11
11
  category: AgentErrorCategory;
12
- label: 'RateLimit' | 'Payment' | 'Network' | 'Timeout' | 'Context' | 'Overloaded' | 'Server' | 'Auth' | 'Schema' | 'Unknown';
12
+ label: 'RateLimit' | 'Payment' | 'PaymentRejected' | 'Network' | 'Timeout' | 'Context' | 'Overloaded' | 'Server' | 'Auth' | 'Schema' | 'Unknown';
13
13
  isTransient: boolean;
14
14
  /** Max retries for this error type (overrides default). undefined = use default. */
15
15
  maxRetries?: number;
16
16
  /** User-facing suggestion for how to recover. Appended to error message in UI. */
17
17
  suggestion?: string;
18
+ /**
19
+ * Upstream-recommended wait time before retrying. Parsed from a
20
+ * `[retry-after-ms=...]` tag the streaming client appends to the error
21
+ * message when the response carries a `Retry-After` header (typically
22
+ * 429 / 503). The agent loop should honor this in place of its
23
+ * default exponential backoff. Capped at 10 minutes upstream so a
24
+ * malicious or buggy server can't pin the agent indefinitely.
25
+ */
26
+ retryAfterMs?: number;
18
27
  }
19
28
  export declare function classifyAgentError(message: string): AgentErrorInfo;
@@ -11,10 +11,42 @@ function includesAny(text, patterns) {
11
11
  }
12
12
  export function classifyAgentError(message) {
13
13
  const err = message.toLowerCase();
14
+ // Extract Retry-After hint that streaming-client appended (see llm.ts
15
+ // 429 path). Surfaces on the AgentErrorInfo so the loop can honor the
16
+ // upstream's recommended wait instead of guessing with exponential
17
+ // backoff.
18
+ let retryAfterMs;
19
+ const retryAfterTag = /\[retry-after-ms=(\d+)\]/i.exec(message);
20
+ if (retryAfterTag) {
21
+ const n = parseInt(retryAfterTag[1], 10);
22
+ if (Number.isFinite(n) && n > 0 && n <= 600_000)
23
+ retryAfterMs = n;
24
+ }
25
+ // payment_rejected — the gateway received a SIGNED payment header and
26
+ // rejected it during verification (signature mismatch, replay-nonce
27
+ // reuse, clock skew, wrong-chain wallet). Different remedy from
28
+ // payment_required: re-presenting the same signature won't help.
29
+ // Verified 2026-05-04 in a live session: ExaSearch returned
30
+ // `Exa /v1/exa/search failed (402): {"error":"Payment verification failed",...}`.
31
+ // Classify BEFORE the generic 'payment' branch below since the body
32
+ // contains both 'payment' and 'verification failed'.
33
+ if (includesAny(err, [
34
+ 'verification failed',
35
+ 'payment verification',
36
+ 'signature mismatch',
37
+ 'invalid payment signature',
38
+ 'invalid x-payment',
39
+ 'nonce reuse',
40
+ 'replay protection',
41
+ ])) {
42
+ return {
43
+ category: 'payment_rejected', label: 'PaymentRejected', isTransient: false, maxRetries: 0,
44
+ suggestion: 'The gateway rejected your signed payment. Run `franklin balance` to confirm funds + chain. Common causes: clock skew (resync system clock), wrong chain selected (use `/chain` to switch), or stale nonce (the same retry will fail). Switch to a free model with `/model free` to keep working.',
45
+ };
46
+ }
14
47
  if (includesAny(err, [
15
48
  'insufficient',
16
49
  'payment',
17
- 'verification failed',
18
50
  'balance',
19
51
  '402',
20
52
  'free tier',
@@ -55,6 +87,7 @@ export function classifyAgentError(message) {
55
87
  return {
56
88
  category: 'rate_limit', label: 'RateLimit', isTransient: true, maxRetries: 1,
57
89
  suggestion: 'Try /model to switch to a different model, or wait a moment and /retry.',
90
+ retryAfterMs,
58
91
  };
59
92
  }
60
93
  if (includesAny(err, [
package/dist/agent/llm.js CHANGED
@@ -430,7 +430,25 @@ export class ModelClient {
430
430
  }
431
431
  if (!response.ok) {
432
432
  const errorBody = await response.text().catch(() => 'unknown error');
433
- const message = extractApiErrorMessage(errorBody);
433
+ let message = extractApiErrorMessage(errorBody);
434
+ // 429 with Retry-After header: tag the error message so the
435
+ // classifier can extract and the loop can honor it. Verified
436
+ // 2026-05-04 in a live session: a 429 fired with the loop's
437
+ // exponential backoff (~1-2s) but the upstream's actual
438
+ // Retry-After window was ~30s — the agent retried prematurely
439
+ // and burned its rate_limit retry budget. Anthropic + most
440
+ // gateways send Retry-After as either seconds (integer) or an
441
+ // HTTP-date; we only honor the seconds form (the date form is
442
+ // rare in practice and harder to validate against clock skew).
443
+ if (response.status === 429) {
444
+ const retryAfter = response.headers.get('retry-after');
445
+ if (retryAfter) {
446
+ const seconds = parseInt(retryAfter, 10);
447
+ if (Number.isFinite(seconds) && seconds > 0 && seconds <= 600) {
448
+ message = `${message} [retry-after-ms=${seconds * 1000}]`;
449
+ }
450
+ }
451
+ }
434
452
  // Runtime tool_choice retry. The static allowlist at line ~35
435
453
  // catches the case where the request goes directly to a model
436
454
  // whose name contains `deepseek-reasoner` / `openai/o1` /
@@ -1156,8 +1156,17 @@ export async function interactiveSession(config, getUserInput, onEvent, onAbortR
1156
1156
  }
1157
1157
  }
1158
1158
  recoveryAttempts++;
1159
- const backoffMs = getBackoffDelay(recoveryAttempts);
1160
- logger.warn(`[franklin] ${classified.label} error retrying in ${(backoffMs / 1000).toFixed(1)}s (attempt ${recoveryAttempts}/${effectiveMaxRetries}): ${errMsg.slice(0, 100)}`);
1159
+ // Honor an upstream Retry-After (parsed from the response by
1160
+ // llm.ts when 429+ Retry-After is present) over our own
1161
+ // exponential backoff. Verified 2026-05-04: a 429 with
1162
+ // Retry-After=30s was retried after ~1.5s exponential backoff
1163
+ // → got 429 again → burned the rate_limit retry budget. Cap at
1164
+ // 30s so the agent never feels "frozen" — anything longer
1165
+ // falls back to a different model instead.
1166
+ const upstreamWaitMs = classified.retryAfterMs;
1167
+ const honorUpstream = typeof upstreamWaitMs === 'number' && upstreamWaitMs <= 30_000;
1168
+ const backoffMs = honorUpstream ? upstreamWaitMs : getBackoffDelay(recoveryAttempts);
1169
+ logger.warn(`[franklin] ${classified.label} error — retrying in ${(backoffMs / 1000).toFixed(1)}s (attempt ${recoveryAttempts}/${effectiveMaxRetries})${honorUpstream ? ' (upstream Retry-After)' : ''}: ${errMsg.slice(0, 100)}`);
1161
1170
  // Surface the actual error + model so the user can see which model
1162
1171
  // is failing and what the upstream said. Old "Retrying after Server
1163
1172
  // error" was uninformative — users couldn't tell whether to wait,
@@ -1172,8 +1181,12 @@ export async function interactiveSession(config, getUserInput, onEvent, onAbortR
1172
1181
  }
1173
1182
  // ── Payment failure: auto-fallback to free models ──
1174
1183
  // Track payment-failed models for the entire session — unlike transient errors,
1175
- // 402s will keep failing until the user adds funds.
1176
- if (classified.category === 'payment') {
1184
+ // 402s will keep failing until the user adds funds. Also handles
1185
+ // payment_rejected (signature verified-and-rejected by gateway):
1186
+ // same fallback path, but the suggestion text in classifier guides
1187
+ // the user toward clock-skew / chain-mismatch fixes rather than
1188
+ // "add funds."
1189
+ if (classified.category === 'payment' || classified.category === 'payment_rejected') {
1177
1190
  turnFailedModels.add(config.model);
1178
1191
  paymentFailedModels.set(config.model, Date.now());
1179
1192
  // Bound the Map so long sessions don't leak. LRU-evict oldest by timestamp.
@@ -1226,7 +1239,18 @@ export async function interactiveSession(config, getUserInput, onEvent, onAbortR
1226
1239
  }
1227
1240
  }
1228
1241
  // ── Unrecoverable: show error with suggestion from classifier ──
1229
- const suggestion = classified.suggestion ? `\nTip: ${classified.suggestion}` : '';
1242
+ // For rate_limit specifically, augment the classifier's generic
1243
+ // suggestion with an explicit "all free models exhausted — switch
1244
+ // to a paid model" hint when we got here because pickFreeFallback
1245
+ // returned null. Verified 2026-05-04: the screenshot's session
1246
+ // ended with a bare "[RateLimit] API error: 429" because every
1247
+ // free model had already been ruled out earlier in the turn —
1248
+ // the user had a funded wallet but no signal that paid models
1249
+ // were the way out.
1250
+ let suggestion = classified.suggestion ? `\nTip: ${classified.suggestion}` : '';
1251
+ if (classified.category === 'rate_limit' && turnFailedModels.size > 0) {
1252
+ suggestion = `\nTip: All free models tried this turn are rate-limited. Switch to a paid model with /model anthropic/claude-sonnet-4.6 (or any other paid model) and retry — your wallet handles it. Or wait ~60s and /retry the same turn.`;
1253
+ }
1230
1254
  onEvent({
1231
1255
  kind: 'turn_done',
1232
1256
  reason: 'error',
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@blockrun/franklin",
3
- "version": "3.15.55",
3
+ "version": "3.15.57",
4
4
  "description": "Franklin — The AI agent with a wallet. Spends USDC autonomously to get real work done. Pay per action, no subscriptions.",
5
5
  "type": "module",
6
6
  "exports": {