@blockrun/franklin 3.15.34 → 3.15.36

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/llm.js CHANGED
@@ -8,6 +8,21 @@ import { USER_AGENT } from '../config.js';
8
8
  import { routeRequest, parseRoutingProfile } from '../router/index.js';
9
9
  import { ThinkTagStripper } from './think-tag-stripper.js';
10
10
  import { isNemotronProseModel, stripNemotronProse } from './nemotron-prose-stripper.js';
11
+ // Reasoning-tier models the gateway routes to that reject `tool_choice`
12
+ // outright. Pattern: OpenAI o1/o3 family + DeepSeek's reasoner variant.
13
+ // Add new entries as their 400 errors appear in real sessions; this is
14
+ // a known-bad allowlist, not a guess. Wildcard substring match keeps it
15
+ // resilient to model-revision suffixes (`o1-mini`, `o3-2026-04`, etc.).
16
+ const MODELS_WITHOUT_TOOL_CHOICE_SUBSTR = [
17
+ 'deepseek-reasoner',
18
+ 'openai/o1',
19
+ 'openai/o3',
20
+ ];
21
+ function modelDoesNotSupportToolChoice(model) {
22
+ if (!model)
23
+ return false;
24
+ return MODELS_WITHOUT_TOOL_CHOICE_SUBSTR.some(s => model.includes(s));
25
+ }
11
26
  function parseTimeoutEnv(name) {
12
27
  const raw = process.env[name];
13
28
  const parsed = raw ? Number.parseInt(raw, 10) : NaN;
@@ -309,6 +324,17 @@ export class ModelClient {
309
324
  (!Array.isArray(requestPayload['tools']) || requestPayload['tools'].length === 0)) {
310
325
  delete requestPayload['tool_choice'];
311
326
  }
327
+ // Models that don't support `tool_choice` (reasoning-only families).
328
+ // Verified 2026-05-04 from a real session: grounding-retry forced
329
+ // tool_choice on a request that ended up on deepseek-reasoner, which
330
+ // returned `400 Invalid request: deepseek-reasoner does not support
331
+ // this tool_choice`. Same shape applies to OpenAI o1 / o3 and
332
+ // similar restricted reasoning models. Strip silently — the agent
333
+ // loop's grounding-retry contract already tolerates the field
334
+ // disappearing (it'll re-evaluate next turn).
335
+ if (requestPayload['tool_choice'] !== undefined && modelDoesNotSupportToolChoice(request.model)) {
336
+ delete requestPayload['tool_choice'];
337
+ }
312
338
  // ── GLM-specific optimizations ───────────────────────────────────────────
313
339
  // GLM models work best with temperature=0.8 per official zai spec.
314
340
  // Enable thinking mode only for explicit reasoning variants (-thinking-).
@@ -431,6 +431,11 @@ export async function interactiveSession(config, getUserInput, onEvent, onAbortR
431
431
  updateSessionMeta(sessionId, {
432
432
  model: config.model,
433
433
  workDir,
434
+ // Pin the session's chain so `franklin --resume` can restore it
435
+ // even after `franklin <chain>` shortcuts mutate the persisted
436
+ // default. updateSessionMeta treats this field as sticky once
437
+ // recorded — see storage.ts.
438
+ chain: config.chain,
434
439
  turnCount,
435
440
  messageCount: history.length,
436
441
  inputTokens: sessionInputTokens,
@@ -46,7 +46,28 @@ export async function startCommand(options) {
46
46
  const { findLatestSessionForDir } = await import('../ui/session-picker.js');
47
47
  continueResolvedId = findLatestSessionForDir(process.cwd())?.id;
48
48
  }
49
- const chain = loadChain();
49
+ // Sessions are wallet-bound: the conversation, audit trail, and tool
50
+ // results live on whichever chain the session was started on. If
51
+ // we're resuming, prefer the session's recorded chain over the
52
+ // persisted default — `franklin solana` / `franklin base` shortcuts
53
+ // mutate that default, and a debug invocation between restarts
54
+ // shouldn't be able to silently move the user to a different wallet.
55
+ // Only sessions created in 3.15.35+ have the field; older sessions
56
+ // fall back to the persisted default (matches pre-3.15.35 behavior).
57
+ let chain = loadChain();
58
+ const resumeIdEarly = (typeof options.resume === 'string' && options.resume !== 'picker') ? options.resume
59
+ : continueResolvedId;
60
+ if (resumeIdEarly) {
61
+ const { loadSessionMeta } = await import('../session/storage.js');
62
+ const sessMeta = loadSessionMeta(resumeIdEarly);
63
+ if (sessMeta?.chain && sessMeta.chain !== chain) {
64
+ console.log(chalk.dim(` Restoring session's chain: ${sessMeta.chain} (default was ${chain}; session is wallet-bound to ${sessMeta.chain})`));
65
+ chain = sessMeta.chain;
66
+ }
67
+ else if (sessMeta?.chain) {
68
+ chain = sessMeta.chain;
69
+ }
70
+ }
50
71
  const apiUrl = API_URLS[chain];
51
72
  const config = loadConfig();
52
73
  // Resolve model. Priority: explicit --model > resumed session's model > user
@@ -13,6 +13,18 @@ export interface SessionMeta {
13
13
  updatedAt: number;
14
14
  turnCount: number;
15
15
  messageCount: number;
16
+ /**
17
+ * Chain (`base` | `solana`) the session was started on. Captured at
18
+ * session creation so `franklin --resume` can restore the same chain
19
+ * even if the user later changed their default via
20
+ * `franklin solana` / `franklin base`. Verified 2026-05-04: a debug
21
+ * invocation flipped `~/.blockrun/.chain` to `solana`; the next
22
+ * `--resume` silently moved the user from their funded Base wallet
23
+ * to an underfunded Solana wallet. Sessions are wallet-bound by
24
+ * conversation context — switching chains mid-resume is a bug.
25
+ * Optional for back-compat with pre-3.15.35 sessions.
26
+ */
27
+ chain?: 'base' | 'solana';
16
28
  inputTokens?: number;
17
29
  outputTokens?: number;
18
30
  costUsd?: number;
@@ -113,6 +113,12 @@ export function updateSessionMeta(sessionId, meta) {
113
113
  ...(meta.channel !== undefined || existing?.channel !== undefined
114
114
  ? { channel: meta.channel ?? existing?.channel }
115
115
  : {}),
116
+ // Chain (base / solana) is sticky once set. We never let a later
117
+ // update overwrite an existing value with undefined — that would
118
+ // silently drop the bind-to-original-chain guarantee.
119
+ ...(meta.chain !== undefined || existing?.chain !== undefined
120
+ ? { chain: existing?.chain ?? meta.chain }
121
+ : {}),
116
122
  ...(meta.toolCallCounts !== undefined || existing?.toolCallCounts !== undefined
117
123
  ? { toolCallCounts: meta.toolCallCounts ?? existing?.toolCallCounts }
118
124
  : {}),
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@blockrun/franklin",
3
- "version": "3.15.34",
3
+ "version": "3.15.36",
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": {