diff --git a/mateclaw-server/src/main/java/vip/mate/agent/binding/controller/AgentBindingController.java b/mateclaw-server/src/main/java/vip/mate/agent/binding/controller/AgentBindingController.java
index 31bec483..3acb5654 100644
--- a/mateclaw-server/src/main/java/vip/mate/agent/binding/controller/AgentBindingController.java
+++ b/mateclaw-server/src/main/java/vip/mate/agent/binding/controller/AgentBindingController.java
@@ -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();
}
diff --git a/mateclaw-server/src/main/java/vip/mate/agent/binding/service/AgentBindingService.java b/mateclaw-server/src/main/java/vip/mate/agent/binding/service/AgentBindingService.java
index da7fe756..115ba089 100644
--- a/mateclaw-server/src/main/java/vip/mate/agent/binding/service/AgentBindingService.java
+++ b/mateclaw-server/src/main/java/vip/mate/agent/binding/service/AgentBindingService.java
@@ -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:
+ *
+ *
+ *
{@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).
+ *
{@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.
+ *
non-empty set — the explicit allowlist.
+ *
+ *
+ *
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 getBoundSkillIds(Long agentId) {
+ if (isSkillsDisabled(agentId)) {
+ return Set.of();
+ }
List 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()
.eq(AgentSkillBinding::getAgentId, agentId)
@@ -161,7 +184,14 @@ public class AgentBindingService implements AgentBindingResolver {
}
/**
- * 批量设置 Agent 的 skill 绑定(替换模式)
+ * Replace the agent's skill binding set.
+ *
+ *
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 not
+ * touch the flag — the caller (UI toggle) owns that bit.
*/
public void setSkillBindings(Long agentId, List 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()
.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}:
+ *
+ *
+ *
{@code null} — no binding rows and {@code tools_disabled=false}.
+ * Caller defers to the global default tool set.
+ *
{@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}).
+ *
non-empty set — the explicit allowlist.
+ *
*/
public Set getBoundToolNames(Long agentId) {
+ if (isToolsDisabled(agentId)) {
+ return Set.of();
+ }
List 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 {
*
*/
public Set 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 boundSkillIds = getBoundSkillIds(agentId);
Set 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 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 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 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 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()
.eq(AgentToolBinding::getAgentId, agentId)
@@ -715,6 +788,12 @@ public class AgentBindingService implements AgentBindingResolver {
public void setToolBindings(Long agentId, List 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()
.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);
+ }
}
diff --git a/mateclaw-server/src/main/java/vip/mate/agent/model/AgentEntity.java b/mateclaw-server/src/main/java/vip/mate/agent/model/AgentEntity.java
index df95ab63..f2ba494c 100644
--- a/mateclaw-server/src/main/java/vip/mate/agent/model/AgentEntity.java
+++ b/mateclaw-server/src/main/java/vip/mate/agent/model/AgentEntity.java
@@ -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.
+ *
+ *
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.
+ *
+ *
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.
+ *
+ *
Same defaulting / auto-clear / update strategy contract as
+ * {@link #skillsDisabled}.
+ */
+ @TableField(value = "tools_disabled")
+ private Boolean toolsDisabled;
+
@TableField(fill = FieldFill.INSERT)
private LocalDateTime createTime;
diff --git a/mateclaw-server/src/main/resources/db/migration/h2/V126__agent_binding_disabled_flags.sql b/mateclaw-server/src/main/resources/db/migration/h2/V126__agent_binding_disabled_flags.sql
new file mode 100644
index 00000000..f6f3a91c
--- /dev/null
+++ b/mateclaw-server/src/main/resources/db/migration/h2/V126__agent_binding_disabled_flags.sql
@@ -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;
diff --git a/mateclaw-server/src/main/resources/db/migration/mysql/V126__agent_binding_disabled_flags.sql b/mateclaw-server/src/main/resources/db/migration/mysql/V126__agent_binding_disabled_flags.sql
new file mode 100644
index 00000000..0c07b716
--- /dev/null
+++ b/mateclaw-server/src/main/resources/db/migration/mysql/V126__agent_binding_disabled_flags.sql
@@ -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;
diff --git a/mateclaw-server/src/test/java/vip/mate/agent/binding/AgentBindingServiceTest.java b/mateclaw-server/src/test/java/vip/mate/agent/binding/AgentBindingServiceTest.java
index 73f1d5aa..f8c17552 100644
--- a/mateclaw-server/src/test/java/vip/mate/agent/binding/AgentBindingServiceTest.java
+++ b/mateclaw-server/src/test/java/vip/mate/agent/binding/AgentBindingServiceTest.java
@@ -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 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 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 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 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 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 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 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 空保存不动 flag(toggle 自己拥有该位)")
+ 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");
+ }
}
diff --git a/mateclaw-ui/src/i18n/locales/en-US.ts b/mateclaw-ui/src/i18n/locales/en-US.ts
index 968f19cc..91c1aeaf 100644
--- a/mateclaw-ui/src/i18n/locales/en-US.ts
+++ b/mateclaw-ui/src/i18n/locales/en-US.ts
@@ -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',
diff --git a/mateclaw-ui/src/i18n/locales/zh-CN.ts b/mateclaw-ui/src/i18n/locales/zh-CN.ts
index bf19e665..f2b835e1 100644
--- a/mateclaw-ui/src/i18n/locales/zh-CN.ts
+++ b/mateclaw-ui/src/i18n/locales/zh-CN.ts
@@ -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: '高级:手选原子工具',
diff --git a/mateclaw-ui/src/types/index.ts b/mateclaw-ui/src/types/index.ts
index 3929a0f7..2c0025c9 100644
--- a/mateclaw-ui/src/types/index.ts
+++ b/mateclaw-ui/src/types/index.ts
@@ -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
}
diff --git a/mateclaw-ui/src/views/Agents.vue b/mateclaw-ui/src/views/Agents.vue
index a2e9aeb8..29289efc 100644
--- a/mateclaw-ui/src/views/Agents.vue
+++ b/mateclaw-ui/src/views/Agents.vue
@@ -209,11 +209,17 @@