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}):
*
*
- *
{@code null} — no binding rows. Caller treats this as "no
- * agent-level restriction; inherit every KB in the agent's
- * workspace" (the default wiki-tool behavior).
- *
{@code Set.of()} — rows exist but none are {@code enabled=true}.
- * Caller treats this as "explicitly scoped to zero KBs".
+ *
{@code null} — {@code wiki_disabled=false} AND no binding rows.
+ * Caller treats this as "no agent-level restriction; inherit every
+ * KB in the agent's workspace" (the default wiki-tool behavior).
+ *
{@code Set.of()} — either {@code wiki_disabled=true}, or binding
+ * rows exist but none are {@code enabled=true}. Caller treats this
+ * as "explicitly scoped to zero KBs" — wiki tools degrade with
+ * their standard "no knowledge base" message.
*
non-empty set — the explicit allowlist.
*
+ *
+ *
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') }}