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
19 changes: 18 additions & 1 deletion astrbot/core/astr_main_agent.py
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@
from typing import Any, TypeGuard, cast

from astrbot import logger
from astrbot.core.agent.btw.loop_routes import route_is_available_in_loop
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
Expand Down Expand Up @@ -560,6 +561,11 @@ def _append_skills_prompt(
plugin_context: CoreExecutionContext,
) -> SkillSnapshot:
runtime = str(cfg.get("computer_use_runtime", "none") or "none")
profile = plugin_context.get_config(umo=event.unified_msg_origin)
btw_config = profile.get("btw", {})
btw_config = btw_config if isinstance(btw_config, dict) else {}
btw_enabled = bool(btw_config.get("enabled", False))
loop_mode = "work" if event.get_extra("btw_loop") == "work" else "conversation"
skill_manager = plugin_context.skill_manager or SkillManager(
builtin_skill_catalog=plugin_context.catalogs.builtin_skills,
)
Expand All @@ -568,11 +574,22 @@ def _append_skills_prompt(
cfg,
plugin_context.catalogs.plugins,
)
if btw_enabled:
skills = [
skill
for skill in skills
if route_is_available_in_loop(
btw_config.get("skill_routes"),
route_key="skill_name",
route_id=skill.name,
loop_mode=loop_mode,
)
]
workspace_skills = (
skill_manager.list_workspace_skills(
_get_workspace_path_for_umo(event.unified_msg_origin)
)
if runtime == "local"
if runtime == "local" and (not btw_enabled or loop_mode == "work")
else []
)
if persona and persona.get("skills") 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 @@ -200,6 +200,7 @@
"work_session": {"max_age_seconds": 3600},
"plugin_routes": [],
"mcp_routes": [],
"skill_routes": [],
},
"provider_stt_settings": {
"enable": False,
Expand Down Expand Up @@ -4760,6 +4761,13 @@
"_special": "select_mcp_loop_routes",
"condition": {"btw.enabled": True},
},
"btw.skill_routes": {
"description": "Skills 循环分配",
"type": "list",
"hint": "普通 Skill 默认注入两个循环,可显式限制到单一循环;工作区 Skill 仅在本地工作循环可用。",
"_special": "select_skill_loop_routes",
"condition": {"btw.enabled": True},
},
},
}

Expand Down
51 changes: 41 additions & 10 deletions dashboard/src/components/shared/CapabilityLoopSelector.vue
Original file line number Diff line number Diff line change
Expand Up @@ -40,15 +40,16 @@

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

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

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

Expand All @@ -70,10 +71,26 @@ 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 routeKey = computed<RouteKey>(() =>
props.kind === 'mcp' ? 'server_name' : 'skill_name',
);
const defaultLoop = computed<LoopMode>(() =>
props.kind === 'mcp' ? 'work' : 'both',
);
const hint = computed(() =>
tm(
props.kind === 'mcp'
? 'capabilityLoopSelector.mcpHint'
: 'capabilityLoopSelector.skillHint',
),
);
const emptyMessage = computed(() =>
tm(
props.kind === 'mcp'
? 'capabilityLoopSelector.emptyMcp'
: 'capabilityLoopSelector.emptySkill',
),
);
const loopOptions = computed(() => [
{ title: tm('capabilityLoopSelector.conversation'), value: 'conversation' },
{ title: tm('capabilityLoopSelector.work'), value: 'work' },
Expand Down Expand Up @@ -117,7 +134,7 @@ function normalizeItems(value: unknown): CapabilityItem[] {
(item): item is Record<string, unknown> =>
item !== null && typeof item === 'object',
)
.filter((item) => item.active !== false)
.filter((item) => item.active !== false && item.plugin_active !== false)
.map((item) => {
const id = typeof item.name === 'string' ? item.name.trim() : '';
const description =
Expand All @@ -133,12 +150,26 @@ function normalizeItems(value: unknown): CapabilityItem[] {
);
}

function normalizeSkillsPayload(value: unknown): unknown[] {
if (value === null || typeof value !== 'object') return [];
const skills = (value as { skills?: unknown }).skills;
return Array.isArray(skills) ? skills : [];
}

async function loadCapabilities() {
loading.value = true;
try {
const response = await mcpApi.list();
if (props.kind === 'mcp') {
const response = await mcpApi.list();
capabilities.value =
response.data.status === 'ok' ? normalizeItems(response.data.data) : [];
return;
}

const response = await skillApi.list();
const skills = normalizeSkillsPayload(response.data.data);
capabilities.value =
response.data.status === 'ok' ? normalizeItems(response.data.data) : [];
response.data.status === 'ok' ? normalizeItems(skills) : [];
} catch {
capabilities.value = [];
} finally {
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 @@ -76,6 +76,13 @@
@update:model-value="emitUpdate"
/>
</template>
<template v-else-if="itemMeta?._special === 'select_skill_loop_routes'">
<CapabilityLoopSelector
kind="skill"
:model-value="modelValue"
@update:model-value="emitUpdate"
/>
</template>
<template v-else-if="itemMeta?._special === 't2i_template'">
<T2ITemplateEditor />
</template>
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -1219,6 +1219,10 @@
"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."
},
"skill_routes": {
"description": "Skill loop assignments",
"hint": "Skills default to both loops; explicitly restrict an enabled Skill to one loop when needed. Workspace Skills remain work-only with the local runtime."
}
}
}
Expand Down
4 changes: 3 additions & 1 deletion dashboard/src/i18n/locales/en-US/features/config.json
Original file line number Diff line number Diff line change
Expand Up @@ -215,6 +215,8 @@
"conversation": "Conversation only",
"work": "Work only",
"both": "Conversation and Work",
"emptyMcp": "There are no enabled MCP servers."
"emptyMcp": "There are no enabled MCP servers.",
"skillHint": "Skills default to both loops. Workspace Skills remain available only to the work loop. Workspace Skills remain work-only with the local runtime.",
"emptySkill": "There are no enabled Skills."
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -1213,6 +1213,10 @@
"mcp_routes": {
"description": "MCP 服务器循环分配",
"hint": "MCP 工具默认仅在工作循环可用;可为每个已启用服务器显式改为对话循环或两者。"
},
"skill_routes": {
"description": "Skills 循环分配",
"hint": "Skill 默认注入两个循环;可为每个已启用 Skill 显式限制到单一循环。 工作区 Skill 仅在本地工作循环可用。"
}
}
}
Expand Down
4 changes: 3 additions & 1 deletion dashboard/src/i18n/locales/zh-CN/features/config.json
Original file line number Diff line number Diff line change
Expand Up @@ -215,6 +215,8 @@
"conversation": "仅对话循环",
"work": "仅工作循环",
"both": "对话与工作循环",
"emptyMcp": "当前没有已启用的 MCP 服务器。"
"emptyMcp": "当前没有已启用的 MCP 服务器。",
"skillHint": "Skill 默认注入两个循环;工作区 Skill 仍仅在工作循环中可用。 工作区 Skill 仅在本地工作循环可用。",
"emptySkill": "当前没有已启用的 Skill。"
}
}
51 changes: 51 additions & 0 deletions dashboard/tests/capabilityLoopSelector.vitest.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,12 +5,16 @@ import { mountWithVuetify } from './utils/mountWithVuetify';

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

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

