From 0e6b1b5c0d48b14a79040fbb2db672c60d9be5ce Mon Sep 17 00:00:00 2001 From: YUZHEthefool <2804776511@qq.com> Date: Thu, 10 Sep 2026 23:39:09 +0800 Subject: [PATCH] feat(btw): constrain computer runtimes across loop handoffs Extract Computer Use boundaries from the original PR #28 prototype and apply them through the current request tool catalog. Keep conversation requests and handoffs free of computer tools, and let work select the inherited, local, sandbox, or disabled runtime without changing authority. Fixes #129 AI-Generated: true Generated-At: 2026-09-10T15:38:41Z --- astrbot/core/agent/btw/runtime_policy.py | 35 ++++++++ astrbot/core/astr_agent_tool_exec.py | 34 +++++++- astrbot/core/astr_main_agent.py | 21 ++++- astrbot/core/config/default.py | 14 +++- astrbot/core/tool_catalog.py | 23 ++++++ .../en-US/features/config-metadata.json | 4 + .../zh-CN/features/config-metadata.json | 4 + docs/en/dev/astrbot-config.md | 6 ++ docs/zh/dev/astrbot-config.md | 6 ++ tests/unit/test_astr_agent_tool_exec.py | 64 +++++++++++++++ tests/unit/test_astr_main_agent.py | 82 +++++++++++++++++++ 11 files changed, 290 insertions(+), 3 deletions(-) create mode 100644 astrbot/core/agent/btw/runtime_policy.py diff --git a/astrbot/core/agent/btw/runtime_policy.py b/astrbot/core/agent/btw/runtime_policy.py new file mode 100644 index 0000000000..ffc1c942ea --- /dev/null +++ b/astrbot/core/agent/btw/runtime_policy.py @@ -0,0 +1,35 @@ +"""Computer runtime selection for BTW requests and their handoffs.""" + +from collections.abc import Mapping + + +def resolve_computer_runtime( + profile: Mapping, + loop: object, + inherited: str, +) -> str: + """Resolve the runtime without granting any tool authorization. + + Args: + profile: The current configuration profile. + loop: The event's loop marker; only ``work`` selects the work loop. + inherited: The runtime selected before applying BTW settings. + + Returns: + The inherited runtime when BTW is disabled, otherwise the permitted + runtime for this loop. + """ + btw = profile.get("btw", {}) + if not isinstance(btw, Mapping) or not btw.get("enabled", False): + return inherited + if loop != "work": + return "none" + work = btw.get("work_loop", {}) + runtime = ( + work.get("computer_use_runtime", "inherit") + if isinstance(work, Mapping) + else "inherit" + ) + if runtime not in ("none", "local", "sandbox"): + runtime = inherited + return runtime if runtime in ("none", "local", "sandbox") else "none" diff --git a/astrbot/core/astr_agent_tool_exec.py b/astrbot/core/astr_agent_tool_exec.py index 1da4caeebe..21def86d16 100644 --- a/astrbot/core/astr_agent_tool_exec.py +++ b/astrbot/core/astr_agent_tool_exec.py @@ -11,6 +11,7 @@ import mcp from astrbot import logger +from astrbot.core.agent.btw.runtime_policy import resolve_computer_runtime from astrbot.core.agent.handoff import HandoffTool from astrbot.core.agent.llm_types import ProviderRequest from astrbot.core.agent.mcp_client import MCPTool @@ -300,6 +301,29 @@ def _get_runtime_computer_tools( } return {} + @classmethod + def _filter_handoff_computer_tools( + cls, toolset: ToolSet, *, cfg: dict, runtime: str + ) -> ToolSet: + """Keep handoffs inside the originating loop's computer boundary.""" + from astrbot.core.tool_catalog import COMPUTER_TOOL_ACTIONS, COMPUTER_TOOL_NAMES + + btw = cfg.get("btw", {}) + if ( + not isinstance(btw, dict) + or not btw.get("enabled", False) + or runtime != "none" + ): + return toolset + return ToolSet( + [ + tool + for tool in toolset.tools + if tool.name not in COMPUTER_TOOL_NAMES + and not COMPUTER_TOOL_ACTIONS.intersection(cls._required_actions(tool)) + ] + ) + @classmethod def _build_handoff_toolset( cls, @@ -310,7 +334,11 @@ def _build_handoff_toolset( event = run_context.context.event cfg = ctx.get_config(umo=event.unified_msg_origin) provider_settings = cfg.get("provider_settings", {}) - runtime = str(provider_settings.get("computer_use_runtime", "none")) + runtime = resolve_computer_runtime( + cfg, + event.get_extra("btw_loop"), + str(provider_settings.get("computer_use_runtime", "none")), + ) # An explicitly empty handoff tool list needs no registry lookup. In # particular, this keeps the handoff execution path independent from @@ -341,6 +369,9 @@ def _build_handoff_toolset( toolset.add_tool(registered_tool) for runtime_tool in runtime_computer_tools.values(): toolset.add_tool(runtime_tool) + toolset = cls._filter_handoff_computer_tools( + toolset, cfg=cfg, runtime=runtime + ) return None if toolset.empty() else toolset toolset = ToolSet() @@ -355,6 +386,7 @@ def _build_handoff_toolset( toolset.add_tool(runtime_tool) elif isinstance(tool_name_or_obj, FunctionTool): toolset.add_tool(tool_name_or_obj) + toolset = cls._filter_handoff_computer_tools(toolset, cfg=cfg, runtime=runtime) return None if toolset.empty() else toolset @classmethod diff --git a/astrbot/core/astr_main_agent.py b/astrbot/core/astr_main_agent.py index 3a1ace1423..18170d499b 100644 --- a/astrbot/core/astr_main_agent.py +++ b/astrbot/core/astr_main_agent.py @@ -6,11 +6,12 @@ import re import zoneinfo from collections.abc import Coroutine, Mapping -from dataclasses import dataclass, field +from dataclasses import dataclass, field, replace from pathlib import Path from typing import Any, TypeGuard, cast from astrbot import logger +from astrbot.core.agent.btw.runtime_policy import resolve_computer_runtime from astrbot.core.agent.chat_model import ChatModel from astrbot.core.agent.handoff import HandoffTool from astrbot.core.agent.llm_types import ProviderRequest @@ -182,6 +183,8 @@ class MainAgentBuildConfig: safety_mode_strategy: str = "system_prompt" computer_use_runtime: str = "none" """The runtime for agent computer use: none, local, or sandbox.""" + allow_computer_tools: bool = True + """Whether request tools may include computer capabilities.""" sandbox_cfg: dict = field(default_factory=dict) add_cron_tools: bool = True """This will add cron job management tools to the main agent for proactive cron job execution.""" @@ -1249,6 +1252,7 @@ def _assemble_request_tool_catalog( persona_tools=persona_tools, surface=surface, computer_use_runtime=config.computer_use_runtime, + allow_computer_tools=config.allow_computer_tools, plugin_names=event.plugins_name, registered_tools=registered_tools, session_tool_names=session_tool_names, @@ -1956,6 +1960,21 @@ async def build_main_agent( If apply_reset is False, will not call reset on the agent runner. """ + profile = plugin_context.get_config(umo=event.unified_msg_origin) + btw = profile.get("btw", {}) + if isinstance(btw, Mapping) and btw.get("enabled", False): + runtime = resolve_computer_runtime( + profile, event.get_extra("btw_loop"), config.computer_use_runtime + ) + config = replace( + config, + computer_use_runtime=runtime, + allow_computer_tools=runtime != "none", + provider_settings={ + **config.provider_settings, + "computer_use_runtime": runtime, + }, + ) provider = provider or _select_provider( event, plugin_context, config.provider_id_override ) diff --git a/astrbot/core/config/default.py b/astrbot/core/config/default.py index ec85de5fc3..dbe23a5aaa 100644 --- a/astrbot/core/config/default.py +++ b/astrbot/core/config/default.py @@ -191,7 +191,12 @@ "btw": { "enabled": False, "conversation_loop": {"provider_id": ""}, - "work_loop": {"enabled": False, "provider_id": "", "max_concurrent": 2}, + "work_loop": { + "enabled": False, + "provider_id": "", + "computer_use_runtime": "inherit", + "max_concurrent": 2, + }, "work_session": {"max_age_seconds": 3600}, }, "provider_stt_settings": { @@ -4720,6 +4725,13 @@ "hint": "留空时沿用当前会话的模型选择。配置后优先使用此模型。", "condition": {"btw.enabled": True}, }, + "btw.work_loop.computer_use_runtime": { + "description": "工作循环 Computer Use 运行时", + "type": "string", + "options": ["inherit", "none", "local", "sandbox"], + "hint": "inherit 沿用当前 Computer Use 配置。对话循环始终禁用电脑和文件工具;工作循环仍须满足已有角色、路径和沙箱授权规则。", + "condition": {"btw.enabled": True}, + }, "btw.work_loop.max_concurrent": { "description": "工作任务执行并发", "type": "int", diff --git a/astrbot/core/tool_catalog.py b/astrbot/core/tool_catalog.py index bba576fb03..e1597927eb 100644 --- a/astrbot/core/tool_catalog.py +++ b/astrbot/core/tool_catalog.py @@ -80,6 +80,24 @@ "astrbot_cua_keyboard_type", ) +COMPUTER_TOOL_NAMES: frozenset[str] = frozenset( + LOCAL_COMPUTER_TOOLS + + SANDBOX_BASE_COMPUTER_TOOLS + + SANDBOX_BROWSER_TOOLS + + NEO_LIFECYCLE_TOOLS + + CUA_COMPUTER_TOOLS +) +COMPUTER_TOOL_ACTIONS: frozenset[str] = frozenset( + { + "tool.local_exec", + "tool.python_exec", + "tool.file_read", + "tool.file_write", + "tool.browser_control", + "tool.computer_use", + } +) + WORKSPACE_FILE_READ_TOOLS: frozenset[str] = frozenset( {"astrbot_file_read_tool", "astrbot_grep_tool"} ) @@ -121,6 +139,7 @@ class ToolCatalogInputs: sandbox_booter: str = "shipyard_neo" sandbox_capabilities: Sequence[str] | None = None elevated_instance_tool_actions: frozenset[str] = frozenset() + allow_computer_tools: bool = True plugins: PluginLookup | None = None @@ -411,6 +430,10 @@ def _apply_visibility(names: set[str], *, inputs: ToolCatalogInputs) -> set[str] if tool is None or not getattr(tool, "active", True): continue actions = tool_required_actions(tool) + if not inputs.allow_computer_tools and ( + name in COMPUTER_TOOL_NAMES or COMPUTER_TOOL_ACTIONS.intersection(actions) + ): + continue if name in WORKSPACE_FILE_READ_TOOLS and name not in computer_names: continue if inputs.computer_use_runtime == "none" and _is_computer_capability_action( diff --git a/dashboard/src/i18n/locales/en-US/features/config-metadata.json b/dashboard/src/i18n/locales/en-US/features/config-metadata.json index 4f72d8b32a..eec4a68c7a 100644 --- a/dashboard/src/i18n/locales/en-US/features/config-metadata.json +++ b/dashboard/src/i18n/locales/en-US/features/config-metadata.json @@ -1194,6 +1194,10 @@ "provider_id": { "description": "Work loop model", "hint": "Leave empty to keep the current session model selection. When set, this model takes priority." + }, + "computer_use_runtime": { + "description": "Work loop Computer Use runtime", + "hint": "inherit uses the existing Computer Use setting. The conversation loop has no computer or file tools. Work still follows existing role, path, and sandbox authorization rules." } }, "work_session": { diff --git a/dashboard/src/i18n/locales/zh-CN/features/config-metadata.json b/dashboard/src/i18n/locales/zh-CN/features/config-metadata.json index 8f20540d75..09500de907 100644 --- a/dashboard/src/i18n/locales/zh-CN/features/config-metadata.json +++ b/dashboard/src/i18n/locales/zh-CN/features/config-metadata.json @@ -1188,6 +1188,10 @@ "provider_id": { "description": "工作循环模型", "hint": "留空时沿用当前会话的模型选择。配置后优先使用此模型。" + }, + "computer_use_runtime": { + "description": "工作循环 Computer Use 运行时", + "hint": "inherit 沿用当前 Computer Use 配置。对话循环始终禁用电脑和文件工具;工作循环仍须满足已有角色、路径和沙箱授权规则。" } }, "work_session": { diff --git a/docs/en/dev/astrbot-config.md b/docs/en/dev/astrbot-config.md index 6d5cbae096..6759d1c102 100644 --- a/docs/en/dev/astrbot-config.md +++ b/docs/en/dev/astrbot-config.md @@ -193,6 +193,12 @@ When `btw.enabled` is enabled for a local Agent profile, `btw.conversation_loop. The selected provider must still be a configured chat model. An unavailable or incompatible loop provider fails through the existing model-selection error path; it does not silently switch to the other loop's model. Existing model fallback and retry settings continue to apply to the selected primary provider. +### Computer Use boundaries + +With BTW enabled, the conversation loop runs with Computer Use set to `none`, including handoffs and explicitly supplied tools. Host shell, Python, filesystem, browser, CUA, and sandbox Skill lifecycle tools stay outside its tool catalog. Ordinary Skill manuals remain available through `read_skill`. + +`btw.work_loop.computer_use_runtime` accepts `inherit` (default), `none`, `local`, or `sandbox`. `inherit` uses `provider_settings.computer_use_runtime`. The effective runtime applies to the work request and its handoffs; `none` excludes computer tools even when they were explicitly declared. Disabling BTW preserves the existing Computer Use configuration. These settings select capabilities; they do not grant roles or bypass authorization, WebChat step-up, path restrictions, or sandbox checks. + ## SubAgents, speech, and knowledge base - `subagent_orchestrator.main_enable` enables handoffs. diff --git a/docs/zh/dev/astrbot-config.md b/docs/zh/dev/astrbot-config.md index 37cd171dc2..5bd89e33e2 100644 --- a/docs/zh/dev/astrbot-config.md +++ b/docs/zh/dev/astrbot-config.md @@ -195,6 +195,12 @@ API Key 属于敏感配置。不要把真实 `cmd_config.json`、截图、日志 所选提供商仍须是已配置的对话模型。不存在或类型不适用的循环提供商沿用现有模型选择错误路径,不会静默改用另一个循环的模型。已有模型回退和重试设置继续作用于所选主模型。 +### Computer Use 边界 + +启用 BTW 后,对话循环的 Computer Use 固定为 `none`,并约束其子代理转交与显式传入的工具。宿主机 Shell、Python、文件系统、浏览器、CUA 和沙箱 Skill 生命周期工具不会进入对话循环工具目录。普通 Skill 手册仍可通过 `read_skill` 阅读。 + +`btw.work_loop.computer_use_runtime` 支持 `inherit`(默认)、`none`、`local` 和 `sandbox`。`inherit` 沿用 `provider_settings.computer_use_runtime`。实际运行时同时作用于工作请求及其子代理转交;`none` 也会排除显式声明的电脑工具。关闭 BTW 后沿用现有 Computer Use 配置。这些设置只选择能力,不授予角色,也不绕过授权、WebChat step-up、路径限制或沙箱检查。 + ## 子代理、语音与知识库 - `subagent_orchestrator.main_enable`:启用 handoff。 diff --git a/tests/unit/test_astr_agent_tool_exec.py b/tests/unit/test_astr_agent_tool_exec.py index fafdd49eb7..089b4c1177 100644 --- a/tests/unit/test_astr_agent_tool_exec.py +++ b/tests/unit/test_astr_agent_tool_exec.py @@ -233,6 +233,70 @@ def test_build_handoff_toolset_keeps_declared_tools(runtime): ) +@pytest.mark.parametrize("tool_selection", ["all", "names", "objects"]) +@pytest.mark.parametrize( + ("enabled", "loop", "override", "expected_runtime"), + [ + (True, None, "inherit", "none"), + (True, "conversation", "sandbox", "none"), + (True, "work", "inherit", "local"), + (True, "work", "sandbox", "sandbox"), + (True, "work", "none", "none"), + (False, "conversation", "sandbox", "local"), + ], +) +def test_handoff_respects_btw_runtime_for_all_tool_declarations( + tool_selection, enabled, loop, override, expected_runtime +): + from astrbot.core.tool_catalog import COMPUTER_TOOL_NAMES + + manager = FunctionToolManager() + declared = [ + FunctionTool(name=name, description=name, parameters={}) + for name in ( + "weather", + "astrbot_file_read_tool", + "astrbot_create_skill_payload", + ) + ] + manager.func_list = declared + event = _DummyEvent() + event.get_extra = lambda key, default=None: loop if key == "btw_loop" else default + profile = { + "provider_settings": {"computer_use_runtime": "local"}, + "btw": {"enabled": enabled, "work_loop": {"computer_use_runtime": override}}, + } + context = SimpleNamespace( + get_config=lambda **_: profile, + get_llm_tool_manager=lambda: manager, + ) + run_context = ContextWrapper(context=SimpleNamespace(event=event, context=context)) + tools = ( + None + if tool_selection == "all" + else ( + [tool.name for tool in declared] if tool_selection == "names" else declared + ) + ) + + toolset = FunctionToolExecutor._build_handoff_toolset(run_context, tools) + + assert toolset is not None + names = toolset.names() + assert "weather" in names + if expected_runtime == "none": + assert not COMPUTER_TOOL_NAMES.intersection(names) + else: + assert "astrbot_file_read_tool" in names + assert "astrbot_create_skill_payload" in names + if tool_selection == "all": + assert ("astrbot_execute_python" in names) is (expected_runtime == "local") + assert ("astrbot_execute_ipython" in names) is ( + expected_runtime == "sandbox" + ) + assert profile["provider_settings"]["computer_use_runtime"] == "local" + + @pytest.mark.asyncio async def test_collect_handoff_image_urls_normalizes_filters_and_appends_event_image( monkeypatch: pytest.MonkeyPatch, diff --git a/tests/unit/test_astr_main_agent.py b/tests/unit/test_astr_main_agent.py index 5084cc7be4..f113929d74 100644 --- a/tests/unit/test_astr_main_agent.py +++ b/tests/unit/test_astr_main_agent.py @@ -24,6 +24,88 @@ from astrbot.core.star.star import StarMetadata +@pytest.mark.asyncio +@pytest.mark.parametrize( + ("enabled", "loop", "override", "expected"), + [ + (True, None, "sandbox", "none"), + (True, "conversation", "local", "none"), + (True, "work", "inherit", "local"), + (True, "work", "none", "none"), + (True, "work", "sandbox", "sandbox"), + (True, "work", "local", "local"), + (True, "work", {"invalid": True}, "local"), + (False, "conversation", "sandbox", "local"), + ], +) +async def test_btw_build_applies_runtime_before_request_preparation( + monkeypatch, + mock_event, + mock_context, + mock_provider, + enabled, + loop, + override, + expected, +): + mock_event.set_extra("btw_loop", loop) + mock_context.get_config.return_value = { + "btw": {"enabled": enabled, "work_loop": {"computer_use_runtime": override}}, + } + config = ama.MainAgentBuildConfig( + tool_call_timeout=60, + computer_use_runtime="local", + provider_settings={ + "computer_use_runtime": "local", + "image_compress_enabled": False, + }, + ) + prepare = AsyncMock(return_value=False) + monkeypatch.setattr(ama, "_prepare_request_for_agent", prepare) + monkeypatch.setattr(ama, "prepare_event_attachments", AsyncMock()) + + await ama.build_main_agent( + event=mock_event, + plugin_context=mock_context, + config=config, + provider=mock_provider, + req=ProviderRequest(prompt="test"), + ) + + effective = prepare.await_args.args[3] + assert effective.computer_use_runtime == expected + assert effective.provider_settings["computer_use_runtime"] == expected + assert effective.allow_computer_tools is (expected != "none") + assert effective.provider_settings["image_compress_enabled"] is False + assert config.computer_use_runtime == "local" + assert config.provider_settings["computer_use_runtime"] == "local" + + +def test_btw_catalog_rejects_reintroduced_computer_tools(mock_event, mock_context): + from astrbot.core.tool_catalog import COMPUTER_TOOL_NAMES + + tools = [ + FunctionTool(name=name, description=name, parameters={}) + for name in sorted(COMPUTER_TOOL_NAMES | {"weather"}) + ] + req = ProviderRequest(prompt="test", func_tool=ToolSet(tools)) + + ama._assemble_request_tool_catalog( + mock_event, + req, + mock_context, + ama.MainAgentBuildConfig( + tool_call_timeout=60, + computer_use_runtime="none", + allow_computer_tools=False, + ), + ) + + assert req.func_tool is not None + assert "weather" in req.func_tool.names() + assert not COMPUTER_TOOL_NAMES.intersection(req.func_tool.names()) + + @pytest.fixture def mock_provider(): """Create a mock provider."""