klaude-code 2.7.0__py3-none-any.whl → 2.8.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.
- klaude_code/auth/AGENTS.md +325 -0
- klaude_code/auth/__init__.py +17 -1
- klaude_code/auth/antigravity/__init__.py +20 -0
- klaude_code/auth/antigravity/exceptions.py +17 -0
- klaude_code/auth/antigravity/oauth.py +320 -0
- klaude_code/auth/antigravity/pkce.py +25 -0
- klaude_code/auth/antigravity/token_manager.py +45 -0
- klaude_code/auth/base.py +4 -0
- klaude_code/auth/claude/oauth.py +29 -9
- klaude_code/auth/codex/exceptions.py +4 -0
- klaude_code/cli/auth_cmd.py +53 -3
- klaude_code/cli/cost_cmd.py +83 -160
- klaude_code/cli/list_model.py +50 -0
- klaude_code/cli/main.py +1 -1
- klaude_code/config/assets/builtin_config.yaml +108 -0
- klaude_code/config/builtin_config.py +5 -11
- klaude_code/config/config.py +24 -10
- klaude_code/const.py +1 -0
- klaude_code/core/agent.py +5 -1
- klaude_code/core/agent_profile.py +28 -32
- klaude_code/core/compaction/AGENTS.md +112 -0
- klaude_code/core/compaction/__init__.py +11 -0
- klaude_code/core/compaction/compaction.py +707 -0
- klaude_code/core/compaction/overflow.py +30 -0
- klaude_code/core/compaction/prompts.py +97 -0
- klaude_code/core/executor.py +103 -2
- klaude_code/core/manager/llm_clients.py +5 -0
- klaude_code/core/manager/llm_clients_builder.py +14 -2
- klaude_code/core/prompts/prompt-antigravity.md +80 -0
- klaude_code/core/prompts/prompt-codex-gpt-5-2.md +335 -0
- klaude_code/core/reminders.py +7 -2
- klaude_code/core/task.py +126 -0
- klaude_code/core/tool/todo/todo_write_tool.py +1 -1
- klaude_code/core/turn.py +3 -1
- klaude_code/llm/antigravity/__init__.py +3 -0
- klaude_code/llm/antigravity/client.py +558 -0
- klaude_code/llm/antigravity/input.py +261 -0
- klaude_code/llm/registry.py +1 -0
- klaude_code/protocol/events.py +18 -0
- klaude_code/protocol/llm_param.py +1 -0
- klaude_code/protocol/message.py +23 -1
- klaude_code/protocol/op.py +15 -1
- klaude_code/protocol/op_handler.py +5 -0
- klaude_code/session/session.py +36 -0
- klaude_code/skill/assets/create-plan/SKILL.md +6 -6
- klaude_code/tui/command/__init__.py +3 -0
- klaude_code/tui/command/compact_cmd.py +32 -0
- klaude_code/tui/command/fork_session_cmd.py +110 -14
- klaude_code/tui/command/model_picker.py +5 -1
- klaude_code/tui/command/thinking_cmd.py +1 -1
- klaude_code/tui/commands.py +6 -0
- klaude_code/tui/components/rich/markdown.py +57 -1
- klaude_code/tui/components/rich/theme.py +10 -2
- klaude_code/tui/components/tools.py +39 -25
- klaude_code/tui/components/user_input.py +1 -1
- klaude_code/tui/input/__init__.py +5 -2
- klaude_code/tui/input/drag_drop.py +6 -57
- klaude_code/tui/input/key_bindings.py +10 -0
- klaude_code/tui/input/prompt_toolkit.py +19 -6
- klaude_code/tui/machine.py +25 -0
- klaude_code/tui/renderer.py +67 -4
- klaude_code/tui/runner.py +18 -2
- klaude_code/tui/terminal/image.py +72 -10
- klaude_code/tui/terminal/selector.py +31 -7
- {klaude_code-2.7.0.dist-info → klaude_code-2.8.0.dist-info}/METADATA +1 -1
- {klaude_code-2.7.0.dist-info → klaude_code-2.8.0.dist-info}/RECORD +68 -52
- klaude_code/core/prompts/prompt-codex-gpt-5-1-codex-max.md +0 -117
- {klaude_code-2.7.0.dist-info → klaude_code-2.8.0.dist-info}/WHEEL +0 -0
- {klaude_code-2.7.0.dist-info → klaude_code-2.8.0.dist-info}/entry_points.txt +0 -0
|
@@ -0,0 +1,707 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import asyncio
|
|
4
|
+
import json
|
|
5
|
+
from collections.abc import Sequence
|
|
6
|
+
from dataclasses import dataclass
|
|
7
|
+
from enum import Enum
|
|
8
|
+
from typing import cast
|
|
9
|
+
|
|
10
|
+
from klaude_code.llm import LLMClientABC
|
|
11
|
+
from klaude_code.protocol import llm_param, message, model
|
|
12
|
+
from klaude_code.session.session import Session
|
|
13
|
+
|
|
14
|
+
from .prompts import (
|
|
15
|
+
COMPACTION_SUMMARY_PREFIX,
|
|
16
|
+
SUMMARIZATION_PROMPT,
|
|
17
|
+
SUMMARIZATION_SYSTEM_PROMPT,
|
|
18
|
+
TASK_PREFIX_SUMMARIZATION_PROMPT,
|
|
19
|
+
UPDATE_SUMMARIZATION_PROMPT,
|
|
20
|
+
)
|
|
21
|
+
|
|
22
|
+
_MAX_TOOL_OUTPUT_CHARS = 4000
|
|
23
|
+
_MAX_TOOL_CALL_CHARS = 2000
|
|
24
|
+
_DEFAULT_IMAGE_TOKENS = 1200
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
class CompactionReason(str, Enum):
|
|
28
|
+
THRESHOLD = "threshold"
|
|
29
|
+
OVERFLOW = "overflow"
|
|
30
|
+
MANUAL = "manual"
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
@dataclass(frozen=True)
|
|
34
|
+
class CompactionConfig:
|
|
35
|
+
reserve_tokens: int
|
|
36
|
+
keep_recent_tokens: int
|
|
37
|
+
max_summary_tokens: int
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
@dataclass(frozen=True)
|
|
41
|
+
class CompactionResult:
|
|
42
|
+
summary: str
|
|
43
|
+
first_kept_index: int
|
|
44
|
+
tokens_before: int | None
|
|
45
|
+
details: message.CompactionDetails | None
|
|
46
|
+
kept_items_brief: list[message.KeptItemBrief]
|
|
47
|
+
|
|
48
|
+
def to_entry(self) -> message.CompactionEntry:
|
|
49
|
+
"""Convert to a CompactionEntry for persisting in session history."""
|
|
50
|
+
return message.CompactionEntry(
|
|
51
|
+
summary=self.summary,
|
|
52
|
+
first_kept_index=self.first_kept_index,
|
|
53
|
+
tokens_before=self.tokens_before,
|
|
54
|
+
details=self.details,
|
|
55
|
+
kept_items_brief=self.kept_items_brief,
|
|
56
|
+
)
|
|
57
|
+
|
|
58
|
+
|
|
59
|
+
def _resolve_compaction_config(llm_config: llm_param.LLMConfigParameter) -> CompactionConfig:
|
|
60
|
+
default_reserve = 16384
|
|
61
|
+
default_keep = 20000
|
|
62
|
+
context_limit = llm_config.context_limit or 0
|
|
63
|
+
if context_limit <= 0:
|
|
64
|
+
reserve = default_reserve
|
|
65
|
+
keep_recent = default_keep
|
|
66
|
+
else:
|
|
67
|
+
reserve = min(default_reserve, max(2048, int(context_limit * 0.25)))
|
|
68
|
+
keep_recent = min(default_keep, max(4096, int(context_limit * 0.35)))
|
|
69
|
+
max_keep = max(0, context_limit - reserve)
|
|
70
|
+
if max_keep:
|
|
71
|
+
keep_recent = min(keep_recent, max_keep)
|
|
72
|
+
max_summary = max(1024, int(reserve * 0.8))
|
|
73
|
+
return CompactionConfig(reserve_tokens=reserve, keep_recent_tokens=keep_recent, max_summary_tokens=max_summary)
|
|
74
|
+
|
|
75
|
+
|
|
76
|
+
def should_compact_threshold(
|
|
77
|
+
*,
|
|
78
|
+
session: Session,
|
|
79
|
+
config: CompactionConfig | None,
|
|
80
|
+
llm_config: llm_param.LLMConfigParameter,
|
|
81
|
+
) -> bool:
|
|
82
|
+
compaction_config = config or _resolve_compaction_config(llm_config)
|
|
83
|
+
context_limit = llm_config.context_limit or _get_last_context_limit(session)
|
|
84
|
+
if context_limit is None:
|
|
85
|
+
return False
|
|
86
|
+
tokens_before = _get_last_context_tokens(session)
|
|
87
|
+
# After compaction, the last successful assistant usage reflects the pre-compaction
|
|
88
|
+
# context window. For threshold checks we want the *current* LLM-facing view.
|
|
89
|
+
if tokens_before is None or _has_compaction_after_last_successful_usage(session):
|
|
90
|
+
tokens_before = _estimate_history_tokens(session.get_llm_history())
|
|
91
|
+
return tokens_before > context_limit - compaction_config.reserve_tokens
|
|
92
|
+
|
|
93
|
+
|
|
94
|
+
def _has_compaction_after_last_successful_usage(session: Session) -> bool:
|
|
95
|
+
"""Return True if the newest compaction entry is newer than the last usable assistant usage.
|
|
96
|
+
|
|
97
|
+
In that case, usage.context_size is stale for threshold decisions.
|
|
98
|
+
"""
|
|
99
|
+
|
|
100
|
+
history = session.conversation_history
|
|
101
|
+
|
|
102
|
+
last_compaction_idx = -1
|
|
103
|
+
for idx in range(len(history) - 1, -1, -1):
|
|
104
|
+
if isinstance(history[idx], message.CompactionEntry):
|
|
105
|
+
last_compaction_idx = idx
|
|
106
|
+
break
|
|
107
|
+
|
|
108
|
+
if last_compaction_idx < 0:
|
|
109
|
+
return False
|
|
110
|
+
|
|
111
|
+
last_usage_idx = -1
|
|
112
|
+
for idx in range(len(history) - 1, -1, -1):
|
|
113
|
+
item = history[idx]
|
|
114
|
+
if not isinstance(item, message.AssistantMessage):
|
|
115
|
+
continue
|
|
116
|
+
if item.usage is None:
|
|
117
|
+
continue
|
|
118
|
+
if item.stop_reason in {"aborted", "error"}:
|
|
119
|
+
continue
|
|
120
|
+
last_usage_idx = idx
|
|
121
|
+
break
|
|
122
|
+
|
|
123
|
+
if last_usage_idx < 0:
|
|
124
|
+
return True
|
|
125
|
+
|
|
126
|
+
return last_compaction_idx > last_usage_idx
|
|
127
|
+
|
|
128
|
+
|
|
129
|
+
async def run_compaction(
|
|
130
|
+
*,
|
|
131
|
+
session: Session,
|
|
132
|
+
reason: CompactionReason,
|
|
133
|
+
focus: str | None,
|
|
134
|
+
llm_client: LLMClientABC,
|
|
135
|
+
llm_config: llm_param.LLMConfigParameter,
|
|
136
|
+
cancel: asyncio.Event | None = None,
|
|
137
|
+
) -> CompactionResult:
|
|
138
|
+
del reason
|
|
139
|
+
if cancel is not None and cancel.is_set():
|
|
140
|
+
raise asyncio.CancelledError
|
|
141
|
+
|
|
142
|
+
compaction_config = _resolve_compaction_config(llm_config)
|
|
143
|
+
history = session.conversation_history
|
|
144
|
+
if not history:
|
|
145
|
+
raise ValueError("No conversation history to compact")
|
|
146
|
+
_, last_compaction = _find_last_compaction(history)
|
|
147
|
+
base_start_index = last_compaction.first_kept_index if last_compaction else 0
|
|
148
|
+
|
|
149
|
+
cut_index = _find_cut_index(history, base_start_index, compaction_config.keep_recent_tokens)
|
|
150
|
+
cut_index = _adjust_cut_index(history, cut_index, base_start_index)
|
|
151
|
+
|
|
152
|
+
if cut_index <= base_start_index:
|
|
153
|
+
raise ValueError("Nothing to compact (session too small)")
|
|
154
|
+
|
|
155
|
+
previous_summary = last_compaction.summary if last_compaction else None
|
|
156
|
+
tokens_before = _get_last_context_tokens(session)
|
|
157
|
+
if tokens_before is None:
|
|
158
|
+
tokens_before = _estimate_history_tokens(history)
|
|
159
|
+
|
|
160
|
+
split_task = _is_split_task(history, base_start_index, cut_index)
|
|
161
|
+
task_start_index = _find_task_start_index(history, base_start_index, cut_index) if split_task else -1
|
|
162
|
+
|
|
163
|
+
messages_to_summarize = _collect_messages(history, base_start_index, task_start_index if split_task else cut_index)
|
|
164
|
+
task_prefix_messages: list[message.Message] = []
|
|
165
|
+
if split_task and task_start_index >= 0:
|
|
166
|
+
task_prefix_messages = _collect_messages(history, task_start_index, cut_index)
|
|
167
|
+
|
|
168
|
+
if not messages_to_summarize and not task_prefix_messages and not previous_summary:
|
|
169
|
+
raise ValueError("Nothing to compact (no messages to summarize)")
|
|
170
|
+
|
|
171
|
+
if cancel is not None and cancel.is_set():
|
|
172
|
+
raise asyncio.CancelledError
|
|
173
|
+
|
|
174
|
+
summary = await _build_summary(
|
|
175
|
+
messages_to_summarize=messages_to_summarize,
|
|
176
|
+
task_prefix_messages=task_prefix_messages,
|
|
177
|
+
previous_summary=previous_summary,
|
|
178
|
+
focus=focus,
|
|
179
|
+
llm_client=llm_client,
|
|
180
|
+
config=compaction_config,
|
|
181
|
+
cancel=cancel,
|
|
182
|
+
)
|
|
183
|
+
|
|
184
|
+
file_ops = _collect_file_operations(
|
|
185
|
+
session=session,
|
|
186
|
+
summarized_messages=messages_to_summarize,
|
|
187
|
+
task_prefix_messages=task_prefix_messages,
|
|
188
|
+
previous_details=last_compaction.details if last_compaction else None,
|
|
189
|
+
)
|
|
190
|
+
summary += _format_file_operations(file_ops.read_files, file_ops.modified_files)
|
|
191
|
+
|
|
192
|
+
kept_items_brief = _collect_kept_items_brief(history, cut_index)
|
|
193
|
+
|
|
194
|
+
return CompactionResult(
|
|
195
|
+
summary=summary,
|
|
196
|
+
first_kept_index=cut_index,
|
|
197
|
+
tokens_before=tokens_before,
|
|
198
|
+
details=file_ops,
|
|
199
|
+
kept_items_brief=kept_items_brief,
|
|
200
|
+
)
|
|
201
|
+
|
|
202
|
+
|
|
203
|
+
def _collect_kept_items_brief(history: list[message.HistoryEvent], cut_index: int) -> list[message.KeptItemBrief]:
|
|
204
|
+
"""Extract brief info about kept (non-compacted) messages."""
|
|
205
|
+
items: list[message.KeptItemBrief] = []
|
|
206
|
+
tool_counts: dict[str, int] = {}
|
|
207
|
+
|
|
208
|
+
def _flush_tool_counts() -> None:
|
|
209
|
+
for tool_name, count in tool_counts.items():
|
|
210
|
+
items.append(message.KeptItemBrief(item_type=tool_name, count=count))
|
|
211
|
+
tool_counts.clear()
|
|
212
|
+
|
|
213
|
+
def _get_preview(text: str, max_len: int = 30) -> str:
|
|
214
|
+
text = text.strip().replace("\n", " ")
|
|
215
|
+
if len(text) > max_len:
|
|
216
|
+
return text[:max_len] + "..."
|
|
217
|
+
return text
|
|
218
|
+
|
|
219
|
+
for idx in range(cut_index, len(history)):
|
|
220
|
+
item = history[idx]
|
|
221
|
+
if isinstance(item, message.CompactionEntry):
|
|
222
|
+
continue
|
|
223
|
+
|
|
224
|
+
if isinstance(item, message.UserMessage):
|
|
225
|
+
_flush_tool_counts()
|
|
226
|
+
text = _join_text_parts(item.parts)
|
|
227
|
+
items.append(message.KeptItemBrief(item_type="User", preview=_get_preview(text)))
|
|
228
|
+
|
|
229
|
+
elif isinstance(item, message.AssistantMessage):
|
|
230
|
+
_flush_tool_counts()
|
|
231
|
+
text = _join_text_parts(item.parts)
|
|
232
|
+
if text.strip():
|
|
233
|
+
items.append(message.KeptItemBrief(item_type="Assistant", preview=_get_preview(text)))
|
|
234
|
+
|
|
235
|
+
elif isinstance(item, message.ToolResultMessage):
|
|
236
|
+
tool_name = _normalize_tool_name(str(item.tool_name))
|
|
237
|
+
tool_counts[tool_name] = tool_counts.get(tool_name, 0) + 1
|
|
238
|
+
|
|
239
|
+
_flush_tool_counts()
|
|
240
|
+
return items
|
|
241
|
+
|
|
242
|
+
|
|
243
|
+
def _normalize_tool_name(tool_name: str) -> str:
|
|
244
|
+
"""Return tool name as-is (no normalization).
|
|
245
|
+
|
|
246
|
+
We intentionally avoid enumerating tool names here; display should reflect
|
|
247
|
+
what was recorded in history.
|
|
248
|
+
"""
|
|
249
|
+
|
|
250
|
+
return tool_name.strip()
|
|
251
|
+
|
|
252
|
+
|
|
253
|
+
def _call_args_probably_modify_file(args: dict[str, object]) -> bool:
|
|
254
|
+
"""Heuristically detect file modifications from tool call arguments.
|
|
255
|
+
|
|
256
|
+
This avoids enumerating tool names; we infer intent from argument structure.
|
|
257
|
+
"""
|
|
258
|
+
|
|
259
|
+
# Common edit signature.
|
|
260
|
+
if "old" in args and "new" in args:
|
|
261
|
+
return True
|
|
262
|
+
# Common write signature.
|
|
263
|
+
if "content" in args:
|
|
264
|
+
return True
|
|
265
|
+
# Common apply_patch signature.
|
|
266
|
+
patch = args.get("patch")
|
|
267
|
+
if isinstance(patch, str) and "*** Begin Patch" in patch:
|
|
268
|
+
return True
|
|
269
|
+
# Batch edits.
|
|
270
|
+
edits = args.get("edits")
|
|
271
|
+
if isinstance(edits, list):
|
|
272
|
+
return True
|
|
273
|
+
return False
|
|
274
|
+
|
|
275
|
+
|
|
276
|
+
def _collect_file_operations(
|
|
277
|
+
*,
|
|
278
|
+
session: Session,
|
|
279
|
+
summarized_messages: list[message.Message],
|
|
280
|
+
task_prefix_messages: list[message.Message],
|
|
281
|
+
previous_details: message.CompactionDetails | None,
|
|
282
|
+
) -> message.CompactionDetails:
|
|
283
|
+
read_set: set[str] = set()
|
|
284
|
+
modified_set: set[str] = set()
|
|
285
|
+
|
|
286
|
+
if previous_details is not None:
|
|
287
|
+
read_set.update(previous_details.read_files)
|
|
288
|
+
modified_set.update(previous_details.modified_files)
|
|
289
|
+
|
|
290
|
+
for path in session.file_tracker:
|
|
291
|
+
read_set.add(path)
|
|
292
|
+
|
|
293
|
+
for msg in (*summarized_messages, *task_prefix_messages):
|
|
294
|
+
if isinstance(msg, message.AssistantMessage):
|
|
295
|
+
_extract_file_ops_from_tool_calls(msg, read_set, modified_set)
|
|
296
|
+
if isinstance(msg, message.ToolResultMessage):
|
|
297
|
+
_extract_modified_files_from_tool_result(msg, modified_set)
|
|
298
|
+
|
|
299
|
+
read_files = sorted(read_set - modified_set)
|
|
300
|
+
modified_files = sorted(modified_set)
|
|
301
|
+
return message.CompactionDetails(read_files=read_files, modified_files=modified_files)
|
|
302
|
+
|
|
303
|
+
|
|
304
|
+
def _extract_file_ops_from_tool_calls(
|
|
305
|
+
msg: message.AssistantMessage, read_set: set[str], modified_set: set[str]
|
|
306
|
+
) -> None:
|
|
307
|
+
for part in msg.parts:
|
|
308
|
+
if not isinstance(part, message.ToolCallPart):
|
|
309
|
+
continue
|
|
310
|
+
try:
|
|
311
|
+
args = json.loads(part.arguments_json)
|
|
312
|
+
except json.JSONDecodeError:
|
|
313
|
+
continue
|
|
314
|
+
if not isinstance(args, dict):
|
|
315
|
+
continue
|
|
316
|
+
args_dict = cast(dict[str, object], args)
|
|
317
|
+
path = args_dict.get("file_path") or args_dict.get("path")
|
|
318
|
+
if not isinstance(path, str):
|
|
319
|
+
continue
|
|
320
|
+
|
|
321
|
+
# Always track referenced paths as read context.
|
|
322
|
+
read_set.add(path)
|
|
323
|
+
|
|
324
|
+
# Detect modifications via argument structure (no tool name enumeration).
|
|
325
|
+
if _call_args_probably_modify_file(args_dict):
|
|
326
|
+
modified_set.add(path)
|
|
327
|
+
|
|
328
|
+
|
|
329
|
+
def _extract_modified_files_from_tool_result(msg: message.ToolResultMessage, modified_set: set[str]) -> None:
|
|
330
|
+
ui_extra = msg.ui_extra
|
|
331
|
+
if ui_extra is None:
|
|
332
|
+
return
|
|
333
|
+
match ui_extra:
|
|
334
|
+
case model.DiffUIExtra() as diff:
|
|
335
|
+
modified_set.update(file.file_path for file in diff.files)
|
|
336
|
+
case model.MarkdownDocUIExtra() as doc:
|
|
337
|
+
modified_set.add(doc.file_path)
|
|
338
|
+
case model.MultiUIExtra() as multi:
|
|
339
|
+
for item in multi.items:
|
|
340
|
+
if isinstance(item, model.DiffUIExtra):
|
|
341
|
+
modified_set.update(file.file_path for file in item.files)
|
|
342
|
+
elif isinstance(item, model.MarkdownDocUIExtra):
|
|
343
|
+
modified_set.add(item.file_path)
|
|
344
|
+
case _:
|
|
345
|
+
pass
|
|
346
|
+
|
|
347
|
+
|
|
348
|
+
def _format_file_operations(read_files: list[str], modified_files: list[str]) -> str:
|
|
349
|
+
sections: list[str] = []
|
|
350
|
+
if read_files:
|
|
351
|
+
sections.append("<read-files>\n" + "\n".join(read_files) + "\n</read-files>")
|
|
352
|
+
if modified_files:
|
|
353
|
+
sections.append("<modified-files>\n" + "\n".join(modified_files) + "\n</modified-files>")
|
|
354
|
+
if not sections:
|
|
355
|
+
return ""
|
|
356
|
+
return "\n\n" + "\n\n".join(sections)
|
|
357
|
+
|
|
358
|
+
|
|
359
|
+
def _find_last_compaction(
|
|
360
|
+
history: list[message.HistoryEvent],
|
|
361
|
+
) -> tuple[int, message.CompactionEntry | None]:
|
|
362
|
+
for idx in range(len(history) - 1, -1, -1):
|
|
363
|
+
item = history[idx]
|
|
364
|
+
if isinstance(item, message.CompactionEntry):
|
|
365
|
+
return idx, item
|
|
366
|
+
return -1, None
|
|
367
|
+
|
|
368
|
+
|
|
369
|
+
def _find_cut_index(history: list[message.HistoryEvent], start_index: int, keep_recent_tokens: int) -> int:
|
|
370
|
+
tokens = 0
|
|
371
|
+
cut_index = start_index
|
|
372
|
+
for idx in range(len(history) - 1, start_index - 1, -1):
|
|
373
|
+
item = history[idx]
|
|
374
|
+
if isinstance(item, message.CompactionEntry):
|
|
375
|
+
continue
|
|
376
|
+
if isinstance(item, message.Message):
|
|
377
|
+
tokens += _estimate_tokens(item)
|
|
378
|
+
# Never cut on a tool result; keeping tool results without their corresponding
|
|
379
|
+
# assistant tool call breaks LLM-facing history.
|
|
380
|
+
if (
|
|
381
|
+
tokens >= keep_recent_tokens
|
|
382
|
+
and isinstance(item, message.Message)
|
|
383
|
+
and not isinstance(item, message.ToolResultMessage)
|
|
384
|
+
):
|
|
385
|
+
cut_index = idx
|
|
386
|
+
break
|
|
387
|
+
return cut_index
|
|
388
|
+
|
|
389
|
+
|
|
390
|
+
def _adjust_cut_index(history: list[message.HistoryEvent], cut_index: int, start_index: int) -> int:
|
|
391
|
+
if not history:
|
|
392
|
+
return 0
|
|
393
|
+
if cut_index < start_index:
|
|
394
|
+
return start_index
|
|
395
|
+
|
|
396
|
+
def _skip_leading_tool_results(idx: int) -> int:
|
|
397
|
+
while idx < len(history) and isinstance(history[idx], message.ToolResultMessage):
|
|
398
|
+
idx += 1
|
|
399
|
+
return idx
|
|
400
|
+
|
|
401
|
+
# Prefer moving the cut backwards to include the assistant tool call.
|
|
402
|
+
while cut_index > start_index and isinstance(history[cut_index], message.ToolResultMessage):
|
|
403
|
+
cut_index -= 1
|
|
404
|
+
|
|
405
|
+
# If we cannot move backwards enough (e.g. start_index is itself a tool result due to
|
|
406
|
+
# old persisted sessions), move forward to avoid starting kept history with tool results.
|
|
407
|
+
if isinstance(history[cut_index], message.ToolResultMessage):
|
|
408
|
+
forward = _skip_leading_tool_results(cut_index)
|
|
409
|
+
if forward < len(history):
|
|
410
|
+
cut_index = forward
|
|
411
|
+
|
|
412
|
+
if isinstance(history[cut_index], message.DeveloperMessage):
|
|
413
|
+
forward = _find_anchor_index(history, cut_index + 1, forward=True)
|
|
414
|
+
if forward is not None:
|
|
415
|
+
return forward
|
|
416
|
+
backward = _find_anchor_index(history, cut_index - 1, forward=False)
|
|
417
|
+
if backward is not None:
|
|
418
|
+
return backward
|
|
419
|
+
|
|
420
|
+
return cut_index
|
|
421
|
+
|
|
422
|
+
|
|
423
|
+
def _find_anchor_index(history: list[message.HistoryEvent], start: int, *, forward: bool) -> int | None:
|
|
424
|
+
indices = range(start, len(history)) if forward else range(start, -1, -1)
|
|
425
|
+
for idx in indices:
|
|
426
|
+
item = history[idx]
|
|
427
|
+
if isinstance(item, (message.UserMessage, message.ToolResultMessage)):
|
|
428
|
+
return idx
|
|
429
|
+
return None
|
|
430
|
+
|
|
431
|
+
|
|
432
|
+
def _is_split_task(history: list[message.HistoryEvent], start_index: int, cut_index: int) -> bool:
|
|
433
|
+
if cut_index <= start_index:
|
|
434
|
+
return False
|
|
435
|
+
if isinstance(history[cut_index], message.UserMessage):
|
|
436
|
+
return False
|
|
437
|
+
task_start_index = _find_task_start_index(history, start_index, cut_index)
|
|
438
|
+
return task_start_index >= 0
|
|
439
|
+
|
|
440
|
+
|
|
441
|
+
def _find_task_start_index(history: list[message.HistoryEvent], start_index: int, cut_index: int) -> int:
|
|
442
|
+
for idx in range(cut_index, start_index - 1, -1):
|
|
443
|
+
if isinstance(history[idx], message.UserMessage):
|
|
444
|
+
return idx
|
|
445
|
+
return -1
|
|
446
|
+
|
|
447
|
+
|
|
448
|
+
def _collect_messages(history: list[message.HistoryEvent], start_index: int, end_index: int) -> list[message.Message]:
|
|
449
|
+
if end_index < start_index:
|
|
450
|
+
return []
|
|
451
|
+
return [
|
|
452
|
+
item
|
|
453
|
+
for item in history[start_index:end_index]
|
|
454
|
+
if isinstance(item, message.Message) and not isinstance(item, message.SystemMessage)
|
|
455
|
+
]
|
|
456
|
+
|
|
457
|
+
|
|
458
|
+
async def _build_summary(
|
|
459
|
+
*,
|
|
460
|
+
messages_to_summarize: list[message.Message],
|
|
461
|
+
task_prefix_messages: list[message.Message],
|
|
462
|
+
previous_summary: str | None,
|
|
463
|
+
focus: str | None,
|
|
464
|
+
llm_client: LLMClientABC,
|
|
465
|
+
config: CompactionConfig,
|
|
466
|
+
cancel: asyncio.Event | None,
|
|
467
|
+
) -> str:
|
|
468
|
+
if cancel is not None and cancel.is_set():
|
|
469
|
+
raise asyncio.CancelledError
|
|
470
|
+
|
|
471
|
+
if task_prefix_messages:
|
|
472
|
+
history_task = (
|
|
473
|
+
_generate_summary(
|
|
474
|
+
messages_to_summarize,
|
|
475
|
+
llm_client,
|
|
476
|
+
config,
|
|
477
|
+
focus,
|
|
478
|
+
previous_summary,
|
|
479
|
+
cancel,
|
|
480
|
+
)
|
|
481
|
+
if messages_to_summarize or previous_summary
|
|
482
|
+
else asyncio.sleep(0, result="No prior history.")
|
|
483
|
+
)
|
|
484
|
+
prefix_task = _generate_task_prefix_summary(task_prefix_messages, llm_client, config, cancel)
|
|
485
|
+
history_summary, task_prefix_summary = await asyncio.gather(history_task, prefix_task)
|
|
486
|
+
return f"{COMPACTION_SUMMARY_PREFIX}\n\n<summary>{history_summary}\n\n---\n\n**Task Context (split task):**\n\n{task_prefix_summary}\n\n</summary>"
|
|
487
|
+
|
|
488
|
+
return await _generate_summary(
|
|
489
|
+
messages_to_summarize,
|
|
490
|
+
llm_client,
|
|
491
|
+
config,
|
|
492
|
+
focus,
|
|
493
|
+
previous_summary,
|
|
494
|
+
cancel,
|
|
495
|
+
)
|
|
496
|
+
|
|
497
|
+
|
|
498
|
+
async def _generate_summary(
|
|
499
|
+
messages_to_summarize: list[message.Message],
|
|
500
|
+
llm_client: LLMClientABC,
|
|
501
|
+
config: CompactionConfig,
|
|
502
|
+
focus: str | None,
|
|
503
|
+
previous_summary: str | None,
|
|
504
|
+
cancel: asyncio.Event | None,
|
|
505
|
+
) -> str:
|
|
506
|
+
serialized = _serialize_conversation(messages_to_summarize)
|
|
507
|
+
parts: list[message.Part] = [
|
|
508
|
+
message.TextPart(text=f"<conversation>\n{serialized}\n</conversation>"),
|
|
509
|
+
]
|
|
510
|
+
if previous_summary:
|
|
511
|
+
parts.append(
|
|
512
|
+
message.TextPart(text=f"\n\n<previous-summary>\n{previous_summary}\n</previous-summary>"),
|
|
513
|
+
)
|
|
514
|
+
base_prompt = UPDATE_SUMMARIZATION_PROMPT
|
|
515
|
+
else:
|
|
516
|
+
base_prompt = SUMMARIZATION_PROMPT
|
|
517
|
+
parts.append(
|
|
518
|
+
message.TextPart(text=f"\n\n<instructions>\n{base_prompt}\n</instructions>"),
|
|
519
|
+
)
|
|
520
|
+
if focus:
|
|
521
|
+
parts.append(
|
|
522
|
+
message.TextPart(text=f"\n\nAdditional focus: {focus}"),
|
|
523
|
+
)
|
|
524
|
+
return await _call_summarizer(
|
|
525
|
+
input=[message.UserMessage(parts=parts)],
|
|
526
|
+
llm_client=llm_client,
|
|
527
|
+
max_tokens=config.max_summary_tokens,
|
|
528
|
+
cancel=cancel,
|
|
529
|
+
)
|
|
530
|
+
|
|
531
|
+
|
|
532
|
+
async def _generate_task_prefix_summary(
|
|
533
|
+
messages: list[message.Message],
|
|
534
|
+
llm_client: LLMClientABC,
|
|
535
|
+
config: CompactionConfig,
|
|
536
|
+
cancel: asyncio.Event | None,
|
|
537
|
+
) -> str:
|
|
538
|
+
serialized = _serialize_conversation(messages)
|
|
539
|
+
return await _call_summarizer(
|
|
540
|
+
input=[
|
|
541
|
+
message.UserMessage(
|
|
542
|
+
parts=[
|
|
543
|
+
message.TextPart(text=f"<conversation>\n{serialized}\n</conversation>\n\n"),
|
|
544
|
+
message.TextPart(text=TASK_PREFIX_SUMMARIZATION_PROMPT),
|
|
545
|
+
]
|
|
546
|
+
)
|
|
547
|
+
],
|
|
548
|
+
llm_client=llm_client,
|
|
549
|
+
max_tokens=config.max_summary_tokens,
|
|
550
|
+
cancel=cancel,
|
|
551
|
+
)
|
|
552
|
+
|
|
553
|
+
|
|
554
|
+
async def _call_summarizer(
|
|
555
|
+
*,
|
|
556
|
+
input: list[message.Message],
|
|
557
|
+
llm_client: LLMClientABC,
|
|
558
|
+
max_tokens: int,
|
|
559
|
+
cancel: asyncio.Event | None,
|
|
560
|
+
) -> str:
|
|
561
|
+
if cancel is not None and cancel.is_set():
|
|
562
|
+
raise asyncio.CancelledError
|
|
563
|
+
|
|
564
|
+
call_param = llm_param.LLMCallParameter(
|
|
565
|
+
input=input,
|
|
566
|
+
system=SUMMARIZATION_SYSTEM_PROMPT,
|
|
567
|
+
session_id=None,
|
|
568
|
+
)
|
|
569
|
+
call_param.max_tokens = max_tokens
|
|
570
|
+
call_param.tools = None
|
|
571
|
+
|
|
572
|
+
stream = await llm_client.call(call_param)
|
|
573
|
+
accumulated: list[str] = []
|
|
574
|
+
final_text: str | None = None
|
|
575
|
+
async for item in stream:
|
|
576
|
+
if isinstance(item, message.AssistantTextDelta):
|
|
577
|
+
accumulated.append(item.content)
|
|
578
|
+
elif isinstance(item, message.StreamErrorItem):
|
|
579
|
+
raise RuntimeError(item.error)
|
|
580
|
+
elif isinstance(item, message.AssistantMessage):
|
|
581
|
+
final_text = message.join_text_parts(item.parts)
|
|
582
|
+
|
|
583
|
+
if cancel is not None and cancel.is_set():
|
|
584
|
+
raise asyncio.CancelledError
|
|
585
|
+
|
|
586
|
+
text = final_text if final_text is not None else "".join(accumulated)
|
|
587
|
+
if not text.strip():
|
|
588
|
+
raise ValueError("Summarizer returned empty output")
|
|
589
|
+
return text.strip()
|
|
590
|
+
|
|
591
|
+
|
|
592
|
+
def _serialize_conversation(messages: list[message.Message]) -> str:
|
|
593
|
+
parts: list[str] = []
|
|
594
|
+
for msg in messages:
|
|
595
|
+
if isinstance(msg, message.UserMessage):
|
|
596
|
+
text = _join_text_parts(msg.parts)
|
|
597
|
+
if not text:
|
|
598
|
+
text = _render_images(msg.parts)
|
|
599
|
+
if text:
|
|
600
|
+
parts.append(f"[User]: {text}")
|
|
601
|
+
elif isinstance(msg, message.AssistantMessage):
|
|
602
|
+
text_parts: list[str] = []
|
|
603
|
+
thinking_parts: list[str] = []
|
|
604
|
+
tool_calls: list[str] = []
|
|
605
|
+
for part in msg.parts:
|
|
606
|
+
if isinstance(part, message.TextPart):
|
|
607
|
+
text_parts.append(part.text)
|
|
608
|
+
elif isinstance(part, message.ThinkingTextPart):
|
|
609
|
+
thinking_parts.append(part.text)
|
|
610
|
+
elif isinstance(part, message.ToolCallPart):
|
|
611
|
+
args = _truncate_text(part.arguments_json, _MAX_TOOL_CALL_CHARS)
|
|
612
|
+
tool_calls.append(f"{part.tool_name}({args})")
|
|
613
|
+
if thinking_parts:
|
|
614
|
+
parts.append("[Assistant thinking]: " + "\n".join(thinking_parts))
|
|
615
|
+
if text_parts:
|
|
616
|
+
parts.append("[Assistant]: " + "\n".join(text_parts))
|
|
617
|
+
if tool_calls:
|
|
618
|
+
parts.append("[Assistant tool calls]: " + "; ".join(tool_calls))
|
|
619
|
+
elif isinstance(msg, message.ToolResultMessage):
|
|
620
|
+
content = _truncate_text(msg.output_text, _MAX_TOOL_OUTPUT_CHARS)
|
|
621
|
+
if content:
|
|
622
|
+
parts.append(f"[Tool result]: {content}")
|
|
623
|
+
elif isinstance(msg, message.DeveloperMessage):
|
|
624
|
+
text = _join_text_parts(msg.parts)
|
|
625
|
+
if text:
|
|
626
|
+
parts.append(f"[Developer]: {text}")
|
|
627
|
+
else: # SystemMessage
|
|
628
|
+
text = _join_text_parts(msg.parts)
|
|
629
|
+
if text:
|
|
630
|
+
parts.append(f"[System]: {text}")
|
|
631
|
+
return "\n\n".join(parts)
|
|
632
|
+
|
|
633
|
+
|
|
634
|
+
def _join_text_parts(parts: Sequence[message.Part]) -> str:
|
|
635
|
+
return "".join(part.text for part in parts if isinstance(part, message.TextPart))
|
|
636
|
+
|
|
637
|
+
|
|
638
|
+
def _render_images(parts: Sequence[message.Part]) -> str:
|
|
639
|
+
images: list[str] = []
|
|
640
|
+
for part in parts:
|
|
641
|
+
if isinstance(part, message.ImageURLPart):
|
|
642
|
+
images.append(part.url)
|
|
643
|
+
elif isinstance(part, message.ImageFilePart):
|
|
644
|
+
images.append(part.file_path)
|
|
645
|
+
if not images:
|
|
646
|
+
return ""
|
|
647
|
+
return "image: " + ", ".join(images)
|
|
648
|
+
|
|
649
|
+
|
|
650
|
+
def _truncate_text(text: str, max_chars: int) -> str:
|
|
651
|
+
if len(text) <= max_chars:
|
|
652
|
+
return text
|
|
653
|
+
return text[:max_chars] + "...(truncated)"
|
|
654
|
+
|
|
655
|
+
|
|
656
|
+
def _estimate_history_tokens(history: list[message.HistoryEvent]) -> int:
|
|
657
|
+
return sum(_estimate_tokens(item) for item in history if isinstance(item, message.Message))
|
|
658
|
+
|
|
659
|
+
|
|
660
|
+
def _estimate_tokens(msg: message.Message) -> int:
|
|
661
|
+
chars = 0
|
|
662
|
+
if isinstance(msg, message.UserMessage):
|
|
663
|
+
chars = sum(len(part.text) for part in msg.parts if isinstance(part, message.TextPart))
|
|
664
|
+
chars += _count_image_tokens(msg.parts)
|
|
665
|
+
elif isinstance(msg, message.AssistantMessage):
|
|
666
|
+
for part in msg.parts:
|
|
667
|
+
if isinstance(part, (message.TextPart, message.ThinkingTextPart)):
|
|
668
|
+
chars += len(part.text)
|
|
669
|
+
elif isinstance(part, message.ToolCallPart):
|
|
670
|
+
chars += len(part.tool_name) + len(part.arguments_json)
|
|
671
|
+
elif isinstance(msg, message.ToolResultMessage):
|
|
672
|
+
chars += len(msg.output_text)
|
|
673
|
+
chars += _count_image_tokens(msg.parts)
|
|
674
|
+
else: # DeveloperMessage or SystemMessage
|
|
675
|
+
chars += sum(len(part.text) for part in msg.parts if isinstance(part, message.TextPart))
|
|
676
|
+
return max(1, (chars + 3) // 4)
|
|
677
|
+
|
|
678
|
+
|
|
679
|
+
def _count_image_tokens(parts: list[message.Part]) -> int:
|
|
680
|
+
count = sum(1 for part in parts if isinstance(part, (message.ImageURLPart, message.ImageFilePart)))
|
|
681
|
+
return count * _DEFAULT_IMAGE_TOKENS
|
|
682
|
+
|
|
683
|
+
|
|
684
|
+
def _get_last_context_tokens(session: Session) -> int | None:
|
|
685
|
+
for item in reversed(session.conversation_history):
|
|
686
|
+
if not isinstance(item, message.AssistantMessage):
|
|
687
|
+
continue
|
|
688
|
+
if item.usage is None:
|
|
689
|
+
continue
|
|
690
|
+
if item.stop_reason in {"aborted", "error"}:
|
|
691
|
+
continue
|
|
692
|
+
usage = item.usage
|
|
693
|
+
if usage.context_size is not None:
|
|
694
|
+
return usage.context_size
|
|
695
|
+
return usage.total_tokens
|
|
696
|
+
return None
|
|
697
|
+
|
|
698
|
+
|
|
699
|
+
def _get_last_context_limit(session: Session) -> int | None:
|
|
700
|
+
for item in reversed(session.conversation_history):
|
|
701
|
+
if not isinstance(item, message.AssistantMessage):
|
|
702
|
+
continue
|
|
703
|
+
if item.usage is None:
|
|
704
|
+
continue
|
|
705
|
+
if item.usage.context_limit is not None:
|
|
706
|
+
return item.usage.context_limit
|
|
707
|
+
return None
|