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
14 changes: 14 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
@@ -1,5 +1,19 @@
# Changelog

## Franklin Agent 3.29.8 — wallet spend-ceiling + paid-tool accounting (round-4 money audit)

A fourth review round swept the paid-tool surface beyond the changed files. Headline: the `--max-spend` hard cap and several paid tools were blind to real USDC spend. All adversarially verified.

- **`--max-spend` now bounds TOTAL USDC, not just LLM tokens.** The session cap only counted LLM token cost; every paid tool (ImageGen/VideoGen/MusicGen, Exa, Surf, RealFace, Voice, Phone, Modal, DeFiLlama, RPC, Prediction) settled USDC through its own x402 path and counted **$0** against the cap — so `franklin -p "generate 30 images" --max-spend 0.50` could spend multiples of the cap with the guard never firing. A new live-spend accumulator (`getLiveSpendUsd`) is diffed across the whole turn — snapshotted **before the stream** (so mid-stream concurrent paid tools like DeFiLlama/RPC are captured, not raced past) and netted against the LLM's own `callCost` — then folded into the session total, with the ceiling re-checked after every tool batch.
- **Charged-but-failed LLM calls are now counted.** A paid LLM call whose stream then failed (idle timeout, mid-stream error, or Esc) debited USDC but was never folded into the session cost / cap — and the drift compounded across `--resume`. The loop's error path now captures `getLastPaidUsd()` before the next attempt resets it.
- **Paid-but-invisible tools now record spend.** DeFiLlama (5 tools), MultiChainRPC, PredictionMarket (now persisted to stats, not just in-memory telemetry), MusicGen, and panel-initiated phone Buy/Renew/List all settled real USDC but never called `recordUsage` — so `franklin stats` under-reported and its reconciliation couldn't even detect the gap. All now record the settled amount (parity with `surf.ts`).
- **$5 phone Buy/Renew now confirm the spend.** The two most expensive autonomous actions had no spend-confirm while a $0.05 Modal create did; both now gate on `onAskUser` (skipped under `FRANKLIN_MEDIA_AUTO_APPROVE_ALL`).
- **VoiceStatus no longer floods stats.** Its internal poll loop recorded one telemetry row per 5s poll (up to 420/call), evicting real spend history (1000-row cap); it now records once per invocation.
- **Trading guardrails.** The per-position risk cap now values the held position at cost basis (not the incoming order price), so a buy can't slip the cap after a price drop; an over-sized close now flattens to the held qty instead of throwing a confusing `only X held` error.
- **Merged + refined PR #89** (thanks @samsamtrum): Jupiter swaps no longer round a sub-precision amount **up** to one atomic unit. Follow-up on top: floor excess precision to the atomic unit (so agent-computed amounts and float noise like `0.1 + 0.2` still swap) and reject only true dust, with a clearer message + unit test.

New no-spend regression tests cover the live-spend ceiling basis, the risk cost-basis cap, the over-sized close clamp, and the Jupiter atomic-unit math. Verified: local suite 479/479.

## Franklin Agent 3.29.7 — finish the Exa wire-format fix (twin prefetch path) + budget-safety follow-ups

A second multi-agent review of the 3.29.5/3.29.6 branch found that both the Exa wire-format fix and the image-budget fix each missed a path. This lands the adversarially-verified fixes:
Expand Down
4 changes: 2 additions & 2 deletions package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

