main
py 365 lines 12.7 KB
Raw
1 """Core compaction logic for the compaction plugin."""
2 import os
3 from collections import deque
4
5 import models as models_module
6 from agent import Agent
7 from helpers import files, tokens
8 from helpers.history import History, clear_responses_provider_state, output_text
9 from helpers.persist_chat import (
10 export_json_chat,
11 get_chat_folder_path,
12 save_tmp_chat,
13 remove_msg_files,
14 )
15 from helpers.state_monitor_integration import mark_dirty_all
16 from helpers.localization import Localization
17
18 MIN_COMPACTION_TOKENS = 1000
19 COMPACTION_CHUNK_TARGET_RATIO = 0.9
20 COMPACTION_CHUNK_VERIFY_RATIO = 0.98
21
22 from plugins._model_config.helpers.model_config import (
23 get_chat_model_config,
24 get_utility_model_config,
25 get_preset_by_name,
26 build_model_config,
27 build_chat_model,
28 build_utility_model,
29 )
30
31
32 def _save_pre_compaction_backup(context, full_text: str) -> dict[str, str]:
33 """Save the original chat as JSON and plain text before compaction.
34
35 Returns dict with 'json' and 'txt' absolute file paths.
36 """
37 timestamp = Localization.get().now().strftime("%Y%m%d-%H%M%S")
38 backup_dir = os.path.join(get_chat_folder_path(context.id), "backups")
39 os.makedirs(backup_dir, exist_ok=True)
40
41 json_path = os.path.join(backup_dir, f"pre-compact-{timestamp}.json")
42 txt_path = os.path.join(backup_dir, f"pre-compact-{timestamp}.txt")
43
44 json_content = export_json_chat(context)
45 files.write_file(json_path, json_content)
46 files.write_file(txt_path, full_text)
47
48 return {"json": json_path, "txt": txt_path}
49
50
51 def _build_model(use_chat_model: bool, preset_name: str | None, agent):
52 """Build the LLM model for compaction based on user selection.
53
54 If preset_name is given, builds from that preset's config.
55 Otherwise falls back to the agent's currently configured model.
56 """
57 if preset_name:
58 preset = get_preset_by_name(preset_name)
59 if preset:
60 model_key = "chat" if use_chat_model else "utility"
61 cfg = preset.get(model_key, {})
62 if cfg.get("provider") or cfg.get("name"):
63 mc = build_model_config(cfg, models_module.ModelType.CHAT)
64 return cfg, models_module.get_chat_model(
65 mc.provider, mc.name, model_config=mc, **mc.build_kwargs()
66 )
67
68 if use_chat_model:
69 cfg = get_chat_model_config(agent)
70 return cfg, build_chat_model(agent)
71 else:
72 cfg = get_utility_model_config(agent)
73 return cfg, build_utility_model(agent)
74
75
76 async def run_compaction(
77 context,
78 use_chat_model: bool = True,
79 preset_name: str | None = None,
80 ) -> None:
81 """
82 Compact the chat history into a single summarized message.
83
84 This function:
85 1. Extracts the full conversation text
86 2. Estimates token count and checks against model context window
87 3. If needed, splits history and summarizes iteratively
88 4. Calls the LLM to generate a comprehensive summary
89 5. Replaces the history with a single context message containing the summary
90 6. Resets the log and creates a response log item
91 7. Persists the changes
92
93 The function streams progress to the frontend via the log system.
94 If any error occurs, the original history is preserved.
95 """
96 agent = context.agent0
97
98 try:
99 # Step 1: Extract full conversation text
100 history_output = agent.history.output()
101 full_text = output_text(history_output, ai_label="assistant", human_label="user")
102
103 if not full_text.strip():
104 raise ValueError("No conversation content to compact")
105
106 # Step 2: Estimate tokens, resolve model, and compute context budget
107 token_count = tokens.approximate_tokens(full_text)
108
109 resolved_cfg, model = _build_model(use_chat_model, preset_name, agent)
110 ctx_length = int(resolved_cfg.get("ctx_length", 128000)) if resolved_cfg else 128000
111 max_input_tokens = int(ctx_length * 0.7)
112
113 # Step 3: Create progress log item (count user-visible messages only)
114 visible_types = {"user", "response"}
115 visible_count = sum(1 for item in context.log.logs if item.type in visible_types)
116 log_item = context.log.log(
117 type="info",
118 heading="Compacting chat history...",
119 content=f"Analyzing {visible_count} messages (~{token_count} tokens)...",
120 )
121
122 # Step 4: Handle large histories by chunking if necessary
123 if token_count > max_input_tokens:
124 summary = await _compact_large_history(
125 agent, full_text, token_count, max_input_tokens, log_item, model
126 )
127 else:
128 summary = await _compact_single_pass(
129 agent, full_text, log_item, model
130 )
131
132 if not summary or not summary.strip():
133 raise ValueError("Compaction produced empty summary")
134
135 # Step 5: Save pre-compaction backup before destroying history
136 backup_paths = _save_pre_compaction_backup(context, full_text)
137
138 # Step 6: Replace history with compacted version
139 backup_note = (
140 f"\n\n---\n"
141 f"*Pre-compaction backup of the full original conversation:*\n"
142 f"- `{backup_paths['txt']}`"
143 )
144 compacted_content = f"## Context compacted\n\n{summary}{backup_note}"
145
146 agent.history = History(agent=agent)
147 # History summaries are context, not orphaned assistant turns.
148 agent.history.add_message(ai=False, content=compacted_content)
149 clear_responses_provider_state(agent)
150 agent.data.pop(Agent.DATA_NAME_CTX_WINDOW, None)
151
152 # Clear subordinate chain
153 agent.data.pop(Agent.DATA_NAME_SUBORDINATE, None)
154 context.streaming_agent = None
155
156 # Step 7: Reset log and create response
157 context.log.reset()
158 context.log.log(
159 type="response",
160 heading="Context compacted",
161 content=compacted_content,
162 update_progress="none",
163 )
164
165 # Step 8: Persist and notify
166 save_tmp_chat(context)
167 remove_msg_files(context.id)
168
169 # Step 9: Force progress bar to inactive state LAST
170 # This must happen after all log operations and persist
171 context.log.set_progress("Waiting for input", 0, False)
172 mark_dirty_all(reason="plugins.compaction.compact_chat")
173
174 except Exception as e:
175 # Log error but don't modify history
176 context.log.log(
177 type="error",
178 heading="Compaction Failed",
179 content=str(e),
180 )
181 mark_dirty_all(reason="plugins.compaction.compact_chat_error")
182 raise
183
184
185 async def _compact_single_pass(agent, full_text: str, log_item, model) -> str:
186 """Compact history in a single LLM call using the provided model."""
187 system_prompt = agent.read_prompt("compact.sys.md")
188 user_prompt = agent.read_prompt("compact.msg.md", conversation=full_text)
189
190 async def stream_cb(chunk: str, total: str):
191 if chunk:
192 log_item.stream(content=chunk)
193
194 summary, _ = await model.unified_call(
195 system_message=system_prompt,
196 user_message=user_prompt,
197 response_callback=stream_cb,
198 )
199 return summary
200
201
202 async def _compact_large_history(
203 agent, full_text: str, token_count: int, max_input_tokens: int, log_item, model
204 ) -> str:
205 """Handle large histories by splitting into chunks and summarizing iteratively."""
206 chunks = _split_text_for_compaction(agent, full_text, token_count, max_input_tokens)
207 log_item.update(
208 content=f"History is large (~{token_count} tokens). Splitting into {len(chunks)} chunks...",
209 )
210
211 summaries = []
212 for i, chunk in enumerate(chunks, 1):
213 log_item.update(content=f"Summarizing part {i}/{len(chunks)}...")
214
215 system_prompt = agent.read_prompt("compact.sys.md")
216 user_prompt = agent.read_prompt("compact.msg.md", conversation=chunk)
217
218 chunk_summary, _ = await model.unified_call(
219 system_message=system_prompt,
220 user_message=user_prompt,
221 )
222 summaries.append(chunk_summary)
223
224 combined = "\n\n---\n\n".join(summaries)
225 log_item.update(content="Creating final summary from parts...")
226
227 final_prompt = agent.read_prompt("compact.sys.md")
228 final_user = agent.read_prompt(
229 "compact.msg.md",
230 conversation=f"This is a multi-part conversation. Here are summaries of each part:\n\n{combined}",
231 )
232
233 async def stream_cb(chunk: str, total: str):
234 if chunk:
235 log_item.stream(content=chunk)
236
237 final_summary, _ = await model.unified_call(
238 system_message=final_prompt,
239 user_message=final_user,
240 response_callback=stream_cb,
241 )
242 return final_summary
243
244
245 def _split_text_for_compaction(
246 agent, full_text: str, token_count: int, max_input_tokens: int
247 ) -> list[str]:
248 """Split large compaction input into prompt-safe chunks.
249
250 The previous line-midpoint split left a single-line payload as one empty
251 chunk plus one still-oversized chunk. This splitter derives a conservative
252 character target from the measured token density, then verifies each prompt
253 and keeps splitting any chunk that still exceeds the model input budget.
254 """
255 text = full_text or ""
256 if not text:
257 return []
258
259 prompt_overhead = _compaction_input_tokens(agent, "")
260 usable_tokens = max(max_input_tokens - prompt_overhead, 1)
261 target_tokens = max(int(usable_tokens * COMPACTION_CHUNK_TARGET_RATIO), 1)
262
263 if token_count <= target_tokens:
264 return [text]
265
266 chars_per_token = max(len(text) / max(token_count, 1), 0.01)
267 target_chars = max(int(target_tokens * chars_per_token), 1)
268 chunks = _split_text_by_chars(text, target_chars)
269
270 verified: list[str] = []
271 max_verified_tokens = max(int(max_input_tokens * COMPACTION_CHUNK_VERIFY_RATIO), 1)
272 pending = deque(chunk for chunk in chunks if chunk)
273
274 while pending:
275 chunk = pending.popleft()
276 if not chunk:
277 continue
278
279 if (
280 len(chunk) <= 1
281 or _compaction_input_tokens(agent, chunk) <= max_verified_tokens
282 ):
283 verified.append(chunk)
284 continue
285
286 split_chunks = _split_text_by_chars(chunk, max(len(chunk) // 2, 1))
287 if len(split_chunks) <= 1:
288 verified.append(chunk)
289 else:
290 pending.extendleft(reversed(split_chunks))
291
292 return verified
293
294
295 def _compaction_input_tokens(agent, conversation: str) -> int:
296 system_prompt = agent.read_prompt("compact.sys.md")
297 user_prompt = agent.read_prompt("compact.msg.md", conversation=conversation)
298 return tokens.approximate_tokens(system_prompt) + tokens.approximate_tokens(
299 user_prompt
300 )
301
302
303 def _split_text_by_chars(text: str, target_chars: int) -> list[str]:
304 if not text:
305 return []
306
307 target_chars = max(int(target_chars), 1)
308 chunks: list[str] = []
309 start = 0
310 length = len(text)
311
312 while start < length:
313 end = min(start + target_chars, length)
314 if end < length:
315 floor = start + max((end - start) // 2, 1)
316 split_at = text.rfind("\n", floor, end)
317 if split_at == -1:
318 split_at = text.rfind(" ", floor, end)
319 if split_at > start:
320 end = split_at + 1
321
322 chunk = text[start:end]
323 if chunk:
324 chunks.append(chunk)
325 start = end
326
327 return chunks
328
329
330 async def get_compaction_stats(context) -> dict:
331 """
332 Get statistics about the current chat for the confirmation modal.
333
334 Returns:
335 dict with message_count, token_count, model_name
336 """
337 agent = context.agent0
338
339 # Count user-visible conversation turns only
340 # 'user' = user sent a message, 'response' = agent final response
341 # Other types (agent, tool, code_exe, etc.) are intermediate processing steps
342 visible_types = {"user", "response"}
343 message_count = sum(
344 1 for item in context.log.logs
345 if item.type in visible_types
346 )
347
348 # Estimate tokens
349 history_output = agent.history.output()
350 full_text = output_text(history_output, ai_label="assistant", human_label="user")
351 token_count = tokens.approximate_tokens(full_text) if full_text else 0
352
353 # Get model names for both chat and utility
354 chat_cfg = get_chat_model_config(agent)
355 utility_cfg = get_utility_model_config(agent)
356 chat_model_name = chat_cfg.get("name", "Default") if chat_cfg else "Default"
357 utility_model_name = utility_cfg.get("name", "Default") if utility_cfg else "Default"
358
359 return {
360 "message_count": message_count,
361 "token_count": token_count,
362 "model_name": chat_model_name,
363 "chat_model_name": chat_model_name,
364 "utility_model_name": utility_model_name,
365 }