# AgentHub 会话运行面协议（仅调用方 · 单页）

> **读者**：只**调用** AgentHub 会话能力（多轮流式对话 / 语音 / 附件 / 文生图）的开发者——Agent 的建模与发布由平台方或租户管理员完成，你拿到的是「地址 + API-Key + 已发布的 Agent ID」。
> **交付方式**：本页随凭证一并交付，自包含；不需要访问 AgentHub 仓库。
> **契约事实源**：`openapi.yaml`（SSOT）——冲突时以 openapi 为准。深度接入（自建 Agent / 建模 / 计费对账全链）另见 SaaS 接入 Cookbook。
> **状态基准**：2026-09-03（帧与计费语义不变；知识库 / 支付开闸边界见对外 how-to，不在本页展开）。

---

## 1. 认证（两个请求头，全程不变）

```
X-AgentHub-Api-Key: ahk_...            # 租户级 API-Key（服务端凭据）
X-AgentHub-On-Behalf-Of: <uuid>        # 影子用户 ID（你的终端用户在平台侧的映射）
```

- API-Key 是**服务端到服务端**凭据——不要放进浏览器 / 小程序等终端代码；
- JSON 路径为 `Envelope`：`{"code": "...", "message": "...", "data": {...}}`，`code != "SUCCESS"` 即业务错误（**SSE 流除外**，见 §4）；仓库 openapi 部分写成裸 schema，解析以本页与 cookbook 为准。
- 凭据丢失只能轮转（`POST /api/v1/delivery/api-keys/{id}:rotate`），旧 Key 即时失效。

## 2. 端点总览（运行面）

| 端点 | 用途 |
|---|---|
| `POST /api/v1/app/conversations` | 开会话（body：`{"agent_id": "<uuid>", "client_conversation_key": "<可选幂等键>"}`）→ `data.id` = 会话 ID；命中幂等键返回 200 原会话 |
| `POST /api/v1/app/conversations/{id}/turns` | 跑一轮，响应为 SSE 流（§4） |
| `GET /api/v1/app/conversations/{id}/turns/{turn_id}` | 断连重拉：返回该轮完整结果，幂等不重跑不重复扣费（§5） |
| `GET /api/v1/app/conversations/{id}/messages?limit=&offset=` | 历史分页（`MessagePageOut{items, limit, offset}`） |
| `POST /api/v1/app/conversations/{id}:end` | 显式结束（幂等）；闭合后只读。空闲超时也会自动闭合 |

会话状态机：`active → closing → closed`；`closed` 后所有写操作返回 409 `CONVERSATION_CLOSED`。**会话钉死打开时的 Agent 版本**——Agent 重新发布后需开新会话才生效。

## 3. 轮次请求体（`content_blocks`）

```json
{"content_blocks": [
  {"type": "text", "text": "帮我查退款政策"},
  {"type": "file", "file_id": "<uuid>", "filename": "notes.txt", "excerpt": "<commit 返回的摘录>"},
  {"type": "choice_selection", "card_message_id": "<uuid>", "selected": ["<option_id>"]}
]}
```

- `text`：普通文本轮；
- `file`：附件随轮（先走附件三步上传 authorize→直传→commit，`excerpt` 以 commit 返回为准）；
- `choice_selection`：回上一轮 `turn_card` 的选项。

## 4. SSE 帧目录（核心）

`POST .../turns` 成功时返回 `200 text/event-stream`（响应头 `Cache-Control: no-cache`、`X-Accel-Buffering: no`）。帧为标准 SSE 三行式：

```
id: 3:2                      ← 帧号 = {轮次 seq}:{帧序号}，单调递增
event: turn_delta
data: {"turn_id": "...", "seq": 2, "text": "退款政策如下"}

```

（空行 = 帧边界；`data` 均为单行 JSON）

| 帧 | 次数 | `data` 负载 |
|---|---|---|
| `turn_started` | 1 | `{"turn_id", "seq", "status": "generating"}` —— **turn_id 是断连重拉的钥匙，首帧必到** |
| `turn_delta` | 0..N | `{"turn_id", "seq", "text"}` —— 增量文本，按序拼接 |
| `turn_card` | 0..1 | `{"turn_id", "card": {"type": "choice_card", "card_schema_version": 1, "prompt", "options": [{"id", "label"}], "multi", "fallback_text"}}` |
| `turn_file` | 0..N | `{"turn_id", "file": {"type": "file", "file_id", "filename", "mime_type", "size_bytes"}}` —— 文生图/工件产物；取内容走 `GET .../files/{file_id}`（短 TTL 签名 URL） |
| `turn_completed` | 1 | `{"turn_id", "status": "succeeded", "points_charged": int\|null, "message_ids": ["<user_msg_id>", "<assistant_msg_id>"]}`；低余额时附 `"balance_warning": true`（仅提示不阻断） |

