import { cleanupExpiredHandoffs, getHandoff, hasActiveHandoff, type HandoffPayload, upsertHandoff, } from "../../src/lib/db/contextHandoffs.ts"; import { estimateTokens } from "./contextManager.ts"; import { stripMarkdownCodeFence } from "../utils/aiSdkCompat.ts"; export const HANDOFF_WARNING_THRESHOLD = 0.85; export const HANDOFF_EXHAUSTION_THRESHOLD = 0.95; const MAX_HISTORY_TOKENS_FOR_SUMMARY = 8000; const DEFAULT_MAX_MESSAGES_FOR_SUMMARY = 30; const DEFAULT_SUMMARY_RESPONSE_TOKENS = 800; const MAX_SUMMARY_LENGTH = 2000; const MAX_TASK_PROGRESS_LENGTH = 1200; const MAX_DECISIONS = 8; const MAX_ENTITIES = 10; const DEFAULT_TTL_MS = 5 * 60 * 60 * 1000; const OMNI_MODEL_TAG_PATTERN = /(?:\\n|\n|\r)*[^<]+<\/omniModel>(?:\\n|\n|\r)*/g; const inflightHandoffGenerations = new Set(); const HANDOFF_PROMPT_TEMPLATE = `You are a context summarizer. Analyze the conversation below and generate a structured handoff summary. This summary will be used to restore context when this conversation is moved to a new AI account. CONVERSATION HISTORY: {HISTORY} Generate a JSON object with this exact structure: { "summary": "A clear, dense summary of what has been discussed and accomplished (max 200 words). Focus on what the AI needs to know to continue seamlessly.", "keyDecisions": ["decision1", "decision2"], "taskProgress": "Current state of the task: what's done, what's pending, next steps", "activeEntities": ["file1.ts", "feature X", "topic Y"] } Important: Return ONLY the JSON object, no markdown, no explanation.`; export type MessageLike = { role?: string; content?: unknown; }; export interface ContextRelayConfig { handoffModel?: string; handoffThreshold?: number; handoffProviders?: string[]; maxMessagesForSummary?: number; } export interface UniversalHandoffConfig { /** Enable universal context handoff for any model/provider switch */ enabled: boolean; /** When to generate handoff: always = every turn, on-switch = only when model changes, on-error = only after error fallback */ trigger: "always" | "on-switch" | "on-error"; /** Providers allowed to generate handoffs. Empty array = all providers */ providerAllowlist: string[]; /** Max messages to include in summary generation */ maxMessagesForSummary: number; /** Model to use for summary generation (e.g., 'gpt-4o-mini'). Empty = use same model as request */ handoffModel: string; /** Handoff TTL in minutes */ ttlMinutes: number; /** Preserve existing system prompt when injecting handoff */ preserveSystemPrompt: boolean; } export const DEFAULT_UNIVERSAL_HANDOFF_CONFIG: UniversalHandoffConfig = { enabled: true, trigger: "on-switch", providerAllowlist: [], maxMessagesForSummary: 30, handoffModel: "", ttlMinutes: 300, preserveSystemPrompt: true, }; export const SKIP_UNIVERSAL_HANDOFF_FLAG = "_omnirouteSkipUniversalHandoff"; export function resolveUniversalHandoffConfig( comboConfig: Record | null | undefined, globalConfig: Record | null | undefined ): UniversalHandoffConfig { const rawCombo = comboConfig ?? {}; const rawGlobal = globalConfig ?? {}; const getBool = (key: keyof UniversalHandoffConfig, fallback: boolean): boolean => { if (typeof rawCombo[key] === "boolean") return rawCombo[key] as boolean; if (typeof rawGlobal[key] === "boolean") return rawGlobal[key] as boolean; return fallback; }; const getString = (key: keyof UniversalHandoffConfig, fallback: string): string => { if (typeof rawCombo[key] === "string") { const v = (rawCombo[key] as string).trim(); if (v.length > 0) return v; } if (typeof rawGlobal[key] === "string") { const v = (rawGlobal[key] as string).trim(); if (v.length > 0) return v; } return fallback; }; const getNumber = ( key: keyof UniversalHandoffConfig, fallback: number, min?: number, max?: number ): number => { let candidate: number | undefined; const raw = rawCombo[key] ?? rawGlobal[key]; if (typeof raw === "number" && Number.isFinite(raw)) { candidate = raw; } else if (typeof raw === "string") { const parsed = Number(raw); if (Number.isFinite(parsed)) candidate = parsed; } if (candidate === undefined) return fallback; if (min !== undefined && candidate < min) return min; if (max !== undefined && candidate > max) return max; return candidate; }; const getStringArray = (key: keyof UniversalHandoffConfig, fallback: string[]): string[] => { const raw = rawCombo[key] ?? rawGlobal[key]; if (!Array.isArray(raw)) return fallback; return raw .map((item) => (typeof item === "string" ? item.trim().toLowerCase() : "")) .filter(Boolean); }; const triggerRaw = getString("trigger", DEFAULT_UNIVERSAL_HANDOFF_CONFIG.trigger); const trigger: UniversalHandoffConfig["trigger"] = triggerRaw === "always" || triggerRaw === "on-error" ? triggerRaw : "on-switch"; return { enabled: getBool("enabled", DEFAULT_UNIVERSAL_HANDOFF_CONFIG.enabled), trigger, providerAllowlist: getStringArray( "providerAllowlist", DEFAULT_UNIVERSAL_HANDOFF_CONFIG.providerAllowlist ), maxMessagesForSummary: getNumber( "maxMessagesForSummary", DEFAULT_UNIVERSAL_HANDOFF_CONFIG.maxMessagesForSummary, 5, 100 ), handoffModel: getString("handoffModel", DEFAULT_UNIVERSAL_HANDOFF_CONFIG.handoffModel), ttlMinutes: getNumber("ttlMinutes", DEFAULT_UNIVERSAL_HANDOFF_CONFIG.ttlMinutes, 1, 10080), preserveSystemPrompt: getBool( "preserveSystemPrompt", DEFAULT_UNIVERSAL_HANDOFF_CONFIG.preserveSystemPrompt ), }; } export interface ParsedHandoffContent { summary: string; keyDecisions: string[]; taskProgress: string; activeEntities: string[]; } export function resolveContextRelayConfig( config?: Record | null ): Required { const rawThreshold = Number(config?.handoffThreshold); const rawMaxMessages = Number(config?.maxMessagesForSummary); const hasExplicitProviders = Array.isArray(config?.handoffProviders); const handoffProviders = hasExplicitProviders ? (config?.handoffProviders as unknown[]) .map((item) => (typeof item === "string" ? item.trim().toLowerCase() : "")) .filter(Boolean) : ["codex"]; return { handoffModel: typeof config?.handoffModel === "string" && config.handoffModel.trim().length > 0 ? config.handoffModel.trim() : "", handoffThreshold: Number.isFinite(rawThreshold) && rawThreshold > 0 && rawThreshold < HANDOFF_EXHAUSTION_THRESHOLD ? rawThreshold : HANDOFF_WARNING_THRESHOLD, handoffProviders: hasExplicitProviders ? handoffProviders : ["codex"], maxMessagesForSummary: Number.isFinite(rawMaxMessages) && rawMaxMessages >= 5 && rawMaxMessages <= 100 ? Math.round(rawMaxMessages) : DEFAULT_MAX_MESSAGES_FOR_SUMMARY, }; } function getInflightKey(sessionId: string, comboName: string): string { return `${sessionId}::${comboName}`; } function toTextContent(content: unknown): string { if (typeof content === "string") return content; if (!Array.isArray(content)) return ""; return content .map((part) => { if (!part || typeof part !== "object") return ""; if (typeof (part as Record).text === "string") { return String((part as Record).text); } if (typeof (part as Record).content === "string") { return String((part as Record).content); } return ""; }) .filter(Boolean) .join("\n"); } function formatMessagesForPrompt(messages: MessageLike[]): string { return messages .map((message, index) => { const role = typeof message.role === "string" ? message.role : "unknown"; const content = toTextContent(message.content).trim(); if (!content) return ""; return `[${index + 1}] ${role.toUpperCase()}:\n${content}`; }) .filter(Boolean) .join("\n\n"); } export function selectMessagesForSummary(messages: MessageLike[], maxMessages: number): MessageLike[] { const validMessages = messages.filter((m) => m && typeof m === "object"); const system = validMessages.filter( (m) => typeof m.role === "string" && (m.role === "system" || m.role === "developer") ); const nonSystem = validMessages.filter( (m) => typeof m.role !== "string" || (m.role !== "system" && m.role !== "developer") ); const recentMessages = [...system, ...nonSystem.slice(-maxMessages)]; let working = [...recentMessages]; while (working.length > system.length + 1) { const history = formatMessagesForPrompt(working); if (estimateTokens(history) <= MAX_HISTORY_TOKENS_FOR_SUMMARY) { return working; } working = [...system, ...working.slice(system.length + 1)]; } const fallbackHistory = formatMessagesForPrompt(working); if (estimateTokens(fallbackHistory) > MAX_HISTORY_TOKENS_FOR_SUMMARY) { // If there are system messages, return them so the caller can still produce context. // If there are no system messages (system=[]), fall back to the single most-recent // non-system message rather than returning [] which would silently drop the handoff. if (system.length > 0) { return system; } const lastNonSystem = nonSystem[nonSystem.length - 1]; return lastNonSystem ? [lastNonSystem] : []; } return working; } function normalizeStringArray(value: unknown, maxItems: number, maxLength = 240): string[] { if (!Array.isArray(value)) return []; return value .map((item) => (typeof item === "string" ? item.trim() : "")) .filter(Boolean) .slice(0, maxItems) .map((item) => item.slice(0, maxLength)); } function sanitizeJsonCandidate(content: string): string { return content.replace(OMNI_MODEL_TAG_PATTERN, "").trim(); } function extractJsonCandidate(content: string): string { const stripped = sanitizeJsonCandidate(String(stripMarkdownCodeFence(content) || "")); if (!stripped) return ""; try { JSON.parse(stripped); return stripped; } catch { const firstBrace = stripped.indexOf("{"); const lastBrace = stripped.lastIndexOf("}"); if (firstBrace >= 0 && lastBrace > firstBrace) { return stripped.slice(firstBrace, lastBrace + 1); } return stripped; } } export function parseHandoffJSON(content: string): ParsedHandoffContent | null { const candidate = extractJsonCandidate(content); if (!candidate) return null; try { const parsed = JSON.parse(candidate) as Record; const summary = typeof parsed.summary === "string" ? parsed.summary.trim().slice(0, MAX_SUMMARY_LENGTH) : ""; const taskProgress = typeof parsed.taskProgress === "string" ? parsed.taskProgress.trim().slice(0, MAX_TASK_PROGRESS_LENGTH) : ""; const keyDecisions = normalizeStringArray(parsed.keyDecisions, MAX_DECISIONS); const activeEntities = normalizeStringArray(parsed.activeEntities, MAX_ENTITIES); if (!summary) return null; return { summary, keyDecisions, taskProgress, activeEntities, }; } catch { return null; } } function escapeXml(value: string): string { return value .replaceAll("&", "&") .replaceAll("<", "<") .replaceAll(">", ">") .replaceAll('"', """) .replaceAll("'", "'"); } function getResponseText(json: Record): string { const choices = Array.isArray(json.choices) ? json.choices : []; const firstChoice = choices[0] as Record | undefined; const firstMessage = firstChoice?.message as Record | undefined; if (typeof firstMessage?.content === "string") { return firstMessage.content; } if (Array.isArray(firstMessage?.content)) { return toTextContent(firstMessage.content); } const output = Array.isArray(json.output) ? json.output : []; for (const item of output) { if (!item || typeof item !== "object") continue; const content = Array.isArray((item as Record).content) ? ((item as Record).content as Array>) : []; for (const part of content) { if (typeof part?.text === "string") return part.text; } } const content = Array.isArray(json.content) ? json.content : []; for (const part of content) { if (!part || typeof part !== "object") continue; if (typeof (part as Record).text === "string") { return String((part as Record).text); } } return ""; } async function generateHandoffAsync(options: { sessionId: string; comboName: string; connectionId: string; percentUsed: number; messages: MessageLike[]; model: string; expiresAt: string | null; config?: ContextRelayConfig | null; handleSingleModel: (body: Record, modelStr: string) => Promise; }): Promise { cleanupExpiredHandoffs(); const relayConfig = resolveContextRelayConfig(options.config as Record); const summaryModel = relayConfig.handoffModel || options.model; const selectedMessages = selectMessagesForSummary( Array.isArray(options.messages) ? options.messages : [], relayConfig.maxMessagesForSummary ); const historyText = formatMessagesForPrompt(selectedMessages); if (!historyText) return; const summaryPrompt = HANDOFF_PROMPT_TEMPLATE.replace("{HISTORY}", historyText); const summaryBody = { model: summaryModel, messages: [{ role: "user", content: summaryPrompt }], stream: false, max_tokens: DEFAULT_SUMMARY_RESPONSE_TOKENS, temperature: 0.1, _omnirouteSkipContextRelay: true, _omnirouteInternalRequest: "context-handoff", }; const response = await options.handleSingleModel(summaryBody, summaryModel); if (!response.ok) return; let content = ""; try { const json = (await response.clone().json()) as Record; content = getResponseText(json); } catch { try { content = await response.clone().text(); } catch { content = ""; } } const parsed = parseHandoffJSON(content); if (!parsed) return; upsertHandoff({ sessionId: options.sessionId, comboName: options.comboName, fromAccount: options.connectionId, summary: parsed.summary, keyDecisions: parsed.keyDecisions, taskProgress: parsed.taskProgress, activeEntities: parsed.activeEntities, messageCount: Array.isArray(options.messages) ? options.messages.length : 0, model: summaryModel, warningThresholdPct: relayConfig.handoffThreshold, generatedAt: new Date().toISOString(), expiresAt: options.expiresAt || new Date(Date.now() + DEFAULT_TTL_MS).toISOString(), }); } export function maybeGenerateHandoff(options: { sessionId: string | null; comboName: string; connectionId: string | null; percentUsed: number; messages: MessageLike[]; model: string; expiresAt: string | null; config?: ContextRelayConfig | null; handleSingleModel: (body: Record, modelStr: string) => Promise; }): void { if (!options.sessionId || !options.connectionId) return; const relayConfig = resolveContextRelayConfig(options.config as Record); if (relayConfig.handoffProviders.length === 0) return; if (options.percentUsed < relayConfig.handoffThreshold) return; if (options.percentUsed >= HANDOFF_EXHAUSTION_THRESHOLD) return; cleanupExpiredHandoffs(); if (hasActiveHandoff(options.sessionId, options.comboName)) return; const inflightKey = getInflightKey(options.sessionId, options.comboName); if (inflightHandoffGenerations.has(inflightKey)) return; inflightHandoffGenerations.add(inflightKey); setImmediate(() => { generateHandoffAsync({ ...options, sessionId: options.sessionId as string, connectionId: options.connectionId as string, config: relayConfig, }) .catch((err) => { if (process.env.NODE_ENV !== "test") { console.warn("[context-relay] Handoff generation failed:", err?.message || err); } }) .finally(() => { inflightHandoffGenerations.delete(inflightKey); }); }); } export function buildHandoffSystemMessage(payload: HandoffPayload): string { const decisions = payload.keyDecisions.map((decision) => ` - ${escapeXml(decision)}`).join("\n"); const entities = payload.activeEntities.map((entity) => escapeXml(entity)).join(", "); return ` Account quota transfer - continuing from previous session ${escapeXml(payload.summary)} ${escapeXml(payload.taskProgress)} ${decisions} ${entities} ${payload.messageCount} You are continuing a conversation that was transferred from another account due to quota limits. The context above contains a concise summary of the prior work. Continue seamlessly from where the session left off.`; } export function injectHandoffIntoBody( body: Record, payload: HandoffPayload ): Record { const handoffContent = buildHandoffSystemMessage(payload); const isResponsesRequest = Object.prototype.hasOwnProperty.call(body, "input") || Object.prototype.hasOwnProperty.call(body, "instructions"); if (isResponsesRequest) { const existingInstructions = typeof body.instructions === "string" && body.instructions.trim().length > 0 ? body.instructions : ""; const nextBody: Record = { ...body, instructions: existingInstructions ? `${handoffContent}\n\n${existingInstructions}` : handoffContent, }; if (Array.isArray(nextBody.messages) && nextBody.messages.length === 0) { const { messages: _messages, ...rest } = nextBody; return rest; } return nextBody; } const handoffMessage = { role: "system", content: handoffContent, }; const messages = Array.isArray(body.messages) ? [...body.messages] : []; return { ...body, messages: [handoffMessage, ...messages], }; } export function buildUniversalHandoffSystemMessage( prevModel: string, currModel: string, reason: string, payload?: HandoffPayload | null ): string { const escapedPrev = escapeXml(prevModel); const escapedCurr = escapeXml(currModel); const escapedReason = escapeXml(reason); if (!payload || !payload.summary) { return ` ${escapedReason} ${escapedPrev} ${escapedCurr} A continuación se resume toda la conversacion para continuar sin perder el hilo. `; } const decisions = payload.keyDecisions.map((d) => ` - ${escapeXml(d)}`).join("\n"); const entities = payload.activeEntities.map((e) => escapeXml(e)).join(", "); return ` ${escapedReason} ${escapedPrev} ${escapedCurr} ${escapeXml(payload.summary)} ${escapeXml(payload.taskProgress)} ${decisions} ${entities} ${payload.messageCount} Continues conversation transfered from ${escapedPrev} to ${escapedCurr}. The context above contains a concise summary of prior work. Continue seamlessly from where the session left off.`; } /** * Evaluate whether a universal handoff is needed for a model/provider switch. *, * @returns "generate" - need to create a new handoff summary * "inject" - handoff already exists, just inject it * "skip" - no handoff needed */ export function shouldGenerateUniversalHandoff(options: { sessionId: string | null; comboName: string; previousModel: string | null; currentModel: string; universalConfig: UniversalHandoffConfig; }): "generate" | "inject" | "skip" { if (!options.universalConfig.enabled) return "skip"; if (!options.previousModel) return "skip"; if (options.previousModel === options.currentModel) return "skip"; // Check if handoff already exists for this session/combo if (options.sessionId) { const existing = getHandoff(options.sessionId, options.comboName); if (existing && existing.summary) return "inject"; } return "generate"; } /** * Generate a universal handoff summary for any model/provider switch. */ async function generateUniversalHandoffAsync(options: { sessionId: string; comboName: string; messages: MessageLike[]; prevModel: string; currModel: string; handoffModel: string; ttlMs: number; maxMessages: number; providerAllowlist: string[]; handleSingleModel: (body: Record, modelStr: string) => Promise; }): Promise { const selectedMessages = selectMessagesForSummary( Array.isArray(options.messages) ? options.messages : [], options.maxMessages ); const historyText = formatMessagesForPrompt(selectedMessages); if (!historyText) return; const summaryPrompt = HANDOFF_PROMPT_TEMPLATE.replace("{HISTORY}", historyText); const summaryModel = options.handoffModel || options.currModel; const summaryBody: Record = { model: summaryModel, messages: [{ role: "user", content: summaryPrompt }], stream: false, max_tokens: DEFAULT_SUMMARY_RESPONSE_TOKENS, temperature: 0.1, _omnirouteSkipContextRelay: true, _omnirouteInternalRequest: "universal-handoff", }; const response = await options.handleSingleModel(summaryBody, summaryModel); if (!response.ok) return; let content = ""; try { const json = (await response.clone().json()) as Record; content = getResponseText(json); } catch { try { content = await response.clone().text(); } catch { content = ""; } } const parsed = parseHandoffJSON(content); if (!parsed) return; upsertHandoff({ sessionId: options.sessionId, comboName: options.comboName, fromAccount: `universal:${options.prevModel}`, summary: parsed.summary, keyDecisions: parsed.keyDecisions, taskProgress: parsed.taskProgress, activeEntities: parsed.activeEntities, messageCount: Array.isArray(options.messages) ? options.messages.length : 0, model: summaryModel, lastModel: options.prevModel, warningThresholdPct: 0, generatedAt: new Date().toISOString(), expiresAt: new Date(Date.now() + options.ttlMs).toISOString(), }); } export function maybeGenerateUniversalHandoff(options: { sessionId: string | null; comboName: string; messages: MessageLike[]; prevModel: string | null; currModel: string; universalConfig: UniversalHandoffConfig; handleSingleModel: (body: Record, modelStr: string) => Promise; }): void { const decision = shouldGenerateUniversalHandoff({ sessionId: options.sessionId, comboName: options.comboName, previousModel: options.prevModel, currentModel: options.currModel, universalConfig: options.universalConfig, }); if (decision !== "generate") return; if (!options.sessionId) return; const inflightKey = getInflightKey(options.sessionId, options.comboName); if (inflightHandoffGenerations.has(inflightKey)) return; inflightHandoffGenerations.add(inflightKey); const ttlMs = (options.universalConfig.ttlMinutes || 300) * 60 * 1000; setImmediate(() => { generateUniversalHandoffAsync({ sessionId: options.sessionId as string, comboName: options.comboName, messages: options.messages, prevModel: options.prevModel || "unknown", currModel: options.currModel, handoffModel: options.universalConfig.handoffModel || options.currModel, ttlMs, maxMessages: options.universalConfig.maxMessagesForSummary, providerAllowlist: options.universalConfig.providerAllowlist, handleSingleModel: options.handleSingleModel, }) .catch((err) => { if (process.env.NODE_ENV !== "test") { console.warn("[universal-handoff] Generation failed:", err?.message || err); } }) .finally(() => { inflightHandoffGenerations.delete(inflightKey); }); }); } export function injectUniversalHandoffBody( body: Record, prevModel: string, currModel: string, reason: string, existingPayload?: HandoffPayload | null ): Record { const handoffContent = buildUniversalHandoffSystemMessage( prevModel, currModel, reason, existingPayload ); const isResponsesRequest = Object.prototype.hasOwnProperty.call(body, "input") || Object.prototype.hasOwnProperty.call(body, "instructions"); if (isResponsesRequest) { const existingInstructions = typeof body.instructions === "string" && body.instructions.trim().length > 0 ? body.instructions : ""; const nextBody: Record = { ...body, instructions: existingInstructions ? `${handoffContent}\n\n${existingInstructions}` : handoffContent, }; if (Array.isArray(nextBody.messages) && nextBody.messages.length === 0) { const { messages: _messages, ...rest } = nextBody; return rest; } return nextBody; } const handoffMessage = { role: "system", content: handoffContent, }; const messages = Array.isArray(body.messages) ? [...body.messages] : []; return { ...body, messages: [handoffMessage, ...messages], }; }