aru-code 0.48.0__tar.gz → 0.52.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.48.0/aru_code.egg-info → aru_code-0.52.0}/PKG-INFO +1 -1
- aru_code-0.52.0/aru/__init__.py +1 -0
- {aru_code-0.48.0 → aru_code-0.52.0}/aru/config.py +23 -0
- {aru_code-0.48.0 → aru_code-0.52.0}/aru/display.py +1 -1
- aru_code-0.52.0/aru/doom_loop.py +137 -0
- {aru_code-0.48.0 → aru_code-0.52.0}/aru/events.py +56 -0
- {aru_code-0.48.0 → aru_code-0.52.0}/aru/plugins/hooks.py +6 -0
- {aru_code-0.48.0 → aru_code-0.52.0}/aru/streaming.py +102 -0
- {aru_code-0.48.0 → aru_code-0.52.0}/aru/tools/delegate.py +43 -0
- {aru_code-0.48.0 → aru_code-0.52.0}/aru/tools/tasklist.py +99 -10
- {aru_code-0.48.0 → aru_code-0.52.0}/aru/tui/app.py +604 -187
- aru_code-0.52.0/aru/tui/notifications.py +238 -0
- aru_code-0.52.0/aru/tui/screens/__init__.py +17 -0
- aru_code-0.52.0/aru/tui/screens/keymap.py +160 -0
- aru_code-0.52.0/aru/tui/screens/session_picker.py +189 -0
- aru_code-0.52.0/aru/tui/themes.py +99 -0
- {aru_code-0.48.0 → aru_code-0.52.0}/aru/tui/widgets/chat.py +50 -1
- {aru_code-0.48.0 → aru_code-0.52.0}/aru/tui/widgets/completer.py +1 -0
- {aru_code-0.48.0 → aru_code-0.52.0}/aru/tui/widgets/context_pane.py +1 -1
- aru_code-0.52.0/aru/tui/widgets/file_link.py +255 -0
- aru_code-0.52.0/aru/tui/widgets/prompt_area.py +329 -0
- aru_code-0.52.0/aru/tui/widgets/prompt_queue.py +145 -0
- aru_code-0.52.0/aru/tui/widgets/subagent_panel.py +274 -0
- aru_code-0.52.0/aru/tui/widgets/tasklist_panel.py +204 -0
- {aru_code-0.48.0 → aru_code-0.52.0/aru_code.egg-info}/PKG-INFO +1 -1
- {aru_code-0.48.0 → aru_code-0.52.0}/aru_code.egg-info/SOURCES.txt +16 -0
- {aru_code-0.48.0 → aru_code-0.52.0}/pyproject.toml +1 -1
- {aru_code-0.48.0 → aru_code-0.52.0}/tests/test_chat_scrollable.py +13 -2
- aru_code-0.52.0/tests/test_doom_loop.py +492 -0
- {aru_code-0.48.0 → aru_code-0.52.0}/tests/test_events_schema.py +17 -0
- aru_code-0.52.0/tests/test_subagent_tool_events.py +301 -0
- {aru_code-0.48.0 → aru_code-0.52.0}/tests/test_tui_completer.py +18 -19
- {aru_code-0.48.0 → aru_code-0.52.0}/tests/test_tui_copy.py +52 -7
- aru_code-0.52.0/tests/test_tui_file_link.py +108 -0
- {aru_code-0.48.0 → aru_code-0.52.0}/tests/test_tui_input_behaviour.py +123 -69
- {aru_code-0.48.0 → aru_code-0.52.0}/tests/test_tui_mode_cycle.py +4 -4
- {aru_code-0.48.0 → aru_code-0.52.0}/tests/test_tui_permission_flow.py +2 -2
- aru_code-0.52.0/tests/test_tui_plan_task_render.py +127 -0
- aru_code-0.52.0/tests/test_tui_prompt_queue.py +109 -0
- {aru_code-0.48.0 → aru_code-0.52.0}/tests/test_tui_shell_bang.py +18 -18
- {aru_code-0.48.0 → aru_code-0.52.0}/tests/test_tui_slash_bridge.py +3 -3
- aru_code-0.52.0/tests/test_tui_subagent_panel.py +300 -0
- aru_code-0.52.0/tests/test_tui_theme.py +85 -0
- aru_code-0.48.0/aru/__init__.py +0 -1
- aru_code-0.48.0/aru/tui/screens/__init__.py +0 -8
- aru_code-0.48.0/tests/test_tui_plan_task_render.py +0 -95
- {aru_code-0.48.0 → aru_code-0.52.0}/LICENSE +0 -0
- {aru_code-0.48.0 → aru_code-0.52.0}/README.md +0 -0
- {aru_code-0.48.0 → aru_code-0.52.0}/aru/agent_factory.py +0 -0
- {aru_code-0.48.0 → aru_code-0.52.0}/aru/agents/__init__.py +0 -0
- {aru_code-0.48.0 → aru_code-0.52.0}/aru/agents/base.py +0 -0
- {aru_code-0.48.0 → aru_code-0.52.0}/aru/agents/catalog.py +0 -0
- {aru_code-0.48.0 → aru_code-0.52.0}/aru/agents/planner.py +0 -0
- {aru_code-0.48.0 → aru_code-0.52.0}/aru/cache_patch.py +0 -0
- {aru_code-0.48.0 → aru_code-0.52.0}/aru/checkpoints.py +0 -0
- {aru_code-0.48.0 → aru_code-0.52.0}/aru/cli.py +0 -0
- {aru_code-0.48.0 → aru_code-0.52.0}/aru/commands.py +0 -0
- {aru_code-0.48.0 → aru_code-0.52.0}/aru/completers.py +0 -0
- {aru_code-0.48.0 → aru_code-0.52.0}/aru/context.py +0 -0
- {aru_code-0.48.0 → aru_code-0.52.0}/aru/format/__init__.py +0 -0
- {aru_code-0.48.0 → aru_code-0.52.0}/aru/format/manager.py +0 -0
- {aru_code-0.48.0 → aru_code-0.52.0}/aru/format/runner.py +0 -0
- {aru_code-0.48.0 → aru_code-0.52.0}/aru/history_blocks.py +0 -0
- {aru_code-0.48.0 → aru_code-0.52.0}/aru/lsp/__init__.py +0 -0
- {aru_code-0.48.0 → aru_code-0.52.0}/aru/lsp/client.py +0 -0
- {aru_code-0.48.0 → aru_code-0.52.0}/aru/lsp/manager.py +0 -0
- {aru_code-0.48.0 → aru_code-0.52.0}/aru/lsp/protocol.py +0 -0
- {aru_code-0.48.0 → aru_code-0.52.0}/aru/memory/__init__.py +0 -0
- {aru_code-0.48.0 → aru_code-0.52.0}/aru/memory/extractor.py +0 -0
- {aru_code-0.48.0 → aru_code-0.52.0}/aru/memory/loader.py +0 -0
- {aru_code-0.48.0 → aru_code-0.52.0}/aru/memory/store.py +0 -0
- {aru_code-0.48.0 → aru_code-0.52.0}/aru/permissions.py +0 -0
- {aru_code-0.48.0 → aru_code-0.52.0}/aru/plugin_cache.py +0 -0
- {aru_code-0.48.0 → aru_code-0.52.0}/aru/plugins/__init__.py +0 -0
- {aru_code-0.48.0 → aru_code-0.52.0}/aru/plugins/custom_tools.py +0 -0
- {aru_code-0.48.0 → aru_code-0.52.0}/aru/plugins/manager.py +0 -0
- {aru_code-0.48.0 → aru_code-0.52.0}/aru/plugins/tool_api.py +0 -0
- {aru_code-0.48.0 → aru_code-0.52.0}/aru/providers.py +0 -0
- {aru_code-0.48.0 → aru_code-0.52.0}/aru/runner.py +0 -0
- {aru_code-0.48.0 → aru_code-0.52.0}/aru/runtime.py +0 -0
- {aru_code-0.48.0 → aru_code-0.52.0}/aru/select.py +0 -0
- {aru_code-0.48.0 → aru_code-0.52.0}/aru/session.py +0 -0
- {aru_code-0.48.0 → aru_code-0.52.0}/aru/sinks.py +0 -0
- {aru_code-0.48.0 → aru_code-0.52.0}/aru/tool_policy.py +0 -0
- {aru_code-0.48.0 → aru_code-0.52.0}/aru/tools/__init__.py +0 -0
- {aru_code-0.48.0 → aru_code-0.52.0}/aru/tools/_diff.py +0 -0
- {aru_code-0.48.0 → aru_code-0.52.0}/aru/tools/_shared.py +0 -0
- {aru_code-0.48.0 → aru_code-0.52.0}/aru/tools/apply_patch.py +0 -0
- {aru_code-0.48.0 → aru_code-0.52.0}/aru/tools/apply_patch_prompt.txt +0 -0
- {aru_code-0.48.0 → aru_code-0.52.0}/aru/tools/ast_tools.py +0 -0
- {aru_code-0.48.0 → aru_code-0.52.0}/aru/tools/codebase.py +0 -0
- {aru_code-0.48.0 → aru_code-0.52.0}/aru/tools/delegate_prompt.txt +0 -0
- {aru_code-0.48.0 → aru_code-0.52.0}/aru/tools/file_ops.py +0 -0
- {aru_code-0.48.0 → aru_code-0.52.0}/aru/tools/gitignore.py +0 -0
- {aru_code-0.48.0 → aru_code-0.52.0}/aru/tools/lsp.py +0 -0
- {aru_code-0.48.0 → aru_code-0.52.0}/aru/tools/mcp_client.py +0 -0
- {aru_code-0.48.0 → aru_code-0.52.0}/aru/tools/memory_tool.py +0 -0
- {aru_code-0.48.0 → aru_code-0.52.0}/aru/tools/plan_mode.py +0 -0
- {aru_code-0.48.0 → aru_code-0.52.0}/aru/tools/ranker.py +0 -0
- {aru_code-0.48.0 → aru_code-0.52.0}/aru/tools/registry.py +0 -0
- {aru_code-0.48.0 → aru_code-0.52.0}/aru/tools/search.py +0 -0
- {aru_code-0.48.0 → aru_code-0.52.0}/aru/tools/shell.py +0 -0
- {aru_code-0.48.0 → aru_code-0.52.0}/aru/tools/skill.py +0 -0
- {aru_code-0.48.0 → aru_code-0.52.0}/aru/tools/web.py +0 -0
- {aru_code-0.48.0 → aru_code-0.52.0}/aru/tools/worktree.py +0 -0
- {aru_code-0.48.0 → aru_code-0.52.0}/aru/tui/__init__.py +0 -0
- {aru_code-0.48.0 → aru_code-0.52.0}/aru/tui/log_bridge.py +0 -0
- {aru_code-0.48.0 → aru_code-0.52.0}/aru/tui/sanitize.py +0 -0
- {aru_code-0.48.0 → aru_code-0.52.0}/aru/tui/screens/choice.py +0 -0
- {aru_code-0.48.0 → aru_code-0.52.0}/aru/tui/screens/confirm.py +0 -0
- {aru_code-0.48.0 → aru_code-0.52.0}/aru/tui/screens/search.py +0 -0
- {aru_code-0.48.0 → aru_code-0.52.0}/aru/tui/screens/text_input.py +0 -0
- {aru_code-0.48.0 → aru_code-0.52.0}/aru/tui/sinks.py +0 -0
- {aru_code-0.48.0 → aru_code-0.52.0}/aru/tui/slash_bridge.py +0 -0
- {aru_code-0.48.0 → aru_code-0.52.0}/aru/tui/ui.py +0 -0
- {aru_code-0.48.0 → aru_code-0.52.0}/aru/tui/widgets/__init__.py +0 -0
- {aru_code-0.48.0 → aru_code-0.52.0}/aru/tui/widgets/header.py +0 -0
- {aru_code-0.48.0 → aru_code-0.52.0}/aru/tui/widgets/inline_choice.py +0 -0
- {aru_code-0.48.0 → aru_code-0.52.0}/aru/tui/widgets/loaded_pane.py +0 -0
- {aru_code-0.48.0 → aru_code-0.52.0}/aru/tui/widgets/status.py +0 -0
- {aru_code-0.48.0 → aru_code-0.52.0}/aru/tui/widgets/thinking.py +0 -0
- {aru_code-0.48.0 → aru_code-0.52.0}/aru/tui/widgets/tools.py +0 -0
- {aru_code-0.48.0 → aru_code-0.52.0}/aru/ui.py +0 -0
- {aru_code-0.48.0 → aru_code-0.52.0}/aru_code.egg-info/dependency_links.txt +0 -0
- {aru_code-0.48.0 → aru_code-0.52.0}/aru_code.egg-info/entry_points.txt +0 -0
- {aru_code-0.48.0 → aru_code-0.52.0}/aru_code.egg-info/requires.txt +0 -0
- {aru_code-0.48.0 → aru_code-0.52.0}/aru_code.egg-info/top_level.txt +0 -0
- {aru_code-0.48.0 → aru_code-0.52.0}/setup.cfg +0 -0
- {aru_code-0.48.0 → aru_code-0.52.0}/tests/test_agents_base.py +0 -0
- {aru_code-0.48.0 → aru_code-0.52.0}/tests/test_agents_md_coverage.py +0 -0
- {aru_code-0.48.0 → aru_code-0.52.0}/tests/test_apply_patch.py +0 -0
- {aru_code-0.48.0 → aru_code-0.52.0}/tests/test_async_tool_permission.py +0 -0
- {aru_code-0.48.0 → aru_code-0.52.0}/tests/test_cache_patch_metrics.py +0 -0
- {aru_code-0.48.0 → aru_code-0.52.0}/tests/test_cache_patch_stop_reason.py +0 -0
- {aru_code-0.48.0 → aru_code-0.52.0}/tests/test_catalog.py +0 -0
- {aru_code-0.48.0 → aru_code-0.52.0}/tests/test_checkpoints.py +0 -0
- {aru_code-0.48.0 → aru_code-0.52.0}/tests/test_cli.py +0 -0
- {aru_code-0.48.0 → aru_code-0.52.0}/tests/test_cli_advanced.py +0 -0
- {aru_code-0.48.0 → aru_code-0.52.0}/tests/test_cli_base.py +0 -0
- {aru_code-0.48.0 → aru_code-0.52.0}/tests/test_cli_completers.py +0 -0
- {aru_code-0.48.0 → aru_code-0.52.0}/tests/test_cli_new.py +0 -0
- {aru_code-0.48.0 → aru_code-0.52.0}/tests/test_cli_run_cli.py +0 -0
- {aru_code-0.48.0 → aru_code-0.52.0}/tests/test_cli_session.py +0 -0
- {aru_code-0.48.0 → aru_code-0.52.0}/tests/test_cli_shell.py +0 -0
- {aru_code-0.48.0 → aru_code-0.52.0}/tests/test_codebase.py +0 -0
- {aru_code-0.48.0 → aru_code-0.52.0}/tests/test_confabulation_regression.py +0 -0
- {aru_code-0.48.0 → aru_code-0.52.0}/tests/test_config.py +0 -0
- {aru_code-0.48.0 → aru_code-0.52.0}/tests/test_context.py +0 -0
- {aru_code-0.48.0 → aru_code-0.52.0}/tests/test_context_pane.py +0 -0
- {aru_code-0.48.0 → aru_code-0.52.0}/tests/test_cwd_awareness.py +0 -0
- {aru_code-0.48.0 → aru_code-0.52.0}/tests/test_delegate.py +0 -0
- {aru_code-0.48.0 → aru_code-0.52.0}/tests/test_events_backward_compat.py +0 -0
- {aru_code-0.48.0 → aru_code-0.52.0}/tests/test_fork_ctx_concurrency.py +0 -0
- {aru_code-0.48.0 → aru_code-0.52.0}/tests/test_format.py +0 -0
- {aru_code-0.48.0 → aru_code-0.52.0}/tests/test_gitignore.py +0 -0
- {aru_code-0.48.0 → aru_code-0.52.0}/tests/test_guardrails_scenarios.py +0 -0
- {aru_code-0.48.0 → aru_code-0.52.0}/tests/test_invoke_skill.py +0 -0
- {aru_code-0.48.0 → aru_code-0.52.0}/tests/test_invoked_skills.py +0 -0
- {aru_code-0.48.0 → aru_code-0.52.0}/tests/test_loaded_pane_path.py +0 -0
- {aru_code-0.48.0 → aru_code-0.52.0}/tests/test_lsp.py +0 -0
- {aru_code-0.48.0 → aru_code-0.52.0}/tests/test_lsp_rename.py +0 -0
- {aru_code-0.48.0 → aru_code-0.52.0}/tests/test_main.py +0 -0
- {aru_code-0.48.0 → aru_code-0.52.0}/tests/test_markdown_to_text.py +0 -0
- {aru_code-0.48.0 → aru_code-0.52.0}/tests/test_mcp_client.py +0 -0
- {aru_code-0.48.0 → aru_code-0.52.0}/tests/test_mcp_health.py +0 -0
- {aru_code-0.48.0 → aru_code-0.52.0}/tests/test_memory.py +0 -0
- {aru_code-0.48.0 → aru_code-0.52.0}/tests/test_memory_tool.py +0 -0
- {aru_code-0.48.0 → aru_code-0.52.0}/tests/test_microcompact.py +0 -0
- {aru_code-0.48.0 → aru_code-0.52.0}/tests/test_permissions.py +0 -0
- {aru_code-0.48.0 → aru_code-0.52.0}/tests/test_plan_mode_refactor.py +0 -0
- {aru_code-0.48.0 → aru_code-0.52.0}/tests/test_plugin_cache.py +0 -0
- {aru_code-0.48.0 → aru_code-0.52.0}/tests/test_plugin_errors.py +0 -0
- {aru_code-0.48.0 → aru_code-0.52.0}/tests/test_plugin_hooks_v2.py +0 -0
- {aru_code-0.48.0 → aru_code-0.52.0}/tests/test_plugins.py +0 -0
- {aru_code-0.48.0 → aru_code-0.52.0}/tests/test_providers.py +0 -0
- {aru_code-0.48.0 → aru_code-0.52.0}/tests/test_ranker.py +0 -0
- {aru_code-0.48.0 → aru_code-0.52.0}/tests/test_reasoning.py +0 -0
- {aru_code-0.48.0 → aru_code-0.52.0}/tests/test_runner_interrupt.py +0 -0
- {aru_code-0.48.0 → aru_code-0.52.0}/tests/test_runner_recovery.py +0 -0
- {aru_code-0.48.0 → aru_code-0.52.0}/tests/test_runtime.py +0 -0
- {aru_code-0.48.0 → aru_code-0.52.0}/tests/test_select.py +0 -0
- {aru_code-0.48.0 → aru_code-0.52.0}/tests/test_session_free_cost.py +0 -0
- {aru_code-0.48.0 → aru_code-0.52.0}/tests/test_skill_disallowed_tools.py +0 -0
- {aru_code-0.48.0 → aru_code-0.52.0}/tests/test_status_breakdown.py +0 -0
- {aru_code-0.48.0 → aru_code-0.52.0}/tests/test_status_cost.py +0 -0
- {aru_code-0.48.0 → aru_code-0.52.0}/tests/test_streaming_sink.py +0 -0
- {aru_code-0.48.0 → aru_code-0.52.0}/tests/test_tasklist.py +0 -0
- {aru_code-0.48.0 → aru_code-0.52.0}/tests/test_thread_tool_timeout.py +0 -0
- {aru_code-0.48.0 → aru_code-0.52.0}/tests/test_tool_policy.py +0 -0
- {aru_code-0.48.0 → aru_code-0.52.0}/tests/test_truncation_marker.py +0 -0
- {aru_code-0.48.0 → aru_code-0.52.0}/tests/test_tui_app_boot.py +0 -0
- {aru_code-0.48.0 → aru_code-0.52.0}/tests/test_tui_bindings.py +0 -0
- {aru_code-0.48.0 → aru_code-0.52.0}/tests/test_tui_bus_flow.py +0 -0
- {aru_code-0.48.0 → aru_code-0.52.0}/tests/test_tui_chat.py +0 -0
- {aru_code-0.48.0 → aru_code-0.52.0}/tests/test_tui_chat_adversarial.py +0 -0
- {aru_code-0.48.0 → aru_code-0.52.0}/tests/test_tui_completer_dynamic.py +0 -0
- {aru_code-0.48.0 → aru_code-0.52.0}/tests/test_tui_error_display.py +0 -0
- {aru_code-0.48.0 → aru_code-0.52.0}/tests/test_tui_layer12_recovery.py +0 -0
- {aru_code-0.48.0 → aru_code-0.52.0}/tests/test_tui_layer13_recovery.py +0 -0
- {aru_code-0.48.0 → aru_code-0.52.0}/tests/test_tui_mention_expand.py +0 -0
- {aru_code-0.48.0 → aru_code-0.52.0}/tests/test_tui_modals.py +0 -0
- {aru_code-0.48.0 → aru_code-0.52.0}/tests/test_tui_native_selection.py +0 -0
- {aru_code-0.48.0 → aru_code-0.52.0}/tests/test_tui_sidebar_toggle.py +0 -0
- {aru_code-0.48.0 → aru_code-0.52.0}/tests/test_tui_slash_model.py +0 -0
- {aru_code-0.48.0 → aru_code-0.52.0}/tests/test_tui_snapshot_smoke.py +0 -0
- {aru_code-0.48.0 → aru_code-0.52.0}/tests/test_tui_thinking_and_boot.py +0 -0
- {aru_code-0.48.0 → aru_code-0.52.0}/tests/test_tui_widgets_visual.py +0 -0
- {aru_code-0.48.0 → aru_code-0.52.0}/tests/test_ui_adapter.py +0 -0
- {aru_code-0.48.0 → aru_code-0.52.0}/tests/test_worktree.py +0 -0
- {aru_code-0.48.0 → aru_code-0.52.0}/tests/test_worktree_session_restore.py +0 -0
|
@@ -0,0 +1 @@
|
|
|
1
|
+
__version__ = "0.52.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
|
|
|
@@ -174,6 +174,7 @@ async def run_stream(
|
|
|
174
174
|
)
|
|
175
175
|
from aru.cache_patch import get_last_stop_reason, reset_last_stop_reason
|
|
176
176
|
from aru.display import _format_tool_label
|
|
177
|
+
from aru.doom_loop import DoomLoopDetector
|
|
177
178
|
|
|
178
179
|
state = StreamState()
|
|
179
180
|
accumulated = ""
|
|
@@ -188,6 +189,14 @@ async def run_stream(
|
|
|
188
189
|
|
|
189
190
|
# Track tool start times so the sink gets a duration on completion.
|
|
190
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()
|
|
191
200
|
import time as _time
|
|
192
201
|
|
|
193
202
|
while True:
|
|
@@ -229,6 +238,9 @@ async def run_stream(
|
|
|
229
238
|
pending_tool_uses[tool_id] = assistant_blocks[-1]
|
|
230
239
|
|
|
231
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
|
+
)
|
|
232
244
|
sink.on_tool_started(
|
|
233
245
|
tool_id=tool_id,
|
|
234
246
|
tool_name=tool_name,
|
|
@@ -303,6 +315,35 @@ async def run_stream(
|
|
|
303
315
|
label=tool_name, # sink caches its own label if needed
|
|
304
316
|
)
|
|
305
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
|
+
|
|
306
347
|
# When the last active tool in the round completed, close it
|
|
307
348
|
# and let the sink flush deferred renders (plan panel etc.).
|
|
308
349
|
if not pending_tool_uses:
|
|
@@ -352,3 +393,64 @@ async def run_stream(
|
|
|
352
393
|
state.accumulated = accumulated
|
|
353
394
|
sink.on_stream_finished(final_content=accumulated)
|
|
354
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:
|
|
@@ -59,6 +59,11 @@ def _show(panel: Panel) -> None:
|
|
|
59
59
|
so we route the panel into the ChatPane via ``call_from_thread``
|
|
60
60
|
instead (matches how TextualBusSink hands rich renderables off to
|
|
61
61
|
the app loop).
|
|
62
|
+
|
|
63
|
+
The TUI sidebar (``TasklistPanel``) listens to ``tasklist.updated``
|
|
64
|
+
/ ``plan.updated`` events directly — it does not use this path. The
|
|
65
|
+
REPL still gets the panel printed; the TUI also still prints it
|
|
66
|
+
inline as a fallback when the user has the sidebar hidden.
|
|
62
67
|
"""
|
|
63
68
|
ctx = get_ctx()
|
|
64
69
|
if ctx.display and hasattr(ctx.display, "show_permission"):
|
|
@@ -69,25 +74,106 @@ def _show(panel: Panel) -> None:
|
|
|
69
74
|
return
|
|
70
75
|
tui_app = getattr(ctx, "tui_app", None)
|
|
71
76
|
if tui_app is not None:
|
|
77
|
+
# Sidebar consumes events; we only print into chat when the
|
|
78
|
+
# sidebar is hidden (Ctrl+B toggle) so the user still has
|
|
79
|
+
# somewhere to see the panel.
|
|
72
80
|
try:
|
|
73
|
-
from aru.tui.widgets.
|
|
74
|
-
|
|
75
|
-
# Scrollable=True so a big panel (diff preview / task list /
|
|
76
|
-
# plan steps) stays contained — the user can scroll inside
|
|
77
|
-
# it without losing the chat stream above/below.
|
|
78
|
-
kwargs = {"scrollable": True}
|
|
81
|
+
from aru.tui.widgets.tasklist_panel import TasklistPanel
|
|
82
|
+
sidebar = None
|
|
79
83
|
try:
|
|
80
|
-
tui_app.
|
|
81
|
-
chat.add_renderable, panel, **kwargs
|
|
82
|
-
)
|
|
84
|
+
sidebar = tui_app.query_one(TasklistPanel)
|
|
83
85
|
except Exception:
|
|
84
|
-
|
|
86
|
+
pass
|
|
87
|
+
if sidebar is not None and sidebar.has_class("-hidden"):
|
|
88
|
+
from aru.tui.widgets.chat import ChatPane
|
|
89
|
+
chat = tui_app.query_one(ChatPane)
|
|
90
|
+
kwargs = {"scrollable": True}
|
|
91
|
+
try:
|
|
92
|
+
tui_app.call_from_thread(
|
|
93
|
+
chat.add_renderable, panel, **kwargs
|
|
94
|
+
)
|
|
95
|
+
except Exception:
|
|
96
|
+
chat.add_renderable(panel, **kwargs)
|
|
97
|
+
# When the sidebar is visible, the event-driven render is
|
|
98
|
+
# the canonical view; chat stays clean.
|
|
85
99
|
return
|
|
86
100
|
except Exception:
|
|
87
101
|
pass
|
|
88
102
|
ctx.console.print(panel)
|
|
89
103
|
|
|
90
104
|
|
|
105
|
+
def _publish_tasklist(tasks: list[dict]) -> None:
|
|
106
|
+
"""Best-effort publish of ``tasklist.updated`` to the plugin bus."""
|
|
107
|
+
try:
|
|
108
|
+
ctx = get_ctx()
|
|
109
|
+
mgr = getattr(ctx, "plugin_manager", None)
|
|
110
|
+
if mgr is None:
|
|
111
|
+
return
|
|
112
|
+
payload = {"tasks": [dict(t) for t in tasks]}
|
|
113
|
+
# Plugin manager.publish is async; spawn as a task so call sites
|
|
114
|
+
# that are themselves sync (tools run in threads) don't block.
|
|
115
|
+
try:
|
|
116
|
+
import asyncio
|
|
117
|
+
loop = asyncio.get_running_loop()
|
|
118
|
+
loop.create_task(mgr.publish("tasklist.updated", payload))
|
|
119
|
+
except RuntimeError:
|
|
120
|
+
# No running loop in this thread — fall back to scheduling
|
|
121
|
+
# on the App's loop if we can reach it.
|
|
122
|
+
tui_app = getattr(ctx, "tui_app", None)
|
|
123
|
+
if tui_app is not None:
|
|
124
|
+
try:
|
|
125
|
+
tui_app.call_from_thread(
|
|
126
|
+
_schedule_publish, mgr, "tasklist.updated", payload
|
|
127
|
+
)
|
|
128
|
+
except Exception:
|
|
129
|
+
pass
|
|
130
|
+
except Exception:
|
|
131
|
+
pass
|
|
132
|
+
|
|
133
|
+
|
|
134
|
+
def _publish_plan(steps: list) -> None:
|
|
135
|
+
"""Best-effort publish of ``plan.updated`` with the current plan."""
|
|
136
|
+
try:
|
|
137
|
+
ctx = get_ctx()
|
|
138
|
+
mgr = getattr(ctx, "plugin_manager", None)
|
|
139
|
+
if mgr is None:
|
|
140
|
+
return
|
|
141
|
+
payload = {
|
|
142
|
+
"steps": [
|
|
143
|
+
{
|
|
144
|
+
"index": getattr(s, "index", 0),
|
|
145
|
+
"description": getattr(s, "description", ""),
|
|
146
|
+
"status": getattr(s, "status", "pending"),
|
|
147
|
+
}
|
|
148
|
+
for s in steps
|
|
149
|
+
]
|
|
150
|
+
}
|
|
151
|
+
try:
|
|
152
|
+
import asyncio
|
|
153
|
+
loop = asyncio.get_running_loop()
|
|
154
|
+
loop.create_task(mgr.publish("plan.updated", payload))
|
|
155
|
+
except RuntimeError:
|
|
156
|
+
tui_app = getattr(ctx, "tui_app", None)
|
|
157
|
+
if tui_app is not None:
|
|
158
|
+
try:
|
|
159
|
+
tui_app.call_from_thread(
|
|
160
|
+
_schedule_publish, mgr, "plan.updated", payload
|
|
161
|
+
)
|
|
162
|
+
except Exception:
|
|
163
|
+
pass
|
|
164
|
+
except Exception:
|
|
165
|
+
pass
|
|
166
|
+
|
|
167
|
+
|
|
168
|
+
def _schedule_publish(mgr, event_type: str, payload: dict) -> None:
|
|
169
|
+
"""Helper run via call_from_thread to schedule an async publish."""
|
|
170
|
+
try:
|
|
171
|
+
import asyncio
|
|
172
|
+
asyncio.create_task(mgr.publish(event_type, payload))
|
|
173
|
+
except Exception:
|
|
174
|
+
pass
|
|
175
|
+
|
|
176
|
+
|
|
91
177
|
def create_task_list(tasks: list[str]) -> str:
|
|
92
178
|
"""Set (or replace) the subtask list for the current phase.
|
|
93
179
|
|
|
@@ -115,6 +201,7 @@ def create_task_list(tasks: list[str]) -> str:
|
|
|
115
201
|
created = store.create(tasks)
|
|
116
202
|
panel = _render_task_list(created)
|
|
117
203
|
_show(panel)
|
|
204
|
+
_publish_tasklist(created)
|
|
118
205
|
|
|
119
206
|
task_lines = "\n".join(f" {t['index']}. {t['description']}" for t in created)
|
|
120
207
|
verb = "replaced" if was_replaced else "created"
|
|
@@ -143,6 +230,7 @@ def update_task(index: int, status: str) -> str:
|
|
|
143
230
|
all_tasks = store.get_all()
|
|
144
231
|
panel = _render_task_list(all_tasks)
|
|
145
232
|
_show(panel)
|
|
233
|
+
_publish_tasklist(all_tasks)
|
|
146
234
|
|
|
147
235
|
# Check if all done
|
|
148
236
|
completed_count = sum(1 for t in all_tasks if t["status"] == "completed")
|
|
@@ -177,6 +265,7 @@ def flush_plan_render(session) -> None:
|
|
|
177
265
|
if not steps:
|
|
178
266
|
return
|
|
179
267
|
_show(_render_plan_steps(steps))
|
|
268
|
+
_publish_plan(steps)
|
|
180
269
|
|
|
181
270
|
|
|
182
271
|
def _render_plan_steps(steps: list) -> Panel:
|