Skip to content

Commit 1142274

Browse files
committed
feat: Implement token usage accounting across LLM interactions and enforce per-ask budget limits
1 parent 8cb4164 commit 1142274

7 files changed

Lines changed: 400 additions & 16 deletions

File tree

src/components/panel/views/LeftAIView.jsx

Lines changed: 99 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -31,6 +31,31 @@ import { getAllTabularData, clearTabularData } from '../../../services/tabularDa
3131
// Shared Components
3232
import PanelIconButton from '../../shared/PanelIconButton.jsx';
3333

34+
// Ask-queue safety bounds. MAX_WIZARD_QUEUE caps how many asks can pile up
35+
// behind a running one; MAX_CONSECUTIVE_ASK_ERRORS pauses auto-draining after
36+
// this many back-to-back hard failures so a broken config can't drain a whole
37+
// queue into wasted API calls.
38+
const MAX_WIZARD_QUEUE = 10;
39+
const MAX_CONSECUTIVE_ASK_ERRORS = 3;
40+
41+
/**
42+
* Read a wizard iteration-cap setting from localStorage, preserving an explicit
43+
* 0 (= ∞ intent; the server clamps it to the per-tier hard ceiling). Only an
44+
* absent/empty/NaN value falls back to the default — note Number(null) is 0, so
45+
* the raw string must be guarded before coercion (a plain `||`/`??` would either
46+
* drop the intended 0 or treat "missing" as 0).
47+
*/
48+
function readWizardIterations(key, def) {
49+
try {
50+
const raw = localStorage.getItem(key);
51+
if (raw == null || raw === '') return def;
52+
const n = Number(raw);
53+
return Number.isFinite(n) ? n : def;
54+
} catch {
55+
return def;
56+
}
57+
}
58+
3459
/**
3560
* Build the update object for a node from a server enrichment match.
3661
* Shared between single and batch enrichment.
@@ -528,6 +553,11 @@ const LeftAIView = ({ compact = false,
528553
const wizardSendQueueRef = React.useRef([]); // [{ message, opts, conversationId }]
529554
const pendingDrainRef = React.useRef(null); // ask waiting for its tab to become active
530555
const [queuedSendCount, setQueuedSendCount] = React.useState(0);
556+
// Ask-queue circuit breaker: count consecutive hard failures so a queue of
557+
// asks that all error can't silently burn API calls one after another. Paused
558+
// draining resumes only when the user manually sends again or hits Stop.
559+
const consecutiveAskErrorsRef = React.useRef(0);
560+
const queuePausedRef = React.useRef(false);
531561
const [wizardStage, setWizardStage] = React.useState(null); // Track current wizard stage
532562
const [druidInstance, setDruidInstance] = React.useState(null); // Druid cognitive state manager
533563
// Synchronously hydrate conversations from localStorage to avoid async race conditions.
@@ -1278,6 +1308,7 @@ const LeftAIView = ({ compact = false,
12781308
// two runs ever contend for the shared run state.
12791309
React.useEffect(() => {
12801310
if (isProcessing) return;
1311+
if (queuePausedRef.current) return; // breaker tripped — wait for manual resume
12811312
if (pendingDrainRef.current) return; // a drained ask is already awaiting its tab
12821313
if (wizardSendQueueRef.current.length === 0) return;
12831314
const next = wizardSendQueueRef.current.shift();
@@ -1739,22 +1770,56 @@ const LeftAIView = ({ compact = false,
17391770
}
17401771
};
17411772

1773+
// Record a hard ask failure. After MAX_CONSECUTIVE_ASK_ERRORS in a row, trip the
1774+
// breaker: clear the queue and pause auto-draining so a broken config (bad key,
1775+
// endpoint down) can't drain every queued ask into wasted API calls.
1776+
const recordAskFailure = () => {
1777+
consecutiveAskErrorsRef.current += 1;
1778+
const hasQueued = wizardSendQueueRef.current.length > 0 || !!pendingDrainRef.current;
1779+
if (consecutiveAskErrorsRef.current >= MAX_CONSECUTIVE_ASK_ERRORS && hasQueued) {
1780+
queuePausedRef.current = true;
1781+
wizardSendQueueRef.current = [];
1782+
pendingDrainRef.current = null;
1783+
setQueuedSendCount(0);
1784+
addMessage('system', `Paused queued asks after ${MAX_CONSECUTIVE_ASK_ERRORS} consecutive errors. Fix the issue, then send again to resume.`);
1785+
}
1786+
};
1787+
17421788
const handleSendMessage = async (overrideInput, sendOptions) => {
17431789
const inputToUse = typeof overrideInput === 'string' ? overrideInput : currentInput;
17441790
if (!inputToUse.trim() && pendingAttachments.length === 0) return;
17451791
// A run is already in flight — queue this ask (bound to the tab it targets)
17461792
// rather than dropping it or letting a second run clobber the shared run state.
17471793
// drainWizardQueue() below picks it up when the current run completes.
17481794
if (isProcessingRef.current) {
1749-
wizardSendQueueRef.current.push({
1795+
const q = wizardSendQueueRef.current;
1796+
const targetConv = activeConversationIdRef.current;
1797+
// Dedup: don't queue an ask identical to one already waiting (same text +
1798+
// tab). Guards against double-dispatch (e.g. a programmatic event firing twice).
1799+
const isDupe = q.some(item => item.message === inputToUse && item.conversationId === targetConv);
1800+
if (isDupe) {
1801+
if (typeof overrideInput !== 'string') setCurrentInput('');
1802+
return;
1803+
}
1804+
// Bound the queue so runaway enqueueing can't pile up unbounded work.
1805+
if (q.length >= MAX_WIZARD_QUEUE) {
1806+
addMessage('system', `Ask queue is full (${MAX_WIZARD_QUEUE}). This ask was not queued — wait for the current runs to finish.`);
1807+
if (typeof overrideInput !== 'string') setCurrentInput('');
1808+
return;
1809+
}
1810+
q.push({
17501811
message: inputToUse,
17511812
opts: sendOptions,
1752-
conversationId: activeConversationIdRef.current,
1813+
conversationId: targetConv,
17531814
});
1754-
setQueuedSendCount(wizardSendQueueRef.current.length);
1815+
setQueuedSendCount(q.length);
17551816
if (typeof overrideInput !== 'string') setCurrentInput('');
17561817
return;
17571818
}
1819+
// A manual (non-queued) send means the user is actively driving — clear any
1820+
// tripped breaker so queued asks can drain again after this run.
1821+
queuePausedRef.current = false;
1822+
consecutiveAskErrorsRef.current = 0;
17581823
// Optional display-override path used by programmatic dispatchers (e.g., the Ask The Wizard
17591824
// chip). When set, the UI shows displayContent (a short summary) and the LLM's
17601825
// conversation-history replay uses replayContent (defaults to displayContent), while the
@@ -1907,9 +1972,11 @@ const LeftAIView = ({ compact = false,
19071972
try {
19081973
// Reuse the autonomous agent handler but with Druid prompt
19091974
await handleAutonomousAgent(messagePayload, 'druid');
1975+
consecutiveAskErrorsRef.current = 0; // a completed ask resets the breaker
19101976
} catch (error) {
19111977
console.error('Druid error:', error);
19121978
addMessage('system', `Druid error: ${error.message}`);
1979+
recordAskFailure();
19131980
} finally {
19141981
setIsProcessing(false);
19151982
}
@@ -1931,9 +1998,11 @@ const LeftAIView = ({ compact = false,
19311998
} else {
19321999
await handleQuestion(messagePayload);
19332000
}
2001+
consecutiveAskErrorsRef.current = 0; // a completed ask resets the breaker
19342002
} catch (error) {
19352003
console.error('[AI Collaboration] Error processing message:', error);
19362004
addMessage('system', `Error: ${error.message}`);
2005+
recordAskFailure();
19372006
} finally {
19382007
setIsProcessing(false);
19392008
setCurrentAgentRequest(null);
@@ -1946,6 +2015,9 @@ const LeftAIView = ({ compact = false,
19462015
wizardSendQueueRef.current = [];
19472016
pendingDrainRef.current = null;
19482017
setQueuedSendCount(0);
2018+
// Reset the error breaker so a fresh send after Stop drains normally.
2019+
queuePausedRef.current = false;
2020+
consecutiveAskErrorsRef.current = 0;
19492021
if (currentAgentRequest) {
19502022
currentAgentRequest.abort();
19512023
setCurrentAgentRequest(null);
@@ -2227,8 +2299,11 @@ const LeftAIView = ({ compact = false,
22272299
model: apiConfig.model,
22282300
settings: {
22292301
...apiConfig.settings,
2230-
maxIterationsLocal: Number(localStorage.getItem('rs.wizard.maxIterationsLocal')) || 177,
2231-
maxIterationsCloud: Number(localStorage.getItem('rs.wizard.maxIterationsCloud')) || 77,
2302+
// Preserve an explicit 0 (= ∞ intent, clamped to the tier ceiling
2303+
// server-side) instead of coercing it back to the default via ||.
2304+
// Absent/empty/NaN → default. Number(null) is 0, so guard raw first.
2305+
maxIterationsLocal: readWizardIterations('rs.wizard.maxIterationsLocal', 177),
2306+
maxIterationsCloud: readWizardIterations('rs.wizard.maxIterationsCloud', 77),
22322307
},
22332308
modelTier: apiConfig.modelTier || 'large'
22342309
} : null
@@ -2415,6 +2490,14 @@ const LeftAIView = ({ compact = false,
24152490
const openThinkIdx = blocks.findLastIndex(b => b.type === 'thinking' && !b.collapsed);
24162491
if (openThinkIdx >= 0) blocks[openThinkIdx] = { ...blocks[openThinkIdx], collapsed: true };
24172492
blocks.push({ type: 'steering', kind: event.kind || 'nudge', content: event.content || '' });
2493+
} else if (event.type === 'usage') {
2494+
// Running per-ask token totals from the agent loop. Each event
2495+
// carries the cumulative ask totals, so last-write wins.
2496+
msg.tokenUsage = {
2497+
promptTokens: event.askPromptTokens || 0,
2498+
completionTokens: event.askCompletionTokens || 0,
2499+
totalTokens: event.askTotalTokens || 0
2500+
};
24182501
} else if (event.type === 'error') {
24192502
blocks.push({ type: 'text', content: `Error: ${event.message}` });
24202503
msg.content = `Error: ${event.message}`;
@@ -2780,6 +2863,9 @@ const LeftAIView = ({ compact = false,
27802863
meta.push(`\n Complete: ${msg.metadata.isComplete}`);
27812864
}
27822865
}
2866+
if (msg.tokenUsage?.totalTokens > 0) {
2867+
meta.push(`\n Tokens: ${msg.tokenUsage.totalTokens.toLocaleString()} (prompt ${msg.tokenUsage.promptTokens.toLocaleString()} + completion ${msg.tokenUsage.completionTokens.toLocaleString()})`);
2868+
}
27832869

27842870
if (meta.length > 0) {
27852871
text += meta.join('');
@@ -3442,6 +3528,14 @@ const LeftAIView = ({ compact = false,
34423528
) : null}
34433529
{hasDefinitiveContent && <div className="ai-message-timestamp" style={{ display: 'flex', alignItems: 'center', gap: '6px', width: '100%', justifyContent: message.sender === 'user' ? 'flex-end' : 'flex-start' }}>
34443530
{new Date(message.timestamp).toLocaleTimeString()}
3531+
{message.sender === 'ai' && message.tokenUsage?.totalTokens > 0 && (
3532+
<span
3533+
style={{ opacity: 0.6, fontSize: '11px' }}
3534+
title={`Prompt ${message.tokenUsage.promptTokens.toLocaleString()} + completion ${message.tokenUsage.completionTokens.toLocaleString()} tokens across this ask`}
3535+
>
3536+
· {message.tokenUsage.totalTokens.toLocaleString()} tok
3537+
</span>
3538+
)}
34453539
{message.sender === 'user' && (
34463540
<button
34473541
onClick={() => {

src/components/settings/AISection.jsx

Lines changed: 23 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,22 @@ import apiKeyManager from '../../services/apiKeyManager.js';
55
import debugConfig from '../../utils/debugConfig.js';
66
import './AISection.css';
77

8+
/**
9+
* Read a stored wizard iteration cap, preserving an explicit 0 (= ∞ on the
10+
* slider). Absent/empty/NaN → default. Number(null) is 0, so the raw string is
11+
* guarded before coercion.
12+
*/
13+
function readStoredIterations(key, def) {
14+
try {
15+
const raw = localStorage.getItem(key);
16+
if (raw == null || raw === '') return def;
17+
const n = Number(raw);
18+
return Number.isFinite(n) ? n : def;
19+
} catch {
20+
return def;
21+
}
22+
}
23+
824
/**
925
* AI Settings Section - Adapted to Settings Modal patterns
1026
* Uses settings-row, selects, and inline controls
@@ -28,8 +44,11 @@ const AISection = () => {
2844
const [allowKeyEdit, setAllowKeyEdit] = useState(true);
2945
const [localPresets] = useState(() => apiKeyManager.getLocalProviderPresets());
3046
const [selectedPreset, setSelectedPreset] = useState(null);
31-
const [maxIterationsLocal, setMaxIterationsLocal] = useState(() => Number(localStorage.getItem('rs.wizard.maxIterationsLocal') ?? 177) || 177);
32-
const [maxIterationsCloud, setMaxIterationsCloud] = useState(() => Number(localStorage.getItem('rs.wizard.maxIterationsCloud') ?? 77) || 77);
47+
// Preserve an explicit stored 0 (= ∞ on the slider) instead of coercing it back
48+
// to the default via ||. Absent/empty/NaN → default. (Number(null) is 0, so the
49+
// raw string is guarded before coercion.)
50+
const [maxIterationsLocal, setMaxIterationsLocal] = useState(() => readStoredIterations('rs.wizard.maxIterationsLocal', 177));
51+
const [maxIterationsCloud, setMaxIterationsCloud] = useState(() => readStoredIterations('rs.wizard.maxIterationsCloud', 77));
3352
const [connectionTestResult, setConnectionTestResult] = useState(null);
3453
const [isTestingConnection, setIsTestingConnection] = useState(false);
3554
const [wizardConnectionPref, setWizardConnectionPref] = useState(() => {
@@ -859,7 +878,7 @@ const AISection = () => {
859878
<div className="settings-row">
860879
<div className="settings-row-label">
861880
Local model iterations
862-
<div className="settings-row-description">Max tool calls per turn for local/small models. 0 = unlimited</div>
881+
<div className="settings-row-description">Max tool calls per turn for local/small models. 0 = max (capped at 300)</div>
863882
</div>
864883
<div style={{ display: 'flex', alignItems: 'center', gap: '10px' }}>
865884
<input
@@ -880,7 +899,7 @@ const AISection = () => {
880899
<div className="settings-row">
881900
<div className="settings-row-label">
882901
Cloud model iterations
883-
<div className="settings-row-description">Max tool calls per turn for cloud models. 0 = unlimited</div>
902+
<div className="settings-row-description">Max tool calls per turn for cloud models. 0 = max (capped at 100)</div>
884903
</div>
885904
<div style={{ display: 'flex', alignItems: 'center', gap: '10px' }}>
886905
<input

src/wizard/AgentLoop.js

Lines changed: 43 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -769,6 +769,9 @@ export async function* runAgent(userMessage, graphState, config = {}, ensureSche
769769

770770
const hasTabularData = Array.isArray(graphState?._tabularData) && graphState._tabularData.length > 0;
771771
const maxIterations = config.maxIterations || DEFAULT_MAX_ITERATIONS;
772+
// Hard per-ask token budget (cost ceiling independent of iteration count). 0 or
773+
// absent → no budget cap (iteration cap still applies).
774+
const maxAskTokens = (config.maxAskTokens && config.maxAskTokens > 0) ? config.maxAskTokens : Infinity;
772775

773776
// Build static system prompt template (context will be appended fresh each iteration)
774777
const systemPromptTemplate = baseSystemPrompt
@@ -844,6 +847,11 @@ export async function* runAgent(userMessage, graphState, config = {}, ensureSche
844847
return { type: 'steering', kind, content };
845848
};
846849

850+
// Per-ask token accounting, summed across every iteration's LLM call. Surfaced
851+
// to the UI/logs and used as a hard per-ask cost ceiling (see maxAskTokens).
852+
let askPromptTokens = 0;
853+
let askCompletionTokens = 0;
854+
847855
for (let iteration = 0; iteration < maxIterations; iteration++) {
848856
// Rebuild context from (potentially mutated) graphState so LLM sees current state
849857
{
@@ -866,6 +874,7 @@ export async function* runAgent(userMessage, graphState, config = {}, ensureSche
866874
try {
867875
let iterationContent = '';
868876
let iterationToolCalls = [];
877+
let iterationUsage = null; // last usage chunk this call (providers report cumulative)
869878

870879
// Re-evaluate tool selection each iteration (graphState changes after tool execution)
871880
const userMessageText = typeof userMessage === 'string'
@@ -900,6 +909,11 @@ export async function* runAgent(userMessage, graphState, config = {}, ensureSche
900909
yield { type: 'thinking', content: chunk.content };
901910
continue;
902911
}
912+
if (chunk.type === 'usage') {
913+
// Providers report cumulative usage for the call, so last-seen wins.
914+
iterationUsage = chunk.usage;
915+
continue;
916+
}
903917
if (chunk.type === 'text') {
904918
const newContent = chunk.content;
905919
if (!newContent) continue;
@@ -984,6 +998,35 @@ export async function* runAgent(userMessage, graphState, config = {}, ensureSche
984998
}
985999
}
9861000

1001+
// Fold this iteration's token usage into the per-ask running total and surface
1002+
// it (per-iteration + running ask totals) so the UI and server logs can show
1003+
// real spend. Providers that don't report usage simply leave iterationUsage null.
1004+
if (iterationUsage) {
1005+
askPromptTokens += iterationUsage.promptTokens || 0;
1006+
askCompletionTokens += iterationUsage.completionTokens || 0;
1007+
yield {
1008+
type: 'usage',
1009+
iteration: iteration + 1,
1010+
promptTokens: iterationUsage.promptTokens || 0,
1011+
completionTokens: iterationUsage.completionTokens || 0,
1012+
totalTokens: iterationUsage.totalTokens || 0,
1013+
askPromptTokens,
1014+
askCompletionTokens,
1015+
askTotalTokens: askPromptTokens + askCompletionTokens
1016+
};
1017+
1018+
// Hard per-ask cost ceiling. Stop once cumulative spend crosses the budget,
1019+
// regardless of how many iterations remain. Runs after this iteration's tool
1020+
// calls have already been yielded so no in-flight work is lost.
1021+
const askTotalTokens = askPromptTokens + askCompletionTokens;
1022+
if (askTotalTokens > maxAskTokens) {
1023+
console.error(`[AgentLoop] Per-ask token budget reached (${askTotalTokens} > ${maxAskTokens}) at iteration ${iteration + 1}. Stopping.`);
1024+
yield { type: 'system_note', content: `Stopped: this ask reached its token budget (${askTotalTokens.toLocaleString()} tokens). Send "continue" to keep going.` };
1025+
yield { type: 'done', iterations: iteration + 1, reason: 'token_budget', askPromptTokens, askCompletionTokens, askTotalTokens };
1026+
return;
1027+
}
1028+
}
1029+
9871030
// Text tool-call salvage (Task 5): if the model produced NO native tool_calls but
9881031
// wrote one or more calls as prose, recover them and feed them through the normal
9891032
// dispatch path below. Only names offered this turn are accepted. This is harmless

0 commit comments

Comments
 (0)