mirror of
https://gitee.com/mateos/mateclaw.git
synced 2026-09-13 03:13:41 +08:00
fix(wiki): let agents reach every visible KB via kbName + wiki_list_kbs (#224)
This commit is contained in:
parent
3ae4498f38
commit
d2b23c049c
@ -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;
|
||||
|
||||
@ -138,6 +138,16 @@ public class WikiContextService {
|
||||
int totalChars = 0;
|
||||
int maxChars = properties.getMaxContextChars();
|
||||
|
||||
// Every page is rendered under its KB heading so the agent can see
|
||||
// which KB each slug lives in. This becomes load-bearing when more
|
||||
// than one KB is visible: wiki tools accept an optional `kbName`
|
||||
// argument, and the LLM is expected to copy the heading text into
|
||||
// that argument when reaching for a slug outside the primary KB.
|
||||
// Without the heading the agent would call wiki_read_page(slug=...)
|
||||
// without kbName and silently miss the slug — that's the surface of
|
||||
// issue #224 mapped into the prompt-side context.
|
||||
boolean multipleKbs = kbs.size() > 1;
|
||||
|
||||
for (WikiKnowledgeBaseEntity kb : kbs) {
|
||||
List<WikiPageEntity> pages = pageService.listSummaries(kb.getId());
|
||||
if (pages.isEmpty()) continue;
|
||||
@ -172,6 +182,13 @@ public class WikiContextService {
|
||||
}
|
||||
|
||||
sb.append("Use wiki_read_page(slug) for details. Use wiki_search_pages(query) to search.\n");
|
||||
if (multipleKbs) {
|
||||
sb.append("Multiple knowledge bases visible — every wiki tool takes an optional ")
|
||||
.append("`kbName` argument (the heading text above). Pass it when the slug ")
|
||||
.append("you want lives outside the agent's primary KB; otherwise the tool ")
|
||||
.append("falls back to the primary and may return 'page not found'. Call ")
|
||||
.append("wiki_list_kbs first if unsure which KB to target.\n");
|
||||
}
|
||||
sb.append("</wiki-context>");
|
||||
|
||||
return sb.toString();
|
||||
|
||||
@ -138,6 +138,30 @@ public class WikiKnowledgeBaseService {
|
||||
return kbs.get(0);
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolve a specific knowledge base by name, restricted to the agent's
|
||||
* visibility set (agent-bound KBs + shared NULL KBs). Used by wiki tools
|
||||
* that accept an optional {@code kbName} parameter so the LLM can target
|
||||
* a non-primary KB when the agent reaches more than one.
|
||||
* <p>
|
||||
* Match is exact and case-sensitive — the LLM is expected to copy the
|
||||
* name verbatim from {@code wiki_list_kbs} output. Returns {@code null}
|
||||
* when no visible KB has that name, so callers can surface a "use
|
||||
* wiki_list_kbs to discover names" hint instead of silently falling back
|
||||
* to the primary KB (which would mask a bad pick).
|
||||
*/
|
||||
public WikiKnowledgeBaseEntity findByName(Long agentId, String kbName) {
|
||||
if (kbName == null || kbName.isBlank()) {
|
||||
return null;
|
||||
}
|
||||
for (WikiKnowledgeBaseEntity kb : listByAgentId(agentId)) {
|
||||
if (kbName.equals(kb.getName())) {
|
||||
return kb;
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
public WikiKnowledgeBaseEntity getById(Long id) {
|
||||
return kbMapper.selectById(id);
|
||||
}
|
||||
|
||||
@ -83,6 +83,50 @@ public class WikiTool {
|
||||
this.objectMapper = objectMapper;
|
||||
}
|
||||
|
||||
// ==================== Knowledge-base discovery ====================
|
||||
|
||||
@Tool(description = """
|
||||
List every knowledge base visible to this agent — both KBs explicitly
|
||||
bound to the agent and shared workspace-level KBs.
|
||||
|
||||
Every other wiki tool (read / list / search / semantic search / …)
|
||||
accepts an OPTIONAL `kbName` parameter. When omitted, the tool falls
|
||||
back to the agent's "primary" KB (the agent-bound KB if any, else
|
||||
the most recently updated shared KB), which is fine when the agent
|
||||
only reaches one KB. When the agent reaches more than one and the
|
||||
data you need lives in a non-primary KB, call wiki_list_kbs first
|
||||
and pass the chosen `name` as `kbName` so the tool actually queries
|
||||
the right KB instead of silently hitting the primary one.
|
||||
|
||||
Output fields per KB:
|
||||
- name — copy verbatim into other tools' `kbName` param
|
||||
- description — operator-supplied summary
|
||||
- pageCount — number of pages currently in the KB
|
||||
- isPrimary — true for the KB used when `kbName` is omitted
|
||||
- boundToAgent — true if the KB is explicitly bound to this agent
|
||||
""")
|
||||
public String wiki_list_kbs(
|
||||
@ToolParam(description = "Agent ID") Long agentId) {
|
||||
List<WikiKnowledgeBaseEntity> kbs = kbService.listByAgentId(agentId);
|
||||
WikiKnowledgeBaseEntity primary = kbService.resolvePrimaryKb(agentId);
|
||||
Long primaryId = primary == null ? null : primary.getId();
|
||||
|
||||
JSONArray arr = new JSONArray();
|
||||
for (WikiKnowledgeBaseEntity kb : kbs) {
|
||||
arr.add(JSONUtil.createObj()
|
||||
.set("name", kb.getName())
|
||||
.set("description", kb.getDescription())
|
||||
.set("pageCount", kb.getPageCount() == null ? 0 : kb.getPageCount())
|
||||
.set("isPrimary", kb.getId().equals(primaryId))
|
||||
.set("boundToAgent", kb.getAgentId() != null));
|
||||
}
|
||||
return JSONUtil.createObj()
|
||||
.set("kbCount", kbs.size())
|
||||
.set("primary", primary == null ? null : primary.getName())
|
||||
.set("kbs", arr)
|
||||
.toString();
|
||||
}
|
||||
|
||||
// ==================== RFC-032: Enhanced wiki_read_page ====================
|
||||
|
||||
@Tool(description = """
|
||||
@ -95,15 +139,16 @@ public class WikiTool {
|
||||
@ToolParam(description = "Agent ID") Long agentId,
|
||||
@ToolParam(description = "Page slug") String slug,
|
||||
@ToolParam(description = "Max characters to return (null = full page)", required = false) Integer maxChars,
|
||||
@ToolParam(description = "Section heading to extract (null = all sections)", required = false) String sectionHeading) {
|
||||
@ToolParam(description = "Section heading to extract (null = all sections)", required = false) String sectionHeading,
|
||||
@ToolParam(description = "Target knowledge base name (from wiki_list_kbs). Omit to use the agent's primary KB.", required = false) String kbName) {
|
||||
|
||||
if (slug == null || slug.isBlank()) {
|
||||
return error("slug is required");
|
||||
}
|
||||
|
||||
Long kbId = resolveKbId(agentId);
|
||||
Long kbId = resolveKbId(agentId, kbName);
|
||||
if (kbId == null) {
|
||||
return error("No wiki knowledge base found for this agent");
|
||||
return noKbError(kbName);
|
||||
}
|
||||
|
||||
WikiPageEntity page = pageService.getBySlug(kbId, slug);
|
||||
@ -140,11 +185,12 @@ public class WikiTool {
|
||||
""")
|
||||
public String wiki_list_pages(
|
||||
@ToolParam(description = "Agent ID") Long agentId,
|
||||
@ToolParam(description = "Title keyword filter (optional)", required = false) String query) {
|
||||
@ToolParam(description = "Title keyword filter (optional)", required = false) String query,
|
||||
@ToolParam(description = "Target knowledge base name (from wiki_list_kbs). Omit to use the agent's primary KB.", required = false) String kbName) {
|
||||
|
||||
Long kbId = resolveKbId(agentId);
|
||||
Long kbId = resolveKbId(agentId, kbName);
|
||||
if (kbId == null) {
|
||||
return error("No wiki knowledge base found for this agent");
|
||||
return noKbError(kbName);
|
||||
}
|
||||
|
||||
List<WikiPageLite> pages;
|
||||
@ -197,15 +243,16 @@ public class WikiTool {
|
||||
@ToolParam(description = "Agent ID") Long agentId,
|
||||
@ToolParam(description = "Search query") String query,
|
||||
@ToolParam(description = "Mode: keyword|semantic|hybrid (default: hybrid)", required = false) String mode,
|
||||
@ToolParam(description = "Max results (default 5, max 20)", required = false) Integer topK) {
|
||||
@ToolParam(description = "Max results (default 5, max 20)", required = false) Integer topK,
|
||||
@ToolParam(description = "Target knowledge base name (from wiki_list_kbs). Omit to use the agent's primary KB.", required = false) String kbName) {
|
||||
|
||||
if (query == null || query.isBlank()) {
|
||||
return error("query is required");
|
||||
}
|
||||
|
||||
Long kbId = resolveKbId(agentId);
|
||||
Long kbId = resolveKbId(agentId, kbName);
|
||||
if (kbId == null) {
|
||||
return error("No wiki knowledge base found for this agent");
|
||||
return noKbError(kbName);
|
||||
}
|
||||
|
||||
int k = (topK != null && topK > 0) ? Math.min(topK, 20) : 5;
|
||||
@ -246,15 +293,16 @@ public class WikiTool {
|
||||
public String wiki_semantic_search(
|
||||
@ToolParam(description = "Agent ID") Long agentId,
|
||||
@ToolParam(description = "Natural language query") String query,
|
||||
@ToolParam(description = "Max results (default 5)", required = false) Integer topK) {
|
||||
@ToolParam(description = "Max results (default 5)", required = false) Integer topK,
|
||||
@ToolParam(description = "Target knowledge base name (from wiki_list_kbs). Omit to use the agent's primary KB.", required = false) String kbName) {
|
||||
|
||||
if (query == null || query.isBlank()) {
|
||||
return error("query is required");
|
||||
}
|
||||
|
||||
Long kbId = resolveKbId(agentId);
|
||||
Long kbId = resolveKbId(agentId, kbName);
|
||||
if (kbId == null) {
|
||||
return error("No wiki knowledge base found for this agent");
|
||||
return noKbError(kbName);
|
||||
}
|
||||
|
||||
int k = (topK != null && topK > 0) ? Math.min(topK, 20) : 5;
|
||||
@ -309,15 +357,16 @@ public class WikiTool {
|
||||
""")
|
||||
public String wiki_trace_source(
|
||||
@ToolParam(description = "Agent ID") Long agentId,
|
||||
@ToolParam(description = "Page slug") String slug) {
|
||||
@ToolParam(description = "Page slug") String slug,
|
||||
@ToolParam(description = "Target knowledge base name (from wiki_list_kbs). Omit to use the agent's primary KB.", required = false) String kbName) {
|
||||
|
||||
if (slug == null || slug.isBlank()) {
|
||||
return error("slug is required");
|
||||
}
|
||||
|
||||
Long kbId = resolveKbId(agentId);
|
||||
Long kbId = resolveKbId(agentId, kbName);
|
||||
if (kbId == null) {
|
||||
return error("No wiki knowledge base found for this agent");
|
||||
return noKbError(kbName);
|
||||
}
|
||||
|
||||
WikiPageEntity page = pageService.getBySlug(kbId, slug);
|
||||
@ -339,7 +388,8 @@ public class WikiTool {
|
||||
public String wiki_create_page(
|
||||
@ToolParam(description = "Agent ID") Long agentId,
|
||||
@ToolParam(description = "Page title") String title,
|
||||
@ToolParam(description = "Page content (Markdown)") String content) {
|
||||
@ToolParam(description = "Page content (Markdown)") String content,
|
||||
@ToolParam(description = "Target knowledge base name (from wiki_list_kbs). Omit to use the agent's primary KB.", required = false) String kbName) {
|
||||
|
||||
if (title == null || title.isBlank()) {
|
||||
return error("title is required");
|
||||
@ -348,9 +398,9 @@ public class WikiTool {
|
||||
return error("content is required");
|
||||
}
|
||||
|
||||
Long kbId = resolveKbId(agentId);
|
||||
Long kbId = resolveKbId(agentId, kbName);
|
||||
if (kbId == null) {
|
||||
return error("No wiki knowledge base found for this agent. Create one first.");
|
||||
return noKbError(kbName);
|
||||
}
|
||||
|
||||
String slug = title.toLowerCase()
|
||||
@ -392,13 +442,14 @@ public class WikiTool {
|
||||
@ToolParam(description = "Agent ID") Long agentId,
|
||||
@ToolParam(description = "Topic to compile a page about (natural language)") String topic,
|
||||
@ToolParam(description = "Optional explicit slug for the page", required = false) String slug,
|
||||
@ToolParam(description = "Max evidence chunks (default 8, max 20)", required = false) Integer maxEvidenceChunks) {
|
||||
@ToolParam(description = "Max evidence chunks (default 8, max 20)", required = false) Integer maxEvidenceChunks,
|
||||
@ToolParam(description = "Target knowledge base name (from wiki_list_kbs). Omit to use the agent's primary KB.", required = false) String kbName) {
|
||||
|
||||
if (topic == null || topic.isBlank()) {
|
||||
return error("topic is required");
|
||||
}
|
||||
Long kbId = resolveKbId(agentId);
|
||||
if (kbId == null) return error("No wiki knowledge base found for this agent");
|
||||
Long kbId = resolveKbId(agentId, kbName);
|
||||
if (kbId == null) return noKbError(kbName);
|
||||
if (compileService == null) return error("Compile service not available");
|
||||
|
||||
try {
|
||||
@ -439,11 +490,12 @@ public class WikiTool {
|
||||
public String wiki_read_many(
|
||||
@ToolParam(description = "Agent ID") Long agentId,
|
||||
@ToolParam(description = "Comma-separated slugs (max 10)") String slugs,
|
||||
@ToolParam(description = "Max chars returned per page (default 2000, max 8000)", required = false) Integer maxCharsPerPage) {
|
||||
@ToolParam(description = "Max chars returned per page (default 2000, max 8000)", required = false) Integer maxCharsPerPage,
|
||||
@ToolParam(description = "Target knowledge base name (from wiki_list_kbs). Omit to use the agent's primary KB.", required = false) String kbName) {
|
||||
|
||||
if (slugs == null || slugs.isBlank()) return error("slugs is required");
|
||||
Long kbId = resolveKbId(agentId);
|
||||
if (kbId == null) return error("No wiki knowledge base found for this agent");
|
||||
Long kbId = resolveKbId(agentId, kbName);
|
||||
if (kbId == null) return noKbError(kbName);
|
||||
|
||||
int cap = (maxCharsPerPage == null || maxCharsPerPage <= 0) ? 2000 : Math.min(8000, maxCharsPerPage);
|
||||
List<String> slugList = Arrays.stream(slugs.split(","))
|
||||
@ -484,8 +536,9 @@ public class WikiTool {
|
||||
""")
|
||||
public String wiki_archive_page(
|
||||
@ToolParam(description = "Agent ID") Long agentId,
|
||||
@ToolParam(description = "Page slug to archive") String slug) {
|
||||
return setArchivedTool(agentId, slug, true, "archived");
|
||||
@ToolParam(description = "Page slug to archive") String slug,
|
||||
@ToolParam(description = "Target knowledge base name (from wiki_list_kbs). Omit to use the agent's primary KB.", required = false) String kbName) {
|
||||
return setArchivedTool(agentId, slug, true, "archived", kbName);
|
||||
}
|
||||
|
||||
@Tool(description = """
|
||||
@ -494,14 +547,15 @@ public class WikiTool {
|
||||
""")
|
||||
public String wiki_unarchive_page(
|
||||
@ToolParam(description = "Agent ID") Long agentId,
|
||||
@ToolParam(description = "Page slug to unarchive") String slug) {
|
||||
return setArchivedTool(agentId, slug, false, "unarchived");
|
||||
@ToolParam(description = "Page slug to unarchive") String slug,
|
||||
@ToolParam(description = "Target knowledge base name (from wiki_list_kbs). Omit to use the agent's primary KB.", required = false) String kbName) {
|
||||
return setArchivedTool(agentId, slug, false, "unarchived", kbName);
|
||||
}
|
||||
|
||||
private String setArchivedTool(Long agentId, String slug, boolean archive, String verb) {
|
||||
private String setArchivedTool(Long agentId, String slug, boolean archive, String verb, String kbName) {
|
||||
if (slug == null || slug.isBlank()) return error("slug is required");
|
||||
Long kbId = resolveKbId(agentId);
|
||||
if (kbId == null) return error("No wiki knowledge base found for this agent");
|
||||
Long kbId = resolveKbId(agentId, kbName);
|
||||
if (kbId == null) return noKbError(kbName);
|
||||
boolean changed;
|
||||
try {
|
||||
changed = pageService.setArchived(kbId, slug, archive);
|
||||
@ -521,15 +575,16 @@ public class WikiTool {
|
||||
""")
|
||||
public String wiki_delete_page(
|
||||
@ToolParam(description = "Agent ID") Long agentId,
|
||||
@ToolParam(description = "Page slug to delete") String slug) {
|
||||
@ToolParam(description = "Page slug to delete") String slug,
|
||||
@ToolParam(description = "Target knowledge base name (from wiki_list_kbs). Omit to use the agent's primary KB.", required = false) String kbName) {
|
||||
|
||||
if (slug == null || slug.isBlank()) {
|
||||
return error("slug is required");
|
||||
}
|
||||
|
||||
Long kbId = resolveKbId(agentId);
|
||||
Long kbId = resolveKbId(agentId, kbName);
|
||||
if (kbId == null) {
|
||||
return error("No wiki knowledge base found for this agent");
|
||||
return noKbError(kbName);
|
||||
}
|
||||
|
||||
WikiPageEntity page = pageService.getBySlug(kbId, slug);
|
||||
@ -568,10 +623,11 @@ public class WikiTool {
|
||||
public String wiki_related_pages(
|
||||
@ToolParam(description = "Agent ID") Long agentId,
|
||||
@ToolParam(description = "Page slug") String slug,
|
||||
@ToolParam(description = "Max results (default 5, max 10)", required = false) Integer topK) {
|
||||
@ToolParam(description = "Max results (default 5, max 10)", required = false) Integer topK,
|
||||
@ToolParam(description = "Target knowledge base name (from wiki_list_kbs). Omit to use the agent's primary KB.", required = false) String kbName) {
|
||||
|
||||
Long kbId = resolveKbId(agentId);
|
||||
if (kbId == null) return error("No wiki knowledge base found for this agent");
|
||||
Long kbId = resolveKbId(agentId, kbName);
|
||||
if (kbId == null) return noKbError(kbName);
|
||||
if (relationService == null) return error("Relation service not available");
|
||||
|
||||
int k = (topK != null && topK > 0) ? Math.min(topK, 10) : 5;
|
||||
@ -599,10 +655,11 @@ public class WikiTool {
|
||||
public String wiki_explain_relation(
|
||||
@ToolParam(description = "Agent ID") Long agentId,
|
||||
@ToolParam(description = "First page slug") String slugA,
|
||||
@ToolParam(description = "Second page slug") String slugB) {
|
||||
@ToolParam(description = "Second page slug") String slugB,
|
||||
@ToolParam(description = "Target knowledge base name (from wiki_list_kbs). Omit to use the agent's primary KB.", required = false) String kbName) {
|
||||
|
||||
Long kbId = resolveKbId(agentId);
|
||||
if (kbId == null) return error("No wiki knowledge base found for this agent");
|
||||
Long kbId = resolveKbId(agentId, kbName);
|
||||
if (kbId == null) return noKbError(kbName);
|
||||
if (relationService == null) return error("Relation service not available");
|
||||
|
||||
RelationExplanation ex = relationService.explain(kbId, slugA, slugB);
|
||||
@ -623,10 +680,11 @@ public class WikiTool {
|
||||
""")
|
||||
public String wiki_enrich_page(
|
||||
@ToolParam(description = "Agent ID") Long agentId,
|
||||
@ToolParam(description = "Page slug") String slug) {
|
||||
@ToolParam(description = "Page slug") String slug,
|
||||
@ToolParam(description = "Target knowledge base name (from wiki_list_kbs). Omit to use the agent's primary KB.", required = false) String kbName) {
|
||||
|
||||
Long kbId = resolveKbId(agentId);
|
||||
if (kbId == null) return error("No wiki knowledge base found for this agent");
|
||||
Long kbId = resolveKbId(agentId, kbName);
|
||||
if (kbId == null) return noKbError(kbName);
|
||||
if (jobService == null || eventPublisher == null) return error("Job service not available");
|
||||
|
||||
WikiPageEntity page = pageService.getBySlug(kbId, slug);
|
||||
@ -653,9 +711,10 @@ public class WikiTool {
|
||||
human title, and a description of what the prompt produces.
|
||||
""")
|
||||
public String wiki_list_transformations(
|
||||
@ToolParam(description = "Agent ID") Long agentId) {
|
||||
Long kbId = resolveKbId(agentId);
|
||||
if (kbId == null) return error("No wiki knowledge base found for this agent");
|
||||
@ToolParam(description = "Agent ID") Long agentId,
|
||||
@ToolParam(description = "Target knowledge base name (from wiki_list_kbs). Omit to use the agent's primary KB.", required = false) String kbName) {
|
||||
Long kbId = resolveKbId(agentId, kbName);
|
||||
if (kbId == null) return noKbError(kbName);
|
||||
if (transformationService == null) return error("Transformations not available");
|
||||
|
||||
WikiKnowledgeBaseEntity kb = kbService.getById(kbId);
|
||||
@ -682,11 +741,12 @@ public class WikiTool {
|
||||
public String wiki_apply_transformation(
|
||||
@ToolParam(description = "Agent ID") Long agentId,
|
||||
@ToolParam(description = "Transformation name (from wiki_list_transformations)") String name,
|
||||
@ToolParam(description = "Raw material ID to run the transformation against") Long rawId) {
|
||||
@ToolParam(description = "Raw material ID to run the transformation against") Long rawId,
|
||||
@ToolParam(description = "Target knowledge base name (from wiki_list_kbs). Omit to use the agent's primary KB.", required = false) String kbName) {
|
||||
if (name == null || name.isBlank()) return error("name is required");
|
||||
if (rawId == null) return error("rawId is required");
|
||||
Long kbId = resolveKbId(agentId);
|
||||
if (kbId == null) return error("No wiki knowledge base found for this agent");
|
||||
Long kbId = resolveKbId(agentId, kbName);
|
||||
if (kbId == null) return noKbError(kbName);
|
||||
if (transformationService == null || transformationExecutor == null) {
|
||||
return error("Transformations not available");
|
||||
}
|
||||
@ -727,11 +787,12 @@ public class WikiTool {
|
||||
public String wiki_apply_transformation_to_page(
|
||||
@ToolParam(description = "Agent ID") Long agentId,
|
||||
@ToolParam(description = "Transformation name (from wiki_list_transformations)") String name,
|
||||
@ToolParam(description = "Source wiki page slug to run the transformation against") String slug) {
|
||||
@ToolParam(description = "Source wiki page slug to run the transformation against") String slug,
|
||||
@ToolParam(description = "Target knowledge base name (from wiki_list_kbs). Omit to use the agent's primary KB.", required = false) String kbName) {
|
||||
if (name == null || name.isBlank()) return error("name is required");
|
||||
if (slug == null || slug.isBlank()) return error("slug is required");
|
||||
Long kbId = resolveKbId(agentId);
|
||||
if (kbId == null) return error("No wiki knowledge base found for this agent");
|
||||
Long kbId = resolveKbId(agentId, kbName);
|
||||
if (kbId == null) return noKbError(kbName);
|
||||
if (transformationService == null || transformationExecutor == null) {
|
||||
return error("Transformations not available");
|
||||
}
|
||||
@ -776,10 +837,11 @@ public class WikiTool {
|
||||
""")
|
||||
public String wiki_aggregate_transformation(
|
||||
@ToolParam(description = "Agent ID") Long agentId,
|
||||
@ToolParam(description = "Transformation name (from wiki_list_transformations)") String name) {
|
||||
@ToolParam(description = "Transformation name (from wiki_list_transformations)") String name,
|
||||
@ToolParam(description = "Target knowledge base name (from wiki_list_kbs). Omit to use the agent's primary KB.", required = false) String kbName) {
|
||||
if (name == null || name.isBlank()) return error("name is required");
|
||||
Long kbId = resolveKbId(agentId);
|
||||
if (kbId == null) return error("No wiki knowledge base found for this agent");
|
||||
Long kbId = resolveKbId(agentId, kbName);
|
||||
if (kbId == null) return noKbError(kbName);
|
||||
if (transformationService == null || transformationAggregator == null) {
|
||||
return error("Transformations not available");
|
||||
}
|
||||
@ -818,8 +880,44 @@ public class WikiTool {
|
||||
// ==================== Helpers ====================
|
||||
|
||||
private Long resolveKbId(Long agentId) {
|
||||
WikiKnowledgeBaseEntity kb = kbService.resolvePrimaryKb(agentId);
|
||||
return kb == null ? null : kb.getId();
|
||||
return resolveKbId(agentId, null);
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolve the KB a tool call should operate on, honouring an optional
|
||||
* caller-supplied {@code kbName}.
|
||||
* <ul>
|
||||
* <li>{@code kbName} blank → existing single-primary fallback
|
||||
* ({@link WikiKnowledgeBaseService#resolvePrimaryKb}). Preserves
|
||||
* the legacy single-KB UX for agents with only one accessible KB.</li>
|
||||
* <li>{@code kbName} provided → exact-name lookup restricted to the
|
||||
* agent's visible KBs. Returns {@code null} when the name does
|
||||
* not match a visible KB so the caller can emit a "use
|
||||
* wiki_list_kbs" hint instead of silently routing to the primary
|
||||
* (which is the surface of issue #224 — LLM picks a non-primary
|
||||
* KB but the tool still hits the primary one).</li>
|
||||
* </ul>
|
||||
*/
|
||||
private Long resolveKbId(Long agentId, String kbName) {
|
||||
if (kbName == null || kbName.isBlank()) {
|
||||
WikiKnowledgeBaseEntity primary = kbService.resolvePrimaryKb(agentId);
|
||||
return primary == null ? null : primary.getId();
|
||||
}
|
||||
WikiKnowledgeBaseEntity match = kbService.findByName(agentId, kbName);
|
||||
return match == null ? null : match.getId();
|
||||
}
|
||||
|
||||
/**
|
||||
* Standardised "couldn't resolve KB" error. When the caller passed a
|
||||
* non-blank {@code kbName} that didn't match, the message points them at
|
||||
* {@code wiki_list_kbs} so the LLM has a clear next step.
|
||||
*/
|
||||
private String noKbError(String kbName) {
|
||||
if (kbName != null && !kbName.isBlank()) {
|
||||
return error("Knowledge base '" + kbName
|
||||
+ "' not visible to this agent. Use wiki_list_kbs to see available KBs.");
|
||||
}
|
||||
return error("No wiki knowledge base found for this agent");
|
||||
}
|
||||
|
||||
private JSONArray resolveSourceFiles(String sourceRawIdsJson) {
|
||||
|
||||
@ -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();
|
||||
}
|
||||
}
|
||||
|
||||
@ -0,0 +1,178 @@
|
||||
package vip.mate.wiki.tool;
|
||||
|
||||
import cn.hutool.json.JSONArray;
|
||||
import cn.hutool.json.JSONObject;
|
||||
import cn.hutool.json.JSONUtil;
|
||||
import com.fasterxml.jackson.databind.ObjectMapper;
|
||||
import org.junit.jupiter.api.DisplayName;
|
||||
import org.junit.jupiter.api.Test;
|
||||
import vip.mate.wiki.model.WikiKnowledgeBaseEntity;
|
||||
import vip.mate.wiki.model.WikiPageEntity;
|
||||
import vip.mate.wiki.service.HybridRetriever;
|
||||
import vip.mate.wiki.service.WikiKnowledgeBaseService;
|
||||
import vip.mate.wiki.service.WikiPageService;
|
||||
import vip.mate.wiki.service.WikiRawMaterialService;
|
||||
|
||||
import java.util.List;
|
||||
|
||||
import static org.assertj.core.api.Assertions.assertThat;
|
||||
import static org.mockito.ArgumentMatchers.eq;
|
||||
import static org.mockito.Mockito.mock;
|
||||
import static org.mockito.Mockito.when;
|
||||
|
||||
/**
|
||||
* Routing-level coverage for {@link WikiTool#resolveKbId(Long, String)}
|
||||
* (exercised through the public {@code wiki_list_pages} and
|
||||
* {@code wiki_list_kbs} surfaces).
|
||||
*
|
||||
* <p>The behaviour these tests pin in place is the fix for the upstream
|
||||
* "single-KB collapse" bug: when an agent reaches more than one knowledge
|
||||
* base, every wiki tool used to silently operate on whichever KB the
|
||||
* primary-fallback picked, with no way for the LLM to target a different
|
||||
* one. The {@code kbName} parameter and {@code wiki_list_kbs} discovery
|
||||
* tool together close that gap. These tests verify:
|
||||
* <ol>
|
||||
* <li>blank {@code kbName} → routes to the primary KB (legacy behaviour
|
||||
* preserved for single-KB agents);</li>
|
||||
* <li>named {@code kbName} that matches a visible KB → routes to that
|
||||
* specific KB, not the primary;</li>
|
||||
* <li>named {@code kbName} that doesn't match → fail-closed error that
|
||||
* names the bad pick and points the agent at
|
||||
* {@code wiki_list_kbs} so the next attempt can pick a valid name;</li>
|
||||
* <li>{@code wiki_list_kbs} surfaces every visible KB along with the
|
||||
* {@code isPrimary} / {@code boundToAgent} flags the LLM needs to
|
||||
* decide which to target.</li>
|
||||
* </ol>
|
||||
*/
|
||||
class WikiToolKbNameRoutingTest {
|
||||
|
||||
private static final Long AGENT = 7L;
|
||||
private static final long PRIMARY_KB = 100L;
|
||||
private static final long OTHER_KB = 200L;
|
||||
|
||||
private final WikiPageService pageService = mock(WikiPageService.class);
|
||||
private final WikiKnowledgeBaseService kbService = mock(WikiKnowledgeBaseService.class);
|
||||
private final WikiRawMaterialService rawService = mock(WikiRawMaterialService.class);
|
||||
private final HybridRetriever hybridRetriever = mock(HybridRetriever.class);
|
||||
private final ObjectMapper objectMapper = new ObjectMapper();
|
||||
|
||||
private final WikiTool tool = new WikiTool(pageService, kbService, rawService,
|
||||
hybridRetriever, objectMapper);
|
||||
|
||||
private static WikiKnowledgeBaseEntity kb(long id, String name, Long agentId) {
|
||||
WikiKnowledgeBaseEntity entity = new WikiKnowledgeBaseEntity();
|
||||
entity.setId(id);
|
||||
entity.setName(name);
|
||||
entity.setAgentId(agentId);
|
||||
entity.setPageCount(0);
|
||||
return entity;
|
||||
}
|
||||
|
||||
private static WikiPageEntity page(String slug, String title) {
|
||||
WikiPageEntity entity = new WikiPageEntity();
|
||||
entity.setSlug(slug);
|
||||
entity.setTitle(title);
|
||||
entity.setPageType("user");
|
||||
return entity;
|
||||
}
|
||||
|
||||
/** Mock listSummaries to return KB-specific page slugs so the test can
|
||||
* prove which KB the tool actually queried. */
|
||||
private void wirePages() {
|
||||
when(pageService.listSummaries(eq(PRIMARY_KB))).thenReturn(List.of(
|
||||
page("primary-only-slug", "Primary KB Page")));
|
||||
when(pageService.listSummaries(eq(OTHER_KB))).thenReturn(List.of(
|
||||
page("other-only-slug", "Other KB Page")));
|
||||
}
|
||||
|
||||
// ==================== wiki_list_pages routing ====================
|
||||
|
||||
@Test
|
||||
@DisplayName("wiki_list_pages with blank kbName routes to the primary KB")
|
||||
void blankKbNameRoutesToPrimary() {
|
||||
wirePages();
|
||||
when(kbService.resolvePrimaryKb(AGENT)).thenReturn(kb(PRIMARY_KB, "Primary", AGENT));
|
||||
|
||||
String json = tool.wiki_list_pages(AGENT, null, null);
|
||||
JSONObject obj = JSONUtil.parseObj(json);
|
||||
|
||||
JSONArray pages = obj.getJSONArray("pages");
|
||||
assertThat(pages).hasSize(1);
|
||||
assertThat(pages.getJSONObject(0).getStr("slug")).isEqualTo("primary-only-slug");
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("wiki_list_pages with a known kbName routes to that KB instead of the primary")
|
||||
void namedKbNameRoutesToNamedKb() {
|
||||
wirePages();
|
||||
// Primary fallback still wired so the test would fail loudly if the
|
||||
// tool silently used it despite a non-blank kbName.
|
||||
when(kbService.resolvePrimaryKb(AGENT)).thenReturn(kb(PRIMARY_KB, "Primary", AGENT));
|
||||
when(kbService.findByName(AGENT, "Other")).thenReturn(kb(OTHER_KB, "Other", null));
|
||||
|
||||
String json = tool.wiki_list_pages(AGENT, null, "Other");
|
||||
JSONObject obj = JSONUtil.parseObj(json);
|
||||
|
||||
JSONArray pages = obj.getJSONArray("pages");
|
||||
assertThat(pages).hasSize(1);
|
||||
assertThat(pages.getJSONObject(0).getStr("slug")).isEqualTo("other-only-slug");
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("wiki_list_pages with an unknown kbName fails closed and names the bad pick")
|
||||
void unknownKbNameFailsClosed() {
|
||||
when(kbService.findByName(AGENT, "Bogus")).thenReturn(null);
|
||||
// Primary still mockable; the routing must NOT silently fall through.
|
||||
when(kbService.resolvePrimaryKb(AGENT)).thenReturn(kb(PRIMARY_KB, "Primary", AGENT));
|
||||
|
||||
String json = tool.wiki_list_pages(AGENT, null, "Bogus");
|
||||
JSONObject obj = JSONUtil.parseObj(json);
|
||||
|
||||
String err = obj.getStr("error");
|
||||
assertThat(err)
|
||||
.as("error message should name the bad pick and point at wiki_list_kbs")
|
||||
.contains("Bogus")
|
||||
.contains("wiki_list_kbs");
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("wiki_list_pages with no resolvable KB at all emits the no-KB error (legacy path)")
|
||||
void noResolvableKbReturnsLegacyError() {
|
||||
when(kbService.resolvePrimaryKb(AGENT)).thenReturn(null);
|
||||
|
||||
String json = tool.wiki_list_pages(AGENT, null, null);
|
||||
JSONObject obj = JSONUtil.parseObj(json);
|
||||
|
||||
assertThat(obj.getStr("error")).contains("No wiki knowledge base found");
|
||||
}
|
||||
|
||||
// ==================== wiki_list_kbs ====================
|
||||
|
||||
@Test
|
||||
@DisplayName("wiki_list_kbs enumerates every visible KB with isPrimary / boundToAgent")
|
||||
void wikiListKbsEnumeratesAll() {
|
||||
when(kbService.listByAgentId(AGENT)).thenReturn(List.of(
|
||||
kb(OTHER_KB, "Other", null),
|
||||
kb(PRIMARY_KB, "Primary", AGENT)));
|
||||
when(kbService.resolvePrimaryKb(AGENT)).thenReturn(kb(PRIMARY_KB, "Primary", AGENT));
|
||||
|
||||
String json = tool.wiki_list_kbs(AGENT);
|
||||
JSONObject obj = JSONUtil.parseObj(json);
|
||||
|
||||
assertThat(obj.getInt("kbCount")).isEqualTo(2);
|
||||
assertThat(obj.getStr("primary")).isEqualTo("Primary");
|
||||
|
||||
JSONArray kbs = obj.getJSONArray("kbs");
|
||||
assertThat(kbs).hasSize(2);
|
||||
|
||||
JSONObject other = kbs.getJSONObject(0);
|
||||
assertThat(other.getStr("name")).isEqualTo("Other");
|
||||
assertThat(other.getBool("isPrimary")).isFalse();
|
||||
assertThat(other.getBool("boundToAgent")).isFalse();
|
||||
|
||||
JSONObject primary = kbs.getJSONObject(1);
|
||||
assertThat(primary.getStr("name")).isEqualTo("Primary");
|
||||
assertThat(primary.getBool("isPrimary")).isTrue();
|
||||
assertThat(primary.getBool("boundToAgent")).isTrue();
|
||||
}
|
||||
}
|
||||
Loading…
Reference in New Issue
Block a user