实现Debug EML实时Trace调试链路

This commit is contained in:
andy
2026-07-12 14:59:03 +08:00
parent eee37315a0
commit 8d8fae670a
20 changed files with 1571 additions and 76 deletions

View File

@@ -1,5 +1,6 @@
package cn.nianxx.thhotel.integrations.ai.superagent.common.result;
import com.fasterxml.jackson.annotation.JsonProperty;
import java.util.List;
/**
@@ -15,6 +16,7 @@ import java.util.List;
* @param outputTokens 输出 token 数
* @param totalTokens 总 token 数
* @param eventTypes SSE 事件类型列表
* @param traceEvents SuperAgent 公开 Trace 事件列表,只包含可展示摘要字段
*/
public record SuperAgentOpenApiResult(
String sessionId,
@@ -26,6 +28,44 @@ public record SuperAgentOpenApiResult(
Integer inputTokens,
Integer outputTokens,
Integer totalTokens,
List<String> eventTypes
List<String> eventTypes,
@JsonProperty("trace_events")
List<SuperAgentOpenApiTraceEvent> traceEvents
) {
/**
* 兼容未启用 Trace 的历史调用方。
*/
public SuperAgentOpenApiResult(
String sessionId,
String runId,
String profileId,
String profileVersionId,
String modelName,
String rawAnswer,
Integer inputTokens,
Integer outputTokens,
Integer totalTokens,
List<String> eventTypes) {
this(
sessionId,
runId,
profileId,
profileVersionId,
modelName,
rawAnswer,
inputTokens,
outputTokens,
totalTokens,
eventTypes,
List.of());
}
/**
* 复制集合,避免结果对象被调用方后续修改。
*/
public SuperAgentOpenApiResult {
eventTypes = eventTypes == null ? List.of() : List.copyOf(eventTypes);
traceEvents = traceEvents == null ? List.of() : List.copyOf(traceEvents);
}
}

View File

@@ -0,0 +1,37 @@
package cn.nianxx.thhotel.integrations.ai.superagent.common.result;
import com.fasterxml.jackson.annotation.JsonProperty;
/**
* SuperAgent Open API 公开 Trace 事件。只保留对外策略允许的摘要字段不保存原始工具入参、Secret 或 Cookie。
*
* @param event SuperAgent 公开 trace 事件类型
* @param runId SuperAgent run ID
* @param messageId AI message ID
* @param toolCallId 工具调用 ID
* @param toolName 工具名称
* @param text 公开推理或进度摘要
* @param inputSummary 工具入参摘要
* @param outputSummary 工具输出摘要
* @param status 运行或任务状态
* @param ts SuperAgent 事件时间戳
*/
public record SuperAgentOpenApiTraceEvent(
String event,
@JsonProperty("run_id")
String runId,
@JsonProperty("message_id")
String messageId,
@JsonProperty("tool_call_id")
String toolCallId,
@JsonProperty("tool_name")
String toolName,
String text,
@JsonProperty("input_summary")
String inputSummary,
@JsonProperty("output_summary")
String outputSummary,
String status,
String ts
) {
}

View File

@@ -2,6 +2,8 @@ package cn.nianxx.thhotel.integrations.ai.superagent.service;
import cn.nianxx.thhotel.integrations.ai.superagent.common.request.SuperAgentMailDebugRequest;
import cn.nianxx.thhotel.integrations.ai.superagent.common.result.SuperAgentOpenApiResult;
import cn.nianxx.thhotel.integrations.ai.superagent.common.result.SuperAgentOpenApiTraceEvent;
import java.util.function.Consumer;
/**
* SuperAgent Open API 客户端端口。业务层只依赖该接口,不直接拼 HTTP 或解析 SSE。
@@ -11,5 +13,15 @@ public interface SuperAgentOpenApiClient {
/**
* 创建 SuperAgent session 并发送邮件 Debug 消息,返回最终 AI 回答。
*/
SuperAgentOpenApiResult invokeMailDebug(SuperAgentMailDebugRequest request);
default SuperAgentOpenApiResult invokeMailDebug(SuperAgentMailDebugRequest request) {
return invokeMailDebug(request, traceEvent -> {
});
}
/**
* 创建 SuperAgent session 并发送邮件 Debug 消息,边解析边回调公开 Trace 事件。
*/
SuperAgentOpenApiResult invokeMailDebug(
SuperAgentMailDebugRequest request,
Consumer<SuperAgentOpenApiTraceEvent> traceConsumer);
}

