aru-code 0.47.0__tar.gz → 0.51.0__tar.gz

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 (210) hide show
  1. {aru_code-0.47.0/aru_code.egg-info → aru_code-0.51.0}/PKG-INFO +1 -1
  2. aru_code-0.51.0/aru/__init__.py +1 -0
  3. {aru_code-0.47.0 → aru_code-0.51.0}/aru/config.py +23 -0
  4. {aru_code-0.47.0 → aru_code-0.51.0}/aru/display.py +1 -1
  5. aru_code-0.51.0/aru/doom_loop.py +137 -0
  6. {aru_code-0.47.0 → aru_code-0.51.0}/aru/events.py +56 -0
  7. {aru_code-0.47.0 → aru_code-0.51.0}/aru/plugins/hooks.py +6 -0
  8. {aru_code-0.47.0 → aru_code-0.51.0}/aru/runner.py +8 -2
  9. {aru_code-0.47.0 → aru_code-0.51.0}/aru/session.py +13 -1
  10. {aru_code-0.47.0 → aru_code-0.51.0}/aru/sinks.py +5 -0
  11. {aru_code-0.47.0 → aru_code-0.51.0}/aru/streaming.py +110 -0
  12. {aru_code-0.47.0 → aru_code-0.51.0}/aru/tools/delegate.py +43 -0
  13. {aru_code-0.47.0 → aru_code-0.51.0}/aru/tools/tasklist.py +99 -10
  14. {aru_code-0.47.0 → aru_code-0.51.0}/aru/tui/app.py +681 -195
  15. aru_code-0.51.0/aru/tui/log_bridge.py +128 -0
  16. aru_code-0.51.0/aru/tui/notifications.py +238 -0
  17. aru_code-0.51.0/aru/tui/screens/__init__.py +17 -0
  18. aru_code-0.51.0/aru/tui/screens/keymap.py +160 -0
  19. aru_code-0.51.0/aru/tui/screens/session_picker.py +189 -0
  20. {aru_code-0.47.0 → aru_code-0.51.0}/aru/tui/sinks.py +10 -0
  21. aru_code-0.51.0/aru/tui/themes.py +99 -0
  22. {aru_code-0.47.0 → aru_code-0.51.0}/aru/tui/widgets/chat.py +50 -1
  23. {aru_code-0.47.0 → aru_code-0.51.0}/aru/tui/widgets/completer.py +1 -0
  24. aru_code-0.51.0/aru/tui/widgets/file_link.py +255 -0
  25. aru_code-0.51.0/aru/tui/widgets/prompt_area.py +329 -0
  26. aru_code-0.51.0/aru/tui/widgets/prompt_queue.py +145 -0
  27. aru_code-0.51.0/aru/tui/widgets/subagent_panel.py +274 -0
  28. aru_code-0.51.0/aru/tui/widgets/tasklist_panel.py +200 -0
  29. {aru_code-0.47.0 → aru_code-0.51.0/aru_code.egg-info}/PKG-INFO +1 -1
  30. {aru_code-0.47.0 → aru_code-0.51.0}/aru_code.egg-info/SOURCES.txt +20 -0
  31. {aru_code-0.47.0 → aru_code-0.51.0}/pyproject.toml +1 -1
  32. {aru_code-0.47.0 → aru_code-0.51.0}/tests/test_chat_scrollable.py +13 -2
  33. aru_code-0.51.0/tests/test_doom_loop.py +492 -0
  34. {aru_code-0.47.0 → aru_code-0.51.0}/tests/test_events_schema.py +17 -0
  35. aru_code-0.51.0/tests/test_session_free_cost.py +59 -0
  36. {aru_code-0.47.0 → aru_code-0.51.0}/tests/test_streaming_sink.py +3 -0
  37. aru_code-0.51.0/tests/test_subagent_tool_events.py +301 -0
  38. {aru_code-0.47.0 → aru_code-0.51.0}/tests/test_tui_completer.py +18 -19
  39. {aru_code-0.47.0 → aru_code-0.51.0}/tests/test_tui_copy.py +52 -7
  40. aru_code-0.51.0/tests/test_tui_error_display.py +205 -0
  41. aru_code-0.51.0/tests/test_tui_file_link.py +108 -0
  42. {aru_code-0.47.0 → aru_code-0.51.0}/tests/test_tui_input_behaviour.py +123 -69
  43. {aru_code-0.47.0 → aru_code-0.51.0}/tests/test_tui_mode_cycle.py +4 -4
  44. {aru_code-0.47.0 → aru_code-0.51.0}/tests/test_tui_permission_flow.py +2 -2
  45. aru_code-0.51.0/tests/test_tui_plan_task_render.py +127 -0
  46. aru_code-0.51.0/tests/test_tui_prompt_queue.py +109 -0
  47. {aru_code-0.47.0 → aru_code-0.51.0}/tests/test_tui_shell_bang.py +18 -18
  48. {aru_code-0.47.0 → aru_code-0.51.0}/tests/test_tui_slash_bridge.py +3 -3
  49. aru_code-0.51.0/tests/test_tui_slash_model.py +141 -0
  50. aru_code-0.51.0/tests/test_tui_subagent_panel.py +300 -0
  51. aru_code-0.51.0/tests/test_tui_theme.py +85 -0
  52. aru_code-0.47.0/aru/__init__.py +0 -1
  53. aru_code-0.47.0/aru/tui/screens/__init__.py +0 -8
  54. aru_code-0.47.0/tests/test_tui_plan_task_render.py +0 -95
  55. {aru_code-0.47.0 → aru_code-0.51.0}/LICENSE +0 -0
  56. {aru_code-0.47.0 → aru_code-0.51.0}/README.md +0 -0
  57. {aru_code-0.47.0 → aru_code-0.51.0}/aru/agent_factory.py +0 -0
  58. {aru_code-0.47.0 → aru_code-0.51.0}/aru/agents/__init__.py +0 -0
  59. {aru_code-0.47.0 → aru_code-0.51.0}/aru/agents/base.py +0 -0
  60. {aru_code-0.47.0 → aru_code-0.51.0}/aru/agents/catalog.py +0 -0
  61. {aru_code-0.47.0 → aru_code-0.51.0}/aru/agents/planner.py +0 -0
  62. {aru_code-0.47.0 → aru_code-0.51.0}/aru/cache_patch.py +0 -0
  63. {aru_code-0.47.0 → aru_code-0.51.0}/aru/checkpoints.py +0 -0
  64. {aru_code-0.47.0 → aru_code-0.51.0}/aru/cli.py +0 -0
  65. {aru_code-0.47.0 → aru_code-0.51.0}/aru/commands.py +0 -0
  66. {aru_code-0.47.0 → aru_code-0.51.0}/aru/completers.py +0 -0
  67. {aru_code-0.47.0 → aru_code-0.51.0}/aru/context.py +0 -0
  68. {aru_code-0.47.0 → aru_code-0.51.0}/aru/format/__init__.py +0 -0
  69. {aru_code-0.47.0 → aru_code-0.51.0}/aru/format/manager.py +0 -0
  70. {aru_code-0.47.0 → aru_code-0.51.0}/aru/format/runner.py +0 -0
  71. {aru_code-0.47.0 → aru_code-0.51.0}/aru/history_blocks.py +0 -0
  72. {aru_code-0.47.0 → aru_code-0.51.0}/aru/lsp/__init__.py +0 -0
  73. {aru_code-0.47.0 → aru_code-0.51.0}/aru/lsp/client.py +0 -0
  74. {aru_code-0.47.0 → aru_code-0.51.0}/aru/lsp/manager.py +0 -0
  75. {aru_code-0.47.0 → aru_code-0.51.0}/aru/lsp/protocol.py +0 -0
  76. {aru_code-0.47.0 → aru_code-0.51.0}/aru/memory/__init__.py +0 -0
  77. {aru_code-0.47.0 → aru_code-0.51.0}/aru/memory/extractor.py +0 -0
  78. {aru_code-0.47.0 → aru_code-0.51.0}/aru/memory/loader.py +0 -0
  79. {aru_code-0.47.0 → aru_code-0.51.0}/aru/memory/store.py +0 -0
  80. {aru_code-0.47.0 → aru_code-0.51.0}/aru/permissions.py +0 -0
  81. {aru_code-0.47.0 → aru_code-0.51.0}/aru/plugin_cache.py +0 -0
  82. {aru_code-0.47.0 → aru_code-0.51.0}/aru/plugins/__init__.py +0 -0
  83. {aru_code-0.47.0 → aru_code-0.51.0}/aru/plugins/custom_tools.py +0 -0
  84. {aru_code-0.47.0 → aru_code-0.51.0}/aru/plugins/manager.py +0 -0
  85. {aru_code-0.47.0 → aru_code-0.51.0}/aru/plugins/tool_api.py +0 -0
  86. {aru_code-0.47.0 → aru_code-0.51.0}/aru/providers.py +0 -0
  87. {aru_code-0.47.0 → aru_code-0.51.0}/aru/runtime.py +0 -0
  88. {aru_code-0.47.0 → aru_code-0.51.0}/aru/select.py +0 -0
  89. {aru_code-0.47.0 → aru_code-0.51.0}/aru/tool_policy.py +0 -0
  90. {aru_code-0.47.0 → aru_code-0.51.0}/aru/tools/__init__.py +0 -0
  91. {aru_code-0.47.0 → aru_code-0.51.0}/aru/tools/_diff.py +0 -0
  92. {aru_code-0.47.0 → aru_code-0.51.0}/aru/tools/_shared.py +0 -0
  93. {aru_code-0.47.0 → aru_code-0.51.0}/aru/tools/apply_patch.py +0 -0
  94. {aru_code-0.47.0 → aru_code-0.51.0}/aru/tools/apply_patch_prompt.txt +0 -0
  95. {aru_code-0.47.0 → aru_code-0.51.0}/aru/tools/ast_tools.py +0 -0
  96. {aru_code-0.47.0 → aru_code-0.51.0}/aru/tools/codebase.py +0 -0
  97. {aru_code-0.47.0 → aru_code-0.51.0}/aru/tools/delegate_prompt.txt +0 -0
  98. {aru_code-0.47.0 → aru_code-0.51.0}/aru/tools/file_ops.py +0 -0
  99. {aru_code-0.47.0 → aru_code-0.51.0}/aru/tools/gitignore.py +0 -0
  100. {aru_code-0.47.0 → aru_code-0.51.0}/aru/tools/lsp.py +0 -0
  101. {aru_code-0.47.0 → aru_code-0.51.0}/aru/tools/mcp_client.py +0 -0
  102. {aru_code-0.47.0 → aru_code-0.51.0}/aru/tools/memory_tool.py +0 -0
  103. {aru_code-0.47.0 → aru_code-0.51.0}/aru/tools/plan_mode.py +0 -0
  104. {aru_code-0.47.0 → aru_code-0.51.0}/aru/tools/ranker.py +0 -0
  105. {aru_code-0.47.0 → aru_code-0.51.0}/aru/tools/registry.py +0 -0
  106. {aru_code-0.47.0 → aru_code-0.51.0}/aru/tools/search.py +0 -0
  107. {aru_code-0.47.0 → aru_code-0.51.0}/aru/tools/shell.py +0 -0
  108. {aru_code-0.47.0 → aru_code-0.51.0}/aru/tools/skill.py +0 -0
  109. {aru_code-0.47.0 → aru_code-0.51.0}/aru/tools/web.py +0 -0
  110. {aru_code-0.47.0 → aru_code-0.51.0}/aru/tools/worktree.py +0 -0
  111. {aru_code-0.47.0 → aru_code-0.51.0}/aru/tui/__init__.py +0 -0
  112. {aru_code-0.47.0 → aru_code-0.51.0}/aru/tui/sanitize.py +0 -0
  113. {aru_code-0.47.0 → aru_code-0.51.0}/aru/tui/screens/choice.py +0 -0
  114. {aru_code-0.47.0 → aru_code-0.51.0}/aru/tui/screens/confirm.py +0 -0
  115. {aru_code-0.47.0 → aru_code-0.51.0}/aru/tui/screens/search.py +0 -0
  116. {aru_code-0.47.0 → aru_code-0.51.0}/aru/tui/screens/text_input.py +0 -0
  117. {aru_code-0.47.0 → aru_code-0.51.0}/aru/tui/slash_bridge.py +0 -0
  118. {aru_code-0.47.0 → aru_code-0.51.0}/aru/tui/ui.py +0 -0
  119. {aru_code-0.47.0 → aru_code-0.51.0}/aru/tui/widgets/__init__.py +0 -0
  120. {aru_code-0.47.0 → aru_code-0.51.0}/aru/tui/widgets/context_pane.py +0 -0
  121. {aru_code-0.47.0 → aru_code-0.51.0}/aru/tui/widgets/header.py +0 -0
  122. {aru_code-0.47.0 → aru_code-0.51.0}/aru/tui/widgets/inline_choice.py +0 -0
  123. {aru_code-0.47.0 → aru_code-0.51.0}/aru/tui/widgets/loaded_pane.py +0 -0
  124. {aru_code-0.47.0 → aru_code-0.51.0}/aru/tui/widgets/status.py +0 -0
  125. {aru_code-0.47.0 → aru_code-0.51.0}/aru/tui/widgets/thinking.py +0 -0
  126. {aru_code-0.47.0 → aru_code-0.51.0}/aru/tui/widgets/tools.py +0 -0
  127. {aru_code-0.47.0 → aru_code-0.51.0}/aru/ui.py +0 -0
  128. {aru_code-0.47.0 → aru_code-0.51.0}/aru_code.egg-info/dependency_links.txt +0 -0
  129. {aru_code-0.47.0 → aru_code-0.51.0}/aru_code.egg-info/entry_points.txt +0 -0
  130. {aru_code-0.47.0 → aru_code-0.51.0}/aru_code.egg-info/requires.txt +0 -0
  131. {aru_code-0.47.0 → aru_code-0.51.0}/aru_code.egg-info/top_level.txt +0 -0
  132. {aru_code-0.47.0 → aru_code-0.51.0}/setup.cfg +0 -0
  133. {aru_code-0.47.0 → aru_code-0.51.0}/tests/test_agents_base.py +0 -0
  134. {aru_code-0.47.0 → aru_code-0.51.0}/tests/test_agents_md_coverage.py +0 -0
  135. {aru_code-0.47.0 → aru_code-0.51.0}/tests/test_apply_patch.py +0 -0
  136. {aru_code-0.47.0 → aru_code-0.51.0}/tests/test_async_tool_permission.py +0 -0
  137. {aru_code-0.47.0 → aru_code-0.51.0}/tests/test_cache_patch_metrics.py +0 -0
  138. {aru_code-0.47.0 → aru_code-0.51.0}/tests/test_cache_patch_stop_reason.py +0 -0
  139. {aru_code-0.47.0 → aru_code-0.51.0}/tests/test_catalog.py +0 -0
  140. {aru_code-0.47.0 → aru_code-0.51.0}/tests/test_checkpoints.py +0 -0
  141. {aru_code-0.47.0 → aru_code-0.51.0}/tests/test_cli.py +0 -0
  142. {aru_code-0.47.0 → aru_code-0.51.0}/tests/test_cli_advanced.py +0 -0
  143. {aru_code-0.47.0 → aru_code-0.51.0}/tests/test_cli_base.py +0 -0
  144. {aru_code-0.47.0 → aru_code-0.51.0}/tests/test_cli_completers.py +0 -0
  145. {aru_code-0.47.0 → aru_code-0.51.0}/tests/test_cli_new.py +0 -0
  146. {aru_code-0.47.0 → aru_code-0.51.0}/tests/test_cli_run_cli.py +0 -0
  147. {aru_code-0.47.0 → aru_code-0.51.0}/tests/test_cli_session.py +0 -0
  148. {aru_code-0.47.0 → aru_code-0.51.0}/tests/test_cli_shell.py +0 -0
  149. {aru_code-0.47.0 → aru_code-0.51.0}/tests/test_codebase.py +0 -0
  150. {aru_code-0.47.0 → aru_code-0.51.0}/tests/test_confabulation_regression.py +0 -0
  151. {aru_code-0.47.0 → aru_code-0.51.0}/tests/test_config.py +0 -0
  152. {aru_code-0.47.0 → aru_code-0.51.0}/tests/test_context.py +0 -0
  153. {aru_code-0.47.0 → aru_code-0.51.0}/tests/test_context_pane.py +0 -0
  154. {aru_code-0.47.0 → aru_code-0.51.0}/tests/test_cwd_awareness.py +0 -0
  155. {aru_code-0.47.0 → aru_code-0.51.0}/tests/test_delegate.py +0 -0
  156. {aru_code-0.47.0 → aru_code-0.51.0}/tests/test_events_backward_compat.py +0 -0
  157. {aru_code-0.47.0 → aru_code-0.51.0}/tests/test_fork_ctx_concurrency.py +0 -0
  158. {aru_code-0.47.0 → aru_code-0.51.0}/tests/test_format.py +0 -0
  159. {aru_code-0.47.0 → aru_code-0.51.0}/tests/test_gitignore.py +0 -0
  160. {aru_code-0.47.0 → aru_code-0.51.0}/tests/test_guardrails_scenarios.py +0 -0
  161. {aru_code-0.47.0 → aru_code-0.51.0}/tests/test_invoke_skill.py +0 -0
  162. {aru_code-0.47.0 → aru_code-0.51.0}/tests/test_invoked_skills.py +0 -0
  163. {aru_code-0.47.0 → aru_code-0.51.0}/tests/test_loaded_pane_path.py +0 -0
  164. {aru_code-0.47.0 → aru_code-0.51.0}/tests/test_lsp.py +0 -0
  165. {aru_code-0.47.0 → aru_code-0.51.0}/tests/test_lsp_rename.py +0 -0
  166. {aru_code-0.47.0 → aru_code-0.51.0}/tests/test_main.py +0 -0
  167. {aru_code-0.47.0 → aru_code-0.51.0}/tests/test_markdown_to_text.py +0 -0
  168. {aru_code-0.47.0 → aru_code-0.51.0}/tests/test_mcp_client.py +0 -0
  169. {aru_code-0.47.0 → aru_code-0.51.0}/tests/test_mcp_health.py +0 -0
  170. {aru_code-0.47.0 → aru_code-0.51.0}/tests/test_memory.py +0 -0
  171. {aru_code-0.47.0 → aru_code-0.51.0}/tests/test_memory_tool.py +0 -0
  172. {aru_code-0.47.0 → aru_code-0.51.0}/tests/test_microcompact.py +0 -0
  173. {aru_code-0.47.0 → aru_code-0.51.0}/tests/test_permissions.py +0 -0
  174. {aru_code-0.47.0 → aru_code-0.51.0}/tests/test_plan_mode_refactor.py +0 -0
  175. {aru_code-0.47.0 → aru_code-0.51.0}/tests/test_plugin_cache.py +0 -0
  176. {aru_code-0.47.0 → aru_code-0.51.0}/tests/test_plugin_errors.py +0 -0
  177. {aru_code-0.47.0 → aru_code-0.51.0}/tests/test_plugin_hooks_v2.py +0 -0
  178. {aru_code-0.47.0 → aru_code-0.51.0}/tests/test_plugins.py +0 -0
  179. {aru_code-0.47.0 → aru_code-0.51.0}/tests/test_providers.py +0 -0
  180. {aru_code-0.47.0 → aru_code-0.51.0}/tests/test_ranker.py +0 -0
  181. {aru_code-0.47.0 → aru_code-0.51.0}/tests/test_reasoning.py +0 -0
  182. {aru_code-0.47.0 → aru_code-0.51.0}/tests/test_runner_interrupt.py +0 -0
  183. {aru_code-0.47.0 → aru_code-0.51.0}/tests/test_runner_recovery.py +0 -0
  184. {aru_code-0.47.0 → aru_code-0.51.0}/tests/test_runtime.py +0 -0
  185. {aru_code-0.47.0 → aru_code-0.51.0}/tests/test_select.py +0 -0
  186. {aru_code-0.47.0 → aru_code-0.51.0}/tests/test_skill_disallowed_tools.py +0 -0
  187. {aru_code-0.47.0 → aru_code-0.51.0}/tests/test_status_breakdown.py +0 -0
  188. {aru_code-0.47.0 → aru_code-0.51.0}/tests/test_status_cost.py +0 -0
  189. {aru_code-0.47.0 → aru_code-0.51.0}/tests/test_tasklist.py +0 -0
  190. {aru_code-0.47.0 → aru_code-0.51.0}/tests/test_thread_tool_timeout.py +0 -0
  191. {aru_code-0.47.0 → aru_code-0.51.0}/tests/test_tool_policy.py +0 -0
  192. {aru_code-0.47.0 → aru_code-0.51.0}/tests/test_truncation_marker.py +0 -0
  193. {aru_code-0.47.0 → aru_code-0.51.0}/tests/test_tui_app_boot.py +0 -0
  194. {aru_code-0.47.0 → aru_code-0.51.0}/tests/test_tui_bindings.py +0 -0
  195. {aru_code-0.47.0 → aru_code-0.51.0}/tests/test_tui_bus_flow.py +0 -0
  196. {aru_code-0.47.0 → aru_code-0.51.0}/tests/test_tui_chat.py +0 -0
  197. {aru_code-0.47.0 → aru_code-0.51.0}/tests/test_tui_chat_adversarial.py +0 -0
  198. {aru_code-0.47.0 → aru_code-0.51.0}/tests/test_tui_completer_dynamic.py +0 -0
  199. {aru_code-0.47.0 → aru_code-0.51.0}/tests/test_tui_layer12_recovery.py +0 -0
  200. {aru_code-0.47.0 → aru_code-0.51.0}/tests/test_tui_layer13_recovery.py +0 -0
  201. {aru_code-0.47.0 → aru_code-0.51.0}/tests/test_tui_mention_expand.py +0 -0
  202. {aru_code-0.47.0 → aru_code-0.51.0}/tests/test_tui_modals.py +0 -0
  203. {aru_code-0.47.0 → aru_code-0.51.0}/tests/test_tui_native_selection.py +0 -0
  204. {aru_code-0.47.0 → aru_code-0.51.0}/tests/test_tui_sidebar_toggle.py +0 -0
  205. {aru_code-0.47.0 → aru_code-0.51.0}/tests/test_tui_snapshot_smoke.py +0 -0
  206. {aru_code-0.47.0 → aru_code-0.51.0}/tests/test_tui_thinking_and_boot.py +0 -0
  207. {aru_code-0.47.0 → aru_code-0.51.0}/tests/test_tui_widgets_visual.py +0 -0
  208. {aru_code-0.47.0 → aru_code-0.51.0}/tests/test_ui_adapter.py +0 -0
  209. {aru_code-0.47.0 → aru_code-0.51.0}/tests/test_worktree.py +0 -0
  210. {aru_code-0.47.0 → aru_code-0.51.0}/tests/test_worktree_session_restore.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: aru-code
