mirror of
https://github.com/windmill-labs/windmill.git
synced 2026-09-19 16:02:31 +00:00
Compare commits
1
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
1ccf2b7e72 |
@@ -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}
|
||||
<ProviderModelSelector />
|
||||
|
||||
{#if contextUsage}
|
||||
<div
|
||||
class={twMerge(
|
||||
'inline-flex items-center gap-1 px-1.5 py-0.5 rounded text-2xs tabular-nums',
|
||||
contextUsage.colorClass
|
||||
)}
|
||||
title="Context used — older messages are dropped as this fills"
|
||||
>
|
||||
{contextUsage.label}
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
{#if aiChatManager.mode === AIMode.APP && appContext && (appContext.inspectorElement || appContext.codeSelection)}
|
||||
{#if appContext.inspectorElement}
|
||||
<div
|
||||
|
||||
@@ -70,8 +70,18 @@ import {
|
||||
import { isGlobalAiEnabled } from './global/gate'
|
||||
|
||||
// If the estimated token usage is greater than the model context window - the threshold, we delete the oldest message
|
||||
const MAX_TOKENS_THRESHOLD_PERCENTAGE = 0.05
|
||||
const MAX_TOKENS_HARD_LIMIT = 5000
|
||||
export const MAX_TOKENS_THRESHOLD_PERCENTAGE = 0.05
|
||||
export const MAX_TOKENS_HARD_LIMIT = 5000
|
||||
|
||||
// The token count above which the oldest messages start being dropped for a given
|
||||
// context window. Shared with the UI so the context-size badge can colour itself to
|
||||
// match when trimming actually kicks in.
|
||||
export function getTrimThreshold(modelContextWindow: number): number {
|
||||
return (
|
||||
modelContextWindow -
|
||||
Math.max(modelContextWindow * MAX_TOKENS_THRESHOLD_PERCENTAGE, MAX_TOKENS_HARD_LIMIT)
|
||||
)
|
||||
}
|
||||
const AI_AUTONOMY_MODE_STORAGE_KEY = 'ai-chat-autonomy-mode'
|
||||
const LEGACY_AUTO_ACCEPT_TOOL_CONFIRMATIONS_STORAGE_KEY = 'ai-chat-yolo-mode'
|
||||
|
||||
@@ -169,6 +179,14 @@ export class AIChatManager {
|
||||
currentReply = $state<string>('')
|
||||
displayMessages = $state<DisplayMessage[]>([])
|
||||
messages = $state<ChatCompletionMessageParam[]>([])
|
||||
// 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<number | undefined>(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<AIAutonomyMode>(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<string, any>) =>
|
||||
this.flowAiChatHelpers?.testFlow(args)
|
||||
testActiveFlow: async (args?: Record<string, any>) => 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
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
})
|
||||
})
|
||||
|
||||
@@ -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()
|
||||
}
|
||||
|
||||
|
||||
@@ -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<ChatLoopResul
|
||||
|
||||
const addedMessages: ChatCompletionMessageParam[] = []
|
||||
let tokenUsage = emptyChatTokenUsage()
|
||||
let lastIterationUsage = emptyChatTokenUsage()
|
||||
let iterations = 0
|
||||
let hitMaxIterations = false
|
||||
|
||||
@@ -133,14 +136,12 @@ export async function runChatLoop(config: ChatLoopConfig): Promise<ChatLoopResul
|
||||
parseOptions
|
||||
)
|
||||
tokenUsage = addChatTokenUsage(tokenUsage, continueCompletion.tokenUsage)
|
||||
lastIterationUsage = continueCompletion.tokenUsage
|
||||
if (!continueCompletion.shouldContinue) {
|
||||
break
|
||||
}
|
||||
} catch (err) {
|
||||
console.warn(
|
||||
'OpenAI Responses API failed, falling back to Completions API:',
|
||||
err
|
||||
)
|
||||
console.warn('OpenAI Responses API failed, falling back to Completions API:', err)
|
||||
const errorMessage = err instanceof Error ? err.message : String(err)
|
||||
if (errorMessage.includes('Responses API is not enabled')) {
|
||||
skipResponsesApi = true
|
||||
@@ -167,20 +168,16 @@ export async function runChatLoop(config: ChatLoopConfig): Promise<ChatLoopResul
|
||||
parseOptions
|
||||
)
|
||||
tokenUsage = addChatTokenUsage(tokenUsage, continueCompletion.tokenUsage)
|
||||
lastIterationUsage = continueCompletion.tokenUsage
|
||||
if (!continueCompletion.shouldContinue) {
|
||||
break
|
||||
}
|
||||
}
|
||||
} else if (isAnthropic) {
|
||||
const completion = await getAnthropicCompletion(
|
||||
messageParams,
|
||||
abortController,
|
||||
toolDefs,
|
||||
{
|
||||
forceModelProvider: modelProvider,
|
||||
anthropicClient: clients.anthropic
|
||||
}
|
||||
)
|
||||
const completion = await getAnthropicCompletion(messageParams, abortController, toolDefs, {
|
||||
forceModelProvider: modelProvider,
|
||||
anthropicClient: clients.anthropic
|
||||
})
|
||||
if (completion) {
|
||||
const continueCompletion = await parseAnthropicCompletion(
|
||||
completion,
|
||||
@@ -193,6 +190,7 @@ export async function runChatLoop(config: ChatLoopConfig): Promise<ChatLoopResul
|
||||
parseOptions
|
||||
)
|
||||
tokenUsage = addChatTokenUsage(tokenUsage, continueCompletion.tokenUsage)
|
||||
lastIterationUsage = continueCompletion.tokenUsage
|
||||
if (!continueCompletion.shouldContinue) {
|
||||
break
|
||||
}
|
||||
@@ -214,6 +212,7 @@ export async function runChatLoop(config: ChatLoopConfig): Promise<ChatLoopResul
|
||||
parseOptions
|
||||
)
|
||||
tokenUsage = addChatTokenUsage(tokenUsage, continueCompletion.tokenUsage)
|
||||
lastIterationUsage = continueCompletion.tokenUsage
|
||||
if (!continueCompletion.shouldContinue) {
|
||||
break
|
||||
}
|
||||
@@ -221,5 +220,5 @@ export async function runChatLoop(config: ChatLoopConfig): Promise<ChatLoopResul
|
||||
}
|
||||
}
|
||||
|
||||
return { addedMessages, tokenUsage, hitMaxIterations }
|
||||
return { addedMessages, tokenUsage, lastIterationUsage, hitMaxIterations }
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user