feat(agent): explicit "no skills / no tools" opt-out flags (#184)

This commit is contained in:
matevip 2026-05-26 22:07:12 +08:00
parent ff2620dfcf
commit 0ac325a337
10 changed files with 680 additions and 58 deletions

View File

@ -52,8 +52,12 @@ public class AgentBindingController {
verifyAgentWorkspace(agentId, workspaceId);
bindingService.setSkillBindings(agentId, skillIds);
agentService.invalidateAgentCache(agentId);
// The Vue client always sends an array, but a non-Vue caller (curl /
// SDK) can POST a body of just `null`, which Spring binds to a null
// list. The service tolerates that guard the audit message too.
int count = skillIds == null ? 0 : skillIds.size();
auditEventService.record("UPDATE", "AGENT_SKILL", String.valueOf(agentId),
"skills=" + skillIds.size(), null);
"skills=" + count, null);
return R.ok();
}
@ -98,8 +102,10 @@ public class AgentBindingController {
verifyAgentWorkspace(agentId, workspaceId);
bindingService.setToolBindings(agentId, toolNames);
agentService.invalidateAgentCache(agentId);
// Same null-safety rationale as setSkills above.
int count = toolNames == null ? 0 : toolNames.size();
auditEventService.record("UPDATE", "AGENT_TOOL", String.valueOf(agentId),
"tools=" + toolNames.size(), null);
"tools=" + count, null);
return R.ok();
}

View File