3
- Version: 0.47.0
3
+ Version: 0.51.0
4
4
  Summary: A Claude Code clone built with Agno agents
5
5
  Author-email: Estevao <estevaofon@gmail.com>
6
6
  License-Expression: MIT
@@ -0,0 +1 @@
1
+ __version__ = "0.51.0"
@@ -180,6 +180,16 @@ class AgentConfig:
180
180
  # Formatter config per language (Tier 3 #1). Same shape as `lsp`, plus
181
181
  # an `enabled` top-level boolean to flip auto-format on/off in aggregate.
182
182
  format: dict[str, Any] = field(default_factory=dict)
183
+ # TUI theme name (one of the presets in `aru/tui/themes.py`). When unset
184
+ # the App keeps Textual's default. Hot-reloadable via the `/theme` slash
185
+ # command, but the persisted choice lives in `aru.json`.
186
+ theme: str = ""
187
+ # OS notification policy: "off" | "background" | "long" | "always".
188
+ # See `aru/tui/notifications.py` for the threshold logic.
189
+ notify: str = "background"
190
+ # Minimum turn duration (seconds) to ring the bell when `notify: long`
191
+ # or `notify: always`. Default 30s — short turns don't warrant a chime.
192
+ notify_threshold_sec: float = 30.0
183
193
 
