iCode-TUI 0.29.1__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 (1138) hide show
  1. chrys/__init__.py +16 -0
  2. chrys/app/__init__.py +3 -0
  3. chrys/app/acp/__init__.py +3 -0
  4. chrys/app/acp/bridge.py +664 -0
  5. chrys/app/acp/content.py +132 -0
  6. chrys/app/acp/doc/frontend-api.md +425 -0
  7. chrys/app/acp/history.py +585 -0
  8. chrys/app/acp/server.py +1796 -0
  9. chrys/app/acp/session_manager.py +1815 -0
  10. chrys/app/acp/tool_status.py +45 -0
  11. chrys/app/cli/__init__.py +3 -0
  12. chrys/app/cli/acp.py +178 -0
  13. chrys/app/cli/app.py +165 -0
  14. chrys/app/cli/approvals.py +101 -0
  15. chrys/app/cli/headless.py +223 -0
  16. chrys/app/cli/launch_cwd.py +42 -0
  17. chrys/app/cli/profiles.py +298 -0
  18. chrys/app/cli/progress.py +857 -0
  19. chrys/app/cli/run.py +425 -0
  20. chrys/app/cli/serve.py +1593 -0
  21. chrys/app/cli/tool_summary.py +225 -0
  22. chrys/app/cli/trajectory.py +201 -0
  23. chrys/app/cli/workflow.py +601 -0
  24. chrys/app/features/__init__.py +3 -0
  25. chrys/app/features/buddy/__init__.py +3 -0
  26. chrys/app/features/buddy/actions.py +68 -0
  27. chrys/app/features/buddy/animation.py +76 -0
  28. chrys/app/features/buddy/commands.py +196 -0
  29. chrys/app/features/buddy/docs/design.md +187 -0
  30. chrys/app/features/buddy/hatchery.py +80 -0
  31. chrys/app/features/buddy/lifecycle.py +41 -0
  32. chrys/app/features/buddy/model.py +265 -0
  33. chrys/app/features/buddy/pixel_renderer.py +113 -0
  34. chrys/app/features/buddy/pixel_sprites.py +279 -0
  35. chrys/app/features/buddy/portrait.py +76 -0
  36. chrys/app/features/buddy/progression.py +58 -0
  37. chrys/app/features/buddy/replies.py +191 -0
  38. chrys/app/features/buddy/sprites/alien.toml +70 -0
  39. chrys/app/features/buddy/sprites/axolotl.toml +71 -0
  40. chrys/app/features/buddy/sprites/bat.toml +69 -0
  41. chrys/app/features/buddy/sprites/bee.toml +69 -0
  42. chrys/app/features/buddy/sprites/cactus.toml +73 -0
  43. chrys/app/features/buddy/sprites/capybara.toml +70 -0
  44. chrys/app/features/buddy/sprites/cat.toml +69 -0
  45. chrys/app/features/buddy/sprites/crab.toml +70 -0
  46. chrys/app/features/buddy/sprites/dragon.toml +71 -0
  47. chrys/app/features/buddy/sprites/duck.toml +69 -0
  48. chrys/app/features/buddy/sprites/fox.toml +69 -0
  49. chrys/app/features/buddy/sprites/frog.toml +70 -0
  50. chrys/app/features/buddy/sprites/ghost.toml +70 -0
  51. chrys/app/features/buddy/sprites/goose.toml +69 -0
  52. chrys/app/features/buddy/sprites/jelly.toml +70 -0
  53. chrys/app/features/buddy/sprites/llama.toml +70 -0
  54. chrys/app/features/buddy/sprites/mushroom.toml +69 -0
  55. chrys/app/features/buddy/sprites/octopus.toml +70 -0
  56. chrys/app/features/buddy/sprites/owl.toml +70 -0
  57. chrys/app/features/buddy/sprites/panda.toml +71 -0
  58. chrys/app/features/buddy/sprites/penguin.toml +69 -0
  59. chrys/app/features/buddy/sprites/rabbit.toml +70 -0
  60. chrys/app/features/buddy/sprites/robot.toml +72 -0
  61. chrys/app/features/buddy/sprites/shark.toml +70 -0
  62. chrys/app/features/buddy/sprites/snail.toml +69 -0
  63. chrys/app/features/buddy/sprites/snake.toml +69 -0
  64. chrys/app/features/buddy/sprites/tabby.toml +69 -0
  65. chrys/app/features/buddy/sprites/turtle.toml +69 -0
  66. chrys/app/features/buddy/store.py +143 -0
  67. chrys/app/features/session_title/__init__.py +7 -0
  68. chrys/app/features/session_title/updater.py +582 -0
  69. chrys/app/installer.py +651 -0
  70. chrys/app/parsing.py +17 -0
  71. chrys/app/tui/__init__.py +3 -0
  72. chrys/app/tui/app.py +1287 -0
  73. chrys/app/tui/behaviors/__init__.py +3 -0
  74. chrys/app/tui/behaviors/click_outside_dismiss.py +90 -0
  75. chrys/app/tui/behaviors/insert_clipboard.py +73 -0
  76. chrys/app/tui/behaviors/right_click_copy.py +146 -0
  77. chrys/app/tui/binding_display.py +139 -0
  78. chrys/app/tui/buddy_messages.py +17 -0
  79. chrys/app/tui/buddy_reply.py +86 -0
  80. chrys/app/tui/chrys.tcss +549 -0
  81. chrys/app/tui/clipboard.py +109 -0
  82. chrys/app/tui/copy_messages.py +10 -0
  83. chrys/app/tui/i18n.py +230 -0
  84. chrys/app/tui/language.py +58 -0
  85. chrys/app/tui/notifications/__init__.py +12 -0
  86. chrys/app/tui/notifications/drivers.py +180 -0
  87. chrys/app/tui/notifications/service.py +126 -0
  88. chrys/app/tui/notifications/settings.py +141 -0
  89. chrys/app/tui/screens/__init__.py +3 -0
  90. chrys/app/tui/screens/agents/__init__.py +8 -0
  91. chrys/app/tui/screens/agents/agent_draft_store.py +937 -0
  92. chrys/app/tui/screens/agents/config.py +2142 -0
  93. chrys/app/tui/screens/agents/config.tcss +169 -0
  94. chrys/app/tui/screens/agents/panels/__init__.py +3 -0
  95. chrys/app/tui/screens/agents/panels/acp.py +1176 -0
  96. chrys/app/tui/screens/agents/panels/basic.py +497 -0
  97. chrys/app/tui/screens/agents/panels/compaction.py +231 -0
  98. chrys/app/tui/screens/agents/panels/config_card.py +218 -0
  99. chrys/app/tui/screens/agents/panels/instructions.py +91 -0
  100. chrys/app/tui/screens/agents/panels/markdown_report.py +76 -0
  101. chrys/app/tui/screens/agents/panels/mcp.py +2262 -0
  102. chrys/app/tui/screens/agents/panels/memory.py +684 -0
  103. chrys/app/tui/screens/agents/panels/path_entry.py +227 -0
  104. chrys/app/tui/screens/agents/panels/skills.py +806 -0
  105. chrys/app/tui/screens/agents/panels/subagents.py +652 -0
  106. chrys/app/tui/screens/agents/panels/tools.py +205 -0
  107. chrys/app/tui/screens/agents/picker.py +103 -0
  108. chrys/app/tui/screens/agents/skill_paths.py +38 -0
  109. chrys/app/tui/screens/agents/validation_messages.py +452 -0
  110. chrys/app/tui/screens/dialogs/__init__.py +35 -0
  111. chrys/app/tui/screens/dialogs/agent_load.py +834 -0
  112. chrys/app/tui/screens/dialogs/agent_load.tcss +78 -0
  113. chrys/app/tui/screens/dialogs/app_mode.py +55 -0
  114. chrys/app/tui/screens/dialogs/approval/__init__.py +15 -0
  115. chrys/app/tui/screens/dialogs/approval/approval.tcss +311 -0
  116. chrys/app/tui/screens/dialogs/approval/bodies/__init__.py +3 -0
  117. chrys/app/tui/screens/dialogs/approval/bodies/file_edit.py +210 -0
  118. chrys/app/tui/screens/dialogs/approval/bodies/sub_agent.py +82 -0
  119. chrys/app/tui/screens/dialogs/approval/body.py +110 -0
  120. chrys/app/tui/screens/dialogs/approval/dialog.py +565 -0
  121. chrys/app/tui/screens/dialogs/approval/mode.py +105 -0
  122. chrys/app/tui/screens/dialogs/ask_user.py +148 -0
  123. chrys/app/tui/screens/dialogs/ask_user.tcss +62 -0
  124. chrys/app/tui/screens/dialogs/base.py +84 -0
  125. chrys/app/tui/screens/dialogs/confirm.py +184 -0
  126. chrys/app/tui/screens/dialogs/confirm.tcss +46 -0
  127. chrys/app/tui/screens/dialogs/connection_test.py +183 -0
  128. chrys/app/tui/screens/dialogs/connection_test.tcss +92 -0
  129. chrys/app/tui/screens/dialogs/diagram.py +102 -0
  130. chrys/app/tui/screens/dialogs/diagram.tcss +24 -0
  131. chrys/app/tui/screens/dialogs/editor.py +378 -0
  132. chrys/app/tui/screens/dialogs/editor.tcss +132 -0
  133. chrys/app/tui/screens/dialogs/file_picker.py +582 -0
  134. chrys/app/tui/screens/dialogs/file_picker.tcss +78 -0
  135. chrys/app/tui/screens/dialogs/fork_session.py +219 -0
  136. chrys/app/tui/screens/dialogs/fork_session.tcss +75 -0
  137. chrys/app/tui/screens/dialogs/image_compression.py +102 -0
  138. chrys/app/tui/screens/dialogs/image_compression.tcss +28 -0
  139. chrys/app/tui/screens/dialogs/man_page.py +172 -0
  140. chrys/app/tui/screens/dialogs/man_page.tcss +39 -0
  141. chrys/app/tui/screens/dialogs/new_folder.py +277 -0
  142. chrys/app/tui/screens/dialogs/new_folder.tcss +51 -0
  143. chrys/app/tui/screens/dialogs/notice.tcss +14 -0
  144. chrys/app/tui/screens/dialogs/prompt_cache.py +144 -0
  145. chrys/app/tui/screens/dialogs/prompt_cache.tcss +38 -0
  146. chrys/app/tui/screens/dialogs/runtime_details.py +371 -0
  147. chrys/app/tui/screens/dialogs/runtime_details.tcss +49 -0
  148. chrys/app/tui/screens/dialogs/session_title.py +110 -0
  149. chrys/app/tui/screens/dialogs/session_title.tcss +43 -0
  150. chrys/app/tui/screens/dialogs/tool_view.py +207 -0
  151. chrys/app/tui/screens/dialogs/tool_view.tcss +43 -0
  152. chrys/app/tui/screens/dialogs/vision_unsupported.py +122 -0
  153. chrys/app/tui/screens/dialogs/vision_unsupported.tcss +35 -0
  154. chrys/app/tui/screens/dialogs/workflow_confirm.py +91 -0
  155. chrys/app/tui/screens/dialogs/workflow_confirm.tcss +17 -0
  156. chrys/app/tui/screens/dialogs/workflow_input.py +258 -0
  157. chrys/app/tui/screens/dialogs/workflow_input.tcss +73 -0
  158. chrys/app/tui/screens/dialogs/workflow_node.py +462 -0
  159. chrys/app/tui/screens/dialogs/workflow_node.tcss +41 -0
  160. chrys/app/tui/screens/dialogs/workflow_picker.py +46 -0
  161. chrys/app/tui/screens/dialogs/workflow_result.py +136 -0
  162. chrys/app/tui/screens/dialogs/workflow_result.tcss +14 -0
  163. chrys/app/tui/screens/diff/__init__.py +22 -0
  164. chrys/app/tui/screens/diff/rollback_modal.py +1381 -0
  165. chrys/app/tui/screens/diff/rollback_modal.tcss +248 -0
  166. chrys/app/tui/screens/diff/screen.py +1532 -0
  167. chrys/app/tui/screens/diff/screen.tcss +104 -0
  168. chrys/app/tui/screens/guides/__init__.py +7 -0
  169. chrys/app/tui/screens/guides/_docs/en/guides/configuration/agents.md +178 -0
  170. chrys/app/tui/screens/guides/_docs/en/guides/configuration/approval.md +117 -0
  171. chrys/app/tui/screens/guides/_docs/en/guides/configuration/chat-options.md +276 -0
  172. chrys/app/tui/screens/guides/_docs/en/guides/configuration/compaction.md +42 -0
  173. chrys/app/tui/screens/guides/_docs/en/guides/configuration/memory.md +48 -0
  174. chrys/app/tui/screens/guides/_docs/en/guides/configuration/models.md +158 -0
  175. chrys/app/tui/screens/guides/_docs/en/guides/configuration/settings.md +210 -0
  176. chrys/app/tui/screens/guides/_docs/en/guides/configuration/themes.md +61 -0
  177. chrys/app/tui/screens/guides/_docs/en/guides/configuration/tools.md +60 -0
  178. chrys/app/tui/screens/guides/_docs/en/guides/configuration/web-tools.md +286 -0
  179. chrys/app/tui/screens/guides/_docs/en/guides/daily-use/sessions.md +77 -0
  180. chrys/app/tui/screens/guides/_docs/en/guides/daily-use/workspaces.md +51 -0
  181. chrys/app/tui/screens/guides/_docs/en/guides/extensions/external-acp-agents.md +126 -0
  182. chrys/app/tui/screens/guides/_docs/en/guides/extensions/hooks.md +167 -0
  183. chrys/app/tui/screens/guides/_docs/en/guides/extensions/mcp.md +95 -0
  184. chrys/app/tui/screens/guides/_docs/en/guides/extensions/skills.md +175 -0
  185. chrys/app/tui/screens/guides/_docs/en/guides/running/icode-acp.md +61 -0
  186. chrys/app/tui/screens/guides/_docs/en/guides/running/icode-run.md +190 -0
  187. chrys/app/tui/screens/guides/_docs/en/guides/running/icode-serve.md +239 -0
  188. chrys/app/tui/screens/guides/_docs/en/guides/running/workflows.md +664 -0
  189. chrys/app/tui/screens/guides/_docs/en/reference/agent-profile.md +548 -0
  190. chrys/app/tui/screens/guides/_docs/en/reference/hooks.md +602 -0
  191. chrys/app/tui/screens/guides/_docs/en/reference/opentelemetry.md +177 -0
  192. chrys/app/tui/screens/guides/_docs/en/reference/settings.md +245 -0
  193. chrys/app/tui/screens/guides/_docs/en/reference/themes.md +149 -0
  194. chrys/app/tui/screens/guides/_docs/en/reference/tool-kinds-and-names.md +58 -0
  195. chrys/app/tui/screens/guides/_docs/en/reference/tui-slash-commands.md +76 -0
  196. chrys/app/tui/screens/guides/_docs/en/reference/workflows.md +1006 -0
  197. chrys/app/tui/screens/guides/_docs/en/start/getting-started.md +170 -0
  198. chrys/app/tui/screens/guides/_docs/en/start/what-is-icode.md +34 -0
  199. chrys/app/tui/screens/guides/_docs/index.yaml +100 -0
  200. chrys/app/tui/screens/guides/_docs/zh-Hans/guides/configuration/agents.md +178 -0
  201. chrys/app/tui/screens/guides/_docs/zh-Hans/guides/configuration/approval.md +117 -0
  202. chrys/app/tui/screens/guides/_docs/zh-Hans/guides/configuration/chat-options.md +276 -0
  203. chrys/app/tui/screens/guides/_docs/zh-Hans/guides/configuration/compaction.md +42 -0
  204. chrys/app/tui/screens/guides/_docs/zh-Hans/guides/configuration/memory.md +48 -0
  205. chrys/app/tui/screens/guides/_docs/zh-Hans/guides/configuration/models.md +158 -0
  206. chrys/app/tui/screens/guides/_docs/zh-Hans/guides/configuration/settings.md +210 -0
  207. chrys/app/tui/screens/guides/_docs/zh-Hans/guides/configuration/themes.md +61 -0
  208. chrys/app/tui/screens/guides/_docs/zh-Hans/guides/configuration/tools.md +60 -0
  209. chrys/app/tui/screens/guides/_docs/zh-Hans/guides/configuration/web-tools.md +286 -0
  210. chrys/app/tui/screens/guides/_docs/zh-Hans/guides/daily-use/sessions.md +77 -0
  211. chrys/app/tui/screens/guides/_docs/zh-Hans/guides/daily-use/workspaces.md +51 -0
  212. chrys/app/tui/screens/guides/_docs/zh-Hans/guides/extensions/external-acp-agents.md +126 -0
  213. chrys/app/tui/screens/guides/_docs/zh-Hans/guides/extensions/hooks.md +167 -0
  214. chrys/app/tui/screens/guides/_docs/zh-Hans/guides/extensions/mcp.md +95 -0
  215. chrys/app/tui/screens/guides/_docs/zh-Hans/guides/extensions/skills.md +175 -0
  216. chrys/app/tui/screens/guides/_docs/zh-Hans/guides/running/icode-acp.md +61 -0
  217. chrys/app/tui/screens/guides/_docs/zh-Hans/guides/running/icode-run.md +190 -0
  218. chrys/app/tui/screens/guides/_docs/zh-Hans/guides/running/icode-serve.md +239 -0
  219. chrys/app/tui/screens/guides/_docs/zh-Hans/guides/running/workflows.md +664 -0
  220. chrys/app/tui/screens/guides/_docs/zh-Hans/reference/agent-profile.md +548 -0
  221. chrys/app/tui/screens/guides/_docs/zh-Hans/reference/hooks.md +602 -0
  222. chrys/app/tui/screens/guides/_docs/zh-Hans/reference/opentelemetry.md +177 -0
  223. chrys/app/tui/screens/guides/_docs/zh-Hans/reference/settings.md +245 -0
  224. chrys/app/tui/screens/guides/_docs/zh-Hans/reference/themes.md +149 -0
  225. chrys/app/tui/screens/guides/_docs/zh-Hans/reference/tool-kinds-and-names.md +58 -0
  226. chrys/app/tui/screens/guides/_docs/zh-Hans/reference/tui-slash-commands.md +76 -0
  227. chrys/app/tui/screens/guides/_docs/zh-Hans/reference/workflows.md +1005 -0
  228. chrys/app/tui/screens/guides/_docs/zh-Hans/start/getting-started.md +170 -0
  229. chrys/app/tui/screens/guides/_docs/zh-Hans/start/what-is-icode.md +34 -0
  230. chrys/app/tui/screens/guides/index.py +215 -0
  231. chrys/app/tui/screens/guides/reader.py +173 -0
  232. chrys/app/tui/screens/guides/screen.py +442 -0
  233. chrys/app/tui/screens/guides/screen.tcss +52 -0
  234. chrys/app/tui/screens/logs/__init__.py +7 -0
  235. chrys/app/tui/screens/logs/screen.py +504 -0
  236. chrys/app/tui/screens/logs/screen.tcss +45 -0
  237. chrys/app/tui/screens/main/__init__.py +7 -0
  238. chrys/app/tui/screens/main/action_availability.py +30 -0
  239. chrys/app/tui/screens/main/buddy_command.py +143 -0
  240. chrys/app/tui/screens/main/commands.py +1073 -0
  241. chrys/app/tui/screens/main/config_actions.py +464 -0
  242. chrys/app/tui/screens/main/copy_actions.py +117 -0
  243. chrys/app/tui/screens/main/dialog_controllers.py +826 -0
  244. chrys/app/tui/screens/main/dialog_gateway.py +166 -0
  245. chrys/app/tui/screens/main/diff_controller.py +478 -0
  246. chrys/app/tui/screens/main/engine_read_model.py +184 -0
  247. chrys/app/tui/screens/main/event_handlers.py +2014 -0
  248. chrys/app/tui/screens/main/input_flow.py +241 -0
  249. chrys/app/tui/screens/main/live_diff.py +244 -0
  250. chrys/app/tui/screens/main/main_screen_presenter.py +295 -0
  251. chrys/app/tui/screens/main/model_indicator.py +217 -0
  252. chrys/app/tui/screens/main/navigation.py +343 -0
  253. chrys/app/tui/screens/main/ports.py +571 -0
  254. chrys/app/tui/screens/main/recent_dirs.py +123 -0
  255. chrys/app/tui/screens/main/rollback_controller.py +586 -0
  256. chrys/app/tui/screens/main/runtime_info.py +71 -0
  257. chrys/app/tui/screens/main/screen.py +2265 -0
  258. chrys/app/tui/screens/main/screen.tcss +21 -0
  259. chrys/app/tui/screens/main/session_handlers.py +1127 -0
  260. chrys/app/tui/screens/main/session_title.py +239 -0
  261. chrys/app/tui/screens/main/settings_coordinator.py +365 -0
  262. chrys/app/tui/screens/main/settings_persistence.py +184 -0
  263. chrys/app/tui/screens/main/shell_mode.py +112 -0
  264. chrys/app/tui/screens/main/state.py +289 -0
  265. chrys/app/tui/screens/main/submit_state.py +11 -0
  266. chrys/app/tui/screens/main/subscriptions.py +177 -0
  267. chrys/app/tui/screens/main/suggestions.py +982 -0
  268. chrys/app/tui/screens/main/tool_action_bridge.py +75 -0
  269. chrys/app/tui/screens/main/view_adapter.py +1567 -0
  270. chrys/app/tui/screens/main/workflow_browser.py +747 -0
  271. chrys/app/tui/screens/main/workflow_content.py +248 -0
  272. chrys/app/tui/screens/main/workflow_controller.py +240 -0
  273. chrys/app/tui/screens/main/workflow_feedback.py +137 -0
  274. chrys/app/tui/screens/main/workflow_flow.py +49 -0
  275. chrys/app/tui/screens/main/workflow_run_control.py +456 -0
  276. chrys/app/tui/screens/main/workflow_session_view.py +316 -0
  277. chrys/app/tui/screens/main/workspace_actions.py +272 -0
  278. chrys/app/tui/screens/main/workspace_branch.py +194 -0
  279. chrys/app/tui/screens/menus/__init__.py +8 -0
  280. chrys/app/tui/screens/menus/languages.py +114 -0
  281. chrys/app/tui/screens/models/__init__.py +7 -0
  282. chrys/app/tui/screens/models/picker.py +147 -0
  283. chrys/app/tui/screens/models/screen.py +2043 -0
  284. chrys/app/tui/screens/models/screen.tcss +255 -0
  285. chrys/app/tui/screens/sessions/__init__.py +8 -0
  286. chrys/app/tui/screens/sessions/presenter.py +586 -0
  287. chrys/app/tui/screens/sessions/screen.py +779 -0
  288. chrys/app/tui/screens/sessions/screen.tcss +127 -0
  289. chrys/app/tui/screens/settings/__init__.py +29 -0
  290. chrys/app/tui/screens/settings/danger.py +26 -0
  291. chrys/app/tui/screens/settings/dialog.py +281 -0
  292. chrys/app/tui/screens/settings/layout.py +519 -0
  293. chrys/app/tui/screens/settings/migrate_dialog.py +253 -0
  294. chrys/app/tui/screens/settings/panes/__init__.py +3 -0
  295. chrys/app/tui/screens/settings/panes/notifications.py +248 -0
  296. chrys/app/tui/screens/settings/panes/sessions.py +148 -0
  297. chrys/app/tui/screens/settings/ports.py +55 -0
  298. chrys/app/tui/screens/settings/provenance.py +102 -0
  299. chrys/app/tui/screens/settings/rows.py +546 -0
  300. chrys/app/tui/screens/settings/settings.tcss +406 -0
  301. chrys/app/tui/screens/themes/__init__.py +8 -0
  302. chrys/app/tui/screens/themes/dialogs.py +330 -0
  303. chrys/app/tui/screens/themes/editor.py +632 -0
  304. chrys/app/tui/screens/themes/editor.tcss +268 -0
  305. chrys/app/tui/screens/themes/messages.py +95 -0
  306. chrys/app/tui/screens/themes/palette.py +694 -0
  307. chrys/app/tui/screens/themes/panel.py +113 -0
  308. chrys/app/tui/screens/themes/picker.py +203 -0
  309. chrys/app/tui/screens/themes/save.py +191 -0
  310. chrys/app/tui/support/__init__.py +3 -0
  311. chrys/app/tui/support/file_manager.py +64 -0
  312. chrys/app/tui/support/gc_freeze.py +1114 -0
  313. chrys/app/tui/support/path_shortcuts.py +50 -0
  314. chrys/app/tui/support/prompt_history.py +298 -0
  315. chrys/app/tui/support/workspace_mru.py +476 -0
  316. chrys/app/tui/terminal/__init__.py +6 -0
  317. chrys/app/tui/terminal/_pty_backend.py +114 -0
  318. chrys/app/tui/terminal/emulator/__init__.py +24 -0
  319. chrys/app/tui/terminal/emulator/buffer.py +456 -0
  320. chrys/app/tui/terminal/emulator/charsets.py +113 -0
  321. chrys/app/tui/terminal/emulator/core.py +963 -0
  322. chrys/app/tui/terminal/emulator/input.py +342 -0
  323. chrys/app/tui/terminal/emulator/parser.py +227 -0
  324. chrys/app/tui/terminal/emulator/pen.py +158 -0
  325. chrys/app/tui/terminal/launcher.py +455 -0
  326. chrys/app/tui/terminal/leaked_reports.py +44 -0
  327. chrys/app/tui/terminal/panel.py +323 -0
  328. chrys/app/tui/terminal/pty_process.py +405 -0
  329. chrys/app/tui/terminal/rendering.py +151 -0
  330. chrys/app/tui/terminal/shell.py +273 -0
  331. chrys/app/tui/terminal/shell_integration.py +199 -0
  332. chrys/app/tui/terminal/title.py +224 -0
  333. chrys/app/tui/terminal/widget.py +654 -0
  334. chrys/app/tui/theme.py +422 -0
  335. chrys/app/tui/theme_loader.py +201 -0
  336. chrys/app/tui/theme_stylesheet.py +49 -0
  337. chrys/app/tui/themes/__init__.py +3 -0
  338. chrys/app/tui/themes/document.py +120 -0
  339. chrys/app/tui/themes/preview.py +170 -0
  340. chrys/app/tui/themes/store.py +176 -0
  341. chrys/app/tui/toasts.py +69 -0
  342. chrys/app/tui/util/__init__.py +3 -0
  343. chrys/app/tui/util/context_pressure.py +146 -0
  344. chrys/app/tui/util/debug_log.py +292 -0
  345. chrys/app/tui/util/diff_entries.py +64 -0
  346. chrys/app/tui/util/formatting.py +53 -0
  347. chrys/app/tui/util/git_branch.py +326 -0
  348. chrys/app/tui/util/invocation_progress.py +57 -0
  349. chrys/app/tui/util/logo.py +32 -0
  350. chrys/app/tui/util/message_gate.py +35 -0
  351. chrys/app/tui/util/mutation_sources.py +14 -0
  352. chrys/app/tui/util/picker_text.py +27 -0
  353. chrys/app/tui/util/removal.py +47 -0
  354. chrys/app/tui/util/rich_style.py +36 -0
  355. chrys/app/tui/util/source_text.py +12 -0
  356. chrys/app/tui/util/static_update.py +88 -0
  357. chrys/app/tui/util/todo_format.py +51 -0
  358. chrys/app/tui/util/visibility.py +214 -0
  359. chrys/app/tui/widgets/__init__.py +60 -0
  360. chrys/app/tui/widgets/ask_user_controls.py +652 -0
  361. chrys/app/tui/widgets/ask_user_markdown.py +17 -0
  362. chrys/app/tui/widgets/ask_user_prompt.py +606 -0
  363. chrys/app/tui/widgets/buttons.py +52 -0
  364. chrys/app/tui/widgets/chat/__init__.py +3 -0
  365. chrys/app/tui/widgets/chat/agent_transcript_surface.py +818 -0
  366. chrys/app/tui/widgets/chat/chrome.py +296 -0
  367. chrys/app/tui/widgets/chat/compaction_card.py +353 -0
  368. chrys/app/tui/widgets/chat/context_fold.py +77 -0
  369. chrys/app/tui/widgets/chat/file_snapshot.py +91 -0
  370. chrys/app/tui/widgets/chat/image_preview.py +214 -0
  371. chrys/app/tui/widgets/chat/live_renderer.py +383 -0
  372. chrys/app/tui/widgets/chat/messages.py +1039 -0
  373. chrys/app/tui/widgets/chat/panel.py +1769 -0
  374. chrys/app/tui/widgets/chat/ports.py +245 -0
  375. chrys/app/tui/widgets/chat/renderers/__init__.py +7 -0
  376. chrys/app/tui/widgets/chat/renderers/ask_user.py +747 -0
  377. chrys/app/tui/widgets/chat/renderers/execute.py +431 -0
  378. chrys/app/tui/widgets/chat/renderers/file_edit.py +925 -0
  379. chrys/app/tui/widgets/chat/renderers/hosted_code.py +83 -0
  380. chrys/app/tui/widgets/chat/renderers/hosted_generic.py +330 -0
  381. chrys/app/tui/widgets/chat/renderers/hosted_image.py +107 -0
  382. chrys/app/tui/widgets/chat/renderers/hosted_mcp.py +43 -0
  383. chrys/app/tui/widgets/chat/renderers/hosted_search.py +167 -0
  384. chrys/app/tui/widgets/chat/renderers/hosted_shell.py +132 -0
  385. chrys/app/tui/widgets/chat/renderers/read_file.py +399 -0
  386. chrys/app/tui/widgets/chat/renderers/search.py +348 -0
  387. chrys/app/tui/widgets/chat/renderers/skill.py +564 -0
  388. chrys/app/tui/widgets/chat/renderers/sleep.py +287 -0
  389. chrys/app/tui/widgets/chat/renderers/sub_agent.py +1385 -0
  390. chrys/app/tui/widgets/chat/renderers/todo.py +114 -0
  391. chrys/app/tui/widgets/chat/renderers/web.py +69 -0
  392. chrys/app/tui/widgets/chat/replay.py +1605 -0
  393. chrys/app/tui/widgets/chat/replay_mount.py +322 -0
  394. chrys/app/tui/widgets/chat/scroll_controller.py +579 -0
  395. chrys/app/tui/widgets/chat/selection_controller.py +435 -0
  396. chrys/app/tui/widgets/chat/session_json.py +508 -0
  397. chrys/app/tui/widgets/chat/toc_model.py +156 -0
  398. chrys/app/tui/widgets/chat/tool_call.py +2478 -0
  399. chrys/app/tui/widgets/chat/tool_registry.py +753 -0
  400. chrys/app/tui/widgets/chat/tool_renderers.py +247 -0
  401. chrys/app/tui/widgets/chat/tool_view_builders.py +260 -0
  402. chrys/app/tui/widgets/chat/transcript_reader.py +57 -0
  403. chrys/app/tui/widgets/checkbox.py +24 -0
  404. chrys/app/tui/widgets/chrome/__init__.py +3 -0
  405. chrys/app/tui/widgets/chrome/app_header.py +283 -0
  406. chrys/app/tui/widgets/chrome/commands.py +95 -0
  407. chrys/app/tui/widgets/chrome/file_index.py +68 -0
  408. chrys/app/tui/widgets/chrome/file_scanner.py +670 -0
  409. chrys/app/tui/widgets/chrome/footer.py +284 -0
  410. chrys/app/tui/widgets/chrome/image_paste.py +351 -0
  411. chrys/app/tui/widgets/chrome/input_bar.py +1299 -0
  412. chrys/app/tui/widgets/chrome/status_bar.py +911 -0
  413. chrys/app/tui/widgets/chrome/suggestion_list.py +652 -0
  414. chrys/app/tui/widgets/click_affordance.py +24 -0
  415. chrys/app/tui/widgets/color_picker/__init__.py +254 -0
  416. chrys/app/tui/widgets/color_picker/controls.py +171 -0
  417. chrys/app/tui/widgets/color_picker/model.py +89 -0
  418. chrys/app/tui/widgets/dialog_buttons.py +58 -0
  419. chrys/app/tui/widgets/dialog_scroll.py +24 -0
  420. chrys/app/tui/widgets/diff_view/__init__.py +20 -0
  421. chrys/app/tui/widgets/diff_view/cells.py +59 -0
  422. chrys/app/tui/widgets/diff_view/code.py +280 -0
  423. chrys/app/tui/widgets/diff_view/compute.py +286 -0
  424. chrys/app/tui/widgets/diff_view/gutter.py +161 -0
  425. chrys/app/tui/widgets/diff_view/inline.py +77 -0
  426. chrys/app/tui/widgets/diff_view/palette.py +87 -0
  427. chrys/app/tui/widgets/diff_view/protocols.py +24 -0
  428. chrys/app/tui/widgets/diff_view/rows.py +138 -0
  429. chrys/app/tui/widgets/diff_view/unified.py +267 -0
  430. chrys/app/tui/widgets/diff_view/widget.py +209 -0
  431. chrys/app/tui/widgets/editor/__init__.py +79 -0
  432. chrys/app/tui/widgets/editor/coordinates.py +30 -0
  433. chrys/app/tui/widgets/editor/highlighter.py +139 -0
  434. chrys/app/tui/widgets/editor/keymaps.py +357 -0
  435. chrys/app/tui/widgets/editor/types.py +216 -0
  436. chrys/app/tui/widgets/editor/vim.py +1195 -0
  437. chrys/app/tui/widgets/editor/widget.py +401 -0
  438. chrys/app/tui/widgets/file_preview.py +58 -0
  439. chrys/app/tui/widgets/hatch.py +138 -0
  440. chrys/app/tui/widgets/input.py +66 -0
  441. chrys/app/tui/widgets/loading.py +125 -0
  442. chrys/app/tui/widgets/markdown/__init__.py +25 -0
  443. chrys/app/tui/widgets/markdown/_utils.py +21 -0
  444. chrys/app/tui/widgets/markdown/anchors.py +71 -0
  445. chrys/app/tui/widgets/markdown/blocks.py +115 -0
  446. chrys/app/tui/widgets/markdown/copyable.py +163 -0
  447. chrys/app/tui/widgets/markdown/diagram/__init__.py +35 -0
  448. chrys/app/tui/widgets/markdown/diagram/canvas.py +409 -0
  449. chrys/app/tui/widgets/markdown/diagram/charts.py +52 -0
  450. chrys/app/tui/widgets/markdown/diagram/diagnostics.py +101 -0
  451. chrys/app/tui/widgets/markdown/diagram/geometry.py +46 -0
  452. chrys/app/tui/widgets/markdown/diagram/layout.py +494 -0
  453. chrys/app/tui/widgets/markdown/diagram/messages.py +18 -0
  454. chrys/app/tui/widgets/markdown/diagram/model.py +275 -0
  455. chrys/app/tui/widgets/markdown/diagram/parser.py +173 -0
  456. chrys/app/tui/widgets/markdown/diagram/parsers/__init__.py +3 -0
  457. chrys/app/tui/widgets/markdown/diagram/parsers/charts.py +349 -0
  458. chrys/app/tui/widgets/markdown/diagram/parsers/common.py +403 -0
  459. chrys/app/tui/widgets/markdown/diagram/parsers/graphs.py +1072 -0
  460. chrys/app/tui/widgets/markdown/diagram/parsers/planning.py +317 -0
  461. chrys/app/tui/widgets/markdown/diagram/parsers/schedule.py +298 -0
  462. chrys/app/tui/widgets/markdown/diagram/parsers/schemas.py +241 -0
  463. chrys/app/tui/widgets/markdown/diagram/parsers/structure.py +221 -0
  464. chrys/app/tui/widgets/markdown/diagram/presentation.py +32 -0
  465. chrys/app/tui/widgets/markdown/diagram/renderers/__init__.py +3 -0
  466. chrys/app/tui/widgets/markdown/diagram/renderers/charts.py +275 -0
  467. chrys/app/tui/widgets/markdown/diagram/renderers/common.py +73 -0
  468. chrys/app/tui/widgets/markdown/diagram/renderers/planning.py +157 -0
  469. chrys/app/tui/widgets/markdown/diagram/renderers/schedule.py +202 -0
  470. chrys/app/tui/widgets/markdown/diagram/renderers/structure.py +77 -0
  471. chrys/app/tui/widgets/markdown/diagram/renderers/xy.py +385 -0
  472. chrys/app/tui/widgets/markdown/diagram/router.py +651 -0
  473. chrys/app/tui/widgets/markdown/diagram/specs/__init__.py +3 -0
  474. chrys/app/tui/widgets/markdown/diagram/specs/charts.py +94 -0
  475. chrys/app/tui/widgets/markdown/diagram/specs/planning.py +68 -0
  476. chrys/app/tui/widgets/markdown/diagram/specs/schedule.py +65 -0
  477. chrys/app/tui/widgets/markdown/diagram/specs/structure.py +23 -0
  478. chrys/app/tui/widgets/markdown/diagram/viewer.py +92 -0
  479. chrys/app/tui/widgets/markdown/links.py +47 -0
  480. chrys/app/tui/widgets/markdown/math/__init__.py +36 -0
  481. chrys/app/tui/widgets/markdown/math/layout.py +739 -0
  482. chrys/app/tui/widgets/markdown/math/markdown.py +291 -0
  483. chrys/app/tui/widgets/markdown/math/parser.py +325 -0
  484. chrys/app/tui/widgets/markdown/math/symbols.py +325 -0
  485. chrys/app/tui/widgets/markdown/navigator.py +80 -0
  486. chrys/app/tui/widgets/markdown/parser.py +1057 -0
  487. chrys/app/tui/widgets/markdown/stream.py +73 -0
  488. chrys/app/tui/widgets/markdown/toc.py +98 -0
  489. chrys/app/tui/widgets/markdown/viewer.py +146 -0
  490. chrys/app/tui/widgets/markdown/widget.py +2046 -0
  491. chrys/app/tui/widgets/marquee.py +101 -0
  492. chrys/app/tui/widgets/option_menu.py +82 -0
  493. chrys/app/tui/widgets/page_navigator.py +118 -0
  494. chrys/app/tui/widgets/select.py +196 -0
  495. chrys/app/tui/widgets/selection.py +59 -0
  496. chrys/app/tui/widgets/sidebar/__init__.py +3 -0
  497. chrys/app/tui/widgets/sidebar/buddy.py +367 -0
  498. chrys/app/tui/widgets/sidebar/context.py +482 -0
  499. chrys/app/tui/widgets/sidebar/debug.py +195 -0
  500. chrys/app/tui/widgets/sidebar/empty_state.py +11 -0
  501. chrys/app/tui/widgets/sidebar/panel.py +198 -0
  502. chrys/app/tui/widgets/sidebar/tasks.py +198 -0
  503. chrys/app/tui/widgets/sidebar/toc.py +166 -0
  504. chrys/app/tui/widgets/sidebar/toc_types.py +30 -0
  505. chrys/app/tui/widgets/syntax_theme.py +85 -0
  506. chrys/app/tui/widgets/text_area.py +258 -0
  507. chrys/app/tui/widgets/trajectory/__init__.py +8 -0
  508. chrys/app/tui/widgets/trajectory/chartkit.py +264 -0
  509. chrys/app/tui/widgets/trajectory/insights.py +1266 -0
  510. chrys/app/tui/widgets/trajectory/overview.py +1101 -0
  511. chrys/app/tui/widgets/trajectory/panel.py +983 -0
  512. chrys/app/tui/widgets/trajectory/presentation.py +311 -0
  513. chrys/app/tui/widgets/trajectory/session_info.py +124 -0
  514. chrys/app/tui/widgets/trajectory/text_view.py +124 -0
  515. chrys/app/tui/widgets/trajectory/timeline.py +380 -0
  516. chrys/app/tui/widgets/welcome.py +126 -0
  517. chrys/app/tui/widgets/workflow/__init__.py +5 -0
  518. chrys/app/tui/widgets/workflow/edges.py +89 -0
  519. chrys/app/tui/widgets/workflow/graph.py +869 -0
  520. chrys/app/tui/widgets/workflow/info.py +310 -0
  521. chrys/app/tui/widgets/workflow/node_view.py +27 -0
  522. chrys/app/tui/widgets/workflow/output.py +225 -0
  523. chrys/app/tui/widgets/workflow/panel.py +658 -0
  524. chrys/app/tui/widgets/workflow/projector.py +324 -0
  525. chrys/app/tui/widgets/workflow/records.py +69 -0
  526. chrys/app/tui/widgets/workflow/scrollbar.py +82 -0
  527. chrys/app/tui/widgets/workflow/selection.py +151 -0
  528. chrys/app/tui/widgets/workflow/source.py +65 -0
  529. chrys/app/tui/widgets/workflow/text.py +257 -0
  530. chrys/app/tui/widgets/workflow/values.py +317 -0
  531. chrys/foundation/__init__.py +0 -0
  532. chrys/foundation/branding.py +15 -0
  533. chrys/foundation/config/__init__.py +7 -0
  534. chrys/foundation/config/coercion.py +374 -0
  535. chrys/foundation/config/context.py +34 -0
  536. chrys/foundation/config/env_file.py +141 -0
  537. chrys/foundation/config/env_layers.py +370 -0
  538. chrys/foundation/config/migrations.py +521 -0
  539. chrys/foundation/config/notification_events.py +22 -0
  540. chrys/foundation/config/process_settings.py +332 -0
  541. chrys/foundation/config/runtime_pointer.py +133 -0
  542. chrys/foundation/config/settings.py +1952 -0
  543. chrys/foundation/config/settings_store.py +996 -0
  544. chrys/foundation/config/spec.py +396 -0
  545. chrys/foundation/config/user_settings.py +140 -0
  546. chrys/foundation/config/warnings.py +262 -0
  547. chrys/foundation/config/web_values.py +135 -0
  548. chrys/foundation/config/yaml_store.py +487 -0
  549. chrys/foundation/errors/__init__.py +68 -0
  550. chrys/foundation/errors/_legacy.py +117 -0
  551. chrys/foundation/errors/_text.py +13 -0
  552. chrys/foundation/errors/_walk.py +85 -0
  553. chrys/foundation/errors/classify.py +278 -0
  554. chrys/foundation/errors/display.py +274 -0
  555. chrys/foundation/errors/formatting.py +283 -0
  556. chrys/foundation/errors/kinds.py +67 -0
  557. chrys/foundation/errors/network.py +497 -0
  558. chrys/foundation/errors/provider.py +445 -0
  559. chrys/foundation/errors/route.py +85 -0
  560. chrys/foundation/events/__init__.py +8 -0
  561. chrys/foundation/events/bus.py +143 -0
  562. chrys/foundation/events/types.py +1629 -0
  563. chrys/foundation/events/workflow.py +38 -0
  564. chrys/foundation/hosted_tools.py +142 -0
  565. chrys/foundation/i18n/__init__.py +32 -0
  566. chrys/foundation/i18n/_catalogs/zh-Hans/LC_MESSAGES/chrys.mo +0 -0
  567. chrys/foundation/i18n/catalogs.py +42 -0
  568. chrys/foundation/i18n/formatting.py +202 -0
  569. chrys/foundation/i18n/locale.py +92 -0
  570. chrys/foundation/i18n/localizer.py +187 -0
  571. chrys/foundation/i18n/messages.py +268 -0
  572. chrys/foundation/io/__init__.py +3 -0
  573. chrys/foundation/io/result_content.py +237 -0
  574. chrys/foundation/models/__init__.py +3 -0
  575. chrys/foundation/models/approval_reuse.py +23 -0
  576. chrys/foundation/models/ask_user.py +468 -0
  577. chrys/foundation/models/execution.py +18 -0
  578. chrys/foundation/models/history_markers.py +115 -0
  579. chrys/foundation/models/invocations.py +63 -0
  580. chrys/foundation/models/mutation_scope.py +28 -0
  581. chrys/foundation/models/session_env.py +87 -0
  582. chrys/foundation/models/session_surface.py +29 -0
  583. chrys/foundation/models/todos.py +74 -0
  584. chrys/foundation/models/turns.py +125 -0
  585. chrys/foundation/models/workflow_session.py +89 -0
  586. chrys/foundation/models/workspace.py +59 -0
  587. chrys/foundation/net/__init__.py +3 -0
  588. chrys/foundation/net/address_class.py +57 -0
  589. chrys/foundation/net/route_probe.py +94 -0
  590. chrys/foundation/net/url.py +107 -0
  591. chrys/foundation/observability/__init__.py +3 -0
  592. chrys/foundation/observability/exporters.py +174 -0
  593. chrys/foundation/observability/gate.py +31 -0
  594. chrys/foundation/observability/setup.py +497 -0
  595. chrys/foundation/observability/sink.py +228 -0
  596. chrys/foundation/patches/__init__.py +19 -0
  597. chrys/foundation/patches/mdurl_cache.py +59 -0
  598. chrys/foundation/patches/patcher.py +287 -0
  599. chrys/foundation/patches/staged_members.py +205 -0
  600. chrys/foundation/patches/textual_block_border.py +105 -0
  601. chrys/foundation/patches/textual_button_ansi.py +65 -0
  602. chrys/foundation/patches/textual_button_ansi_old.txt +68 -0
  603. chrys/foundation/patches/textual_callback_cache.py +248 -0
  604. chrys/foundation/patches/textual_callback_dispatch.py +109 -0
  605. chrys/foundation/patches/textual_compositor_cjk.py +566 -0
  606. chrys/foundation/patches/textual_dispatch_cache.py +150 -0
  607. chrys/foundation/patches/textual_ime_cursor_anchor.py +92 -0
  608. chrys/foundation/patches/textual_kitty_keyboard.py +268 -0
  609. chrys/foundation/patches/textual_lru_acyclic.py +241 -0
  610. chrys/foundation/patches/textual_message_pump.py +136 -0
  611. chrys/foundation/patches/textual_node_diet.py +875 -0
  612. chrys/foundation/patches/textual_one_shot_timer.py +48 -0
  613. chrys/foundation/patches/textual_option_list.py +90 -0
  614. chrys/foundation/patches/textual_precompose.py +89 -0
  615. chrys/foundation/patches/textual_pruned_tabs.py +69 -0
  616. chrys/foundation/patches/textual_reflow_reuse.py +1753 -0
  617. chrys/foundation/patches/textual_removed_node_caches.py +86 -0
  618. chrys/foundation/patches/textual_removed_screen_callbacks.py +55 -0
  619. chrys/foundation/patches/textual_selection_extract.py +48 -0
  620. chrys/foundation/patches/textual_strip_cycles.py +119 -0
  621. chrys/foundation/patches/textual_tab_selection.py +1611 -0
  622. chrys/foundation/patches/textual_timer_skip.py +70 -0
  623. chrys/foundation/patches/textual_utf8_decoder.py +106 -0
  624. chrys/foundation/patches/textual_win_sleep.py +167 -0
  625. chrys/foundation/patches/watchdog_fsevents.py +70 -0
  626. chrys/foundation/patches/watchdog_windows.py +52 -0
  627. chrys/foundation/platform/__init__.py +556 -0
  628. chrys/foundation/platform/c_api.py +32 -0
  629. chrys/foundation/platform/child_reap.py +107 -0
  630. chrys/foundation/platform/command_line.py +95 -0
  631. chrys/foundation/platform/files.py +1470 -0
  632. chrys/foundation/platform/output_capture.py +113 -0
  633. chrys/foundation/platform/paths.py +193 -0
  634. chrys/foundation/platform/process.py +1468 -0
  635. chrys/foundation/platform/pty_output.py +19 -0
  636. chrys/foundation/platform/runtime_paths.py +281 -0
  637. chrys/foundation/platform/win_console.py +129 -0
  638. chrys/foundation/reasoning_origin.py +67 -0
  639. chrys/foundation/recovery.py +16 -0
  640. chrys/foundation/retry.py +278 -0
  641. chrys/foundation/text/__init__.py +3 -0
  642. chrys/foundation/text/encoding.py +603 -0
  643. chrys/foundation/text/images.py +304 -0
  644. chrys/foundation/text/lines.py +24 -0
  645. chrys/foundation/text/mentions.py +90 -0
  646. chrys/foundation/text/model_json.py +25 -0
  647. chrys/foundation/text/reading_order.py +20 -0
  648. chrys/foundation/text/tokenizer.py +25 -0
  649. chrys/foundation/text/tool_output.py +287 -0
  650. chrys/foundation/text/yaml_io.py +62 -0
  651. chrys/foundation/tool_call_context.py +202 -0
  652. chrys/foundation/tool_execution_stamp.py +184 -0
  653. chrys/foundation/tool_invocation_order.py +48 -0
  654. chrys/foundation/tool_kinds.py +102 -0
  655. chrys/foundation/tool_result_metadata.py +287 -0
  656. chrys/foundation/trajectory/__init__.py +10 -0
  657. chrys/foundation/trajectory/context.py +481 -0
  658. chrys/foundation/trajectory/envelope.py +571 -0
  659. chrys/foundation/trajectory/event_types.py +294 -0
  660. chrys/foundation/trajectory/finalizers.py +46 -0
  661. chrys/foundation/trajectory/fingerprint.py +62 -0
  662. chrys/foundation/trajectory/ids.py +155 -0
  663. chrys/foundation/trajectory/keys.py +99 -0
  664. chrys/foundation/trajectory/lease.py +173 -0
  665. chrys/foundation/trajectory/metadata.py +72 -0
  666. chrys/foundation/trajectory/reader.py +271 -0
  667. chrys/foundation/trajectory/recovery.py +225 -0
  668. chrys/foundation/trajectory/revisions.py +152 -0
  669. chrys/foundation/trajectory/segments.py +225 -0
  670. chrys/foundation/trajectory/tools.py +117 -0
  671. chrys/foundation/trajectory/usage.py +151 -0
  672. chrys/foundation/trajectory/writer.py +972 -0
  673. chrys/foundation/trajectory_timing.py +97 -0
  674. chrys/foundation/util/__init__.py +3 -0
  675. chrys/foundation/util/async_tasks.py +29 -0
  676. chrys/foundation/util/chrys_headers.py +23 -0
  677. chrys/foundation/util/env_templates.py +104 -0
  678. chrys/foundation/util/filenames.py +96 -0
  679. chrys/foundation/util/header_charset.py +138 -0
  680. chrys/foundation/util/httpx_helpers.py +12 -0
  681. chrys/foundation/util/lock.py +236 -0
  682. chrys/foundation/util/once_close.py +46 -0
  683. chrys/foundation/util/session_ids.py +29 -0
  684. chrys/foundation/util/shell_tokens.py +88 -0
  685. chrys/foundation/util/sub_agent_context.py +38 -0
  686. chrys/foundation/util/time.py +35 -0
  687. chrys/foundation/util/unicode_scalars.py +36 -0
  688. chrys/foundation/vendor/__init__.py +43 -0
  689. chrys/foundation/vendor/ripgrep/rg-aarch64-apple-darwin +0 -0
  690. chrys/foundation/vendor/ripgrep/rg-aarch64-pc-windows-msvc.exe +0 -0
  691. chrys/foundation/vendor/ripgrep/rg-aarch64-unknown-linux-gnu +0 -0
  692. chrys/foundation/vendor/ripgrep/rg-x86_64-apple-darwin +0 -0
  693. chrys/foundation/vendor/ripgrep/rg-x86_64-pc-windows-msvc.exe +0 -0
  694. chrys/foundation/vendor/ripgrep/rg-x86_64-unknown-linux-musl +0 -0
  695. chrys/kernel/__init__.py +256 -0
  696. chrys/kernel/_content.py +1866 -0
  697. chrys/kernel/_loop_recorder.py +631 -0
  698. chrys/kernel/_null_overlay.py +1026 -0
  699. chrys/kernel/_result_ceiling.py +216 -0
  700. chrys/kernel/_serialization.py +431 -0
  701. chrys/kernel/_tool_arg_errors.py +1629 -0
  702. chrys/kernel/_tool_execution.py +1172 -0
  703. chrys/kernel/_tool_expansion.py +26 -0
  704. chrys/kernel/_types.py +2598 -0
  705. chrys/kernel/agent.py +994 -0
  706. chrys/kernel/client.py +1084 -0
  707. chrys/kernel/compaction.py +1315 -0
  708. chrys/kernel/exceptions.py +211 -0
  709. chrys/kernel/exchanges.py +683 -0
  710. chrys/kernel/identity.py +95 -0
  711. chrys/kernel/images.py +36 -0
  712. chrys/kernel/instrumentation.py +1618 -0
  713. chrys/kernel/loop.py +2308 -0
  714. chrys/kernel/middleware.py +616 -0
  715. chrys/kernel/sessions.py +604 -0
  716. chrys/kernel/tools.py +1061 -0
  717. chrys/kernel/types.py +126 -0
  718. chrys/orchestration/__init__.py +3 -0
  719. chrys/orchestration/engine/__init__.py +3 -0
  720. chrys/orchestration/engine/assembly.py +236 -0
  721. chrys/orchestration/engine/build/__init__.py +3 -0
  722. chrys/orchestration/engine/build/builder.py +1061 -0
  723. chrys/orchestration/engine/build/construction.py +408 -0
  724. chrys/orchestration/engine/build/loaded.py +139 -0
  725. chrys/orchestration/engine/engine.py +789 -0
  726. chrys/orchestration/engine/execution.py +771 -0
  727. chrys/orchestration/engine/loader.py +865 -0
  728. chrys/orchestration/engine/rollback.py +918 -0
  729. chrys/orchestration/engine/run/__init__.py +3 -0
  730. chrys/orchestration/engine/run/active_injection.py +419 -0
  731. chrys/orchestration/engine/run/attachments.py +519 -0
  732. chrys/orchestration/engine/run/bindings.py +1095 -0
  733. chrys/orchestration/engine/run/coordinator.py +1062 -0
  734. chrys/orchestration/engine/run/finalizer.py +417 -0
  735. chrys/orchestration/engine/run/input_refs.py +60 -0
  736. chrys/orchestration/engine/run/prompt_content.py +457 -0
  737. chrys/orchestration/engine/run/resume.py +299 -0
  738. chrys/orchestration/engine/run/retry.py +948 -0
  739. chrys/orchestration/engine/run/runner.py +625 -0
  740. chrys/orchestration/engine/run/runtime_skills.py +146 -0
  741. chrys/orchestration/engine/run/sub_agent_coordination.py +126 -0
  742. chrys/orchestration/engine/run/turn_hooks.py +258 -0
  743. chrys/orchestration/engine/run/turn_state.py +149 -0
  744. chrys/orchestration/engine/run/working_dir.py +50 -0
  745. chrys/orchestration/engine/session_lifecycle.py +2013 -0
  746. chrys/orchestration/engine/state/__init__.py +3 -0
  747. chrys/orchestration/engine/state/active_session.py +183 -0
  748. chrys/orchestration/engine/state/controls.py +639 -0
  749. chrys/orchestration/engine/state/current_agent.py +26 -0
  750. chrys/orchestration/engine/state/lifecycle_permits.py +395 -0
  751. chrys/orchestration/engine/state/machine.py +193 -0
  752. chrys/orchestration/engine/state/session_writer.py +434 -0
  753. chrys/orchestration/engine/trajectory.py +714 -0
  754. chrys/orchestration/engine/usage.py +118 -0
  755. chrys/orchestration/invoker/__init__.py +3 -0
  756. chrys/orchestration/invoker/acp.py +727 -0
  757. chrys/orchestration/invoker/acp_protocol.py +1115 -0
  758. chrys/orchestration/invoker/acp_spec.py +79 -0
  759. chrys/orchestration/invoker/attempts.py +994 -0
  760. chrys/orchestration/invoker/child_compaction.py +131 -0
  761. chrys/orchestration/invoker/child_history.py +99 -0
  762. chrys/orchestration/invoker/contracts.py +253 -0
  763. chrys/orchestration/invoker/evidence.py +94 -0
  764. chrys/orchestration/invoker/kernel.py +343 -0
  765. chrys/orchestration/invoker/origin.py +79 -0
  766. chrys/orchestration/invoker/resources.py +343 -0
  767. chrys/orchestration/invoker/runtime.py +299 -0
  768. chrys/orchestration/invoker/usage.py +80 -0
  769. chrys/orchestration/session_hooks.py +117 -0
  770. chrys/orchestration/session_host.py +1023 -0
  771. chrys/orchestration/session_resources.py +95 -0
  772. chrys/orchestration/session_usage.py +219 -0
  773. chrys/orchestration/startup.py +255 -0
  774. chrys/orchestration/sub_agents/__init__.py +3 -0
  775. chrys/orchestration/sub_agents/acp_policy.py +269 -0
  776. chrys/orchestration/sub_agents/kernel_policy.py +848 -0
  777. chrys/orchestration/sub_agents/registration.py +44 -0
  778. chrys/orchestration/sub_agents/shell.py +337 -0
  779. chrys/orchestration/sub_agents/tools.py +2228 -0
  780. chrys/orchestration/workflows/__init__.py +3 -0
  781. chrys/orchestration/workflows/agent_archive.py +156 -0
  782. chrys/orchestration/workflows/agent_node.py +894 -0
  783. chrys/orchestration/workflows/agent_node_build.py +687 -0
  784. chrys/orchestration/workflows/catalog.py +332 -0
  785. chrys/orchestration/workflows/coordinator.py +771 -0
  786. chrys/orchestration/workflows/hooks.py +176 -0
  787. chrys/orchestration/workflows/preview.py +322 -0
  788. chrys/orchestration/workflows/rollback.py +151 -0
  789. chrys/orchestration/workflows/runner.py +986 -0
  790. chrys/orchestration/workflows/session.py +390 -0
  791. chrys/orchestration/workflows/settings.py +97 -0
  792. chrys/orchestration/workflows/validation.py +674 -0
  793. chrys/orchestration/workflows/worker_client.py +1002 -0
  794. chrys/service/__init__.py +3 -0
  795. chrys/service/acp_client/__init__.py +65 -0
  796. chrys/service/acp_client/_transport.py +922 -0
  797. chrys/service/acp_client/client.py +1311 -0
  798. chrys/service/acp_client/errors.py +224 -0
  799. chrys/service/acp_client/protocol.py +220 -0
  800. chrys/service/acp_client/spawn.py +239 -0
  801. chrys/service/acp_client/spec.py +100 -0
  802. chrys/service/agent_middleware/__init__.py +60 -0
  803. chrys/service/agent_middleware/_metadata_keys.py +52 -0
  804. chrys/service/agent_middleware/control/__init__.py +3 -0
  805. chrys/service/agent_middleware/control/approval.py +769 -0
  806. chrys/service/agent_middleware/control/approval_hooks.py +62 -0
  807. chrys/service/agent_middleware/control/ask_user.py +207 -0
  808. chrys/service/agent_middleware/control/interrupt.py +50 -0
  809. chrys/service/agent_middleware/control/sleep.py +118 -0
  810. chrys/service/agent_middleware/control/todo.py +117 -0
  811. chrys/service/agent_middleware/events/__init__.py +3 -0
  812. chrys/service/agent_middleware/events/hook_dispatch.py +250 -0
  813. chrys/service/agent_middleware/events/hosted_tools.py +1762 -0
  814. chrys/service/agent_middleware/events/intermediate_text.py +128 -0
  815. chrys/service/agent_middleware/events/rejection_metadata.py +50 -0
  816. chrys/service/agent_middleware/events/result_persistence.py +98 -0
  817. chrys/service/agent_middleware/events/sub_agent_events.py +951 -0
  818. chrys/service/agent_middleware/events/tool_error_text.py +22 -0
  819. chrys/service/agent_middleware/events/tool_events.py +779 -0
  820. chrys/service/agent_middleware/injection.py +510 -0
  821. chrys/service/agent_middleware/reminders/__init__.py +91 -0
  822. chrys/service/agent_middleware/reminders/archive_pointer.py +154 -0
  823. chrys/service/agent_middleware/reminders/base.py +25 -0
  824. chrys/service/agent_middleware/reminders/context_usage.py +74 -0
  825. chrys/service/agent_middleware/reminders/file_change.py +63 -0
  826. chrys/service/agent_middleware/reminders/mcp.py +39 -0
  827. chrys/service/agent_middleware/reminders/profile_switch.py +106 -0
  828. chrys/service/agent_middleware/reminders/runtime_env.py +190 -0
  829. chrys/service/agent_middleware/reminders/skills.py +44 -0
  830. chrys/service/agent_middleware/reminders/sub_agents.py +35 -0
  831. chrys/service/agent_middleware/reminders/todo.py +35 -0
  832. chrys/service/agent_middleware/reminders/turn_line.py +48 -0
  833. chrys/service/agent_middleware/response_validation.py +1380 -0
  834. chrys/service/agent_middleware/system_reminder.py +1107 -0
  835. chrys/service/agent_middleware/validators.py +396 -0
  836. chrys/service/analytics/__init__.py +124 -0
  837. chrys/service/analytics/_actions.py +896 -0
  838. chrys/service/analytics/_context_evidence.py +357 -0
  839. chrys/service/analytics/_critical_path.py +179 -0
  840. chrys/service/analytics/_facts.py +919 -0
  841. chrys/service/analytics/_insights.py +769 -0
  842. chrys/service/analytics/_metric_ops.py +83 -0
  843. chrys/service/analytics/_session_projection.py +460 -0
  844. chrys/service/analytics/_timeline.py +434 -0
  845. chrys/service/analytics/_turn_graph.py +1237 -0
  846. chrys/service/analytics/_turns.py +1377 -0
  847. chrys/service/analytics/_workflows.py +171 -0
  848. chrys/service/analytics/aggregation.py +1474 -0
  849. chrys/service/analytics/classification.py +128 -0
  850. chrys/service/analytics/export.py +803 -0
  851. chrys/service/analytics/findings.py +282 -0
  852. chrys/service/analytics/math.py +79 -0
  853. chrys/service/analytics/model.py +886 -0
  854. chrys/service/analytics/reader.py +321 -0
  855. chrys/service/approval/__init__.py +3 -0
  856. chrys/service/approval/arbitration.py +102 -0
  857. chrys/service/approval/command_identity.py +109 -0
  858. chrys/service/approval/correlation.py +57 -0
  859. chrys/service/approval/grant_store.py +108 -0
  860. chrys/service/approval/judge.py +589 -0
  861. chrys/service/approval/level.py +15 -0
  862. chrys/service/approval/policy.py +209 -0
  863. chrys/service/approval/reuse.py +223 -0
  864. chrys/service/approval/reuse_binding.py +133 -0
  865. chrys/service/approval/safety_classifier.py +536 -0
  866. chrys/service/approval/turn_context.py +49 -0
  867. chrys/service/context/__init__.py +3 -0
  868. chrys/service/context/compaction/__init__.py +38 -0
  869. chrys/service/context/compaction/breaker.py +156 -0
  870. chrys/service/context/compaction/budgets.py +93 -0
  871. chrys/service/context/compaction/calibration.py +77 -0
  872. chrys/service/context/compaction/compression.py +690 -0
  873. chrys/service/context/compaction/current_turn_drop.py +458 -0
  874. chrys/service/context/compaction/events.py +201 -0
  875. chrys/service/context/compaction/exclusions.py +261 -0
  876. chrys/service/context/compaction/groups.py +361 -0
  877. chrys/service/context/compaction/last_words.py +1996 -0
  878. chrys/service/context/compaction/last_words_state.py +752 -0
  879. chrys/service/context/compaction/scoped.py +472 -0
  880. chrys/service/context/compaction/spill.py +1947 -0
  881. chrys/service/context/compaction/strategy.py +1116 -0
  882. chrys/service/context/compaction/summaries.py +319 -0
  883. chrys/service/context/compaction/turns.py +375 -0
  884. chrys/service/context/manager.py +174 -0
  885. chrys/service/context/memory_loader.py +792 -0
  886. chrys/service/context/middleware/__init__.py +3 -0
  887. chrys/service/context/middleware/usage.py +332 -0
  888. chrys/service/context/providers/__init__.py +3 -0
  889. chrys/service/context/providers/context_management.py +432 -0
  890. chrys/service/context/providers/history.py +647 -0
  891. chrys/service/context/providers/memory.py +50 -0
  892. chrys/service/context/tool_content.py +22 -0
  893. chrys/service/hooks/__init__.py +37 -0
  894. chrys/service/hooks/detached_worker.py +118 -0
  895. chrys/service/hooks/docs/README.md +14 -0
  896. chrys/service/hooks/docs/authoring.md +684 -0
  897. chrys/service/hooks/docs/configuration.md +499 -0
  898. chrys/service/hooks/docs/design.md +359 -0
  899. chrys/service/hooks/docs/project-hooks.md +348 -0
  900. chrys/service/hooks/events.py +157 -0
  901. chrys/service/hooks/loader.py +665 -0
  902. chrys/service/hooks/manager.py +807 -0
  903. chrys/service/hooks/matcher.py +111 -0
  904. chrys/service/hooks/outbox.py +294 -0
  905. chrys/service/hooks/runner.py +572 -0
  906. chrys/service/hooks/schema.py +407 -0
  907. chrys/service/llm/__init__.py +7 -0
  908. chrys/service/llm/_structured_outputs.py +192 -0
  909. chrys/service/llm/anthropic_messages/__init__.py +9 -0
  910. chrys/service/llm/anthropic_messages/client.py +254 -0
  911. chrys/service/llm/anthropic_messages/decode.py +331 -0
  912. chrys/service/llm/anthropic_messages/history.py +279 -0
  913. chrys/service/llm/anthropic_messages/request.py +323 -0
  914. chrys/service/llm/anthropic_messages/server_tools.py +319 -0
  915. chrys/service/llm/anthropic_messages/stream.py +297 -0
  916. chrys/service/llm/anthropic_messages/thinking_binding.py +156 -0
  917. chrys/service/llm/chat_completions/__init__.py +9 -0
  918. chrys/service/llm/chat_completions/client.py +369 -0
  919. chrys/service/llm/chat_completions/decode.py +338 -0
  920. chrys/service/llm/chat_completions/history.py +657 -0
  921. chrys/service/llm/chat_completions/reasoning.py +244 -0
  922. chrys/service/llm/chat_completions/request.py +192 -0
  923. chrys/service/llm/chat_completions/stream.py +546 -0
  924. chrys/service/llm/chat_completions/validation.py +308 -0
  925. chrys/service/llm/clients.py +613 -0
  926. chrys/service/llm/images.py +65 -0
  927. chrys/service/llm/mock.py +413 -0
  928. chrys/service/llm/observer.py +446 -0
  929. chrys/service/llm/one_shot.py +106 -0
  930. chrys/service/llm/openai_exceptions.py +93 -0
  931. chrys/service/llm/openai_responses/__init__.py +10 -0
  932. chrys/service/llm/openai_responses/client.py +323 -0
  933. chrys/service/llm/openai_responses/decode.py +432 -0
  934. chrys/service/llm/openai_responses/history.py +519 -0
  935. chrys/service/llm/openai_responses/hosted.py +625 -0
  936. chrys/service/llm/openai_responses/replay.py +629 -0
  937. chrys/service/llm/openai_responses/request.py +342 -0
  938. chrys/service/llm/openai_responses/stream.py +847 -0
  939. chrys/service/llm/openai_timestamps.py +57 -0
  940. chrys/service/llm/providers.py +107 -0
  941. chrys/service/llm/proxy_route.py +54 -0
  942. chrys/service/llm/raw_http_log.py +309 -0
  943. chrys/service/llm/route_facts.py +63 -0
  944. chrys/service/llm/route_sessions.py +49 -0
  945. chrys/service/llm/sdk_anthropic.py +18 -0
  946. chrys/service/llm/sdk_headers.py +47 -0
  947. chrys/service/llm/sdk_openai.py +18 -0
  948. chrys/service/llm/sdk_retry.py +59 -0
  949. chrys/service/llm/wire_client.py +246 -0
  950. chrys/service/mcp/__init__.py +3 -0
  951. chrys/service/mcp/_connection.py +235 -0
  952. chrys/service/mcp/_http_transport.py +350 -0
  953. chrys/service/mcp/_progressive.py +459 -0
  954. chrys/service/mcp/_stdio_transport.py +614 -0
  955. chrys/service/mcp/_tool_mixins.py +96 -0
  956. chrys/service/mcp/adapter.py +1031 -0
  957. chrys/service/mcp/cache.py +476 -0
  958. chrys/service/mcp/content.py +32 -0
  959. chrys/service/mcp/errors.py +169 -0
  960. chrys/service/mcp/owned.py +1330 -0
  961. chrys/service/mcp/result_limits.py +168 -0
  962. chrys/service/mcp/thinking_warning.py +95 -0
  963. chrys/service/mcp/validation.py +74 -0
  964. chrys/service/mutations/__init__.py +84 -0
  965. chrys/service/mutations/coordination.py +894 -0
  966. chrys/service/mutations/detector.py +458 -0
  967. chrys/service/mutations/git_calibrator.py +397 -0
  968. chrys/service/mutations/git_state.py +647 -0
  969. chrys/service/mutations/pipeline.py +779 -0
  970. chrys/service/mutations/scanner.py +466 -0
  971. chrys/service/mutations/snapshot_files.py +233 -0
  972. chrys/service/mutations/store.py +625 -0
  973. chrys/service/mutations/tool_names.py +15 -0
  974. chrys/service/mutations/trace.py +246 -0
  975. chrys/service/mutations/tracker.py +1289 -0
  976. chrys/service/mutations/types.py +559 -0
  977. chrys/service/mutations/workspace_changes.py +1583 -0
  978. chrys/service/profiles/__init__.py +2 -0
  979. chrys/service/profiles/agents/__init__.py +3 -0
  980. chrys/service/profiles/agents/builtins/Code.yaml +199 -0
  981. chrys/service/profiles/agents/builtins/Explore.yaml +62 -0
  982. chrys/service/profiles/agents/builtins/General.yaml +114 -0
  983. chrys/service/profiles/agents/builtins/QA.yaml +128 -0
  984. chrys/service/profiles/agents/loader.py +808 -0
  985. chrys/service/profiles/agents/registry.py +727 -0
  986. chrys/service/profiles/agents/schema.py +371 -0
  987. chrys/service/profiles/agents/serializer.py +196 -0
  988. chrys/service/profiles/coercion.py +22 -0
  989. chrys/service/profiles/models/__init__.py +3 -0
  990. chrys/service/profiles/models/env_bridge.py +157 -0
  991. chrys/service/profiles/models/loader.py +282 -0
  992. chrys/service/profiles/models/options.py +438 -0
  993. chrys/service/profiles/models/registry.py +104 -0
  994. chrys/service/profiles/models/resolver.py +286 -0
  995. chrys/service/profiles/models/schema.py +91 -0
  996. chrys/service/profiles/models/serializer.py +76 -0
  997. chrys/service/session/__init__.py +3 -0
  998. chrys/service/session/checkpoint.py +156 -0
  999. chrys/service/session/history.py +1616 -0
  1000. chrys/service/session/message_metadata.py +151 -0
  1001. chrys/service/session/persistence.py +435 -0
  1002. chrys/service/session/runtime_metadata.py +210 -0
  1003. chrys/service/session/sub_agent_logs.py +1069 -0
  1004. chrys/service/session/sub_agent_transcript.py +654 -0
  1005. chrys/service/skills/__init__.py +3 -0
  1006. chrys/service/skills/adapter.py +408 -0
  1007. chrys/service/skills/constants.py +8 -0
  1008. chrys/service/skills/loader.py +575 -0
  1009. chrys/service/skills/model.py +130 -0
  1010. chrys/service/skills/provider.py +841 -0
  1011. chrys/service/skills/runner.py +390 -0
  1012. chrys/service/state/__init__.py +3 -0
  1013. chrys/service/state/_fork.py +710 -0
  1014. chrys/service/state/_session_files.py +175 -0
  1015. chrys/service/state/_session_meta.py +552 -0
  1016. chrys/service/state/locks.py +154 -0
  1017. chrys/service/state/serializers.py +156 -0
  1018. chrys/service/state/session_catalog.py +361 -0
  1019. chrys/service/state/session_listing.py +84 -0
  1020. chrys/service/state/session_migration.py +446 -0
  1021. chrys/service/state/session_mru.py +315 -0
  1022. chrys/service/state/store.py +2132 -0
  1023. chrys/service/state/workflow.py +158 -0
  1024. chrys/service/text_blocks.py +47 -0
  1025. chrys/service/todos/__init__.py +3 -0
  1026. chrys/service/todos/reminder.py +31 -0
  1027. chrys/service/todos/tracker.py +58 -0
  1028. chrys/service/tools/__init__.py +3 -0
  1029. chrys/service/tools/approval_targets.py +92 -0
  1030. chrys/service/tools/builtins/__init__.py +3 -0
  1031. chrys/service/tools/builtins/ask_user.py +326 -0
  1032. chrys/service/tools/builtins/doc_converter/__init__.py +494 -0
  1033. chrys/service/tools/builtins/doc_converter/artifacts.py +392 -0
  1034. chrys/service/tools/builtins/doc_converter/parsers/__init__.py +3 -0
  1035. chrys/service/tools/builtins/doc_converter/parsers/_table.py +34 -0
  1036. chrys/service/tools/builtins/doc_converter/parsers/base.py +101 -0
  1037. chrys/service/tools/builtins/doc_converter/parsers/docx.py +88 -0
  1038. chrys/service/tools/builtins/doc_converter/parsers/pdf.py +144 -0
  1039. chrys/service/tools/builtins/doc_converter/parsers/pptx.py +103 -0
  1040. chrys/service/tools/builtins/doc_converter/parsers/xls.py +32 -0
  1041. chrys/service/tools/builtins/doc_converter/parsers/xlsx.py +51 -0
  1042. chrys/service/tools/builtins/doc_converter/registry.py +55 -0
  1043. chrys/service/tools/builtins/filesystem.py +1071 -0
  1044. chrys/service/tools/builtins/search.py +1265 -0
  1045. chrys/service/tools/builtins/shell.py +967 -0
  1046. chrys/service/tools/builtins/shell_command_filtering/__init__.py +5 -0
  1047. chrys/service/tools/builtins/shell_command_filtering/policy.py +60 -0
  1048. chrys/service/tools/builtins/shell_command_filtering/presets.py +152 -0
  1049. chrys/service/tools/builtins/shell_command_filtering/readonly/__init__.py +358 -0
  1050. chrys/service/tools/builtins/shell_command_filtering/readonly/commands.py +416 -0
  1051. chrys/service/tools/builtins/shell_command_filtering/readonly/git.py +408 -0
  1052. chrys/service/tools/builtins/shell_command_filtering/readonly/syntax.py +87 -0
  1053. chrys/service/tools/builtins/shell_command_filtering/tokenizer.py +214 -0
  1054. chrys/service/tools/builtins/shell_filter.py +24 -0
  1055. chrys/service/tools/builtins/sleep.py +91 -0
  1056. chrys/service/tools/builtins/todo.py +64 -0
  1057. chrys/service/tools/builtins/web/__init__.py +6 -0
  1058. chrys/service/tools/builtins/web/build.py +268 -0
  1059. chrys/service/tools/builtins/web/config.py +338 -0
  1060. chrys/service/tools/builtins/web/credentials.py +37 -0
  1061. chrys/service/tools/builtins/web/fetch/__init__.py +3 -0
  1062. chrys/service/tools/builtins/web/fetch/cache.py +68 -0
  1063. chrys/service/tools/builtins/web/fetch/convert.py +56 -0
  1064. chrys/service/tools/builtins/web/fetch/tool.py +223 -0
  1065. chrys/service/tools/builtins/web/hosted.py +61 -0
  1066. chrys/service/tools/builtins/web/http.py +330 -0
  1067. chrys/service/tools/builtins/web/progress.py +18 -0
  1068. chrys/service/tools/builtins/web/search/__init__.py +3 -0
  1069. chrys/service/tools/builtins/web/search/exa_mcp.py +146 -0
  1070. chrys/service/tools/builtins/web/search/html.py +152 -0
  1071. chrys/service/tools/builtins/web/search/json_api.py +227 -0
  1072. chrys/service/tools/builtins/web/search/providers.py +77 -0
  1073. chrys/service/tools/builtins/web/search/tool.py +218 -0
  1074. chrys/service/tools/builtins/web/search/types.py +28 -0
  1075. chrys/service/tools/builtins/web/tls_identity.py +66 -0
  1076. chrys/service/tools/kinds.py +115 -0
  1077. chrys/service/tools/names.py +47 -0
  1078. chrys/service/tools/registry.py +242 -0
  1079. chrys/service/tools/result_metadata.py +103 -0
  1080. chrys/service/tools/session_artifacts.py +196 -0
  1081. chrys/service/tools/spill.py +218 -0
  1082. chrys/service/tools/workspace_paths.py +81 -0
  1083. chrys/service/trajectory/__init__.py +11 -0
  1084. chrys/service/trajectory/approvals.py +201 -0
  1085. chrys/service/trajectory/compaction.py +420 -0
  1086. chrys/service/trajectory/fork.py +75 -0
  1087. chrys/service/trajectory/hooks.py +217 -0
  1088. chrys/service/trajectory/items.py +41 -0
  1089. chrys/service/trajectory/preparation.py +374 -0
  1090. chrys/service/trajectory/retries.py +95 -0
  1091. chrys/service/trajectory/revisions.py +129 -0
  1092. chrys/service/trajectory/segmented.py +163 -0
  1093. chrys/service/trajectory/session.py +762 -0
  1094. chrys/service/trajectory/state.py +83 -0
  1095. chrys/service/trajectory/sub_agents.py +169 -0
  1096. chrys/service/trajectory/tombstone.py +262 -0
  1097. chrys/service/trajectory/tools.py +323 -0
  1098. chrys/service/trajectory/validation.py +130 -0
  1099. chrys/service/trajectory/waits.py +161 -0
  1100. chrys/service/trajectory/workflow.py +148 -0
  1101. chrys/service/vision.py +128 -0
  1102. chrys/service/workflows/__init__.py +3 -0
  1103. chrys/service/workflows/admission.py +151 -0
  1104. chrys/service/workflows/artifacts.py +109 -0
  1105. chrys/service/workflows/asks.py +130 -0
  1106. chrys/service/workflows/builtins/__init__.py +12 -0
  1107. chrys/service/workflows/builtins/demo-workflow.manifest.json +635 -0
  1108. chrys/service/workflows/builtins/demo-workflow.py +756 -0
  1109. chrys/service/workflows/discovery.py +554 -0
  1110. chrys/service/workflows/environment.py +284 -0
  1111. chrys/service/workflows/graph.py +430 -0
  1112. chrys/service/workflows/history.py +82 -0
  1113. chrys/service/workflows/interpreter.py +107 -0
  1114. chrys/service/workflows/journal.py +334 -0
  1115. chrys/service/workflows/layout.py +56 -0
  1116. chrys/service/workflows/ledger.py +149 -0
  1117. chrys/service/workflows/model_selection.py +18 -0
  1118. chrys/service/workflows/orphans.py +209 -0
  1119. chrys/service/workflows/outcomes.py +46 -0
  1120. chrys/service/workflows/protocol.py +210 -0
  1121. chrys/service/workflows/records.py +142 -0
  1122. chrys/service/workflows/scheduler.py +770 -0
  1123. chrys/service/workflows/sdk/__init__.py +51 -0
  1124. chrys/service/workflows/sdk/_ask.py +167 -0
  1125. chrys/service/workflows/sdk/_builder.py +781 -0
  1126. chrys/service/workflows/sdk/_values.py +92 -0
  1127. chrys/service/workflows/sdk_artifact.py +74 -0
  1128. chrys/service/workflows/store.py +494 -0
  1129. chrys/service/workflows/transcript.py +102 -0
  1130. chrys/service/workflows/values.py +73 -0
  1131. chrys/service/workflows/worker_host.py +1820 -0
  1132. chrys/workflows.py +38 -0
  1133. icode_tui-0.29.1.dist-info/METADATA +104 -0
  1134. icode_tui-0.29.1.dist-info/RECORD +1138 -0
  1135. icode_tui-0.29.1.dist-info/WHEEL +4 -0
  1136. icode_tui-0.29.1.dist-info/entry_points.txt +2 -0
  1137. icode_tui-0.29.1.dist-info/licenses/LICENSE +202 -0
  1138. icode_tui-0.29.1.dist-info/licenses/NOTICE +1955 -0
