fix(wiki): let agents reach every visible KB via kbName + wiki_list_kbs (#224)

This commit is contained in:
matevip 2026-05-26 13:53:32 +08:00
parent 3ae4498f38
commit d2b23c049c
6 changed files with 441 additions and 60 deletions

View File

@ -250,7 +250,13 @@ public class ChannelMessageRouter {
// no longer exists for downstream consumers to reference.
return;
}
if (!Boolean.TRUE.equals(fresh.getEnabled())) {
// Only drop on an EXPLICIT enabled=false. A null enabled (which the
// production DB never returns but tests / hand-constructed entities
// do) means "not declared", and treating it as disabled would
// collapse every downstream behaviour into a silent drop which is
// exactly how the previous !Boolean.TRUE.equals(...) form regressed
// mock-driven tests that don't bother seeding the flag.
if (Boolean.FALSE.equals(fresh.getEnabled())) {
log.warn("[{}] Channel {} (id={}) is disabled; dropping message from {}",
adapter.getChannelType(), fresh.getName(), fresh.getId(), message.getSenderId());
return;
@ -571,7 +577,7 @@ public class ChannelMessageRouter {
message.getSenderId());
return;
}
if (!Boolean.TRUE.equals(fresh.getEnabled())) {
if (Boolean.FALSE.equals(fresh.getEnabled())) {
log.warn("[{}] Channel {} (id={}) is disabled at processing time; dropping message from {}",
adapter.getChannelType(), fresh.getName(), fresh.getId(), message.getSenderId());
return;
@ -1168,7 +1174,7 @@ public class ChannelMessageRouter {
if (fresh == null) {
return Flux.error(new IllegalStateException("Channel no longer exists"));
}
if (!Boolean.TRUE.equals(fresh.getEnabled())) {
if (Boolean.FALSE.equals(fresh.getEnabled())) {
return Flux.error(new IllegalStateException("Channel is disabled"));
}
channelEntity = fresh;

View File

@ -138,6 +138,16 @@ public class WikiContextService {
int totalChars = 0;
int maxChars = properties.getMaxContextChars();
// Every page is rendered under its KB heading so the agent can see
// which KB each slug lives in. This becomes load-bearing when more
// than one KB is visible: wiki tools accept an optional `kbName`
// argument, and the LLM is expected to copy the heading text into
// that argument when reaching for a slug outside the primary KB.
// Without the heading the agent would call wiki_read_page(slug=...)
// without kbName and silently miss the slug that's the surface of
// issue #224 mapped into the prompt-side context.
boolean multipleKbs = kbs.size() > 1;
for (WikiKnowledgeBaseEntity kb : kbs) {
List<WikiPageEntity> pages = pageService.listSummaries(kb.getId());
if (pages.isEmpty()) continue;
@ -172,6 +182,13 @@ public class WikiContextService {
}
sb.append("Use wiki_read_page(slug) for details. Use wiki_search_pages(query) to search.\n");
if (multipleKbs) {
sb.append("Multiple knowledge bases visible — every wiki tool takes an optional ")
.append("`kbName` argument (the heading text above). Pass it when the slug ")
.append("you want lives outside the agent's primary KB; otherwise the tool ")
.append("falls back to the primary and may return 'page not found'. Call ")
.append("wiki_list_kbs first if unsure which KB to target.\n");
}
sb.append("</wiki-context>");
return sb.toString();

View File

@ -138,6 +138,30 @@ public class WikiKnowledgeBaseService {
return kbs.get(0);
}
/**
* Resolve a specific knowledge base by name, restricted to the agent's
* visibility set (agent-bound KBs + shared NULL KBs). Used by wiki tools
* that accept an optional {@code kbName} parameter so the LLM can target
* a non-primary KB when the agent reaches more than one.
* <p>
* Match is exact and case-sensitive the LLM is expected to copy the
* name verbatim from {@code wiki_list_kbs} output. Returns {@code null}
* when no visible KB has that name, so callers can surface a "use
* wiki_list_kbs to discover names" hint instead of silently falling back
* to the primary KB (which would mask a bad pick).
*/
public WikiKnowledgeBaseEntity findByName(Long agentId, String kbName) {
if (kbName == null || kbName.isBlank()) {
return null;
}
for (WikiKnowledgeBaseEntity kb : listByAgentId(agentId)) {
if (kbName.equals(kb.getName())) {
return kb;
}
}
return null;
}
public WikiKnowledgeBaseEntity getById(Long id) {
return kbMapper.selectById(id);
}

View File

@ -83,6 +83,50 @@ public class WikiTool {
this.objectMapper = objectMapper;
}
// ==================== Knowledge-base discovery ====================
@Tool(description = """
List every knowledge base visible to this agent both KBs explicitly
bound to the agent and shared workspace-level KBs.
Every other wiki tool (read / list / search / semantic search / )
accepts an OPTIONAL `kbName` parameter. When omitted, the tool falls
back to the agent's "primary" KB (the agent-bound KB if any, else
the most recently updated shared KB), which is fine when the agent
only reaches one KB. When the agent reaches more than one and the
data you need lives in a non-primary KB, call wiki_list_kbs first
and pass the chosen `name` as `kbName` so the tool actually queries
the right KB instead of silently hitting the primary one.
Output fields per KB:
- name copy verbatim into other tools' `kbName` param
- description operator-supplied summary
- pageCount number of pages currently in the KB
- isPrimary true for the KB used when `kbName` is omitted
- boundToAgent true if the KB is explicitly bound to this agent
""")
public String wiki_list_kbs(
@ToolParam(description = "Agent ID") Long agentId) {
List<WikiKnowledgeBaseEntity> kbs = kbService.listByAgentId(agentId);
WikiKnowledgeBaseEntity primary = kbService.resolvePrimaryKb(agentId);
Long primaryId = primary == null ? null : primary.getId();
JSONArray arr = new JSONArray();
for (WikiKnowledgeBaseEntity kb : kbs) {
arr.add(JSONUtil.createObj()
.set("name", kb.getName())
.set("description", kb.getDescription())
.set("pageCount", kb.getPageCount() == null ? 0 : kb.getPageCount())
.set("isPrimary", kb.getId().equals(primaryId))
.set("boundToAgent", kb.getAgentId() != null));
}
return JSONUtil.createObj()
.set("kbCount", kbs.size())
.set("primary", primary == null ? null : primary.getName())
.set("kbs", arr)
.toString();
}
// ==================== RFC-032: Enhanced wiki_read_page ====================
@Tool(description = """
@ -95,15 +139,16 @@ public class WikiTool {
@ToolParam(description = "Agent ID") Long agentId,
@ToolParam(description = "Page slug") String slug,
@ToolParam(description = "Max characters to return (null = full page)", required = false) Integer maxChars,
@ToolParam(description = "Section heading to extract (null = all sections)", required = false) String sectionHeading) {
@ToolParam(description = "Section heading to extract (null = all sections)", required = false) String sectionHeading,
@ToolParam(description = "Target knowledge base name (from wiki_list_kbs). Omit to use the agent's primary KB.", required = false) String kbName) {
if (slug == null || slug.isBlank()) {
return error("slug is required");
}
Long kbId = resolveKbId(agentId);
Long kbId = resolveKbId(agentId, kbName);
if (kbId == null) {
return error("No wiki knowledge base found for this agent");
return noKbError(kbName);
}
WikiPageEntity page = pageService.getBySlug(kbId, slug);
@ -140,11 +185,12 @@ public class WikiTool {
""")
public String wiki_list_pages(
@ToolParam(description = "Agent ID") Long agentId,
@ToolParam(description = "Title keyword filter (optional)", required = false) String query) {
@ToolParam(description = "Title keyword filter (optional)", required = false) String query,
@ToolParam(description = "Target knowledge base name (from wiki_list_kbs). Omit to use the agent's primary KB.", required = false) String kbName) {
Long kbId = resolveKbId(agentId);
Long kbId = resolveKbId(agentId, kbName);
if (kbId == null) {
return error("No wiki knowledge base found for this agent");
return noKbError(kbName);
}
List<WikiPageLite> pages;
@ -197,15 +243,16 @@ public class WikiTool {
@ToolParam(description = "Agent ID") Long agentId,
@ToolParam(description = "Search query") String query,
@ToolParam(description = "Mode: keyword|semantic|hybrid (default: hybrid)", required = false) String mode,
@ToolParam(description = "Max results (default 5, max 20)", required = false) Integer topK) {
@ToolParam(description = "Max results (default 5, max 20)", required = false) Integer topK,
@ToolParam(description = "Target knowledge base name (from wiki_list_kbs). Omit to use the agent's primary KB.", required = false) String kbName) {
if (query == null || query.isBlank()) {
return error("query is required");
}
Long kbId = resolveKbId(agentId);
Long kbId = resolveKbId(agentId, kbName);
if (kbId == null) {
return error("No wiki knowledge base found for this agent");
return noKbError(kbName);
}
int k = (topK != null && topK > 0) ? Math.min(topK, 20) : 5;
@ -246,15 +293,16 @@ public class WikiTool {
public String wiki_semantic_search(
@ToolParam(description = "Agent ID") Long agentId,
@ToolParam(description = "Natural language query") String query,
@ToolParam(description = "Max results (default 5)", required = false) Integer topK) {
@ToolParam(description = "Max results (default 5)", required = false) Integer topK,
@ToolParam(description = "Target knowledge base name (from wiki_list_kbs). Omit to use the agent's primary KB.", required = false) String kbName) {
if (query == null || query.isBlank()) {
return error("query is required");
}
Long kbId = resolveKbId(agentId);
Long kbId = resolveKbId(agentId, kbName);
if (kbId == null) {
return error("No wiki knowledge base found for this agent");
return noKbError(kbName);
}
int k = (topK != null && topK > 0) ? Math.min(topK, 20) : 5;
@ -309,15 +357,16 @@ public class WikiTool {
""")
public String wiki_trace_source(
@ToolParam(description = "Agent ID") Long agentId,
@ToolParam(description = "Page slug") String slug) {
@ToolParam(description = "Page slug") String slug,
@ToolParam(description = "Target knowledge base name (from wiki_list_kbs). Omit to use the agent's primary KB.", required = false) String kbName) {
if (slug == null || slug.isBlank()) {
return error("slug is required");
}
Long kbId = resolveKbId(agentId);
Long kbId = resolveKbId(agentId, kbName);
if (kbId == null) {
return error("No wiki knowledge base found for this agent");
return noKbError(kbName);
}
WikiPageEntity page = pageService.getBySlug(kbId, slug);
@ -339,7 +388,8 @@ public class WikiTool {
public String wiki_create_page(
@ToolParam(description = "Agent ID") Long agentId,
@ToolParam(description = "Page title") String title,
@ToolParam(description = "Page content (Markdown)") String content) {
@ToolParam(description = "Page content (Markdown)") String content,
@ToolParam(description = "Target knowledge base name (from wiki_list_kbs). Omit to use the agent's primary KB.", required = false) String kbName) {
if (title == null || title.isBlank()) {
return error("title is required");
@ -348,9 +398,9 @@ public class WikiTool {
return error("content is required");
}
Long kbId = resolveKbId(agentId);
Long kbId = resolveKbId(agentId, kbName);
if (kbId == null) {
return error("No wiki knowledge base found for this agent. Create one first.");
return noKbError(kbName);
}
String slug = title.toLowerCase()
@ -392,13 +442,14 @@ public class WikiTool {
@ToolParam(description = "Agent ID") Long agentId,
@ToolParam(description = "Topic to compile a page about (natural language)") String topic,
@ToolParam(description = "Optional explicit slug for the page", required = false) String slug,
@ToolParam(description = "Max evidence chunks (default 8, max 20)", required = false) Integer maxEvidenceChunks) {
@ToolParam(description = "Max evidence chunks (default 8, max 20)", required = false) Integer maxEvidenceChunks,
@ToolParam(description = "Target knowledge base name (from wiki_list_kbs). Omit to use the agent's primary KB.", required = false) String kbName) {
if (topic == null || topic.isBlank()) {
return error("topic is required");
}
Long kbId = resolveKbId(agentId);
if (kbId == null) return error("No wiki knowledge base found for this agent");
Long kbId = resolveKbId(agentId, kbName);
if (kbId == null) return noKbError(kbName);
if (compileService == null) return error("Compile service not available");
try {
@ -439,11 +490,12 @@ public class WikiTool {
public String wiki_read_many(
@ToolParam(description = "Agent ID") Long agentId,
@ToolParam(description = "Comma-separated slugs (max 10)") String slugs,
@ToolParam(description = "Max chars returned per page (default 2000, max 8000)", required = false) Integer maxCharsPerPage) {
@ToolParam(description = "Max chars returned per page (default 2000, max 8000)", required = false) Integer maxCharsPerPage,
@ToolParam(description = "Target knowledge base name (from wiki_list_kbs). Omit to use the agent's primary KB.", required = false) String kbName) {
if (slugs == null || slugs.isBlank()) return error("slugs is required");
Long kbId = resolveKbId(agentId);
if (kbId == null) return error("No wiki knowledge base found for this agent");
Long kbId = resolveKbId(agentId, kbName);
if (kbId == null) return noKbError(kbName);
int cap = (maxCharsPerPage == null || maxCharsPerPage <= 0) ? 2000 : Math.min(8000, maxCharsPerPage);
List<String> slugList = Arrays.stream(slugs.split(","))
@ -484,8 +536,9 @@ public class WikiTool {
""")
public String wiki_archive_page(
@ToolParam(description = "Agent ID") Long agentId,
@ToolParam(description = "Page slug to archive") String slug) {
return setArchivedTool(agentId, slug, true, "archived");
@ToolParam(description = "Page slug to archive") String slug,
@ToolParam(description = "Target knowledge base name (from wiki_list_kbs). Omit to use the agent's primary KB.", required = false) String kbName) {
return setArchivedTool(agentId, slug, true, "archived", kbName);
}
@Tool(description = """
@ -494,14 +547,15 @@ public class WikiTool {
""")
public String wiki_unarchive_page(
@ToolParam(description = "Agent ID") Long agentId,
@ToolParam(description = "Page slug to unarchive") String slug) {
return setArchivedTool(agentId, slug, false, "unarchived");
@ToolParam(description = "Page slug to unarchive") String slug,
@ToolParam(description = "Target knowledge base name (from wiki_list_kbs). Omit to use the agent's primary KB.", required = false) String kbName) {
return setArchivedTool(agentId, slug, false, "unarchived", kbName);
}
private String setArchivedTool(Long agentId, String slug, boolean archive, String verb) {
private String setArchivedTool(Long agentId, String slug, boolean archive, String verb, String kbName) {
if (slug == null || slug.isBlank()) return error("slug is required");
Long kbId = resolveKbId(agentId);
if (kbId == null) return error("No wiki knowledge base found for this agent");
Long kbId = resolveKbId(agentId, kbName);
if (kbId == null) return noKbError(kbName);
boolean changed;
try {
changed = pageService.setArchived(kbId, slug, archive);
@ -521,15 +575,16 @@ public class WikiTool {
""")
public String wiki_delete_page(
@ToolParam(description = "Agent ID") Long agentId,
@ToolParam(description = "Page slug to delete") String slug) {
@ToolParam(description = "Page slug to delete") String slug,
@ToolParam(description = "Target knowledge base name (from wiki_list_kbs). Omit to use the agent's primary KB.", required = false) String kbName) {
if (slug == null || slug.isBlank()) {
return error("slug is required");
}
Long kbId = resolveKbId(agentId);
Long kbId = resolveKbId(agentId, kbName);
if (kbId == null) {
return error("No wiki knowledge base found for this agent");
return noKbError(kbName);
}
WikiPageEntity page = pageService.getBySlug(kbId, slug);
@ -568,10 +623,11 @@ public class WikiTool {
public String wiki_related_pages(
@ToolParam(description = "Agent ID") Long agentId,
@ToolParam(description = "Page slug") String slug,
@ToolParam(description = "Max results (default 5, max 10)", required = false) Integer topK) {
@ToolParam(description = "Max results (default 5, max 10)", required = false) Integer topK,
@ToolParam(description = "Target knowledge base name (from wiki_list_kbs). Omit to use the agent's primary KB.", required = false) String kbName) {
Long kbId = resolveKbId(agentId);
if (kbId == null) return error("No wiki knowledge base found for this agent");
Long kbId = resolveKbId(agentId, kbName);
if (kbId == null) return noKbError(kbName);
if (relationService == null) return error("Relation service not available");
int k = (topK != null && topK > 0) ? Math.min(topK, 10) : 5;
@ -599,10 +655,11 @@ public class WikiTool {
public String wiki_explain_relation(
@ToolParam(description = "Agent ID") Long agentId,
@ToolParam(description = "First page slug") String slugA,
@ToolParam(description = "Second page slug") String slugB) {
@ToolParam(description = "Second page slug") String slugB,
@ToolParam(description = "Target knowledge base name (from wiki_list_kbs). Omit to use the agent's primary KB.", required = false) String kbName) {
Long kbId = resolveKbId(agentId);
if (kbId == null) return error("No wiki knowledge base found for this agent");
Long kbId = resolveKbId(agentId, kbName);
if (kbId == null) return noKbError(kbName);
if (relationService == null) return error("Relation service not available");
RelationExplanation ex = relationService.explain(kbId, slugA, slugB);
@ -623,10 +680,11 @@ public class WikiTool {
""")
public String wiki_enrich_page(
@ToolParam(description = "Agent ID") Long agentId,
@ToolParam(description = "Page slug") String slug) {
@ToolParam(description = "Page slug") String slug,
@ToolParam(description = "Target knowledge base name (from wiki_list_kbs). Omit to use the agent's primary KB.", required = false) String kbName) {
Long kbId = resolveKbId(agentId);
if (kbId == null) return error("No wiki knowledge base found for this agent");
Long kbId = resolveKbId(agentId, kbName);
if (kbId == null) return noKbError(kbName);
if (jobService == null || eventPublisher == null) return error("Job service not available");
WikiPageEntity page = pageService.getBySlug(kbId, slug);
@ -653,9 +711,10 @@ public class WikiTool {
human title, and a description of what the prompt produces.
""")
public String wiki_list_transformations(
@ToolParam(description = "Agent ID") Long agentId) {
Long kbId = resolveKbId(agentId);
if (kbId == null) return error("No wiki knowledge base found for this agent");
@ToolParam(description = "Agent ID") Long agentId,
@ToolParam(description = "Target knowledge base name (from wiki_list_kbs). Omit to use the agent's primary KB.", required = false) String kbName) {
Long kbId = resolveKbId(agentId, kbName);
if (kbId == null) return noKbError(kbName);
if (transformationService == null) return error("Transformations not available");
WikiKnowledgeBaseEntity kb = kbService.getById(kbId);
@ -682,11 +741,12 @@ public class WikiTool {
public String wiki_apply_transformation(
@ToolParam(description = "Agent ID") Long agentId,
@ToolParam(description = "Transformation name (from wiki_list_transformations)") String name,
@ToolParam(description = "Raw material ID to run the transformation against") Long rawId) {
@ToolParam(description = "Raw material ID to run the transformation against") Long rawId,
@ToolParam(description = "Target knowledge base name (from wiki_list_kbs). Omit to use the agent's primary KB.", required = false) String kbName) {
if (name == null || name.isBlank()) return error("name is required");
if (rawId == null) return error("rawId is required");
Long kbId = resolveKbId(agentId);
if (kbId == null) return error("No wiki knowledge base found for this agent");
Long kbId = resolveKbId(agentId, kbName);
if (kbId == null) return noKbError(kbName);
if (transformationService == null || transformationExecutor == null) {
return error("Transformations not available");
}
@ -727,11 +787,12 @@ public class WikiTool {
public String wiki_apply_transformation_to_page(
@ToolParam(description = "Agent ID") Long agentId,
@ToolParam(description = "Transformation name (from wiki_list_transformations)") String name,
@ToolParam(description = "Source wiki page slug to run the transformation against") String slug) {
@ToolParam(description = "Source wiki page slug to run the transformation against") String slug,
@ToolParam(description = "Target knowledge base name (from wiki_list_kbs). Omit to use the agent's primary KB.", required = false) String kbName) {
if (name == null || name.isBlank()) return error("name is required");
if (slug == null || slug.isBlank()) return error("slug is required");
Long kbId = resolveKbId(agentId);
if (kbId == null) return error("No wiki knowledge base found for this agent");
Long kbId = resolveKbId(agentId, kbName);
if (kbId == null) return noKbError(kbName);
if (transformationService == null || transformationExecutor == null) {
return error("Transformations not available");
}
@ -776,10 +837,11 @@ public class WikiTool {
""")
public String wiki_aggregate_transformation(
@ToolParam(description = "Agent ID") Long agentId,
@ToolParam(description = "Transformation name (from wiki_list_transformations)") String name) {
@ToolParam(description = "Transformation name (from wiki_list_transformations)") String name,
@ToolParam(description = "Target knowledge base name (from wiki_list_kbs). Omit to use the agent's primary KB.", required = false) String kbName) {
if (name == null || name.isBlank()) return error("name is required");
Long kbId = resolveKbId(agentId);
if (kbId == null) return error("No wiki knowledge base found for this agent");
Long kbId = resolveKbId(agentId, kbName);
if (kbId == null) return noKbError(kbName);
if (transformationService == null || transformationAggregator == null) {
return error("Transformations not available");
}
@ -818,8 +880,44 @@ public class WikiTool {
// ==================== Helpers ====================
private Long resolveKbId(Long agentId) {
WikiKnowledgeBaseEntity kb = kbService.resolvePrimaryKb(agentId);
return kb == null ? null : kb.getId();
return resolveKbId(agentId, null);
}
/**
* Resolve the KB a tool call should operate on, honouring an optional
* caller-supplied {@code kbName}.
* <ul>
* <li>{@code kbName} blank existing single-primary fallback
* ({@link WikiKnowledgeBaseService#resolvePrimaryKb}). Preserves
* the legacy single-KB UX for agents with only one accessible KB.</li>
* <li>{@code kbName} provided exact-name lookup restricted to the
* agent's visible KBs. Returns {@code null} when the name does
* not match a visible KB so the caller can emit a "use
* wiki_list_kbs" hint instead of silently routing to the primary
* (which is the surface of issue #224 LLM picks a non-primary
* KB but the tool still hits the primary one).</li>
* </ul>
*/
private Long resolveKbId(Long agentId, String kbName) {
if (kbName == null || kbName.isBlank()) {
WikiKnowledgeBaseEntity primary = kbService.resolvePrimaryKb(agentId);
return primary == null ? null : primary.getId();
}
WikiKnowledgeBaseEntity match = kbService.findByName(agentId, kbName);
return match == null ? null : match.getId();
}
/**
* Standardised "couldn't resolve KB" error. When the caller passed a
* non-blank {@code kbName} that didn't match, the message points them at
* {@code wiki_list_kbs} so the LLM has a clear next step.
*/
private String noKbError(String kbName) {
if (kbName != null && !kbName.isBlank()) {
return error("Knowledge base '" + kbName
+ "' not visible to this agent. Use wiki_list_kbs to see available KBs.");
}
return error("No wiki knowledge base found for this agent");
}
private JSONArray resolveSourceFiles(String sourceRawIdsJson) {

View File

@ -28,9 +28,14 @@ class WikiKnowledgeBaseServiceTest {
kbMapper, null, null, null, null, null);
private static WikiKnowledgeBaseEntity kb(long id, Long agentId) {
return kb(id, agentId, null);
}
private static WikiKnowledgeBaseEntity kb(long id, Long agentId, String name) {
WikiKnowledgeBaseEntity entity = new WikiKnowledgeBaseEntity();
entity.setId(id);
entity.setAgentId(agentId);
entity.setName(name);
return entity;
}
@ -64,4 +69,57 @@ class WikiKnowledgeBaseServiceTest {
assertThat(service.resolvePrimaryKb(7L)).isNull();
}
// ==================== findByName ====================
//
// The wiki tools added a kbName parameter so the LLM can target a
// non-primary KB. findByName is the resolution layer behind that
// parameter it must restrict the match to KBs visible to the agent
// and refuse to silently fall through to the primary on a miss, so a
// bad pick surfaces as a clear "use wiki_list_kbs" hint instead of
// routing to the wrong KB.
@Test
@DisplayName("findByName matches by exact name within the agent's visible set")
void findByNameMatchesVisibleKb() {
when(kbMapper.selectList(any())).thenReturn(List.of(
kb(900L, null, "Shared Docs"),
kb(100L, 7L, "Agent Personal KB")));
WikiKnowledgeBaseEntity hit = service.findByName(7L, "Agent Personal KB");
assertThat(hit).isNotNull();
assertThat(hit.getId()).isEqualTo(100L);
WikiKnowledgeBaseEntity sharedHit = service.findByName(7L, "Shared Docs");
assertThat(sharedHit).isNotNull();
assertThat(sharedHit.getId()).isEqualTo(900L);
}
@Test
@DisplayName("findByName returns null when name does not match any visible KB")
void findByNameMissReturnsNull() {
when(kbMapper.selectList(any())).thenReturn(List.of(
kb(900L, null, "Shared Docs"),
kb(100L, 7L, "Agent Personal KB")));
assertThat(service.findByName(7L, "Nonexistent KB")).isNull();
}
@Test
@DisplayName("findByName is case-sensitive — LLM must copy the name verbatim")
void findByNameIsCaseSensitive() {
when(kbMapper.selectList(any())).thenReturn(List.of(
kb(100L, 7L, "Agent Personal KB")));
assertThat(service.findByName(7L, "agent personal kb")).isNull();
assertThat(service.findByName(7L, "Agent Personal KB")).isNotNull();
}
@Test
@DisplayName("findByName returns null for blank / null kbName")
void findByNameBlankReturnsNull() {
assertThat(service.findByName(7L, null)).isNull();
assertThat(service.findByName(7L, "")).isNull();
assertThat(service.findByName(7L, " ")).isNull();
}
}

View File

@ -0,0 +1,178 @@
package vip.mate.wiki.tool;
import cn.hutool.json.JSONArray;
import cn.hutool.json.JSONObject;
import cn.hutool.json.JSONUtil;
import com.fasterxml.jackson.databind.ObjectMapper;
import org.junit.jupiter.api.DisplayName;
import org.junit.jupiter.api.Test;
import vip.mate.wiki.model.WikiKnowledgeBaseEntity;
import vip.mate.wiki.model.WikiPageEntity;
import vip.mate.wiki.service.HybridRetriever;
import vip.mate.wiki.service.WikiKnowledgeBaseService;
import vip.mate.wiki.service.WikiPageService;
import vip.mate.wiki.service.WikiRawMaterialService;
import java.util.List;
import static org.assertj.core.api.Assertions.assertThat;
import static org.mockito.ArgumentMatchers.eq;
import static org.mockito.Mockito.mock;
import static org.mockito.Mockito.when;
/**
* Routing-level coverage for {@link WikiTool#resolveKbId(Long, String)}
* (exercised through the public {@code wiki_list_pages} and
* {@code wiki_list_kbs} surfaces).
*
* <p>The behaviour these tests pin in place is the fix for the upstream
* "single-KB collapse" bug: when an agent reaches more than one knowledge
* base, every wiki tool used to silently operate on whichever KB the
* primary-fallback picked, with no way for the LLM to target a different
* one. The {@code kbName} parameter and {@code wiki_list_kbs} discovery
* tool together close that gap. These tests verify:
* <ol>
* <li>blank {@code kbName} routes to the primary KB (legacy behaviour
* preserved for single-KB agents);</li>
* <li>named {@code kbName} that matches a visible KB routes to that
* specific KB, not the primary;</li>
* <li>named {@code kbName} that doesn't match fail-closed error that
* names the bad pick and points the agent at
* {@code wiki_list_kbs} so the next attempt can pick a valid name;</li>
* <li>{@code wiki_list_kbs} surfaces every visible KB along with the
* {@code isPrimary} / {@code boundToAgent} flags the LLM needs to
* decide which to target.</li>
* </ol>
*/
class WikiToolKbNameRoutingTest {
private static final Long AGENT = 7L;
private static final long PRIMARY_KB = 100L;
private static final long OTHER_KB = 200L;
private final WikiPageService pageService = mock(WikiPageService.class);
private final WikiKnowledgeBaseService kbService = mock(WikiKnowledgeBaseService.class);
private final WikiRawMaterialService rawService = mock(WikiRawMaterialService.class);
private final HybridRetriever hybridRetriever = mock(HybridRetriever.class);
private final ObjectMapper objectMapper = new ObjectMapper();
private final WikiTool tool = new WikiTool(pageService, kbService, rawService,
hybridRetriever, objectMapper);
private static WikiKnowledgeBaseEntity kb(long id, String name, Long agentId) {
WikiKnowledgeBaseEntity entity = new WikiKnowledgeBaseEntity();
entity.setId(id);
entity.setName(name);
entity.setAgentId(agentId);
entity.setPageCount(0);
return entity;
}
private static WikiPageEntity page(String slug, String title) {
WikiPageEntity entity = new WikiPageEntity();
entity.setSlug(slug);
entity.setTitle(title);
entity.setPageType("user");
return entity;
}
/** Mock listSummaries to return KB-specific page slugs so the test can
* prove which KB the tool actually queried. */
private void wirePages() {
when(pageService.listSummaries(eq(PRIMARY_KB))).thenReturn(List.of(
page("primary-only-slug", "Primary KB Page")));
when(pageService.listSummaries(eq(OTHER_KB))).thenReturn(List.of(
page("other-only-slug", "Other KB Page")));
}
// ==================== wiki_list_pages routing ====================
@Test
@DisplayName("wiki_list_pages with blank kbName routes to the primary KB")
void blankKbNameRoutesToPrimary() {
wirePages();
when(kbService.resolvePrimaryKb(AGENT)).thenReturn(kb(PRIMARY_KB, "Primary", AGENT));
String json = tool.wiki_list_pages(AGENT, null, null);
JSONObject obj = JSONUtil.parseObj(json);
JSONArray pages = obj.getJSONArray("pages");
assertThat(pages).hasSize(1);
assertThat(pages.getJSONObject(0).getStr("slug")).isEqualTo("primary-only-slug");
}
@Test
@DisplayName("wiki_list_pages with a known kbName routes to that KB instead of the primary")
void namedKbNameRoutesToNamedKb() {
wirePages();
// Primary fallback still wired so the test would fail loudly if the
// tool silently used it despite a non-blank kbName.
when(kbService.resolvePrimaryKb(AGENT)).thenReturn(kb(PRIMARY_KB, "Primary", AGENT));
when(kbService.findByName(AGENT, "Other")).thenReturn(kb(OTHER_KB, "Other", null));
String json = tool.wiki_list_pages(AGENT, null, "Other");
JSONObject obj = JSONUtil.parseObj(json);
JSONArray pages = obj.getJSONArray("pages");
assertThat(pages).hasSize(1);
assertThat(pages.getJSONObject(0).getStr("slug")).isEqualTo("other-only-slug");
}
@Test
@DisplayName("wiki_list_pages with an unknown kbName fails closed and names the bad pick")
void unknownKbNameFailsClosed() {
when(kbService.findByName(AGENT, "Bogus")).thenReturn(null);
// Primary still mockable; the routing must NOT silently fall through.
when(kbService.resolvePrimaryKb(AGENT)).thenReturn(kb(PRIMARY_KB, "Primary", AGENT));
String json = tool.wiki_list_pages(AGENT, null, "Bogus");
JSONObject obj = JSONUtil.parseObj(json);
String err = obj.getStr("error");
assertThat(err)
.as("error message should name the bad pick and point at wiki_list_kbs")
.contains("Bogus")
.contains("wiki_list_kbs");
}
@Test
@DisplayName("wiki_list_pages with no resolvable KB at all emits the no-KB error (legacy path)")
void noResolvableKbReturnsLegacyError() {
when(kbService.resolvePrimaryKb(AGENT)).thenReturn(null);
String json = tool.wiki_list_pages(AGENT, null, null);
JSONObject obj = JSONUtil.parseObj(json);
assertThat(obj.getStr("error")).contains("No wiki knowledge base found");
}
// ==================== wiki_list_kbs ====================
@Test
@DisplayName("wiki_list_kbs enumerates every visible KB with isPrimary / boundToAgent")
void wikiListKbsEnumeratesAll() {
when(kbService.listByAgentId(AGENT)).thenReturn(List.of(
kb(OTHER_KB, "Other", null),
kb(PRIMARY_KB, "Primary", AGENT)));
when(kbService.resolvePrimaryKb(AGENT)).thenReturn(kb(PRIMARY_KB, "Primary", AGENT));
String json = tool.wiki_list_kbs(AGENT);
JSONObject obj = JSONUtil.parseObj(json);
assertThat(obj.getInt("kbCount")).isEqualTo(2);
assertThat(obj.getStr("primary")).isEqualTo("Primary");
JSONArray kbs = obj.getJSONArray("kbs");
assertThat(kbs).hasSize(2);
JSONObject other = kbs.getJSONObject(0);
assertThat(other.getStr("name")).isEqualTo("Other");
assertThat(other.getBool("isPrimary")).isFalse();
assertThat(other.getBool("boundToAgent")).isFalse();
JSONObject primary = kbs.getJSONObject(1);
assertThat(primary.getStr("name")).isEqualTo("Primary");
assertThat(primary.getBool("isPrimary")).isTrue();
assertThat(primary.getBool("boundToAgent")).isTrue();
}
}