# SuperAgent Open API Java SSE 接入与断流恢复

## 1. 目标

本文档定义 Java 后端调用 SuperAgent Open API 流式接口时必须实现的客户端行为，重点解决以下问题：

- SSE 响应在 `answer`、结构化 `error` 或 `end` 前提前关闭。
- Java 抛出 `IOException: Premature EOF`、`EOFException` 或 incomplete chunked response。
- 网络中断后误把部分回答当成成功结果。
- 网络中断后重新 POST，造成同一业务消息被执行两次。
- 无法使用调用方 `debug_run_id` 关联 SuperAgent 内部 `run_id`。

适用接口：

```text
POST /api/open/agent-sessions/{session_id}/messages/stream?include_trace=true
GET  /api/open/agent-sessions/{session_id}/runs/{run_id}/events
GET  /api/open/agent-sessions/{session_id}/runs/{run_id}
```

推荐运行环境：Java 17 或更高版本，JDK `java.net.http.HttpClient`，Jackson 2.x。

## 2. Java 端必须遵守的成功条件

Java 客户端只有在同时满足以下条件时才能把调用判定为成功：

1. 收到最终 AI 内容：优先使用 `message.final`，兼容累计 `message.delta`。
2. 收到 `run.completed`，并且 `status=success`。
3. 收到顶层 SSE `event: end`。
4. 没有收到顶层 `event: error` 或 `trace.data.event=run.failed`。

以下情况都不能返回成功：

- 只收到部分 `message.delta` 后 EOF。
- 收到 `run.completed`，但没有最终 AI 内容。
- 收到最终内容，但没有 `end`。
- HTTP 连接正常关闭，但未出现协议终止事件。
- 运行状态仍为 `pending`、`running`、`interrupted`、`timeout` 或 `error`。

## 3. 请求头与关联信息

推荐请求头：

```http
Authorization: Bearer df_open_xxx
Accept: text/event-stream
Content-Type: application/json
Cache-Control: no-cache
X-Request-ID: 2076266481248989185
```

也可以使用：

```http
X-DeerFlow-Open-API-Key: df_open_xxx
```

每次业务调用生成一个稳定的 `debug_run_id`，同时写入：

- `X-Request-ID` 请求头。
- 本地业务日志。
- 本地调用记录。
- 请求 `metadata.debug_run_id`，前提是外部应用策略允许该 metadata 字段。

请求示例：

```json
{
  "message": "用户问题",
  "idempotency_key": "business-message-20260712-0001",
  "metadata": {
    "debug_run_id": "2076266481248989185",
    "conversation_id": "conversation-001"
  }
}
```

`idempotency_key` 必须在同一业务消息的所有尝试中保持不变。服务端未确认幂等命中前，不得依赖它盲目重发初始 POST。

## 4. 超时配置

SSE 是长连接，不要给整个 `HttpRequest` 设置 30 秒或 60 秒总超时。

推荐：

```java
HttpClient client = HttpClient.newBuilder()
    .connectTimeout(Duration.ofSeconds(10))
    .version(HttpClient.Version.HTTP_1_1)
    .build();
```

初始 SSE 请求不要调用：

```java
requestBuilder.timeout(Duration.ofSeconds(60));
```

客户端应分别控制：

- 建连超时：10 秒左右。
- 首个事件超时：由业务层 watchdog 控制，例如 60 秒。
- 心跳空闲超时：建议 45～90 秒，必须大于服务端心跳间隔。
- 总业务执行超时：根据 Agent 任务类型配置，例如 10～30 分钟。
- 重连次数：建议 5 次。

`Premature EOF` 表示对端提前关闭响应，不是普通读取等待超时。单纯增大 read timeout 不能修复该问题。

## 5. 必须保存的流状态

每个运行至少保存：

```java
final class SseRunState {
    final StringBuilder deltaContent = new StringBuilder();
    String finalContent;
    String lastEventId;
    URI runUri;
    String runId;
    String failureCode;
    String failureMessage;
    boolean runCompleted;
    boolean endReceived;
}
```

状态建议在收到每个事件后同步写入本地调用记录，以便进程重启后仍能诊断。

## 6. SSE 解析要求

Java 客户端必须正确处理：

- `event:`：事件类型。
- `data:`：允许多行，使用换行拼接。
- `id:`：保存最后一个非空事件 ID。
- 以 `:` 开头的 heartbeat/comment。
- 空行：表示一帧 SSE 结束。
- EOF：若未收到 `end`，必须抛出可恢复的流中断异常。

不要通过搜索字符串 `"event: end"` 解析流；必须按 SSE 帧解析。

