@blockrun/franklin 3.8.32 → 3.8.34

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.
@@ -68,7 +68,7 @@ export function classifyAgentError(message) {
68
68
  'deadline exceeded',
69
69
  ])) {
70
70
  return {
71
- category: 'timeout', label: 'Timeout', isTransient: true,
71
+ category: 'timeout', label: 'Timeout', isTransient: true, maxRetries: 1,
72
72
  suggestion: 'Check your network connection. Use /retry to try again.',
73
73
  };
74
74
  }
@@ -84,7 +84,7 @@ export function classifyAgentError(message) {
84
84
  'dns resolution',
85
85
  ])) {
86
86
  return {
87
- category: 'network', label: 'Network', isTransient: true,
87
+ category: 'network', label: 'Network', isTransient: true, maxRetries: 1,
88
88
  suggestion: 'Check your network connection. Use /retry to try again.',
89
89
  };
90
90
  }
package/dist/agent/llm.js CHANGED
@@ -5,7 +5,60 @@
5
5
  */
6
6
  import { getOrCreateWallet, getOrCreateSolanaWallet, createPaymentPayload, createSolanaPaymentPayload, parsePaymentRequired, extractPaymentDetails, solanaKeyToBytes, SOLANA_NETWORK, } from '@blockrun/llm';
7
7
  import { USER_AGENT } from '../config.js';
8
+ import { routeRequest, parseRoutingProfile } from '../router/index.js';
8
9
  import { ThinkTagStripper } from './think-tag-stripper.js';
