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: + * + * + * + *

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}: + * + *

*/ 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 @@