feat(agent): add wiki_disabled opt-out flag for knowledge bases

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().
This commit is contained in:
倪程伟 2026-06-19 13:48:33 +08:00 committed by matevip
parent 0ab11f8922
commit 22a212a2e6
10 changed files with 292 additions and 15 deletions

View File

@ -980,16 +980,26 @@ public class AgentBindingService implements AgentBindingResolver {
* {@link #getBoundSkillIds}):
*
* <ul>
* <li>{@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).</li>
* <li>{@code Set.of()} rows exist but none are {@code enabled=true}.
* Caller treats this as "explicitly scoped to zero KBs".</li>
* <li>{@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).</li>
* <li>{@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.</li>
* <li>non-empty set the explicit allowlist.</li>
* </ul>
*
* <p>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<Long> getBoundKbIds(Long agentId) {
if (isWikiDisabled(agentId)) {
return Set.of();
}
List<AgentWikiKbBinding> 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<AgentWikiKbBinding>()
.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);
}
}

View File

@ -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).
*
* <p>Same defaulting / auto-clear / update strategy contract as
* {@link #skillsDisabled}.
*/
@TableField(value = "wiki_disabled")
private Boolean wikiDisabled;
@TableField(fill = FieldFill.INSERT)
private LocalDateTime createTime;

View File

@ -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;

View File

@ -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;

View File

@ -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;

View File

@ -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<Long> 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<Long> 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();
}
}

View File

@ -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',

View File

@ -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: '暂无可用知识库',

View File

@ -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
}

View File

@ -256,7 +256,8 @@
</button>
<button v-if="editingAgent" class="modal-tab" :class="{ active: modalTab === 'wiki' }" @click="modalTab = 'wiki'">
{{ t('agents.tabs.wiki', 'Wiki') }}
<span v-if="selectedKbIds.length" class="tab-badge">{{ selectedKbIds.length }}</span>
<span v-if="form.wikiDisabled" class="tab-badge tab-badge--off">{{ t('agents.binding.disableAllWikiBadge') }}</span>
<span v-else-if="selectedKbIds.length" class="tab-badge">{{ selectedKbIds.length }}</span>
</button>
</div>
@ -622,24 +623,46 @@
<span class="binding-intro__kicker">{{ t('agents.binding.wikiKicker') }}</span>
<p class="binding-intro__tagline">{{ t('agents.binding.wikiTagline') }}</p>
</div>
<!-- Issue #304: explicit "no KBs" toggle. Empty selection alone
falls through to "inherit workspace-wide" (every KB visible,
context bloat for agents that don't need a KB), so an operator
who wants zero KBs needs this dedicated bit mirrors the
skills_disabled / tools_disabled pattern. -->
<div class="binding-disable-row">
<label class="binding-disable-label">
<input type="checkbox" v-model="form.wikiDisabled" class="binding-disable-checkbox" />
<span class="binding-disable-text">
<strong>{{ t('agents.binding.disableAllWiki') }}</strong>
<span class="binding-disable-hint">{{ t('agents.binding.disableAllWikiHint') }}</span>
</span>
</label>
</div>
<p class="binding-hint">{{ t('agents.binding.wikiHint') }}</p>
<div v-if="availableKBs.length === 0" class="binding-empty">{{ t('agents.binding.noKBs') }}</div>
<template v-else>
<p class="binding-hint" :class="{ 'binding-hint--warn': selectedKbIds.length > 0 }">
{{ selectedKbIds.length === 0 ? t('agents.binding.wikiScopeAll') : t('agents.binding.wikiScopeLimited', { count: selectedKbIds.length }) }}
<p class="binding-hint" :class="{ 'binding-hint--warn': !form.wikiDisabled && selectedKbIds.length > 0 }">
<template v-if="form.wikiDisabled">{{ t('agents.binding.wikiScopeNone') }}</template>
<template v-else>{{ selectedKbIds.length === 0 ? t('agents.binding.wikiScopeAll') : t('agents.binding.wikiScopeLimited', { count: selectedKbIds.length }) }}</template>
</p>
<div class="binding-list">
<div class="binding-list" :class="{ 'binding-list--disabled': form.wikiDisabled }">
<label
v-for="kb in availableKBs"
:key="kb.id"
class="binding-item"
:class="{ selected: isKbInScope(kb.id) }"
:class="{
selected: !form.wikiDisabled && isKbInScope(kb.id),
'binding-item--inert': form.wikiDisabled,
}"
>
<!-- Manual :checked (not v-model) so flipping the toggle
back on restores the previous picks in one click. Same
inert-checkbox trick the skills picker uses (issue #184). -->
<input
type="checkbox"
class="binding-checkbox"
:checked="isKbInScope(kb.id)"
:checked="!form.wikiDisabled && isKbInScope(kb.id)"
@change="toggleKbScope(kb.id)"
:disabled="form.wikiDisabled"
/>
<span class="binding-icon">📚</span>
<div class="binding-info">
@ -650,7 +673,8 @@
<button
type="button"
class="kb-primary-toggle"
:class="{ 'kb-primary-toggle--active': selectedKBId === String(kb.id) }"
:class="{ 'kb-primary-toggle--active': !form.wikiDisabled && selectedKBId === String(kb.id) }"
:disabled="form.wikiDisabled"
:title="t('agents.binding.wikiSetPrimary')"
@click.prevent.stop="setPrimaryKb(kb.id)"
>{{ selectedKBId === String(kb.id) ? t('agents.binding.wikiPrimary') : t('agents.binding.wikiSetPrimary') }}</button>
@ -945,6 +969,10 @@ const defaultForm = (): Partial<Agent> & { name: string; defaultThinkingLevel: s
// "zero rows = inherit global default" contract for newly-created agents.
skillsDisabled: false,
toolsDisabled: false,
// Issue #304 same contract for the wiki/knowledge-base picker. Empty
// selection alone would fall through to "inherit workspace-wide", so an
// operator who wants zero KBs in the context needs this dedicated bit.
wikiDisabled: false,
})
const form = ref(defaultForm())
@ -1259,6 +1287,7 @@ async function openEditModal(agent: Agent) {
primaryKbId: agent.primaryKbId != null ? String(agent.primaryKbId) : null,
skillsDisabled: agent.skillsDisabled === true,
toolsDisabled: agent.toolsDisabled === true,
wikiDisabled: (agent as any).wikiDisabled === true,
}
tagInput.value = ''
recentlyRemovedTag.value = null
@ -1377,9 +1406,12 @@ async function saveAgent() {
await agentBindingApi.setSkills(agentId, skillIdsToSave)
await agentBindingApi.setTools(agentId, toolNamesToSave)
await agentBindingApi.setProviderPreferences(agentId, selectedProviderIds.value)
// KB access scope. Empty = unrestricted (workspace-wide). Sent as
// strings per the Snowflake-precision contract.
await agentBindingApi.setKbs(agentId, selectedKbIds.value)
// KB access scope. Issue #304: when wiki_disabled is on the agent
// sees zero KBs regardless of the binding list, so clear the save
// payload same pattern as skills/tools above. Empty save leaves
// the wiki_disabled flag untouched (the toggle owns the bit).
const kbIdsToSave = form.value.wikiDisabled ? [] : selectedKbIds.value
await agentBindingApi.setKbs(agentId, kbIdsToSave)
} catch (bindingError: any) {
mcToast.error(bindingError?.message || t('agents.messages.saveFailed'))
// Pull the authoritative server state back into the editing form so