// CRITICAL import { randomUUID } from "node:crypto"; import type { AppContext } from "../../types/context"; import { badRequest, notFound, serviceUnavailable } from "../../core/errors"; import { fetchInference } from "../../services/inference/inference-client"; import { COMPACTION_SYSTEM_PROMPT, COMPACTION_USER_PROMPT } from "./configs"; type MessageRecord = Record; export interface ChatCompactionOptions { model?: string; systemPrompt?: string; title?: string; preserveFirst?: boolean; preserveLast?: boolean; } export interface ChatCompactionResult { session: Record; summary: string; } const getString = (value: unknown): string | undefined => typeof value === "string" ? value : undefined; const getNumber = (value: unknown): number | undefined => typeof value === "number" && !Number.isNaN(value) ? value : undefined; const formatToolCalls = (toolCalls: unknown[]): string => { const formatted = toolCalls .map((call) => { if (!call || typeof call !== "object") return null; const record = call as Record; const function_ = record["function"] as Record | undefined; const name = getString(function_?.["name"]) ?? "tool"; const args = getString(function_?.["arguments"]); const callLabel = args ? `${name}(${args})` : `${name}()`; const rawResult = record["result"]; if (rawResult === undefined || rawResult === null) { return callLabel; } let resultText = ""; let isError = false; if (typeof rawResult === "string") { resultText = rawResult; } else if (typeof rawResult === "object") { const resultRecord = rawResult as Record; const content = getString(resultRecord["content"]); if (content) { resultText = content; } else { try { resultText = JSON.stringify(rawResult); } catch { resultText = String(rawResult); } } isError = resultRecord["isError"] === true; } else { resultText = String(rawResult); } if (!resultText) { return callLabel; } const errorSuffix = isError ? " (error)" : ""; return `${callLabel} => ${resultText}${errorSuffix}`; }) .filter((value): value is string => Boolean(value)); return formatted.length > 0 ? `\n\n[Tool calls]: ${formatted.join("; ")}` : ""; }; const buildSummaryMessages = ( messages: MessageRecord[] ): Array<{ role: string; content: string }> => { return messages .map((message) => { const role = getString(message["role"]) ?? "assistant"; const content = getString(message["content"]) ?? ""; const toolCalls = Array.isArray(message["tool_calls"]) ? message["tool_calls"] : []; const toolSuffix = toolCalls.length > 0 ? formatToolCalls(toolCalls) : ""; const combined = `${content}${toolSuffix}`.trim(); if (!combined) return null; return { role, content: combined }; }) .filter((value): value is { role: string; content: string } => Boolean(value)); }; const buildSystemPrompt = (systemPrompt?: string): string => { if (systemPrompt && systemPrompt.trim()) { return `${COMPACTION_SYSTEM_PROMPT}\n\nOriginal system prompt:\n${systemPrompt.trim()}`; } return COMPACTION_SYSTEM_PROMPT; }; const resolveModel = ( options: ChatCompactionOptions, session: MessageRecord, fallbackModel?: string ): string => { return options.model || getString(session["model"]) || fallbackModel || "default"; }; const requestSummary = async ( context: AppContext, model: string, systemPrompt: string, messages: Array<{ role: string; content: string }> ): Promise => { const response = await fetchInference(context, "/v1/chat/completions", { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ model, messages: [ { role: "system", content: systemPrompt }, ...messages, { role: "user", content: COMPACTION_USER_PROMPT }, ], temperature: 0.3, stream: false, }), }); if (!response.ok) { throw serviceUnavailable(`Compaction summary failed (${response.status})`); } const data = (await response.json()) as { choices?: Array<{ message?: { content?: string } }>; }; const summary = data.choices?.[0]?.message?.content?.trim() ?? ""; if (!summary) { throw badRequest("Compaction summary returned empty content"); } return summary; }; const createSummaryMessage = (summary: string, model?: string): MessageRecord => { const content = `Context summary (compacted on ${new Date().toLocaleString()}):\n\n${summary}`; return { role: "assistant", content, model: model ?? null, parts: [{ type: "text", text: content }], }; }; const pickFirstUserMessage = (messages: MessageRecord[]): MessageRecord | null => messages.find((message) => getString(message["role"]) === "user") ?? null; const pickLastMessage = (messages: MessageRecord[]): MessageRecord | null => messages.length > 0 ? (messages[messages.length - 1] ?? null) : null; const cloneMessageToSession = ( context: AppContext, sessionId: string, message: MessageRecord ): void => { const role = getString(message["role"]) ?? "assistant"; const content = getString(message["content"]) ?? undefined; const model = getString(message["model"]) ?? undefined; const toolCalls = Array.isArray(message["tool_calls"]) ? message["tool_calls"] : undefined; const toolCallId = getString(message["tool_call_id"]) ?? undefined; const toolName = getString(message["name"]) ?? undefined; const parts = Array.isArray(message["parts"]) ? message["parts"] : undefined; const metadata = Object.prototype.hasOwnProperty.call(message, "metadata") ? message["metadata"] : undefined; const promptTokens = getNumber(message["request_prompt_tokens"]); const toolsTokens = getNumber(message["request_tools_tokens"]); const totalInputTokens = getNumber(message["request_total_input_tokens"]); const completionTokens = getNumber(message["request_completion_tokens"]); context.stores.chatStore.addMessage( sessionId, randomUUID(), role, content, model, toolCalls, promptTokens, toolsTokens, totalInputTokens, completionTokens, parts, metadata, toolCallId, toolName ); }; export const compactChatSession = async ( context: AppContext, sessionId: string, options: ChatCompactionOptions = {} ): Promise => { const session = context.stores.chatStore.getSession(sessionId); if (!session) { throw notFound("Session not found"); } const current = await context.processManager.findInferenceProcess(context.config.inference_port); if (!current) { throw serviceUnavailable("No model running"); } const messages = Array.isArray(session["messages"]) ? (session["messages"] as MessageRecord[]) : []; if (messages.length === 0) { throw badRequest("Session has no messages to compact"); } const summaryMessages = buildSummaryMessages(messages); if (summaryMessages.length === 0) { throw badRequest("Session messages are empty"); } const systemPrompt = buildSystemPrompt(options.systemPrompt); const model = resolveModel(options, session, current.served_model_name ?? undefined); const summaryText = await requestSummary(context, model, systemPrompt, summaryMessages); const firstUser = options.preserveFirst === false ? null : pickFirstUserMessage(messages); const lastMessage = options.preserveLast === false ? null : pickLastMessage(messages); const titleBase = getString(session["title"]) ?? "Chat"; const newTitle = options.title ?? `${titleBase} (Compacted)`; const newSessionId = randomUUID(); const agentState = Object.prototype.hasOwnProperty.call(session, "agent_state") ? session["agent_state"] : undefined; const newSession = context.stores.chatStore.createSession( newSessionId, newTitle, model, sessionId, agentState ); if (firstUser) { cloneMessageToSession(context, newSessionId, firstUser); } const summaryMessage = createSummaryMessage(summaryText, model); cloneMessageToSession(context, newSessionId, summaryMessage); if (lastMessage && (!firstUser || lastMessage["id"] !== firstUser["id"])) { cloneMessageToSession(context, newSessionId, lastMessage); } const hydrated = context.stores.chatStore.getSession(newSessionId) ?? newSession; return { session: hydrated, summary: summaryText, }; };