184
194
  @property
185
195
  def has_instructions(self) -> bool:
@@ -545,6 +555,19 @@ def _apply_config_data(config: AgentConfig, data: dict, root: Path) -> None:
545
555
  config.lsp = data["lsp"]
546
556
  if "format" in data and isinstance(data["format"], dict):
547
557
  config.format = data["format"]
558
+ if "theme" in data and isinstance(data["theme"], str):
559
+ config.theme = data["theme"].strip()
560
+ if "notify" in data and isinstance(data["notify"], str):
561
+ nv = data["notify"].strip().lower()
562
+ if nv in ("off", "background", "long", "always"):
563
+ config.notify = nv
564
+ if "notify_threshold_sec" in data:
565
+ try:
566
+ v = float(data["notify_threshold_sec"])
567
+ if v > 0:
568
+ config.notify_threshold_sec = v
569
+ except (TypeError, ValueError):
570
+ pass
548
571
  if "instructions" in data and isinstance(data["instructions"], list):
549
572
  entries = [str(e) for e in data["instructions"] if isinstance(e, str)]
550
573
  config.rules_instructions = _resolve_instructions(entries, root)
@@ -25,7 +25,7 @@ aru_logo = """
25
25
  """
26
26
 
27
27
  neon_green = "#39ff14"