## 7. 事件处理规则

### 7.1 Trace 事件

```text
event: trace
data: {"event":"message.delta","text":"部分回答"}
```

处理方式：按事件 ID 去重后追加到 `deltaContent`。

```text
event: trace
data: {"event":"message.final","text":"完整最终回答"}
```

处理方式：写入 `finalContent`。该事件是最终答案的权威来源，不要再把此前 delta 追加到它后面。

```text
event: trace
data: {"event":"run.completed","status":"success"}
```

处理方式：设置 `runCompleted=true`，但继续读取，直到收到顶层 `end`。

```text
event: trace
data: {"event":"run.failed","error":{"code":"...","message":"..."}}
```

处理方式：记录失败信息，继续读取到 `end`，最终抛出业务异常。

### 7.2 顶层错误和结束事件

```text
event: error
data: {"code":"open_agent_sse_stream_error","message":"...","retryable":true}

event: end
data: null
```

收到 `error` 后不得返回已累计的部分回答。收到 `end` 后按第 2 节的成功条件判定最终结果。

## 8. 断流恢复流程

初始 POST 返回 HTTP 200 后，立即读取响应头：

```http
Content-Location: /api/open/agent-sessions/{session_id}/runs/{run_id}
```

客户端必须保存：

- `runUri`：由请求基础地址解析 `Content-Location`。
- `runId`：从 `runUri` 提取。
- `lastEventId`：从最后一个完整 SSE 帧提取。

遇到 EOF、连接重置或 incomplete chunked response 时：

1. 不要重新发送初始消息 POST。
2. 查询 `GET {runUri}`。
3. 如果状态为 `pending` 或 `running`，订阅 `GET {runUri}/events`。
4. 重连请求携带 `Last-Event-ID`。
5. 使用指数退避和随机抖动，最多重连 5 次。
6. 如果状态为 `success`，但没有最终答案，仍尝试事件恢复；恢复失败则报告“运行成功但最终内容丢失”。
7. 如果状态为 `error`、`timeout` 或 `interrupted`，停止重连并返回明确失败。

重连请求：

```http
GET /api/open/agent-sessions/{session_id}/runs/{run_id}/events
Authorization: Bearer df_open_xxx
Accept: text/event-stream
Last-Event-ID: 1752319583000-17
X-Request-ID: 2076266481248989185
```

恢复接口要求外部应用具备 `agent_sessions:read` scope，并启用 trace policy。

## 9. JDK HttpClient 参考实现

以下代码展示核心逻辑。项目可按现有异常体系、日志框架和 DTO 规范拆分。