@ -118,14 +118,33 @@ public class AgentBindingService implements AgentBindingResolver {
}
/**
* 获取 Agent 绑定的 enabled skill ID 集合
* 返回 null 表示该 agent 没有自定义绑定使用全局默认
* Effective bound skill IDs for the agent. Three return states:
*
* <ul>
* <li>{@code null} no binding rows exist and the agent has not opted
* out of skills. Caller treats this as "no agent-level restriction;
* inherit every globally-enabled skill" (legacy default).</li>
* <li>{@code Set.of()} either {@code skills_disabled=true} on the agent,
* or binding rows exist but none are {@code enabled=true}. Caller
* treats this as "this agent is explicitly scoped to zero skills"
* no SKILL.md catalog injection, no skill-expanded tools.</li>
* <li>non-empty set the explicit allowlist.</li>
* </ul>
*
* <p>The {@code skills_disabled} flag takes precedence over row count, so
* a stale (disabled flag + leftover rows) row combination still surfaces
* as "no skills". The {@code setSkillBindings} / {@code bindSkill} writers
* keep these in sync by auto-clearing the flag when a non-empty row set is
* persisted.
*/
@Override
public Set<Long> getBoundSkillIds(Long agentId) {
if (isSkillsDisabled(agentId)) {
return Set.of();
}
List<AgentSkillBinding> bindings = listSkillBindings(agentId);
if (bindings.isEmpty()) {
return null; // 无绑定 全局默认
return null; // no rows inherit global default
}
return bindings.stream()
.filter(b -> Boolean.TRUE.equals(b.getEnabled()))
@ -135,7 +154,11 @@ public class AgentBindingService implements AgentBindingResolver {
public AgentSkillBinding bindSkill(Long agentId, Long skillId) {
requireSameWorkspace(agentId, skillId);
// 检查是否已绑定
// Adding any skill binding is a concrete commitment the operator
// wants this skill on the agent, which contradicts an opt-out flag.
// Clear the flag here so the data layer never holds a
// "skills_disabled=true + binding rows" contradiction.
clearSkillsDisabledFlag(agentId);
AgentSkillBinding existing = skillBindingMapper.selectOne(
new LambdaQueryWrapper<AgentSkillBinding>()
.eq(AgentSkillBinding::getAgentId, agentId)
@ -161,7 +184,14 @@ public class AgentBindingService implements AgentBindingResolver {
}
/**
* 批量设置 Agent skill 绑定替换模式
* Replace the agent's skill binding set.
*
* <p>Side effect: when {@code skillIds} contains at least one entry,
* the {@code skills_disabled} flag on the agent is auto-cleared. A
* non-empty save is a concrete commitment to those skills, so the
* data layer never holds a {@code disabled=true} + binding rows
* contradiction. An empty / null save does <strong>not</strong>
* touch the flag the caller (UI toggle) owns that bit.
*/
public void setSkillBindings(Long agentId, List<Long> skillIds) {
// Validate every incoming skill BEFORE touching the binding rows;
@ -173,11 +203,17 @@ public class AgentBindingService implements AgentBindingResolver {
requireSameWorkspace(agentId, skillId);
}
}
// 删除旧绑定
// Auto-clear the flag only when an explicit non-empty binding is
// being committed. An empty save is ambiguous the UI may be
// either "uncheck everything" (keep flag as-is so the toggle
// remains the source of truth) or just "no rows" (legacy). We let
// the writer of skills_disabled (typically the agent PUT) own that.
if (skillIds != null && !skillIds.isEmpty()) {
clearSkillsDisabledFlag(agentId);
}
skillBindingMapper.delete(
new LambdaQueryWrapper<AgentSkillBinding>()
.eq(AgentSkillBinding::getAgentId, agentId));
// 创建新绑定
if (skillIds != null) {
for (Long skillId : skillIds) {
AgentSkillBinding binding = new AgentSkillBinding();
@ -374,13 +410,26 @@ public class AgentBindingService implements AgentBindingResolver {
}
/**
* 获取 Agent 绑定的 enabled tool name 集合
* 返回 null 表示该 agent 没有自定义绑定使用全局默认
* Effective bound tool names for the agent. Mirrors the three-state
* contract of {@link #getBoundSkillIds}:
*
* <ul>
* <li>{@code null} no binding rows and {@code tools_disabled=false}.
* Caller defers to the global default tool set.</li>
* <li>{@code Set.of()} {@code tools_disabled=true}, or all rows are
* {@code enabled=false}. The agent is explicitly scoped to no
* user-pickable tools (system-level memory primitives still flow
* through {@link #getEffectiveToolNames}).</li>
* <li>non-empty set the explicit allowlist.</li>
* </ul>
*/
public Set<String> getBoundToolNames(Long agentId) {
if (isToolsDisabled(agentId)) {
return Set.of();
}
List<AgentToolBinding> bindings = listToolBindings(agentId);
if (bindings.isEmpty()) {
return null; // 无绑定 全局默认
return null; // no rows inherit global default
}
return bindings.stream()
.filter(b -> Boolean.TRUE.equals(b.getEnabled()))
@ -433,26 +482,43 @@ public class AgentBindingService implements AgentBindingResolver {
* </ul>
*/
public Set<String> getEffectiveToolNames(Long agentId) {
AgentEntity agent = agentMapper.selectById(agentId);
boolean skillsDisabled = agent != null && Boolean.TRUE.equals(agent.getSkillsDisabled());
boolean toolsDisabled = agent != null && Boolean.TRUE.equals(agent.getToolsDisabled());
Set<Long> boundSkillIds = getBoundSkillIds(agentId);
Set<String> directTools = getBoundToolNames(agentId);
// (1) null + null no restriction; defer to the global default.
// Four-state matrix see issue #184.
//
// (1) Pure legacy: no flags, no rows on either side defer to global
// default (returns null). Agents created before V126 must remain
// bit-identical to their previous runtime contract.
if (boundSkillIds == null && directTools == null) {
return null;
}
// (2) Skills-only opt-out with no explicit tool restriction still
// defer tools to the global default. Without this carve-out, a
// user who only said "no skills" would silently lose every
// non-MCP global tool because the merge branch only emits the
// SYSTEM_LEVEL set. The SKILL.md catalog itself is still
// suppressed via getBoundSkillIds returning Set.of().
if (skillsDisabled && !toolsDisabled && directTools == null) {
return null;
}
Set<String> merged = new LinkedHashSet<>();
if (boundSkillIds != null) {
if (boundSkillIds != null && !boundSkillIds.isEmpty()) {
for (Long skillId : boundSkillIds) {
ResolvedSkill resolved = findResolvedSkillById(skillId);
if (resolved == null) continue;
if (!vip.mate.skill.runtime.SkillRuntimeService.passesActiveGate(resolved)) {
// §14.2 fix: a disabled / security-blocked / setup-needed
// skill must not contribute tools to the LLM
// advertisement even if it's still bound. Without this
// guard, users see ghost tools for skills they thought
// were off.
// A disabled / security-blocked / setup-needed skill must
// not contribute tools to the LLM advertisement even if
// it's still bound otherwise the user sees ghost tools
// for skills they thought were off.
continue;
}
Set<String> skillTools = resolved.getEffectiveAllowedTools();
@ -461,32 +527,36 @@ public class AgentBindingService implements AgentBindingResolver {
}
if (directTools != null) {
// Advanced 直选的原子 tool§9.2 调整 B
merged.addAll(directTools);
}
// System-level tools that don't belong to any single skill but
// are agent-wide capabilities. Without this carve-out, binding
// any skill silently strips record_lesson / remember / structured-
// memory tools, breaking the §11 self-evolution loop entirely
// (the LLM stops being able to write to LESSONS.md / MEMORY.md).
// are agent-wide capabilities structured memory primitives,
// workspace memory CRUD, etc. Without this carve-out, binding any
// skill silently strips record_lesson / remember / *memory_file
// tools, breaking the self-evolution loop. These survive even
// toolsDisabled=true because they are agent-internal infrastructure,
// unrelated to the user-facing capability picker.
merged.addAll(SYSTEM_LEVEL_TOOLS);
// MCP tools. An agent that bound only a skill or a built-in tool
// and ticked no MCP row keeps full access to every enabled MCP
// tool: MCP servers are an administrator-enabled capability and
// must not silently vanish just because some unrelated binding
// exists. But once the operator ticks specific MCP rows, that is a
// deliberate per-agent scope only those MCP tools (already merged
// via directTools above) stay, and the rest are not auto-joined, so
// a role can be limited to a fixed MCP tool set. To instead hide a
// single MCP tool from an agent that ticked no MCP row, use the
// tool-guard deny path applied upstream in AgentGraphBuilder.
Set<String> enabledMcpTools = getEnabledMcpToolNames();
boolean agentScopedMcpExplicitly =
directTools != null && !Collections.disjoint(directTools, enabledMcpTools);
if (!agentScopedMcpExplicitly) {
merged.addAll(enabledMcpTools);
// and ticked no MCP row normally keeps full access to every enabled
// MCP tool (administrator-level capabilities should not silently
// vanish just because some unrelated binding exists). Two cases
// suppress the auto-include:
// - tools_disabled=true the user explicitly opted out of every
// non-system tool. Auto-joining MCP would defeat that intent.
// - The agent ticked at least one MCP tool itself that signals a
// deliberate per-agent MCP scope; only the ticked subset stays.
// To deny a single MCP tool when none are ticked and tools are
// enabled, use the tool-guard deny path in AgentGraphBuilder.
if (!toolsDisabled) {
Set<String> enabledMcpTools = getEnabledMcpToolNames();
boolean agentScopedMcpExplicitly = directTools != null && !directTools.isEmpty()
&& !Collections.disjoint(directTools, enabledMcpTools);
if (!agentScopedMcpExplicitly) {
merged.addAll(enabledMcpTools);
}
}
return merged;
@ -669,6 +739,9 @@ public class AgentBindingService implements AgentBindingResolver {
}
public AgentToolBinding bindTool(Long agentId, String toolName) {
// Mirror of bindSkill: writing any tool row clears the opt-out flag
// so the binding state cannot contradict the agent-level toggle.
clearToolsDisabledFlag(agentId);
AgentToolBinding existing = toolBindingMapper.selectOne(
new LambdaQueryWrapper<AgentToolBinding>()
.eq(AgentToolBinding::getAgentId, agentId)
@ -715,6 +788,12 @@ public class AgentBindingService implements AgentBindingResolver {
public void setToolBindings(Long agentId, List<String> toolNames) {
validateNewToolBindings(agentId, toolNames);
// Side effect parallel to setSkillBindings: a non-empty save is an
// explicit commitment to those tools, so the opt-out flag is
// auto-cleared. Empty saves leave the flag untouched.
if (toolNames != null && !toolNames.isEmpty()) {
clearToolsDisabledFlag(agentId);
}
toolBindingMapper.delete(
new LambdaQueryWrapper<AgentToolBinding>()
.eq(AgentToolBinding::getAgentId, agentId));
@ -827,4 +906,57 @@ public class AgentBindingService implements AgentBindingResolver {
providerPreferenceMapper.insert(row);
}
}
// ==================== Binding-mode flags (V126) ====================
/**
* Read-side check for the agent's "skills opted out entirely" toggle.
* Returns {@code false} when the agent row is missing a missing agent
* has no opinion, so binding queries fall through to the legacy
* row-count path (which will surface the missing-agent issue at a more
* useful layer than a binding read).
*/
private boolean isSkillsDisabled(Long agentId) {
if (agentId == null) return false;
AgentEntity agent = agentMapper.selectById(agentId);
return agent != null && Boolean.TRUE.equals(agent.getSkillsDisabled());
}
/** Mirror of {@link #isSkillsDisabled} for the tools opt-out toggle. */
private boolean isToolsDisabled(Long agentId) {
if (agentId == null) return false;
AgentEntity agent = agentMapper.selectById(agentId);
return agent != null && Boolean.TRUE.equals(agent.getToolsDisabled());
}
/**
* Flip {@code skills_disabled} back to false on the agent row. No-op
* when already false or the agent doesn't exist. Used as an auto-clear
* step in {@link #bindSkill} / {@link #setSkillBindings} so writing a
* concrete binding always wins over a stale opt-out flag.
*/
private void clearSkillsDisabledFlag(Long agentId) {
if (agentId == null) return;
AgentEntity agent = agentMapper.selectById(agentId);
if (agent == null || !Boolean.TRUE.equals(agent.getSkillsDisabled())) {
return;
}
AgentEntity update = new AgentEntity();
update.setId(agentId);
update.setSkillsDisabled(false);
agentMapper.updateById(update);
}
/** Mirror of {@link #clearSkillsDisabledFlag} for the tools toggle. */
private void clearToolsDisabledFlag(Long agentId) {
if (agentId == null) return;
AgentEntity agent = agentMapper.selectById(agentId);
if (agent == null || !Boolean.TRUE.equals(agent.getToolsDisabled())) {
return;
}
AgentEntity update = new AgentEntity();
update.setId(agentId);
update.setToolsDisabled(false);
agentMapper.updateById(update);
}
}

View File

@ -86,6 +86,42 @@ public class AgentEntity {
@TableField(value = "workspace_base_path", updateStrategy = FieldStrategy.ALWAYS)
private String workspaceBasePath;
/**
* Explicit opt-out from every skill. When {@code true}, the binding service
* returns {@link java.util.Collections#emptySet()} from
* {@code getBoundSkillIds}, which (a) suppresses every {@code SKILL.md}
* catalog entry from the system prompt and (b) drops skill-expanded tools
* out of the effective tool set.
*
* <p>Default {@code false} preserves the legacy "zero rows = inherit global
* default" behaviour for every legacy agent. The flag is auto-cleared when
* a non-empty skill binding is written, so the data layer never holds a
* "{@code disabled=true} + binding rows" contradiction.
*
* <p>Default {@code NOT_NULL} update strategy is deliberate: a frontend
* PUT that explicitly carries {@code true} or {@code false} writes through
* (both are non-null Boolean), while a sparse partial update (e.g. the
* auto-clear helper that constructs a one-field entity) won't emit the
* other flag's column as a stray {@code SET ... = NULL} that would
* collide with the {@code NOT NULL} DDL.
*/
@TableField(value = "skills_disabled")
private Boolean skillsDisabled;
/**
* Explicit opt-out from every non-system-level tool. When {@code true},
* {@code getBoundToolNames} returns {@link java.util.Collections#emptySet()}
* and the MCP auto-include in {@code getEffectiveToolNames} is suppressed;
* the structured-memory primitives (record_lesson / remember / workspace
* memory CRUD) still pass through because they are agent-internal
* capabilities unrelated to the user-facing capability picker.
*
* <p>Same defaulting / auto-clear / update strategy contract as
* {@link #skillsDisabled}.
*/
@TableField(value = "tools_disabled")
private Boolean toolsDisabled;
@TableField(fill = FieldFill.INSERT)
private LocalDateTime createTime;

View File

@ -0,0 +1,12 @@
-- V126: Two binding-mode flags on mate_agent.
--
-- skills_disabled / tools_disabled flip the "zero binding rows" semantic from
-- "inherit every globally-enabled capability" to "this agent has explicitly
-- opted out". Without these columns, an operator who wanted an agent with no
-- skills had to bind a dummy skill — otherwise the runtime fell back to the
-- global default and every skill's catalog entry got injected into the system
-- prompt (issue #184).
--
-- Both default to FALSE so legacy agents are bit-identical to pre-V126 behavior.
ALTER TABLE mate_agent ADD COLUMN IF NOT EXISTS skills_disabled BOOLEAN NOT NULL DEFAULT FALSE;
ALTER TABLE mate_agent ADD COLUMN IF NOT EXISTS tools_disabled BOOLEAN NOT NULL DEFAULT FALSE;

View File

@ -0,0 +1,33 @@
-- V126: Two binding-mode flags on mate_agent (MySQL).
--
-- skills_disabled / tools_disabled flip the "zero binding rows" semantic from
-- "inherit every globally-enabled capability" to "this agent has explicitly
-- opted out". Without these columns, an operator who wanted an agent with no
-- skills had to bind a dummy skill — otherwise the runtime fell back to the
-- global default and every skill's catalog entry got injected into the system
-- prompt (issue #184).
--
-- Idempotent: INFORMATION_SCHEMA guard for each column, since MySQL does not
-- support `ADD COLUMN IF NOT EXISTS`.
SET @col_exists := (
SELECT COUNT(*) FROM INFORMATION_SCHEMA.COLUMNS
WHERE TABLE_SCHEMA = DATABASE()
AND TABLE_NAME = 'mate_agent'
AND COLUMN_NAME = 'skills_disabled'
);
SET @stmt := IF(@col_exists = 0,
'ALTER TABLE mate_agent ADD COLUMN skills_disabled TINYINT(1) NOT NULL DEFAULT 0',
'SELECT 1');
PREPARE s FROM @stmt; EXECUTE s; DEALLOCATE PREPARE s;
SET @col_exists := (
SELECT COUNT(*) FROM INFORMATION_SCHEMA.COLUMNS
WHERE TABLE_SCHEMA = DATABASE()
AND TABLE_NAME = 'mate_agent'
AND COLUMN_NAME = 'tools_disabled'
);
SET @stmt := IF(@col_exists = 0,
'ALTER TABLE mate_agent ADD COLUMN tools_disabled TINYINT(1) NOT NULL DEFAULT 0',
'SELECT 1');
PREPARE s FROM @stmt; EXECUTE s; DEALLOCATE PREPARE s;

View File

@ -431,4 +431,218 @@ class AgentBindingServiceTest {
assertNotNull(count);
assertEquals(0, count, "unbind 应该物理删除,而不是软删(软删会留 deleted=1 行,占用唯一索引槽位导致 rebind 失败)");
}
// ==================== V126 binding-mode flags (issue #184) ====================
/**
* Flip the {@code skills_disabled} column on the seeded agent row.
* Tests need a direct lever because {@link AgentBindingService} only
* exposes the auto-clear side; setting the flag is the controller's job.
*/
private void setSkillsDisabledFlag(boolean value) {
jdbcTemplate.update(
"UPDATE mate_agent SET skills_disabled = ? WHERE id = ?",
value, agentId);
}
/** Mirror of {@link #setSkillsDisabledFlag} for the tools toggle. */
private void setToolsDisabledFlag(boolean value) {
jdbcTemplate.update(
"UPDATE mate_agent SET tools_disabled = ? WHERE id = ?",
value, agentId);
}
/** Boolean column readback so the auto-clear assertions don't lie. */
private boolean readSkillsDisabledFlag() {
Boolean v = jdbcTemplate.queryForObject(
"SELECT skills_disabled FROM mate_agent WHERE id = ?",
Boolean.class, agentId);
return Boolean.TRUE.equals(v);
}
private boolean readToolsDisabledFlag() {
Boolean v = jdbcTemplate.queryForObject(
"SELECT tools_disabled FROM mate_agent WHERE id = ?",
Boolean.class, agentId);
return Boolean.TRUE.equals(v);
}
@Test
@DisplayName("issue #184: skills_disabled=true → getBoundSkillIds 返回 emptySet不是 null")
void getBoundSkillIdsReturnsEmptyWhenSkillsDisabled() {
// No binding rows at all + flag on. Pre-V126 contract returned null
// (= inherit global default); the new flag flips the read to an
// explicit "no skills" so SKILL.md catalog injection stays off.
setSkillsDisabledFlag(true);
Set<Long> result = bindingService.getBoundSkillIds(agentId);
assertNotNull(result, "skills_disabled=true 时绝不能返回 null —— 否则下游会把它当作 'inherit global default'");
assertTrue(result.isEmpty(), "应当是显式的空 set");
}
@Test
@DisplayName("issue #184: tools_disabled=true → getBoundToolNames 返回 emptySet不是 null")
void getBoundToolNamesReturnsEmptyWhenToolsDisabled() {
setToolsDisabledFlag(true);
Set<String> result = bindingService.getBoundToolNames(agentId);
assertNotNull(result, "tools_disabled=true 时绝不能返回 null");
assertTrue(result.isEmpty(), "应当是显式的空 set");
}
@Test
@DisplayName("issue #184 matrix (T,F,*,0): skillsDisabled 但无 tool 绑定 → effective 返回 null工具继承全局默认")
void skillsDisabledNoToolBindingsStillInheritsDefaultTools() {
// This is the critical case the design review caught: silently
// returning {SYSTEM + MCP} here would strip every non-MCP global
// built-in tool just because the user said "no skills". The fix
// returns null so AgentToolSet.withAllowedToolsOnly(null) no
// restriction global default tools flow through.
setSkillsDisabledFlag(true);
Set<String> effective = bindingService.getEffectiveToolNames(agentId);
assertNull(effective,
"skillsDisabled=true 但用户没主动限制工具时effective 必须返回 null —— "
+ "否则非 MCP 的全局工具会被悄悄收窄掉");
}
@Test
@DisplayName("issue #184 matrix (T,F,*,>0): skillsDisabled + 显式 tool 绑定 → 仅这些 tool + SYSTEM + MCP-rule")
void skillsDisabledWithToolBindingsScopesToTools() {
seedBuiltinTool("scoped_tool_probe");
setSkillsDisabledFlag(true);
bindingService.setToolBindings(agentId, List.of("scoped_tool_probe"));
// Auto-clear is opt-in: we want to verify the matrix when both states
// coexist transiently (i.e. a client wrote tool bindings without
// touching the flag through the UI). bindSkill/setSkillBindings only
// clears its own flag; setToolBindings clears tools_disabled, not
// skills_disabled so skills_disabled survives here.
Set<String> effective = bindingService.getEffectiveToolNames(agentId);
assertNotNull(effective, "存在显式 tool 绑定时不能返回 null");
assertTrue(effective.contains("scoped_tool_probe"), "用户勾选的工具必须在 allowlist");
assertTrue(effective.contains("record_lesson"), "system-level 内核工具必须保留");
assertFalse(effective.isEmpty());
}
@Test
@DisplayName("issue #184 matrix (F,T,0,*): toolsDisabled 无 skill 绑定 → 仅 system-level不并入 MCP不继承默认")
void toolsDisabledReturnsSystemOnlyAndSkipsMcp() {
seedMcpServerWithOneTool(8_888_201L, "issue184-mcp", "leaked_probe");
setToolsDisabledFlag(true);
Set<String> effective = bindingService.getEffectiveToolNames(agentId);
assertNotNull(effective, "toolsDisabled=true 时绝不能返回 null那会让全局默认工具又流回来");
assertTrue(effective.contains("record_lesson"), "system-level memory 工具必须保留");
boolean hasMcp = effective.stream().anyMatch(n -> n != null && n.startsWith("mcp_"));
assertFalse(hasMcp,
"toolsDisabled=true 时 enabled MCP 工具绝不能自动并入 —— 否则用户的 '禁用所有工具' 意图被违背。"
+ "实际 allowlist: " + effective);
}
@Test
@DisplayName("issue #184 matrix (F,T,>0,*): toolsDisabled + skill 绑定 → skill 扩展 + SYSTEM不并入 MCP")
void toolsDisabledKeepsSkillExpansionButSkipsMcp() {
long skillId = 7_777_801L;
seedSkill(skillId);
bindingService.bindSkill(agentId, skillId);
seedMcpServerWithOneTool(8_888_202L, "issue184-mcp-b", "mcp_should_be_hidden");
setToolsDisabledFlag(true);
Set<String> effective = bindingService.getEffectiveToolNames(agentId);
assertNotNull(effective);
// Skill expansion is contingent on the resolved manifest declaring
// allowed_tools, which test fixtures don't seed; the contract we
// verify here is the MCP suppression + SYSTEM survival. (Skill
// expansion correctness is exercised in other tests / by the runtime.)
assertTrue(effective.contains("record_lesson"), "system-level 必须保留");
boolean hasMcp = effective.stream().anyMatch(n -> n != null && n.startsWith("mcp_"));
assertFalse(hasMcp, "toolsDisabled=true 即使有 skill 绑定也不能自动并入 MCP");
}
@Test
@DisplayName("issue #184 matrix (T,T,*,*): 两 flag 都开 → 仅 system-level无 MCP无默认")
void bothDisabledReturnsSystemOnly() {
seedMcpServerWithOneTool(8_888_203L, "issue184-mcp-c", "should_be_hidden");
setSkillsDisabledFlag(true);
setToolsDisabledFlag(true);
Set<String> effective = bindingService.getEffectiveToolNames(agentId);
assertNotNull(effective);
assertTrue(effective.contains("record_lesson"), "system-level 必须保留");
boolean hasMcp = effective.stream().anyMatch(n -> n != null && n.startsWith("mcp_"));
assertFalse(hasMcp, "两 flag 全开时 MCP 必须完全隐藏");
// Sanity: the set should be roughly the SYSTEM_LEVEL_TOOLS list
// we don't enforce equality (the constant evolves) but it should be
// substantially smaller than the catalog of every enabled tool.
assertTrue(effective.size() < 100,
"两 flag 全开时返回的应该只是 system-level 内核工具,体积明显小于完整默认集。"
+ "实际大小: " + effective.size());
}
@Test
@DisplayName("issue #184: setSkillBindings 非空保存自动清掉 skills_disabled数据层不留矛盾态")
void setSkillBindingsNonEmptyAutoClearsSkillsDisabledFlag() {
long skillId = 7_777_802L;
seedSkill(skillId);
setSkillsDisabledFlag(true);
assertTrue(readSkillsDisabledFlag(), "前置flag 应为 true");
bindingService.setSkillBindings(agentId, List.of(skillId));
assertFalse(readSkillsDisabledFlag(),
"写入非空 skill 绑定应当自动清掉 skills_disabled —— "
+ "否则 DB 会出现 'disabled=true + 有绑定行' 的矛盾态");
}
@Test
@DisplayName("issue #184: setSkillBindings 空保存不动 flagtoggle 自己拥有该位)")
void setSkillBindingsEmptySaveDoesNotTouchFlag() {
// Empty save is ambiguous: it might be "uncheck everything" from the
// UI that owns skills_disabled separately, or just "no rows". Letting
// the writer of the flag own it (agent PUT) keeps the toggle the
// single source of truth.
setSkillsDisabledFlag(true);
bindingService.setSkillBindings(agentId, List.of());
assertTrue(readSkillsDisabledFlag(),
"空保存不应清掉 flag —— 否则 UI 的'禁用所有技能' toggle 在用户'取消所有勾选'后会被悄悄翻掉");
}
@Test
@DisplayName("issue #184: bindSkill 单次绑定自动清掉 skills_disabled")
void bindSkillSingleAutoClearsSkillsDisabledFlag() {
long skillId = 7_777_803L;
seedSkill(skillId);
setSkillsDisabledFlag(true);
bindingService.bindSkill(agentId, skillId);
assertFalse(readSkillsDisabledFlag(),
"单条 bindSkill 也算明确的承诺,应当自动清掉 flag");
}
@Test
@DisplayName("issue #184: setToolBindings 非空保存自动清掉 tools_disabled")
void setToolBindingsNonEmptyAutoClearsToolsDisabledFlag() {
seedBuiltinTool("autoclear_probe");
setToolsDisabledFlag(true);
assertTrue(readToolsDisabledFlag(), "前置flag 应为 true");
bindingService.setToolBindings(agentId, List.of("autoclear_probe"));
assertFalse(readToolsDisabledFlag(),
"写入非空 tool 绑定应当自动清掉 tools_disabled");
}
@Test
@DisplayName("issue #184: bindTool 单次绑定自动清掉 tools_disabled")
void bindToolSingleAutoClearsToolsDisabledFlag() {
setToolsDisabledFlag(true);
bindingService.bindTool(agentId, "autoclear_single_probe");
assertFalse(readToolsDisabledFlag(),
"单条 bindTool 也算明确的承诺,应当自动清掉 flag");
}
}

View File

@ -1219,9 +1219,15 @@ export default {
skillsKicker: 'Trained workflows',
skillsTagline: 'A skill is a workflow — a step-by-step playbook the LLM follows for a coherent multi-step task.',
skillsHint: 'Select skills this agent can use. Leave empty to use all enabled skills.',
disableAllSkills: 'This agent uses no skills',
disableAllSkillsHint: 'Saving with this on clears the agent\'s skill bindings and marks it as "explicitly no skills": the LLM loads no SKILL.md catalog entries and skill-expanded tools do not enter the context. System-level primitives (memory, delegation, etc.) are unaffected. When off, picking nothing still falls back to "inherit global default".',
disableAllSkillsBadge: 'Off',
toolsKicker: 'Atomic tools the agent can call',
toolsTagline: 'A tool is one call, one thing. The LLM decides when to invoke each tool autonomously.',
toolsHint: 'Select tools this agent can use. Leave empty to use all enabled tools.',
disableAllTools: 'This agent uses no user-pickable tools',
disableAllToolsHint: 'Saving with this on clears the agent\'s tool bindings and marks it as "explicitly no tools": neither user-pickable tools nor any enabled MCP tools enter the allowlist. System-level primitives (structured memory, workspace memory files, delegation, etc.) remain available so the agent can still operate. When off, picking nothing still falls back to "inherit global default".',
disableAllToolsBadge: 'Off',
searchSkills: 'Search skill name, description, or version',
searchTools: 'Search tool name, description, source, or group',
advancedToolsTitle: 'Advanced: Hand-picked atomic tools',

View File

@ -1111,9 +1111,15 @@ export default {
skillsKicker: '受过培训的工作流程',
skillsTagline: '技能 = 一段流程一份工作手册。LLM 按手册执行一系列连贯动作。',
skillsHint: '选择此智能体可使用的技能。留空则使用所有已启用的技能。',
disableAllSkills: '此智能体不使用任何技能',
disableAllSkillsHint: '开启后保存会清空该智能体的技能绑定并标记为「显式无技能」LLM 不再加载任何 SKILL.md 目录,技能扩展的工具也不会进入上下文。系统级内核工具(记忆、委派等)不受影响。关闭后,未勾选任何技能仍按「继承全局默认」处理。',
disableAllSkillsBadge: '已禁用',
toolsKicker: '会用的原子工具',
toolsTagline: '工具 = 一次调用,做一件事。由 LLM 自主决定何时调用。',
toolsHint: '选择此智能体可使用的工具。留空则使用所有已启用的工具。',
disableAllTools: '此智能体不使用任何用户可选工具',
disableAllToolsHint: '开启后保存会清空该智能体的工具绑定,并标记为「显式无工具」:用户可选工具与已启用的 MCP 工具都不会进入 allowlist。系统级内核工具结构化记忆、工作区记忆文件、委派等仍保留以保证基本运行。关闭后未勾选任何工具仍按「继承全局默认」处理。',
disableAllToolsBadge: '已禁用',
searchSkills: '搜索技能名称、描述或版本',
searchTools: '搜索工具名称、描述、来源或分组',
advancedToolsTitle: '高级:手选原子工具',

View File

@ -44,6 +44,19 @@ export interface Agent {
icon?: string
tags?: string
workspaceBasePath?: string
/**
* Explicit opt-out: drop every SKILL.md catalog entry from the system
* prompt and exclude skill-expanded tools. Independent of binding rows
* (when `true`, the agent is treated as "no skills" regardless of any
* leftover `mate_agent_skill` rows). Defaults to `false`.
*/
skillsDisabled?: boolean
/**
* Explicit opt-out: exclude every non-system-level tool from the agent's
* effective set and suppress MCP auto-include. System-level memory and
* delegation primitives still pass through. Defaults to `false`.
*/
toolsDisabled?: boolean
createTime?: string
updateTime?: string
}

View File

@ -209,11 +209,17 @@
</button>
<button v-if="editingAgent" class="modal-tab" :class="{ active: modalTab === 'skills' }" @click="modalTab = 'skills'">
{{ t('agents.tabs.skills', 'Skills') }}
<span v-if="selectedSkillIds.length" class="tab-badge">{{ selectedSkillIds.length }}</span>
<!-- Issue #184: when the disable flag is on, suppress the
stale-pick count badge and show an "off" state instead
the count would otherwise contradict the disable toggle
visible in the tab content. -->
<span v-if="form.skillsDisabled" class="tab-badge tab-badge--off">{{ t('agents.binding.disableAllSkillsBadge') }}</span>
<span v-else-if="selectedSkillIds.length" class="tab-badge">{{ selectedSkillIds.length }}</span>
</button>
<button v-if="editingAgent" class="modal-tab" :class="{ active: modalTab === 'tools' }" @click="modalTab = 'tools'">
{{ t('agents.tabs.tools', 'Tools') }}
<span v-if="selectedToolNames.length" class="tab-badge">{{ selectedToolNames.length }}</span>
<span v-if="form.toolsDisabled" class="tab-badge tab-badge--off">{{ t('agents.binding.disableAllToolsBadge') }}</span>
<span v-else-if="selectedToolNames.length" class="tab-badge">{{ selectedToolNames.length }}</span>
</button>
<button v-if="editingAgent" class="modal-tab" :class="{ active: modalTab === 'providers' }" @click="modalTab = 'providers'">
{{ t('agents.tabs.providers', 'Providers') }}
@ -333,24 +339,55 @@
<span class="binding-intro__kicker">{{ t('agents.binding.skillsKicker') }}</span>
<p class="binding-intro__tagline">{{ t('agents.binding.skillsTagline') }}</p>
</div>
<!-- Issue #184: explicit "no skills" toggle. Empty selection
alone falls back to "inherit global default" (legacy
contract), so users who want zero skills in the context
need this dedicated bit. -->
<div class="binding-disable-row">
<label class="binding-disable-label">
<input type="checkbox" v-model="form.skillsDisabled" class="binding-disable-checkbox" />
<span class="binding-disable-text">
<strong>{{ t('agents.binding.disableAllSkills') }}</strong>
<span class="binding-disable-hint">{{ t('agents.binding.disableAllSkillsHint') }}</span>
</span>
</label>
</div>
<p class="binding-hint">{{ t('agents.binding.skillsHint') }}</p>
<div v-if="availableSkills.length === 0" class="binding-empty">{{ t('agents.binding.noSkills') }}</div>
<template v-else>
<div class="binding-search">
<div class="binding-search" :class="{ 'binding-search--disabled': form.skillsDisabled }">
<svg width="15" height="15" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<circle cx="11" cy="11" r="8"/><line x1="21" y1="21" x2="16.65" y2="16.65"/>
</svg>
<input v-model="skillBindingSearch" :placeholder="t('agents.binding.searchSkills')" />
<input v-model="skillBindingSearch" :placeholder="t('agents.binding.searchSkills')" :disabled="form.skillsDisabled" />
</div>
<div v-if="filteredAvailableSkills.length === 0" class="binding-empty binding-empty--compact">{{ t('agents.binding.noMatchingSkills') }}</div>
<div v-else class="binding-list">
<div v-else class="binding-list" :class="{ 'binding-list--disabled': form.skillsDisabled }">
<label
v-for="skill in filteredAvailableSkills"
:key="skill.id"
class="binding-item"
:class="{ selected: selectedSkillIds.includes(skill.id) }"
:class="{
selected: !form.skillsDisabled && selectedSkillIds.includes(skill.id),
'binding-item--inert': form.skillsDisabled,
}"
>
<input type="checkbox" :value="skill.id" v-model="selectedSkillIds" class="binding-checkbox" />
<!-- Manual :checked instead of v-model: when skillsDisabled
is on, the stale picks still live in selectedSkillIds
(we keep them so flipping the toggle back off restores
the previous selection in one click). Driving the
checkbox from a derived expression lets us hide the
stale checked state from the user without mutating
the underlying array. The save path already clears
the array on send when the flag is on. -->
<input
type="checkbox"
:value="skill.id"
:checked="!form.skillsDisabled && selectedSkillIds.includes(skill.id)"
@change="onSkillToggle(skill.id, $event)"
class="binding-checkbox"
:disabled="form.skillsDisabled"
/>
<span class="binding-icon"><SkillIcon :value="skill.icon" :size="20" :fallback="'🧩'" /></span>
<div class="binding-info">
<span class="binding-name">{{ resolveSkillName(skill) }}</span>
@ -371,13 +408,30 @@
<span class="binding-intro__kicker">{{ t('agents.binding.toolsKicker') }}</span>
<p class="binding-intro__tagline">{{ t('agents.binding.toolsTagline') }}</p>
</div>
<details class="advanced-tools" :open="selectedToolNames.length > 0 || advancedToolsOpen">
<!-- Issue #184 mirror of the skills tab: explicit opt-out so the
LLM advertises zero user-pickable tools (system-level
memory primitives still pass see backend SYSTEM_LEVEL_TOOLS). -->
<div class="binding-disable-row">
<label class="binding-disable-label">
<input type="checkbox" v-model="form.toolsDisabled" class="binding-disable-checkbox" />
<span class="binding-disable-text">
<strong>{{ t('agents.binding.disableAllTools') }}</strong>
<span class="binding-disable-hint">{{ t('agents.binding.disableAllToolsHint') }}</span>
</span>
</label>
</div>
<!-- Issue #184: when the disable flag is on, treat the count as
zero for visual affordances auto-open / count badge / chevron
should all behave as if there are no picks, matching the
"saving clears bindings" contract. The underlying array is
left intact so toggling the flag back off restores them. -->
<details class="advanced-tools" :open="(!form.toolsDisabled && selectedToolNames.length > 0) || advancedToolsOpen">
<summary class="advanced-tools-summary" @click.prevent="advancedToolsOpen = !advancedToolsOpen">
<span class="advanced-tools-title">
{{ t('agents.binding.advancedToolsTitle') }}
<span v-if="selectedToolNames.length > 0" class="advanced-tools-count">{{ selectedToolNames.length }}</span>
<span v-if="!form.toolsDisabled && selectedToolNames.length > 0" class="advanced-tools-count">{{ selectedToolNames.length }}</span>
</span>
<span class="advanced-tools-chevron">{{ (advancedToolsOpen || selectedToolNames.length > 0) ? '▾' : '▸' }}</span>
<span class="advanced-tools-chevron">{{ (advancedToolsOpen || (!form.toolsDisabled && selectedToolNames.length > 0)) ? '▾' : '▸' }}</span>
</summary>
<p class="binding-hint">{{ t('agents.binding.toolsHint') }}</p>
<p class="binding-hint advanced-tools-note">{{ t('agents.binding.advancedToolsHint') }}</p>
@ -423,9 +477,10 @@
:key="tool.rowId || `${group.groupId}#${tool.rawName}#${tool.name}`"
class="binding-item"
:class="{
selected: tool._isSelected,
selected: !form.toolsDisabled && tool._isSelected,
'binding-item--stale': tool.stale,
'binding-item--unavailable': !tool.available,
'binding-item--inert': form.toolsDisabled,
}"
:title="!tool.available
? t('agents.binding.toolUnavailableTooltip', { reason: tool.unavailableReason || '' })
@ -437,12 +492,17 @@
both, so we drive each row's checked flag from
the pre-computed _isSelected derived in
availableToolGroups, which considers whether
this row's name is owned by a bindable twin. -->
this row's name is owned by a bindable twin.
Issue #184: gate visual checked-state on the
disable flag so stale picks don't show through
when "this agent uses no user-pickable tools"
is on. selectedToolNames is preserved so flipping
the toggle back off restores the prior selection. -->
<input
type="checkbox"
class="binding-checkbox"
:checked="tool._isSelected"
:disabled="tool._isDisabled"
:checked="!form.toolsDisabled && tool._isSelected"
:disabled="tool._isDisabled || form.toolsDisabled"
@change="onToolToggle(tool.name, $event)"
/>
<span class="binding-icon">
@ -672,6 +732,24 @@ function onToolToggle(toolName: string, event: Event) {
selectedToolNames.value = selectedToolNames.value.filter((n) => n !== toolName)
}
}
/**
* Skill checkbox handler. Issue #184 the row is driven by an explicit
* {@code :checked} expression instead of {@code v-model} so the visual
* checked state can be suppressed when {@code skillsDisabled} is on
* without dropping the picks from {@code selectedSkillIds}. Toggling
* the disable flag back off restores the prior selection in one click.
*/
function onSkillToggle(skillId: number | string, event: Event) {
const target = event.target as HTMLInputElement
if (target.checked) {
if (!selectedSkillIds.value.includes(skillId as number)) {
selectedSkillIds.value.push(skillId as number)
}
} else {
selectedSkillIds.value = selectedSkillIds.value.filter((id) => id !== skillId)
}
}
const selectedSkillIds = ref<number[]>([])
const selectedToolNames = ref<string[]>([])
// RFC-009 PR-3: per-agent provider preference order
@ -709,6 +787,10 @@ const defaultForm = (): Partial<Agent> & { name: string; defaultThinkingLevel: s
enabled: true,
defaultThinkingLevel: null,
workspaceBasePath: null,
// Issue #184 explicit opt-out flags. Default false matches the legacy
// "zero rows = inherit global default" contract for newly-created agents.
skillsDisabled: false,
toolsDisabled: false,
})
const form = ref(defaultForm())
@ -897,6 +979,8 @@ async function openEditModal(agent: Agent) {
enabled: agent.enabled,
defaultThinkingLevel: (agent as any).defaultThinkingLevel || null,
workspaceBasePath: agent.workspaceBasePath || null,
skillsDisabled: agent.skillsDisabled === true,
toolsDisabled: agent.toolsDisabled === true,
}
profileForm.value = parsePrompt(agent.systemPrompt)
modalTab.value = 'basic'
@ -964,13 +1048,44 @@ async function saveAgent() {
agentId = res.data?.id
}
// Save bindings (only for existing agents or after create returns id)
// Sequential binding saves (issue #184). Two coupled concerns:
//
// 1. Disabled-flag intent must win over stale picks. The opt-out
// toggles only disable the picker visually selectedSkillIds /
// selectedToolNames keep whatever was previously bound. If we sent
// those stale picks to setSkills/setTools while the flag is on, the
// backend's auto-clear self-heals the flag back to false (because
// "non-empty save = concrete commitment"), and the user's "disable
// everything" intent vanishes silently. So we clear the array here
// before sending saving [] preserves the flag, and the runtime
// contract ("flag wins over rows") is honored.
//
// 2. Sequential order, not Promise.all. Parallel binding calls would
// leave half-applied state on a partial failure; serial means we
// know exactly which side persisted and can pull the authoritative
// server state back if anything throws.
const skillIdsToSave = form.value.skillsDisabled ? [] : selectedSkillIds.value
const toolNamesToSave = form.value.toolsDisabled ? [] : selectedToolNames.value
if (agentId && editingAgent.value) {
await Promise.all([
agentBindingApi.setSkills(agentId, selectedSkillIds.value),
agentBindingApi.setTools(agentId, selectedToolNames.value),
agentBindingApi.setProviderPreferences(agentId, selectedProviderIds.value),
])
try {
await agentBindingApi.setSkills(agentId, skillIdsToSave)
await agentBindingApi.setTools(agentId, toolNamesToSave)
await agentBindingApi.setProviderPreferences(agentId, selectedProviderIds.value)
} catch (bindingError: any) {
mcToast.error(bindingError?.message || t('agents.messages.saveFailed'))
// Pull the authoritative server state back into the editing form so
// the user sees what actually persisted instead of stale picks.
try {
const fresh: any = await agentApi.get(agentId)
if (fresh?.data) {
await openEditModal(fresh.data)
}
} catch {
// Reload failure on top of binding failure: best effort, stop here.
}
return
}
}
mcToast.success(t('agents.messages.saveSuccess'))
@ -1295,6 +1410,14 @@ html.dark .seg-count.warn {
border-radius: 9px; background: var(--mc-primary); color: white;
font-size: 11px; font-weight: 600;
}
/* Issue #184: "off" variant for the disable-all state. Neutral grey instead
of brand orange because the badge represents a constraint, not a count. */
.tab-badge--off {
min-width: auto; padding: 0 8px;
background: var(--mc-bg-sunken, rgba(0,0,0,0.08));
color: var(--mc-text-tertiary);
border: 1px solid var(--mc-border-light);
}
/* Binding Tab */
.binding-tab { min-height: 200px; }
@ -1327,6 +1450,47 @@ html.dark .seg-count.warn {
}
.binding-empty { padding: 40px; text-align: center; color: var(--mc-text-tertiary); font-size: 14px; }
.binding-empty--compact { padding: 24px 12px; }
/* Issue #184 — opt-out row that sits above the picker list. */
.binding-disable-row {
margin: 0 0 12px;
padding: 12px 14px;
border: 1px dashed var(--mc-border);
border-radius: 8px;
background: var(--mc-bg-sunken);
}
.binding-disable-label {
display: flex;
align-items: flex-start;
gap: 10px;
cursor: pointer;
}
.binding-disable-checkbox {
flex-shrink: 0;
accent-color: var(--mc-primary);
width: 16px;
height: 16px;
margin-top: 2px;
}
.binding-disable-text {
display: flex;
flex-direction: column;
gap: 2px;
font-size: 13px;
color: var(--mc-text-primary);
line-height: 1.4;
}
.binding-disable-text strong { font-weight: 600; }
.binding-disable-hint {
font-size: 12px;
color: var(--mc-text-tertiary);
line-height: 1.5;
}
/* When the opt-out is on, the picker list is still visible (so the user
can see what they're disabling) but rendered inert no hover affordance,
greyed-out interactions. */
.binding-search--disabled,
.binding-list--disabled { opacity: 0.45; pointer-events: none; }
.binding-item--inert { cursor: not-allowed; }
.binding-search {
display: flex;
align-items: center;