@blockrun/franklin 3.15.59 → 3.15.61
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/loop.d.ts +9 -0
- package/dist/agent/loop.js +79 -2
- package/package.json +1 -1
package/dist/agent/loop.d.ts
CHANGED
|
@@ -20,6 +20,15 @@ export declare function looksLikeGatewayErrorAsText(parts: ContentPart[]): {
|
|
|
20
20
|
match: boolean;
|
|
21
21
|
message: string;
|
|
22
22
|
};
|
|
23
|
+
/**
|
|
24
|
+
* Walk a Dialogue and replace large `image.source.data` (base64) blocks
|
|
25
|
+
* inside `tool_result.content` arrays with a tiny placeholder. The
|
|
26
|
+
* accompanying text block already names the file path so the model on
|
|
27
|
+
* resume can re-Read it if it needs to see the image again. Returns a
|
|
28
|
+
* shallow clone so the in-memory history (used for the rest of the
|
|
29
|
+
* current turn) keeps the full image data.
|
|
30
|
+
*/
|
|
31
|
+
export declare function stripLargeImageData(message: Dialogue): Dialogue;
|
|
23
32
|
/**
|
|
24
33
|
* Identify models known to hallucinate tool calls (invented names, literal
|
|
25
34
|
* `[TOOLCALL]` / `<tool_call>` text in answers) — they need the explicit
|
package/dist/agent/loop.js
CHANGED
|
@@ -288,6 +288,64 @@ function getBackoffDelay(attempt, maxDelayMs = 32_000) {
|
|
|
288
288
|
const jitter = base * 0.25 * (Math.random() * 2 - 1); // ±25%
|
|
289
289
|
return Math.max(500, Math.round(base + jitter));
|
|
290
290
|
}
|
|
291
|
+
/**
|
|
292
|
+
* Threshold for stripping inline base64 image data on session-disk
|
|
293
|
+
* writes. Mirrors `streaming-executor.ts:PERSIST_THRESHOLD` so a Read of
|
|
294
|
+
* a small icon (favicon-sized PNG, ~3 KB base64) round-trips through
|
|
295
|
+
* resume intact, while a Read of a screenshot or generated artwork
|
|
296
|
+
* (typically 200 KB+ base64) gets path-stubbed.
|
|
297
|
+
*/
|
|
298
|
+
const SESSION_IMAGE_STRIP_THRESHOLD = 50_000;
|
|
299
|
+
/**
|
|
300
|
+
* Walk a Dialogue and replace large `image.source.data` (base64) blocks
|
|
301
|
+
* inside `tool_result.content` arrays with a tiny placeholder. The
|
|
302
|
+
* accompanying text block already names the file path so the model on
|
|
303
|
+
* resume can re-Read it if it needs to see the image again. Returns a
|
|
304
|
+
* shallow clone so the in-memory history (used for the rest of the
|
|
305
|
+
* current turn) keeps the full image data.
|
|
306
|
+
*/
|
|
307
|
+
export function stripLargeImageData(message) {
|
|
308
|
+
if (!Array.isArray(message.content))
|
|
309
|
+
return message;
|
|
310
|
+
let mutated = false;
|
|
311
|
+
// Cast through `unknown` because Dialogue's content union doesn't expose
|
|
312
|
+
// the tool_result shape with image blocks at the type level — they flow
|
|
313
|
+
// in via the loop's outcome-building path. Runtime structure is what
|
|
314
|
+
// matters here; we only mutate when we positively identify the shape.
|
|
315
|
+
const newContent = message.content.map((part) => {
|
|
316
|
+
if (typeof part === 'object' &&
|
|
317
|
+
part !== null &&
|
|
318
|
+
part.type === 'tool_result' &&
|
|
319
|
+
Array.isArray(part.content)) {
|
|
320
|
+
const tr = part;
|
|
321
|
+
let inner = tr.content;
|
|
322
|
+
let innerMutated = false;
|
|
323
|
+
const cleaned = inner.map((block) => {
|
|
324
|
+
if (block &&
|
|
325
|
+
typeof block === 'object' &&
|
|
326
|
+
block.type === 'image' &&
|
|
327
|
+
block.source?.type === 'base64' &&
|
|
328
|
+
(block.source.data?.length ?? 0) > SESSION_IMAGE_STRIP_THRESHOLD) {
|
|
329
|
+
innerMutated = true;
|
|
330
|
+
const sz = (block.source.data ?? '').length;
|
|
331
|
+
return {
|
|
332
|
+
type: 'text',
|
|
333
|
+
text: `<image stripped from session log: ${(sz / 1024).toFixed(1)} KB base64. ` +
|
|
334
|
+
`See accompanying text block for the source path; re-Read to inline again.>`,
|
|
335
|
+
};
|
|
336
|
+
}
|
|
337
|
+
return block;
|
|
338
|
+
});
|
|
339
|
+
if (innerMutated) {
|
|
340
|
+
mutated = true;
|
|
341
|
+
inner = cleaned;
|
|
342
|
+
return { ...tr, content: inner };
|
|
343
|
+
}
|
|
344
|
+
}
|
|
345
|
+
return part;
|
|
346
|
+
});
|
|
347
|
+
return mutated ? { ...message, content: newContent } : message;
|
|
348
|
+
}
|
|
291
349
|
/**
|
|
292
350
|
* Format the user-facing "switching model" line. Includes the resolved
|
|
293
351
|
* concrete model in parentheses when the user-facing alias (e.g.
|
|
@@ -483,7 +541,16 @@ export async function interactiveSession(config, getUserInput, onEvent, onAbortR
|
|
|
483
541
|
});
|
|
484
542
|
};
|
|
485
543
|
const persistSessionMessage = (message) => {
|
|
486
|
-
|
|
544
|
+
// Strip large base64 image bytes before writing to session jsonl. The
|
|
545
|
+
// tool_result wrap at line ~1788 inlines image data so vision models
|
|
546
|
+
// can see it during the live turn — but PNG bytes can be ~600 KB
|
|
547
|
+
// each, and the inline content bypasses persistLargeResult (which
|
|
548
|
+
// only checks `result.output.length`). Verified 2026-05-05: a single
|
|
549
|
+
// Read of `/tmp/mamba_hd_p9.png` produced an 850 KB session jsonl
|
|
550
|
+
// line; a 5-turn session with multiple .png reads grew to 12 MB.
|
|
551
|
+
// The model already saw the bytes in this turn's in-memory history,
|
|
552
|
+
// so disk only needs the path reference for resume.
|
|
553
|
+
appendToSession(sessionId, stripLargeImageData(message));
|
|
487
554
|
persistSessionMeta();
|
|
488
555
|
};
|
|
489
556
|
pruneOldSessions(sessionId); // Cleanup old sessions on start, protect current
|
|
@@ -987,6 +1054,15 @@ export async function interactiveSession(config, getUserInput, onEvent, onAbortR
|
|
|
987
1054
|
// llm.ts if `tools` ended up empty, so it's safe to attach here.
|
|
988
1055
|
const callToolChoice = forceToolChoiceNextRound;
|
|
989
1056
|
forceToolChoiceNextRound = null;
|
|
1057
|
+
// Wall-clock start of the model call. Used by the recordUsage call
|
|
1058
|
+
// a few hundred lines below so franklin-stats.json captures real
|
|
1059
|
+
// latency. Verified 2026-05-05: `franklin stats` reported
|
|
1060
|
+
// `avgLat=0.0s` for every model across 5300+ requests because the
|
|
1061
|
+
// agent-loop callsite always passed 0 for latencyMs (proxy path
|
|
1062
|
+
// already measured correctly). `franklin insights` couldn't surface
|
|
1063
|
+
// "this model is consistently slow" or "fallback was faster" until
|
|
1064
|
+
// this was fixed.
|
|
1065
|
+
const llmCallStartedAt = Date.now();
|
|
990
1066
|
try {
|
|
991
1067
|
const result = await client.complete({
|
|
992
1068
|
model: resolvedModel,
|
|
@@ -1287,7 +1363,8 @@ export async function interactiveSession(config, getUserInput, onEvent, onAbortR
|
|
|
1287
1363
|
// franklin-debug.log; `franklin insights` was therefore useless
|
|
1288
1364
|
// for spotting a hot routing chain.
|
|
1289
1365
|
const costEstimate = estimateCost(resolvedModel, inputTokens, usage.outputTokens, 1);
|
|
1290
|
-
|
|
1366
|
+
const llmLatencyMs = Date.now() - llmCallStartedAt;
|
|
1367
|
+
recordUsage(resolvedModel, inputTokens, usage.outputTokens, costEstimate, llmLatencyMs, turnFailedModels.size > 0);
|
|
1291
1368
|
// ── Circuit breakers: prevent infinite-loop wallet drain ──
|
|
1292
1369
|
// Per-turn $-cap was removed in v3.11.0 — runaway loops are caught by
|
|
1293
1370
|
// MAX_TOOL_CALLS_PER_TURN (25) and MAX_TINY_RESPONSES (2) above; the
|
package/package.json
CHANGED