10
+ function parseTimeoutEnv(name) {
11
+ const raw = process.env[name];
12
+ const parsed = raw ? Number.parseInt(raw, 10) : NaN;
13
+ return Number.isFinite(parsed) && parsed >= 0 ? parsed : null;
14
+ }
15
+ function getModelRequestTimeoutMs() {
16
+ return (parseTimeoutEnv('FRANKLIN_MODEL_REQUEST_TIMEOUT_MS') ??
17
+ parseTimeoutEnv('FRANKLIN_MODEL_IDLE_TIMEOUT_MS') ??
18
+ 8_000);
19
+ }
20
+ function getModelStreamIdleTimeoutMs() {
21
+ return (parseTimeoutEnv('FRANKLIN_MODEL_STREAM_IDLE_TIMEOUT_MS') ??
22
+ parseTimeoutEnv('FRANKLIN_MODEL_IDLE_TIMEOUT_MS') ??
23
+ 25_000);
24
+ }
25
+ function linkAbortSignal(parent, child) {
26
+ if (!parent)
27
+ return () => { };
28
+ if (parent.aborted) {
29
+ child.abort(parent.reason);
30
+ return () => { };
31
+ }
32
+ const forward = () => child.abort(parent.reason);
33
+ parent.addEventListener('abort', forward, { once: true });
34
+ return () => parent.removeEventListener('abort', forward);
35
+ }
36
+ function createModelTimeoutError(stage, model, timeoutMs) {
37
+ return new Error(`Model ${stage} timed out after ${timeoutMs}ms on ${model}`);
38
+ }
39
+ async function withAbortableTimeout(work, controller, timeoutError, timeoutMs) {
40
+ if (timeoutMs <= 0)
41
+ return work();
42
+ let timer;
43
+ try {
44
+ return await Promise.race([
45
+ work(),
46
+ new Promise((_, reject) => {
47
+ timer = setTimeout(() => {
48
+ try {
49
+ controller.abort(timeoutError);
50
+ }
51
+ catch { /* ignore */ }
52
+ reject(timeoutError);
53
+ }, timeoutMs);
54
+ }),
55
+ ]);
56
+ }
57
+ finally {
58
+ if (timer)
59
+ clearTimeout(timer);
60
+ }
61
+ }
9
62
  /**
10
63
  * Extract the most human-readable message from an error body.
11
64
  * Some gateways wrap provider errors multiple times, e.g.
@@ -188,9 +241,7 @@ export class ModelClient {
188
241
  resolveVirtualModel(model) {
189
242
  if (!model.startsWith('blockrun/'))
190
243
  return model;
191
- // Import router dynamically to avoid circular deps
192
244
  try {
193
- const { routeRequest, parseRoutingProfile } = require('../router/index.js');
194
245
  const profile = parseRoutingProfile(model);
195
246
  if (profile) {
196
247
  const result = routeRequest('', profile);
@@ -288,39 +339,48 @@ export class ModelClient {
288
339
  if (this.debug) {
289
340
  console.error(`[franklin] POST ${endpoint} model=${request.model}`);
290
341
  }
291
- let response = await fetch(endpoint, {
292
- method: 'POST',
293
- headers,
294
- body,
295
- signal,
296
- });
297
- // Handle x402 payment
298
- if (response.status === 402) {
299
- if (this.debug)
300
- console.error('[franklin] Payment required — signing...');
301
- const paymentHeader = await this.signPayment(response);
302
- if (!paymentHeader) {
303
- yield { kind: 'error', payload: { message: 'Payment signing failed' } };
304
- return;
305
- }
306
- response = await fetch(endpoint, {
342
+ const requestTimeoutMs = getModelRequestTimeoutMs();
343
+ const streamTimeoutMs = getModelStreamIdleTimeoutMs();
344
+ const requestController = new AbortController();
345
+ const unlinkAbort = linkAbortSignal(signal, requestController);
346
+ try {
347
+ let response = await withAbortableTimeout(() => fetch(endpoint, {
307
348
  method: 'POST',
308
- headers: { ...headers, ...paymentHeader },
349
+ headers,
309
350
  body,
310
- signal,
311
- });
351
+ signal: requestController.signal,
352
+ }), requestController, createModelTimeoutError('request', request.model, requestTimeoutMs), requestTimeoutMs);
353
+ // Handle x402 payment
354
+ if (response.status === 402) {
355
+ if (this.debug)
356
+ console.error('[franklin] Payment required — signing...');
357
+ const paymentHeader = await this.signPayment(response);
358
+ if (!paymentHeader) {
359
+ yield { kind: 'error', payload: { message: 'Payment signing failed' } };
360
+ return;
361
+ }
362
+ response = await withAbortableTimeout(() => fetch(endpoint, {
363
+ method: 'POST',
364
+ headers: { ...headers, ...paymentHeader },
365
+ body,
366
+ signal: requestController.signal,
367
+ }), requestController, createModelTimeoutError('request', request.model, requestTimeoutMs), requestTimeoutMs);
368
+ }
369
+ if (!response.ok) {
370
+ const errorBody = await response.text().catch(() => 'unknown error');
371
+ const message = extractApiErrorMessage(errorBody);
372
+ yield {
373
+ kind: 'error',
374
+ payload: { status: response.status, message },
375
+ };
376
+ return;
377
+ }
378
+ // Parse SSE stream
379
+ yield* this.parseSSEStream(response, requestController, streamTimeoutMs, request.model);
312
380
  }
313
- if (!response.ok) {
314
- const errorBody = await response.text().catch(() => 'unknown error');
315
- const message = extractApiErrorMessage(errorBody);
316
- yield {
317
- kind: 'error',
318
- payload: { status: response.status, message },
319
- };
320
- return;
381
+ finally {
382
+ unlinkAbort();
321
383
  }
322
- // Parse SSE stream
323
- yield* this.parseSSEStream(response, signal);
324
384
  }
325
385
  /**
326
386
  * Non-streaming completion for simple requests.
@@ -617,7 +677,7 @@ export class ModelClient {
617
677
  return header;
618
678
  }
619
679
  // ─── SSE Parsing ───────────────────────────────────────────────────────
620
- async *parseSSEStream(response, signal) {
680
+ async *parseSSEStream(response, controller, timeoutMs, model) {
621
681
  const reader = response.body?.getReader();
622
682
  if (!reader) {
623
683
  yield { kind: 'error', payload: { message: 'No response body' } };
@@ -630,9 +690,9 @@ export class ModelClient {
630
690
  const MAX_BUFFER = 1_000_000; // 1MB buffer cap
631
691
  try {
632
692
  while (true) {
633
- if (signal?.aborted)
693
+ if (controller.signal.aborted)
634
694
  break;
635
- const { done, value } = await reader.read();
695
+ const { done, value } = await withAbortableTimeout(() => reader.read(), controller, createModelTimeoutError('stream', model, timeoutMs), timeoutMs);
636
696
  if (done)
637
697
  break;
638
698
  buffer += decoder.decode(value, { stream: true });
@@ -34,7 +34,6 @@ function isRunning(pid) {
34
34
  }
35
35
  catch { /* fall through to ps */ }
36
36
  try {
37
- const { execSync } = require('node:child_process');
38
37
  const cmd = execSync(`ps -p ${pid} -o command=`, { encoding: 'utf-8', stdio: ['pipe', 'pipe', 'ignore'] }).trim();
39
38
  return /franklin|runcode|node.*dist\/index/.test(cmd);
40
39
  }
@@ -20,7 +20,7 @@
20
20
  * files — telemetry is just a stable, aggregation-friendly view of
21
21
  * information the user already has.
22
22
  */
