super-code-assistant 3.3.6__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (61) hide show
  1. commands/__init__.py +859 -0
  2. core/__init__.py +0 -0
  3. core/config.py +263 -0
  4. core/config_template.json +7 -0
  5. core/context.py +271 -0
  6. core/engine.py +635 -0
  7. core/file_state.py +279 -0
  8. core/llm.py +309 -0
  9. core/model_capabilities.py +45 -0
  10. core/permissions.py +204 -0
  11. core/sandbox/__init__.py +15 -0
  12. core/sandbox/blacklist.py +176 -0
  13. core/sandbox/config.py +38 -0
  14. core/sandbox/network.py +136 -0
  15. core/sandbox/path_protection.py +126 -0
  16. core/session.py +295 -0
  17. core/tool.py +45 -0
  18. features/__init__.py +0 -0
  19. features/compact.py +945 -0
  20. features/coordinator.py +105 -0
  21. features/cost_tracker.py +184 -0
  22. features/extract_memories.py +326 -0
  23. features/find_relevant_memories.py +376 -0
  24. features/git_ai.py +256 -0
  25. features/memory.py +531 -0
  26. features/memory_age.py +66 -0
  27. features/memory_scan.py +153 -0
  28. features/memory_types.py +34 -0
  29. features/plan.py +327 -0
  30. features/skills.py +300 -0
  31. features/worker_manager.py +232 -0
  32. mcp/__init__.py +0 -0
  33. mcp/client.py +112 -0
  34. mcp/loader.py +80 -0
  35. mcp/tool_proxy.py +59 -0
  36. super_code_assistant-3.3.6.dist-info/METADATA +45 -0
  37. super_code_assistant-3.3.6.dist-info/RECORD +61 -0
  38. super_code_assistant-3.3.6.dist-info/WHEEL +5 -0
  39. super_code_assistant-3.3.6.dist-info/entry_points.txt +2 -0
  40. super_code_assistant-3.3.6.dist-info/top_level.txt +7 -0
  41. tools/__init__.py +21 -0
  42. tools/agent.py +132 -0
  43. tools/ask_user.py +111 -0
  44. tools/bash.py +77 -0
  45. tools/file_edit.py +269 -0
  46. tools/file_read.py +206 -0
  47. tools/file_write.py +78 -0
  48. tools/glob_tool.py +81 -0
  49. tools/grep_tool.py +134 -0
  50. tools/plan_tools.py +75 -0
  51. tools/skill.py +108 -0
  52. tools/tool.py +44 -0
  53. tools/web_fetch.py +129 -0
  54. tools/web_search.py +220 -0
  55. tui/__init__.py +0 -0
  56. tui/app.py +726 -0
  57. tui/clipboard_image.py +42 -0
  58. tui/keylistener.py +140 -0
  59. tui/prompt.py +752 -0
  60. tui/query.py +200 -0
  61. tui/rendering.py +135 -0
