diff --git a/mateclaw-server/src/main/java/vip/mate/channel/ChannelMessageRouter.java b/mateclaw-server/src/main/java/vip/mate/channel/ChannelMessageRouter.java index 81398692..975be499 100644 --- a/mateclaw-server/src/main/java/vip/mate/channel/ChannelMessageRouter.java +++ b/mateclaw-server/src/main/java/vip/mate/channel/ChannelMessageRouter.java @@ -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; diff --git a/mateclaw-server/src/main/java/vip/mate/wiki/service/WikiContextService.java b/mateclaw-server/src/main/java/vip/mate/wiki/service/WikiContextService.java index cfec94f7..e2969a18 100644 --- a/mateclaw-server/src/main/java/vip/mate/wiki/service/WikiContextService.java +++ b/mateclaw-server/src/main/java/vip/mate/wiki/service/WikiContextService.java @@ -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 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(""); return sb.toString(); diff --git a/mateclaw-server/src/main/java/vip/mate/wiki/service/WikiKnowledgeBaseService.java b/mateclaw-server/src/main/java/vip/mate/wiki/service/WikiKnowledgeBaseService.java index bb142cab..e82866fb 100644 --- a/mateclaw-server/src/main/java/vip/mate/wiki/service/WikiKnowledgeBaseService.java +++ b/mateclaw-server/src/main/java/vip/mate/wiki/service/WikiKnowledgeBaseService.java @@ -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. + *

+ * 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); } diff --git a/mateclaw-server/src/main/java/vip/mate/wiki/tool/WikiTool.java b/mateclaw-server/src/main/java/vip/mate/wiki/tool/WikiTool.java index 93fea5d5..4b1d5dbc 100644 --- a/mateclaw-server/src/main/java/vip/mate/wiki/tool/WikiTool.java +++ b/mateclaw-server/src/main/java/vip/mate/wiki/tool/WikiTool.java @@ -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 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 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 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}. + *

+ */ + 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) { diff --git a/mateclaw-server/src/test/java/vip/mate/wiki/service/WikiKnowledgeBaseServiceTest.java b/mateclaw-server/src/test/java/vip/mate/wiki/service/WikiKnowledgeBaseServiceTest.java index a012823c..7713f89e 100644 --- a/mateclaw-server/src/test/java/vip/mate/wiki/service/WikiKnowledgeBaseServiceTest.java +++ b/mateclaw-server/src/test/java/vip/mate/wiki/service/WikiKnowledgeBaseServiceTest.java @@ -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(); + } } diff --git a/mateclaw-server/src/test/java/vip/mate/wiki/tool/WikiToolKbNameRoutingTest.java b/mateclaw-server/src/test/java/vip/mate/wiki/tool/WikiToolKbNameRoutingTest.java new file mode 100644 index 00000000..d09884a3 --- /dev/null +++ b/mateclaw-server/src/test/java/vip/mate/wiki/tool/WikiToolKbNameRoutingTest.java @@ -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). + * + *

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

    + *
  1. blank {@code kbName} → routes to the primary KB (legacy behaviour + * preserved for single-KB agents);
  2. + *
  3. named {@code kbName} that matches a visible KB → routes to that + * specific KB, not the primary;
  4. + *
  5. 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;
  6. + *
  7. {@code wiki_list_kbs} surfaces every visible KB along with the + * {@code isPrimary} / {@code boundToAgent} flags the LLM needs to + * decide which to target.
  8. + *
+ */ +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(); + } +}