```java
import com.fasterxml.jackson.databind.JsonNode;
import com.fasterxml.jackson.databind.ObjectMapper;

import java.io.BufferedReader;
import java.io.EOFException;
import java.io.IOException;
import java.io.InputStream;
import java.io.InputStreamReader;
import java.net.URI;
import java.net.http.HttpClient;
import java.net.http.HttpRequest;
import java.net.http.HttpResponse;
import java.nio.charset.StandardCharsets;
import java.time.Duration;
import java.util.HashSet;
import java.util.Set;
import java.util.concurrent.ThreadLocalRandom;

public final class SuperAgentSseClient {
    private final ObjectMapper mapper = new ObjectMapper();
    private final HttpClient client = HttpClient.newBuilder()
        .connectTimeout(Duration.ofSeconds(10))
        .version(HttpClient.Version.HTTP_1_1)
        .build();

    public String invoke(
        URI streamUri,
        String apiKey,
        String requestId,
        String requestJson
    ) throws Exception {
        HttpRequest request = baseRequest(streamUri, apiKey, requestId)
            .header("Content-Type", "application/json")
            .POST(HttpRequest.BodyPublishers.ofString(requestJson))
            .build();

        HttpResponse<InputStream> response = client.send(
            request,
            HttpResponse.BodyHandlers.ofInputStream()
        );
        require2xx(response.statusCode());

        String contentLocation = response.headers()
            .firstValue("Content-Location")
            .orElseThrow(() -> new IOException("Missing Content-Location"));

        SseRunState state = new SseRunState();
        state.runUri = streamUri.resolve(contentLocation);
        state.runId = state.runUri.getPath().replaceFirst(".*/runs/", "");

        try {
            consume(response.body(), state);
        } catch (IOException firstFailure) {
            recover(apiKey, requestId, state, firstFailure);
        }

        return requireSuccessfulResult(state);
    }

    private void recover(
        String apiKey,
        String requestId,
        SseRunState state,
        IOException firstFailure
    ) throws Exception {
        IOException lastFailure = firstFailure;
        URI eventsUri = URI.create(
            state.runUri.toString().replaceAll("/+$", "") + "/events"
        );

        for (int attempt = 1; attempt <= 5; attempt++) {
            Thread.sleep(backoffMillis(attempt));

            String status = queryRunStatus(
                state.runUri, apiKey, requestId
            );
            if ("error".equals(status)
                || "timeout".equals(status)
                || "interrupted".equals(status)) {
                throw new IOException(
                    "SuperAgent run is terminal: runId=" + state.runId
                        + ", status=" + status
                );
            }

            HttpRequest.Builder builder = baseRequest(
                eventsUri, apiKey, requestId
            ).GET();
            if (state.lastEventId != null) {
                builder.header("Last-Event-ID", state.lastEventId);
            }

            try {
                HttpResponse<InputStream> response = client.send(
                    builder.build(),
                    HttpResponse.BodyHandlers.ofInputStream()
                );
                require2xx(response.statusCode());
                consume(response.body(), state);
                if (state.endReceived) {
                    return;
                }
            } catch (IOException failure) {
                lastFailure = failure;
            }
        }

        throw new IOException(
            "SuperAgent SSE recovery exhausted; runId=" + state.runId
                + ", lastEventId=" + state.lastEventId,
            lastFailure
        );
    }

    private String queryRunStatus(
        URI runUri,
        String apiKey,
        String requestId
    ) throws IOException, InterruptedException {
        HttpRequest request = HttpRequest.newBuilder(runUri)
            .header("Authorization", "Bearer " + apiKey)
            .header("Accept", "application/json")
            .header("X-Request-ID", requestId)
            .GET()
            .build();
        HttpResponse<String> response = client.send(
            request,
            HttpResponse.BodyHandlers.ofString(StandardCharsets.UTF_8)
        );
        require2xx(response.statusCode());
        return mapper.readTree(response.body()).path("status").asText();
    }

    private void consume(InputStream input, SseRunState state)
        throws IOException {
        try (BufferedReader reader = new BufferedReader(
            new InputStreamReader(input, StandardCharsets.UTF_8)
        )) {
            String event = "message";
            String id = null;
            StringBuilder data = new StringBuilder();
            String line;

            while ((line = reader.readLine()) != null) {
                if (line.startsWith(":")) {
                    continue;
                }
                if (line.isEmpty()) {
                    dispatch(event, id, data.toString(), state);
                    event = "message";
                    id = null;
                    data.setLength(0);
                    if (state.endReceived) {
                        return;
                    }
                    continue;
                }
                if (line.startsWith("event:")) {
                    event = line.substring(6).trim();
                } else if (line.startsWith("id:")) {
                    id = line.substring(3).trim();
                } else if (line.startsWith("data:")) {
                    if (data.length() > 0) {
                        data.append('\n');
                    }
                    data.append(line.substring(5).trim());
                }
            }
        }

        if (!state.endReceived) {
            throw new EOFException(
                "Premature EOF before SSE end; runId=" + state.runId
                    + ", lastEventId=" + state.lastEventId
            );
        }
    }

    private void dispatch(
        String event,
        String id,
        String data,
        SseRunState state
    ) throws IOException {
        if (id != null && !id.isBlank()) {
            if (!state.processedEventIds.add(id)) {
                return;
            }
            state.lastEventId = id;
        }
        if ("end".equals(event)) {
            state.endReceived = true;
            return;
        }
        if ("error".equals(event)) {
            JsonNode error = parseJson(data);
            state.failureCode = error.path("code").asText("stream_error");
            state.failureMessage = error.path("message").asText(data);
            return;
        }
        if (!"trace".equals(event) || data.isBlank()) {
            return;
        }

        JsonNode trace = parseJson(data);
        switch (trace.path("event").asText()) {
            case "message.delta" ->
                state.deltaContent.append(trace.path("text").asText(""));
            case "message.final" ->
                state.finalContent = trace.path("text").asText("");
            case "run.completed" ->
                state.runCompleted = "success".equals(
                    trace.path("status").asText()
                );
            case "run.failed" -> {
                JsonNode error = trace.path("error");
                state.failureCode = error.path("code")
                    .asText("run_failed");
                state.failureMessage = error.path("message")
                    .asText(error.toString());
            }
            default -> {
                // reasoning/tool/progress/task/step 事件按业务需要记录。
            }
        }
    }

    private JsonNode parseJson(String data) throws IOException {
        return data == null || data.isBlank()
            ? mapper.createObjectNode()
            : mapper.readTree(data);
    }

    private static HttpRequest.Builder baseRequest(
        URI uri,
        String apiKey,
        String requestId
    ) {
        return HttpRequest.newBuilder(uri)
            .header("Authorization", "Bearer " + apiKey)
            .header("Accept", "text/event-stream")
            .header("Cache-Control", "no-cache")
            .header("X-Request-ID", requestId);
    }

    private static String requireSuccessfulResult(SseRunState state)
        throws IOException {
        if (state.failureCode != null) {
            throw new IOException(
                "SuperAgent failed: " + state.failureCode + ": "
                    + state.failureMessage
            );
        }
        if (!state.endReceived) {
            throw new EOFException("Missing terminal end event");
        }
        if (!state.runCompleted) {
            throw new IOException("Missing successful run.completed event");
        }
        String answer = state.finalContent;
        if (answer == null || answer.isBlank()) {
            answer = state.deltaContent.toString();
        }
        if (answer.isBlank()) {
            throw new IOException(
                "Run completed but final AI content was not delivered"
            );
        }
        return answer;
    }

    private static void require2xx(int statusCode) throws IOException {
        if (statusCode < 200 || statusCode >= 300) {
            throw new IOException("Unexpected HTTP status: " + statusCode);
        }
    }

    private static long backoffMillis(int attempt) {
        long base = Math.min(8_000L, 500L << (attempt - 1));
        return base + ThreadLocalRandom.current().nextLong(200L);
    }

    static final class SseRunState {
        final StringBuilder deltaContent = new StringBuilder();
        final Set<String> processedEventIds = new HashSet<>();
        String finalContent;
        String lastEventId;
        URI runUri;
        String runId;
        String failureCode;
        String failureMessage;
        boolean runCompleted;
        boolean endReceived;
    }
}
```

