refactor(harness): remove legacy agent architecture
This commit is contained in:
@@ -0,0 +1,170 @@
|
||||
package com.superbiz.agent.harness.audit;
|
||||
|
||||
import com.alibaba.cloud.ai.graph.RunnableConfig;
|
||||
import com.alibaba.cloud.ai.graph.agent.hook.HookPosition;
|
||||
import com.alibaba.cloud.ai.graph.agent.hook.HookPositions;
|
||||
import com.alibaba.cloud.ai.graph.agent.hook.messages.AgentCommand;
|
||||
import com.alibaba.cloud.ai.graph.agent.hook.messages.MessagesModelHook;
|
||||
import com.fasterxml.jackson.core.JsonProcessingException;
|
||||
import com.fasterxml.jackson.databind.ObjectMapper;
|
||||
import com.superbiz.agent.domain.entity.AgentStep;
|
||||
import com.superbiz.agent.repository.AgentStepRepository;
|
||||
import org.slf4j.Logger;
|
||||
import org.slf4j.LoggerFactory;
|
||||
import org.springframework.ai.chat.messages.AssistantMessage;
|
||||
import org.springframework.ai.chat.messages.Message;
|
||||
|
||||
import java.util.LinkedHashMap;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
import java.util.Objects;
|
||||
import java.util.concurrent.ConcurrentHashMap;
|
||||
|
||||
@HookPositions({HookPosition.BEFORE_MODEL, HookPosition.AFTER_MODEL})
|
||||
public final class HarnessAgentAuditHook extends MessagesModelHook {
|
||||
|
||||
private static final Logger log = LoggerFactory.getLogger(HarnessAgentAuditHook.class);
|
||||
|
||||
private final AgentStepRepository repository;
|
||||
private final ObjectMapper objectMapper;
|
||||
private final String agentName;
|
||||
private final ConcurrentHashMap<String, Integer> stepCounters = new ConcurrentHashMap<>();
|
||||
private final ConcurrentHashMap<String, PendingStep> pendingSteps = new ConcurrentHashMap<>();
|
||||
|
||||
public HarnessAgentAuditHook(AgentStepRepository repository, ObjectMapper objectMapper, String agentName) {
|
||||
this.repository = Objects.requireNonNull(repository, "repository must not be null");
|
||||
this.objectMapper = Objects.requireNonNull(objectMapper, "objectMapper must not be null");
|
||||
if (agentName == null || agentName.isBlank()) {
|
||||
throw new IllegalArgumentException("agentName must not be blank");
|
||||
}
|
||||
this.agentName = agentName;
|
||||
}
|
||||
|
||||
@Override
|
||||
public String getName() {
|
||||
return "harness_agent_audit";
|
||||
}
|
||||
|
||||
@Override
|
||||
public AgentCommand beforeModel(List<Message> messages, RunnableConfig config) {
|
||||
AuditIdentity identity = identity(config);
|
||||
if (identity == null) {
|
||||
log.warn("Skipping AgentStep audit because RunnableConfig identity is missing");
|
||||
return new AgentCommand(messages);
|
||||
}
|
||||
int stepIndex = stepCounters.merge(identity.runId(), 0, (current, ignored) -> current + 1);
|
||||
try {
|
||||
AgentStep saved = repository.save(AgentStep.builder()
|
||||
.sessionId(identity.sessionId())
|
||||
.runId(identity.runId())
|
||||
.stepIndex(stepIndex)
|
||||
.agentName(agentName)
|
||||
.modelInput(write(inputMetadata(messages)))
|
||||
.hasToolCall(false)
|
||||
.build());
|
||||
pendingSteps.put(stepKey(identity.runId(), stepIndex),
|
||||
new PendingStep(saved.getId(), System.nanoTime()));
|
||||
} catch (RuntimeException exception) {
|
||||
log.warn("Failed to persist AgentStep audit before model: agent={}", agentName);
|
||||
}
|
||||
return new AgentCommand(messages);
|
||||
}
|
||||
|
||||
@Override
|
||||
public AgentCommand afterModel(List<Message> messages, RunnableConfig config) {
|
||||
AuditIdentity identity = identity(config);
|
||||
if (identity == null) {
|
||||
log.warn("Skipping AgentStep audit completion because RunnableConfig identity is missing");
|
||||
return new AgentCommand(messages);
|
||||
}
|
||||
int stepIndex = stepCounters.getOrDefault(identity.runId(), 0);
|
||||
PendingStep pending = pendingSteps.remove(stepKey(identity.runId(), stepIndex));
|
||||
if (pending == null || pending.id() == null) {
|
||||
return new AgentCommand(messages);
|
||||
}
|
||||
try {
|
||||
AgentStep step = repository.findById(pending.id()).orElse(null);
|
||||
if (step != null) {
|
||||
AssistantMessage assistant = lastAssistant(messages);
|
||||
List<String> toolNames = assistant == null || assistant.getToolCalls() == null
|
||||
? List.of()
|
||||
: assistant.getToolCalls().stream().map(AssistantMessage.ToolCall::name)
|
||||
.distinct().sorted().toList();
|
||||
step.setModelOutput(write(outputMetadata(assistant, toolNames)));
|
||||
step.setThought(null);
|
||||
step.setHasToolCall(!toolNames.isEmpty());
|
||||
step.setDurationMs(durationMillis(pending.startedNanos()));
|
||||
repository.save(step);
|
||||
}
|
||||
} catch (RuntimeException exception) {
|
||||
log.warn("Failed to complete AgentStep audit: agent={}", agentName);
|
||||
}
|
||||
return new AgentCommand(messages);
|
||||
}
|
||||
|
||||
private Map<String, Object> inputMetadata(List<Message> messages) {
|
||||
List<Message> safeMessages = messages == null ? List.of() : messages;
|
||||
Map<String, Object> metadata = new LinkedHashMap<>();
|
||||
metadata.put("message_count", safeMessages.size());
|
||||
metadata.put("roles", safeMessages.stream()
|
||||
.map(message -> message.getMessageType().getValue())
|
||||
.toList());
|
||||
return metadata;
|
||||
}
|
||||
|
||||
private Map<String, Object> outputMetadata(AssistantMessage assistant, List<String> toolNames) {
|
||||
Map<String, Object> metadata = new LinkedHashMap<>();
|
||||
metadata.put("has_text", assistant != null
|
||||
&& assistant.getText() != null && !assistant.getText().isBlank());
|
||||
metadata.put("tool_names", toolNames);
|
||||
return metadata;
|
||||
}
|
||||
|
||||
private AuditIdentity identity(RunnableConfig config) {
|
||||
if (config == null) {
|
||||
return null;
|
||||
}
|
||||
String sessionId = config.metadata("sessionId").map(Object::toString).orElse(null);
|
||||
String runId = config.metadata("runId").map(Object::toString).orElse(null);
|
||||
return hasText(sessionId) && hasText(runId) ? new AuditIdentity(sessionId, runId) : null;
|
||||
}
|
||||
|
||||
private AssistantMessage lastAssistant(List<Message> messages) {
|
||||
if (messages == null) {
|
||||
return null;
|
||||
}
|
||||
for (int index = messages.size() - 1; index >= 0; index--) {
|
||||
if (messages.get(index) instanceof AssistantMessage assistant) {
|
||||
return assistant;
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
private String write(Map<String, Object> value) {
|
||||
try {
|
||||
return objectMapper.writeValueAsString(value);
|
||||
} catch (JsonProcessingException exception) {
|
||||
throw new IllegalStateException("Audit metadata is not serializable", exception);
|
||||
}
|
||||
}
|
||||
|
||||
private static int durationMillis(long startedNanos) {
|
||||
long value = Math.max(0L, (System.nanoTime() - startedNanos) / 1_000_000L);
|
||||
return value >= Integer.MAX_VALUE ? Integer.MAX_VALUE : (int) value;
|
||||
}
|
||||
|
||||
private static String stepKey(String runId, int stepIndex) {
|
||||
return runId + ':' + stepIndex;
|
||||
}
|
||||
|
||||
private static boolean hasText(String value) {
|
||||
return value != null && !value.isBlank();
|
||||
}
|
||||
|
||||
private record AuditIdentity(String sessionId, String runId) {
|
||||
}
|
||||
|
||||
private record PendingStep(Long id, long startedNanos) {
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,72 @@
|
||||
package com.superbiz.agent.harness.audit;
|
||||
|
||||
import com.fasterxml.jackson.core.JsonProcessingException;
|
||||
import com.fasterxml.jackson.databind.ObjectMapper;
|
||||
import com.superbiz.agent.domain.entity.ToolInvocation;
|
||||
import com.superbiz.agent.harness.contract.InvocationStatus;
|
||||
import com.superbiz.agent.repository.ToolInvocationRepository;
|
||||
import org.springframework.stereotype.Component;
|
||||
|
||||
import java.util.LinkedHashMap;
|
||||
import java.util.Map;
|
||||
import java.util.Objects;
|
||||
|
||||
@Component
|
||||
public final class JpaToolInvocationAuditSink implements ToolInvocationAuditSink {
|
||||
|
||||
private final ToolInvocationRepository repository;
|
||||
private final ObjectMapper objectMapper;
|
||||
|
||||
public JpaToolInvocationAuditSink(ToolInvocationRepository repository, ObjectMapper objectMapper) {
|
||||
this.repository = Objects.requireNonNull(repository, "repository must not be null");
|
||||
this.objectMapper = Objects.requireNonNull(objectMapper, "objectMapper must not be null");
|
||||
}
|
||||
|
||||
@Override
|
||||
public void record(ToolInvocationAuditEvent event) {
|
||||
Objects.requireNonNull(event, "event must not be null");
|
||||
repository.save(ToolInvocation.builder()
|
||||
.sessionId(event.sessionId())
|
||||
.runId(event.runId())
|
||||
.toolName(event.toolName())
|
||||
.inputParams(write(inputMetadata(event)))
|
||||
.outputPreview("status=%s,evidence_status=%s".formatted(
|
||||
event.status(), event.evidenceStatus()))
|
||||
.outputLength(event.agentResultBytes())
|
||||
.retrievalLayer("HARNESS")
|
||||
.isTruncated(false)
|
||||
.relevanceLevel(event.evidenceStatus().name())
|
||||
.retrievalDetails(write(resultMetadata(event)))
|
||||
.durationMs(event.durationMs())
|
||||
.success(event.status() == InvocationStatus.READY)
|
||||
.errorMessage(event.errorCode())
|
||||
.build());
|
||||
}
|
||||
|
||||
private Map<String, Object> inputMetadata(ToolInvocationAuditEvent event) {
|
||||
Map<String, Object> metadata = new LinkedHashMap<>();
|
||||
metadata.put("tool_call_id", event.toolCallId());
|
||||
metadata.put("request_bytes", event.requestBytes());
|
||||
return metadata;
|
||||
}
|
||||
|
||||
private Map<String, Object> resultMetadata(ToolInvocationAuditEvent event) {
|
||||
Map<String, Object> metadata = new LinkedHashMap<>();
|
||||
metadata.put("tool_call_id", event.toolCallId());
|
||||
metadata.put("status", event.status().name());
|
||||
metadata.put("evidence_status", event.evidenceStatus().name());
|
||||
metadata.put("agent_result_bytes", event.agentResultBytes());
|
||||
if (event.errorCode() != null) {
|
||||
metadata.put("error_code", event.errorCode());
|
||||
}
|
||||
return metadata;
|
||||
}
|
||||
|
||||
private String write(Map<String, Object> value) {
|
||||
try {
|
||||
return objectMapper.writeValueAsString(value);
|
||||
} catch (JsonProcessingException exception) {
|
||||
throw new IllegalStateException("Tool audit metadata is not serializable", exception);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,43 @@
|
||||
package com.superbiz.agent.harness.audit;
|
||||
|
||||
import com.superbiz.agent.harness.contract.EvidenceStatus;
|
||||
import com.superbiz.agent.harness.contract.InvocationStatus;
|
||||
|
||||
import java.util.Objects;
|
||||
|
||||
public record ToolInvocationAuditEvent(
|
||||
String sessionId,
|
||||
String runId,
|
||||
String toolCallId,
|
||||
String toolName,
|
||||
InvocationStatus status,
|
||||
EvidenceStatus evidenceStatus,
|
||||
String errorCode,
|
||||
int durationMs,
|
||||
int requestBytes,
|
||||
int agentResultBytes) {
|
||||
|
||||
public ToolInvocationAuditEvent {
|
||||
requireText(sessionId, "sessionId");
|
||||
requireText(runId, "runId");
|
||||
requireText(toolCallId, "toolCallId");
|
||||
requireText(toolName, "toolName");
|
||||
Objects.requireNonNull(status, "status must not be null");
|
||||
Objects.requireNonNull(evidenceStatus, "evidenceStatus must not be null");
|
||||
if (durationMs < 0 || requestBytes < 0 || agentResultBytes < 0) {
|
||||
throw new IllegalArgumentException("Audit counters must not be negative");
|
||||
}
|
||||
if (status == InvocationStatus.READY && errorCode != null) {
|
||||
throw new IllegalArgumentException("READY audit must not contain errorCode");
|
||||
}
|
||||
if (status == InvocationStatus.ERROR && (errorCode == null || errorCode.isBlank())) {
|
||||
throw new IllegalArgumentException("ERROR audit requires errorCode");
|
||||
}
|
||||
}
|
||||
|
||||
private static void requireText(String value, String name) {
|
||||
if (value == null || value.isBlank()) {
|
||||
throw new IllegalArgumentException(name + " must not be blank");
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,12 @@
|
||||
package com.superbiz.agent.harness.audit;
|
||||
|
||||
@FunctionalInterface
|
||||
public interface ToolInvocationAuditSink {
|
||||
|
||||
void record(ToolInvocationAuditEvent event);
|
||||
|
||||
static ToolInvocationAuditSink noop() {
|
||||
return ignored -> {
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -3,6 +3,8 @@ package com.superbiz.agent.harness.tool.boundary;
|
||||
import com.fasterxml.jackson.core.JsonProcessingException;
|
||||
import com.fasterxml.jackson.databind.JsonNode;
|
||||
import com.fasterxml.jackson.databind.ObjectMapper;
|
||||
import com.superbiz.agent.harness.audit.ToolInvocationAuditEvent;
|
||||
import com.superbiz.agent.harness.audit.ToolInvocationAuditSink;
|
||||
import com.superbiz.agent.harness.contract.EvidenceStatus;
|
||||
import com.superbiz.agent.harness.core.BudgetExceededException;
|
||||
import com.superbiz.agent.harness.core.DiagnosisHarnessCore;
|
||||
@@ -18,6 +20,9 @@ import org.slf4j.Logger;
|
||||
import org.slf4j.LoggerFactory;
|
||||
|
||||
import java.time.Clock;
|
||||
import java.time.Duration;
|
||||
import java.time.Instant;
|
||||
import java.nio.charset.StandardCharsets;
|
||||
import java.util.Objects;
|
||||
|
||||
public final class ToolBoundary {
|
||||
@@ -29,23 +34,44 @@ public final class ToolBoundary {
|
||||
private final CanonicalInvocationStore store;
|
||||
private final ObjectMapper objectMapper;
|
||||
private final Clock clock;
|
||||
private final ToolInvocationAuditSink auditSink;
|
||||
|
||||
public ToolBoundary(DiagnosisHarnessCore core,
|
||||
ToolCallKeyFactory keyFactory,
|
||||
CanonicalInvocationStore store,
|
||||
ObjectMapper objectMapper,
|
||||
Clock clock) {
|
||||
this(core, keyFactory, store, objectMapper, clock, ToolInvocationAuditSink.noop());
|
||||
}
|
||||
|
||||
public ToolBoundary(DiagnosisHarnessCore core,
|
||||
ToolCallKeyFactory keyFactory,
|
||||
CanonicalInvocationStore store,
|
||||
ObjectMapper objectMapper,
|
||||
Clock clock,
|
||||
ToolInvocationAuditSink auditSink) {
|
||||
this.core = Objects.requireNonNull(core, "core must not be null");
|
||||
this.keyFactory = Objects.requireNonNull(keyFactory, "keyFactory must not be null");
|
||||
this.store = Objects.requireNonNull(store, "store must not be null");
|
||||
this.objectMapper = Objects.requireNonNull(objectMapper, "objectMapper must not be null");
|
||||
this.clock = Objects.requireNonNull(clock, "clock must not be null");
|
||||
this.auditSink = Objects.requireNonNull(auditSink, "auditSink must not be null");
|
||||
}
|
||||
|
||||
public ToolBoundaryResult execute(RunContext context,
|
||||
ToolCallRequestEnvelope request,
|
||||
ToolExecutor executor,
|
||||
ToolResultProjector projector) {
|
||||
Instant startedAt = clock.instant();
|
||||
ToolBoundaryResult result = executeCanonical(context, request, executor, projector);
|
||||
auditSafely(context, request, result, startedAt);
|
||||
return result;
|
||||
}
|
||||
|
||||
private ToolBoundaryResult executeCanonical(RunContext context,
|
||||
ToolCallRequestEnvelope request,
|
||||
ToolExecutor executor,
|
||||
ToolResultProjector projector) {
|
||||
String toolCallId = request == null ? null : request.toolCallId();
|
||||
String key;
|
||||
try {
|
||||
@@ -193,6 +219,36 @@ public final class ToolBoundary {
|
||||
return ToolBoundaryErrorCode.INVALID_REQUEST;
|
||||
}
|
||||
|
||||
private void auditSafely(RunContext context,
|
||||
ToolCallRequestEnvelope request,
|
||||
ToolBoundaryResult result,
|
||||
Instant startedAt) {
|
||||
if (context == null || request == null || result == null
|
||||
|| !context.runId().equals(request.runId())
|
||||
|| request.toolCallId() == null || request.toolCallId().isBlank()
|
||||
|| request.toolName() == null || request.toolName().isBlank()) {
|
||||
return;
|
||||
}
|
||||
try {
|
||||
auditSink.record(new ToolInvocationAuditEvent(
|
||||
context.sessionId(), context.runId(), request.toolCallId(), request.toolName(),
|
||||
result.status(), result.evidenceStatus(), result.errorCode(),
|
||||
saturatingInt(Math.max(0L, Duration.between(startedAt, clock.instant()).toMillis())),
|
||||
utf8Bytes(request.requestJson()), utf8Bytes(result.agentResult())));
|
||||
} catch (RuntimeException exception) {
|
||||
log.warn("Failed to persist Tool durable audit: tool={}, status={}",
|
||||
request.toolName(), result.status());
|
||||
}
|
||||
}
|
||||
|
||||
private static int utf8Bytes(String value) {
|
||||
return value == null ? 0 : saturatingInt(value.getBytes(StandardCharsets.UTF_8).length);
|
||||
}
|
||||
|
||||
private static int saturatingInt(long value) {
|
||||
return value >= Integer.MAX_VALUE ? Integer.MAX_VALUE : (int) value;
|
||||
}
|
||||
|
||||
private static final class InvalidToolCallIdException extends IllegalArgumentException {
|
||||
private InvalidToolCallIdException(Throwable cause) {
|
||||
super("Invalid tool call ID", cause);
|
||||
|
||||
Reference in New Issue
Block a user