@blockrun/franklin 3.15.56 → 3.15.58
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/error-classifier.d.ts +9 -0
- package/dist/agent/error-classifier.js +12 -0
- package/dist/agent/llm.js +19 -1
- package/dist/agent/loop.js +23 -3
- package/dist/commands/content.d.ts +17 -0
- package/dist/commands/content.js +141 -0
- package/dist/index.js +5 -0
- package/package.json +1 -1
|
@@ -15,5 +15,14 @@ export interface AgentErrorInfo {
|
|
|
15
15
|
maxRetries?: number;
|
|
16
16
|
/** User-facing suggestion for how to recover. Appended to error message in UI. */
|
|
17
17
|
suggestion?: string;
|
|
18
|
+
/**
|
|
19
|
+
* Upstream-recommended wait time before retrying. Parsed from a
|
|
20
|
+
* `[retry-after-ms=...]` tag the streaming client appends to the error
|
|
21
|
+
* message when the response carries a `Retry-After` header (typically
|
|
22
|
+
* 429 / 503). The agent loop should honor this in place of its
|
|
23
|
+
* default exponential backoff. Capped at 10 minutes upstream so a
|
|
24
|
+
* malicious or buggy server can't pin the agent indefinitely.
|
|
25
|
+
*/
|
|
26
|
+
retryAfterMs?: number;
|
|
18
27
|
}
|
|
19
28
|
export declare function classifyAgentError(message: string): AgentErrorInfo;
|
|
@@ -11,6 +11,17 @@ function includesAny(text, patterns) {
|
|
|
11
11
|
}
|
|
12
12
|
export function classifyAgentError(message) {
|
|
13
13
|
const err = message.toLowerCase();
|
|
14
|
+
// Extract Retry-After hint that streaming-client appended (see llm.ts
|
|
15
|
+
// 429 path). Surfaces on the AgentErrorInfo so the loop can honor the
|
|
16
|
+
// upstream's recommended wait instead of guessing with exponential
|
|
17
|
+
// backoff.
|
|
18
|
+
let retryAfterMs;
|
|
19
|
+
const retryAfterTag = /\[retry-after-ms=(\d+)\]/i.exec(message);
|
|
20
|
+
if (retryAfterTag) {
|
|
21
|
+
const n = parseInt(retryAfterTag[1], 10);
|
|
22
|
+
if (Number.isFinite(n) && n > 0 && n <= 600_000)
|
|
23
|
+
retryAfterMs = n;
|
|
24
|
+
}
|
|
14
25
|
// payment_rejected — the gateway received a SIGNED payment header and
|
|
15
26
|
// rejected it during verification (signature mismatch, replay-nonce
|
|
16
27
|
// reuse, clock skew, wrong-chain wallet). Different remedy from
|
|
@@ -76,6 +87,7 @@ export function classifyAgentError(message) {
|
|
|
76
87
|
return {
|
|
77
88
|
category: 'rate_limit', label: 'RateLimit', isTransient: true, maxRetries: 1,
|
|
78
89
|
suggestion: 'Try /model to switch to a different model, or wait a moment and /retry.',
|
|
90
|
+
retryAfterMs,
|
|
79
91
|
};
|
|
80
92
|
}
|
|
81
93
|
if (includesAny(err, [
|
package/dist/agent/llm.js
CHANGED
|
@@ -430,7 +430,25 @@ export class ModelClient {
|
|
|
430
430
|
}
|
|
431
431
|
if (!response.ok) {
|
|
432
432
|
const errorBody = await response.text().catch(() => 'unknown error');
|
|
433
|
-
|
|
433
|
+
let message = extractApiErrorMessage(errorBody);
|
|
434
|
+
// 429 with Retry-After header: tag the error message so the
|
|
435
|
+
// classifier can extract and the loop can honor it. Verified
|
|
436
|
+
// 2026-05-04 in a live session: a 429 fired with the loop's
|
|
437
|
+
// exponential backoff (~1-2s) but the upstream's actual
|
|
438
|
+
// Retry-After window was ~30s — the agent retried prematurely
|
|
439
|
+
// and burned its rate_limit retry budget. Anthropic + most
|
|
440
|
+
// gateways send Retry-After as either seconds (integer) or an
|
|
441
|
+
// HTTP-date; we only honor the seconds form (the date form is
|
|
442
|
+
// rare in practice and harder to validate against clock skew).
|
|
443
|
+
if (response.status === 429) {
|
|
444
|
+
const retryAfter = response.headers.get('retry-after');
|
|
445
|
+
if (retryAfter) {
|
|
446
|
+
const seconds = parseInt(retryAfter, 10);
|
|
447
|
+
if (Number.isFinite(seconds) && seconds > 0 && seconds <= 600) {
|
|
448
|
+
message = `${message} [retry-after-ms=${seconds * 1000}]`;
|
|
449
|
+
}
|
|
450
|
+
}
|
|
451
|
+
}
|
|
434
452
|
// Runtime tool_choice retry. The static allowlist at line ~35
|
|
435
453
|
// catches the case where the request goes directly to a model
|
|
436
454
|
// whose name contains `deepseek-reasoner` / `openai/o1` /
|
package/dist/agent/loop.js
CHANGED
|
@@ -1156,8 +1156,17 @@ export async function interactiveSession(config, getUserInput, onEvent, onAbortR
|
|
|
1156
1156
|
}
|
|
1157
1157
|
}
|
|
1158
1158
|
recoveryAttempts++;
|
|
1159
|
-
|
|
1160
|
-
|
|
1159
|
+
// Honor an upstream Retry-After (parsed from the response by
|
|
1160
|
+
// llm.ts when 429+ Retry-After is present) over our own
|
|
1161
|
+
// exponential backoff. Verified 2026-05-04: a 429 with
|
|
1162
|
+
// Retry-After=30s was retried after ~1.5s exponential backoff
|
|
1163
|
+
// → got 429 again → burned the rate_limit retry budget. Cap at
|
|
1164
|
+
// 30s so the agent never feels "frozen" — anything longer
|
|
1165
|
+
// falls back to a different model instead.
|
|
1166
|
+
const upstreamWaitMs = classified.retryAfterMs;
|
|
1167
|
+
const honorUpstream = typeof upstreamWaitMs === 'number' && upstreamWaitMs <= 30_000;
|
|
1168
|
+
const backoffMs = honorUpstream ? upstreamWaitMs : getBackoffDelay(recoveryAttempts);
|
|
1169
|
+
logger.warn(`[franklin] ${classified.label} error — retrying in ${(backoffMs / 1000).toFixed(1)}s (attempt ${recoveryAttempts}/${effectiveMaxRetries})${honorUpstream ? ' (upstream Retry-After)' : ''}: ${errMsg.slice(0, 100)}`);
|
|
1161
1170
|
// Surface the actual error + model so the user can see which model
|
|
1162
1171
|
// is failing and what the upstream said. Old "Retrying after Server
|
|
1163
1172
|
// error" was uninformative — users couldn't tell whether to wait,
|
|
@@ -1230,7 +1239,18 @@ export async function interactiveSession(config, getUserInput, onEvent, onAbortR
|
|
|
1230
1239
|
}
|
|
1231
1240
|
}
|
|
1232
1241
|
// ── Unrecoverable: show error with suggestion from classifier ──
|
|
1233
|
-
|
|
1242
|
+
// For rate_limit specifically, augment the classifier's generic
|
|
1243
|
+
// suggestion with an explicit "all free models exhausted — switch
|
|
1244
|
+
// to a paid model" hint when we got here because pickFreeFallback
|
|
1245
|
+
// returned null. Verified 2026-05-04: the screenshot's session
|
|
1246
|
+
// ended with a bare "[RateLimit] API error: 429" because every
|
|
1247
|
+
// free model had already been ruled out earlier in the turn —
|
|
1248
|
+
// the user had a funded wallet but no signal that paid models
|
|
1249
|
+
// were the way out.
|
|
1250
|
+
let suggestion = classified.suggestion ? `\nTip: ${classified.suggestion}` : '';
|
|
1251
|
+
if (classified.category === 'rate_limit' && turnFailedModels.size > 0) {
|
|
1252
|
+
suggestion = `\nTip: All free models tried this turn are rate-limited. Switch to a paid model with /model anthropic/claude-sonnet-4.6 (or any other paid model) and retry — your wallet handles it. Or wait ~60s and /retry the same turn.`;
|
|
1253
|
+
}
|
|
1234
1254
|
onEvent({
|
|
1235
1255
|
kind: 'turn_done',
|
|
1236
1256
|
reason: 'error',
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `franklin content` CLI surface — human-facing read access to the
|
|
3
|
+
* Content library that lives at ~/.blockrun/content.json.
|
|
4
|
+
*
|
|
5
|
+
* Tools (ContentCreate / ContentAddAsset) write the library during agent
|
|
6
|
+
* sessions; before this command, there was no way to see the resulting
|
|
7
|
+
* spend without scripting against the JSON file. Verified 2026-05-04 in
|
|
8
|
+
* a live session: user asked "我花了多少钱做这个", agent ran
|
|
9
|
+
* `franklin content list` and got "no content subcommand", fell back to
|
|
10
|
+
* estimating from memory.
|
|
11
|
+
*
|
|
12
|
+
* Subcommands:
|
|
13
|
+
* - list : table of id, type, title, status, spent/budget, assets
|
|
14
|
+
* - show <idOrPrefix>: full detail of one Content, including each asset
|
|
15
|
+
*/
|
|
16
|
+
import { Command } from 'commander';
|
|
17
|
+
export declare function buildContentCommand(): Command;
|
|
@@ -0,0 +1,141 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `franklin content` CLI surface — human-facing read access to the
|
|
3
|
+
* Content library that lives at ~/.blockrun/content.json.
|
|
4
|
+
*
|
|
5
|
+
* Tools (ContentCreate / ContentAddAsset) write the library during agent
|
|
6
|
+
* sessions; before this command, there was no way to see the resulting
|
|
7
|
+
* spend without scripting against the JSON file. Verified 2026-05-04 in
|
|
8
|
+
* a live session: user asked "我花了多少钱做这个", agent ran
|
|
9
|
+
* `franklin content list` and got "no content subcommand", fell back to
|
|
10
|
+
* estimating from memory.
|
|
11
|
+
*
|
|
12
|
+
* Subcommands:
|
|
13
|
+
* - list : table of id, type, title, status, spent/budget, assets
|
|
14
|
+
* - show <idOrPrefix>: full detail of one Content, including each asset
|
|
15
|
+
*/
|
|
16
|
+
import os from 'node:os';
|
|
17
|
+
import path from 'node:path';
|
|
18
|
+
import { Command } from 'commander';
|
|
19
|
+
import { loadLibrary } from '../content/store.js';
|
|
20
|
+
const DEFAULT_PATH = path.join(os.homedir(), '.blockrun', 'content.json');
|
|
21
|
+
function fmtUsd(n) {
|
|
22
|
+
return `$${n.toFixed(2)}`;
|
|
23
|
+
}
|
|
24
|
+
/**
|
|
25
|
+
* Resolve a user-typed id-or-prefix to a single Content record. Returns
|
|
26
|
+
* either the matching record or an error message; the caller prints.
|
|
27
|
+
*
|
|
28
|
+
* Accepts the full UUID, a prefix (≥4 chars), or — for convenience — a
|
|
29
|
+
* substring match against the title (case-insensitive). Ambiguity returns
|
|
30
|
+
* the candidates so the user can disambiguate without rerunning blind.
|
|
31
|
+
*/
|
|
32
|
+
function resolveContent(all, input) {
|
|
33
|
+
const q = input.trim();
|
|
34
|
+
if (!q)
|
|
35
|
+
return { error: 'Provide an id, id-prefix, or title substring.' };
|
|
36
|
+
const exact = all.find(c => c.id === q);
|
|
37
|
+
if (exact)
|
|
38
|
+
return { found: exact };
|
|
39
|
+
const prefix = q.length >= 4 ? all.filter(c => c.id.startsWith(q)) : [];
|
|
40
|
+
if (prefix.length === 1)
|
|
41
|
+
return { found: prefix[0] };
|
|
42
|
+
if (prefix.length > 1) {
|
|
43
|
+
return { error: `Ambiguous prefix "${q}" — matches:\n${prefix.map(c => ` ${c.id} ${c.title}`).join('\n')}` };
|
|
44
|
+
}
|
|
45
|
+
const lower = q.toLowerCase();
|
|
46
|
+
const titled = all.filter(c => c.title.toLowerCase().includes(lower));
|
|
47
|
+
if (titled.length === 1)
|
|
48
|
+
return { found: titled[0] };
|
|
49
|
+
if (titled.length > 1) {
|
|
50
|
+
return { error: `Ambiguous title "${q}" — matches:\n${titled.map(c => ` ${c.id} ${c.title}`).join('\n')}` };
|
|
51
|
+
}
|
|
52
|
+
return { error: `No Content matches "${q}".` };
|
|
53
|
+
}
|
|
54
|
+
export function buildContentCommand() {
|
|
55
|
+
const cmd = new Command('content').description('Inspect Content library (assets, spend, budget)');
|
|
56
|
+
cmd
|
|
57
|
+
.command('list')
|
|
58
|
+
.description('List all Content records, newest first')
|
|
59
|
+
.action(() => {
|
|
60
|
+
const lib = loadLibrary(DEFAULT_PATH);
|
|
61
|
+
if (!lib) {
|
|
62
|
+
console.log('No Content library yet. Tools like ContentCreate populate it during agent sessions.');
|
|
63
|
+
return;
|
|
64
|
+
}
|
|
65
|
+
const all = lib.list();
|
|
66
|
+
if (all.length === 0) {
|
|
67
|
+
console.log('No Content records.');
|
|
68
|
+
return;
|
|
69
|
+
}
|
|
70
|
+
// Header + rows. Truncate id to 8-char prefix and title to 40 chars
|
|
71
|
+
// so common terminal widths (80-100) fit a row on one line.
|
|
72
|
+
console.log(['id'.padEnd(8), 'type'.padEnd(8), 'status'.padEnd(10), 'spent/cap'.padEnd(13), 'assets', 'title'].join(' '));
|
|
73
|
+
for (const c of all) {
|
|
74
|
+
const id8 = c.id.slice(0, 8);
|
|
75
|
+
const spend = `${fmtUsd(c.spentUsd)}/${fmtUsd(c.budgetUsd)}`;
|
|
76
|
+
const title = c.title.length > 40 ? c.title.slice(0, 39) + '…' : c.title;
|
|
77
|
+
console.log([
|
|
78
|
+
id8.padEnd(8),
|
|
79
|
+
c.type.padEnd(8),
|
|
80
|
+
c.status.padEnd(10),
|
|
81
|
+
spend.padEnd(13),
|
|
82
|
+
String(c.assets.length).padEnd(6),
|
|
83
|
+
title,
|
|
84
|
+
].join(' '));
|
|
85
|
+
}
|
|
86
|
+
// Footer with rolled-up spend.
|
|
87
|
+
const totalSpent = all.reduce((s, c) => s + c.spentUsd, 0);
|
|
88
|
+
const totalBudget = all.reduce((s, c) => s + c.budgetUsd, 0);
|
|
89
|
+
console.log();
|
|
90
|
+
console.log(`Total: ${fmtUsd(totalSpent)} spent across ${all.length} content${all.length === 1 ? '' : 's'} (cap ${fmtUsd(totalBudget)}).`);
|
|
91
|
+
});
|
|
92
|
+
cmd
|
|
93
|
+
.command('show <idOrPrefix>')
|
|
94
|
+
.description('Show full detail for one Content record (id, prefix, or title substring)')
|
|
95
|
+
.action((input) => {
|
|
96
|
+
const lib = loadLibrary(DEFAULT_PATH);
|
|
97
|
+
if (!lib) {
|
|
98
|
+
console.log('No Content library yet.');
|
|
99
|
+
process.exit(1);
|
|
100
|
+
}
|
|
101
|
+
const result = resolveContent(lib.list(), input);
|
|
102
|
+
if ('error' in result) {
|
|
103
|
+
console.error(result.error);
|
|
104
|
+
process.exit(1);
|
|
105
|
+
}
|
|
106
|
+
const c = result.found;
|
|
107
|
+
console.log(`# ${c.title}`);
|
|
108
|
+
console.log();
|
|
109
|
+
console.log(`id: ${c.id}`);
|
|
110
|
+
console.log(`type: ${c.type}`);
|
|
111
|
+
console.log(`status: ${c.status}`);
|
|
112
|
+
console.log(`spent: ${fmtUsd(c.spentUsd)} / ${fmtUsd(c.budgetUsd)} cap`);
|
|
113
|
+
console.log(`created: ${new Date(c.createdAt).toISOString()}`);
|
|
114
|
+
if (c.publishedAt)
|
|
115
|
+
console.log(`published: ${new Date(c.publishedAt).toISOString()}`);
|
|
116
|
+
console.log();
|
|
117
|
+
if (c.assets.length > 0) {
|
|
118
|
+
console.log(`## Assets (${c.assets.length})`);
|
|
119
|
+
for (const a of c.assets) {
|
|
120
|
+
console.log(`- ${a.kind.padEnd(6)} ${fmtUsd(a.costUsd).padStart(7)} ${a.source}`);
|
|
121
|
+
console.log(` ${a.data}`);
|
|
122
|
+
}
|
|
123
|
+
console.log();
|
|
124
|
+
}
|
|
125
|
+
if (c.drafts.length > 0) {
|
|
126
|
+
console.log(`## Drafts (${c.drafts.length})`);
|
|
127
|
+
c.drafts.forEach((d, i) => {
|
|
128
|
+
const preview = d.text.length > 80 ? d.text.slice(0, 79) + '…' : d.text;
|
|
129
|
+
console.log(`- #${i + 1} ${preview}`);
|
|
130
|
+
});
|
|
131
|
+
console.log();
|
|
132
|
+
}
|
|
133
|
+
if (c.distribution.length > 0) {
|
|
134
|
+
console.log(`## Distribution (${c.distribution.length})`);
|
|
135
|
+
for (const dist of c.distribution) {
|
|
136
|
+
console.log(`- ${dist.channel}${dist.url ? ` ${dist.url}` : ''} (${new Date(dist.at).toISOString()})`);
|
|
137
|
+
}
|
|
138
|
+
}
|
|
139
|
+
});
|
|
140
|
+
return cmd;
|
|
141
|
+
}
|
package/dist/index.js
CHANGED
|
@@ -24,6 +24,7 @@ import { initCommand } from './commands/init.js';
|
|
|
24
24
|
import { uninitCommand } from './commands/uninit.js';
|
|
25
25
|
import { proxyCommand } from './commands/proxy.js';
|
|
26
26
|
import { buildTaskCommand } from './commands/task.js';
|
|
27
|
+
import { buildContentCommand } from './commands/content.js';
|
|
27
28
|
import { VERSION as version } from './config.js';
|
|
28
29
|
const program = new Command();
|
|
29
30
|
program
|
|
@@ -221,6 +222,10 @@ program
|
|
|
221
222
|
// `franklin task <subcmd>` — human-facing CLI for detached background tasks.
|
|
222
223
|
// Defined in src/commands/task.ts; subcommands: list, tail, cancel, wait.
|
|
223
224
|
program.addCommand(buildTaskCommand());
|
|
225
|
+
// `franklin content <subcmd>` — read access to the Content library
|
|
226
|
+
// (~/.blockrun/content.json) so users + agent shell-outs can inspect
|
|
227
|
+
// spend without scripting against the JSON file. Subcommands: list, show.
|
|
228
|
+
program.addCommand(buildContentCommand());
|
|
224
229
|
// Hidden internal subcommand — invoked by startDetachedTask via spawn(detached).
|
|
225
230
|
// The underscore prefix signals "not for humans"; we still register it via
|
|
226
231
|
// commander so exit codes and arg parsing stay consistent with the rest of
|
package/package.json
CHANGED