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,1616 @@
1
+ # Copyright (c) Huawei Technologies Co., Ltd. 2026. All rights reserved.
2
+
3
+ """SessionHistoryManager — single owner of all ``chrys_history`` state mutations.
4
+
5
+ Centralizes the 16+ history-mutation methods that were scattered across
6
+ AgentEngine. All marker insertion, message trimming, metadata persistence,
7
+ and state repair operations go through this class.
8
+
9
+ The manager holds a reference to the live ``chrys_history`` provider state
10
+ dict (shared with CompressibleHistoryProvider). Mutations
11
+ are visible to Agent's history provider immediately.
12
+ """
13
+
14
+ from __future__ import annotations
15
+
16
+ import copy
17
+ import json
18
+ import logging
19
+ from collections.abc import Hashable, Mapping
20
+ from datetime import datetime
21
+ from typing import TYPE_CHECKING, Any, Protocol, cast
22
+
23
+ from chrys.foundation.i18n.formatting import format_message
24
+ from chrys.foundation.models.history_markers import (
25
+ AWAITING_SUB_AGENTS_MESSAGE,
26
+ EXECUTION_INTERRUPTED_MESSAGE,
27
+ HistoryMarkerKind,
28
+ copy_reminder_record,
29
+ )
30
+ from chrys.foundation.models.turns import (
31
+ UserMessageKind,
32
+ current_turn_start,
33
+ is_continuation_message,
34
+ user_text_matches,
35
+ )
36
+ from chrys.foundation.platform.files import surrogate_safe_text
37
+ from chrys.foundation.tool_invocation_order import read_tool_invocation_order
38
+ from chrys.foundation.tool_kinds import KIND_SUB_AGENT
39
+ from chrys.foundation.trajectory.metadata import ensure_analytics_item_id
40
+ from chrys.kernel import Message
41
+ from chrys.kernel.exchanges import (
42
+ PREVALIDATION_ERROR_KINDS,
43
+ EmptyIdPolicy,
44
+ LiveAccessor,
45
+ NoneIdPolicy,
46
+ PairingPolicy,
47
+ iter_exchanges,
48
+ pair_results,
49
+ )
50
+ from chrys.service.agent_middleware.events.tool_events import ToolBatchRecord
51
+ from chrys.service.agent_middleware.injection import ConsumedInjection
52
+ from chrys.service.session.message_metadata import (
53
+ TOOL_RESULT_METADATA_KEY,
54
+ normalize_created_at,
55
+ persisted_tool_call_kind,
56
+ stamp_message_created_at,
57
+ )
58
+ from chrys.service.session.sub_agent_logs import SUB_AGENT_RESTORE_CONSUMED_KEY
59
+ from chrys.service.trajectory.items import ensure_history_item_ids
60
+
61
+ if TYPE_CHECKING:
62
+ from chrys.kernel import Content
63
+ from chrys.kernel.exchanges import Exchange, ExchangePairing, Occurrence, PairingKey
64
+
65
+ logger = logging.getLogger(__name__)
66
+
67
+
68
+ class MergeableLoopRecorder(Protocol):
69
+ """Recorder view consumed by history recovery."""
70
+
71
+ @property
72
+ def initial_count(self) -> int | None: ...
73
+
74
+ @property
75
+ def captured_count(self) -> int | None: ...
76
+
77
+ @property
78
+ def loop_messages(self) -> list[Message] | None: ...
79
+
80
+
81
+ _USER_APPROVAL_STATUSES = frozenset({"user_approved", "user_rejected"})
82
+
83
+
84
+ def _select_message_approval_from_contents(message: Message) -> dict[str, str] | None:
85
+ """Return the best legacy message-level approval from function-call contents."""
86
+ selected: dict[str, str] | None = None
87
+ for content in message.contents:
88
+ if content.type != "function_call" or content.informational_only:
89
+ continue
90
+ approval = content.additional_properties.get("_approval")
91
+ if not isinstance(approval, dict):
92
+ continue
93
+ if selected is None or (
94
+ selected.get("status") not in _USER_APPROVAL_STATUSES and approval.get("status") in _USER_APPROVAL_STATUSES
95
+ ):
96
+ selected = approval
97
+ return selected
98
+
99
+
100
+ def _sync_message_approval_from_contents(message: Message) -> None:
101
+ """Refresh legacy message-level approval after function-call contents change."""
102
+ approval = _select_message_approval_from_contents(message)
103
+ if approval is None:
104
+ message.additional_properties.pop("_approval", None)
105
+ else:
106
+ message.additional_properties["_approval"] = approval
107
+
108
+
109
+ def _sub_agent_result_metadata(record: dict[str, Any]) -> dict[str, str]:
110
+ metadata: dict[str, str] = {}
111
+ invocation_id = record.get("invocation_id")
112
+ if isinstance(invocation_id, str) and invocation_id:
113
+ metadata["sub_agent_invocation_id"] = invocation_id
114
+ log_file = record.get("sub_agent_log_file")
115
+ if isinstance(log_file, str) and log_file:
116
+ metadata["sub_agent_log_file"] = log_file
117
+ return metadata
118
+
119
+
120
+ def _content_is_image(content: Any) -> bool:
121
+ media_type = content.media_type
122
+ return isinstance(media_type, str) and media_type.startswith("image/")
123
+
124
+
125
+ def _message_has_image_content(message: Message) -> bool:
126
+ return any(_content_is_image(content) for content in message.contents)
127
+
128
+
129
+ def _contents_have_image_content(contents: list[Any]) -> bool:
130
+ return any(not isinstance(content, str) and _content_is_image(content) for content in contents)
131
+
132
+
133
+ # Trim's pairing row: actionable local function calls only (hosted
134
+ # informational calls have no function_result by design), function results
135
+ # only, id-less calls answered solely by id-less PREVALIDATION failures
136
+ # (complete-or-delete fails closed), empty-id calls paired positionally,
137
+ # and malformed non-string ids normalized by their string form.
138
+ _TRIM_PAIRING_POLICY = PairingPolicy(
139
+ call_types=frozenset({"function_call"}),
140
+ include_informational_calls=False,
141
+ result_types=frozenset({"function_result"}),
142
+ none_id=NoneIdPolicy.POSITIONAL_FAILURES,
143
+ empty_id=EmptyIdPolicy.POSITIONAL,
144
+ malformed_id="stringify",
145
+ )
146
+
147
+ # Approval's pairing row: local actionable calls only; id-less calls and
148
+ # results are UNPAIRABLE occurrences — visible to eligibility and the
149
+ # block-wide falsy failure boolean without consuming pair slots; malformed
150
+ # ids compare by string form at the pairing layer while every
151
+ # decision-matching stage keeps its raw reads.
152
+ _APPROVAL_PAIRING_POLICY = PairingPolicy(
153
+ call_types=frozenset({"function_call"}),
154
+ include_informational_calls=False,
155
+ result_types=frozenset({"function_result"}),
156
+ none_id=NoneIdPolicy.UNPAIRABLE_OCCURRENCE,
157
+ empty_id=EmptyIdPolicy.UNPAIRABLE_OCCURRENCE,
158
+ malformed_id="stringify",
159
+ )
160
+
161
+
162
+ def _approval_exclusions(
163
+ messages: list[Message], exchange: Exchange, accessor: LiveAccessor
164
+ ) -> tuple[set[PairingKey], bool]:
165
+ """One exchange's never-approvable evidence, from the eligible view.
166
+
167
+ Returns the truthy keys answered by an ELIGIBLE prevalidation-failure
168
+ result, plus the block-wide falsy boolean: whether any eligible id-less
169
+ failure occurrence exists in either falsy stream (stream-blind — an
170
+ unpairable failure can only answer SOME id-less call of the block, so
171
+ every id-less call fails closed).
172
+ """
173
+ pairing = pair_results(messages, exchange, accessor, _APPROVAL_PAIRING_POLICY)
174
+ excluded_keys = {
175
+ key
176
+ for key, occurrences in pairing.eligible_results.truthy.items()
177
+ if any(occurrence.error_kind in PREVALIDATION_ERROR_KINDS for occurrence in occurrences)
178
+ }
179
+ falsy_failure = any(
180
+ occurrence.error_kind in PREVALIDATION_ERROR_KINDS
181
+ for stream in pairing.eligible_results.falsy.values()
182
+ for occurrence in stream
183
+ )
184
+ return excluded_keys, falsy_failure
185
+
186
+
187
+ # Sub-agent repair's pairing row: local function calls and results only.
188
+ # None-id calls are IGNORED (sub-agent calls carry minted persisted ids, so
189
+ # an id-less call is not repair's to answer); empty-id calls pair
190
+ # positionally against empty-id results; malformed ids pair by string form
191
+ # while record lookups and synthetic results keep the original value.
192
+ _REPAIR_PAIRING_POLICY = PairingPolicy(
193
+ call_types=frozenset({"function_call"}),
194
+ include_informational_calls=False,
195
+ result_types=frozenset({"function_result"}),
196
+ none_id=NoneIdPolicy.IGNORE,
197
+ empty_id=EmptyIdPolicy.POSITIONAL,
198
+ malformed_id="stringify",
199
+ )
200
+
201
+
202
+ def _repair_call_entries(pairing: ExchangePairing) -> list[tuple[Occurrence, object, bool]]:
203
+ """One exchange's repairable call occurrences in transcript order.
204
+
205
+ Returns ``(occurrence, truthy pairing key or None, answered)`` per call.
206
+ Truthy keys are answered at KEY level (``answered_keys``): duplicate ids
207
+ inside one response are shadowed by dispatch and one result answers
208
+ every occurrence, so sibling duplicates are one dangling invocation.
209
+ Empty-id calls are answered per POSITIONAL slot — each unanswered
210
+ occurrence is its own dangling invocation.
211
+ """
212
+ answered_keys = pairing.answered_keys
213
+ entries: list[tuple[Occurrence, object, bool]] = []
214
+ for key, assignments in pairing.truthy_assignments.items():
215
+ for call, _result in assignments:
216
+ entries.append((call, key, key in answered_keys))
217
+ for call, result in pairing.falsy_assignments["empty"]:
218
+ entries.append((call, None, result is not None))
219
+ entries.sort(key=lambda entry: (entry[0].message_index, entry[0].content_index))
220
+ return entries
221
+
222
+
223
+ def _trim_exchange_siblings(messages: list[Message], exchange: Exchange, pairing: ExchangePairing) -> bool:
224
+ """Trim one exchange's unanswered calls, sibling by sibling.
225
+
226
+ Every response sibling is checked against the exchange-wide pairing
227
+ (the shared output block and embedded results answer any sibling's
228
+ calls). Per sibling, the deletion branches mirror what trim has always
229
+ applied to a single message: all answered → keep; partially answered →
230
+ drop only the unanswered call contents; nothing answered → pop the
231
+ whole message, unless a hosted informational call shields it into the
232
+ surgical branch.
233
+
234
+ Truthy ids are answered at KEY level: duplicate ids are shadowed by
235
+ dispatch and one result answers every occurrence, so no duplicate of
236
+ an answered id is ever trimmed. Falsy ids stay occurrence-level —
237
+ each unanswered positional slot is its own dangling invocation.
238
+
239
+ Returns True when any call-carrying sibling survives (complete,
240
+ surgically trimmed, or shielded) — trim stops there; False when every
241
+ call-carrying sibling was popped, letting the caller continue walking
242
+ into earlier history exactly as the message-by-message walk did. A
243
+ run head with no in-policy call occurrences (informational-only,
244
+ out-of-scope call types, or plain text) also survives: the walk has
245
+ always stopped at an assistant run it has no actionable calls in.
246
+ """
247
+ matched_by_sibling: dict[int, list[Occurrence]] = {}
248
+ unmatched_by_sibling: dict[int, list[Occurrence]] = {}
249
+ answered_keys = pairing.answered_keys
250
+ for key, assignments in pairing.truthy_assignments.items():
251
+ bucket = matched_by_sibling if key in answered_keys else unmatched_by_sibling
252
+ for call, _result in assignments:
253
+ bucket.setdefault(call.message_index, []).append(call)
254
+ for assignments in pairing.falsy_assignments.values():
255
+ for call, result in assignments:
256
+ bucket = matched_by_sibling if result is not None else unmatched_by_sibling
257
+ bucket.setdefault(call.message_index, []).append(call)
258
+
259
+ head_index = exchange.response_indices[0]
260
+ survived = head_index not in matched_by_sibling and head_index not in unmatched_by_sibling
261
+ for sibling_index in reversed(exchange.response_indices):
262
+ matched = matched_by_sibling.get(sibling_index, [])
263
+ unmatched = unmatched_by_sibling.get(sibling_index, [])
264
+ if not unmatched:
265
+ if matched:
266
+ survived = True
267
+ continue
268
+ message = messages[sibling_index]
269
+ informational = any(c.type == "function_call" and c.informational_only for c in message.contents)
270
+ if matched or informational:
271
+ dropped = {occurrence.content_index for occurrence in unmatched}
272
+ message.contents = [c for index, c in enumerate(message.contents) if index not in dropped]
273
+ _sync_message_approval_from_contents(message)
274
+ survived = True
275
+ continue
276
+ messages.pop(sibling_index)
277
+ return survived
278
+
279
+
280
+ def _effective_metadata_start_index(messages: list[Message], start_index: int | None) -> int:
281
+ """Clamp a post-run metadata start index to the current logical turn."""
282
+ if start_index is None or start_index < 0:
283
+ start_index = 0
284
+ elif start_index > len(messages):
285
+ start_index = len(messages)
286
+
287
+ # Tools like compress_context can remove earlier messages before
288
+ # post-run metadata is persisted. The previous turn marker is the
289
+ # stable logical boundary for a normal run, so use it as a floor when
290
+ # it lands before the numeric index.
291
+ if start_index > 0:
292
+ turn_start = current_turn_start(messages)
293
+ if 0 < turn_start < start_index:
294
+ start_index = turn_start
295
+ return start_index
296
+
297
+
298
+ def stamp_history_item_ids(state: dict[str, Any]) -> None:
299
+ """Give restored history occurrence identities before it becomes live."""
300
+ messages = state.get("messages")
301
+ if isinstance(messages, list) and messages:
302
+ ensure_history_item_ids(messages)
303
+
304
+
305
+ class SessionHistoryManager:
306
+ """Owns all mutations to the ``chrys_history`` session state dict.
307
+
308
+ Usage::
309
+
310
+ history = SessionHistoryManager()
311
+ stamp_history_item_ids(executor.history_state)
312
+ history.bind(executor.history_state)
313
+ history.insert_turn_marker()
314
+ """
315
+
316
+ def __init__(self) -> None:
317
+ self._state: dict[str, Any] | None = None
318
+
319
+ @property
320
+ def is_bound(self) -> bool:
321
+ """Whether this manager has been bound to a state dict."""
322
+ return self._state is not None
323
+
324
+ def bind(self, state: dict[str, Any]) -> None:
325
+ """Bind to a live ``chrys_history`` state dict.
326
+
327
+ Called after agent construction. The manager mutates this dict
328
+ directly — CompressibleHistoryProvider sees the changes immediately.
329
+
330
+ The caller stamps restored history before binding it. Binding itself
331
+ only replaces the live state reference.
332
+ """
333
+ self._state = state
334
+
335
+ @property
336
+ def state(self) -> dict[str, Any]:
337
+ """The full ``chrys_history`` state dict.
338
+
339
+ Raises ``RuntimeError`` if not yet bound.
340
+ """
341
+ if self._state is None:
342
+ raise RuntimeError("SessionHistoryManager not bound — call bind() first")
343
+ return self._state
344
+
345
+ @property
346
+ def messages(self) -> list:
347
+ """The active message list (may be empty)."""
348
+ return self.state.get("messages", [])
349
+
350
+ def get_deep_copy(self) -> dict[str, Any] | None:
351
+ """Return a deep copy of the current state, or ``None`` if empty."""
352
+ if self._state is None:
353
+ return None
354
+ raw = self._state
355
+ return copy.deepcopy(raw) if raw else None
356
+
357
+ # ------------------------------------------------------------------
358
+ # Marker operations
359
+ # ------------------------------------------------------------------
360
+
361
+ def insert_turn_marker(self) -> None:
362
+ """Insert a turn marker at the end of history."""
363
+ state = self._state
364
+ if state is None or not state.get("messages"):
365
+ return
366
+ from chrys.service.context.providers.history import CompressibleHistoryProvider
367
+
368
+ turn_counter = state.get("turn_counter", 0) + 1
369
+ CompressibleHistoryProvider.insert_marker(state, turn_counter)
370
+
371
+ def insert_interrupted_marker(
372
+ self,
373
+ reason: str | None = None,
374
+ source: str = "user",
375
+ status_code: str | None = None,
376
+ ) -> None:
377
+ """Insert an interruption marker so it replays on session restore."""
378
+ if reason is None:
379
+ reason = format_message(EXECUTION_INTERRUPTED_MESSAGE.bind())
380
+ status_code = HistoryMarkerKind.STATUS_EXECUTION_INTERRUPTED
381
+ state = self._state
382
+ if state is None:
383
+ return
384
+ messages = state.get("messages")
385
+ if not isinstance(messages, list):
386
+ return
387
+ marker = Message("assistant", [reason])
388
+ marker.additional_properties[HistoryMarkerKind.KEY] = HistoryMarkerKind.INTERRUPTED
389
+ marker.additional_properties["_interrupted_by"] = source
390
+ ensure_analytics_item_id(marker.additional_properties)
391
+ if status_code is not None:
392
+ marker.additional_properties[HistoryMarkerKind.STATUS_CODE_KEY] = status_code
393
+ insert_at = len(messages)
394
+ while insert_at > 0:
395
+ kind = messages[insert_at - 1].additional_properties.get(HistoryMarkerKind.KEY)
396
+ if kind != HistoryMarkerKind.TURN:
397
+ break
398
+ insert_at -= 1
399
+ messages.insert(insert_at, marker)
400
+
401
+ def trailing_status_marker(self) -> tuple[str, str] | None:
402
+ """Return the trailing status marker ``(kind, source)`` if present.
403
+
404
+ Used to detect restored sessions that ended with a failure,
405
+ where the FSM has been reset to IDLE but the history still
406
+ carries the error state. ``awaiting_sub_agents`` counts as an
407
+ error marker too: a session that reloaded while sub-agents were
408
+ paused should be treated the same as an interrupted run for the
409
+ purposes of the user's next message.
410
+
411
+ Structural turn markers at the tail are skipped. ``source`` is the
412
+ marker's ``_interrupted_by`` value when present; non-interrupted
413
+ status markers return an empty source.
414
+ """
415
+ state = self._state
416
+ if state is None:
417
+ return None
418
+ messages = state.get("messages")
419
+ if not isinstance(messages, list) or not messages:
420
+ return None
421
+ # SessionHistoryManager is the sole writer and stores kernel Messages.
422
+ typed_messages = cast("list[Message]", messages)
423
+ for m in reversed(typed_messages):
424
+ chrys_kind = m.additional_properties.get(HistoryMarkerKind.KEY)
425
+ if chrys_kind == HistoryMarkerKind.TURN:
426
+ continue
427
+ if chrys_kind not in HistoryMarkerKind.STATUS_MARKERS:
428
+ return None
429
+ source = m.additional_properties.get("_interrupted_by", "")
430
+ return chrys_kind, source if isinstance(source, str) else ""
431
+ return None
432
+
433
+ def has_trailing_error_markers(self) -> bool:
434
+ """Check if history ends with error/interrupted markers."""
435
+ return self.trailing_status_marker() is not None
436
+
437
+ def remove_trailing_markers(self) -> None:
438
+ """Remove interrupted/error/awaiting markers and turn markers from end of history.
439
+
440
+ Every trailing ``turn`` marker popped also rolls
441
+ ``state["turn_counter"]`` back by one. Without that, a
442
+ subsequent :meth:`insert_turn_marker` (which computes the next
443
+ index as ``turn_counter + 1``) would advance past the slot we
444
+ just freed, desynchronising ``turn_counter`` from the number of
445
+ markers actually present in history. That desync surfaces most
446
+ visibly in the retry flow: ``_on_user_retry`` calls this method
447
+ to drop the failed turn's marker before ``_post_run`` re-inserts
448
+ one on successful resume, and before the fix the restored-session
449
+ counter would read ``N + 1`` for an ``N``-turn conversation
450
+ (breaking the rollback picker's "Turn N (Current)" sentinel).
451
+ Turn markers are inserted sequentially, so decrementing per
452
+ removed marker keeps ``turn_counter`` aligned with ``max(_turn)``
453
+ of the surviving markers.
454
+ """
455
+ state = self._state
456
+ if state is None:
457
+ return
458
+ messages = state.get("messages")
459
+ if not isinstance(messages, list):
460
+ return
461
+ # SessionHistoryManager is the sole writer and stores kernel Messages.
462
+ typed_messages = cast("list[Message]", messages)
463
+ while typed_messages:
464
+ chrys_kind = typed_messages[-1].additional_properties.get(HistoryMarkerKind.KEY)
465
+ if chrys_kind in HistoryMarkerKind.SESSION_COUNT_EXCLUDED:
466
+ if chrys_kind == HistoryMarkerKind.TURN:
467
+ state["turn_counter"] = max(0, state.get("turn_counter", 0) - 1)
468
+ typed_messages.pop()
469
+ else:
470
+ break
471
+
472
+ # ------------------------------------------------------------------
473
+ # Sub-agent pause marker
474
+ # ------------------------------------------------------------------
475
+
476
+ def upsert_awaiting_sub_agents_marker(self, invocation_ids: list[str]) -> None:
477
+ """Ensure history carries an ``awaiting_sub_agents`` marker listing ``invocation_ids``.
478
+
479
+ Idempotent — if a marker already exists (as the last non-turn
480
+ message), its ``_invocation_ids`` list is updated in place.
481
+ Otherwise a fresh marker is appended. Empty ``invocation_ids``
482
+ removes any existing marker (use :meth:`remove_awaiting_sub_agents_marker`
483
+ for that explicitly — this is defensive).
484
+ """
485
+ state = self._state
486
+ if state is None:
487
+ return
488
+ messages = state.get("messages")
489
+ if not isinstance(messages, list):
490
+ return
491
+ # SessionHistoryManager is the sole writer and stores kernel Messages.
492
+ typed_messages = cast("list[Message]", messages)
493
+
494
+ if not invocation_ids:
495
+ self.remove_awaiting_sub_agents_marker()
496
+ return
497
+
498
+ # Find an existing marker to update in place.
499
+ for m in reversed(typed_messages):
500
+ kind = m.additional_properties.get(HistoryMarkerKind.KEY)
501
+ if kind == HistoryMarkerKind.TURN:
502
+ continue
503
+ if kind == HistoryMarkerKind.AWAITING_SUB_AGENTS:
504
+ m.additional_properties["_invocation_ids"] = list(invocation_ids)
505
+ m.additional_properties[HistoryMarkerKind.STATUS_CODE_KEY] = (
506
+ HistoryMarkerKind.STATUS_AWAITING_SUB_AGENTS
507
+ )
508
+ return
509
+ break
510
+
511
+ marker = Message(
512
+ "assistant",
513
+ [format_message(AWAITING_SUB_AGENTS_MESSAGE.bind(count=len(invocation_ids)))],
514
+ )
515
+ marker.additional_properties[HistoryMarkerKind.KEY] = HistoryMarkerKind.AWAITING_SUB_AGENTS
516
+ marker.additional_properties[HistoryMarkerKind.STATUS_CODE_KEY] = HistoryMarkerKind.STATUS_AWAITING_SUB_AGENTS
517
+ marker.additional_properties["_invocation_ids"] = list(invocation_ids)
518
+ ensure_analytics_item_id(marker.additional_properties)
519
+ messages.append(marker)
520
+
521
+ def remove_awaiting_sub_agents_marker(self) -> None:
522
+ """Strip every ``awaiting_sub_agents`` marker from history.
523
+
524
+ Called when the paused-invocation set empties (last sub-agent
525
+ resolved) or when the parent run transitions past AWAITING_SUB_AGENTS
526
+ into IDLE/INTERRUPTED/FAILED (the marker is superseded by those
527
+ terminal markers).
528
+ """
529
+ state = self._state
530
+ if state is None:
531
+ return
532
+ messages = state.get("messages")
533
+ if not isinstance(messages, list):
534
+ return
535
+ state["messages"] = [
536
+ m
537
+ for m in messages
538
+ if m.additional_properties.get(HistoryMarkerKind.KEY) != HistoryMarkerKind.AWAITING_SUB_AGENTS
539
+ ]
540
+
541
+ def remove_orphaned_user_message(self) -> None:
542
+ """Remove a trailing user message that received no model response.
543
+
544
+ After ``remove_trailing_markers()`` strips error/turn markers, the
545
+ last message may be a user message with zero assistant or tool
546
+ responses after it. This means the model produced nothing before
547
+ failing (e.g. 401 auth error). Removing it keeps the persisted
548
+ history consistent with what the user saw during the live session
549
+ — the failed message disappeared when they typed a new one.
550
+
551
+ If the model DID produce output (tool calls, partial response)
552
+ before failing, the user message and model output are preserved.
553
+ """
554
+ state = self._state
555
+ if state is None:
556
+ return
557
+ messages = state.get("messages")
558
+ if not isinstance(messages, list) or not messages:
559
+ return
560
+ if messages[-1].role == "user":
561
+ logger.debug("remove_orphaned_user_message: removing '%s'", (messages[-1].text or "")[:60])
562
+ messages.pop()
563
+
564
+ def remove_all_status_markers(self) -> None:
565
+ """Remove ALL interrupted/error/awaiting-sub-agents markers from history.
566
+
567
+ Covers every ephemeral status marker class. ``turn`` markers are
568
+ structural (they drive compression boundaries) and stay put.
569
+ """
570
+ state = self._state
571
+ if state is None:
572
+ return
573
+ messages = state.get("messages")
574
+ if not isinstance(messages, list):
575
+ return
576
+ state["messages"] = [
577
+ m
578
+ for m in messages
579
+ if m.additional_properties.get(HistoryMarkerKind.KEY) not in HistoryMarkerKind.STATUS_MARKERS
580
+ ]
581
+
582
+ def remove_trailing_agent_text(self) -> None:
583
+ """Remove trailing text-only assistant message on interrupt.
584
+
585
+ When the user interrupts during the LLM's final response (no tool
586
+ calls), the response may have already been stored by ``after_run``
587
+ before the interrupt flag is checked. This detects and removes
588
+ that leaked response.
589
+ """
590
+ state = self._state
591
+ if state is None:
592
+ return
593
+ messages = state.get("messages")
594
+ if not isinstance(messages, list) or not messages:
595
+ return
596
+
597
+ last = messages[-1]
598
+ if last.role != "assistant":
599
+ return
600
+
601
+ if last.additional_properties.get(HistoryMarkerKind.KEY):
602
+ return
603
+
604
+ has_function_call = any(c.type == "function_call" for c in last.contents)
605
+ if has_function_call:
606
+ return
607
+
608
+ logger.debug("remove_trailing_agent_text: removing leaked final response")
609
+ messages.pop()
610
+
611
+ # ------------------------------------------------------------------
612
+ # Message repair
613
+ # ------------------------------------------------------------------
614
+
615
+ def ensure_user_message(
616
+ self,
617
+ text: str,
618
+ created_at: datetime | str | None = None,
619
+ contents: list[Any] | None = None,
620
+ *,
621
+ kind: UserMessageKind = "opener",
622
+ item_id: str | None = None,
623
+ reminder_source: Mapping[str, Any] | None = None,
624
+ ) -> None:
625
+ """Ensure the user message is present in session state.
626
+
627
+ When Agent.run errors before HistoryProvider.after_run fires, the user
628
+ message is never stored. This adds it so ``resume()`` can find it.
629
+
630
+ With ``SystemReminderMiddleware``, session state messages are always
631
+ clean (no ``<system-reminder>`` tags), so plain text comparison is
632
+ sufficient for dedup. When the caller supplies multimodal contents,
633
+ preserve them so retry/follow-up validation can still tell that the
634
+ turn included image bytes.
635
+
636
+ Dedup is scoped to the **current turn region** — messages after
637
+ the last ``_chrys_kind='turn'`` marker. Without scoping, a
638
+ same-text user message from an earlier turn (or an injection
639
+ anchored into one) would make the fallback skip the append and
640
+ lose this turn's user message on interrupt-before-persist.
641
+ Real-world repro: user typed "what time is it?" as an injection
642
+ during turn 1, later typed the same text again as a fresh
643
+ turn-3 user message and interrupted within 1 s → framework
644
+ hadn't stored it yet → fallback saw the earlier injection →
645
+ no append → saved state had interrupted + turn markers for
646
+ turn 3 but no user message, so reload dropped the prompt.
647
+ See ``test_ensure_user_message_appends_when_text_matches_earlier_turn``.
648
+
649
+ Dedup is also **kind-aware**: a candidate matches only when text AND
650
+ *kind* agree (``user_text_matches``) — retry guidance (``"injected"``)
651
+ worded identically to the turn opener must append a second, flagged
652
+ message rather than silently dedup against the opener (and vice
653
+ versa). The matched message's flags are never backfilled: stamping
654
+ the real opener ``_injected`` would stop it opening the turn.
655
+ Recovery never synthesizes a ``"continuation"`` kind — bare-resume
656
+ nudge branches clear the recovery input instead.
657
+
658
+ *reminder_source* is the properties of the copy the request sent: an
659
+ appended message carries its reminder record, so later requests
660
+ re-render the reminders it went out with. A matched message keeps
661
+ its own record.
662
+ """
663
+ state = self._state
664
+ if state is None:
665
+ return
666
+ messages = state.setdefault("messages", [])
667
+ start = current_turn_start(messages)
668
+ for m in messages[start:]:
669
+ if user_text_matches(m, text, kind=kind):
670
+ stamp_message_created_at(m, created_at)
671
+ ensure_analytics_item_id(m.additional_properties, item_id=item_id)
672
+ if (
673
+ contents is not None
674
+ and _contents_have_image_content(contents)
675
+ and not _message_has_image_content(m)
676
+ ):
677
+ m.contents = Message("user", contents).contents
678
+ return
679
+ msg = Message("user", contents if contents is not None else [text])
680
+ if kind == "injected":
681
+ msg.additional_properties[HistoryMarkerKind.INJECTED_KEY] = True
682
+ stamp_message_created_at(msg, created_at)
683
+ ensure_analytics_item_id(msg.additional_properties, item_id=item_id)
684
+ copy_reminder_record(reminder_source, msg.additional_properties)
685
+ messages.append(msg)
686
+
687
+ def tag_last_user_message(self, key: str, value: Any) -> None:
688
+ """Set an ``additional_properties`` key on the last user message.
689
+
690
+ Deliberately the LITERAL last user message, whatever its kind
691
+ (§2.3): with mid-turn injections that is the last injection — the
692
+ message actually on the wire. Flagged legacy ``continue`` nudges
693
+ are the one exception: the provider filters them off the wire and
694
+ replay hides them before reading metadata, so a tag written there
695
+ would vanish from every consumer.
696
+ """
697
+ if self._state is None:
698
+ return
699
+ for m in reversed(self._state.get("messages", [])):
700
+ if m.role == "user" and not is_continuation_message(m):
701
+ m.additional_properties[key] = value
702
+ return
703
+
704
+ def remove_continuation_message(self) -> None:
705
+ """Remove the synthetic ``continue`` nudge(s) injected by ``resume()``.
706
+
707
+ Two passes over the current turn region (messages after the last
708
+ ``_chrys_kind='turn'`` marker):
709
+
710
+ 1. Remove **all** ``_continuation``-flagged user messages, with no
711
+ ``seen_work`` precondition. The flag is authoritative — every
712
+ flagged message is a synthetic placeholder by construction — and
713
+ first-only would strand a crash-leftover nudge (a pass that
714
+ persisted its nudge but died before cleanup). This also covers
715
+ the no-prior-user-message synthesis, where nothing precedes the
716
+ nudge in its region.
717
+ 2. Legacy fallback, run **unconditionally** after pass 1: the
718
+ ``text == "continue"`` + ``seen_work`` match for pre-flag
719
+ histories, skipping ``_injected``-flagged messages — a user-typed
720
+ literal ``continue`` follow-up must never be popped in place of a
721
+ synthetic nudge. Gating pass 2 on "pass 1 removed nothing" would
722
+ strand the legacy nudge of a MIXED region (pre-upgrade unflagged
723
+ leftover + new flagged nudge). Always running is safe:
724
+ ``seen_work`` requires work *before* the match, so an opener
725
+ whose text is literally ``continue`` is untouchable.
726
+
727
+ Scoping matters: a user may legitimately type ``"continue"``
728
+ as a nudge mid-turn in an earlier turn (e.g. "explain X" →
729
+ partial response → user types "continue" → assistant
730
+ finishes). A global forward scan would trip ``seen_work`` on
731
+ that earlier turn's assistant message and pop the earlier
732
+ nudge — losing it and leaving the current turn's injected
733
+ ``"continue"`` in history forever. See
734
+ ``test_remove_continuation_message_scopes_to_current_turn``.
735
+ """
736
+ state = self._state
737
+ if state is None:
738
+ return
739
+ messages = state.get("messages")
740
+ if not isinstance(messages, list):
741
+ return
742
+ start = current_turn_start(messages)
743
+ for i in range(len(messages) - 1, start - 1, -1):
744
+ if is_continuation_message(messages[i]):
745
+ messages.pop(i)
746
+ seen_work = False
747
+ for i in range(start, len(messages)):
748
+ msg = messages[i]
749
+ if msg.role in ("assistant", "tool"):
750
+ seen_work = True
751
+ elif (
752
+ msg.role == "user"
753
+ and seen_work
754
+ and (msg.text or "").strip() == "continue"
755
+ and not msg.additional_properties.get(HistoryMarkerKind.INJECTED_KEY)
756
+ ):
757
+ messages.pop(i)
758
+ return
759
+
760
+ def merge_loop_messages(self, loop_recorder: MergeableLoopRecorder, *, insert_index: int | None = None) -> None:
761
+ """Recover completed tool loop iterations into session state.
762
+
763
+ When the tool loop terminates early (interrupt or error),
764
+ ``after_run`` only stores the last iteration's messages.
765
+ ``LoopRecorder`` snapshots the accumulated messages before each
766
+ LLM call. This method inserts them at the correct position.
767
+
768
+ ``insert_index`` is the caller's pass-start boundary (where this
769
+ run's output chronologically belongs). It beats the last-user-message
770
+ anchor when retained work from an earlier same-turn pass follows that
771
+ anchor: after an empty retry the synthetic continuation was already
772
+ removed, so anchoring to the original user message alone would insert
773
+ this pass BEFORE the earlier pass's retained messages, inverting the
774
+ transcript. The anchor still wins when it sits later (e.g. a mid-run
775
+ injection user message persisted after the boundary).
776
+ """
777
+ loop_msgs = loop_recorder.loop_messages
778
+ if not loop_msgs:
779
+ logger.debug(
780
+ "merge_loop_messages: no loop messages (initial_count=%s, captured=%s)",
781
+ loop_recorder.initial_count,
782
+ loop_recorder.captured_count,
783
+ )
784
+ return
785
+
786
+ state = self._state
787
+ if state is None:
788
+ return
789
+ messages = state.get("messages")
790
+ if not isinstance(messages, list) or not messages:
791
+ return
792
+
793
+ logger.debug(
794
+ "merge_loop_messages: %d loop messages to merge, %d existing messages",
795
+ len(loop_msgs),
796
+ len(messages),
797
+ )
798
+
799
+ # A recovery checkpoint may already have replayed a clean injection
800
+ # that LoopRecorder also captured for the next tool request. Drop only
801
+ # exact consumption-id duplicates; same-text distinct injections stay.
802
+ current_region = messages[current_turn_start(messages) :]
803
+ persisted_injection_ids = {
804
+ injection_id
805
+ for message in current_region
806
+ if isinstance(
807
+ injection_id := message.additional_properties.get(HistoryMarkerKind.INJECTION_ID_KEY),
808
+ str,
809
+ )
810
+ }
811
+ if persisted_injection_ids:
812
+ loop_msgs = [
813
+ message
814
+ for message in loop_msgs
815
+ if message.additional_properties.get(HistoryMarkerKind.INJECTION_ID_KEY) not in persisted_injection_ids
816
+ ]
817
+ if not loop_msgs:
818
+ return
819
+
820
+ # Check if loop messages are already present (normal completion path).
821
+ first_loop = loop_msgs[0]
822
+ for m in messages:
823
+ if m is first_loop:
824
+ logger.debug("merge_loop_messages: already present (identity match), skipping")
825
+ return
826
+
827
+ # Find the last user message — loop messages go right after it.
828
+ user_idx = -1
829
+ for i in range(len(messages) - 1, -1, -1):
830
+ if messages[i].role == "user":
831
+ user_idx = i
832
+ break
833
+
834
+ if user_idx < 0:
835
+ logger.debug("merge_loop_messages: no user message found")
836
+ return
837
+
838
+ insert_at = user_idx + 1
839
+ if insert_index is not None:
840
+ insert_at = max(insert_at, min(insert_index, len(messages)))
841
+ for j, lm in enumerate(loop_msgs):
842
+ messages.insert(insert_at + j, lm)
843
+ logger.debug(
844
+ "merge_loop_messages: inserted %d messages at index %d, total now %d",
845
+ len(loop_msgs),
846
+ insert_at,
847
+ len(messages),
848
+ )
849
+
850
+ def inject_error_results_for_sub_agents(
851
+ self,
852
+ records: list[dict[str, Any]],
853
+ sub_agent_tool_names: set[str],
854
+ ) -> int:
855
+ """Insert synthetic error tool-results for dangling sub-agent function_calls.
856
+
857
+ ``records`` is a list of serialized paused-sub-agent dicts
858
+ (as produced by :meth:`SubAgentSessionArtifactService.drain_paused_records`) and
859
+ ``sub_agent_tool_names`` is the current registry of sub-agent
860
+ tool names. For each assistant message with unmatched
861
+ sub-agent function_calls (no corresponding ``function_result``),
862
+ we append a tool message carrying an ``Error:`` result.
863
+
864
+ **Pairing policy** (two-pass):
865
+
866
+ 1. Prefer direct pairing by ``parent_call_id`` — records that
867
+ carry it identify the exact framework call_id of the
868
+ assistant function_call that invoked the sub-agent, so we
869
+ can reattach each record to its own call even when the same
870
+ sub-agent tool was invoked concurrently.
871
+ 2. Fall back to tool_name + appearance order for records
872
+ without a usable ``parent_call_id`` (older saved sessions
873
+ or edge cases where the field is empty).
874
+
875
+ Dangling calls that have no matching record (e.g. profile
876
+ removed the sub-agent) get a generic error so history stays
877
+ well-formed. Returns the number of error results injected.
878
+ """
879
+ state = self._state
880
+ if state is None:
881
+ return 0
882
+ messages = state.get("messages")
883
+ if not isinstance(messages, list) or not messages:
884
+ return 0
885
+ record_tool_names: set[str] = set()
886
+ for rec in records:
887
+ name = rec.get("tool_name")
888
+ invocation_id = rec.get("invocation_id")
889
+ if isinstance(name, str) and name and isinstance(invocation_id, str) and invocation_id:
890
+ record_tool_names.add(name)
891
+ candidate_tool_names = set(sub_agent_tool_names) | record_tool_names
892
+
893
+ def _is_sub_agent_call(content: Any) -> bool:
894
+ # Match by current/record tool_name OR by the call's OWN persisted
895
+ # kind marker. A profile change can drop the tool from the registry
896
+ # AND drain can lose every record (over-cap/corrupt/missing), yet
897
+ # the persisted function_call still self-identifies as a sub-agent
898
+ # via ``_chrys_tool_kind``. Repair it regardless, or the parent
899
+ # assistant call is left dangling and the next provider request
900
+ # rejects the history. This is why there is no early return on an
901
+ # empty ``candidate_tool_names`` — kind-marked calls still qualify.
902
+ if content.name in candidate_tool_names:
903
+ return True
904
+ return persisted_tool_call_kind(content.additional_properties) == KIND_SUB_AGENT
905
+
906
+ # Import locally — ``Content`` is only needed here and keeping
907
+ # it out of the module-level import surface mirrors how other
908
+ # methods pull in framework-internal content helpers.
909
+ # Two pools so pass-1 (by parent_call_id) and pass-2 (by
910
+ # tool_name appearance order) don't trip each other. A record
911
+ # with a usable parent_call_id goes into ``by_call_id`` only;
912
+ # records without it fall through to the ordered pool.
913
+ from collections import defaultdict, deque
914
+
915
+ from chrys.kernel import Content
916
+
917
+ by_call_id: dict[str, list[dict[str, Any]]] = defaultdict(list)
918
+ name_pool: dict[str, deque[dict[str, Any]]] = defaultdict(deque)
919
+ for rec in records:
920
+ parent_call_id = rec.get("parent_provider_call_id") or rec.get("parent_call_id") or ""
921
+ if parent_call_id:
922
+ by_call_id[parent_call_id].append(rec)
923
+ else:
924
+ name_pool[rec.get("tool_name", "")].append(rec)
925
+
926
+ # Materialize exchange boundaries and pairings once; injections are
927
+ # queued and inserted after the scan, so coordinates stay valid.
928
+ accessor = LiveAccessor()
929
+ exchange_list = list(iter_exchanges(messages, accessor))
930
+ entries_per_exchange = [
931
+ _repair_call_entries(pair_results(messages, exchange, accessor, _REPAIR_PAIRING_POLICY))
932
+ for exchange in exchange_list
933
+ ]
934
+
935
+ # Count distinct unresolved exchanges per ORIGINAL call id, not raw
936
+ # call occurrences (sibling duplicates in one exchange are one
937
+ # dangling invocation). Reuse across separate exchanges remains
938
+ # ambiguous and must continue to fail closed. Unhashable originals
939
+ # are skipped — they can never match a record-keyed direct lookup.
940
+ dangling_exchanges: dict[Any, set[int]] = defaultdict(set)
941
+ for position, entries in enumerate(entries_per_exchange):
942
+ for call, key, answered in entries:
943
+ if answered or key is None:
944
+ continue
945
+ content = messages[call.message_index].contents[call.content_index]
946
+ if not _is_sub_agent_call(content) or not isinstance(content.call_id, Hashable):
947
+ continue
948
+ dangling_exchanges[content.call_id].add(position)
949
+ dangling_call_counts = {call_id: len(positions) for call_id, positions in dangling_exchanges.items()}
950
+
951
+ def _format_err(name: str, record: dict[str, Any] | None) -> str:
952
+ if record is None:
953
+ msg = f"Error: sub-agent '{name}' was paused when the session was saved; state discarded on reload."
954
+ else:
955
+ last_error = record.get("last_error") or "unknown"
956
+ msg = (
957
+ f"Error: sub-agent '{name}' was paused when the session was saved; "
958
+ f"state discarded on reload — {last_error}"
959
+ )
960
+ # This string is injected into provider-visible repaired history; a restored
961
+ # pending record can carry a lone surrogate (round-tripped from an owner-valid
962
+ # audit) that the LLM request serializer's strict encode would crash on. The
963
+ # file sink cannot cover a network encode, so neutralize at the interpolation.
964
+ return surrogate_safe_text(msg)
965
+
966
+ injections: list[tuple[int, Message]] = [] # (insert_before_index, msg)
967
+ injected = 0
968
+ for position, exchange in enumerate(exchange_list):
969
+ # Every synthetic result lands at the exchange's shared output
970
+ # boundary, after the whole consecutive assistant run. Truthy
971
+ # keys schedule at most once per exchange: queued injections do
972
+ # not enter ``messages`` until after this scan, and sibling
973
+ # duplicates of one id are a single dangling invocation.
974
+ insert_at = exchange.output_indices[0] if exchange.output_indices else exchange.response_indices[-1] + 1
975
+ scheduled_keys: set[object] = set()
976
+ for call, key, answered in entries_per_exchange[position]:
977
+ if answered or (key is not None and key in scheduled_keys):
978
+ continue
979
+ content = messages[call.message_index].contents[call.content_index]
980
+ if not _is_sub_agent_call(content):
981
+ continue
982
+ original_id = content.call_id
983
+
984
+ # Pass 1 — direct pair by parent_call_id, always through the
985
+ # ORIGINAL persisted id. An unhashable original is an
986
+ # unmatched lookup (never a normalized substitution) and
987
+ # falls through to the name pool exactly as an unknown id.
988
+ record = None
989
+ direct_ambiguous = False
990
+ direct_records = by_call_id.get(original_id) if isinstance(original_id, Hashable) else None
991
+ if direct_records:
992
+ if len(direct_records) == 1 and dangling_call_counts.get(original_id, 0) == 1:
993
+ record = direct_records.pop()
994
+ by_call_id.pop(original_id, None)
995
+ else:
996
+ direct_ambiguous = True
997
+ logger.warning(
998
+ "Sub-agent reload found ambiguous parent_call_id=%s for '%s'; "
999
+ "injecting generic repair and preserving pending records.",
1000
+ original_id,
1001
+ content.name,
1002
+ )
1003
+ # Pass 2 — fall back to name+order for records that
1004
+ # didn't carry a parent_call_id (legacy sessions).
1005
+ if record is None and not direct_ambiguous and name_pool.get(content.name):
1006
+ record = name_pool[content.name].popleft()
1007
+ logger.warning(
1008
+ "Sub-agent reload paired '%s' via tool_name+order fallback "
1009
+ "(call_id=%s); re-save the session to persist parent_call_id "
1010
+ "for precise recovery on next reload.",
1011
+ content.name,
1012
+ original_id,
1013
+ )
1014
+
1015
+ err_text = _format_err(content.name, record)
1016
+ result = Content.from_function_result(call_id=original_id, result=err_text)
1017
+ if record is not None:
1018
+ record[SUB_AGENT_RESTORE_CONSUMED_KEY] = True
1019
+ metadata = _sub_agent_result_metadata(record)
1020
+ if metadata:
1021
+ result.additional_properties[TOOL_RESULT_METADATA_KEY] = metadata
1022
+ injections.append((insert_at, Message("tool", [result])))
1023
+ if key is not None:
1024
+ scheduled_keys.add(key)
1025
+ injected += 1
1026
+
1027
+ # Insert from the end so earlier indices stay valid. Every synthetic
1028
+ # result lands at the response's shared result-block boundary, after
1029
+ # all consecutive assistant siblings. Multiple results targeting the
1030
+ # same boundary retain appearance order through stable reverse
1031
+ # iteration and repeated insertion at that boundary.
1032
+ for insert_at, msg in reversed(injections):
1033
+ messages.insert(insert_at, msg)
1034
+ return injected
1035
+
1036
+ def trim_to_last_complete_tool_results(self) -> None:
1037
+ """Trim messages to the last complete tool exchange on interrupt.
1038
+
1039
+ Walks backwards to the last exchange (a run of assistant siblings
1040
+ plus the tool output answering it) and removes function calls the
1041
+ exchange never answered — checking EVERY sibling of the run against
1042
+ the shared output block and embedded results, per trim's pairing
1043
+ row. Exchanges whose call siblings all pop cascade the walk into
1044
+ earlier history; any surviving sibling stops it.
1045
+ """
1046
+ state = self._state
1047
+ if state is None:
1048
+ return
1049
+ messages = state.get("messages")
1050
+ if not isinstance(messages, list) or not messages:
1051
+ return
1052
+
1053
+ # Materialize boundaries and pairings BEFORE any mutation: the walk
1054
+ # only pops or edits messages at indices at-or-above everything it
1055
+ # still has left to visit, so earlier exchanges' coordinates stay
1056
+ # valid as it descends.
1057
+ accessor = LiveAccessor()
1058
+ exchange_list = list(iter_exchanges(messages, accessor))
1059
+ pairings = [pair_results(messages, exchange, accessor, _TRIM_PAIRING_POLICY) for exchange in exchange_list]
1060
+ owner_by_response_index = {
1061
+ index: position for position, exchange in enumerate(exchange_list) for index in exchange.response_indices
1062
+ }
1063
+ output_indices = {index for exchange in exchange_list for index in exchange.output_indices}
1064
+
1065
+ i = len(messages) - 1
1066
+ while i >= 0:
1067
+ msg = messages[i]
1068
+ if msg.role != "assistant":
1069
+ i -= 1
1070
+ continue
1071
+
1072
+ if HistoryMarkerKind.KEY in msg.additional_properties:
1073
+ # Assistant role alone does not make a message a response
1074
+ # sibling. In particular, the awaiting-sub-agents marker is
1075
+ # a deliberate shield for its dangling parent call until
1076
+ # reload repair can answer it.
1077
+ break
1078
+
1079
+ position = owner_by_response_index.get(i)
1080
+ if position is None:
1081
+ if i in output_indices:
1082
+ # A result-only assistant continuing an exchange's output
1083
+ # block; the exchange is processed at its response run.
1084
+ i -= 1
1085
+ continue
1086
+ if (
1087
+ i > 0
1088
+ and messages[i - 1].role == "assistant"
1089
+ and HistoryMarkerKind.KEY not in messages[i - 1].additional_properties
1090
+ ):
1091
+ # A call-less assistant run is not a safe trim boundary
1092
+ # while it may still lead back to a response's calls.
1093
+ i -= 1
1094
+ continue
1095
+ break
1096
+
1097
+ exchange = exchange_list[position]
1098
+ if _trim_exchange_siblings(messages, exchange, pairings[position]):
1099
+ break
1100
+ i = exchange.response_indices[0] - 1
1101
+
1102
+ # ------------------------------------------------------------------
1103
+ # Metadata persistence
1104
+ # ------------------------------------------------------------------
1105
+
1106
+ def persist_batch_ids(self, batch_records: list[ToolBatchRecord]) -> dict[int, Message]:
1107
+ """Assign ``_batch_id`` to assistant messages by record identity.
1108
+
1109
+ Matching is keyed by ``(provider_call_id, tool_name)`` — the
1110
+ persisted ``function_call.call_id`` IS the raw provider id — never
1111
+ by position: positional zips misattribute whenever a pre-run
1112
+ unbatched straggler, an interrupted pass, or mid-pass compression
1113
+ shifts the pairing. Deliberately NO ``tool_order`` and NO chrys-id
1114
+ fallback (an order-only match hits a stale slot *uniquely*, and the
1115
+ chrys id may be a fresh uuid that matches nothing); a record
1116
+ without ``provider_call_id`` is dropped with a debug log — for
1117
+ batch attribution, loss beats misattribution.
1118
+
1119
+ Duplicate keys are matched **bucketed, never per record**:
1120
+ sequential-id providers reset ids per response, so the same key
1121
+ legitimately repeats within one pass. Records group per key in
1122
+ drain (chronological) order and un-batched slots per key in message
1123
+ order; for N records only the NEWEST N slots stay eligible (surplus
1124
+ oldest slots are stale stragglers and stay un-batched), then
1125
+ records assign chronologically within the bucket. Slots are
1126
+ (message, function_call content) pairs; the first record assigned
1127
+ to a message writes ``_batch_id``, later records verify the id
1128
+ matches and no-op (debug log on mismatch, never overwrite). The
1129
+ scan bound is the current turn region — identity matching is what
1130
+ makes the wide bound safe; a strict numeric floor would orphan
1131
+ same-pass pre-compression batches.
1132
+
1133
+ Returns ``{batch_id: anchor message}`` — the first message of each
1134
+ batch in record (chronological) order — for
1135
+ :meth:`persist_intermediate_texts`. Must be called before it.
1136
+ """
1137
+ anchors: dict[int, Message] = {}
1138
+ if not batch_records:
1139
+ return anchors
1140
+ state = self._state
1141
+ if state is None:
1142
+ return anchors
1143
+ messages = state.get("messages")
1144
+ if not isinstance(messages, list):
1145
+ return anchors
1146
+
1147
+ record_buckets: dict[tuple[str, str], list[tuple[int, ToolBatchRecord]]] = {}
1148
+ for drain_idx, record in enumerate(batch_records):
1149
+ if not record.provider_call_id:
1150
+ logger.debug(
1151
+ "persist_batch_ids: dropping record without provider_call_id (tool=%s order=%s batch=%s)",
1152
+ record.tool_name,
1153
+ record.tool_order,
1154
+ record.batch_id,
1155
+ )
1156
+ continue
1157
+ record_buckets.setdefault((record.provider_call_id, record.tool_name), []).append((drain_idx, record))
1158
+ if not record_buckets:
1159
+ return anchors
1160
+
1161
+ # Slots: (provider_call_id, tool_name) → messages in order, from
1162
+ # un-batched assistant function_call messages in the current turn
1163
+ # region. The un-batched filter stays message-level.
1164
+ slot_buckets: dict[tuple[str, str], list[Message]] = {}
1165
+ start = current_turn_start(messages)
1166
+ for msg in messages[start:]:
1167
+ if msg.role != "assistant" or "_batch_id" in msg.additional_properties:
1168
+ continue
1169
+ for c in msg.contents:
1170
+ if c.type != "function_call" or c.informational_only or not c.call_id:
1171
+ continue
1172
+ slot_buckets.setdefault((c.call_id, c.name or ""), []).append(msg)
1173
+
1174
+ assignments: list[tuple[int, int, Message]] = []
1175
+ for key, keyed_records in record_buckets.items():
1176
+ slots = slot_buckets.get(key, [])
1177
+ if len(slots) < len(keyed_records):
1178
+ logger.debug(
1179
+ "persist_batch_ids: %d record(s) for key %s found no slot",
1180
+ len(keyed_records) - len(slots),
1181
+ key,
1182
+ )
1183
+ eligible = slots[-len(keyed_records) :]
1184
+ # strict=False: a slot shortfall is tolerated (logged above) —
1185
+ # surplus records simply stay un-stamped.
1186
+ for (drain_idx, record), msg in zip(keyed_records, eligible, strict=False):
1187
+ assignments.append((drain_idx, record.batch_id, msg))
1188
+
1189
+ # Stamp in global chronological (drain) order so the anchor of a
1190
+ # multi-message batch is the batch's FIRST message.
1191
+ for _drain_idx, batch_id, msg in sorted(assignments, key=lambda a: a[0]):
1192
+ existing = msg.additional_properties.get("_batch_id")
1193
+ if existing is None:
1194
+ msg.additional_properties["_batch_id"] = batch_id
1195
+ elif existing != batch_id:
1196
+ logger.debug(
1197
+ "persist_batch_ids: batch id mismatch on message (existing=%s record=%s) — not overwritten",
1198
+ existing,
1199
+ batch_id,
1200
+ )
1201
+ continue
1202
+ anchors.setdefault(batch_id, msg)
1203
+ return anchors
1204
+
1205
+ def persist_intermediate_texts(self, texts: dict[int, str], batch_anchors: dict[int, Message]) -> None:
1206
+ """Embed captured intermediate texts into their batches' anchor messages.
1207
+
1208
+ ``texts`` maps ``batch_id → text`` recorded at capture time;
1209
+ ``batch_anchors`` is :meth:`persist_batch_ids`'s return value (which
1210
+ **must** run first). Assignment is a pure ``batch_id`` lookup — the
1211
+ old last-user-message anchor and positional pairing are gone. A
1212
+ text whose batch matched no message this pass (e.g. every call in
1213
+ the batch failed argument validation and produced no record) is
1214
+ DROPPED with a debug log, never positionally re-attached: loss
1215
+ beats misattribution. Anchors also keep a fresh text off any
1216
+ stale same-id ``_batch_id`` from a restored session's earlier
1217
+ process, whose counter restarted.
1218
+ """
1219
+ if not texts:
1220
+ return
1221
+ for batch_id, text in sorted(texts.items()):
1222
+ if not text:
1223
+ continue
1224
+ msg = batch_anchors.get(batch_id)
1225
+ if msg is None:
1226
+ logger.debug(
1227
+ "persist_intermediate_texts: dropping text for unmatched batch %s (%d chars)",
1228
+ batch_id,
1229
+ len(text),
1230
+ )
1231
+ continue
1232
+ # Match intermediate_text_signal(), which concatenates text
1233
+ # parts without separators before deciding whether a sidecar is
1234
+ # needed. Replay also recognizes this form for compatibility.
1235
+ existing_text = "".join((c.text or "") for c in msg.contents if c.type == "text")
1236
+ if existing_text == text:
1237
+ continue
1238
+ msg.additional_properties["_intermediate_text"] = text
1239
+
1240
+ def persist_approval_decisions(
1241
+ self,
1242
+ decisions: list[dict[str, str]],
1243
+ *,
1244
+ start_index: int | None = None,
1245
+ ) -> None:
1246
+ """Tag assistant messages with approval decision info.
1247
+
1248
+ Matches decisions to function-call contents by the kernel-stamped
1249
+ invocation ordinal first, then by persisted call_id, then by legacy
1250
+ tool-name order. Order matching is a pure stamp read — this method
1251
+ never counts function calls itself, so calls that failed pre-pipeline
1252
+ validation cannot shift later matches. Ordinals restart at zero for
1253
+ every kernel run and one logical turn can hold several runs (a
1254
+ retried pass after a failure, interrupt/resume), so the scan region
1255
+ can retain same-ordinal calls from an earlier pass — typically a
1256
+ pre-validation failure that never reached the approval middleware.
1257
+ Calls whose immediate results carry a pre-pipeline ``tool_error_kind``
1258
+ are excluded from every stage (they can never hold a decision);
1259
+ beyond that, decisions always describe the most recent pass, so each
1260
+ order decision attaches to the LAST stamp match, never the first. An
1261
+ unstamped content (legacy
1262
+ history) skips the order stage and falls through to the id/name
1263
+ stages. When ``start_index`` is supplied, matching is scoped to the
1264
+ current turn so older same-name tool calls cannot consume new
1265
+ approval decisions.
1266
+ """
1267
+ if not decisions:
1268
+ return
1269
+ state = self._state
1270
+ if state is None:
1271
+ return
1272
+ messages = state.get("messages")
1273
+ if not isinstance(messages, list):
1274
+ return
1275
+
1276
+ start_index = _effective_metadata_start_index(messages, start_index)
1277
+
1278
+ usable_decisions = [decision for decision in decisions if decision.get("status") != "approval_pending"]
1279
+ if not usable_decisions:
1280
+ return
1281
+
1282
+ # An unhashable original id stays OUT of the scoped set (a string
1283
+ # decision id could never equal it; including it would only crash
1284
+ # the build) — its call still matches by stamp or name below.
1285
+ scoped_call_ids = {
1286
+ c.call_id
1287
+ for msg in messages[start_index:]
1288
+ if msg.role == "assistant"
1289
+ for c in msg.contents
1290
+ if c.type == "function_call" and not c.informational_only and c.call_id and isinstance(c.call_id, Hashable)
1291
+ }
1292
+ used_decision_indexes: set[int] = set()
1293
+ fallback_cursor = 0
1294
+
1295
+ def _decision_order(decision: dict[str, str]) -> int | None:
1296
+ raw = decision.get("tool_order")
1297
+ if raw is None:
1298
+ return None
1299
+ try:
1300
+ return int(raw)
1301
+ except ValueError:
1302
+ return None
1303
+
1304
+ def _pop_by_call_id(call_id: str, tool_name: str) -> dict[str, str] | None:
1305
+ if not call_id:
1306
+ return None
1307
+ for idx, decision in enumerate(usable_decisions):
1308
+ if idx in used_decision_indexes:
1309
+ continue
1310
+ if decision.get("call_id") == call_id and decision.get("tool_name") == tool_name:
1311
+ used_decision_indexes.add(idx)
1312
+ return decision
1313
+ return None
1314
+
1315
+ def _fallback_eligible(decision: dict[str, str]) -> bool:
1316
+ if _decision_order(decision) is not None:
1317
+ return False
1318
+ call_id = decision.get("call_id", "")
1319
+ return not call_id or call_id not in scoped_call_ids
1320
+
1321
+ def _pop_fallback(tool_name: str) -> dict[str, str] | None:
1322
+ nonlocal fallback_cursor
1323
+ while fallback_cursor < len(usable_decisions):
1324
+ idx = fallback_cursor
1325
+ decision = usable_decisions[idx]
1326
+ if idx in used_decision_indexes or not _fallback_eligible(decision):
1327
+ fallback_cursor += 1
1328
+ continue
1329
+ if decision.get("tool_name") != tool_name:
1330
+ return None
1331
+ used_decision_indexes.add(idx)
1332
+ fallback_cursor += 1
1333
+ return decision
1334
+ return None
1335
+
1336
+ def _approval_for(content: Content, decision: dict[str, str]) -> dict[str, str]:
1337
+ approval: dict[str, str] = {
1338
+ "tool_name": decision.get("tool_name", ""),
1339
+ "status": decision.get("status", ""),
1340
+ "request_id": decision.get("request_id", ""),
1341
+ }
1342
+ if decision.get("call_id") and decision["call_id"] == (content.call_id or ""):
1343
+ approval["call_id"] = decision["call_id"]
1344
+ if decision.get("reason"):
1345
+ approval["reason"] = decision["reason"]
1346
+ if decision.get("grant_ids"):
1347
+ approval["grant_ids"] = decision["grant_ids"]
1348
+ return approval
1349
+
1350
+ def _apply_modified_args(content: Content, decision: dict[str, str]) -> bool:
1351
+ raw = decision.get("modified_args")
1352
+ if not raw:
1353
+ return False
1354
+ try:
1355
+ parsed = json.loads(raw)
1356
+ except json.JSONDecodeError:
1357
+ logger.debug("persist_approval_decisions: invalid modified_args JSON for %s", content.call_id)
1358
+ return False
1359
+ if not isinstance(parsed, dict):
1360
+ return False
1361
+ content.arguments = parsed
1362
+ return True
1363
+
1364
+ # Calls answered by a pre-pipeline validation failure (unparseable or
1365
+ # invalid arguments, unknown tool) never reached the approval
1366
+ # middleware, so they can never legitimately hold a decision —
1367
+ # exclude them from every matching stage, independent of message
1368
+ # order. The evidence is exchange-scoped (call ids are not globally
1369
+ # unique) and read from the ELIGIBLE pairing view, so embedded
1370
+ # failure results count while a marker- or foreign-role-carried
1371
+ # result never does.
1372
+ never_approvable_ids: set[int] = set()
1373
+ accessor = LiveAccessor()
1374
+ owner_by_response_index: dict[int, int] = {}
1375
+ exchange_exclusions: list[tuple[set[PairingKey], bool]] = []
1376
+ for position, exchange in enumerate(iter_exchanges(messages, accessor)):
1377
+ exchange_exclusions.append(_approval_exclusions(messages, exchange, accessor))
1378
+ for index in exchange.response_indices:
1379
+ owner_by_response_index[index] = position
1380
+
1381
+ # Stage 1 — kernel-stamped ordinal matching. Attach each order
1382
+ # decision to the LAST eligible stamp match in the region, never the
1383
+ # first: an earlier same-turn pass can leave behind a same-ordinal
1384
+ # call of the same name (ordinals restart per kernel run, and a
1385
+ # retried or resumed turn retains the failed pass's completed
1386
+ # messages), and decisions always belong to the most recent pass.
1387
+ # Decision call ids cannot disambiguate here — they carry the
1388
+ # chrys-minted id while contents keep the provider wire id, two
1389
+ # namespaces that never compare equal.
1390
+ stamped_candidates: list[tuple[Message, Content, int]] = []
1391
+ for msg_idx in range(start_index, len(messages)):
1392
+ msg = messages[msg_idx]
1393
+ if msg.role != "assistant":
1394
+ continue
1395
+ position = owner_by_response_index.get(msg_idx)
1396
+ if position is not None:
1397
+ excluded_keys, falsy_failure = exchange_exclusions[position]
1398
+ else:
1399
+ excluded_keys, falsy_failure = set(), False
1400
+ for c in msg.contents:
1401
+ if c.type != "function_call" or c.informational_only:
1402
+ continue
1403
+ if c.call_id is None:
1404
+ # The kernel raises on a None call id before the
1405
+ # middleware context is created, so such a call can never
1406
+ # hold a decision. An EMPTY id is different: provider
1407
+ # adapters mint "" for absent wire ids and those calls do
1408
+ # reach approval — they stay eligible unless their block
1409
+ # holds an unpairable id-less failure (below).
1410
+ never_approvable_ids.add(id(c))
1411
+ continue
1412
+ raw_id = c.call_id
1413
+ normalized_id = raw_id if isinstance(raw_id, str) else (str(raw_id) if raw_id else "")
1414
+ if ("call_id", normalized_id) in excluded_keys or (falsy_failure and not normalized_id):
1415
+ never_approvable_ids.add(id(c))
1416
+ continue
1417
+ if "_approval" not in c.additional_properties:
1418
+ stamped = read_tool_invocation_order(c.additional_properties)
1419
+ if stamped is not None:
1420
+ stamped_candidates.append((msg, c, stamped))
1421
+
1422
+ for idx, order_decision in enumerate(usable_decisions):
1423
+ order = _decision_order(order_decision)
1424
+ if order is None:
1425
+ continue
1426
+ for msg, content, stamped in reversed(stamped_candidates):
1427
+ if (
1428
+ stamped == order
1429
+ and (content.name or "") == order_decision.get("tool_name")
1430
+ and "_approval" not in content.additional_properties
1431
+ ):
1432
+ content.additional_properties["_approval"] = _approval_for(content, order_decision)
1433
+ _apply_modified_args(content, order_decision)
1434
+ _sync_message_approval_from_contents(msg)
1435
+ used_decision_indexes.add(idx)
1436
+ break
1437
+
1438
+ for msg in messages[start_index:]:
1439
+ if len(used_decision_indexes) == len(usable_decisions):
1440
+ break
1441
+ if msg.role != "assistant":
1442
+ continue
1443
+
1444
+ message_changed = False
1445
+ for c in msg.contents:
1446
+ if c.type != "function_call" or c.informational_only or id(c) in never_approvable_ids:
1447
+ continue
1448
+ if "_approval" not in c.additional_properties:
1449
+ decision = _pop_by_call_id(c.call_id or "", c.name or "")
1450
+ if decision is None:
1451
+ decision = _pop_fallback(c.name or "")
1452
+ if decision is not None:
1453
+ c.additional_properties["_approval"] = _approval_for(c, decision)
1454
+ _apply_modified_args(c, decision)
1455
+ message_changed = True
1456
+ if message_changed:
1457
+ # Preserve the legacy message-level field for existing
1458
+ # replay/read paths. Multi-call messages keep exact per-call
1459
+ # approvals on the contents above.
1460
+ _sync_message_approval_from_contents(msg)
1461
+
1462
+ def backfill_missing_created_at(
1463
+ self,
1464
+ created_at: datetime | str | None = None,
1465
+ *,
1466
+ start_index: int | None = None,
1467
+ ) -> int:
1468
+ """Stamp missing timestamp metadata on current-run edge-case messages."""
1469
+ state = self._state
1470
+ if state is None:
1471
+ return 0
1472
+ messages = state.get("messages")
1473
+ if not isinstance(messages, list):
1474
+ return 0
1475
+ start_index = len(messages) if start_index is None else _effective_metadata_start_index(messages, start_index)
1476
+ timestamp = normalize_created_at(created_at)
1477
+ count = 0
1478
+ for msg in messages[start_index:]:
1479
+ if stamp_message_created_at(msg, timestamp):
1480
+ count += 1
1481
+ return count
1482
+
1483
+ def persist_consumed_injections(self, injections: list[ConsumedInjection]) -> None:
1484
+ """Insert consumed injection messages into session history.
1485
+
1486
+ Each consumed injection records a text, timestamp, and ``anchor``
1487
+ is an :class:`InjectionAnchor` captured by :class:`InjectionMiddleware`
1488
+ at consumption time — describing the message the injection should
1489
+ land *after*.
1490
+
1491
+ :meth:`InjectionAnchor.find_in` tries exact identity
1492
+ (``id(contents)``) and ``message_id`` first, then falls back to
1493
+ structural ``role`` + ``call_ids`` matching when message assembly
1494
+ has replaced the captured objects. If no key
1495
+ matches, fall back to appending at the end.
1496
+ """
1497
+ if not injections:
1498
+ return
1499
+ state = self._state
1500
+ if state is None:
1501
+ return
1502
+ messages = state.get("messages")
1503
+ if not isinstance(messages, list):
1504
+ return
1505
+ current_region = messages[current_turn_start(messages) :]
1506
+ persisted_by_id = {
1507
+ consumption_id: message
1508
+ for message in current_region
1509
+ if isinstance(
1510
+ consumption_id := message.additional_properties.get(HistoryMarkerKind.INJECTION_ID_KEY),
1511
+ str,
1512
+ )
1513
+ }
1514
+ missing: list[ConsumedInjection] = []
1515
+ for injection in injections:
1516
+ existing = persisted_by_id.get(injection.consumption_id) if injection.consumption_id is not None else None
1517
+ if existing is None:
1518
+ missing.append(injection)
1519
+ else:
1520
+ _stamp_consumption(existing, injection)
1521
+ _insert_consumed_injection_messages(messages, missing)
1522
+
1523
+ def replay_consumed_injections(self, injections: list[ConsumedInjection]) -> None:
1524
+ """Re-insert consumed injections missing from history (crash-recovery).
1525
+
1526
+ Consumed injections normally reach history only at finalization
1527
+ (:meth:`persist_consumed_injections`); a recovery checkpoint built
1528
+ mid-run must replay them itself or a hard crash loses the injected
1529
+ user message entirely. Dedup against copies already present in the
1530
+ current turn region is IDENTITY-first: an injection is a duplicate
1531
+ only when a region message carries its ``_injection_id`` stamp —
1532
+ text matching cannot tell a persisted copy of *this* consumption
1533
+ from a distinct earlier injection that happens to share the text
1534
+ (e.g. a resumed turn whose prior run persisted a same-text note),
1535
+ and dropping the new one loses user input. Kind-aware text matching
1536
+ (``user_text_matches`` with ``kind="injected"``) remains only as the
1537
+ fallback for id-less legacy entries. The survivors route through
1538
+ the same anchor-resolution + insertion routine as the finalizer
1539
+ path, preserving consumption order (an anchor miss appends at the
1540
+ end, which is still before the checkpoint's terminal markers —
1541
+ callers run this in the pre-shaping window).
1542
+ """
1543
+ if not injections:
1544
+ return
1545
+ state = self._state
1546
+ if state is None:
1547
+ return
1548
+ messages = state.get("messages")
1549
+ if not isinstance(messages, list):
1550
+ return
1551
+ start = current_turn_start(messages)
1552
+ region = messages[start:]
1553
+ region_injections_by_id = {
1554
+ injection_id: message
1555
+ for message in region
1556
+ if isinstance(
1557
+ injection_id := message.additional_properties.get(HistoryMarkerKind.INJECTION_ID_KEY),
1558
+ str,
1559
+ )
1560
+ }
1561
+
1562
+ missing: list[ConsumedInjection] = []
1563
+ for injection in injections:
1564
+ if injection.consumption_id is not None:
1565
+ existing = region_injections_by_id.get(injection.consumption_id)
1566
+ else:
1567
+ existing = next(
1568
+ (message for message in region if user_text_matches(message, injection.text, kind="injected")),
1569
+ None,
1570
+ )
1571
+ if existing is None:
1572
+ missing.append(injection)
1573
+ else:
1574
+ _stamp_consumption(existing, injection)
1575
+ _insert_consumed_injection_messages(messages, missing)
1576
+
1577
+
1578
+ def _stamp_consumption(message: Message, injection: ConsumedInjection) -> None:
1579
+ """Give a persisted copy of *injection* the identity, time and reminders its consumption had."""
1580
+ ensure_analytics_item_id(message.additional_properties, item_id=injection.analytics_item_id)
1581
+ if injection.created_at is not None:
1582
+ stamp_message_created_at(message, injection.created_at)
1583
+ copy_reminder_record(injection.wire_properties, message.additional_properties)
1584
+
1585
+
1586
+ def _insert_consumed_injection_messages(messages: list[Message], injections: list[ConsumedInjection]) -> None:
1587
+ """Insert consumed-injection messages at their anchor positions.
1588
+
1589
+ Shared core of :meth:`SessionHistoryManager.persist_consumed_injections`
1590
+ (finalizer path) and :meth:`~SessionHistoryManager.replay_consumed_injections`
1591
+ (crash-recovery checkpoint path) — both must place injections identically.
1592
+
1593
+ Anchors resolve against the **pre-insertion** list (batch resolve), then
1594
+ insertions apply highest index first so earlier indices are not shifted.
1595
+ For equal indices (multiple injections sharing one anchor), LATER-queued
1596
+ inserts first — each insert pushes prior items down, so the last insert
1597
+ lands at the anchor position, which is the earliest queued. Final
1598
+ ascending-index order matches queue order. An anchor miss appends at
1599
+ the end, which also preserves queue order.
1600
+ """
1601
+ insertions: list[tuple[int, int, Message]] = []
1602
+ for queue_order, injection in enumerate(injections):
1603
+ match = injection.anchor.find_in(messages)
1604
+ insert_idx = match + 1 if match is not None else len(messages)
1605
+ inj_msg = Message("user", [injection.text])
1606
+ inj_msg.additional_properties[HistoryMarkerKind.INJECTED_KEY] = True
1607
+ if injection.consumption_id:
1608
+ # Per-consumption identity: replay_consumed_injections dedups on
1609
+ # this stamp, never on text alone.
1610
+ inj_msg.additional_properties[HistoryMarkerKind.INJECTION_ID_KEY] = injection.consumption_id
1611
+ _stamp_consumption(inj_msg, injection)
1612
+ insertions.append((insert_idx, queue_order, inj_msg))
1613
+
1614
+ insertions.sort(key=lambda t: (t[0], t[1]), reverse=True)
1615
+ for idx, _order, msg in insertions:
1616
+ messages.insert(idx, msg)