""" Token accounting a inteligentní správa kontextu. Dvě odpovědnosti: 1. TokenStats — thread-safe počítadlo tokenů. Přesná čísla bere z `usage` pole odpovědí (vLLM i HF router je vracejí), takže globální statistiky (/health, UI) i per-turn statistiky (patička v chatu) jsou skutečné, ne odhady. Cache hity se nepočítají (nic nestály). 2. compact_messages — deterministická kompakce kontextu, aby se dlouhé agentní smyčky vešly do context window a šetřily KV cache: - Fáze 1 (stárnutí): staré výsledky nástrojů (mimo posledních `keep_last_steps` bloků) se zkrátí na `aged_chars` znaků — přesné výpisy souborů/logů ztrácejí hodnotu, jakmile na ně agent zareagoval. - Fáze 2 (vypouštění): pokud se stále nevejde, vypouští NEJSTARŠÍ celé bloky (assistant + jeho tool odpovědi) hned za kotvou a nahradí je jednou souhrnnou poznámkou, aby model věděl, že historie byla zkrácena. - Vždy zůstává: systémové zprávy, první user zpráva (zadání úlohy) a posledních `keep_last_steps` bloků v plném znění. Kompakce nikdy nerozbije strukturu tool-callingu (tool zpráva bez svého assistant/tool_calls předchůdce) — vypouští se výhradně celé bloky. Odhad tokenů je heuristika ~4 znaky/token (±15 %) — pro rozhodnutí „kdy kompaktovat" bohatě stačí; přesná čísla dodává `usage` po každém volání. """ from __future__ import annotations import json import time from threading import Lock CHARS_PER_TOKEN = 4 MESSAGE_OVERHEAD_TOKENS = 4 # role, oddělovače, formát šablony # ---------------------------------------------------------------- odhad tokenů def _message_text(msg: dict) -> str: content = msg.get("content") or "" if isinstance(content, list): content = json.dumps(content, ensure_ascii=False) text = str(content) if msg.get("tool_calls"): text += json.dumps(msg["tool_calls"], ensure_ascii=False) return text def estimate_tokens(messages: list[dict]) -> int: """Hrubý odhad počtu tokenů zpráv (~4 znaky/token + režie zprávy).""" total = 0 for msg in messages: total += MESSAGE_OVERHEAD_TOKENS + len(_message_text(msg)) // CHARS_PER_TOKEN return total # ---------------------------------------------------------------- statistiky class TokenStats: """Thread-safe počítadlo skutečné spotřeby tokenů (z `usage` odpovědí). Používá se dvojmo: globální instance (celkové statistiky od startu app) a per-turn instance (patička jednoho chatu vč. sub-agentů). """ def __init__(self): self._lock = Lock() self.calls = 0 self.prompt_tokens = 0 self.completion_tokens = 0 self.by_source: dict[str, dict] = {} self.last_context_tokens = 0 # prompt_tokens posledního volání self.saved_tokens = 0 # ušetřeno kompakcí kontextu self.started_at = time.time() def add(self, source: str, usage) -> None: """Zaznamená jedno LLM volání. `usage` je objekt/dict s prompt_tokens/completion_tokens, nebo None (pak se nepočítá nic).""" if usage is None: return prompt = getattr(usage, "prompt_tokens", None) completion = getattr(usage, "completion_tokens", None) if prompt is None and isinstance(usage, dict): prompt = usage.get("prompt_tokens") completion = usage.get("completion_tokens") prompt = int(prompt or 0) completion = int(completion or 0) with self._lock: self.calls += 1 self.prompt_tokens += prompt self.completion_tokens += completion self.last_context_tokens = prompt src = self.by_source.setdefault( source, {"calls": 0, "prompt_tokens": 0, "completion_tokens": 0}) src["calls"] += 1 src["prompt_tokens"] += prompt src["completion_tokens"] += completion def add_saved(self, tokens: int) -> None: with self._lock: self.saved_tokens += max(0, tokens) def snapshot(self) -> dict: with self._lock: return { "calls": self.calls, "prompt_tokens": self.prompt_tokens, "completion_tokens": self.completion_tokens, "total_tokens": self.prompt_tokens + self.completion_tokens, "last_context_tokens": self.last_context_tokens, "saved_by_compaction_tokens": self.saved_tokens, "by_source": {k: dict(v) for k, v in self.by_source.items()}, "since": int(self.started_at), } # ---------------------------------------------------------------- kompakce TRUNCATION_MARK = "\n…[zkráceno kompakcí kontextu]" def _split_blocks(messages: list[dict]) -> tuple[list[dict], list[list[dict]]]: """Rozdělí zprávy na (kotva, bloky). Kotva = úvodní system zprávy + první user zpráva. Blok = souvislá skupina, kterou lze bezpečně vypustit celou: assistant s tool_calls + jeho tool odpovědi, nebo samostatná user/assistant zpráva. """ anchor: list[dict] = [] i = 0 while i < len(messages) and messages[i].get("role") == "system": anchor.append(messages[i]) i += 1 if i < len(messages) and messages[i].get("role") == "user": anchor.append(messages[i]) i += 1 blocks: list[list[dict]] = [] current: list[dict] = [] for msg in messages[i:]: if msg.get("role") == "tool": # tool patří k předchozímu assistant tool_calls bloku current.append(msg) continue if current: blocks.append(current) current = [msg] if current: blocks.append(current) return anchor, blocks def _age_tool_results(blocks: list[list[dict]], keep_last: int, aged_chars: int) -> int: """Zkrátí tool výsledky ve starých blocích. Vrací počet ušetřených znaků.""" saved_chars = 0 for block in blocks[:-keep_last] if keep_last > 0 else blocks: for msg in block: if msg.get("role") != "tool": continue content = msg.get("content") or "" if isinstance(content, str) and len(content) > aged_chars: saved_chars += len(content) - aged_chars msg["content"] = content[:aged_chars] + TRUNCATION_MARK return saved_chars def _drop_summary(dropped: list[list[dict]]) -> dict: tools = [] for block in dropped: for msg in block: for tc in msg.get("tool_calls") or []: name = (tc.get("function") or {}).get("name") if name: tools.append(name) tool_note = f" (nástroje: {', '.join(tools[:15])})" if tools else "" return {"role": "system", "content": f"🧹 [Kompakce kontextu: vypuštěno {len(dropped)} " f"starších kroků konverzace{tool_note}. Shrnutí stavu " f"si drž ve vlastních odpovědích.]"} def compact_messages(messages: list[dict], budget_tokens: int, keep_last_steps: int = 6, aged_chars: int = 2000) -> tuple[list[dict], int]: """Zhutní kontext pod budget_tokens. Vrací (nové zprávy, ušetřené tokeny). Deterministická, bez LLM volání. Vstup nemodifikuje (pracuje s kopií). Pokud se ani po vypuštění všeho kromě kotvy a posledních bloků nevejde, vrací nejlepší možný výsledek (best effort). """ before = estimate_tokens(messages) if before <= budget_tokens: return messages, 0 work = [dict(m) for m in messages] anchor, blocks = _split_blocks(work) # Fáze 1: stárnutí starých tool výsledků _age_tool_results(blocks, keep_last_steps, aged_chars) flat = anchor + [m for b in blocks for m in b] if estimate_tokens(flat) <= budget_tokens: return flat, before - estimate_tokens(flat) # Fáze 2: vypouštění nejstarších bloků (poslední keep_last_steps nikdy) dropped: list[list[dict]] = [] keep = max(1, keep_last_steps) while len(blocks) > keep: dropped.append(blocks.pop(0)) flat = anchor + [_drop_summary(dropped)] + [m for b in blocks for m in b] if estimate_tokens(flat) <= budget_tokens: break if dropped: flat = anchor + [_drop_summary(dropped)] + [m for b in blocks for m in b] return flat, max(0, before - estimate_tokens(flat))