28
- shadow_green = "#042800"
28
+ shadow_green = "#073e00"
29
29
 
30
30
 
31
31
  def _build_logo_with_shadow(logo_text: str) -> Text:
@@ -0,0 +1,137 @@
1
+ """Doom-loop detection: catch agents stuck repeating identical tool calls.
2
+
3
+ A *doom-loop* is the agent invoking the same tool with the same arguments
4
+ N times in a row, with no intervening different call. The model has lost
5
+ the plot — typically because a tool kept failing and the model forgot it
6
+ already tried that exact thing — and without intervention the run will
7
+ just burn budget until the context window fills.
8
+
9
+ This module gives the runner a cheap, deterministic detector. The
10
+ heuristic mirrors OpenCode's ``session/processor.ts:188-211``:
11
+
12
+ * keep a sliding window of the last N (tool_name, sorted-args) pairs;
13
+ * when the window is full and every entry equals the latest one, fire.
14
+
15
+ When detection fires the runner pauses, asks the user "continue or
16
+ abort?", and either resets the buffer for that tool (allowing the model
17
+ a fresh chance) or aborts the run.
18
+
19
+ Threshold is **3** by default — same as OpenCode. Override per-process
20
+ via the ``ARU_DOOM_LOOP_THRESHOLD`` env var (must be ≥ 2; values below
21
+ fall back to the default to avoid pathologically eager prompts).
22
+
23
+ Args equality uses ``json.dumps(..., sort_keys=True)`` so two calls with
24
+ the same logical args but differing key order — ``{"a": 1, "b": 2}`` vs
25
+ ``{"b": 2, "a": 1}`` — are correctly treated as identical. ``default=str``
26
+ keeps non-JSON values (e.g. Path) from raising; the resulting string is
27
+ still a stable signature for equality.
28
+ """
29
+
30
+ from __future__ import annotations
31
+
32
+ import json
33
+ import os
34
+ from collections import deque
35
+ from typing import Any
36
+
37
+
38
+ DEFAULT_THRESHOLD = 3
39
+ _ENV_VAR = "ARU_DOOM_LOOP_THRESHOLD"
40
+
41
+
42
+ def _stable_signature(tool_name: str, tool_args: Any) -> tuple[str, str]:
43
+ """Return a hashable equality signature for a tool invocation.
44
+
45
+ The args portion is a JSON dump with ``sort_keys=True`` so two calls
46
+ that differ only by key order in the args dict are treated as equal.
47
+ Non-JSON values (Paths, datetimes, etc.) are stringified so the dump
48
+ never raises — the goal is a stable signature, not a round-trippable
49
+ payload.
50
+ """
51
+ if isinstance(tool_args, dict):
52
+ try:
53
+ args_repr = json.dumps(tool_args, sort_keys=True, default=str)
54
+ except Exception:
55
+ # json.dumps can still fail on truly exotic values (e.g.
56
+ # circular refs). Fallback to repr — less stable but never
57
+ # raises, and the detector tolerates occasional mismatches.
58
+ args_repr = repr(sorted(tool_args.items()))
59
+ elif tool_args is None:
60
+ args_repr = "null"
61
+ else:
62
+ # Non-dict args (rare — Agno usually wraps in a dict) — just str.
63
+ args_repr = str(tool_args)
64
+ return (tool_name or "", args_repr)
65
+
66
+
67
+ def threshold_from_env() -> int:
68
+ """Read ``ARU_DOOM_LOOP_THRESHOLD`` or return the default.
69
+
70
+ Values < 2 fall back to the default — a threshold of 1 would fire on
71
+ the very first call which is meaningless, and a threshold of 0 makes
72
+ the deque() unbounded. Invalid values (non-int) also fall back.
73
+ """
74
+ raw = os.environ.get(_ENV_VAR)
75
+ if raw is None:
76
+ return DEFAULT_THRESHOLD
77
+ try:
78
+ v = int(raw)
79
+ except ValueError:
80
+ return DEFAULT_THRESHOLD
81
+ if v < 2:
82
+ return DEFAULT_THRESHOLD
83
+ return v
84
+
85
+
86
+ class DoomLoopDetector:
87
+ """Sliding-window detector for repeated identical tool calls.
88
+
89
+ Each ``record(tool_name, tool_args)`` call appends a signature to the
90
+ window and returns ``True`` iff the window is now full **and** every
91
+ entry in it is identical (i.e. the last N calls were the exact same
92
+ tool with the exact same args).
93
+
94
+ The detector is stateless beyond its window — there is no notion of
95
+ sessions or scopes. The runner instantiates one detector per turn of
96
+ the primary agent loop; sub-agents that run their own arun loop
97
+ (delegate.py) get their own detector via the same wiring.
98
+ """
99
+
100
+ def __init__(self, threshold: int | None = None) -> None:
101
+ self.threshold: int = threshold if threshold is not None else threshold_from_env()
102
+ self._recent: deque[tuple[str, str]] = deque(maxlen=self.threshold)
103
+
104
+ def record(self, tool_name: str, tool_args: Any) -> bool:
105
+ """Append a call's signature; return True if a doom-loop is now detected."""
106
+ sig = _stable_signature(tool_name, tool_args)
107
+ self._recent.append(sig)
108
+ if len(self._recent) < self.threshold:
109
+ return False
110
+ first = self._recent[0]
111
+ return all(s == first for s in self._recent)
112
+
113
+ def reset(self) -> None:
114
+ """Forget all recorded calls. Used after manual intervention."""
115
+ self._recent.clear()
116
+
117
+ def reset_for_tool(self, tool_name: str) -> None:
118
+ """Drop every entry whose tool_name equals *tool_name*.
119
+
120
+ Called after the user chooses *continue* on a doom-loop prompt:
121
+ the buffer is wiped for that specific tool so the very next call
122
+ doesn't immediately re-trigger the same prompt. Other tools'
123
+ history (which were not part of the loop) is preserved so a
124
+ secondary loop on a different tool still detects.
125
+ """
126
+ kept = [s for s in self._recent if s[0] != tool_name]
127
+ self._recent = deque(kept, maxlen=self.threshold)
128
+
129
+ def __len__(self) -> int: # convenience for tests
130
+ return len(self._recent)
131
+
132
+
133
+ __all__ = [
134
+ "DEFAULT_THRESHOLD",
135
+ "DoomLoopDetector",
136
+ "threshold_from_env",
137
+ ]
@@ -103,6 +103,31 @@ class SubagentCompleteEvent(BaseEvent):
103
103
  duration_ms: float = 0.0
