Spaces:
Paused
Paused
File size: 8,691 Bytes
ee826ee | 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 127 128 129 130 131 132 133 134 135 136 137 138 139 140 141 142 143 144 145 146 147 148 149 150 151 152 153 154 155 156 157 158 159 160 161 162 163 164 165 166 167 168 169 170 171 172 173 174 175 176 177 178 179 180 181 182 183 184 185 186 187 188 189 190 191 192 193 194 195 | /**
* 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;
}
}
}
|