fix(wiki): harden kbName/kbId routing — ambiguous fail-closed, kbId param, prompt cleanup (#224)

This commit is contained in:
matevip 2026-05-26 14:31:07 +08:00
parent d2b23c049c
commit ff2620dfcf
7 changed files with 714 additions and 146 deletions

View File

@ -138,25 +138,29 @@ 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.
// Each KB renders as a HEADING-ONLY block (### <name>) followed by a
// metadata line and its page list. The heading deliberately contains
// ONLY the KB name no em-dash, no parenthesised page count, no
// description so the LLM can safely copy the entire post-### text
// verbatim into the `kbName` tool argument. The previous form
// "### <name> — <description> (N pages)" let the LLM paste the
// whole row into kbName and break the exact-match lookup.
boolean multipleKbs = kbs.size() > 1;
for (WikiKnowledgeBaseEntity kb : kbs) {
List<WikiPageEntity> pages = pageService.listSummaries(kb.getId());
if (pages.isEmpty()) continue;
sb.append("### ").append(kb.getName());
// Heading: pure KB name. This is what `kbName` expects verbatim.
sb.append("### ").append(kb.getName()).append("\n");
// Metadata line: page count first (easy to scan), then optional
// description. Lives on its own line so it can't be confused for
// part of the name.
sb.append(pages.size()).append(" pages");
if (kb.getDescription() != null && !kb.getDescription().isBlank()) {
sb.append("").append(kb.getDescription());
}
sb.append(" (").append(pages.size()).append(" pages)\n\n");
sb.append("\n\n");
boolean compact = pages.size() > 20;
@ -183,11 +187,12 @@ 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("Multiple knowledge bases visible — every wiki tool takes an ")
.append("optional `kbName` argument. Set it to the EXACT text after ")
.append("`### ` on the heading line (do NOT include the page count or ")
.append("description). When two KBs share a name, call wiki_list_kbs ")
.append("and pass `kbId` instead. Omit both and the tool falls back to ")
.append("the agent's primary KB, which may return 'page not found'.\n");
}
sb.append("</wiki-context>");

View File

@ -146,16 +146,46 @@ public class WikiKnowledgeBaseService {
* <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).
* when zero OR more than one KB matches the name; callers wanting to
* distinguish the two cases (so the LLM can be told to disambiguate by
* id) should call {@link #findAllByName} instead. The single-match
* convenience contract here keeps the legacy call sites simple.
*/
public WikiKnowledgeBaseEntity findByName(Long agentId, String kbName) {
List<WikiKnowledgeBaseEntity> matches = findAllByName(agentId, kbName);
return matches.size() == 1 ? matches.get(0) : null;
}
/**
* All KBs visible to {@code agentId} whose name matches {@code kbName}
* exactly. Returns an empty list when the name is blank or no KB matches;
* returns >1 entries when the workspace has duplicate KB names (no DB
* unique constraint protects against this), in which case the caller
* MUST disambiguate (typically by surfacing a kbId-based picker to the
* LLM) rather than silently picking the first one.
*/
public List<WikiKnowledgeBaseEntity> findAllByName(Long agentId, String kbName) {
if (kbName == null || kbName.isBlank()) {
return null;
return List.of();
}
List<WikiKnowledgeBaseEntity> out = new java.util.ArrayList<>();
for (WikiKnowledgeBaseEntity kb : listByAgentId(agentId)) {
if (kbName.equals(kb.getName())) {
out.add(kb);
}
}
return out;
}
/**
* Resolve a KB by id, but ONLY when it's in the agent's visibility set
* a deliberate fail-closed gate so an LLM cannot pivot to an arbitrary KB
* by guessing or scraping an id from someone else's workspace.
*/
public WikiKnowledgeBaseEntity findVisibleById(Long agentId, Long kbId) {
if (kbId == null) return null;
for (WikiKnowledgeBaseEntity kb : listByAgentId(agentId)) {
if (kbId.equals(kb.getId())) {
return kb;
}
}

View File

@ -31,7 +31,14 @@ import java.util.stream.Collectors;
/**
* Wiki knowledge base tools for agent conversations.
* <p>
* All tools auto-resolve kbId from agentId; LLM never needs to pass it directly.
* Every tool resolves its target KB through a small precedence ladder:
* an explicit {@code kbId} from {@code wiki_list_kbs} wins outright, then
* an explicit {@code kbName} (with fail-closed "ambiguous"/"not visible"
* errors when needed), and only when both are absent does the tool fall
* back to the agent's primary KB. The {@code kbId} surface is public so
* the LLM can disambiguate duplicate-named KBs see
* {@link #wiki_list_kbs} and {@code WikiKnowledgeBaseService.findVisibleById}
* for the visibility gate.
*
* @author MateClaw Team
*/
@ -90,19 +97,21 @@ public class WikiTool {
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.
accepts two OPTIONAL routing arguments. Use them in this order:
1. `kbName` readable name from the output below. Easiest.
2. `kbId` numeric id from the output below. Use this when
two KBs share the same name and `kbName` returns
an "Ambiguous kbName" error.
Omit both and the tool falls back to the agent's primary KB,
which is fine when the agent only reaches one KB.
Output fields per KB:
- name copy verbatim into other tools' `kbName` param
- kbId string-encoded numeric id (use as `kbId` param)
- name copy verbatim into `kbName` param
- description operator-supplied summary
- pageCount number of pages currently in the KB
- isPrimary true for the KB used when `kbName` is omitted
- isPrimary true for the KB used when both routing
arguments are omitted
- boundToAgent true if the KB is explicitly bound to this agent
""")
public String wiki_list_kbs(
@ -113,7 +122,14 @@ public class WikiTool {
JSONArray arr = new JSONArray();
for (WikiKnowledgeBaseEntity kb : kbs) {
// kbId is rendered as a STRING per the workspace-wide Snowflake-
// precision rule: a 19-digit id round-tripped through a JSON
// number loses its last 2-3 digits whenever it touches a JS
// runtime. The LLM hands the value back to us through a Java
// Long @ToolParam, which is precision-safe, so the lossy hop
// is purely defensive.
arr.add(JSONUtil.createObj()
.set("kbId", String.valueOf(kb.getId()))
.set("name", kb.getName())
.set("description", kb.getDescription())
.set("pageCount", kb.getPageCount() == null ? 0 : kb.getPageCount())
@ -140,16 +156,16 @@ public class WikiTool {
@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 = "Target knowledge base name (from wiki_list_kbs). Omit to use the agent's primary KB.", required = false) String kbName) {
@ToolParam(description = "Target knowledge base name (from wiki_list_kbs). Omit to use the agent's primary KB; switch to `kbId` when two KBs share the name.", required = false) String kbName,
@ToolParam(description = "Numeric KB id from wiki_list_kbs. Use when `kbName` returns an ambiguous-name error.", required = false) Long kbId) {
if (slug == null || slug.isBlank()) {
return error("slug is required");
}
Long kbId = resolveKbId(agentId, kbName);
if (kbId == null) {
return noKbError(kbName);
}
KbResolution kbRes = resolveKb(agentId, kbName, kbId);
if (kbRes.hasError()) return kbRes.errorJson();
kbId = kbRes.kbId();
WikiPageEntity page = pageService.getBySlug(kbId, slug);
if (page == null) {
@ -186,12 +202,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 = "Target knowledge base name (from wiki_list_kbs). Omit to use the agent's primary KB.", required = false) String kbName) {
@ToolParam(description = "Target knowledge base name (from wiki_list_kbs). Omit to use the agent's primary KB; switch to `kbId` when two KBs share the name.", required = false) String kbName,
@ToolParam(description = "Numeric KB id from wiki_list_kbs. Use when `kbName` returns an ambiguous-name error.", required = false) Long kbId) {
Long kbId = resolveKbId(agentId, kbName);
if (kbId == null) {
return noKbError(kbName);
}
KbResolution kbRes = resolveKb(agentId, kbName, kbId);
if (kbRes.hasError()) return kbRes.errorJson();
kbId = kbRes.kbId();
List<WikiPageLite> pages;
if (query != null && !query.isBlank()) {
@ -244,16 +260,16 @@ public class WikiTool {
@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 = "Target knowledge base name (from wiki_list_kbs). Omit to use the agent's primary KB.", required = false) String kbName) {
@ToolParam(description = "Target knowledge base name (from wiki_list_kbs). Omit to use the agent's primary KB; switch to `kbId` when two KBs share the name.", required = false) String kbName,
@ToolParam(description = "Numeric KB id from wiki_list_kbs. Use when `kbName` returns an ambiguous-name error.", required = false) Long kbId) {
if (query == null || query.isBlank()) {
return error("query is required");
}
Long kbId = resolveKbId(agentId, kbName);
if (kbId == null) {
return noKbError(kbName);
}
KbResolution kbRes = resolveKb(agentId, kbName, kbId);
if (kbRes.hasError()) return kbRes.errorJson();
kbId = kbRes.kbId();
int k = (topK != null && topK > 0) ? Math.min(topK, 20) : 5;
List<PageSearchResult> results = hybridRetriever.search(kbId, query, mode, k);
@ -294,16 +310,16 @@ public class WikiTool {
@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 = "Target knowledge base name (from wiki_list_kbs). Omit to use the agent's primary KB.", required = false) String kbName) {
@ToolParam(description = "Target knowledge base name (from wiki_list_kbs). Omit to use the agent's primary KB; switch to `kbId` when two KBs share the name.", required = false) String kbName,
@ToolParam(description = "Numeric KB id from wiki_list_kbs. Use when `kbName` returns an ambiguous-name error.", required = false) Long kbId) {
if (query == null || query.isBlank()) {
return error("query is required");
}
Long kbId = resolveKbId(agentId, kbName);
if (kbId == null) {
return noKbError(kbName);
}
KbResolution kbRes = resolveKb(agentId, kbName, kbId);
if (kbRes.hasError()) return kbRes.errorJson();
kbId = kbRes.kbId();
int k = (topK != null && topK > 0) ? Math.min(topK, 20) : 5;
List<HybridRetriever.ChunkHit> hits = hybridRetriever.searchChunks(kbId, query, k);
@ -358,16 +374,16 @@ public class WikiTool {
public String wiki_trace_source(
@ToolParam(description = "Agent ID") Long agentId,
@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) {
@ToolParam(description = "Target knowledge base name (from wiki_list_kbs). Omit to use the agent's primary KB; switch to `kbId` when two KBs share the name.", required = false) String kbName,
@ToolParam(description = "Numeric KB id from wiki_list_kbs. Use when `kbName` returns an ambiguous-name error.", required = false) Long kbId) {
if (slug == null || slug.isBlank()) {
return error("slug is required");
}
Long kbId = resolveKbId(agentId, kbName);
if (kbId == null) {
return noKbError(kbName);
}
KbResolution kbRes = resolveKb(agentId, kbName, kbId);
if (kbRes.hasError()) return kbRes.errorJson();
kbId = kbRes.kbId();
WikiPageEntity page = pageService.getBySlug(kbId, slug);
if (page == null) {
@ -389,7 +405,8 @@ public class WikiTool {
@ToolParam(description = "Agent ID") Long agentId,
@ToolParam(description = "Page title") String title,
@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) {
@ToolParam(description = "Target knowledge base name (from wiki_list_kbs). Omit to use the agent's primary KB; switch to `kbId` when two KBs share the name.", required = false) String kbName,
@ToolParam(description = "Numeric KB id from wiki_list_kbs. Use when `kbName` returns an ambiguous-name error.", required = false) Long kbId) {
if (title == null || title.isBlank()) {
return error("title is required");
@ -398,10 +415,9 @@ public class WikiTool {
return error("content is required");
}
Long kbId = resolveKbId(agentId, kbName);
if (kbId == null) {
return noKbError(kbName);
}
KbResolution kbRes = resolveKb(agentId, kbName, kbId);
if (kbRes.hasError()) return kbRes.errorJson();
kbId = kbRes.kbId();
String slug = title.toLowerCase()
.replaceAll("[^a-z0-9\\u4e00-\\u9fff]+", "-")
@ -443,13 +459,15 @@ public class WikiTool {
@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 = "Target knowledge base name (from wiki_list_kbs). Omit to use the agent's primary KB.", required = false) String kbName) {
@ToolParam(description = "Target knowledge base name (from wiki_list_kbs). Omit to use the agent's primary KB; switch to `kbId` when two KBs share the name.", required = false) String kbName,
@ToolParam(description = "Numeric KB id from wiki_list_kbs. Use when `kbName` returns an ambiguous-name error.", required = false) Long kbId) {
if (topic == null || topic.isBlank()) {
return error("topic is required");
}
Long kbId = resolveKbId(agentId, kbName);
if (kbId == null) return noKbError(kbName);
KbResolution kbRes = resolveKb(agentId, kbName, kbId);
if (kbRes.hasError()) return kbRes.errorJson();
kbId = kbRes.kbId();
if (compileService == null) return error("Compile service not available");
try {
@ -491,11 +509,13 @@ public class WikiTool {
@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 = "Target knowledge base name (from wiki_list_kbs). Omit to use the agent's primary KB.", required = false) String kbName) {
@ToolParam(description = "Target knowledge base name (from wiki_list_kbs). Omit to use the agent's primary KB; switch to `kbId` when two KBs share the name.", required = false) String kbName,
@ToolParam(description = "Numeric KB id from wiki_list_kbs. Use when `kbName` returns an ambiguous-name error.", required = false) Long kbId) {
if (slugs == null || slugs.isBlank()) return error("slugs is required");
Long kbId = resolveKbId(agentId, kbName);
if (kbId == null) return noKbError(kbName);
KbResolution kbRes = resolveKb(agentId, kbName, kbId);
if (kbRes.hasError()) return kbRes.errorJson();
kbId = kbRes.kbId();
int cap = (maxCharsPerPage == null || maxCharsPerPage <= 0) ? 2000 : Math.min(8000, maxCharsPerPage);
List<String> slugList = Arrays.stream(slugs.split(","))
@ -537,8 +557,9 @@ public class WikiTool {
public String wiki_archive_page(
@ToolParam(description = "Agent ID") Long agentId,
@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);
@ToolParam(description = "Target knowledge base name (from wiki_list_kbs). Omit to use the agent's primary KB; switch to `kbId` when two KBs share the name.", required = false) String kbName,
@ToolParam(description = "Numeric KB id from wiki_list_kbs. Use when `kbName` returns an ambiguous-name error.", required = false) Long kbId) {
return setArchivedTool(agentId, slug, true, "archived", kbName, kbId);
}
@Tool(description = """
@ -548,14 +569,16 @@ public class WikiTool {
public String wiki_unarchive_page(
@ToolParam(description = "Agent ID") Long agentId,
@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);
@ToolParam(description = "Target knowledge base name (from wiki_list_kbs). Omit to use the agent's primary KB; switch to `kbId` when two KBs share the name.", required = false) String kbName,
@ToolParam(description = "Numeric KB id from wiki_list_kbs. Use when `kbName` returns an ambiguous-name error.", required = false) Long kbId) {
return setArchivedTool(agentId, slug, false, "unarchived", kbName, kbId);
}
private String setArchivedTool(Long agentId, String slug, boolean archive, String verb, String kbName) {
private String setArchivedTool(Long agentId, String slug, boolean archive, String verb, String kbName, Long kbId) {
if (slug == null || slug.isBlank()) return error("slug is required");
Long kbId = resolveKbId(agentId, kbName);
if (kbId == null) return noKbError(kbName);
KbResolution kbRes = resolveKb(agentId, kbName, kbId);
if (kbRes.hasError()) return kbRes.errorJson();
kbId = kbRes.kbId();
boolean changed;
try {
changed = pageService.setArchived(kbId, slug, archive);
@ -576,16 +599,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 = "Target knowledge base name (from wiki_list_kbs). Omit to use the agent's primary KB.", required = false) String kbName) {
@ToolParam(description = "Target knowledge base name (from wiki_list_kbs). Omit to use the agent's primary KB; switch to `kbId` when two KBs share the name.", required = false) String kbName,
@ToolParam(description = "Numeric KB id from wiki_list_kbs. Use when `kbName` returns an ambiguous-name error.", required = false) Long kbId) {
if (slug == null || slug.isBlank()) {
return error("slug is required");
}
Long kbId = resolveKbId(agentId, kbName);
if (kbId == null) {
return noKbError(kbName);
}
KbResolution kbRes = resolveKb(agentId, kbName, kbId);
if (kbRes.hasError()) return kbRes.errorJson();
kbId = kbRes.kbId();
WikiPageEntity page = pageService.getBySlug(kbId, slug);
if (page == null) {
@ -624,10 +647,12 @@ public class WikiTool {
@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 = "Target knowledge base name (from wiki_list_kbs). Omit to use the agent's primary KB.", required = false) String kbName) {
@ToolParam(description = "Target knowledge base name (from wiki_list_kbs). Omit to use the agent's primary KB; switch to `kbId` when two KBs share the name.", required = false) String kbName,
@ToolParam(description = "Numeric KB id from wiki_list_kbs. Use when `kbName` returns an ambiguous-name error.", required = false) Long kbId) {
Long kbId = resolveKbId(agentId, kbName);
if (kbId == null) return noKbError(kbName);
KbResolution kbRes = resolveKb(agentId, kbName, kbId);
if (kbRes.hasError()) return kbRes.errorJson();
kbId = kbRes.kbId();
if (relationService == null) return error("Relation service not available");
int k = (topK != null && topK > 0) ? Math.min(topK, 10) : 5;
@ -656,10 +681,12 @@ public class WikiTool {
@ToolParam(description = "Agent ID") Long agentId,
@ToolParam(description = "First page slug") String slugA,
@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) {
@ToolParam(description = "Target knowledge base name (from wiki_list_kbs). Omit to use the agent's primary KB; switch to `kbId` when two KBs share the name.", required = false) String kbName,
@ToolParam(description = "Numeric KB id from wiki_list_kbs. Use when `kbName` returns an ambiguous-name error.", required = false) Long kbId) {
Long kbId = resolveKbId(agentId, kbName);
if (kbId == null) return noKbError(kbName);
KbResolution kbRes = resolveKb(agentId, kbName, kbId);
if (kbRes.hasError()) return kbRes.errorJson();
kbId = kbRes.kbId();
if (relationService == null) return error("Relation service not available");
RelationExplanation ex = relationService.explain(kbId, slugA, slugB);
@ -681,10 +708,12 @@ public class WikiTool {
public String wiki_enrich_page(
@ToolParam(description = "Agent ID") Long agentId,
@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) {
@ToolParam(description = "Target knowledge base name (from wiki_list_kbs). Omit to use the agent's primary KB; switch to `kbId` when two KBs share the name.", required = false) String kbName,
@ToolParam(description = "Numeric KB id from wiki_list_kbs. Use when `kbName` returns an ambiguous-name error.", required = false) Long kbId) {
Long kbId = resolveKbId(agentId, kbName);
if (kbId == null) return noKbError(kbName);
KbResolution kbRes = resolveKb(agentId, kbName, kbId);
if (kbRes.hasError()) return kbRes.errorJson();
kbId = kbRes.kbId();
if (jobService == null || eventPublisher == null) return error("Job service not available");
WikiPageEntity page = pageService.getBySlug(kbId, slug);
@ -712,9 +741,11 @@ public class WikiTool {
""")
public String wiki_list_transformations(
@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);
@ToolParam(description = "Target knowledge base name (from wiki_list_kbs). Omit to use the agent's primary KB; switch to `kbId` when two KBs share the name.", required = false) String kbName,
@ToolParam(description = "Numeric KB id from wiki_list_kbs. Use when `kbName` returns an ambiguous-name error.", required = false) Long kbId) {
KbResolution kbRes = resolveKb(agentId, kbName, kbId);
if (kbRes.hasError()) return kbRes.errorJson();
kbId = kbRes.kbId();
if (transformationService == null) return error("Transformations not available");
WikiKnowledgeBaseEntity kb = kbService.getById(kbId);
@ -742,11 +773,13 @@ public class WikiTool {
@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 = "Target knowledge base name (from wiki_list_kbs). Omit to use the agent's primary KB.", required = false) String kbName) {
@ToolParam(description = "Target knowledge base name (from wiki_list_kbs). Omit to use the agent's primary KB; switch to `kbId` when two KBs share the name.", required = false) String kbName,
@ToolParam(description = "Numeric KB id from wiki_list_kbs. Use when `kbName` returns an ambiguous-name error.", required = false) Long kbId) {
if (name == null || name.isBlank()) return error("name is required");
if (rawId == null) return error("rawId is required");
Long kbId = resolveKbId(agentId, kbName);
if (kbId == null) return noKbError(kbName);
KbResolution kbRes = resolveKb(agentId, kbName, kbId);
if (kbRes.hasError()) return kbRes.errorJson();
kbId = kbRes.kbId();
if (transformationService == null || transformationExecutor == null) {
return error("Transformations not available");
}
@ -788,11 +821,13 @@ public class WikiTool {
@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 = "Target knowledge base name (from wiki_list_kbs). Omit to use the agent's primary KB.", required = false) String kbName) {
@ToolParam(description = "Target knowledge base name (from wiki_list_kbs). Omit to use the agent's primary KB; switch to `kbId` when two KBs share the name.", required = false) String kbName,
@ToolParam(description = "Numeric KB id from wiki_list_kbs. Use when `kbName` returns an ambiguous-name error.", required = false) Long kbId) {
if (name == null || name.isBlank()) return error("name is required");
if (slug == null || slug.isBlank()) return error("slug is required");
Long kbId = resolveKbId(agentId, kbName);
if (kbId == null) return noKbError(kbName);
KbResolution kbRes = resolveKb(agentId, kbName, kbId);
if (kbRes.hasError()) return kbRes.errorJson();
kbId = kbRes.kbId();
if (transformationService == null || transformationExecutor == null) {
return error("Transformations not available");
}
@ -838,10 +873,12 @@ 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 = "Target knowledge base name (from wiki_list_kbs). Omit to use the agent's primary KB.", required = false) String kbName) {
@ToolParam(description = "Target knowledge base name (from wiki_list_kbs). Omit to use the agent's primary KB; switch to `kbId` when two KBs share the name.", required = false) String kbName,
@ToolParam(description = "Numeric KB id from wiki_list_kbs. Use when `kbName` returns an ambiguous-name error.", required = false) Long kbId) {
if (name == null || name.isBlank()) return error("name is required");
Long kbId = resolveKbId(agentId, kbName);
if (kbId == null) return noKbError(kbName);
KbResolution kbRes = resolveKb(agentId, kbName, kbId);
if (kbRes.hasError()) return kbRes.errorJson();
kbId = kbRes.kbId();
if (transformationService == null || transformationAggregator == null) {
return error("Transformations not available");
}
@ -880,37 +917,124 @@ public class WikiTool {
// ==================== Helpers ====================
private Long resolveKbId(Long agentId) {
return resolveKbId(agentId, null);
return resolveKbId(agentId, null, null);
}
/**
* Resolve the KB a tool call should operate on, honouring an optional
* caller-supplied {@code kbName}.
* Outcome of resolving a KB for a tool call. Exactly one of
* {@code kbId} / {@code errorJson} is non-null:
* <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>
* <li>{@code kbId} present caller proceeds with that KB.</li>
* <li>{@code errorJson} present caller returns it as-is so the LLM
* sees an unambiguous error pointing at the next action
* (call {@code wiki_list_kbs} / pick a different name / pass
* {@code kbId}).</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();
private record KbResolution(Long kbId, String errorJson) {
static KbResolution ok(Long id) { return new KbResolution(id, null); }
static KbResolution err(String json) { return new KbResolution(null, json); }
boolean hasError() { return errorJson != null; }
}
/**
* Single helper every wiki tool uses. Caller passes the agent id and at
* most one of {@code kbId} / {@code kbName}; the helper decides which
* KB the operation runs against and emits a uniform error when no
* unambiguous target can be picked.
*
* <p>Resolution rules (in order):
* <ol>
* <li>{@code kbId} non-null resolve only via
* {@link WikiKnowledgeBaseService#findVisibleById}. Out-of-visibility
* ids fail closed no silent fallback to the primary or to a
* same-name shared KB.</li>
* <li>{@code kbName} non-blank look up every visible KB with that
* name. Single match use it. Zero match fail closed pointing
* at {@code wiki_list_kbs}. Multiple matches fail closed with
* the list of candidate {@code kbId}s, telling the LLM to retry
* with {@code kbId}.</li>
* <li>Both blank fall back to
* {@link WikiKnowledgeBaseService#resolvePrimaryKb} so single-KB
* agents keep their old zero-config behaviour.</li>
* </ol>
*/
private KbResolution resolveKb(Long agentId, String kbName, Long kbId) {
// Treat kbId<=0 as "not supplied". Spring AI's @Tool JSON-schema
// generator doesn't carry the "optional, may be absent" semantic
// through to the LLM in the way Java would expect a nullable Long,
// so the model frequently fills unused numeric optionals with 0
// ("openai-chatgpt" was observed doing this on every wiki call).
// Real Snowflake ids are always 19-digit positive longs, so
// {0, negative} can be safely treated as the empty case.
if (kbId != null && kbId > 0L) {
WikiKnowledgeBaseEntity byId = kbService.findVisibleById(agentId, kbId);
if (byId == null) {
return KbResolution.err(error(
"Knowledge base id=" + kbId + " not visible to this agent. "
+ "Use wiki_list_kbs to see available KBs."));
}
return KbResolution.ok(byId.getId());
}
WikiKnowledgeBaseEntity match = kbService.findByName(agentId, kbName);
return match == null ? null : match.getId();
if (kbName != null && !kbName.isBlank()) {
List<WikiKnowledgeBaseEntity> matches = kbService.findAllByName(agentId, kbName);
if (matches.isEmpty()) {
return KbResolution.err(error(
"Knowledge base '" + kbName + "' not visible to this agent. "
+ "Use wiki_list_kbs to see available KBs."));
}
if (matches.size() > 1) {
// Duplicate names exist (no DB unique constraint). The LLM
// cannot disambiguate from kbName alone surface every
// candidate's id (as String per the Snowflake-precision
// contract) and demand a kbId retry.
JSONArray candidates = new JSONArray();
for (WikiKnowledgeBaseEntity kb : matches) {
candidates.add(JSONUtil.createObj()
.set("kbId", String.valueOf(kb.getId()))
.set("name", kb.getName())
.set("description", kb.getDescription())
.set("boundToAgent", kb.getAgentId() != null));
}
JSONObject obj = JSONUtil.createObj()
.set("error", "Ambiguous kbName '" + kbName + "' — "
+ matches.size() + " visible KBs share this name. "
+ "Retry with `kbId` from the candidates list below.")
.set("candidates", candidates);
return KbResolution.err(obj.toString());
}
return KbResolution.ok(matches.get(0).getId());
}
WikiKnowledgeBaseEntity primary = kbService.resolvePrimaryKb(agentId);
if (primary == null) {
return KbResolution.err(error("No wiki knowledge base found for this agent"));
}
return KbResolution.ok(primary.getId());
}
/**
* Legacy 2-arg routing kept for the tool methods that haven't been
* widened to accept {@code kbId} yet. Always returns null when the
* resolution would have surfaced an error callers turn that into a
* {@link #noKbError(String)} message.
*/
private Long resolveKbId(Long agentId, String kbName) {
return resolveKbId(agentId, kbName, null);
}
private Long resolveKbId(Long agentId, String kbName, Long kbId) {
KbResolution res = resolveKb(agentId, kbName, kbId);
return res.hasError() ? null : res.kbId();
}
/**
* 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.
*
* <p>NOTE: ambiguous-kbName errors are emitted directly by
* {@link #resolveKb} so the LLM also gets the candidate list, not just
* a flat string. This helper handles the simpler "not visible" case.
*/
private String noKbError(String kbName) {
if (kbName != null && !kbName.isBlank()) {

View File

@ -6,6 +6,7 @@ import org.junit.jupiter.api.Test;
import vip.mate.wiki.WikiProperties;
import vip.mate.wiki.dto.PageSearchResult;
import vip.mate.wiki.model.WikiKnowledgeBaseEntity;
import vip.mate.wiki.model.WikiPageEntity;
import java.util.List;
@ -127,4 +128,85 @@ class WikiContextServiceTest {
return PageSearchResult.of(slug, slug, snippet, snippet,
List.of("keyword"), null, score);
}
// ==================== buildWikiContext heading + hint format ====================
//
// These tests lock in the unambiguous heading layout: heading text after
// `### ` MUST equal the KB name verbatim and nothing more, so the LLM
// can safely copy it into the `kbName` tool argument. The previous form
// "### {name} — {description} ({N} pages)" let the LLM paste the entire
// row and break findByName's exact-match lookup. The multi-KB hint must
// also call out kbId as the disambiguator for duplicate names.
private static WikiKnowledgeBaseEntity kbWithName(long id, String name, String description, Long agentId) {
WikiKnowledgeBaseEntity kb = new WikiKnowledgeBaseEntity();
kb.setId(id);
kb.setName(name);
kb.setDescription(description);
kb.setAgentId(agentId);
kb.setPageCount(0);
return kb;
}
private static WikiPageEntity simplePage(String slug, String title, String summary) {
WikiPageEntity p = new WikiPageEntity();
p.setSlug(slug);
p.setTitle(title);
p.setSummary(summary);
p.setPageType("user");
return p;
}
@Test
@DisplayName("buildWikiContext heading is JUST the KB name — no description, no page count")
void buildWikiContextHeadingIsBareName() {
WikiKnowledgeBaseEntity kb = kbWithName(100L, "QA-Bug-Test KB",
"A KB created via UI E2E test to surface wiki bugs", null);
when(kbService.listByAgentId(1L)).thenReturn(List.of(kb));
when(pageService.listSummaries(100L)).thenReturn(List.of(
simplePage("mateclaw", "MateClaw", "Entry page")));
String out = service.buildWikiContext(1L);
// Heading line is exact pasting this into kbName must work without trim/strip.
assertThat(out).contains("### QA-Bug-Test KB\n");
// Description and page count live on the next line, not in the heading.
assertThat(out).doesNotContain("### QA-Bug-Test KB —");
assertThat(out).doesNotContain("### QA-Bug-Test KB (");
assertThat(out).contains("1 pages — A KB created via UI E2E test");
}
@Test
@DisplayName("buildWikiContext multi-KB hint mentions kbName + kbId + wiki_list_kbs")
void buildWikiContextMultiKbHint() {
when(kbService.listByAgentId(1L)).thenReturn(List.of(
kbWithName(100L, "Alpha", null, null),
kbWithName(200L, "Beta", null, null)));
when(pageService.listSummaries(100L)).thenReturn(List.of(simplePage("a", "A", null)));
when(pageService.listSummaries(200L)).thenReturn(List.of(simplePage("b", "B", null)));
String out = service.buildWikiContext(1L);
// Hint must point the LLM at the right argument and at the
// disambiguator for duplicate names.
assertThat(out)
.contains("kbName")
.contains("kbId")
.contains("wiki_list_kbs")
.contains("EXACT text after `### `");
}
@Test
@DisplayName("buildWikiContext single-KB output omits the multi-KB hint")
void buildWikiContextSingleKbSkipsHint() {
WikiKnowledgeBaseEntity kb = kbWithName(100L, "Solo", null, null);
when(kbService.listByAgentId(1L)).thenReturn(List.of(kb));
when(pageService.listSummaries(100L)).thenReturn(List.of(simplePage("a", "A", null)));
String out = service.buildWikiContext(1L);
// The "multiple knowledge bases" hint is wasted prompt budget when
// there's only one KB; it must stay off.
assertThat(out).doesNotContain("Multiple knowledge bases visible");
}
}

View File

@ -122,4 +122,62 @@ class WikiKnowledgeBaseServiceTest {
assertThat(service.findByName(7L, "")).isNull();
assertThat(service.findByName(7L, " ")).isNull();
}
// ==================== findByName ambiguity + findAllByName + findVisibleById ====================
//
// mate_wiki_knowledge_base has no unique constraint on name (one DB row
// per workspace + (name nullable + duplicates allowed) by design), so
// a non-blank kbName can match more than one visible KB. The single-
// result findByName must not silently pick "the first one" in that
// case callers route through findAllByName + an ambiguous-error
// surface so the LLM is forced to disambiguate by kbId.
@Test
@DisplayName("findByName returns null when more than one visible KB shares the name")
void findByNameAmbiguousReturnsNull() {
when(kbMapper.selectList(any())).thenReturn(List.of(
kb(100L, 7L, "Docs"),
kb(900L, null, "Docs")));
assertThat(service.findByName(7L, "Docs"))
.as("ambiguous matches collapse to null — caller must use findAllByName")
.isNull();
}
@Test
@DisplayName("findAllByName returns every visible KB sharing the name")
void findAllByNameReturnsAllMatches() {
when(kbMapper.selectList(any())).thenReturn(List.of(
kb(100L, 7L, "Docs"),
kb(900L, null, "Docs"),
kb(800L, null, "Other")));
List<WikiKnowledgeBaseEntity> hits = service.findAllByName(7L, "Docs");
assertThat(hits).hasSize(2);
assertThat(hits).extracting(WikiKnowledgeBaseEntity::getId).containsExactly(100L, 900L);
}
@Test
@DisplayName("findAllByName returns empty for blank kbName")
void findAllByNameBlankReturnsEmpty() {
assertThat(service.findAllByName(7L, null)).isEmpty();
assertThat(service.findAllByName(7L, " ")).isEmpty();
}
@Test
@DisplayName("findVisibleById returns the KB only when it is in the agent's visibility set")
void findVisibleByIdGate() {
when(kbMapper.selectList(any())).thenReturn(List.of(
kb(100L, 7L, "Bound KB"),
kb(900L, null, "Shared KB")));
// Visible: returned.
assertThat(service.findVisibleById(7L, 100L)).isNotNull();
assertThat(service.findVisibleById(7L, 900L)).isNotNull();
// Not in visibility set: deliberate fail-closed gate so an LLM
// can't pivot to an arbitrary KB by guessing an id.
assertThat(service.findVisibleById(7L, 99999L)).isNull();
assertThat(service.findVisibleById(7L, null)).isNull();
}
}

View File

@ -21,27 +21,31 @@ 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).
* Routing-level coverage for the {@code kbName} / {@code kbId} resolver
* shared by every wiki tool (exercised through {@code wiki_list_pages} and
* {@code wiki_list_kbs}).
*
* <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:
* one. The fix shipped in three layers:
* <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>
* <li>blank {@code kbName} + blank {@code kbId} still routes to the
* primary KB so the single-KB UX stays zero-config;</li>
* <li>named {@code kbName} that matches exactly one visible KB routes
* to that KB, not the primary;</li>
* <li>named {@code kbName} that doesn't match any visible KB fail-closed
* error naming the bad pick and pointing at {@code wiki_list_kbs};</li>
* <li>named {@code kbName} that matches MORE than one visible KB (the
* schema has no unique constraint on KB name) fail-closed error
* listing every candidate's {@code kbId} so the LLM can retry via
* {@code kbId} instead;</li>
* <li>{@code kbId} provided uses {@link WikiKnowledgeBaseService#findVisibleById}
* (visibility gate enforced; out-of-set ids fail closed);</li>
* <li>{@code wiki_list_kbs} surfaces every visible KB with {@code kbId}
* rendered as a String (workspace-wide Snowflake-precision rule),
* plus {@code isPrimary} / {@code boundToAgent} flags.</li>
* </ol>
*/
class WikiToolKbNameRoutingTest {
@ -49,6 +53,8 @@ class WikiToolKbNameRoutingTest {
private static final Long AGENT = 7L;
private static final long PRIMARY_KB = 100L;
private static final long OTHER_KB = 200L;
private static final long DUP_BOUND_KB = 300L;
private static final long DUP_SHARED_KB = 400L;
private final WikiPageService pageService = mock(WikiPageService.class);
private final WikiKnowledgeBaseService kbService = mock(WikiKnowledgeBaseService.class);
@ -83,17 +89,21 @@ class WikiToolKbNameRoutingTest {
page("primary-only-slug", "Primary KB Page")));
when(pageService.listSummaries(eq(OTHER_KB))).thenReturn(List.of(
page("other-only-slug", "Other KB Page")));
when(pageService.listSummaries(eq(DUP_BOUND_KB))).thenReturn(List.of(
page("dup-bound-slug", "Bound Docs Page")));
when(pageService.listSummaries(eq(DUP_SHARED_KB))).thenReturn(List.of(
page("dup-shared-slug", "Shared Docs Page")));
}
// ==================== wiki_list_pages routing ====================
// ==================== wiki_list_pages routing happy paths ====================
@Test
@DisplayName("wiki_list_pages with blank kbName routes to the primary KB")
void blankKbNameRoutesToPrimary() {
@DisplayName("blank kbName + blank kbId routes to the primary KB")
void blankRoutesToPrimary() {
wirePages();
when(kbService.resolvePrimaryKb(AGENT)).thenReturn(kb(PRIMARY_KB, "Primary", AGENT));
String json = tool.wiki_list_pages(AGENT, null, null);
String json = tool.wiki_list_pages(AGENT, null, null, null);
JSONObject obj = JSONUtil.parseObj(json);
JSONArray pages = obj.getJSONArray("pages");
@ -102,15 +112,15 @@ class WikiToolKbNameRoutingTest {
}
@Test
@DisplayName("wiki_list_pages with a known kbName routes to that KB instead of the primary")
@DisplayName("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));
when(kbService.findAllByName(AGENT, "Other")).thenReturn(List.of(kb(OTHER_KB, "Other", null)));
String json = tool.wiki_list_pages(AGENT, null, "Other");
String json = tool.wiki_list_pages(AGENT, null, "Other", null);
JSONObject obj = JSONUtil.parseObj(json);
JSONArray pages = obj.getJSONArray("pages");
@ -119,28 +129,120 @@ class WikiToolKbNameRoutingTest {
}
@Test
@DisplayName("wiki_list_pages with an unknown kbName fails closed and names the bad pick")
@DisplayName("kbId routes through the visibility gate to that KB")
void kbIdRoutesViaVisibilityGate() {
wirePages();
when(kbService.resolvePrimaryKb(AGENT)).thenReturn(kb(PRIMARY_KB, "Primary", AGENT));
when(kbService.findVisibleById(AGENT, OTHER_KB)).thenReturn(kb(OTHER_KB, "Other", null));
String json = tool.wiki_list_pages(AGENT, null, null, OTHER_KB);
JSONObject obj = JSONUtil.parseObj(json);
assertThat(obj.getJSONArray("pages").getJSONObject(0).getStr("slug"))
.isEqualTo("other-only-slug");
}
@Test
@DisplayName("kbId wins when both kbName and kbId are supplied")
void kbIdWinsOverKbName() {
wirePages();
when(kbService.findVisibleById(AGENT, OTHER_KB)).thenReturn(kb(OTHER_KB, "Other", null));
// Deliberately do NOT stub findAllByName if the tool consulted
// kbName at all (or fell back to primary), the call would NPE.
String json = tool.wiki_list_pages(AGENT, null, "anything", OTHER_KB);
JSONObject obj = JSONUtil.parseObj(json);
assertThat(obj.getJSONArray("pages").getJSONObject(0).getStr("slug"))
.isEqualTo("other-only-slug");
}
// ==================== fail-closed paths ====================
@Test
@DisplayName("unknown kbName fails closed and names the bad pick")
void unknownKbNameFailsClosed() {
when(kbService.findByName(AGENT, "Bogus")).thenReturn(null);
when(kbService.findAllByName(AGENT, "Bogus")).thenReturn(List.of());
// 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");
String json = tool.wiki_list_pages(AGENT, null, "Bogus", null);
JSONObject obj = JSONUtil.parseObj(json);
String err = obj.getStr("error");
assertThat(err)
assertThat(obj.getStr("error"))
.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)")
@DisplayName("ambiguous kbName fails closed and surfaces every candidate kbId")
void ambiguousKbNameFailsClosed() {
when(kbService.findAllByName(AGENT, "Docs")).thenReturn(List.of(
kb(DUP_BOUND_KB, "Docs", AGENT),
kb(DUP_SHARED_KB, "Docs", null)));
when(kbService.resolvePrimaryKb(AGENT)).thenReturn(kb(PRIMARY_KB, "Primary", AGENT));
String json = tool.wiki_list_pages(AGENT, null, "Docs", null);
JSONObject obj = JSONUtil.parseObj(json);
// Error must be ambiguity-flavoured so the LLM knows to retry with kbId.
assertThat(obj.getStr("error"))
.contains("Ambiguous")
.contains("Docs")
.contains("kbId");
// Candidates list must carry both rows with stringified kbId.
JSONArray candidates = obj.getJSONArray("candidates");
assertThat(candidates).hasSize(2);
assertThat(candidates.getJSONObject(0).getStr("kbId"))
.isEqualTo(String.valueOf(DUP_BOUND_KB));
assertThat(candidates.getJSONObject(0).getBool("boundToAgent")).isTrue();
assertThat(candidates.getJSONObject(1).getStr("kbId"))
.isEqualTo(String.valueOf(DUP_SHARED_KB));
assertThat(candidates.getJSONObject(1).getBool("boundToAgent")).isFalse();
}
@Test
@DisplayName("kbId == 0 is treated as absent (LLM default for unused numeric optionals)")
void kbIdZeroTreatedAsAbsent() {
wirePages();
when(kbService.resolvePrimaryKb(AGENT)).thenReturn(kb(PRIMARY_KB, "Primary", AGENT));
// findVisibleById must NOT be consulted when kbId=0 that path
// would return null and surface a spurious "kbId=0 not visible" error,
// which is exactly the production regression this test prevents.
String json = tool.wiki_list_pages(AGENT, null, null, 0L);
JSONObject obj = JSONUtil.parseObj(json);
assertThat(obj.getStr("error"))
.as("kbId=0 must fall through to primary, NOT raise a not-visible error")
.isNull();
assertThat(obj.getJSONArray("pages").getJSONObject(0).getStr("slug"))
.isEqualTo("primary-only-slug");
}
@Test
@DisplayName("kbId outside the agent's visibility set fails closed")
void kbIdOutOfVisibilityFailsClosed() {
// Visibility gate returns null for an unrelated id.
when(kbService.findVisibleById(AGENT, 99999L)).thenReturn(null);
when(kbService.resolvePrimaryKb(AGENT)).thenReturn(kb(PRIMARY_KB, "Primary", AGENT));
String json = tool.wiki_list_pages(AGENT, null, null, 99999L);
JSONObject obj = JSONUtil.parseObj(json);
assertThat(obj.getStr("error"))
.contains("99999")
.contains("not visible")
.contains("wiki_list_kbs");
}
@Test
@DisplayName("no resolvable KB at all emits the legacy no-KB error")
void noResolvableKbReturnsLegacyError() {
when(kbService.resolvePrimaryKb(AGENT)).thenReturn(null);
String json = tool.wiki_list_pages(AGENT, null, null);
String json = tool.wiki_list_pages(AGENT, null, null, null);
JSONObject obj = JSONUtil.parseObj(json);
assertThat(obj.getStr("error")).contains("No wiki knowledge base found");
@ -149,7 +251,7 @@ class WikiToolKbNameRoutingTest {
// ==================== wiki_list_kbs ====================
@Test
@DisplayName("wiki_list_kbs enumerates every visible KB with isPrimary / boundToAgent")
@DisplayName("wiki_list_kbs enumerates every visible KB with stringified kbId + flags")
void wikiListKbsEnumeratesAll() {
when(kbService.listByAgentId(AGENT)).thenReturn(List.of(
kb(OTHER_KB, "Other", null),
@ -166,11 +268,15 @@ class WikiToolKbNameRoutingTest {
assertThat(kbs).hasSize(2);
JSONObject other = kbs.getJSONObject(0);
assertThat(other.getStr("kbId"))
.as("kbId must be a String to preserve Snowflake precision")
.isEqualTo(String.valueOf(OTHER_KB));
assertThat(other.getStr("name")).isEqualTo("Other");
assertThat(other.getBool("isPrimary")).isFalse();
assertThat(other.getBool("boundToAgent")).isFalse();
JSONObject primary = kbs.getJSONObject(1);
assertThat(primary.getStr("kbId")).isEqualTo(String.valueOf(PRIMARY_KB));
assertThat(primary.getStr("name")).isEqualTo("Primary");
assertThat(primary.getBool("isPrimary")).isTrue();
assertThat(primary.getBool("boundToAgent")).isTrue();

View File

@ -0,0 +1,163 @@
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 org.springframework.ai.support.ToolCallbacks;
import org.springframework.ai.tool.ToolCallback;
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.Arrays;
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;
/**
* Spring AI binding round-trip for the wiki tools' KB-routing contract.
*
* <p>The unit tests in {@link WikiToolKbNameRoutingTest} cover the Java-level
* routing logic, but the LLM never calls those methods directly it serializes
* a JSON tool call which Spring AI's {@link ToolCallbacks#from} layer deserializes
* back into method arguments. Two coercion hops in that pipeline are easy to
* break unnoticed:
*
* <ol>
* <li>{@code wiki_list_kbs} returns {@code "kbId": "<digits>"} as a JSON
* string (workspace-wide Snowflake-precision rule). When the LLM hands
* that exact string back as {@code kbId} on a follow-up call, the Java
* method declares {@code Long kbId} so the framework must coerce
* string Long without precision loss.</li>
* <li>OpenAI-style chat models frequently populate "unused numeric
* optionals" with {@code 0}. The routing layer treats {@code kbId > 0}
* as the only "supplied" sentinel; any binding change that lets a real
* 19-digit id collapse to 0 (e.g. silent float coercion) would also
* break the round-trip even though the unit tests still pass.</li>
* </ol>
*
* These two tests pin the contract end-to-end.
*/
class WikiToolSpringBindingTest {
private static final Long AGENT = 7L;
private static final long PRIMARY_KB = 100L;
// Real-shape Snowflake id 19 digits, beyond JS Number.MAX_SAFE_INTEGER.
// Verifies the precision-safe round-trip the workspace rule mandates.
private static final long SNOWFLAKE_KB = 2054907618529591298L;
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 ToolCallback callbackFor(String functionName) {
return Arrays.stream(ToolCallbacks.from(tool))
.filter(cb -> functionName.equals(cb.getToolDefinition().name()))
.findFirst()
.orElseThrow(() -> new AssertionError("No ToolCallback for " + functionName));
}
private static WikiKnowledgeBaseEntity kb(long id, String name, Long agentId) {
WikiKnowledgeBaseEntity e = new WikiKnowledgeBaseEntity();
e.setId(id);
e.setName(name);
e.setAgentId(agentId);
e.setPageCount(0);
return e;
}
private static WikiPageEntity page(String slug, String title) {
WikiPageEntity p = new WikiPageEntity();
p.setSlug(slug);
p.setTitle(title);
p.setPageType("user");
return p;
}
@Test
@DisplayName("wiki_list_kbs emits kbId as a JSON STRING and round-trips back through Long kbId")
void kbIdRoundTripsAsStringWithoutPrecisionLoss() {
// Arrange: one agent-bound KB whose id is a real-shape 19-digit
// Snowflake. wiki_list_kbs must surface this as a string so a JS
// hop never truncates it; the follow-up tool call then has to
// accept that same string and coerce it back to a Long without loss.
WikiKnowledgeBaseEntity snowflakeKb = kb(SNOWFLAKE_KB, "Big Data KB", AGENT);
when(kbService.listByAgentId(AGENT)).thenReturn(List.of(snowflakeKb));
when(kbService.resolvePrimaryKb(AGENT)).thenReturn(snowflakeKb);
when(kbService.findVisibleById(AGENT, SNOWFLAKE_KB)).thenReturn(snowflakeKb);
when(pageService.listSummaries(eq(SNOWFLAKE_KB))).thenReturn(List.of(
page("only-page", "Only Page")));
// Step 1: wiki_list_kbs through the real Spring AI ToolCallback binding.
ToolCallback listKbs = callbackFor("wiki_list_kbs");
String listJson = listKbs.call("{\"agentId\":" + AGENT + "}");
JSONObject listObj = JSONUtil.parseObj(listJson);
JSONArray kbs = listObj.getJSONArray("kbs");
assertThat(kbs).hasSize(1);
JSONObject row = kbs.getJSONObject(0);
// kbId MUST be a JSON string. Reading it back via getStr should equal
// the exact 19-digit id; reading via getLong should also work (Hutool
// parses string-or-number). The two checks combined catch a regression
// that emits the id as a JSON number (which the LLM/JS hop would round).
String advertisedKbId = row.getStr("kbId");
assertThat(advertisedKbId)
.as("wiki_list_kbs MUST publish kbId as a string")
.isEqualTo(String.valueOf(SNOWFLAKE_KB));
assertThat(row.get("kbId"))
.as("the raw JSON node must be a String, not a Number")
.isInstanceOf(String.class);
// Step 2: feed that exact string back to wiki_list_pages as kbId,
// exactly as an LLM tool call would. The framework must coerce
// String Long with no precision loss, and the routing layer must
// resolve via findVisibleById (NOT fall back to primary).
ToolCallback listPages = callbackFor("wiki_list_pages");
String pagesJson = listPages.call("{\"agentId\":" + AGENT
+ ",\"kbId\":\"" + advertisedKbId + "\"}");
JSONObject pagesObj = JSONUtil.parseObj(pagesJson);
assertThat(pagesObj.getStr("error"))
.as("string-kbId round-trip must NOT raise a not-visible error")
.isNull();
assertThat(pagesObj.getJSONArray("pages").getJSONObject(0).getStr("slug"))
.isEqualTo("only-page");
}
@Test
@DisplayName("kbId=0 from an LLM tool call falls through to primary instead of failing closed")
void kbIdZeroFromToolCallFallsThroughToPrimary() {
// The openai-chatgpt family was observed populating every unused
// numeric optional with 0 in tool-call JSON. The routing layer
// must treat that as "absent" otherwise every wiki_* call
// surfaces a spurious "kbId=0 not visible" error.
WikiKnowledgeBaseEntity primary = kb(PRIMARY_KB, "Primary", AGENT);
when(kbService.resolvePrimaryKb(AGENT)).thenReturn(primary);
when(pageService.listSummaries(eq(PRIMARY_KB))).thenReturn(List.of(
page("primary-page", "Primary Page")));
ToolCallback listPages = callbackFor("wiki_list_pages");
// Note: kbId arrives as JSON number 0 the exact shape the
// production regression had.
String json = listPages.call("{\"agentId\":" + AGENT + ",\"kbId\":0}");
JSONObject obj = JSONUtil.parseObj(json);
assertThat(obj.getStr("error")).isNull();
assertThat(obj.getJSONArray("pages").getJSONObject(0).getStr("slug"))
.isEqualTo("primary-page");
}
}