diff --git a/astrbot/core/agent/btw/loop_routes.py b/astrbot/core/agent/btw/loop_routes.py new file mode 100644 index 0000000000..895424d115 --- /dev/null +++ b/astrbot/core/agent/btw/loop_routes.py @@ -0,0 +1,38 @@ +"""Resolve capability assignments shared by BTW request paths.""" + + +def route_is_available_in_loop( + routes: object, + *, + route_key: str, + route_id: str, + loop_mode: str, + default_loop: str = "both", +) -> bool: + """Resolve a list assignment, falling back to the capability's default. + + Args: + routes: List of dictionaries containing the capability key and ``loop``. + route_key: Key identifying a capability in an assignment. + route_id: Capability identifier to look up. + loop_mode: Current loop; missing or invalid values mean conversation. + default_loop: Assignment used for missing or malformed entries. + + Returns: + Whether the capability is available in the current loop. + """ + loop_mode = "work" if loop_mode == "work" else "conversation" + route = default_loop + if isinstance(routes, list): + for entry in routes: + if not isinstance(entry, dict) or entry.get(route_key) != route_id: + continue + candidate = entry.get("loop") + if isinstance(candidate, str) and candidate in { + "conversation", + "work", + "both", + }: + route = candidate + break + return route in {"both", loop_mode} diff --git a/astrbot/core/astr_agent_tool_exec.py b/astrbot/core/astr_agent_tool_exec.py index 21def86d16..b0af8470b5 100644 --- a/astrbot/core/astr_agent_tool_exec.py +++ b/astrbot/core/astr_agent_tool_exec.py @@ -32,6 +32,7 @@ MessageEventResult, ) from astrbot.core.platform.message_session import MessageSession +from astrbot.core.tool_catalog import tool_is_available_in_loop from astrbot.core.tools.computer_tools import ( CuaKeyboardTypeTool, CuaMouseClickTool, @@ -301,6 +302,24 @@ def _get_runtime_computer_tools( } return {} + @staticmethod + def _filter_handoff_tools_for_loop(toolset: ToolSet, *, cfg, ctx, event) -> ToolSet: + """Keep handoff tools within the originating loop's assignments.""" + btw_config = cfg.get("btw", {}) + if not isinstance(btw_config, dict) or not btw_config.get("enabled", False): + return toolset + plugins = getattr(getattr(ctx, "catalogs", None), "plugins", None) + loop_mode = "work" if event.get_extra("btw_loop") == "work" else "conversation" + return ToolSet( + [ + tool + for tool in toolset.tools + if tool_is_available_in_loop( + tool, btw_config=btw_config, loop_mode=loop_mode, plugins=plugins + ) + ] + ) + @classmethod def _filter_handoff_computer_tools( cls, toolset: ToolSet, *, cfg: dict, runtime: str @@ -372,6 +391,9 @@ def _build_handoff_toolset( toolset = cls._filter_handoff_computer_tools( toolset, cfg=cfg, runtime=runtime ) + toolset = cls._filter_handoff_tools_for_loop( + toolset, cfg=cfg, ctx=ctx, event=event + ) return None if toolset.empty() else toolset toolset = ToolSet() @@ -387,6 +409,9 @@ def _build_handoff_toolset( 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) + toolset = cls._filter_handoff_tools_for_loop( + toolset, cfg=cfg, ctx=ctx, event=event + ) 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 18170d499b..b28959aa62 100644 --- a/astrbot/core/astr_main_agent.py +++ b/astrbot/core/astr_main_agent.py @@ -1226,6 +1226,7 @@ def _assemble_request_tool_catalog( cfg = plugin_context.get_config(umo=event.unified_msg_origin) provider_settings = cfg.get("provider_settings", {}) ltm_settings = cfg.get("provider_ltm_settings", {}) + btw_config = cfg.get("btw", {}) memory_manager = _get_context_runtime_attr(plugin_context, "memory_manager") tool_manager = plugin_context.get_llm_tool_manager() registered_tools = _registered_tools_table(tool_manager) @@ -1270,6 +1271,8 @@ def _assemble_request_tool_catalog( sandbox_capabilities=sandbox_capabilities, elevated_instance_tool_actions=elevated_instance_tool_actions, plugins=plugin_context.catalogs.plugins, + btw_config=btw_config if isinstance(btw_config, dict) else None, + loop_mode="work" if event.get_extra("btw_loop") == "work" else "conversation", ) existing = req.func_tool if existing is not None: diff --git a/astrbot/core/config/default.py b/astrbot/core/config/default.py index dbe23a5aaa..56f2949995 100644 --- a/astrbot/core/config/default.py +++ b/astrbot/core/config/default.py @@ -198,6 +198,7 @@ "max_concurrent": 2, }, "work_session": {"max_age_seconds": 3600}, + "plugin_routes": [], }, "provider_stt_settings": { "enable": False, @@ -4744,6 +4745,13 @@ "hint": "已完成、失败或取消的工作会话保留时间,默认 3600 秒。", "condition": {"btw.enabled": True}, }, + "btw.plugin_routes": { + "description": "插件工具循环分配", + "type": "list", + "hint": "插件 LLM 工具默认仅在工作循环可用;可显式分配给对话循环或两者。插件指令不受此设置影响。", + "_special": "select_plugin_loop_routes", + "condition": {"btw.enabled": True}, + }, }, } diff --git a/astrbot/core/tool_catalog.py b/astrbot/core/tool_catalog.py index e1597927eb..c143136ef4 100644 --- a/astrbot/core/tool_catalog.py +++ b/astrbot/core/tool_catalog.py @@ -5,6 +5,7 @@ from typing import Literal, Protocol from astrbot import logger +from astrbot.core.agent.btw.loop_routes import route_is_available_in_loop from astrbot.core.agent.mcp_client import MCPTool from astrbot.core.agent.tool import FunctionTool, ToolSet from astrbot.core.auth.models import WEBCHAT_INSTANCE_TOOL_ACTIONS @@ -141,6 +142,8 @@ class ToolCatalogInputs: elevated_instance_tool_actions: frozenset[str] = frozenset() allow_computer_tools: bool = True plugins: PluginLookup | None = None + btw_config: Mapping[str, object] | None = None + loop_mode: str = "conversation" def assemble_tool_catalog(inputs: ToolCatalogInputs) -> ToolSet: @@ -422,6 +425,31 @@ def _apply_plugin_filter( return kept +def tool_is_available_in_loop( + tool: FunctionTool, + *, + btw_config: Mapping[str, object] | None, + loop_mode: str, + plugins: PluginLookup | None, +) -> bool: + """Apply the same BTW capability assignment in the catalog and handoffs.""" + if not btw_config or not btw_config.get("enabled", False): + return True + raw_tool = getattr(tool, "_wrapped", tool) + module_path = getattr(raw_tool, "handler_module_path", None) + plugin = plugins.get_by_module(module_path) if plugins and module_path else None + if plugin is None or getattr(plugin, "reserved", False): + return True + plugin_id = getattr(plugin, "root_dir_name", None) or getattr(plugin, "name", "") + return route_is_available_in_loop( + btw_config.get("plugin_routes"), + route_key="plugin_id", + route_id=plugin_id, + loop_mode=loop_mode, + default_loop="work", + ) + + def _apply_visibility(names: set[str], *, inputs: ToolCatalogInputs) -> set[str]: visible: set[str] = set() computer_names = _on_demand_computer_tools(inputs) @@ -429,6 +457,13 @@ def _apply_visibility(names: set[str], *, inputs: ToolCatalogInputs) -> set[str] tool = inputs.registered_tools.get(name) if tool is None or not getattr(tool, "active", True): continue + if not tool_is_available_in_loop( + tool, + btw_config=inputs.btw_config, + loop_mode=inputs.loop_mode, + plugins=inputs.plugins, + ): + continue actions = tool_required_actions(tool) if not inputs.allow_computer_tools and ( name in COMPUTER_TOOL_NAMES or COMPUTER_TOOL_ACTIONS.intersection(actions) diff --git a/dashboard/src/components/shared/ConfigItemRenderer.vue b/dashboard/src/components/shared/ConfigItemRenderer.vue index a77382a606..ec5d6028be 100644 --- a/dashboard/src/components/shared/ConfigItemRenderer.vue +++ b/dashboard/src/components/shared/ConfigItemRenderer.vue @@ -63,6 +63,12 @@ @update:model-value="emitUpdate" /> + @@ -306,6 +312,7 @@ import ProviderSelector from './ProviderSelector.vue'; import PersonaSelector from './PersonaSelector.vue'; import KnowledgeBaseSelector from './KnowledgeBaseSelector.vue'; import PluginSetSelector from './PluginSetSelector.vue'; +import PluginLoopSelector from './PluginLoopSelector.vue'; import T2ITemplateEditor from './T2ITemplateEditor.vue'; import DashboardTotpManager from './DashboardTotpManager.vue'; import { computed, ref } from 'vue'; diff --git a/dashboard/src/components/shared/PluginLoopSelector.vue b/dashboard/src/components/shared/PluginLoopSelector.vue new file mode 100644 index 0000000000..005f5541d2 --- /dev/null +++ b/dashboard/src/components/shared/PluginLoopSelector.vue @@ -0,0 +1,135 @@ + + + + + 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 eec4a68c7a..447116efeb 100644 --- a/dashboard/src/i18n/locales/en-US/features/config-metadata.json +++ b/dashboard/src/i18n/locales/en-US/features/config-metadata.json @@ -1211,6 +1211,10 @@ "description": "Conversation loop model", "hint": "Leave empty to keep the current session model selection. When set, this model takes priority." } + }, + "plugin_routes": { + "description": "Plugin tool loop assignments", + "hint": "Plugin LLM tools default to the work loop; explicitly assign an enabled plugin to the conversation loop or both when needed." } } } diff --git a/dashboard/src/i18n/locales/en-US/features/config.json b/dashboard/src/i18n/locales/en-US/features/config.json index 828c1c950b..ed9f45aeed 100644 --- a/dashboard/src/i18n/locales/en-US/features/config.json +++ b/dashboard/src/i18n/locales/en-US/features/config.json @@ -198,5 +198,14 @@ "confirm": "confirm", "cancel": "cancel" } + }, + "pluginLoopSelector": { + "hint": "Plugin LLM tools default to Work only. You can explicitly allow Conversation only or both loops; plugin commands are outside this tool route.", + "plugin": "Plugin", + "loop": "Available loop", + "conversation": "Conversation only", + "work": "Work only", + "both": "Conversation and Work", + "empty": "There are no enabled non-system plugins." } } 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 09500de907..73ea5b3247 100644 --- a/dashboard/src/i18n/locales/zh-CN/features/config-metadata.json +++ b/dashboard/src/i18n/locales/zh-CN/features/config-metadata.json @@ -1205,6 +1205,10 @@ "description": "对话循环模型", "hint": "留空时沿用当前会话的模型选择。配置后优先使用此模型。" } + }, + "plugin_routes": { + "description": "插件工具循环分配", + "hint": "插件 LLM 工具默认仅在工作循环可用;可为每个已启用插件显式改为对话循环或两者。" } } } diff --git a/dashboard/src/i18n/locales/zh-CN/features/config.json b/dashboard/src/i18n/locales/zh-CN/features/config.json index 4704a0ecd6..e5a5474ff6 100644 --- a/dashboard/src/i18n/locales/zh-CN/features/config.json +++ b/dashboard/src/i18n/locales/zh-CN/features/config.json @@ -198,5 +198,14 @@ "confirm": "确定", "cancel": "取消" } + }, + "pluginLoopSelector": { + "hint": "插件 LLM 工具默认仅在工作循环可用。可显式改为仅对话循环或两个循环;插件命令不受此工具路由控制。", + "plugin": "插件", + "loop": "可用循环", + "conversation": "仅对话循环", + "work": "仅工作循环", + "both": "对话与工作循环", + "empty": "当前没有已启用的非系统插件。" } } diff --git a/dashboard/tests/pluginLoopSelector.vitest.ts b/dashboard/tests/pluginLoopSelector.vitest.ts new file mode 100644 index 0000000000..40c3501fab --- /dev/null +++ b/dashboard/tests/pluginLoopSelector.vitest.ts @@ -0,0 +1,73 @@ +import { flushPromises } from '@vue/test-utils'; +import { beforeEach, describe, expect, it, vi } from 'vitest'; +import PluginLoopSelector from '@/components/shared/PluginLoopSelector.vue'; +import { mountWithVuetify } from './utils/mountWithVuetify'; + +const testState = vi.hoisted(() => ({ + pluginListMock: vi.fn(), +})); + +vi.mock('@/api/v1', () => ({ + pluginApi: { + list: testState.pluginListMock, + }, +})); + +describe('PluginLoopSelector', () => { + beforeEach(() => { + testState.pluginListMock.mockResolvedValue({ + data: { + status: 'ok', + data: [ + { + name: 'example-plugin', + root_dir_name: 'example-plugin', + display_name: 'Example Plugin', + activated: true, + reserved: false, + }, + { + name: 'system-plugin', + activated: true, + reserved: true, + }, + ], + }, + }); + }); + + it('defaults plugin tools to work and preserves an explicit both override', async () => { + const wrapper = mountWithVuetify(PluginLoopSelector, { + props: { + modelValue: [], + }, + }); + + await flushPromises(); + + expect(wrapper.text()).toContain('Example Plugin'); + expect(wrapper.text()).not.toContain('system-plugin'); + + const select = wrapper.findComponent({ name: 'VSelect' }); + expect(select.props('modelValue')).toBe('work'); + + select.vm.$emit('update:modelValue', 'both'); + await wrapper.vm.$nextTick(); + + expect(wrapper.emitted('update:modelValue')).toEqual([ + [[{ plugin_id: 'example-plugin', loop: 'both' }]], + ]); + + await wrapper.setProps({ + modelValue: [{ plugin_id: 'example-plugin', loop: 'both' }], + }); + select.vm.$emit('update:modelValue', 'work'); + await wrapper.vm.$nextTick(); + + expect(wrapper.emitted('update:modelValue')).toEqual([ + [[{ plugin_id: 'example-plugin', loop: 'both' }]], + [[]], + ]); + wrapper.unmount(); + }); +}); diff --git a/docs/en/dev/astrbot-config.md b/docs/en/dev/astrbot-config.md index 6759d1c102..2f78205ff9 100644 --- a/docs/en/dev/astrbot-config.md +++ b/docs/en/dev/astrbot-config.md @@ -199,6 +199,12 @@ With BTW enabled, the conversation loop runs with Computer Use set to `none`, in `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. +## BTW plugin tool assignments + +When BTW is enabled in a configuration profile, **Config → BTW dual loops → Plugin tool loop assignments** assigns each enabled non-system plugin's LLM tools to conversation, work, or both loops. An unassigned plugin defaults to work. Selecting both saves an explicit override; selecting work again removes it. Disabling BTW preserves normal tool availability. + +The main Agent and its subagent handoffs apply the same assignment, together with existing Persona, profile, and authorization restrictions. An assignment never grants permission to execute a tool. Plugin event handlers and explicit commands keep their existing execution path; this setting does not turn an entire plugin into a background task. + ## 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 5bd89e33e2..eef9bedd46 100644 --- a/docs/zh/dev/astrbot-config.md +++ b/docs/zh/dev/astrbot-config.md @@ -201,6 +201,12 @@ API Key 属于敏感配置。不要把真实 `cmd_config.json`、截图、日志 `btw.work_loop.computer_use_runtime` 支持 `inherit`(默认)、`none`、`local` 和 `sandbox`。`inherit` 沿用 `provider_settings.computer_use_runtime`。实际运行时同时作用于工作请求及其子代理转交;`none` 也会排除显式声明的电脑工具。关闭 BTW 后沿用现有 Computer Use 配置。这些设置只选择能力,不授予角色,也不绕过授权、WebChat step-up、路径限制或沙箱检查。 +## BTW 插件工具循环分配 + +在配置档中启用 BTW 后,可通过 **配置文件 → BTW 双循环 → 插件工具循环分配** 为每个已启用的非系统插件选择对话循环、工作循环或两者。未分配的插件默认仅工作循环可用;选择两者会保存显式覆盖,重新选择工作循环会移除覆盖。关闭 BTW 后保留普通工具可用性。 + +主 Agent 与其子 Agent handoff 应用相同分配,并继续遵守 Persona、配置档与授权限制。循环分配不会授予工具执行权限。插件事件处理器和显式命令保留原有执行路径;此设置不会把整个插件转换为后台任务。 + ## 子代理、语音与知识库 - `subagent_orchestrator.main_enable`:启用 handoff。 diff --git a/tests/unit/test_btw_capability_routes.py b/tests/unit/test_btw_capability_routes.py new file mode 100644 index 0000000000..fd30ad21c7 --- /dev/null +++ b/tests/unit/test_btw_capability_routes.py @@ -0,0 +1,128 @@ +"""BTW assignments apply across catalog assembly and nested handoffs.""" + +import json +from types import SimpleNamespace + +import pytest + +from astrbot.core.agent.llm_types import ProviderRequest +from astrbot.core.agent.run_context import ContextWrapper +from astrbot.core.agent.tool import FunctionTool +from astrbot.core.astr_agent_tool_exec import FunctionToolExecutor +from astrbot.core.astr_main_agent import ( + MainAgentBuildConfig, + _assemble_request_tool_catalog, +) +from astrbot.core.config.astrbot_config import AstrBotConfig +from astrbot.core.skills._skill_snapshot import SkillSnapshot +from astrbot.core.tool_catalog import ToolCatalogInputs, assemble_tool_catalog +from astrbot.core.tools.function_tool_manager import FunctionToolManager + + +@pytest.fixture +def plugin_context(): + plugin = SimpleNamespace(root_dir_name="example", name="example", reserved=False) + plugins = SimpleNamespace( + get_by_module=lambda path: plugin if path == "plugins.example.main" else None + ) + plugin_tool = FunctionTool( + name="plugin_tool", + description="Plugin operation", + parameters={"type": "object", "properties": {}}, + handler_module_path="plugins.example.main", + required_actions=("session.read",), + ) + builtin_tool = FunctionTool( + name="builtin_tool", + description="Built-in operation", + parameters={"type": "object", "properties": {}}, + ) + manager = FunctionToolManager() + manager.func_list = [plugin_tool, builtin_tool] + return SimpleNamespace( + catalogs=SimpleNamespace(plugins=plugins), + get_llm_tool_manager=lambda: manager, + subagent_orchestrator=None, + ) + + +@pytest.mark.parametrize( + ("enabled", "loop", "routes", "allowed"), + [ + (True, None, [], False), + (True, "conversation", [], False), + (True, "work", [], True), + (False, "conversation", [], True), + (True, "conversation", [{"plugin_id": "example", "loop": "both"}], True), + (True, "work", [{"plugin_id": "example", "loop": "conversation"}], False), + (True, "conversation", [{"plugin_id": "example", "loop": "invalid"}], False), + (True, "conversation", {"example": "both"}, False), + (True, "conversation", [None, {"plugin_id": "example", "loop": []}], False), + ], +) +@pytest.mark.parametrize("handoff_selection", [None, ["plugin_tool", "builtin_tool"]]) +def test_plugin_assignments_match_main_and_handoff( + plugin_context, enabled, loop, routes, allowed, handoff_selection +): + cfg = {"btw": {"enabled": enabled, "plugin_routes": routes}} + plugin_context.get_config = lambda **_kwargs: cfg + event = SimpleNamespace( + unified_msg_origin="webchat:FriendMessage:test", + get_extra=lambda key, default=None: loop if key == "btw_loop" else default, + plugins_name=None, + platform_meta=SimpleNamespace(support_proactive_message=False), + get_message_type=lambda: None, + ) + req = ProviderRequest(prompt="hello") + _assemble_request_tool_catalog( + event, + req, + plugin_context, + MainAgentBuildConfig(tool_call_timeout=60, add_cron_tools=False), + ) + run_context = ContextWrapper( + context=SimpleNamespace(event=event, context=plugin_context) + ) + handoff = FunctionToolExecutor._build_handoff_toolset( + run_context, tools=handoff_selection + ) + expected = {"builtin_tool", "plugin_tool"} if allowed else {"builtin_tool"} + assert req.func_tool is not None + assert set(req.func_tool.names()) == expected + assert handoff is not None + assert set(handoff.names()) == expected + + +def test_plugin_assignment_does_not_restore_persona_filtered_tool(plugin_context): + tools = plugin_context.get_llm_tool_manager().func_list + catalog = assemble_tool_catalog( + ToolCatalogInputs( + snapshot=SkillSnapshot(skills=(), runtime="none"), + persona_tools=[], + surface="im", + computer_use_runtime="none", + plugin_names=None, + registered_tools={tool.name: tool for tool in tools}, + session_tool_names=frozenset(tool.name for tool in tools), + plugins=plugin_context.catalogs.plugins, + btw_config={ + "enabled": True, + "plugin_routes": [{"plugin_id": "example", "loop": "both"}], + }, + ) + ) + assert catalog.empty() + + +def test_plugin_routes_survive_profile_save(tmp_path): + path = tmp_path / "profile.json" + routes = [{"plugin_id": "example", "loop": "both"}] + path.write_text(json.dumps({"btw": {"plugin_routes": routes}}), encoding="utf-8") + config = AstrBotConfig( + config_path=str(path), default_config={"btw": {"plugin_routes": []}} + ) + config.save_config() + assert ( + json.loads(path.read_text(encoding="utf-8-sig"))["btw"]["plugin_routes"] + == routes + )