qi-coding-agent 0.1.0__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.
- qi_agent/__init__.py +18 -0
- qi_agent/abort.py +40 -0
- qi_agent/auth.py +216 -0
- qi_agent/cli.py +2178 -0
- qi_agent/compaction.py +475 -0
- qi_agent/config.py +409 -0
- qi_agent/docs/cli.md +188 -0
- qi_agent/docs/compaction.md +148 -0
- qi_agent/docs/configuration.md +89 -0
- qi_agent/docs/containerization.md +80 -0
- qi_agent/docs/docs.json +1 -0
- qi_agent/docs/environment-variables.md +47 -0
- qi_agent/docs/extensions.md +382 -0
- qi_agent/docs/how-qi-works.md +93 -0
- qi_agent/docs/index.md +61 -0
- qi_agent/docs/json.md +112 -0
- qi_agent/docs/keybindings.md +83 -0
- qi_agent/docs/models.md +138 -0
- qi_agent/docs/packages.md +155 -0
- qi_agent/docs/providers.md +288 -0
- qi_agent/docs/quickstart.md +109 -0
- qi_agent/docs/security.md +69 -0
- qi_agent/docs/session-format.md +299 -0
- qi_agent/docs/sessions.md +127 -0
- qi_agent/docs/settings.md +138 -0
- qi_agent/docs/skills.md +124 -0
- qi_agent/docs/slash-commands.md +55 -0
- qi_agent/docs/themes.md +78 -0
- qi_agent/docs/tui.md +109 -0
- qi_agent/docs/usage.md +112 -0
- qi_agent/export_html.py +95 -0
- qi_agent/extensions.py +2267 -0
- qi_agent/llm.py +609 -0
- qi_agent/loader.py +299 -0
- qi_agent/model_catalog.py +108 -0
- qi_agent/models.py +103 -0
- qi_agent/packages.py +430 -0
- qi_agent/paths.py +242 -0
- qi_agent/pep723.py +74 -0
- qi_agent/presets.py +293 -0
- qi_agent/prompt.py +150 -0
- qi_agent/registry.py +485 -0
- qi_agent/runner.py +584 -0
- qi_agent/runtime.py +1940 -0
- qi_agent/session.py +739 -0
- qi_agent/settings.py +501 -0
- qi_agent/share.py +111 -0
- qi_agent/system_prompt.py +278 -0
- qi_agent/theme.py +389 -0
- qi_agent/themes/dark.json +90 -0
- qi_agent/themes/light.json +89 -0
- qi_agent/titling.py +90 -0
- qi_agent/tools/__init__.py +349 -0
- qi_agent/tools/shell.py +276 -0
- qi_agent/trust.py +106 -0
- qi_agent/tui.py +6224 -0
- qi_agent/workspaces.py +116 -0
- qi_coding_agent-0.1.0.dist-info/METADATA +151 -0
- qi_coding_agent-0.1.0.dist-info/RECORD +62 -0
- qi_coding_agent-0.1.0.dist-info/WHEEL +4 -0
- qi_coding_agent-0.1.0.dist-info/entry_points.txt +2 -0
- qi_coding_agent-0.1.0.dist-info/licenses/LICENSE +21 -0
qi_agent/compaction.py
ADDED
|
@@ -0,0 +1,475 @@
|
|
|
1
|
+
"""上下文压缩与分支摘要(移植 pi 的 `core/compaction`)。
|
|
2
|
+
|
|
3
|
+
两种机制共用同一套结构化摘要格式与同一条 LLM 调用路径:
|
|
4
|
+
|
|
5
|
+
· **压缩(compaction)**:上下文超过「contextWindow - reserveTokens」或用户 `/compact` 时,
|
|
6
|
+
把旧消息压成一段结构化摘要,保留最近约 `keepRecentTokens` 的内容;摘要落成
|
|
7
|
+
`type=compaction` entry,带 `firstKeptEntryId`(从哪条 entry 开始保留)。
|
|
8
|
+
· **分支摘要(branch summary)**:`/tree` 跳到别的分支时,把「被放弃的那段」压成摘要挂到
|
|
9
|
+
新位置(`type=branch_summary`),切回来继续时上下文不丢。
|
|
10
|
+
|
|
11
|
+
与 pi 一致的两个细节:
|
|
12
|
+
· 切点只落在「turn 边界」或 assistant 消息上,**绝不在 tool 结果上**(它必须跟着它的调用);
|
|
13
|
+
· 单个 turn 就超过预算时做 **split turn**:前缀单独摘要,与历史摘要拼在一起。
|
|
14
|
+
|
|
15
|
+
qi 的差异:`custom`(助手叙述)在 qi 里**不进上下文**(见 runtime._history),所以压缩也当它
|
|
16
|
+
不可见 —— 与上下文语义保持一致,不额外发明规则。
|
|
17
|
+
"""
|
|
18
|
+
|
|
19
|
+
from __future__ import annotations
|
|
20
|
+
|
|
21
|
+
from dataclasses import dataclass, field
|
|
22
|
+
from typing import Any
|
|
23
|
+
|
|
24
|
+
from .llm import ChatMessage, LLMClient, usage_to_dict
|
|
25
|
+
|
|
26
|
+
DEFAULT_RESERVE_TOKENS = 16384
|
|
27
|
+
"""给模型回答留的余量(pi 默认)。"""
|
|
28
|
+
|
|
29
|
+
DEFAULT_KEEP_RECENT_TOKENS = 20000
|
|
30
|
+
"""压缩后至少保留的近期内容(pi 默认)。"""
|
|
31
|
+
|
|
32
|
+
SUMMARIZATION_SYSTEM_PROMPT = """You are a context summarization assistant. Your task is to read a conversation between a user and an AI assistant, then produce a structured summary following the exact format specified.
|
|
33
|
+
|
|
34
|
+
Do NOT continue the conversation. Do NOT respond to any questions in the conversation. ONLY output the structured summary."""
|
|
35
|
+
|
|
36
|
+
SUMMARIZATION_PROMPT = """The messages above are a conversation to summarize. Create a structured context checkpoint summary that another LLM will use to continue the work.
|
|
37
|
+
|
|
38
|
+
Use this EXACT format:
|
|
39
|
+
|
|
40
|
+
## Goal
|
|
41
|
+
[What is the user trying to accomplish? Can be multiple items if the session covers different tasks.]
|
|
42
|
+
|
|
43
|
+
## Constraints & Preferences
|
|
44
|
+
- [Any constraints, preferences, or requirements mentioned by user]
|
|
45
|
+
- [Or "(none)" if none were mentioned]
|
|
46
|
+
|
|
47
|
+
## Progress
|
|
48
|
+
### Done
|
|
49
|
+
- [x] [Completed tasks/changes]
|
|
50
|
+
|
|
51
|
+
### In Progress
|
|
52
|
+
- [ ] [Current work]
|
|
53
|
+
|
|
54
|
+
### Blocked
|
|
55
|
+
- [Issues preventing progress, if any]
|
|
56
|
+
|
|
57
|
+
## Key Decisions
|
|
58
|
+
- **[Decision]**: [Brief rationale]
|
|
59
|
+
|
|
60
|
+
## Next Steps
|
|
61
|
+
1. [Ordered list of what should happen next]
|
|
62
|
+
|
|
63
|
+
## Critical Context
|
|
64
|
+
- [Any data, examples, or references needed to continue]
|
|
65
|
+
- [Or "(none)" if not applicable]
|
|
66
|
+
|
|
67
|
+
Keep each section concise. Preserve exact file paths, function names, and error messages."""
|
|
68
|
+
|
|
69
|
+
UPDATE_SUMMARIZATION_INSTRUCTIONS = """Update the existing structured summary with new information. RULES:
|
|
70
|
+
- PRESERVE all existing information from the previous summary
|
|
71
|
+
- ADD new progress, decisions, and context from the new messages
|
|
72
|
+
- UPDATE the Progress section: move items from "In Progress" to "Done" when completed
|
|
73
|
+
- UPDATE "Next Steps" based on what was accomplished
|
|
74
|
+
- PRESERVE exact file paths, function names, and error messages
|
|
75
|
+
- If something is no longer relevant, you may remove it
|
|
76
|
+
|
|
77
|
+
Use this EXACT format:
|
|
78
|
+
|
|
79
|
+
## Goal
|
|
80
|
+
[Preserve existing goals, add new ones if the task expanded]
|
|
81
|
+
|
|
82
|
+
## Constraints & Preferences
|
|
83
|
+
- [Preserve existing, add new if discovered]
|
|
84
|
+
|
|
85
|
+
## Progress
|
|
86
|
+
### Done
|
|
87
|
+
- [x] [Include previously done items and newly completed items]
|
|
88
|
+
|
|
89
|
+
### In Progress
|
|
90
|
+
- [ ] [Current work]
|
|
91
|
+
|
|
92
|
+
### Blocked
|
|
93
|
+
- [Issues preventing progress, if any]
|
|
94
|
+
|
|
95
|
+
## Key Decisions
|
|
96
|
+
- **[Decision]**: [Brief rationale]
|
|
97
|
+
|
|
98
|
+
## Next Steps
|
|
99
|
+
1. [Updated list of what should happen next]
|
|
100
|
+
|
|
101
|
+
## Critical Context
|
|
102
|
+
- [Preserve existing and add new important details]"""
|
|
103
|
+
|
|
104
|
+
UPDATE_SUMMARIZATION_PROMPT = f"""The messages above are NEW conversation messages to incorporate into the existing summary provided in <previous-summary> tags.
|
|
105
|
+
|
|
106
|
+
{UPDATE_SUMMARIZATION_INSTRUCTIONS}"""
|
|
107
|
+
|
|
108
|
+
TURN_PREFIX_SUMMARIZATION_PROMPT = """This is the PREFIX of a turn that was too large to keep. The SUFFIX (recent work) is retained.
|
|
109
|
+
|
|
110
|
+
Summarize the prefix to provide context for the retained suffix:
|
|
111
|
+
|
|
112
|
+
## Original Request
|
|
113
|
+
[What did the user ask for in this turn?]
|
|
114
|
+
|
|
115
|
+
## Early Progress
|
|
116
|
+
- [Key decisions and work done in the prefix]
|
|
117
|
+
|
|
118
|
+
## Context for Suffix
|
|
119
|
+
- [Information needed to understand the retained recent work]
|
|
120
|
+
|
|
121
|
+
Be concise. Focus on what's needed to understand the kept suffix."""
|
|
122
|
+
|
|
123
|
+
BRANCH_SUMMARY_PROMPT = """Create a structured summary of this conversation branch for context when returning later.
|
|
124
|
+
|
|
125
|
+
Use this EXACT format:
|
|
126
|
+
|
|
127
|
+
## Goal
|
|
128
|
+
[What was the user trying to accomplish in this branch?]
|
|
129
|
+
|
|
130
|
+
## Constraints & Preferences
|
|
131
|
+
- [Any constraints, preferences, or requirements mentioned]
|
|
132
|
+
- [Or "(none)" if none were mentioned]
|
|
133
|
+
|
|
134
|
+
## Progress
|
|
135
|
+
### Done
|
|
136
|
+
- [x] [Completed tasks/changes]
|
|
137
|
+
|
|
138
|
+
### In Progress
|
|
139
|
+
- [ ] [Work that was started but not finished]
|
|
140
|
+
|
|
141
|
+
### Blocked
|
|
142
|
+
- [Issues preventing progress, if any]
|
|
143
|
+
|
|
144
|
+
## Key Decisions
|
|
145
|
+
- **[Decision]**: [Brief rationale]
|
|
146
|
+
|
|
147
|
+
## Next Steps
|
|
148
|
+
1. [What should happen next to continue this work]
|
|
149
|
+
|
|
150
|
+
Keep each section concise. Preserve exact file paths, function names, and error messages."""
|
|
151
|
+
|
|
152
|
+
BRANCH_SUMMARY_PREAMBLE = """The user explored a different conversation branch before returning here.
|
|
153
|
+
Summary of that exploration:
|
|
154
|
+
|
|
155
|
+
"""
|
|
156
|
+
|
|
157
|
+
|
|
158
|
+
# ── 估算(与 pi 同口径:chars/4)──────────────────────────────
|
|
159
|
+
|
|
160
|
+
|
|
161
|
+
def estimate_tokens(text: str) -> int:
|
|
162
|
+
"""`chars/4` 保守估计(pi `estimateTokens`)。"""
|
|
163
|
+
return -(-len(text) // 4)
|
|
164
|
+
|
|
165
|
+
|
|
166
|
+
def entry_tokens(entry: dict) -> int:
|
|
167
|
+
"""单条 entry 的**上下文** token 估算。
|
|
168
|
+
|
|
169
|
+
注意 qi 的 tool 结果是 `type=tool` entry,而 `_history` 只把 `message` 送进上下文
|
|
170
|
+
(跨轮不带工具结果 —— 那是 qi 现有设计,不是这里改的),所以工具算 0。
|
|
171
|
+
摘要输入会带上工具结果(见 `is_summarizable_entry`),因为那是「发生过什么」的事实。
|
|
172
|
+
"""
|
|
173
|
+
kind = entry.get("type")
|
|
174
|
+
if kind == "message":
|
|
175
|
+
role = entry.get("role")
|
|
176
|
+
if role == "tool":
|
|
177
|
+
return estimate_tokens(str(entry.get("result") or ""))
|
|
178
|
+
return estimate_tokens(str(entry.get("content") or ""))
|
|
179
|
+
if kind in ("compaction", "branch_summary"):
|
|
180
|
+
return estimate_tokens(str(entry.get("summary") or ""))
|
|
181
|
+
return 0 # tool / dispatch / state / custom(叙述):不进上下文
|
|
182
|
+
|
|
183
|
+
|
|
184
|
+
def is_summarizable_entry(entry: dict) -> bool:
|
|
185
|
+
"""会不会被送进**摘要模型**的输入(比上下文多一类:工具结果)。"""
|
|
186
|
+
return entry.get("type") == "tool" or is_context_entry(entry)
|
|
187
|
+
|
|
188
|
+
|
|
189
|
+
def context_tokens(entries: list[dict]) -> int:
|
|
190
|
+
"""一段 entry 序列的上下文体积(按 entry 估算)。"""
|
|
191
|
+
return sum(entry_tokens(e) for e in entries)
|
|
192
|
+
|
|
193
|
+
|
|
194
|
+
def messages_tokens(messages: list) -> int:
|
|
195
|
+
"""已经组好的上下文消息体积 —— 触发压缩时要用**这个**,而不是原始 entry:
|
|
196
|
+
压缩过的内容不该再计入(否则会每轮都重复触发)。"""
|
|
197
|
+
return sum(estimate_tokens(str(getattr(m, "content", "") or "")) for m in messages)
|
|
198
|
+
|
|
199
|
+
|
|
200
|
+
def should_compact(tokens: int, context_window: int, *, enabled: bool = True,
|
|
201
|
+
reserve_tokens: int = DEFAULT_RESERVE_TOKENS) -> bool:
|
|
202
|
+
"""超了「窗口 - 余量」才压缩(pi `shouldCompact`)。"""
|
|
203
|
+
if not enabled or context_window <= 0:
|
|
204
|
+
return False
|
|
205
|
+
return tokens > context_window - reserve_tokens
|
|
206
|
+
|
|
207
|
+
|
|
208
|
+
# ── 切点(pi `findCutPoint`)────────────────────────────────
|
|
209
|
+
|
|
210
|
+
|
|
211
|
+
def is_context_entry(entry: dict) -> bool:
|
|
212
|
+
"""这条 entry 会不会变成**上下文消息**(qi:message / compaction / branch_summary)。"""
|
|
213
|
+
kind = entry.get("type")
|
|
214
|
+
if kind == "message":
|
|
215
|
+
return True
|
|
216
|
+
return kind in ("compaction", "branch_summary")
|
|
217
|
+
|
|
218
|
+
|
|
219
|
+
def is_cut_point(entry: dict) -> bool:
|
|
220
|
+
"""可作为「开始保留」的位置:user/assistant 消息,compaction/branch_summary。
|
|
221
|
+
|
|
222
|
+
绝不在 tool 结果上切 —— 它必须跟在它的调用后面。
|
|
223
|
+
"""
|
|
224
|
+
kind = entry.get("type")
|
|
225
|
+
if kind in ("compaction", "branch_summary"):
|
|
226
|
+
return True
|
|
227
|
+
if kind != "message":
|
|
228
|
+
return False
|
|
229
|
+
return entry.get("role") in ("user", "assistant")
|
|
230
|
+
|
|
231
|
+
|
|
232
|
+
def is_turn_start(entry: dict) -> bool:
|
|
233
|
+
"""turn 起点:user 消息 / branch_summary / compaction(assistant 与 tool 不是)。"""
|
|
234
|
+
kind = entry.get("type")
|
|
235
|
+
if kind in ("compaction",):
|
|
236
|
+
return False
|
|
237
|
+
if kind == "branch_summary":
|
|
238
|
+
return True
|
|
239
|
+
return kind == "message" and entry.get("role") == "user"
|
|
240
|
+
|
|
241
|
+
|
|
242
|
+
@dataclass
|
|
243
|
+
class Preparation:
|
|
244
|
+
"""压缩前的计划(pi `prepareCompaction` 的返回)。"""
|
|
245
|
+
|
|
246
|
+
first_kept_entry_id: str
|
|
247
|
+
messages_to_summarize: list[dict] = field(default_factory=list)
|
|
248
|
+
turn_prefix_messages: list[dict] = field(default_factory=list)
|
|
249
|
+
is_split_turn: bool = False
|
|
250
|
+
tokens_before: int = 0
|
|
251
|
+
previous_summary: str | None = None
|
|
252
|
+
|
|
253
|
+
@property
|
|
254
|
+
def empty(self) -> bool:
|
|
255
|
+
return not self.messages_to_summarize and not self.turn_prefix_messages
|
|
256
|
+
|
|
257
|
+
|
|
258
|
+
def find_cut_point(entries: list[dict], start: int, end: int,
|
|
259
|
+
keep_recent_tokens: int) -> tuple[int, int, bool]:
|
|
260
|
+
"""返回 `(first_kept_index, turn_start_index, is_split_turn)`。
|
|
261
|
+
|
|
262
|
+
算法(pi 同款):从最新往回累加,累计到 `keepRecentTokens` 时,把切点放到**不早于**
|
|
263
|
+
当前位置的最近一个合法切点;再向前吞掉相邻的不可见 entry(dispatch/state/叙述)。
|
|
264
|
+
"""
|
|
265
|
+
cut_points = [i for i in range(start, end) if is_cut_point(entries[i])]
|
|
266
|
+
if not cut_points:
|
|
267
|
+
return start, -1, False
|
|
268
|
+
|
|
269
|
+
accumulated = 0
|
|
270
|
+
cut_index = cut_points[0]
|
|
271
|
+
for i in range(end - 1, start - 1, -1):
|
|
272
|
+
tokens = entry_tokens(entries[i])
|
|
273
|
+
if tokens == 0:
|
|
274
|
+
continue
|
|
275
|
+
accumulated += tokens
|
|
276
|
+
if accumulated >= keep_recent_tokens:
|
|
277
|
+
cut_index = next((c for c in cut_points if c >= i), cut_points[-1])
|
|
278
|
+
break
|
|
279
|
+
|
|
280
|
+
# 向前吞掉不影响上下文的相邻 entry(dispatch/state/叙述),让切点更贴近预算。
|
|
281
|
+
# 注意:工具结果在 qi 里虽然不进上下文,但**不是元数据** —— 不能被吞掉,
|
|
282
|
+
# 否则切点会落在 tool 结果上(它必须跟在它的调用后面)。
|
|
283
|
+
while cut_index > start:
|
|
284
|
+
prev = entries[cut_index - 1]
|
|
285
|
+
if prev.get("type") == "compaction" or is_summarizable_entry(prev):
|
|
286
|
+
break
|
|
287
|
+
cut_index -= 1
|
|
288
|
+
|
|
289
|
+
if is_turn_start(entries[cut_index]):
|
|
290
|
+
return cut_index, -1, False
|
|
291
|
+
turn_start = next((i for i in range(cut_index, start - 1, -1)
|
|
292
|
+
if is_turn_start(entries[i])), -1)
|
|
293
|
+
return cut_index, turn_start, turn_start != -1
|
|
294
|
+
|
|
295
|
+
|
|
296
|
+
def prepare_compaction(branch: list[dict], *, keep_recent_tokens: int = DEFAULT_KEEP_RECENT_TOKENS
|
|
297
|
+
) -> Preparation | None:
|
|
298
|
+
"""算出「要摘要哪些、从哪开始保留」;没什么可压时返回 None。"""
|
|
299
|
+
if not branch:
|
|
300
|
+
return None
|
|
301
|
+
if branch[-1].get("type") == "compaction":
|
|
302
|
+
return None # 刚压过,且之后没有新内容
|
|
303
|
+
|
|
304
|
+
previous_summary: str | None = None
|
|
305
|
+
boundary_start = 0
|
|
306
|
+
prev_index = -1
|
|
307
|
+
for i in range(len(branch) - 1, -1, -1):
|
|
308
|
+
if branch[i].get("type") == "compaction":
|
|
309
|
+
prev_index = i
|
|
310
|
+
break
|
|
311
|
+
if prev_index >= 0:
|
|
312
|
+
previous_summary = str(branch[prev_index].get("summary") or "")
|
|
313
|
+
first_kept = branch[prev_index].get("firstKeptEntryId")
|
|
314
|
+
found = next((i for i, e in enumerate(branch) if str(e.get("id")) == str(first_kept)), -1)
|
|
315
|
+
boundary_start = found if found >= 0 else prev_index + 1
|
|
316
|
+
|
|
317
|
+
cut_index, turn_start, is_split = find_cut_point(
|
|
318
|
+
branch, boundary_start, len(branch), keep_recent_tokens)
|
|
319
|
+
first_kept_entry = branch[cut_index]
|
|
320
|
+
if not first_kept_entry.get("id"):
|
|
321
|
+
return None # 没迁移过的老 entry:不给 id 就没法引用
|
|
322
|
+
|
|
323
|
+
history_end = turn_start if is_split else cut_index
|
|
324
|
+
to_summarize = [e for e in branch[boundary_start:history_end] if is_summarizable_entry(e)]
|
|
325
|
+
turn_prefix: list[dict] = []
|
|
326
|
+
if is_split and turn_start >= 0:
|
|
327
|
+
turn_prefix = [e for e in branch[turn_start:cut_index] if is_summarizable_entry(e)]
|
|
328
|
+
|
|
329
|
+
prep = Preparation(
|
|
330
|
+
first_kept_entry_id=str(first_kept_entry["id"]),
|
|
331
|
+
messages_to_summarize=to_summarize,
|
|
332
|
+
turn_prefix_messages=turn_prefix,
|
|
333
|
+
is_split_turn=is_split,
|
|
334
|
+
tokens_before=context_tokens(branch),
|
|
335
|
+
previous_summary=previous_summary,
|
|
336
|
+
)
|
|
337
|
+
return None if prep.empty else prep
|
|
338
|
+
|
|
339
|
+
|
|
340
|
+
# ── 摘要生成(pi `generateSummary` / `compact`)───────────────
|
|
341
|
+
|
|
342
|
+
|
|
343
|
+
def serialize_conversation(entries: list[dict]) -> str:
|
|
344
|
+
"""把 entry 序列摊成纯文本给摘要模型(避免它以为要接着对话)。"""
|
|
345
|
+
parts: list[str] = []
|
|
346
|
+
for entry in entries:
|
|
347
|
+
kind = entry.get("type")
|
|
348
|
+
if kind == "message":
|
|
349
|
+
role = entry.get("role")
|
|
350
|
+
body = str(entry.get("result") if role == "tool" else entry.get("content") or "")
|
|
351
|
+
if not body.strip():
|
|
352
|
+
continue
|
|
353
|
+
who = {"user": "User", "assistant": "Assistant", "tool": "Tool"}.get(str(role), str(role))
|
|
354
|
+
parts.append(f"[{who}]: {body}")
|
|
355
|
+
elif kind == "tool":
|
|
356
|
+
body = str(entry.get("result") or "")
|
|
357
|
+
if body.strip():
|
|
358
|
+
label = str(entry.get("tool") or "tool")
|
|
359
|
+
parts.append(f"[Tool {label}]: {body}")
|
|
360
|
+
elif kind == "compaction":
|
|
361
|
+
parts.append(f"[Previous summary]: {entry.get('summary')}")
|
|
362
|
+
elif kind == "branch_summary":
|
|
363
|
+
parts.append(f"[Branch summary]: {entry.get('summary')}")
|
|
364
|
+
return "\n\n".join(parts)
|
|
365
|
+
|
|
366
|
+
|
|
367
|
+
def summary_context_message(summary: str, *, kind: str = "compaction") -> ChatMessage:
|
|
368
|
+
"""把摘要包成一条**上下文消息**(pi 把它放在 system 之后、kept 消息之前)。
|
|
369
|
+
|
|
370
|
+
用 `user` 角色而不是 system:provider 对「多个 system」支持不一,而摘要本来就是
|
|
371
|
+
「过去发生的对话」的替身。
|
|
372
|
+
"""
|
|
373
|
+
label = "上下文已压缩" if kind == "compaction" else "另一条分支的摘要"
|
|
374
|
+
return ChatMessage(role="user",
|
|
375
|
+
content=f"[{label}]\n{summary}\n[摘要结束,以下是其后的消息]")
|
|
376
|
+
|
|
377
|
+
|
|
378
|
+
async def _summarize(llm: LLMClient, entries: list[dict], *, previous_summary: str | None,
|
|
379
|
+
instructions: str | None, prompt: str) -> tuple[str, dict]:
|
|
380
|
+
text = serialize_conversation(entries)
|
|
381
|
+
body = f"<conversation>\n{text}\n</conversation>\n\n"
|
|
382
|
+
if previous_summary:
|
|
383
|
+
body += f"<previous-summary>\n{previous_summary}\n</previous-summary>\n\n"
|
|
384
|
+
base = prompt
|
|
385
|
+
if instructions:
|
|
386
|
+
base = f"{base}\n\nAdditional focus: {instructions}"
|
|
387
|
+
body += base
|
|
388
|
+
resp = await llm.chat([ChatMessage(role="system", content=SUMMARIZATION_SYSTEM_PROMPT),
|
|
389
|
+
ChatMessage(role="user", content=body)])
|
|
390
|
+
if resp.tool_calls:
|
|
391
|
+
raise RuntimeError("摘要模型试图调用工具")
|
|
392
|
+
return (resp.text or "").strip(), resp.usage
|
|
393
|
+
|
|
394
|
+
|
|
395
|
+
async def compact(llm: LLMClient, prep: Preparation, *,
|
|
396
|
+
instructions: str | None = None) -> dict:
|
|
397
|
+
"""执行压缩,返回要落盘的 compaction entry(不含 id/parentId/ts)。
|
|
398
|
+
|
|
399
|
+
split turn 时生成两段摘要并拼在一起(pi 同款)。
|
|
400
|
+
"""
|
|
401
|
+
if prep.is_split_turn and prep.turn_prefix_messages:
|
|
402
|
+
if prep.messages_to_summarize:
|
|
403
|
+
history, usage = await _summarize(llm, prep.messages_to_summarize,
|
|
404
|
+
previous_summary=prep.previous_summary,
|
|
405
|
+
instructions=instructions,
|
|
406
|
+
prompt=(UPDATE_SUMMARIZATION_PROMPT
|
|
407
|
+
if prep.previous_summary
|
|
408
|
+
else SUMMARIZATION_PROMPT))
|
|
409
|
+
else:
|
|
410
|
+
history, usage = "No prior history.", {}
|
|
411
|
+
prefix, prefix_usage = await _summarize(llm, prep.turn_prefix_messages,
|
|
412
|
+
previous_summary=None,
|
|
413
|
+
instructions=None,
|
|
414
|
+
prompt=TURN_PREFIX_SUMMARIZATION_PROMPT)
|
|
415
|
+
summary = f"{history}\n\n---\n\n**Turn Context (split turn):**\n\n{prefix}"
|
|
416
|
+
merged = dict(prefix_usage)
|
|
417
|
+
for key, value in (usage or {}).items():
|
|
418
|
+
if isinstance(value, (int, float)) and isinstance(merged.get(key), (int, float)):
|
|
419
|
+
merged[key] = merged[key] + value
|
|
420
|
+
else:
|
|
421
|
+
merged[key] = value
|
|
422
|
+
usage = merged
|
|
423
|
+
else:
|
|
424
|
+
summary, usage = await _summarize(llm, prep.messages_to_summarize,
|
|
425
|
+
previous_summary=prep.previous_summary,
|
|
426
|
+
instructions=instructions,
|
|
427
|
+
prompt=(UPDATE_SUMMARIZATION_PROMPT
|
|
428
|
+
if prep.previous_summary
|
|
429
|
+
else SUMMARIZATION_PROMPT))
|
|
430
|
+
return {"type": "compaction", "summary": summary,
|
|
431
|
+
"firstKeptEntryId": prep.first_kept_entry_id,
|
|
432
|
+
"tokensBefore": prep.tokens_before,
|
|
433
|
+
"usage": usage_to_dict(usage)}
|
|
434
|
+
|
|
435
|
+
|
|
436
|
+
async def summarize_branch(llm: LLMClient, entries: list[dict]) -> tuple[str, dict]:
|
|
437
|
+
"""分支摘要:把「被放弃的那条分支」压成一段摘要。"""
|
|
438
|
+
summary, usage = await _summarize(llm, entries, previous_summary=None, instructions=None,
|
|
439
|
+
prompt=BRANCH_SUMMARY_PROMPT)
|
|
440
|
+
return summary, usage_to_dict(usage)
|
|
441
|
+
|
|
442
|
+
|
|
443
|
+
def common_ancestor(branch_a: list[dict], branch_b: list[dict]) -> str | None:
|
|
444
|
+
"""两条分支(正序 entry 列表)最深的共同祖先 id。"""
|
|
445
|
+
ids_a = [str(e.get("id")) for e in branch_a]
|
|
446
|
+
ids_b = {str(e.get("id")) for e in branch_b}
|
|
447
|
+
for entry_id in reversed(ids_a):
|
|
448
|
+
if entry_id in ids_b:
|
|
449
|
+
return entry_id
|
|
450
|
+
return None
|
|
451
|
+
|
|
452
|
+
|
|
453
|
+
def branch_to_summarize(branch: list[dict], from_id: str | None,
|
|
454
|
+
target_branch: list[dict]) -> list[dict]:
|
|
455
|
+
"""要摘要的「被放弃段」:从 `from_id` 往回走到共同祖先(不含祖先)。"""
|
|
456
|
+
ancestor = common_ancestor(branch, target_branch)
|
|
457
|
+
out: list[dict] = []
|
|
458
|
+
by_id = {str(e.get("id")): e for e in branch}
|
|
459
|
+
node = by_id.get(str(from_id)) if from_id else None
|
|
460
|
+
while node is not None:
|
|
461
|
+
node_id = str(node.get("id"))
|
|
462
|
+
if node_id == ancestor:
|
|
463
|
+
break
|
|
464
|
+
if is_context_entry(node):
|
|
465
|
+
out.append(node)
|
|
466
|
+
parent = node.get("parentId")
|
|
467
|
+
node = by_id.get(str(parent)) if parent else None
|
|
468
|
+
out.reverse()
|
|
469
|
+
return out
|
|
470
|
+
|
|
471
|
+
|
|
472
|
+
def usage_int(usage: dict[str, Any] | None, key: str) -> int:
|
|
473
|
+
"""usage 字段容错读取(摘要调用也计入会话用量)。"""
|
|
474
|
+
value = (usage or {}).get(key)
|
|
475
|
+
return value if isinstance(value, int) else 0
|