@blockrun/franklin 3.25.2 → 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 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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@blockrun/franklin",
3
- "version": "3.25.2",
3
+ "version": "3.25.3",
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": {