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."""