Spaces:
Paused
Paused
| /** | |
| * context-engine.js — Context Engineering para Zelin | |
| * ==================================================== | |
| * Basado en investigación real: | |
| * - Google ADK (Dic 2025): "Context as a compiled view, not a string buffer" | |
| * - Liu et al. "Lost in the Middle" (2023): info en el medio se ignora 30%+ más | |
| * - Manus AI architecture: event stream tipado + aggressive pruning | |
| * - Context Rot research (2025): degradación empieza a 50% del context window | |
| * | |
| * TRES MEJORAS CONCRETAS: | |
| * 1. Event Stream — historial tipado en vez de texto crudo | |
| * 2. Strategic Positioning — info crítica al INICIO y al FINAL (anti-lost-in-middle) | |
| * 3. Auto-compaction jerárquica — trigger a 50%, no cuando se llena | |
| */ | |
| import { callAIBackground } from './ai.js'; | |
| import { isLocalAIReady, ollamaChatDirect } from './local-ai.js'; | |
| // ── 1. EVENT STREAM tipado ──────────────────────────────────────────────────── | |
| // En vez de guardar raw text, cada evento tiene tipo y metadatos | |
| // Así el compilador sabe qué es importante y qué puede comprimir | |
| export function createEvent(type, content, metadata = {}) { | |
| return { type, content, ts: Date.now(), ...metadata }; | |
| } | |
| export const EventTypes = { | |
| USER_MESSAGE : 'user_message', | |
| BOT_RESPONSE : 'bot_response', | |
| TOOL_CALL : 'tool_call', | |
| TOOL_RESULT : 'tool_result', // pruneable después de ser observado | |
| SYSTEM_NOTE : 'system_note', | |
| SUMMARY : 'summary', // resultado de compaction | |
| }; | |
| // Convertir events tipados a messages para la API | |
| export function compileEventsToMessages(events, systemPrompt) { | |
| const messages = [{ role: 'system', content: systemPrompt }]; | |
| for (const evt of events) { | |
| switch (evt.type) { | |
| case EventTypes.USER_MESSAGE: | |
| messages.push({ role: 'user', content: evt.content }); | |
| break; | |
| case EventTypes.BOT_RESPONSE: | |
| messages.push({ role: 'assistant', content: evt.content }); | |
| break; | |
| case EventTypes.SUMMARY: | |
| // Resúmenes van como contexto del sistema, no como mensajes | |
| messages[0] = { role: 'system', content: systemPrompt + '\n\n[Resumen de conversación anterior]: ' + evt.content }; | |
| break; | |
| case EventTypes.TOOL_RESULT: | |
| // Solo incluir si no fue marcado como observado (pruneable) | |
| if (!evt.pruned) { | |
| messages.push({ role: 'user', content: '[Resultado de herramienta]: ' + evt.content.slice(0, 300) }); | |
| } | |
| break; | |
| // tool_call y system_note no se pasan al modelo | |
| } | |
| } | |
| return messages; | |
| } | |
| // ── 2. STRATEGIC POSITIONING (anti-lost-in-middle) ───────────────────────────── | |
| // Investigación: LLMs atienden al INICIO y al FINAL, ignoran el MEDIO | |
| // Solución: poner la info más importante al inicio y fin del system prompt | |
| // NUNCA en el medio | |
| export function buildStrategicPrompt(parts) { | |
| const { | |
| coreIdentity, // quién es Zelin — al INICIO (siempre) | |
| securityRules, // reglas de seguridad — al INICIO (crítico) | |
| ragContext, // contexto RAG — al INICIO si existe | |
| conversationSummary,// resumen previo — en el medio (está bien, es contexto) | |
| userProfile, // perfil del usuario — al FINAL (referencia) | |
| currentTime, // hora actual — al FINAL | |
| instructions, // instrucciones de respuesta — al FINAL (máxima atención) | |
| } = parts; | |
| // INICIO: lo más crítico — identidad + seguridad + contexto RAG | |
| let prompt = coreIdentity + '\n\n'; | |
| if (securityRules) prompt += securityRules + '\n\n'; | |
| if (ragContext) prompt += '## CONTEXTO RELEVANTE DEL SERVIDOR\n' + ragContext + '\n\n'; | |
| // MEDIO: contexto de soporte (comprimir agresivamente si es largo) | |
| if (conversationSummary) prompt += '## RESUMEN PREVIO\n' + conversationSummary + '\n\n'; | |
| // FINAL: perfil + instrucciones — alta atención del modelo | |
| if (userProfile) prompt += '## CON QUIÉN HABLAS\n' + userProfile + '\n\n'; | |
| if (currentTime) prompt += `Hora actual: ${currentTime}\n\n`; | |
| if (instructions) prompt += '## INSTRUCCIONES DE RESPUESTA\n' + instructions; | |
| return prompt.trim(); | |
| } | |
| // ── 3. AUTO-COMPACTION JERÁRQUICA ──────────────────────────────────────────── | |
| // Trigger a 50% del context window estimado (no esperar a que se llene) | |
| // Manus: "Compress immediately after acknowledging tool outputs" | |
| // Google ADK: sliding window con overlap para no perder contexto | |
| // Estimación tokens: ~1 token por 3.5 chars en español | |
| export function estimateTokens(messages) { | |
| return Math.ceil(messages.reduce((acc, m) => acc + (m.content?.length ?? 0), 0) / 3.5); | |
| } | |
| const CONTEXT_WINDOW = { | |
| 'fast' : 4096, | |
| 'chat' : 8192, | |
| 'spanish' : 8192, | |
| 'reasoning' : 16384, | |
| 'code' : 16384, | |
| 'default' : 8192, | |
| }; | |
| const COMPACTION_THRESHOLD = 0.50; // 50% → compactar (investigación recomienda 50%) | |
| export async function shouldCompact(messages, taskType = 'default') { | |
| const tokens = estimateTokens(messages); | |
| const window = CONTEXT_WINDOW[taskType] ?? CONTEXT_WINDOW.default; | |
| const fillPct = tokens / window; | |
| return fillPct >= COMPACTION_THRESHOLD; | |
| } | |
| // Compaction jerárquica: mantener últimos N verbatim, resumir el resto | |
| export async function hierarchicalCompact(messages, taskType = 'default') { | |
| const KEEP_RECENT = 6; // últimos 6 mensajes siempre verbatim | |
| const sys = messages.filter(m => m.role === 'system'); | |
| const convo = messages.filter(m => m.role !== 'system'); | |
| if (convo.length <= KEEP_RECENT) return messages; // nada que compactar | |
| const toCompress = convo.slice(0, convo.length - KEEP_RECENT); | |
| const recent = convo.slice(convo.length - KEEP_RECENT); | |
| const text = toCompress.map(m => `${m.role === 'user' ? 'Usuario' : 'Zelin'}: ${m.content?.slice(0, 400) ?? ''}`).join('\n'); | |
| let summary; | |
| try { | |
| // Preferir modelo local para no gastar API en compaction | |
| if (isLocalAIReady()) { | |
| summary = await ollamaChatDirect([ | |
| { role: 'system', content: 'Resume en 3-4 frases en español los puntos clave de esta conversación. Solo hechos importantes, sin detalles redundantes.' }, | |
| { role: 'user', content: text.slice(0, 3000) }, | |
| ], 200, 10000); | |
| } else { | |
| summary = await callAIBackground([ | |
| { role: 'system', content: 'Resume en 3-4 frases los puntos clave. Solo hechos, sin detalles redundantes.' }, | |
| { role: 'user', content: text.slice(0, 3000) }, | |
| ], 'fast', 200); | |
| } | |
| } catch { | |
| // Si falla la compaction, mantener solo los últimos mensajes | |
| return [...sys, ...recent]; | |
| } | |
| const summaryMsg = { role: 'user', content: `[Resumen de conversación anterior]: ${summary}` }; | |
| return [...sys, summaryMsg, { role: 'assistant', content: 'Entendido.' }, ...recent]; | |
| } | |
| // Wrapper completo: compactar si necesario, con strategic positioning | |
| export async function engineerContext(messages, systemPrompt, taskType, ragContext = '') { | |
| // 1. Separar system del historial | |
| const nonSystem = messages.filter(m => m.role !== 'system'); | |
| // 2. Auto-compaction si supera 50% | |
| let optimizedHistory = nonSystem; | |
| if (await shouldCompact([{ role: 'system', content: systemPrompt }, ...nonSystem], taskType)) { | |
| console.log('[ContextEngine] 📦 Auto-compaction activada (>50% window)'); | |
| const compacted = await hierarchicalCompact([{ role: 'system', content: systemPrompt }, ...nonSystem], taskType); | |
| optimizedHistory = compacted.filter(m => m.role !== 'system'); | |
| } | |
| // 3. Strategic positioning del system prompt | |
| // Info crítica al inicio, instrucciones al final | |
| const optimizedSystem = ragContext | |
| ? systemPrompt.replace( | |
| /## INFORMACIÓN RELEVANTE DEL SERVIDOR \(RAG\)\n[\s\S]*?(?=\n\n##|$)/, | |
| '' // quitar RAG del medio si ya estaba | |
| ) + `\n\n## CONTEXTO RELEVANTE (posicionado al inicio para máxima atención)\n${ragContext}` | |
| : systemPrompt; | |
| return [{ role: 'system', content: optimizedSystem }, ...optimizedHistory]; | |
| } | |
| // ── Pruning de tool results (como hace Manus) ───────────────────────────────── | |
| // Después de que el agente usa un resultado de herramienta, marcarlo como "observado" | |
| // Para que no siga ocupando context en futuras llamadas | |
| export function markToolResultPruned(events, toolCallId) { | |
| for (const e of events) { | |
| if (e.type === EventTypes.TOOL_RESULT && e.toolCallId === toolCallId) { | |
| e.pruned = true; | |
| } | |
| } | |
| } | |