104
104
 
105
105
 
106
+ class SubagentToolStartedEvent(BaseEvent):
107
+ """A tool call started inside a running sub-agent.
108
+
109
+ Distinct from ``tool.called`` — that event fires from the orchestrator's
110
+ own tool loop. ``subagent.tool.started`` fires from inside a delegated
111
+ sub-agent's stream and carries the parent ``task_id`` so the TUI's
112
+ SubagentPanel can update the right row.
113
+ """
114
+
115
+ event_type: Literal["subagent.tool.started"] = "subagent.tool.started"
116
+ task_id: str = "" # subagent's task_id (the row key)
117
+ tool_id: str = ""
118
+ tool_name: str = ""
119
+ tool_args_preview: str = "" # first ~80 chars of args for the row label
120
+
121
+
122
+ class SubagentToolCompletedEvent(BaseEvent):
123
+ event_type: Literal["subagent.tool.completed"] = "subagent.tool.completed"
124
+ task_id: str = ""
125
+ tool_id: str = ""
126
+ tool_name: str = ""
127
+ duration_ms: float = 0.0
128
+ error: str | None = None
129
+
130
+
106
131
  # ── Workspace / cwd ───────────────────────────────────────────────────
107
132
 
108
133
 
@@ -141,6 +166,29 @@ class PermissionModeChangedEvent(BaseEvent):
141
166
  # ── Intra-turn metrics ────────────────────────────────────────────────