describe('CapabilityLoopSelector', () => {
Expand All @@ -24,6 +28,22 @@ describe('CapabilityLoopSelector', () => {
],
},
});
testState.skillListMock.mockResolvedValue({
data: {
status: 'ok',
data: {
skills: [
{ name: 'workspace-skill', active: true },
{ name: 'disabled-skill', active: false },
{
name: 'disabled-plugin-skill',
active: true,
plugin_active: false,
},
],
},
},
});
});

it('defaults MCP servers to work and preserves an explicit both override', async () => {
Expand All @@ -50,4 +70,35 @@ describe('CapabilityLoopSelector', () => {
]);
wrapper.unmount();
});

it('uses Skill names as the assignment key', async () => {
const wrapper = mountWithVuetify(CapabilityLoopSelector, {
props: {
kind: 'skill',
modelValue: [],
},
});

await flushPromises();

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

const select = wrapper.findComponent({ name: 'VSelect' });
expect(select.props('modelValue')).toBe('both');
select.vm.$emit('update:modelValue', 'conversation');
await wrapper.vm.$nextTick();

expect(wrapper.emitted('update:modelValue')).toEqual([
[[{ skill_name: 'workspace-skill', loop: 'conversation' }]],
]);
await wrapper.setProps({
modelValue: [{ skill_name: 'workspace-skill', loop: 'conversation' }],
});
select.vm.$emit('update:modelValue', 'both');
await wrapper.vm.$nextTick();
expect(wrapper.emitted('update:modelValue')?.at(-1)).toEqual([[]]);
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 @@ -211,6 +211,12 @@ With BTW enabled, **MCP server loop assignments** selects conversation, work, or

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.

## BTW Skill visibility

With BTW enabled, **Skill loop assignments** chooses conversation, work, or both for each enabled ordinary Skill. Ordinary Skills default to both loops; choosing one loop saves an override, and choosing both removes it. Workspace Skills are available only to the work loop with the `local` runtime. Disabling BTW preserves the standard Skill selection path.

Loop assignments narrow the enabled Skills before the request's Skill snapshot is frozen. The prompt, `read_skill`, and Skill-declared tool candidates therefore use the same selection. Persona and plugin restrictions still apply, including an empty Persona Skill list. A loop assignment never grants execution permission: `read_skill` can read permitted Skill manuals when Computer Use is `none`, while Shell and Python remain unavailable.

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

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

## BTW Skill 循环可见性

启用 BTW 后,可通过 **Skills 循环分配** 为每个已启用的普通 Skill 选择对话循环、工作循环或两者。普通 Skill 默认在两个循环可见;选择单一循环会保存覆盖,重新选择两者会移除覆盖。工作区 Skill 仅在使用 `local` 运行时的工作循环中可用。关闭 BTW 后保留标准 Skill 选择路径。

循环分配在请求 Skill 快照冻结之前筛选已启用的 Skill,因此提示词、`read_skill` 和 Skill 声明的候选工具使用同一选择结果。Persona 与插件限制继续生效,包括 Persona 的空 Skill 列表。循环分配不会授予执行权限:Computer Use 为 `none` 时,`read_skill` 仍可读取允许的 Skill 手册,但 Shell 和 Python 仍不可用。

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

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