View File

@@ -2,16 +2,22 @@ package cn.nianxx.thhotel.integrations.ai.superagent.service.impl;
import cn.nianxx.thhotel.integrations.ai.superagent.common.request.SuperAgentMailDebugRequest;
import cn.nianxx.thhotel.integrations.ai.superagent.common.result.SuperAgentOpenApiResult;
import cn.nianxx.thhotel.integrations.ai.superagent.common.result.SuperAgentOpenApiTraceEvent;
import cn.nianxx.thhotel.integrations.ai.superagent.service.SuperAgentOpenApiClient;
import com.fasterxml.jackson.databind.JsonNode;
import com.fasterxml.jackson.databind.ObjectMapper;
import java.io.InputStreamReader;
import java.nio.charset.StandardCharsets;
import java.util.LinkedHashMap;
import java.util.Map;
import java.util.UUID;
import java.util.function.Consumer;
import org.springframework.http.client.SimpleClientHttpRequestFactory;
import org.springframework.http.MediaType;
import org.springframework.stereotype.Service;
import org.springframework.util.StreamUtils;
import org.springframework.web.client.RestClient;
import org.springframework.web.client.RestClientResponseException;
/**
* SuperAgent Open API HTTP 客户端实现。负责创建 session、发送 SSE 消息和解析最终回答。
@@ -39,7 +45,9 @@ public class SuperAgentOpenApiClientImpl implements SuperAgentOpenApiClient {
* 调用 SuperAgent Open API 邮件 Debug 流程;配置缺失时直接失败,避免静默跳过真实调用。
*/
@Override
public SuperAgentOpenApiResult invokeMailDebug(SuperAgentMailDebugRequest request) {
public SuperAgentOpenApiResult invokeMailDebug(
SuperAgentMailDebugRequest request,
Consumer<SuperAgentOpenApiTraceEvent> traceConsumer) {
validateProperties();
try {
RestClient restClient = RestClient.builder()
@@ -47,8 +55,7 @@ public class SuperAgentOpenApiClientImpl implements SuperAgentOpenApiClient {
.requestFactory(requestFactory())
.build();
String sessionId = createSession(restClient, request);
String sseBody = sendMessage(restClient, sessionId, request);
return sseParser.parse(sessionId, sseBody);
return sendMessage(restClient, sessionId, request, traceConsumer);
} catch (SuperAgentOpenApiException exception) {
throw exception;
} catch (Exception exception) {
@@ -86,24 +93,46 @@ public class SuperAgentOpenApiClientImpl implements SuperAgentOpenApiClient {
}
/**
* 发送 Debug 邮件消息并读取 SSE 文本响应。
* 发送 Debug 邮件消息并流式解析 SSE 文本响应。
*/
private String sendMessage(RestClient restClient, String sessionId, SuperAgentMailDebugRequest request) {
private SuperAgentOpenApiResult sendMessage(
RestClient restClient,
String sessionId,
SuperAgentMailDebugRequest request,
Consumer<SuperAgentOpenApiTraceEvent> traceConsumer) {
String csrfToken = UUID.randomUUID().toString();
Map<String, Object> body = new LinkedHashMap<>();
body.put("message", request.message());
body.put("idempotency_key", request.idempotencyKey() + "-message");
body.put("metadata", request.metadata());
return restClient.post()
.uri("/api/open/agent-sessions/{sessionId}/messages/stream", sessionId)
.uri(uriBuilder -> uriBuilder
.path("/api/open/agent-sessions/{sessionId}/messages/stream")
.queryParam("include_trace", "true")
.build(sessionId))
.contentType(MediaType.APPLICATION_JSON)
.accept(MediaType.TEXT_EVENT_STREAM)
.header("Authorization", "Bearer " + properties.getApiKey())
.header("X-CSRF-Token", csrfToken)
.header("Cookie", "csrf_token=" + csrfToken)
.body(body)
.retrieve()
.body(String.class);
.exchange((clientRequest, clientResponse) -> {
if (!clientResponse.getStatusCode().is2xxSuccessful()) {
byte[] responseBody = StreamUtils.copyToByteArray(clientResponse.getBody());
throw new RestClientResponseException(
"SuperAgent Open API HTTP 调用失败。",
clientResponse.getStatusCode(),
clientResponse.getStatusText(),
clientResponse.getHeaders(),
responseBody,
StandardCharsets.UTF_8);
}
try (InputStreamReader reader = new InputStreamReader(
clientResponse.getBody(),
StandardCharsets.UTF_8)) {
return sseParser.parse(sessionId, reader, traceConsumer);
}
});
}
/**

View File

@@ -1,12 +1,19 @@
package cn.nianxx.thhotel.integrations.ai.superagent.service.impl;
import cn.nianxx.thhotel.integrations.ai.superagent.common.result.SuperAgentOpenApiResult;
import cn.nianxx.thhotel.integrations.ai.superagent.common.result.SuperAgentOpenApiTraceEvent;
import com.fasterxml.jackson.databind.JsonNode;
import com.fasterxml.jackson.databind.ObjectMapper;
import java.io.BufferedReader;
import java.io.IOException;
import java.io.Reader;
import java.io.StringReader;
import java.util.ArrayList;
import java.util.LinkedHashSet;
import java.util.List;
import java.util.Set;
import java.util.function.Consumer;
import java.util.regex.Pattern;
import org.springframework.stereotype.Component;
/**
@@ -15,6 +22,13 @@ import org.springframework.stereotype.Component;
@Component
public class SuperAgentOpenApiSseParser {
private static final Pattern JSON_SECRET_PATTERN = Pattern.compile(
"(?i)(\"(?:api[_-]?key|token|secret|password|cookie|authorization)\"\\s*:\\s*\")[^\"]*(\")");
private static final Pattern HEADER_SECRET_PATTERN = Pattern.compile(
"(?i)((?:authorization|cookie)\\s*:\\s*)[^\\s,;]+");
private static final Pattern TEXT_SECRET_PATTERN = Pattern.compile(
"(?i)((?:api[_-]?key|token|secret|password)\\s*[=:]\\s*)[^\\s,;}]+");
private final ObjectMapper objectMapper;
/**
@@ -35,18 +49,50 @@ public class SuperAgentOpenApiSseParser {
* 解析 SSE 文本,返回最终 AI 回答和调用元数据。
*/
public SuperAgentOpenApiResult parse(String sessionId, String sseBody) {
return parse(sessionId, sseBody, traceEvent -> {
});
}
/**
* 解析 SSE 文本,边解析边回调 SuperAgent 公开 Trace 事件。
*/
public SuperAgentOpenApiResult parse(
String sessionId,
String sseBody,
Consumer<SuperAgentOpenApiTraceEvent> traceConsumer) {
if (sseBody == null || sseBody.isBlank()) {
throw new SuperAgentOpenApiException("SuperAgent SSE 响应为空。");
}
return parse(sessionId, new StringReader(sseBody), traceConsumer);
}
/**
* 从 Reader 流式解析 SSE适用于 Open API 长连接响应。
*/
public SuperAgentOpenApiResult parse(
String sessionId,
Reader reader,
Consumer<SuperAgentOpenApiTraceEvent> traceConsumer) {
if (reader == null) {
throw new SuperAgentOpenApiException("SuperAgent SSE 响应为空。");
}
Set<String> eventTypes = new LinkedHashSet<>();
ParsedState state = new ParsedState();
for (SseEvent event : splitEvents(sseBody)) {
eventTypes.add(event.eventType());
consumeEvent(event, state);
if (state.endSeen) {
break;
}
try {
parseLines(reader, event -> {
eventTypes.add(event.eventType());
consumeEvent(event, state, safeTraceConsumer(traceConsumer));
}, state);
} catch (IOException exception) {
throw new SuperAgentOpenApiException("SuperAgent SSE 读取失败。", exception);
}
return buildResult(sessionId, eventTypes, state);
}
/**
* 汇总解析状态并生成结果对象。
*/
private SuperAgentOpenApiResult buildResult(String sessionId, Set<String> eventTypes, ParsedState state) {
if (!state.endSeen) {
throw new SuperAgentOpenApiException("SuperAgent SSE 未收到结束事件。");
}
@@ -70,13 +116,17 @@ public class SuperAgentOpenApiSseParser {
state.inputTokens,
state.outputTokens,
state.totalTokens,
List.copyOf(eventTypes));
List.copyOf(eventTypes),
List.copyOf(state.traceEvents));
}
/**
* 消费单个 SSE 事件。
*/
private void consumeEvent(SseEvent event, ParsedState state) {
private void consumeEvent(
SseEvent event,
ParsedState state,
Consumer<SuperAgentOpenApiTraceEvent> traceConsumer) {
try {
if ("end".equals(event.eventType())) {
state.endSeen = true;
@@ -94,6 +144,9 @@ public class SuperAgentOpenApiSseParser {
if ("values".equals(event.eventType())) {
consumeValuesEvent(data, state);
}
if ("trace".equals(event.eventType())) {
consumeTraceEvent(data, state, traceConsumer);
}
} catch (Exception exception) {
throw new SuperAgentOpenApiException("SuperAgent SSE JSON 解析失败。", exception);
}
@@ -159,31 +212,96 @@ public class SuperAgentOpenApiSseParser {
}
/**
* 拆分 SSE 事件块,支持多行 data
* 消费公开 Trace 事件,只提取可展示摘要字段
*/
private List<SseEvent> splitEvents(String sseBody) {
String[] blocks = sseBody.split("\\R\\s*\\R");
List<SseEvent> events = new ArrayList<>();
for (String block : blocks) {
String eventType = "message";
StringBuilder data = new StringBuilder();
for (String line : block.split("\\R")) {
if (line.startsWith("event:")) {
eventType = line.substring("event:".length()).trim();
continue;
}
if (line.startsWith("data:")) {
if (!data.isEmpty()) {
data.append('\n');
}
data.append(line.substring("data:".length()).trim());
}
private void consumeTraceEvent(
JsonNode data,
ParsedState state,
Consumer<SuperAgentOpenApiTraceEvent> traceConsumer) {
JsonNode trace = data.path("data").isObject() && data.path("event").isMissingNode()
? data.path("data")
: data;
JsonNode toolCalls = trace.path("tool_calls");
if (toolCalls.isArray() && !toolCalls.isEmpty()) {
for (JsonNode toolCall : toolCalls) {
emitTraceEvent(traceEvent(trace, toolCall), state, traceConsumer);
}
if (!data.isEmpty() || "end".equals(eventType)) {
events.add(new SseEvent(eventType, data.toString()));
return;
}
emitTraceEvent(traceEvent(trace, trace), state, traceConsumer);
}
/**
* 构造公开 Trace 事件对象。
*/
private SuperAgentOpenApiTraceEvent traceEvent(JsonNode trace, JsonNode detail) {
return new SuperAgentOpenApiTraceEvent(
safeTraceText(text(trace, "event", null)),
safeTraceText(text(trace, "run_id", null)),
safeTraceText(text(trace, "message_id", null)),
safeTraceText(text(detail, "tool_call_id", text(trace, "tool_call_id", null))),
safeTraceText(text(detail, "name", text(trace, "name", null))),
safeTraceText(text(trace, "text", null)),
safeTraceText(summary(detail.path("input"), summary(trace.path("input"), null))),
safeTraceText(summary(detail.path("output"), summary(trace.path("output"), null))),
safeTraceText(text(trace, "status", null)),
safeTraceText(text(trace, "ts", null)));
}
/**
* 保存并回调 Trace 事件。
*/
private void emitTraceEvent(
SuperAgentOpenApiTraceEvent traceEvent,
ParsedState state,
Consumer<SuperAgentOpenApiTraceEvent> traceConsumer) {
if (traceEvent.event() == null || traceEvent.event().isBlank()) {
return;
}
state.traceEvents.add(traceEvent);
traceConsumer.accept(traceEvent);
}
/**
* 流式拆分 SSE 事件块,支持多行 data。
*/
private void parseLines(Reader reader, Consumer<SseEvent> consumer, ParsedState state) throws IOException {
BufferedReader bufferedReader = reader instanceof BufferedReader existingReader
? existingReader
: new BufferedReader(reader);
SseEventBuilder builder = new SseEventBuilder();
String line;
while ((line = bufferedReader.readLine()) != null) {
if (line.isBlank()) {
flushEvent(builder, consumer);
if (state.endSeen) {
return;
}
continue;
}
if (line.startsWith(":")) {
continue;
}
if (line.startsWith("event:")) {
builder.eventType(line.substring("event:".length()).trim());
continue;
}
if (line.startsWith("data:")) {
builder.appendData(line.substring("data:".length()).trim());
}
}
return events;
flushEvent(builder, consumer);
}
/**
* 输出当前累积事件并重置 builder。
*/
private void flushEvent(SseEventBuilder builder, Consumer<SseEvent> consumer) {
SseEvent event = builder.build();
if (event != null) {
consumer.accept(event);
}
builder.reset();
}
/**
@@ -205,6 +323,42 @@ public class SuperAgentOpenApiSseParser {
return value.asInt();
}
/**
* 读取公开 summary 字段。
*/
private String summary(JsonNode node, String fallback) {
if (node == null || node.isMissingNode() || node.isNull()) {
return fallback;
}
String value = text(node, "summary", null);
if (value != null) {
return value;
}
return node.isTextual() ? node.asText() : fallback;
}
/**
* Trace 展示字段做短文本脱敏与截断。
*/
private String safeTraceText(String value) {
if (value == null || value.isBlank()) {
return null;
}
String sanitized = JSON_SECRET_PATTERN.matcher(value).replaceAll("$1***$2");
sanitized = HEADER_SECRET_PATTERN.matcher(sanitized).replaceAll("$1***");
sanitized = TEXT_SECRET_PATTERN.matcher(sanitized).replaceAll("$1***");
return sanitized.length() > 2048 ? sanitized.substring(0, 2048) : sanitized;
}
/**
* Trace 消费者为空时使用 no-op。
*/
private Consumer<SuperAgentOpenApiTraceEvent> safeTraceConsumer(
Consumer<SuperAgentOpenApiTraceEvent> traceConsumer) {
return traceConsumer == null ? traceEvent -> {
} : traceConsumer;
}
/**
* 读取 AI content兼容字符串、文本片段数组和简单文本对象。
*/
@@ -252,6 +406,37 @@ public class SuperAgentOpenApiSseParser {
private record SseEvent(String eventType, String data) {
}
/**
* SSE 事件累积器。
*/
private static final class SseEventBuilder {
private String eventType = "message";
private final StringBuilder data = new StringBuilder();
private void eventType(String eventType) {
this.eventType = eventType == null || eventType.isBlank() ? "message" : eventType;
}
private void appendData(String value) {
if (!data.isEmpty()) {
data.append('\n');
}
data.append(value);
}
private SseEvent build() {
if (data.isEmpty() && !"end".equals(eventType)) {
return null;
}
return new SseEvent(eventType, data.toString());
}
private void reset() {
eventType = "message";
data.setLength(0);
}
}
/**
* SSE 解析过程中的可变状态。
*/
@@ -270,5 +455,6 @@ public class SuperAgentOpenApiSseParser {
private Integer fallbackInputTokens;
private Integer fallbackOutputTokens;
private Integer fallbackTotalTokens;
private final List<SuperAgentOpenApiTraceEvent> traceEvents = new ArrayList<>();
}
}