142
167
 
143
168
 
169
+ class TasklistUpdatedEvent(BaseEvent):
170
+ """Published whenever ``create_task_list`` / ``update_task`` mutates
171
+ the executor's subtask list. Lets the TUI ``TasklistPanel`` render
172
+ a live sidebar without reading the chat for the Rich panel.
173
+
174
+ ``tasks`` is the full list (newest snapshot) — subscribers do not
175
+ need to maintain incremental state. Empty list = list cleared.
176
+ """
177
+
178
+ event_type: Literal["tasklist.updated"] = "tasklist.updated"
179
+ tasks: list[dict[str, Any]] = Field(default_factory=list)
180
+
181
+
182
+ class PlanUpdatedEvent(BaseEvent):
183
+ """Published when macro plan steps change (enter_plan_mode /
184
+ update_plan_step). Same shape contract as TasklistUpdatedEvent —
185
+ full snapshot, sidebar consumes directly.
186
+ """
187
+
188
+ event_type: Literal["plan.updated"] = "plan.updated"
189
+ steps: list[dict[str, Any]] = Field(default_factory=list)
190
+
191
+
144
192
  class MetricsUpdatedEvent(BaseEvent):
145
193
  """Published after each internal LLM API call (from ``cache_patch``).
146
194
 
@@ -176,11 +224,15 @@ AruEvent = Union[
176
224
  ToolCompletedEvent,
177
225
  SubagentStartEvent,
178
226
  SubagentCompleteEvent,
227
+ SubagentToolStartedEvent,
228
+ SubagentToolCompletedEvent,
179
229
  CwdChangedEvent,
180
230
  FileChangedEvent,
181
231
  PermissionDeniedEvent,
182
232
  PermissionModeChangedEvent,
183
233
  MetricsUpdatedEvent,
234
+ TasklistUpdatedEvent,
235
+ PlanUpdatedEvent,
184
236
  ]
185
237
 
186
238
 
@@ -196,11 +248,15 @@ EVENT_MODELS: dict[str, type[BaseEvent]] = {
196
248
  "tool.completed": ToolCompletedEvent,
197
249
  "subagent.start": SubagentStartEvent,
198
250
  "subagent.complete": SubagentCompleteEvent,
251
+ "subagent.tool.started": SubagentToolStartedEvent,
252
+ "subagent.tool.completed": SubagentToolCompletedEvent,
199
253
  "cwd.changed": CwdChangedEvent,
200
254
  "file.changed": FileChangedEvent,
201
255
  "permission.denied": PermissionDeniedEvent,
202
256
  "permission.mode.changed": PermissionModeChangedEvent,
203
257
  "metrics.updated": MetricsUpdatedEvent,
258
+ "tasklist.updated": TasklistUpdatedEvent,
259
+ "plan.updated": PlanUpdatedEvent,
204
260
  }
205
261
 
206
262
 
@@ -67,6 +67,8 @@ VALID_HOOKS = frozenset({
67
67
  # Sub-agent (Tier 2 #3)
68
68
  "subagent.start", # After delegate_task spawns a sub-agent
69
69
  "subagent.complete", # After sub-agent terminates (ok, error, cancelled)
70
+ "subagent.tool.started", # Inside a sub-agent: a tool call started
71
+ "subagent.tool.completed", # Inside a sub-agent: a tool call completed
70
72
 
71
73
  # Turn lifecycle (Tier 2 #3)
72
74
  "turn.start", # Beginning of runner.prompt for a new user turn
@@ -76,6 +78,10 @@ VALID_HOOKS = frozenset({
76
78
  "metrics.updated", # After every internal LLM API call (cache_patch);
77
79
  # lets the TUI refresh tokens/cost mid-turn so long
78
80
  # implementation runs don't sit silent for minutes.
81
+
82
+ # Tasklist / plan visibility (Tier 2 #6 sidebar)
83
+ "tasklist.updated", # create_task_list / update_task — full snapshot
84
+ "plan.updated", # enter_plan_mode / update_plan_step — full snapshot
79
85
  })
80
86
 
81
87
 
@@ -695,8 +695,14 @@ async def run_agent_capture(agent, message: str, session=None, lightweight: bool
695
695
  sink.exit()
696
696
  except Exception:
697
697
  pass
698
- from rich.markup import escape
699
- console.print(f"[red]Error: {escape(str(e))}[/red]")
698
+ # Route via the sink so the message reaches the right surface:
699
+ # REPL → Rich console; TUI → ChatPane system message (Textual
700
+ # hijacks stderr/stdout, so ``console.print`` would be invisible).
701
+ try:
702
+ sink.on_error(str(e))
703
+ except Exception:
704
+ from rich.markup import escape
705
+ console.print(f"[red]Error: {escape(str(e))}[/red]")
700
706
 
701
707
  # Final guard: if a plan is active and the agent ended its turn with
702
708
  # pending steps (without stalling), mark the session so the next turn's
@@ -565,7 +565,19 @@ class Session:
565
565
  self._live_cache_write_added = 0
566
566
 
567
567
  def _get_pricing(self) -> tuple[float, float, float, float]:
568
- """Get per-million-token pricing for the current model."""
568
+ """Get per-million-token pricing for the current model.
569
+
570
+ Free models (e.g. OpenRouter `:free` variants like
571
+ `openrouter/minimax/minimax-m2.5:free`) report no cost. Detected by
572
+ the literal token "free" in the model ref or id — covers the `:free`
573
+ suffix convention plus any future provider that adopts the same
574
+ naming. None of the major paid models contain "free" in their id,
575
+ so false positives are negligible.
576
+ """
577
+ ref = (self.model_ref or "").lower()
578
+ mid = (self.model_id or "").lower()
579
+ if "free" in ref or "free" in mid:
580
+ return (0.0, 0.0, 0.0, 0.0)
569
581
  model_id = self.model_id
570
582
  # Try exact match, then prefix match, then fallback
571
583
  for prefix, pricing in MODEL_PRICING.items():
@@ -238,6 +238,11 @@ class RichLiveSink:
238
238
  else:
239
239
  target.print(message)
240
240
 
241
+ def on_error(self, message: str) -> None:
242
+ from rich.markup import escape
243
+ target = self._live.console if self._live is not None else self.console
244
+ target.print(f"[red]Error: {escape(message)}[/red]")
245
+
241
246
  def on_stream_finished(self, *, final_content: str) -> None:
242
247
  # Nothing to do here — runner flushes trailing markdown after exit()
243
248
  # using the accumulated content + display._flushed_len.
@@ -121,6 +121,14 @@ class StreamSink(Protocol):
121
121
  """Best-effort sideband user message (warnings etc.)."""
122
122
  ...
123
123
 
124
+ def on_error(self, message: str) -> None:
125
+ """Terminal error — runner caught an exception from the agent run.
126
+
127
+ REPL renders via Rich console; TUI must route to the ChatPane so
128
+ the user actually sees it (Textual hijacks stderr/stdout).
129
+ """
130
+ ...
131
+
124
132
  def on_stream_finished(self, *, final_content: str) -> None:
125
133
  """Run finished — sink may render any trailing markdown."""
126
134
  ...
@@ -166,6 +174,7 @@ async def run_stream(
166
174
  )
167
175
  from aru.cache_patch import get_last_stop_reason, reset_last_stop_reason
168
176
  from aru.display import _format_tool_label
177
+ from aru.doom_loop import DoomLoopDetector
169
178
 
170
179
  state = StreamState()
171
180
  accumulated = ""
@@ -180,6 +189,14 @@ async def run_stream(
180
189
 
181
190
  # Track tool start times so the sink gets a duration on completion.
182
191
  tool_start_times: dict[str, float] = {}
192
+ # Cache args at started so the doom-loop detector and any other
193
+ # post-hoc check can read them at completed without depending on the
194
+ # provider always populating ``tool_args`` on the completed event.
195
+ tool_args_by_id: dict[str, Any] = {}
196
+ # Per-turn doom-loop detector — reset on each new run_stream call so
197
+ # repeating the same tool across separate user turns isn't a "loop".
198
+ # See ``aru/doom_loop.py`` for the heuristic.
199
+ doom_loop_detector = DoomLoopDetector()
183
200
  import time as _time
184
201
 
185
202
  while True:
@@ -221,6 +238,9 @@ async def run_stream(
221
238
  pending_tool_uses[tool_id] = assistant_blocks[-1]
222
239
 
223
240
  tool_start_times[tool_id] = _time.monotonic()
241
+ tool_args_by_id[tool_id] = (
242
+ tool_args if isinstance(tool_args, dict) else None
243
+ )
224
244
  sink.on_tool_started(
225
245
  tool_id=tool_id,
226
246
  tool_name=tool_name,
@@ -295,6 +315,35 @@ async def run_stream(
295
315
  label=tool_name, # sink caches its own label if needed
296
316
  )
297
317
 
318
+ # Doom-loop check — if the model just made the same call
319
+ # for the Nth time in a row, pause and ask the user. The
320
+ # detector is per-turn (constructed at run_stream entry)
321
+ # so a legitimate repeat across separate turns doesn't
322
+ # fire. See ``aru/doom_loop.py``.
323
+ tool_args_for_detector = tool_args_by_id.pop(tool_id, None)
324
+ if doom_loop_detector.record(tool_name, tool_args_for_detector):
325
+ if await _handle_doom_loop(
326
+ sink=sink,
327
+ tool_name=tool_name,
328
+ tool_args=tool_args_for_detector,
329
+ ):
330
+ # User chose continue — wipe history for this
331
+ # tool so the very next call doesn't immediately
332
+ # re-prompt. Other tools' history is preserved.
333
+ doom_loop_detector.reset_for_tool(tool_name)
334
+ else:
335
+ # User chose abort. Mark stalled, propagate the
336
+ # abort signal to any in-flight sub-agents, and
337
+ # break out of the stream — the outer recovery
338
+ # loop's stop-reason check will then exit.
339
+ state.stalled = True
340
+ try:
341
+ from aru.runtime import abort_current
342
+ abort_current()
343
+ except Exception:
344
+ pass
345
+ break
346
+
298
347
  # When the last active tool in the round completed, close it
299
348
  # and let the sink flush deferred renders (plan panel etc.).
300
349
  if not pending_tool_uses:
@@ -344,3 +393,64 @@ async def run_stream(
344
393
  state.accumulated = accumulated
345
394
  sink.on_stream_finished(final_content=accumulated)
346
395
  return state
396
+
397
+
398
+ async def _handle_doom_loop(
399
+ *,
400
+ sink: StreamSink,
401
+ tool_name: str,
402
+ tool_args: Any,
403
+ ) -> bool:
404
+ """Notify + prompt the user when a doom-loop fires. Returns True to continue.
405
+
406
+ Routed through ``ctx.ui.confirm`` so REPL gets the prompt_toolkit
407
+ yes/no and TUI gets a ``ConfirmModal`` — same code path as the
408
+ permission prompts. Wrapped in ``asyncio.to_thread`` because the UI
409
+ adapter is sync (it bridges to the Textual event loop internally via
410
+ ``call_from_thread`` and a ``threading.Event``); calling it directly
411
+ from the async loop would deadlock the TUI.
412
+ """
413
+ import asyncio
414
+ import json
415
+
416
+ try:
417
+ args_preview = json.dumps(
418
+ tool_args if isinstance(tool_args, dict) else {},
419
+ sort_keys=True, default=str,
420
+ )
421
+ except Exception:
422
+ args_preview = repr(tool_args)
423
+ if len(args_preview) > 120:
424
+ args_preview = args_preview[:117] + "..."
425
+
426
+ sink.notify(
427
+ f"Doom-loop detected: 3× identical {tool_name}({args_preview}). "
428
+ f"Pausing for user confirmation.",
429
+ style="bold yellow",
430
+ )
431
+
432
+ prompt = (
433
+ f"`{tool_name}` was called 3 times in a row with the same input "
434
+ f"({args_preview}). The agent may be stuck in a loop. Continue?"
435
+ )
436
+
437
+ try:
438
+ from aru.runtime import get_ctx
439
+ ctx = get_ctx()
440
+ except LookupError:
441
+ # No runtime ctx (test harness without init_ctx) — default to
442
+ # abort so the loop doesn't keep firing forever.
443
+ return False
444
+
445
+ ui = getattr(ctx, "ui", None)
446
+ if ui is None:
447
+ # No UI installed — same conservative default. abort.
448
+ return False
449
+
450
+ try:
451
+ return bool(await asyncio.to_thread(ui.confirm, prompt, False))
452
+ except Exception:
453
+ # If the prompt itself raises (timeout, missing app, etc.) we
454
+ # default to abort — the model is stuck and we can't ask the
455
+ # user, so continuing risks burning more budget for nothing.
456
+ return False
@@ -421,11 +421,23 @@ Do not create documentation files unless explicitly asked.
421
421
  "task": (task or "")[:500],
422
422
  })
423
423
 
424
+ from aru.runtime import _schedule_publish as _sched_t
424
425
  try:
425
426
  async for event in agent_instance.arun(task, stream=True, stream_events=True, yield_run_output=True):
426
427
  if is_aborted():
427
428
  _trace.status = "cancelled"
428
429
  _trace.ended_at = _time.monotonic()
430
+ # Emit subagent.complete with status=cancelled so the
431
+ # TUI's SubagentPanel can mark + reap the row instead
432
+ # of leaving it stuck in "running". Mirrors the
433
+ # complete path below.
434
+ _sched_t("subagent.complete", {
435
+ "task_id": _trace.task_id,
436
+ "status": "cancelled",
437
+ "duration": (_trace.ended_at or 0) - (_trace.started_at or 0),
438
+ "tokens_in": _trace.tokens_in,
439
+ "tokens_out": _trace.tokens_out,
440
+ })
429
441
  return f"[{label} | task_id={task_id_for_output}] Cancelled by user."
430
442
  if isinstance(event, RunOutput):
431
443
  run_output = event
@@ -433,9 +445,23 @@ Do not create documentation files unless explicitly asked.
433
445
  elif isinstance(event, ToolCallStartedEvent):
434
446
  if hasattr(event, "tool") and event.tool:
435
447
  t_id = getattr(event.tool, "tool_call_id", None) or (event.tool.tool_name or "tool")
448
+ t_name_start = event.tool.tool_name or "tool"
449
+ t_args_start = getattr(event.tool, "tool_args", None)
436
450
  else:
437
451
  t_id = getattr(event, "tool_call_id", None) or getattr(event, "tool_name", "tool")
452
+ t_name_start = getattr(event, "tool_name", "tool")
453
+ t_args_start = getattr(event, "tool_args", None)
438
454
  _tool_starts[t_id] = _time.monotonic()
455
+ # Live TUI: tell the SubagentPanel which subagent is
456
+ # currently running which tool, so a fan-out of N
457
+ # delegates renders as N rows each showing its own
458
+ # in-flight tool instead of opaque "running…".
459
+ _sched_t("subagent.tool.started", {
460
+ "task_id": _trace.task_id,
461
+ "tool_id": str(t_id),
462
+ "tool_name": t_name_start,
463
+ "tool_args_preview": (str(t_args_start) if t_args_start else "")[:80],
464
+ })
439
465
  elif isinstance(event, ToolCallCompletedEvent):
440
466
  if hasattr(event, "tool") and event.tool:
441
467
  t_id = getattr(event.tool, "tool_call_id", None) or getattr(event.tool, "tool_name", "tool")
@@ -452,12 +478,29 @@ Do not create documentation files unless explicitly asked.
452
478
  "args_preview": (str(t_args) if t_args else "")[:150],
453
479
  "duration": round(dur, 3),
454
480
  })
481
+ _sched_t("subagent.tool.completed", {
482
+ "task_id": _trace.task_id,
483
+ "tool_id": str(t_id),
484
+ "tool_name": t_name,
485
+ "duration_ms": round(dur * 1000, 1),
486
+ "error": None,
487
+ })
455
488
  elif isinstance(event, RunContentEvent):
456
489
  if hasattr(event, "content") and event.content:
457
490
  result_content += event.content
458
491
  except Exception:
459
492
  _trace.status = "error"
460
493
  _trace.ended_at = _time.monotonic()
494
+ # Same rationale as cancel: surface a complete event with
495
+ # status=error so the panel can stop the row instead of
496
+ # leaving it spinning forever.
497
+ _sched_t("subagent.complete", {
498
+ "task_id": _trace.task_id,
499
+ "status": "error",
500
+ "duration": (_trace.ended_at or 0) - (_trace.started_at or 0),
501
+ "tokens_in": _trace.tokens_in,
502
+ "tokens_out": _trace.tokens_out,
503
+ })
461
504
  raise
462
505
 
463
506
  if run_output and hasattr(run_output, "metrics") and run_output.metrics: