diff --git a/package-lock.json b/package-lock.json index d93a3d7..bc5838f 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "K.G.Studio", - "version": "0.17.4-build.20260520", + "version": "0.19.0-build.20260531", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "K.G.Studio", - "version": "0.17.4-build.20260520", + "version": "0.19.0-build.20260531", "dependencies": { "@breezystack/lamejs": "^1.2.7", "class-transformer": "^0.5.1", diff --git a/public/config.json b/public/config.json index e9c1bd7..3940f0b 100644 --- a/public/config.json +++ b/public/config.json @@ -3,6 +3,7 @@ "language": "auto", "llm_provider": "local_browser", "persist_api_keys_non_localhost": false, + "auto_compact_threshold_percent": 90, "openai": { "api_key": "", "flex": false, diff --git a/public/prompts/system_compaction.md b/public/prompts/system_compaction.md new file mode 100644 index 0000000..3429964 --- /dev/null +++ b/public/prompts/system_compaction.md @@ -0,0 +1,16 @@ +You are summarizing a K.G.Studio Musician Assistant conversation so it can continue in a smaller context window. + +Your job: +- Preserve the current task objective. +- Preserve accepted constraints, decisions, and user preferences. +- Preserve important tool calls, tool outcomes, and error messages that still matter. +- Preserve relevant project context such as BPM, key, time signature, region boundaries, and track/instrument context when it affects the task. +- Preserve unfinished work and the next best action. + +Rules: +- Be concise but specific. +- Prefer durable facts over conversational filler. +- Do not rewrite the user's intent. +- Do not include long verbatim transcript excerpts. +- Do not invent missing information. +- Make the summary usable as a direct handoff for the next model turn. diff --git a/src/agent/compact/ConversationCompactor.test.ts b/src/agent/compact/ConversationCompactor.test.ts new file mode 100644 index 0000000..2ac0e0e --- /dev/null +++ b/src/agent/compact/ConversationCompactor.test.ts @@ -0,0 +1,70 @@ +import { describe, expect, it, vi } from 'vitest'; +import { ConversationCompactor } from './ConversationCompactor'; +import type { LLMProvider } from '../llm/LLMProvider'; +import type { Message } from '../core/AgentState'; + +function createStubProvider(summaryPrefix = 'summary'): LLMProvider { + return { + async *generateStream(messages) { + const source = messages[0]?.content ?? ''; + yield { type: 'text', content: `${summaryPrefix}:${String(source).slice(0, 12)}` }; + yield { type: 'done', content: '', finishReason: 'stop' }; + }, + }; +} + +describe('ConversationCompactor', () => { + it('preserves the recent raw tail while compacting the prefix', async () => { + const compactor = new ConversationCompactor({ + provider: createStubProvider(), + systemPrompt: 'compact prompt', + }); + const messages: Message[] = [ + { id: '1', role: 'user', content: 'older user', timestamp: 1 }, + { id: '2', role: 'assistant', content: 'older reply', timestamp: 2 }, + { id: '3', role: 'user', content: 'recent user', timestamp: 3 }, + { id: '4', role: 'assistant', content: 'recent reply', timestamp: 4 }, + ]; + + const result = await compactor.compact(messages, 2); + + expect(result.changed).toBe(true); + expect(result.summary).toContain('Compacted conversation summary:'); + expect(result.compactedConversation).toContain('recent user'); + expect(result.compactedConversation).toContain('recent reply'); + }); + + it('returns unchanged when there is no compactable prefix', async () => { + const compactor = new ConversationCompactor({ + provider: createStubProvider(), + systemPrompt: 'compact prompt', + }); + const messages: Message[] = [ + { id: '1', role: 'user', content: 'only user', timestamp: 1 }, + { id: '2', role: 'assistant', content: 'only reply', timestamp: 2 }, + ]; + + const result = await compactor.compact(messages, 0); + + expect(result.changed).toBe(false); + }); + + it('emits progress while generating chunk summaries', async () => { + const onProgress = vi.fn(); + const compactor = new ConversationCompactor({ + provider: createStubProvider(), + systemPrompt: 'compact prompt', + onProgress, + }); + const messages: Message[] = [ + { id: '1', role: 'user', content: 'older user', timestamp: 1 }, + { id: '2', role: 'assistant', content: 'older reply', timestamp: 2 }, + { id: '3', role: 'user', content: 'recent user', timestamp: 3 }, + { id: '4', role: 'assistant', content: 'recent reply', timestamp: 4 }, + ]; + + await compactor.compact(messages, 2); + + expect(onProgress).toHaveBeenCalled(); + }); +}); diff --git a/src/agent/compact/ConversationCompactor.ts b/src/agent/compact/ConversationCompactor.ts new file mode 100644 index 0000000..4dcc99c --- /dev/null +++ b/src/agent/compact/ConversationCompactor.ts @@ -0,0 +1,204 @@ +import type { Message } from '../core/AgentState'; +import type { LLMProvider } from '../llm/LLMProvider'; +import type { OpenAIToolDefinition } from '../tools/BaseTool'; + +export interface CompactProgress { + chunkIndex: number; + chunkCount: number; + receivedTokenCount: number; +} + +export interface ConversationCompactorOptions { + provider: LLMProvider; + systemPrompt: string; + tools?: OpenAIToolDefinition[]; + focus?: string; + onProgress?: (progress: CompactProgress) => void; +} + +export interface ConversationCompactionResult { + changed: boolean; + compactedConversation: string; + summary: string; + tailStartIndex: number; +} + +const CHUNK_CHARACTER_BUDGET = 24_000; + +function formatMessage(message: Message): string { + const parts = [`[${message.role.toUpperCase()}]`]; + if (message.is_compacted_summary) { + parts.push('[COMPACTED_SUMMARY]'); + } + + if (message.content) { + parts.push(message.content); + } + + if (message.tool_calls?.length) { + for (const toolCall of message.tool_calls) { + parts.push( + `TOOL_CALL ${toolCall.function.name}: ${toolCall.function.arguments}`, + ); + } + } + + if (message.tool_call_id) { + parts.push(`TOOL_RESULT_FOR ${message.tool_call_id}`); + } + + return parts.join('\n'); +} + +function splitIntoChunks(serializedMessages: string[]): string[] { + const chunks: string[] = []; + let currentChunk = ''; + + for (const serialized of serializedMessages) { + if (!currentChunk) { + currentChunk = serialized; + continue; + } + + if ((currentChunk.length + serialized.length + 2) > CHUNK_CHARACTER_BUDGET) { + chunks.push(currentChunk); + currentChunk = serialized; + continue; + } + + currentChunk += `\n\n${serialized}`; + } + + if (currentChunk) { + chunks.push(currentChunk); + } + + return chunks; +} + +function buildCompactionUserPrompt(chunkText: string, chunkIndex: number, chunkCount: number, focus?: string): string { + const focusSection = focus?.trim() + ? `Focus instruction from the user: ${focus.trim()}\n\n` + : ''; + + return `${focusSection}Summarize this conversation history chunk for future continuation. + +Preserve: +- the active goal +- accepted constraints and decisions +- important tool results and errors +- relevant project, track, region, and music context +- unfinished work and next steps + +Do not quote the full transcript. Produce a concise but durable handoff summary. + +Chunk ${chunkIndex + 1} of ${chunkCount}: + +${chunkText}`; +} + +export class ConversationCompactor { + private readonly provider: LLMProvider; + private readonly systemPrompt: string; + private readonly tools: OpenAIToolDefinition[]; + private readonly focus?: string; + private readonly onProgress?: (progress: CompactProgress) => void; + + constructor(options: ConversationCompactorOptions) { + this.provider = options.provider; + this.systemPrompt = options.systemPrompt; + this.tools = options.tools ?? []; + this.focus = options.focus; + this.onProgress = options.onProgress; + } + + async compact(messages: Message[], tailStartIndex: number): Promise { + if (tailStartIndex <= 0 || tailStartIndex >= messages.length) { + return { + changed: false, + compactedConversation: this.renderConversation(messages), + summary: '', + tailStartIndex, + }; + } + + const prefix = messages.slice(0, tailStartIndex); + const serializedMessages = prefix.map(formatMessage); + const chunks = splitIntoChunks(serializedMessages); + + if (chunks.length === 0) { + return { + changed: false, + compactedConversation: this.renderConversation(messages), + summary: '', + tailStartIndex, + }; + } + + let summaries = await Promise.all( + chunks.map((chunk, index) => this.summarizeChunk(chunk, index, chunks.length)), + ); + + while (summaries.length > 1) { + const mergedChunks = splitIntoChunks(summaries.map((summary, index) => `SUMMARY ${index + 1}\n${summary}`)); + summaries = await Promise.all( + mergedChunks.map((chunk, index) => this.summarizeChunk(chunk, index, mergedChunks.length)), + ); + } + + const summary = `Compacted conversation summary:\n${summaries[0].trim()}`; + const compactedConversation = this.renderConversation([ + { + ...messages[0], + id: 'compacted-summary-preview', + role: 'assistant', + content: summary, + is_compacted_summary: true, + compact_trigger: 'manual', + tool_calls: undefined, + tool_call_id: undefined, + }, + ...messages.slice(tailStartIndex), + ]); + + return { + changed: true, + compactedConversation, + summary, + tailStartIndex, + }; + } + + renderConversation(messages: Message[]): string { + return messages.map(formatMessage).join('\n\n'); + } + + private async summarizeChunk(chunkText: string, chunkIndex: number, chunkCount: number): Promise { + const prompt = buildCompactionUserPrompt(chunkText, chunkIndex, chunkCount, this.focus); + const messages: Message[] = [ + { + id: `compact_user_${chunkIndex}`, + role: 'user', + content: prompt, + timestamp: Date.now(), + }, + ]; + + let receivedTokenCount = 0; + let summary = ''; + + for await (const chunk of this.provider.generateStream(messages, this.systemPrompt, [])) { + if (chunk.type === 'text') { + summary += chunk.content; + receivedTokenCount += 1; + this.onProgress?.({ + chunkIndex, + chunkCount, + receivedTokenCount, + }); + } + } + + return summary.trim(); + } +} diff --git a/src/agent/core/AgentCore.ts b/src/agent/core/AgentCore.ts index 3dd9b7a..f6830cd 100644 --- a/src/agent/core/AgentCore.ts +++ b/src/agent/core/AgentCore.ts @@ -6,6 +6,19 @@ import { useProjectStore } from '../../stores/projectStore'; import type { StreamChunk } from '../llm/StreamingTypes'; import type { ToolCall } from './AgentState'; import type { OpenAIToolDefinition } from '../tools/BaseTool'; +import { ConversationCompactor, type CompactProgress } from '../compact/ConversationCompactor'; +import { ConfigManager } from '../../core/config/ConfigManager'; + +export interface CompactConversationOptions { + trigger: 'manual' | 'auto'; + focus?: string; + onProgress?: (progress: CompactProgress) => void; +} + +export interface CompactConversationResult { + changed: boolean; + compactedConversation: string; +} /** * Main orchestrator for the AI agent system. @@ -52,6 +65,10 @@ export class AgentCore { }); } + private async getSystemPrompt(templatePath?: string): Promise { + return SystemPrompts.getSystemPromptWithContext(templatePath); + } + /** * Execute a single tool call and return the result */ @@ -207,6 +224,105 @@ export class AgentCore { this.agentState.clearMessages(); } + async shouldCompactBeforeNextTurn(userInput: string): Promise { + if (!this.llmProvider?.estimateHistoryTokens || !this.llmProvider.getContextWindow) { + return false; + } + + const contextWindow = this.llmProvider.getContextWindow(); + if (!contextWindow) { + return false; + } + + const tools = this.getToolDefinitions(); + const systemPrompt = await this.getSystemPrompt( + this.llmProvider.getPreferredSystemPromptPath?.(), + ); + const thresholdPercent = await this.getAutoCompactThresholdPercent(); + const reservedOutputTokens = this.llmProvider.getReservedOutputTokens?.() ?? 4096; + const hypotheticalMessages = [ + ...this.agentState.getMessages(), + { + id: `preflight_${Date.now()}`, + role: 'user' as const, + content: userInput, + timestamp: Date.now(), + }, + ]; + const estimatedTokens = await this.llmProvider.estimateHistoryTokens( + hypotheticalMessages, + systemPrompt, + tools, + ); + const thresholdTokens = Math.floor(contextWindow * (thresholdPercent / 100)); + + return (estimatedTokens + reservedOutputTokens) >= thresholdTokens; + } + + async compactConversation(options: CompactConversationOptions): Promise { + if (!this.llmProvider) { + throw new Error('No LLM provider configured'); + } + + const messages = this.agentState.getMessages(); + if (messages.length < 2) { + return { + changed: false, + compactedConversation: messages.map(message => message.content ?? '').join('\n\n'), + }; + } + + const tailStartIndex = this.agentState.findRecentTailStartIndex(); + if (tailStartIndex <= 0 || tailStartIndex >= messages.length) { + return { + changed: false, + compactedConversation: messages.map(message => message.content ?? '').join('\n\n'), + }; + } + + const compactionPrompt = await this.getSystemPrompt('prompts/system_compaction.md'); + const compactor = new ConversationCompactor({ + provider: this.llmProvider, + systemPrompt: compactionPrompt, + tools: this.getToolDefinitions(), + focus: options.focus, + onProgress: options.onProgress, + }); + const result = await compactor.compact(messages, tailStartIndex); + if (!result.changed) { + return { + changed: false, + compactedConversation: result.compactedConversation, + }; + } + + const nextMessages = this.agentState.createCompactedHistory( + result.summary, + result.tailStartIndex, + options.trigger, + ); + this.agentState.replaceMessages(nextMessages); + console.log('------------ COMPACTED CONVERSATION ------------'); + console.log(result.compactedConversation); + console.log('------------------------------------------------'); + + return { + changed: true, + compactedConversation: result.compactedConversation, + }; + } + + async retryAfterCompaction( + userInput: string, + options: Omit = {}, + ): Promise { + return this.compactConversation({ + trigger: 'auto', + focus: options.focus, + onProgress: options.onProgress, + }); + } + getIsWorkingOnTask(): boolean { return this.agentState.getIsWorkingOnTask(); } @@ -214,4 +330,22 @@ export class AgentCore { setIsWorkingOnTask(isWorking: boolean): void { this.agentState.setIsWorkingOnTask(isWorking); } + + private async getAutoCompactThresholdPercent(): Promise<80 | 90 | 95> { + try { + const configManager = ConfigManager.instance(); + if (!configManager.getIsInitialized()) { + await configManager.initialize(); + } + + const configured = Number(configManager.get('general.auto_compact_threshold_percent')); + if (configured === 80 || configured === 95) { + return configured; + } + } catch (error) { + console.warn('Failed to load auto-compact threshold, using default.', error); + } + + return 90; + } } diff --git a/src/agent/core/AgentState.test.ts b/src/agent/core/AgentState.test.ts new file mode 100644 index 0000000..548f6d2 --- /dev/null +++ b/src/agent/core/AgentState.test.ts @@ -0,0 +1,65 @@ +import { describe, expect, it } from 'vitest'; +import { AgentState } from './AgentState'; + +describe('AgentState compaction helpers', () => { + it('preserves the most recent exchange block as the raw tail', () => { + const state = new AgentState('conv_test'); + state.addMessage('user', 'first'); + state.addMessage('assistant', 'first reply'); + state.addMessage('user', 'second'); + state.addMessage('assistant', 'second reply'); + + expect(state.findRecentTailStartIndex()).toBe(2); + }); + + it('creates compacted history with a synthetic summary message', () => { + const state = new AgentState('conv_test'); + state.addMessage('user', 'first'); + state.addMessage('assistant', 'first reply'); + state.addMessage('user', 'second'); + state.addMessage('assistant', 'second reply'); + + const compacted = state.createCompactedHistory('summary', 2, 'manual'); + + expect(compacted).toHaveLength(3); + expect(compacted[0]).toMatchObject({ + role: 'assistant', + content: 'summary', + is_compacted_summary: true, + compact_trigger: 'manual', + }); + expect(compacted[1].content).toBe('second'); + expect(compacted[2].content).toBe('second reply'); + }); + + it('retains full history after current history is compacted', () => { + const state = new AgentState('conv_test'); + state.addMessage('user', 'first'); + state.addMessage('assistant', 'first reply'); + state.addMessage('user', 'second'); + state.addMessage('assistant', 'second reply'); + + const compacted = state.createCompactedHistory('summary', 2, 'manual'); + state.replaceMessages(compacted); + + expect(state.getMessages()).toHaveLength(3); + expect(state.getFullMessages()).toHaveLength(4); + expect(state.getFullMessages().map(message => message.content)).toEqual([ + 'first', + 'first reply', + 'second', + 'second reply', + ]); + }); + + it('clears full history when conversation is cleared', () => { + const state = new AgentState('conv_test'); + state.addMessage('user', 'first'); + state.addMessage('assistant', 'reply'); + + state.clearMessages(); + + expect(state.getMessages()).toHaveLength(0); + expect(state.getFullMessages()).toHaveLength(0); + }); +}); diff --git a/src/agent/core/AgentState.ts b/src/agent/core/AgentState.ts index d860a78..0e2c4b6 100644 --- a/src/agent/core/AgentState.ts +++ b/src/agent/core/AgentState.ts @@ -21,10 +21,13 @@ export interface Message { timestamp: number; tool_calls?: ToolCall[]; // present on assistant messages when LLM invokes tools tool_call_id?: string; // present on tool-result messages, links back to ToolCall.id + is_compacted_summary?: boolean; + compact_trigger?: 'manual' | 'auto'; } export class AgentState { private messages: Message[] = []; + private fullMessages: Message[] = []; private conversationId: string; private isWorkingOnTask: boolean = false; @@ -39,7 +42,12 @@ export class AgentState { addMessage( role: 'user' | 'assistant' | 'tool', content: string | null, - options?: { tool_calls?: ToolCall[]; tool_call_id?: string } + options?: { + tool_calls?: ToolCall[]; + tool_call_id?: string; + is_compacted_summary?: boolean; + compact_trigger?: 'manual' | 'auto'; + } ): string { const message: Message = { id: this.generateMessageId(), @@ -48,9 +56,12 @@ export class AgentState { timestamp: Date.now(), ...(options?.tool_calls ? { tool_calls: options.tool_calls } : {}), ...(options?.tool_call_id ? { tool_call_id: options.tool_call_id } : {}), + ...(options?.is_compacted_summary ? { is_compacted_summary: true } : {}), + ...(options?.compact_trigger ? { compact_trigger: options.compact_trigger } : {}), }; this.messages.push(message); + this.fullMessages.push({ ...message }); return message.id; } @@ -64,6 +75,17 @@ export class AgentState { if (options?.tool_calls) { this.messages[messageIndex].tool_calls = options.tool_calls; } + } + + const fullMessageIndex = this.fullMessages.findIndex(msg => msg.id === messageId); + if (fullMessageIndex !== -1) { + this.fullMessages[fullMessageIndex].content = content; + if (options?.tool_calls) { + this.fullMessages[fullMessageIndex].tool_calls = options.tool_calls; + } + } + + if (messageIndex !== -1 || fullMessageIndex !== -1) { return true; } return false; @@ -76,6 +98,14 @@ export class AgentState { const messageIndex = this.messages.findIndex(msg => msg.id === messageId); if (messageIndex !== -1) { this.messages.splice(messageIndex, 1); + } + + const fullMessageIndex = this.fullMessages.findIndex(msg => msg.id === messageId); + if (fullMessageIndex !== -1) { + this.fullMessages.splice(fullMessageIndex, 1); + } + + if (messageIndex !== -1 || fullMessageIndex !== -1) { return true; } return false; @@ -86,6 +116,7 @@ export class AgentState { */ removeLastMessages(count: number): void { this.messages.splice(-count, count); + this.fullMessages.splice(-count, count); } /** @@ -95,6 +126,10 @@ export class AgentState { return [...this.messages]; } + getFullMessages(): Message[] { + return [...this.fullMessages]; + } + /** * Get the conversation ID */ @@ -107,6 +142,11 @@ export class AgentState { */ clearMessages(): void { this.messages = []; + this.fullMessages = []; + } + + replaceMessages(messages: Message[]): void { + this.messages = [...messages]; } /** @@ -116,6 +156,29 @@ export class AgentState { return this.messages.slice(-count); } + findRecentTailStartIndex(): number { + for (let i = this.messages.length - 1; i >= 0; i -= 1) { + if (this.messages[i].role === 'user') { + return i; + } + } + return this.messages.length; + } + + createCompactedHistory(summary: string, tailStartIndex: number, trigger: 'manual' | 'auto'): Message[] { + const preservedTail = this.messages.slice(Math.max(0, tailStartIndex)); + const summaryMessage: Message = { + id: this.generateMessageId(), + role: 'assistant', + content: summary, + timestamp: Date.now(), + is_compacted_summary: true, + compact_trigger: trigger, + }; + + return [summaryMessage, ...preservedTail]; + } + private generateConversationId(): string { return `conv_${Date.now()}_${Math.random().toString(36).substring(2, 15)}`; } diff --git a/src/agent/llm/LLMProvider.ts b/src/agent/llm/LLMProvider.ts index 7a8db11..8b4bb14 100644 --- a/src/agent/llm/LLMProvider.ts +++ b/src/agent/llm/LLMProvider.ts @@ -2,9 +2,18 @@ import OpenAI from 'openai'; import type { StreamChunk } from './StreamingTypes'; import type { Message, ToolCall } from '../core/AgentState'; import type { OpenAIToolDefinition } from '../tools/BaseTool'; +import { getModelTokenLimits } from './modelTokenLimits'; export interface LLMProvider { getPreferredSystemPromptPath?(): string | undefined; + getContextWindow?(): number | undefined; + getReservedOutputTokens?(): number | undefined; + estimateHistoryTokens?( + messages: Message[], + systemPrompt?: string, + tools?: OpenAIToolDefinition[], + ): Promise | number; + isContextTooLongError?(error: unknown): boolean; generateStream( messages: Message[], systemPrompt?: string, @@ -12,6 +21,28 @@ export interface LLMProvider { ): AsyncIterableIterator; } +function extractErrorDetails(error: unknown): { code?: string; message?: string } { + if (!error || typeof error !== 'object') { + return {}; + } + + const asRecord = error as Record; + const nestedError = asRecord.error && typeof asRecord.error === 'object' + ? asRecord.error as Record + : undefined; + const code = typeof asRecord.code === 'string' + ? asRecord.code + : typeof nestedError?.code === 'string' + ? nestedError.code + : undefined; + const message = typeof asRecord.message === 'string' + ? asRecord.message + : typeof nestedError?.message === 'string' + ? nestedError.message + : undefined; + return { code, message }; +} + /** * OpenAI-compatible provider implementation. * Works with OpenAI and OpenAI-compatible APIs (OpenRouter, Ollama, vLLM, etc.) @@ -31,6 +62,53 @@ export class OpenAICompatibleLLMProvider implements LLMProvider { this.model = model; } + getContextWindow(): number | undefined { + return getModelTokenLimits(this.model)?.contextWindow; + } + + getReservedOutputTokens(): number | undefined { + const limits = getModelTokenLimits(this.model); + if (!limits) { + return undefined; + } + + if (typeof limits.reservedOutputTokens === 'number') { + return limits.reservedOutputTokens; + } + + if (typeof limits.maxOutputTokens === 'number') { + return Math.min(limits.maxOutputTokens, 8_192); + } + + return undefined; + } + + estimateHistoryTokens( + messages: Message[], + systemPrompt?: string, + tools?: OpenAIToolDefinition[], + ): number { + const openaiMessages = this.convertMessages(messages, systemPrompt); + const payload = JSON.stringify({ + model: this.model, + messages: openaiMessages, + tools: tools ?? [], + }); + + // Conservative browser-side estimate for preflight checks. + return Math.ceil(payload.length / 3); + } + + isContextTooLongError(error: unknown): boolean { + const { code, message } = extractErrorDetails(error); + if (code === 'context_length_exceeded') { + return true; + } + + return typeof message === 'string' + && /context window|maximum context length|input exceeds the context window|context too long/i.test(message); + } + private convertMessages( messages: Message[], systemPrompt?: string, diff --git a/src/agent/llm/LocalBrowserLLMProvider.ts b/src/agent/llm/LocalBrowserLLMProvider.ts index 46de966..b0b9a37 100644 --- a/src/agent/llm/LocalBrowserLLMProvider.ts +++ b/src/agent/llm/LocalBrowserLLMProvider.ts @@ -60,6 +60,33 @@ export class LocalBrowserLLMProvider implements LLMProvider { return 'prompts/system_compact.md'; } + getContextWindow(): number { + return this.getConfiguredContextLength(); + } + + getReservedOutputTokens(): number { + const contextWindow = this.getConfiguredContextLength(); + return Math.max(1024, Math.min(4096, Math.floor(contextWindow * 0.1))); + } + + async estimateHistoryTokens( + messages: Message[], + systemPrompt?: string, + tools?: OpenAIToolDefinition[], + ): Promise { + const inference = await this.ensureInference(); + const prompt = this.renderPrompt(messages, systemPrompt, tools); + return inference.sizeInTokens(prompt); + } + + isContextTooLongError(error: unknown): boolean { + if (!(error instanceof Error)) { + return false; + } + + return /context|token|maxTokens|kv-cache|too long|overflow/i.test(error.message); + } + private async ensureInference(): Promise { await LocalLLMModelManager.ensureRuntimeSupported(); if (this.inference) { diff --git a/src/agent/llm/modelTokenLimits.test.ts b/src/agent/llm/modelTokenLimits.test.ts new file mode 100644 index 0000000..0e67f67 --- /dev/null +++ b/src/agent/llm/modelTokenLimits.test.ts @@ -0,0 +1,51 @@ +import { describe, expect, it } from 'vitest'; +import { getModelTokenLimits } from './modelTokenLimits'; + +describe('modelTokenLimits', () => { + it('returns OpenAI limits for supported GPT models', () => { + expect(getModelTokenLimits('gpt-5.2')).toEqual({ + contextWindow: 400_000, + maxOutputTokens: 128_000, + }); + + expect(getModelTokenLimits('gpt-4o')).toEqual({ + contextWindow: 128_000, + maxOutputTokens: 16_384, + }); + }); + + it('returns Claude limits for supported direct and OpenRouter aliases', () => { + expect(getModelTokenLimits('claude-sonnet-4.6')).toEqual({ + contextWindow: 200_000, + reservedOutputTokens: 8_192, + }); + + expect(getModelTokenLimits('anthropic/claude-opus-4.6')).toEqual({ + contextWindow: 200_000, + reservedOutputTokens: 8_192, + }); + }); + + it('matches snapshot-style suffixes by prefix', () => { + expect(getModelTokenLimits('gpt-5.2-2025-12-11')).toEqual({ + contextWindow: 400_000, + maxOutputTokens: 128_000, + }); + + expect(getModelTokenLimits('anthropic/claude-sonnet-4.6-20260101')).toEqual({ + contextWindow: 200_000, + reservedOutputTokens: 8_192, + }); + }); + + it('returns Gemini limits for supported Gemini models', () => { + expect(getModelTokenLimits('gemini-2.5-flash')).toEqual({ + contextWindow: 1_048_576, + maxOutputTokens: 65_536, + }); + }); + + it('returns undefined for unknown models', () => { + expect(getModelTokenLimits('custom-company-model')).toBeUndefined(); + }); +}); diff --git a/src/agent/llm/modelTokenLimits.ts b/src/agent/llm/modelTokenLimits.ts new file mode 100644 index 0000000..9262b8f --- /dev/null +++ b/src/agent/llm/modelTokenLimits.ts @@ -0,0 +1,49 @@ +export interface ModelTokenLimits { + contextWindow: number; + maxOutputTokens?: number; + reservedOutputTokens?: number; +} + +const MODEL_TOKEN_LIMITS: Record = { + // OpenAI official model pages / compare docs + 'gpt-5.2': { contextWindow: 400_000, maxOutputTokens: 128_000 }, + 'gpt-5-mini': { contextWindow: 400_000, maxOutputTokens: 128_000 }, + 'gpt-5-nano': { contextWindow: 400_000, maxOutputTokens: 128_000 }, + 'gpt-5': { contextWindow: 400_000, maxOutputTokens: 128_000 }, + 'gpt-4o': { contextWindow: 128_000, maxOutputTokens: 16_384 }, + + // GPT-5.4 family is present in current OpenAI docs, but exact limit pages were not surfaced. + // Use GPT-5 family limits as a best-effort alias until exact per-model docs are available. + 'gpt-5.4': { contextWindow: 400_000, maxOutputTokens: 128_000 }, + 'gpt-5.4-mini': { contextWindow: 400_000, maxOutputTokens: 128_000 }, + 'gpt-5.4-nano': { contextWindow: 400_000, maxOutputTokens: 128_000 }, + + // Claude official docs: 200k standard context window across these families. + // Anthropic docs do not expose a simple per-model max output token table for these aliases, + // so we keep a conservative preflight reserve instead of claiming an exact output maximum. + 'claude-sonnet-4.6': { contextWindow: 200_000, reservedOutputTokens: 8_192 }, + 'claude-opus-4.6': { contextWindow: 200_000, reservedOutputTokens: 8_192 }, + 'claude-sonnet-4.5': { contextWindow: 200_000, reservedOutputTokens: 8_192 }, + 'claude-opus-4.5': { contextWindow: 200_000, reservedOutputTokens: 8_192 }, + 'claude-sonnet-4': { contextWindow: 200_000, reservedOutputTokens: 8_192 }, + 'claude-opus-4.1': { contextWindow: 200_000, reservedOutputTokens: 8_192 }, + 'anthropic/claude-sonnet-4.6': { contextWindow: 200_000, reservedOutputTokens: 8_192 }, + 'anthropic/claude-opus-4.6': { contextWindow: 200_000, reservedOutputTokens: 8_192 }, + 'anthropic/claude-sonnet-4.5': { contextWindow: 200_000, reservedOutputTokens: 8_192 }, + 'anthropic/claude-opus-4.5': { contextWindow: 200_000, reservedOutputTokens: 8_192 }, + 'anthropic/claude-sonnet-4': { contextWindow: 200_000, reservedOutputTokens: 8_192 }, + 'anthropic/claude-opus-4.1': { contextWindow: 200_000, reservedOutputTokens: 8_192 }, + + // Gemini official model docs + 'gemini-2.5-flash': { contextWindow: 1_048_576, maxOutputTokens: 65_536 }, +}; + +export function getModelTokenLimits(model: string): ModelTokenLimits | undefined { + const exact = MODEL_TOKEN_LIMITS[model]; + if (exact) { + return exact; + } + + const prefix = Object.keys(MODEL_TOKEN_LIMITS).find(key => model.startsWith(`${key}-`)); + return prefix ? MODEL_TOKEN_LIMITS[prefix] : undefined; +} diff --git a/src/components/ChatBox.css b/src/components/ChatBox.css index f71ee3a..0d0a9bb 100644 --- a/src/components/ChatBox.css +++ b/src/components/ChatBox.css @@ -313,6 +313,28 @@ color: #909090; } +.message-divider-banner { + display: flex; + align-items: center; + gap: 12px; + width: 100%; + padding: 2px 0; +} + +.message-divider-banner-line { + flex: 1 1 auto; + height: 1px; + background: linear-gradient(90deg, rgba(102, 102, 102, 0.2) 0%, rgba(112, 112, 112, 0.55) 50%, rgba(102, 102, 102, 0.2) 100%); +} + +.message-divider-banner-label { + flex: 0 0 auto; + color: #8c8c8c; + font-size: 11px; + letter-spacing: 0.02em; + white-space: nowrap; +} + /* Abort link styling */ .abort-link { background: none !important; @@ -326,7 +348,7 @@ } .abort-link:hover { - color: #9b88ff !important; + color: #5a9fd4 !important; text-decoration: none !important; } diff --git a/src/components/ChatBox.test.tsx b/src/components/ChatBox.test.tsx index e9a1f56..66b0edd 100644 --- a/src/components/ChatBox.test.tsx +++ b/src/components/ChatBox.test.tsx @@ -1,11 +1,28 @@ import React from 'react'; -import { beforeAll, describe, expect, it, vi } from 'vitest'; -import { render, screen } from '@testing-library/react'; +import { beforeAll, beforeEach, describe, expect, it, vi } from 'vitest'; +import { fireEvent, render, screen, waitFor } from '@testing-library/react'; import ChatBox from './ChatBox'; import { I18nContext } from '../i18n/I18nProvider'; import type { ResolvedLocaleCode } from '../i18n/types'; import { translate } from '../i18n/translate'; +const { + agentCoreMock, + processUserMessageMock, + processStreamMock, +} = vi.hoisted(() => ({ + agentCoreMock: { + setLLMProvider: vi.fn(), + getLLMProvider: vi.fn(() => ({ getPreferredSystemPromptPath: vi.fn() })), + abortCurrentRequest: vi.fn(), + getAgentState: vi.fn(() => ({ getMessages: vi.fn(() => []) })), + compactConversation: vi.fn(async () => ({ changed: true, compactedConversation: 'summary' })), + shouldCompactBeforeNextTurn: vi.fn(async () => false), + }, + processUserMessageMock: vi.fn(), + processStreamMock: vi.fn(async () => ''), +})); + vi.mock('./chat', () => ({ UserMessage: ({ content }: { content: string }) =>
{content}
, AssistantMessage: ({ content }: { content: string }) =>
{content}
, @@ -13,12 +30,7 @@ vi.mock('./chat', () => ({ vi.mock('../agent/core/AgentCore', () => ({ AgentCore: { - instance: () => ({ - setLLMProvider: vi.fn(), - getLLMProvider: vi.fn(), - abortCurrentRequest: vi.fn(), - getAgentState: vi.fn(() => ({ getMessages: vi.fn(() => []) })), - }), + instance: () => agentCoreMock, }, })); @@ -66,18 +78,29 @@ vi.mock('../util/chatUtil', () => ({ })); vi.mock('../util/messageFilter/UserMessageFilter', () => ({ - processUserMessage: vi.fn(), + processUserMessage: processUserMessageMock, })); vi.mock('../hooks/useStreamProcessor', () => ({ useStreamProcessor: () => ({ abortController: null, - processStream: vi.fn(), + processStream: processStreamMock, }), })); vi.mock('../utils/chatMessageUtils', () => ({ - createMessage: vi.fn(), + createMessage: vi.fn((role: 'user' | 'assistant', content: string) => ({ + id: `${role}-${content}`, + role, + content, + })), + createStreamingMessage: vi.fn(() => ({ + id: 'streaming-message', + role: 'assistant', + content: 'Thinking... click here to abort.', + isStreaming: true, + tokenCount: 0, + })), addWelcomeMessage: vi.fn().mockResolvedValue(null), })); @@ -134,6 +157,13 @@ describe('ChatBox', () => { Element.prototype.scrollIntoView = vi.fn(); }); + beforeEach(() => { + processUserMessageMock.mockReset(); + processStreamMock.mockClear(); + agentCoreMock.compactConversation.mockClear(); + agentCoreMock.shouldCompactBeforeNextTurn.mockResolvedValue(false); + }); + it('renders the English assistant title under en_us', () => { renderWithLocale('en_us'); @@ -151,4 +181,28 @@ describe('ChatBox', () => { expect(screen.getByRole('heading', { level: 3, name: 'Assistant musical K.G.Studio' })).toBeTruthy(); }); + + it('shows compacting status and completion for /compact', async () => { + processUserMessageMock.mockResolvedValue({ + displayUserMessage: false, + sendToLLM: false, + finalMessageForLLM: null, + pseudoAssistantResponse: null, + metadata: { + command: 'compact', + focus: 'keep the latest work', + }, + }); + + renderWithLocale('en_us'); + + const input = screen.getByPlaceholderText('Press Enter to send message, Shift + Enter for new line'); + fireEvent.change(input, { target: { value: '/compact keep the latest work' } }); + fireEvent.keyDown(input, { key: 'Enter', shiftKey: false }); + + await waitFor(() => { + expect(agentCoreMock.compactConversation).toHaveBeenCalled(); + expect(screen.getByText('Conversation Compacted')).toBeTruthy(); + }); + }); }); diff --git a/src/components/ChatBox.tsx b/src/components/ChatBox.tsx index 0881490..19f0582 100644 --- a/src/components/ChatBox.tsx +++ b/src/components/ChatBox.tsx @@ -11,7 +11,7 @@ import { SystemPrompts } from '../agent/core/SystemPrompts'; import { clearChatHistoryAndUI, registerClearChatUICallback } from '../util/chatUtil'; import { processUserMessage } from '../util/messageFilter/UserMessageFilter'; import { useStreamProcessor } from '../hooks/useStreamProcessor'; -import { createMessage, addWelcomeMessage } from '../utils/chatMessageUtils'; +import { createMessage, createStreamingMessage, addWelcomeMessage } from '../utils/chatMessageUtils'; import { formatLocalDateTime } from '../util/timeUtil'; import { downloadBlob, buildTimestampSuffix } from '../util/miscUtil'; import { LocalLLMModelManager, type LocalLLMModelState } from '../util/localLLMModelManager'; @@ -70,6 +70,7 @@ const ChatBox: React.FC = ({ isVisible }) => { const [messages, setMessages] = useState([]); const [isProcessing, setIsProcessing] = useState(false); + const [isCompacting, setIsCompacting] = useState(false); const [lastUserMessage, setLastUserMessage] = useState(''); const [localModelState, setLocalModelState] = useState(LocalLLMModelManager.getState()); const [activeProvider, setActiveProvider] = useState('openai'); @@ -87,7 +88,7 @@ const ChatBox: React.FC = ({ isVisible }) => { const handleExportOptionSelect = (option: string) => { if (option === 'Export conversation as JSON') { try { - const agentMessages = AgentCore.instance().getAgentState().getMessages(); + const agentMessages = AgentCore.instance().getAgentState().getFullMessages(); const exportMessages = agentMessages.map((m) => ({ id: m.id, role: m.role, @@ -105,7 +106,7 @@ const ChatBox: React.FC = ({ isVisible }) => { } else if (option === 'Export conversation as Markdown') { (async () => { try { - const agentMessages = AgentCore.instance().getAgentState().getMessages(); + const agentMessages = AgentCore.instance().getAgentState().getFullMessages(); const templateUrl = `${import.meta.env.BASE_URL}chat/export_conversation_template.md`; const res = await fetch(templateUrl); const template = await res.text(); @@ -204,6 +205,7 @@ const ChatBox: React.FC = ({ isVisible }) => { changedKeys.includes('general.llm_provider') || changedKeys.includes('general.local_browser.context_length') || changedKeys.some(k => k.startsWith('general.openai.')) || + changedKeys.some(k => k.startsWith('general.claude_openrouter.')) || changedKeys.some(k => k.startsWith('general.openai_compatible.')) ) { applyProviderFromConfig(); @@ -255,8 +257,78 @@ const ChatBox: React.FC = ({ isVisible }) => { clearChatHistoryAndUI(setStatus); }; + const runCompactionWithStatus = useCallback(async ( + trigger: 'manual' | 'auto', + focus?: string, + ): Promise => { + const agentCore = AgentCore.instance(); + const statusMessage = createMessage('assistant', 'Compacting Conversation'); + const progressMessage = createStreamingMessage(); + let progressTokenCount = 0; + + handleMessageAdd(statusMessage); + handleMessageAdd(progressMessage); + setIsCompacting(true); + + try { + const result = await agentCore.compactConversation({ + trigger, + focus, + onProgress: () => { + progressTokenCount += 1; + handleMessageUpdate(progressMessage.id, (msg) => ({ + ...msg, + content: `Processing...${progressTokenCount > 0 ? ` ${progressTokenCount} tokens received.` : ''} click here to abort.`, + tokenCount: progressTokenCount, + })); + }, + }); + + handleMessageUpdate(statusMessage.id, (msg) => ({ + ...msg, + content: result.changed ? 'Conversation Compacted' : 'Nothing to Compact Yet', + })); + handleMessageRemove(progressMessage.id); + return result.changed; + } catch (error) { + console.error('Conversation compaction failed:', error); + handleMessageUpdate(statusMessage.id, (msg) => ({ + ...msg, + content: `Compaction failed: ${error instanceof Error ? error.message : 'Unable to compact the conversation.'}`, + })); + handleMessageRemove(progressMessage.id); + return false; + } finally { + setIsCompacting(false); + } + }, [handleMessageAdd, handleMessageRemove, handleMessageUpdate]); + + const sendWithCompactionRecovery = useCallback(async ( + llmInput: string, + originalUserMessage: string, + ): Promise => { + try { + await streamProcessor.processStream(llmInput, 'USER'); + } catch (error) { + const provider = AgentCore.instance().getLLMProvider(); + if (provider?.isContextTooLongError?.(error)) { + const compacted = await runCompactionWithStatus('auto'); + if (compacted) { + try { + await streamProcessor.processStream(llmInput, 'USER'); + } catch (retryError) { + console.error('Retry after compaction failed:', retryError, originalUserMessage); + } + return; + } + } + + console.error('Failed to process user message:', error, originalUserMessage); + } + }, [runCompactionWithStatus, streamProcessor]); + const handleSend = async () => { - if (inputValue.trim() && !isProcessing) { + if (inputValue.trim() && !isProcessing && !isCompacting) { const userMessage = inputValue.trim(); setLastUserMessage(userMessage); setInputValue(''); @@ -273,6 +345,14 @@ const ChatBox: React.FC = ({ isVisible }) => { handleMessageAdd(pseudoMessage); } + if (filterResult.metadata?.command === 'compact') { + await runCompactionWithStatus( + 'manual', + typeof filterResult.metadata.focus === 'string' ? filterResult.metadata.focus : undefined, + ); + return; + } + if (!filterResult.sendToLLM || !filterResult.finalMessageForLLM) { return; } @@ -294,7 +374,12 @@ const ChatBox: React.FC = ({ isVisible }) => { } // Process through stream processor — AgentCore handles the full agentic loop internally - await streamProcessor.processStream(filterResult.finalMessageForLLM, 'USER'); + const agentCore = AgentCore.instance(); + if (await agentCore.shouldCompactBeforeNextTurn(filterResult.finalMessageForLLM)) { + await runCompactionWithStatus('auto'); + } + + await sendWithCompactionRecovery(filterResult.finalMessageForLLM, userMessage); } }; @@ -333,13 +418,13 @@ const ChatBox: React.FC = ({ isVisible }) => { // Auto-focus input when processing completes useEffect(() => { - if (!isProcessing && textareaRef.current) { + if (!isProcessing && !isCompacting && textareaRef.current) { setTimeout(() => { textareaRef.current?.focus(); messagesEndRef.current?.scrollIntoView({ behavior: 'smooth' }); }, 0); } - }, [isProcessing]); + }, [isCompacting, isProcessing]); const localRuntimeMessage = localModelState.runtimeSupport.reason; const hasLocalRuntimeHardFailure = !localModelState.runtimeSupport.supported; @@ -448,7 +533,7 @@ const ChatBox: React.FC = ({ isVisible }) => {
- {!isProcessing && ( + {!isProcessing && !isCompacting && (