23
- import type { SessionMeta } from '../session/storage.js';
23
+ import { type SessionMeta } from '../session/storage.js';
24
24
  interface ConsentRecord {
25
25
  enabled: boolean;
26
26
  enabledAt?: number;
@@ -24,12 +24,21 @@ import fs from 'node:fs';
24
24
  import path from 'node:path';
25
25
  import crypto from 'node:crypto';
26
26
  import { BLOCKRUN_DIR, VERSION } from '../config.js';
27
+ import { listSessions } from '../session/storage.js';
27
28
  const CONSENT_FILE = path.join(BLOCKRUN_DIR, 'telemetry-consent.json');
28
29
  const LOG_FILE = path.join(BLOCKRUN_DIR, 'telemetry.jsonl');
29
30
  const INSTALL_ID_FILE = path.join(BLOCKRUN_DIR, 'telemetry-install-id.txt');
30
31
  function ensureDir() {
31
32
  fs.mkdirSync(BLOCKRUN_DIR, { recursive: true });
32
33
  }
34
+ function canonicalDir(dir) {
35
+ try {
36
+ return fs.realpathSync(path.resolve(dir));
37
+ }
38
+ catch {
39
+ return path.resolve(dir);
40
+ }
41
+ }
33
42
  /** Enabled-state check. Default: false. */
34
43
  export function isTelemetryEnabled() {
35
44
  try {
@@ -120,13 +129,9 @@ export function recordSession(meta, chain) {
120
129
  export function recordLatestSessionIfEnabled(workingDir, chain) {
121
130
  if (!isTelemetryEnabled())
122
131
  return;
123
- // Lazy import to avoid a circular session/storage <-> telemetry dependency.
124
- // Using require() here keeps this module synchronous for tests.
125
- /* eslint-disable @typescript-eslint/no-require-imports */
126
- const { listSessions } = require('../session/storage.js');
127
- /* eslint-enable @typescript-eslint/no-require-imports */
132
+ const targetDir = canonicalDir(workingDir);
128
133
  const sessions = listSessions();
129
- const match = sessions.find(s => s.workDir === workingDir);
134
+ const match = sessions.find(s => canonicalDir(s.workDir) === targetDir);
130
135
  if (!match)
131
136
  return;
132
137
  recordSession(match, chain);
package/dist/ui/app.js CHANGED
@@ -333,6 +333,18 @@ function RunCodeApp({ initialModel, workDir, walletAddress, walletBalance, chain
333
333
  const trimmed = value.trim();
334
334
  if (!trimmed)
335
335
  return;
336
+ // Exit commands bypass the busy-queue gate — if the user wants out,
337
+ // they want out immediately, not after whatever turn is in flight.
338
+ // Covers bare 'exit' / 'quit' / 'q' and slash-prefixed /exit / /quit.
339
+ const lower = trimmed.toLowerCase();
340
+ const isExit = lower === 'exit' || lower === 'quit' || lower === 'q' ||
341
+ lower === '/exit' || lower === '/quit';
342
+ if (isExit) {
343
+ onAbort();
344
+ onExit();
345
+ exit();
346
+ return;
347
+ }
336
348
  // If agent is busy, queue the message — it will be auto-submitted when the turn finishes
337
349
  if (!ready) {
338
350
  setQueuedInputs(prev => [...prev, trimmed]);
@@ -340,13 +352,6 @@ function RunCodeApp({ initialModel, workDir, walletAddress, walletBalance, chain
340
352
  showStatus(`Queued message (${queuedInputsRef.current.length + 1} pending)`, 'warning', 1500);
341
353
  return;
342
354
  }
343
- // Bare exit/quit (no slash needed)
344
- const lower = trimmed.toLowerCase();
345
- if (lower === 'exit' || lower === 'quit' || lower === 'q') {
346
- onExit();
347
- exit();
348
- return;
349
- }
350
355
  // ── Slash commands ──
351
356
  if (trimmed.startsWith('/')) {
352
357
  setInput('');
@@ -355,11 +360,8 @@ function RunCodeApp({ initialModel, workDir, walletAddress, walletBalance, chain
355
360
  const parts = trimmed.split(/\s+/);
356
361
  const cmd = parts[0].toLowerCase();
357
362
  switch (cmd) {
358
- case '/exit':
359
- case '/quit':
360
- onExit();
361
- exit();
362
- return;
363
+ // /exit and /quit are handled earlier (before the busy-queue gate)
364
+ // so they exit the session immediately even mid-turn.
363
365
  case '/model':
364
366
  case '/models':
365
367
  if (parts[1]) {
@@ -46,7 +46,9 @@ function modelShort(model) {
46
46
  * Returns { ok, id } on success, or { ok, error, candidates } on failure.
47
47
  */
48
48
  export function resolveSessionIdInput(input) {
49
- const sessions = listSessions();
49
+ return resolveSessionIdFromList(input, listSessions());
50
+ }
51
+ function resolveSessionIdFromList(input, sessions) {
50
52
  // Exact match first
51
53
  const exact = sessions.find((s) => s.id === input);
52
54
  if (exact)
@@ -72,7 +74,7 @@ export function resolvePickerSelection(input, shown, sessions) {
72
74
  const exact = sessions.find((s) => s.id === trimmed);
73
75
  if (exact)
74
76
  return { kind: 'selected', id: exact.id };
75
- const resolved = resolveSessionIdInput(trimmed);
77
+ const resolved = resolveSessionIdFromList(trimmed, sessions);
76
78
  if (resolved.ok) {
77
79
  return { kind: 'selected', id: resolved.id };
78
80
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@blockrun/franklin",
3
- "version": "3.8.32",
3
+ "version": "3.8.34",
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": {