@blockrun/franklin 3.25.1 → 3.25.3
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 +84 -18
- package/dist/commands/start.js +6 -0
- package/dist/ui/app.js +48 -42
- package/dist/ui/paste-heuristics.d.ts +19 -0
- package/dist/ui/paste-heuristics.js +40 -0
- package/package.json +1 -1
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
|
-
// ──
|
|
485
|
-
//
|
|
486
|
-
// "developer"
|
|
487
|
-
//
|
|
488
|
-
|
|
489
|
-
|
|
490
|
-
|
|
491
|
-
|
|
492
|
-
|
|
493
|
-
|
|
494
|
-
|
|
495
|
-
|
|
496
|
-
|
|
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(() =>
|
|
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(() =>
|
|
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(() =>
|
|
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(() =>
|
|
661
|
+
response = await withAbortableTimeout(() => fetchWithUnwrappedCause(endpoint, {
|
|
596
662
|
method: 'POST',
|
|
597
663
|
headers: { ...headers, ...paymentHeader },
|
|
598
664
|
body: retryBody,
|
package/dist/commands/start.js
CHANGED
|
@@ -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
|
@@ -19,6 +19,7 @@ import { estimateCost } from '../pricing.js';
|
|
|
19
19
|
import { formatTokens, shortModelName } from '../stats/format.js';
|
|
20
20
|
import { mouse, forceDisableMouseTracking } from './mouse.js';
|
|
21
21
|
import { resolveAskUserAnswer } from './ask-user-answer.js';
|
|
22
|
+
import { looksLikeImagePasteStub } from './paste-heuristics.js';
|
|
22
23
|
// ─── Full-width input box ──────────────────────────────────────────────────
|
|
23
24
|
const BRACKETED_PASTE_START = '[200~';
|
|
24
25
|
const BRACKETED_PASTE_END = '[201~';
|
|
@@ -445,53 +446,58 @@ function PromptTextInput({ value, onChange, onSubmit, placeholder = '', focus =
|
|
|
445
446
|
const buffered = pasteBufferRef.current;
|
|
446
447
|
pasteBufferRef.current = '';
|
|
447
448
|
pasteActiveRef.current = false;
|
|
448
|
-
// Image-paste detection.
|
|
449
|
-
//
|
|
450
|
-
//
|
|
451
|
-
//
|
|
452
|
-
//
|
|
453
|
-
//
|
|
454
|
-
//
|
|
455
|
-
//
|
|
456
|
-
//
|
|
457
|
-
// the skip.
|
|
458
|
-
//
|
|
459
|
-
// Fix: ALWAYS probe the clipboard on a paste-end. If an image is there,
|
|
460
|
-
// it wins; the bracketed-paste buffer text is treated as terminal noise
|
|
461
|
-
// and dropped. If no image, fall through to the normal text-paste path
|
|
462
|
-
// (collapse-to-block or inline) so text pastes are unaffected. The
|
|
463
|
-
// probe is async (osascript / xclip / wl-paste shell-out, 30-100 ms),
|
|
464
|
-
// so the handler returns immediately and updateValue happens when the
|
|
465
|
-
// Promise resolves.
|
|
449
|
+
// Image-paste detection. Cmd+V on a clipboard image arrives as an empty
|
|
450
|
+
// bracketed paste on macOS Terminal/iTerm2; several Linux terminals
|
|
451
|
+
// instead emit a filename, a `file://` URI, or the raw image header
|
|
452
|
+
// alongside it (3.25.0 only probed on an empty buffer, so those Linux
|
|
453
|
+
// shapes silently dropped the image — fixed in #77). We probe the system
|
|
454
|
+
// clipboard for an image only when the buffer *looks* like one of those
|
|
455
|
+
// stubs; genuine text is inserted synchronously below so the common
|
|
456
|
+
// paste path never waits on the async osascript / xclip / wl-paste
|
|
457
|
+
// shell-out (30-100 ms, but a cold spawn can be more).
|
|
466
458
|
const insertAt = currentCursorOffset;
|
|
467
|
-
|
|
468
|
-
if (
|
|
469
|
-
// Image wins — drop the bracketed-paste buffer (likely a stub the
|
|
470
|
-
// terminal sent for the image).
|
|
471
|
-
const injected = encodeImageBlock(img.path);
|
|
472
|
-
const cur = valueRef.current;
|
|
473
|
-
const at = Math.min(insertAt, cur.length);
|
|
474
|
-
updateValue(cur.slice(0, at) + injected + cur.slice(at), at + injected.length);
|
|
459
|
+
const insertPastedText = (buf, baseOffset) => {
|
|
460
|
+
if (buf.length === 0)
|
|
475
461
|
return;
|
|
476
|
-
|
|
477
|
-
if (img && 'error' in img) {
|
|
478
|
-
const injected = `[Image rejected: ${img.error}] `;
|
|
479
|
-
const cur = valueRef.current;
|
|
480
|
-
const at = Math.min(insertAt, cur.length);
|
|
481
|
-
updateValue(cur.slice(0, at) + injected + cur.slice(at), at + injected.length);
|
|
482
|
-
return;
|
|
483
|
-
}
|
|
484
|
-
// No image on the clipboard — treat as a normal text paste.
|
|
485
|
-
if (buffered.length === 0)
|
|
486
|
-
return;
|
|
487
|
-
const lineCount = buffered.split('\n').length;
|
|
462
|
+
const lineCount = buf.split('\n').length;
|
|
488
463
|
const textToInsert = lineCount >= PASTE_COLLAPSE_LINE_THRESHOLD
|
|
489
|
-
? encodePasteBlock(
|
|
490
|
-
:
|
|
464
|
+
? encodePasteBlock(buf)
|
|
465
|
+
: buf;
|
|
491
466
|
const cur = valueRef.current;
|
|
492
|
-
const at = Math.min(
|
|
467
|
+
const at = Math.min(baseOffset, cur.length);
|
|
493
468
|
updateValue(cur.slice(0, at) + textToInsert + cur.slice(at), at + textToInsert.length);
|
|
494
|
-
}
|
|
469
|
+
};
|
|
470
|
+
if (looksLikeImagePasteStub(buffered)) {
|
|
471
|
+
// The probe is async; the handler returns now and updateValue happens
|
|
472
|
+
// when the Promise resolves. insertAt (captured above) pins the result
|
|
473
|
+
// to where the user pasted even if the cursor moved meanwhile.
|
|
474
|
+
tryReadClipboardImage().then((img) => {
|
|
475
|
+
if (img && 'path' in img) {
|
|
476
|
+
// Image wins — drop the bracketed-paste buffer (the terminal stub).
|
|
477
|
+
const injected = encodeImageBlock(img.path);
|
|
478
|
+
const cur = valueRef.current;
|
|
479
|
+
const at = Math.min(insertAt, cur.length);
|
|
480
|
+
updateValue(cur.slice(0, at) + injected + cur.slice(at), at + injected.length);
|
|
481
|
+
return;
|
|
482
|
+
}
|
|
483
|
+
if (img && 'error' in img) {
|
|
484
|
+
const injected = `[Image rejected: ${img.error}] `;
|
|
485
|
+
const cur = valueRef.current;
|
|
486
|
+
const at = Math.min(insertAt, cur.length);
|
|
487
|
+
updateValue(cur.slice(0, at) + injected + cur.slice(at), at + injected.length);
|
|
488
|
+
return;
|
|
489
|
+
}
|
|
490
|
+
// No image after all — the stub was literal text (e.g. a lone
|
|
491
|
+
// "photo.png" the user actually typed). Insert it as text.
|
|
492
|
+
insertPastedText(buffered, insertAt);
|
|
493
|
+
}).catch(() => {
|
|
494
|
+
// Probe failed unexpectedly — don't lose the paste; insert as text.
|
|
495
|
+
insertPastedText(buffered, insertAt);
|
|
496
|
+
});
|
|
497
|
+
return;
|
|
498
|
+
}
|
|
499
|
+
// Genuine text paste — insert synchronously, no clipboard probe.
|
|
500
|
+
insertPastedText(buffered, currentCursorOffset);
|
|
495
501
|
return;
|
|
496
502
|
}
|
|
497
503
|
if (!text) {
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Pure heuristics for the bracketed-paste handler in app.tsx, split out so they
|
|
3
|
+
* can be unit-tested without pulling in Ink/React.
|
|
4
|
+
*/
|
|
5
|
+
/**
|
|
6
|
+
* Does a bracketed-paste buffer look like a terminal's stand-in for an image
|
|
7
|
+
* paste rather than genuine pasted text?
|
|
8
|
+
*
|
|
9
|
+
* Cmd+V on a clipboard image yields an *empty* buffer on macOS Terminal/iTerm2,
|
|
10
|
+
* but several Linux terminals instead emit a filename, a `file://` URI, or the
|
|
11
|
+
* raw image header alongside the paste. Those shapes — and only those — warrant
|
|
12
|
+
* the (async, 30-100 ms) clipboard probe in app.tsx. Substantial text returns
|
|
13
|
+
* `false` so it can be inserted synchronously, keeping the common paste path
|
|
14
|
+
* instant instead of waiting on an osascript / xclip / wl-paste shell-out.
|
|
15
|
+
*
|
|
16
|
+
* False positives are harmless: a literal text paste of "photo.png" probes the
|
|
17
|
+
* clipboard, finds no image, and falls through to the text path anyway.
|
|
18
|
+
*/
|
|
19
|
+
export declare function looksLikeImagePasteStub(buffered: string): boolean;
|
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Pure heuristics for the bracketed-paste handler in app.tsx, split out so they
|
|
3
|
+
* can be unit-tested without pulling in Ink/React.
|
|
4
|
+
*/
|
|
5
|
+
// Image filenames a terminal might substitute for a pasted image. Anchored, so
|
|
6
|
+
// only a string that *is* such a path matches — not prose that mentions one.
|
|
7
|
+
const IMAGE_FILE_EXT = /\.(png|jpe?g|gif|webp|bmp|tiff?|heic|heif|avif)$/i;
|
|
8
|
+
/**
|
|
9
|
+
* Does a bracketed-paste buffer look like a terminal's stand-in for an image
|
|
10
|
+
* paste rather than genuine pasted text?
|
|
11
|
+
*
|
|
12
|
+
* Cmd+V on a clipboard image yields an *empty* buffer on macOS Terminal/iTerm2,
|
|
13
|
+
* but several Linux terminals instead emit a filename, a `file://` URI, or the
|
|
14
|
+
* raw image header alongside the paste. Those shapes — and only those — warrant
|
|
15
|
+
* the (async, 30-100 ms) clipboard probe in app.tsx. Substantial text returns
|
|
16
|
+
* `false` so it can be inserted synchronously, keeping the common paste path
|
|
17
|
+
* instant instead of waiting on an osascript / xclip / wl-paste shell-out.
|
|
18
|
+
*
|
|
19
|
+
* False positives are harmless: a literal text paste of "photo.png" probes the
|
|
20
|
+
* clipboard, finds no image, and falls through to the text path anyway.
|
|
21
|
+
*/
|
|
22
|
+
export function looksLikeImagePasteStub(buffered) {
|
|
23
|
+
// macOS image paste: empty / whitespace-only bracketed paste.
|
|
24
|
+
if (buffered.trim().length === 0)
|
|
25
|
+
return true;
|
|
26
|
+
// Raw binary the terminal dumped (e.g. PNG's \x1a, or bytes that failed
|
|
27
|
+
// UTF-8 decoding into U+FFFD). Genuine text never carries control chars
|
|
28
|
+
// other than tab / newline / carriage return.
|
|
29
|
+
if (/[\x00-\x08\x0b\x0c\x0e-\x1f�]/.test(buffered))
|
|
30
|
+
return true;
|
|
31
|
+
// A single-line file reference the terminal sent instead of the image bytes.
|
|
32
|
+
const trimmed = buffered.trim();
|
|
33
|
+
if (!trimmed.includes('\n')) {
|
|
34
|
+
if (/^file:\/\//i.test(trimmed))
|
|
35
|
+
return true; // file:// URI
|
|
36
|
+
if (IMAGE_FILE_EXT.test(trimmed))
|
|
37
|
+
return true; // bare filename / path
|
|
38
|
+
}
|
|
39
|
+
return false;
|
|
40
|
+
}
|
package/package.json
CHANGED