feat(harness): add evidence and semantic guards

This commit is contained in:
zhuyongxin
2026-07-21 23:50:51 +08:00
parent 2362665519
commit ee0949d464
40 changed files with 2980 additions and 2 deletions
@@ -0,0 +1,296 @@
package com.superbiz.agent.harness.guard.evidence;
import com.fasterxml.jackson.databind.ObjectMapper;
import com.superbiz.agent.harness.contract.AnalysisKind;
import com.superbiz.agent.harness.contract.DiagnosisDraft;
import com.superbiz.agent.harness.contract.EvidenceStatus;
import com.superbiz.agent.harness.core.DiagnosisHarnessCore;
import com.superbiz.agent.harness.core.RunBudgetLimits;
import com.superbiz.agent.harness.core.RunContext;
import com.superbiz.agent.harness.retry.HarnessRetryPolicies;
import com.superbiz.agent.harness.tool.store.CanonicalInvocationLimits;
import com.superbiz.agent.harness.tool.store.CanonicalInvocationStore;
import com.superbiz.agent.harness.tool.store.CanonicalToolInvocation;
import com.superbiz.agent.harness.tool.store.ToolCallKeyFactory;
import org.junit.jupiter.api.Test;
import java.time.Clock;
import java.time.Duration;
import java.time.Instant;
import java.util.HashMap;
import java.util.List;
import java.util.Map;
import java.util.Optional;
import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.assertFalse;
import static org.junit.jupiter.api.Assertions.assertTrue;
class EvidenceGuardTest {
private static final String PREFIX = "superbiz:harness:tool-call";
private final ObjectMapper objectMapper = new ObjectMapper();
private final InMemoryStore store = new InMemoryStore();
private final EvidenceGuard guard = new EvidenceGuard(
store, new ToolCallKeyFactory(PREFIX), objectMapper);
private final RunContext context = context("run-guard");
@Test
void validCurrentRunRagReferenceProducesInternalIdFreeSnapshot() throws Exception {
String callId = "call-rag-1";
ready(callId, "lookup_knowledge", "{\"query\":\"pool timeout\"}", """
{"evidence_status":"EVIDENCE_FOUND","tool_call_id":"call-rag-1",
"query":"pool timeout","evidence":[{"document_id":"doc-1",
"source":"runbook.md","title":"Pool guide","breadcrumb":"DB > Pool",
"excerpt":"active=50 max=50"}],"returned_count":1,"truncated":false}
""", EvidenceStatus.EVIDENCE_FOUND);
EvidenceGuardResult result = guard.validate(context, draft(callId, AnalysisKind.NORMAL));
assertTrue(result.valid());
VerifiedEvidenceSnapshot snapshot = result.verifiedSnapshot().orElseThrow();
assertEquals("a-1", snapshot.analyses().get(0).analysisId());
assertEquals("runbook.md", snapshot.analyses().get(0).evidence().get(0).source());
assertEquals("active=50 max=50", snapshot.analyses().get(0).evidence().get(0).excerpt());
String json = objectMapper.writeValueAsString(snapshot);
assertFalse(json.contains(callId));
assertFalse(json.contains("raw_response"));
assertEquals(1, snapshot.verifiedSources().size());
}
@Test
void noEvidenceSupportsOnlyScopedNegativeObservation() {
String callId = "call-rag-empty";
ready(callId, "lookup_knowledge", "{\"query\":\"unknown failure\"}", """
{"evidence_status":"NO_EVIDENCE","tool_call_id":"call-rag-empty",
"query":"unknown failure","evidence":[],"returned_count":0,"truncated":false}
""", EvidenceStatus.NO_EVIDENCE);
EvidenceGuardResult result = guard.validate(
context, draft(callId, AnalysisKind.NEGATIVE_OBSERVATION));
assertTrue(result.valid());
VerifiedEvidence evidence = result.verifiedSnapshot().orElseThrow()
.analyses().get(0).evidence().get(0);
assertEquals("unknown failure", evidence.scope());
assertEquals(0, evidence.values().get("match_count"));
}
@Test
void validLogProjectionPreservesSourceScopeTimelineAndExactMessage() {
String callId = "call-log-1";
ready(callId, "query_logs", """
{"topic":"APPLICATION","query":"pool timeout","lookback_minutes":30}
""", """
{"evidence_status":"EVIDENCE_FOUND","tool_call_id":"call-log-1",
"source_kind":"MOCK","scope":{"topic":"APPLICATION","query":"pool timeout",
"start_time":"2026-07-21T10:00:00Z","end_time":"2026-07-21T10:30:00Z"},
"match_count":1,"returned_count":1,"patterns":[],
"events":[{"timestamp":"2026-07-21T10:29:00Z","level":"ERROR",
"service":"order-service","message":"active=50 max=50"}],"truncated":false}
""", EvidenceStatus.EVIDENCE_FOUND);
EvidenceGuardResult result = guard.validate(context, draft(callId, AnalysisKind.NORMAL));
assertTrue(result.valid());
VerifiedEvidence evidence = result.verifiedSnapshot().orElseThrow()
.analyses().get(0).evidence().get(0);
assertEquals("LOG", evidence.sourceType());
assertEquals("APPLICATION (MOCK)", evidence.source());
assertEquals("2026-07-21T10:29:00Z", evidence.timestamp());
assertEquals("active=50 max=50", evidence.excerpt());
assertTrue(evidence.scope().contains("pool timeout"));
}
@Test
void validMysqlProjectionCombinesBoundedRequestScopeAndProjectedRows() {
String callId = "call-mysql-1";
ready(callId, "query_mysql", """
{"data_source":"order_readonly","sql":"SELECT status FROM biz_order WHERE id = ?",
"params":["order-1"]}
""", """
{"evidence_status":"EVIDENCE_FOUND","tool_call_id":"call-mysql-1",
"columns":["status"],"rows":[{"status":"FAILED"}],
"returned_count":1,"truncated":false}
""", EvidenceStatus.EVIDENCE_FOUND);
EvidenceGuardResult result = guard.validate(context, draft(callId, AnalysisKind.NORMAL));
assertTrue(result.valid());
VerifiedEvidence evidence = result.verifiedSnapshot().orElseThrow()
.analyses().get(0).evidence().get(0);
assertEquals("MYSQL", evidence.sourceType());
assertEquals("order_readonly", evidence.source());
assertTrue(evidence.scope().contains("SELECT status"));
assertTrue(evidence.scope().contains("order-1"));
assertEquals("FAILED", evidence.values().get("status"));
}
@Test
void duplicateAnalysisAndBrokenReportReferenceFailBeforeStoreLookup() {
DiagnosisDraft invalid = new DiagnosisDraft(
new DiagnosisDraft.Conclusion("Pool exhausted", List.of("missing")),
List.of(
new DiagnosisDraft.AnalysisItem(
"a-1", AnalysisKind.NORMAL, "first", List.of("call-1")),
new DiagnosisDraft.AnalysisItem(
"a-1", AnalysisKind.NORMAL, "second", List.of("call-2"))),
List.of(new DiagnosisDraft.ActionPlanItem("inspect", List.of(), false)),
List.of(), new DiagnosisDraft.Limitations("order-service", List.of()));
EvidenceGuardResult result = guard.validate(context, invalid);
assertFalse(result.valid());
assertTrue(hasViolation(result, EvidenceViolationCode.ANALYSIS_ID_DUPLICATE));
assertTrue(hasViolation(result, EvidenceViolationCode.ANALYSIS_REFERENCE_UNKNOWN));
assertTrue(hasViolation(result, EvidenceViolationCode.ANALYSIS_REFERENCE_MISSING));
assertTrue(result.verifiedSnapshot().isEmpty());
}
@Test
void fabricatedAndCrossRunReferencesAreRejected() {
EvidenceGuardResult missing = guard.validate(
context, draft("fabricated-call", AnalysisKind.NORMAL));
String callId = "call-cross-run";
String currentKey = PREFIX + ":" + context.runId() + ":" + callId;
store.records.put(currentKey, CanonicalToolInvocation.projecting(
callId, "another-run", "lookup_knowledge", "{\"query\":\"pool\"}",
Instant.parse("2026-07-21T10:00:00Z"))
.markReady("raw", """
{"evidence_status":"EVIDENCE_FOUND","tool_call_id":"call-cross-run",
"query":"pool","evidence":[{"document_id":"doc-1","source":"guide",
"title":"guide","breadcrumb":"pool","excerpt":"active=50"}],
"returned_count":1,"truncated":false}
""", EvidenceStatus.EVIDENCE_FOUND,
Instant.parse("2026-07-21T10:00:01Z")));
EvidenceGuardResult crossRun = guard.validate(
context, draft(callId, AnalysisKind.NORMAL));
assertTrue(hasViolation(missing, EvidenceViolationCode.INVOCATION_MISSING));
assertTrue(hasViolation(crossRun, EvidenceViolationCode.INVOCATION_NOT_REFERENCABLE));
}
@Test
void projectingInvocationAndEvidenceKindMismatchAreRejected() {
String projectingId = "call-projecting";
store.records.put(PREFIX + ":" + context.runId() + ":" + projectingId,
CanonicalToolInvocation.projecting(
projectingId, context.runId(), "lookup_knowledge", "{\"query\":\"pool\"}",
Instant.parse("2026-07-21T10:00:00Z")));
EvidenceGuardResult projecting = guard.validate(
context, draft(projectingId, AnalysisKind.NORMAL));
String emptyId = "call-empty-normal";
ready(emptyId, "lookup_knowledge", "{\"query\":\"pool\"}", """
{"evidence_status":"NO_EVIDENCE","tool_call_id":"call-empty-normal",
"query":"pool","evidence":[],"returned_count":0,"truncated":false}
""", EvidenceStatus.NO_EVIDENCE);
EvidenceGuardResult mismatch = guard.validate(
context, draft(emptyId, AnalysisKind.NORMAL));
assertTrue(hasViolation(projecting, EvidenceViolationCode.INVOCATION_NOT_REFERENCABLE));
assertTrue(hasViolation(mismatch, EvidenceViolationCode.EVIDENCE_KIND_MISMATCH));
}
@Test
void projectionIdOrStatusMismatchFailsClosed() {
String callId = "call-mismatch";
ready(callId, "lookup_knowledge", "{\"query\":\"pool\"}", """
{"evidence_status":"NO_EVIDENCE","tool_call_id":"another-call",
"query":"pool","evidence":[],"returned_count":0,"truncated":false}
""", EvidenceStatus.EVIDENCE_FOUND);
EvidenceGuardResult result = guard.validate(context, draft(callId, AnalysisKind.NORMAL));
assertTrue(hasViolation(result, EvidenceViolationCode.PROJECTION_ID_MISMATCH));
}
@Test
void canonicalRecordIdMustMatchTheDraftReferenceEvenUnderCorruptedKey() {
String referencedId = "call-requested";
CanonicalToolInvocation wrongRecord = CanonicalToolInvocation.projecting(
"call-other", context.runId(), "lookup_knowledge", "{\"query\":\"pool\"}",
Instant.parse("2026-07-21T10:00:00Z"))
.markReady("raw", """
{"evidence_status":"EVIDENCE_FOUND","tool_call_id":"call-other",
"query":"pool","evidence":[{"document_id":"doc-1","source":"guide",
"title":"guide","breadcrumb":"pool","excerpt":"active=50"}],
"returned_count":1,"truncated":false}
""", EvidenceStatus.EVIDENCE_FOUND,
Instant.parse("2026-07-21T10:00:01Z"));
store.records.put(PREFIX + ":" + context.runId() + ":" + referencedId, wrongRecord);
EvidenceGuardResult result = guard.validate(
context, draft(referencedId, AnalysisKind.NORMAL));
assertTrue(hasViolation(result, EvidenceViolationCode.INVOCATION_ID_MISMATCH));
}
private boolean hasViolation(EvidenceGuardResult result, EvidenceViolationCode code) {
return result.violations().stream().anyMatch(violation -> violation.code() == code);
}
private DiagnosisDraft draft(String callId, AnalysisKind kind) {
return new DiagnosisDraft(
new DiagnosisDraft.Conclusion("Pool exhausted", List.of("a-1")),
List.of(new DiagnosisDraft.AnalysisItem(
"a-1", kind, "Pool reached its limit", List.of(callId))),
List.of(new DiagnosisDraft.ActionPlanItem(
"Inspect long transactions", List.of("a-1"), false)),
List.of(new DiagnosisDraft.Recommendation(
"Add saturation alert", List.of("a-1"))),
new DiagnosisDraft.Limitations("order-service", List.of()));
}
private void ready(String callId, String toolName, String request,
String agentResult, EvidenceStatus evidenceStatus) {
String key = PREFIX + ":" + context.runId() + ":" + callId;
CanonicalToolInvocation invocation = CanonicalToolInvocation.projecting(
callId, context.runId(), toolName, request, Instant.parse("2026-07-21T10:00:00Z"))
.markReady("raw-must-not-be-read", agentResult, evidenceStatus,
Instant.parse("2026-07-21T10:00:01Z"));
store.records.put(key, invocation);
}
private RunContext context(String runId) {
return new DiagnosisHarnessCore(
Clock.systemUTC(), () -> runId, Duration.ofMinutes(5),
new RunBudgetLimits(10, 10, 10, 10_000, 10_000, 20_000, 1_000_000),
HarnessRetryPolicies.strict()).startRun("session-guard", runId);
}
private static final class InMemoryStore implements CanonicalInvocationStore {
private final Map<String, CanonicalToolInvocation> records = new HashMap<>();
private final CanonicalInvocationLimits limits =
new CanonicalInvocationLimits(Duration.ofHours(2), 1_000_000, 64_000);
@Override
public CanonicalInvocationLimits limits() {
return limits;
}
@Override
public void begin(String key, CanonicalToolInvocation invocation) {
records.put(key, invocation);
}
@Override
public Optional<CanonicalToolInvocation> find(String key) {
return Optional.ofNullable(records.get(key));
}
@Override
public CanonicalToolInvocation markReady(String key, String rawResponse,
String agentResult, EvidenceStatus evidenceStatus,
Instant completedAt) {
throw new UnsupportedOperationException();
}
@Override
public CanonicalToolInvocation markError(String key, String rawResponse,
String errorCode, Instant completedAt) {
throw new UnsupportedOperationException();
}
}
}
@@ -0,0 +1,231 @@
package com.superbiz.agent.harness.guard.semantic;
import com.fasterxml.jackson.databind.ObjectMapper;
import com.superbiz.agent.harness.contract.AnalysisKind;
import com.superbiz.agent.harness.contract.DiagnosisDraft;
import com.superbiz.agent.harness.contract.SemanticVerdict;
import com.superbiz.agent.harness.core.DiagnosisHarnessCore;
import com.superbiz.agent.harness.core.RunBudgetLimits;
import com.superbiz.agent.harness.core.RunContext;
import com.superbiz.agent.harness.guard.evidence.VerifiedAnalysisEvidence;
import com.superbiz.agent.harness.guard.evidence.VerifiedEvidence;
import com.superbiz.agent.harness.guard.evidence.VerifiedEvidenceSnapshot;
import com.superbiz.agent.harness.retry.HarnessRetryExecutor;
import com.superbiz.agent.harness.retry.HarnessRetryPolicies;
import com.superbiz.agent.harness.retry.RetryAttempt;
import org.junit.jupiter.api.AfterEach;
import org.junit.jupiter.api.Test;
import org.springframework.ai.chat.messages.AssistantMessage;
import org.springframework.ai.chat.metadata.ChatResponseMetadata;
import org.springframework.ai.chat.metadata.DefaultUsage;
import org.springframework.ai.chat.model.ChatModel;
import org.springframework.ai.chat.model.ChatResponse;
import org.springframework.ai.chat.model.Generation;
import org.springframework.ai.chat.prompt.Prompt;
import java.time.Clock;
import java.time.Duration;
import java.util.ArrayList;
import java.util.List;
import java.util.Map;
import java.util.concurrent.ExecutorService;
import java.util.concurrent.Executors;
import java.util.concurrent.CompletableFuture;
import java.util.concurrent.CompletionException;
import java.util.concurrent.CountDownLatch;
import java.util.concurrent.TimeUnit;
import java.util.concurrent.atomic.AtomicInteger;
import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.assertFalse;
import static org.junit.jupiter.api.Assertions.assertTrue;
import static org.junit.jupiter.api.Assertions.assertThrows;
class SemanticGuardTest {
private final ObjectMapper objectMapper = new ObjectMapper();
private final ExecutorService executor = Executors.newCachedThreadPool();
@AfterEach
void shutdownExecutor() {
executor.shutdownNow();
}
@Test
void unsupportedIsAValidBusinessDecisionAndIsNotRetried() {
DiagnosisHarnessCore core = core();
RunContext context = core.startRun("session-semantic", "run-semantic");
ScriptedChatModel model = new ScriptedChatModel(
"{\"verdict\":\"UNSUPPORTED\",\"reason\":\"evidence does not prove the root cause\"}");
List<RetryAttempt> attempts = new ArrayList<>();
SemanticGuard guard = new SemanticGuard(
core,
new HarnessRetryExecutor(core),
new GuardModelCall(core, model, executor),
objectMapper,
new SemanticGuardLimits(100_000, 10_000,
Duration.ofSeconds(2), Duration.ofSeconds(3)),
attempts::add);
SemanticGuardDecision decision = guard.review(
context, SemanticGuardInput.from("Why did payment fail?", draft(), snapshot()));
assertEquals(SemanticVerdict.UNSUPPORTED, decision.verdict());
assertEquals(1, model.calls.get());
assertEquals(1, attempts.size());
assertTrue(attempts.get(0).success());
String prompt = model.prompts.get(0);
assertTrue(prompt.contains("Why did payment fail?"));
assertTrue(prompt.contains("active=50 max=50"));
assertFalse(prompt.contains("call-rag-1"));
assertFalse(prompt.contains("tool_call_id"));
assertFalse(prompt.contains("raw_response"));
}
@Test
void parseFailureRetriesOnceWithTheExactSameInput() {
DiagnosisHarnessCore core = core();
RunContext context = core.startRun("session-retry", "run-retry");
ScriptedChatModel model = new ScriptedChatModel(
"not-json", "{\"verdict\":\"SUPPORTED\",\"reason\":\"all claims are grounded\"}");
List<RetryAttempt> attempts = new ArrayList<>();
SemanticGuard guard = new SemanticGuard(
core, new HarnessRetryExecutor(core),
new GuardModelCall(core, model, executor), objectMapper,
new SemanticGuardLimits(100_000, 10_000,
Duration.ofSeconds(2), Duration.ofSeconds(3)), attempts::add);
SemanticGuardDecision decision = guard.review(
context, SemanticGuardInput.from("Why did payment fail?", draft(), snapshot()));
assertEquals(SemanticVerdict.SUPPORTED, decision.verdict());
assertEquals(2, model.calls.get());
assertEquals(model.prompts.get(0), model.prompts.get(1));
assertEquals(com.superbiz.agent.harness.retry.RetryFailure.PARSE_ERROR,
attempts.get(0).failure());
assertTrue(attempts.get(1).success());
assertEquals(2, context.budget().snapshot().modelCalls());
assertEquals(16, context.budget().snapshot().totalTokens());
}
@Test
void attemptTimeoutCancelsBothPermittedModelCalls() throws Exception {
DiagnosisHarnessCore core = core();
RunContext context = core.startRun("session-timeout", "run-timeout");
BlockingChatModel model = new BlockingChatModel(2);
List<RetryAttempt> attempts = new ArrayList<>();
SemanticGuard guard = new SemanticGuard(
core, new HarnessRetryExecutor(core),
new GuardModelCall(core, model, executor), objectMapper,
new SemanticGuardLimits(100_000, 10_000,
Duration.ofMillis(50), Duration.ofMillis(500)), attempts::add);
com.superbiz.agent.harness.retry.RetryExecutionException failure = assertThrows(
com.superbiz.agent.harness.retry.RetryExecutionException.class,
() -> guard.review(context,
SemanticGuardInput.from("Why?", draft(), snapshot())));
assertEquals(com.superbiz.agent.harness.retry.RetryFailure.TIMEOUT, failure.failure());
assertEquals(2, failure.attempts());
assertTrue(model.interrupted.await(2, TimeUnit.SECONDS));
assertEquals(2, model.calls.get());
assertEquals(2, attempts.size());
}
@Test
void runCancellationInterruptsPendingModelAndDoesNotRetry() throws Exception {
DiagnosisHarnessCore core = core();
RunContext context = core.startRun("session-cancel", "run-cancel");
BlockingChatModel model = new BlockingChatModel(1);
SemanticGuard guard = new SemanticGuard(
core, new HarnessRetryExecutor(core),
new GuardModelCall(core, model, executor), objectMapper,
new SemanticGuardLimits(100_000, 10_000,
Duration.ofSeconds(2), Duration.ofSeconds(3)), ignored -> { });
CompletableFuture<SemanticGuardDecision> execution = CompletableFuture.supplyAsync(
() -> guard.review(context,
SemanticGuardInput.from("Why?", draft(), snapshot())));
assertTrue(model.started.await(2, TimeUnit.SECONDS));
core.cancel(context,
com.superbiz.agent.harness.core.RunCancellationReason.USER_REQUESTED);
CompletionException thrown = assertThrows(CompletionException.class, execution::join);
com.superbiz.agent.harness.retry.RetryExecutionException failure =
(com.superbiz.agent.harness.retry.RetryExecutionException) thrown.getCause();
assertEquals(com.superbiz.agent.harness.retry.RetryFailure.CANCELLED, failure.failure());
assertTrue(model.interrupted.await(2, TimeUnit.SECONDS));
assertEquals(1, model.calls.get());
}
private DiagnosisDraft draft() {
return new DiagnosisDraft(
new DiagnosisDraft.Conclusion("Pool exhausted", List.of("a-1")),
List.of(new DiagnosisDraft.AnalysisItem(
"a-1", AnalysisKind.NORMAL, "Pool reached its limit", List.of("call-rag-1"))),
List.of(new DiagnosisDraft.ActionPlanItem(
"Inspect long transactions", List.of("a-1"), false)),
List.of(new DiagnosisDraft.Recommendation(
"Add saturation alert", List.of("a-1"))),
new DiagnosisDraft.Limitations("order-service, last 30 minutes", List.of("No slow SQL")));
}
private VerifiedEvidenceSnapshot snapshot() {
return new VerifiedEvidenceSnapshot(List.of(new VerifiedAnalysisEvidence(
"a-1", "Pool reached its limit", AnalysisKind.NORMAL,
List.of(new VerifiedEvidence(
"LOG", "APPLICATION (MOCK)", "order-service, last 30 minutes",
"2026-07-21T10:29:00Z", "active=50 max=50", Map.of("count", 1))))));
}
private DiagnosisHarnessCore core() {
return new DiagnosisHarnessCore(
Clock.systemUTC(), () -> "unused", Duration.ofMinutes(5),
new RunBudgetLimits(10, 10, 10, 100_000, 100_000, 200_000, 1_000_000),
HarnessRetryPolicies.strict());
}
private static final class ScriptedChatModel implements ChatModel {
private final List<String> responses;
private final List<String> prompts = new ArrayList<>();
private final AtomicInteger calls = new AtomicInteger();
private ScriptedChatModel(String... responses) {
this.responses = List.of(responses);
}
@Override
public ChatResponse call(Prompt prompt) {
prompts.add(prompt.getContents());
int index = calls.getAndIncrement();
ChatResponseMetadata metadata = ChatResponseMetadata.builder()
.usage(new DefaultUsage(5, 3)).build();
return new ChatResponse(
List.of(new Generation(new AssistantMessage(responses.get(index)))), metadata);
}
}
private static final class BlockingChatModel implements ChatModel {
private final AtomicInteger calls = new AtomicInteger();
private final CountDownLatch started = new CountDownLatch(1);
private final CountDownLatch interrupted;
private BlockingChatModel(int expectedInterruptions) {
this.interrupted = new CountDownLatch(expectedInterruptions);
}
@Override
public ChatResponse call(Prompt prompt) {
calls.incrementAndGet();
started.countDown();
try {
Thread.sleep(10_000);
throw new AssertionError("blocking model should be interrupted");
} catch (InterruptedException exception) {
interrupted.countDown();
Thread.currentThread().interrupt();
throw new IllegalStateException("interrupted", exception);
}
}
}
}
@@ -0,0 +1,314 @@
package com.superbiz.agent.harness.release;
import com.fasterxml.jackson.databind.ObjectMapper;
import com.superbiz.agent.harness.contract.AnalysisKind;
import com.superbiz.agent.harness.contract.DiagnosisDraft;
import com.superbiz.agent.harness.contract.EvidenceStatus;
import com.superbiz.agent.harness.contract.FallbackType;
import com.superbiz.agent.harness.contract.ReleaseOutcome;
import com.superbiz.agent.harness.core.DiagnosisHarnessCore;
import com.superbiz.agent.harness.core.RunBudgetLimits;
import com.superbiz.agent.harness.core.RunContext;
import com.superbiz.agent.harness.guard.evidence.EvidenceGuard;
import com.superbiz.agent.harness.guard.semantic.GuardModelCall;
import com.superbiz.agent.harness.guard.semantic.SemanticGuard;
import com.superbiz.agent.harness.guard.semantic.SemanticGuardLimits;
import com.superbiz.agent.harness.retry.HarnessRetryExecutor;
import com.superbiz.agent.harness.retry.HarnessRetryPolicies;
import com.superbiz.agent.harness.tool.store.CanonicalInvocationLimits;
import com.superbiz.agent.harness.tool.store.CanonicalInvocationStore;
import com.superbiz.agent.harness.tool.store.CanonicalToolInvocation;
import com.superbiz.agent.harness.tool.store.ToolCallKeyFactory;
import org.junit.jupiter.api.AfterEach;
import org.junit.jupiter.api.Test;
import org.springframework.ai.chat.messages.AssistantMessage;
import org.springframework.ai.chat.metadata.ChatResponseMetadata;
import org.springframework.ai.chat.metadata.DefaultUsage;
import org.springframework.ai.chat.model.ChatModel;
import org.springframework.ai.chat.model.ChatResponse;
import org.springframework.ai.chat.model.Generation;
import org.springframework.ai.chat.prompt.Prompt;
import java.time.Clock;
import java.time.Duration;
import java.time.Instant;
import java.util.ArrayList;
import java.util.HashMap;
import java.util.List;
import java.util.Map;
import java.util.Optional;
import java.util.concurrent.ExecutorService;
import java.util.concurrent.Executors;
import java.util.concurrent.atomic.AtomicInteger;
import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.assertFalse;
import static org.junit.jupiter.api.Assertions.assertNotNull;
import static org.junit.jupiter.api.Assertions.assertNull;
import static org.junit.jupiter.api.Assertions.assertSame;
class DiagnosisReleaseUseCaseTest {
private static final String PREFIX = "superbiz:harness:tool-call";
private final ObjectMapper objectMapper = new ObjectMapper();
private final ExecutorService executor = Executors.newCachedThreadPool();
@AfterEach
void shutdownExecutor() {
executor.shutdownNow();
}
@Test
void supportedVerifiedDraftIsReleasedUnchangedWithoutRepair() {
DiagnosisHarnessCore core = core();
RunContext context = core.startRun("session-release", "run-release");
InMemoryStore store = new InMemoryStore();
ready(store, context, "call-rag-1");
ScriptedChatModel model = new ScriptedChatModel(
"{\"verdict\":\"SUPPORTED\",\"reason\":\"all claims are grounded\"}");
HarnessRetryExecutor retries = new HarnessRetryExecutor(core);
GuardModelCall modelCall = new GuardModelCall(core, model, executor);
DiagnosisReleaseUseCase useCase = new DiagnosisReleaseUseCase(
new EvidenceGuard(store, new ToolCallKeyFactory(PREFIX), objectMapper),
new EvidenceRepair(core, retries, modelCall, objectMapper,
new EvidenceRepairLimits(100_000, 20_000, Duration.ofSeconds(2)), ignored -> { }),
new SemanticGuard(core, retries, modelCall, objectMapper,
new SemanticGuardLimits(100_000, 20_000,
Duration.ofSeconds(2), Duration.ofSeconds(3)), ignored -> { }),
new SafeFallbackFactory());
DiagnosisDraft draft = draft("a-1", "call-rag-1", "Pool exhausted");
DiagnosisReleaseResult result = useCase.execute(context, "Why did payment fail?", draft);
assertEquals(ReleaseOutcome.SUCCESS, result.outcome());
assertSame(draft, result.draft());
assertNull(result.fallback());
assertEquals(1, result.verifiedEvidence().analyses().size());
assertEquals(1, model.calls.get());
}
@Test
void oneStructuralRepairCanFixIdsWithoutChangingReportSemantics() {
Fixture fixture = fixture(
repairedDuplicateDraft("Pool exhausted", "a-2"),
"{\"verdict\":\"SUPPORTED\",\"reason\":\"grounded\"}");
DiagnosisDraft original = duplicateDraft();
DiagnosisReleaseResult result = fixture.useCase.execute(
fixture.context, "Why did payment fail?", original);
assertEquals(ReleaseOutcome.SUCCESS, result.outcome());
assertEquals(List.of("a-1", "a-2"), result.draft().analysis().stream()
.map(DiagnosisDraft.AnalysisItem::analysisId).toList());
assertEquals("Pool exhausted", result.draft().conclusion().text());
assertEquals(2, fixture.model.calls.get());
assertFalse(fixture.model.prompts.get(1).contains("call-rag-1"));
}
@Test
void repairThatChangesVisibleSemanticsReturnsEvidenceFallback() throws Exception {
Fixture fixture = fixture(repairedDuplicateDraft("Different conclusion", "a-2"));
DiagnosisReleaseResult result = fixture.useCase.execute(
fixture.context, "Why did payment fail?", duplicateDraft());
assertFallback(result, FallbackType.EVIDENCE_VALIDATION_FAILED, 0);
String json = objectMapper.writeValueAsString(result);
assertFalse(json.contains("Pool exhausted"));
assertFalse(json.contains("Different conclusion"));
assertEquals(1, fixture.model.calls.get());
}
@Test
void secondEvidenceValidationFailureHasNoVerifiedSources() {
Fixture fixture = fixture(repairedDuplicateDraft("Pool exhausted", "a-1"));
DiagnosisReleaseResult result = fixture.useCase.execute(
fixture.context, "Why did payment fail?", duplicateDraft());
assertFallback(result, FallbackType.EVIDENCE_VALIDATION_FAILED, 0);
assertEquals(1, fixture.model.calls.get());
}
@Test
void unsupportedNeverReleasesDraftOrAuditReason() throws Exception {
Fixture fixture = fixture(
"{\"verdict\":\"UNSUPPORTED\",\"reason\":\"secret-audit-reason\"}");
DiagnosisDraft draft = draft("a-1", "call-rag-1", "Pool exhausted");
DiagnosisReleaseResult result = fixture.useCase.execute(
fixture.context, "Why did payment fail?", draft);
assertFallback(result, FallbackType.SEMANTIC_UNSUPPORTED, 1);
String json = objectMapper.writeValueAsString(result);
assertFalse(json.contains("Pool exhausted"));
assertFalse(json.contains("secret-audit-reason"));
assertEquals(1, fixture.model.calls.get());
}
@Test
void twoInvalidSemanticOutputsReturnUnavailableWithoutDraft() throws Exception {
Fixture fixture = fixture("not-json", "still-not-json");
DiagnosisReleaseResult result = fixture.useCase.execute(
fixture.context, "Why did payment fail?",
draft("a-1", "call-rag-1", "Pool exhausted"));
assertFallback(result, FallbackType.SEMANTIC_UNAVAILABLE, 1);
assertFalse(objectMapper.writeValueAsString(result).contains("Pool exhausted"));
assertEquals(2, fixture.model.calls.get());
}
private Fixture fixture(String... responses) {
DiagnosisHarnessCore core = core();
RunContext context = core.startRun("session-fixture", "run-fixture");
InMemoryStore store = new InMemoryStore();
ready(store, context, "call-rag-1");
ScriptedChatModel model = new ScriptedChatModel(responses);
HarnessRetryExecutor retries = new HarnessRetryExecutor(core);
GuardModelCall modelCall = new GuardModelCall(core, model, executor);
DiagnosisReleaseUseCase useCase = new DiagnosisReleaseUseCase(
new EvidenceGuard(store, new ToolCallKeyFactory(PREFIX), objectMapper),
new EvidenceRepair(core, retries, modelCall, objectMapper,
new EvidenceRepairLimits(100_000, 20_000, Duration.ofSeconds(2)), ignored -> { }),
new SemanticGuard(core, retries, modelCall, objectMapper,
new SemanticGuardLimits(100_000, 20_000,
Duration.ofSeconds(2), Duration.ofSeconds(3)), ignored -> { }),
new SafeFallbackFactory());
return new Fixture(context, model, useCase);
}
private DiagnosisDraft duplicateDraft() {
return new DiagnosisDraft(
new DiagnosisDraft.Conclusion("Pool exhausted", List.of("dup")),
List.of(
new DiagnosisDraft.AnalysisItem(
"dup", AnalysisKind.NORMAL, "Pool reached its limit", List.of("call-rag-1")),
new DiagnosisDraft.AnalysisItem(
"dup", AnalysisKind.NORMAL, "Requests are waiting", List.of("call-rag-1"))),
List.of(new DiagnosisDraft.ActionPlanItem(
"Inspect long transactions", List.of("dup"), false)),
List.of(new DiagnosisDraft.Recommendation(
"Add saturation alert", List.of("dup"))),
new DiagnosisDraft.Limitations("order-service", List.of()));
}
private String repairedDuplicateDraft(String conclusion, String secondAnalysisId) {
return """
{
"conclusion":{"text":"%s","based_on_analysis_ids":["a-1"]},
"analysis":[
{"analysis_id":"a-1","kind":"NORMAL","text":"Pool reached its limit","tool_call_ids":["call-rag-1"]},
{"analysis_id":"%s","kind":"NORMAL","text":"Requests are waiting","tool_call_ids":["call-rag-1"]}
],
"action_plan":[{"action":"Inspect long transactions","based_on_analysis_ids":["a-1"],"requires_human_confirmation":false}],
"recommendations":[{"text":"Add saturation alert","based_on_analysis_ids":["a-1"]}],
"limitations":{"scope":"order-service","missing_info":[]}
}
""".formatted(conclusion, secondAnalysisId);
}
private void assertFallback(DiagnosisReleaseResult result,
FallbackType type, int expectedSources) {
assertEquals(ReleaseOutcome.FALLBACK, result.outcome());
assertNull(result.draft());
assertNotNull(result.fallback());
assertEquals(type, result.fallback().type());
assertNull(result.fallback().conclusion());
assertEquals(expectedSources, result.fallback().verifiedSources().size());
assertEquals(0, result.verifiedEvidence().analyses().size());
}
private DiagnosisDraft draft(String analysisId, String callId, String conclusion) {
return new DiagnosisDraft(
new DiagnosisDraft.Conclusion(conclusion, List.of(analysisId)),
List.of(new DiagnosisDraft.AnalysisItem(
analysisId, AnalysisKind.NORMAL, "Pool reached its limit", List.of(callId))),
List.of(new DiagnosisDraft.ActionPlanItem(
"Inspect long transactions", List.of(analysisId), false)),
List.of(new DiagnosisDraft.Recommendation(
"Add saturation alert", List.of(analysisId))),
new DiagnosisDraft.Limitations("order-service", List.of()));
}
private void ready(InMemoryStore store, RunContext context, String callId) {
CanonicalToolInvocation invocation = CanonicalToolInvocation.projecting(
callId, context.runId(), "lookup_knowledge", "{\"query\":\"pool timeout\"}",
Instant.parse("2026-07-21T10:00:00Z"))
.markReady("raw-must-not-be-read", """
{"evidence_status":"EVIDENCE_FOUND","tool_call_id":"call-rag-1",
"query":"pool timeout","evidence":[{"document_id":"doc-1",
"source":"runbook.md","title":"Pool guide","breadcrumb":"DB > Pool",
"excerpt":"active=50 max=50"}],"returned_count":1,"truncated":false}
""", EvidenceStatus.EVIDENCE_FOUND,
Instant.parse("2026-07-21T10:00:01Z"));
store.records.put(PREFIX + ":" + context.runId() + ":" + callId, invocation);
}
private DiagnosisHarnessCore core() {
return new DiagnosisHarnessCore(
Clock.systemUTC(), () -> "unused", Duration.ofMinutes(5),
new RunBudgetLimits(10, 10, 10, 100_000, 100_000, 200_000, 1_000_000),
HarnessRetryPolicies.strict());
}
private static final class ScriptedChatModel implements ChatModel {
private final List<String> responses;
private final List<String> prompts = new ArrayList<>();
private final AtomicInteger calls = new AtomicInteger();
private ScriptedChatModel(String... responses) {
this.responses = List.of(responses);
}
@Override
public ChatResponse call(Prompt prompt) {
prompts.add(prompt.getContents());
int index = calls.getAndIncrement();
ChatResponseMetadata metadata = ChatResponseMetadata.builder()
.usage(new DefaultUsage(5, 3)).build();
return new ChatResponse(
List.of(new Generation(new AssistantMessage(responses.get(index)))), metadata);
}
}
private static final class InMemoryStore implements CanonicalInvocationStore {
private final Map<String, CanonicalToolInvocation> records = new HashMap<>();
private final CanonicalInvocationLimits limits =
new CanonicalInvocationLimits(Duration.ofHours(2), 1_000_000, 64_000);
@Override
public CanonicalInvocationLimits limits() {
return limits;
}
@Override
public void begin(String key, CanonicalToolInvocation invocation) {
records.put(key, invocation);
}
@Override
public Optional<CanonicalToolInvocation> find(String key) {
return Optional.ofNullable(records.get(key));
}
@Override
public CanonicalToolInvocation markReady(String key, String rawResponse,
String agentResult, EvidenceStatus evidenceStatus,
Instant completedAt) {
throw new UnsupportedOperationException();
}
@Override
public CanonicalToolInvocation markError(String key, String rawResponse,
String errorCode, Instant completedAt) {
throw new UnsupportedOperationException();
}
}
private record Fixture(
RunContext context,
ScriptedChatModel model,
DiagnosisReleaseUseCase useCase) {
}
}