feat(harness): freeze aci tool contracts

This commit is contained in:
zhuyongxin
2026-07-21 18:01:18 +08:00
parent 58c39107c5
commit 4274f3350b
32 changed files with 977 additions and 1 deletions
@@ -0,0 +1,26 @@
package com.superbiz.agent.harness.tool.contract;
public final class AgentToolContracts {
public static final String LOOKUP_KNOWLEDGE = "lookup_knowledge";
public static final String QUERY_LOGS = "query_logs";
public static final String QUERY_MYSQL = "query_mysql";
public static final String LOOKUP_KNOWLEDGE_DESCRIPTION =
"查询内部知识库中的文档、接口说明、错误码和排障手册。"
+ "适用于稳定背景知识,不用于查询实时日志、指标或数据库状态。"
+ "输入 query:需要查询的问题或关键词。";
public static final String QUERY_LOGS_DESCRIPTION =
"查询指定逻辑日志主题在时间窗口内与目标相关的日志证据。"
+ "适用于应用错误、慢查询和系统事件,不用于查询指标或数据库表。"
+ "输入 topic、query、lookback_minutes。";
public static final String QUERY_MYSQL_DESCRIPTION =
"在授权的逻辑数据源上执行参数化只读查询,获取业务数据库事实。"
+ "只用于已知库表字段的 SELECT,不用于发现表结构或执行写操作。"
+ "输入 data_source、sql、params。";
private AgentToolContracts() {
}
}
@@ -0,0 +1,10 @@
package com.superbiz.agent.harness.tool.contract;
import com.fasterxml.jackson.annotation.JsonProperty;
public record LogEvent(
@JsonProperty("timestamp") String timestamp,
@JsonProperty("level") String level,
@JsonProperty("service") String service,
@JsonProperty("message") String message) {
}
@@ -0,0 +1,12 @@
package com.superbiz.agent.harness.tool.contract;
import com.fasterxml.jackson.annotation.JsonProperty;
public record LogPattern(
@JsonProperty("count") long count,
@JsonProperty("first_seen") String firstSeen,
@JsonProperty("last_seen") String lastSeen,
@JsonProperty("level") String level,
@JsonProperty("service") String service,
@JsonProperty("example") String example) {
}
@@ -0,0 +1,10 @@
package com.superbiz.agent.harness.tool.contract;
import com.fasterxml.jackson.annotation.JsonProperty;
public record LogQueryScope(
@JsonProperty("topic") LogTopic topic,
@JsonProperty("query") String query,
@JsonProperty("start_time") String startTime,
@JsonProperty("end_time") String endTime) {
}
@@ -0,0 +1,5 @@
package com.superbiz.agent.harness.tool.contract;
public enum LogSourceKind {
MOCK
}
@@ -0,0 +1,7 @@
package com.superbiz.agent.harness.tool.contract;
public enum LogTopic {
APPLICATION,
DATABASE_SLOW_QUERY,
SYSTEM_EVENTS
}
@@ -0,0 +1,15 @@
package com.superbiz.agent.harness.tool.contract;
import com.fasterxml.jackson.annotation.JsonProperty;
import java.util.List;
public record MysqlToolRequest(
@JsonProperty("data_source") String dataSource,
@JsonProperty("sql") String sql,
@JsonProperty("params") List<Object> params) {
public MysqlToolRequest {
params = ToolContractCollections.immutable(params);
}
}
@@ -0,0 +1,21 @@
package com.superbiz.agent.harness.tool.contract;
import com.fasterxml.jackson.annotation.JsonProperty;
import com.superbiz.agent.harness.contract.EvidenceStatus;
import java.util.List;
import java.util.Map;
public record MysqlToolResult(
@JsonProperty("evidence_status") EvidenceStatus evidenceStatus,
@JsonProperty("tool_call_id") String toolCallId,
@JsonProperty("columns") List<String> columns,
@JsonProperty("rows") List<Map<String, Object>> rows,
@JsonProperty("returned_count") int returnedCount,
@JsonProperty("truncated") boolean truncated) {
public MysqlToolResult {
columns = ToolContractCollections.immutable(columns);
rows = ToolContractCollections.immutableRows(rows);
}
}
@@ -0,0 +1,9 @@
package com.superbiz.agent.harness.tool.contract;
import com.fasterxml.jackson.annotation.JsonProperty;
public record QueryLogsRequest(
@JsonProperty("topic") LogTopic topic,
@JsonProperty("query") String query,
@JsonProperty("lookback_minutes") Integer lookbackMinutes) {
}
@@ -0,0 +1,23 @@
package com.superbiz.agent.harness.tool.contract;
import com.fasterxml.jackson.annotation.JsonProperty;
import com.superbiz.agent.harness.contract.EvidenceStatus;
import java.util.List;
public record QueryLogsToolResult(
@JsonProperty("evidence_status") EvidenceStatus evidenceStatus,
@JsonProperty("tool_call_id") String toolCallId,
@JsonProperty("source_kind") LogSourceKind sourceKind,
@JsonProperty("scope") LogQueryScope scope,
@JsonProperty("match_count") long matchCount,
@JsonProperty("returned_count") int returnedCount,
@JsonProperty("patterns") List<LogPattern> patterns,
@JsonProperty("events") List<LogEvent> events,
@JsonProperty("truncated") boolean truncated) {
public QueryLogsToolResult {
patterns = ToolContractCollections.immutable(patterns);
events = ToolContractCollections.immutable(events);
}
}
@@ -0,0 +1,11 @@
package com.superbiz.agent.harness.tool.contract;
import com.fasterxml.jackson.annotation.JsonProperty;
public record RagEvidence(
@JsonProperty("document_id") String documentId,
@JsonProperty("source") String source,
@JsonProperty("title") String title,
@JsonProperty("breadcrumb") String breadcrumb,
@JsonProperty("excerpt") String excerpt) {
}
@@ -0,0 +1,7 @@
package com.superbiz.agent.harness.tool.contract;
import com.fasterxml.jackson.annotation.JsonProperty;
public record RagToolRequest(
@JsonProperty("query") String query) {
}
@@ -0,0 +1,19 @@
package com.superbiz.agent.harness.tool.contract;
import com.fasterxml.jackson.annotation.JsonProperty;
import com.superbiz.agent.harness.contract.EvidenceStatus;
import java.util.List;
public record RagToolResult(
@JsonProperty("evidence_status") EvidenceStatus evidenceStatus,
@JsonProperty("tool_call_id") String toolCallId,
@JsonProperty("query") String query,
@JsonProperty("evidence") List<RagEvidence> evidence,
@JsonProperty("returned_count") int returnedCount,
@JsonProperty("truncated") boolean truncated) {
public RagToolResult {
evidence = ToolContractCollections.immutable(evidence);
}
}
@@ -0,0 +1,32 @@
package com.superbiz.agent.harness.tool.contract;
import java.util.Collections;
import java.util.LinkedHashMap;
import java.util.List;
import java.util.Map;
final class ToolContractCollections {
private ToolContractCollections() {
}
static <T> List<T> immutable(List<T> values) {
return values == null ? List.of() : List.copyOf(values);
}
static List<Map<String, Object>> immutableRows(List<Map<String, Object>> rows) {
if (rows == null) {
return List.of();
}
return rows.stream()
.map(ToolContractCollections::immutableRow)
.toList();
}
private static Map<String, Object> immutableRow(Map<String, Object> row) {
if (row == null) {
return Map.of();
}
return Collections.unmodifiableMap(new LinkedHashMap<>(row));
}
}
@@ -0,0 +1,73 @@
package com.superbiz.agent.harness.tool.contract;
import com.fasterxml.jackson.databind.JsonNode;
import com.fasterxml.jackson.databind.ObjectMapper;
import com.superbiz.agent.harness.contract.EvidenceStatus;
import org.junit.jupiter.api.Test;
import java.util.ArrayList;
import java.util.LinkedHashMap;
import java.util.List;
import java.util.Map;
import java.util.Set;
import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.assertFalse;
import static org.junit.jupiter.api.Assertions.assertThrows;
class MysqlToolContractTest {
private final ObjectMapper objectMapper = new ObjectMapper();
@Test
void freezesLogicalParameterizedRequestWithoutConnectionData() {
List<Object> params = new ArrayList<>(List.of("order-123"));
MysqlToolRequest request = new MysqlToolRequest(
"order_readonly",
"SELECT order_id, payment_status FROM biz_order WHERE order_id = ?",
params);
params.clear();
JsonNode json = objectMapper.valueToTree(request);
assertEquals(Set.of("data_source", "sql", "params"), ToolContractAssertions.fieldNames(json));
assertEquals(1, json.path("params").size());
assertFalse(json.has("tool_call_id"));
assertFalse(json.has("jdbc_url"));
assertFalse(json.has("username"));
assertFalse(json.has("password"));
assertFalse(json.has("timeout"));
assertThrows(UnsupportedOperationException.class, () -> request.params().add("other"));
}
@Test
void freezesBoundedStructuredRowsWithDeepImmutability() {
Map<String, Object> row = new LinkedHashMap<>();
row.put("order_id", "order-123");
row.put("payment_status", "FAILED");
List<Map<String, Object>> rows = new ArrayList<>(List.of(row));
List<String> columns = new ArrayList<>(List.of("order_id", "payment_status"));
MysqlToolResult result = new MysqlToolResult(
EvidenceStatus.EVIDENCE_FOUND,
"framework-call-mysql-1",
columns,
rows,
1,
false);
row.put("secret", "must-not-appear");
rows.clear();
columns.clear();
JsonNode json = objectMapper.valueToTree(result);
assertEquals(Set.of("evidence_status", "tool_call_id", "columns", "rows",
"returned_count", "truncated"), ToolContractAssertions.fieldNames(json));
assertEquals("framework-call-mysql-1", json.path("tool_call_id").asText());
assertEquals(List.of("order_id", "payment_status"), result.columns());
assertEquals(1, json.path("rows").size());
assertFalse(json.path("rows").get(0).has("secret"));
assertThrows(UnsupportedOperationException.class,
() -> result.rows().get(0).put("other", "value"));
assertEquals("query_mysql", AgentToolContracts.QUERY_MYSQL);
ToolContractAssertions.assertImplementationNeutral(AgentToolContracts.QUERY_MYSQL_DESCRIPTION);
}
}
@@ -0,0 +1,76 @@
package com.superbiz.agent.harness.tool.contract;
import com.fasterxml.jackson.databind.JsonNode;
import com.fasterxml.jackson.databind.ObjectMapper;
import com.superbiz.agent.harness.contract.EvidenceStatus;
import org.junit.jupiter.api.Test;
import java.util.ArrayList;
import java.util.List;
import java.util.Set;
import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.assertFalse;
import static org.junit.jupiter.api.Assertions.assertThrows;
class QueryLogsToolContractTest {
private final ObjectMapper objectMapper = new ObjectMapper();
@Test
void freezesLogicalRequestWithoutInfrastructureControls() {
JsonNode json = objectMapper.valueToTree(
new QueryLogsRequest(LogTopic.APPLICATION, "order-service HikariCP timeout", 30));
assertEquals(Set.of("topic", "query", "lookback_minutes"), ToolContractAssertions.fieldNames(json));
assertEquals("APPLICATION", json.path("topic").asText());
assertFalse(json.has("tool_call_id"));
assertFalse(json.has("region"));
assertFalse(json.has("log_topic"));
assertFalse(json.has("limit"));
assertEquals(Set.of("APPLICATION", "DATABASE_SLOW_QUERY", "SYSTEM_EVENTS"),
java.util.Arrays.stream(LogTopic.values()).map(Enum::name)
.collect(java.util.stream.Collectors.toSet()));
}
@Test
void freezesMockProvenanceScopeAndBoundedTimeline() {
List<LogPattern> patterns = new ArrayList<>(List.of(new LogPattern(
84, "2026-07-21T10:00:00Z", "2026-07-21T10:29:00Z",
"ERROR", "order-service", "HikariPool connection is not available")));
List<LogEvent> events = new ArrayList<>(List.of(new LogEvent(
"2026-07-21T10:29:00Z", "ERROR", "order-service",
"HikariPool connection is not available")));
QueryLogsToolResult result = new QueryLogsToolResult(
EvidenceStatus.EVIDENCE_FOUND,
"framework-call-log-1",
LogSourceKind.MOCK,
new LogQueryScope(LogTopic.APPLICATION, "order-service HikariCP timeout",
"2026-07-21T10:00:00Z", "2026-07-21T10:30:00Z"),
126,
1,
patterns,
events,
true);
patterns.clear();
events.clear();
JsonNode json = objectMapper.valueToTree(result);
assertEquals(Set.of("evidence_status", "tool_call_id", "source_kind", "scope",
"match_count", "returned_count", "patterns", "events", "truncated"),
ToolContractAssertions.fieldNames(json));
assertEquals("framework-call-log-1", json.path("tool_call_id").asText());
assertEquals("MOCK", json.path("source_kind").asText());
assertEquals(126, json.path("match_count").asLong());
assertEquals(1, json.path("returned_count").asInt());
assertEquals(1, json.path("patterns").size());
assertEquals(1, json.path("events").size());
assertEquals(Set.of("topic", "query", "start_time", "end_time"),
ToolContractAssertions.fieldNames(json.path("scope")));
assertThrows(UnsupportedOperationException.class,
() -> result.events().add(new LogEvent(null, null, null, null)));
assertEquals("query_logs", AgentToolContracts.QUERY_LOGS);
ToolContractAssertions.assertImplementationNeutral(AgentToolContracts.QUERY_LOGS_DESCRIPTION);
}
}
@@ -0,0 +1,61 @@
package com.superbiz.agent.harness.tool.contract;
import com.fasterxml.jackson.databind.JsonNode;
import com.fasterxml.jackson.databind.ObjectMapper;
import com.superbiz.agent.harness.contract.EvidenceStatus;
import com.superbiz.agent.harness.contract.InvocationStatus;
import org.junit.jupiter.api.Test;
import java.util.ArrayList;
import java.util.List;
import java.util.Set;
import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.assertFalse;
import static org.junit.jupiter.api.Assertions.assertThrows;
class RagToolContractTest {
private final ObjectMapper objectMapper = new ObjectMapper();
@Test
void freezesMinimalRequestAndBoundedEvidenceResult() {
RagToolRequest request = new RagToolRequest("支付超时处理方式");
JsonNode requestJson = objectMapper.valueToTree(request);
assertEquals(Set.of("query"), ToolContractAssertions.fieldNames(requestJson));
assertFalse(requestJson.has("tool_call_id"));
List<RagEvidence> source = new ArrayList<>();
source.add(new RagEvidence("payment-timeout-guide", "payment-timeout.md",
"支付超时排查", "支付系统 > 故障排查", "先检查 ERR_TIMEOUT 发生时间。"));
RagToolResult result = new RagToolResult(EvidenceStatus.EVIDENCE_FOUND, "framework-call-rag-1",
request.query(), source, 1, false);
source.clear();
JsonNode json = objectMapper.valueToTree(result);
assertEquals(Set.of("evidence_status", "tool_call_id", "query", "evidence",
"returned_count", "truncated"), ToolContractAssertions.fieldNames(json));
assertEquals("EVIDENCE_FOUND", json.path("evidence_status").asText());
assertEquals("framework-call-rag-1", json.path("tool_call_id").asText());
assertEquals(1, json.path("evidence").size());
assertEquals(Set.of("document_id", "source", "title", "breadcrumb", "excerpt"),
ToolContractAssertions.fieldNames(json.path("evidence").get(0)));
assertFalse(json.has("context_pack"));
assertFalse(json.has("retrieval_trace"));
assertThrows(UnsupportedOperationException.class,
() -> result.evidence().add(new RagEvidence("other", "other.md", null, null, "other")));
}
@Test
void keepsLifecycleAndEvidenceStatusNamespacesIndependent() {
assertEquals(Set.of("PROJECTING", "READY", "ERROR"), enumNames(InvocationStatus.values()));
assertEquals(Set.of("EVIDENCE_FOUND", "NO_EVIDENCE", "ERROR"), enumNames(EvidenceStatus.values()));
assertEquals("lookup_knowledge", AgentToolContracts.LOOKUP_KNOWLEDGE);
ToolContractAssertions.assertImplementationNeutral(AgentToolContracts.LOOKUP_KNOWLEDGE_DESCRIPTION);
}
private Set<String> enumNames(Enum<?>[] values) {
return java.util.Arrays.stream(values).map(Enum::name).collect(java.util.stream.Collectors.toSet());
}
}
@@ -0,0 +1,33 @@
package com.superbiz.agent.harness.tool.contract;
import com.fasterxml.jackson.databind.JsonNode;
import java.util.HashSet;
import java.util.Locale;
import java.util.Set;
import static org.junit.jupiter.api.Assertions.assertFalse;
import static org.junit.jupiter.api.Assertions.assertTrue;
final class ToolContractAssertions {
private static final Set<String> INTERNAL_TERMS = Set.of(
"milvus", "l0", "l1", "rerank", "region", "topicid", "redis",
"jdbc", "credential", "password", "topk", "limit", "trace");
private ToolContractAssertions() {
}
static Set<String> fieldNames(JsonNode node) {
Set<String> names = new HashSet<>();
node.fieldNames().forEachRemaining(names::add);
return names;
}
static void assertImplementationNeutral(String description) {
assertTrue(description.length() < 180, "Tool description must remain concise");
String normalized = description.toLowerCase(Locale.ROOT);
INTERNAL_TERMS.forEach(term -> assertFalse(normalized.contains(term),
() -> "Tool description leaks internal term: " + term));
}
}