forked from BlockRunAI/Franklin
-
Notifications
You must be signed in to change notification settings - Fork 0
Expand file tree
/
Copy pathloop.ts
More file actions
2285 lines (2137 loc) · 109 KB
/
Copy pathloop.ts
File metadata and controls
2285 lines (2137 loc) · 109 KB
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
556
557
558
559
560
561
562
563
564
565
566
567
568
569
570
571
572
573
574
575
576
577
578
579
580
581
582
583
584
585
586
587
588
589
590
591
592
593
594
595
596
597
598
599
600
601
602
603
604
605
606
607
608
609
610
611
612
613
614
615
616
617
618
619
620
621
622
623
624
625
626
627
628
629
630
631
632
633
634
635
636
637
638
639
640
641
642
643
644
645
646
647
648
649
650
651
652
653
654
655
656
657
658
659
660
661
662
663
664
665
666
667
668
669
670
671
672
673
674
675
676
677
678
679
680
681
682
683
684
685
686
687
688
689
690
691
692
693
694
695
696
697
698
699
700
701
702
703
704
705
706
707
708
709
710
711
712
713
714
715
716
717
718
719
720
721
722
723
724
725
726
727
728
729
730
731
732
733
734
735
736
737
738
739
740
741
742
743
744
745
746
747
748
749
750
751
752
753
754
755
756
757
758
759
760
761
762
763
764
765
766
767
768
769
770
771
772
773
774
775
776
777
778
779
780
781
782
783
784
785
786
787
788
789
790
791
792
793
794
795
796
797
798
799
800
801
802
803
804
805
806
807
808
809
810
811
812
813
814
815
816
817
818
819
820
821
822
823
824
825
826
827
828
829
830
831
832
833
834
835
836
837
838
839
840
841
842
843
844
845
846
847
848
849
850
851
852
853
854
855
856
857
858
859
860
861
862
863
864
865
866
867
868
869
870
871
872
873
874
875
876
877
878
879
880
881
882
883
884
885
886
887
888
889
890
891
892
893
894
895
896
897
898
899
900
901
902
903
904
905
906
907
908
909
910
911
912
913
914
915
916
917
918
919
920
921
922
923
924
925
926
927
928
929
930
931
932
933
934
935
936
937
938
939
940
941
942
943
944
945
946
947
948
949
950
951
952
953
954
955
956
957
958
959
960
961
962
963
964
965
966
967
968
969
970
971
972
973
974
975
976
977
978
979
980
981
982
983
984
985
986
987
988
989
990
991
992
993
994
995
996
997
998
999
1000
/**
* Franklin Agent Loop
* The core reasoning-action cycle: prompt → model → extract capabilities → execute → repeat.
*/
import { ModelClient } from './llm.js';
import { autoCompactIfNeeded, forceCompact, microCompact } from './compact.js';
import { estimateHistoryTokens, updateActualTokens, resetTokenAnchor, getAnchoredTokenCount, getContextWindow, setEstimationModel } from './tokens.js';
import { handleSlashCommand } from './commands.js';
import { loadBundledSkills, getSkillVars } from '../skills/bootstrap.js';
import { reduceTokens } from './reduce.js';
import { redactSecrets, stashSecretsToEnv, formatRedactionWarning } from './secret-redact.js';
import { PermissionManager } from './permissions.js';
import { StreamingExecutor } from './streaming-executor.js';
import { optimizeHistory, CAPPED_MAX_TOKENS, ESCALATED_MAX_TOKENS, getMaxOutputTokens } from './optimize.js';
import { classifyAgentError } from './error-classifier.js';
import { SessionToolGuard } from './tool-guard.js';
import { resetToolSessionState } from '../tools/index.js';
import { CORE_TOOL_NAMES, dynamicToolsEnabled } from '../tools/tool-categories.js';
import { createActivateToolCapability } from '../tools/activate.js';
import { recordUsage } from '../stats/tracker.js';
import { loadConfig } from '../commands/config.js';
import { recordSessionUsage } from '../stats/session-tracker.js';
import { appendAudit, extractLastUserPrompt } from '../stats/audit.js';
import { logger, setDebugMode } from '../logger.js';
import { runDataHygiene } from '../storage/hygiene.js';
import { isTestFixtureModel } from '../stats/test-fixture.js';
import { setSessionPersistenceDisabled } from '../session/storage.js';
import { estimateCost, OPUS_PRICING } from '../pricing.js';
import { maybeMidSessionExtract } from '../learnings/extractor.js';
import { extractMentions, buildEntityContext, loadEntities } from '../brain/store.js';
import { routeRequest, routeRequestAsync, resolveTierToModel, parseRoutingProfile, getFallbackChain, pickFreeFallback } from '../router/index.js';
import type { Tier, RoutingProfile } from '../router/index.js';
import { recordOutcome } from '../router/local-elo.js';
import { shouldPlan, getPlanningPrompt, getExecutorModel, isExecutorStuck, toolCallSignature } from './planner.js';
import { shouldVerify, runVerification } from './verification.js';
import {
shouldCheckGrounding,
checkGrounding,
renderGroundingFollowup,
buildGroundingRetryInstruction,
extractMissingToolNames,
} from './evaluator.js';
import type { ToolChoice } from './llm.js';
import { augmentUserMessage, prefetchForIntent } from './intent-prefetch.js';
import { analyzeTurn, type TurnAnalysis } from './turn-analyzer.js';
import { evaluateTimeoutRetry } from './retry-policy.js';
import {
MAX_AUTO_CONTINUATIONS_PER_TURN,
buildContinuationPrompt,
isAutoContinuationDisabled,
} from './continuation.js';
import {
createSessionId,
appendToSession,
updateSessionMeta,
pruneOldSessions,
loadSessionHistory,
loadSessionMeta,
} from '../session/storage.js';
import type {
AgentConfig,
CapabilityHandler,
CapabilityInvocation,
ContentPart,
Dialogue,
StreamEvent,
UserContentPart,
} from './types.js';
/**
* Atomically replace all elements in a history array.
* Safer than `history.length = 0; history.push(...)` because if push throws
* (e.g., OOM), the array is already in its new state — not empty.
* Uses splice to do a single atomic operation on the array.
*/
function replaceHistory(target: Dialogue[], replacement: Dialogue[]): void {
target.splice(0, target.length, ...replacement);
}
const EXTERNAL_WALL_FAILURE_PATTERN =
/\b(?:401|403|429|5\d{2})\b|\bunauthor|\bforbid|\bWAF\b|\bcloudflare\b|\bfault filter\b|\bblocked\b|\binvalid (?:auth|api|token|key|bearer)\b/i;
export function isExternalWallFailure(toolName: string, output: string, isError?: boolean): boolean {
if (toolName === 'WebFetch') {
return isError === true || EXTERNAL_WALL_FAILURE_PATTERN.test(output);
}
if (toolName === 'Bash') {
// Bash is a general-purpose local tool. Non-zero exits from tests,
// builds, git, etc. are useful debugging signal, not proof that the
// model is thrashing against an external auth/firewall wall.
return output.length > 0 && EXTERNAL_WALL_FAILURE_PATTERN.test(output);
}
return false;
}
// ─── Pushback detection ───────────────────────────────────────────────────
// Formerly a pair of regex lists (PUSHBACK_STRONG / PUSHBACK_WEAK) plus a
// claim-on-prior-turn check — ~70 lines of keyword heuristics. Replaced by
// `turnAnalysis.isPushback` from `turn-analyzer.ts` (v3.8.27): the free
// classifier reads the user's actual phrasing AND the prior assistant
// reply and decides whether this turn is a correction. Zero keyword
// allowlist, works across languages and phrasings the regex never covered.
/**
* Sanitize history: fix orphaned tool results AND inject missing results.
*
* Two problems this solves:
* 1. Orphaned tool_results — results without matching tool_use calls (remove them)
* 2. Missing tool_results — tool_use calls without matching results (inject stubs)
* This happens when the model response includes tool calls that weren't executed
* (e.g., abort mid-stream, error before tool execution). The API requires every
* tool_use to have a corresponding tool_result or it rejects the request.
*/
function sanitizeHistory(history: Dialogue[]): Dialogue[] {
// Collect all tool_use IDs from assistant messages
const callIds = new Set<string>();
// Collect all tool_result IDs from user messages
const resultIds = new Set<string>();
for (const msg of history) {
if (msg.role === 'assistant' && Array.isArray(msg.content)) {
for (const part of msg.content) {
if ((part as any).type === 'tool_use' && (part as any).id) {
callIds.add((part as any).id);
}
}
}
if (msg.role === 'user' && Array.isArray(msg.content)) {
for (const part of msg.content) {
if ((part as any).type === 'tool_result' && (part as any).tool_use_id) {
resultIds.add((part as any).tool_use_id);
}
}
}
}
// 1. Remove orphaned tool results (results without matching calls)
const orphanedResults = new Set([...resultIds].filter(id => !callIds.has(id)));
// 2. Find missing tool results (calls without matching results)
const missingResults = new Set([...callIds].filter(id => !resultIds.has(id)));
if (orphanedResults.size === 0 && missingResults.size === 0) return history;
const result: Dialogue[] = [];
for (let i = 0; i < history.length; i++) {
const msg = history[i];
if (msg.role === 'user' && Array.isArray(msg.content)) {
// Remove orphaned tool results
if (orphanedResults.size > 0) {
const filtered = (msg.content as any[]).filter(
p => !(p.type === 'tool_result' && orphanedResults.has(p.tool_use_id))
);
if (filtered.length === 0) continue; // Skip empty messages
result.push({ ...msg, content: filtered });
} else {
result.push(msg);
}
continue;
}
result.push(msg);
// After each assistant message with tool_use, check if the next message
// contains all the required tool_results. If not, inject stubs.
if (msg.role === 'assistant' && Array.isArray(msg.content) && missingResults.size > 0) {
const toolUseIds: string[] = [];
for (const part of msg.content as any[]) {
if (part.type === 'tool_use' && missingResults.has(part.id)) {
toolUseIds.push(part.id);
}
}
if (toolUseIds.length > 0) {
// Check if the next message already has some of these results
const nextMsg = history[i + 1];
const nextResultIds = new Set<string>();
if (nextMsg?.role === 'user' && Array.isArray(nextMsg.content)) {
for (const part of nextMsg.content as any[]) {
if (part.type === 'tool_result') {
nextResultIds.add(part.tool_use_id);
}
}
}
// Inject stub results for any tool_use IDs that are truly missing
const stubParts: UserContentPart[] = [];
for (const id of toolUseIds) {
if (!nextResultIds.has(id)) {
stubParts.push({
type: 'tool_result',
tool_use_id: id,
content: '[Tool execution was interrupted — result not available]',
is_error: true,
});
missingResults.delete(id); // Don't inject twice
}
}
if (stubParts.length > 0) {
// If next message is a user message, prepend stubs to it
if (nextMsg?.role === 'user' && Array.isArray(nextMsg.content)) {
// Will be handled when we process that message next
const existingContent = orphanedResults.size > 0
? (nextMsg.content as any[]).filter(
p => !(p.type === 'tool_result' && orphanedResults.has(p.tool_use_id))
)
: [...(nextMsg.content as any[])];
// Replace the next message with merged content
history[i + 1] = { role: 'user', content: [...stubParts, ...existingContent] };
} else {
// No user message follows — insert a new one with the stubs
result.push({ role: 'user', content: stubParts });
}
}
}
}
}
return result;
}
/**
* Detect media-related errors (image too large, too many images, PDF too large).
* These can be recovered by stripping media blocks and retrying.
*/
/**
* True when the assistant's last emitted text segment ends with a question
* mark (ASCII `?` or fullwidth `?`). Used to render an end-of-turn marker
* so users don't read the post-question silence as "Franklin died." Trim
* trailing whitespace + closing punctuation that doesn't change intent
* (newlines, single closing quote/paren) before checking.
*/
function endedWithQuestion(parts: ContentPart[] | undefined): boolean {
if (!parts || parts.length === 0) return false;
// Walk back to the last text segment. Skip thinking/tool_use parts.
for (let i = parts.length - 1; i >= 0; i--) {
const p = parts[i];
if (p.type !== 'text') continue;
const text = (p as { text?: string }).text;
if (typeof text !== 'string') return false;
// Strip trailing whitespace + the ~3 closing chars that commonly
// follow a question without changing it (")", "'", "\"", "*", ")",
// "*", whitespace).
const trimmed = text.replace(/[\s)\]'"*`)]+$/u, '');
return /[??]$/.test(trimmed);
}
return false;
}
function isMediaSizeError(msg: string): boolean {
return (
(msg.includes('image exceeds') && msg.includes('maximum')) ||
(msg.includes('image dimensions exceed')) ||
/maximum of \d+ PDF pages/.test(msg) ||
(msg.includes('image') && msg.includes('too large')) ||
(msg.includes('PDF') && msg.includes('too large'))
);
}
/**
* Strip image and document blocks from history, replacing with text placeholders.
* Used for media error recovery — retry without the oversized media.
*/
function stripMediaFromHistory(history: Dialogue[]): { history: Dialogue[]; stripped: boolean } {
let stripped = false;
const result = history.map(msg => {
if (typeof msg.content === 'string' || !Array.isArray(msg.content)) return msg;
let modified = false;
const cleaned = msg.content.map((part: any) => {
if (part.type === 'image') {
modified = true;
stripped = true;
return { type: 'text' as const, text: '[image removed — too large for context]' };
}
if (part.type === 'document') {
modified = true;
stripped = true;
return { type: 'text' as const, text: '[document removed — too large for context]' };
}
// Also strip media nested inside tool_result content arrays
if (part.type === 'tool_result' && Array.isArray(part.content)) {
const cleanedContent = part.content.map((c: any) => {
if (c.type === 'image' || c.type === 'document') {
modified = true;
stripped = true;
return { type: 'text' as const, text: `[${c.type} removed — too large for context]` };
}
return c;
});
return modified ? { ...part, content: cleanedContent } : part;
}
return part;
});
return modified ? { ...msg, content: cleaned } : msg;
}) as Dialogue[];
return { history: stripped ? result : history, stripped };
}
/**
* Detect when the gateway leaked an upstream rate-limit / quota error as a
* 200-OK text content block instead of a real HTTP error. The Anthropic
* provider in particular surfaces per-day TPM exhaustion as a bracketed
* "[Error: Too many tokens per day, please wait before trying again.]"
* message glued into the assistant text channel, which then poisons grounding
* checks and gets persisted to session history as if it were a real reply.
*
* Treat any assistant turn whose entire text payload is a single bracketed
* `[Error: ...]` line — and contains no tool_use / thinking blocks — as a
* masquerading transport error. The caller throws to let the existing
* classifier + retry path take over.
*/
export function looksLikeGatewayErrorAsText(parts: ContentPart[]): { match: boolean; message: string } {
if (parts.length === 0) return { match: false, message: '' };
// Reject if any non-text content (real tool calls, real thinking) was emitted.
const textParts: string[] = [];
for (const p of parts) {
if (p.type === 'tool_use') return { match: false, message: '' };
if (p.type === 'text' && typeof (p as { text?: string }).text === 'string') {
textParts.push((p as { text: string }).text);
}
}
const joined = textParts.join('').trim();
if (!joined) return { match: false, message: '' };
// Pattern: `[Error: ...]` taking up the entire text payload, modulo
// surrounding whitespace. Allow the bracket to be the whole message OR
// the message to start with it (some gateways append a stray newline).
const m = /^\[Error:\s*([^\]]+?)\]\s*$/.exec(joined);
if (!m) return { match: false, message: '' };
return { match: true, message: m[1].trim() };
}
/**
* Domain check for the grounding-retry force-tool path. A specialized tool
* (TradingMarket, DefiLlama*, jupiter*, base0x*, SearchX) should only be
* pinned by tool_choice when the user prompt actually references that
* tool's domain — otherwise we let the smart generator pick from any tool.
*
* The motivating bug: a real-estate question ("可以还价 20% 吗") had its
* answer flagged as ungrounded for citing $/sqft figures. The cheap
* evaluator model picked TradingMarket as the missing tool because it
* was the first example in the evaluator prompt. Forcing TradingMarket
* (a crypto-only tool) on a housing question made the retry useless.
*
* This function returns false for specialized tools when the prompt has
* no matching domain keywords; the caller falls back to "any" tool.
* General-purpose tools (WebSearch, ExaSearch, ExaAnswer, WebFetch,
* ExaReadUrls) always pass — they're domain-agnostic.
*/
function isToolRelevantToPrompt(toolName: string, promptLower: string): boolean {
// Crypto trading tools — need a ticker, "crypto", "coin", "swap", etc.
if (/^(Trading|DefiLlama|Jupiter|Base0x|Base0xGasless)/i.test(toolName)) {
return /\b(btc|eth|sol|xrp|doge|usdc|usdt|crypto|coin|token|defi|tvl|yield|swap|jupiter|uniswap|pump\.fun|solana|base chain|polygon|ethereum|币|代币|链上|做空|做多)\b/i.test(promptLower);
}
// X.com search — need an @handle, "twitter", "tweet", "X.com"
if (/^SearchX$/i.test(toolName) || /^PostToX$/i.test(toolName)) {
return /(@\w+|twitter|x\.com|tweet|推特)/i.test(promptLower);
}
// Image / video / music gen — need a creative-content request
if (/^(ImageGen|VideoGen|MusicGen)$/i.test(toolName)) {
return /\b(image|picture|photo|video|clip|music|song|generate|create|render|draw|画|图|视频|音乐|歌)\b/i.test(promptLower);
}
// General-purpose / file / shell tools — always relevant.
return true;
}
/**
* Detect a "stalled at intent" assistant turn: model emitted text-of-intent
* (e.g. "Let me check Node.js…", "I'll start by running npm install") but
* never bound a tool_use block. Coder-tuned models (qwen3-coder-*) and
* NIM-hosted Llama-4-Maverick frequently end_turn after declaring an action,
* stranding the agent loop with no progress.
*
* Returns true when the turn looks like a stall — caller should switch to a
* tool-use-strong model and retry the same prompt instead of treating the
* declared-but-unexecuted intent as the model's final answer.
*
* Conservative by design: only fires when the *tail* of the text shows
* action-intent + the message is long enough to look like a real plan, so
* legitimate short answers ("yes", "looks good") never get re-invoked.
*/
export function looksLikeStalledIntent(text: string): boolean {
if (!text) return false;
const trimmed = text.trim();
if (trimmed.length < 24) return false;
// Look at the last ~400 chars only — intent-to-act lives near the end.
const tail = trimmed.slice(-400).toLowerCase();
// Strong "I'm about to do something" markers near the tail.
const englishIntent =
/\b(let me|let's|i'?ll|i will|i need to|first[,\s]+(?:i|let)|now let'?s|now i'?ll|next[,\s]+i'?ll)\b[\s\S]{0,80}\b(check|verify|run|test|inspect|look|examine|confirm|see|try|install|build|create|start|begin)\b/;
const verifyMarkers =
/\b(let'?s verify|let me check|let me run|let me inspect|let me test|let me look|let me see|let me try|let me start|i'?m going to|i'?ll start by|i'?ll first|i'?ll now)\b/;
if (englishIntent.test(tail)) return true;
if (verifyMarkers.test(tail)) return true;
return false;
}
/**
* Calculate backoff delay with jitter to avoid thundering herd.
* Base: exponential (2^attempt * 1000ms), jitter: ±25%.
*/
function getBackoffDelay(attempt: number, maxDelayMs = 32_000): number {
const base = Math.min(Math.pow(2, attempt) * 1000, maxDelayMs);
const jitter = base * 0.25 * (Math.random() * 2 - 1); // ±25%
return Math.max(500, Math.round(base + jitter));
}
/**
* Threshold for stripping inline base64 image data on session-disk
* writes. Mirrors `streaming-executor.ts:PERSIST_THRESHOLD` so a Read of
* a small icon (favicon-sized PNG, ~3 KB base64) round-trips through
* resume intact, while a Read of a screenshot or generated artwork
* (typically 200 KB+ base64) gets path-stubbed.
*/
const SESSION_IMAGE_STRIP_THRESHOLD = 50_000;
interface ToolResultImageBlock {
type: 'image';
source: { type: 'base64'; media_type: string; data: string };
}
interface ToolResultTextBlock {
type: 'text';
text: string;
}
type ToolResultContentBlock = ToolResultTextBlock | ToolResultImageBlock | { type: string; [k: string]: unknown };
/**
* Walk a Dialogue and replace large `image.source.data` (base64) blocks
* inside `tool_result.content` arrays with a tiny placeholder. The
* accompanying text block already names the file path so the model on
* resume can re-Read it if it needs to see the image again. Returns a
* shallow clone so the in-memory history (used for the rest of the
* current turn) keeps the full image data.
*/
export function stripLargeImageData(message: Dialogue): Dialogue {
if (!Array.isArray(message.content)) return message;
let mutated = false;
// Cast through `unknown` because Dialogue's content union doesn't expose
// the tool_result shape with image blocks at the type level — they flow
// in via the loop's outcome-building path. Runtime structure is what
// matters here; we only mutate when we positively identify the shape.
const newContent = (message.content as unknown[]).map((part) => {
if (
typeof part === 'object' &&
part !== null &&
(part as { type?: string }).type === 'tool_result' &&
Array.isArray((part as { content?: unknown }).content)
) {
const tr = part as { type: 'tool_result'; content: ToolResultContentBlock[]; [k: string]: unknown };
let inner = tr.content;
let innerMutated = false;
const cleaned = inner.map((block) => {
if (
block &&
typeof block === 'object' &&
block.type === 'image' &&
(block as ToolResultImageBlock).source?.type === 'base64' &&
((block as ToolResultImageBlock).source.data?.length ?? 0) > SESSION_IMAGE_STRIP_THRESHOLD
) {
innerMutated = true;
const sz = ((block as ToolResultImageBlock).source.data ?? '').length;
return {
type: 'text',
text: `<image stripped from session log: ${(sz / 1024).toFixed(1)} KB base64. ` +
`See accompanying text block for the source path; re-Read to inline again.>`,
} as ToolResultTextBlock;
}
return block;
});
if (innerMutated) {
mutated = true;
inner = cleaned;
return { ...tr, content: inner };
}
}
return part;
});
return mutated ? { ...message, content: newContent as Dialogue['content'] } : message;
}
/**
* Format the user-facing "switching model" line. Includes the resolved
* concrete model in parentheses when the user-facing alias (e.g.
* `blockrun/auto`) differs from what was actually being called (e.g.
* `anthropic/claude-sonnet-4.6`). Verified 2026-05-04 in a live session:
* a payment fail surfaced as `*blockrun/auto failed — switching to
* nvidia/qwen3-coder-480b*` with no hint of which concrete model
* actually failed, and no hint of why. The reason label closes that gap.
*/
function formatModelSwitch(
alias: string,
resolved: string,
reason: string,
newModel: string,
): string {
const oldDisplay = alias === resolved ? alias : `${alias} (${resolved})`;
return `${oldDisplay} ${reason} — switching to ${newModel}`;
}
/**
* Identify models known to hallucinate tool calls (invented names, literal
* `[TOOLCALL]` / `<tool_call>` text in answers) — they need the explicit
* "Available tools" inventory appended to the system prompt. Strong frontier
* models skip the nag so their prompt cache doesn't turn over.
*
* Exported so tests can pin the classification without a live API.
*/
export function isWeakModel(model: string): boolean {
const m = model.toLowerCase();
// NVIDIA-hosted open models have been observed confabulating tool calls.
// `blockrun/free` resolves to an NVIDIA model before the API call, so
// catching the `nvidia/` prefix also catches the free-profile path.
if (m.startsWith('nvidia/')) return true;
if (m.includes('nemotron-ultra')) return true;
if (m.includes('qwen3-coder')) return true;
// GLM-4* is weak; GLM-5+ is capable enough to skip the nag.
if (/^zai\/glm-4/.test(m)) return true;
// DeepSeek's smaller / quantized SKUs tend to role-play tools too.
if (/deepseek[-_/](r1|v3|chat)-?(lite|mini|tiny)/.test(m)) return true;
return false;
}
// ─── Interactive Session ───────────────────────────────────────────────────
/**
* Run a multi-turn interactive session.
* Each user message triggers a full agent loop.
* Returns the accumulated conversation history.
*/
export async function interactiveSession(
config: AgentConfig,
getUserInput: () => Promise<string | null>,
onEvent: (event: StreamEvent) => void,
onAbortReady?: (abort: () => void) => void
): Promise<Dialogue[]> {
// Clear module-level tool caches left over from a prior session in the same
// process. Matters when Franklin is used as a library or driven by tests
// that call interactiveSession() more than once — stale fileReadTracker /
// fetchCache / backgroundTasks entries from the previous run would otherwise
// fool Edit/Write into skipping the read-before-edit check or serve cached
// webfetch content fetched under the previous session's intent.
resetToolSessionState();
// Wire stderr-mirroring of log lines to the same flag the agent already
// uses to gate verbose console output. File writes happen regardless.
setDebugMode(!!config.debug);
// In-process tests run interactiveSession() with model="local/test*"
// and were creating real session files on the user's machine —
// verified 19 of 33 metas (57.6%) were polluted on a real install.
// Gate session persistence at the entry point so the rest of the
// loop doesn't have to thread the flag through. Tests that genuinely
// exercise the persistence path use a non-fixture model name like
// `zai/glm-5.1` (mock-server-backed) so they keep writing.
setSessionPersistenceDisabled(isTestFixtureModel(config.model));
const client = new ModelClient({
apiUrl: config.apiUrl,
chain: config.chain,
debug: config.debug,
});
// ── Dynamic tool visibility ──
// Register ActivateTool before building the capability map so the agent
// can always reach the meta-tool. When FRANKLIN_DYNAMIC_TOOLS=0 is set,
// `activeTools` is seeded with every registered name — behaves as the
// pre-3.8.9 static registry.
const capabilityMap = new Map<string, CapabilityHandler>();
for (const cap of config.capabilities) {
capabilityMap.set(cap.spec.name, cap);
}
const activeTools: Set<string> = new Set();
const dynamicTools = dynamicToolsEnabled();
if (dynamicTools) {
for (const name of CORE_TOOL_NAMES) {
if (capabilityMap.has(name)) activeTools.add(name);
}
} else {
for (const cap of config.capabilities) activeTools.add(cap.spec.name);
}
const activateToolCap = createActivateToolCapability({ activeTools, allTools: capabilityMap });
capabilityMap.set(activateToolCap.spec.name, activateToolCap);
if (dynamicTools) activeTools.add(activateToolCap.spec.name);
const allToolDefs = [...capabilityMap.values()].map(c => c.spec);
const buildCallToolDefs = () =>
dynamicTools ? allToolDefs.filter(t => activeTools.has(t.name)) : allToolDefs;
const buildActiveCapabilityMap = () =>
dynamicTools
? new Map([...capabilityMap.entries()].filter(([name]) => activeTools.has(name)))
: capabilityMap;
const maxTurns = config.maxTurns ?? 15;
const workDir = config.workingDir ?? process.cwd();
const permissions = new PermissionManager(
config.permissionMode ?? 'default',
config.permissionPromptFn
);
const history: Dialogue[] = [];
let lastUserInput = ''; // For /retry
config.baseModel = config.model; // User's intended model — /model command updates this
let turnFailedModels = new Set<string>(); // Models that failed this turn (cleared each new turn)
// ── Skills (file-loaded SKILL.md prompt-rewrite slash commands) ──
// Bundled-only in Phase 1 of the skills MVP. User-global and project-local
// discovery + the budget-cap-usd / cost-receipt enforcement contract land
// in Phase 2 — see docs/plans/2026-04-29-franklin-skills-mvp-design.md.
const skillBoot = loadBundledSkills();
if (skillBoot.errors.length > 0 && config.debug) {
for (const err of skillBoot.errors) {
onEvent({ kind: 'text_delta', text: `[skills] ${err.path}: ${err.error}\n` });
}
}
const skillRegistry = skillBoot.registry;
// Track models that failed with 402 (payment required) across turns.
// These persist until the session ends — unlike transient errors, payment failures
// will keep failing until the user adds funds. Map stores failure timestamp for future TTL.
const paymentFailedModels = new Map<string, number>(); // model → timestamp
// Plan-then-execute: session-level disable flag lives on config (set by /noplan command)
// Session persistence — reuse existing session ID when resuming, else create new
const sessionId = config.resumeSessionId || createSessionId();
config.onSessionStart?.(sessionId);
let turnCount = 0;
// Resume: hydrate history from the saved JSONL transcript.
// Sanitize to drop any orphaned tool_use / tool_result pairs from a crash.
// Carry over running totals from prior runs so resume preserves them — see
// the `let sessionInputTokens` comment below.
let resumedInputTokens = 0;
let resumedOutputTokens = 0;
let resumedCostUsd = 0;
let resumedSavedVsOpusUsd = 0;
if (config.resumeSessionId) {
const prior = loadSessionHistory(config.resumeSessionId);
if (prior.length > 0) {
const sanitized = sanitizeHistory(prior);
replaceHistory(history, sanitized);
const meta = loadSessionMeta(config.resumeSessionId);
if (meta) {
turnCount = meta.turnCount ?? 0;
// Pre-3.15.38 these fell on the floor — every resume reset the
// running cost/token totals to zero, then `updateSessionMeta`
// wrote the new (smaller) numbers back over the historical
// values. Verified 2026-05-04 from a real session: efd5e412
// had $2.65 + 200K input tokens accumulated, then a resume
// rewrote the meta to {costUsd: 0, inputTokens: 0, ...}
// before the user ran their next turn.
resumedInputTokens = meta.inputTokens ?? 0;
resumedOutputTokens = meta.outputTokens ?? 0;
resumedCostUsd = meta.costUsd ?? 0;
resumedSavedVsOpusUsd = meta.savedVsOpusUsd ?? 0;
}
}
}
let tokenBudgetWarned = false; // Emit token budget warning at most once per session
let lastSessionActivity = Date.now();
let lastRoutedModel = ''; // last model chosen by router (for local elo)
let lastRoutedCategory = ''; // last category detected (for local elo)
// Session-cumulative counters. Seeded from prior session meta on resume so
// `franklin insights` and the status bar show the *true* session total
// across every restart, not just what happened since the latest process
// boot.
let sessionInputTokens = resumedInputTokens;
let sessionOutputTokens = resumedOutputTokens;
let sessionCostUsd = resumedCostUsd;
let sessionSavedVsOpus = resumedSavedVsOpusUsd;
// Per-tool call counts aggregated across every turn. Session-scope, not
// per-turn. Counts the *name* of each tool invocation only — no inputs,
// outputs, or paths. Fed into opt-in telemetry at session end.
const sessionToolCounts = new Map<string, number>();
const toolGuard = new SessionToolGuard();
const persistSessionMeta = () => {
updateSessionMeta(sessionId, {
model: config.model,
workDir,
// Pin the session's chain so `franklin --resume` can restore it
// even after `franklin <chain>` shortcuts mutate the persisted
// default. updateSessionMeta treats this field as sticky once
// recorded — see storage.ts.
chain: config.chain,
turnCount,
messageCount: history.length,
inputTokens: sessionInputTokens,
outputTokens: sessionOutputTokens,
costUsd: sessionCostUsd,
savedVsOpusUsd: sessionSavedVsOpus,
...(config.sessionChannel !== undefined ? { channel: config.sessionChannel } : {}),
...(sessionToolCounts.size > 0
? { toolCallCounts: Object.fromEntries(sessionToolCounts) }
: {}),
});
};
const persistSessionMessage = (message: Dialogue) => {
// Strip large base64 image bytes before writing to session jsonl. The
// tool_result wrap at line ~1788 inlines image data so vision models
// can see it during the live turn — but PNG bytes can be ~600 KB
// each, and the inline content bypasses persistLargeResult (which
// only checks `result.output.length`). Verified 2026-05-05: a single
// Read of `/tmp/mamba_hd_p9.png` produced an 850 KB session jsonl
// line; a 5-turn session with multiple .png reads grew to 12 MB.
// The model already saw the bytes in this turn's in-memory history,
// so disk only needs the path reference for resume.
appendToSession(sessionId, stripLargeImageData(message));
persistSessionMeta();
};
pruneOldSessions(sessionId); // Cleanup old sessions on start, protect current
// Trim ~/.blockrun/data + cost_log + remove legacy files + sweep
// orphan tool-results dirs. Logs a summary if anything was actually
// touched — pre-3.15.31 hygiene was completely silent and the only
// way to verify it was running was poking disk yourself.
const hygieneReport = runDataHygiene();
const totalCleaned =
hygieneReport.legacyFilesRemoved +
hygieneReport.dataFilesTrimmed +
hygieneReport.costLogRowsTrimmed +
hygieneReport.orphanToolResultsRemoved +
hygieneReport.brainJunkEntitiesRemoved +
hygieneReport.oldTasksRemoved;
if (totalCleaned > 0) {
logger.info(
`[franklin] Data hygiene: ${hygieneReport.legacyFilesRemoved} legacy, ${hygieneReport.dataFilesTrimmed} data files, ${hygieneReport.costLogRowsTrimmed} cost_log rows, ${hygieneReport.orphanToolResultsRemoved} orphan tool-results dirs, ${hygieneReport.brainJunkEntitiesRemoved} junk brain entities, ${hygieneReport.oldTasksRemoved} expired tasks cleaned`
);
}
persistSessionMeta();
// Flush session meta on SIGINT/SIGTERM so mid-stream Ctrl+C doesn't
// leave a stale .meta.json (wrong turnCount/messageCount/cost).
const exitFlush = () => {
try { persistSessionMeta(); } catch { /* best effort */ }
};
process.once('SIGINT', exitFlush);
process.once('SIGTERM', exitFlush);
while (true) {
let input = await getUserInput();
if (input === null) break; // User wants to exit
if (input === '') continue; // Empty input → re-prompt
// ── Slash command dispatch ──
if (input.startsWith('/')) {
// /retry re-sends the last user message
if (input === '/retry') {
// Record retry as negative signal for local elo
if (lastRoutedCategory && lastRoutedModel) {
recordOutcome(lastRoutedCategory, lastRoutedModel, 'retried');
}
if (!lastUserInput) {
onEvent({ kind: 'text_delta', text: 'No previous message to retry.\n' });
onEvent({ kind: 'turn_done', reason: 'completed' });
continue;
}
input = lastUserInput;
} else {
const cmdResult = await handleSlashCommand(input, {
history, config, client, sessionId, onEvent,
skillRegistry,
skillVars: getSkillVars({ chain: config.chain }),
});
if (cmdResult.handled) continue;
if (cmdResult.rewritten) input = cmdResult.rewritten;
}
}
// ── Secret redaction at the input boundary ──
// Catch GitHub PATs / API keys / private keys before they enter
// history, get persisted, or hit the model. Detected values are
// stashed on process.env (predictable name like GITHUB_TOKEN) so
// subsequent Bash tool calls can still use them via `$GITHUB_TOKEN`
// — the user keeps the convenience of "remember this credential"
// without the chat-history exposure that just happened.
const { redactedText, matches: secretMatches } = redactSecrets(input);
if (secretMatches.length > 0) {
const envVarsSet = stashSecretsToEnv(secretMatches);
onEvent({
kind: 'text_delta',
text: formatRedactionWarning(secretMatches, envVarsSet),
});
input = redactedText;
}
lastUserInput = input;
// Push the user's clean message; any harness-injected annotations
// (pushback SYSTEM NOTE, prefetch context block) are applied AFTER
// the turn analyzer runs so they get driven by model-decided flags
// instead of keyword regex.
history.push({ role: 'user', content: input });
turnCount++;
toolGuard.startTurn();
persistSessionMessage({ role: 'user', content: input });
// ── Model recovery: try original model at the start of each new turn ──
// If we fell back to a free model last turn due to a transient error, try original again.
// But DON'T reset if the original model had a payment failure — it will just fail again.
const baseModel = config.baseModel ?? config.model;
if (config.model !== baseModel && !paymentFailedModels.has(baseModel)) {
config.model = baseModel;
config.onModelChange?.(baseModel, 'system');
}
turnFailedModels = new Set<string>(); // Fresh slate for transient failures this turn
// ── Brain auto-recall (computed once per user turn) ──
// Scan the new user message plus the previous assistant reply (so
// cross-turn references like "that company we discussed" still resolve)
// for entity mentions, and build the context string. The inner agent
// loop can iterate many times (planner + executor steps); the user's
// input doesn't change between those iterations, so caching here saves
// loadEntities + loadObservations + loadRelations on every re-entry.
let turnBrainContext = '';
try {
const lastAssistantBeforeThisTurn = [...history.slice(0, -1)]
.reverse()
.find((m: Dialogue) => m.role === 'assistant');
const flatten = (d: Dialogue | undefined): string => {
if (!d) return '';
if (typeof d.content === 'string') return d.content;
if (!Array.isArray(d.content)) return '';
return (d.content as Array<{ type: string; text?: string }>)
.filter(p => p.type === 'text')
.map(p => p.text ?? '')
.join(' ');
};
const scanText = input + '\n' + flatten(lastAssistantBeforeThisTurn);
if (scanText.trim().length > 0) {
const entities = loadEntities();
if (entities.length > 0) {
const mentioned = extractMentions(scanText, entities);
if (mentioned.length > 0) {
turnBrainContext = buildEntityContext(mentioned, entities) ?? '';
}
}
}
} catch {
/* brain is optional — never block a turn on recall */
}
const abort = new AbortController();
onAbortReady?.(() => abort.abort());
let loopCount = 0;
let recoveryAttempts = 0;
let autoContinuationCount = 0;
const MAX_RECOVERY_ATTEMPTS = 5;
// Track per-model server-error streak so we can break out of a stuck
// upstream and try the next model in the routing fallback chain instead
// of burning all MAX_RECOVERY_ATTEMPTS retries on the same failure.
const serverErrorsByModel = new Map<string, number>();
const SERVER_ERROR_STREAK_BEFORE_SWITCH = 2;
let compactFailures = 0;
// Research-bloat compaction is fire-once per turn. A later turn can hit
// the trigger organically after the first compact, but firing twice from
// the same threshold would flap on every iteration once crossed.
let bloatCompactedThisTurn = false;
let maxTokensOverride: number | undefined;
const turnIdleReference = lastSessionActivity;
lastSessionActivity = Date.now();
// ── Grounding retry state (per turn) ──
// When the post-response evaluator finds UNGROUNDED claims, we inject a
// corrective user message and re-enter the loop so the generator can
// answer again with the missing tool calls. 1-retry cap: if round 2
// still UNGROUNDED, ship the annotated response and let the user
// decide — avoids pathological loops, caps wall-clock cost.
let groundingRetryCount = 0;
const MAX_GROUNDING_RETRIES = 1;
// When the previous round failed grounding and we're retrying, force the
// model to actually call a tool this round instead of trusting it to
// comply with a soft instruction. Single-shot — cleared after attached.
// Set to `{ type: "tool", name: "X" }` if the evaluator named exactly
// one available tool, else `{ type: "any" }` so the model picks.
let forceToolChoiceNextRound: ToolChoice | null = null;
// ── Plan-then-execute state (per turn) ──
let planActive = false;
let planPlannerModel = '';
let planExecutorModel = '';
let planEscalationCount = 0;
let planConsecutiveErrors = 0;
let lastToolSig = ''; // For same-tool repeat detection
// ── Tool call guardrails (inspired by hermes-agent) ──
let turnToolCalls = 0; // Total tool calls this user turn
const turnToolCounts = new Map<string, number>(); // Per-tool-name counts this turn
const readFileCache = new Set<string>(); // Files already read (dedup)
const MAX_TOOL_CALLS_PER_TURN = 25; // Hard cap per user turn
// Hard break threshold for runaways. The cap above is soft — we
// inject a "limit reached" tool_result once and let the model
// close out. If it ignores that signal and keeps calling tools,
// we force end the turn to prevent unbounded billing. Verified
// on a real user log: one turn went 25 → 100 tool calls before
// the loop ended via maxTurns (much later, much more expensive).
const HARD_TOOL_CAP = MAX_TOOL_CALLS_PER_TURN * 2;
let toolCapWarned = false; // Log + inject only once per turn
const SAME_TOOL_WARN_THRESHOLD = 3; // Warn after N calls to same tool (lowered from 5 — search loops were wasting turns)
// Repetition-based hard stop. 3.15.28 used a count-based threshold
// (Bash called 6× → break) which incorrectly killed legitimate
// exploratory data work — verified 2026-05-04 in a real Opus session
// running data-engineering on GCS logs: 15 distinct gsutil/bq calls,
// each producing new insights, would have been cut off at call 6.
// 3.15.30 detects ACTUAL loops by tracking the (tool, input)
// signature: only break when the model calls the SAME signature 3
// times in one turn. Different inputs → exploration, allowed.
const SAME_SIGNATURE_HARD_STOP = 3;
// Tracks which tool names have already had a warn injected this turn.
// Without it, every call past threshold pushes another [SYSTEM] STOP
// tool_result into the model's context — same shape bug as the cap
// spam fixed in 3.15.24, just in a sibling guardrail.
const sameToolWarned = new Set<string>();
// Tracks how many times each (tool, input)-signature has been called
// this turn. Different inputs → different signatures → exploration.
const turnSignatureCounts = new Map<string, number>();
// ── No-progress guardrail: kill infinite tiny-response loops ──
let consecutiveTinyResponses = 0; // Count of consecutive calls with <10 output tokens
const MAX_TINY_RESPONSES = 2; // Break after N tiny responses — if 2 calls return near-empty, something is wrong
// ── Turn cost accumulator ──
// Surfaced in cap-exceeded messages so the user sees what the wasted
// turn actually cost ("$0.05 spent before this turn was killed") instead
// of just "tool limit exceeded". sessionCostUsd is too coarse — it
// includes earlier productive turns the user got real value from.
let turnCostUsd = 0;
// ── Failed-external-call guardrail ──
// The signature loop guard only catches exact-input repeats. It misses
// "thrashing exploration": model calls Bash 17 different ways trying to
// fix a 401 against the same dead endpoint. Verified 2026-05-05 in a
// real session: glm-5.1 burned 50 calls / $0.05 trying every auth
// variation against api.querit.ai (Cloudflare WAF blocked them all)
// before the signature guard finally fired on the first exact repeat.
// We count consecutive Bash/WebFetch calls whose output looks like a
// network/auth failure; reset on any non-failed external call. Five
// failures in a row is a wall, not exploration.
let consecutiveFailedExternal = 0;
const MAX_CONSECUTIVE_FAILED_EXTERNAL = 5;
const EXTERNAL_TOOL_NAMES = new Set(['Bash', 'WebFetch']);
// ── Turn analysis (one classifier call, drives routing + prefetch) ──
// Single LLM pass that answers every routing-adjacent question the
// harness needs BEFORE the main model runs: tier, ticker intent,
// pushback, planning need, live-data signal. Replaces what used to be
// two separate classifier calls (router + prefetch) plus keyword rule
// engines for pushback / shouldPlan. Safe-defaults on any failure so
// the main flow never blocks on it.
let turnAnalysis: TurnAnalysis | null = null;
try {
// Anchor 1: the user's current message (already in lastUserInput).
// Anchor 2: first chunk of the previous assistant reply — gives the
// analyzer enough context to resolve deictic follow-ups like "那 AAPL 呢".
const lastAssistantText = (() => {
const prior = [...history.slice(0, -1)].reverse()
.find((m: Dialogue) => m.role === 'assistant');
if (!prior) return '';
if (typeof prior.content === 'string') return prior.content;
if (!Array.isArray(prior.content)) return '';
return (prior.content as Array<{ type: string; text?: string }>)
.filter(p => p.type === 'text')
.map(p => p.text ?? '')
.join(' ');
})();
// Anchor 3: the very first user message in this session (session goal).
const sessionGoal = (() => {
const first = history.find((m: Dialogue) => m.role === 'user');
if (!first) return '';
return typeof first.content === 'string' ? first.content : '';
})();
turnAnalysis = await analyzeTurn(input, {
lastAssistantText,
sessionGoal,
client,
});
} catch {
// Analyzer is best-effort; ignore.
}
// ── Pushback annotation ─────────────────────────────────────────
// If the analyzer judged this turn as a user correction of the
// previous answer, inject a SYSTEM NOTE into the user message so the
// model resets its approach rather than doubling down. Replaces the
// former PUSHBACK_STRONG / PUSHBACK_WEAK regex lists — model-decided,
// no keyword allowlist to rot.
if (turnAnalysis?.isPushback) {
const lastIdx = history.length - 1;
const last = history[lastIdx];
if (last && last.role === 'user' && typeof last.content === 'string') {
history[lastIdx] = {
role: 'user',
content: `${last.content}\n\n[SYSTEM NOTE] The user is correcting you. Your previous response was wrong or off-target. Do NOT continue the previous approach. Re-read the conversation, identify what specifically the user is correcting, and change your strategy. If the user pointed out a fact (e.g. "we are using X"), treat that fact as ground truth and rebuild your answer around it.`,
};
}
}