@blockrun/franklin 3.15.49 → 3.15.51

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
@@ -431,11 +431,64 @@ export class ModelClient {
431
431
  if (!response.ok) {
432
432
  const errorBody = await response.text().catch(() => 'unknown error');
433
433
  const message = extractApiErrorMessage(errorBody);
434
- yield {
435
- kind: 'error',
436
- payload: { status: response.status, message },
437
- };
438
- return;
434
+ // Runtime tool_choice retry. The static allowlist at line ~35
435
+ // catches the case where the request goes directly to a model
436
+ // whose name contains `deepseek-reasoner` / `openai/o1` /
437
+ // `openai/o3`. But the gateway sometimes ALIASES a different
438
+ // model name to a reasoner backend — verified 2026-05-04 in a
439
+ // live session: a request for `deepseek/deepseek-v4-pro`
440
+ // returned `400 Invalid request: 400 deepseek-reasoner does not
441
+ // support this tool_choice`, because the gateway routed v4-pro
442
+ // to a deepseek-reasoner upstream. The static allowlist can't
443
+ // know that. Catch the error, drop tool_choice, re-fire once.
444
+ // No payment re-sign needed — original 402 already settled, and
445
+ // the gateway treats this as the same logical request.
446
+ const lc = message.toLowerCase();
447
+ const looksLikeToolChoiceReject = response.status === 400 &&
448
+ lc.includes('tool_choice') &&
449
+ (lc.includes('not support') || lc.includes('unsupported') || lc.includes('does not support'));
450
+ if (looksLikeToolChoiceReject && requestPayload['tool_choice'] !== undefined) {
451
+ delete requestPayload['tool_choice'];
452
+ const retryBody = JSON.stringify(requestPayload);
453
+ if (this.debug) {
454
+ console.error(`[franklin] tool_choice rejected by upstream; retrying without it (model=${request.model})`);
455
+ }
456
+ response = await withAbortableTimeout(() => fetch(endpoint, {
457
+ method: 'POST',
458
+ headers,
459
+ body: retryBody,
460
+ signal: requestController.signal,
461
+ }), requestController, createModelTimeoutError('request', request.model, requestTimeoutMs), requestTimeoutMs);
462
+ if (response.status === 402) {
463
+ const paymentHeader = await this.signPayment(response);
464
+ if (!paymentHeader) {
465
+ yield { kind: 'error', payload: { message: 'Payment signing failed' } };
466
+ return;
467
+ }
468
+ response = await withAbortableTimeout(() => fetch(endpoint, {
469
+ method: 'POST',
470
+ headers: { ...headers, ...paymentHeader },
471
+ body: retryBody,
472
+ signal: requestController.signal,
473
+ }), requestController, createModelTimeoutError('request', request.model, requestTimeoutMs), requestTimeoutMs);
474
+ }
475
+ if (!response.ok) {
476
+ const retryBodyText = await response.text().catch(() => 'unknown error');
477
+ yield {
478
+ kind: 'error',
479
+ payload: { status: response.status, message: extractApiErrorMessage(retryBodyText) },
480
+ };
481
+ return;
482
+ }
483
+ // Successful retry — fall through to SSE parsing below.
484
+ }
485
+ else {
486
+ yield {
487
+ kind: 'error',
488
+ payload: { status: response.status, message },
489
+ };
490
+ return;
491
+ }
439
492
  }
440
493
  // Parse SSE stream
441
494
  yield* this.parseSSEStream(response, requestController, streamTimeoutMs, request.model);
package/dist/ui/app.js CHANGED
@@ -14,6 +14,7 @@ import { resolveModel, PICKER_CATEGORIES, PICKER_MODELS_FLAT, } from './model-pi
14
14
  import { estimateCost } from '../pricing.js';
15
15
  import { formatTokens, shortModelName } from '../stats/format.js';
16
16
  import { mouse, forceDisableMouseTracking } from './mouse.js';
17
+ import { resolveAskUserAnswer } from './ask-user-answer.js';
17
18
  // ─── Full-width input box ──────────────────────────────────────────────────
18
19
  const DISABLE_AUTO_WRAP = '\x1b[?7l';
19
20
  const ENABLE_AUTO_WRAP = '\x1b[?7h';
@@ -827,7 +828,12 @@ function RunCodeApp({ initialModel, workDir, walletAddress, walletBalance, chain
827
828
  ? _jsxs(Text, { color: r.ctxPct >= 80 ? 'red' : r.ctxPct >= 50 ? 'yellow' : undefined, dimColor: r.ctxPct < 50, children: [" \u00B7 ctx ", r.ctxPct, "%"] })
828
829
  : ''] }) }))] }, r.key));