@@ -0,0 +1,1996 @@
1
+ # Copyright (c) Huawei Technologies Co., Ltd. 2026. All rights reserved.
2
+
3
+ """LAST_WORDS generator — summarises dropped tool-call work for Phase 4 compaction.
4
+
5
+ When Phase 4 compaction fires, it drops every tool-call and inline
6
+ assistant-text message for the current turn and replaces them with a
7
+ ``<system-reminder>[LAST_WORDS]…</system-reminder>`` block appended to the
8
+ user message. This module produces the text for that reminder.
9
+
10
+ The primary path is a physically scoped natural-completion side call: the
11
+ client-owned :class:`chrys.kernel.LastWordsCompleter` sends only the current
12
+ turn's validated group timeline plus one instruction message. Tool use is
13
+ suppressed by the triple no-tools directive wrapped around the code-owned
14
+ contract and base guidance plus any profile supplement.
15
+
16
+ The original fresh two-message reconstruction prompt (fixed instruction and
17
+ optional supplement as the system message + a text re-rendering of the dropped
18
+ context, sent over a separate ``route_kind="last-words"`` session) is retained
19
+ strictly as the fallback, used only when the completer is absent, the provider
20
+ rejects the side call for context-window size, or the side call's local retry
21
+ budget is exhausted (including repeated illegal tool-call responses).
22
+
23
+ The LAST_WORDS note is always LLM-generated — there is no programmatic
24
+ fallback. On LLM failure (network error, empty response, etc.)
25
+ ``generate()`` retries the LAST_WORDS request itself on transient failures
26
+ so a dropped compaction connection resends the same summariser prompt
27
+ instead of replaying the whole agent/tool attempt. If the fallback's
28
+ retries are exhausted, it raises :class:`LastWordsGenerationError` (the
29
+ final SDK exception is chained via ``__cause__``). Compaction requires a
30
+ fresh non-empty note from the current round, so no current-turn work is
31
+ silently dropped when generation ultimately fails.
32
+ """
33
+
34
+ from __future__ import annotations
35
+
36
+ import asyncio
37
+ import json
38
+ import logging
39
+ import math
40
+ import os
41
+ import re
42
+ import time
43
+ from collections.abc import Mapping
44
+ from dataclasses import dataclass
45
+ from datetime import UTC, datetime
46
+ from pathlib import Path
47
+ from typing import TYPE_CHECKING, NamedTuple
48
+ from uuid import uuid4
49
+
50
+ from chrys.foundation.errors import ProviderResponseError, clean_error_message, is_retryable, may_be_context_overflow
51
+ from chrys.foundation.models.turns import is_continuation_message
52
+ from chrys.foundation.retry import TRANSIENT_RETRY_BACKOFF_SECONDS, RetryAttemptInfo
53
+ from chrys.foundation.text.tokenizer import MixedLanguageTokenizer
54
+ from chrys.foundation.trajectory.context import side_call_scope
55
+ from chrys.foundation.trajectory.envelope import ActorRole
56
+ from chrys.foundation.trajectory.event_types import RetryMode, RetryReason
57
+ from chrys.kernel import (
58
+ TOOL_CALL_CONTENT_TYPES,
59
+ Content,
60
+ LastWordsToolCallError,
61
+ Message,
62
+ TokenizerProtocol,
63
+ raise_if_context_window_filled,
64
+ report_wire_progress,
65
+ )
66
+ from chrys.service.agent_middleware.system_reminder import escape_system_reminder_tags
67
+ from chrys.service.llm.one_shot import get_final_response
68
+ from chrys.service.profiles.agents.schema import DEFAULT_LAST_WORDS_MAX_OUTPUT_TOKENS
69
+ from chrys.service.profiles.models.options import (
70
+ AUTO_INTERLEAVED_THINKING_OPTION,
71
+ PROMPT_CACHE_KEY_OPTION,
72
+ STREAM_REQUIRES_FINISH_REASON_OPTION,
73
+ THINKING_BLOCK_BINDING_OPTION,
74
+ )
75
+ from chrys.service.trajectory.compaction import current_compaction_operation_id
76
+ from chrys.service.trajectory.retries import RetryBackoffTrace
77
+
78
+ from .groups import _render_tool_result_value
79
+ from .scoped import DEGRADED_SCOPED_PREAMBLE, ScopedGroup, prepare_scoped_slice
80
+
81
+ if TYPE_CHECKING:
82
+ from collections.abc import Awaitable, Callable
83
+ from typing import Any
84
+
85
+ from chrys.kernel import LastWordsCompleter
86
+ from chrys.service.profiles.models.schema import ModelProfile
87
+
88
+ _log = logging.getLogger(__name__)
89
+
90
+ _COMPLETER_MAX_RETRIES = 2
91
+ _SLICE_SAFETY_MARGIN_TOKENS = 2_048
92
+ _FALLBACK_TEMPLATE_MAX_CHARS = 16_000
93
+ _FALLBACK_TEMPLATE_COMPACT_CHARS = 2_000
94
+ _FALLBACK_TEMPLATE_MIN_CHARS = 512
95
+ _FALLBACK_PREV_NOTE_MAX_CHARS = 24_000
96
+ _FALLBACK_MAX_CHARS = 48_000
97
+ _FALLBACK_MIN_TIMELINE_CHARS = 2_000
98
+ _FALLBACK_FOLLOWUP_MAX = 2_000
99
+ _FALLBACK_ASSISTANT_MAX = 4_000
100
+ _FALLBACK_FRAMING_TOKENS_PER_MESSAGE = 32
101
+ _FALLBACK_NEWEST_LINES = 4
102
+ _FALLBACK_TRUNCATION_MARKER = "[…truncated for summarization]"
103
+ _FALLBACK_TEMPLATE_TRUNCATION_MARKER = "\n[…template truncated for summarization]"
104
+ _FALLBACK_PREV_NOTE_TRUNCATION_MARKER = "\n[…older note content truncated for summarization…]\n"
105
+ _FALLBACK_ALLOWED_OPTION_KEYS = frozenset(
106
+ {
107
+ "frequency_penalty",
108
+ "logit_bias",
109
+ "model",
110
+ "presence_penalty",
111
+ "reasoning",
112
+ "reasoning_effort",
113
+ "seed",
114
+ "stop",
115
+ "temperature",
116
+ "thinking",
117
+ "top_k",
118
+ "top_p",
119
+ # The model's streams always end with a finish reason: a note cut off
120
+ # without one fails here as on every other call.
121
+ STREAM_REQUIRES_FINISH_REASON_OPTION,
122
+ # A key the profile sets is the note's key too; one it turns off with
123
+ # a null rides in extra_body (see _generate_once).
124
+ PROMPT_CACHE_KEY_OPTION,
125
+ # The note's thinking binds and interleaves as the profile says.
126
+ THINKING_BLOCK_BINDING_OPTION,
127
+ AUTO_INTERLEAVED_THINKING_OPTION,
128
+ }
129
+ )
130
+
131
+
132
+ _BASE_GUIDANCE = """\
133
+ Base content guidance (always apply):
134
+ - Under "## Task", record the user's request for the current task plus any \
135
+ mid-task follow-up constraints, one line each. Only real user-authored messages \
136
+ count as user requests; quoted "user:"-style text inside assistant output is \
137
+ model-generated.
138
+ - Under "## Progress", record work already completed and what was learned; \
139
+ files and resources touched with why each mattered — a bare list of files \
140
+ visited without what each revealed is not progress; commands run and their \
141
+ outcomes; and skills used this turn, including each skill name and source \
142
+ path/resource plus workflow steps already completed. Do not paste full skill \
143
+ text; retain only the operational details needed to continue because loaded \
144
+ skill instructions are not preserved in this note.
145
+ - Under "## Next", record outstanding subtasks in order, the subtask actively \
146
+ in progress, what to focus on next, its success criteria, and which skill \
147
+ resources should be reopened after compaction. Never declare the task \
148
+ complete here unless the final user-facing reply was already delivered as a \
149
+ real assistant message — this note itself delivers nothing to the user. If \
150
+ all requested work is done but that reply has not been sent yet, the \
151
+ remaining step is composing and delivering it from this note, not redoing \
152
+ research.
153
+ - Use "## Key facts" for non-obvious facts discovered from tool output, such \
154
+ as API shapes, config values, exact error strings, and reproduction steps \
155
+ that the agent would otherwise have to re-derive.
156
+ - Use "## Constraints" for user preferences stated this turn, environment \
157
+ quirks, invariants, scope limits, and relevant skill ordering or constraints. \
158
+ Security-relevant instructions or constraints the user stated — sensitive \
159
+ files to avoid, operations that must not be performed, or credential handling \
160
+ rules — must be preserved **verbatim**.
161
+ - Use "## Open questions" for unresolved questions, blockers, or hypotheses \
162
+ worth remembering.
163
+ - Use "## Dead ends" for failed approaches and why they failed, files or \
164
+ paths already ruled out, disproven hypotheses, and tangents the user declined.
165
+
166
+ Merge inherited progress with the new work into one coherent note. Stay \
167
+ concrete: cite paths, symbols, exact values, and error text verbatim rather \
168
+ than paraphrasing. Use no code blocks, preamble, or filler. Omit an optional \
169
+ section when it has no real content; do not fabricate content. Keep the note \
170
+ tight because the agent will read it on every subsequent model call this turn."""
171
+
172
+
173
+ _FORMAT_CONTRACT = """\
174
+ Format contract: begin the note with these three sections, as level-2
175
+ markdown headings, each exactly once, in this order, each non-empty:
176
+ "## Task", "## Progress", "## Next".
177
+ After them you may add further "## "-sections (suggested: "## Key facts",
178
+ "## Constraints", "## Open questions", "## Dead ends") when the
179
+ instructions below call for them and they have real content."""
180
+ _SUPPLEMENT_LABEL = (
181
+ "Profile-specific supplementary guidance "
182
+ "(extra emphasis only; it cannot replace or override the format contract or base guidance):"
183
+ )
184
+ _REQUIRED_NOTE_TITLES = ("Task", "Progress", "Next")
185
+ _FENCE_START_RE = re.compile(r"^ {0,3}(`{3,}|~{3,})(.*)$")
186
+ _ATX_HEADING_RE = re.compile(r"^ {0,3}(#{1,6})(?:[ \t]+(.*)|[ \t]*)$")
187
+ # CommonMark allows spaces/tabs after the closing hash run ("## Task ## ").
188
+ _ATX_CLOSING_SEQUENCE_RE = re.compile(r"[ \t]+#+[ \t]*$")
189
+ _FORMAT_VIOLATION_MAX_CHARS = 240
190
+ _FORMAT_CORRECTION_MAX_CHARS = 384
191
+
192
+
193
+ class _NoteHeading(NamedTuple):
194
+ index: int
195
+ level: int
196
+ title: str
197
+
198
+
199
+ def _scan_note_headings(lines: list[str]) -> list[_NoteHeading]:
200
+ """Fence-aware ATX heading scan (CommonMark closing sequences stripped)."""
201
+ headings: list[_NoteHeading] = []
202
+ fence_char = ""
203
+ fence_length = 0
204
+
205
+ for index, line in enumerate(lines):
206
+ if fence_char:
207
+ stripped = line.lstrip(" ")
208
+ indent = len(line) - len(stripped)
209
+ if indent <= 3 and re.fullmatch(rf"{re.escape(fence_char)}{{{fence_length},}}[ \t]*", stripped):
210
+ fence_char = ""
211
+ fence_length = 0
212
+ continue
213
+
214
+ fence_match = _FENCE_START_RE.match(line)
215
+ if fence_match is not None:
216
+ marker = fence_match.group(1)
217
+ info = fence_match.group(2)
218
+ # CommonMark forbids backticks in a backtick fence's info
219
+ # string. Treating such a line as a fence would let an ordinary
220
+ # model line hide duplicate or missing required headings.
221
+ if marker[0] != "`" or "`" not in info:
222
+ fence_char = marker[0]
223
+ fence_length = len(marker)
224
+ continue
225
+
226
+ heading_match = _ATX_HEADING_RE.match(line)
227
+ if heading_match is not None:
228
+ title = _ATX_CLOSING_SEQUENCE_RE.sub("", heading_match.group(2) or "").rstrip(" \t")
229
+ headings.append(_NoteHeading(index, len(heading_match.group(1)), title))
230
+
231
+ return headings
232
+
233
+
234
+ def _validate_note_format(text: str) -> tuple[str | None, str]:
235
+ """Validate the structured-note grammar and canonicalize repairable drift.
236
+
237
+ Returns ``(first violation or None, canonical note text)``. The grammar
238
+ is deliberately laxer than what the prompt requests: the required
239
+ sections ("Task", "Progress", "Next") are accepted at any ATX level and
240
+ in any order, and extra sections may appear between them — those are
241
+ mechanical drift, so acceptance repairs them (headings normalized to
242
+ ``##``, required sections reordered, extras moved after) instead of
243
+ burning a corrective side call. Hard violations remain hard: a missing
244
+ or duplicated required section is ambiguous, an empty one defeats the
245
+ note's purpose, and prose or a provider wrapper before the first section
246
+ heading means the response is not the bare note that was requested.
247
+ Subsection headings (level 3+, non-required titles) belong to the
248
+ enclosing section's body and count as content.
249
+ """
250
+ lines = text.splitlines()
251
+ headings = _scan_note_headings(lines)
252
+ # Section boundaries: any level-1/2 heading, plus required titles at any
253
+ # level (a "### Next" is still the Next section, just mis-levelled).
254
+ boundaries = [h for h in headings if h.level <= 2 or h.title in _REQUIRED_NOTE_TITLES]
255
+
256
+ required_found: dict[str, _NoteHeading] = {}
257
+ for required in _REQUIRED_NOTE_TITLES:
258
+ positions = [heading for heading in boundaries if heading.title == required]
259
+ if not positions:
260
+ return f'missing required heading "## {required}"', text
261
+ if len(positions) > 1:
262
+ return f'required heading "## {required}" appears more than once', text
263
+ required_found[required] = positions[0]
264
+
265
+ first_non_blank = next((index for index, line in enumerate(lines) if line.strip()), None)
266
+ if not boundaries or boundaries[0].index != first_non_blank:
267
+ return 'note must begin with required heading "## Task"', text
268
+
269
+ for position, boundary in enumerate(boundaries):
270
+ if boundary.title not in _REQUIRED_NOTE_TITLES:
271
+ continue
272
+ end = boundaries[position + 1].index if position + 1 < len(boundaries) else len(lines)
273
+ if not any(line.strip() for line in lines[boundary.index + 1 : end]):
274
+ return f'section "## {boundary.title}" has no non-blank body content', text
275
+
276
+ return None, _canonicalize_note(text, lines, boundaries)
277
+
278
+
279
+ def _canonicalize_note(text: str, lines: list[str], boundaries: list[_NoteHeading]) -> str:
280
+ """Rebuild a valid note into canonical shape; keep already-canonical bytes."""
281
+ required_order = [b for b in boundaries if b.title in _REQUIRED_NOTE_TITLES]
282
+ last_required_index = required_order[-1].index if required_order else 0
283
+ already_canonical = (
284
+ [b.title for b in required_order] == list(_REQUIRED_NOTE_TITLES)
285
+ # The exact heading line, not just the level: indent, trailing
286
+ # whitespace, and closing sequences ("## Task ##") all rebuild.
287
+ and all(lines[b.index] == f"## {b.title}" for b in required_order)
288
+ and all(b.index > last_required_index for b in boundaries if b.title not in _REQUIRED_NOTE_TITLES)
289
+ )
290
+ if already_canonical:
291
+ return text
292
+
293
+ required_sections: dict[str, str] = {}
294
+ extra_sections: list[str] = []
295
+ for position, boundary in enumerate(boundaries):
296
+ end = boundaries[position + 1].index if position + 1 < len(boundaries) else len(lines)
297
+ body = lines[boundary.index + 1 : end]
298
+ while body and not body[-1].strip():
299
+ body.pop()
300
+ if boundary.title in _REQUIRED_NOTE_TITLES:
301
+ required_sections[boundary.title] = "\n".join([f"## {boundary.title}", *body])
302
+ else:
303
+ # Extra sections keep their exact heading line; only their
304
+ # placement (after the required prefix) is canonicalized.
305
+ extra_sections.append("\n".join([lines[boundary.index], *body]))
306
+ ordered = [required_sections[title] for title in _REQUIRED_NOTE_TITLES]
307
+ return "\n\n".join([*ordered, *extra_sections])
308
+
309
+
310
+ def _note_format_violation(text: str) -> str | None:
311
+ """Return the first structured-note grammar violation, if any."""
312
+ return _validate_note_format(text)[0]
313
+
314
+
315
+ # Minimum note length the prompts request. Sits well above the enforcement
316
+ # floor (``LastWordsGenerator._MIN_NOTE_CHARS``, measured in characters) so a
317
+ # compliant model never trips it. When the note budget falls below this
318
+ # (extended thinking squeezing a small output cap), ``_output_budgets`` warns:
319
+ # the prompt's minimum and the stated cap become contradictory.
320
+ _MIN_NOTE_TOKENS = 500
321
+
322
+ # The completer side call keeps the live request's tools list untouched but
323
+ # forces ``tool_choice`` to ``"none"``: instruction-only suppression proved
324
+ # insufficient (models still attempted typed tool calls), and the cache
325
+ # argument for inheriting the live value died with the physically scoped
326
+ # slice — the side call's message prefix already diverges from the live call,
327
+ # so the options delta forfeits nothing. These no-tools directives stay on
328
+ # top: stated once before and twice after the guidance, because
329
+ # ``tool_choice`` cannot stop tool-call MARKUP emitted as plain text (live
330
+ # DeepSeek did exactly that in 7/9 side calls). The client-side response
331
+ # guard remains the hard backstop for typed tool calls.
332
+ _NO_TOOLS_OPENER = (
333
+ "IMPORTANT: Do NOT call any tools while handling this request. Reply with plain text "
334
+ "only. This is a mechanical summarisation task — do not deliberate or plan in "
335
+ "extended thinking; start writing the note immediately and spend your output on its "
336
+ "content. Write at least {min_note_tokens} tokens of note content, and size the note "
337
+ "to finish comfortably under the {max_output_tokens}-token output cap, because "
338
+ "anything beyond the cap is cut off mid-sentence and lost."
339
+ )
340
+ _NO_TOOLS_REMINDER = (
341
+ "Reminder: do NOT call any tools, and do NOT emit tool-call markup of any kind "
342
+ "(no function-call syntax, no XML- or DSML-style tool tags). The progress note "
343
+ "itself, written as plain prose, is the only valid reply."
344
+ )
345
+ _NO_TOOLS_FINAL = (
346
+ "Once more: do NOT call any tools, and do not spend output on deliberation. Final "
347
+ "check before you answer: the reply must contain zero tool calls and zero tool-call "
348
+ "markup — plain text only, at least {min_note_tokens} tokens of note content, "
349
+ "concluding within the {max_output_tokens}-token output cap."
350
+ )
351
+
352
+
353
+ # The reconstruction fallback shares the completer's length contract. The
354
+ # base guidance bounds verbosity ("keep the note tight") but states no minimum, so
355
+ # an obediently terse note would starve the note floor (``_MIN_NOTE_CHARS``)
356
+ # into retries; the explicit minimum keeps compliant models above it.
357
+ _FALLBACK_LENGTH_DIRECTIVE = (
358
+ "Write at least {min_note_tokens} tokens of note content — the note replaces the "
359
+ "agent's entire dropped tool-call history, so a fragmentary note loses real work — "
360
+ "and size it to finish comfortably under the {max_output_tokens}-token "
361
+ "output cap, because anything beyond the cap is cut off mid-sentence and lost."
362
+ )
363
+
364
+
365
+ def _prompt_block(tag: str, content: str) -> str:
366
+ """Render an XML-ish data block, defanging the block's OWN tag literals.
367
+
368
+ ``escape_system_reminder_tags`` only defangs the two reminder tags —
369
+ content containing this block's closing tag would terminate the block
370
+ early and the remainder would masquerade as prompt structure. The full
371
+ multiline form is kept (these blocks are the note-writer's full-fidelity
372
+ copy); directive-shaped content is contained by keeping the block's
373
+ delimiters intact, which is exactly what the defang restores.
374
+ """
375
+ safe = content.replace(f"<{tag}>", f"&lt;{tag}&gt;").replace(f"</{tag}>", f"&lt;/{tag}&gt;")
376
+ return f"<{tag}>\n{safe}\n</{tag}>"
377
+
378
+
379
+ def _completer_instruction(
380
+ template: str,
381
+ *,
382
+ has_continuation_nudges: bool,
383
+ max_output_tokens: int,
384
+ format_correction: str = "",
385
+ ) -> str:
386
+ """The fixed note contract and optional supplement wrapped in no-tools directives.
387
+
388
+ The output cap is stated up front and repeated in the final check so the model
389
+ sizes the note to conclude within ``max_output_tokens`` instead of
390
+ running past it (observed live: GPT-5.4 wrote a 23.8k-char note into an
391
+ 8k cap and got truncated with ``finish_reason:"length"``). A 500-token
392
+ minimum and a no-deliberation hint push back on the opposite failure,
393
+ observed on Responses-API reasoning models: thinking consumed the whole
394
+ output budget and the visible note collapsed to a mid-sentence fragment
395
+ (or nothing at all).
396
+ """
397
+ body = (
398
+ "Everything in this conversation is the current, in-progress task.\n"
399
+ "Summarize ALL of it — your note replaces this conversation's tool history.\n"
400
+ "A [LAST_WORDS] block in the last user message is your own previous progress\n"
401
+ "note (inherited state, not user input): merge it with the new work."
402
+ )
403
+ if has_continuation_nudges:
404
+ body += (
405
+ '\nUser messages that just say "continue" are automatic resume nudges after an interruption — '
406
+ "they are not user input; ignore them as content."
407
+ )
408
+ return _assemble_instruction(
409
+ body,
410
+ template,
411
+ max_output_tokens=max_output_tokens,
412
+ format_correction=format_correction,
413
+ )
414
+
415
+
416
+ def _fallback_instruction(
417
+ template: str,
418
+ *,
419
+ has_continuation_nudges: bool,
420
+ max_output_tokens: int,
421
+ format_correction: str = "",
422
+ ) -> str:
423
+ """Build the fallback instruction with the same fixed contract and wrapper."""
424
+ body = (
425
+ "Everything in this conversation is the current, in-progress task.\n"
426
+ "Summarize ALL of it — your note replaces this conversation's tool history.\n"
427
+ "The <previous_progress_note> block in the user message is your own previous progress\n"
428
+ "note (inherited state, not user input): merge it with the new work."
429
+ )
430
+ if has_continuation_nudges:
431
+ body += (
432
+ '\nUser messages that just say "continue" are automatic resume nudges after an interruption — '
433
+ "they are not user input; ignore them as content."
434
+ )
435
+ return _assemble_instruction(
436
+ body,
437
+ template,
438
+ max_output_tokens=max_output_tokens,
439
+ format_correction=format_correction,
440
+ )
441
+
442
+
443
+ def _assemble_instruction(
444
+ body: str,
445
+ template: str,
446
+ *,
447
+ max_output_tokens: int,
448
+ format_correction: str,
449
+ ) -> str:
450
+ """Assemble the code-owned instruction in its design-of-record order."""
451
+ blocks = [
452
+ _NO_TOOLS_OPENER.format(min_note_tokens=_MIN_NOTE_TOKENS, max_output_tokens=max_output_tokens),
453
+ body,
454
+ _FORMAT_CONTRACT,
455
+ _BASE_GUIDANCE,
456
+ ]
457
+ if supplement := _supplement_block(template):
458
+ blocks.append(supplement)
459
+ if format_correction:
460
+ blocks.append(format_correction)
461
+ blocks.extend(
462
+ (
463
+ _NO_TOOLS_REMINDER,
464
+ _NO_TOOLS_FINAL.format(min_note_tokens=_MIN_NOTE_TOKENS, max_output_tokens=max_output_tokens),
465
+ )
466
+ )
467
+ return "\n\n".join(blocks)
468
+
469
+
470
+ def _format_correction(violation: str) -> str:
471
+ safe_violation = " ".join(violation.split())[:_FORMAT_VIOLATION_MAX_CHARS]
472
+ prefix = "Your previous note was rejected: "
473
+ suffix = ". Re-emit the full note with the required headings."
474
+ available = _FORMAT_CORRECTION_MAX_CHARS - len(prefix) - len(suffix)
475
+ return f"{prefix}{safe_violation[:available]}{suffix}"
476
+
477
+
478
+ def _bounded_format_violation(violation: str) -> str:
479
+ """Return a single-line violation safe for logs, events, and retry prompts."""
480
+ return " ".join(violation.split())[:_FORMAT_VIOLATION_MAX_CHARS]
481
+
482
+
483
+ def _normalize_note_response(text: str | None) -> str:
484
+ """Drop provider-added outer newlines without changing Markdown indentation."""
485
+ if text is None or not text.strip():
486
+ return ""
487
+ return text.strip("\r\n")
488
+
489
+
490
+ def _truncate_template(template: str, max_chars: int = _FALLBACK_TEMPLATE_MAX_CHARS) -> str:
491
+ clean = template.rstrip()
492
+ if len(clean) <= max_chars:
493
+ return clean
494
+ keep = max(0, max_chars - len(_FALLBACK_TEMPLATE_TRUNCATION_MARKER))
495
+ return clean[:keep].rstrip() + _FALLBACK_TEMPLATE_TRUNCATION_MARKER
496
+
497
+
498
+ def _supplement_block(template: str, max_chars: int = _FALLBACK_TEMPLATE_MAX_CHARS) -> str:
499
+ """Render the bounded profile supplement, or nothing for an empty template."""
500
+ if not template.strip():
501
+ return ""
502
+ supplement = _truncate_template(template, max_chars)
503
+ return f"{_SUPPLEMENT_LABEL}\n{supplement}"
504
+
505
+
506
+ class LastWordsGenerationError(RuntimeError):
507
+ """Raised when the LAST_WORDS LLM call fails for any reason.
508
+
509
+ Wraps both transport-level failures (SDK exceptions chained via
510
+ ``__cause__``) and empty-response failures behind a single, uniform
511
+ exception type. ``LastWordsGenerator`` retries the compaction LLM
512
+ request internally before raising this, so outer agent retry loops
513
+ should treat it as a terminal compaction failure, not as a reason to
514
+ replay completed tool work.
515
+ """
516
+
517
+
518
+ class LastWordsSpendBudgetExceeded(LastWordsGenerationError):
519
+ """Raised before a provider attempt would exhaust the turn spend budget."""
520
+
521
+
522
+ # Card-facing text for a spend-budget refusal, shared with the strategy's
523
+ # entry-time trip so both surfaces render the identical reason.
524
+ SPEND_BUDGET_FAILURE_REASON = "progress-note token budget exceeded for current turn"
525
+
526
+
527
+ @dataclass(frozen=True, slots=True)
528
+ class _GeneratedNote:
529
+ text: str
530
+ format_violation: str = ""
531
+
532
+
533
+ @dataclass(slots=True)
534
+ class _FormatRetryState:
535
+ violations: list[str]
536
+ correction_violation: str = ""
537
+ pending_note: _GeneratedNote | None = None
538
+ pending_instruction: str = ""
539
+ pending_request_note: str = ""
540
+
541
+
542
+ @dataclass(frozen=True, slots=True)
543
+ class CompactionStatus:
544
+ """UX progress signal for one LAST_WORDS generation pass.
545
+
546
+ Emitted by :meth:`LastWordsGenerator.generate` through the
547
+ ``publish_status`` callback so frontends can show a live
548
+ "Compacting conversation…" indicator over the whole generation
549
+ window (LLM call plus local retries and backoff). ``stage`` is
550
+ ``"started"``, ``"finished"``, or ``"committed"``; the remaining fields
551
+ are only meaningful on the finished signal. A ``"committed"`` signal
552
+ (see :meth:`LastWordsGenerator.publish_committed`) follows a
553
+ finished-ok signal once the compaction strategy has durably applied
554
+ the round — spill written, note set, messages excluded; a finished-ok
555
+ round without it was abandoned. ``outcome`` is ``"ok"``,
556
+ ``"failed"``, or ``"canceled"``; ``last_words`` carries the note
557
+ text only when ``outcome == "ok"``. ``format_violation`` records the
558
+ structured-note violation when the bounded corrective retry process
559
+ deliberately accepted a fresh malformed note. ``failure_reason`` is a
560
+ short human-readable cause set on ``"failed"`` outcomes whose cause is
561
+ a known safety limit (spend budget, round limit); frontends show it in
562
+ place of the duration on the failure card.
563
+ """
564
+
565
+ compaction_id: str
566
+ stage: str
567
+ outcome: str = ""
568
+ duration_ms: int = 0
569
+ last_words: str = ""
570
+ format_violation: str = ""
571
+ failure_reason: str = ""
572
+
573
+
574
+ def _log_detached_publish_failure(task: asyncio.Future) -> None:
575
+ """Consume a detached committed-publish task's outcome.
576
+
577
+ The publish body swallows ordinary exceptions itself, so this normally
578
+ only retrieves ``None`` — but retrieving is mandatory to keep asyncio
579
+ from logging "exception was never retrieved" if the detached task dies.
580
+ """
581
+ if task.cancelled():
582
+ return
583
+ exc = task.exception()
584
+ if exc is not None:
585
+ _log.debug("LAST_WORDS committed publish failed after detach", exc_info=exc)
586
+
587
+
588
+ class LastWordsGenerator:
589
+ """Generates the LAST_WORDS progress note for Phase 4 compaction.
590
+
591
+ Prefers the client-owned scoped side call when the strategy passes a
592
+ completer (see :meth:`generate`); otherwise falls back to a
593
+ single-turn reconstruction call using the owning agent's
594
+ ``ModelProfile`` (model, HTTP options, chat_options) over a separate
595
+ ``route_kind="last-words"`` session. Always LLM-generated — transient
596
+ failures retry the same compaction prompt locally with the standard
597
+ retry budget and backoff. Callers (the compaction strategy) must not
598
+ drop current-turn work unless generation succeeds.
599
+ """
600
+
601
+ _MAX_RETRIES: int = 5
602
+ _MAX_CORRECTIVE_RETRIES: int = 5
603
+ _BACKOFF_SCHEDULE: tuple[float, ...] = TRANSIENT_RETRY_BACKOFF_SECONDS
604
+ # A LAST_WORDS note replaces an entire amputated turn, and Phase 4 only
605
+ # fires on a near-full context — there is always substantial work to
606
+ # record, so a fragmentary note is worse than a retry. Observed live
607
+ # (GPT-5.4 Responses): reasoning consumed the whole output budget and the
608
+ # visible note collapsed to an 85-char mid-sentence fragment that passed
609
+ # the old non-empty check. The floor sits far below any compliant note
610
+ # (the instruction asks for ``_MIN_NOTE_TOKENS``) but far above such fragments.
611
+ _MIN_NOTE_CHARS: int = 300
612
+
613
+ def __init__(
614
+ self,
615
+ profile: ModelProfile,
616
+ template: str = "",
617
+ max_output_tokens: int = DEFAULT_LAST_WORDS_MAX_OUTPUT_TOKENS,
618
+ log_dir: Path | None = None,
619
+ session_id: str | None = None,
620
+ parent_session_id: str | None = None,
621
+ session_dir: Path | None = None,
622
+ max_transient_retries: int | None = None,
623
+ publish_retry: Callable[[RetryAttemptInfo], Awaitable[None]] | None = None,
624
+ publish_status: Callable[[CompactionStatus], Awaitable[None]] | None = None,
625
+ report_usage: Callable[[Mapping[str, Any]], None] | None = None,
626
+ ) -> None:
627
+ self._profile = profile
628
+ self._template = template
629
+ self._max_output_tokens = max_output_tokens
630
+ self._log_dir = log_dir
631
+ self._session_id = session_id
632
+ self._parent_session_id = parent_session_id
633
+ self._session_dir = session_dir
634
+ self._max_transient_retries = max(
635
+ 0,
636
+ max_transient_retries if max_transient_retries is not None else type(self)._MAX_RETRIES,
637
+ )
638
+ self._publish_retry = publish_retry
639
+ self._publish_status = publish_status
640
+ self._report_usage = report_usage
641
+ self._last_compaction_id = ""
642
+ # Strong refs to in-flight detached committed publishes — asyncio
643
+ # only weakly references running tasks, so without an anchor a
644
+ # publish that outlives its cancelled shield could be
645
+ # garbage-collected before delivering.
646
+ self._detached_publishes: set[asyncio.Future] = set()
647
+ self._client = None
648
+ self._client_lock = asyncio.Lock()
649
+ self._closed = False
650
+ self._chat_options: dict | None = None
651
+
652
+ def _report_side_call_usage(self, usage_details: Mapping[str, Any]) -> None:
653
+ """Forward one side-call response's provider-reported usage.
654
+
655
+ Every attempt spends real tokens — including responses that later
656
+ fail validation or acceptance — so this fires per response observed.
657
+ A telemetry sink must never break note generation: failures are
658
+ swallowed to a debug log.
659
+ """
660
+ if self._report_usage is None or not usage_details:
661
+ return
662
+ try:
663
+ self._report_usage(usage_details)
664
+ except Exception:
665
+ _log.debug("side-call usage report failed", exc_info=True)
666
+
667
+ async def _get_client(self):
668
+ # Fallback-only: the lazily created separate client (and its derived
669
+ # route_kind="last-words" session) never serves the completer path.
670
+ async with self._client_lock:
671
+ if self._closed:
672
+ raise LastWordsGenerationError("last-words generator is closed")
673
+ if self._client is None:
674
+ from chrys.service.llm.clients import create_client
675
+
676
+ self._client = await create_client(
677
+ self._profile,
678
+ session_id=self._session_id,
679
+ parent_session_id=self._parent_session_id,
680
+ session_dir=self._session_dir,
681
+ )
682
+ return self._client
683
+
684
+ async def aclose(self) -> None:
685
+ """Close the fallback client, if one was created; later generations are refused."""
686
+ self._closed = True
687
+ async with self._client_lock:
688
+ client, self._client = self._client, None
689
+ if client is not None:
690
+ await client.aclose()
691
+
692
+ def _profile_chat_options(self) -> dict:
693
+ """Parsed profile chat options, cached (fallback options + budget math)."""
694
+ if self._chat_options is None:
695
+ from chrys.service.profiles.models.options import effective_chat_options
696
+
697
+ self._chat_options = effective_chat_options(self._profile) or {}
698
+ return self._chat_options
699
+
700
+ def _thinking_budget_tokens(self) -> int:
701
+ """Extended-thinking budget riding in the profile options (0 = none).
702
+
703
+ Anthropic's ``thinking.budget_tokens`` spends from the same
704
+ ``max_tokens`` pool as the visible note, and the API rejects any
705
+ request whose ``max_tokens`` does not exceed it.
706
+ """
707
+ from chrys.service.profiles.models.options import output_cap_option_value
708
+
709
+ thinking = self._profile_chat_options().get("thinking")
710
+ if isinstance(thinking, Mapping) and thinking.get("type") == "enabled":
711
+ budget = output_cap_option_value(thinking, "budget_tokens")
712
+ if budget is not None:
713
+ return budget
714
+ return 0
715
+
716
+ def _model_output_cap(self) -> int:
717
+ """Hard ``max_tokens`` ceiling for note calls (0 = unknown).
718
+
719
+ Prefers the profile's explicit ``max_output_tokens``. When that is
720
+ unset, a user-set output cap in the profile chat options (any
721
+ provider spelling) serves as the ceiling instead: every live call
722
+ sends it, so it is known provider-valid, while anything above it is
723
+ unproven.
724
+ """
725
+ cap = self._profile.max_output_tokens
726
+ if cap > 0:
727
+ return cap
728
+ from chrys.service.profiles.models.options import OUTPUT_CAP_OPTION_ALIASES, output_cap_option_value
729
+
730
+ options = self._profile_chat_options()
731
+ for alias in OUTPUT_CAP_OPTION_ALIASES:
732
+ value = output_cap_option_value(options, alias)
733
+ if value is not None:
734
+ return value
735
+ return 0
736
+
737
+ def _output_budgets(self) -> tuple[int, int]:
738
+ """``(note_budget, wire_max_tokens)`` for a note-generation call.
739
+
740
+ ``note_budget`` is the visible-note share stated in the prompt;
741
+ ``wire_max_tokens`` is the ``max_tokens`` actually sent. Both start
742
+ from the configured ``max_output_tokens`` clamped to the model's hard
743
+ output cap (:meth:`_model_output_cap` — DeepSeek rejects anything
744
+ above 8192 outright). When the profile enables extended thinking,
745
+ the wire value is raised by ``budget_tokens`` (which spends from the
746
+ same pool) so thinking cannot starve the note, then re-clamped to
747
+ the cap.
748
+ """
749
+ cap = self._model_output_cap()
750
+ note = self._max_output_tokens
751
+ if cap > 0:
752
+ note = min(note, cap)
753
+ thinking = self._thinking_budget_tokens()
754
+ wire = note
755
+ if thinking > 0:
756
+ wire = note + thinking
757
+ if cap > 0 and wire > cap:
758
+ wire = cap
759
+ note = max(wire - thinking, 1)
760
+ if thinking > 0 and wire <= thinking:
761
+ _log.warning(
762
+ "extended-thinking budget_tokens (%d) meets or exceeds the model output cap (%d); "
763
+ "LAST_WORDS note calls will likely be rejected by the provider",
764
+ thinking,
765
+ cap,
766
+ )
767
+ elif note < _MIN_NOTE_TOKENS:
768
+ _log.warning(
769
+ "LAST_WORDS note budget (%d tokens) is below the %d-token minimum the prompt asks "
770
+ "for (model output cap %d, extended-thinking budget %d); notes will likely be "
771
+ "truncated and rejected by the note-length floor",
772
+ note,
773
+ _MIN_NOTE_TOKENS,
774
+ cap,
775
+ thinking,
776
+ )
777
+ return note, wire
778
+
779
+ async def generate(
780
+ self,
781
+ scoped_groups: list[ScopedGroup],
782
+ previous_last_words: str | None,
783
+ *,
784
+ degraded_opener: bool,
785
+ has_continuation_nudges: bool,
786
+ completer: LastWordsCompleter | None = None,
787
+ tokenizer: TokenizerProtocol | None = None,
788
+ system_overhead_tokens: int = 0,
789
+ tool_definition_tokens: int = 0,
790
+ request_overhead_tokens: int = 0,
791
+ calibration_ratio: float = 1.0,
792
+ spend_side_call_tokens: Callable[[int], bool] | None = None,
793
+ ) -> str:
794
+ """Produce an updated LAST_WORDS note, publishing UX status signals.
795
+
796
+ ``spend_side_call_tokens`` is the narrow Phase-4 admission hook: it is
797
+ called synchronously with the estimated input size immediately before
798
+ every provider attempt. Returning false raises
799
+ :class:`LastWordsSpendBudgetExceeded` before that request starts unless
800
+ a fresh malformed note is already available for safe acceptance.
801
+
802
+ Wraps :meth:`_generate` (the actual side-call/fallback machinery)
803
+ with a started/finished :class:`CompactionStatus` pair through
804
+ ``publish_status`` so frontends can surface the otherwise-silent
805
+ generation window. The finished signal always fires — on success
806
+ (``outcome="ok"`` with the note text), terminal failure
807
+ (``"failed"``), and cancellation (``"canceled"``) — so a live
808
+ indicator never dangles. Status publication is UX-only: failures
809
+ to publish are swallowed and never affect generation — except
810
+ cancellation, which is always honored: delivered during the
811
+ started publish it aborts before the LLM call; during the
812
+ finished publish it propagates instead of returning the note.
813
+ """
814
+ compaction_id = uuid4().hex
815
+ # Remembered so the strategy's post-commit publish_committed() call
816
+ # can correlate with this round's started/finished pair.
817
+ self._last_compaction_id = compaction_id
818
+ started_at = time.monotonic()
819
+ outcome = "failed"
820
+ note = ""
821
+ format_violation = ""
822
+ failure_reason = ""
823
+ try:
824
+ # Published inside the try: an interrupt that lands while this
825
+ # await is suspended in a frontend handler must flow through the
826
+ # canceled/finished machinery below — swallowing it would let
827
+ # the expensive LLM call start after the user already cancelled.
828
+ await self._publish_status_cancellable(CompactionStatus(compaction_id=compaction_id, stage="started"))
829
+ generated = await self._generate(
830
+ scoped_groups,
831
+ previous_last_words,
832
+ degraded_opener=degraded_opener,
833
+ has_continuation_nudges=has_continuation_nudges,
834
+ completer=completer,
835
+ tokenizer=tokenizer or MixedLanguageTokenizer(),
836
+ system_overhead_tokens=system_overhead_tokens,
837
+ tool_definition_tokens=tool_definition_tokens,
838
+ request_overhead_tokens=request_overhead_tokens,
839
+ calibration_ratio=calibration_ratio,
840
+ spend_side_call_tokens=spend_side_call_tokens,
841
+ )
842
+ note = generated.text
843
+ format_violation = generated.format_violation
844
+ outcome = "ok" if note else "failed"
845
+ return note
846
+ except LastWordsSpendBudgetExceeded:
847
+ failure_reason = SPEND_BUDGET_FAILURE_REASON
848
+ raise
849
+ except asyncio.CancelledError:
850
+ outcome = "canceled"
851
+ raise
852
+ finally:
853
+ finished = CompactionStatus(
854
+ compaction_id=compaction_id,
855
+ stage="finished",
856
+ outcome=outcome,
857
+ duration_ms=int((time.monotonic() - started_at) * 1000),
858
+ last_words=note if outcome == "ok" else "",
859
+ format_violation=format_violation if outcome == "ok" else "",
860
+ failure_reason=failure_reason if outcome == "failed" else "",
861
+ )
862
+ try:
863
+ await self._publish_status_cancellable(finished)
864
+ except asyncio.CancelledError:
865
+ # A cancellation delivered while the finished signal awaits
866
+ # frontend handlers must be honored — returning the note
867
+ # would swallow the user's interrupt. But when this
868
+ # ``finally`` is already unwinding a cancellation, the
869
+ # publish-await raises a *fresh* CancelledError; re-raising
870
+ # it would merely replace the original traceback, so let
871
+ # the propagating one win.
872
+ if outcome != "canceled":
873
+ raise
874
+ _log.debug("LAST_WORDS finished publish cancelled during teardown", exc_info=True)
875
+
876
+ async def _publish_status_cancellable(self, status: CompactionStatus) -> None:
877
+ """Publish a status signal, letting cancellation propagate.
878
+
879
+ Ordinary publish failures are swallowed (UX-only signals must never
880
+ break generation), but ``CancelledError`` — and any other
881
+ ``BaseException`` — propagates: an interrupt delivered during the
882
+ started publish must stop the compaction before the LLM call
883
+ begins, and one during the finished publish must not be traded for
884
+ the generated note (the caller decides when a propagating
885
+ cancellation already covers it).
886
+ """
887
+ if self._publish_status is None:
888
+ return
889
+ try:
890
+ await self._publish_status(status)
891
+ except Exception:
892
+ _log.debug("LAST_WORDS status publish failed (stage=%s)", status.stage, exc_info=True)
893
+
894
+ async def publish_committed(self) -> None:
895
+ """Publish a ``stage="committed"`` status for the last generated note.
896
+
897
+ Called by the compaction strategy only after the round is durably
898
+ applied — spill written (or checkpointed through recovery
899
+ persistence), note set, manifest appended, and messages excluded.
900
+ The finished-ok signal from :meth:`generate` fires *before* that
901
+ commit, so observers that must count real compactions (not merely
902
+ successful note generations) wait for this signal instead.
903
+
904
+ Delivery is shielded from cancellation: by the time this fires the
905
+ round is already durable, so an interrupt arriving mid-publish must
906
+ not erase the only signal that distinguishes a committed round from
907
+ an abandoned one. The detached publish finishes in the background
908
+ while the cancellation propagates to the caller.
909
+ """
910
+ if not self._last_compaction_id:
911
+ return
912
+ status = CompactionStatus(compaction_id=self._last_compaction_id, stage="committed")
913
+ # Consumed on publish: one generate, one committed signal. A stray
914
+ # second call must not re-emit "committed" for an old round.
915
+ self._last_compaction_id = ""
916
+ publish = asyncio.ensure_future(self._publish_status_cancellable(status))
917
+ self._detached_publishes.add(publish)
918
+ publish.add_done_callback(self._discard_detached_publish)
919
+ await asyncio.shield(publish)
920
+
921
+ def _discard_detached_publish(self, task: asyncio.Future) -> None:
922
+ self._detached_publishes.discard(task)
923
+ _log_detached_publish_failure(task)
924
+
925
+ async def publish_breaker_trip(self, failure_reason: str) -> None:
926
+ """Publish an immediate started+failed status pair for an entry-time trip.
927
+
928
+ When the persisted Phase-4 breaker refuses entry, the strategy skips
929
+ generation entirely, so :meth:`generate`'s status pair never fires.
930
+ This publishes one terminal failure card carrying the trip reason —
931
+ without it the round would vanish silently while the model window
932
+ keeps overflowing.
933
+ """
934
+ compaction_id = uuid4().hex
935
+ await self._publish_status_cancellable(CompactionStatus(compaction_id=compaction_id, stage="started"))
936
+ await self._publish_status_cancellable(
937
+ CompactionStatus(
938
+ compaction_id=compaction_id,
939
+ stage="finished",
940
+ outcome="failed",
941
+ failure_reason=failure_reason,
942
+ )
943
+ )
944
+
945
+ async def _generate(
946
+ self,
947
+ scoped_groups: list[ScopedGroup],
948
+ previous_last_words: str | None,
949
+ *,
950
+ degraded_opener: bool,
951
+ has_continuation_nudges: bool,
952
+ completer: LastWordsCompleter | None = None,
953
+ tokenizer: TokenizerProtocol,
954
+ system_overhead_tokens: int,
955
+ tool_definition_tokens: int,
956
+ request_overhead_tokens: int,
957
+ calibration_ratio: float,
958
+ spend_side_call_tokens: Callable[[int], bool] | None,
959
+ ) -> _GeneratedNote:
960
+ """Generate from one scoped timeline, with the bounded reconstruction as terminal path."""
961
+ format_state = _FormatRetryState([])
962
+ if completer is not None:
963
+ side_call_note = await self._generate_via_completer(
964
+ completer,
965
+ scoped_groups,
966
+ tokenizer=tokenizer,
967
+ has_continuation_nudges=has_continuation_nudges,
968
+ system_overhead_tokens=system_overhead_tokens,
969
+ tool_definition_tokens=tool_definition_tokens,
970
+ request_overhead_tokens=request_overhead_tokens,
971
+ calibration_ratio=calibration_ratio,
972
+ spend_side_call_tokens=spend_side_call_tokens,
973
+ format_state=format_state,
974
+ )
975
+ if side_call_note is not None:
976
+ return side_call_note
977
+ _log.warning("LAST_WORDS side call demoted, generating via reconstruction fallback")
978
+ return await self._generate_via_fallback(
979
+ scoped_groups,
980
+ previous_last_words,
981
+ degraded_opener=degraded_opener,
982
+ has_continuation_nudges=has_continuation_nudges,
983
+ spend_side_call_tokens=spend_side_call_tokens,
984
+ format_state=format_state,
985
+ )
986
+
987
+ async def _generate_via_fallback(
988
+ self,
989
+ scoped_groups: list[ScopedGroup],
990
+ previous_last_words: str | None,
991
+ *,
992
+ degraded_opener: bool,
993
+ has_continuation_nudges: bool,
994
+ spend_side_call_tokens: Callable[[int], bool] | None,
995
+ format_state: _FormatRetryState,
996
+ ) -> _GeneratedNote:
997
+ note_budget, wire_max_tokens = self._output_budgets()
998
+ candidate_index = 0
999
+ transient_attempt = 0
1000
+ corrective_attempt = 0
1001
+ last_context_error: Exception | None = None
1002
+ max_transient_retries = self._max_transient_retries
1003
+ max_corrective_retries = max(0, type(self)._MAX_CORRECTIVE_RETRIES)
1004
+ while True:
1005
+ format_correction = (
1006
+ _format_correction(format_state.correction_violation) if format_state.correction_violation else ""
1007
+ )
1008
+ candidates = _fallback_candidates(
1009
+ scoped_groups,
1010
+ previous_last_words,
1011
+ template=self._template,
1012
+ degraded_opener=degraded_opener,
1013
+ has_continuation_nudges=has_continuation_nudges,
1014
+ note_budget=note_budget,
1015
+ format_correction=format_correction,
1016
+ )
1017
+ if candidate_index >= len(candidates):
1018
+ if accepted := self._accept_pending_format_note(
1019
+ format_state,
1020
+ reason="corrective request could not fit the model context window",
1021
+ ):
1022
+ return accepted
1023
+ message = "Fallback request cannot fit the model context window without dropping current-turn work"
1024
+ if last_context_error is not None:
1025
+ raise LastWordsGenerationError(message) from last_context_error
1026
+ raise LastWordsGenerationError(message)
1027
+ candidate = candidates[candidate_index]
1028
+ estimated_input_tokens = _fallback_admission_tokens(candidate.messages, output_reserve=0)
1029
+ send_max_tokens = wire_max_tokens
1030
+ admitted_tokens = _fallback_admission_tokens(candidate.messages, output_reserve=wire_max_tokens)
1031
+ if admitted_tokens > self._profile.max_context_tokens:
1032
+ if candidate_index < len(candidates) - 1 or format_state.pending_note is not None:
1033
+ # With a pending accept-with-flag note, exhaustion falls back
1034
+ # to accepting it, so skipping the corrective call is safe.
1035
+ _log.warning(
1036
+ "LAST_WORDS fallback candidate '%s' rejected by admission preflight (%d > %d tokens)",
1037
+ candidate.stage,
1038
+ admitted_tokens,
1039
+ self._profile.max_context_tokens,
1040
+ )
1041
+ candidate_index += 1
1042
+ continue
1043
+ # The byte-conservative estimate overcharges real tokenization by
1044
+ # roughly 4x on prose, so rejecting the last-resort candidate
1045
+ # locally would dead-end Phase 4 on small-context profiles whose
1046
+ # requests actually fit. Send it and let the provider tokenizer
1047
+ # rule; a genuine context rejection still ends the shrink ladder.
1048
+ # The output reserve is exact rather than an overestimate, so
1049
+ # clamp it too: providers enforcing input + max_tokens <= context
1050
+ # would otherwise deterministically reject profiles whose output
1051
+ # cap rivals their context window. Keep at least the note floor
1052
+ # (plus any thinking budget) but never exceed wire_max_tokens,
1053
+ # which already respects the model's hard output cap — below the
1054
+ # floor, the provider verdict is the real answer.
1055
+ room = self._profile.max_context_tokens - estimated_input_tokens
1056
+ thinking = self._thinking_budget_tokens()
1057
+ min_send = thinking + _MIN_NOTE_TOKENS if thinking > 0 else _MIN_NOTE_TOKENS
1058
+ if room < send_max_tokens:
1059
+ send_max_tokens = min(wire_max_tokens, max(min_send, room))
1060
+ if send_max_tokens < wire_max_tokens:
1061
+ # Re-render the terminal candidate so the in-prompt length
1062
+ # directive matches the clamped wire budget — otherwise the
1063
+ # model is told it may write far more than max_tokens allows
1064
+ # and the note risks mid-section truncation. The clamped
1065
+ # budget only shrinks the rendered text, so the admission
1066
+ # estimate used for the clamp stays an upper bound.
1067
+ clamped_note_budget = max(1, send_max_tokens - thinking) if thinking > 0 else send_max_tokens
1068
+ candidate = _fallback_candidates(
1069
+ scoped_groups,
1070
+ previous_last_words,
1071
+ template=self._template,
1072
+ degraded_opener=degraded_opener,
1073
+ has_continuation_nudges=has_continuation_nudges,
1074
+ note_budget=clamped_note_budget,
1075
+ format_correction=format_correction,
1076
+ )[-1]
1077
+ estimated_input_tokens = _fallback_admission_tokens(candidate.messages, output_reserve=0)
1078
+ _log.warning(
1079
+ "LAST_WORDS final fallback candidate '%s' exceeds the conservative admission estimate "
1080
+ "(%d > %d tokens); sending it anyway with max_tokens=%d because the provider "
1081
+ "tokenizer is authoritative",
1082
+ candidate.stage,
1083
+ admitted_tokens,
1084
+ self._profile.max_context_tokens,
1085
+ send_max_tokens,
1086
+ )
1087
+ if spend_side_call_tokens is not None and not spend_side_call_tokens(estimated_input_tokens):
1088
+ if accepted := self._accept_pending_format_note(
1089
+ format_state,
1090
+ reason="side-call spend budget stopped the corrective retry",
1091
+ ):
1092
+ return accepted
1093
+ raise LastWordsSpendBudgetExceeded("Phase-4 side-call token budget exhausted before fallback attempt")
1094
+ try:
1095
+ output_text = await self._generate_once(list(candidate.messages), max_tokens=send_max_tokens)
1096
+ except Exception as exc:
1097
+ # A false positive only shrinks the candidate; a miss would resend
1098
+ # the same oversized candidate until the retry budget runs out.
1099
+ if may_be_context_overflow(exc):
1100
+ last_context_error = exc
1101
+ candidate_index += 1
1102
+ _log.warning(
1103
+ "LAST_WORDS fallback candidate '%s' hit a provider context rejection; shrinking: %s",
1104
+ candidate.stage,
1105
+ clean_error_message(exc),
1106
+ )
1107
+ self._write_log(
1108
+ candidate.instruction,
1109
+ "",
1110
+ "",
1111
+ error=f"provider context rejection at {candidate.stage}; advancing shrink sequence: {exc}",
1112
+ request_note=f"fallback stage: {candidate.stage}",
1113
+ )
1114
+ continue
1115
+ if transient_attempt >= max_transient_retries or not is_retryable(exc):
1116
+ if accepted := self._accept_pending_format_note(
1117
+ format_state,
1118
+ reason="corrective retry failed at the provider",
1119
+ ):
1120
+ return accepted
1121
+ _log.warning("LastWordsGenerator LLM call failed after local retries: %s", exc)
1122
+ self._write_log(
1123
+ candidate.instruction,
1124
+ "",
1125
+ "",
1126
+ error=str(exc),
1127
+ request_note=f"fallback stage: {candidate.stage}",
1128
+ )
1129
+ raise LastWordsGenerationError("Failed to generate last words for the current task") from exc
1130
+ delay = self._delay(transient_attempt)
1131
+ _log.warning(
1132
+ "LastWordsGenerator LLM call failed, retrying compaction request in %gs (attempt %d/%d): %s",
1133
+ delay,
1134
+ transient_attempt + 1,
1135
+ max_transient_retries,
1136
+ clean_error_message(exc),
1137
+ )
1138
+ self._write_log(
1139
+ candidate.instruction,
1140
+ "",
1141
+ "",
1142
+ error=f"attempt {transient_attempt + 1}/{max_transient_retries} failed, retrying: {exc}",
1143
+ request_note=f"fallback stage: {candidate.stage}",
1144
+ )
1145
+ await self._publish_retry_attempt(
1146
+ clean_error_message(exc),
1147
+ transient_attempt + 1,
1148
+ max_transient_retries,
1149
+ delay,
1150
+ )
1151
+ transient_attempt += 1
1152
+ await self._sleep_for_retry(delay, reason_code=RetryReason.TRANSIENT_ERROR)
1153
+ continue
1154
+
1155
+ format_violation: str | None = None
1156
+ repeated_violation = False
1157
+ canonical_text = output_text
1158
+ if output_text:
1159
+ violation, canonical_text = _validate_note_format(output_text)
1160
+ if violation is not None:
1161
+ format_violation = _bounded_format_violation(violation)
1162
+ repeated_violation = format_violation in format_state.violations
1163
+ if not repeated_violation:
1164
+ format_state.violations.append(format_violation)
1165
+ format_state.correction_violation = format_violation
1166
+ else:
1167
+ # A non-empty response with valid structure proves the last
1168
+ # correction was followed. A remaining length shortfall may
1169
+ # still retry, but it no longer needs stale format feedback.
1170
+ format_state.correction_violation = ""
1171
+ shortfall = self._note_shortfall(canonical_text)
1172
+ if shortfall is None:
1173
+ if format_violation is not None:
1174
+ format_state.pending_note = _GeneratedNote(output_text, format_violation=format_violation)
1175
+ format_state.pending_instruction = candidate.instruction
1176
+ format_state.pending_request_note = f"fallback stage: {candidate.stage}"
1177
+ if repeated_violation or corrective_attempt >= max_corrective_retries:
1178
+ reason = (
1179
+ "identical violation repeated"
1180
+ if repeated_violation
1181
+ else "corrective retry budget was exhausted"
1182
+ )
1183
+ accepted = self._accept_pending_format_note(format_state, reason=reason)
1184
+ if accepted is None:
1185
+ raise RuntimeError("LAST_WORDS format candidate disappeared before acceptance")
1186
+ return accepted
1187
+ delay = self._delay(corrective_attempt)
1188
+ _log.warning(
1189
+ "LAST_WORDS format violation, retrying compaction request in %gs (attempt %d/%d): %s",
1190
+ delay,
1191
+ corrective_attempt + 1,
1192
+ max_corrective_retries,
1193
+ format_violation,
1194
+ )
1195
+ self._write_log(
1196
+ candidate.instruction,
1197
+ output_text,
1198
+ "",
1199
+ error=(
1200
+ f"attempt {corrective_attempt + 1}/{max_corrective_retries} returned format violation "
1201
+ f"{format_violation}, retrying"
1202
+ ),
1203
+ request_note=f"fallback stage: {candidate.stage}",
1204
+ )
1205
+ await self._publish_retry_attempt(
1206
+ format_violation,
1207
+ corrective_attempt + 1,
1208
+ max_corrective_retries,
1209
+ delay,
1210
+ )
1211
+ corrective_attempt += 1
1212
+ await self._sleep_for_retry(delay, reason_code=RetryReason.VALIDATION_REJECTED)
1213
+ continue
1214
+ self._write_log(
1215
+ candidate.instruction,
1216
+ output_text,
1217
+ canonical_text,
1218
+ request_note=f"fallback stage: {candidate.stage}",
1219
+ )
1220
+ return _GeneratedNote(canonical_text)
1221
+
1222
+ if corrective_attempt >= max_corrective_retries:
1223
+ if format_state.pending_note is not None:
1224
+ self._write_log(
1225
+ candidate.instruction,
1226
+ output_text,
1227
+ "",
1228
+ error=f"{shortfall} after retries; accepting the earlier complete malformed note",
1229
+ request_note=f"fallback stage: {candidate.stage}",
1230
+ )
1231
+ accepted = self._accept_pending_format_note(
1232
+ format_state,
1233
+ reason=f"{shortfall} and corrective retry budget was exhausted",
1234
+ )
1235
+ if accepted is None:
1236
+ raise RuntimeError("LAST_WORDS format candidate disappeared before acceptance")
1237
+ return accepted
1238
+ if output_text:
1239
+ accepted_error = f"{shortfall} after retries, accepted"
1240
+ if format_violation is not None:
1241
+ format_state.pending_note = _GeneratedNote(output_text, format_violation=format_violation)
1242
+ format_state.pending_instruction = candidate.instruction
1243
+ format_state.pending_request_note = f"fallback stage: {candidate.stage}"
1244
+ accepted = self._accept_pending_format_note(
1245
+ format_state,
1246
+ reason=f"{shortfall} and corrective retry budget was exhausted",
1247
+ )
1248
+ if accepted is None:
1249
+ raise RuntimeError("LAST_WORDS format candidate disappeared before acceptance")
1250
+ return accepted
1251
+ _log.warning(
1252
+ "LastWordsGenerator note still %s after local retries; accepting the short note",
1253
+ shortfall,
1254
+ )
1255
+ self._write_log(
1256
+ candidate.instruction,
1257
+ output_text,
1258
+ canonical_text,
1259
+ error=accepted_error,
1260
+ request_note=f"fallback stage: {candidate.stage}",
1261
+ )
1262
+ return _GeneratedNote(canonical_text, format_violation=format_violation or "")
1263
+ if accepted := self._accept_pending_format_note(
1264
+ format_state,
1265
+ reason="corrective retry budget was exhausted after an empty response",
1266
+ ):
1267
+ return accepted
1268
+ _log.warning("LastWordsGenerator returned %s after local retries", shortfall)
1269
+ self._write_log(
1270
+ candidate.instruction,
1271
+ output_text,
1272
+ "",
1273
+ error=shortfall,
1274
+ request_note=f"fallback stage: {candidate.stage}",
1275
+ )
1276
+ raise LastWordsGenerationError("Failed to generate last words for the current task")
1277
+ delay = self._delay(corrective_attempt)
1278
+ _log.warning(
1279
+ "LastWordsGenerator returned %s, retrying compaction request in %gs (attempt %d/%d)",
1280
+ shortfall,
1281
+ delay,
1282
+ corrective_attempt + 1,
1283
+ max_corrective_retries,
1284
+ )
1285
+ self._write_log(
1286
+ candidate.instruction,
1287
+ output_text,
1288
+ "",
1289
+ error=f"attempt {corrective_attempt + 1}/{max_corrective_retries} returned {shortfall}, retrying",
1290
+ request_note=f"fallback stage: {candidate.stage}",
1291
+ )
1292
+ await self._publish_retry_attempt(
1293
+ shortfall,
1294
+ corrective_attempt + 1,
1295
+ max_corrective_retries,
1296
+ delay,
1297
+ )
1298
+ corrective_attempt += 1
1299
+ await self._sleep_for_retry(delay, reason_code=RetryReason.VALIDATION_REJECTED)
1300
+
1301
+ async def _generate_via_completer(
1302
+ self,
1303
+ completer: LastWordsCompleter,
1304
+ scoped_groups: list[ScopedGroup],
1305
+ *,
1306
+ tokenizer: TokenizerProtocol,
1307
+ has_continuation_nudges: bool,
1308
+ system_overhead_tokens: int,
1309
+ tool_definition_tokens: int,
1310
+ request_overhead_tokens: int,
1311
+ calibration_ratio: float,
1312
+ spend_side_call_tokens: Callable[[int], bool] | None,
1313
+ format_state: _FormatRetryState,
1314
+ ) -> _GeneratedNote | None:
1315
+ """Run the physically scoped side call within its global input budget.
1316
+
1317
+ Returns the generated note or ``None`` to demote. ``format_state``
1318
+ carries corrective-retry state across the fallback boundary:
1319
+
1320
+ * provider context-window rejection of the side call — retrying the
1321
+ same oversized scoped request cannot succeed;
1322
+ * any other non-retryable failure;
1323
+ * exhaustion of the local retry budget on transient transport
1324
+ errors, repeated illegal tool-call responses
1325
+ (:class:`~chrys.kernel.LastWordsToolCallError` from the client's
1326
+ response guard), or empty/too-short responses (``_note_shortfall``).
1327
+
1328
+ Provider failures demote to fallback. The only direct exception is
1329
+ :class:`LastWordsSpendBudgetExceeded`, raised before an over-budget
1330
+ provider attempt when no fresh malformed note is available to accept.
1331
+ """
1332
+ note_budget, wire_max_tokens = self._output_budgets()
1333
+ # ``system_overhead_tokens`` is the calibrated fixed request overhead.
1334
+ # The explicit request/tool estimates are floors before calibration or
1335
+ # when provider framing is undercounted; adding them would double-charge
1336
+ # overlapping content.
1337
+ fixed_request_overhead_tokens = max(
1338
+ 0,
1339
+ system_overhead_tokens,
1340
+ request_overhead_tokens,
1341
+ tool_definition_tokens,
1342
+ )
1343
+ for attempt in range(_COMPLETER_MAX_RETRIES + 1):
1344
+ format_correction = (
1345
+ _format_correction(format_state.correction_violation) if format_state.correction_violation else ""
1346
+ )
1347
+ instruction = _completer_instruction(
1348
+ self._template,
1349
+ has_continuation_nudges=has_continuation_nudges,
1350
+ max_output_tokens=note_budget,
1351
+ format_correction=format_correction,
1352
+ )
1353
+ instruction_tokens = tokenizer.count_tokens(instruction)
1354
+ usable_raw = math.floor(
1355
+ (self._profile.max_context_tokens - wire_max_tokens - _SLICE_SAFETY_MARGIN_TOKENS) / calibration_ratio
1356
+ )
1357
+ slice_budget = usable_raw - fixed_request_overhead_tokens - instruction_tokens
1358
+ prepared = prepare_scoped_slice(scoped_groups, tokenizer=tokenizer, slice_budget=slice_budget)
1359
+ if prepared is None:
1360
+ _log.warning(
1361
+ "LAST_WORDS scoped slice failed budget or runtime shape validation; "
1362
+ "demoting to fallback (budget=%d)",
1363
+ slice_budget,
1364
+ )
1365
+ return None
1366
+ base_messages = prepared.messages
1367
+ prompt_note = (
1368
+ f"(physically scoped side call: {len(base_messages)} messages, "
1369
+ f"{prepared.estimated_tokens} estimated message tokens, {prepared.clipped_results} clipped results, "
1370
+ f"{prepared.clipped_assistant_texts} clipped assistant texts)"
1371
+ )
1372
+ estimated_input_tokens = prepared.estimated_tokens + instruction_tokens + fixed_request_overhead_tokens
1373
+ if spend_side_call_tokens is not None and not spend_side_call_tokens(estimated_input_tokens):
1374
+ if accepted := self._accept_pending_format_note(
1375
+ format_state,
1376
+ reason="side-call spend budget stopped the corrective retry",
1377
+ ):
1378
+ return accepted
1379
+ raise LastWordsSpendBudgetExceeded("Phase-4 side-call token budget exhausted before completer attempt")
1380
+ try:
1381
+ output_text = _normalize_note_response(
1382
+ await completer.complete_last_words(
1383
+ base_messages,
1384
+ instruction,
1385
+ max_output_tokens=wire_max_tokens,
1386
+ on_usage=self._report_side_call_usage,
1387
+ )
1388
+ )
1389
+ except Exception as exc:
1390
+ if may_be_context_overflow(exc):
1391
+ _log.warning(
1392
+ "LAST_WORDS side call rejected for context-window size, demoting to fallback: %s",
1393
+ clean_error_message(exc),
1394
+ )
1395
+ self._write_log(
1396
+ instruction,
1397
+ "",
1398
+ "",
1399
+ error=f"context-window rejection, demoting to fallback: {exc}",
1400
+ request_note=prompt_note,
1401
+ )
1402
+ return None
1403
+ retryable = is_retryable(exc) or isinstance(exc, LastWordsToolCallError)
1404
+ if attempt >= _COMPLETER_MAX_RETRIES or not retryable:
1405
+ _log.warning("LAST_WORDS side call failed after local retries, demoting to fallback: %s", exc)
1406
+ self._write_log(
1407
+ instruction,
1408
+ "",
1409
+ "",
1410
+ error=f"side call failed, demoting to fallback: {exc}",
1411
+ request_note=prompt_note,
1412
+ )
1413
+ return None
1414
+ delay = self._delay(attempt)
1415
+ _log.warning(
1416
+ "LAST_WORDS side call failed, retrying in %gs (attempt %d/%d): %s",
1417
+ delay,
1418
+ attempt + 1,
1419
+ _COMPLETER_MAX_RETRIES,
1420
+ clean_error_message(exc),
1421
+ )
1422
+ self._write_log(
1423
+ instruction,
1424
+ "",
1425
+ "",
1426
+ error=f"attempt {attempt + 1}/{_COMPLETER_MAX_RETRIES} failed, retrying: {exc}",
1427
+ request_note=prompt_note,
1428
+ )
1429
+ await self._publish_retry_attempt(
1430
+ clean_error_message(exc),
1431
+ attempt + 1,
1432
+ _COMPLETER_MAX_RETRIES,
1433
+ delay,
1434
+ )
1435
+ await self._sleep_for_retry(delay, reason_code=RetryReason.TRANSIENT_ERROR)
1436
+ continue
1437
+
1438
+ format_violation: str | None = None
1439
+ repeated_violation = False
1440
+ canonical_text = output_text
1441
+ if output_text:
1442
+ violation, canonical_text = _validate_note_format(output_text)
1443
+ if violation is not None:
1444
+ format_violation = _bounded_format_violation(violation)
1445
+ repeated_violation = format_violation in format_state.violations
1446
+ if not repeated_violation:
1447
+ format_state.violations.append(format_violation)
1448
+ format_state.correction_violation = format_violation
1449
+ else:
1450
+ format_state.correction_violation = ""
1451
+ shortfall = self._note_shortfall(canonical_text)
1452
+ if shortfall is None:
1453
+ if format_violation is not None:
1454
+ format_state.pending_note = _GeneratedNote(output_text, format_violation=format_violation)
1455
+ format_state.pending_instruction = instruction
1456
+ format_state.pending_request_note = prompt_note
1457
+ if repeated_violation:
1458
+ accepted = self._accept_pending_format_note(
1459
+ format_state,
1460
+ reason="identical violation repeated",
1461
+ )
1462
+ if accepted is None:
1463
+ raise RuntimeError("LAST_WORDS format candidate disappeared before acceptance")
1464
+ return accepted
1465
+ if attempt >= _COMPLETER_MAX_RETRIES:
1466
+ _log.warning(
1467
+ "LAST_WORDS side call returned a fresh format violation at its retry limit; "
1468
+ "demoting to fallback: %s",
1469
+ format_violation,
1470
+ )
1471
+ self._write_log(
1472
+ instruction,
1473
+ output_text,
1474
+ "",
1475
+ error=f"format violation {format_violation}, demoting to fallback for corrective retry",
1476
+ request_note=prompt_note,
1477
+ )
1478
+ return None
1479
+ delay = self._delay(attempt)
1480
+ _log.warning(
1481
+ "LAST_WORDS side call returned a format violation, retrying in %gs (attempt %d/%d): %s",
1482
+ delay,
1483
+ attempt + 1,
1484
+ _COMPLETER_MAX_RETRIES,
1485
+ format_violation,
1486
+ )
1487
+ self._write_log(
1488
+ instruction,
1489
+ output_text,
1490
+ "",
1491
+ error=(
1492
+ f"attempt {attempt + 1}/{_COMPLETER_MAX_RETRIES} returned format violation "
1493
+ f"{format_violation}, retrying"
1494
+ ),
1495
+ request_note=prompt_note,
1496
+ )
1497
+ await self._publish_retry_attempt(
1498
+ format_violation,
1499
+ attempt + 1,
1500
+ _COMPLETER_MAX_RETRIES,
1501
+ delay,
1502
+ )
1503
+ await self._sleep_for_retry(delay, reason_code=RetryReason.VALIDATION_REJECTED)
1504
+ continue
1505
+ self._write_log(
1506
+ instruction,
1507
+ output_text,
1508
+ canonical_text,
1509
+ request_note=prompt_note,
1510
+ )
1511
+ return _GeneratedNote(canonical_text)
1512
+
1513
+ if attempt >= _COMPLETER_MAX_RETRIES:
1514
+ _log.warning("LAST_WORDS side call returned %s after local retries, demoting to fallback", shortfall)
1515
+ self._write_log(
1516
+ instruction,
1517
+ output_text,
1518
+ "",
1519
+ error=f"{shortfall}, demoting to fallback",
1520
+ request_note=prompt_note,
1521
+ )
1522
+ return None
1523
+ delay = self._delay(attempt)
1524
+ _log.warning(
1525
+ "LAST_WORDS side call returned %s, retrying in %gs (attempt %d/%d)",
1526
+ shortfall,
1527
+ delay,
1528
+ attempt + 1,
1529
+ _COMPLETER_MAX_RETRIES,
1530
+ )
1531
+ self._write_log(
1532
+ instruction,
1533
+ output_text,
1534
+ "",
1535
+ error=f"attempt {attempt + 1}/{_COMPLETER_MAX_RETRIES} returned {shortfall}, retrying",
1536
+ request_note=prompt_note,
1537
+ )
1538
+ await self._publish_retry_attempt(shortfall, attempt + 1, _COMPLETER_MAX_RETRIES, delay)
1539
+ await self._sleep_for_retry(delay, reason_code=RetryReason.VALIDATION_REJECTED)
1540
+
1541
+ return None
1542
+
1543
+ def _accept_pending_format_note(
1544
+ self,
1545
+ state: _FormatRetryState,
1546
+ *,
1547
+ reason: str,
1548
+ ) -> _GeneratedNote | None:
1549
+ """Accept a fresh malformed note when bounded correction cannot continue."""
1550
+ note = state.pending_note
1551
+ if note is None:
1552
+ return None
1553
+ _log.warning(
1554
+ "LAST_WORDS format violation accepted when corrective retries stopped (%s): %s",
1555
+ reason,
1556
+ note.format_violation,
1557
+ )
1558
+ self._write_log(
1559
+ state.pending_instruction,
1560
+ note.text,
1561
+ note.text,
1562
+ error=f"format violation accepted: {note.format_violation}; corrective retries stopped: {reason}",
1563
+ request_note=state.pending_request_note or None,
1564
+ )
1565
+ return note
1566
+
1567
+ async def _publish_retry_attempt(self, reason: str, attempt: int, max_attempts: int, delay_seconds: float) -> None:
1568
+ if self._publish_retry is None:
1569
+ return
1570
+ try:
1571
+ await self._publish_retry(
1572
+ RetryAttemptInfo(
1573
+ reason=reason,
1574
+ attempt=attempt,
1575
+ max_attempts=max_attempts,
1576
+ delay_seconds=delay_seconds,
1577
+ )
1578
+ )
1579
+ except Exception:
1580
+ _log.debug("Failed to publish LAST_WORDS retry event", exc_info=True)
1581
+
1582
+ async def _sleep_for_retry(self, delay_seconds: float, *, reason_code: str) -> None:
1583
+ trace = RetryBackoffTrace.open(
1584
+ parent_operation_id=current_compaction_operation_id(),
1585
+ retry_mode=RetryMode.COMPACTION,
1586
+ )
1587
+ if trace is not None:
1588
+ await trace.scheduled(reason_code=reason_code, delay_seconds=delay_seconds)
1589
+ await asyncio.sleep(delay_seconds)
1590
+ if trace is not None:
1591
+ await trace.started()
1592
+
1593
+ def _delay(self, attempt: int) -> float:
1594
+ schedule = type(self)._BACKOFF_SCHEDULE
1595
+ if not schedule:
1596
+ return 0
1597
+ return schedule[min(attempt, len(schedule) - 1)]
1598
+
1599
+ def _note_shortfall(self, text: str) -> str | None:
1600
+ """Why ``text`` is unusable as a note (``None`` when acceptable).
1601
+
1602
+ Non-empty is not enough: a truncated fragment silently replaces the
1603
+ whole dropped turn, so anything under ``_MIN_NOTE_CHARS`` is retried
1604
+ like an empty response. Callers pass the *canonical* note (violating
1605
+ responses come through unchanged — the validator returns them as-is)
1606
+ and the floor is measured on whitespace-collapsed content: anything
1607
+ canonicalization strips (blank-line padding, heading closing
1608
+ sequences) or collapsing removes must not clear the floor only for
1609
+ the accepted note to shrink below it and authorize dropping the
1610
+ current turn.
1611
+ """
1612
+ substantive_chars = len("".join(text.split()))
1613
+ if not substantive_chars:
1614
+ return "empty response"
1615
+ floor = type(self)._MIN_NOTE_CHARS
1616
+ if substantive_chars < floor:
1617
+ return f"note too short ({substantive_chars} substantive chars < {floor} minimum)"
1618
+ return None
1619
+
1620
+ async def _generate_once(self, messages: list[Message], *, max_tokens: int) -> str:
1621
+ client = await self._get_client()
1622
+ profile_options = self._profile_chat_options()
1623
+ options = {key: value for key, value in profile_options.items() if key in _FALLBACK_ALLOWED_OPTION_KEYS}
1624
+ extra_body = profile_options.get("extra_body")
1625
+ if isinstance(extra_body, Mapping) and PROMPT_CACHE_KEY_OPTION in extra_body:
1626
+ # Of extra_body, only the prompt cache key, or its null, applies to the note.
1627
+ options["extra_body"] = {PROMPT_CACHE_KEY_OPTION: extra_body[PROMPT_CACHE_KEY_OPTION]}
1628
+ options["max_tokens"] = max_tokens
1629
+ report_wire_progress()
1630
+ try:
1631
+ with side_call_scope(ActorRole.COMPACTION):
1632
+ response = await get_final_response(
1633
+ client,
1634
+ messages,
1635
+ stream=self._profile.stream,
1636
+ options=options,
1637
+ timeout=self._profile.http_read_timeout,
1638
+ )
1639
+ except ProviderResponseError as err:
1640
+ # A response the adapter failed consumed provider tokens too.
1641
+ if err.usage_details:
1642
+ self._report_side_call_usage(err.usage_details)
1643
+ raise
1644
+ usage_details = response.usage_details
1645
+ if usage_details:
1646
+ self._report_side_call_usage(usage_details)
1647
+ raise_if_context_window_filled(response)
1648
+ return _normalize_note_response(response.raw_text)
1649
+
1650
+ def _write_log(
1651
+ self,
1652
+ instruction: str,
1653
+ raw_response: str,
1654
+ final_text: str,
1655
+ error: str | None = None,
1656
+ request_note: str | None = None,
1657
+ ) -> None:
1658
+ """Write one side-call debug log.
1659
+
1660
+ Deliberately excludes the user/timeline payload (user messages, tool
1661
+ calls, previous note) — session data lives in the spill records. The
1662
+ log keeps only the instruction actually sent, the model's raw
1663
+ response, and the text placed in the LAST_WORDS tag for the next
1664
+ turn, plus a small request summary when provided.
1665
+ """
1666
+ if self._log_dir is None:
1667
+ return
1668
+ try:
1669
+ self._log_dir.mkdir(parents=True, exist_ok=True)
1670
+ # Salt with a short uuid fragment: concurrent Phase-3 rounds
1671
+ # in the same millisecond (e.g. main agent + sub-agent) would
1672
+ # otherwise overwrite each other's log.
1673
+ ts = datetime.now(UTC).strftime("%H%M%S_%f")[:-3]
1674
+ path = self._log_dir / f"last_words_{ts}_{uuid4().hex[:6]}.log"
1675
+ lines: list[str] = []
1676
+ lines.append("=" * 78)
1677
+ lines.append(f"LAST_WORDS @ {datetime.now(UTC).isoformat()}")
1678
+ lines.append("=" * 78)
1679
+ if request_note:
1680
+ lines.append("")
1681
+ lines.append("--- REQUEST ---")
1682
+ lines.append(request_note)
1683
+ lines.append("")
1684
+ lines.append("--- INSTRUCTION ---")
1685
+ lines.append(instruction.rstrip() or "(none)")
1686
+ lines.append("")
1687
+ lines.append("--- RAW RESPONSE ---")
1688
+ lines.append(raw_response.rstrip() if raw_response else "(empty)")
1689
+ if error:
1690
+ lines.append("")
1691
+ lines.append("--- ERROR ---")
1692
+ lines.append(error)
1693
+ lines.append("")
1694
+ lines.append("--- LAST_WORDS ---")
1695
+ lines.append(final_text.rstrip() or "(none)")
1696
+ lines.append("")
1697
+ # Owner-only like the spill records: the log carries the note
1698
+ # text and raw model output, which summarise session content.
1699
+ descriptor = os.open(path, os.O_WRONLY | os.O_CREAT | os.O_EXCL, 0o600)
1700
+ with os.fdopen(descriptor, "w", encoding="utf-8") as output:
1701
+ output.write("\n".join(lines))
1702
+ except Exception:
1703
+ _log.debug("Failed to write last_words log", exc_info=True)
1704
+
1705
+
1706
+ @dataclass(frozen=True, slots=True)
1707
+ class _TimelineItem:
1708
+ text: str
1709
+ tool_calls: int = 0
1710
+
1711
+
1712
+ @dataclass(frozen=True, slots=True)
1713
+ class _FallbackSpec:
1714
+ stage: str
1715
+ timeline_ceiling: int
1716
+ previous_note_cap: int
1717
+ supplement_cap: int = _FALLBACK_TEMPLATE_MAX_CHARS
1718
+ newest_few: bool = False
1719
+
1720
+
1721
+ @dataclass(frozen=True, slots=True)
1722
+ class _FallbackCandidate:
1723
+ stage: str
1724
+ messages: tuple[Message, Message]
1725
+ instruction: str
1726
+
1727
+
1728
+ def _fallback_candidates(
1729
+ scoped_groups: list[ScopedGroup],
1730
+ previous_last_words: str | None,
1731
+ *,
1732
+ template: str,
1733
+ degraded_opener: bool,
1734
+ has_continuation_nudges: bool,
1735
+ note_budget: int,
1736
+ format_correction: str,
1737
+ ) -> list[_FallbackCandidate]:
1738
+ half_timeline = max(1, _FALLBACK_MAX_CHARS // 2)
1739
+ half_note = max(1, _FALLBACK_PREV_NOTE_MAX_CHARS // 2)
1740
+ specs = (
1741
+ _FallbackSpec("bounded", _FALLBACK_MAX_CHARS, _FALLBACK_PREV_NOTE_MAX_CHARS),
1742
+ _FallbackSpec("half-timeline", half_timeline, _FALLBACK_PREV_NOTE_MAX_CHARS),
1743
+ _FallbackSpec("half-previous-note", half_timeline, half_note),
1744
+ _FallbackSpec("compact-supplement", half_timeline, half_note, _FALLBACK_TEMPLATE_COMPACT_CHARS),
1745
+ _FallbackSpec(
1746
+ "newest-few",
1747
+ _FALLBACK_MIN_TIMELINE_CHARS,
1748
+ _FALLBACK_MIN_TIMELINE_CHARS,
1749
+ _FALLBACK_TEMPLATE_MIN_CHARS,
1750
+ newest_few=True,
1751
+ ),
1752
+ )
1753
+ return [
1754
+ _render_fallback_candidate(
1755
+ scoped_groups,
1756
+ previous_last_words,
1757
+ template=template,
1758
+ degraded_opener=degraded_opener,
1759
+ has_continuation_nudges=has_continuation_nudges,
1760
+ note_budget=note_budget,
1761
+ format_correction=format_correction,
1762
+ spec=spec,
1763
+ )
1764
+ for spec in specs
1765
+ ]
1766
+
1767
+
1768
+ def _render_fallback_candidate(
1769
+ scoped_groups: list[ScopedGroup],
1770
+ previous_last_words: str | None,
1771
+ *,
1772
+ template: str,
1773
+ degraded_opener: bool,
1774
+ has_continuation_nudges: bool,
1775
+ note_budget: int,
1776
+ format_correction: str,
1777
+ spec: _FallbackSpec,
1778
+ ) -> _FallbackCandidate:
1779
+ user_request = _timeline_opener(scoped_groups, degraded_opener=degraded_opener)
1780
+ user_request = _truncate_head(user_request, spec.timeline_ceiling)
1781
+ previous_note = _middle_truncate_previous_note(previous_last_words or "(none)", spec.previous_note_cap)
1782
+ work_done = _format_dropped(
1783
+ scoped_groups,
1784
+ max_chars=spec.timeline_ceiling,
1785
+ newest_few=spec.newest_few,
1786
+ )
1787
+ sections = [
1788
+ f"Current turn user request:\n{_prompt_block('user_request', escape_system_reminder_tags(user_request))}",
1789
+ f"Previous progress note:\n{_prompt_block('previous_progress_note', escape_system_reminder_tags(previous_note))}",
1790
+ (
1791
+ "Work done since (about to be dropped from context):\n"
1792
+ f"{_prompt_block('work_done_since', escape_system_reminder_tags(work_done))}"
1793
+ ),
1794
+ (
1795
+ "Write the updated progress note now. "
1796
+ f"{_FALLBACK_LENGTH_DIRECTIVE.format(min_note_tokens=_MIN_NOTE_TOKENS, max_output_tokens=note_budget)}"
1797
+ ),
1798
+ ]
1799
+ user_prompt = "\n\n".join(sections)
1800
+ system_instruction = _fallback_instruction(
1801
+ _truncate_template(template, spec.supplement_cap),
1802
+ has_continuation_nudges=has_continuation_nudges,
1803
+ max_output_tokens=note_budget,
1804
+ format_correction=format_correction,
1805
+ )
1806
+ return _FallbackCandidate(
1807
+ stage=spec.stage,
1808
+ messages=(Message("system", [system_instruction]), Message("user", [user_prompt])),
1809
+ instruction=system_instruction,
1810
+ )
1811
+
1812
+
1813
+ def _fallback_admission_tokens(messages: tuple[Message, ...], *, output_reserve: int) -> int:
1814
+ """Conservative whole-request counter for the terminal fallback."""
1815
+ total = max(0, output_reserve)
1816
+ for message in messages:
1817
+ serialized = json.dumps(
1818
+ message.to_dict(),
1819
+ ensure_ascii=False,
1820
+ sort_keys=True,
1821
+ separators=(",", ":"),
1822
+ default=str,
1823
+ )
1824
+ total += len(serialized.encode("utf-8")) + _FALLBACK_FRAMING_TOKENS_PER_MESSAGE
1825
+ return total
1826
+
1827
+
1828
+ def _timeline_opener(scoped_groups: list[ScopedGroup], *, degraded_opener: bool) -> str:
1829
+ if degraded_opener:
1830
+ return DEGRADED_SCOPED_PREAMBLE
1831
+ for group in scoped_groups:
1832
+ if group.kind != "user":
1833
+ continue
1834
+ for message in group.messages:
1835
+ text = _user_authored_text(message)
1836
+ if text:
1837
+ return text
1838
+ return "(none)"
1839
+
1840
+
1841
+ def _format_dropped(
1842
+ scoped_groups: list[ScopedGroup],
1843
+ *,
1844
+ max_chars: int = _FALLBACK_MAX_CHARS,
1845
+ newest_few: bool = False,
1846
+ ) -> str:
1847
+ """Render one bounded timeline with group-local call/result pairing."""
1848
+ items: list[_TimelineItem] = []
1849
+ opener_seen = False
1850
+ for group in scoped_groups:
1851
+ if group.kind == "user":
1852
+ for message in group.messages:
1853
+ if not opener_seen:
1854
+ opener_seen = True
1855
+ continue
1856
+ if is_continuation_message(message):
1857
+ continue
1858
+ text = _user_authored_text(message)
1859
+ if text:
1860
+ items.append(_TimelineItem(f"- user said: {_truncate_head(text, _FALLBACK_FOLLOWUP_MAX)}"))
1861
+ continue
1862
+ if group.kind == "tool_call":
1863
+ item = _format_tool_group(group)
1864
+ if item is not None:
1865
+ items.append(item)
1866
+ continue
1867
+ for message in group.messages:
1868
+ text = (message.text or "").strip()
1869
+ if text:
1870
+ items.append(_TimelineItem(f"- agent said: {_truncate_head(text, _FALLBACK_ASSISTANT_MAX)}"))
1871
+ return _bound_timeline(items, max_chars=max_chars, newest_few=newest_few)
1872
+
1873
+
1874
+ def _format_tool_group(group: ScopedGroup) -> _TimelineItem | None:
1875
+ call_info: dict[str, tuple[str, str]] = {}
1876
+ call_order: list[str] = []
1877
+ lines: list[str] = []
1878
+ for message in group.messages:
1879
+ for content in message.contents:
1880
+ if content.type not in TOOL_CALL_CONTENT_TYPES:
1881
+ continue
1882
+ call_id = content.call_id or ""
1883
+ name = _call_name(content)
1884
+ call_info[call_id] = (name, _format_arguments(content.arguments))
1885
+ call_order.append(call_id)
1886
+ lines.append(f"- call {name}({call_info[call_id][1]})")
1887
+ if message.role == "assistant":
1888
+ text = " ".join(
1889
+ content.text or "" for content in message.contents if content.type == "text" and content.text
1890
+ ).strip()
1891
+ if text:
1892
+ lines.append(f"- agent said: {_truncate_head(text, _FALLBACK_ASSISTANT_MAX)}")
1893
+ for message in group.messages:
1894
+ for content in message.contents:
1895
+ if not content.type.endswith("_result"):
1896
+ continue
1897
+ call_id = content.call_id or ""
1898
+ name = call_info.get(call_id, ("tool", ""))[0]
1899
+ lines.append(f" result[{name}]: {_result_payload_text(content)}")
1900
+ if not lines:
1901
+ return None
1902
+ return _TimelineItem("\n".join(lines), tool_calls=max(1, len(call_order)))
1903
+
1904
+
1905
+ def _call_name(content: Content) -> str:
1906
+ if content.type == "function_call":
1907
+ return content.name or "tool"
1908
+ if content.type in {"hosted_tool_call", "mcp_server_tool_call", "search_tool_call"}:
1909
+ return content.tool_name or "tool"
1910
+ return content.type.removesuffix("_tool_call") or "tool"
1911
+
1912
+
1913
+ def _format_arguments(arguments: object) -> str:
1914
+ if arguments is None:
1915
+ return ""
1916
+ if isinstance(arguments, Mapping):
1917
+ return ", ".join(
1918
+ f"{key}={json.dumps(value, ensure_ascii=False, default=str)}" for key, value in arguments.items()
1919
+ )
1920
+ return str(arguments)
1921
+
1922
+
1923
+ def _result_payload_text(content: Content) -> str:
1924
+ if content.type in {"function_result", "hosted_tool_result", "search_tool_result"}:
1925
+ value = content.items or content.result
1926
+ elif content.type == "mcp_server_tool_result":
1927
+ value = content.output
1928
+ else:
1929
+ value = content.outputs
1930
+ # The compaction renderer: an image or other binary payload becomes a short
1931
+ # placeholder, never its base64 data.
1932
+ return _render_tool_result_value(value)
1933
+
1934
+
1935
+ def _user_authored_text(message: Message) -> str:
1936
+ parts: list[str] = []
1937
+ for content in message.contents:
1938
+ if content.type != "text" or not content.text:
1939
+ continue
1940
+ text = content.text.strip()
1941
+ if text.startswith("<system-reminder>\n") and text.endswith("</system-reminder>"):
1942
+ continue
1943
+ parts.append(content.text)
1944
+ return " ".join(parts).strip()
1945
+
1946
+
1947
+ def _bound_timeline(items: list[_TimelineItem], *, max_chars: int, newest_few: bool) -> str:
1948
+ ceiling = max(1, max_chars)
1949
+ retained = list(items)
1950
+ omitted_calls = 0
1951
+ if newest_few and len(retained) > _FALLBACK_NEWEST_LINES:
1952
+ omitted_calls += sum(item.tool_calls for item in retained[:-_FALLBACK_NEWEST_LINES])
1953
+ retained = retained[-_FALLBACK_NEWEST_LINES:]
1954
+
1955
+ def render() -> str:
1956
+ lines = [item.text for item in retained]
1957
+ if omitted_calls:
1958
+ lines.insert(0, f"… {omitted_calls} earlier calls omitted — indexed in the dropped-record manifest")
1959
+ return "\n".join(lines) if lines else "(nothing dropped)"
1960
+
1961
+ rendered = render()
1962
+ while len(rendered) > ceiling:
1963
+ tool_indices = [index for index, item in enumerate(retained) if item.tool_calls]
1964
+ if len(tool_indices) <= 1:
1965
+ break
1966
+ removed = retained.pop(tool_indices[0])
1967
+ omitted_calls += removed.tool_calls
1968
+ rendered = render()
1969
+ if len(rendered) <= ceiling:
1970
+ return rendered
1971
+ marker = f"{_FALLBACK_TRUNCATION_MARKER}\n"
1972
+ if omitted_calls:
1973
+ marker = f"… {omitted_calls} earlier calls omitted — indexed in the dropped-record manifest\n{marker}"
1974
+ if len(marker) >= ceiling:
1975
+ return marker[:ceiling]
1976
+ return marker + rendered[-(ceiling - len(marker)) :]
1977
+
1978
+
1979
+ def _truncate_head(text: str, limit: int) -> str:
1980
+ if len(text) <= limit:
1981
+ return text
1982
+ if limit <= len(_FALLBACK_TRUNCATION_MARKER):
1983
+ return _FALLBACK_TRUNCATION_MARKER[:limit]
1984
+ keep = limit - len(_FALLBACK_TRUNCATION_MARKER)
1985
+ return text[:keep] + _FALLBACK_TRUNCATION_MARKER
1986
+
1987
+
1988
+ def _middle_truncate_previous_note(text: str, limit: int) -> str:
1989
+ if len(text) <= limit:
1990
+ return text
1991
+ if limit <= len(_FALLBACK_PREV_NOTE_TRUNCATION_MARKER):
1992
+ return _FALLBACK_PREV_NOTE_TRUNCATION_MARKER[:limit]
1993
+ keep = limit - len(_FALLBACK_PREV_NOTE_TRUNCATION_MARKER)
1994
+ head = (keep + 1) // 2
1995
+ tail = keep - head
1996
+ return f"{text[:head]}{_FALLBACK_PREV_NOTE_TRUNCATION_MARKER}{text[len(text) - tail :]}"