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
38 changes: 38 additions & 0 deletions astrbot/core/agent/btw/loop_routes.py
Original file line number Diff line number Diff line change
@@ -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}
25 changes: 25 additions & 0 deletions astrbot/core/astr_agent_tool_exec.py
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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()
Expand All @@ -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
Expand Down
3 changes: 3 additions & 0 deletions astrbot/core/astr_main_agent.py
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down Expand Up @@ -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:
Expand Down
8 changes: 8 additions & 0 deletions astrbot/core/config/default.py
Original file line number Diff line number Diff line change
Expand Up @@ -198,6 +198,7 @@
"max_concurrent": 2,
},
"work_session": {"max_age_seconds": 3600},
"plugin_routes": [],
},
"provider_stt_settings": {
"enable": False,
Expand Down Expand Up @@ -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},
},
},
}

Expand Down
35 changes: 35 additions & 0 deletions astrbot/core/tool_catalog.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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:
Expand Down Expand Up @@ -422,13 +425,45 @@ 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)
for name in names:
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)
Expand Down
7 changes: 7 additions & 0 deletions dashboard/src/components/shared/ConfigItemRenderer.vue
Original file line number Diff line number Diff line change
Expand Up @@ -63,6 +63,12 @@
@update:model-value="emitUpdate"
/>
</template>
<template v-else-if="itemMeta?._special === 'select_plugin_loop_routes'">
<PluginLoopSelector
:model-value="modelValue"
@update:model-value="emitUpdate"
/>
</template>
<template v-else-if="itemMeta?._special === 't2i_template'">
<T2ITemplateEditor />
</template>
Expand Down Expand Up @@ -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';
Expand Down
135 changes: 135 additions & 0 deletions dashboard/src/components/shared/PluginLoopSelector.vue
Original file line number Diff line number Diff line change
@@ -0,0 +1,135 @@
<template>
<div class="plugin-loop-selector">
<v-progress-linear
v-if="loading"
indeterminate
color="primary"
class="mb-3"
/>
<v-alert density="compact" variant="tonal" type="info" class="mb-3">
{{ tm('pluginLoopSelector.hint') }}
</v-alert>
<v-table v-if="plugins.length" density="compact">
<thead>
<tr>
<th>{{ tm('pluginLoopSelector.plugin') }}</th>
<th>{{ tm('pluginLoopSelector.loop') }}</th>
</tr>
</thead>
<tbody>
<tr v-for="plugin in plugins" :key="plugin.id">
<td>{{ plugin.label }}</td>
<td class="plugin-loop-selector__control">
<v-select
:model-value="routeFor(plugin.id)"
:items="loopOptions"
density="compact"
hide-details
variant="outlined"
@update:model-value="setRoute(plugin.id, $event)"
/>
</td>
</tr>
</tbody>
</v-table>
<div v-else-if="!loading" class="text-medium-emphasis text-body-2">
{{ tm('pluginLoopSelector.empty') }}
</div>
</div>
</template>

<script setup lang="ts">
import { computed, onMounted, ref } from 'vue';
import { pluginApi } from '@/api/v1';
import { useModuleI18n } from '@/i18n/composables';

type LoopMode = 'conversation' | 'work' | 'both';
const defaultLoop: LoopMode = 'work';

interface PluginRoute {
plugin_id?: unknown;
loop?: unknown;
}

interface PluginItem {
id: string;
label: string;
}

const props = defineProps<{
modelValue?: unknown;
}>();

const emit = defineEmits<{
'update:modelValue': [value: PluginRoute[]];
}>();

const { tm } = useModuleI18n('features/config');
const loading = ref(false);
const plugins = ref<PluginItem[]>([]);

const loopOptions = computed(() => [
{ title: tm('pluginLoopSelector.conversation'), value: 'conversation' },
{ title: tm('pluginLoopSelector.work'), value: 'work' },
{ title: tm('pluginLoopSelector.both'), value: 'both' },
]);

function routes(): PluginRoute[] {
return Array.isArray(props.modelValue)
? props.modelValue.filter(
(route): route is PluginRoute =>
route !== null && typeof route === 'object',
)
: [];
}

function routeFor(pluginId: string): LoopMode {
const route = routes().find((item) => item.plugin_id === pluginId)?.loop;
return route === 'conversation' || route === 'work' || route === 'both'
? route
: defaultLoop;
}

function setRoute(pluginId: string, value: unknown) {
const loop: LoopMode =
value === 'conversation' || value === 'work' || value === 'both'
? value
: defaultLoop;
const next = routes().filter((item) => item.plugin_id !== pluginId);
if (loop !== defaultLoop) {
next.push({ plugin_id: pluginId, loop });
}
emit('update:modelValue', next);
}

async function loadPlugins() {
loading.value = true;
try {
const response = await pluginApi.list();
if (response.data.status !== 'ok') return;
plugins.value = (response.data.data || [])
.filter((plugin) => plugin.activated && !plugin.reserved)
.map((plugin) => {
const id = String(plugin.root_dir_name || plugin.name || '');
return {
id,
label: String(plugin.display_name || plugin.name || id),
};
})
.filter((plugin) => plugin.id)
.sort((left, right) => left.label.localeCompare(right.label));
} catch {
plugins.value = [];
} finally {
loading.value = false;
}
}

onMounted(loadPlugins);
</script>

<style scoped>
.plugin-loop-selector__control {
min-width: 220px;
}
</style>
Original file line number Diff line number Diff line change
Expand Up @@ -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."
}
}
}
Expand Down
9 changes: 9 additions & 0 deletions dashboard/src/i18n/locales/en-US/features/config.json
Original file line number Diff line number Diff line change
Expand Up @@ -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."
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -1205,6 +1205,10 @@
"description": "对话循环模型",
"hint": "留空时沿用当前会话的模型选择。配置后优先使用此模型。"
}
},
"plugin_routes": {
"description": "插件工具循环分配",
"hint": "插件 LLM 工具默认仅在工作循环可用;可为每个已启用插件显式改为对话循环或两者。"
}
}
}
Expand Down
Loading
Loading