@blockrun/franklin 3.25.2 → 3.25.4

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
@@ -102,6 +102,58 @@ function linkAbortSignal(parent, child) {
102
102
  function createModelTimeoutError(stage, model, timeoutMs) {
103
103
  return new Error(`Model ${stage} timed out after ${timeoutMs}ms on ${model}`);
104
104
  }
105
+ /**
106
+ * Walk a tool-schema object and drop any `enum` whose entries are strings
107
+ * containing "/". Grok's request validator rejects such enums outright (see
108
+ * the call site for the verbatim upstream error). The model still sees the
109
+ * intended values via the tool's description text, so dropping the schema-
110
+ * level constraint is purely a compatibility shim — no behavioral loss.
111
+ */
112
+ function stripSlashEnumsForGrok(node) {
113
+ if (Array.isArray(node))
114
+ return node.map(stripSlashEnumsForGrok);
115
+ if (!node || typeof node !== 'object')
116
+ return node;
117
+ const out = {};
118
+ for (const [k, v] of Object.entries(node)) {
119
+ if (k === 'enum' &&
120
+ Array.isArray(v) &&
121
+ v.some((x) => typeof x === 'string' && x.includes('/'))) {
122
+ continue; // drop the constraint entirely
123
+ }
124
+ out[k] = stripSlashEnumsForGrok(v);
125
+ }
126
+ return out;
127
+ }
128
+ /**
129
+ * Wrap `fetch()` so that undici's opaque `TypeError: fetch failed` is
130
+ * replaced with the underlying network reason (ECONNRESET, UND_ERR_*,
131
+ * certificate, DNS, etc.). Without this, every transient connection blip
132
+ * surfaces to the user as "Network: fetch failed" with no way to tell
133
+ * whether it's their network, the gateway, or the upstream provider.
134
+ *
135
+ * Verified 2026-06-03: stress-testing claude-sonnet-4.6 reproduces
136
+ * intermittent "fetch failed" (cheetah's report on 3.25.0). With this
137
+ * helper the message becomes e.g. "fetch failed (UND_ERR_SOCKET: other
138
+ * side closed)" which is actionable.
139
+ */
140
+ async function fetchWithUnwrappedCause(url, init) {
141
+ try {
142
+ return await fetch(url, init);
143
+ }
144
+ catch (err) {
145
+ if (err instanceof Error && err.message === 'fetch failed' && err.cause) {
146
+ const cause = err.cause;
147
+ const detail = cause.code || cause.errno || cause.message;
148
+ if (detail) {
149
+ const enriched = new Error(`fetch failed (${detail})`);
150
+ enriched.cause = err.cause;
151
+ throw enriched;
152
+ }
153
+ }
154
+ throw err;
155
+ }
156
+ }
105
157
  async function withAbortableTimeout(work, controller, timeoutError, timeoutMs) {
106
158
  if (timeoutMs <= 0)
107
159
  return work();
@@ -416,6 +468,20 @@ export class ModelClient {
416
468
  if (requestPayload['tool_choice'] !== undefined && modelDoesNotSupportToolChoice(request.model)) {
417
469
  delete requestPayload['tool_choice'];
418
470
  }
471
+ // ── Grok: strip enum constraints containing "/" from tool schemas ────────
472
+ // Verified 2026-06-03 via Franklin repro: xAI's request validator hard-
473
+ // rejects any tool-schema enum string containing "/", e.g.
474
+ // "[engine_imposed] /properties/endpoint/enum/0: '/' in 'enum' string
475
+ // value is currently not supported"
476
+ // The Surf tools (and a few others) use endpoint paths like
477
+ // "market/ranking" as enum values to constrain the model's choice. The
478
+ // path list is also enumerated in each tool's description text, so the
479
+ // model still sees the legal values — only the schema-level constraint
480
+ // gets dropped. Other providers keep the enum unchanged.
481
+ if (request.model.startsWith('xai/') && Array.isArray(requestPayload['tools'])) {
482
+ const tools = requestPayload['tools'];
483
+ requestPayload['tools'] = tools.map((tool) => stripSlashEnumsForGrok(tool));
484
+ }
419
485
  // ── GLM-specific optimizations ───────────────────────────────────────────
420
486
  // GLM models work best with temperature=0.8 per official zai spec.
421
487
  // Enable thinking mode only for explicit reasoning variants (-thinking-).
@@ -481,20 +547,20 @@ export class ModelClient {
481
547
  // session reached ≥3 messages (system + tool + 3 = 5). See issue #73.
482
548
  requestPayload = applyAnthropicPromptCaching(requestPayload, request);
483
549
  }
484
- // ── GPT-5 / Codex: use "developer" role for system prompt ──────────────
485
- // OpenAI GPT models give stronger instruction-following weight to the
486
- // "developer" role. Move the top-level system prompt into messages[0]
487
- // with role "developer" instead of the default "system".
488
- const isGPT5OrCodex = request.model.includes('gpt-5') || request.model.includes('codex');
489
- if (isGPT5OrCodex && typeof request.system === 'string' && request.system.length > 0) {
490
- const systemRole = 'developer';
491
- const existingMessages = requestPayload['messages'] || [];
492
- requestPayload['messages'] = [
493
- { role: systemRole, content: request.system },
494
- ...existingMessages,
495
- ];
496
- delete requestPayload['system'];
497
- }
550
+ // ── No client-side system developer role rewrite for GPT-5/Codex ─────
551
+ // We used to move the top-level `system` field into `messages[0]` with
552
+ // role "developer" for GPT-5/Codex (OpenAI docs say that role gets
553
+ // stronger instruction-following weight). But the BlockRun gateway
554
+ // speaks Anthropic Messages, which only accepts user|assistant in
555
+ // messages[] the developer-role payload returns HTTP 400 from the
556
+ // gateway's protocol validator BEFORE it ever reaches OpenAI:
557
+ // {"error":{"message":"messages.0.role: Invalid option: expected
558
+ // one of \"user\"|\"assistant\""}}
559
+ // Verified 2026-06-03 via direct curl + Franklin repro: all GPT-5
560
+ // family models (mini/nano/5.4/5.5) were silently failing under
561
+ // headless -p mode. Keep `system` as a top-level field and let the
562
+ // gateway translate to whatever the upstream needs (it already knows
563
+ // gpt-5 expects developer role internally).
498
564
  const body = JSON.stringify(requestPayload);
499
565
  const endpoint = `${this.apiUrl}/v1/messages`;
500
566
  const headers = {
@@ -515,7 +581,7 @@ export class ModelClient {
515
581
  const requestController = new AbortController();
516
582
  const unlinkAbort = linkAbortSignal(signal, requestController);
517
583
  try {
518
- let response = await withAbortableTimeout(() => fetch(endpoint, {
584
+ let response = await withAbortableTimeout(() => fetchWithUnwrappedCause(endpoint, {
519
585
  method: 'POST',
520
586
  headers,
521
587
  body,
@@ -530,7 +596,7 @@ export class ModelClient {
530
596
  yield { kind: 'error', payload: { message: 'Payment signing failed' } };
531
597
  return;
532
598
  }
533
- response = await withAbortableTimeout(() => fetch(endpoint, {
599
+ response = await withAbortableTimeout(() => fetchWithUnwrappedCause(endpoint, {
534
600
  method: 'POST',
535
601
  headers: { ...headers, ...paymentHeader },
536
602
  body,
@@ -580,7 +646,7 @@ export class ModelClient {
580
646
  if (this.debug) {
581
647
  console.error(`[franklin] tool_choice rejected by upstream; retrying without it (model=${request.model})`);
582
648
  }
583
- response = await withAbortableTimeout(() => fetch(endpoint, {
649
+ response = await withAbortableTimeout(() => fetchWithUnwrappedCause(endpoint, {
584
650
  method: 'POST',
585
651
  headers,
586
652
  body: retryBody,
@@ -592,7 +658,7 @@ export class ModelClient {
592
658
  yield { kind: 'error', payload: { message: 'Payment signing failed' } };
593
659
  return;
594
660
  }
595
- response = await withAbortableTimeout(() => fetch(endpoint, {
661
+ response = await withAbortableTimeout(() => fetchWithUnwrappedCause(endpoint, {
596
662
  method: 'POST',
597
663
  headers: { ...headers, ...paymentHeader },
598
664
  body: retryBody,
@@ -420,6 +420,12 @@ async function runOneShot(agentConfig, prompt) {
420
420
  }
421
421
  else if (event.kind === 'turn_done') {
422
422
  exitCode = oneShotExitCodeForTurnReason(event.reason);
423
+ // Without this, headless callers see exit 1 + zero stderr — impossible
424
+ // to triage. Verified 2026-06-03: GPT-5 family failing with HTTP 400
425
+ // from the gateway looked identical to a network timeout in `-p` mode.
426
+ if (event.reason !== 'completed' && event.error) {
427
+ process.stderr.write(`\n${event.error}\n`);
428
+ }
423
429
  process.stdout.write('\n');
424
430
  }
425
431
  });
package/dist/ui/app.js CHANGED
@@ -61,6 +61,21 @@ function encodePasteBlock(content) {
61
61
  function encodeImageBlock(absolutePath) {
62
62
  return `${IMG_BLOCK_START}${Buffer.from(absolutePath, 'utf8').toString('base64')}${IMG_BLOCK_END}`;
63
63
  }
64
+ /**
65
+ * Probe the clipboard for an image and return the input-block to splice in at
66
+ * the cursor — an encoded `[IMG:…]` block on success, an inline
67
+ * `[Image rejected: …]` notice if the image was found but unusable, or null
68
+ * when there's no image. Shared by PromptTextInput's Ctrl+V path and VimInput
69
+ * (which renders instead of PromptTextInput in vim mode).
70
+ */
71
+ async function readClipboardImageInjection() {
72
+ const img = await tryReadClipboardImage();
73
+ if (img && 'path' in img)
74
+ return encodeImageBlock(img.path);
75
+ if (img && 'error' in img)
76
+ return `[Image rejected: ${img.error}] `;
77
+ return null;
78
+ }
64
79
  function decodeBlockPayload(token, startMarker, endMarker) {
65
80
  if (!token.startsWith(startMarker) || !token.endsWith(endMarker))
66
81
  return token;
@@ -126,6 +141,15 @@ function decodePromptValue(value) {
126
141
  }
127
142
  return decoded + value.slice(cursor);
128
143
  }
144
+ function promptValueForDisplay(value) {
145
+ let rendered = '';
146
+ let cursor = 0;
147
+ for (const block of findPasteBlocks(value)) {
148
+ rendered += value.slice(cursor, block.start) + pasteSummary(block);
149
+ cursor = block.end;
150
+ }
151
+ return rendered + value.slice(cursor);
152
+ }
129
153
  /**
130
154
  * Read the system clipboard, and if it currently holds an image, save it to
131
155
  * a temp file and return the absolute path. Otherwise return null.
@@ -378,6 +402,15 @@ function PromptTextInput({ value, onChange, onSubmit, placeholder = '', focus =
378
402
  onChange(nextValue);
379
403
  setCursorOffset(cursorOffsetRef.current);
380
404
  }, [onChange]);
405
+ const insertClipboardImageAt = useCallback((insertAt) => {
406
+ readClipboardImageInjection().then((injected) => {
407
+ if (!injected)
408
+ return; // no image on clipboard — nothing to do
409
+ const cur = valueRef.current;
410
+ const at = Math.min(insertAt, cur.length);
411
+ updateValue(cur.slice(0, at) + injected + cur.slice(at), at + injected.length);
412
+ }).catch(() => { });
413
+ }, [updateValue]);
381
414
  useInput((input, key) => {
382
415
  if (!focus)
383
416
  return;
@@ -393,7 +426,7 @@ function PromptTextInput({ value, onChange, onSubmit, placeholder = '', focus =
393
426
  pasteBufferRef.current = '';
394
427
  }
395
428
  if (key.return && !isPasting) {
396
- onSubmit(decodePromptValue(currentValue));
429
+ onSubmit(currentValue);
397
430
  return;
398
431
  }
399
432
  if (key.home || (key.ctrl && input === 'a')) {
@@ -434,6 +467,13 @@ function PromptTextInput({ value, onChange, onSubmit, placeholder = '', focus =
434
467
  }
435
468
  return;
436
469
  }
470
+ // Some Linux terminals do not emit a bracketed-paste event for image-only
471
+ // clipboard contents. Ctrl+V gives users a raw-key fallback that probes the
472
+ // same clipboard image path without relying on terminal paste behavior.
473
+ if (key.ctrl && input === 'v') {
474
+ insertClipboardImageAt(currentCursorOffset);
475
+ return;
476
+ }
437
477
  if (key.upArrow || key.downArrow || key.tab || key.ctrl || key.meta)
438
478
  return;
439
479
  let text = normalizeInputNewlines(stripPasteMarkers(input));
@@ -515,7 +555,7 @@ function PromptTextInput({ value, onChange, onSubmit, placeholder = '', focus =
515
555
  return _jsx(Text, { children: rendered });
516
556
  }
517
557
  function formatUserPromptForDisplay(value) {
518
- return `❯ ${decodePromptValue(value)}`;
558
+ return `❯ ${promptValueForDisplay(value)}`;
519
559
  }
520
560
  function disableTerminalAutoWrap() {
521
561
  if (!process.stdout.isTTY)
@@ -601,7 +641,7 @@ function InputBox({ input, setInput, onSubmit, model, balance, chain, walletTail
601
641
  const leadingGlyph = (awaitingApproval || awaitingAnswer)
602
642
  ? _jsx(Text, { color: "yellow", bold: true, children: "\u26A0 " })
603
643
  : (showSpinner ? _jsxs(Text, { color: "yellow", children: [_jsx(Spinner, { type: "dots" }), " "] }) : null);
604
- return (_jsxs(Box, { flexDirection: "column", marginTop: 1, children: [_jsxs(Box, { borderStyle: "round", borderColor: borderColor, borderDimColor: !borderColor, paddingX: 1, width: boxWidth, children: [leadingGlyph, _jsx(Box, { flexGrow: 1, children: vimMode ? (_jsx(VimInput, { value: input, onChange: setInput, onSubmit: onSubmit, placeholder: placeholder, focus: focused !== false, showMode: true, onModeChange: onVimModeChange })) : (_jsx(PromptTextInput, { value: input, onChange: setInput, onSubmit: onSubmit, placeholder: placeholder, focus: focused !== false })) })] }), _jsx(Box, { marginLeft: 2, children: _jsxs(Text, { dimColor: true, children: [busy ? _jsx(Text, { color: "yellow", children: _jsx(Spinner, { type: "dots" }) }) : null, busy ? ' ' : '', shortModelName(model), " \u00B7 ", (() => {
644
+ return (_jsxs(Box, { flexDirection: "column", marginTop: 1, children: [_jsxs(Box, { borderStyle: "round", borderColor: borderColor, borderDimColor: !borderColor, paddingX: 1, width: boxWidth, children: [leadingGlyph, _jsx(Box, { flexGrow: 1, children: vimMode ? (_jsx(VimInput, { value: input, onChange: setInput, onSubmit: onSubmit, placeholder: placeholder, focus: focused !== false, showMode: true, onModeChange: onVimModeChange, onClipboardImage: readClipboardImageInjection })) : (_jsx(PromptTextInput, { value: input, onChange: setInput, onSubmit: onSubmit, placeholder: placeholder, focus: focused !== false })) })] }), _jsx(Box, { marginLeft: 2, children: _jsxs(Text, { dimColor: true, children: [busy ? _jsx(Text, { color: "yellow", children: _jsx(Spinner, { type: "dots" }) }) : null, busy ? ' ' : '', shortModelName(model), " \u00B7 ", (() => {
605
645
  // Color the balance by funding state. Real session 2026-05-04
606
646
  // had a user staring at "$0.08 USDC" in dim text wondering
607
647
  // whether it meant "out of money" or "wrong chain". Make
@@ -1042,17 +1082,20 @@ function RunCodeApp({ initialModel, workDir, walletAddress, walletBalance, chain
1042
1082
  turnTierRef.current = undefined;
1043
1083
  turnSavingsRef.current = undefined;
1044
1084
  turnCtxPctRef.current = undefined;
1045
- onSubmit(lastPrompt);
1085
+ onSubmit(decodePromptValue(lastPrompt).trim());
1046
1086
  return;
1047
1087
  default:
1048
- // All other slash commands pass through to the agent loop's command registry
1088
+ // All other slash commands pass through to the agent loop's command registry.
1089
+ // Decode here too: a slash command can carry an encoded paste/image block
1090
+ // as an argument, and the registry expects real text / file paths,
1091
+ // not the encoded block sentinels.
1049
1092
  setStreamText('');
1050
1093
  setThinking(false);
1051
1094
  setThinkingText('');
1052
1095
  setTools(new Map());
1053
1096
  setWaiting(true);
1054
1097
  setReady(false);
1055
- onSubmit(trimmed);
1098
+ onSubmit(decodePromptValue(trimmed).trim());
1056
1099
  return;
1057
1100
  }
1058
1101
  }
@@ -1091,7 +1134,7 @@ function RunCodeApp({ initialModel, workDir, walletAddress, walletBalance, chain
1091
1134
  turnTierRef.current = undefined;
1092
1135
  turnSavingsRef.current = undefined;
1093
1136
  turnCtxPctRef.current = undefined;
1094
- onSubmit(trimmed);
1137
+ onSubmit(decodePromptValue(trimmed).trim());
1095
1138
  }, [ready, currentModel, totalCost, onSubmit, onModelChange, requestExit, lastPrompt, inputHistory, showStatus]);
1096
1139
  // Mouse support — OFF by default because Node stdin is shared: mouse escape
1097
1140
  // sequences leak into Ink's input handler as typed text. Opt in with
@@ -14,6 +14,11 @@ interface VimInputProps {
14
14
  focus?: boolean;
15
15
  showMode?: boolean;
16
16
  onModeChange?: (mode: VimMode) => void;
17
+ /** Probe the clipboard for an image and return the input-block to splice in
18
+ * (or null if there's no image). Wired to the same path as PromptTextInput's
19
+ * Ctrl+V fallback so vim-mode users on terminals that don't emit a
20
+ * bracketed-paste event for images can still paste. */
21
+ onClipboardImage?: () => Promise<string | null>;
17
22
  }
18
- export default function VimInput({ value, onChange, onSubmit, placeholder, focus, showMode, onModeChange, }: VimInputProps): import("react/jsx-runtime").JSX.Element;
23
+ export default function VimInput({ value, onChange, onSubmit, placeholder, focus, showMode, onModeChange, onClipboardImage, }: VimInputProps): import("react/jsx-runtime").JSX.Element;
19
24
  export {};
@@ -56,13 +56,18 @@ function endWord(text, pos) {
56
56
  i++;
57
57
  return Math.min(i, text.length - 1);
58
58
  }
59
- export default function VimInput({ value, onChange, onSubmit, placeholder = '', focus = true, showMode = true, onModeChange, }) {
59
+ export default function VimInput({ value, onChange, onSubmit, placeholder = '', focus = true, showMode = true, onModeChange, onClipboardImage, }) {
60
60
  const [mode, setMode] = useState('insert');
61
61
  const [cursor, setCursor] = useState(value.length);
62
62
  const [cmdBuf, setCmdBuf] = useState(''); // accumulated command buffer (for counts + operators)
63
63
  const [yankBuf, setYankBuf] = useState(''); // internal clipboard
64
64
  const [undoStack, setUndoStack] = useState([]); // simple undo
65
65
  const lastValueRef = useRef(value);
66
+ // Mirror the latest value prop every render so the async Ctrl+V clipboard
67
+ // insert (which resolves after the keypress) never splices into a stale
68
+ // string when the parent swaps `value` mid-probe — e.g. a submit clears the
69
+ // input, or another paste path writes first.
70
+ lastValueRef.current = value;
66
71
  // Keep cursor in bounds when value changes externally
67
72
  const clampedCursor = Math.min(cursor, mode === 'normal' ? Math.max(0, value.length - 1) : value.length);
68
73
  const switchMode = useCallback((newMode) => {
@@ -155,6 +160,24 @@ export default function VimInput({ value, onChange, onSubmit, placeholder = '',
155
160
  updateValue(value.slice(0, clampedCursor), clampedCursor);
156
161
  return;
157
162
  }
163
+ // Ctrl+V: clipboard-image fallback for terminals that don't emit a
164
+ // bracketed-paste event for image-only clipboards. Probe is async, so the
165
+ // handler returns now and updateValue happens when it resolves; capture
166
+ // the cursor offset so the block lands where the user pasted.
167
+ if (key.ctrl && input === 'v') {
168
+ if (onClipboardImage) {
169
+ const at = clampedCursor;
170
+ saveUndo();
171
+ onClipboardImage().then((injected) => {
172
+ if (!injected)
173
+ return;
174
+ const cur = lastValueRef.current;
175
+ const pos = Math.min(at, cur.length);
176
+ updateValue(cur.slice(0, pos) + injected + cur.slice(pos), pos + injected.length);
177
+ }).catch(() => { });
178
+ }
179
+ return;
180
+ }
158
181
  // Skip control chars and tab
159
182
  if (key.ctrl || key.meta || key.tab)
160
183
  return;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@blockrun/franklin",
3
- "version": "3.25.2",
3
+ "version": "3.25.4",
4
4
  "description": "Franklin Agent — 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": {