tau-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.
Files changed (283) hide show
  1. tau/__init__.py +0 -0
  2. tau/agent/__init__.py +11 -0
  3. tau/agent/prompt/__init__.py +10 -0
  4. tau/agent/prompt/builder.py +302 -0
  5. tau/agent/prompt/types.py +33 -0
  6. tau/agent/service.py +369 -0
  7. tau/agent/types.py +61 -0
  8. tau/auth/manager.py +247 -0
  9. tau/auth/storage.py +82 -0
  10. tau/auth/types.py +41 -0
  11. tau/builtins/__init__.py +4 -0
  12. tau/builtins/__pycache__/__init__.cpython-313.pyc +0 -0
  13. tau/builtins/__pycache__/__init__.cpython-314.pyc +0 -0
  14. tau/builtins/commands/__init__.py +41 -0
  15. tau/builtins/commands/__pycache__/__init__.cpython-313.pyc +0 -0
  16. tau/builtins/commands/__pycache__/__init__.cpython-314.pyc +0 -0
  17. tau/builtins/commands/__pycache__/clear.cpython-313.pyc +0 -0
  18. tau/builtins/commands/__pycache__/clear.cpython-314.pyc +0 -0
  19. tau/builtins/commands/__pycache__/compact.cpython-313.pyc +0 -0
  20. tau/builtins/commands/__pycache__/compact.cpython-314.pyc +0 -0
  21. tau/builtins/commands/__pycache__/reload.cpython-313.pyc +0 -0
  22. tau/builtins/commands/__pycache__/reload.cpython-314.pyc +0 -0
  23. tau/builtins/commands/__pycache__/session.cpython-313.pyc +0 -0
  24. tau/builtins/commands/__pycache__/session.cpython-314.pyc +0 -0
  25. tau/builtins/commands/clear.py +16 -0
  26. tau/builtins/commands/compact.py +28 -0
  27. tau/builtins/commands/reload.py +27 -0
  28. tau/builtins/commands/session.py +19 -0
  29. tau/builtins/extensions/footer/__init__.py +76 -0
  30. tau/builtins/extensions/footer/__pycache__/__init__.cpython-313.pyc +0 -0
  31. tau/builtins/extensions/footer/__pycache__/git.cpython-313.pyc +0 -0
  32. tau/builtins/extensions/footer/__pycache__/model.cpython-313.pyc +0 -0
  33. tau/builtins/extensions/footer/__pycache__/utils.cpython-313.pyc +0 -0
  34. tau/builtins/extensions/footer/git.py +26 -0
  35. tau/builtins/extensions/footer/model.py +69 -0
  36. tau/builtins/extensions/footer/utils.py +44 -0
  37. tau/builtins/extensions/header/__init__.py +18 -0
  38. tau/builtins/extensions/header/__pycache__/__init__.cpython-313.pyc +0 -0
  39. tau/builtins/models/__init__.py +0 -0
  40. tau/builtins/models/__pycache__/__init__.cpython-313.pyc +0 -0
  41. tau/builtins/models/__pycache__/text.cpython-313.pyc +0 -0
  42. tau/builtins/models/audio.py +43 -0
  43. tau/builtins/models/image.py +43 -0
  44. tau/builtins/models/text.py +482 -0
  45. tau/builtins/models/video.py +40 -0
  46. tau/builtins/prompts/commit.md +7 -0
  47. tau/builtins/prompts/docs.md +7 -0
  48. tau/builtins/prompts/explain.md +7 -0
  49. tau/builtins/prompts/fix.md +7 -0
  50. tau/builtins/prompts/refactor.md +7 -0
  51. tau/builtins/prompts/review.md +7 -0
  52. tau/builtins/prompts/test.md +7 -0
  53. tau/builtins/providers/__init__.py +0 -0
  54. tau/builtins/providers/__pycache__/__init__.cpython-313.pyc +0 -0
  55. tau/builtins/providers/__pycache__/text.cpython-313.pyc +0 -0
  56. tau/builtins/providers/audio.py +10 -0
  57. tau/builtins/providers/image.py +9 -0
  58. tau/builtins/providers/text.py +33 -0
  59. tau/builtins/providers/video.py +6 -0
  60. tau/builtins/skills/code-review/SKILL.md +4 -0
  61. tau/builtins/skills/debug/SKILL.md +4 -0
  62. tau/builtins/skills/git-commit/SKILL.md +4 -0
  63. tau/builtins/themes/dark.yaml +1 -0
  64. tau/builtins/themes/light.yaml +46 -0
  65. tau/builtins/tools/__init__.py +73 -0
  66. tau/builtins/tools/__pycache__/__init__.cpython-313.pyc +0 -0
  67. tau/builtins/tools/__pycache__/__init__.cpython-314.pyc +0 -0
  68. tau/builtins/tools/__pycache__/bash.cpython-313.pyc +0 -0
  69. tau/builtins/tools/__pycache__/bash.cpython-314.pyc +0 -0
  70. tau/builtins/tools/__pycache__/edit.cpython-313.pyc +0 -0
  71. tau/builtins/tools/__pycache__/edit.cpython-314.pyc +0 -0
  72. tau/builtins/tools/__pycache__/glob.cpython-313.pyc +0 -0
  73. tau/builtins/tools/__pycache__/glob.cpython-314.pyc +0 -0
  74. tau/builtins/tools/__pycache__/grep.cpython-313.pyc +0 -0
  75. tau/builtins/tools/__pycache__/grep.cpython-314.pyc +0 -0
  76. tau/builtins/tools/__pycache__/ls.cpython-313.pyc +0 -0
  77. tau/builtins/tools/__pycache__/ls.cpython-314.pyc +0 -0
  78. tau/builtins/tools/__pycache__/read.cpython-313.pyc +0 -0
  79. tau/builtins/tools/__pycache__/read.cpython-314.pyc +0 -0
  80. tau/builtins/tools/__pycache__/terminal.cpython-313.pyc +0 -0
  81. tau/builtins/tools/__pycache__/terminal.cpython-314.pyc +0 -0
  82. tau/builtins/tools/__pycache__/write.cpython-313.pyc +0 -0
  83. tau/builtins/tools/__pycache__/write.cpython-314.pyc +0 -0
  84. tau/builtins/tools/edit.py +215 -0
  85. tau/builtins/tools/glob.py +112 -0
  86. tau/builtins/tools/grep.py +146 -0
  87. tau/builtins/tools/ls.py +135 -0
  88. tau/builtins/tools/read.py +122 -0
  89. tau/builtins/tools/terminal.py +150 -0
  90. tau/builtins/tools/write.py +105 -0
  91. tau/commands/__init__.py +10 -0
  92. tau/commands/registry.py +71 -0
  93. tau/commands/types.py +33 -0
  94. tau/console/__init__.py +0 -0
  95. tau/console/cli.py +266 -0
  96. tau/console/commands/__init__.py +0 -0
  97. tau/console/commands/auth.py +193 -0
  98. tau/console/commands/packages.py +104 -0
  99. tau/console/commands/update.py +76 -0
  100. tau/core/__init__.py +0 -0
  101. tau/core/registry.py +102 -0
  102. tau/engine/__init__.py +47 -0
  103. tau/engine/service.py +768 -0
  104. tau/engine/types.py +163 -0
  105. tau/extensions/__init__.py +28 -0
  106. tau/extensions/api.py +928 -0
  107. tau/extensions/context.py +462 -0
  108. tau/extensions/events.py +70 -0
  109. tau/extensions/loader.py +386 -0
  110. tau/extensions/runtime.py +184 -0
  111. tau/extensions/settings.py +137 -0
  112. tau/hooks/__init__.py +112 -0
  113. tau/hooks/engine.py +237 -0
  114. tau/hooks/inference.py +21 -0
  115. tau/hooks/runtime.py +126 -0
  116. tau/hooks/service.py +121 -0
  117. tau/hooks/session.py +117 -0
  118. tau/hooks/tui.py +61 -0
  119. tau/hooks/types.py +72 -0
  120. tau/inference/__init__.py +180 -0
  121. tau/inference/api/__init__.py +0 -0
  122. tau/inference/api/audio/__init__.py +0 -0
  123. tau/inference/api/audio/base.py +29 -0
  124. tau/inference/api/audio/builtins.py +15 -0
  125. tau/inference/api/audio/elevenlabs_audio.py +183 -0
  126. tau/inference/api/audio/gemini_audio.py +95 -0
  127. tau/inference/api/audio/openai_audio.py +159 -0
  128. tau/inference/api/audio/registry.py +15 -0
  129. tau/inference/api/audio/sarvam_audio.py +163 -0
  130. tau/inference/api/audio/service.py +103 -0
  131. tau/inference/api/audio/utils.py +47 -0
  132. tau/inference/api/image/__init__.py +0 -0
  133. tau/inference/api/image/base.py +17 -0
  134. tau/inference/api/image/builtins.py +8 -0
  135. tau/inference/api/image/gemini_image.py +77 -0
  136. tau/inference/api/image/openai_image.py +103 -0
  137. tau/inference/api/image/openrouter.py +144 -0
  138. tau/inference/api/image/registry.py +15 -0
  139. tau/inference/api/image/service.py +71 -0
  140. tau/inference/api/registry.py +82 -0
  141. tau/inference/api/text/__init__.py +0 -0
  142. tau/inference/api/text/anthropic_claude_code.py +222 -0
  143. tau/inference/api/text/anthropic_messages.py +196 -0
  144. tau/inference/api/text/base.py +40 -0
  145. tau/inference/api/text/builtins.py +19 -0
  146. tau/inference/api/text/gemini_generate.py +234 -0
  147. tau/inference/api/text/github_copilot_chat.py +172 -0
  148. tau/inference/api/text/google_antigravity.py +522 -0
  149. tau/inference/api/text/mistral_chat.py +284 -0
  150. tau/inference/api/text/ollama_chat.py +200 -0
  151. tau/inference/api/text/openai_codex_responses.py +497 -0
  152. tau/inference/api/text/openai_completions.py +227 -0
  153. tau/inference/api/text/openai_responses.py +235 -0
  154. tau/inference/api/text/registry.py +50 -0
  155. tau/inference/api/text/service.py +297 -0
  156. tau/inference/api/text/types.py +7 -0
  157. tau/inference/api/text/utils.py +228 -0
  158. tau/inference/api/video/__init__.py +0 -0
  159. tau/inference/api/video/base.py +26 -0
  160. tau/inference/api/video/builtins.py +7 -0
  161. tau/inference/api/video/fal_video.py +119 -0
  162. tau/inference/api/video/openrouter_video.py +142 -0
  163. tau/inference/api/video/registry.py +15 -0
  164. tau/inference/api/video/service.py +72 -0
  165. tau/inference/model/__init__.py +0 -0
  166. tau/inference/model/registry.py +102 -0
  167. tau/inference/model/types.py +65 -0
  168. tau/inference/provider/__init__.py +0 -0
  169. tau/inference/provider/oauth/__init__.py +35 -0
  170. tau/inference/provider/oauth/anthropic_claude_code.py +286 -0
  171. tau/inference/provider/oauth/github_copilot.py +333 -0
  172. tau/inference/provider/oauth/google_antigravity.py +258 -0
  173. tau/inference/provider/oauth/openai_codex.py +309 -0
  174. tau/inference/provider/oauth/pkce.py +14 -0
  175. tau/inference/provider/oauth/types.py +46 -0
  176. tau/inference/provider/oauth/utils.py +154 -0
  177. tau/inference/provider/registry.py +141 -0
  178. tau/inference/provider/types.py +114 -0
  179. tau/inference/types.py +549 -0
  180. tau/inference/utils.py +219 -0
  181. tau/message/__init__.py +0 -0
  182. tau/message/types.py +482 -0
  183. tau/message/utils.py +178 -0
  184. tau/packages/__init__.py +11 -0
  185. tau/packages/manager.py +190 -0
  186. tau/packages/types.py +20 -0
  187. tau/packages/utils.py +67 -0
  188. tau/prompts/expand.py +58 -0
  189. tau/prompts/loader.py +69 -0
  190. tau/prompts/registry.py +45 -0
  191. tau/prompts/types.py +24 -0
  192. tau/rpc/__init__.py +8 -0
  193. tau/rpc/mode.py +783 -0
  194. tau/rpc/types.py +252 -0
  195. tau/runtime/service.py +759 -0
  196. tau/runtime/types.py +303 -0
  197. tau/session/branch_summarization.py +312 -0
  198. tau/session/compaction.py +646 -0
  199. tau/session/manager.py +652 -0
  200. tau/session/types.py +188 -0
  201. tau/session/utils.py +233 -0
  202. tau/settings/manager.py +1077 -0
  203. tau/settings/paths.py +150 -0
  204. tau/settings/storage.py +63 -0
  205. tau/settings/types.py +173 -0
  206. tau/settings/utils.py +25 -0
  207. tau/skills/loader.py +91 -0
  208. tau/skills/registry.py +70 -0
  209. tau/skills/types.py +25 -0
  210. tau/themes/loader.py +238 -0
  211. tau/themes/registry.py +108 -0
  212. tau/themes/types.py +19 -0
  213. tau/tool/__init__.py +3 -0
  214. tau/tool/registry.py +117 -0
  215. tau/tool/render.py +21 -0
  216. tau/tool/types.py +244 -0
  217. tau/trust/__init__.py +13 -0
  218. tau/trust/manager.py +80 -0
  219. tau/trust/types.py +14 -0
  220. tau/trust/utils.py +72 -0
  221. tau/tui/__init__.py +54 -0
  222. tau/tui/agent_hooks.py +346 -0
  223. tau/tui/ansi.py +330 -0
  224. tau/tui/app.py +540 -0
  225. tau/tui/autocomplete.py +33 -0
  226. tau/tui/capabilities.py +119 -0
  227. tau/tui/commands/__init__.py +3 -0
  228. tau/tui/commands/appearance.py +498 -0
  229. tau/tui/commands/auth.py +232 -0
  230. tau/tui/commands/context.py +38 -0
  231. tau/tui/commands/misc.py +82 -0
  232. tau/tui/commands/model.py +118 -0
  233. tau/tui/commands/session.py +464 -0
  234. tau/tui/component.py +268 -0
  235. tau/tui/components/__init__.py +0 -0
  236. tau/tui/components/autocomplete_manager.py +267 -0
  237. tau/tui/components/autocomplete_picker.py +143 -0
  238. tau/tui/components/box.py +90 -0
  239. tau/tui/components/command_palette.py +144 -0
  240. tau/tui/components/dynamic_border.py +19 -0
  241. tau/tui/components/file_picker.py +233 -0
  242. tau/tui/components/image.py +181 -0
  243. tau/tui/components/inline_selector.py +71 -0
  244. tau/tui/components/layout.py +1194 -0
  245. tau/tui/components/message_list.py +692 -0
  246. tau/tui/components/modal.py +97 -0
  247. tau/tui/components/model_palette.py +204 -0
  248. tau/tui/components/picker_overlay.py +174 -0
  249. tau/tui/components/prompt_overlay.py +236 -0
  250. tau/tui/components/resume_modal.py +372 -0
  251. tau/tui/components/select_list.py +222 -0
  252. tau/tui/components/settings_modal.py +274 -0
  253. tau/tui/components/settings_schema.py +203 -0
  254. tau/tui/components/spinner.py +119 -0
  255. tau/tui/components/text_input.py +396 -0
  256. tau/tui/components/text_prompt.py +82 -0
  257. tau/tui/components/tree_select_list.py +580 -0
  258. tau/tui/components/trust_screen.py +97 -0
  259. tau/tui/diff.py +114 -0
  260. tau/tui/fuzzy.py +99 -0
  261. tau/tui/input.py +496 -0
  262. tau/tui/input_handler.py +716 -0
  263. tau/tui/keybindings.py +87 -0
  264. tau/tui/markdown.py +286 -0
  265. tau/tui/message_renderers.py +31 -0
  266. tau/tui/overlay.py +326 -0
  267. tau/tui/renderer.py +378 -0
  268. tau/tui/terminal.py +499 -0
  269. tau/tui/theme.py +148 -0
  270. tau/tui/tui.py +544 -0
  271. tau/tui/ui_context.py +768 -0
  272. tau/tui/utils.py +20 -0
  273. tau/utils/__init__.py +0 -0
  274. tau/utils/http_proxy.py +221 -0
  275. tau/utils/image_processing.py +172 -0
  276. tau/utils/secrets.py +59 -0
  277. tau/utils/version_check.py +60 -0
  278. tau_coding_agent-0.1.0.dist-info/METADATA +177 -0
  279. tau_coding_agent-0.1.0.dist-info/RECORD +283 -0
  280. tau_coding_agent-0.1.0.dist-info/WHEEL +5 -0
  281. tau_coding_agent-0.1.0.dist-info/entry_points.txt +2 -0
  282. tau_coding_agent-0.1.0.dist-info/licenses/LICENSE +21 -0
  283. tau_coding_agent-0.1.0.dist-info/top_level.txt +1 -0
