Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
485 changes: 476 additions & 9 deletions package-lock.json

Large diffs are not rendered by default.

1 change: 1 addition & 0 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -69,6 +69,7 @@
"@blockrun/llm": "^2.0.0",
"@colbymchenry/codegraph": "^0.9.7",
"@modelcontextprotocol/sdk": "^1.29.0",
"@slack/bolt": "^4.7.3",
"@solana/spl-token": "^0.4.14",
"@solana/web3.js": "^1.98.4",
"@types/react": "^19.2.14",
Expand Down
10 changes: 9 additions & 1 deletion src/agent/llm.ts
Original file line number Diff line number Diff line change
Expand Up @@ -520,7 +520,7 @@ export class ModelClient {
* default model.
*/
private resolveVirtualModel(model: string): string {
if (!model.startsWith('blockrun/')) return model;
if (!model || !model.startsWith('blockrun/')) return model;

try {
const profile = parseRoutingProfile(model);
Expand Down Expand Up @@ -563,6 +563,14 @@ export class ModelClient {
// Reset the per-call charge tracker. signBasePayment / signSolanaPayment
// will set it when the gateway demands a 402 settlement.
this.lastPaidUsd = 0;
// Guard: a missing/non-string model (e.g. a flaky-gateway fallback that
// produced undefined) must not hard-crash with a cryptic
// "reading 'startsWith'". Normalize to the routing profile, which resolves
// to a concrete model below.
if (!request.model || typeof request.model !== 'string') {
console.error('[franklin] request.model was missing — defaulting to blockrun/auto');
request = { ...request, model: 'blockrun/auto' };
}
// Resolve virtual models before any API call
const resolvedModel = this.resolveVirtualModel(request.model);
if (resolvedModel !== request.model) {
Expand Down
19 changes: 14 additions & 5 deletions src/agent/loop.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@
*/

import { ModelClient } from './llm.js';
import { autoCompactIfNeeded, forceCompact, microCompact } from './compact.js';
import { autoCompactIfNeeded, forceCompact, microCompact, projectCompactionSavings } 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';
Expand Down Expand Up @@ -1153,13 +1153,22 @@ export async function interactiveSession(
// compacting (the compact itself runs on a cheaper model
// and costs <$0.05).
const TURN_COST_CAP_FOR_EARLY_COMPACT = 1.00;
// ROI gate: forceCompact (used below) has no savings check of its own, so
// without this it fires even on a tiny history and reports "saved 1%" —
// a wasted summarizer round-trip. Only compact when the projected savings
// clear the floor (≥20%), which a small history can never do.
// The ROI gate applies ONLY to the call-count trigger: the $1.00 cost cap
// is an emergency brake (see the 2026-05-11 note above) and must fire
// even when projected savings are low — gating it would reintroduce the
// $9.45 runaway it was added to stop.
const bloatTriggered =
(turnToolCalls > 15 && turnCostUsd > 0.03 && projectCompactionSavings(history).worthIt) ||
turnCostUsd > TURN_COST_CAP_FOR_EARLY_COMPACT;
if (
config.costSaver !== false &&
!bloatCompactedThisTurn &&
compactFailures < 3 &&
(
(turnToolCalls > 15 && turnCostUsd > 0.03) ||
turnCostUsd > TURN_COST_CAP_FOR_EARLY_COMPACT
)
bloatTriggered
) {
try {
const beforeTokens = estimateHistoryTokens(history);
Expand Down
29 changes: 27 additions & 2 deletions src/agent/streaming-executor.ts
Original file line number Diff line number Diff line change
Expand Up @@ -329,8 +329,33 @@ export class StreamingExecutor {
case 'WebFetch':
case 'WebSearch':
return ((input.url ?? input.query) as string) || undefined;
default:
return undefined;
default: {
// Generic fallback so EVERY tool shows what it's doing. For enum/router
// tools (e.g. Surf*) the `endpoint` is the real action — show it, paired
// with the most relevant param, e.g. "market/etf · BTC". Otherwise pick
// the single most meaningful argument.
const PARAM_KEYS = [
'query', 'q', 'search', 'prompt', 'question', 'text',
'symbol', 'pair', 'metric', 'indicator', 'ticker', 'coin', 'asset', 'market',
'protocol', 'handle', 'chain', 'address', 'addresses', 'hash', 'conditionId',
'url', 'id', 'slug', 'name', 'path', 'pattern', 'to', 'number',
];
const firstParam = (): string => {
for (const k of PARAM_KEYS) {
const v = input[k];
if (typeof v === 'string' && v.trim()) return v.trim();
}
return '';
};
// The "action" field (endpoint / action) is the real verb — show it even
// when there's no param (e.g. PredictionMarket `leaderboard`).
const action =
(typeof input.endpoint === 'string' && input.endpoint.trim()) ||
(typeof input.action === 'string' && input.action.trim()) || '';
const combined = [action, firstParam()].filter(Boolean).join(' · ');
if (!combined) return undefined;
return combined.length > 80 ? combined.slice(0, 80) + '…' : combined;
}
}
}
}
4 changes: 4 additions & 0 deletions src/agent/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -217,4 +217,8 @@ export interface AgentConfig {
maxSpendUsd?: number;
/** Show user-visible harness prefetch status lines (interactive UX only). */
showPrefetchStatus?: boolean;
/** Mid-turn "research-bloat" compaction — summarizes history when a turn
* racks up many tool calls + spend, to cut input-replay cost. Default on;
* set false to disable (the desktop exposes this as a toggle). */
costSaver?: boolean;
}
Loading