feat: add manual/auto conversation compaction and preserve full export history
This commit is contained in:
Generated
+2
-2
@@ -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",
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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.
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
@@ -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<ConversationCompactionResult> {
|
||||
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<string> {
|
||||
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();
|
||||
}
|
||||
}
|
||||
@@ -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<string> {
|
||||
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<boolean> {
|
||||
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<CompactConversationResult> {
|
||||
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<CompactConversationOptions, 'trigger'> = {},
|
||||
): Promise<CompactConversationResult> {
|
||||
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;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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)}`;
|
||||
}
|
||||
|
||||
@@ -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> | number;
|
||||
isContextTooLongError?(error: unknown): boolean;
|
||||
generateStream(
|
||||
messages: Message[],
|
||||
systemPrompt?: string,
|
||||
@@ -12,6 +21,28 @@ export interface LLMProvider {
|
||||
): AsyncIterableIterator<StreamChunk>;
|
||||
}
|
||||
|
||||
function extractErrorDetails(error: unknown): { code?: string; message?: string } {
|
||||
if (!error || typeof error !== 'object') {
|
||||
return {};
|
||||
}
|
||||
|
||||
const asRecord = error as Record<string, unknown>;
|
||||
const nestedError = asRecord.error && typeof asRecord.error === 'object'
|
||||
? asRecord.error as Record<string, unknown>
|
||||
: 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,
|
||||
|
||||
@@ -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<number> {
|
||||
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<GemmaInference> {
|
||||
await LocalLLMModelManager.ensureRuntimeSupported();
|
||||
if (this.inference) {
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,49 @@
|
||||
export interface ModelTokenLimits {
|
||||
contextWindow: number;
|
||||
maxOutputTokens?: number;
|
||||
reservedOutputTokens?: number;
|
||||
}
|
||||
|
||||
const MODEL_TOKEN_LIMITS: Record<string, ModelTokenLimits> = {
|
||||
// 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;
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
|
||||
@@ -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 }) => <div>{content}</div>,
|
||||
AssistantMessage: ({ content }: { content: string }) => <div>{content}</div>,
|
||||
@@ -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: '<span class="processing-wave">Thinking...</span> 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();
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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<ChatBoxProps> = ({ isVisible }) => {
|
||||
|
||||
const [messages, setMessages] = useState<ChatMessage[]>([]);
|
||||
const [isProcessing, setIsProcessing] = useState(false);
|
||||
const [isCompacting, setIsCompacting] = useState(false);
|
||||
const [lastUserMessage, setLastUserMessage] = useState<string>('');
|
||||
const [localModelState, setLocalModelState] = useState<LocalLLMModelState>(LocalLLMModelManager.getState());
|
||||
const [activeProvider, setActiveProvider] = useState<string>('openai');
|
||||
@@ -87,7 +88,7 @@ const ChatBox: React.FC<ChatBoxProps> = ({ 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<ChatBoxProps> = ({ 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<ChatBoxProps> = ({ 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<ChatBoxProps> = ({ isVisible }) => {
|
||||
clearChatHistoryAndUI(setStatus);
|
||||
};
|
||||
|
||||
const runCompactionWithStatus = useCallback(async (
|
||||
trigger: 'manual' | 'auto',
|
||||
focus?: string,
|
||||
): Promise<boolean> => {
|
||||
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: `<span class="processing-wave">Processing...</span>${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<void> => {
|
||||
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<ChatBoxProps> = ({ 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<ChatBoxProps> = ({ 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<ChatBoxProps> = ({ 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<ChatBoxProps> = ({ isVisible }) => {
|
||||
<div ref={messagesEndRef} />
|
||||
</div>
|
||||
|
||||
{!isProcessing && (
|
||||
{!isProcessing && !isCompacting && (
|
||||
<div className="chatbox-input-area">
|
||||
<textarea
|
||||
ref={textareaRef}
|
||||
|
||||
@@ -93,4 +93,21 @@ describe('AssistantMessage', () => {
|
||||
expect(codeElement).toBeInTheDocument();
|
||||
expect(codeElement).toHaveTextContent('const value = 1;');
|
||||
});
|
||||
|
||||
it('renders compacting and compacted messages as divider banners', () => {
|
||||
const { rerender, container } = render(
|
||||
<AssistantMessage content="Compacting Conversation" />
|
||||
);
|
||||
|
||||
expect(container.querySelector('.message-divider-banner')).toBeInTheDocument();
|
||||
expect(screen.getByLabelText('Compacting Conversation')).toBeInTheDocument();
|
||||
|
||||
rerender(<AssistantMessage content="Conversation Compacted" />);
|
||||
|
||||
expect(screen.getByLabelText('Conversation Compacted')).toBeInTheDocument();
|
||||
|
||||
rerender(<AssistantMessage content="Nothing to Compact Yet" />);
|
||||
|
||||
expect(screen.getByLabelText('Nothing to Compact Yet')).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -44,6 +44,9 @@ const formatTps = (value?: number): string | null => {
|
||||
|
||||
const THINKING_LABEL = 'Thinking...';
|
||||
const PROCESSING_LABEL = 'Processing...';
|
||||
const COMPACTION_IN_PROGRESS_LABEL = 'Compacting Conversation';
|
||||
const COMPACTION_DONE_LABEL = 'Conversation Compacted';
|
||||
const COMPACTION_EMPTY_LABEL = 'Nothing to Compact Yet';
|
||||
|
||||
const formatThinkingDuration = (elapsedSeconds: number): string => {
|
||||
if (elapsedSeconds < 60) {
|
||||
@@ -62,6 +65,9 @@ const AssistantMessage: React.FC<AssistantMessageProps> = ({ content, isStreamin
|
||||
const [thinkingElapsedSeconds, setThinkingElapsedSeconds] = useState(0);
|
||||
const processingWaveLabels = [THINKING_LABEL, PROCESSING_LABEL];
|
||||
const isThinking = isStreaming && content.includes(`<span class="processing-wave">${THINKING_LABEL}</span>`);
|
||||
const isCompactionBanner = content === COMPACTION_IN_PROGRESS_LABEL
|
||||
|| content === COMPACTION_DONE_LABEL
|
||||
|| content === COMPACTION_EMPTY_LABEL;
|
||||
|
||||
useEffect(() => {
|
||||
if (!isThinking) {
|
||||
@@ -83,6 +89,16 @@ const AssistantMessage: React.FC<AssistantMessageProps> = ({ content, isStreamin
|
||||
}, [isThinking]);
|
||||
|
||||
const renderContent = () => {
|
||||
if (isCompactionBanner) {
|
||||
return (
|
||||
<div className="message-divider-banner" aria-label={content}>
|
||||
<span className="message-divider-banner-line" aria-hidden="true" />
|
||||
<span className="message-divider-banner-label">{content}</span>
|
||||
<span className="message-divider-banner-line" aria-hidden="true" />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// Handle special abort link for streaming messages
|
||||
if (isStreaming && onAbort && content.includes('click here to abort')) {
|
||||
const processingWaveMarkup = processingWaveLabels
|
||||
|
||||
@@ -16,6 +16,7 @@ const configState = new Map<string, unknown>([
|
||||
['general.language', 'auto'],
|
||||
['general.llm_provider', 'local_browser'],
|
||||
['general.persist_api_keys_non_localhost', false],
|
||||
['general.auto_compact_threshold_percent', 90],
|
||||
['general.openai.api_key', ''],
|
||||
['general.openai.model', 'gpt-5.4-mini'],
|
||||
['general.openai.flex', false],
|
||||
@@ -107,6 +108,7 @@ describe('GeneralSettings', () => {
|
||||
beforeEach(() => {
|
||||
configState.set('general.language', 'auto');
|
||||
configState.set('general.local_browser.context_length', 65536);
|
||||
configState.set('general.auto_compact_threshold_percent', 90);
|
||||
configManagerMock.get.mockClear();
|
||||
configManagerMock.set.mockClear();
|
||||
localModelState.isCached = false;
|
||||
@@ -173,6 +175,19 @@ describe('GeneralSettings', () => {
|
||||
});
|
||||
});
|
||||
|
||||
it('renders and persists the auto-compact threshold', async () => {
|
||||
renderSettings();
|
||||
|
||||
const select = await screen.findByLabelText('Auto-Compact Threshold');
|
||||
expect((select as HTMLSelectElement).value).toBe('90');
|
||||
|
||||
fireEvent.change(select, { target: { value: '80' } });
|
||||
|
||||
await waitFor(() => {
|
||||
expect(configManagerMock.set).toHaveBeenCalledWith('general.auto_compact_threshold_percent', 80);
|
||||
});
|
||||
});
|
||||
|
||||
it('renders and persists local runtime download URLs', async () => {
|
||||
renderSettings();
|
||||
|
||||
|
||||
@@ -41,6 +41,7 @@ const GeneralSettings: React.FC = () => {
|
||||
const [claudeOpenRouterModel, setClaudeOpenRouterModel] = useState<string>('');
|
||||
const [openaiFlex, setOpenaiFlex] = useState<boolean>(false);
|
||||
const [persistApiKeysNonLocalhost, setPersistApiKeysNonLocalhost] = useState<boolean>(false);
|
||||
const [autoCompactThresholdPercent, setAutoCompactThresholdPercent] = useState<80 | 90 | 95>(90);
|
||||
const [compatibleKey, setCompatibleKey] = useState<string>('');
|
||||
const [compatibleBaseUrl, setCompatibleBaseUrl] = useState<string>('');
|
||||
const [compatibleModel, setCompatibleModel] = useState<string>('');
|
||||
@@ -107,6 +108,9 @@ const GeneralSettings: React.FC = () => {
|
||||
setOpenaiModel((configManager.get('general.openai.model') as string) || '');
|
||||
setOpenaiFlex((configManager.get('general.openai.flex') as boolean) ?? false);
|
||||
setPersistApiKeysNonLocalhost((configManager.get('general.persist_api_keys_non_localhost') as boolean) ?? false);
|
||||
setAutoCompactThresholdPercent(
|
||||
((configManager.get('general.auto_compact_threshold_percent') as 80 | 90 | 95 | undefined) ?? 90),
|
||||
);
|
||||
setGeminiKey((configManager.get('general.gemini.api_key') as string) || '');
|
||||
setGeminiModel((configManager.get('general.gemini.model') as string) || '');
|
||||
setClaudeKey((configManager.get('general.claude.api_key') as string) || '');
|
||||
@@ -206,6 +210,18 @@ const GeneralSettings: React.FC = () => {
|
||||
}
|
||||
};
|
||||
|
||||
const handleAutoCompactThresholdChange = async (value: string) => {
|
||||
const parsed = Number(value);
|
||||
const normalized: 80 | 90 | 95 = parsed === 80 || parsed === 95 ? parsed : 90;
|
||||
setAutoCompactThresholdPercent(normalized);
|
||||
try {
|
||||
await configManager.set('general.auto_compact_threshold_percent', normalized);
|
||||
console.log('Auto-compact threshold changed to:', normalized);
|
||||
} catch (error) {
|
||||
console.error('Failed to save auto-compact threshold:', error);
|
||||
}
|
||||
};
|
||||
|
||||
const handleGeminiKeyChange = (value: string) => {
|
||||
setGeminiKey(value);
|
||||
debouncedSave('general.gemini.api_key', value);
|
||||
@@ -407,6 +423,25 @@ const GeneralSettings: React.FC = () => {
|
||||
{t('settings.general.persistKeys.help')}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="settings-item">
|
||||
<label className="settings-label" htmlFor="general-auto-compact-threshold">
|
||||
Auto-Compact Threshold
|
||||
</label>
|
||||
<select
|
||||
id="general-auto-compact-threshold"
|
||||
className="settings-select"
|
||||
value={autoCompactThresholdPercent}
|
||||
onChange={(e) => void handleAutoCompactThresholdChange(e.target.value)}
|
||||
>
|
||||
<option value="95">Conservative (95%)</option>
|
||||
<option value="90">Standard (90%)</option>
|
||||
<option value="80">Early (80%)</option>
|
||||
</select>
|
||||
<div className="settings-help" style={{ fontSize: '12px', color: '#888', marginTop: '4px' }}>
|
||||
Compact the conversation before the next request when estimated context usage reaches this level.
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="settings-group">
|
||||
|
||||
@@ -10,6 +10,7 @@ interface AppConfig {
|
||||
language: LanguageSetting;
|
||||
llm_provider: 'local_browser' | 'openai' | 'gemini' | 'claude' | 'claude_openrouter' | 'openai_compatible';
|
||||
persist_api_keys_non_localhost: boolean;
|
||||
auto_compact_threshold_percent: 80 | 90 | 95;
|
||||
local_browser: {
|
||||
context_length: 32768 | 65536 | 131072;
|
||||
model_url: string;
|
||||
@@ -209,6 +210,7 @@ export class ConfigManager {
|
||||
language: 'auto',
|
||||
llm_provider: 'local_browser',
|
||||
persist_api_keys_non_localhost: false,
|
||||
auto_compact_threshold_percent: 90,
|
||||
openai: {
|
||||
api_key: '',
|
||||
flex: false,
|
||||
|
||||
@@ -144,7 +144,7 @@ export const useStreamProcessor = (options: StreamProcessorOptions): StreamProce
|
||||
isStreaming: false,
|
||||
tokenCount: undefined
|
||||
}));
|
||||
return '';
|
||||
throw error;
|
||||
} finally {
|
||||
setAbortController(null);
|
||||
setIsProcessing(false);
|
||||
|
||||
@@ -287,14 +287,29 @@ describe('processUserMessage slash commands', () => {
|
||||
expect(result.pseudoAssistantResponse).toContain('chat/hotkeys.md');
|
||||
});
|
||||
|
||||
it('parses /compact and returns manual compaction metadata', async () => {
|
||||
const result = await processUserMessage('/compact keep the current chord decisions');
|
||||
|
||||
expect(result).toMatchObject({
|
||||
displayUserMessage: false,
|
||||
sendToLLM: false,
|
||||
finalMessageForLLM: null,
|
||||
pseudoAssistantResponse: null,
|
||||
metadata: {
|
||||
command: 'compact',
|
||||
focus: 'keep the current chord decisions',
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
it('lists the new hotkeys commands for unknown slash commands', async () => {
|
||||
const result = await processUserMessage('/unknown foo');
|
||||
|
||||
expect(storeState.setStatus).toHaveBeenCalledWith(
|
||||
'Unknown command: /unknown. Available commands: /clear, /welcome, /help, /hotkeys, /hotkey'
|
||||
'Unknown command: /unknown. Available commands: /clear, /welcome, /help, /hotkeys, /hotkey, /compact'
|
||||
);
|
||||
expect(result.pseudoAssistantResponse).toBe(
|
||||
'Unknown command: /unknown foo.\nAvailable commands: /clear, /welcome, /help, /hotkeys, /hotkey'
|
||||
'Unknown command: /unknown foo.\nAvailable commands: /clear, /welcome, /help, /hotkeys, /hotkey, /compact'
|
||||
);
|
||||
expect(result).toMatchObject({
|
||||
displayUserMessage: false,
|
||||
|
||||
@@ -218,9 +218,22 @@ export async function processUserMessage(originalMessage: string): Promise<UserM
|
||||
}
|
||||
}
|
||||
|
||||
case '/compact': {
|
||||
return {
|
||||
displayUserMessage: false,
|
||||
sendToLLM: false,
|
||||
finalMessageForLLM: null,
|
||||
pseudoAssistantResponse: null,
|
||||
metadata: {
|
||||
command: 'compact',
|
||||
focus: argString.trim() || undefined,
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
default: {
|
||||
const { setStatus } = useProjectStore.getState();
|
||||
const help = 'Available commands: /clear, /welcome, /help, /hotkeys, /hotkey';
|
||||
const help = 'Available commands: /clear, /welcome, /help, /hotkeys, /hotkey, /compact';
|
||||
setStatus(`Unknown command: ${command}. ${help}`);
|
||||
return {
|
||||
displayUserMessage: false,
|
||||
|
||||
Reference in New Issue
Block a user