Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
35 changes: 35 additions & 0 deletions astrbot/core/agent/btw/runtime_policy.py
Original file line number Diff line number Diff line change
@@ -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"
34 changes: 33 additions & 1 deletion astrbot/core/astr_agent_tool_exec.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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,
Expand All @@ -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
Expand Down Expand Up @@ -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()
Expand All @@ -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
Expand Down
21 changes: 20 additions & 1 deletion astrbot/core/astr_main_agent.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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."""
Expand Down Expand Up @@ -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,
Expand Down Expand Up @@ -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
)
Expand Down
14 changes: 13 additions & 1 deletion astrbot/core/config/default.py
Original file line number Diff line number Diff line change
Expand Up @@ -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": {
Expand Down Expand Up @@ -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",
Expand Down
23 changes: 23 additions & 0 deletions astrbot/core/tool_catalog.py
Original file line number Diff line number Diff line change
Expand Up @@ -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"}
)
Expand Down Expand Up @@ -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


Expand Down Expand Up @@ -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(
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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": {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -1188,6 +1188,10 @@
"provider_id": {
"description": "工作循环模型",
"hint": "留空时沿用当前会话的模型选择。配置后优先使用此模型。"
},
"computer_use_runtime": {
"description": "工作循环 Computer Use 运行时",
"hint": "inherit 沿用当前 Computer Use 配置。对话循环始终禁用电脑和文件工具;工作循环仍须满足已有角色、路径和沙箱授权规则。"
}
},
"work_session": {
Expand Down
6 changes: 6 additions & 0 deletions docs/en/dev/astrbot-config.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
6 changes: 6 additions & 0 deletions docs/zh/dev/astrbot-config.md
Original file line number Diff line number Diff line change
Expand Up @@ -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。
Expand Down
64 changes: 64 additions & 0 deletions tests/unit/test_astr_agent_tool_exec.py
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down
Loading
Loading