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
chrys/kernel/loop.py ADDED
@@ -0,0 +1,2308 @@
1
+ # Copyright (c) Microsoft. All rights reserved.
2
+ # Copyright (c) Huawei Technologies Co., Ltd. 2026. All rights reserved.
3
+ # Contains code adapted from Microsoft Agent Framework (MIT License; see NOTICE).
4
+
5
+ """Model/tool loop over a chat middleware layer and a provider wire client.
6
+
7
+ ``ToolLoopLayer`` owns per-run middleware pipelines, dispatch, result assembly,
8
+ continuation state and interrupt-recovery recording. Each ``get_response``
9
+ builds one ``_LoopRun`` (the blocking and streaming drivers) over a
10
+ ``_WireCaller`` (logical model calls and their wire retry lanes) and a
11
+ ``_LoopTrajectory`` (cycle, exchange and retry events, and the landed tool
12
+ operations not yet dispatched). Tool calls execute in ``_tool_execution``;
13
+ the interrupt-recovery journal is ``_loop_recorder``.
14
+
15
+ Tool containers become fresh run-local lists, since direct callers and
16
+ progressive exposure can add tools after agent preparation. Inner stream
17
+ result hooks run once; final assembly must not replay them.
18
+
19
+ Kernel dependencies stay within this package, allowed third-party packages
20
+ and foundation. Intra-package imports are relative.
21
+ """
22
+
23
+ from __future__ import annotations
24
+
25
+ import asyncio
26
+ import functools
27
+ import inspect
28
+ import logging
29
+ from collections.abc import Mapping, Sequence
30
+ from copy import copy
31
+ from dataclasses import dataclass
32
+ from enum import Enum
33
+ from time import monotonic_ns
34
+ from typing import TYPE_CHECKING, Any, Protocol, cast
35
+
36
+ from chrys.foundation.errors import (
37
+ clean_error_message,
38
+ invalidates_continuation_token,
39
+ is_context_overflow,
40
+ is_thinking_binding_rejection,
41
+ )
42
+ from chrys.foundation.retry import StreamStall
43
+ from chrys.foundation.tool_invocation_order import TOOL_INVOCATION_ORDER_KEY
44
+ from chrys.foundation.trajectory.context import (
45
+ TRAJECTORY_CONTEXT_KWARG,
46
+ TRAJECTORY_EXCHANGE_KWARG,
47
+ ExchangeTrace,
48
+ TrajectoryContext,
49
+ current_trajectory,
50
+ trajectory_scope,
51
+ )
52
+ from chrys.foundation.trajectory.envelope import MeasurementSource, measurement
53
+ from chrys.foundation.trajectory.event_types import (
54
+ EventType as TrajectoryEventType,
55
+ )
56
+ from chrys.foundation.trajectory.event_types import (
57
+ ExchangeOutcome,
58
+ RetryMode,
59
+ RetryReason,
60
+ ToolOutcome,
61
+ )
62
+ from chrys.foundation.trajectory.ids import new_analytics_id
63
+ from chrys.foundation.trajectory.metadata import (
64
+ ANALYTICS_ITEM_ID_KEY,
65
+ OPERATION_ID_KEY,
66
+ )
67
+
68
+ from ._content import Content, add_usage_details, normalize_stream_usage
69
+ from ._loop_recorder import LoopRecorder, _message_snapshot
70
+ from ._tool_execution import (
71
+ _execute_function_calls,
72
+ _is_actionable_function_call,
73
+ _record_unexecuted_tool_operation,
74
+ )
75
+ from ._types import (
76
+ ChatResponse,
77
+ ChatResponseUpdate,
78
+ Message,
79
+ ResponseStream,
80
+ )
81
+ from .client import _wire_message_view, resolve_storage_mode_and_handles, start_with_wire_progress
82
+ from .compaction import ContextOverflowSink
83
+ from .exchanges import TOOL_CALL_CONTENT_TYPES
84
+ from .identity import WeakIdentityRegistry
85
+ from .middleware import FunctionMiddlewarePipeline, _as_middleware_list, split_middleware
86
+ from .sessions import AgentSession, is_local_history_conversation_id
87
+ from .tools import (
88
+ normalize_tools,
89
+ )
90
+
91
+ if TYPE_CHECKING:
92
+ from collections.abc import AsyncIterable, Awaitable, Callable
93
+
94
+ from chrys.foundation.trajectory.envelope import EventDraft
95
+
96
+ from ._content import UsageDetails
97
+ from .middleware import ChatMiddleware, ChatMiddlewareLayer, FunctionMiddleware
98
+
99
+ logger = logging.getLogger(__name__)
100
+
101
+ # Default bounds on model/tool iterations and consecutive failed tool batches.
102
+ DEFAULT_MAX_ITERATIONS = 40
103
+ DEFAULT_MAX_CONSECUTIVE_ERRORS = 3
104
+
105
+ # Pause between continuation polls of a still-running background response.
106
+ # Retrieval returns immediately while the response is queued/in_progress, so
107
+ # an unpaced loop would hammer the provider straight into rate limits.
108
+ CONTINUATION_POLL_INTERVAL_SECONDS: float = 2.0
109
+
110
+
111
+ class StallExhaustedAction(Enum):
112
+ """Action taken when the stream-idle retry budget is exhausted."""
113
+
114
+ BLOCKING_FALLBACK = "blocking_fallback"
115
+ RAISE = "raise"
116
+
117
+
118
+ class WireRetryPolicy(Protocol):
119
+ """Per-run policy for retrying one logical provider call."""
120
+
121
+ max_retries: int
122
+ stall_timeout_seconds: float | None
123
+ stall_max_retries: int
124
+ stall_exhausted_action: StallExhaustedAction
125
+ # Probe for the hosted tool calls a replay must not re-run (side-effectful
126
+ # or of unknown safety) that the current wire attempt already ran
127
+ # server-side; None when the caller has no such probe.
128
+ hosted_commits_in_flight: Callable[[], tuple[str, ...]] | None
129
+
130
+ def backoff_seconds(self, attempt: int) -> int: ...
131
+
132
+ def is_retryable(self, exc: BaseException) -> bool: ...
133
+
134
+ def is_interrupted(self) -> bool: ...
135
+
136
+ async def sleep(self, seconds: int) -> bool: ...
137
+
138
+ async def on_retry(
139
+ self,
140
+ message: str,
141
+ attempt: int,
142
+ max_attempts: int,
143
+ delay_seconds: int,
144
+ exc: BaseException,
145
+ ) -> None: ...
146
+
147
+ def before_retry(self) -> None: ...
148
+
149
+
150
+ @dataclass(frozen=True, slots=True)
151
+ class ConsumedInjectionMessageProbe:
152
+ """Downward-safe seam for clean injections consumed on a wire call.
153
+
154
+ Chat middleware may append transient reminders after injection. The
155
+ service-owned probe exposes only the clean user messages so a later tool
156
+ iteration can retain the user's instruction without persisting wire-only
157
+ enrichment.
158
+ """
159
+
160
+ drain_consumed_injection_messages: Callable[[], list[Message]]
161
+ commit_consumed_injections: Callable[[], None] | None = None
162
+
163
+ def take_consumed_messages(self) -> list[Message]:
164
+ """Take clean user messages consumed by the latest wire call."""
165
+ return self.drain_consumed_injection_messages()
166
+
167
+ def commit(self) -> None:
168
+ """Commit the latest logical call's injection transaction."""
169
+ if self.commit_consumed_injections is not None:
170
+ self.commit_consumed_injections()
171
+
172
+
173
+ _MAX_ITERATIONS_FALLBACK_TEXT = "Maximum iterations reached before a final answer could be produced."
174
+ _CONSECUTIVE_ERRORS_FALLBACK_TEXT = (
175
+ "Tool calls kept failing, so they were stopped before a final answer could be produced."
176
+ )
177
+ _MAX_FUNCTION_CALLS_FALLBACK_TEXT = "Maximum function calls reached before a final answer could be produced."
178
+
179
+ _USER_VISIBLE_CONTENT_TYPES = frozenset({"data", "uri", "error", "hosted_file", "hosted_vector_store"})
180
+
181
+
182
+ def _strip_unexecutable_calls_from_response(response: ChatResponse) -> bool:
183
+ """Drop actionable function calls from an exhaustion-tail final response.
184
+
185
+ The tail's request carries ``tool_choice="none"``; a provider that ignores
186
+ it returns calls the loop will never execute, and a dangling call recorded
187
+ on this success path has no later repair. Messages the strip empties are
188
+ dropped, and a ``"tool_calls"`` finish reason is normalized to ``"stop"``
189
+ — the stripped response holds no remaining tool work, so advertising it
190
+ would route a plain-text final as unfinished. Returns whether anything
191
+ was removed.
192
+ """
193
+ removed = False
194
+ kept_messages: list[Message] = []
195
+ for message in response.messages:
196
+ if any(_is_actionable_function_call(content) for content in message.contents):
197
+ removed = True
198
+ remaining = [content for content in message.contents if not _is_actionable_function_call(content)]
199
+ if not remaining:
200
+ continue
201
+ message.contents = remaining
202
+ kept_messages.append(message)
203
+ if removed:
204
+ response.messages = kept_messages
205
+ if response.finish_reason == "tool_calls":
206
+ response.finish_reason = "stop"
207
+ return removed
208
+
209
+
210
+ def _strip_unexecutable_calls_from_update(
211
+ update: ChatResponseUpdate, *, suppress_finish_reason: bool = False
212
+ ) -> ChatResponseUpdate | None:
213
+ """Copy-on-write strip of actionable calls from an exhaustion-tail update.
214
+
215
+ Runs in the tail's own yield loop — above the client filter seam, so the
216
+ ResponseStream filter contract ("must return an update") is not involved
217
+ and a fully emptied update can be dropped by returning ``None``. The
218
+ incoming update is never mutated: the retry stream's recorded updates
219
+ must keep the provider's original contents so the post-assembly response
220
+ strip still observes what came back (that observation drives the
221
+ service-storage desync guard). An emptied update still passes through
222
+ when it carries meaningful metadata — any semantic field ``from_updates``
223
+ or a streaming consumer reads (continuation handles, model/timestamps/
224
+ ids, provider metadata); usage survives as a retained content.
225
+ ``raw_representation`` deliberately does NOT exempt: every wire-parsed
226
+ update carries its SDK event, so exempting it would make the drop
227
+ unreachable.
228
+
229
+ ``finish_reason`` never exempts and is cleared from every stripped copy:
230
+ the provider concluded the turn on calls this strip is withholding, and
231
+ a consumer keying on the first terminal reason would treat the run as
232
+ complete before the tail's corrective final update re-emits the real
233
+ one. ``suppress_finish_reason`` extends that verdict to updates with no
234
+ call of their own — providers commonly emit the call delta and the
235
+ finish-reason chunk separately, so once the tail has stripped a call the
236
+ caller must suppress every later provider reason too, or the reason-only
237
+ chunk crosses untouched ahead of the corrective update.
238
+ """
239
+ has_actionable_call = any(_is_actionable_function_call(content) for content in update.contents)
240
+ if not has_actionable_call and not (suppress_finish_reason and update.finish_reason is not None):
241
+ return update
242
+ stripped = copy(update)
243
+ stripped.contents = [content for content in update.contents if not _is_actionable_function_call(content)]
244
+ stripped.finish_reason = None
245
+ if (
246
+ stripped.contents
247
+ or any(
248
+ value is not None
249
+ for value in (
250
+ stripped.response_id,
251
+ stripped.conversation_id,
252
+ stripped.continuation_token,
253
+ stripped.model,
254
+ stripped.created_at,
255
+ stripped.message_id,
256
+ stripped.author_name,
257
+ )
258
+ )
259
+ or stripped.additional_properties
260
+ ):
261
+ return stripped
262
+ return None
263
+
264
+
265
+ def _response_has_visible_content(response: ChatResponse) -> bool:
266
+ for message in response.messages:
267
+ for content in message.contents:
268
+ if content.type == "text":
269
+ if content.text and content.text.strip():
270
+ return True
271
+ elif content.type in _USER_VISIBLE_CONTENT_TYPES:
272
+ return True
273
+ return False
274
+
275
+
276
+ def _ensure_exhaustion_fallback_response(response: ChatResponse, fallback_text: str) -> bool:
277
+ """Synthesize *fallback_text* when the final response has nothing visible.
278
+
279
+ Runs unconditionally after the tail strip (and, in streaming, after echo
280
+ shell cleanup): a blank or reasoning-only final response would otherwise
281
+ return as an empty success — the exact shape response validation exists to
282
+ prevent — regardless of whether the strip removed anything.
283
+ """
284
+ if _response_has_visible_content(response):
285
+ return False
286
+ fallback_content = Content.from_text(fallback_text)
287
+ if response.messages and not response.messages[-1].contents:
288
+ response.messages[-1].role = "assistant"
289
+ response.messages[-1].contents = [fallback_content]
290
+ else:
291
+ response.messages.append(Message(role="assistant", contents=[fallback_content]))
292
+ return True
293
+
294
+
295
+ def _invalidate_service_continuation_state(
296
+ response: ChatResponse,
297
+ session: AgentSession | None,
298
+ ) -> None:
299
+ """Withhold a stripped, service-stored response's continuation handles.
300
+
301
+ The service retains the unstripped transcript (calls with no outputs)
302
+ behind them; propagating a handle would reproduce next turn the 400 the
303
+ strip prevents. Clearing forces authoritative local-history replay. The
304
+ withheld handles — the response's own id included, since a stateful
305
+ provider accepts it back as ``previous_response_id`` — are recorded on
306
+ the session so a caller repeating one under any handle spelling gets
307
+ suppressed instead of re-sent alongside that replay.
308
+ """
309
+ if session is not None:
310
+ for handle in (response.conversation_id, response.response_id, session.service_session_id):
311
+ if isinstance(handle, str) and handle and not is_local_history_conversation_id(handle):
312
+ session.invalidated_service_session_ids.add(handle)
313
+ response.conversation_id = None
314
+ response.response_id = None
315
+ response._chrys_service_state_invalidated = True
316
+ if session is not None and session.service_session_id is not None:
317
+ session.service_session_id = None
318
+
319
+
320
+ # --------------------------------------------------------------------------- #
321
+ # Loop helpers (response shaping)
322
+ # --------------------------------------------------------------------------- #
323
+
324
+
325
+ def _extract_function_calls(response: ChatResponse) -> list[Content]:
326
+ """Collect unresolved, deduplicated function calls from a response.
327
+
328
+ Skips calls that already carry a result
329
+ in the same response and duplicate ``call_id``\\ s. Echoed calls never
330
+ reach this filter — ``_land_response_contents`` removes them from
331
+ the response before anything else observes it.
332
+ """
333
+ function_results = {
334
+ item.call_id
335
+ for message in response.messages
336
+ for item in message.contents
337
+ if item.type == "function_result" and item.call_id
338
+ }
339
+ seen_call_ids: set[str] = set()
340
+ function_calls: list[Content] = []
341
+ for message in response.messages:
342
+ for item in message.contents:
343
+ if not _is_actionable_function_call(item):
344
+ continue
345
+ if item.call_id and item.call_id in function_results:
346
+ continue
347
+ if item.call_id and item.call_id in seen_call_ids:
348
+ continue
349
+ if item.call_id:
350
+ seen_call_ids.add(item.call_id)
351
+ function_calls.append(item)
352
+ return function_calls
353
+
354
+
355
+ _HOSTED_CALL_CONTENT_TYPES = frozenset(TOOL_CALL_CONTENT_TYPES - {"function_call"})
356
+ """Provider-hosted tool invocations a landed response may carry (observed, never executed here)."""
357
+
358
+
359
+ def _hosted_call_kinds(response: ChatResponse) -> list[str]:
360
+ """Content types of the hosted calls in *response*, in emission order."""
361
+ return [
362
+ item.type
363
+ for message in response.messages
364
+ for item in message.contents
365
+ if item.type in _HOSTED_CALL_CONTENT_TYPES
366
+ ]
367
+
368
+
369
+ def _land_response_contents(
370
+ response: ChatResponse,
371
+ next_ordinal: int,
372
+ echo_registry: WeakIdentityRegistry,
373
+ *,
374
+ exchange_operation_id: str | None = None,
375
+ ) -> int:
376
+ """Normalize a landed response's contents and stamp fresh function calls.
377
+
378
+ This loop is the single authoritative producer of ``_chrys_tool_invocation_order``.
379
+ Landing happens at response arrival — before recording, dispatch
380
+ extraction, tool lookup, and argument validation — over every
381
+ non-informational ``function_call`` content in model emission order.
382
+ Calls the dispatch filter drops (already-answered, duplicate ids,
383
+ no-tools batches) and calls that fail pre-pipeline validation still
384
+ consume ordinals, so persisted numbering can never diverge from pipeline
385
+ numbering. Tool-operation ids are assigned later, once response-scoped
386
+ pairing has distinguished work this loop owns from calls the provider
387
+ already answered. Returns the next unassigned ordinal.
388
+
389
+ Sole-authority consequences: a pre-existing stamp on a content this run
390
+ has NOT numbered is foreign data (a bridged or cached response replaying
391
+ another run's history) and is overwritten — preserving it while the
392
+ counter advances would let a stale value collide with a fresh assignment.
393
+
394
+ ``echo_registry`` holds the identity of every content the conversation
395
+ already holds — the loop seeds it from caller history and response
396
+ contents land here, batch results register in
397
+ ``_handle_function_call_results``; usage stays out via the registry's
398
+ ``ignore_usage`` construction policy. A content OBJECT reappearing
399
+ later (a client echoing prior messages back, or the same object
400
+ repeated within one response) is an echo of history, not new work, and
401
+ is removed before the recorder, the dispatch filter, and final
402
+ transcript assembly observe it: an echoed call would otherwise
403
+ re-execute or persist as a dangling duplicate, and an echoed result
404
+ would duplicate an answer the transcript already holds. Removal is by
405
+ object identity only — a DIFFERENT content reusing an echoed call id
406
+ is fresh work (providers mint per-response counter ids) and lands
407
+ normally; a dead member's recycled address can never match, because
408
+ membership requires the registered object itself to still be alive.
409
+ Identity removal presumes assembly cannot launder an echoed object into
410
+ a new one: ``ChatResponseUpdate`` assembly MERGES consecutive same-call
411
+ function-call fragments (and coalesces adjacent text) into NEW objects,
412
+ so the streaming loop registers ``_strip_echoed_update`` as a per-turn
413
+ update filter on the wire stream — conversation-held objects are
414
+ removed at the innermost stream BEFORE accumulation, so a merge copy of
415
+ an echo never exists, a fresh call reusing an echoed id assembles alone
416
+ with its own arguments, and the provider finalizer plus every result
417
+ hook observe the same echo-free sequence (no re-assembly ever
418
+ overwrites a hook's rewrite of the response). Call-id-level provenance
419
+ cannot do this job: an id carrying both a laundered echo merge and a
420
+ fresh call is ambiguous per id, and either verdict misfires
421
+ (re-executing the echo or swallowing the fresh call via duplicate-id
422
+ dispatch). The reverse direction of the same laundering is covered by
423
+ ``_record_stream_fragment_identities`` after landing: merges mint NEW
424
+ assembled objects, so the raw fragments a client just streamed would
425
+ otherwise stay unknown to the memo and could be replayed next turn.
426
+
427
+ Message wrappers are never shared with the client, in either direction.
428
+ Every retained message is REPLACED in ``response.messages`` by a
429
+ loop-owned shallow copy (own contents list, copied
430
+ ``additional_properties``) — or dropped when nothing fresh remains.
431
+ That isolation cuts both ways: echo removal never mutates the incoming
432
+ message (the same object may sit in the accumulated transcript, and a
433
+ client-held alias must keep its view), and a stateful client that later
434
+ mutates a message object it returned — appending a fresh call between
435
+ iterations — cannot rewrite the history this run already landed.
436
+ The request direction is covered too: ``_WireCaller._call`` sends EVERY
437
+ outgoing message — caller history included — as a per-call view
438
+ (``_wire_message_view``), so mutating an INPUT message can corrupt
439
+ neither landed history nor the caller's session-state objects; message
440
+ metadata still writes through because views share the wrapper's
441
+ ``additional_properties`` dict. ``messages`` is rebound, not mutated,
442
+ so caller-held aliases of the original list keep their view. The
443
+ CONTENT objects stay the originals: the registry references them
444
+ weakly, so an entry lives exactly as long as its object and a
445
+ collected content simply stops being a member.
446
+
447
+ Landing is also where the trajectory identities are minted, under the
448
+ same sole-authority rule as the ordinal: every retained message gets a
449
+ fresh analytics item id and is bound to ``exchange_operation_id`` (the
450
+ wire exchange that produced it); every fresh function call gets its own
451
+ item id. A pre-existing operation stamp is foreign data (a replayed or
452
+ cached response) and is removed, never trusted. The dispatch collector
453
+ later assigns one operation id per unresolved call id; duplicate call
454
+ contents share it, while a provider-answered call has no Chrys tool
455
+ operation to name.
456
+ """
457
+ normalized_messages: list[Message] = []
458
+ for message in response.messages:
459
+ fresh_contents: list[Content] = []
460
+ dropped_echo = False
461
+ for item in message.contents:
462
+ if item in echo_registry:
463
+ dropped_echo = True
464
+ continue
465
+ echo_registry.register(item)
466
+ if _is_actionable_function_call(item):
467
+ item.additional_properties[TOOL_INVOCATION_ORDER_KEY] = next_ordinal
468
+ next_ordinal += 1
469
+ item.additional_properties.pop(OPERATION_ID_KEY, None)
470
+ if item.type == "function_call":
471
+ item.additional_properties[ANALYTICS_ITEM_ID_KEY] = new_analytics_id()
472
+ fresh_contents.append(item)
473
+ if dropped_echo and not fresh_contents:
474
+ continue
475
+ # Snapshot the wrapper, keep the contents: the client holds its own
476
+ # reference to this message and may mutate it between iterations.
477
+ snapshot = _message_snapshot(message, fresh_contents)
478
+ snapshot.additional_properties[ANALYTICS_ITEM_ID_KEY] = new_analytics_id()
479
+ if exchange_operation_id is not None:
480
+ snapshot.additional_properties[OPERATION_ID_KEY] = exchange_operation_id
481
+ normalized_messages.append(snapshot)
482
+ response.messages = normalized_messages
483
+ return next_ordinal
484
+
485
+
486
+ def _stamp_function_call_operations(response: ChatResponse, function_calls: Sequence[Content]) -> None:
487
+ """Assign one tool operation to each unresolved call the loop may dispatch.
488
+
489
+ ``_extract_function_calls`` is the response-scoped pairing authority: a
490
+ call already answered in the same response is provider-owned history, not
491
+ a Chrys tool operation. Repeated unresolved ``call_id`` contents describe
492
+ the same dispatch (the extractor keeps the first), so they share that
493
+ first call's operation id rather than opening operations nobody executes.
494
+ Empty ids are not deduplicated and therefore keep one operation per call.
495
+ """
496
+ operation_by_identity: dict[int, str] = {}
497
+ operation_by_call_id: dict[str, str] = {}
498
+ for function_call in function_calls:
499
+ operation_id = new_analytics_id()
500
+ function_call.additional_properties[OPERATION_ID_KEY] = operation_id
501
+ operation_by_identity[id(function_call)] = operation_id
502
+ if function_call.call_id:
503
+ operation_by_call_id[function_call.call_id] = operation_id
504
+
505
+ for message in response.messages:
506
+ for item in message.contents:
507
+ if not _is_actionable_function_call(item) or id(item) in operation_by_identity:
508
+ continue
509
+ operation_id = operation_by_call_id.get(item.call_id or "")
510
+ if operation_id is not None:
511
+ item.additional_properties[OPERATION_ID_KEY] = operation_id
512
+
513
+
514
+ def _strip_echoed_update(
515
+ update: ChatResponseUpdate,
516
+ echo_registry: WeakIdentityRegistry,
517
+ ) -> ChatResponseUpdate:
518
+ """The update with conversation-held content objects removed.
519
+
520
+ Registered per turn as an update filter on the wire stream
521
+ (:meth:`ResponseStream.with_update_filter`), so it runs at the innermost
522
+ stream BEFORE accumulation: update assembly merges consecutive same-call
523
+ function-call fragments (and coalesces adjacent text) into NEW objects,
524
+ so an echoed historical object that participates in a merge would
525
+ launder its identity past the echo memo. Filtering ahead of assembly
526
+ means a laundered merge copy never exists, a fresh call reusing an
527
+ echoed call id assembles alone with its own arguments, and — because
528
+ the provider finalizer and every result hook see the already-filtered
529
+ updates — no re-assembly ever overwrites a hook's rewrite of the
530
+ response ("hooks run before tool extraction" stays true). The incoming
531
+ update is never mutated: one carrying an echo is shallow-copied with a
532
+ filtered contents list, so a producer-held alias keeps its shape.
533
+ The copy carries private, non-serialized provenance through stream
534
+ proxies and assembly, letting the loop drop only the logical message
535
+ shell this update actually emptied. The marker is attached to the
536
+ accepted update object rather than keyed by provider message ID, which
537
+ can collide across validation retries.
538
+ """
539
+ if any(item in echo_registry for item in update.contents):
540
+ update = copy(update)
541
+ update._chrys_echo_content_stripped = True
542
+ update.contents = [item for item in update.contents if item not in echo_registry]
543
+ return update
544
+
545
+
546
+ def _remove_echo_emptied_message_shells(response: ChatResponse) -> None:
547
+ """Remove only accepted-stream messages left empty by echo stripping.
548
+
549
+ A call-wide marker cannot distinguish an unrelated legitimate empty
550
+ message from an echo-only one. Per-update provenance is folded into the
551
+ assembled Message, so this cleanup remains per message and per accepted
552
+ validation attempt without trusting non-unique provider message IDs.
553
+ """
554
+ response.messages = [
555
+ message for message in response.messages if message.contents or not message._chrys_echo_content_stripped
556
+ ]
557
+
558
+
559
+ def _record_stream_fragment_identities(
560
+ stream: ResponseStream[ChatResponseUpdate, ChatResponse],
561
+ echo_registry: WeakIdentityRegistry,
562
+ ) -> None:
563
+ """Record the identity of every accepted stream content.
564
+
565
+ Landing only sees the ASSEMBLED response, and assembly merges
566
+ multi-fragment calls (and coalesces adjacent text) into NEW objects —
567
+ so the raw fragment objects the client actually yielded would never
568
+ enter the echo registry, and a stateful client replaying a buffered
569
+ fragment on a later turn would sail past ``_strip_echoed_update`` and
570
+ re-execute the call. The consumed stream's accumulated updates are the
571
+ accepted (post-strip) sequence; under the response-validation proxy
572
+ they are the SELECTED attempt's replay, so failed attempts' fragments
573
+ are not recorded and can be legitimately re-sent on a retry.
574
+
575
+ Must run AFTER ``_land_response_contents``: a single-fragment call's
576
+ assembled content IS the fragment object, and pre-registering it
577
+ would make landing drop the fresh call as its own echo.
578
+
579
+ No retention side: a replay requires the client to still HOLD the
580
+ fragment, which keeps the weak entry alive; a collected fragment's
581
+ entry evicts, so its recycled id can never strip a genuinely fresh
582
+ content. Usage is request-scoped accounting rather than conversation
583
+ content (a client may reuse a mutable usage object across model
584
+ calls), so the registry's ``ignore_usage`` policy keeps it out.
585
+ """
586
+ for update in stream.updates:
587
+ for item in update.contents:
588
+ echo_registry.register(item)
589
+
590
+
591
+ def _record_wire_request_content_identities(
592
+ messages: Sequence[Message],
593
+ echo_registry: WeakIdentityRegistry,
594
+ ) -> None:
595
+ """Register the post-middleware contents of one actual wire request.
596
+
597
+ Called at the chat pipeline's final-handler boundary, after every
598
+ middleware has transformed the request and immediately before the inner
599
+ client sees it. This closes the identity-registry gap for wire-only
600
+ messages such as injections and reminders, including repeated
601
+ ``call_next`` retries.
602
+
603
+ No retention side: a weak entry lives exactly as long as its object,
604
+ so a collected wire-only enrichment's entry evicts and its recycled id
605
+ can never strip a genuinely fresh content. Usage is request-scoped
606
+ accounting; the registry's ``ignore_usage`` policy keeps it out.
607
+ """
608
+ for message in messages:
609
+ for item in message.contents:
610
+ echo_registry.register(item)
611
+
612
+
613
+ def _stream_usage_chunks(update: ChatResponseUpdate) -> list[Mapping[str, Any]]:
614
+ """Return usage snapshots carried by one streaming update."""
615
+ return [
616
+ content.usage_details
617
+ for content in update.contents
618
+ if content.type == "usage" and content.usage_details is not None
619
+ ]
620
+
621
+
622
+ def _truncated_final_function_call_ids(response: ChatResponse, function_calls: Sequence[Content]) -> set[int]:
623
+ """Return the object id for the final content block when it is a function call."""
624
+ if response.finish_reason != "length" or not function_calls:
625
+ return set()
626
+ final_content = next(
627
+ (item for message in reversed(response.messages) for item in reversed(message.contents)),
628
+ None,
629
+ )
630
+ if final_content is None or not _is_actionable_function_call(final_content):
631
+ return set()
632
+ function_call_ids = {id(item) for item in function_calls}
633
+ final_id = id(final_content)
634
+ return {final_id} if final_id in function_call_ids else set()
635
+
636
+
637
+ def _prepend_fcc_messages(response: ChatResponse, fcc_messages: list[Message]) -> None:
638
+ """Insert accumulated loop messages before the final response."""
639
+ if not fcc_messages:
640
+ return
641
+ for msg in reversed(fcc_messages):
642
+ response.messages.insert(0, msg)
643
+
644
+
645
+ def _update_conversation_id(
646
+ kwargs: dict[str, Any],
647
+ conversation_id: str | None,
648
+ options: dict[str, Any] | None = None,
649
+ ) -> None:
650
+ """Write a service conversation id back into kwargs/options."""
651
+ if conversation_id is None:
652
+ return
653
+ if "chat_options" in kwargs:
654
+ kwargs["chat_options"]["conversation_id"] = conversation_id
655
+ else:
656
+ kwargs["conversation_id"] = conversation_id
657
+ if options is not None:
658
+ options["conversation_id"] = conversation_id
659
+
660
+
661
+ def _update_continuation_state(
662
+ kwargs: dict[str, Any],
663
+ response: ChatResponse,
664
+ *,
665
+ session: AgentSession | None,
666
+ options: dict[str, Any] | None = None,
667
+ ) -> None:
668
+ """Update in-flight and persisted continuation state from a response."""
669
+ conversation_id = response.conversation_id
670
+ if conversation_id is None:
671
+ return
672
+
673
+ _update_conversation_id(kwargs, conversation_id, options)
674
+ if (
675
+ session is not None
676
+ and not response.has_internal_conversation_id()
677
+ and session.service_session_id != conversation_id
678
+ ):
679
+ session.service_session_id = conversation_id
680
+
681
+
682
+ def _clear_internal_conversation_id(response: ChatResponse) -> ChatResponse:
683
+ """Strip a client-internal conversation id before returning."""
684
+ if response.has_internal_conversation_id():
685
+ response.conversation_id = None
686
+ response.clear_internal_conversation_id()
687
+ return response
688
+
689
+
690
+ def _handle_function_call_results(
691
+ *,
692
+ response: ChatResponse,
693
+ function_call_results: list[Content],
694
+ fcc_messages: list[Message],
695
+ echo_registry: WeakIdentityRegistry,
696
+ errors_in_a_row: int,
697
+ had_errors: bool,
698
+ max_errors: int,
699
+ result_carrier_item_id: str,
700
+ recorder: LoopRecorder | None = None,
701
+ ) -> tuple[str, int]:
702
+ """Fold a batch of tool results back into the response.
703
+
704
+ Results are ``function_result`` contents. Returns
705
+ ``(action, errors_in_a_row)`` where action is
706
+ ``"continue"`` or ``"stop"`` (consecutive-error cap reached: submit the
707
+ collected results once more with tools disabled).
708
+
709
+ New results register in the echo registry —
710
+ they are part of the run's transcript from here on, so a client echoing
711
+ them back in a later response must see them removed as history rather
712
+ than landing a duplicate answer (``_land_response_contents``).
713
+ """
714
+ for result in function_call_results:
715
+ echo_registry.register(result)
716
+ if had_errors:
717
+ errors_in_a_row += 1
718
+ reached_error_limit = errors_in_a_row >= max_errors
719
+ if reached_error_limit:
720
+ logger.warning(
721
+ "Maximum consecutive function call errors reached (%d). "
722
+ "Stopping further function calls for this request.",
723
+ max_errors,
724
+ )
725
+ else:
726
+ errors_in_a_row = 0
727
+ reached_error_limit = False
728
+
729
+ result_message = Message(role="tool", contents=function_call_results)
730
+ # The carrier is a persisted item of its own. Its batch-wide id was minted
731
+ # at dispatch, before any tool terminal named it, so the next context
732
+ # revision and every tool.operation.finished agree on this same item.
733
+ result_message.additional_properties[ANALYTICS_ITEM_ID_KEY] = result_carrier_item_id
734
+ response.messages.append(result_message)
735
+ if recorder is not None:
736
+ recorder.seal_exchange(result_message)
737
+ fcc_messages.extend(response.messages)
738
+ return ("stop" if reached_error_limit else "continue", errors_in_a_row)
739
+
740
+
741
+ # --------------------------------------------------------------------------- #
742
+ # Run building blocks
743
+ # --------------------------------------------------------------------------- #
744
+
745
+ # The first tracked continuation token is always applied: nothing equals it.
746
+ _UNTRACKED = object()
747
+
748
+
749
+ def _wire_request_observer(
750
+ echo_registry: WeakIdentityRegistry,
751
+ caller_observer: Callable[[Sequence[Message]], None] | None,
752
+ ) -> Callable[[Sequence[Message]], None]:
753
+ internal_observer = functools.partial(
754
+ _record_wire_request_content_identities,
755
+ echo_registry=echo_registry,
756
+ )
757
+ if caller_observer is None:
758
+ return internal_observer
759
+
760
+ def observe(messages: Sequence[Message]) -> None:
761
+ # Echo tracking remains the loop's invariant even if a caller
762
+ # observer raises; callers then see the exact same provider
763
+ # views immediately after the internal identity recorder.
764
+ internal_observer(messages)
765
+ caller_observer(messages)
766
+
767
+ return observe
768
+
769
+
770
+ async def _watchdog_await(awaitable: Awaitable[Any], timeout: float | None, label: str) -> Any:
771
+ # Idle timing: a pull whose first byte waits on compaction (and
772
+ # its LAST_WORDS side call) stays alive while that work reports
773
+ # progress, and stalls after *timeout* without any.
774
+ if timeout is None:
775
+ return await awaitable
776
+ event_loop = asyncio.get_running_loop()
777
+ last_progress = event_loop.time()
778
+
779
+ def _on_progress() -> None:
780
+ nonlocal last_progress
781
+ last_progress = event_loop.time()
782
+
783
+ task = start_with_wire_progress(awaitable, _on_progress)
784
+ try:
785
+ while not task.done():
786
+ idle_budget = last_progress + timeout - event_loop.time()
787
+ if idle_budget <= 0:
788
+ break
789
+ await asyncio.wait((task,), timeout=idle_budget)
790
+ except asyncio.CancelledError:
791
+ # The pull task may still be running INSIDE the stream's
792
+ # generator; closing that stream before the task settles
793
+ # would raise "asynchronous generator is already running".
794
+ task.cancel()
795
+ await asyncio.gather(task, return_exceptions=True)
796
+ raise
797
+ if task.done():
798
+ return task.result()
799
+ task.cancel()
800
+ await asyncio.gather(task, return_exceptions=True)
801
+ raise StreamStall(f"{label} produced no progress for {timeout:g}s")
802
+
803
+
804
+ class _LoopTrajectory:
805
+ """Trajectory bookkeeping for one loop run.
806
+
807
+ One ``model.cycle`` per loop acquisition, one ``model.exchange`` per wire
808
+ attempt, ``retry.*`` around every wire retry; all of them are no-ops
809
+ without a bound context. The exchange trace rides to the wire client in
810
+ ``client_kwargs`` — the loop cannot bind it ambiently because the provider
811
+ stream is lazy and resolves outside this call's context.
812
+
813
+ State, by lifetime:
814
+
815
+ - cycle, from ``cycle_started`` to ``cycle_finished`` or ``abort``: the
816
+ cycle context, its start time, exchange count and last exchange id;
817
+ - wire attempt, from ``begin_exchange`` to ``close_exchange``: the open
818
+ exchange trace;
819
+ - retry, from ``retry_scheduled`` to the next ``begin_exchange``: the id
820
+ minted for the next exchange;
821
+ - landing, from one ``cycle_finished`` to the next: the exchange context
822
+ the landed tool operations and hosted calls hang under;
823
+ - landing to dispatch: the tool operations minted for landed calls. This
824
+ ledger runs with or without a bound context; each entry settles under
825
+ the context it landed in.
826
+
827
+ ``retry_policy`` is read only to classify a failed exchange as retryable.
828
+ """
829
+
830
+ def __init__(self, root: TrajectoryContext | None, *, retry_policy: WireRetryPolicy | None) -> None:
831
+ self._root = root
832
+ self._retry_policy = retry_policy
833
+ self._cycle: TrajectoryContext | None = None
834
+ self._cycle_started_ns = 0
835
+ self._cycle_exchange_count = 0
836
+ self._active_exchange: ExchangeTrace | None = None
837
+ self._pending_exchange_id: str | None = None
838
+ self._last_exchange_id: str | None = None
839
+ self._landed_exchange: TrajectoryContext | None = None
840
+ self._undispatched_tool_operations: dict[int, tuple[Content, TrajectoryContext | None]] = {}
841
+
842
+ @property
843
+ def last_exchange_id(self) -> str | None:
844
+ return self._last_exchange_id
845
+
846
+ async def _emit(self, draft: EventDraft) -> None:
847
+ if self._root is None:
848
+ return
849
+ try:
850
+ await self._root.sink.emit(draft)
851
+ except Exception:
852
+ logger.debug("Trajectory emit failed", exc_info=True)
853
+
854
+ async def cycle_started(self, cycle_index: int, *, tools_offered: bool) -> None:
855
+ root = self._root
856
+ if root is None:
857
+ return
858
+ cycle_id = new_analytics_id()
859
+ cycle = root.with_cycle(cycle_id)
860
+ self._cycle = cycle
861
+ self._cycle_started_ns = monotonic_ns()
862
+ self._cycle_exchange_count = 0
863
+ self._last_exchange_id = None
864
+ await self._emit(
865
+ cycle.draft(
866
+ TrajectoryEventType.MODEL_CYCLE_STARTED,
867
+ operation_id=cycle_id,
868
+ parent_operation_id=root.run_operation_id,
869
+ payload={"cycle_index": cycle_index, "tools_offered": tools_offered},
870
+ )
871
+ )
872
+
873
+ def _cycle_finished_draft(self, *, outcome: str, function_call_count: int = 0) -> EventDraft | None:
874
+ cycle = self._cycle
875
+ if cycle is None or cycle.cycle_operation_id is None:
876
+ return None
877
+ self._landed_exchange = cycle.with_exchange(self._last_exchange_id)
878
+ return cycle.draft(
879
+ TrajectoryEventType.MODEL_CYCLE_FINISHED,
880
+ operation_id=cycle.cycle_operation_id,
881
+ parent_operation_id=cycle.run_operation_id,
882
+ payload={
883
+ "outcome": outcome,
884
+ "exchange_count": self._cycle_exchange_count,
885
+ "function_call_count": function_call_count,
886
+ # Monotonic at both ends: a wall clock that steps mid-cycle
887
+ # would otherwise stretch or flatten the span it measures.
888
+ "duration_ms": max(0, (monotonic_ns() - self._cycle_started_ns) // 1_000_000),
889
+ "final_exchange_operation_id": self._last_exchange_id,
890
+ },
891
+ measurements={"/payload/duration_ms": measurement(MeasurementSource.MONOTONIC_CLOCK, method_version=1)},
892
+ )
893
+
894
+ async def cycle_finished(self, response: ChatResponse, function_calls: Sequence[Content]) -> None:
895
+ """Close the cycle *response* landed in and hand its calls to the operation ledger."""
896
+ draft = self._cycle_finished_draft(outcome=ExchangeOutcome.SUCCESS, function_call_count=len(function_calls))
897
+ self._register_landed_tool_operations(response, function_calls)
898
+ if draft is None:
899
+ return
900
+ await self._hosted_calls(response)
901
+ # Given up only here: an interrupt during the hosted-call records
902
+ # above still leaves the cycle for the abort path to close.
903
+ self._cycle = None
904
+ await self._emit(draft)
905
+
906
+ async def _hosted_calls(self, response: ChatResponse) -> None:
907
+ # Hosted calls never become tool operations (the provider ran
908
+ # them); one fact per call keeps the exchange's fan-out visible
909
+ # without an unbounded array on the exchange event.
910
+ exchange = self._landed_exchange
911
+ if exchange is None or exchange.exchange_operation_id is None:
912
+ return
913
+ for ordinal, hosted_kind in enumerate(_hosted_call_kinds(response)):
914
+ await self._emit(
915
+ exchange.draft(
916
+ TrajectoryEventType.HOSTED_CALL_OBSERVED,
917
+ operation_id=new_analytics_id(),
918
+ parent_operation_id=exchange.exchange_operation_id,
919
+ payload={
920
+ "parent_exchange_operation_id": exchange.exchange_operation_id,
921
+ "hosted_kind": hosted_kind,
922
+ "ordinal": ordinal,
923
+ },
924
+ )
925
+ )
926
+
927
+ def abort(self, outcome: str) -> None:
928
+ # Synchronous close for exits that cannot await (cancellation,
929
+ # generator close): the open exchange and cycle are queued in
930
+ # order without waiting for the ack.
931
+ self.close_exchange(outcome)
932
+ draft = self._cycle_finished_draft(outcome=outcome)
933
+ self._cycle = None
934
+ if draft is not None and self._root is not None:
935
+ try:
936
+ self._root.sink.emit_soon(draft)
937
+ except Exception:
938
+ logger.debug("Trajectory cycle close failed", exc_info=True)
939
+
940
+ def exchange_scope(self) -> trajectory_scope:
941
+ # Ambient context for the tool batch a landed response spawns:
942
+ # tool middleware hangs its operations under the producing
943
+ # exchange. Without a loop-owned context the ambient one (if any)
944
+ # is simply re-bound.
945
+ if self._landed_exchange is None:
946
+ return trajectory_scope(current_trajectory())
947
+ return trajectory_scope(self._landed_exchange)
948
+
949
+ def begin_exchange(self, client_kwargs: dict[str, Any]) -> dict[str, Any]:
950
+ """Open the exchange for the next wire attempt; returns the kwargs that carry it."""
951
+ self._active_exchange = None
952
+ cycle = self._cycle
953
+ if cycle is None:
954
+ return client_kwargs
955
+ exchange_id = self._pending_exchange_id or new_analytics_id()
956
+ self._pending_exchange_id = None
957
+ self._last_exchange_id = exchange_id
958
+ self._cycle_exchange_count += 1
959
+ self._active_exchange = ExchangeTrace(cycle.with_exchange(exchange_id))
960
+ return {**client_kwargs, TRAJECTORY_EXCHANGE_KWARG: self._active_exchange}
961
+
962
+ def stall_observed(self) -> None:
963
+ if self._active_exchange is not None:
964
+ self._active_exchange.stall_observed()
965
+
966
+ def close_exchange(self, outcome: str, *, exc: BaseException | None = None) -> None:
967
+ # Idempotent: a wire client that already reported its own terminal
968
+ # marker wins; this closes the abandoned/failed remainder.
969
+ trace = self._active_exchange
970
+ if trace is None:
971
+ return
972
+ self._active_exchange = None
973
+ # A middleware beneath the loop may have re-issued the request in
974
+ # place (validation retry): the handle then names the final
975
+ # exchange and every re-issue was one more acquisition.
976
+ self._last_exchange_id = trace.operation_id
977
+ self._cycle_exchange_count += trace.generation
978
+ payload: dict[str, Any] = {}
979
+ if exc is not None:
980
+ payload["error_code"] = type(exc).__name__
981
+ policy = self._retry_policy
982
+ payload["retryable"] = bool(policy is not None and policy.is_retryable(exc))
983
+ try:
984
+ trace.finished(outcome=outcome, payload=payload)
985
+ except Exception:
986
+ logger.debug("Trajectory exchange close failed", exc_info=True)
987
+
988
+ async def retry_scheduled(
989
+ self,
990
+ *,
991
+ reason_code: str,
992
+ retry_mode: str,
993
+ delay_seconds: int,
994
+ fallback_to_blocking: bool,
995
+ committed_work_present: bool,
996
+ ) -> None:
997
+ cycle = self._cycle
998
+ if cycle is None:
999
+ return
1000
+ previous = self._last_exchange_id
1001
+ next_id = new_analytics_id()
1002
+ self._pending_exchange_id = next_id
1003
+ await self._emit(
1004
+ cycle.draft(
1005
+ TrajectoryEventType.RETRY_SCHEDULED,
1006
+ operation_id=next_id,
1007
+ parent_operation_id=cycle.cycle_operation_id,
1008
+ payload={
1009
+ "reason_code": reason_code,
1010
+ "delay_ms": max(0, int(delay_seconds * 1000)),
1011
+ "retry_mode": retry_mode,
1012
+ "previous_operation_id": previous,
1013
+ "committed_work_present": committed_work_present,
1014
+ "fallback_to_blocking": fallback_to_blocking,
1015
+ },
1016
+ )
1017
+ )
1018
+
1019
+ async def retry_started(self, *, retry_mode: str) -> None:
1020
+ cycle = self._cycle
1021
+ next_id = self._pending_exchange_id
1022
+ if cycle is None or next_id is None:
1023
+ return
1024
+ await self._emit(
1025
+ cycle.draft(
1026
+ TrajectoryEventType.RETRY_STARTED,
1027
+ operation_id=next_id,
1028
+ parent_operation_id=cycle.cycle_operation_id,
1029
+ payload={
1030
+ "retry_mode": retry_mode,
1031
+ "next_operation_id": next_id,
1032
+ "previous_operation_id": self._last_exchange_id,
1033
+ },
1034
+ )
1035
+ )
1036
+
1037
+ def _register_landed_tool_operations(
1038
+ self,
1039
+ response: ChatResponse,
1040
+ function_calls: Sequence[Content],
1041
+ ) -> None:
1042
+ """Mint and remember every operation until the one dispatch point takes it."""
1043
+ _stamp_function_call_operations(response, function_calls)
1044
+ operation_context = self._landed_exchange or current_trajectory()
1045
+ for function_call in function_calls:
1046
+ self._undispatched_tool_operations[id(function_call)] = (function_call, operation_context)
1047
+
1048
+ def mark_tool_operations_dispatched(self, function_calls: Sequence[Content]) -> None:
1049
+ for function_call in function_calls:
1050
+ self._undispatched_tool_operations.pop(id(function_call), None)
1051
+
1052
+ async def settle_undispatched_tool_operations(self, *, queued: bool = False) -> None:
1053
+ """Close every operation minted but never handed to the execution batch.
1054
+
1055
+ This is the single reconciliation point for no-tools responses,
1056
+ exhaustion tails, future early returns, and failures between
1057
+ landing and dispatch. Every entry keeps the exchange context it
1058
+ landed under rather than consulting the loop's later current one.
1059
+ """
1060
+ pending = list(self._undispatched_tool_operations.values())
1061
+ self._undispatched_tool_operations.clear()
1062
+ first_failure: BaseException | None = None
1063
+ for function_call, operation_context in pending:
1064
+ try:
1065
+ with trajectory_scope(operation_context):
1066
+ await _record_unexecuted_tool_operation(
1067
+ function_call,
1068
+ outcome=ToolOutcome.FILTERED,
1069
+ queued=queued,
1070
+ )
1071
+ except BaseException as exc:
1072
+ # The helper settles the current pair before propagating a
1073
+ # cancelled ack. Continue so one cancellation cannot strand
1074
+ # the rest of a parallel batch, then preserve the caller's
1075
+ # original control-flow signal.
1076
+ if first_failure is None:
1077
+ first_failure = exc
1078
+ if first_failure is not None:
1079
+ raise first_failure
1080
+
1081
+
1082
+ class _WireCaller:
1083
+ """The loop's logical model calls and their wire retry lanes.
1084
+
1085
+ A logical call is one ``blocking_response`` or ``streaming_response``:
1086
+ every request it sends until a response lands — continuation polls,
1087
+ transient and stall retries, the stall fallback to a blocking request
1088
+ and one context-overflow resend. Retries need a wire policy, which a
1089
+ service-side run never gets.
1090
+
1091
+ State, by lifetime: the continuation token last mirrored to the retry
1092
+ owner lives for the run; the overflow-resend flag for one logical call
1093
+ (the stall fallback inherits it); the transient and stall counters for
1094
+ one ``blocking_response`` call or the streaming attempts of one
1095
+ ``streaming_response``, so the stall fallback's blocking request starts
1096
+ a fresh transient budget; the provider stream and its usage chunks for
1097
+ one request. Of the run's options, only ``continuation_token`` is
1098
+ written here.
1099
+ """
1100
+
1101
+ def __init__(
1102
+ self,
1103
+ inner: ChatMiddlewareLayer,
1104
+ *,
1105
+ options: dict[str, Any],
1106
+ client_kwargs: dict[str, Any],
1107
+ chat_middleware: list[ChatMiddleware],
1108
+ compaction_strategy: Any,
1109
+ tokenizer: Any,
1110
+ policy: WireRetryPolicy | None,
1111
+ recorder: LoopRecorder | None,
1112
+ injection_probe: ConsumedInjectionMessageProbe | None,
1113
+ continuation_token_observer: Callable[[Any], None] | None,
1114
+ trajectory: _LoopTrajectory,
1115
+ ) -> None:
1116
+ self._inner = inner
1117
+ self._options = options
1118
+ self._client_kwargs = client_kwargs
1119
+ self._chat_middleware = chat_middleware
1120
+ self._compaction_strategy = compaction_strategy
1121
+ self._tokenizer = tokenizer
1122
+ self._policy = policy
1123
+ self._recorder = recorder
1124
+ self._injection_probe = injection_probe
1125
+ self._continuation_token_observer = continuation_token_observer
1126
+ self._trajectory = trajectory
1127
+ # Sentinel start: the logical call may begin with a retry-owned token
1128
+ # already in its options (whole-run retry resuming a background
1129
+ # response), so the first tracked value — including a terminal
1130
+ # ``None`` — must always be applied, never swallowed by dedupe.
1131
+ self._last_tracked_continuation_token: Any = _UNTRACKED
1132
+
1133
+ def cancel_outcome(self) -> str:
1134
+ policy = self._policy
1135
+ return (
1136
+ ExchangeOutcome.INTERRUPTED if policy is not None and policy.is_interrupted() else ExchangeOutcome.CANCELLED
1137
+ )
1138
+
1139
+ def _track_continuation_token(self, token: Any) -> None:
1140
+ # Mirror the live token into the retry owner's request state: a
1141
+ # transient poll failure must resume THIS response on the next
1142
+ # attempt (outer whole-run retry included), never re-issue the
1143
+ # original create request. Providers re-announce the id on every
1144
+ # progress event, so identical repeats are dropped.
1145
+ if token == self._last_tracked_continuation_token:
1146
+ return
1147
+ self._last_tracked_continuation_token = token
1148
+ if token is None:
1149
+ self._options.pop("continuation_token", None)
1150
+ else:
1151
+ self._options["continuation_token"] = token
1152
+ if self._continuation_token_observer is not None:
1153
+ self._continuation_token_observer(token)
1154
+
1155
+ async def _call(
1156
+ self,
1157
+ prepped: list[Message],
1158
+ *,
1159
+ as_stream: bool,
1160
+ stream_update_filter: Callable[[ChatResponseUpdate], ChatResponseUpdate] | None = None,
1161
+ request_message_observer: Callable[[Sequence[Message]], None],
1162
+ ) -> Any:
1163
+ # The recorder keeps the loop's canonical objects (its identity
1164
+ # dedup depends on re-recorded history being the SAME objects).
1165
+ if self._recorder is not None:
1166
+ await self._recorder.record_pre_call(prepped)
1167
+ # EVERY outgoing message is a per-call view — caller history
1168
+ # included — so a client mutating a received message in place can
1169
+ # corrupt neither the loop's transcript nor the caller's
1170
+ # session-state objects. Message-metadata write-through survives:
1171
+ # views share the wrapper's additional_properties dict, which is
1172
+ # how compaction exclusion flags reach stored history.
1173
+ wire_view = [_wire_message_view(m) for m in prepped]
1174
+ return self._inner.get_response(
1175
+ wire_view,
1176
+ stream=as_stream,
1177
+ stream_update_filter=stream_update_filter,
1178
+ request_message_observer=request_message_observer,
1179
+ options=self._options,
1180
+ middleware=self._chat_middleware,
1181
+ compaction_strategy=self._compaction_strategy,
1182
+ tokenizer=self._tokenizer,
1183
+ client_kwargs=self._trajectory.begin_exchange(self._client_kwargs),
1184
+ )
1185
+
1186
+ async def _schedule_retry(
1187
+ self,
1188
+ policy: WireRetryPolicy,
1189
+ exc: BaseException,
1190
+ *,
1191
+ message: str,
1192
+ attempt: int,
1193
+ max_attempts: int,
1194
+ delay_seconds: int | None = None,
1195
+ fallback_to_blocking: bool = False,
1196
+ retry_mode: str | None = None,
1197
+ ) -> None:
1198
+ if self._options.get("continuation_token") is None:
1199
+ # A retry that resumes an already-created response via its
1200
+ # continuation token must NOT replay consumed injections: the
1201
+ # create that consumed them succeeded (the provider holds
1202
+ # them), and a poll never consumes a replay — the batch would
1203
+ # sit stranded until commit destroys it.
1204
+ policy.before_retry()
1205
+ delay = policy.backoff_seconds(attempt - 1) if delay_seconds is None else delay_seconds
1206
+ stalled = isinstance(exc, StreamStall)
1207
+ if retry_mode is None:
1208
+ retry_mode = RetryMode.STALL_FALLBACK if fallback_to_blocking else RetryMode.WIRE
1209
+ recorder = self._recorder
1210
+ await self._trajectory.retry_scheduled(
1211
+ reason_code=RetryReason.STREAM_STALL if stalled else RetryReason.TRANSIENT_ERROR,
1212
+ retry_mode=retry_mode,
1213
+ delay_seconds=delay,
1214
+ fallback_to_blocking=fallback_to_blocking,
1215
+ committed_work_present=recorder is not None and recorder.committed_count > 0,
1216
+ )
1217
+ await policy.on_retry(message, attempt, max_attempts, delay, exc)
1218
+ if policy.is_interrupted() or await policy.sleep(delay):
1219
+ raise asyncio.CancelledError
1220
+ await self._trajectory.retry_started(retry_mode=retry_mode)
1221
+
1222
+ async def _pause_between_continuation_polls(self) -> None:
1223
+ # Task cancellation is the interrupt channel for both service and
1224
+ # local runs; the policy flag check covers soft interrupts that
1225
+ # only set state.
1226
+ policy = self._policy
1227
+ if policy is not None and policy.is_interrupted():
1228
+ raise asyncio.CancelledError
1229
+ if CONTINUATION_POLL_INTERVAL_SECONDS > 0:
1230
+ await asyncio.sleep(CONTINUATION_POLL_INTERVAL_SECONDS)
1231
+
1232
+ def _hosted_commits_vetoing_replay(self, policy: WireRetryPolicy) -> tuple[str, ...]:
1233
+ # Without a live continuation token a retry re-creates the request
1234
+ # and re-runs the hosted tool calls the failed attempt already
1235
+ # executed server-side; with one it merely resumes the same
1236
+ # response, which is safe.
1237
+ hosted_probe = policy.hosted_commits_in_flight
1238
+ hosted_commits = tuple(hosted_probe()) if hosted_probe is not None else ()
1239
+ if hosted_commits and self._options.get("continuation_token") is None:
1240
+ return hosted_commits
1241
+ return ()
1242
+
1243
+ def _note_context_overflow(self, exc: BaseException) -> bool:
1244
+ # The provider measured the real input and found the window full:
1245
+ # the strategy compacts before the next request instead of letting
1246
+ # it resend the rejected input. Runs before any retry decision, so
1247
+ # service-side runs (no wire policy) are noted too. Returns whether
1248
+ # compacting and resending can help. Thinking the service refused
1249
+ # as bound to another conversation is no full window, even when the
1250
+ # refusal names it: the client resends without that thinking, or
1251
+ # the profile asked for the refusal.
1252
+ strategy = self._compaction_strategy
1253
+ if (
1254
+ not isinstance(strategy, ContextOverflowSink)
1255
+ or not is_context_overflow(exc)
1256
+ or is_thinking_binding_rejection(exc)
1257
+ ):
1258
+ return False
1259
+ return strategy.note_context_overflow(exc)
1260
+
1261
+ def _resends_after_overflow(self, policy: WireRetryPolicy, *, noted: bool, recovered: bool) -> bool:
1262
+ # One in-place resend per logical call, outside the transient and
1263
+ # stall budgets. Service-side runs have no wire policy and only
1264
+ # keep the note; a live continuation token would poll the rejected
1265
+ # response, and hosted work an attempt already ran must not rerun.
1266
+ return (
1267
+ noted
1268
+ and not recovered
1269
+ and self._options.get("continuation_token") is None
1270
+ and not self._hosted_commits_vetoing_replay(policy)
1271
+ )
1272
+
1273
+ async def _schedule_overflow_resend(self, policy: WireRetryPolicy, exc: BaseException) -> None:
1274
+ # The strategy holds the note, so the resend's client preparation
1275
+ # compacts before the request goes out.
1276
+ await self._schedule_retry(
1277
+ policy,
1278
+ exc,
1279
+ message=clean_error_message(exc),
1280
+ attempt=1,
1281
+ max_attempts=1,
1282
+ delay_seconds=0,
1283
+ retry_mode=RetryMode.CONTEXT_OVERFLOW,
1284
+ )
1285
+
1286
+ async def blocking_response(
1287
+ self,
1288
+ prepped: list[Message],
1289
+ *,
1290
+ request_message_observer: Callable[[Sequence[Message]], None],
1291
+ overflow_recovered: bool = False,
1292
+ ) -> ChatResponse:
1293
+ retry_attempt = 0
1294
+ while True:
1295
+ policy = self._policy
1296
+ if policy is not None and policy.is_interrupted():
1297
+ raise asyncio.CancelledError
1298
+ try:
1299
+ while True:
1300
+ response = await _resolve_response(
1301
+ await self._call(
1302
+ prepped,
1303
+ as_stream=False,
1304
+ request_message_observer=request_message_observer,
1305
+ )
1306
+ )
1307
+ self._trajectory.close_exchange(ExchangeOutcome.SUCCESS)
1308
+ if response.continuation_token is None:
1309
+ self._track_continuation_token(None)
1310
+ if self._injection_probe is not None:
1311
+ self._injection_probe.commit()
1312
+ return response
1313
+ self._track_continuation_token(response.continuation_token)
1314
+ await self._pause_between_continuation_polls()
1315
+ except asyncio.CancelledError:
1316
+ self._trajectory.close_exchange(self.cancel_outcome())
1317
+ raise
1318
+ except Exception as exc:
1319
+ self._trajectory.close_exchange(ExchangeOutcome.ERROR, exc=exc)
1320
+ if invalidates_continuation_token(exc):
1321
+ # The failure judged a terminal response: a retry must
1322
+ # issue a fresh request, never re-poll the completed
1323
+ # (immutable) one.
1324
+ self._track_continuation_token(None)
1325
+ noted = self._note_context_overflow(exc)
1326
+ if policy is not None and self._resends_after_overflow(
1327
+ policy, noted=noted, recovered=overflow_recovered
1328
+ ):
1329
+ overflow_recovered = True
1330
+ await self._schedule_overflow_resend(policy, exc)
1331
+ continue
1332
+ if policy is None or not policy.is_retryable(exc) or retry_attempt >= policy.max_retries:
1333
+ raise
1334
+ hosted_commits = self._hosted_commits_vetoing_replay(policy)
1335
+ if hosted_commits:
1336
+ logger.warning(
1337
+ "Not retrying wire call: provider-hosted tool call(s) already "
1338
+ "executed in the failed attempt (%s)",
1339
+ ", ".join(str(label) for label in hosted_commits),
1340
+ )
1341
+ raise
1342
+ retry_attempt += 1
1343
+ await self._schedule_retry(
1344
+ policy,
1345
+ exc,
1346
+ message=clean_error_message(exc),
1347
+ attempt=retry_attempt,
1348
+ max_attempts=policy.max_retries,
1349
+ )
1350
+
1351
+ def streaming_response(
1352
+ self,
1353
+ prepped: list[Message],
1354
+ *,
1355
+ stream_update_filter: Callable[[ChatResponseUpdate], ChatResponseUpdate],
1356
+ request_message_observer: Callable[[Sequence[Message]], None],
1357
+ ) -> ResponseStream[ChatResponseUpdate, ChatResponse]:
1358
+ final_response: ChatResponse | None = None
1359
+ # The provider stream of the attempt being read; None once it was
1360
+ # read to its end.
1361
+ inner_stream: ResponseStream[ChatResponseUpdate, ChatResponse] | None = None
1362
+ logical_stream: ResponseStream[ChatResponseUpdate, ChatResponse]
1363
+
1364
+ async def _updates() -> AsyncIterable[ChatResponseUpdate]:
1365
+ nonlocal final_response, inner_stream
1366
+ retry_attempt = 0
1367
+ stall_retry_attempt = 0
1368
+ overflow_recovered = False
1369
+ while True:
1370
+ policy = self._policy
1371
+ if policy is not None and policy.is_interrupted():
1372
+ raise asyncio.CancelledError
1373
+ inner_stream = None
1374
+ try:
1375
+ while True:
1376
+ raw_stream = await self._call(
1377
+ prepped,
1378
+ as_stream=True,
1379
+ stream_update_filter=stream_update_filter,
1380
+ request_message_observer=request_message_observer,
1381
+ )
1382
+ if not isinstance(raw_stream, ResponseStream):
1383
+ raise TypeError("Streaming wire call did not return a ResponseStream.")
1384
+ inner_stream = raw_stream
1385
+ inner_stream.with_update_filter(stream_update_filter)
1386
+ stream_usage_chunks: list[Mapping[str, Any]] = []
1387
+ iterator = inner_stream.__aiter__()
1388
+ while True:
1389
+ try:
1390
+ update = await _watchdog_await(
1391
+ iterator.__anext__(),
1392
+ policy.stall_timeout_seconds if policy is not None else None,
1393
+ "Streaming response",
1394
+ )
1395
+ except StopAsyncIteration:
1396
+ break
1397
+ if update.continuation_token is not None:
1398
+ # Providers announce the background
1399
+ # response id mid-stream; mirror it
1400
+ # immediately so a disconnect before
1401
+ # finalization retries by retrieval, not
1402
+ # by a duplicate create.
1403
+ self._track_continuation_token(update.continuation_token)
1404
+ stream_usage_chunks.extend(_stream_usage_chunks(update))
1405
+ yield update
1406
+ response = await _watchdog_await(
1407
+ inner_stream.get_final_response(),
1408
+ policy.stall_timeout_seconds if policy is not None else None,
1409
+ "Streaming response finalization",
1410
+ )
1411
+ inner_stream = None
1412
+ self._trajectory.close_exchange(ExchangeOutcome.SUCCESS)
1413
+ response.latest_usage_details = normalize_stream_usage(stream_usage_chunks)
1414
+ if response.latest_usage_details is None:
1415
+ response.latest_usage_details = response.usage_details
1416
+ if response.continuation_token is None:
1417
+ self._track_continuation_token(None)
1418
+ final_response = response
1419
+ if self._injection_probe is not None:
1420
+ self._injection_probe.commit()
1421
+ return
1422
+ self._track_continuation_token(response.continuation_token)
1423
+ await self._pause_between_continuation_polls()
1424
+ except asyncio.CancelledError:
1425
+ self._trajectory.close_exchange(self.cancel_outcome())
1426
+ if inner_stream is not None:
1427
+ await inner_stream.aclose()
1428
+ raise
1429
+ except Exception as exc:
1430
+ if isinstance(exc, StreamStall):
1431
+ self._trajectory.stall_observed()
1432
+ self._trajectory.close_exchange(ExchangeOutcome.STALLED, exc=exc)
1433
+ else:
1434
+ self._trajectory.close_exchange(ExchangeOutcome.ERROR, exc=exc)
1435
+ if inner_stream is not None:
1436
+ try:
1437
+ await inner_stream.aclose()
1438
+ except Exception:
1439
+ logger.debug("Failed to close abandoned provider stream", exc_info=True)
1440
+ if invalidates_continuation_token(exc):
1441
+ # The failure judged a terminal response: a retry
1442
+ # must issue a fresh request, never re-poll the
1443
+ # completed (immutable) one.
1444
+ self._track_continuation_token(None)
1445
+ noted = self._note_context_overflow(exc)
1446
+ if policy is None:
1447
+ raise
1448
+ hosted_commits = self._hosted_commits_vetoing_replay(policy)
1449
+ if hosted_commits:
1450
+ logger.warning(
1451
+ "Not replaying wire call: provider-hosted tool call(s) already "
1452
+ "executed in the aborted stream (%s)",
1453
+ ", ".join(str(label) for label in hosted_commits),
1454
+ )
1455
+ raise
1456
+ if self._resends_after_overflow(policy, noted=noted, recovered=overflow_recovered):
1457
+ overflow_recovered = True
1458
+ await self._schedule_overflow_resend(policy, exc)
1459
+ logical_stream._updates.clear()
1460
+ yield ChatResponseUpdate.retry_boundary()
1461
+ continue
1462
+ if isinstance(exc, StreamStall):
1463
+ if stall_retry_attempt >= policy.stall_max_retries:
1464
+ if policy.stall_exhausted_action is StallExhaustedAction.RAISE:
1465
+ raise
1466
+ await self._schedule_retry(
1467
+ policy,
1468
+ exc,
1469
+ message="Stream stalled; retrying with a blocking response",
1470
+ attempt=stall_retry_attempt + 1,
1471
+ max_attempts=policy.stall_max_retries + 1,
1472
+ delay_seconds=0,
1473
+ fallback_to_blocking=True,
1474
+ )
1475
+ logical_stream._updates.clear()
1476
+ yield ChatResponseUpdate.retry_boundary()
1477
+ final_response = await self.blocking_response(
1478
+ prepped,
1479
+ request_message_observer=request_message_observer,
1480
+ overflow_recovered=overflow_recovered,
1481
+ )
1482
+ return
1483
+ stall_retry_attempt += 1
1484
+ await self._schedule_retry(
1485
+ policy,
1486
+ exc,
1487
+ message="Stream stalled",
1488
+ attempt=stall_retry_attempt,
1489
+ max_attempts=policy.stall_max_retries,
1490
+ )
1491
+ logical_stream._updates.clear()
1492
+ yield ChatResponseUpdate.retry_boundary()
1493
+ continue
1494
+ if not policy.is_retryable(exc) or retry_attempt >= policy.max_retries:
1495
+ raise
1496
+ retry_attempt += 1
1497
+ await self._schedule_retry(
1498
+ policy,
1499
+ exc,
1500
+ message=clean_error_message(exc),
1501
+ attempt=retry_attempt,
1502
+ max_attempts=policy.max_retries,
1503
+ )
1504
+ logical_stream._updates.clear()
1505
+ yield ChatResponseUpdate.retry_boundary()
1506
+
1507
+ def _finalizer(_updates: Sequence[ChatResponseUpdate]) -> ChatResponse:
1508
+ if final_response is None:
1509
+ raise RuntimeError("Logical streaming call ended without a final response.")
1510
+ return final_response
1511
+
1512
+ async def _close_abandoned_attempt() -> None:
1513
+ # A close of this logical call mid-response closes the provider
1514
+ # stream it was reading, as ``_LoopRun.close_abandoned_read`` does
1515
+ # one level up; one already closed or read to its end is a no-op.
1516
+ if inner_stream is not None:
1517
+ try:
1518
+ await inner_stream.aclose()
1519
+ except Exception:
1520
+ logger.debug("Failed to close abandoned provider stream", exc_info=True)
1521
+
1522
+ logical_stream = ResponseStream(_updates(), finalizer=_finalizer).with_cleanup_hook(_close_abandoned_attempt)
1523
+ return logical_stream
1524
+
1525
+
1526
+ class _LoopRun:
1527
+ """One ``ToolLoopLayer.get_response`` run: the two loop drivers and the steps they share.
1528
+
1529
+ ``run_blocking`` and ``stream`` (assembled by ``finalize_stream``) drive
1530
+ the same iterations: send the prepped messages, land the response, close
1531
+ its cycle, run the landed calls as one batch and queue the next request —
1532
+ until no local call is left, or the iteration budget runs out and a final
1533
+ request without tools ends the run. They differ in how usage is counted
1534
+ and how a terminating batch ends the run; the streaming driver also
1535
+ yields between steps and withholds service-side handles ahead of the
1536
+ yields a consumer may stop at. Every step the two drive identically is a
1537
+ method here.
1538
+
1539
+ The run's transcript state — echo registry, prepped and accumulated loop
1540
+ messages, tool ordinal, error and call counters, aggregated usage — lives
1541
+ for the run; the caller's history is read when a driver starts. Only the
1542
+ streaming driver writes ``_latest_usage``, ``_final_messages`` and
1543
+ ``_service_state_invalidated``, which ``finalize_stream`` reads, and
1544
+ ``_reading``, which ``close_abandoned_read`` reads and clears.
1545
+
1546
+ Writes outside the run: ``tool_choice`` in the run's options, and
1547
+ ``tools`` through each tool batch (normalized per batch, so the next
1548
+ request offers the live list); the recorder's journal (landed responses,
1549
+ staged exchanges and their sealed results); the injection probe's drain;
1550
+ continuation state in the client kwargs, options and session through
1551
+ ``_update_continuation_state`` and ``_invalidate_service_continuation_state``;
1552
+ and the streaming driver's two session pre-clears. ``_WireCaller`` writes
1553
+ the options' ``continuation_token``.
1554
+ """
1555
+
1556
+ def __init__(
1557
+ self,
1558
+ messages: Sequence[Message],
1559
+ *,
1560
+ options: dict[str, Any],
1561
+ client_kwargs: dict[str, Any],
1562
+ service_side: bool,
1563
+ session: AgentSession | None,
1564
+ recorder: LoopRecorder | None,
1565
+ injection_probe: ConsumedInjectionMessageProbe | None,
1566
+ pipeline: FunctionMiddlewarePipeline,
1567
+ additional_function_arguments: dict[str, Any],
1568
+ request_message_observer: Callable[[Sequence[Message]], None] | None,
1569
+ max_iterations: int,
1570
+ max_consecutive_errors: int,
1571
+ max_function_calls: int | None,
1572
+ tool_result_ceiling_tokens: int | None,
1573
+ trajectory: _LoopTrajectory,
1574
+ wire: _WireCaller,
1575
+ ) -> None:
1576
+ self._messages = messages
1577
+ self._options = options
1578
+ self._client_kwargs = client_kwargs
1579
+ self._service_side = service_side
1580
+ self._session = session
1581
+ self._recorder = recorder
1582
+ self._injection_probe = injection_probe
1583
+ self._pipeline = pipeline
1584
+ self._additional_function_arguments = additional_function_arguments
1585
+ self._max_iterations = max_iterations
1586
+ self._max_consecutive_errors = max_consecutive_errors
1587
+ self._max_function_calls = max_function_calls
1588
+ self._tool_result_ceiling_tokens = tool_result_ceiling_tokens
1589
+ self._trajectory = trajectory
1590
+ self._wire = wire
1591
+ self._response_format = options.get("response_format")
1592
+ # Per-run identity registry of every content the conversation
1593
+ # holds — seeded from caller history so a client echoing a
1594
+ # historical object cannot re-execute a past side-effecting
1595
+ # call; _land_response_contents removes reappearances as
1596
+ # echoes. ignore_usage: echo filtering never strips usage.
1597
+ self._echo_registry = WeakIdentityRegistry(ignore_usage=True)
1598
+ self._wire_request_observer = _wire_request_observer(self._echo_registry, request_message_observer)
1599
+ self._prepped_messages: list[Message] = []
1600
+ self._fcc_messages: list[Message] = []
1601
+ self._next_tool_ordinal = 0
1602
+ self._errors_in_a_row = 0
1603
+ self._total_function_calls = 0
1604
+ self._aggregated_usage: UsageDetails | None = None
1605
+ self._latest_usage: UsageDetails | None = None
1606
+ # Authoritative streamed transcript, recorded by each loop exit: the final response
1607
+ # reuses these already-assembled Message objects instead of re-merging
1608
+ # raw updates, so kernel-stamped call provenance survives streaming.
1609
+ # ``None`` means no loop exit completed — finalize_stream keeps the
1610
+ # ``from_updates`` merge as a defensive fallback.
1611
+ self._final_messages: list[Message] | None = None
1612
+ # Set by the streaming exits that withhold a service-stored response's
1613
+ # handles (termination, exhaustion strip): finalize_stream rebuilds
1614
+ # the response from raw updates, which still carry those continuation
1615
+ # ids, so it withholds them again.
1616
+ self._service_state_invalidated = False
1617
+ # The logical call the streaming driver is reading; None once it was
1618
+ # read to its end.
1619
+ self._reading: ResponseStream[ChatResponseUpdate, ChatResponse] | None = None
1620
+
1621
+ # -- steps both drivers share ------------------------------------------
1622
+
1623
+ def _start(self) -> None:
1624
+ for message in self._messages:
1625
+ for content in message.contents:
1626
+ self._echo_registry.register(content)
1627
+ self._prepped_messages = list(self._messages)
1628
+
1629
+ def _land(self, response: ChatResponse) -> None:
1630
+ self._next_tool_ordinal = _land_response_contents(
1631
+ response,
1632
+ self._next_tool_ordinal,
1633
+ self._echo_registry,
1634
+ exchange_operation_id=self._trajectory.last_exchange_id,
1635
+ )
1636
+
1637
+ def _take_consumed_injections(self) -> list[Message]:
1638
+ return self._injection_probe.take_consumed_messages() if self._injection_probe else []
1639
+
1640
+ def _record_response_state(self, response: ChatResponse) -> None:
1641
+ _update_continuation_state(
1642
+ self._client_kwargs,
1643
+ response,
1644
+ session=self._session,
1645
+ options=self._options,
1646
+ )
1647
+ if self._recorder is not None:
1648
+ self._recorder.record_response(response)
1649
+
1650
+ def _queue_consumed_injections(self, response: ChatResponse, consumed_injection_messages: list[Message]) -> None:
1651
+ if response.conversation_id is not None:
1652
+ self._prepped_messages = []
1653
+ else:
1654
+ self._prepped_messages.extend(consumed_injection_messages)
1655
+
1656
+ async def _finish_cycle(self, response: ChatResponse) -> list[Content]:
1657
+ function_calls = _extract_function_calls(response)
1658
+ await self._trajectory.cycle_finished(response, function_calls)
1659
+ return function_calls
1660
+
1661
+ async def _run_tool_batch(
1662
+ self, response: ChatResponse, function_calls: list[Content]
1663
+ ) -> tuple[list[Content], bool, str]:
1664
+ """Run the landed calls as one batch and fold their results into the loop messages.
1665
+
1666
+ Returns the results, whether middleware terminated the loop, and
1667
+ ``"stop"`` when the error streak asks for a final turn without tools.
1668
+ """
1669
+ result_carrier_item_id = new_analytics_id()
1670
+ result_commits = (
1671
+ self._recorder.stage_exchange(
1672
+ response.messages,
1673
+ function_calls,
1674
+ result_carrier_item_id=result_carrier_item_id,
1675
+ )
1676
+ if self._recorder is not None
1677
+ else None
1678
+ )
1679
+ response_truncated = response.finish_reason == "length"
1680
+ with self._trajectory.exchange_scope():
1681
+ results, should_terminate, had_errors = await _execute_function_calls(
1682
+ function_calls=function_calls,
1683
+ result_commits=result_commits,
1684
+ tool_options=self._options,
1685
+ custom_args=self._additional_function_arguments,
1686
+ invocation_session=self._session,
1687
+ pipeline=self._pipeline,
1688
+ result_carrier_item_id=result_carrier_item_id,
1689
+ response_truncated=response_truncated,
1690
+ truncated_final_function_call_ids=(
1691
+ _truncated_final_function_call_ids(response, function_calls) if response_truncated else None
1692
+ ),
1693
+ tool_result_ceiling_tokens=self._tool_result_ceiling_tokens,
1694
+ dispatch_observer=self._trajectory.mark_tool_operations_dispatched,
1695
+ )
1696
+ action, self._errors_in_a_row = _handle_function_call_results(
1697
+ response=response,
1698
+ function_call_results=results,
1699
+ fcc_messages=self._fcc_messages,
1700
+ echo_registry=self._echo_registry,
1701
+ errors_in_a_row=self._errors_in_a_row,
1702
+ had_errors=had_errors,
1703
+ max_errors=self._max_consecutive_errors,
1704
+ result_carrier_item_id=result_carrier_item_id,
1705
+ recorder=self._recorder,
1706
+ )
1707
+ self._total_function_calls += sum(1 for r in results if r.type == "function_result")
1708
+ return results, should_terminate, action
1709
+
1710
+ def _tool_limit_fallback_text(self, action: str) -> str | None:
1711
+ """Return the final turn's fallback text when the batch just run hit a tool limit, else None.
1712
+
1713
+ A hit limit ends the loop like iteration exhaustion: the collected
1714
+ results go out once more in the final turn without tools, whose calls
1715
+ are stripped, never run — a provider ignoring ``tool_choice="none"``
1716
+ cannot get another batch executed.
1717
+ """
1718
+ if action == "stop":
1719
+ return _CONSECUTIVE_ERRORS_FALLBACK_TEXT
1720
+ if self._max_function_calls is not None and self._total_function_calls >= self._max_function_calls:
1721
+ # Best-effort limit, checked after each parallel batch.
1722
+ logger.info(
1723
+ "Maximum function calls reached (%d/%d). Stopping further function calls for this request.",
1724
+ self._total_function_calls,
1725
+ self._max_function_calls,
1726
+ )
1727
+ return _MAX_FUNCTION_CALLS_FALLBACK_TEXT
1728
+ return None
1729
+
1730
+ def _reset_required_tool_choice(self) -> None:
1731
+ # 'required' tool_choice resets after one iteration.
1732
+ if self._options.get("tool_choice") == "required" or (
1733
+ isinstance(self._options.get("tool_choice"), dict)
1734
+ and self._options.get("tool_choice", {}).get("mode") == "required"
1735
+ ):
1736
+ self._options["tool_choice"] = None
1737
+
1738
+ def _queue_batch(self, response: ChatResponse) -> None:
1739
+ if response.conversation_id is not None:
1740
+ # Conversation APIs already hold the function-call message;
1741
+ # send only the new result message.
1742
+ self._prepped_messages.clear()
1743
+ if response.messages:
1744
+ self._prepped_messages.append(response.messages[-1])
1745
+ else:
1746
+ self._prepped_messages.extend(response.messages)
1747
+
1748
+ def _log_iterations_exhausted(self, response: ChatResponse | None) -> None:
1749
+ if response is not None:
1750
+ logger.info(
1751
+ "Maximum iterations reached (%d). Requesting final response without tools.",
1752
+ self._max_iterations,
1753
+ )
1754
+
1755
+ # -- blocking driver ----------------------------------------------------
1756
+
1757
+ async def run_blocking(self) -> ChatResponse:
1758
+ interrupted = False
1759
+ try:
1760
+ return await self._blocking_iterations()
1761
+ except asyncio.CancelledError:
1762
+ # Nothing below may wait on the writer once the consumer
1763
+ # is gone: the settlement queues its lines instead.
1764
+ interrupted = True
1765
+ self._trajectory.abort(self._wire.cancel_outcome())
1766
+ raise
1767
+ except Exception:
1768
+ self._trajectory.abort(ExchangeOutcome.ERROR)
1769
+ raise
1770
+ finally:
1771
+ await self._trajectory.settle_undispatched_tool_operations(queued=interrupted)
1772
+
1773
+ async def _blocking_iterations(self) -> ChatResponse:
1774
+ self._start()
1775
+ response: ChatResponse | None = None
1776
+ tail_cycle_index = self._max_iterations
1777
+ fallback_text = _MAX_ITERATIONS_FALLBACK_TEXT
1778
+
1779
+ for cycle_index in range(self._max_iterations):
1780
+ await self._trajectory.cycle_started(cycle_index, tools_offered=bool(self._options.get("tools")))
1781
+ response = await self._wire.blocking_response(
1782
+ self._prepped_messages,
1783
+ request_message_observer=self._wire_request_observer,
1784
+ )
1785
+ self._land(response)
1786
+ consumed_injection_messages = self._take_consumed_injections()
1787
+ response.latest_usage_details = response.usage_details
1788
+ self._aggregated_usage = add_usage_details(self._aggregated_usage, response.usage_details)
1789
+ self._record_response_state(response)
1790
+ self._queue_consumed_injections(response, consumed_injection_messages)
1791
+
1792
+ function_calls = await self._finish_cycle(response)
1793
+ if not (function_calls and self._options.get("tools")):
1794
+ _prepend_fcc_messages(response, self._fcc_messages)
1795
+ response.usage_details = self._aggregated_usage
1796
+ return _clear_internal_conversation_id(response)
1797
+
1798
+ _results, should_terminate, action = await self._run_tool_batch(response, function_calls)
1799
+ if should_terminate:
1800
+ # Middleware termination: return the current response
1801
+ # (tool results already appended) without an fcc
1802
+ # prepend. Streaming termination instead returns the
1803
+ # accumulated transcript because earlier updates were delivered.
1804
+ if self._service_side:
1805
+ # No further request ever posts this batch's
1806
+ # results, so the service transcript behind the
1807
+ # mirrored handle keeps its calls unanswered.
1808
+ # Record and withhold the handles like the
1809
+ # exhaustion strip does; the marker lets the
1810
+ # agent post-hook install the local fallback.
1811
+ _invalidate_service_continuation_state(response, self._session)
1812
+ response.usage_details = self._aggregated_usage
1813
+ return _clear_internal_conversation_id(response)
1814
+ self._queue_batch(response)
1815
+ limit_text = self._tool_limit_fallback_text(action)
1816
+ if limit_text is not None:
1817
+ tail_cycle_index, fallback_text = cycle_index + 1, limit_text
1818
+ break
1819
+ self._reset_required_tool_choice()
1820
+ else:
1821
+ self._log_iterations_exhausted(response)
1822
+
1823
+ # Loop exhausted or a tool limit hit: final model call with
1824
+ # tool_choice="none" so the model produces plain text instead of
1825
+ # orphaned function calls.
1826
+ self._options["tool_choice"] = "none"
1827
+ await self._trajectory.cycle_started(tail_cycle_index, tools_offered=bool(self._options.get("tools")))
1828
+ response = await self._wire.blocking_response(
1829
+ self._prepped_messages,
1830
+ request_message_observer=self._wire_request_observer,
1831
+ )
1832
+ self._land(response)
1833
+ # Counted before the strip below: a provider that ignored
1834
+ # ``tool_choice="none"`` did ask for calls, and a tail that
1835
+ # reported none would read as a compliant one.
1836
+ await self._finish_cycle(response)
1837
+ stripped = _strip_unexecutable_calls_from_response(response)
1838
+ _ensure_exhaustion_fallback_response(response, fallback_text)
1839
+ # Gate on the resolved storage mode, not response metadata: under
1840
+ # conversation storage the service holds the stripped calls even
1841
+ # when the parsed response failed to carry the handle, and a
1842
+ # client-side-storage response's metadata must survive untouched.
1843
+ if stripped and self._service_side:
1844
+ _invalidate_service_continuation_state(response, self._session)
1845
+ self._take_consumed_injections()
1846
+ response.latest_usage_details = response.usage_details
1847
+ self._aggregated_usage = add_usage_details(self._aggregated_usage, response.usage_details)
1848
+ self._record_response_state(response)
1849
+ response.usage_details = self._aggregated_usage
1850
+ _prepend_fcc_messages(response, self._fcc_messages)
1851
+ return _clear_internal_conversation_id(response)
1852
+
1853
+ # -- streaming driver ---------------------------------------------------
1854
+
1855
+ def _count_streamed_usage(self, response: ChatResponse) -> None:
1856
+ latest_usage = response.latest_usage_details
1857
+ if latest_usage is None:
1858
+ latest_usage = response.usage_details
1859
+ response.latest_usage_details = latest_usage
1860
+ self._latest_usage = latest_usage
1861
+ if latest_usage is not None:
1862
+ self._aggregated_usage = add_usage_details(self._aggregated_usage, latest_usage)
1863
+
1864
+ async def stream(self) -> AsyncIterable[ChatResponseUpdate]:
1865
+ # One generator end to end: every exit but the normal return closes
1866
+ # the trajectory bookkeeping it opened. A consumer abandoning the
1867
+ # stream lands here as GeneratorExit, an interrupt as CancelledError.
1868
+ # Delegating the body to an inner generator would lose the
1869
+ # GeneratorExit: closing this generator does not close one it is
1870
+ # iterating, so the inner body would see it only at garbage collection.
1871
+ # For the same reason the logical call it is reading is closed apart,
1872
+ # by ``close_abandoned_read``.
1873
+ interrupted = False
1874
+ try:
1875
+ self._start()
1876
+ response: ChatResponse | None = None
1877
+ tail_cycle_index = self._max_iterations
1878
+ fallback_text = _MAX_ITERATIONS_FALLBACK_TEXT
1879
+
1880
+ for cycle_index in range(self._max_iterations):
1881
+ await self._trajectory.cycle_started(cycle_index, tools_offered=bool(self._options.get("tools")))
1882
+ # Echoed conversation-held objects must never reach update
1883
+ # assembly (merges would launder their identity past the
1884
+ # memo). Delivered on the REQUEST path so the middleware
1885
+ # pipeline attaches it to the stream its final handler
1886
+ # resolves — beneath every middleware, which is the only
1887
+ # placement that crosses semantic stream proxies (response
1888
+ # validation drains the inner stream and replays it) and
1889
+ # re-attaches on every validation retry. The provider
1890
+ # finalizer, every result hook, and the yielded updates all
1891
+ # observe one echo-free sequence, so nothing is re-assembled
1892
+ # behind a hook's back.
1893
+ echo_filter = functools.partial(
1894
+ _strip_echoed_update,
1895
+ echo_registry=self._echo_registry,
1896
+ )
1897
+ logical_stream = self._wire.streaming_response(
1898
+ self._prepped_messages,
1899
+ stream_update_filter=echo_filter,
1900
+ request_message_observer=self._wire_request_observer,
1901
+ )
1902
+ self._reading = logical_stream
1903
+ async for update in logical_stream:
1904
+ yield update
1905
+ self._reading = None
1906
+ # Triggers the inner stream's finalizer and result hooks (the
1907
+ # wire client's intermediate-text hook runs before tool
1908
+ # extraction below — "hook before tool detection" ordering).
1909
+ response = await logical_stream.get_final_response()
1910
+ _remove_echo_emptied_message_shells(response)
1911
+ self._land(response)
1912
+ _record_stream_fragment_identities(logical_stream, self._echo_registry)
1913
+ self._count_streamed_usage(response)
1914
+ consumed_injection_messages = self._take_consumed_injections()
1915
+ self._record_response_state(response)
1916
+ self._queue_consumed_injections(response, consumed_injection_messages)
1917
+
1918
+ # Continue only for unresolved local function calls.
1919
+ function_calls = await self._finish_cycle(response)
1920
+ if not (function_calls and self._options.get("tools")):
1921
+ self._final_messages = [*self._fcc_messages, *response.messages]
1922
+ return
1923
+
1924
+ results, should_terminate, action = await self._run_tool_batch(response, function_calls)
1925
+ limit_text = None if should_terminate else self._tool_limit_fallback_text(action)
1926
+ if should_terminate and self._service_side:
1927
+ # No further request ever posts this batch's results, so
1928
+ # the service transcript behind the mirrored handle keeps
1929
+ # its calls unanswered. Record and withhold the handles
1930
+ # like the exhaustion strip does — ahead of the
1931
+ # last-iteration clear below, which would drop the
1932
+ # session's copy unrecorded — and raise the run flag
1933
+ # so ``finalize_stream`` stamps the verdict on the
1934
+ # assembled response for the agent post-hook.
1935
+ self._service_state_invalidated = True
1936
+ _invalidate_service_continuation_state(response, self._session)
1937
+ elif (
1938
+ (limit_text is not None or cycle_index + 1 == self._max_iterations)
1939
+ and self._service_side
1940
+ and self._session is not None
1941
+ ):
1942
+ # Last batch (iterations exhausted or a tool limit hit):
1943
+ # the yield below is the final suspension point before
1944
+ # the exhaustion tail, and these results are not posted
1945
+ # to the service until the tail request lands.
1946
+ # A consumer closing at that yield must not inherit the
1947
+ # mirrored handle — the service transcript behind it still
1948
+ # holds this batch's unanswered calls. The finalized tail
1949
+ # restores the fresh handle via
1950
+ # ``_update_continuation_state``.
1951
+ self._session.service_session_id = None
1952
+ # Synthesized tool-result update so from_updates can rebuild the
1953
+ # full transcript.
1954
+ yield ChatResponseUpdate(contents=results, role="tool")
1955
+ if should_terminate:
1956
+ # This batch (calls + results) was already folded into
1957
+ # _fcc_messages by _handle_function_call_results, so the
1958
+ # assembly is the accumulated transcript with no tail.
1959
+ # Deliberate asymmetry with the non-streaming termination
1960
+ # return (only the terminating response, no prepend):
1961
+ # each path keeps its existing transcript shape.
1962
+ self._final_messages = list(self._fcc_messages)
1963
+ return
1964
+ self._queue_batch(response)
1965
+ if limit_text is not None:
1966
+ tail_cycle_index, fallback_text = cycle_index + 1, limit_text
1967
+ break
1968
+ self._reset_required_tool_choice()
1969
+ else:
1970
+ self._log_iterations_exhausted(response)
1971
+
1972
+ # Loop exhausted or a tool limit hit: final non-tool streaming turn.
1973
+ self._options["tool_choice"] = "none"
1974
+ if self._service_side and self._session is not None:
1975
+ # The tail request consumes the mirrored handle: the moment it
1976
+ # lands, the service transcript behind that handle holds this
1977
+ # run's still-unanswered calls, so a consumer abandoning the
1978
+ # tail must not inherit it (the tail updates below are marked
1979
+ # against eager re-mirroring for the same reason). Successful
1980
+ # finalization restores the fresh handle via
1981
+ # ``_update_continuation_state`` — or withholds it when the
1982
+ # strip invalidates the service state.
1983
+ self._session.service_session_id = None
1984
+ echo_filter = functools.partial(
1985
+ _strip_echoed_update,
1986
+ echo_registry=self._echo_registry,
1987
+ )
1988
+ await self._trajectory.cycle_started(tail_cycle_index, tools_offered=bool(self._options.get("tools")))
1989
+ final_logical_stream = self._wire.streaming_response(
1990
+ self._prepped_messages,
1991
+ stream_update_filter=echo_filter,
1992
+ request_message_observer=self._wire_request_observer,
1993
+ )
1994
+ tail_stripped_call = False
1995
+ self._reading = final_logical_stream
1996
+ async for update in final_logical_stream:
1997
+ kept = _strip_unexecutable_calls_from_update(update, suppress_finish_reason=tail_stripped_call)
1998
+ if kept is not update:
1999
+ # A call was stripped: from here on no provider
2000
+ # finish_reason may cross the stream — providers commonly
2001
+ # emit the call delta and the finish-reason chunk
2002
+ # separately, and a first-terminal consumer would stop at
2003
+ # that later chunk before the corrective final update
2004
+ # re-emits the corrected reason.
2005
+ tail_stripped_call = True
2006
+ if kept is not None:
2007
+ # Ephemeral marker (never serialized): eager continuation
2008
+ # mirrors must skip tail updates. The run is ending, so a
2009
+ # mid-stream mirror has no disconnect-recovery value left,
2010
+ # and a consumer abandoning the stream mid-tail would
2011
+ # otherwise keep the session pointed at a service
2012
+ # transcript whose stripped calls will never be answered;
2013
+ # the tail's own verdict (post-hook restore or
2014
+ # invalidation) is the sole writer once the stream ends.
2015
+ kept.__dict__["_chrys_exhaustion_tail_update"] = True
2016
+ yield kept
2017
+ self._reading = None
2018
+ final_response = await final_logical_stream.get_final_response()
2019
+ _remove_echo_emptied_message_shells(final_response)
2020
+ self._land(final_response)
2021
+ await self._finish_cycle(final_response)
2022
+ _record_stream_fragment_identities(final_logical_stream, self._echo_registry)
2023
+ stripped = _strip_unexecutable_calls_from_response(final_response)
2024
+ fallback_added = _ensure_exhaustion_fallback_response(final_response, fallback_text)
2025
+ # Invalidate BEFORE the fallback yield: a yield suspends the
2026
+ # generator, and a consumer that stops right after the fallback
2027
+ # would otherwise leave the session pointing at the stale service
2028
+ # transcript (the eager transform already wrote it mid-stream).
2029
+ # Gate on the resolved storage mode, not response metadata: under
2030
+ # conversation storage the service holds the stripped calls even
2031
+ # when the parsed response failed to carry the handle, and a
2032
+ # client-side-storage response's metadata must survive untouched.
2033
+ if stripped and self._service_side:
2034
+ self._service_state_invalidated = True
2035
+ _invalidate_service_continuation_state(final_response, self._session)
2036
+ if fallback_added:
2037
+ # Streamed consumers saw no visible content either (the yield
2038
+ # loop strips the same calls); surface the fallback text there
2039
+ # too, not only on the assembled response. The stripped tail
2040
+ # updates carry no terminal reason (the update strip clears
2041
+ # it), so this update supplies the stream's sole terminal
2042
+ # "stop" — the fallback ends the run, there is no tool work
2043
+ # left to route.
2044
+ yield ChatResponseUpdate(
2045
+ role="assistant",
2046
+ contents=[Content.from_text(fallback_text)],
2047
+ finish_reason="stop",
2048
+ )
2049
+ elif stripped and final_response.finish_reason:
2050
+ # No fallback (the final kept visible text), but the strip
2051
+ # suppressed every provider reason after the first stripped
2052
+ # call and normalized the wire's "tool_calls" to "stop":
2053
+ # streamed consumers need the corrected signal — whatever
2054
+ # reason the assembled response settled on, "stop" or a
2055
+ # non-stop reason like "length" — and it must arrive only
2056
+ # after every stripped update.
2057
+ yield ChatResponseUpdate(role="assistant", contents=[], finish_reason=final_response.finish_reason)
2058
+ self._count_streamed_usage(final_response)
2059
+ self._take_consumed_injections()
2060
+ self._record_response_state(final_response)
2061
+ self._final_messages = [*self._fcc_messages, *final_response.messages]
2062
+ except asyncio.CancelledError:
2063
+ # Nothing below may wait on the writer once the consumer is
2064
+ # gone: the settlement queues its lines instead.
2065
+ interrupted = True
2066
+ self._trajectory.abort(self._wire.cancel_outcome())
2067
+ raise
2068
+ except GeneratorExit:
2069
+ interrupted = True
2070
+ self._trajectory.abort(ExchangeOutcome.ABANDONED)
2071
+ raise
2072
+ except Exception:
2073
+ self._trajectory.abort(ExchangeOutcome.ERROR)
2074
+ raise
2075
+ finally:
2076
+ await self._trajectory.settle_undispatched_tool_operations(queued=interrupted)
2077
+
2078
+ async def close_abandoned_read(self) -> None:
2079
+ """Cleanup hook of the run's stream: close the logical call it was reading.
2080
+
2081
+ A logical call is left unread when the consumer closes the run's
2082
+ stream mid-response, or when the run fails between two pulls of it.
2083
+ On every other exit it was read to its end, or it ended by itself
2084
+ (error, cancellation) and closing it again has no effect. Closing it
2085
+ closes the provider stream under it, so that stream's own cleanup
2086
+ hooks (usage, telemetry) run before the close or the failure reaches
2087
+ the consumer. The hook runs after ``stream`` exited and aborted its
2088
+ trajectory, never from a ``GeneratorExit`` handler: finalization
2089
+ closes each abandoned generator in a task of its own, and a handler
2090
+ closing another generator would collide with that generator's own
2091
+ close.
2092
+ """
2093
+ reading, self._reading = self._reading, None
2094
+ if reading is not None:
2095
+ try:
2096
+ await reading.aclose()
2097
+ except Exception:
2098
+ logger.debug("Failed to close an abandoned logical stream", exc_info=True)
2099
+
2100
+ def finalize_stream(self, updates: Sequence[ChatResponseUpdate]) -> ChatResponse:
2101
+ # Inner result hooks already ran via get_final_response; do not run them again.
2102
+ response = ChatResponse.from_updates(updates, output_format_type=self._response_format)
2103
+ if self._final_messages is not None:
2104
+ # The loop is the sole reconstruction authority: keep
2105
+ # ``from_updates`` for response-level fields, but reuse the
2106
+ # loop's assembled Message objects. Re-merging raw fragments
2107
+ # here would rebuild multi-fragment function calls as new
2108
+ # Content objects, dropping the kernel-stamped invocation
2109
+ # ordinal, tool kind/context, and folded result metadata —
2110
+ # and could glue a boundary-shaped delta onto the synthesized
2111
+ # tool message. Structured-output ``.value`` parses lazily
2112
+ # from these swapped messages; their text is identical.
2113
+ response.messages = list(self._final_messages)
2114
+ response.usage_details = self._aggregated_usage
2115
+ response.latest_usage_details = self._latest_usage
2116
+ if self._service_state_invalidated:
2117
+ # ``from_updates`` restored the handles from raw updates; the
2118
+ # run withheld them (exhaustion strip or middleware termination)
2119
+ # because the service-side transcript holds calls it never sees
2120
+ # answered.
2121
+ response.conversation_id = None
2122
+ response.response_id = None
2123
+ response._chrys_service_state_invalidated = True
2124
+ return response
2125
+
2126
+
2127
+ # --------------------------------------------------------------------------- #
2128
+ # ToolLoopLayer
2129
+ # --------------------------------------------------------------------------- #
2130
+
2131
+
2132
+ class ToolLoopLayer:
2133
+ """Composition-style tool-invocation loop over an inner chat layer.
2134
+
2135
+ Constructor settings bound iteration and tool execution. Clients that
2136
+ need a single model call can use the inner chat layer directly.
2137
+
2138
+ Per-run state arrives via ``client_kwargs``: ``session`` (continuation
2139
+ bookkeeping target) and ``loop_recorder`` (chrys interrupt-recovery
2140
+ recorder) are popped here; everything else flows to the inner layer
2141
+ untouched, with continuation ids written back into the same dict between
2142
+ iterations.
2143
+ """
2144
+
2145
+ def __init__(
2146
+ self,
2147
+ inner: ChatMiddlewareLayer,
2148
+ *,
2149
+ middleware: FunctionMiddleware | Sequence[FunctionMiddleware] | None = None,
2150
+ max_iterations: int = DEFAULT_MAX_ITERATIONS,
2151
+ max_consecutive_errors: int = DEFAULT_MAX_CONSECUTIVE_ERRORS,
2152
+ max_function_calls: int | None = None,
2153
+ tool_result_ceiling_tokens: int | None = None,
2154
+ ) -> None:
2155
+ self.inner = inner
2156
+ split = split_middleware(middleware)
2157
+ if split.chat:
2158
+ raise TypeError(
2159
+ "ToolLoopLayer constructor middleware must be FunctionMiddleware; "
2160
+ f"chat middleware belongs to the inner ChatMiddlewareLayer, got {split.chat!r}"
2161
+ )
2162
+ self.function_middleware: list[FunctionMiddleware] = split.function
2163
+ self.max_iterations = max_iterations
2164
+ self.max_consecutive_errors = max_consecutive_errors
2165
+ self.max_function_calls = max_function_calls
2166
+ self.tool_result_ceiling_tokens = tool_result_ceiling_tokens
2167
+
2168
+ def __getattr__(self, name: str) -> Any:
2169
+ # Delegate unknown attributes to the inner layer so agent-facing client
2170
+ # surface (model, STORES_BY_DEFAULT, ...) resolves through the stack.
2171
+ if name == "inner":
2172
+ raise AttributeError(name)
2173
+ return getattr(self.inner, name)
2174
+
2175
+ async def aclose(self) -> None:
2176
+ await self.inner.aclose()
2177
+
2178
+ def get_response(
2179
+ self,
2180
+ messages: Sequence[Message],
2181
+ *,
2182
+ stream: bool = False,
2183
+ options: Mapping[str, Any] | None = None,
2184
+ middleware: ChatMiddleware | FunctionMiddleware | Sequence[ChatMiddleware | FunctionMiddleware] | None = None,
2185
+ compaction_strategy: Any = None,
2186
+ tokenizer: Any = None,
2187
+ function_invocation_kwargs: Mapping[str, Any] | None = None,
2188
+ client_kwargs: Mapping[str, Any] | None = None,
2189
+ request_message_observer: Callable[[Sequence[Message]], None] | None = None,
2190
+ ) -> Awaitable[ChatResponse] | ResponseStream[ChatResponseUpdate, ChatResponse]:
2191
+ """Build a lazy model/tool run.
2192
+
2193
+ ``stream=False`` returns an un-awaited coroutine; ``stream=True`` returns
2194
+ a ``ResponseStream`` synchronously. Execution begins when the result is driven.
2195
+ The run's settings are resolved here; the caller's messages are read
2196
+ when the run starts.
2197
+ """
2198
+ # Merge the middleware kwarg with the
2199
+ # client_kwargs channel, split chat vs function, pop per-run state.
2200
+ effective_client_kwargs = dict(client_kwargs) if client_kwargs is not None else {}
2201
+ if middleware is not None:
2202
+ existing = effective_client_kwargs.get("middleware")
2203
+ effective_client_kwargs["middleware"] = [
2204
+ *_as_middleware_list(existing),
2205
+ *_as_middleware_list(middleware),
2206
+ ]
2207
+ runtime_split = split_middleware(effective_client_kwargs.pop("middleware", None))
2208
+ pipeline = FunctionMiddlewarePipeline(*self.function_middleware, *runtime_split.function)
2209
+ per_call_chat: list[ChatMiddleware] = runtime_split.chat
2210
+
2211
+ raw_session = effective_client_kwargs.pop("session", None)
2212
+ invocation_session: AgentSession | None = raw_session if isinstance(raw_session, AgentSession) else None
2213
+ raw_recorder = effective_client_kwargs.pop("loop_recorder", None)
2214
+ recorder: LoopRecorder | None = raw_recorder if isinstance(raw_recorder, LoopRecorder) else None
2215
+ raw_injection_probe = effective_client_kwargs.pop("consumed_injection_message_probe", None)
2216
+ injection_probe = (
2217
+ raw_injection_probe if isinstance(raw_injection_probe, ConsumedInjectionMessageProbe) else None
2218
+ )
2219
+ raw_wire_retry_policy = effective_client_kwargs.pop("wire_retry_policy", None)
2220
+ raw_token_observer = effective_client_kwargs.pop("continuation_token_observer", None)
2221
+ continuation_token_observer: Callable[[Any], None] | None = (
2222
+ raw_token_observer if callable(raw_token_observer) else None
2223
+ )
2224
+ raw_trajectory = effective_client_kwargs.pop(TRAJECTORY_CONTEXT_KWARG, None)
2225
+ trajectory: TrajectoryContext | None = raw_trajectory if isinstance(raw_trajectory, TrajectoryContext) else None
2226
+
2227
+ filtered_kwargs = effective_client_kwargs
2228
+
2229
+ additional_function_arguments = dict(function_invocation_kwargs) if function_invocation_kwargs else {}
2230
+ if options and (additional_opts := options.get("additional_function_arguments")):
2231
+ additional_function_arguments.update(additional_opts)
2232
+
2233
+ mutable_options: dict[str, Any] = dict(options) if options else {}
2234
+ # Tool-invocation-only key; not recognized by chat service APIs.
2235
+ mutable_options.pop("additional_function_arguments", None)
2236
+ # Run-local mutable tools list (progressive tool exposure): fresh list so
2237
+ # the caller's container is never mutated, same object shared with the
2238
+ # model and the per-batch tool map.
2239
+ if mutable_options.get("tools"):
2240
+ mutable_options["tools"] = normalize_tools(mutable_options["tools"])
2241
+
2242
+ try:
2243
+ stores_by_default = bool(self.STORES_BY_DEFAULT)
2244
+ except AttributeError:
2245
+ stores_by_default = False
2246
+ try:
2247
+ force_stateless = bool(self.FORCES_STATELESS)
2248
+ except AttributeError:
2249
+ force_stateless = False
2250
+ storage = resolve_storage_mode_and_handles(
2251
+ mutable_options,
2252
+ stores_by_default=stores_by_default,
2253
+ client_kwargs=filtered_kwargs,
2254
+ force_stateless=force_stateless,
2255
+ )
2256
+ wire_retry_policy = (
2257
+ cast(WireRetryPolicy, raw_wire_retry_policy)
2258
+ if raw_wire_retry_policy is not None and not storage.service_side
2259
+ else None
2260
+ )
2261
+
2262
+ run_trajectory = _LoopTrajectory(trajectory, retry_policy=wire_retry_policy)
2263
+ wire = _WireCaller(
2264
+ self.inner,
2265
+ options=mutable_options,
2266
+ client_kwargs=filtered_kwargs,
2267
+ chat_middleware=per_call_chat,
2268
+ compaction_strategy=compaction_strategy,
2269
+ tokenizer=tokenizer,
2270
+ policy=wire_retry_policy,
2271
+ recorder=recorder,
2272
+ injection_probe=injection_probe,
2273
+ continuation_token_observer=continuation_token_observer,
2274
+ trajectory=run_trajectory,
2275
+ )
2276
+ run = _LoopRun(
2277
+ messages,
2278
+ options=mutable_options,
2279
+ client_kwargs=filtered_kwargs,
2280
+ service_side=storage.service_side,
2281
+ session=invocation_session,
2282
+ recorder=recorder,
2283
+ injection_probe=injection_probe,
2284
+ pipeline=pipeline,
2285
+ additional_function_arguments=additional_function_arguments,
2286
+ request_message_observer=request_message_observer,
2287
+ max_iterations=self.max_iterations,
2288
+ max_consecutive_errors=self.max_consecutive_errors,
2289
+ max_function_calls=self.max_function_calls,
2290
+ tool_result_ceiling_tokens=self.tool_result_ceiling_tokens,
2291
+ trajectory=run_trajectory,
2292
+ wire=wire,
2293
+ )
2294
+ if not stream:
2295
+ return run.run_blocking()
2296
+ return ResponseStream(run.stream(), finalizer=run.finalize_stream).with_cleanup_hook(run.close_abandoned_read)
2297
+
2298
+
2299
+ async def _resolve_response(value: Any) -> ChatResponse:
2300
+ """Resolve the inner layer's non-streaming return to a ``ChatResponse``.
2301
+
2302
+ The inner :class:`~.middleware.ChatMiddlewareLayer` returns an un-awaited
2303
+ coroutine for ``stream=False``; a plain client returning a response object
2304
+ directly is accepted too.
2305
+ """
2306
+ if inspect.isawaitable(value):
2307
+ return await value
2308
+ return value