aru-code 0.53.0__tar.gz → 0.55.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.53.0/aru_code.egg-info → aru_code-0.55.0}/PKG-INFO +1 -1
- aru_code-0.55.0/aru/__init__.py +1 -0
- {aru_code-0.53.0 → aru_code-0.55.0}/aru/cache_patch.py +233 -0
- {aru_code-0.53.0 → aru_code-0.55.0}/aru/cli.py +9 -0
- {aru_code-0.53.0 → aru_code-0.55.0}/aru/commands.py +1 -0
- {aru_code-0.53.0 → aru_code-0.55.0}/aru/permissions.py +27 -3
- {aru_code-0.53.0 → aru_code-0.55.0}/aru/runner.py +14 -1
- {aru_code-0.53.0 → aru_code-0.55.0}/aru/runtime.py +95 -0
- {aru_code-0.53.0 → aru_code-0.55.0}/aru/session.py +117 -8
- aru_code-0.55.0/aru/tools/_shared.py +145 -0
- {aru_code-0.53.0 → aru_code-0.55.0}/aru/tools/apply_patch.py +83 -0
- {aru_code-0.53.0 → aru_code-0.55.0}/aru/tools/lsp.py +29 -0
- {aru_code-0.53.0 → aru_code-0.55.0}/aru/tools/registry.py +10 -1
- {aru_code-0.53.0 → aru_code-0.55.0}/aru/tui/app.py +15 -1
- {aru_code-0.53.0 → aru_code-0.55.0}/aru/tui/ui.py +65 -23
- {aru_code-0.53.0 → aru_code-0.55.0}/aru/tui/widgets/completer.py +1 -0
- {aru_code-0.53.0 → aru_code-0.55.0}/aru/tui/widgets/thinking.py +31 -4
- {aru_code-0.53.0 → aru_code-0.55.0/aru_code.egg-info}/PKG-INFO +1 -1
- {aru_code-0.53.0 → aru_code-0.55.0}/aru_code.egg-info/SOURCES.txt +1 -0
- {aru_code-0.53.0 → aru_code-0.55.0}/pyproject.toml +1 -1
- {aru_code-0.53.0 → aru_code-0.55.0}/tests/test_apply_patch.py +133 -0
- {aru_code-0.53.0 → aru_code-0.55.0}/tests/test_cache_patch_metrics.py +4 -1
- {aru_code-0.53.0 → aru_code-0.55.0}/tests/test_cli.py +5 -1
- {aru_code-0.53.0 → aru_code-0.55.0}/tests/test_context.py +15 -6
- {aru_code-0.53.0 → aru_code-0.55.0}/tests/test_lsp_rename.py +54 -0
- aru_code-0.55.0/tests/test_permission_timeout_suspension.py +171 -0
- {aru_code-0.53.0 → aru_code-0.55.0}/tests/test_tui_permission_flow.py +59 -0
- aru_code-0.53.0/aru/__init__.py +0 -1
- aru_code-0.53.0/aru/tools/_shared.py +0 -94
- {aru_code-0.53.0 → aru_code-0.55.0}/LICENSE +0 -0
- {aru_code-0.53.0 → aru_code-0.55.0}/README.md +0 -0
- {aru_code-0.53.0 → aru_code-0.55.0}/aru/_debug/__init__.py +0 -0
- {aru_code-0.53.0 → aru_code-0.55.0}/aru/_debug/analyze_trace.py +0 -0
- {aru_code-0.53.0 → aru_code-0.55.0}/aru/_debug/loop_tracer.py +0 -0
- {aru_code-0.53.0 → aru_code-0.55.0}/aru/agent_factory.py +0 -0
- {aru_code-0.53.0 → aru_code-0.55.0}/aru/agents/__init__.py +0 -0
- {aru_code-0.53.0 → aru_code-0.55.0}/aru/agents/base.py +0 -0
- {aru_code-0.53.0 → aru_code-0.55.0}/aru/agents/catalog.py +0 -0
- {aru_code-0.53.0 → aru_code-0.55.0}/aru/agents/planner.py +0 -0
- {aru_code-0.53.0 → aru_code-0.55.0}/aru/checkpoints.py +0 -0
- {aru_code-0.53.0 → aru_code-0.55.0}/aru/completers.py +0 -0
- {aru_code-0.53.0 → aru_code-0.55.0}/aru/config.py +0 -0
- {aru_code-0.53.0 → aru_code-0.55.0}/aru/context.py +0 -0
- {aru_code-0.53.0 → aru_code-0.55.0}/aru/display.py +0 -0
- {aru_code-0.53.0 → aru_code-0.55.0}/aru/doom_loop.py +0 -0
- {aru_code-0.53.0 → aru_code-0.55.0}/aru/events.py +0 -0
- {aru_code-0.53.0 → aru_code-0.55.0}/aru/format/__init__.py +0 -0
- {aru_code-0.53.0 → aru_code-0.55.0}/aru/format/manager.py +0 -0
- {aru_code-0.53.0 → aru_code-0.55.0}/aru/format/runner.py +0 -0
- {aru_code-0.53.0 → aru_code-0.55.0}/aru/history_blocks.py +0 -0
- {aru_code-0.53.0 → aru_code-0.55.0}/aru/lsp/__init__.py +0 -0
- {aru_code-0.53.0 → aru_code-0.55.0}/aru/lsp/client.py +0 -0
- {aru_code-0.53.0 → aru_code-0.55.0}/aru/lsp/manager.py +0 -0
- {aru_code-0.53.0 → aru_code-0.55.0}/aru/lsp/protocol.py +0 -0
- {aru_code-0.53.0 → aru_code-0.55.0}/aru/memory/__init__.py +0 -0
- {aru_code-0.53.0 → aru_code-0.55.0}/aru/memory/extractor.py +0 -0
- {aru_code-0.53.0 → aru_code-0.55.0}/aru/memory/loader.py +0 -0
- {aru_code-0.53.0 → aru_code-0.55.0}/aru/memory/store.py +0 -0
- {aru_code-0.53.0 → aru_code-0.55.0}/aru/plugin_cache.py +0 -0
- {aru_code-0.53.0 → aru_code-0.55.0}/aru/plugins/__init__.py +0 -0
- {aru_code-0.53.0 → aru_code-0.55.0}/aru/plugins/custom_tools.py +0 -0
- {aru_code-0.53.0 → aru_code-0.55.0}/aru/plugins/hooks.py +0 -0
- {aru_code-0.53.0 → aru_code-0.55.0}/aru/plugins/manager.py +0 -0
- {aru_code-0.53.0 → aru_code-0.55.0}/aru/plugins/tool_api.py +0 -0
- {aru_code-0.53.0 → aru_code-0.55.0}/aru/providers.py +0 -0
- {aru_code-0.53.0 → aru_code-0.55.0}/aru/select.py +0 -0
- {aru_code-0.53.0 → aru_code-0.55.0}/aru/sinks.py +0 -0
- {aru_code-0.53.0 → aru_code-0.55.0}/aru/streaming.py +0 -0
- {aru_code-0.53.0 → aru_code-0.55.0}/aru/tool_policy.py +0 -0
- {aru_code-0.53.0 → aru_code-0.55.0}/aru/tools/__init__.py +0 -0
- {aru_code-0.53.0 → aru_code-0.55.0}/aru/tools/_diff.py +0 -0
- {aru_code-0.53.0 → aru_code-0.55.0}/aru/tools/apply_patch_prompt.txt +0 -0
- {aru_code-0.53.0 → aru_code-0.55.0}/aru/tools/ast_tools.py +0 -0
- {aru_code-0.53.0 → aru_code-0.55.0}/aru/tools/codebase.py +0 -0
- {aru_code-0.53.0 → aru_code-0.55.0}/aru/tools/delegate.py +0 -0
- {aru_code-0.53.0 → aru_code-0.55.0}/aru/tools/delegate_prompt.txt +0 -0
- {aru_code-0.53.0 → aru_code-0.55.0}/aru/tools/file_ops.py +0 -0
- {aru_code-0.53.0 → aru_code-0.55.0}/aru/tools/gitignore.py +0 -0
- {aru_code-0.53.0 → aru_code-0.55.0}/aru/tools/mcp_client.py +0 -0
- {aru_code-0.53.0 → aru_code-0.55.0}/aru/tools/memory_tool.py +0 -0
- {aru_code-0.53.0 → aru_code-0.55.0}/aru/tools/plan_mode.py +0 -0
- {aru_code-0.53.0 → aru_code-0.55.0}/aru/tools/ranker.py +0 -0
- {aru_code-0.53.0 → aru_code-0.55.0}/aru/tools/search.py +0 -0
- {aru_code-0.53.0 → aru_code-0.55.0}/aru/tools/shell.py +0 -0
- {aru_code-0.53.0 → aru_code-0.55.0}/aru/tools/skill.py +0 -0
- {aru_code-0.53.0 → aru_code-0.55.0}/aru/tools/tasklist.py +0 -0
- {aru_code-0.53.0 → aru_code-0.55.0}/aru/tools/web.py +0 -0
- {aru_code-0.53.0 → aru_code-0.55.0}/aru/tools/worktree.py +0 -0
- {aru_code-0.53.0 → aru_code-0.55.0}/aru/tui/__init__.py +0 -0
- {aru_code-0.53.0 → aru_code-0.55.0}/aru/tui/log_bridge.py +0 -0
- {aru_code-0.53.0 → aru_code-0.55.0}/aru/tui/notifications.py +0 -0
- {aru_code-0.53.0 → aru_code-0.55.0}/aru/tui/sanitize.py +0 -0
- {aru_code-0.53.0 → aru_code-0.55.0}/aru/tui/screens/__init__.py +0 -0
- {aru_code-0.53.0 → aru_code-0.55.0}/aru/tui/screens/choice.py +0 -0
- {aru_code-0.53.0 → aru_code-0.55.0}/aru/tui/screens/confirm.py +0 -0
- {aru_code-0.53.0 → aru_code-0.55.0}/aru/tui/screens/keymap.py +0 -0
- {aru_code-0.53.0 → aru_code-0.55.0}/aru/tui/screens/search.py +0 -0
- {aru_code-0.53.0 → aru_code-0.55.0}/aru/tui/screens/session_picker.py +0 -0
- {aru_code-0.53.0 → aru_code-0.55.0}/aru/tui/screens/text_input.py +0 -0
- {aru_code-0.53.0 → aru_code-0.55.0}/aru/tui/sinks.py +0 -0
- {aru_code-0.53.0 → aru_code-0.55.0}/aru/tui/slash_bridge.py +0 -0
- {aru_code-0.53.0 → aru_code-0.55.0}/aru/tui/themes.py +0 -0
- {aru_code-0.53.0 → aru_code-0.55.0}/aru/tui/widgets/__init__.py +0 -0
- {aru_code-0.53.0 → aru_code-0.55.0}/aru/tui/widgets/chat.py +0 -0
- {aru_code-0.53.0 → aru_code-0.55.0}/aru/tui/widgets/context_pane.py +0 -0
- {aru_code-0.53.0 → aru_code-0.55.0}/aru/tui/widgets/file_link.py +0 -0
- {aru_code-0.53.0 → aru_code-0.55.0}/aru/tui/widgets/header.py +0 -0
- {aru_code-0.53.0 → aru_code-0.55.0}/aru/tui/widgets/inline_choice.py +0 -0
- {aru_code-0.53.0 → aru_code-0.55.0}/aru/tui/widgets/loaded_pane.py +0 -0
- {aru_code-0.53.0 → aru_code-0.55.0}/aru/tui/widgets/prompt_area.py +0 -0
- {aru_code-0.53.0 → aru_code-0.55.0}/aru/tui/widgets/prompt_queue.py +0 -0
- {aru_code-0.53.0 → aru_code-0.55.0}/aru/tui/widgets/status.py +0 -0
- {aru_code-0.53.0 → aru_code-0.55.0}/aru/tui/widgets/subagent_panel.py +0 -0
- {aru_code-0.53.0 → aru_code-0.55.0}/aru/tui/widgets/tasklist_panel.py +0 -0
- {aru_code-0.53.0 → aru_code-0.55.0}/aru/tui/widgets/tools.py +0 -0
- {aru_code-0.53.0 → aru_code-0.55.0}/aru/ui.py +0 -0
- {aru_code-0.53.0 → aru_code-0.55.0}/aru_code.egg-info/dependency_links.txt +0 -0
- {aru_code-0.53.0 → aru_code-0.55.0}/aru_code.egg-info/entry_points.txt +0 -0
- {aru_code-0.53.0 → aru_code-0.55.0}/aru_code.egg-info/requires.txt +0 -0
- {aru_code-0.53.0 → aru_code-0.55.0}/aru_code.egg-info/top_level.txt +0 -0
- {aru_code-0.53.0 → aru_code-0.55.0}/setup.cfg +0 -0
- {aru_code-0.53.0 → aru_code-0.55.0}/tests/test_agents_base.py +0 -0
- {aru_code-0.53.0 → aru_code-0.55.0}/tests/test_agents_md_coverage.py +0 -0
- {aru_code-0.53.0 → aru_code-0.55.0}/tests/test_async_tool_permission.py +0 -0
- {aru_code-0.53.0 → aru_code-0.55.0}/tests/test_cache_patch_stop_reason.py +0 -0
- {aru_code-0.53.0 → aru_code-0.55.0}/tests/test_catalog.py +0 -0
- {aru_code-0.53.0 → aru_code-0.55.0}/tests/test_chat_scrollable.py +0 -0
- {aru_code-0.53.0 → aru_code-0.55.0}/tests/test_checkpoints.py +0 -0
- {aru_code-0.53.0 → aru_code-0.55.0}/tests/test_cli_advanced.py +0 -0
- {aru_code-0.53.0 → aru_code-0.55.0}/tests/test_cli_base.py +0 -0
- {aru_code-0.53.0 → aru_code-0.55.0}/tests/test_cli_completers.py +0 -0
- {aru_code-0.53.0 → aru_code-0.55.0}/tests/test_cli_new.py +0 -0
- {aru_code-0.53.0 → aru_code-0.55.0}/tests/test_cli_run_cli.py +0 -0
- {aru_code-0.53.0 → aru_code-0.55.0}/tests/test_cli_session.py +0 -0
- {aru_code-0.53.0 → aru_code-0.55.0}/tests/test_cli_shell.py +0 -0
- {aru_code-0.53.0 → aru_code-0.55.0}/tests/test_codebase.py +0 -0
- {aru_code-0.53.0 → aru_code-0.55.0}/tests/test_confabulation_regression.py +0 -0
- {aru_code-0.53.0 → aru_code-0.55.0}/tests/test_config.py +0 -0
- {aru_code-0.53.0 → aru_code-0.55.0}/tests/test_context_pane.py +0 -0
- {aru_code-0.53.0 → aru_code-0.55.0}/tests/test_cwd_awareness.py +0 -0
- {aru_code-0.53.0 → aru_code-0.55.0}/tests/test_delegate.py +0 -0
- {aru_code-0.53.0 → aru_code-0.55.0}/tests/test_doom_loop.py +0 -0
- {aru_code-0.53.0 → aru_code-0.55.0}/tests/test_events_backward_compat.py +0 -0
- {aru_code-0.53.0 → aru_code-0.55.0}/tests/test_events_schema.py +0 -0
- {aru_code-0.53.0 → aru_code-0.55.0}/tests/test_fork_ctx_concurrency.py +0 -0
- {aru_code-0.53.0 → aru_code-0.55.0}/tests/test_format.py +0 -0
- {aru_code-0.53.0 → aru_code-0.55.0}/tests/test_gitignore.py +0 -0
- {aru_code-0.53.0 → aru_code-0.55.0}/tests/test_guardrails_scenarios.py +0 -0
- {aru_code-0.53.0 → aru_code-0.55.0}/tests/test_invoke_skill.py +0 -0
- {aru_code-0.53.0 → aru_code-0.55.0}/tests/test_invoked_skills.py +0 -0
- {aru_code-0.53.0 → aru_code-0.55.0}/tests/test_loaded_pane_path.py +0 -0
- {aru_code-0.53.0 → aru_code-0.55.0}/tests/test_lsp.py +0 -0
- {aru_code-0.53.0 → aru_code-0.55.0}/tests/test_main.py +0 -0
- {aru_code-0.53.0 → aru_code-0.55.0}/tests/test_markdown_to_text.py +0 -0
- {aru_code-0.53.0 → aru_code-0.55.0}/tests/test_mcp_client.py +0 -0
- {aru_code-0.53.0 → aru_code-0.55.0}/tests/test_mcp_health.py +0 -0
- {aru_code-0.53.0 → aru_code-0.55.0}/tests/test_memory.py +0 -0
- {aru_code-0.53.0 → aru_code-0.55.0}/tests/test_memory_tool.py +0 -0
- {aru_code-0.53.0 → aru_code-0.55.0}/tests/test_microcompact.py +0 -0
- {aru_code-0.53.0 → aru_code-0.55.0}/tests/test_permissions.py +0 -0
- {aru_code-0.53.0 → aru_code-0.55.0}/tests/test_plan_mode_refactor.py +0 -0
- {aru_code-0.53.0 → aru_code-0.55.0}/tests/test_plugin_cache.py +0 -0
- {aru_code-0.53.0 → aru_code-0.55.0}/tests/test_plugin_errors.py +0 -0
- {aru_code-0.53.0 → aru_code-0.55.0}/tests/test_plugin_hooks_v2.py +0 -0
- {aru_code-0.53.0 → aru_code-0.55.0}/tests/test_plugins.py +0 -0
- {aru_code-0.53.0 → aru_code-0.55.0}/tests/test_providers.py +0 -0
- {aru_code-0.53.0 → aru_code-0.55.0}/tests/test_ranker.py +0 -0
- {aru_code-0.53.0 → aru_code-0.55.0}/tests/test_reasoning.py +0 -0
- {aru_code-0.53.0 → aru_code-0.55.0}/tests/test_runner_interrupt.py +0 -0
- {aru_code-0.53.0 → aru_code-0.55.0}/tests/test_runner_recovery.py +0 -0
- {aru_code-0.53.0 → aru_code-0.55.0}/tests/test_runtime.py +0 -0
- {aru_code-0.53.0 → aru_code-0.55.0}/tests/test_select.py +0 -0
- {aru_code-0.53.0 → aru_code-0.55.0}/tests/test_session_free_cost.py +0 -0
- {aru_code-0.53.0 → aru_code-0.55.0}/tests/test_skill_disallowed_tools.py +0 -0
- {aru_code-0.53.0 → aru_code-0.55.0}/tests/test_status_breakdown.py +0 -0
- {aru_code-0.53.0 → aru_code-0.55.0}/tests/test_status_cost.py +0 -0
- {aru_code-0.53.0 → aru_code-0.55.0}/tests/test_streaming_sink.py +0 -0
- {aru_code-0.53.0 → aru_code-0.55.0}/tests/test_subagent_tool_events.py +0 -0
- {aru_code-0.53.0 → aru_code-0.55.0}/tests/test_tasklist.py +0 -0
- {aru_code-0.53.0 → aru_code-0.55.0}/tests/test_thread_tool_timeout.py +0 -0
- {aru_code-0.53.0 → aru_code-0.55.0}/tests/test_tool_policy.py +0 -0
- {aru_code-0.53.0 → aru_code-0.55.0}/tests/test_truncation_marker.py +0 -0
- {aru_code-0.53.0 → aru_code-0.55.0}/tests/test_tui_app_boot.py +0 -0
- {aru_code-0.53.0 → aru_code-0.55.0}/tests/test_tui_bindings.py +0 -0
- {aru_code-0.53.0 → aru_code-0.55.0}/tests/test_tui_bus_flow.py +0 -0
- {aru_code-0.53.0 → aru_code-0.55.0}/tests/test_tui_chat.py +0 -0
- {aru_code-0.53.0 → aru_code-0.55.0}/tests/test_tui_chat_adversarial.py +0 -0
- {aru_code-0.53.0 → aru_code-0.55.0}/tests/test_tui_completer.py +0 -0
- {aru_code-0.53.0 → aru_code-0.55.0}/tests/test_tui_completer_dynamic.py +0 -0
- {aru_code-0.53.0 → aru_code-0.55.0}/tests/test_tui_copy.py +0 -0
- {aru_code-0.53.0 → aru_code-0.55.0}/tests/test_tui_error_display.py +0 -0
- {aru_code-0.53.0 → aru_code-0.55.0}/tests/test_tui_file_link.py +0 -0
- {aru_code-0.53.0 → aru_code-0.55.0}/tests/test_tui_input_behaviour.py +0 -0
- {aru_code-0.53.0 → aru_code-0.55.0}/tests/test_tui_layer12_recovery.py +0 -0
- {aru_code-0.53.0 → aru_code-0.55.0}/tests/test_tui_layer13_recovery.py +0 -0
- {aru_code-0.53.0 → aru_code-0.55.0}/tests/test_tui_mention_expand.py +0 -0
- {aru_code-0.53.0 → aru_code-0.55.0}/tests/test_tui_modals.py +0 -0
- {aru_code-0.53.0 → aru_code-0.55.0}/tests/test_tui_mode_cycle.py +0 -0
- {aru_code-0.53.0 → aru_code-0.55.0}/tests/test_tui_native_selection.py +0 -0
- {aru_code-0.53.0 → aru_code-0.55.0}/tests/test_tui_plan_task_render.py +0 -0
- {aru_code-0.53.0 → aru_code-0.55.0}/tests/test_tui_prompt_queue.py +0 -0
- {aru_code-0.53.0 → aru_code-0.55.0}/tests/test_tui_shell_bang.py +0 -0
- {aru_code-0.53.0 → aru_code-0.55.0}/tests/test_tui_sidebar_toggle.py +0 -0
- {aru_code-0.53.0 → aru_code-0.55.0}/tests/test_tui_slash_bridge.py +0 -0
- {aru_code-0.53.0 → aru_code-0.55.0}/tests/test_tui_slash_model.py +0 -0
- {aru_code-0.53.0 → aru_code-0.55.0}/tests/test_tui_snapshot_smoke.py +0 -0
- {aru_code-0.53.0 → aru_code-0.55.0}/tests/test_tui_subagent_panel.py +0 -0
- {aru_code-0.53.0 → aru_code-0.55.0}/tests/test_tui_theme.py +0 -0
- {aru_code-0.53.0 → aru_code-0.55.0}/tests/test_tui_thinking_and_boot.py +0 -0
- {aru_code-0.53.0 → aru_code-0.55.0}/tests/test_tui_widgets_visual.py +0 -0
- {aru_code-0.53.0 → aru_code-0.55.0}/tests/test_ui_adapter.py +0 -0
- {aru_code-0.53.0 → aru_code-0.55.0}/tests/test_worktree.py +0 -0
- {aru_code-0.53.0 → aru_code-0.55.0}/tests/test_worktree_session_restore.py +0 -0
|
@@ -0,0 +1 @@
|
|
|
1
|
+
__version__ = "0.55.0"
|
|
@@ -23,6 +23,9 @@ regardless of which provider is used.
|
|
|
23
23
|
|
|
24
24
|
from __future__ import annotations
|
|
25
25
|
|
|
26
|
+
import os as _os
|
|
27
|
+
import time as _time
|
|
28
|
+
|
|
26
29
|
# Token-budget pruning (aligned with OpenCode's strategy):
|
|
27
30
|
# - Protect recent tool results within a token budget
|
|
28
31
|
# - Only prune if there's enough to free (avoid churn)
|
|
@@ -43,6 +46,22 @@ _last_call_cache_write: int = 0
|
|
|
43
46
|
# We normalize "length" → "max_tokens" so callers can check a single value.
|
|
44
47
|
_last_call_stop_reason: str | None = None
|
|
45
48
|
|
|
49
|
+
# Per-call observability ring buffer. Each accumulate_model_metrics fire
|
|
50
|
+
# appends one record; the ring caps at _CALL_HISTORY_MAX so a long-running
|
|
51
|
+
# session doesn't grow unbounded. Surfaced via /calls so users can see
|
|
52
|
+
# *which* models / model_types / call sites produced each request — the
|
|
53
|
+
# canonical "why are there N api_calls?" diagnosis surface.
|
|
54
|
+
_CALL_HISTORY_MAX = 200
|
|
55
|
+
_call_history: list[dict] = []
|
|
56
|
+
|
|
57
|
+
# Pending request metadata captured by the request-side patch right before
|
|
58
|
+
# the provider call goes out. Read by ``_patched_accumulate`` after the
|
|
59
|
+
# response lands and merged into the matching call_history record so /calls
|
|
60
|
+
# shows both the response usage AND a summary of what was sent. Single-
|
|
61
|
+
# slot global is OK: aru runs requests sequentially per ctx, and the patch
|
|
62
|
+
# captures-then-clears synchronously around each invocation.
|
|
63
|
+
_pending_request_meta: dict | None = None
|
|
64
|
+
|
|
46
65
|
# Micro-compaction metrics (process-wide, reset by tests via
|
|
47
66
|
# reset_microcompact_stats()). Recorded by _prune_tool_messages every time it
|
|
48
67
|
# fires from the format_function_call_results patch. Surfaced in /cost so
|
|
@@ -105,6 +124,92 @@ def reset_last_stop_reason() -> None:
|
|
|
105
124
|
_last_call_stop_reason = None
|
|
106
125
|
|
|
107
126
|
|
|
127
|
+
def _summarize_request(messages, tools=None) -> dict:
|
|
128
|
+
"""Build a compact summary of an outgoing request for /calls.
|
|
129
|
+
|
|
130
|
+
We deliberately don't store full message bodies — a single tool result
|
|
131
|
+
can be tens of KB and a long session would balloon memory. We keep:
|
|
132
|
+
|
|
133
|
+
* count of messages and per-role tally
|
|
134
|
+
* total chars across messages (proxy for prompt size)
|
|
135
|
+
* snippet of the first message (usually system prompt) and the last
|
|
136
|
+
message (usually the freshest user/tool turn — what the model is
|
|
137
|
+
responding to)
|
|
138
|
+
* snippet of the most recent ``user`` message specifically
|
|
139
|
+
* tool count
|
|
140
|
+
|
|
141
|
+
Snippets are capped at 240 chars. Enough to identify the call without
|
|
142
|
+
storing PII-heavy or token-heavy bodies.
|
|
143
|
+
"""
|
|
144
|
+
out = {
|
|
145
|
+
"n_messages": 0,
|
|
146
|
+
"roles": {},
|
|
147
|
+
"total_chars": 0,
|
|
148
|
+
"first_snippet": "",
|
|
149
|
+
"last_snippet": "",
|
|
150
|
+
"last_user_snippet": "",
|
|
151
|
+
"n_tools": 0,
|
|
152
|
+
}
|
|
153
|
+
try:
|
|
154
|
+
msgs = list(messages or [])
|
|
155
|
+
out["n_messages"] = len(msgs)
|
|
156
|
+
out["n_tools"] = len(tools or [])
|
|
157
|
+
last_user = ""
|
|
158
|
+
for i, m in enumerate(msgs):
|
|
159
|
+
role = (getattr(m, "role", None) or "?")
|
|
160
|
+
out["roles"][role] = out["roles"].get(role, 0) + 1
|
|
161
|
+
content = getattr(m, "content", None)
|
|
162
|
+
if content is None:
|
|
163
|
+
content = getattr(m, "text", "")
|
|
164
|
+
if not isinstance(content, str):
|
|
165
|
+
try:
|
|
166
|
+
content = str(content)
|
|
167
|
+
except Exception:
|
|
168
|
+
content = ""
|
|
169
|
+
out["total_chars"] += len(content)
|
|
170
|
+
if i == 0:
|
|
171
|
+
out["first_snippet"] = content[:240]
|
|
172
|
+
if role == "user":
|
|
173
|
+
last_user = content[:240]
|
|
174
|
+
if msgs:
|
|
175
|
+
last = msgs[-1]
|
|
176
|
+
lc = getattr(last, "content", None) or getattr(last, "text", "")
|
|
177
|
+
if not isinstance(lc, str):
|
|
178
|
+
try:
|
|
179
|
+
lc = str(lc)
|
|
180
|
+
except Exception:
|
|
181
|
+
lc = ""
|
|
182
|
+
out["last_snippet"] = lc[:240]
|
|
183
|
+
out["last_user_snippet"] = last_user
|
|
184
|
+
except Exception:
|
|
185
|
+
pass
|
|
186
|
+
return out
|
|
187
|
+
|
|
188
|
+
|
|
189
|
+
def _capture_request_meta(messages, tools=None) -> None:
|
|
190
|
+
"""Stash a request summary into the pending slot for the next accumulate."""
|
|
191
|
+
global _pending_request_meta
|
|
192
|
+
_pending_request_meta = _summarize_request(messages, tools)
|
|
193
|
+
|
|
194
|
+
|
|
195
|
+
def get_call_history() -> list[dict]:
|
|
196
|
+
"""Return a copy of the per-API-call ring buffer.
|
|
197
|
+
|
|
198
|
+
Each entry: ``{n, model_type, model_id, provider, input_tokens,
|
|
199
|
+
output_tokens, cache_read, cache_write, stop_reason, caller, ts}``.
|
|
200
|
+
``input_tokens`` is the *normalized* value (cache stripped for OpenAI-
|
|
201
|
+
style providers). ``caller`` is the agno file:line that invoked
|
|
202
|
+
accumulate_model_metrics — useful for distinguishing main-model calls
|
|
203
|
+
from parser/output-model/memory/recovery calls.
|
|
204
|
+
"""
|
|
205
|
+
return list(_call_history)
|
|
206
|
+
|
|
207
|
+
|
|
208
|
+
def reset_call_history() -> None:
|
|
209
|
+
"""Clear the call ring buffer. Useful at session start or in tests."""
|
|
210
|
+
_call_history.clear()
|
|
211
|
+
|
|
212
|
+
|
|
108
213
|
def get_microcompact_stats() -> dict:
|
|
109
214
|
"""Return process-wide micro-compaction metrics.
|
|
110
215
|
|
|
@@ -317,6 +422,72 @@ def _prune_tool_messages(messages):
|
|
|
317
422
|
_PATCH_APPLIED = False
|
|
318
423
|
|
|
319
424
|
|
|
425
|
+
def _patch_request_capture():
|
|
426
|
+
"""Wrap the agno methods that receive ``messages`` right before the
|
|
427
|
+
provider HTTP call so /calls can show what was actually sent.
|
|
428
|
+
|
|
429
|
+
We hook the four ``Model._{a,}invoke{_stream,}_with_retry`` methods
|
|
430
|
+
on ``agno.models.base.Model`` — these are the chokepoint each subclass
|
|
431
|
+
flows through (sync/async × stream/non-stream). Each wrapper takes a
|
|
432
|
+
cheap snapshot of ``kwargs["messages"]`` into ``_pending_request_meta``
|
|
433
|
+
immediately before delegating to the original. ``_patched_accumulate``
|
|
434
|
+
then reads-and-clears that slot when the matching response lands.
|
|
435
|
+
|
|
436
|
+
The wrappers are best-effort: any exception during snapshotting is
|
|
437
|
+
swallowed so we never break the actual model call. Stream wrappers
|
|
438
|
+
must remain async generators (``async for ... yield``) — collecting
|
|
439
|
+
the stream first would defeat streaming.
|
|
440
|
+
"""
|
|
441
|
+
try:
|
|
442
|
+
from agno.models.base import Model
|
|
443
|
+
except ImportError:
|
|
444
|
+
return
|
|
445
|
+
|
|
446
|
+
_orig_invoke = Model._invoke_with_retry
|
|
447
|
+
_orig_ainvoke = Model._ainvoke_with_retry
|
|
448
|
+
_orig_invoke_stream = Model._invoke_stream_with_retry
|
|
449
|
+
_orig_ainvoke_stream = Model._ainvoke_stream_with_retry
|
|
450
|
+
|
|
451
|
+
def _wrap_invoke(self, **kwargs):
|
|
452
|
+
try:
|
|
453
|
+
_capture_request_meta(kwargs.get("messages"), kwargs.get("tools"))
|
|
454
|
+
except Exception:
|
|
455
|
+
pass
|
|
456
|
+
return _orig_invoke(self, **kwargs)
|
|
457
|
+
|
|
458
|
+
async def _wrap_ainvoke(self, **kwargs):
|
|
459
|
+
try:
|
|
460
|
+
_capture_request_meta(kwargs.get("messages"), kwargs.get("tools"))
|
|
461
|
+
except Exception:
|
|
462
|
+
pass
|
|
463
|
+
return await _orig_ainvoke(self, **kwargs)
|
|
464
|
+
|
|
465
|
+
def _wrap_invoke_stream(self, **kwargs):
|
|
466
|
+
try:
|
|
467
|
+
_capture_request_meta(kwargs.get("messages"), kwargs.get("tools"))
|
|
468
|
+
except Exception:
|
|
469
|
+
pass
|
|
470
|
+
# _invoke_stream_with_retry returns an Iterator (sync generator)
|
|
471
|
+
return _orig_invoke_stream(self, **kwargs)
|
|
472
|
+
|
|
473
|
+
async def _wrap_ainvoke_stream(self, **kwargs):
|
|
474
|
+
try:
|
|
475
|
+
_capture_request_meta(kwargs.get("messages"), kwargs.get("tools"))
|
|
476
|
+
except Exception:
|
|
477
|
+
pass
|
|
478
|
+
# _ainvoke_stream_with_retry is an async generator — we must
|
|
479
|
+
# re-yield rather than return it (returning an async generator
|
|
480
|
+
# from an async def function wraps it in a coroutine that yields
|
|
481
|
+
# the generator object, which the caller would not iterate).
|
|
482
|
+
async for chunk in _orig_ainvoke_stream(self, **kwargs):
|
|
483
|
+
yield chunk
|
|
484
|
+
|
|
485
|
+
Model._invoke_with_retry = _wrap_invoke
|
|
486
|
+
Model._ainvoke_with_retry = _wrap_ainvoke
|
|
487
|
+
Model._invoke_stream_with_retry = _wrap_invoke_stream
|
|
488
|
+
Model._ainvoke_stream_with_retry = _wrap_ainvoke_stream
|
|
489
|
+
|
|
490
|
+
|
|
320
491
|
def apply_cache_patch():
|
|
321
492
|
"""Apply all patches to reduce Agno's token consumption.
|
|
322
493
|
|
|
@@ -334,6 +505,7 @@ def apply_cache_patch():
|
|
|
334
505
|
_patch_per_call_metrics()
|
|
335
506
|
_patch_stop_reason_capture()
|
|
336
507
|
_patch_overflow_recovery()
|
|
508
|
+
_patch_request_capture()
|
|
337
509
|
_PATCH_APPLIED = True
|
|
338
510
|
|
|
339
511
|
|
|
@@ -515,6 +687,10 @@ def _publish_live_metrics(
|
|
|
515
687
|
session.total_output_tokens += output_tokens
|
|
516
688
|
session.total_cache_read_tokens += cache_read
|
|
517
689
|
session.total_cache_write_tokens += cache_write
|
|
690
|
+
# Count real API requests (one per accumulate call). track_tokens
|
|
691
|
+
# used to do this at turn-end (++1), which collapsed multi-tool
|
|
692
|
+
# turns — a turn with N tool calls = N+1 requests but counted as 1.
|
|
693
|
+
session.api_calls = (getattr(session, "api_calls", 0) or 0) + 1
|
|
518
694
|
session._live_input_added = (
|
|
519
695
|
getattr(session, "_live_input_added", 0) + input_tokens
|
|
520
696
|
)
|
|
@@ -587,6 +763,16 @@ def _patch_per_call_metrics():
|
|
|
587
763
|
global _last_call_input_tokens, _last_call_output_tokens
|
|
588
764
|
global _last_call_cache_read, _last_call_cache_write
|
|
589
765
|
usage = getattr(model_response, "response_usage", None)
|
|
766
|
+
# Capture the call site (agno file:line that invoked accumulate)
|
|
767
|
+
# cheaply — only when there's a usage object worth recording.
|
|
768
|
+
_caller_str = ""
|
|
769
|
+
if usage is not None:
|
|
770
|
+
try:
|
|
771
|
+
import sys as _sys
|
|
772
|
+
_frame = _sys._getframe(1)
|
|
773
|
+
_caller_str = f"{_os.path.basename(_frame.f_code.co_filename)}:{_frame.f_lineno}"
|
|
774
|
+
except Exception:
|
|
775
|
+
_caller_str = "?"
|
|
590
776
|
if usage is not None:
|
|
591
777
|
input_tokens = getattr(usage, "input_tokens", 0) or 0
|
|
592
778
|
output_tokens = getattr(usage, "output_tokens", 0) or 0
|
|
@@ -603,12 +789,59 @@ def _patch_per_call_metrics():
|
|
|
603
789
|
is_anthropic = "anthropic" in (provider_name or "").lower()
|
|
604
790
|
if not is_anthropic and cache_read and input_tokens >= cache_read:
|
|
605
791
|
input_tokens -= cache_read
|
|
792
|
+
# Mutate the shared usage object so the downstream
|
|
793
|
+
# ``_original_accumulate`` writes the *normalized* value
|
|
794
|
+
# into Agno's RunMetrics. Without this, RunMetrics keeps
|
|
795
|
+
# the raw (cache-inclusive) input while ``_last_call_*``
|
|
796
|
+
# and the live publish hold the normalized one, and
|
|
797
|
+
# ``Session.track_tokens`` reconciliation re-adds the
|
|
798
|
+
# cached portion as a fake "missing delta" — exactly the
|
|
799
|
+
# cumulative-vs-last asymmetry users see in /cost.
|
|
800
|
+
try:
|
|
801
|
+
usage.input_tokens = input_tokens
|
|
802
|
+
except (AttributeError, TypeError):
|
|
803
|
+
pass
|
|
606
804
|
|
|
607
805
|
_last_call_input_tokens = input_tokens
|
|
608
806
|
_last_call_output_tokens = output_tokens
|
|
609
807
|
_last_call_cache_read = cache_read
|
|
610
808
|
_last_call_cache_write = cache_write
|
|
611
809
|
|
|
810
|
+
# Per-call observability: append to the ring buffer so /calls
|
|
811
|
+
# can show breakdown by model_type (MODEL vs PARSER_MODEL vs
|
|
812
|
+
# MEMORY_MODEL etc.) and call site. Bounded to _CALL_HISTORY_MAX
|
|
813
|
+
# so a long session doesn't grow unbounded.
|
|
814
|
+
_model_id = ""
|
|
815
|
+
try:
|
|
816
|
+
_model_id = getattr(model, "id", "") or ""
|
|
817
|
+
except Exception:
|
|
818
|
+
pass
|
|
819
|
+
_mt_str = (
|
|
820
|
+
model_type.value
|
|
821
|
+
if hasattr(model_type, "value")
|
|
822
|
+
else str(model_type)
|
|
823
|
+
)
|
|
824
|
+
global _pending_request_meta
|
|
825
|
+
_req_meta = _pending_request_meta or {}
|
|
826
|
+
_pending_request_meta = None
|
|
827
|
+
_call_history.append({
|
|
828
|
+
"n": len(_call_history) + 1,
|
|
829
|
+
"model_type": _mt_str,
|
|
830
|
+
"model_id": _model_id,
|
|
831
|
+
"provider": provider_name or "",
|
|
832
|
+
"input_tokens": input_tokens,
|
|
833
|
+
"output_tokens": output_tokens,
|
|
834
|
+
"cache_read": cache_read,
|
|
835
|
+
"cache_write": cache_write,
|
|
836
|
+
"stop_reason": _last_call_stop_reason,
|
|
837
|
+
"caller": _caller_str,
|
|
838
|
+
"ts": _time.time(),
|
|
839
|
+
"request": _req_meta,
|
|
840
|
+
})
|
|
841
|
+
if len(_call_history) > _CALL_HISTORY_MAX:
|
|
842
|
+
# Keep the most recent N — drop from the front.
|
|
843
|
+
del _call_history[: len(_call_history) - _CALL_HISTORY_MAX]
|
|
844
|
+
|
|
612
845
|
# Intra-turn live session update + bus publish. Gated to the
|
|
613
846
|
# primary agent (subagent_depth == 0) so subagent API calls
|
|
614
847
|
# don't double-count — delegate_task adds subagent totals in
|
|
@@ -761,6 +761,15 @@ async def run_cli(skip_permissions: bool = False, resume_id: str | None = None):
|
|
|
761
761
|
))
|
|
762
762
|
continue
|
|
763
763
|
|
|
764
|
+
if user_input.lower() == "/calls":
|
|
765
|
+
console.print(Panel(
|
|
766
|
+
session.calls_summary,
|
|
767
|
+
title="[bold]Per-API-Call Breakdown[/bold]",
|
|
768
|
+
border_style="cyan",
|
|
769
|
+
padding=(1, 2),
|
|
770
|
+
))
|
|
771
|
+
continue
|
|
772
|
+
|
|
764
773
|
if user_input.lower() == "/subagents":
|
|
765
774
|
from aru.commands import handle_subagents_command
|
|
766
775
|
handle_subagents_command(session)
|
|
@@ -31,6 +31,7 @@ SLASH_COMMANDS = [
|
|
|
31
31
|
("/debug", "Debug utilities (plugin-errors)", "/debug <subcommand>"),
|
|
32
32
|
("/undo", "Undo last turn — restore files and/or conversation", "/undo"),
|
|
33
33
|
("/cost", "Show detailed token usage and cost", "/cost"),
|
|
34
|
+
("/calls", "Show per-API-call breakdown (model_type, tokens, stop_reason, caller)", "/calls"),
|
|
34
35
|
("/yolo", "Toggle DANGEROUSLY skip all permissions (YOLO mode)", "/yolo"),
|
|
35
36
|
("/quit", "Exit aru", "/quit"),
|
|
36
37
|
]
|
|
@@ -27,10 +27,31 @@ from rich.console import Group
|
|
|
27
27
|
from rich.panel import Panel
|
|
28
28
|
from rich.text import Text
|
|
29
29
|
|
|
30
|
-
from aru.runtime import get_ctx
|
|
30
|
+
from aru.runtime import begin_permission_wait, end_permission_wait, get_ctx
|
|
31
31
|
from aru.select import select_option
|
|
32
32
|
|
|
33
33
|
|
|
34
|
+
@contextmanager
|
|
35
|
+
def _permission_prompt_scope(ctx):
|
|
36
|
+
"""Hold ``permission_lock`` while marking the tool-call permission gate.
|
|
37
|
+
|
|
38
|
+
The gate tells the surrounding ``_thread_tool`` wrapper to suspend its
|
|
39
|
+
execution-timeout for as long as we are blocked here — acquiring the lock
|
|
40
|
+
can wait on a sibling tool's open prompt, and the prompt itself waits on
|
|
41
|
+
the user. Without this, the tool could report a timeout mid-prompt and
|
|
42
|
+
then apply the mutation out-of-band once the user finally answered.
|
|
43
|
+
``begin_permission_wait`` runs BEFORE the lock is acquired so the
|
|
44
|
+
lock-wait is covered too; it is a no-op when no gate is installed (async
|
|
45
|
+
tools, tests). See ``aru.runtime.PermissionWaitGate``.
|
|
46
|
+
"""
|
|
47
|
+
begin_permission_wait()
|
|
48
|
+
try:
|
|
49
|
+
with ctx.permission_lock:
|
|
50
|
+
yield
|
|
51
|
+
finally:
|
|
52
|
+
end_permission_wait()
|
|
53
|
+
|
|
54
|
+
|
|
34
55
|
def _resolve_ui(ctx):
|
|
35
56
|
"""Return ``ctx.ui`` or install a ``ReplUI`` on-the-fly.
|
|
36
57
|
|
|
@@ -891,8 +912,11 @@ def check_permission(
|
|
|
891
912
|
except Exception:
|
|
892
913
|
pass # never let plugin errors block permissions
|
|
893
914
|
|
|
894
|
-
# action == "ask" -> prompt user
|
|
895
|
-
|
|
915
|
+
# action == "ask" -> prompt user. The scope holds permission_lock AND
|
|
916
|
+
# suspends the tool-execution timeout while we block on the user (see
|
|
917
|
+
# _permission_prompt_scope) — so a slow human decision can never let the
|
|
918
|
+
# tool time out and then apply the mutation out-of-band.
|
|
919
|
+
with _permission_prompt_scope(ctx):
|
|
896
920
|
# Re-check after acquiring lock (another thread may have resolved it)
|
|
897
921
|
results2 = _resolve_many(category, subjects)
|
|
898
922
|
if any(action == "deny" for action, _ in results2):
|
|
@@ -9,6 +9,7 @@ from dataclasses import dataclass, field
|
|
|
9
9
|
from rich.markdown import Markdown
|
|
10
10
|
|
|
11
11
|
from aru.display import console
|
|
12
|
+
from aru.session import Session
|
|
12
13
|
|
|
13
14
|
|
|
14
15
|
# Categories of tools that modify files (for highlighting in history)
|
|
@@ -640,18 +641,30 @@ async def run_agent_capture(agent, message: str, session=None, lightweight: bool
|
|
|
640
641
|
})
|
|
641
642
|
|
|
642
643
|
# Tier 2 #4: auto-memory extraction (opt-in, fire-and-forget).
|
|
644
|
+
# ``turn_tokens`` here is the size of the *exchange* (user message +
|
|
645
|
+
# assistant reply) — NOT the API call's prompt size. Earlier this
|
|
646
|
+
# used ``last_input_tokens + last_output_tokens``, but
|
|
647
|
+
# ``last_input_tokens`` includes the entire system prompt (~8K on
|
|
648
|
+
# aru with 30+ tools), so ``min_turn_tokens=500`` always tripped
|
|
649
|
+
# even on "Olá"/"ok"-style turns and the extractor fired every
|
|
650
|
+
# turn — burning the curator budget on nothing. Estimating from
|
|
651
|
+
# user+assistant char length matches the docstring intent
|
|
652
|
+
# ("trivial turns 'ok'/'thanks' don't trigger").
|
|
643
653
|
try:
|
|
644
654
|
from aru.memory.extractor import schedule_extraction_task
|
|
645
655
|
from aru.runtime import get_ctx as _get_ctx
|
|
646
656
|
_cfg = getattr(_get_ctx(), "config", None)
|
|
647
657
|
_cfg_memory = getattr(_cfg, "memory", None) or {}
|
|
648
658
|
_project_root = getattr(session, "project_root", None) or os.getcwd()
|
|
659
|
+
_exchange_tokens = Session.estimate_tokens(
|
|
660
|
+
(run_message or "") + (final_content or "")
|
|
661
|
+
)
|
|
649
662
|
schedule_extraction_task(
|
|
650
663
|
project_root=_project_root,
|
|
651
664
|
user_msg=run_message or "",
|
|
652
665
|
assistant_msg=final_content or "",
|
|
653
666
|
config_memory=_cfg_memory,
|
|
654
|
-
turn_tokens=
|
|
667
|
+
turn_tokens=_exchange_tokens,
|
|
655
668
|
)
|
|
656
669
|
except Exception:
|
|
657
670
|
pass # extractor guards internally; swallow any unexpected raise
|
|
@@ -345,6 +345,101 @@ def is_aborted() -> bool:
|
|
|
345
345
|
return False
|
|
346
346
|
|
|
347
347
|
|
|
348
|
+
# ── Permission-wait gate (tool-timeout suspension) ───────────────────
|
|
349
|
+
#
|
|
350
|
+
# Safety-critical. A tool's execution timeout (see
|
|
351
|
+
# ``aru.tools._shared._thread_tool``) must NOT count the time a human spends
|
|
352
|
+
# deciding on a permission prompt. The danger is concrete: if the timeout
|
|
353
|
+
# fired while a prompt was still open, ``asyncio`` reports a timeout to the
|
|
354
|
+
# model — but the worker thread it ran on CANNOT be killed (a Python
|
|
355
|
+
# limitation), so it keeps running, parked on the prompt. The moment the user
|
|
356
|
+
# clicks "yes", that orphaned thread applies the mutation **out-of-band**,
|
|
357
|
+
# after the tool already claimed it timed out. An edit (or a delete) then
|
|
358
|
+
# lands that the user never knowingly approved in-context.
|
|
359
|
+
#
|
|
360
|
+
# To prevent that, ``check_permission`` marks a per-tool-call gate while it
|
|
361
|
+
# blocks on the user, and ``_thread_tool`` suspends its timeout for exactly
|
|
362
|
+
# as long as the gate is active. Decision time is the human's, not the
|
|
363
|
+
# tool's budget.
|
|
364
|
+
#
|
|
365
|
+
# Cross-thread mechanics: the gate object is created per tool call by the
|
|
366
|
+
# ``_thread_tool`` wrapper (on the event loop) and stored in a ContextVar.
|
|
367
|
+
# ``asyncio.to_thread`` copies the context into the worker thread, so
|
|
368
|
+
# ``check_permission`` running on that thread flips the SAME gate object the
|
|
369
|
+
# wrapper polls. Concurrent tool calls each get their own gate, so a prompt
|
|
370
|
+
# open for one call never accidentally exempts a sibling.
|
|
371
|
+
|
|
372
|
+
|
|
373
|
+
class PermissionWaitGate:
|
|
374
|
+
"""Per-tool-call counter of in-flight human permission decisions.
|
|
375
|
+
|
|
376
|
+
A depth counter (not a bool) so re-entrant or repeated permission checks
|
|
377
|
+
within one tool call nest correctly. ``active`` is true whenever at least
|
|
378
|
+
one decision is outstanding.
|
|
379
|
+
"""
|
|
380
|
+
|
|
381
|
+
__slots__ = ("_depth", "_lock")
|
|
382
|
+
|
|
383
|
+
def __init__(self) -> None:
|
|
384
|
+
self._depth = 0
|
|
385
|
+
self._lock = threading.Lock()
|
|
386
|
+
|
|
387
|
+
@property
|
|
388
|
+
def active(self) -> bool:
|
|
389
|
+
with self._lock:
|
|
390
|
+
return self._depth > 0
|
|
391
|
+
|
|
392
|
+
def enter(self) -> None:
|
|
393
|
+
with self._lock:
|
|
394
|
+
self._depth += 1
|
|
395
|
+
|
|
396
|
+
def leave(self) -> None:
|
|
397
|
+
with self._lock:
|
|
398
|
+
if self._depth > 0:
|
|
399
|
+
self._depth -= 1
|
|
400
|
+
|
|
401
|
+
|
|
402
|
+
_perm_wait_gate: contextvars.ContextVar[PermissionWaitGate | None] = (
|
|
403
|
+
contextvars.ContextVar("aru_perm_wait_gate", default=None)
|
|
404
|
+
)
|
|
405
|
+
|
|
406
|
+
|
|
407
|
+
def install_permission_wait_gate() -> tuple[PermissionWaitGate, contextvars.Token]:
|
|
408
|
+
"""Create a fresh gate, install it in the current context, return (gate, token).
|
|
409
|
+
|
|
410
|
+
Called by the ``_thread_tool`` wrapper before offloading to a worker
|
|
411
|
+
thread so the worker (which runs in a copy of this context) shares the
|
|
412
|
+
gate. Pair with ``reset_permission_wait_gate(token)`` in a finally.
|
|
413
|
+
"""
|
|
414
|
+
gate = PermissionWaitGate()
|
|
415
|
+
token = _perm_wait_gate.set(gate)
|
|
416
|
+
return gate, token
|
|
417
|
+
|
|
418
|
+
|
|
419
|
+
def reset_permission_wait_gate(token: contextvars.Token) -> None:
|
|
420
|
+
"""Restore the previous gate binding (undo ``install_permission_wait_gate``)."""
|
|
421
|
+
_perm_wait_gate.reset(token)
|
|
422
|
+
|
|
423
|
+
|
|
424
|
+
def begin_permission_wait() -> None:
|
|
425
|
+
"""Mark that the current tool call is blocking on a human permission decision.
|
|
426
|
+
|
|
427
|
+
Paired with ``end_permission_wait()``. A no-op when no gate is installed
|
|
428
|
+
(async tools like ``bash`` that aren't wrapped by ``_thread_tool``, or
|
|
429
|
+
direct test calls) — those paths have no execution timeout to suspend.
|
|
430
|
+
"""
|
|
431
|
+
gate = _perm_wait_gate.get()
|
|
432
|
+
if gate is not None:
|
|
433
|
+
gate.enter()
|
|
434
|
+
|
|
435
|
+
|
|
436
|
+
def end_permission_wait() -> None:
|
|
437
|
+
"""End the permission-wait window opened by ``begin_permission_wait()``."""
|
|
438
|
+
gate = _perm_wait_gate.get()
|
|
439
|
+
if gate is not None:
|
|
440
|
+
gate.leave()
|
|
441
|
+
|
|
442
|
+
|
|
348
443
|
# ── Shared-state helpers (Stage 4) ───────────────────────────────────
|
|
349
444
|
#
|
|
350
445
|
# Individual ``dict[k] = v``, ``dict.get(k)``, and ``list.append`` are atomic
|