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
8 changes: 8 additions & 0 deletions astrbot/core/config/default.py
Original file line number Diff line number Diff line change
Expand Up @@ -199,6 +199,7 @@
},
"work_session": {"max_age_seconds": 3600},
"plugin_routes": [],
"mcp_routes": [],
},
"provider_stt_settings": {
"enable": False,
Expand Down Expand Up @@ -4752,6 +4753,13 @@
"_special": "select_plugin_loop_routes",
"condition": {"btw.enabled": True},
},
"btw.mcp_routes": {
"description": "MCP 服务器循环分配",
"type": "list",
"hint": "MCP 工具默认仅在工作循环可用;可按服务器显式分配给对话循环或两者。",
"_special": "select_mcp_loop_routes",
"condition": {"btw.enabled": True},
},
},
}

Expand Down
8 changes: 8 additions & 0 deletions astrbot/core/tool_catalog.py
Original file line number Diff line number Diff line change
Expand Up @@ -436,6 +436,14 @@ def tool_is_available_in_loop(
if not btw_config or not btw_config.get("enabled", False):
return True
raw_tool = getattr(tool, "_wrapped", tool)
if isinstance(raw_tool, MCPTool):
return route_is_available_in_loop(
btw_config.get("mcp_routes"),
route_key="server_name",
route_id=raw_tool.mcp_server_name,
loop_mode=loop_mode,
default_loop="work",
)
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):
Expand Down
156 changes: 156 additions & 0 deletions dashboard/src/components/shared/CapabilityLoopSelector.vue
Original file line number Diff line number Diff line change
@@ -0,0 +1,156 @@
<template>
<div class="capability-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">
{{ hint }}
</v-alert>
<v-table v-if="capabilities.length" density="compact">
<thead>
<tr>
<th>{{ tm('capabilityLoopSelector.capability') }}</th>
<th>{{ tm('capabilityLoopSelector.loop') }}</th>
</tr>
</thead>
<tbody>
<tr v-for="capability in capabilities" :key="capability.id">
<td>{{ capability.label }}</td>
<td class="capability-loop-selector__control">
<v-select
:model-value="routeFor(capability.id)"
:items="loopOptions"
density="compact"
hide-details
variant="outlined"
@update:model-value="setRoute(capability.id, $event)"
/>
</td>
</tr>
</tbody>
</v-table>
<div v-else-if="!loading" class="text-medium-emphasis text-body-2">
{{ emptyMessage }}
</div>
</div>
</template>

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

type CapabilityKind = 'mcp';
type LoopMode = 'conversation' | 'work' | 'both';
type RouteKey = 'server_name';

interface RouteEntry {
server_name?: unknown;
loop?: unknown;
}

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

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

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

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

const routeKey = computed<RouteKey>(() => 'server_name');
const defaultLoop = computed<LoopMode>(() => 'work');
const hint = computed(() => tm('capabilityLoopSelector.mcpHint'));
const emptyMessage = computed(() => tm('capabilityLoopSelector.emptyMcp'));
const loopOptions = computed(() => [
{ title: tm('capabilityLoopSelector.conversation'), value: 'conversation' },
{ title: tm('capabilityLoopSelector.work'), value: 'work' },
{ title: tm('capabilityLoopSelector.both'), value: 'both' },
]);

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

function routeFor(capabilityId: string): LoopMode {
const route = routes().find(
(item) => item[routeKey.value] === capabilityId,
)?.loop;
return route === 'conversation' || route === 'work' || route === 'both'
? route
: defaultLoop.value;
}

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

function normalizeItems(value: unknown): CapabilityItem[] {
if (!Array.isArray(value)) return [];
const items = value
.filter(
(item): item is Record<string, unknown> =>
item !== null && typeof item === 'object',
)
.filter((item) => item.active !== false)
.map((item) => {
const id = typeof item.name === 'string' ? item.name.trim() : '';
const description =
typeof item.description === 'string' ? item.description.trim() : '';
return {
id,
label: description ? `${id} — ${description}` : id,
};
})
.filter((item) => item.id);
return [...new Map(items.map((item) => [item.id, item])).values()].sort(
(left, right) => left.label.localeCompare(right.label),
);
}

async function loadCapabilities() {
loading.value = true;
try {
const response = await mcpApi.list();
capabilities.value =
response.data.status === 'ok' ? normalizeItems(response.data.data) : [];
} catch {
capabilities.value = [];
} finally {
loading.value = false;
}
}

onMounted(loadCapabilities);
</script>