三条协议性质（客户端实现必须知道）：

1. **先完结，后吐帧**：服务端把轮次完整执行并落库后才投影为帧流。因此流一旦开始，结果已确定；流中不会再出现业务错误。
2. **流前错误是普通 Envelope**：所有校验失败（404/409/402/422…）发生在流开始之前，以标准 HTTP 状态码 + Envelope JSON 返回——客户端按 `Content-Type` 区分：`application/json` = 错误，`text/event-stream` = 正常。
3. **帧号支持 Last-Event-ID 记账**：`id` 为 `{轮次 seq}:{帧序号}`；但**权威的断连恢复路径是 §5 的 GET 重拉**（幂等、不重跑、不重复扣费）。

## 5. 断连重拉（三条命令的协议）

```
① POST .../turns   → 至少收到首帧 turn_started，记下 turn_id
② （连接断开，丢弃未完成的流）
③ GET .../turns/{turn_id} → TurnOut（该轮全部消息 + 计费，已持久化的最终结果）
```

`TurnOut`：`{"id", "conversation_id", "seq", "status", "operation_id", "points_charged", "messages": [...], "started_at", "ended_at", ...}`。重复 GET 幂等。

## 6. 错误码目录

| HTTP | `code` | 含义 / 处置 |
|---|---|---|
| 401 | `API_KEY_INVALID` | Key 错误/吊销；检查请求头 |
| 402 | `POINTS_INSUFFICIENT` | 余额不足；**零副作用**，充值后原样重试 |
| 403 | `AUTH_TENANT_MISMATCH` | On-Behalf-Of 用户非本租户或非 active |
| 404 | `CONVERSATION_NOT_FOUND` / `CONVERSATION_TURN_NOT_FOUND` / `CONVERSATION_MESSAGE_NOT_FOUND` / `CONVERSATION_ATTACHMENT_NOT_FOUND` | 资源不存在或跨租户（同形防枚举） |
| 409 | `CONVERSATION_CLOSED` | 会话已闭合；开新会话 |
| 409 | `CONVERSATION_ACTIVE_TURN_CONFLICT` | 同会话已有进行中轮次；等它完成或直接 GET 重拉 |
| 409 | `CONVERSATION_AGENT_NOT_PUBLISHED` / `CONVERSATION_AGENT_ARCHIVED` | Agent 未发布/已归档；联系发布方 |
| 422 | `VALIDATION_ERROR` | 请求形状 / MIME / 字节数不过白名单 |
| 422 | `CONVERSATION_SPEECH_NOT_ENABLED` | 该 Agent 快照未启用语音；按「不可用」降级 |
| 500 | `CONVERSATION_TURN_FAILED` | 轮次执行异常（`message` 带 trace_id，找平台方） |
| 504 | `CONVERSATION_GUARDRAIL_VIOLATION` | 护栏拦截（墙钟超时等；`message` 有具体原因） |

## 7. 计费生命周期（你只需要关心四件事）

1. **轮次**：开轮先冻结 `points_per_turn`，成功结清（`turn_completed.points_charged`），**失败全额解冻退回**——试错成本只有成功轮次；
2. **语音**：转写 / 合成各按次计费（快照价），**TTS 缓存命中零扣点**（同消息 + 同音色返回同一 URL）；
3. **余额不足**：402 `POINTS_INSUFFICIENT`，零副作用，引导充值后重试；
4. **低余额提示**：结清后可用余额 < 3×单价时 `turn_completed` 附 `balance_warning: true`（仅提示）。

对账键（流水 `operation_id` 前缀）：轮次 `turn:{turn_id}`、转写 `stt:{transcription_id}`、合成 `tts:{speech_id}`、入账 `grant:...`。

## 8. 可运行示例

最小 Python 客户端（含断连重拉演示，零第三方依赖）+ 本地 mock 服务：AgentHub SDK 包 `sdk/python/examples/`——`mock_conversation_server.py` 起本地假服务，`conversation_minimal.py` 全流程跑通（开会话 → 流式 → 模拟断连 → GET 重拉 → 结束），不接触真实平台即可验证你的接入代码。

## 9. 事实源与联动

本页是 Cookbook 附录 A/B 的**外部交付版**：帧负载 / 错误码与 Cookbook 附录 A/B 双源维护，**变更需双改**。语音 / 附件 / 文生图完整 curl 见 Cookbook §7~§11。知识库绑定见 [knowledge-base-retrieval.md](./knowledge-base-retrieval.md)。充值开闸见 [payment-and-recharge.md](./payment-and-recharge.md)。契约文件：[openapi.yaml](../design/2.technical/api-spec/openapi.yaml)。
