File size: 8,660 Bytes
0f9caed
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
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
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
"""
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))