<style scoped>
.capability-loop-selector__control {
min-width: 220px;
}
</style>
8 changes: 8 additions & 0 deletions dashboard/src/components/shared/ConfigItemRenderer.vue
Original file line number Diff line number Diff line change
Expand Up @@ -69,6 +69,13 @@
@update:model-value="emitUpdate"
/>
</template>
<template v-else-if="itemMeta?._special === 'select_mcp_loop_routes'">
<CapabilityLoopSelector
kind="mcp"
:model-value="modelValue"
@update:model-value="emitUpdate"
/>
</template>
<template v-else-if="itemMeta?._special === 't2i_template'">
<T2ITemplateEditor />
</template>
Expand Down Expand Up @@ -313,6 +320,7 @@ import PersonaSelector from './PersonaSelector.vue';
import KnowledgeBaseSelector from './KnowledgeBaseSelector.vue';
import PluginSetSelector from './PluginSetSelector.vue';
import PluginLoopSelector from './PluginLoopSelector.vue';
import CapabilityLoopSelector from './CapabilityLoopSelector.vue';
import T2ITemplateEditor from './T2ITemplateEditor.vue';
import DashboardTotpManager from './DashboardTotpManager.vue';
import { computed, ref } from 'vue';
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -1215,6 +1215,10 @@
"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."
},
"mcp_routes": {
"description": "MCP server loop assignments",
"hint": "MCP tools default to the work loop; explicitly assign an enabled server 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 @@ -207,5 +207,14 @@
"work": "Work only",
"both": "Conversation and Work",
"empty": "There are no enabled non-system plugins."
},
"capabilityLoopSelector": {
"mcpHint": "MCP tools default to Work only. Expose a server to the conversation loop only when it is appropriate for chat-time use.",
"capability": "Capability",
"loop": "Available loop",
"conversation": "Conversation only",
"work": "Work only",
"both": "Conversation and Work",
"emptyMcp": "There are no enabled MCP servers."
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -1209,6 +1209,10 @@
"plugin_routes": {
"description": "插件工具循环分配",
"hint": "插件 LLM 工具默认仅在工作循环可用;可为每个已启用插件显式改为对话循环或两者。"
},
"mcp_routes": {
"description": "MCP 服务器循环分配",
"hint": "MCP 工具默认仅在工作循环可用;可为每个已启用服务器显式改为对话循环或两者。"
}
}
}
Expand Down
9 changes: 9 additions & 0 deletions dashboard/src/i18n/locales/zh-CN/features/config.json
Original file line number Diff line number Diff line change
Expand Up @@ -207,5 +207,14 @@
"work": "仅工作循环",
"both": "对话与工作循环",
"empty": "当前没有已启用的非系统插件。"
},
"capabilityLoopSelector": {
"mcpHint": "MCP 工具默认仅在工作循环可用。仅在确认服务器适合聊天调用时,才显式开放给对话循环。",
"capability": "能力",
"loop": "可用循环",
"conversation": "仅对话循环",
"work": "仅工作循环",
"both": "对话与工作循环",
"emptyMcp": "当前没有已启用的 MCP 服务器。"
}
}
53 changes: 53 additions & 0 deletions dashboard/tests/capabilityLoopSelector.vitest.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,53 @@
import { flushPromises } from '@vue/test-utils';
import { beforeEach, describe, expect, it, vi } from 'vitest';
import CapabilityLoopSelector from '@/components/shared/CapabilityLoopSelector.vue';
import { mountWithVuetify } from './utils/mountWithVuetify';

const testState = vi.hoisted(() => ({
mcpListMock: vi.fn(),
}));

vi.mock('@/api/v1', () => ({
mcpApi: {
list: testState.mcpListMock,
},
}));

describe('CapabilityLoopSelector', () => {
beforeEach(() => {
testState.mcpListMock.mockResolvedValue({
data: {
status: 'ok',
data: [
{ name: 'workspace-mcp', active: true },
{ name: 'disabled-mcp', active: false },
],
},
});
});

it('defaults MCP servers to work and preserves an explicit both override', async () => {
const wrapper = mountWithVuetify(CapabilityLoopSelector, {
props: {
kind: 'mcp',
modelValue: [],
},
});

await flushPromises();

expect(wrapper.text()).toContain('workspace-mcp');
expect(wrapper.text()).not.toContain('disabled-mcp');

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([
[[{ server_name: 'workspace-mcp', loop: 'both' }]],
]);
wrapper.unmount();
});
});
6 changes: 6 additions & 0 deletions docs/en/dev/astrbot-config.md
Original file line number Diff line number Diff line change
Expand Up @@ -205,6 +205,12 @@ When BTW is enabled in a configuration profile, **Config → BTW dual loops →

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.

## BTW MCP tool assignments

With BTW enabled, **MCP server loop assignments** selects conversation, work, or both for every enabled MCP server. All tools from that server share the assignment in the main Agent and subagent handoffs. Servers without an override default to work; selecting both saves an explicit override, and selecting work removes it. Disabling BTW preserves ordinary MCP tool availability.

Assignments are saved per configuration profile. They control tool visibility and do not replace MCP read/write authorization or the existing connection, private-network, and redirect restrictions.

## 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 @@ -207,6 +207,12 @@ API Key 属于敏感配置。不要把真实 `cmd_config.json`、截图、日志

主 Agent 与其子 Agent handoff 应用相同分配,并继续遵守 Persona、配置档与授权限制。循环分配不会授予工具执行权限。插件事件处理器和显式命令保留原有执行路径;此设置不会把整个插件转换为后台任务。

## BTW MCP 工具循环分配

启用 BTW 后,可通过 **MCP 服务器循环分配** 为每个已启用服务器选择对话循环、工作循环或两者。服务器的所有工具在主 Agent 和子 Agent handoff 中遵循同一分配。没有覆盖条目的服务器默认仅工作循环可用;选择两者会保存显式覆盖,重新选择工作循环会移除覆盖。关闭 BTW 后保留普通 MCP 工具可用性。

分配按配置档保存,只控制工具可见性,不替代 MCP 读写授权,也不改变现有连接、私网访问和重定向限制。

## 子代理、语音与知识库

- `subagent_orchestrator.main_enable`:启用 handoff。
Expand Down
Loading
Loading