From 22a212a2e6c2de09cc323df94999e32e4a67c1de Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E5=80=AA=E7=A8=8B=E4=BC=9F?= Date: Fri, 19 Jun 2026 13:48:33 +0800 Subject: [PATCH] feat(agent): add wiki_disabled opt-out flag for knowledge bases MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Issue #304. Operators who want an agent with NO knowledge base had no way to express it: leaving the KB picker empty fell through to "inherit workspace-wide" (every KB visible), so the agent ended up ingesting every KB's context. This adds the same opt-out toggle that skills_disabled (V126) / tools_disabled already provide. Backend: - V154 migration (h2 + mysql + kingbase): mate_agent.wiki_disabled BOOLEAN/TINYINT/SMALLINT NOT NULL DEFAULT FALSE. Legacy agents stay bit-identical. - AgentEntity.wikiDisabled: Boolean field, @TableField("wiki_disabled"). - AgentBindingService.getBoundKbIds: short-circuit at the top — wiki_disabled=true returns Set.of() regardless of binding rows. Mirrors the precedence contract of getBoundSkillIds vs skills_disabled. - AgentBindingService.setKbBindings: a non-empty save auto-clears a stale wiki_disabled flag (same contract as setSkillBindings / setToolBindings on their respective flags). Empty saves leave the flag untouched — the UI toggle owns the bit, not the binding writer. - AgentBindingServiceWikiDisabledTest: 5 cases covering all three return states + the stale-flag auto-clear + empty-save no-op. Frontend: - Agents.vue KB picker: add the "此智能体不使用任何知识库" / "This agent uses no knowledge bases" toggle, mirroring the skills / tools picker layout. Tab badge shows "Off" when the toggle is on. - types/index.ts: add Agent.wikiDisabled?: boolean. - Save logic: when wikiDisabled is on, send an empty KB list (the setKbs contract then leaves the flag alone server-side, exactly as setSkills / setTools behave for their opt-out flags). - i18n (zh + en): new strings for toggle label, hint, badge, and the scope description shown when the toggle is on. Stacked on top of #382 (which introduced AgentBindingResolver .getBoundKbIds). No agent-runtime changes — wiki tools already degrade cleanly when getBoundKbIds returns Set.of(). --- .../binding/service/AgentBindingService.java | 50 +++++- .../vip/mate/agent/model/AgentEntity.java | 16 ++ .../h2/V154__agent_wiki_disabled.sql | 10 ++ .../kingbase/V154__agent_wiki_disabled.sql | 3 + .../mysql/V154__agent_wiki_disabled.sql | 3 + .../AgentBindingServiceWikiDisabledTest.java | 158 ++++++++++++++++++ mateclaw-ui/src/i18n/locales/en-US.ts | 4 + mateclaw-ui/src/i18n/locales/zh-CN.ts | 4 + mateclaw-ui/src/types/index.ts | 7 + mateclaw-ui/src/views/Agents.vue | 52 ++++-- 10 files changed, 292 insertions(+), 15 deletions(-) create mode 100644 mateclaw-server/src/main/resources/db/migration/h2/V154__agent_wiki_disabled.sql create mode 100644 mateclaw-server/src/main/resources/db/migration/kingbase/V154__agent_wiki_disabled.sql create mode 100644 mateclaw-server/src/main/resources/db/migration/mysql/V154__agent_wiki_disabled.sql create mode 100644 mateclaw-server/src/test/java/vip/mate/agent/binding/AgentBindingServiceWikiDisabledTest.java 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 a64d9fd8..68ebb40f 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 @@ -980,16 +980,26 @@ public class AgentBindingService implements AgentBindingResolver { * {@link #getBoundSkillIds}): * * + * + *