2 changes: 1 addition & 1 deletion package.json
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
{
"name": "@blockrun/franklin",
"version": "3.29.7",
"version": "3.29.8",
"description": "Franklin Agent — The AI agent with a wallet. Spends USDC autonomously to get real work done. Pay per action, no subscriptions.",
"type": "module",
"exports": {
Expand Down
65 changes: 64 additions & 1 deletion src/agent/loop.ts
Original file line number Diff line number Diff line change
Expand Up @@ -20,7 +20,7 @@ import { ToolCallRepair } from './repair/index.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 { recordUsage, getLiveSpendUsd } from '../stats/tracker.js';
import { loadConfig } from '../commands/config.js';
import { recordSessionUsage } from '../stats/session-tracker.js';
import { appendAudit, extractLastUserPrompt } from '../stats/audit.js';
Expand Down Expand Up @@ -1453,6 +1453,14 @@ export async function interactiveSession(
// "this model is consistently slow" or "fallback was faster" until
// this was fixed.
const llmCallStartedAt = Date.now();
// Snapshot live spend BEFORE the stream starts. Concurrent paid tools
// (DeFiLlama/RPC/Prediction/...) are kicked off mid-stream via
// onToolReceived and can settle their x402 charge before collectResults —
// a snapshot taken AFTER the stream would miss them and let them slip the
// --max-spend cap. We subtract the LLM's own callCost from the post-tool
// delta below (it is added to sessionCostUsd separately), leaving a clean
// paid-tool-only total that captures concurrent and sequential tools alike.
const turnSpendBefore = getLiveSpendUsd();
try {
const result = await client.complete(
{
Expand Down Expand Up @@ -1599,6 +1607,20 @@ export async function interactiveSession(
}
}
} catch (err) {
// ── Count a paid-but-dropped LLM charge before any retry resets it ──
// recordSettledPayment debits the wallet BEFORE the SSE stream is parsed;
// if the stream then fails (idle timeout, mid-stream error, or Esc), that
// USDC is real but the success-path accounting (which reads
// getLastPaidUsd) never ran, and the next attempt resets the accumulator.
// Fold it into the session total NOW so --max-spend and the running cost
// stay honest. The on-chain charge is already in cost_log via the SDK;
// this fixes the in-session view / ceiling, not the wire ledger.
const droppedPaidUsd = client.getLastPaidUsd();
if (droppedPaidUsd > 0) {
sessionCostUsd += droppedPaidUsd;
turnCostUsd += droppedPaidUsd;
}

// ── User abort (Esc key) ──
if ((err as Error).name === 'AbortError' || abort.signal.aborted) {
// Save any partial response that was streamed before abort
Expand All @@ -1613,6 +1635,21 @@ export async function interactiveSession(
break;
}

// If the dropped charge pushed us over the hard cap, stop rather than
// retry (a retry would spend more). Abort already returned above.
const capAfterDrop = (config as { maxSpendUsd?: number }).maxSpendUsd;
if (droppedPaidUsd > 0 && typeof capAfterDrop === 'number' && Number.isFinite(capAfterDrop) &&
capAfterDrop > 0 && sessionCostUsd >= capAfterDrop) {
onEvent({
kind: 'text_delta',
text: `\n\n_Max-spend reached: $${sessionCostUsd.toFixed(4)} ≥ cap $${capAfterDrop.toFixed(2)} ` +
`(incl. a charged-but-failed call). Stopping session._\n`,
});
persistSessionMeta();
onEvent({ kind: 'turn_done', reason: 'budget' });
return history;
}

const errMsg = (err as Error).message || '';
const classified = classifyAgentError(errMsg);

Expand Down Expand Up @@ -2287,6 +2324,32 @@ export async function interactiveSession(
onEvent({ kind: 'capability_done', id: inv.id, result });
}

// ── Fold paid-TOOL spend into the session cost ceiling ──
// sessionCostUsd above only tracks LLM token cost; paid tools (ImageGen,
// VideoGen, MusicGen, Exa, Surf, RealFace, Voice, Phone, Modal, DeFiLlama,
// RPC, Prediction) settle USDC through their own x402 paths and report it
// via recordUsage. Diff the live-spend accumulator across the WHOLE turn
// (snapshot taken before the stream, so mid-stream concurrent tools are
// captured too) and subtract the LLM's own callCost — which recordUsage at
// line ~1948 already added to liveSpendUsd and which sessionCostUsd counts
// separately — leaving paid-tool-only spend so --max-spend bounds total USDC.
const toolSpendUsd = Math.max(0, getLiveSpendUsd() - turnSpendBefore - callCost);
if (toolSpendUsd > 0) {
sessionCostUsd += toolSpendUsd;
turnCostUsd += toolSpendUsd;
const cap = (config as { maxSpendUsd?: number }).maxSpendUsd;
if (typeof cap === 'number' && Number.isFinite(cap) && cap > 0 && sessionCostUsd >= cap) {
onEvent({
kind: 'text_delta',
text: `\n\n_Max-spend reached: $${sessionCostUsd.toFixed(4)} ≥ cap $${cap.toFixed(2)} (incl. paid-tool spend). ` +
`Stopping session — further calls would exceed the budget._\n`,
});
persistSessionMeta();
onEvent({ kind: 'turn_done', reason: 'budget' });
return history;
}
}

// ── Tool call guardrails ──
turnToolCalls += results.length;
for (const [inv, result] of results) {
Expand Down
14 changes: 14 additions & 0 deletions src/phone/client.ts
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,7 @@

import { API_URLS, loadChain, type Chain } from '../config.js';
import { postWithPayment } from '../payments/post-with-payment.js';
import { recordUsage } from '../stats/tracker.js';
import { writeCache, type PhoneNumberRecord } from './cache.js';

function phoneEndpoint(chain: Chain, path: string): string {
Expand All @@ -36,6 +37,7 @@ export interface ListNumbersResult {
*/
export async function listNumbers(opts: { walletAddress: string }): Promise<ListNumbersResult> {
const chain = loadChain();
const startedAt = Date.now();
const result = await postWithPayment(
phoneEndpoint(chain, 'numbers/list'),
{},
Expand All @@ -53,6 +55,11 @@ export async function listNumbers(opts: { walletAddress: string }): Promise<List

writeCache({ wallet: opts.walletAddress, chain, numbers });

// Record the $0.001 x402 spend so panel/background list calls land in
// franklin stats — same label the agent's ListPhoneNumbers tool uses so the
// two aggregate (panel-initiated spend was previously dropped entirely).
try { recordUsage('ListPhoneNumbers', 0, 0, 0.001, Date.now() - startedAt); } catch { /* best-effort */ }

return { numbers, count: numbers.length, paid: 0.001 };
}

Expand All @@ -64,6 +71,7 @@ export interface RenewResult {

export async function renewNumber(phoneNumber: string): Promise<RenewResult> {
const chain = loadChain();
const startedAt = Date.now();
const result = await postWithPayment(
phoneEndpoint(chain, 'numbers/renew'),
{ phoneNumber },
Expand All @@ -73,6 +81,8 @@ export async function renewNumber(phoneNumber: string): Promise<RenewResult> {
const message = typeof result.body.error === 'string' ? result.body.error : `gateway ${result.status}`;
throw new Error(message);
}
// Record the $5 x402 spend (parity with the agent's RenewPhoneNumber tool).
try { recordUsage('RenewPhoneNumber', 0, 0, 5.0, Date.now() - startedAt); } catch { /* best-effort */ }
return {
phone_number: String(result.body.phone_number ?? phoneNumber),
expires_at: String(result.body.expires_at ?? ''),
Expand All @@ -92,6 +102,7 @@ export async function buyNumber(opts: {
areaCode?: string;
}): Promise<BuyResult> {
const chain = loadChain();
const startedAt = Date.now();
const body: Record<string, unknown> = { country: opts.country || 'US' };
if (opts.areaCode) body.areaCode = opts.areaCode;
const result = await postWithPayment(
Expand All @@ -103,6 +114,9 @@ export async function buyNumber(opts: {
const message = typeof result.body.error === 'string' ? result.body.error : `gateway ${result.status}`;
throw new Error(message);
}
// Record the $5 x402 spend (parity with the agent's BuyPhoneNumber tool) so a
// panel-initiated buy is no longer invisible to franklin stats / the ledger.
try { recordUsage('BuyPhoneNumber', 0, 0, 5.0, Date.now() - startedAt); } catch { /* best-effort */ }
return {
phone_number: String(result.body.phone_number ?? ''),
expires_at: String(result.body.expires_at ?? ''),
Expand Down
22 changes: 22 additions & 0 deletions src/stats/tracker.ts
Original file line number Diff line number Diff line change
Expand Up @@ -193,6 +193,24 @@ export function flushStats(): void {
if (cachedStats) saveStats(cachedStats);
}

// ── Live spend accumulator (process-lifetime) ──────────────────────────────
// Sum of every positive costUsd passed to recordUsage, counted BEFORE the
// test/audit gates below so it reflects REAL USDC spend regardless of whether
// the row is persisted to history. The agent loop diffs this around tool
// execution to fold paid-tool spend into the --max-spend session ceiling, which
// would otherwise only see LLM token cost. See src/agent/loop.ts.
let liveSpendUsd = 0;

/** Cumulative USDC recorded via recordUsage this process. */
export function getLiveSpendUsd(): number {
return liveSpendUsd;
}

/** Test helper: reset the live-spend accumulator. */
export function resetLiveSpend(): void {
liveSpendUsd = 0;
}

/**
* Record a completed request for stats tracking
*/
Expand All @@ -204,6 +222,10 @@ export function recordUsage(
latencyMs: number,
fallback: boolean = false
): void {
// Count real spend BEFORE the test/audit gates — the --max-spend ceiling must
// see every paid tool call even when history persistence is suppressed.
if (Number.isFinite(costUsd) && costUsd > 0) liveSpendUsd += costUsd;

// Same rationale as appendAudit — tests run in-process with
// local/test* models and would otherwise mix into franklin-stats.json
// history (verified: 8.4% of a real user's 1000-entry history was
Expand Down
19 changes: 13 additions & 6 deletions src/tools/defillama.ts
Original file line number Diff line number Diff line change
Expand Up @@ -29,6 +29,7 @@ import {
import type { CapabilityHandler, CapabilityResult, ExecutionScope } from '../agent/types.js';
import { loadChain, API_URLS, VERSION } from '../config.js';
import { logger } from '../logger.js';
import { recordUsage } from '../stats/tracker.js';

const TIMEOUT_MS = 30_000;

Expand All @@ -48,6 +49,8 @@ async function getWithPayment<T>(path: string, ctx: ExecutionScope): Promise<T>
const onAbort = () => controller.abort();
ctx.abortSignal.addEventListener('abort', onAbort, { once: true });

const startedAt = Date.now();
let paidUsd = 0;
try {
let response = await fetch(endpoint, {
method: 'GET',
Expand All @@ -56,14 +59,15 @@ async function getWithPayment<T>(path: string, ctx: ExecutionScope): Promise<T>
});

if (response.status === 402) {
const paymentHeaders = await signPayment(response, chain, endpoint);
if (!paymentHeaders) {
const signed = await signPayment(response, chain, endpoint);
if (!signed) {
throw new Error('Payment signing failed — check wallet balance');
}
paidUsd = signed.amountUsd;
response = await fetch(endpoint, {
method: 'GET',
signal: controller.signal,
headers: { ...headers, ...paymentHeaders },
headers: { ...headers, ...signed.headers },
});
}

Expand All @@ -72,6 +76,9 @@ async function getWithPayment<T>(path: string, ctx: ExecutionScope): Promise<T>
throw new Error(`DefiLlama ${path} failed (${response.status}): ${errText.slice(0, 200)}`);
}

// Record the settled x402 spend so DeFiLlama calls show up in franklin
// stats / audit AND count against the --max-spend ceiling (parity with surf.ts).
try { recordUsage(`DeFiLlama:${path}`, 0, 0, paidUsd, Date.now() - startedAt); } catch { /* best-effort */ }
return (await response.json()) as T;
} finally {
clearTimeout(timeout);
Expand All @@ -83,7 +90,7 @@ async function signPayment(
response: Response,
chain: 'base' | 'solana',
endpoint: string,
): Promise<Record<string, string> | null> {
): Promise<{ headers: Record<string, string>; amountUsd: number } | null> {
try {
const paymentHeader = await extractPaymentReq(response);
if (!paymentHeader) return null;
Expand All @@ -107,7 +114,7 @@ async function signPayment(
extra: details.extra as Record<string, unknown> | undefined,
},
);
return { 'PAYMENT-SIGNATURE': payload };
return { headers: { 'PAYMENT-SIGNATURE': payload }, amountUsd: Number(details.amount) / 1_000_000 };
}
const wallet = await getOrCreateWallet();
const paymentRequired = parsePaymentRequired(paymentHeader);
Expand All @@ -125,7 +132,7 @@ async function signPayment(
extra: details.extra as Record<string, unknown> | undefined,
},
);
return { 'PAYMENT-SIGNATURE': payload };
return { headers: { 'PAYMENT-SIGNATURE': payload }, amountUsd: Number(details.amount) / 1_000_000 };
} catch (err) {
logger.warn(`[franklin] DefiLlama payment error: ${(err as Error).message}`);
return null;
Expand Down
28 changes: 20 additions & 8 deletions src/tools/jupiter.ts
Original file line number Diff line number Diff line change
Expand Up @@ -124,7 +124,7 @@ function symbolFor(mint: string): string {
return mint.slice(0, 4) + '…';
}

function toAtomicUnits(amount: number, decimals: number): string {
export function toAtomicUnits(amount: number, decimals: number): string {
if (!Number.isFinite(amount) || amount <= 0) {
throw new Error('amount must be a positive finite number');
}
Expand All @@ -133,21 +133,33 @@ function toAtomicUnits(amount: number, decimals: number): string {
throw new Error('decimals must be a nonnegative safe integer');
}

const amountText = amount.toString().includes('e')
? amount.toFixed(decimals + 1)
: amount.toString();
// Parse the decimal string directly — no `float * scale`, which loses
// precision on high-decimal tokens (the original bug #89 fixed). Excess
// precision beyond the token's `decimals` is FLOORED to the atomic unit
// (slice keeps the leading `decimals` digits), so an agent-computed amount —
// ⅓ of a balance, or float noise like 0.1 + 0.2 = 0.30000000000000004 — still
// swaps its representable part instead of being rejected. The original
// Math.round approach instead rounded a sub-unit amount UP to one atomic unit.
// Plain-decimal string (never exponential form, so BigInt(wholePart) can't
// throw on large amounts like 1e21). toLocaleString keeps the exact value to
// 20 fraction digits — more than any token's decimals — and the slice() below
// FLOORS by truncation, so sub-precision dust is never rounded UP to one unit
// (unlike the old toFixed path, which rounded 9.999e-10 up to 1).
const amountText = amount.toLocaleString('en-US', { useGrouping: false, maximumFractionDigits: 20 });
const [wholePart, fractionalPart = ''] = amountText.split('.');
const normalizedFraction = fractionalPart.padEnd(decimals, '0').slice(0, decimals);
const roundedAwayFraction = fractionalPart.slice(decimals);
const hasRoundedAwayValue = /[1-9]/.test(roundedAwayFraction);

const whole = BigInt(wholePart);
const fraction = normalizedFraction === '' ? 0n : BigInt(normalizedFraction);
const scale = 10n ** BigInt(decimals);
const atomic = whole * scale + fraction;

if (atomic === 0n || hasRoundedAwayValue) {
throw new Error(`amount is below the token precision (${decimals} decimals)`);
// Reject only TRUE dust: an amount so small it floors to zero atomic units,
// which Jupiter can't swap. (The old code rounded this UP to 1 unit instead.)
if (atomic === 0n) {
throw new Error(
`amount ${amount} is below the minimum atomic unit for a ${decimals}-decimal token — too small to swap`,
);
}

return atomic.toString();
Expand Down
Loading
Loading