| """ |
| RUBRA β context_engine.py |
| Context Window Management & Session Memory Layer. |
| |
| THE PROBLEM THIS REPLACES: |
| main.py previously did `hist = raw_hist[-30:]` β a flat, fixed slice with |
| no idea how many tokens that actually costs, no regard for which model is |
| about to receive it, and total amnesia for anything older than 15 |
| exchanges (it's just gone, not summarized, not searchable). Separately, |
| RUBRA already had THREE overlapping memory paths that didn't know about |
| each other: `database.py` messages table (raw log), `database.py` |
| user_memory + `memory_engine.py` (durable cross-session facts), and |
| `evolution_engine.py`'s InfiniteMemory JSON blob (episodic/semantic/ |
| personal β reloaded and rewritten to disk in FULL on every single write, |
| which doesn't scale). This module doesn't replace those β it sits above |
| them as the thing that actually DECIDES what goes into the prompt, and |
| introduces the piece that was fully missing: a token budget. |
| |
| DESIGN β four tiers, assembled in priority order until the budget runs out: |
| Tier 0 SYSTEM β persona/rules/persistent facts. Always included, |
| never trimmed (caller's responsibility to keep this |
| tier itself reasonably sized). |
| Tier 1 PINNED β RAG hits / tool context / uploaded-file context for |
| THIS turn. High relevance, bounded size. |
| Tier 2 RECENT β verbatim recent turns, walked newest-first until the |
| remaining budget runs out. This is what "the model |
| actually sees as conversation" β exact wording |
| preserved, since paraphrasing recent turns loses |
| exactly the nuance multi-turn coherence depends on. |
| Tier 3 SUMMARY β a single rolling compacted paragraph covering |
| everything OLDER than what fit in Tier 2. Built |
| INCREMENTALLY (only the newly-pushed-out slice gets |
| summarized each time, folded into the existing |
| summary) so a long-running session never pays an |
| O(n) summarization cost on every turn. |
| |
| Token counts are estimated (chars/4, tuned per empirical average for |
| mixed English/Bengali text) rather than an exact tokenizer β RUBRA talks |
| to several providers (Groq/OpenRouter/Gemini/Cerebras) each with their own |
| tokenizer, so no single exact count would be right for all of them anyway; |
| a slightly conservative heuristic with a safety margin is the correct |
| choice here, not a shortcut. |
| """ |
|
|
| import re |
| import logging |
| from dataclasses import dataclass, field |
| from typing import Optional, List, Dict, Callable, AsyncIterator |
|
|
| from database import summary_load, summary_save |
|
|
| log = logging.getLogger("rubra.context") |
|
|
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| MODEL_CONTEXT_BUDGETS = { |
| "coding": 80_000, |
| "diff_merge": 150_000, |
| "general": 32_000, |
| "vision": 60_000, |
| } |
| DEFAULT_BUDGET = 32_000 |
|
|
| |
| PINNED_TIER_FRACTION = 0.25 |
| SUMMARY_TIER_MAX_CHARS = 3_200 |
|
|
| |
| |
| |
| SUMMARIZE_TRIGGER_DELTA = 6 |
|
|
| CHARS_PER_TOKEN = 4.0 |
|
|
|
|
| def estimate_tokens(text: str) -> int: |
| """Heuristic token estimate. Always rounds up β better to |
| under-fill the budget slightly than to overflow it.""" |
| if not text: |
| return 0 |
| return int(len(text) / CHARS_PER_TOKEN) + 1 |
|
|
|
|
| def estimate_message_tokens(msg: Dict) -> int: |
| content = msg.get("content", "") or "" |
| |
| |
| return estimate_tokens(content) + 4 |
|
|
|
|
| @dataclass |
| class ContextWindow: |
| """What context_engine hands back β main.py/agent.py consume this |
| directly instead of hand-rolling their own history slice.""" |
| recent_hist: List[Dict] = field(default_factory=list) |
| summary_text: str = "" |
| dropped_count: int = 0 |
| budget_tokens: int = 0 |
| used_tokens: int = 0 |
| tiers_used: List[str] = field(default_factory=list) |
|
|
|
|
| def _default_summary_prompt(old_messages: List[Dict], prior_summary: str) -> str: |
| convo = "\n".join( |
| f"{m.get('role','user')}: {(m.get('content') or '')[:400]}" |
| for m in old_messages |
| ) |
| prior = f"EXISTING SUMMARY OF EVEN OLDER CONTEXT:\n{prior_summary}\n\n" if prior_summary else "" |
| return ( |
| f"{prior}" |
| f"Summarize the following older conversation turns into a SHORT " |
| f"(under 150 words) running summary that preserves: who the user " |
| f"is, what they're working on, decisions already made, and any " |
| f"open threads. Merge with the existing summary above if present " |
| f"β produce ONE updated summary, not two separate ones. Write it " |
| f"as plain prose, no headers, no bullet points.\n\n" |
| f"OLDER TURNS TO FOLD IN:\n{convo}\n\n" |
| f"Respond with ONLY the updated summary text." |
| ) |
|
|
|
|
| async def _run_summarizer(llm_func: Callable, old_messages: List[Dict], prior_summary: str) -> str: |
| """llm_func follows the same convention used everywhere else in RUBRA |
| (memory_engine.extract_and_update_memory, code_executor's autofix loop): |
| an async generator over (messages, mode=...) yielding text tokens.""" |
| prompt = _default_summary_prompt(old_messages, prior_summary) |
| out = "" |
| try: |
| async for tok in llm_func([{"role": "user", "content": prompt}], mode="fast"): |
| out += tok |
| except Exception as e: |
| log.warning(f"[CONTEXT] summarization call failed: {e}") |
| return prior_summary |
| out = out.strip() |
| return out[:SUMMARY_TIER_MAX_CHARS] if out else prior_summary |
|
|
|
|
| async def build_context_window( |
| session_id: str, |
| raw_hist: List[Dict], |
| pinned_context: str = "", |
| system_tokens_used: int = 0, |
| budget_key: str = "general", |
| llm_func: Optional[Callable] = None, |
| ) -> ContextWindow: |
| """ |
| The main entry point. Replaces `hist[-30:]`. |
| |
| Args: |
| session_id: for summary checkpoint lookup/storage |
| raw_hist: FULL message history for this session, chronological |
| pinned_context: already-built Tier 1 string (RAG hits, tool |
| output, file context) β sized by the caller, |
| just used here for budget accounting |
| system_tokens_used: estimated tokens already spent on Tier 0 |
| (system prompt + persona + persistent facts) |
| budget_key: which MODEL_CONTEXT_BUDGETS entry to use |
| llm_func: optional async-generator LLM call used to |
| produce/update the rolling summary. If not |
| provided, older turns beyond the verbatim |
| window are simply dropped (old behavior) β |
| summarization is a strict improvement, not |
| a hard requirement, so this degrades |
| gracefully rather than raising. |
| |
| Returns a ContextWindow with recent_hist (verbatim, put straight into |
| the messages list) and summary_text (inject as a single system_note, |
| same pattern main.py already uses for memory_ctx/file_ctx). |
| """ |
| total_budget = MODEL_CONTEXT_BUDGETS.get(budget_key, DEFAULT_BUDGET) |
| remaining = max(total_budget - system_tokens_used, 1000) |
|
|
| pinned_tokens = estimate_tokens(pinned_context) |
| pinned_cap = int(total_budget * PINNED_TIER_FRACTION) |
| if pinned_tokens > pinned_cap: |
| |
| |
| |
| |
| remaining -= pinned_tokens |
| else: |
| remaining -= pinned_tokens |
| remaining = max(remaining, 500) |
|
|
| |
| kept: List[Dict] = [] |
| used = 0 |
| cutoff_index = len(raw_hist) |
| for i in range(len(raw_hist) - 1, -1, -1): |
| m = raw_hist[i] |
| cost = estimate_message_tokens(m) |
| if used + cost > remaining and kept: |
| |
| |
| |
| |
| cutoff_index = i + 1 |
| break |
| kept.append(m) |
| used += cost |
| cutoff_index = i |
| kept.reverse() |
|
|
| tiers_used = ["recent"] if kept else [] |
| if pinned_context: |
| tiers_used.insert(0, "pinned") |
|
|
| |
| summary_text = "" |
| dropped_count = 0 |
| older = raw_hist[:cutoff_index] |
| if older: |
| checkpoint = summary_load(session_id) |
| prior_summary = checkpoint["summary"] |
| through_index = checkpoint["through_index"] |
|
|
| |
| new_slice = older[through_index:] if through_index < len(older) else [] |
|
|
| if new_slice and len(new_slice) >= SUMMARIZE_TRIGGER_DELTA and llm_func is not None: |
| summary_text = await _run_summarizer(llm_func, new_slice, prior_summary) |
| try: |
| summary_save(session_id, summary_text, len(older)) |
| except Exception as e: |
| log.warning(f"[CONTEXT] summary_save failed for {session_id}: {e}") |
| elif prior_summary: |
| |
| |
| |
| summary_text = prior_summary |
| dropped_count = len(new_slice) |
| else: |
| |
| |
| |
| |
| dropped_count = len(new_slice) |
|
|
| if summary_text: |
| tiers_used.append("summary") |
|
|
| return ContextWindow( |
| recent_hist=kept, |
| summary_text=summary_text, |
| dropped_count=dropped_count, |
| budget_tokens=total_budget, |
| used_tokens=system_tokens_used + pinned_tokens + used, |
| tiers_used=tiers_used, |
| ) |
|
|
|
|
| def format_summary_for_prompt(summary_text: str) -> str: |
| """Same system_note convention main.py already uses for memory_ctx β |
| caller does: `hist = [{"role": "system_note", "_summary_ctx": ...}] + hist`.""" |
| if not summary_text: |
| return "" |
| return ( |
| "[EARLIER IN THIS CONVERSATION β summarized, not verbatim]\n" |
| f"{summary_text}\n" |
| "[END SUMMARY β the messages below this are the exact recent turns]" |
| ) |