829
830
  } }), permissionRequest && (_jsxs(Box, { flexDirection: "column", marginTop: 1, marginLeft: 2, children: [_jsx(Text, { color: "red", bold: true, children: "\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501 \u26A0 ACTION REQUIRED \u26A0 \u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501" }), _jsx(Text, { color: "yellow", children: "\u256D\u2500 Permission required \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500" }), _jsxs(Text, { color: "yellow", children: ["\u2502 ", _jsx(Text, { bold: true, children: permissionRequest.toolName })] }), permissionRequest.description.split('\n').map((line, i) => (_jsxs(Text, { dimColor: true, children: ["\u2502 ", line] }, i))), _jsx(Text, { color: "yellow", children: "\u2570\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500" }), _jsx(Box, { marginLeft: 2, children: _jsxs(Text, { children: [_jsx(Text, { bold: true, color: "green", children: "[y]" }), _jsx(Text, { dimColor: true, children: " yes " }), _jsx(Text, { bold: true, color: "cyan", children: "[a]" }), _jsx(Text, { dimColor: true, children: " always " }), _jsx(Text, { bold: true, color: "red", children: "[n]" }), _jsx(Text, { dimColor: true, children: " no" })] }) })] })), askUserRequest && (_jsxs(Box, { flexDirection: "column", marginTop: 1, marginLeft: 2, children: [_jsx(Text, { color: "magenta", bold: true, children: "\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501 \u26A0 ANSWER REQUIRED \u26A0 \u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501" }), _jsx(Text, { color: "cyan", children: "\u256D\u2500 Question \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500" }), _jsxs(Text, { color: "cyan", children: ["\u2502 ", _jsx(Text, { bold: true, children: askUserRequest.question })] }), askUserRequest.options && askUserRequest.options.length > 0 && (askUserRequest.options.map((opt, i) => (_jsxs(Text, { dimColor: true, children: ["\u2502 ", i + 1, ". ", opt] }, i)))), _jsx(Text, { color: "cyan", children: "\u2570\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500" }), _jsxs(Box, { marginLeft: 2, children: [_jsx(Text, { bold: true, children: "answer> " }), _jsx(TextInput, { value: askUserInput, onChange: setAskUserInput, onSubmit: (val) => {
830
- const answer = val.trim() || '(no response)';
831
+ // resolveAskUserAnswer translates "1" / "2" / ... into the
832
+ // matching label string when the dialog showed a numbered
833
+ // option list. Without it, every onAskUser caller's
834
+ // exact-label match fails for digit answers and silently
835
+ // falls through to the default branch (typically cancel).
836
+ const answer = resolveAskUserAnswer(val, askUserRequest.options);
831
837
  const r = askUserRequest.resolve;
832
838
  setAskUserRequest(null);
833
839
  setAskUserInput('');
@@ -0,0 +1,24 @@
1
+ /**
2
+ * Resolve a user-typed AskUser answer against the option list.
3
+ *
4
+ * The TUI renders option labels as a numbered list ("1. X", "2. Y", …),
5
+ * so users naturally type the digit. Every tool-side onAskUser caller
6
+ * (videogen.ts:113, modal.ts:371, jupiter.ts:368, zerox-base.ts:453,
7
+ * zerox-gasless.ts:446) does an exact-string match against the full
8
+ * label, so a bare "1" silently falls through to the caller's default
9
+ * branch — which is typically "cancel".
10
+ *
11
+ * Verified 2026-05-04 in a live session: user typed "1" twice in a
12
+ * VideoGen flow, both invocations returned "Video generation cancelled
13
+ * (No USDC was spent)" even though the wallet had $94.72 and the
14
+ * Content budget had $2.00 untouched.
15
+ *
16
+ * Translation rules:
17
+ * - "" → "(no response)" (preserve the existing empty-input fallback)
18
+ * - "<digit>" with options.length > 0 and 1 ≤ digit ≤ options.length
19
+ * → the matching label string
20
+ * - anything else → the trimmed input verbatim (callers that match
21
+ * against label can still get a literal answer when the user types
22
+ * it out, and free-form text questions still work the same way)
23
+ */
24
+ export declare function resolveAskUserAnswer(raw: string, options: readonly string[] | undefined): string;
@@ -0,0 +1,34 @@
1
+ /**
2
+ * Resolve a user-typed AskUser answer against the option list.
3
+ *
4
+ * The TUI renders option labels as a numbered list ("1. X", "2. Y", …),
5
+ * so users naturally type the digit. Every tool-side onAskUser caller
6
+ * (videogen.ts:113, modal.ts:371, jupiter.ts:368, zerox-base.ts:453,
7
+ * zerox-gasless.ts:446) does an exact-string match against the full
8
+ * label, so a bare "1" silently falls through to the caller's default
9
+ * branch — which is typically "cancel".
10
+ *
11
+ * Verified 2026-05-04 in a live session: user typed "1" twice in a
12
+ * VideoGen flow, both invocations returned "Video generation cancelled
13
+ * (No USDC was spent)" even though the wallet had $94.72 and the
14
+ * Content budget had $2.00 untouched.
15
+ *
16
+ * Translation rules:
17
+ * - "" → "(no response)" (preserve the existing empty-input fallback)
18
+ * - "<digit>" with options.length > 0 and 1 ≤ digit ≤ options.length
19
+ * → the matching label string
20
+ * - anything else → the trimmed input verbatim (callers that match
21
+ * against label can still get a literal answer when the user types
22
+ * it out, and free-form text questions still work the same way)
23
+ */
24
+ export function resolveAskUserAnswer(raw, options) {
25
+ const trimmed = raw.trim();
26
+ if (!trimmed)
27
+ return '(no response)';
28
+ if (options && options.length > 0 && /^\d+$/.test(trimmed)) {
29
+ const idx = parseInt(trimmed, 10) - 1;
30
+ if (idx >= 0 && idx < options.length)
31
+ return options[idx];
32
+ }
33
+ return trimmed;
34
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@blockrun/franklin",
3
- "version": "3.15.49",
3
+ "version": "3.15.51",
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": {