@@ -0,0 +1,646 @@
1
+ """
2
+ Context compaction for long agent sessions.
3
+
4
+ Compaction algorithm:
5
+ - Backwards-walk cut-point detection (never cuts mid-tool-result)
6
+ - Turn-split handling with a separate prefix summary
7
+ - Iterative summary merging (update prompt on subsequent compactions)
8
+ - File operation tracking appended to summary
9
+ """
10
+ from __future__ import annotations
11
+
12
+ import asyncio
13
+ import json
14
+ from dataclasses import dataclass, field
15
+ from typing import TYPE_CHECKING, Any
16
+
17
+ if TYPE_CHECKING:
18
+ from tau.inference.api.text.service import TextLLM
19
+ from tau.session.types import SessionEntry
20
+
21
+
22
+ # ---------------------------------------------------------------------------
23
+ # Settings
24
+ # ---------------------------------------------------------------------------
25
+
26
+ @dataclass
27
+ class CompactionSettings:
28
+ """Configuration for context compaction behavior."""
29
+ enabled: bool = True
30
+ reserve_tokens: int = 16_384
31
+ keep_recent_tokens: int = 20_000
32
+
33
+
34
+ DEFAULT_COMPACTION_SETTINGS = CompactionSettings()
35
+
36
+ TOOL_RESULT_MAX_CHARS = 2_000
37
+ ESTIMATED_IMAGE_CHARS = 4_800
38
+
39
+
40
+ # ---------------------------------------------------------------------------
41
+ # Result / preparation types
42
+ # ---------------------------------------------------------------------------
43
+
44
+ @dataclass
45
+ class CompactionResult:
46
+ """Result of a compaction operation."""
47
+ summary: str
48
+ first_kept_entry_id: str
49
+ tokens_before: int
50
+ details: dict[str, Any] | None = None
51
+
52
+
53
+ @dataclass
54
+ class CompactionPreparation:
55
+ """Preparation data for a compaction operation."""
56
+ first_kept_entry_id: str
57
+ messages_to_summarize: list
58
+ turn_prefix_messages: list
59
+ is_split_turn: bool
60
+ tokens_before: int
61
+ settings: CompactionSettings
62
+ previous_summary: str | None = None
63
+
64
+
65
+ @dataclass
66
+ class ContextUsageEstimate:
67
+ """Estimate of context token usage."""
68
+ tokens: int
69
+ usage_tokens: int
70
+ trailing_tokens: int
71
+ last_usage_index: int | None
72
+
73
+
74
+ @dataclass
75
+ class CutPointResult:
76
+ """Result of finding a context cut point for compaction."""
77
+ first_kept_entry_index: int
78
+ is_split_turn: bool = False
79
+ turn_start_index: int = -1
80
+
81
+
82
+ # ---------------------------------------------------------------------------
83
+ # Token estimation
84
+ # ---------------------------------------------------------------------------
85
+
86
+ def estimate_tokens(message: Any) -> int:
87
+ """Estimate token count for any AgentMessage using chars/4 heuristic."""
88
+ from tau.message.types import (
89
+ UserMessage, AssistantMessage, ToolMessage, TerminalExecutionMessage,
90
+ CustomMessage, CompactionSummaryMessage, BranchSummaryMessage,
91
+ TextContent, ImageContent, ThinkingContent, ToolCallContent, ToolResultContent,
92
+ )
93
+
94
+ chars = 0
95
+ if isinstance(message, UserMessage):
96
+ for c in message.contents:
97
+ if isinstance(c, TextContent):
98
+ chars += len(c.content)
99
+ elif isinstance(c, ImageContent):
100
+ chars += ESTIMATED_IMAGE_CHARS
101
+ elif isinstance(message, AssistantMessage):
102
+ for c in message.contents:
103
+ if isinstance(c, TextContent):
104
+ chars += len(c.content)
105
+ elif isinstance(c, ThinkingContent):
106
+ chars += len(c.content)
107
+ elif isinstance(c, ToolCallContent):
108
+ chars += len(c.name) + len(json.dumps(c.args))
109
+ elif isinstance(message, ToolMessage):
110
+ for c in message.contents:
111
+ if isinstance(c, ToolResultContent):
112
+ chars += len(c.content)
113
+ elif isinstance(message, TerminalExecutionMessage):
114
+ chars = len(message.command) + len(message.output)
115
+ elif isinstance(message, (CompactionSummaryMessage, BranchSummaryMessage)):
116
+ chars = len(message.summary)
117
+ elif isinstance(message, CustomMessage):
118
+ for c in message.contents:
119
+ if isinstance(c, TextContent):
120
+ chars += len(c.content)
121
+
122
+ return max(1, chars // 4)
123
+
124
+
125
+ def estimate_context_tokens(messages: list) -> ContextUsageEstimate:
126
+ """
127
+ Estimate total context tokens.
128
+
129
+ Uses the Usage object from the last non-aborted/non-error assistant message
130
+ as a precise anchor, then estimates trailing messages with chars/4.
131
+ """
132
+ from tau.message.types import AssistantMessage
133
+ from tau.inference.types import StopReason
134
+
135
+ last_usage: int | None = None
136
+ last_usage_idx: int | None = None
137
+
138
+ for i in range(len(messages) - 1, -1, -1):
139
+ msg = messages[i]
140
+ if isinstance(msg, AssistantMessage):
141
+ if msg.stop_reason not in (StopReason.Abort, StopReason.Error):
142
+ u = msg.usage
143
+ total = u.input_tokens + u.output_tokens + u.cache_read_tokens + u.cache_write_tokens
144
+ if total > 0:
145
+ last_usage = total
146
+ last_usage_idx = i
147
+ break
148
+
149
+ if last_usage is None:
150
+ estimated = sum(estimate_tokens(m) for m in messages)
151
+ return ContextUsageEstimate(
152
+ tokens=estimated,
153
+ usage_tokens=0,
154
+ trailing_tokens=estimated,
155
+ last_usage_index=None,
156
+ )
157
+
158
+ trailing = sum(estimate_tokens(messages[i]) for i in range(last_usage_idx + 1, len(messages))) # type: ignore[operator]
159
+ return ContextUsageEstimate(
160
+ tokens=last_usage + trailing,
161
+ usage_tokens=last_usage,
162
+ trailing_tokens=trailing,
163
+ last_usage_index=last_usage_idx,
164
+ )
165
+
166
+
167
+ def should_compact(context_tokens: int, context_window: int, settings: CompactionSettings) -> bool:
168
+ if not settings.enabled or context_window <= 0:
169
+ return False
170
+ return context_tokens > context_window - settings.reserve_tokens
171
+
172
+
173
+ # ---------------------------------------------------------------------------
174
+ # Cut-point detection
175
+ # ---------------------------------------------------------------------------
176
+
177
+ def _is_valid_cut_point(entry: Any) -> bool:
178
+ """Return True if this session entry is a valid place to cut history."""
179
+ from tau.session.types import MessageEntry, CustomMessageEntry, BranchSummaryEntry
180
+ from tau.message.types import UserMessage, AssistantMessage, TerminalExecutionMessage, CustomMessage
181
+ from tau.inference.types import StopReason
182
+
183
+ if isinstance(entry, (CustomMessageEntry, BranchSummaryEntry)):
184
+ return True
185
+ if isinstance(entry, MessageEntry):
186
+ msg = entry.message
187
+ if isinstance(msg, (UserMessage, TerminalExecutionMessage, CustomMessage)):
188
+ return True
189
+ if isinstance(msg, AssistantMessage):
190
+ # Skip aborted-empty assistants — they are visual markers only
191
+ if msg.stop_reason == StopReason.Abort and not msg.contents:
192
+ return False
193
+ return True
194
+ return False
195
+
196
+
197
+ def _entry_message(entry: Any) -> Any | None:
198
+ """Extract AgentMessage from a session entry, skipping compaction entries."""
199
+ from tau.session.types import MessageEntry, CustomMessageEntry, CompactionEntry, BranchSummaryEntry
200
+ from tau.message.types import CustomMessage, BranchSummaryMessage
201
+
202
+ if isinstance(entry, CompactionEntry):
203
+ return None
204
+ if isinstance(entry, MessageEntry):
205
+ return entry.message
206
+ if isinstance(entry, CustomMessageEntry):
207
+ return CustomMessage.from_session(entry=entry)
208
+ if isinstance(entry, BranchSummaryEntry):
209
+ return BranchSummaryMessage(
210
+ summary=entry.summary,
211
+ from_id=entry.from_id,
212
+ timestamp=entry.timestamp,
213
+ )
214
+ return None
215
+
216
+
217
+ def find_cut_point(
218
+ entries: list,
219
+ start_idx: int,
220
+ end_idx: int,
221
+ keep_recent_tokens: int,
222
+ ) -> CutPointResult:
223
+ """
224
+ Walk backwards from newest, accumulating estimated token sizes.
225
+ Stop when the budget is met. Return the first entry to keep.
226
+
227
+ Valid cut points: user, assistant, terminal, custom messages.
228
+ Never cuts at tool messages (must follow their assistant).
229
+ """
230
+ from tau.session.types import MessageEntry, CompactionEntry
231
+ from tau.message.types import UserMessage, TerminalExecutionMessage
232
+
233
+ # Collect valid cut points in [start_idx, end_idx)
234
+ cut_points: list[int] = []
235
+ for i in range(start_idx, end_idx):
236
+ if _is_valid_cut_point(entries[i]):
237
+ cut_points.append(i)
238
+
239
+ if not cut_points:
240
+ return CutPointResult(first_kept_entry_index=start_idx)
241
+
242
+ accumulated = 0
243
+ cut_index = cut_points[0] # default: keep everything from boundary start
244
+
245
+ for i in range(end_idx - 1, start_idx - 1, -1):
246
+ entry = entries[i]
247
+ if not isinstance(entry, MessageEntry):
248
+ continue
249
+ accumulated += estimate_tokens(entry.message)
250
+ if accumulated >= keep_recent_tokens:
251
+ # Snap to the nearest valid cut point at or after i
252
+ for cp in cut_points:
253
+ if cp >= i:
254
+ cut_index = cp
255
+ break
256
+ break
257
+
258
+ # Walk backwards to include any adjacent non-message, non-compaction entries
259
+ while cut_index > start_idx:
260
+ prev = entries[cut_index - 1]
261
+ if isinstance(prev, (MessageEntry, CompactionEntry)):
262
+ break
263
+ cut_index -= 1
264
+
265
+ # Determine whether we're splitting a turn
266
+ cut_entry = entries[cut_index]
267
+ is_user_start = (
268
+ isinstance(cut_entry, MessageEntry)
269
+ and isinstance(cut_entry.message, (UserMessage, TerminalExecutionMessage))
270
+ )
271
+
272
+ from tau.session.types import CustomMessageEntry, BranchSummaryEntry
273
+ if is_user_start or isinstance(cut_entry, (CustomMessageEntry, BranchSummaryEntry)):
274
+ return CutPointResult(first_kept_entry_index=cut_index)
275
+
276
+ # Find the user message that started the turn containing cut_index
277
+ turn_start = -1
278
+ for i in range(cut_index, start_idx - 1, -1):
279
+ e = entries[i]
280
+ if isinstance(e, MessageEntry) and isinstance(e.message, (UserMessage, TerminalExecutionMessage)):
281
+ turn_start = i
282
+ break
283
+ if isinstance(e, (CustomMessageEntry, BranchSummaryEntry)):
284
+ turn_start = i
285
+ break
286
+
287
+ return CutPointResult(
288
+ first_kept_entry_index=cut_index,
289
+ is_split_turn=(turn_start != -1),
290
+ turn_start_index=turn_start,
291
+ )
292
+
293
+
294
+ # ---------------------------------------------------------------------------
295
+ # Serialization (prevents the LLM from continuing the conversation)
296
+ # ---------------------------------------------------------------------------
297
+
298
+ def _truncate(text: str, max_chars: int) -> str:
299
+ if len(text) <= max_chars:
300
+ return text
301
+ truncated = len(text) - max_chars
302
+ return f"{text[:max_chars]}\n\n[... {truncated} more characters truncated]"
303
+
304
+
305
+ def serialize_conversation(messages: list) -> str:
306
+ """
307
+ Render AgentMessages as flat text for the summarisation LLM.
308
+ Tool results are truncated to keep the prompt compact.
309
+ """
310
+ from tau.message.types import (
311
+ UserMessage, AssistantMessage, ToolMessage, TerminalExecutionMessage,
312
+ CustomMessage, CompactionSummaryMessage, BranchSummaryMessage,
313
+ TextContent, ThinkingContent, ToolCallContent, ToolResultContent,
314
+ )
315
+
316
+ parts: list[str] = []
317
+
318
+ for msg in messages:
319
+ if isinstance(msg, CompactionSummaryMessage):
320
+ parts.append(f"[Context Summary]:\n{msg.summary}")
321
+
322
+ elif isinstance(msg, BranchSummaryMessage):
323
+ parts.append(f"[Branch Summary]:\n{msg.summary}")
324
+
325
+ elif isinstance(msg, UserMessage):
326
+ text = "".join(
327
+ c.content for c in msg.contents if isinstance(c, TextContent)
328
+ )
329
+ if text:
330
+ parts.append(f"[User]: {text}")
331
+
332
+ elif isinstance(msg, AssistantMessage):
333
+ thinking_parts = [c.content for c in msg.contents if isinstance(c, ThinkingContent)]
334
+ text_parts = [c.content for c in msg.contents if isinstance(c, TextContent)]
335
+ tool_calls = [
336
+ f"{c.name}({', '.join(f'{k}={json.dumps(v)}' for k, v in c.args.items())})"
337
+ for c in msg.contents if isinstance(c, ToolCallContent)
338
+ ]
339
+ if thinking_parts:
340
+ parts.append(f"[Assistant thinking]: {' '.join(thinking_parts)}")
341
+ if text_parts:
342
+ parts.append(f"[Assistant]: {' '.join(text_parts)}")
343
+ if tool_calls:
344
+ parts.append(f"[Assistant tool calls]: {'; '.join(tool_calls)}")
345
+
346
+ elif isinstance(msg, ToolMessage):
347
+ for c in msg.contents:
348
+ if isinstance(c, ToolResultContent) and c.content:
349
+ parts.append(f"[Tool result]: {_truncate(c.content, TOOL_RESULT_MAX_CHARS)}")
350
+
351
+ elif isinstance(msg, TerminalExecutionMessage):
352
+ text = f"Ran `{msg.command}`"
353
+ if msg.output:
354
+ text += f"\n```\n{_truncate(msg.output, TOOL_RESULT_MAX_CHARS)}\n```"
355
+ parts.append(f"[Terminal]: {text}")
356
+
357
+ elif isinstance(msg, CustomMessage):
358
+ text = "".join(
359
+ c.content for c in msg.contents if isinstance(c, TextContent)
360
+ )
361
+ if text:
362
+ parts.append(f"[{msg.custom_type}]: {text}")
363
+
364
+ return "\n\n".join(parts)
365
+
366
+
367
+ # ---------------------------------------------------------------------------
368
+ # Summarisation prompts
369
+ # ---------------------------------------------------------------------------
370
+
371
+ SUMMARIZATION_SYSTEM_PROMPT = (
372
+ "You are a context summarization assistant. "
373
+ "Read the conversation and produce a structured summary following the exact format requested. "
374
+ "Do NOT continue the conversation. Do NOT respond to questions in it. Output ONLY the summary."
375
+ )
376
+
377
+ SUMMARIZATION_PROMPT = """\
378
+ The messages above are a conversation to summarize. Create a structured context checkpoint \
379
+ summary that another LLM will use to continue the work.
380
+
381
+ Use this EXACT format:
382
+
383
+ ## Goal
384
+ [What is the user trying to accomplish?]
385
+
386
+ ## Constraints & Preferences
387
+ - [Any constraints or preferences the user mentioned, or "(none)"]
388
+
389
+ ## Progress
390
+ ### Done
391
+ - [x] [Completed tasks/changes]
392
+
393
+ ### In Progress
394
+ - [ ] [Current work]
395
+
396
+ ### Blocked
397
+ - [Issues preventing progress, if any]
398
+
399
+ ## Key Decisions
400
+ - **[Decision]**: [Brief rationale]
401
+
402
+ ## Next Steps
403
+ 1. [Ordered list of what should happen next]
404
+
405
+ ## Critical Context
406
+ - [Data, examples, or references needed to continue, or "(none)"]
407
+
408
+ Keep each section concise. Preserve exact file paths, function names, and error messages."""
409
+
410
+ UPDATE_SUMMARIZATION_PROMPT = """\
411
+ The messages above are NEW conversation messages to incorporate into the existing summary \
412
+ provided in <previous-summary> tags.
413
+
414
+ Update the existing structured summary with new information. RULES:
415
+ - PRESERVE all existing information from the previous summary
416
+ - ADD new progress, decisions, and context from the new messages
417
+ - UPDATE the Progress section: move items from "In Progress" to "Done" when completed
418
+ - UPDATE "Next Steps" based on what was accomplished
419
+ - PRESERVE exact file paths, function names, and error messages
420
+
421
+ Use this EXACT format:
422
+
423
+ ## Goal
424
+ [Preserve existing goals, add new ones if the task expanded]
425
+
426
+ ## Constraints & Preferences
427
+ - [Preserve existing, add new ones discovered]
428
+
429
+ ## Progress
430
+ ### Done
431
+ - [x] [Previously done items AND newly completed items]
432
+
433
+ ### In Progress
434
+ - [ ] [Current work — updated based on progress]
435
+
436
+ ### Blocked
437
+ - [Current blockers — remove if resolved]
438
+
439
+ ## Key Decisions
440
+ - **[Decision]**: [Brief rationale] (preserve all previous, add new)
441
+
442
+ ## Next Steps
443
+ 1. [Update based on current state]
444
+
445
+ ## Critical Context
446
+ - [Preserve important context, add new if needed]
447
+
448
+ Keep each section concise. Preserve exact file paths, function names, and error messages."""
449
+
450
+ TURN_PREFIX_SUMMARIZATION_PROMPT = """\
451
+ This is the PREFIX of a turn that was too large to keep. The SUFFIX (recent work) is retained.
452
+
453
+ Summarize the prefix to provide context for the retained suffix:
454
+
455
+ ## Original Request
456
+ [What did the user ask for in this turn?]
457
+
458
+ ## Early Progress
459
+ - [Key decisions and work done in the prefix]
460
+
461
+ ## Context for Suffix
462
+ - [Information needed to understand the retained recent work]
463
+
464
+ Be concise. Focus on what's needed to understand the kept suffix."""
465
+
466
+
467
+ # ---------------------------------------------------------------------------
468
+ # Summary generation (LLM call)
469
+ # ---------------------------------------------------------------------------
470
+
471
+ async def _call_llm_for_summary(
472
+ prompt_text: str,
473
+ llm: TextLLM,
474
+ max_chars: int = 0,
475
+ ) -> str:
476
+ from tau.message.types import UserMessage
477
+ from tau.inference.types import LLMContext, TextEndEvent, TextDeltaEvent
478
+
479
+ context = LLMContext(
480
+ messages=[UserMessage.from_text(prompt_text)],
481
+ system_prompt=SUMMARIZATION_SYSTEM_PROMPT,
482
+ )
483
+ events = await llm.invoke(context)
484
+
485
+ # Prefer TextEndEvent (full accumulated text); fall back to concatenating deltas
486
+ text_end = next((e for e in events if isinstance(e, TextEndEvent)), None)
487
+ if text_end:
488
+ return text_end.text.content
489
+
490
+ return "".join(
491
+ e.text.content for e in events if isinstance(e, TextDeltaEvent)
492
+ )
493
+
494
+
495
+ async def generate_summary(
496
+ messages: list,
497
+ llm: TextLLM,
498
+ reserve_tokens: int,
499
+ previous_summary: str | None = None,
500
+ custom_instructions: str | None = None,
501
+ ) -> str:
502
+ conversation_text = serialize_conversation(messages)
503
+
504
+ base_prompt = UPDATE_SUMMARIZATION_PROMPT if previous_summary else SUMMARIZATION_PROMPT
505
+ if custom_instructions:
506
+ base_prompt = f"{base_prompt}\n\nAdditional focus: {custom_instructions}"
507
+
508
+ prompt = f"<conversation>\n{conversation_text}\n</conversation>\n\n"
509
+ if previous_summary:
510
+ prompt += f"<previous-summary>\n{previous_summary}\n</previous-summary>\n\n"
511
+ prompt += base_prompt
512
+
513
+ return await _call_llm_for_summary(prompt, llm)
514
+
515
+
516
+ async def _generate_turn_prefix_summary(messages: list, llm: TextLLM) -> str:
517
+ conversation_text = serialize_conversation(messages)
518
+ prompt = f"<conversation>\n{conversation_text}\n</conversation>\n\n{TURN_PREFIX_SUMMARIZATION_PROMPT}"
519
+ return await _call_llm_for_summary(prompt, llm)
520
+
521
+
522
+ # ---------------------------------------------------------------------------
523
+ # Prepare — pure, no I/O
524
+ # ---------------------------------------------------------------------------
525
+
526
+ def prepare_compaction(
527
+ entries: list,
528
+ settings: CompactionSettings,
529
+ ) -> CompactionPreparation | None:
530
+ from tau.session.types import CompactionEntry, MessageEntry
531
+
532
+ if not entries:
533
+ return None
534
+
535
+ # Don't compact if the last entry is already a compaction
536
+ if isinstance(entries[-1], CompactionEntry):
537
+ return None
538
+
539
+ # Find the most recent previous compaction
540
+ prev_compaction: CompactionEntry | None = None
541
+ prev_compaction_idx = -1
542
+ for i in range(len(entries) - 1, -1, -1):
543
+ if isinstance(entries[i], CompactionEntry):
544
+ prev_compaction = entries[i]
545
+ prev_compaction_idx = i
546
+ break
547
+
548
+ previous_summary: str | None = None
549
+ boundary_start = 0
550
+ if prev_compaction is not None:
551
+ previous_summary = prev_compaction.summary
552
+ first_kept_idx = next(
553
+ (i for i, e in enumerate(entries) if e.id == prev_compaction.first_kept_entry_id),
554
+ prev_compaction_idx + 1,
555
+ )
556
+ boundary_start = first_kept_idx
557
+
558
+ boundary_end = len(entries)
559
+
560
+ # Estimate current context size from all messages in the branch
561
+ all_messages = [m for e in entries if (m := _entry_message(e)) is not None]
562
+ tokens_before = estimate_context_tokens(all_messages).tokens
563
+
564
+ cut = find_cut_point(entries, boundary_start, boundary_end, settings.keep_recent_tokens)
565
+
566
+ first_kept_entry = entries[cut.first_kept_entry_index]
567
+ first_kept_entry_id: str = getattr(first_kept_entry, "id", "")
568
+ if not first_kept_entry_id:
569
+ return None
570
+
571
+ # Nothing to cut — the entire conversation fits in the keep budget, so
572
+ # there is no history to summarise. Returning None skips compaction.
573
+ if cut.first_kept_entry_index == boundary_start and not cut.is_split_turn:
574
+ return None
575
+
576
+ history_end = cut.turn_start_index if cut.is_split_turn else cut.first_kept_entry_index
577
+
578
+ messages_to_summarize = [
579
+ m for i in range(boundary_start, history_end)
580
+ if (m := _entry_message(entries[i])) is not None
581
+ ]
582
+
583
+ turn_prefix_messages: list = []
584
+ if cut.is_split_turn:
585
+ turn_prefix_messages = [
586
+ m for i in range(cut.turn_start_index, cut.first_kept_entry_index)
587
+ if (m := _entry_message(entries[i])) is not None
588
+ ]
589
+
590
+ return CompactionPreparation(
591
+ first_kept_entry_id=first_kept_entry_id,
592
+ messages_to_summarize=messages_to_summarize,
593
+ turn_prefix_messages=turn_prefix_messages,
594
+ is_split_turn=cut.is_split_turn,
595
+ tokens_before=tokens_before,
596
+ previous_summary=previous_summary,
597
+ settings=settings,
598
+ )
599
+
600
+
601
+ # ---------------------------------------------------------------------------
602
+ # Main compact() — async, calls LLM
603
+ # ---------------------------------------------------------------------------
604
+
605
+ async def compact(
606
+ preparation: CompactionPreparation,
607
+ llm: TextLLM,
608
+ custom_instructions: str | None = None,
609
+ ) -> CompactionResult:
610
+ settings = preparation.settings
611
+
612
+ if preparation.is_split_turn and preparation.turn_prefix_messages:
613
+ # Generate both summaries in parallel
614
+ async def _no_history() -> str:
615
+ return "No prior history."
616
+
617
+ history_coro = (
618
+ generate_summary(
619
+ preparation.messages_to_summarize,
620
+ llm,
621
+ settings.reserve_tokens,
622
+ preparation.previous_summary,
623
+ custom_instructions,
624
+ )
625
+ if preparation.messages_to_summarize
626
+ else _no_history()
627
+ )
628
+ history_text, prefix_text = await asyncio.gather(
629
+ history_coro,
630
+ _generate_turn_prefix_summary(preparation.turn_prefix_messages, llm),
631
+ )
632
+ summary = f"{history_text}\n\n---\n\n**Turn Context (split turn):**\n\n{prefix_text}"
633
+ else:
634
+ summary = await generate_summary(
635
+ preparation.messages_to_summarize,
636
+ llm,
637
+ settings.reserve_tokens,
638
+ preparation.previous_summary,
639
+ custom_instructions,
640
+ )
641
+
642
+ return CompactionResult(
643
+ summary=summary,
644
+ first_kept_entry_id=preparation.first_kept_entry_id,
645
+ tokens_before=preparation.tokens_before,
646
+ )