features/compact.py ADDED
@@ -0,0 +1,945 @@
1
+ """Context compression — summarise old messages to free token budget."""
2
+ from __future__ import annotations
3
+
4
+ import re
5
+ from typing import Any
6
+ from core.llm import LLMClient
7
+
8
+ # ---------------------------------------------------------------------------
9
+ # Constants
10
+ # ---------------------------------------------------------------------------
11
+
12
+ CHARS_PER_TOKEN = 3 # 非 CJK 字符的粗估比例;改3比4更保守,减少代码/JSON 场景低估
13
+ MIN_RECENT_MESSAGES = 6 # 至少保留最近 N 条消息不压缩
14
+ MIN_RECENT_TOKENS = 10_000 # 至少保留最近 N 个 token 不压缩
15
+ COMPACT_MAX_OUTPUT_TOKENS = 16_384 # 摘要最大输出 token 数
16
+
17
+ # ── 按模型计算自动压缩阈值(Step 6) ───────────────────────────────────────
18
+ # 简化版:触发阈值 = 模型 context window × COMPACT_TRIGGER_RATIO。
19
+ # 估算函数 estimate_tokens 已 CJK-aware(中文 1 字 ≈ 1 token),中文重的会话也能
20
+ # 被正确触发。
21
+ COMPACT_TRIGGER_RATIO = 0.8
22
+ DEFAULT_CONTEXT_WINDOW = 128_000 # 未识别模型的兜底窗口
23
+ # 已知模型 → context window 映射。匹配规则:先精确,再按 key 长度倒序做前缀匹配,
24
+ # 让 "gpt-4-32k-0613" 先命中 "gpt-4-32k" 而不是 "gpt-4"。
25
+ MODEL_CONTEXT_WINDOWS: dict[str, int] = {
26
+ # DeepSeek (V4 系列) - 全系标配 1M 上下文
27
+ "deepseek-v4": 1_000_000,
28
+ "deepseek-v4-pro": 1_000_000,
29
+
30
+ # GLM (智谱) - 保留 GLM-4-Plus,新增 GLM-4.7 系列,移除较小的 GLM-4 基础/轻量版
31
+ "glm-4-plus": 128_000, # 高端系列,窗口保持 128K
32
+ "glm-4.7": 200_000, # 最新旗舰模型
33
+ "glm-4.7-air": 200_000, # 轻量版但窗口相同
34
+ "glm-4.7-flash": 200_000, # 极速版但窗口相同
35
+
36
+ # GLM 5 系列(2026 官方文档实测:5/5.1/5-Turbo=200K,5.2/5.3=1M)
37
+ # 前缀匹配按 key 长度倒序:glm-5.3 / glm-5.2 先于 glm-5 命中,不会被误降级
38
+ "glm-5": 200_000, # 前缀覆盖 glm-5.1 / glm-5-turbo(官方均 200K,输出 128K)
39
+ "glm-5.2": 1_000_000, # 1M 旗舰
40
+ "glm-5.3": 1_000_000, # 1M 旗舰(API 即将上线)
41
+
42
+ # Claude (Anthropic) - 4.6 及以上版本支持 1M (beta)
43
+ "claude-opus-4-6": 1_000_000,
44
+ "claude-sonnet-4-6": 1_000_000,
45
+ "claude-haiku-4-6": 200_000,
46
+ }
47
+ # ── PTL(Prompt Too Long)兜底重试 ────────────────────────────────────────
48
+ # 当 summarizer 调用本身因输入过长被 API 拒绝时,从头部丢一段消息再重试,
49
+ # 最多 PTL_RETRY_MAX 次。避免用户被永久卡死(每次 autocompact 都拿同样过大的输入再撞)。
50
+ PTL_RETRY_MAX = 3
51
+ # 重试时在新输入头部插入的合成 marker:让 summarizer 知道前面有内容被截掉了,
52
+ # 同时保证首条消息是 user 角色(满足 OpenAI 协议要求)。
53
+ PTL_MARKER = "[earlier conversation truncated for compaction retry]"
54
+
55
+ # 压缩边界 marker:嵌入在 summary user 消息 content 开头,标识"此处是上一次压缩点"。
56
+ # 用纯文本嵌入而非 dict 字段:天然兼容会话持久化(content 是必持久化字段),且作为
57
+ # 普通文本发给 LLM API 也无副作用。下一次压缩时扫到 marker → 只总结 marker 之后的
58
+ # 增量对话,避免旧 summary 被反复套娃总结导致的信息逐次劣化。
59
+ COMPACT_BOUNDARY_MARKER = "<!-- COMPACT_BOUNDARY -->"
60
+
61
+ # 兼容老 session:在引入 marker 之前生成的 summary 消息以这个固定前缀开头,
62
+ # 也识别为边界,避免老 session 第一次重新压缩时仍然套娃。
63
+ _LEGACY_SUMMARY_PREFIX = "[This is a summary of the conversation so far"
64
+
65
+ # ── Tool-result 剪枝(先瘦身再决定是否摘要,2026-08-14 新增) ────────────────
66
+ # 上下文膨胀的元凶常常不是对话本身,而是几个超大工具输出(grep 全仓库 / 读大文件 /
67
+ # bash 大输出)。剪枝在摘要之前先把这些输出的"中间段"裁掉:纯本地零成本、不调 LLM、
68
+ # 保留头尾(开头通常是命令/摘要信息,结尾通常是错误或最终结果,中间多为重复噪音)。
69
+ # 很多场景剪完 token 已低于触发阈值,根本不需要走 LLM 摘要。
70
+ PRUNE_THRESHOLD_CHARS = 8192 # history 中 tool_result 文本超过该长度才剪(≈2-4K token)
71
+ PRUNE_HEAD_CHARS = 4096 # 保留头部字符数
72
+ PRUNE_TAIL_CHARS = 1024 # 保留尾部字符数
73
+ # recent 的剪枝阈值高于 history:recent 是"当前任务正在进行时"的上下文,模型可能
74
+ # 正基于完整输出工作(如刚 grep 完准备写代码)。只有真正的巨型输出(>32K 字符,
75
+ # 占 token 大头)才剪;中等输出(几千到 2 万字符、可能正被分析)保留完整。
76
+ PRUNE_RECENT_THRESHOLD_CHARS = 32768
77
+ # 剪枝标记:替换被裁掉的中间段,提示模型此处内容被截断(与 COMPACT_BOUNDARY_MARKER
78
+ # 同为纯文本嵌入,对 LLM 无副作用、对持久化天然兼容)
79
+ PRUNE_MARKER = "\n\n[... tool result middle pruned ...]\n\n"
80
+
81
+ # 三明治结构 prompt:
82
+ # - 首尾各嵌一遍"禁止调工具"硬指令(NO_TOOLS_PREAMBLE/TRAILER):summarizer 偶尔
83
+ # 不听话会试图调工具;本路径调用没启用 tools,模型若返回 tool_use 块会让我们
84
+ # 拿不到任何文本输出,整个压缩失败
85
+ # - 强制 <analysis> 草稿区 + <summary> 正式区:让模型先把思考过程写下来再写
86
+ # 正式总结,明显抬高 summary 质量;analysis 块不会进入下一轮 context(被
87
+ # _format_compact_summary 整段剥除)
88
+ # - "All user messages" 强制全列:防止模型主观漏掉早期但关键的用户约束
89
+ # - "Next Step verbatim" 强制引用原文:防止"下一步"被脑补成用户没说过的需求
90
+ COMPACT_PROMPT = """\
91
+ IMPORTANT: Do NOT call any tools or functions in your response. Output ONLY plain text.
92
+
93
+ Please provide a detailed summary of our conversation so far. This summary \
94
+ will replace the earlier messages to free up context space, so it must \
95
+ preserve every detail needed to continue the work seamlessly.
96
+
97
+ Before writing the final summary, work through your analysis inside an \
98
+ <analysis> block. The <analysis> block will be discarded — it exists only to \
99
+ help you produce a higher-quality <summary>. Then write the final summary \
100
+ inside a <summary> block using EXACTLY the section headers below.
101
+
102
+ <analysis>
103
+ Step through the conversation chronologically. Note every user message, every \
104
+ file touched, every error encountered, every decision made. Identify what is \
105
+ load-bearing for continuing the work. Be exhaustive — this draft is for you, \
106
+ not for the user.
107
+ </analysis>
108
+
109
+ <summary>
110
+ ## Primary Request and Intent
111
+ What the user is trying to accomplish overall.
112
+
113
+ ## Key Technical Concepts
114
+ Important technical details, patterns, frameworks, or constraints established.
115
+
116
+ ## Files and Code Sections
117
+ Key files discussed or modified, with brief notes on what was done to each. \
118
+ Quote short relevant code snippets where helpful.
119
+
120
+ ## Errors and Fixes
121
+ Any errors encountered and how they were resolved. Pay special attention to \
122
+ user feedback / corrections.
123
+
124
+ ## Problem Solving
125
+ Problems already solved + any ongoing troubleshooting.
126
+
127
+ ## All User Messages
128
+ List EVERY non-tool-result user message verbatim or near-verbatim, in order. \
129
+ Do NOT skip messages you consider unimportant — user intent is the most \
130
+ critical signal in this summary.
131
+
132
+ ## Pending Tasks
133
+ Outstanding work items the user explicitly asked for but that are not yet done.
134
+
135
+ ## Current Work
136
+ What was being worked on most recently and its current status.
137
+
138
+ ## Optional Next Step
139
+ The next concrete action. This MUST be a verbatim quote of the user's most \
140
+ recent request or instruction; if the user did not specify one, write \
141
+ "(no explicit next step)".
142
+ </summary>
143
+
144
+ REMINDER: Output ONLY plain text. Do NOT call any tools. Do NOT emit \
145
+ tool_use blocks. The entire response must be a single text message containing \
146
+ the <analysis> and <summary> blocks.\
147
+ """
148
+
149
+ COMPACT_SYSTEM = "You are a conversation summarizer. Produce a structured, detailed summary following the user's requested format."
150
+
151
+
152
+ # ---------------------------------------------------------------------------
153
+ # Summary 格式化:剥 <analysis>,提 <summary>
154
+ # ---------------------------------------------------------------------------
155
+
156
+ # 注意 re.DOTALL:让 . 跨行匹配;非贪婪 *? 防止把多个 block 误并成一个。
157
+ _ANALYSIS_RE = re.compile(r"<analysis>.*?</analysis>", re.DOTALL | re.IGNORECASE)
158
+ _SUMMARY_RE = re.compile(r"<summary>(.*?)</summary>", re.DOTALL | re.IGNORECASE)
159
+
160
+
161
+ def _format_compact_summary(raw: str) -> str:
162
+ """把 summarizer 的原始输出处理成最终摘要文本。
163
+
164
+ 处理顺序(每一步都带 fallback,确保任何输入形态都能产出非空字符串):
165
+ 1) 若存在 <summary>...</summary>:取闭包内文本(这是模型按格式输出的正常路径)
166
+ 2) 否则若存在 <analysis>...</analysis>:把 analysis 块整段删掉,剩下的当作摘要
167
+ (某些模型会忽略 <summary> 包装直接写正文)
168
+ 3) 否则原文返回(最坏情况:模型完全没听格式指令)
169
+ 最后压一下连续空行,让最终注入 context 的内容尽量干净。
170
+ """
171
+ text = raw or ""
172
+ m = _SUMMARY_RE.search(text)
173
+ if m:
174
+ body = m.group(1)
175
+ else:
176
+ # 没找到 <summary>:退而求其次,删 <analysis> 块(如果有)保留剩余
177
+ body = _ANALYSIS_RE.sub("", text)
178
+ # 多余空行压成最多一个空行
179
+ body = re.sub(r"\n{3,}", "\n\n", body).strip()
180
+ return body
181
+
182
+
183
+ # ---------------------------------------------------------------------------
184
+ # PTL helpers
185
+ # ---------------------------------------------------------------------------
186
+
187
+ # 常见 prompt-too-long / context length 的判定关键词(小写匹配)。
188
+ # OpenAI 的 BadRequestError 消息里通常含 "context_length_exceeded" 或 "maximum context length";
189
+ # 兼容供应商(DeepSeek/Moonshot 等)措辞略有差异,这里用关键词模糊命中。
190
+ _PTL_KEYWORDS = (
191
+ "context_length_exceeded",
192
+ "maximum context length",
193
+ "prompt is too long",
194
+ "too many tokens",
195
+ "context length",
196
+ "request too large",
197
+ "exceeds max length",
198
+ )
199
+
200
+
201
+ def _is_ptl_error(exc: BaseException) -> bool:
202
+ """识别一次 LLM 调用异常是否为 PTL(输入过长)。
203
+
204
+ 判定策略保守:只看异常字符串是否包含已知关键词。其它 4xx/5xx 不当 PTL,
205
+ 避免把鉴权失败 / 限流 / 网络错误也当 PTL 反复丢消息重试。
206
+ """
207
+ msg = str(exc).lower()
208
+ return any(k in msg for k in _PTL_KEYWORDS)
209
+
210
+
211
+ class EmptySummaryError(Exception):
212
+ """摘要 LLM 返回空文本(思考模型偶发把全部输出放进 reasoning_content、
213
+ content 为空),重试后仍为空。压缩必须中止而不是拿空摘要覆盖历史。"""
214
+
215
+
216
+ # 空摘要重试次数:DeepSeek 等思考模型 content 为空多为偶发,重试 1 次成功率高;
217
+ # 仍空则抛 EmptySummaryError 由调用方中止压缩(历史保持原样)。
218
+ EMPTY_SUMMARY_RETRY_MAX = 1
219
+
220
+
221
+ def _drop_head_for_ptl(messages: list[dict]) -> list[dict] | None:
222
+ """从消息列表头部丢一段,重试更小的输入。返回截断后的新列表;无法再截则返回 None。
223
+
224
+ 策略(最小可用版本):
225
+ - 一次丢约 20% 的消息,至少丢 1 条
226
+ - 不允许把列表丢空:至少保留 1 条业务消息(再加上压缩 prompt 那条 user)
227
+ - 头部插入 PTL_MARKER 合成 user,保证首条是 user 角色
228
+ 注意:messages 末尾那条是压缩 prompt(user),永不丢;只动业务部分。
229
+ """
230
+ if len(messages) <= 2:
231
+ # 只剩 1 条业务 + 1 条 prompt(或更少),再丢就没意义
232
+ return None
233
+ # 末尾的 prompt 不动;业务部分 = messages[:-1]
234
+ body = messages[:-1]
235
+ drop = max(1, len(body) // 5)
236
+ if drop >= len(body):
237
+ # 至少保留一条业务消息
238
+ drop = len(body) - 1
239
+ if drop <= 0:
240
+ return None
241
+ truncated_body = body[drop:]
242
+ # 头部插合成 user marker:(1) 满足"首条必须是 user";(2) 让 summarizer 知道前面被截
243
+ new_messages = [{"role": "user", "content": PTL_MARKER}] + truncated_body + [messages[-1]]
244
+ # 再过一遍交替修正,避免合成 marker 之后正好接一条 user 造成连续同角色
245
+ return _fix_alternation(new_messages)
246
+
247
+
248
+ # ---------------------------------------------------------------------------
249
+ # Helpers
250
+ # ---------------------------------------------------------------------------
251
+
252
+ def _text_of(content: Any) -> str:
253
+ """从消息 content 中提取纯文本(兼容 str、list of blocks 等格式)。"""
254
+ if isinstance(content, str):
255
+ return content
256
+ if isinstance(content, list):
257
+ parts: list[str] = []
258
+ for block in content:
259
+ if isinstance(block, dict):
260
+ parts.append(block.get("text", ""))
261
+ c = block.get("content", "")
262
+ if isinstance(c, str):
263
+ parts.append(c)
264
+ elif isinstance(c, list):
265
+ # tool_result content 可能是 block 列表,如 [{"type":"text","text":"..."}]
266
+ for cb in c:
267
+ if isinstance(cb, dict):
268
+ parts.append(cb.get("text", ""))
269
+ parts.append(str(block.get("input", "")))
270
+ elif hasattr(block, "text"):
271
+ parts.append(getattr(block, "text", ""))
272
+ return " ".join(parts)
273
+ return str(content) if content else ""
274
+
275
+
276
+ # CJK 字符(中日韩)按 1 token 计;其它字符走 chars/4。修复 chars/4 对中文 4 倍
277
+ # 低估的 bug —— 中文重的会话不会因为估算偏小永远到不了阈值。
278
+ _CJK_RE = re.compile(r"[一-鿿぀-ヿ가-힯豈-﫿]")
279
+
280
+ # 多模态:OpenAI 高细节图片 token 公式(Aider 同款,models.py:672-701)
281
+ _IMG_TOKEN_TILE = 170 # 每 512×512 tile
282
+ _IMG_TOKEN_FIXED = 85 # 固定开销
283
+ _IMG_TOKEN_FALLBACK = 1100 # 尺寸解析失败兜底(近似 512×512 高细节)
284
+
285
+
286
+ def _cjk_token_count(text: str) -> int:
287
+ if not text:
288
+ return 0
289
+ cjk = len(_CJK_RE.findall(text))
290
+ return cjk + (len(text) - cjk) // CHARS_PER_TOKEN
291
+
292
+
293
+ def _estimate_image_tokens(block: dict) -> int:
294
+ """按 OpenAI 高细节公式估算图片 token:tiles × 170 + 85。
295
+
296
+ 尺寸来源:零依赖解析 PNG IHDR / JPEG SOF(不引 Pillow);解析失败走固定兜底。
297
+ """
298
+ source = block.get("source", {}) or {}
299
+ data = source.get("data", "")
300
+ if not isinstance(data, str) or not data:
301
+ return _IMG_TOKEN_FALLBACK
302
+ try:
303
+ import base64 as _b64
304
+ raw = _b64.b64decode(data[:8192]) # 只解码前 8KB 足够拿到头部尺寸
305
+ except Exception:
306
+ return _IMG_TOKEN_FALLBACK
307
+ dims = _image_size_from_bytes(raw)
308
+ if dims is None:
309
+ return _IMG_TOKEN_FALLBACK
310
+ width, height = dims
311
+ # ① 超 2048 缩放 ② 短边缩到 768(与 Aider 一致)
312
+ max_dim = max(width, height)
313
+ if max_dim > 2048:
314
+ scale = 2048 / max_dim
315
+ width, height = int(width * scale), int(height * scale)
316
+ min_dim = min(width, height)
317
+ if min_dim > 0:
318
+ scale = 768 / min_dim
319
+ width, height = int(width * scale), int(height * scale)
320
+ import math
321
+ tiles = math.ceil(width / 512) * math.ceil(height / 512)
322
+ return tiles * _IMG_TOKEN_TILE + _IMG_TOKEN_FIXED
323
+
324
+
325
+ def _image_size_from_bytes(data: bytes) -> tuple[int, int] | None:
326
+ """零依赖解析图片尺寸:PNG 读 IHDR、JPEG 扫 SOF 段。失败返回 None。"""
327
+ if data[:8] == b"\x89PNG\r\n\x1a\n" and len(data) >= 24:
328
+ import struct
329
+ w, h = struct.unpack(">II", data[16:24])
330
+ return w, h
331
+ if data[:2] == b"\xff\xd8": # JPEG
332
+ import struct
333
+ i = 2
334
+ while i + 9 < len(data):
335
+ if data[i] != 0xFF:
336
+ i += 1
337
+ continue
338
+ marker = data[i + 1]
339
+ if marker in (0xC0, 0xC1, 0xC2, 0xC3): # SOF0/1/2/3
340
+ h, w = struct.unpack(">HH", data[i + 5:i + 9])
341
+ return w, h
342
+ if marker in (0xD9, 0xDA): # EOI / SOS:SOF 已过或缺失
343
+ break
344
+ seg_len = struct.unpack(">H", data[i + 2:i + 4])[0]
345
+ if seg_len < 2:
346
+ break
347
+ i += 2 + seg_len
348
+ return None
349
+
350
+
351
+ def estimate_tokens(messages: list[dict]) -> int:
352
+ """粗略估算 token 数:CJK 字符按 1:1,其它字符按 1:CHARS_PER_TOKEN。
353
+
354
+ 多模态:message content 为 block 列表时逐 block 计——text 走 CJK 规则、
355
+ image 按 OpenAI 高细节公式(缺失此项会漏算图片 token、压缩触发偏晚)。
356
+ """
357
+ total = 0
358
+ for m in messages:
359
+ content = m.get("content", "")
360
+ if isinstance(content, str):
361
+ total += _cjk_token_count(content)
362
+ elif isinstance(content, list):
363
+ for block in content:
364
+ if not isinstance(block, dict):
365
+ continue
366
+ btype = block.get("type")
367
+ if btype == "text":
368
+ total += _cjk_token_count(block.get("text", ""))
369
+ elif btype == "image":
370
+ total += _estimate_image_tokens(block)
371
+ elif btype == "tool_result":
372
+ total += _cjk_token_count(_text_of(block.get("content", "")))
373
+ else:
374
+ total += _cjk_token_count(_text_of(block))
375
+ return total
376
+
377
+
378
+ def get_context_window(model: str | None) -> int:
379
+ """返回模型的 context window;未识别模型走 DEFAULT_CONTEXT_WINDOW。
380
+
381
+ 匹配顺序:精确 → 最长前缀。最长前缀避免 "gpt-4-32k-0613" 被 "gpt-4" 误命中。
382
+ """
383
+ if not model:
384
+ return DEFAULT_CONTEXT_WINDOW
385
+ if model in MODEL_CONTEXT_WINDOWS:
386
+ return MODEL_CONTEXT_WINDOWS[model]
387
+ # 按 key 长度倒序做前缀匹配(先具体后宽松)
388
+ for key in sorted(MODEL_CONTEXT_WINDOWS.keys(), key=len, reverse=True):
389
+ if model.startswith(key):
390
+ return MODEL_CONTEXT_WINDOWS[key]
391
+ return DEFAULT_CONTEXT_WINDOW
392
+
393
+
394
+ def should_compact(messages: list[dict], model: str | None = None,
395
+ last_input_tokens: int | None = None) -> bool:
396
+ """判断是否需要自动压缩对话。阈值按模型 context window 的 COMPACT_TRIGGER_RATIO 计算。"""
397
+ # 阈值下限是 MIN_RECENT_TOKENS:避免极小窗口模型(如 gpt-4 8K)算出的阈值太低
398
+ # 导致每轮都触发压缩 → 死循环。
399
+ threshold = max(
400
+ int(get_context_window(model) * COMPACT_TRIGGER_RATIO),
401
+ MIN_RECENT_TOKENS,
402
+ )
403
+ return estimate_tokens(messages) > threshold
404
+
405
+
406
+ # ---------------------------------------------------------------------------
407
+ # Compact boundary 识别
408
+ # ---------------------------------------------------------------------------
409
+
410
+ def _is_compact_boundary(msg: dict) -> bool:
411
+ """判断一条消息是否为压缩边界(上一次压缩生成的 summary user 消息)。
412
+
413
+ 检测规则:
414
+ - 必须是 user 消息
415
+ - content 提取的纯文本去掉前导空白后,以 COMPACT_BOUNDARY_MARKER 开头
416
+ 或者以老格式 summary 前缀开头(兼容引入 marker 之前生成的 session)
417
+ """
418
+ if msg.get("role") != "user":
419
+ return False
420
+ text = _text_of(msg.get("content", "")).lstrip()
421
+ return text.startswith(COMPACT_BOUNDARY_MARKER) or text.startswith(_LEGACY_SUMMARY_PREFIX)
422
+
423
+
424
+ def _find_last_boundary_index(messages: list[dict]) -> int:
425
+ """返回最后一个 boundary 消息的下标;不存在返回 -1。倒序扫描,命中即返回。"""
426
+ for i in range(len(messages) - 1, -1, -1):
427
+ if _is_compact_boundary(messages[i]):
428
+ return i
429
+ return -1
430
+
431
+
432
+ def get_messages_after_compact_boundary(messages: list[dict]) -> list[dict]:
433
+ """返回最后一次压缩边界之后的"增量"消息(不含 boundary user 消息本身及其紧随的 ack)。
434
+
435
+ 用途:调用方需要单独看"自上次压缩以来新增了什么"时使用。
436
+ 若不存在 boundary,返回完整列表的浅拷贝。
437
+
438
+ 边界结构约定:[..., boundary_user(=summary), ack_assistant, 增量对话 ...]
439
+ 因此跳过 2 条(boundary + ack)。若 boundary 后没有 ack(极端情况),跳 1 条。
440
+ """
441
+ idx = _find_last_boundary_index(messages)
442
+ if idx < 0:
443
+ return list(messages)
444
+ # boundary 紧随其后通常是我们写死的 ack assistant;跳过 boundary + ack
445
+ skip = 2
446
+ if idx + 1 >= len(messages) or messages[idx + 1].get("role") != "assistant":
447
+ skip = 1
448
+ return list(messages[idx + skip:])
449
+
450
+
451
+ # ---------------------------------------------------------------------------
452
+ # Message splitting
453
+ # ---------------------------------------------------------------------------
454
+
455
+ def _split_recent(messages: list[dict]) -> tuple[list[dict], list[dict]]:
456
+ """将消息切分为 (待压缩的历史部分, 需要保留的最近消息)。"""
457
+ if len(messages) <= MIN_RECENT_MESSAGES:
458
+ return [], list(messages)
459
+
460
+ keep_start = len(messages)
461
+ kept_tokens = 0
462
+ kept_msgs = 0
463
+
464
+ for i in range(len(messages) - 1, -1, -1):
465
+ # 与 estimate_tokens 同套 CJK-aware 规则,避免中文会话保留过少消息
466
+ kept_tokens += estimate_tokens([messages[i]])
467
+ kept_msgs += 1
468
+ keep_start = i
469
+ if kept_msgs >= MIN_RECENT_MESSAGES and kept_tokens >= MIN_RECENT_TOKENS:
470
+ break
471
+
472
+ # 不拆分 tool_use / tool_result 对:如果 keep_start 前一条 assistant 消息含
473
+ # tool_use,则把它也纳入保留范围。改为检查前一条 assistant 而非当前 user 的
474
+ # block 类型:原 all() 检查在 user 含混合 block(_fix_alternation 合并后)或
475
+ # string content 时会漏检,导致 assistant(tool_use) 留在 history 里、对应的
476
+ # tool_result 在 recent 里 → API 400 "insufficient tool messages"。
477
+ # while 循环处理连续嵌套的 tool_use/tool_result 对(如 A1(tool_use), U1(result),
478
+ # A2(tool_use) 都在 split 边界时)。
479
+ while keep_start > 0:
480
+ prev_msg = messages[keep_start - 1]
481
+ if prev_msg.get("role") != "assistant":
482
+ break
483
+ prev_content = prev_msg.get("content", "")
484
+ if not (isinstance(prev_content, list) and any(
485
+ isinstance(b, dict) and b.get("type") == "tool_use" for b in prev_content
486
+ )):
487
+ break
488
+ keep_start -= 1
489
+
490
+ return messages[:keep_start], messages[keep_start:]
491
+
492
+
493
+ # ---------------------------------------------------------------------------
494
+ # Tool-result 剪枝
495
+ # ---------------------------------------------------------------------------
496
+
497
+ def _prune_text(text: str, threshold_chars: int = PRUNE_THRESHOLD_CHARS) -> str | None:
498
+ """单条 tool_result 文本剪枝:超过阈值则保留头尾 + 剪枝标记,否则返回 None。
499
+
500
+ 纯本地确定性操作:不调 LLM、不改消息结构,只替换超长文本的中间段。
501
+ Python str 按 Unicode code point 计长与切片,不会拆散 surrogate pair。
502
+ threshold_chars 可传入不同阈值(recent 用更高阈值,见 PRUNE_RECENT_THRESHOLD_CHARS)。
503
+ """
504
+ if len(text) <= threshold_chars:
505
+ return None
506
+ return text[:PRUNE_HEAD_CHARS] + PRUNE_MARKER + text[-PRUNE_TAIL_CHARS:]
507
+
508
+
509
+ def prune_tool_results(messages: list[dict],
510
+ threshold_chars: int = PRUNE_THRESHOLD_CHARS) -> tuple[list[dict], dict]:
511
+ """剪掉 messages 中所有超长 tool_result 的中间段,返回 (新消息列表, 统计)。
512
+
513
+ 只处理 user 消息 content 里 type == "tool_result" 的 block,其余消息/block
514
+ 原样保留——消息条数、角色交替、tool_use/tool_result 配对全部不变,
515
+ 不会触发任何 API 400 防御逻辑(_fix_alternation / _to_openai_messages 无感)。
516
+
517
+ 被剪的 block 是重建的新 dict(浅拷贝 + 替换 content),不污染调用方的原始消息,
518
+ 因此调用方(compact 的 history 切片共享 engine._messages 的 dict)也安全。
519
+
520
+ threshold_chars:超过该长度的 tool_result 文本才剪。调用方按场景区分——
521
+ history 用默认 8192(旧对话,剪了损失小);recent 用更高的
522
+ PRUNE_RECENT_THRESHOLD_CHARS(当前任务可能正依赖完整输出)。
523
+
524
+ 返回的统计 dict:{"pruned": 剪了几条, "chars_removed": 共省多少字符}。
525
+ """
526
+ pruned_count = 0
527
+ chars_removed = 0
528
+ out: list[dict] = []
529
+ for msg in messages:
530
+ content = msg.get("content", "")
531
+ # 快速跳过:不含 tool_result block 的消息原样保留
532
+ if not (isinstance(content, list) and any(
533
+ isinstance(b, dict) and b.get("type") == "tool_result" for b in content
534
+ )):
535
+ out.append(msg)
536
+ continue
537
+ new_blocks: list[Any] = []
538
+ changed = False
539
+ for block in content:
540
+ if not isinstance(block, dict) or block.get("type") != "tool_result":
541
+ new_blocks.append(block) # 非 tool_result block(text/image 等)原样保留
542
+ continue
543
+ text = block.get("content", "")
544
+ if not isinstance(text, str):
545
+ new_blocks.append(block) # 非 str content(异常形态)不剪,防御性跳过
546
+ continue
547
+ pruned = _prune_text(text, threshold_chars)
548
+ if pruned is None:
549
+ new_blocks.append(block)
550
+ continue
551
+ # 重建 block:浅拷贝 + 替换 content,保留 tool_use_id/is_error/metadata 等字段
552
+ new_block = dict(block)
553
+ new_block["content"] = pruned
554
+ new_blocks.append(new_block)
555
+ changed = True
556
+ pruned_count += 1
557
+ chars_removed += len(text) - len(pruned)
558
+ if changed:
559
+ new_msg = dict(msg)
560
+ new_msg["content"] = new_blocks
561
+ out.append(new_msg)
562
+ else:
563
+ out.append(msg)
564
+ stats = {"pruned": pruned_count, "chars_removed": chars_removed}
565
+ return out, stats
566
+
567
+
568
+ # ---------------------------------------------------------------------------
569
+ # 过期 Read 结果回收(stale reclamation)
570
+ # ---------------------------------------------------------------------------
571
+ # 与剪枝(长度维度)不同,这里是"时效维度"的确定性回收:Read 工具的结果若
572
+ # 对应的文件在会话中被后续 Edit/Write 修改过(file_state 版本号已升高),
573
+ # 则旧版本内容对模型已无用(还可能误导它基于旧代码思考),替换为短标记文本。
574
+ # 判定完全基于 file_state 的版本号,不涉及语义猜测 → 零误伤。
575
+
576
+ STALE_READ_MARKER = (
577
+ "[此文件已被后续编辑修改,以上内容为旧版本。"
578
+ "需要最新内容时请用 Read 工具重新读取该文件,或使用 offset/limit 读取指定行区间]"
579
+ )
580
+
581
+
582
+ def reclaim_stale_read_results(messages: list[dict], session_id: str) -> tuple[list[dict], dict]:
583
+ """回收"文件已被后续编辑修改"的过期 Read tool_result,返回 (新消息列表, 统计)。
584
+
585
+ 只处理 user 消息 content 中 type == "tool_result" 且 metadata 带 snippet_id
586
+ 的 block(即 Read 工具的产物,见 tools/file_read.py 的 snippet_meta)。
587
+ 判定条件:file_state 中该文件的当前 version > snippet 创建时的 file_version
588
+ (文件被 Edit/Write 修改过)→ 该 block 的 content 替换为短标记文本。
589
+
590
+ 保留项(配对/结构安全):
591
+ - block 的 type / tool_use_id / is_error / metadata 字段原样保留
592
+ - 消息条数、角色交替、tool_use ↔ tool_result 配对全部不变
593
+ - 标记文本内含 snippet_id + 行范围,模型需要时可重新 Read / offset-limit 读取
594
+ 不污染输入:重建 block(浅拷贝 + 替换 content),不修改调用方的原始消息。
595
+
596
+ fail-closed:无 metadata / 查不到 snippet / 版本不匹配异常 → 一律不回收。
597
+ 统计:{"reclaimed": 回收几条, "chars_removed": 共省多少字符}。
598
+ """
599
+ from core.file_state import get_file_version, get_snippet
600
+
601
+ reclaimed_count = 0
602
+ chars_removed = 0
603
+ out: list[dict] = []
604
+ for msg in messages:
605
+ content = msg.get("content", "")
606
+ # 快速跳过:不含 tool_result block 的消息原样保留
607
+ if not (isinstance(content, list) and any(
608
+ isinstance(b, dict) and b.get("type") == "tool_result" for b in content
609
+ )):
610
+ out.append(msg)
611
+ continue
612
+ new_blocks: list[Any] = []
613
+ changed = False
614
+ for block in content:
615
+ if not isinstance(block, dict) or block.get("type") != "tool_result":
616
+ new_blocks.append(block) # 非 tool_result block 原样保留
617
+ continue
618
+ meta = block.get("metadata")
619
+ snippet_id = None
620
+ if isinstance(meta, dict):
621
+ snippet_id = meta.get("snippet_id")
622
+ if not snippet_id:
623
+ new_blocks.append(block) # 无 snippet 凭证(Bash/Grep 等)→ 不回收
624
+ continue
625
+ snippet = get_snippet(session_id, snippet_id)
626
+ if snippet is None:
627
+ new_blocks.append(block) # 查不到(/resume 未重建等)→ fail-closed
628
+ continue
629
+ if get_file_version(session_id, snippet.file_path) <= snippet.file_version:
630
+ new_blocks.append(block) # 文件未被修改 → 内容仍有效,保留
631
+ continue
632
+ # 文件已被后续编辑:替换 content 为短标记(保留定位信息)
633
+ text = block.get("content", "")
634
+ if isinstance(text, str):
635
+ chars_removed += len(text)
636
+ start = meta.get("start_line") or snippet.start_line
637
+ end = meta.get("end_line") or snippet.end_line
638
+ scope = meta.get("scope_type") or snippet.scope_type
639
+ marker = (
640
+ f"[snippet_id: {snippet_id} | lines: {start}-{end} | scope: {scope}]\n"
641
+ + STALE_READ_MARKER
642
+ )
643
+ new_block = dict(block)
644
+ new_block["content"] = marker
645
+ new_blocks.append(new_block)
646
+ changed = True
647
+ reclaimed_count += 1
648
+ if changed:
649
+ new_msg = dict(msg)
650
+ new_msg["content"] = new_blocks
651
+ out.append(new_msg)
652
+ else:
653
+ out.append(msg)
654
+ stats = {"reclaimed": reclaimed_count, "chars_removed": chars_removed}
655
+ return out, stats
656
+
657
+
658
+ # ---------------------------------------------------------------------------
659
+ # CompactService
660
+ # ---------------------------------------------------------------------------
661
+
662
+ class CompactService:
663
+ """通过 API 摘要压缩对话上下文。"""
664
+
665
+ def __init__(self, client: LLMClient, model: str,
666
+ cost_tracker=None):
667
+ self._client = client
668
+ self._model = model
669
+ self._cost_tracker = cost_tracker
670
+
671
+ def compact(
672
+ self,
673
+ messages: list[dict],
674
+ system_prompt: str,
675
+ custom_instructions: str = "",
676
+ attachments: list[dict] | None = None,
677
+ skip_if_under_threshold: bool = False,
678
+ ) -> tuple[list[dict], str]:
679
+ """压缩 messages,返回 (new_messages, summary_text)。
680
+
681
+ skip_if_under_threshold:True 时,若剪枝后总 token 已低于自动压缩触发阈值
682
+ (should_compact 判定),则跳过 LLM 摘要、直接返回剪枝结果——"先瘦身再摘要",
683
+ 很多场景剪完根本不需要摘要(零 LLM 调用、零成本)。False 时保持旧行为
684
+ (永远走摘要),供需要强制摘要的调用方使用。
685
+
686
+ 返回的消息列表结构:
687
+ [frozen_prefix(含历次旧边界及其 ack), user: 新边界+摘要, assistant: 确认,
688
+ 最近消息 …, 重注入 attachments …]
689
+
690
+ 关键设计:本次压缩只针对"上一次边界之后"的增量对话做总结。历次旧 summary 及其
691
+ ack 作为 frozen_prefix 原封不动地保留下来。这样可避免多次压缩时旧 summary 被
692
+ 反复再总结导致的信息逐次劣化。
693
+
694
+ attachments:调用方按需构造的"压缩后状态恢复"消息(如 plan reminder、
695
+ worker 状态等),追加在 recent 之后。每条必须是合法 message dict
696
+ (含 role/content)。下次压缩时这些消息会被 `_find_last_boundary_index`
697
+ 视为 boundary 之后的普通消息,但因紧贴上次 boundary、且 _split_recent
698
+ 优先保留尾部,正常情况下会进入 recent 而非被重新总结。
699
+ """
700
+ # 1) 切出 frozen_prefix(旧边界及之前) 和 active(边界之后的增量)
701
+ boundary_idx = _find_last_boundary_index(messages)
702
+ if boundary_idx >= 0:
703
+ # boundary 之后我们写死了一条 assistant ack;frozen_prefix 含 ack 一起冻结
704
+ ack_present = (
705
+ boundary_idx + 1 < len(messages)
706
+ and messages[boundary_idx + 1].get("role") == "assistant"
707
+ )
708
+ frozen_end = boundary_idx + 2 if ack_present else boundary_idx + 1
709
+ frozen_prefix = list(messages[:frozen_end])
710
+ active = list(messages[frozen_end:])
711
+ else:
712
+ frozen_prefix = []
713
+ active = list(messages)
714
+
715
+ # 2) 在 active 内部切出 (history, recent)
716
+ history, recent = _split_recent(active)
717
+
718
+ if not history:
719
+ # 增量太少,没东西可总结:保持 messages 不变
720
+ return list(messages), "(nothing to compact)"
721
+
722
+ # 2.5) Tool-result 剪枝:摘要前先裁掉超长工具输出的中间段(先瘦身再摘要)。
723
+ # history 与 recent 都要剪,但阈值不同:
724
+ # - history 用默认阈值 8192 → 摘要 LLM 的输入瘦身(省钱)
725
+ # - recent 用高阈值 32768 → 只剪真正的巨型输出(当前任务可能正依赖
726
+ # 完整输出,如刚 grep 完准备写代码;中型输出保留完整)
727
+ # 剪枝是纯本地零成本操作;剪完若已低于自动压缩触发阈值,直接跳过 LLM 摘要
728
+ # 返回剪枝结果——很多"上下文膨胀"的元凶只是几个超大工具输出(常在 recent 里,
729
+ # _split_recent 按 token 保留尾部会把它们留在 recent),剪完根本不需要摘要。
730
+ # skip_if_under_threshold 由自动路径(轮间/轮内)传 True。
731
+ pruned_history, prune_stats = prune_tool_results(history)
732
+ pruned_recent, recent_stats = prune_tool_results(
733
+ recent, threshold_chars=PRUNE_RECENT_THRESHOLD_CHARS)
734
+ total_pruned = prune_stats["pruned"] + recent_stats["pruned"]
735
+ if total_pruned > 0 and skip_if_under_threshold \
736
+ and not should_compact(
737
+ frozen_prefix + pruned_history + pruned_recent, self._model):
738
+ summary_note = (
739
+ f"(prune only: {total_pruned} tool result(s) trimmed, "
740
+ f"{prune_stats['chars_removed'] + recent_stats['chars_removed']:,} "
741
+ "chars removed — context under threshold, no summary needed)"
742
+ )
743
+ return list(frozen_prefix) + pruned_history + pruned_recent, summary_note
744
+ history = pruned_history
745
+ recent = pruned_recent
746
+
747
+ prompt = COMPACT_PROMPT
748
+ if custom_instructions:
749
+ prompt += f"\n\nAdditional instructions: {custom_instructions}"
750
+
751
+ # 去除图片/文档以节省 token
752
+ cleaned = _strip_media(history)
753
+ # 防止 history 末尾若是 user(含 tool_result) 与下面追加的 prompt user 被
754
+ # _fix_alternation 合并:合并后这条 user.content 会变成 [tool_result_block,
755
+ # text_block] 混合结构,下游 _to_openai_messages 走多模态分支静默丢弃
756
+ # tool_result,前一条 assistant(tool_use) 找不到响应 → API 返回 400
757
+ # "insufficient tool messages following tool_calls message"。
758
+ # 插入一条短 ack assistant 隔开即可,对 summary 质量无可见影响。
759
+ if cleaned and cleaned[-1].get("role") == "user":
760
+ cleaned.append({"role": "assistant", "content": "Acknowledged."})
761
+ cleaned.append({"role": "user", "content": prompt}) # 然后把压缩的prompt作为user消息提供给ai
762
+
763
+ # 确保首条消息是 user 角色,否则会报错
764
+ if cleaned and cleaned[0].get("role") != "user":
765
+ cleaned.insert(0, {"role": "user", "content": "(conversation start)"})
766
+
767
+ cleaned = _fix_alternation(cleaned)
768
+
769
+ # PTL 兜底:summarizer 调用自身可能因输入过长被 API 拒(典型异常含
770
+ # "context_length_exceeded")。捕获后从头部丢一段重试,最多 PTL_RETRY_MAX 次。
771
+ # 不在此处吞其它异常(鉴权、网络、限流等),避免把无关错误误判成 PTL 反复丢消息。
772
+ # 失败终态:把最后一次异常抛出去,由调用方(tui/app.py 的熔断器或 _cmd_compact)处理。
773
+ # 空摘要兜底:思考模型偶发 content 为空(输出全在 reasoning_content)。
774
+ # 不能像以前那样用 "(compact produced empty summary)" 继续落盘——那会把整个
775
+ # history 覆盖成一行空摘要、信息全丢(2026-08-16 实测事故)。重试
776
+ # EMPTY_SUMMARY_RETRY_MAX 次仍空则抛 EmptySummaryError,由调用方中止压缩。
777
+ attempt = 0
778
+ empty_retries = 0
779
+ while True:
780
+ try:
781
+ response = self._client.create(
782
+ model=self._model,
783
+ max_tokens=COMPACT_MAX_OUTPUT_TOKENS,
784
+ system=COMPACT_SYSTEM,
785
+ messages=cleaned,
786
+ strip_thinking=True, # 摘要不需要推理:剥离思考参数,防 content 为空
787
+ )
788
+ except Exception as e:
789
+ if not _is_ptl_error(e) or attempt >= PTL_RETRY_MAX:
790
+ raise
791
+ truncated = _drop_head_for_ptl(cleaned)
792
+ if truncated is None:
793
+ # 已经无法再丢(业务消息只剩一条),抛出让上层处理
794
+ raise
795
+ cleaned = truncated
796
+ attempt += 1
797
+ continue
798
+
799
+ # 提取摘要文本
800
+ summary_text = ""
801
+ for block in response.content:
802
+ if isinstance(block, dict) and block.get("type") == "text":
803
+ summary_text += block.get("text", "")
804
+ elif hasattr(block, "text"):
805
+ summary_text += block.text
806
+
807
+ # Step 4:剥 <analysis> 草稿块、提取 <summary> 正式块。
808
+ # _format_compact_summary 内部带多级 fallback,对任何输入形态都不会返回 None。
809
+ summary_text = _format_compact_summary(summary_text)
810
+
811
+ if summary_text.strip():
812
+ break
813
+ if empty_retries >= EMPTY_SUMMARY_RETRY_MAX:
814
+ raise EmptySummaryError(
815
+ "摘要 LLM 连续返回空文本(content 为空,思考模型常见),"
816
+ "压缩已中止——历史保持原样,请重试"
817
+ )
818
+ empty_retries += 1
819
+
820
+ # 3) 拼装:frozen_prefix + [新边界 summary, ack] + recent
821
+ # COMPACT_BOUNDARY_MARKER 必须放在 content 最开头(_is_compact_boundary 用 lstrip 后
822
+ # startswith 判定),否则下次压缩识别不到边界。
823
+ new_summary_content = (
824
+ f"{COMPACT_BOUNDARY_MARKER}\n\n"
825
+ "[This is a summary of the conversation so far — "
826
+ "the original messages have been compacted to save context space.]\n\n"
827
+ + summary_text
828
+ )
829
+ new_messages: list[dict] = list(frozen_prefix)
830
+ new_messages.append({
831
+ "role": "user",
832
+ "content": new_summary_content,
833
+ })
834
+ new_messages.append({
835
+ "role": "assistant",
836
+ "content": (
837
+ "Understood. I've reviewed the conversation summary and I'm "
838
+ "ready to continue from where we left off."
839
+ ),
840
+ })
841
+ new_messages.extend(recent)
842
+ # Step 7-A:把"压缩后状态恢复"附件追加到 recent 之后。
843
+ # 仅追加合法 dict(含 role/content),其它静默跳过,避免上层构造错误连锁影响压缩主流程。
844
+ # 角色交替守护:附件全是 user 角色,若直接追加会和 recent 末尾的 user 消息
845
+ # (典型场景:tool_result 收尾的轮次、resume 的脏 session)连成两条 user,
846
+ # 触发部分 API 的 400 报错。每次追加前检查上一条 role,撞了就先插一条短 ack
847
+ # assistant 隔开。不调用 _fix_alternation:它会合并 content 改变类型,对含
848
+ # tool_use/tool_result 的复杂消息有副作用。
849
+ if attachments:
850
+ for att in attachments:
851
+ if not (isinstance(att, dict) and "role" in att and "content" in att):
852
+ continue
853
+ if new_messages and new_messages[-1].get("role") == att.get("role"):
854
+ new_messages.append({
855
+ "role": "assistant" if att.get("role") == "user" else "user",
856
+ "content": "Acknowledged.",
857
+ })
858
+ new_messages.append(att)
859
+
860
+ # Step 8:压缩成功后更新 cost_tracker:
861
+ # 1) 记账本次摘要调用的 token/费用(此前非流式 create 不返回 usage,/cost 漏记);
862
+ # 2) 覆盖 last_input_tokens 为压缩后新历史的估算值——摘要请求的 input 是压缩前的
863
+ # 旧历史,add_usage 会记成旧值导致底部栏 ctx 占用率虚高,下一轮对话再纠正为精确值。
864
+ if self._cost_tracker and response.usage:
865
+ self._cost_tracker.add_usage(self._model, response.usage)
866
+ self._cost_tracker.set_last_input_tokens(estimate_tokens(new_messages))
867
+ return new_messages, summary_text
868
+
869
+
870
+ # ---------------------------------------------------------------------------
871
+ # Media stripping + alternation fix
872
+ # ---------------------------------------------------------------------------
873
+
874
+ def _strip_media(messages: list[dict]) -> list[dict]:
875
+ """在发送给 LLM 进行总结前,移除图片/文档等多媒体内容以节省 Token。"""
876
+ out: list[dict] = []
877
+ for msg in messages:
878
+ content = msg.get("content", "")
879
+ if isinstance(content, list):
880
+ new_blocks: list[Any] = []
881
+ for block in content:
882
+ if isinstance(block, dict):
883
+ btype = block.get("type", "")
884
+ if btype in ("image", "document"):
885
+ new_blocks.append({"type": "text", "text": f"[{btype}]"}) # 图片被替换为占位符
886
+ else:
887
+ new_blocks.append(block)
888
+ else:
889
+ new_blocks.append(block)
890
+ out.append({"role": msg["role"], "content": new_blocks})
891
+ else:
892
+ out.append(dict(msg))
893
+ return out
894
+
895
+
896
+ def _has_tool_blocks(content: Any) -> bool:
897
+ """检查消息 content 中是否包含 tool_use 或 tool_result block。
898
+
899
+ 这些 block 在合并时被污染会导致下游 _to_openai_messages 静默丢弃数据:
900
+ - user 含 tool_result + 任意其它 block → _to_openai_messages 视作混合内容、
901
+ 走 _user_content_blocks_to_openai 分支,tool_result 被丢弃 → 前一条
902
+ assistant(tool_use) 找不到响应 → API 400。
903
+ - assistant 含 tool_use + 任意其它 block → 合并本身安全(_to_openai_messages 同时
904
+ 处理 text 和 tool_calls),但仍以分隔符代替合并以保持一致性。
905
+ """
906
+ if not isinstance(content, list):
907
+ return False
908
+ return any(
909
+ isinstance(b, dict) and b.get("type") in ("tool_use", "tool_result")
910
+ for b in content
911
+ )
912
+
913
+
914
+ def _fix_alternation(messages: list[dict]) -> list[dict]:
915
+ """修正消息列表,确保 user/assistant 角色严格交替,符合 API 要求。
916
+
917
+ 相邻两条消息 role 相同时分两种情况:
918
+ - 若任一消息含 tool_use 或 tool_result block → 插入分隔符(不合并),
919
+ 避免合并后混合 block 导致 _to_openai_messages 静默丢弃 tool 相关数据。
920
+ - 否则合并到前一条消息(两个纯字符串拼成换行分隔的单个字符串;
921
+ 至少一个 list 时转成 list 后拼接)。
922
+ """
923
+ if not messages:
924
+ return messages
925
+ fixed: list[dict] = [messages[0]]
926
+ for msg in messages[1:]:
927
+ if msg["role"] == fixed[-1]["role"]:
928
+ # 涉及 tool block 的合并会静默丢数据 → 插入分隔符
929
+ if _has_tool_blocks(fixed[-1].get("content", "")) or _has_tool_blocks(msg.get("content", "")):
930
+ sep_role = "assistant" if msg["role"] == "user" else "user"
931
+ fixed.append({"role": sep_role, "content": "Acknowledged."})
932
+ fixed.append(msg)
933
+ continue
934
+ # 安全合并
935
+ prev = fixed[-1].get("content", "")
936
+ cur = msg.get("content", "")
937
+ if isinstance(prev, str) and isinstance(cur, str):
938
+ fixed[-1]["content"] = prev + "\n" + cur
939
+ else:
940
+ def _as_list(c: Any) -> list:
941
+ return list(c) if isinstance(c, list) else [{"type": "text", "text": str(c)}]
942
+ fixed[-1]["content"] = _as_list(prev) + _as_list(cur)
943
+ else:
944
+ fixed.append(msg)
945
+ return fixed