@blockrun/franklin 3.15.6 → 3.15.7

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 (2) hide show
  1. package/dist/agent/loop.js +41 -2
  2. package/package.json +1 -1
@@ -22,7 +22,7 @@ import { appendAudit, extractLastUserPrompt } from '../stats/audit.js';
22
22
  import { estimateCost, OPUS_PRICING } from '../pricing.js';
23
23
  import { maybeMidSessionExtract } from '../learnings/extractor.js';
24
24
  import { extractMentions, buildEntityContext, loadEntities } from '../brain/store.js';
25
- import { routeRequestAsync, resolveTierToModel, parseRoutingProfile } from '../router/index.js';
25
+ import { routeRequestAsync, resolveTierToModel, parseRoutingProfile, getFallbackChain } from '../router/index.js';
26
26
  import { recordOutcome } from '../router/local-elo.js';
27
27
  import { shouldPlan, getPlanningPrompt, getExecutorModel, isExecutorStuck, toolCallSignature } from './planner.js';
28
28
  import { shouldVerify, runVerification } from './verification.js';
@@ -505,6 +505,11 @@ export async function interactiveSession(config, getUserInput, onEvent, onAbortR
505
505
  let recoveryAttempts = 0;
506
506
  let autoContinuationCount = 0;
507
507
  const MAX_RECOVERY_ATTEMPTS = 5;
508
+ // Track per-model server-error streak so we can break out of a stuck
509
+ // upstream and try the next model in the routing fallback chain instead
510
+ // of burning all MAX_RECOVERY_ATTEMPTS retries on the same failure.
511
+ const serverErrorsByModel = new Map();
512
+ const SERVER_ERROR_STREAK_BEFORE_SWITCH = 2;
508
513
  let compactFailures = 0;
509
514
  let maxTokensOverride;
510
515
  const turnIdleReference = lastSessionActivity;
@@ -993,14 +998,48 @@ export async function interactiveSession(config, getUserInput, onEvent, onAbortR
993
998
  }
994
999
  }
995
1000
  if (classified.isTransient && recoveryAttempts < effectiveMaxRetries) {
1001
+ // Server-error streak guard: if the same model 5xx's twice in a row
1002
+ // it's almost always an upstream incident, not a blip. Switch to
1003
+ // the next routing fallback instead of waiting out 5 backoffs on a
1004
+ // dead provider — same idea as the payment-failure auto-fallback
1005
+ // below, but for transient server errors. Skipped for non-server
1006
+ // transients (rate limits, network blips) where retry is the right
1007
+ // call. Also skipped when the user picked a concrete model — they
1008
+ // explicitly chose this one, so we shouldn't silently swap.
1009
+ if (classified.category === 'server' && parseRoutingProfile(config.model)) {
1010
+ const streak = (serverErrorsByModel.get(resolvedModel) ?? 0) + 1;
1011
+ serverErrorsByModel.set(resolvedModel, streak);
1012
+ if (streak >= SERVER_ERROR_STREAK_BEFORE_SWITCH) {
1013
+ const fallbackChain = getFallbackChain(routingTier ?? 'MEDIUM', parseRoutingProfile(config.model) ?? 'auto');
1014
+ const nextModel = fallbackChain.find(m => m !== resolvedModel && (serverErrorsByModel.get(m) ?? 0) < SERVER_ERROR_STREAK_BEFORE_SWITCH);
1015
+ if (nextModel) {
1016
+ config.model = nextModel;
1017
+ config.onModelChange?.(nextModel, 'system');
1018
+ recoveryAttempts = 0;
1019
+ onEvent({
1020
+ kind: 'text_delta',
1021
+ text: `\n*${resolvedModel} keeps 5xx'ing (${streak} in a row) — switching to ${nextModel}*\n`,
1022
+ });
1023
+ continue;
1024
+ }
1025
+ // No alternative left in the fallback chain — fall through to
1026
+ // the normal retry path so we at least exhaust attempts before
1027
+ // surrender.
1028
+ }
1029
+ }
996
1030
  recoveryAttempts++;
997
1031
  const backoffMs = getBackoffDelay(recoveryAttempts);
998
1032
  if (config.debug) {
999
1033
  console.error(`[franklin] ${classified.label} error — retrying in ${(backoffMs / 1000).toFixed(1)}s (attempt ${recoveryAttempts}/${effectiveMaxRetries}): ${errMsg.slice(0, 100)}`);
1000
1034
  }
1035
+ // Surface the actual error + model so the user can see which model
1036
+ // is failing and what the upstream said. Old "Retrying after Server
1037
+ // error" was uninformative — users couldn't tell whether to wait,
1038
+ // /retry, or /model-switch.
1039
+ const errSnippet = errMsg.replace(/\s+/g, ' ').slice(0, 100);
1001
1040
  onEvent({
1002
1041
  kind: 'text_delta',
1003
- text: `\n*Retrying (${recoveryAttempts}/${effectiveMaxRetries}) after ${classified.label} error...*\n`,
1042
+ text: `\n*Retrying ${recoveryAttempts}/${effectiveMaxRetries} on ${resolvedModel} — ${classified.label}: ${errSnippet}*\n`,
1004
1043
  });
1005
1044
  await new Promise(r => setTimeout(r, backoffMs));
1006
1045
  continue;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@blockrun/franklin",
3
- "version": "3.15.6",
3
+ "version": "3.15.7",
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": {