feat(agent): support no-evidence references

This commit is contained in:
aruo
2026-07-08 23:30:00 +08:00
parent 7b8c75e571
commit 9a84b3de34
9 changed files with 1040 additions and 28 deletions
@@ -156,7 +156,8 @@ public class ExecutorGatekeeperService {
SEVERITY_LOW_CONFID);
continue;
}
validateEvidenceBinding(binding, validInvocations, target, claim.get("claim_id"), result);
validateEvidenceBinding(binding, validInvocations, target,
claim.get("claim_id"), claim.get("claim_type"), result);
}
}
@@ -181,7 +182,7 @@ public class ExecutorGatekeeperService {
SEVERITY_LOW_CONFID);
continue;
}
validateEvidenceBinding(binding, validInvocations, target, action.get("action_id"), result);
validateEvidenceBinding(binding, validInvocations, target, action.get("action_id"), null, result);
}
}
}
@@ -190,6 +191,7 @@ public class ExecutorGatekeeperService {
Map<Long, ToolInvocation> validInvocations,
String target,
Object ownerId,
Object ownerType,
GatekeeperResult result) {
Map<String, Object> checked = new LinkedHashMap<>();
checked.put("claim_id", ownerId == null ? "" : String.valueOf(ownerId));
@@ -197,18 +199,6 @@ public class ExecutorGatekeeperService {
checked.put("source_invocation_id", binding.get("source_invocation_id"));
checked.put("raw_path", stringValue(binding.get("raw_path")));
Long id = singleInvocationId(binding);
if (id == null) {
checked.put("status", STATUS_FAIL);
checked.put("rule", RULE_INVOCATION_REF);
checked.put("message", "source_invocation_id is required");
result.checked(checked);
result.fail(RULE_INVOCATION_REF, target + ".source_invocation_id",
"source_invocation_id is required", SEVERITY_LOW_CONFID);
return;
}
checked.put("source_invocation_id", id);
String claimedToolName = stringValue(binding.get("tool_name"));
if (claimedToolName.isBlank()) {
checked.put("status", STATUS_FAIL);
@@ -219,6 +209,46 @@ public class ExecutorGatekeeperService {
return;
}
String rawPath = stringValue(binding.get("raw_path"));
if ("negative_observation".equals(stringValue(ownerType)) && !rawPath.isBlank()
&& !"$.no_evidence".equals(rawPath)) {
checked.put("status", STATUS_FAIL);
checked.put("rule", RULE_RAW_PATH);
checked.put("message", "negative_observation must only bind $.no_evidence references");
result.checked(checked);
result.fail(RULE_RAW_PATH, target + ".raw_path",
"negative_observation must only bind $.no_evidence references", SEVERITY_REJECT);
return;
}
Long id = singleInvocationId(binding);
if (id == null) {
id = uniqueInvocationIdByToolRawPathAndExcerpt(validInvocations, claimedToolName, rawPath,
stringValue(binding.get("evidence_excerpt")));
if (id == null) {
id = uniqueInvocationIdByToolAndRawPath(validInvocations, claimedToolName, rawPath);
}
if (id != null) {
result.warn(Map.of(
"rule", "evidence.invocation_auto_backfill_by_raw_path",
"message", "source_invocation_id was auto-filled from the unique evidence reference candidate",
"tool_name", claimedToolName,
"raw_path", rawPath,
"source_invocation_id", id
));
}
}
if (id == null) {
checked.put("status", STATUS_FAIL);
checked.put("rule", RULE_INVOCATION_REF);
checked.put("message", "source_invocation_id is required");
result.checked(checked);
result.fail(RULE_INVOCATION_REF, target + ".source_invocation_id",
"source_invocation_id is required", SEVERITY_LOW_CONFID);
return;
}
checked.put("source_invocation_id", id);
ToolInvocation invocation = validInvocations.get(id);
if (invocation == null) {
checked.put("status", STATUS_FAIL);
@@ -240,7 +270,6 @@ public class ExecutorGatekeeperService {
return;
}
String rawPath = stringValue(binding.get("raw_path"));
if (rawPath.isBlank()) {
checked.put("status", STATUS_FAIL);
checked.put("rule", RULE_RAW_PATH);
@@ -298,6 +327,82 @@ public class ExecutorGatekeeperService {
result.checked(checked);
}
private Long uniqueInvocationIdByToolAndRawPath(Map<Long, ToolInvocation> validInvocations,
String toolName,
String rawPath) {
if (toolName == null || toolName.isBlank() || rawPath == null || rawPath.isBlank()) {
return null;
}
Long matchedId = null;
for (Map.Entry<Long, ToolInvocation> entry : validInvocations.entrySet()) {
ToolInvocation invocation = entry.getValue();
if (!Objects.equals(toolName, invocation.getToolName())) {
continue;
}
if (!evidenceRefsByRawPath(invocation.getRetrievalDetails()).containsKey(rawPath)) {
continue;
}
if (matchedId != null) {
return null;
}
matchedId = entry.getKey();
}
return matchedId;
}
private Long uniqueInvocationIdByToolRawPathAndExcerpt(Map<Long, ToolInvocation> validInvocations,
String toolName,
String rawPath,
String excerpt) {
if (toolName == null || toolName.isBlank()
|| rawPath == null || rawPath.isBlank()
|| excerpt == null || excerpt.isBlank()) {
return null;
}
Long matchedId = null;
for (Map.Entry<Long, ToolInvocation> entry : validInvocations.entrySet()) {
ToolInvocation invocation = entry.getValue();
if (!Objects.equals(toolName, invocation.getToolName())) {
continue;
}
String matchedText = evidenceRefsByRawPath(invocation.getRetrievalDetails()).get(rawPath);
if (matchedText == null || !isBackfillCandidateSupported(rawPath, excerpt, matchedText)) {
continue;
}
if (matchedId != null) {
return null;
}
matchedId = entry.getKey();
}
return matchedId;
}
private boolean isBackfillCandidateSupported(String rawPath, String excerpt, String matchedText) {
if ("$.no_evidence".equals(rawPath)) {
String excerptQuery = semicolonField(excerpt, "query");
String matchedQuery = semicolonField(matchedText, "query");
if (!excerptQuery.isBlank() && !matchedQuery.isBlank()
&& !normalized(excerptQuery).equals(normalized(matchedQuery))) {
return false;
}
}
return isExcerptSupported(excerpt, matchedText);
}
private String semicolonField(String text, String field) {
if (text == null || text.isBlank() || field == null || field.isBlank()) {
return "";
}
String prefix = field + "=";
for (String part : text.split(";")) {
String trimmed = part.trim();
if (trimmed.regionMatches(true, 0, prefix, 0, prefix.length())) {
return trimmed.substring(prefix.length()).trim();
}
}
return "";
}
private Long singleInvocationId(Map<?, ?> binding) {
Long singular = asLong(binding.get("source_invocation_id"));
if (singular != null) {
@@ -88,7 +88,7 @@ public class ToolInvocationRecorder {
if (extraDetails != null && !extraDetails.isEmpty()) {
details.putAll(extraDetails);
}
List<Map<String, Object>> evidenceRefs = extractEvidenceRefs(toolName, output, details);
List<Map<String, Object>> evidenceRefs = extractEvidenceRefs(toolName, inputParams, output, details);
if (!evidenceRefs.isEmpty()) {
details.put("evidence_refs", evidenceRefs);
}
@@ -145,6 +145,12 @@ public class ToolInvocationRecorder {
}
details.put("evidence_blocks", record.evidenceBlocks() == null ? List.of() : record.evidenceBlocks());
List<Map<String, Object>> evidenceRefs = evidenceRefsFromEvidenceBlocks(record.evidenceBlocks());
if (evidenceRefs.isEmpty()
&& EVIDENCE_STATUS_NO_EVIDENCE.equals(normalizeEvidenceStatus(record.success(), record.evidenceStatus()))) {
Map<String, Object> input = new LinkedHashMap<>();
input.put("query", record.query());
evidenceRefs = List.of(noEvidenceRef("lookup_knowledge", input, null, details));
}
if (!evidenceRefs.isEmpty()) {
details.put("evidence_refs", evidenceRefs);
}
@@ -195,7 +201,10 @@ public class ToolInvocationRecorder {
return success ? EVIDENCE_STATUS_SUPPORTED : EVIDENCE_STATUS_FAILED;
}
private List<Map<String, Object>> extractEvidenceRefs(String toolName, String output, Map<String, Object> details) {
private List<Map<String, Object>> extractEvidenceRefs(String toolName,
Map<String, Object> inputParams,
String output,
Map<String, Object> details) {
if (output == null || output.isBlank()) {
return List.of();
}
@@ -205,11 +214,14 @@ public class ToolInvocationRecorder {
try {
JsonNode root = objectMapper.readTree(output);
boolean noEvidence = EVIDENCE_STATUS_NO_EVIDENCE.equals(stringValue(details.get("evidence_status")));
if ("query_metrics".equals(toolName)) {
return evidenceRefsFromArray(root.path("alerts"), "$.alerts", this::alertText);
List<Map<String, Object>> refs = evidenceRefsFromArray(root.path("alerts"), "$.alerts", this::alertText);
return refs.isEmpty() && noEvidence ? List.of(noEvidenceRef(toolName, inputParams, root, details)) : refs;
}
if ("query_logs".equals(toolName)) {
return evidenceRefsFromArray(root.path("logs"), "$.logs", this::logText);
List<Map<String, Object>> refs = evidenceRefsFromArray(root.path("logs"), "$.logs", this::logText);
return refs.isEmpty() && noEvidence ? List.of(noEvidenceRef(toolName, inputParams, root, details)) : refs;
}
} catch (Exception e) {
log.debug("extract evidence_refs failed for tool={}", toolName, e);
@@ -217,6 +229,42 @@ public class ToolInvocationRecorder {
return List.of();
}
private Map<String, Object> noEvidenceRef(String toolName,
Map<String, Object> inputParams,
JsonNode root,
Map<String, Object> details) {
List<String> parts = new ArrayList<>();
addPart(parts, toolName + " returned no evidence");
addPart(parts, "evidence_status=" + stringValue(details.get("evidence_status")));
String query = firstNonBlank(
root == null ? null : textField(root, "query"),
inputParams == null ? null : inputParams.get("query")
);
if (!query.isBlank()) {
addPart(parts, "query=" + query);
}
String topic = firstNonBlank(
root == null ? null : textField(root, "log_topic"),
inputParams == null ? null : inputParams.get("log_topic"),
details == null ? null : details.get("log_topic"),
details == null ? null : details.get("metric_family")
);
if (!topic.isBlank()) {
addPart(parts, "topic=" + topic);
}
if (root != null && root.has("total")) {
addPart(parts, "total=" + root.path("total").asText());
}
String message = root == null ? "" : textField(root, "message");
if (!message.isBlank()) {
addPart(parts, "message=" + message);
}
return Map.of(
"raw_path", "$.no_evidence",
"text", bounded(String.join("; ", parts), 500)
);
}
private List<Map<String, Object>> evidenceRefsFromArray(JsonNode arrayNode,
String pathPrefix,
java.util.function.Function<JsonNode, String> textExtractor) {
@@ -314,6 +362,10 @@ public class ToolInvocationRecorder {
return "";
}
private String stringValue(Object value) {
return value == null ? "" : String.valueOf(value);
}
private String bounded(String value, int limit) {
if (value == null) {
return "";