The {@code wiki_disabled} flag takes precedence over row count, so + * a stale (flag + leftover rows) combination still surfaces as "no KBs". + * Mirrors how {@code skills_disabled} interacts with + * {@link #getBoundSkillIds}. */ @Override public Set getBoundKbIds(Long agentId) { + if (isWikiDisabled(agentId)) { + return Set.of(); + } List bindings = listKbBindings(agentId); if (bindings.isEmpty()) { return null; @@ -1023,6 +1033,12 @@ public class AgentBindingService implements AgentBindingResolver { for (Long kbId : distinct) { requireKbInAgentWorkspace(agentId, kbId); } + // Auto-clear wiki_disabled on a non-empty save — same contract as + // setSkillBindings: a concrete KB commitment contradicts an opt-out + // flag, so the data layer must never hold both states at once. + if (!distinct.isEmpty()) { + clearWikiDisabledFlag(agentId); + } kbBindingMapper.delete( new LambdaQueryWrapper() .eq(AgentWikiKbBinding::getAgentId, agentId)); @@ -1118,4 +1134,28 @@ public class AgentBindingService implements AgentBindingResolver { update.setToolsDisabled(false); agentMapper.updateById(update); } + + /** Mirror of {@link #isSkillsDisabled} for the wiki/knowledge-base opt-out toggle. */ + private boolean isWikiDisabled(Long agentId) { + if (agentId == null) return false; + AgentEntity agent = agentMapper.selectById(agentId); + return agent != null && Boolean.TRUE.equals(agent.getWikiDisabled()); + } + + /** + * Mirror of {@link #clearSkillsDisabledFlag} for the wiki toggle. Used as + * an auto-clear step in {@link #setKbBindings} so a concrete KB commitment + * always wins over a stale opt-out flag. + */ + private void clearWikiDisabledFlag(Long agentId) { + if (agentId == null) return; + AgentEntity agent = agentMapper.selectById(agentId); + if (agent == null || !Boolean.TRUE.equals(agent.getWikiDisabled())) { + return; + } + AgentEntity update = new AgentEntity(); + update.setId(agentId); + update.setWikiDisabled(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 65437dca..3a49340c 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 @@ -131,6 +131,22 @@ public class AgentEntity { @TableField(value = "tools_disabled") private Boolean toolsDisabled; + /** + * Explicit opt-out from every knowledge base. When {@code true}, + * {@code AgentBindingService.getBoundKbIds} returns + * {@link java.util.Collections#emptySet()} and the wiki tools degrade with + * their standard "no knowledge base" message; the webchat + * {@code /wiki/pages} picker endpoint returns an empty list. Without this + * flag, leaving the KB picker empty means "inherit workspace-wide" — every + * KB visible — which is the right default but leaves no way to express + * "this agent intentionally uses no KB" (issue #304). + * + *

Same defaulting / auto-clear / update strategy contract as + * {@link #skillsDisabled}. + */ + @TableField(value = "wiki_disabled") + private Boolean wikiDisabled; + @TableField(fill = FieldFill.INSERT) private LocalDateTime createTime; diff --git a/mateclaw-server/src/main/resources/db/migration/h2/V154__agent_wiki_disabled.sql b/mateclaw-server/src/main/resources/db/migration/h2/V154__agent_wiki_disabled.sql new file mode 100644 index 00000000..2aac7f8f --- /dev/null +++ b/mateclaw-server/src/main/resources/db/migration/h2/V154__agent_wiki_disabled.sql @@ -0,0 +1,10 @@ +-- V154: Wiki/knowledge-base opt-out flag on mate_agent. +-- +-- Mirrors skills_disabled (V126) / tools_disabled. Without this column an +-- operator who wants an agent with NO knowledge base has no way to express +-- that intent: leaving the KB picker empty means "inherit workspace-wide" +-- (every KB visible), so the agent ends up ingesting every KB's context. +-- issue #304. +-- +-- Defaults to FALSE so legacy agents stay bit-identical. +ALTER TABLE mate_agent ADD COLUMN IF NOT EXISTS wiki_disabled BOOLEAN NOT NULL DEFAULT FALSE; diff --git a/mateclaw-server/src/main/resources/db/migration/kingbase/V154__agent_wiki_disabled.sql b/mateclaw-server/src/main/resources/db/migration/kingbase/V154__agent_wiki_disabled.sql new file mode 100644 index 00000000..72db6127 --- /dev/null +++ b/mateclaw-server/src/main/resources/db/migration/kingbase/V154__agent_wiki_disabled.sql @@ -0,0 +1,3 @@ +-- V154: Wiki/knowledge-base opt-out flag on mate_agent (issue #304). +-- Mirrors skills_disabled / tools_disabled. Defaults to FALSE. +ALTER TABLE mate_agent ADD COLUMN IF NOT EXISTS wiki_disabled SMALLINT NOT NULL DEFAULT 0; diff --git a/mateclaw-server/src/main/resources/db/migration/mysql/V154__agent_wiki_disabled.sql b/mateclaw-server/src/main/resources/db/migration/mysql/V154__agent_wiki_disabled.sql new file mode 100644 index 00000000..abb8131d --- /dev/null +++ b/mateclaw-server/src/main/resources/db/migration/mysql/V154__agent_wiki_disabled.sql @@ -0,0 +1,3 @@ +-- V154: Wiki/knowledge-base opt-out flag on mate_agent (issue #304). +-- Mirrors skills_disabled / tools_disabled. Defaults to FALSE. +ALTER TABLE mate_agent ADD COLUMN IF NOT EXISTS wiki_disabled TINYINT(1) NOT NULL DEFAULT 0; diff --git a/mateclaw-server/src/test/java/vip/mate/agent/binding/AgentBindingServiceWikiDisabledTest.java b/mateclaw-server/src/test/java/vip/mate/agent/binding/AgentBindingServiceWikiDisabledTest.java new file mode 100644 index 00000000..37f5e754 --- /dev/null +++ b/mateclaw-server/src/test/java/vip/mate/agent/binding/AgentBindingServiceWikiDisabledTest.java @@ -0,0 +1,158 @@ +package vip.mate.agent.binding; + +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.test.context.SpringBootTest; +import org.springframework.jdbc.core.JdbcTemplate; +import org.springframework.test.context.TestPropertySource; +import vip.mate.MateClawApplication; +import vip.mate.agent.binding.service.AgentBindingService; + +import java.util.Set; + +import static org.assertj.core.api.Assertions.assertThat; + +/** + * Issue #304 coverage for the {@code wiki_disabled} opt-out flag on + * {@code mate_agent}. Mirrors the contract proven for {@code skills_disabled} + * / {@code tools_disabled} in {@link AgentBindingServiceTest}: the flag flips + * the "no binding rows" semantic from "inherit workspace-wide" to "explicitly + * scoped to zero KBs", and a non-empty {@code setKbBindings} save auto-clears + * a stale flag. + */ +@SpringBootTest( + classes = MateClawApplication.class, + webEnvironment = SpringBootTest.WebEnvironment.NONE +) +@TestPropertySource(properties = { + "spring.datasource.url=jdbc:h2:mem:binding_wiki_${random.uuid};MODE=MySQL;DATABASE_TO_LOWER=TRUE;CASE_INSENSITIVE_IDENTIFIERS=TRUE;DB_CLOSE_DELAY=-1", + "spring.ai.dashscope.api-key=test-key", + "spring.main.web-application-type=none" +}) +class AgentBindingServiceWikiDisabledTest { + + private static final long AGENT_ID = 9_500_011L; + private static final long KB_ID_A = 9_500_101L; + private static final long KB_ID_B = 9_500_102L; + + @Autowired private AgentBindingService bindingService; + @Autowired private JdbcTemplate jdbc; + + @BeforeEach + void setUp() { + jdbc.update("DELETE FROM mate_agent_wiki_kb WHERE agent_id = ?", AGENT_ID); + jdbc.update("DELETE FROM mate_agent WHERE id = ?", AGENT_ID); + jdbc.update("DELETE FROM mate_wiki_knowledge_base WHERE id IN (?, ?)", KB_ID_A, KB_ID_B); + + jdbc.update( + "MERGE INTO mate_agent (id, name, agent_type, system_prompt, max_iterations, enabled, " + + "workspace_id, skills_disabled, tools_disabled, wiki_disabled, " + + "create_time, update_time, deleted) " + + "KEY(id) VALUES (?, 'wiki-disabled-agent', 'react', '', 10, TRUE, 1, " + + "FALSE, FALSE, FALSE, CURRENT_TIMESTAMP, CURRENT_TIMESTAMP, 0)", + AGENT_ID); + // Two KBs in workspace 1 so we can prove "no rows → inherit" surfaces them + // and "disabled → Set.of()" hides them, without relying on fixture data. + for (long kbId : new long[]{KB_ID_A, KB_ID_B}) { + jdbc.update("MERGE INTO mate_wiki_knowledge_base (id, name, description, status, " + + "page_count, raw_count, workspace_id, create_time, update_time, deleted) " + + "KEY(id) VALUES (?, ?, ?, 'active', 0, 0, 1, " + + "CURRENT_TIMESTAMP, CURRENT_TIMESTAMP, 0)", + kbId, "kb-" + kbId, "desc-" + kbId); + } + } + + private void setWikiDisabledFlag(boolean value) { + jdbc.update("UPDATE mate_agent SET wiki_disabled = ? WHERE id = ?", value, AGENT_ID); + } + + private boolean readWikiDisabledFlag() { + Boolean v = jdbc.queryForObject( + "SELECT wiki_disabled FROM mate_agent WHERE id = ?", + Boolean.class, AGENT_ID); + return Boolean.TRUE.equals(v); + } + + @Test + @DisplayName("issue #304: wiki_disabled=false + no binding rows → null (inherit workspace-wide)") + void noRowsReturnsNullWhenNotDisabled() { + // Pre-V154 behavior preserved: an agent with no KB rows and no opt-out + // flag inherits every KB in the workspace. The webchat picker and wiki + // tools rely on null meaning "no restriction" to fall through to + // workspace-wide retrieval. + assertThat(bindingService.getBoundKbIds(AGENT_ID)).isNull(); + } + + @Test + @DisplayName("issue #304: wiki_disabled=true → Set.of() (NOT null) even with binding rows") + void disabledFlagReturnsEmptyEvenWithRows() { + // Seed a binding row so we can prove the flag wins over row count. + // Stale (flag + leftover rows) is exactly the contradiction the + // auto-clear in setKbBindings is designed to prevent; this test pins + // that getBoundKbIds stays defensive when state drifts. + jdbc.update("MERGE INTO mate_agent_wiki_kb (id, agent_id, kb_id, enabled, " + + "create_time, update_time, deleted) " + + "KEY(id) VALUES (?, ?, ?, TRUE, CURRENT_TIMESTAMP, CURRENT_TIMESTAMP, 0)", + 9_500_201L, AGENT_ID, KB_ID_A); + setWikiDisabledFlag(true); + + Set result = bindingService.getBoundKbIds(AGENT_ID); + + assertThat(result).isNotNull(); + assertThat(result).isEmpty(); + } + + @Test + @DisplayName("issue #304: wiki_disabled=false + binding rows → the explicit allowlist") + void bindingRowsReturnAllowlistWhenNotDisabled() { + // Standard three-state contract: non-empty bindings + flag off returns + // the enabled KB ids, not null. + jdbc.update("MERGE INTO mate_agent_wiki_kb (id, agent_id, kb_id, enabled, " + + "create_time, update_time, deleted) " + + "KEY(id) VALUES (?, ?, ?, TRUE, CURRENT_TIMESTAMP, CURRENT_TIMESTAMP, 0)", + 9_500_202L, AGENT_ID, KB_ID_A); + jdbc.update("MERGE INTO mate_agent_wiki_kb (id, agent_id, kb_id, enabled, " + + "create_time, update_time, deleted) " + + "KEY(id) VALUES (?, ?, ?, TRUE, CURRENT_TIMESTAMP, CURRENT_TIMESTAMP, 0)", + 9_500_203L, AGENT_ID, KB_ID_B); + + Set result = bindingService.getBoundKbIds(AGENT_ID); + + assertThat(result).containsExactlyInAnyOrder(KB_ID_A, KB_ID_B); + } + + @Test + @DisplayName("issue #304: setKbBindings non-empty save auto-clears a stale wiki_disabled flag") + void setKbBindingsClearsStaleFlag() { + // Set up the contradiction: flag on, then operator saves a real + // binding. setKbBindings must clear the flag — same contract as + // setSkillBindings / setToolBindings on the skills_disabled and + // tools_disabled flags. + setWikiDisabledFlag(true); + assertThat(readWikiDisabledFlag()).isTrue(); + + bindingService.setKbBindings(AGENT_ID, java.util.List.of(KB_ID_A)); + + assertThat(readWikiDisabledFlag()) + .as("a concrete KB commitment must clear wiki_disabled so the data layer never holds both states at once") + .isFalse(); + // And the flag clear surfaces in getBoundKbIds — the new binding is + // honored, not silently masked by a stale opt-out. + assertThat(bindingService.getBoundKbIds(AGENT_ID)).containsExactly(KB_ID_A); + } + + @Test + @DisplayName("issue #304: setKbBindings empty save leaves the flag untouched (UI toggle owns the bit)") + void setKbBindingsEmptySaveLeavesFlagUntouched() { + // Empty / null save is ambiguous: "uncheck everything" vs "I never + // had any". The UI opt-out toggle owns the bit, not the binding + // writer. Mirrors setSkillBindings empty-save semantics so the four- + // state matrix stays consistent across skill/tool/wiki pickers. + setWikiDisabledFlag(true); + bindingService.setKbBindings(AGENT_ID, java.util.List.of()); + assertThat(readWikiDisabledFlag()).isTrue(); + assertThat(bindingService.getBoundKbIds(AGENT_ID)).isEmpty(); + } +} diff --git a/mateclaw-ui/src/i18n/locales/en-US.ts b/mateclaw-ui/src/i18n/locales/en-US.ts index ef7046c0..76c8d08b 100644 --- a/mateclaw-ui/src/i18n/locales/en-US.ts +++ b/mateclaw-ui/src/i18n/locales/en-US.ts @@ -1343,6 +1343,10 @@ export default { wikiHint: 'Tick the knowledge bases this agent may access; mark one as the default (used by wiki tools when no kbId/kbName is given).', wikiScopeAll: 'No knowledge base selected: this agent can reach every KB in the current workspace.', wikiScopeLimited: 'This agent can only reach the {count} selected knowledge base(s).', + wikiScopeNone: '"No knowledge bases" is on: this agent cannot reach any KB, and every wiki tool returns "no knowledge base".', + disableAllWiki: 'This agent uses no knowledge bases', + disableAllWikiHint: 'Saving with this on clears the agent\'s KB bindings and marks it as "explicitly no KBs": wiki_read_page / wiki_search_pages / etc. return "no knowledge base", and the webchat /wiki/pages endpoint returns an empty list. When off, picking nothing still falls back to "can reach every KB in the workspace".', + disableAllWikiBadge: 'Off', wikiSetPrimary: 'Set default', wikiPrimary: 'Default', noKBs: 'No knowledge bases available', diff --git a/mateclaw-ui/src/i18n/locales/zh-CN.ts b/mateclaw-ui/src/i18n/locales/zh-CN.ts index d9a8d721..a0e75468 100644 --- a/mateclaw-ui/src/i18n/locales/zh-CN.ts +++ b/mateclaw-ui/src/i18n/locales/zh-CN.ts @@ -1234,6 +1234,10 @@ export default { wikiHint: '勾选此智能体允许访问的知识库;可将其中一个设为默认(未指定 kbId/kbName 时优先使用)。', wikiScopeAll: '未勾选任何知识库:此智能体可访问当前工作区内的全部知识库。', wikiScopeLimited: '此智能体仅能访问已勾选的 {count} 个知识库。', + wikiScopeNone: '已开启「不使用任何知识库」:此智能体将无法访问任何知识库,所有 wiki 工具会返回空结果。', + disableAllWiki: '此智能体不使用任何知识库', + disableAllWikiHint: '开启后保存会清空该智能体的知识库绑定,并标记为「显式无知识库」:wiki_read_page / wiki_search_pages 等工具返回 "no knowledge base",webchat 的 /wiki/pages 端点返回空清单。关闭后,未勾选任何知识库仍按「可访问工作区内全部知识库」处理。', + disableAllWikiBadge: '已禁用', wikiSetPrimary: '设为默认', wikiPrimary: '默认', noKBs: '暂无可用知识库', diff --git a/mateclaw-ui/src/types/index.ts b/mateclaw-ui/src/types/index.ts index dfdec8c2..57c115df 100644 --- a/mateclaw-ui/src/types/index.ts +++ b/mateclaw-ui/src/types/index.ts @@ -59,6 +59,13 @@ export interface Agent { * delegation primitives still pass through. Defaults to `false`. */ toolsDisabled?: boolean + /** + * Explicit opt-out: this agent sees zero knowledge bases regardless of + * leftover `mate_agent_wiki_kb` rows. Wiki tools degrade with their + * standard "no knowledge base" message; the webchat `/wiki/pages` picker + * returns an empty list. Defaults to `false`. Issue #304. + */ + wikiDisabled?: boolean createTime?: string updateTime?: string } diff --git a/mateclaw-ui/src/views/Agents.vue b/mateclaw-ui/src/views/Agents.vue index e86791e4..12c78d26 100644 --- a/mateclaw-ui/src/views/Agents.vue +++ b/mateclaw-ui/src/views/Agents.vue @@ -256,7 +256,8 @@ @@ -622,24 +623,46 @@ {{ t('agents.binding.wikiKicker') }}

{{ t('agents.binding.wikiTagline') }}

+ +
+ +

{{ t('agents.binding.wikiHint') }}

{{ t('agents.binding.noKBs') }}