oc_chat
Loading...
Searching...
No Matches
context.py
Go to the documentation of this file.
1#!/usr/bin/env python3
2# -*- coding: utf-8 -*-
3# oc_chat/core/context.py
4
5"""
6Context-window compression logic.
7
8Purpose
9-------
10Phase 3: :func:`maybe_compress` triggers from the last API
11last API ``prompt_tokens`` recorded on the session (or 0 on the first turn), then summarises older *conversation turns* while
12keeping the last few turns raw. Summarisation calls are routed to the
13provider's ``summary`` tier model.
14
15Responsibilities
16----------------
17- :func:`maybe_compress` — the public entry point; return ``True`` when
18 history was rewritten, ``False`` otherwise.
19- :func:`_conversation_turns` — split non-system messages into
20 user-rooted turn lists.
21- :func:`_abbreviate_turn_for_summary` — strip bulky successful tool
22 payloads before serialising for the summariser; keep errors verbatim.
23- :func:`_effective_prompt_trigger` — uses API-reported prompt size from last turn;
24 returns 0 on first turn when no history exists.
25
26Diagnostics
27-----------
28Domain: OC_CHAT.CTX
29Levels:
30 L2 — compress START / compressed (rebuilt size).
31 L3 — threshold comparison, turn-count check, summary model id,
32 per-call timing via ``dbg_timed`` label ``summarise``.
33 L4 — deep tracing (per-turn abbreviation decisions).
34
35Compression-failure paths log at L1 with ``override=True``.
36
37Contracts
38---------
39- ``maybe_compress`` mutates ``session.messages`` only when it
40 returns ``True``; on failure the session is left untouched.
41- The system prompt at index 0 is preserved verbatim across compression.
42- The last ``CONTEXT_KEEP_RECENT_TURNS`` user-rooted turns survive raw.
43- ``session.last_turn_max_prompt_tokens`` is reset to 0 on success so
44 the next turn re-evaluates the threshold from scratch.
45
46Dependencies
47------------
48- oc_diagnostics (structured debug logging via _dbg)
49- oc_chat.core.config (shared config constants and ANSI styles)
50- oc_chat.core.client_interface (lazy; ClientInterface type of the summarising client)
51- oc_chat.core.session (lazy; ChatSession type of the session being compressed)
52- oc_chat.core.tokenizer (lazy; message token counting for the estimate helper)
53- oc_ui.presenter_base (lazy; Presenter type for the compression notice)
54"""
55from __future__ import annotations
56
57import copy
58import json
59from typing import TYPE_CHECKING, Any, List, Dict, Union
60
61from oc_diagnostics import _dbg, dbg_timed
62
63if TYPE_CHECKING:
64 from oc_chat.core.client_interface import ClientInterface
65 from oc_chat.core.session import ChatSession
66 from oc_ui.presenter_base import Presenter
67
68from oc_chat.core.config import (
69 CONTEXT_COMPRESS_THRESHOLD,
70 CONTEXT_KEEP_RECENT_TURNS,
71 MODEL_MAX_TOKENS,
72 get_max_context_tokens,
73)
74
75
76def _estimate_tokens(messages: list[dict[str, Any]]) -> int:
77 """Token count estimation for messages.
78
79 Uses tiktoken (cl100k_base) when available for accurate counts.
80 Falls back to ~4 chars/token heuristic if tiktoken not installed.
81
82 This wraps count_message_tokens from the tokenizer module.
83 """
84 from oc_chat.core.tokenizer import count_message_tokens
85 return count_message_tokens(messages)
86
87
88
89def _effective_prompt_trigger(session, messages: list) -> int:
90 """Use API-reported prompt size from the last turn; 0 on first turn."""
91 return int(getattr(session, "last_turn_max_prompt_tokens", 0) or 0)
92
93
94def _conversation_turns(messages: list[dict[str, Any]]) -> list[list[dict[str, Any]]]:
95 """Split non-system messages into user-rooted turns (each starts with a user message)."""
96 if not messages:
97 return []
98 body = messages[1:] if messages[0].get("role") == "system" else list(messages)
99 if not body:
100 return []
101 turns: list[list[dict[str, Any]]] = []
102 cur: list[dict[str, Any]] = []
103 for m in body:
104 if m.get("role") == "user" and cur:
105 turns.append(cur)
106 cur = []
107 cur.append(m)
108 if cur:
109 turns.append(cur)
110 return turns
111
112
113def _abbreviate_tool_for_summary(m: dict[str, Any]) -> dict[str, Any]:
114 """Drop bulky successful tool payloads; keep errors verbatim for the summariser."""
115 if m.get("role") != "tool":
116 return m
117 content = m.get("content") or ""
118 try:
119 obj = json.loads(content)
120 except (json.JSONDecodeError, TypeError):
121 return m
122 if isinstance(obj, dict) and "error" in obj:
123 return m
124 m2 = dict(m)
125 m2["content"] = f"[Successful tool output omitted; {len(content)} chars]"
126 return m2
127
128
129def _abbreviate_turn_for_summary(turn: list[dict[str, Any]]) -> list[dict[str, Any]]:
130 """Aggressively trim old turns before JSON serialisation for the summariser."""
131 out: list[dict[str, Any]] = []
132 for m in turn:
133 role = m.get("role")
134 if role == "tool":
135 out.append(_abbreviate_tool_for_summary(m))
136 continue
137 m2 = copy.deepcopy(m)
138 if role == "assistant":
139 c = m2.get("content")
140 if isinstance(c, str) and len(c) > 1200:
141 m2["content"] = c[:1200] + "\n…[truncated for summariser]"
142 rc = m2.get("reasoning_content")
143 if isinstance(rc, str) and len(rc) > 800:
144 m2["reasoning_content"] = rc[:800] + "\n…[truncated]"
145 out.append(m2)
146 return out
147
148
149def maybe_compress(session: ChatSession, client: ClientInterface, presenter: Presenter | None = None) -> bool:
150 """Compress history if the context budget is exceeded.
151
152 Uses API-reported ``prompt_tokens`` from the last turn against the
153 session model's context window. Older conversation turns are passed to
154 :meth:`DeepSeekClient.one_shot` on ``CONTEXT_COMPRESS_SUMMARY_MODEL`` with
155 successful tool bodies stripped; the last ``CONTEXT_KEEP_RECENT_TURNS``
156 turns stay verbatim in ``session.messages``.
157 """
158 func = "maybe_compress"
159 messages = session.messages
160 max_tokens = get_max_context_tokens(session.model)
161 threshold = int(max_tokens * CONTEXT_COMPRESS_THRESHOLD)
162 effective = _effective_prompt_trigger(session, messages)
163
164 _dbg("OC_CHAT.CTX", func,
165 f"effective={effective} threshold={threshold} max_tokens={max_tokens}", 3)
166
167 if effective < threshold:
168 _dbg("OC_CHAT.CTX", func, "below threshold — skipping", 3)
169 return False
170
171 turns = _conversation_turns(messages)
172 if len(turns) <= CONTEXT_KEEP_RECENT_TURNS:
173 _dbg("OC_CHAT.CTX", func,
174 f"only {len(turns)} turns <= {CONTEXT_KEEP_RECENT_TURNS} — skipping", 3)
175 return False
176
177 system_msg = messages[0] if messages and messages[0].get("role") == "system" else None
178 if system_msg is None:
179 return False
180
181 keep_turns = turns[-CONTEXT_KEEP_RECENT_TURNS :]
182 old_turns = turns[: -CONTEXT_KEEP_RECENT_TURNS]
183
184 _dbg("OC_CHAT.CTX", func,
185 f"compressing: {len(old_turns)} old turns, keeping {len(keep_turns)} recent", 2)
186
187 abbreviated_old: list[dict[str, Any]] = []
188 for t in old_turns:
190 abbreviated_old.append(m)
191
192 summary_prompt = [
193 {
194 "role": "system",
195 "content": (
196 "Summarise the following older conversation turns in 4-8 short paragraphs. "
197 "Preserve critical user goals, file paths, errors, and conclusions. "
198 "Omit verbose tool dumps — they were already stripped to placeholders."
199 ),
200 },
201 {
202 "role": "user",
203 "content": json.dumps(abbreviated_old, ensure_ascii=False),
204 },
205 ]
206
207 summary_model = client.provider.tier("summary") or session.model
208 _dbg("OC_CHAT.CTX", func, f"summary model={summary_model}", 3)
209 with dbg_timed("OC_CHAT.CTX", func, label="summarise", level=3):
210 try:
211 summary = client.one_shot(
212 summary_prompt,
213 summary_model,
214 effort="low",
215 max_tokens=4096,
216 )
217 except Exception as exc:
218 _dbg("OC_CHAT.CTX", func,
219 f"compression failed: {type(exc).__name__}: {exc}", 1, override=True)
220 return False
221
222 rebuilt: list[dict[str, Any]] = [system_msg]
223 rebuilt.append(
224 {"role": "assistant", "content": f"[Earlier conversation summary: {summary}]"}
225 )
226 for t in keep_turns:
227 rebuilt.extend(t)
228
229 session.messages = rebuilt
230 session.last_turn_max_prompt_tokens = 0
231 session.dirty = True
232
233 _dbg("OC_CHAT.CTX", func,
234 f"compressed: {len(old_turns)} old turns -> summary, messages={len(rebuilt)}", 2)
235
236 if presenter:
237 presenter.show_compressed()
238
239 return True
list[dict[str, Any]] _abbreviate_turn_for_summary(list[dict[str, Any]] turn)
Definition context.py:129
int _effective_prompt_trigger(session, list messages)
Definition context.py:89
int _estimate_tokens(list[dict[str, Any]] messages)
Definition context.py:76
list[list[dict[str, Any]]] _conversation_turns(list[dict[str, Any]] messages)
Definition context.py:94
bool maybe_compress(ChatSession session, ClientInterface client, Presenter|None presenter=None)
Definition context.py:149
dict[str, Any] _abbreviate_tool_for_summary(dict[str, Any] m)
Definition context.py:113