diff --git a/frontend/src/lib/components/copilot/chat/AIChatDisplay.svelte b/frontend/src/lib/components/copilot/chat/AIChatDisplay.svelte index e86ce913c7..d82d9c6705 100644 --- a/frontend/src/lib/components/copilot/chat/AIChatDisplay.svelte +++ b/frontend/src/lib/components/copilot/chat/AIChatDisplay.svelte @@ -32,8 +32,10 @@ import Tooltip from '$lib/components/meltComponents/Tooltip.svelte' import Markdown from 'svelte-exmarkdown' import { twMerge } from 'tailwind-merge' - import { AIAutonomyMode, AIMode } from './AIChatManager.svelte' + import { AIAutonomyMode, AIMode, getTrimThreshold } from './AIChatManager.svelte' import { getAiChatManager } from './aiChatManagerContext' + import { getModelContextWindow } from '../lib' + import { tryGetCurrentModel } from '$lib/aiStore' import ChatTypingIndicator from './ChatTypingIndicator.svelte' import AIChatInput from './AIChatInput.svelte' import { getModifierKey } from '$lib/utils' @@ -288,6 +290,34 @@ return aiChatManager.appAiChatHelpers.getSelectedContext() }) + function formatTokenCount(n: number): string { + if (n < 1000) return `${Math.round(n)}` + const k = n / 1000 + if (k < 1000) return `${k < 10 ? k.toFixed(1) : Math.round(k)}k` + const m = n / 1_000_000 + return `${m < 10 ? m.toFixed(1) : Math.round(m)}M` + } + + // Accurate "current context size" badge. Hidden until a real provider-reported count + // exists (fresh chat, or a chat saved before this feature). Colour shifts as the + // context fills toward the threshold where older messages start being dropped. + const contextUsage = $derived.by(() => { + const tokens = aiChatManager.contextTokens + if (tokens == null) return undefined + const window = getModelContextWindow(tryGetCurrentModel()?.model ?? '') + const threshold = getTrimThreshold(window) + const colorClass = + tokens >= threshold + ? 'bg-red-100 dark:bg-red-900/30 text-red-700 dark:text-red-300' + : tokens >= threshold * 0.8 + ? 'bg-amber-100 dark:bg-amber-900/30 text-amber-700 dark:text-amber-300' + : 'bg-surface-secondary text-tertiary' + return { + label: `${formatTokenCount(tokens)} / ${formatTokenCount(window)}`, + colorClass + } + }) + const yoloBypassedTools = $derived.by(() => { return aiChatManager.tools .filter((tool) => tool.requiresConfirmation === true) @@ -643,6 +673,18 @@ {/if} + {#if contextUsage} +
+ {contextUsage.label} +
+ {/if} + {#if aiChatManager.mode === AIMode.APP && appContext && (appContext.inspectorElement || appContext.codeSelection)} {#if appContext.inspectorElement}
('') displayMessages = $state([]) messages = $state([]) + // Accurate "current context size" of the conversation, taken from the provider's + // reported usage on the last turn's final iteration (prompt + completion). + // Undefined until a turn completes (or a chat saved before this existed is restored). + // Doubles as the trim gate's accurate baseline (see checkTokenUsageOverLimit). + contextTokens = $state(undefined) + // Crude chars÷4 estimate measured over the message set at the moment contextTokens + // was last set. Used to calibrate the crude estimator against the real API count. + #anchorCrude: number | undefined = undefined autonomyMode = $state(getPersistedAutonomyMode()) autoAcceptEditsAvailable = $derived(supportsAutoAcceptEdits(this.mode)) autoAcceptEditsActive = $derived( @@ -239,27 +257,44 @@ export class AIChatManager { open = $derived(chatState.size > 0) - checkTokenUsageOverLimit = (messages: ChatCompletionMessageParam[]) => { - const estimatedTokens = messages.reduce((acc, message) => { - // one token is ~ 4 characters - const tokenPerCharacter = 4 - // handle content + // Crude chars÷4 estimate of the tokens a request carries: every message's content + + // tool_calls, PLUS the system message and tool schemas (which the API counts but the + // per-message loop above used to ignore — the source of context overflows in agentic mode). + #crudeEstimate = (messages: ChatCompletionMessageParam[]) => { + const tokenPerCharacter = 4 + let estimate = messages.reduce((acc, message) => { if (message.content) { acc += message.content.length / tokenPerCharacter } - // Handle tool calls if (message.role === 'assistant' && message.tool_calls) { acc += JSON.stringify(message.tool_calls).length / tokenPerCharacter } return acc }, 0) - const model = getCurrentModel() - const modelContextWindow = getModelContextWindow(model.model) - return ( - estimatedTokens > - modelContextWindow - - Math.max(modelContextWindow * MAX_TOKENS_THRESHOLD_PERCENTAGE, MAX_TOKENS_HARD_LIMIT) - ) + estimate += (this.systemMessage?.content?.length ?? 0) / tokenPerCharacter + if (this.tools.length > 0) { + estimate += JSON.stringify(this.tools.map((t) => t.def)).length / tokenPerCharacter + } + return estimate + } + + // Best estimate of the context size for a given message set. When we have a real + // provider-reported anchor (contextTokens) we scale the crude estimate by the + // anchor/crude ratio measured at anchor time — so the figure is accurate at the + // anchor and shrinks proportionally as messages are dropped during trimming. + // Falls back to the crude estimate (which now includes system + tools) on the first + // turn or for chats restored without a stored count. + #estimateContextTokens = (messages: ChatCompletionMessageParam[]) => { + const crude = this.#crudeEstimate(messages) + if (this.contextTokens != null && this.#anchorCrude && this.#anchorCrude > 0) { + return crude * (this.contextTokens / this.#anchorCrude) + } + return crude + } + + checkTokenUsageOverLimit = (messages: ChatCompletionMessageParam[]) => { + const modelContextWindow = getModelContextWindow(tryGetCurrentModel()?.model ?? '') + return this.#estimateContextTokens(messages) > getTrimThreshold(modelContextWindow) } deleteOldestMessage = (messages: ChatCompletionMessageParam[], maxDepth: number = 10) => { @@ -519,8 +554,7 @@ export class AIChatManager { this.tools = globalToolsFor({ sessionPreview: this.isSessionChat }) this.helpers = { ...(this.isSessionChat ? { sessionId: this.sessionId } : {}), - testActiveFlow: async (args?: Record) => - this.flowAiChatHelpers?.testFlow(args) + testActiveFlow: async (args?: Record) => this.flowAiChatHelpers?.testFlow(args) } satisfies GlobalToolHelpers } else if (mode === AIMode.APP) { const customPrompt = getCombinedCustomPrompt(mode) @@ -721,6 +755,14 @@ export class AIChatManager { } } }) + // Anchor the accurate context size on the provider-reported usage of the final + // iteration (prompt already holds the fully-grown context; completion is the + // final reply). Record the crude estimate over the same message set so the trim + // gate can calibrate against this anchor. + if (result.lastIterationUsage?.total) { + this.contextTokens = result.lastIterationUsage.total + this.#anchorCrude = this.#crudeEstimate([...messages, ...result.addedMessages]) + } return result.addedMessages } catch (err) { console.log('chatRequest error', err) @@ -966,7 +1008,7 @@ export class AIChatManager { } this.messages.push(userMessage) - await this.historyManager.saveChat(this.displayMessages, this.messages) + await this.historyManager.saveChat(this.displayMessages, this.messages, this.contextTokens) this.currentReply = '' @@ -1054,7 +1096,7 @@ export class AIChatManager { if (this.autoAcceptEditsActive) { this.acceptPendingFlowEdits() } - await this.historyManager.saveChat(this.displayMessages, this.messages) + await this.historyManager.saveChat(this.displayMessages, this.messages, this.contextTokens) if (isFirstUserTurn && this.afterFirstTurnSaved) { void Promise.resolve(this.afterFirstTurnSaved()).catch((e) => { console.error('AIChatManager afterFirstTurnSaved hook failed', e) @@ -1152,9 +1194,11 @@ export class AIChatManager { saveAndClear = async () => { this.cancel('saveAndClear') - await this.historyManager.save(this.displayMessages, this.messages) + await this.historyManager.save(this.displayMessages, this.messages, this.contextTokens) this.displayMessages = [] this.messages = [] + this.contextTokens = undefined + this.#anchorCrude = undefined } loadPastChat = async (id: string) => { @@ -1162,6 +1206,11 @@ export class AIChatManager { if (chat) { this.displayMessages = chat.displayMessages this.messages = chat.actualMessages + this.contextTokens = chat.contextTokens + // Re-derive the calibration baseline so the trim gate is accurate on the next + // check even before a new turn. Undefined for chats saved before this existed. + this.#anchorCrude = + chat.contextTokens != null ? this.#crudeEstimate(chat.actualMessages) : undefined this.#automaticScroll = true } } diff --git a/frontend/src/lib/components/copilot/chat/AIChatManager.test.ts b/frontend/src/lib/components/copilot/chat/AIChatManager.test.ts index b1c77df84d..dafd36f1ee 100644 --- a/frontend/src/lib/components/copilot/chat/AIChatManager.test.ts +++ b/frontend/src/lib/components/copilot/chat/AIChatManager.test.ts @@ -217,3 +217,72 @@ describe('AIChatManager persisted autonomy default', () => { expect(new AIChatManager().autonomyMode).toBe(AIAutonomyMode.DEFAULT) }) }) + +describe('AIChatManager context trim gate', () => { + // Mocked window is 128000; threshold = 128000 - max(128000*0.05, 5000) = 121600 tokens. + // chars÷4, so ~486400 chars to exceed. + const big = (chars: number) => 'x'.repeat(chars) + + beforeEach(() => { + vi.clearAllMocks() + }) + + it('counts the system message in the estimate (closes the overflow blind spot)', () => { + const manager = new AIChatManager() + const messages = [{ role: 'user', content: 'hi' }] as any + + // Small messages alone are well under the limit... + expect(manager.checkTokenUsageOverLimit(messages)).toBe(false) + + // ...but a large system message must push it over (the old gate ignored it). + manager.systemMessage = { role: 'system', content: big(520_000) } + expect(manager.checkTokenUsageOverLimit(messages)).toBe(true) + }) + + it('counts the tool schemas in the estimate', () => { + const manager = new AIChatManager() + const messages = [{ role: 'user', content: 'hi' }] as any + + expect(manager.checkTokenUsageOverLimit(messages)).toBe(false) + + manager.tools = [{ def: { name: 'huge', schema: big(520_000) } }] as any + expect(manager.checkTokenUsageOverLimit(messages)).toBe(true) + }) + + it('drops an assistant tool-call message together with its tool response', () => { + const manager = new AIChatManager() + const messages = [ + { role: 'assistant', content: null, tool_calls: [{ id: 't1', function: { name: 'f' } }] }, + { role: 'tool', tool_call_id: 't1', content: big(300_000) }, + { role: 'user', content: big(300_000) } + ] as any + + // 3 messages (~600k chars = 150k tokens) exceed the 121.6k threshold; the lone + // user message (~300k chars / 4 = 75k tokens) is under it. + expect(manager.checkTokenUsageOverLimit(messages)).toBe(true) + + const trimmed = manager.deleteOldestMessage([...messages]) + + // The assistant+tool pair is removed together — never a dangling tool at the front. + expect(trimmed.map((m: any) => m.role)).toEqual(['user']) + expect(manager.checkTokenUsageOverLimit(trimmed)).toBe(false) + }) + + it('calibrates the estimate to the accurate anchor when one exists', () => { + const manager = new AIChatManager() + const messages = [{ role: 'user', content: big(200_000) }] as any // crude ≈ 50k tokens + + // Without an anchor the crude estimate (~50k) is under the 121.6k threshold. + expect(manager.checkTokenUsageOverLimit(messages)).toBe(false) + + // Restoring a chat whose real context was ~130k tokens sets the anchor; the gate + // scales the crude estimate by anchor/crude and now reports over-limit. + ;(manager.historyManager as any).loadPastChat = () => ({ + displayMessages: [], + actualMessages: messages, + contextTokens: 130_000 + }) + manager.loadPastChat('id') + expect(manager.checkTokenUsageOverLimit(messages)).toBe(true) + }) +}) diff --git a/frontend/src/lib/components/copilot/chat/HistoryManager.svelte.ts b/frontend/src/lib/components/copilot/chat/HistoryManager.svelte.ts index d07f6943bd..33766c97b1 100644 --- a/frontend/src/lib/components/copilot/chat/HistoryManager.svelte.ts +++ b/frontend/src/lib/components/copilot/chat/HistoryManager.svelte.ts @@ -12,6 +12,10 @@ interface ChatSchema extends IDBSchema { title: string lastModified: number sessionId?: string + // Accurate context size (tokens) at the last saved turn. Optional: chats saved + // before this field existed read back as undefined (no migration needed — IndexedDB + // stores plain objects). + contextTokens?: number } } } @@ -28,6 +32,7 @@ export default class HistoryManager { id: string lastModified: number sessionId?: string + contextTokens?: number } > = $state({}) @@ -104,7 +109,11 @@ export default class HistoryManager { return Object.values(this.savedChats) } - async saveChat(displayMessages: DisplayMessage[], messages: ChatCompletionMessageParam[]) { + async saveChat( + displayMessages: DisplayMessage[], + messages: ChatCompletionMessageParam[], + contextTokens?: number + ) { if (displayMessages.length > 0) { // we don't want to save the snapshot in the history const updatedChat = { @@ -116,6 +125,7 @@ export default class HistoryManager { title: displayMessages[0].content.slice(0, 50), id: this.currentChatId, lastModified: Date.now(), + ...(contextTokens != null ? { contextTokens } : {}), ...(this.sessionId ? { sessionId: this.sessionId } : {}) } this.savedChats = { @@ -129,8 +139,12 @@ export default class HistoryManager { } } - async save(displayMessages: DisplayMessage[], messages: ChatCompletionMessageParam[]) { - await this.saveChat(displayMessages, messages) + async save( + displayMessages: DisplayMessage[], + messages: ChatCompletionMessageParam[], + contextTokens?: number + ) { + await this.saveChat(displayMessages, messages, contextTokens) this.currentChatId = createLongHash() } diff --git a/frontend/src/lib/components/copilot/chat/chatLoop.ts b/frontend/src/lib/components/copilot/chat/chatLoop.ts index a624698cc9..6f3e1cbf15 100644 --- a/frontend/src/lib/components/copilot/chat/chatLoop.ts +++ b/frontend/src/lib/components/copilot/chat/chatLoop.ts @@ -8,16 +8,9 @@ import type { import type { AIProviderModel } from '$lib/gen' import { getCompletion, parseOpenAICompletion } from '../lib' import { getAnthropicCompletion, parseAnthropicCompletion } from './anthropic' -import { - getOpenAIResponsesCompletion, - parseOpenAIResponsesCompletion -} from './openai-responses' +import { getOpenAIResponsesCompletion, parseOpenAIResponsesCompletion } from './openai-responses' import type { Tool, ToolCallbacks } from './shared' -import { - addChatTokenUsage, - emptyChatTokenUsage, - type ChatTokenUsage -} from './tokenUsage' +import { addChatTokenUsage, emptyChatTokenUsage, type ChatTokenUsage } from './tokenUsage' export interface ChatClients { openai: OpenAI @@ -55,6 +48,15 @@ export interface ChatLoopConfig { export interface ChatLoopResult { addedMessages: ChatCompletionMessageParam[] tokenUsage: ChatTokenUsage + /** + * Usage of the final loop iteration only (last write wins). Its prompt already + * includes the fully-grown context (original messages + every tool result this + * turn), and its completion is the final assistant reply now appended — so + * `prompt + completion` approximates the whole conversation's token size after + * the turn. Used as the accurate "current context size" anchor, distinct from + * the cumulative `tokenUsage` above (which sums every iteration = total billed). + */ + lastIterationUsage: ChatTokenUsage hitMaxIterations: boolean } @@ -74,6 +76,7 @@ export async function runChatLoop(config: ChatLoopConfig): Promise