fix(agent): scope KB grounding to wiki-equipped agents and wiki tools

The knowledge-base trust verification recorded wiki citations from every
non-readFile tool response by sniffing its JSON for a top-level title /
pages / chunks field. Tools like getGoalStatus return a top-level title,
which falsely populated the citation set and then forced [n] citations on
the final answer (otherwise flagged EVIDENCE_INSUFFICIENT). Gate citation
mining on the wiki_* tool name instead.

Likewise, the grounded answer contract (cite-or-refuse) was appended to
every ReasoningNode call unconditionally, degrading general agents that
have no knowledge base. Append it only when the agent has a wiki_* tool
bound, scoping the strict regime to KB-grounded scenarios.

Adds a regression test asserting a non-wiki tool with a top-level title
creates no wiki citations.
This commit is contained in:
matevip 2026-06-16 07:46:34 +08:00
parent 88be1f748a
commit 85ceafa055
3 changed files with 55 additions and 7 deletions

View File

@ -284,8 +284,25 @@ public class ReasoningNode implements NodeAction {
+ "5. **内容忠实**:必须准确反映证据内容,不得歪曲、编造或过度推断。\n\n"
+ "**违规后果**:未按规则引用来源或使用未验证的信息将导致回答被拒绝。\n";
private static String buildGroundedSystemPrompt(String basePrompt) {
return basePrompt + TOOL_USE_ENFORCEMENT + GROUNDED_CONTRACT;
private static String buildGroundedSystemPrompt(String basePrompt, boolean groundingEnforced) {
String prompt = basePrompt + TOOL_USE_ENFORCEMENT;
return groundingEnforced ? prompt + GROUNDED_CONTRACT : prompt;
}
/**
* The grounded-answer contract (cite-or-refuse) only fits agents that retrieve
* from a knowledge base. Detecting a bound {@code wiki_*} tool scopes the strict
* regime to those scenarios instead of degrading every agent a casual agent
* with no KB should not be forced to refuse or emit [n] citations.
*/
private boolean hasWikiTool() {
if (toolCallbacks == null) {
return false;
}
return toolCallbacks.stream().anyMatch(cb -> {
String name = cb.getToolDefinition().name();
return name != null && name.toLowerCase(Locale.ROOT).replace("-", "_").startsWith("wiki_");
});
}
private final ChatModel chatModel;
@ -542,14 +559,16 @@ public class ReasoningNode implements NodeAction {
// ======= 构建 Prompt =======
String systemPrompt = accessor.systemPrompt();
// Append tool-use enforcement and grounded contract to every ReasoningNode call.
// Without tool-use enforcement, some models tend to "narrate" instead of calling tools.
// Grounded contract ensures answers are based only on evidence from tool results.
// Tool-use enforcement is always appended: without it some models tend to
// "narrate" instead of calling tools. The grounded contract (cite-or-refuse)
// is appended only when the agent has a knowledge-base (wiki_*) tool bound,
// so KB-grounded scenarios get strict source attribution while general
// agents keep their normal answering behaviour.
//
// Appended at runtime rather than woven into the AgentEntity-stored
// prompt so it stays out of the user-editable agent UI but is still
// always-on for the runtime LLM.
systemPrompt = buildGroundedSystemPrompt(systemPrompt);
systemPrompt = buildGroundedSystemPrompt(systemPrompt, hasWikiTool());
List<Message> messages = accessor.messages();
// Per-loop budget: bound the working message list a single Reasoning

View File

@ -63,7 +63,14 @@ public record SourceEvidenceLedger(
recordReadFile(data, builder);
} else {
recordPlainTextEvidence(data, builder);
recordWikiEvidence(data, builder);
// Only mine wiki citations from wiki retrieval tools. Sniffing every
// tool's JSON for a top-level title/pages/chunks field would let
// unrelated tools (e.g. getGoalStatus, which returns a top-level
// "title") populate the citation set and falsely force [n] citation
// enforcement on the final answer.
if (isWikiTool(response.name())) {
recordWikiEvidence(data, builder);
}
}
}
return builder.build();
@ -275,6 +282,13 @@ public record SourceEvidenceLedger(
return normalized.equals("read_file");
}
private static boolean isWikiTool(String name) {
if (name == null) {
return false;
}
return name.toLowerCase(Locale.ROOT).replace("-", "_").startsWith("wiki_");
}
private static void recordReadFile(String data, Builder builder) {
try {
JsonNode root = MAPPER.readTree(data);

View File

@ -234,4 +234,19 @@ class SourceEvidenceLedgerTest {
assertTrue(rendered.contains("[1] Install Guide - Linux - page 12"));
assertTrue(ledger.validateAnswer(rendered).valid());
}
@Test
@DisplayName("non-wiki tool JSON with a top-level title does not create wiki citations")
void nonWikiToolWithTitleDoesNotForceCitations() {
// getGoalStatus returns a top-level "title" field; it must not be mined as a
// wiki citation, otherwise a final answer with no [n] markers would be wrongly
// flagged EVIDENCE_INSUFFICIENT.
SourceEvidenceLedger ledger = SourceEvidenceLedger.fromToolResponses(List.of(
new ToolResponseMessage.ToolResponse("c1", "getGoalStatus", """
{"active":true,"goalId":"42","title":"Ship the release","status":"in_progress"}
""")));
assertFalse(ledger.hasWikiEvidence());
assertTrue(ledger.validateAnswer("The release is on track.").valid());
}
}