Jackson Maven 依赖示例：

```xml
<dependency>
  <groupId>com.fasterxml.jackson.core</groupId>
  <artifactId>jackson-databind</artifactId>
  <version>${jackson.version}</version>
</dependency>
```

## 10. 去重规则

- 使用 SSE `id` 对事件去重。
- 重连后收到小于或等于已处理 ID 的事件时跳过。
- `message.final` 按 `run_id` 只处理一次。
- `end` 可重复处理，但只能完成一次本地 Future/Promise。
- 不要仅根据文本内容去重；不同 delta 可能具有相同文本。

如果使用多个 Java 实例消费同一业务调用，`run_id + event_id` 的去重状态必须存放在共享存储中。

## 11. 日志与指标

每次调用至少记录：

- `debug_run_id` / `X-Request-ID`。
- `session_id`。
- SuperAgent `run_id`。
- 最后处理的 `event_id`。
- HTTP 状态码。
- 收到的首事件时间、最后事件时间和 `end` 时间。
- 重连次数和每次重连原因。
- 是否收到 `message.final`、`run.completed`、`error`、`end`。
- 最终结果：success、run_failed、stream_interrupted、recovery_exhausted、protocol_incomplete。

建议指标：

```text
superagent_sse_requests_total
superagent_sse_success_total
superagent_sse_premature_eof_total
superagent_sse_reconnect_total
superagent_sse_recovery_success_total
superagent_sse_missing_end_total
superagent_sse_missing_final_content_total
superagent_sse_duration_seconds
```

日志中不要记录 API Key、完整系统提示词或敏感工具输入输出。

## 12. 验收测试

Java 端上线前至少覆盖：

1. 正常 delta → final → completed → end。
2. 无 delta，直接 final → completed → end。
3. 收到结构化 error → end。
4. 收到部分 delta 后 EOF，使用 `Last-Event-ID` 恢复成功。
5. 重连期间再次 EOF，指数退避后恢复成功。
6. 重连超过最大次数，返回 recovery exhausted。
7. completed 但没有 final/delta，返回协议错误。
8. 有 final 但没有 end，不能返回成功。
9. 事件重放时不会重复拼接 delta。
10. API 返回 401、403、404、409、429、5xx。
11. Java 进程取消请求时，本地状态正确关闭。
12. 同一业务消息不会因 EOF 自动重新 POST。

## 13. 上线顺序

1. 先部署服务端 SSE 终止契约、断连继续执行、最终答案事件和 nginx 配置。
2. 在测试环境验证 `/events` 支持 `Last-Event-ID` 恢复。
3. Java 端以灰度方式开启严格 `end` 校验和恢复逻辑。
4. 观察 premature EOF、恢复成功率和缺失最终内容指标。
5. 指标稳定后全量启用。

服务端升级前，Java 可以先完成解析、终止帧校验、关联日志和错误分类，但不要启用自动重发初始 POST，也不能假设 EOF 后原运行仍会继续。
