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.
- {aru_code-0.47.0/aru_code.egg-info → aru_code-0.51.0}/PKG-INFO +1 -1
- aru_code-0.51.0/aru/__init__.py +1 -0
- {aru_code-0.47.0 → aru_code-0.51.0}/aru/config.py +23 -0
- {aru_code-0.47.0 → aru_code-0.51.0}/aru/display.py +1 -1
- aru_code-0.51.0/aru/doom_loop.py +137 -0
- {aru_code-0.47.0 → aru_code-0.51.0}/aru/events.py +56 -0
- {aru_code-0.47.0 → aru_code-0.51.0}/aru/plugins/hooks.py +6 -0
- {aru_code-0.47.0 → aru_code-0.51.0}/aru/runner.py +8 -2
- {aru_code-0.47.0 → aru_code-0.51.0}/aru/session.py +13 -1
- {aru_code-0.47.0 → aru_code-0.51.0}/aru/sinks.py +5 -0
- {aru_code-0.47.0 → aru_code-0.51.0}/aru/streaming.py +110 -0
- {aru_code-0.47.0 → aru_code-0.51.0}/aru/tools/delegate.py +43 -0
- {aru_code-0.47.0 → aru_code-0.51.0}/aru/tools/tasklist.py +99 -10
- {aru_code-0.47.0 → aru_code-0.51.0}/aru/tui/app.py +681 -195
- aru_code-0.51.0/aru/tui/log_bridge.py +128 -0
- aru_code-0.51.0/aru/tui/notifications.py +238 -0
- aru_code-0.51.0/aru/tui/screens/__init__.py +17 -0
- aru_code-0.51.0/aru/tui/screens/keymap.py +160 -0
- aru_code-0.51.0/aru/tui/screens/session_picker.py +189 -0
- {aru_code-0.47.0 → aru_code-0.51.0}/aru/tui/sinks.py +10 -0
- aru_code-0.51.0/aru/tui/themes.py +99 -0
- {aru_code-0.47.0 → aru_code-0.51.0}/aru/tui/widgets/chat.py +50 -1
- {aru_code-0.47.0 → aru_code-0.51.0}/aru/tui/widgets/completer.py +1 -0
- aru_code-0.51.0/aru/tui/widgets/file_link.py +255 -0
- aru_code-0.51.0/aru/tui/widgets/prompt_area.py +329 -0
- aru_code-0.51.0/aru/tui/widgets/prompt_queue.py +145 -0
- aru_code-0.51.0/aru/tui/widgets/subagent_panel.py +274 -0
- aru_code-0.51.0/aru/tui/widgets/tasklist_panel.py +200 -0
- {aru_code-0.47.0 → aru_code-0.51.0/aru_code.egg-info}/PKG-INFO +1 -1
- {aru_code-0.47.0 → aru_code-0.51.0}/aru_code.egg-info/SOURCES.txt +20 -0
- {aru_code-0.47.0 → aru_code-0.51.0}/pyproject.toml +1 -1
- {aru_code-0.47.0 → aru_code-0.51.0}/tests/test_chat_scrollable.py +13 -2
- aru_code-0.51.0/tests/test_doom_loop.py +492 -0
- {aru_code-0.47.0 → aru_code-0.51.0}/tests/test_events_schema.py +17 -0
- aru_code-0.51.0/tests/test_session_free_cost.py +59 -0
- {aru_code-0.47.0 → aru_code-0.51.0}/tests/test_streaming_sink.py +3 -0
- aru_code-0.51.0/tests/test_subagent_tool_events.py +301 -0
- {aru_code-0.47.0 → aru_code-0.51.0}/tests/test_tui_completer.py +18 -19
- {aru_code-0.47.0 → aru_code-0.51.0}/tests/test_tui_copy.py +52 -7
- aru_code-0.51.0/tests/test_tui_error_display.py +205 -0
- aru_code-0.51.0/tests/test_tui_file_link.py +108 -0
- {aru_code-0.47.0 → aru_code-0.51.0}/tests/test_tui_input_behaviour.py +123 -69
- {aru_code-0.47.0 → aru_code-0.51.0}/tests/test_tui_mode_cycle.py +4 -4
- {aru_code-0.47.0 → aru_code-0.51.0}/tests/test_tui_permission_flow.py +2 -2
- aru_code-0.51.0/tests/test_tui_plan_task_render.py +127 -0
- aru_code-0.51.0/tests/test_tui_prompt_queue.py +109 -0
- {aru_code-0.47.0 → aru_code-0.51.0}/tests/test_tui_shell_bang.py +18 -18
- {aru_code-0.47.0 → aru_code-0.51.0}/tests/test_tui_slash_bridge.py +3 -3
- aru_code-0.51.0/tests/test_tui_slash_model.py +141 -0
- aru_code-0.51.0/tests/test_tui_subagent_panel.py +300 -0
- aru_code-0.51.0/tests/test_tui_theme.py +85 -0
- aru_code-0.47.0/aru/__init__.py +0 -1
- aru_code-0.47.0/aru/tui/screens/__init__.py +0 -8
- aru_code-0.47.0/tests/test_tui_plan_task_render.py +0 -95
- {aru_code-0.47.0 → aru_code-0.51.0}/LICENSE +0 -0
- {aru_code-0.47.0 → aru_code-0.51.0}/README.md +0 -0
- {aru_code-0.47.0 → aru_code-0.51.0}/aru/agent_factory.py +0 -0
- {aru_code-0.47.0 → aru_code-0.51.0}/aru/agents/__init__.py +0 -0
- {aru_code-0.47.0 → aru_code-0.51.0}/aru/agents/base.py +0 -0
- {aru_code-0.47.0 → aru_code-0.51.0}/aru/agents/catalog.py +0 -0
- {aru_code-0.47.0 → aru_code-0.51.0}/aru/agents/planner.py +0 -0
- {aru_code-0.47.0 → aru_code-0.51.0}/aru/cache_patch.py +0 -0
- {aru_code-0.47.0 → aru_code-0.51.0}/aru/checkpoints.py +0 -0
- {aru_code-0.47.0 → aru_code-0.51.0}/aru/cli.py +0 -0
- {aru_code-0.47.0 → aru_code-0.51.0}/aru/commands.py +0 -0
- {aru_code-0.47.0 → aru_code-0.51.0}/aru/completers.py +0 -0
- {aru_code-0.47.0 → aru_code-0.51.0}/aru/context.py +0 -0
- {aru_code-0.47.0 → aru_code-0.51.0}/aru/format/__init__.py +0 -0
- {aru_code-0.47.0 → aru_code-0.51.0}/aru/format/manager.py +0 -0
- {aru_code-0.47.0 → aru_code-0.51.0}/aru/format/runner.py +0 -0
- {aru_code-0.47.0 → aru_code-0.51.0}/aru/history_blocks.py +0 -0
- {aru_code-0.47.0 → aru_code-0.51.0}/aru/lsp/__init__.py +0 -0
- {aru_code-0.47.0 → aru_code-0.51.0}/aru/lsp/client.py +0 -0
- {aru_code-0.47.0 → aru_code-0.51.0}/aru/lsp/manager.py +0 -0
- {aru_code-0.47.0 → aru_code-0.51.0}/aru/lsp/protocol.py +0 -0
- {aru_code-0.47.0 → aru_code-0.51.0}/aru/memory/__init__.py +0 -0
- {aru_code-0.47.0 → aru_code-0.51.0}/aru/memory/extractor.py +0 -0
- {aru_code-0.47.0 → aru_code-0.51.0}/aru/memory/loader.py +0 -0
- {aru_code-0.47.0 → aru_code-0.51.0}/aru/memory/store.py +0 -0
- {aru_code-0.47.0 → aru_code-0.51.0}/aru/permissions.py +0 -0
- {aru_code-0.47.0 → aru_code-0.51.0}/aru/plugin_cache.py +0 -0
- {aru_code-0.47.0 → aru_code-0.51.0}/aru/plugins/__init__.py +0 -0
- {aru_code-0.47.0 → aru_code-0.51.0}/aru/plugins/custom_tools.py +0 -0
- {aru_code-0.47.0 → aru_code-0.51.0}/aru/plugins/manager.py +0 -0
- {aru_code-0.47.0 → aru_code-0.51.0}/aru/plugins/tool_api.py +0 -0
- {aru_code-0.47.0 → aru_code-0.51.0}/aru/providers.py +0 -0
- {aru_code-0.47.0 → aru_code-0.51.0}/aru/runtime.py +0 -0
- {aru_code-0.47.0 → aru_code-0.51.0}/aru/select.py +0 -0
- {aru_code-0.47.0 → aru_code-0.51.0}/aru/tool_policy.py +0 -0
- {aru_code-0.47.0 → aru_code-0.51.0}/aru/tools/__init__.py +0 -0
- {aru_code-0.47.0 → aru_code-0.51.0}/aru/tools/_diff.py +0 -0
- {aru_code-0.47.0 → aru_code-0.51.0}/aru/tools/_shared.py +0 -0
- {aru_code-0.47.0 → aru_code-0.51.0}/aru/tools/apply_patch.py +0 -0
- {aru_code-0.47.0 → aru_code-0.51.0}/aru/tools/apply_patch_prompt.txt +0 -0
- {aru_code-0.47.0 → aru_code-0.51.0}/aru/tools/ast_tools.py +0 -0
- {aru_code-0.47.0 → aru_code-0.51.0}/aru/tools/codebase.py +0 -0
- {aru_code-0.47.0 → aru_code-0.51.0}/aru/tools/delegate_prompt.txt +0 -0
- {aru_code-0.47.0 → aru_code-0.51.0}/aru/tools/file_ops.py +0 -0
- {aru_code-0.47.0 → aru_code-0.51.0}/aru/tools/gitignore.py +0 -0
- {aru_code-0.47.0 → aru_code-0.51.0}/aru/tools/lsp.py +0 -0
- {aru_code-0.47.0 → aru_code-0.51.0}/aru/tools/mcp_client.py +0 -0
- {aru_code-0.47.0 → aru_code-0.51.0}/aru/tools/memory_tool.py +0 -0
- {aru_code-0.47.0 → aru_code-0.51.0}/aru/tools/plan_mode.py +0 -0
- {aru_code-0.47.0 → aru_code-0.51.0}/aru/tools/ranker.py +0 -0
- {aru_code-0.47.0 → aru_code-0.51.0}/aru/tools/registry.py +0 -0
- {aru_code-0.47.0 → aru_code-0.51.0}/aru/tools/search.py +0 -0
- {aru_code-0.47.0 → aru_code-0.51.0}/aru/tools/shell.py +0 -0
- {aru_code-0.47.0 → aru_code-0.51.0}/aru/tools/skill.py +0 -0
- {aru_code-0.47.0 → aru_code-0.51.0}/aru/tools/web.py +0 -0
- {aru_code-0.47.0 → aru_code-0.51.0}/aru/tools/worktree.py +0 -0
- {aru_code-0.47.0 → aru_code-0.51.0}/aru/tui/__init__.py +0 -0
- {aru_code-0.47.0 → aru_code-0.51.0}/aru/tui/sanitize.py +0 -0
- {aru_code-0.47.0 → aru_code-0.51.0}/aru/tui/screens/choice.py +0 -0
- {aru_code-0.47.0 → aru_code-0.51.0}/aru/tui/screens/confirm.py +0 -0
- {aru_code-0.47.0 → aru_code-0.51.0}/aru/tui/screens/search.py +0 -0
- {aru_code-0.47.0 → aru_code-0.51.0}/aru/tui/screens/text_input.py +0 -0
- {aru_code-0.47.0 → aru_code-0.51.0}/aru/tui/slash_bridge.py +0 -0
- {aru_code-0.47.0 → aru_code-0.51.0}/aru/tui/ui.py +0 -0
- {aru_code-0.47.0 → aru_code-0.51.0}/aru/tui/widgets/__init__.py +0 -0
- {aru_code-0.47.0 → aru_code-0.51.0}/aru/tui/widgets/context_pane.py +0 -0
- {aru_code-0.47.0 → aru_code-0.51.0}/aru/tui/widgets/header.py +0 -0
- {aru_code-0.47.0 → aru_code-0.51.0}/aru/tui/widgets/inline_choice.py +0 -0
- {aru_code-0.47.0 → aru_code-0.51.0}/aru/tui/widgets/loaded_pane.py +0 -0
- {aru_code-0.47.0 → aru_code-0.51.0}/aru/tui/widgets/status.py +0 -0
- {aru_code-0.47.0 → aru_code-0.51.0}/aru/tui/widgets/thinking.py +0 -0
- {aru_code-0.47.0 → aru_code-0.51.0}/aru/tui/widgets/tools.py +0 -0
- {aru_code-0.47.0 → aru_code-0.51.0}/aru/ui.py +0 -0
- {aru_code-0.47.0 → aru_code-0.51.0}/aru_code.egg-info/dependency_links.txt +0 -0
- {aru_code-0.47.0 → aru_code-0.51.0}/aru_code.egg-info/entry_points.txt +0 -0
- {aru_code-0.47.0 → aru_code-0.51.0}/aru_code.egg-info/requires.txt +0 -0
- {aru_code-0.47.0 → aru_code-0.51.0}/aru_code.egg-info/top_level.txt +0 -0
- {aru_code-0.47.0 → aru_code-0.51.0}/setup.cfg +0 -0
- {aru_code-0.47.0 → aru_code-0.51.0}/tests/test_agents_base.py +0 -0
- {aru_code-0.47.0 → aru_code-0.51.0}/tests/test_agents_md_coverage.py +0 -0
- {aru_code-0.47.0 → aru_code-0.51.0}/tests/test_apply_patch.py +0 -0
- {aru_code-0.47.0 → aru_code-0.51.0}/tests/test_async_tool_permission.py +0 -0
- {aru_code-0.47.0 → aru_code-0.51.0}/tests/test_cache_patch_metrics.py +0 -0
- {aru_code-0.47.0 → aru_code-0.51.0}/tests/test_cache_patch_stop_reason.py +0 -0
- {aru_code-0.47.0 → aru_code-0.51.0}/tests/test_catalog.py +0 -0
- {aru_code-0.47.0 → aru_code-0.51.0}/tests/test_checkpoints.py +0 -0
- {aru_code-0.47.0 → aru_code-0.51.0}/tests/test_cli.py +0 -0
- {aru_code-0.47.0 → aru_code-0.51.0}/tests/test_cli_advanced.py +0 -0
- {aru_code-0.47.0 → aru_code-0.51.0}/tests/test_cli_base.py +0 -0
- {aru_code-0.47.0 → aru_code-0.51.0}/tests/test_cli_completers.py +0 -0
- {aru_code-0.47.0 → aru_code-0.51.0}/tests/test_cli_new.py +0 -0
- {aru_code-0.47.0 → aru_code-0.51.0}/tests/test_cli_run_cli.py +0 -0
- {aru_code-0.47.0 → aru_code-0.51.0}/tests/test_cli_session.py +0 -0
- {aru_code-0.47.0 → aru_code-0.51.0}/tests/test_cli_shell.py +0 -0
- {aru_code-0.47.0 → aru_code-0.51.0}/tests/test_codebase.py +0 -0
- {aru_code-0.47.0 → aru_code-0.51.0}/tests/test_confabulation_regression.py +0 -0
- {aru_code-0.47.0 → aru_code-0.51.0}/tests/test_config.py +0 -0
- {aru_code-0.47.0 → aru_code-0.51.0}/tests/test_context.py +0 -0
- {aru_code-0.47.0 → aru_code-0.51.0}/tests/test_context_pane.py +0 -0
- {aru_code-0.47.0 → aru_code-0.51.0}/tests/test_cwd_awareness.py +0 -0
- {aru_code-0.47.0 → aru_code-0.51.0}/tests/test_delegate.py +0 -0
- {aru_code-0.47.0 → aru_code-0.51.0}/tests/test_events_backward_compat.py +0 -0
- {aru_code-0.47.0 → aru_code-0.51.0}/tests/test_fork_ctx_concurrency.py +0 -0
- {aru_code-0.47.0 → aru_code-0.51.0}/tests/test_format.py +0 -0
- {aru_code-0.47.0 → aru_code-0.51.0}/tests/test_gitignore.py +0 -0
- {aru_code-0.47.0 → aru_code-0.51.0}/tests/test_guardrails_scenarios.py +0 -0
- {aru_code-0.47.0 → aru_code-0.51.0}/tests/test_invoke_skill.py +0 -0
- {aru_code-0.47.0 → aru_code-0.51.0}/tests/test_invoked_skills.py +0 -0
- {aru_code-0.47.0 → aru_code-0.51.0}/tests/test_loaded_pane_path.py +0 -0
- {aru_code-0.47.0 → aru_code-0.51.0}/tests/test_lsp.py +0 -0
- {aru_code-0.47.0 → aru_code-0.51.0}/tests/test_lsp_rename.py +0 -0
- {aru_code-0.47.0 → aru_code-0.51.0}/tests/test_main.py +0 -0
- {aru_code-0.47.0 → aru_code-0.51.0}/tests/test_markdown_to_text.py +0 -0
- {aru_code-0.47.0 → aru_code-0.51.0}/tests/test_mcp_client.py +0 -0
- {aru_code-0.47.0 → aru_code-0.51.0}/tests/test_mcp_health.py +0 -0
- {aru_code-0.47.0 → aru_code-0.51.0}/tests/test_memory.py +0 -0
- {aru_code-0.47.0 → aru_code-0.51.0}/tests/test_memory_tool.py +0 -0
- {aru_code-0.47.0 → aru_code-0.51.0}/tests/test_microcompact.py +0 -0
- {aru_code-0.47.0 → aru_code-0.51.0}/tests/test_permissions.py +0 -0
- {aru_code-0.47.0 → aru_code-0.51.0}/tests/test_plan_mode_refactor.py +0 -0
- {aru_code-0.47.0 → aru_code-0.51.0}/tests/test_plugin_cache.py +0 -0
- {aru_code-0.47.0 → aru_code-0.51.0}/tests/test_plugin_errors.py +0 -0
- {aru_code-0.47.0 → aru_code-0.51.0}/tests/test_plugin_hooks_v2.py +0 -0
- {aru_code-0.47.0 → aru_code-0.51.0}/tests/test_plugins.py +0 -0
- {aru_code-0.47.0 → aru_code-0.51.0}/tests/test_providers.py +0 -0
- {aru_code-0.47.0 → aru_code-0.51.0}/tests/test_ranker.py +0 -0
- {aru_code-0.47.0 → aru_code-0.51.0}/tests/test_reasoning.py +0 -0
- {aru_code-0.47.0 → aru_code-0.51.0}/tests/test_runner_interrupt.py +0 -0
- {aru_code-0.47.0 → aru_code-0.51.0}/tests/test_runner_recovery.py +0 -0
- {aru_code-0.47.0 → aru_code-0.51.0}/tests/test_runtime.py +0 -0
- {aru_code-0.47.0 → aru_code-0.51.0}/tests/test_select.py +0 -0
- {aru_code-0.47.0 → aru_code-0.51.0}/tests/test_skill_disallowed_tools.py +0 -0
- {aru_code-0.47.0 → aru_code-0.51.0}/tests/test_status_breakdown.py +0 -0
- {aru_code-0.47.0 → aru_code-0.51.0}/tests/test_status_cost.py +0 -0
- {aru_code-0.47.0 → aru_code-0.51.0}/tests/test_tasklist.py +0 -0
- {aru_code-0.47.0 → aru_code-0.51.0}/tests/test_thread_tool_timeout.py +0 -0
- {aru_code-0.47.0 → aru_code-0.51.0}/tests/test_tool_policy.py +0 -0
- {aru_code-0.47.0 → aru_code-0.51.0}/tests/test_truncation_marker.py +0 -0
- {aru_code-0.47.0 → aru_code-0.51.0}/tests/test_tui_app_boot.py +0 -0
- {aru_code-0.47.0 → aru_code-0.51.0}/tests/test_tui_bindings.py +0 -0
- {aru_code-0.47.0 → aru_code-0.51.0}/tests/test_tui_bus_flow.py +0 -0
- {aru_code-0.47.0 → aru_code-0.51.0}/tests/test_tui_chat.py +0 -0
- {aru_code-0.47.0 → aru_code-0.51.0}/tests/test_tui_chat_adversarial.py +0 -0
- {aru_code-0.47.0 → aru_code-0.51.0}/tests/test_tui_completer_dynamic.py +0 -0
- {aru_code-0.47.0 → aru_code-0.51.0}/tests/test_tui_layer12_recovery.py +0 -0
- {aru_code-0.47.0 → aru_code-0.51.0}/tests/test_tui_layer13_recovery.py +0 -0
- {aru_code-0.47.0 → aru_code-0.51.0}/tests/test_tui_mention_expand.py +0 -0
- {aru_code-0.47.0 → aru_code-0.51.0}/tests/test_tui_modals.py +0 -0
- {aru_code-0.47.0 → aru_code-0.51.0}/tests/test_tui_native_selection.py +0 -0
- {aru_code-0.47.0 → aru_code-0.51.0}/tests/test_tui_sidebar_toggle.py +0 -0
- {aru_code-0.47.0 → aru_code-0.51.0}/tests/test_tui_snapshot_smoke.py +0 -0
- {aru_code-0.47.0 → aru_code-0.51.0}/tests/test_tui_thinking_and_boot.py +0 -0
- {aru_code-0.47.0 → aru_code-0.51.0}/tests/test_tui_widgets_visual.py +0 -0
- {aru_code-0.47.0 → aru_code-0.51.0}/tests/test_ui_adapter.py +0 -0
- {aru_code-0.47.0 → aru_code-0.51.0}/tests/test_worktree.py +0 -0
- {aru_code-0.47.0 → aru_code-0.51.0}/tests/test_worktree_session_restore.py +0 -0
|
@@ -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)
|
|
@@ -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
|
-
|
|
699
|
-
console
|
|
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:
|