superagent请求问题修复
This commit is contained in:
@@ -14,6 +14,11 @@ FIRE_SAFETY_SUPERAGENT_ENABLED=false
|
||||
# Required when enabled, for example: https://superagent.example.com
|
||||
FIRE_SAFETY_SUPERAGENT_BASE_URL=
|
||||
FIRE_SAFETY_SUPERAGENT_OPEN_API_KEY=
|
||||
# Default to the provider-compatible no-Trace stream. Its strict completion
|
||||
# requires an AI stop, a non-empty message.final, and the top-level end event.
|
||||
# Set true only when the external application's trace_policy.enabled is enabled.
|
||||
# This controls trace visibility/validation; it does not disable Agent MCP calls.
|
||||
FIRE_SAFETY_SUPERAGENT_INCLUDE_TRACE=false
|
||||
FIRE_SAFETY_SUPERAGENT_CONNECT_TIMEOUT=15s
|
||||
FIRE_SAFETY_SUPERAGENT_RECOVERY_MAX_ATTEMPTS=5
|
||||
FIRE_SAFETY_SUPERAGENT_RECOVERY_INITIAL_BACKOFF=250ms
|
||||
|
||||
18
CONTEXT.md
18
CONTEXT.md
@@ -14,19 +14,19 @@
|
||||
| --- | --- | --- |
|
||||
| `cmd/server` | Go 服务进程入口 | 已建立 |
|
||||
| `internal/app` | 应用装配、readiness 和 HTTP 生命周期 | 已建立;按开关装配原生/兼容 Chat、可选 `/chat` 测试页面、SuperAgent 和 MCP/PostGIS |
|
||||
| `internal/config` | 环境配置入口 | 已包含 HTTP、SuperAgent、Chat 兼容 App ID/页面开关/legacy 短凭证开关、MCP 与 PostGIS 配置校验和凭证分离门禁 |
|
||||
| `internal/config` | 环境配置入口 | 已包含 HTTP、SuperAgent(含默认关闭的 IncludeTrace)、Chat 兼容 App ID/页面开关/legacy 短凭证开关、MCP 与 PostGIS 配置校验和凭证分离门禁 |
|
||||
| `internal/handler` | HTTP/MCP 入站协议层 | `GET /health` 已启用;默认关闭的原生 `/api/chat`、可选 DashScope 风格 `completion`、可选 `/chat` 页面/资源和 `/mcp` 已实现 |
|
||||
| `internal/service` | 业务用例编排 | 已实现单进程聊天会话/并发 Run 控制,以及地名候选、有界空间查询、可信数据库全范围/镇街白名单和结果语义 |
|
||||
| `internal/domain` | 森林防火领域模型与规则 | 已包含点位、水源、候选设施、通道、队伍和风险区模型 |
|
||||
| `internal/repository` | PostgreSQL/PostGIS 持久化适配 | 已实现 pgxpool、只读固定 SQL 与 schema/SRID readiness;实库 SRID 元数据、严格 readiness 和 7 个工具真实查询已验证 |
|
||||
| `cmd/postgis-srid-migrate` | 显式 SRID 元数据迁移 | 已执行;默认只读预检,写入需独立迁移凭证和明确 CRS 确认 |
|
||||
| `internal/integration/superagent` | SuperAgent Open API 出站适配 | 已实现并通过模拟 Provider 测试,默认关闭 |
|
||||
| `internal/integration/superagent` | SuperAgent Open API 出站适配 | 已实现并通过模拟 Provider 测试;`FIRE_SAFETY_SUPERAGENT_INCLUDE_TRACE` 默认 false,支持无 Trace 严格完成判定 |
|
||||
| `cmd/superagent-probe` | 无业务数据的显式连通性探针 | 已实现;需要项目专属测试配置 |
|
||||
| `cmd/postgis-probe` | 不读取业务行的 PostGIS readiness 探针 | 已实现;需要只读数据库配置 |
|
||||
| `Dockerfile` / `compose.yaml` | 测试环境容器构建与单实例进程托管 | 已建立;容器内监听 8080,只发布宿主机回环端口 16587;页面开关默认关闭;目标机镜像已构建,容器启动和 health 待重建后验证 |
|
||||
| `pkg` | 可被外部 module 复用的稳定 Go API | 当前为空 |
|
||||
| `docs/import` | 字段/表映射、通用模板和本地数据库样例 | 样例 SQL 含受限数据并被 Git 忽略,不会执行 |
|
||||
| SuperAgent | 对话理解、工具选择和答案组织 | 对话客户端按仓库内 2026-07-12 协议基线实现;同一 SuperAgent 中 `th-hotel-simple-superagent` 已稳定启用/调用,证明其兼容档案不依赖版本配置;公网消防 `/mcp` 已收到一次请求并到达 Go,但旧版本门禁返回错误,消防兼容档案待部署验证,公网消防链路尚未调用数据库工具 |
|
||||
| SuperAgent | 对话理解、工具选择和答案组织 | 对话客户端按仓库内 2026-07-12 协议基线实现;默认请求 `include_trace=false`,无 Trace 只隐藏工具/步骤轨迹,不禁止 Agent 调用 MCP;同一 Key 的 `include_trace=true` 曾因应用策略关闭返回 403 `open_agent_trace_disabled`,改为 false 返回 HTTP 200;同一 SuperAgent 中 `th-hotel-simple-superagent` 已稳定启用/调用,证明其兼容档案不依赖版本配置;公网消防 `/mcp` 已收到一次请求并到达 Go,但消防兼容档案待部署验证,公网消防链路尚未完成完整多工具对话 |
|
||||
| PostgreSQL/PostGIS | 消防空间业务事实的预期权威来源 | 8 表共 4,055 条记录;4,048 条非空几何已标记 EPSG:4326,严格 readiness 与本地真实工具冒烟均通过;35 条无效几何按当前策略排除并告警 |
|
||||
|
||||
## 3. 技术栈
|
||||
@@ -37,9 +37,9 @@
|
||||
- 本地工具链:Go `1.26.6`。
|
||||
- HTTP:Go 标准库 `net/http`。
|
||||
- 测试:Go 标准库 `testing`、`httptest`。
|
||||
- 配置:环境变量;支持 HTTP、SuperAgent、Chat、MCP 与 PostGIS 配置,并默认关闭 Chat 及两个外部方向。
|
||||
- 配置:环境变量;支持 HTTP、SuperAgent、Chat、MCP 与 PostGIS 配置,并默认关闭 Chat 及两个外部方向;SuperAgent Trace 请求默认关闭,可通过 `FIRE_SAFETY_SUPERAGENT_INCLUDE_TRACE` 显式开启。
|
||||
- Chat API:标准库 HTTP/SSE;原生 `/api/chat` 使用静态联调 Bearer,可选 `completion` 兼容入口使用同一信任方向的 `xtoken`;默认要求 Chat 凭证至少 32 个可打印 ASCII 字符,受控迁移时可通过默认关闭的 `FIRE_SAFETY_CHAT_ALLOW_LEGACY_SHORT_TOKEN` 兼容已交付短凭证;两者共享精确 Origin、严格 JSON、总超时、有界单进程会话和同会话并发冲突。兼容入口只在严格成功后发送正文。可选测试页面由 `FIRE_SAFETY_CHAT_PAGE_ENABLED` 控制,提供 `/chat`(开启时 308 到 `/chat/`)、`/chat/`、`/chat/app.css` 和 `/chat/app.js`;页面只在内存中使用用户手动输入的 `xtoken`,复用兼容 SSE 的 `session_id`,不嵌入或持久化 Token。
|
||||
- SuperAgent:标准库 HTTP/SSE 客户端,分离 Session 创建和消息发送,支持严格完成判定与既有 Run 断流恢复。
|
||||
- SuperAgent:标准库 HTTP/SSE 客户端,分离 Session 创建和消息发送,支持按配置选择 Trace/无 Trace、严格完成判定与既有 Run 断流恢复。无 Trace 要求最终 AI 消息 `finish_reason=stop`、非空顶层 `message.final` 和顶层 `end`;Trace 模式额外要求 `run.completed(status=success)`。
|
||||
- MCP:标准库 HTTP/JSON-RPC,服务端固定返回版本标识 `2025-06-18`,同步 JSON 响应,独立 Bearer 和 7 个只读工具;按已稳定接通的 `th-hotel-simple-superagent` 兼容档案处理,initialize 中的 `protocolVersion` 和后续 `MCP-Protocol-Version` Header 都不作为版本拒绝门禁,SuperAgent 无需也不能配置版本。固定返回该版本不表示支持任意其他版本,也不是追求最新协议。地名工具只搜索现有业务记录并要求用户确认候选。
|
||||
- PostgreSQL:`github.com/jackc/pgx/v5 v5.10.0` 原生连接池;连接默认只读并设置 statement timeout。
|
||||
- PostGIS:`ST_Covers`、`ST_DWithin`、`ST_Distance` 和 `ST_ClosestPoint`;只在实库确认 EPSG:4326 后启用。
|
||||
@@ -48,11 +48,11 @@
|
||||
### 计划但尚未接入或确认
|
||||
|
||||
- PostgreSQL/PostGIS 的适用索引和生产查询计划验证。
|
||||
- SuperAgent 到 `/mcp` 的 proven-profile 兼容实现部署后重测,以及 TLS、网络白名单、Token 和完整工具调用链联调;版本 Header 不需要配置,也不是验收门禁。
|
||||
- SuperAgent 到 `/mcp` 的 proven-profile 兼容实现部署后重测,以及 TLS、网络白名单、Token 和完整工具调用链联调;版本 Header 不需要配置,也不是验收门禁。无 Trace 模式已具备本地模拟验证,公网完整 MCP 对话仍待确认。
|
||||
- 任意地址/山名的外部地理编码、别名词典和大数据量地名索引。
|
||||
- 用户聊天的真实身份认证、动态授权、共享/持久会话、主动取消和限流策略;首版默认关闭的静态 Bearer + 内存会话 API 以及默认关闭的静态测试页面已实现。legacy 短凭证仅限受控测试/迁移窗口,轮换后须关闭兼容开关。
|
||||
- 最终用户鉴权、动态角色/区域或租户隔离、持久审计与完整可观测性方案。
|
||||
- 正式前端身份接入;当前 `/chat` 页面仅用于受控测试,现有第三方客户端仅通过受限 DashScope 风格协议适配。
|
||||
- 正式前端身份接入;当前 `/chat` 页面仅用于受控测试,现有第三方客户端仅通过受限 DashScope 风格协议适配。Trace 是否开放由 SuperAgent 外部应用策略决定,不能仅凭客户端参数绕过。
|
||||
|
||||
## 4. 已知业务数据范围
|
||||
|
||||
@@ -75,13 +75,13 @@
|
||||
|
||||
- 用户侧应用:采集用户输入并展示结果;仓库提供默认关闭的 `/chat` 受控测试页面,正式用户端形态仍待确认。
|
||||
- 本 Go 服务:鉴权上下文、会话转发、MCP 工具、数据查询、权限、安全和审计。
|
||||
- SuperAgent:理解自然语言、决定是否调用工具、组织自然语言结果;页面和第三方客户端都通过同一兼容 completion SSE 入口发起对话。
|
||||
- SuperAgent:理解自然语言、决定是否调用工具、组织自然语言结果;页面和第三方客户端都通过同一兼容 completion SSE 入口发起对话。上游无 Trace 仅减少轨迹返回,不改变 Agent 选择或调用 MCP 的能力;是否调用成功需看 MCP 日志和结果。
|
||||
- MCP 工具:提供固定的森林防火领域查询,不开放任意 SQL 或跨权限访问。
|
||||
- PostgreSQL/PostGIS:保存和计算可信空间事实;资源是否可用仍取决于明确状态和数据时效。
|
||||
|
||||
## 6. 当前开发方向与非目标
|
||||
|
||||
当前阶段已有可运行、可测试、文档自解释的 Go 基线、SuperAgent Open API Adapter、默认关闭的原生用户对话 API、可选 DashScope 风格兼容入口、默认关闭的 `/chat` 测试页面,以及空间只读 MCP/PostGIS 实现。对话入口使用独立静态联调凭证和单进程内存会话;页面不嵌入或持久化 Token,使用者手动输入 `xtoken` 并复用同页面内存中的 `session_id`。仓库已有多阶段 Docker/Compose 基线以及精确路径、无 Secret 的 Nginx HTTPS 反向代理示例,模板公开反代 `/chat`、`/chat/`、`/chat/app.css`、`/chat/app.js`、兼容 completion 和 `/mcp`,页面是否可用由 Go 开关控制。目标机镜像构建和 Nginx 语法检查已由现场截图证明通过,容器稳定运行、页面公网响应和 TLS 实际状态仍待验证。2026-09-05 22:35 的现场日志已证明公网 MCP 完成 `fire_safety_search_place_candidates` 一次成功调用(此前的 initialize/notifications/tools/list 也有日志),但其余消防工具和完整多工具链仍待验收。同一 SuperAgent 中 `th-hotel-simple-superagent` 已稳定启用/调用,为本项目提供了 proven-profile 兼容参照,但不能替代消防 endpoint 的完整验收。部署后通过 `direct_success` 或 `compatibility_success` 日志分类确认实际请求,不记录客户端原始版本值。MCP 默认关闭,实库严格 readiness 和全部 7 个工具的本地真实查询已通过。下一阶段先在测试服务器验证页面开关、静态资源、兼容 Chat 首轮/多轮和公网 TLS,再完成真实消防 Profile 的完整 MCP 工具链联调。
|
||||
当前阶段已有可运行、可测试、文档自解释的 Go 基线、SuperAgent Open API Adapter、默认关闭的原生用户对话 API、可选 DashScope 风格兼容入口、默认关闭的 `/chat` 测试页面,以及空间只读 MCP/PostGIS 实现。对话入口使用独立静态联调凭证和单进程内存会话;页面不嵌入或持久化 Token,使用者手动输入 `xtoken` 并复用同页面内存中的 `session_id`。SuperAgent Open API 默认通过 `FIRE_SAFETY_SUPERAGENT_INCLUDE_TRACE=false` 使用无 Trace 流:严格成功要求最终 AI 消息 `finish_reason=stop`、非空顶层 `message.final` 和顶层 `end`;无 Trace 不代表 Agent 不能调用 MCP。若设置 true,则仍要求 `run.completed(status=success)`,且外部应用策略必须允许 Trace。当前同一 Key 的 true 请求因 `open_agent_trace_disabled` 返回 403;false 模式下真实探针和本地兼容 Chat SSE 均已严格成功。仓库已有多阶段 Docker/Compose 基线以及精确路径、无 Secret 的 Nginx HTTPS 反向代理示例,模板公开反代 `/chat`、`/chat/`、`/chat/app.css`、`/chat/app.js`、兼容 completion 和 `/mcp`,页面是否可用由 Go 开关控制。目标机镜像构建和 Nginx 语法检查已由现场截图证明通过,容器稳定运行、页面公网响应和 TLS 实际状态仍待验证。2026-09-05 22:35 的现场日志已证明公网 MCP 完成 `fire_safety_search_place_candidates` 一次成功调用(此前的 initialize/notifications/tools/list 也有日志),但其余消防工具和完整多工具链仍待验收。同一 SuperAgent 中 `th-hotel-simple-superagent` 已稳定启用/调用,为本项目提供了 proven-profile 兼容参照,但不能替代消防 endpoint 的完整验收。部署后通过 `direct_success` 或 `compatibility_success` 日志分类确认实际请求,不记录客户端原始版本值。MCP 默认关闭,实库严格 readiness 和全部 7 个工具的本地真实查询已通过。下一阶段先在测试服务器重建包含无 Trace 配置的服务,验证页面开关、静态资源、兼容 Chat 首轮/多轮和公网 TLS,再完成真实消防 Profile 的完整 MCP 工具链联调。
|
||||
|
||||
本阶段不实现:
|
||||
|
||||
|
||||
@@ -4,15 +4,15 @@
|
||||
| --- | --- |
|
||||
| 最近更新 | 2026-09-06 |
|
||||
| 当前分支 | `main` |
|
||||
| 当前阶段 | 对话、SuperAgent、空间 MCP、默认关闭的测试页面与测试环境容器部署基线已完成;公网地点候选 MCP 已现场成功一次,页面公网验收和完整 MCP 多工具链仍待验证 |
|
||||
| 当前重点 | 完成 `/chat` 测试页面的配置开关、静态资源与公网验收,同时重建目标机并验证完整公网 MCP 工具链路 |
|
||||
| 当前阶段 | 对话、SuperAgent、空间 MCP、默认关闭的测试页面、无 Trace 兼容模式与测试环境容器部署基线已完成;公网地点候选 MCP 已现场成功一次,页面公网验收和完整 MCP 多工具链仍待验证 |
|
||||
| 当前重点 | 使用 `FIRE_SAFETY_SUPERAGENT_INCLUDE_TRACE=false` 绕过当前 Trace 策略限制,重建目标机并验证兼容 Chat 与完整公网 MCP 工具链路 |
|
||||
|
||||
## 1. 当前 Checkpoint
|
||||
|
||||
- 名称:`fire-safety-ymd-public-chat-page-v1`
|
||||
- 状态:Ready for Review(本地实现与验证完成;测试服务器部署待用户执行)
|
||||
- 目标:提供默认关闭的 `/chat` 测试页面和 `/chat/` 规范化入口,按第三方兼容 `completion` SSE 请求,支持用户手动输入测试 `xtoken` 和同页面内存 `session_id` 复用;通过 Nginx 公网反代页面及其 CSS/JavaScript 资源,并保持 Token、真实用户认证和生产授权边界不变。
|
||||
- 非目标:替用户提交或推送 Git、直接修改远程服务器、创建数据库容器、迁移生产数据、签发证书、改变 DNS/安全组、实现真实用户认证、动态授权、会话持久化或生产审计。
|
||||
- 名称:`fire-safety-ymd-superagent-no-trace-mode-v1`
|
||||
- 状态:Ready for Review(无 Trace 自动化、真实 SuperAgent 探针和本地兼容 Chat 冒烟均已通过;测试服务器重建和公网完整 MCP 对话待用户执行)
|
||||
- 目标:增加 `FIRE_SAFETY_SUPERAGENT_INCLUDE_TRACE` 配置,默认使用 `include_trace=false`,在不开放 SuperAgent Trace 策略时仍严格验证最终 AI 消息 `finish_reason=stop`、非空顶层 `message.final` 与顶层 `end`;开启 Trace 时继续要求 `run.completed(status=success)`,并保持既有 Chat/MCP 安全边界。
|
||||
- 非目标:替用户提交或推送 Git、直接修改远程服务器、创建数据库容器、迁移生产数据、签发证书、改变 DNS/安全组、实现真实用户认证、动态授权、会话持久化或生产审计;无 Trace 也不承诺已完成公网 MCP 多工具对话验证。
|
||||
|
||||
当前进展:
|
||||
|
||||
@@ -23,6 +23,7 @@
|
||||
- Nginx 示例增加 HTTP 到 HTTPS 跳转和 HTTP 429 JSON 限流响应,精确反代 `/chat`、`/chat/`、`/chat/app.css`、`/chat/app.js`、兼容 completion 和 `/mcp`,仍不比较、保存或注入 Chat、MCP、SuperAgent 或数据库 Secret。
|
||||
- 运维手册记录 Git 前置条件、服务器目录、Secret 权限、Compose/Nginx 启停、页面开关、Chat/MCP 冒烟、SuperAgent 回调、更新和回滚。
|
||||
- SuperAgent 无法在其配置中指定 MCP 协议版本;同一 SuperAgent 中 `th-hotel-simple-superagent` 已稳定启用/调用,现场实现不读取 `initialize.params.protocolVersion`,不读取或校验 `MCP-Protocol-Version` Header,并固定返回 `2025-06-18`。消防 MCP 按该 proven profile 兼容:版本字段和 Header 不作为拒绝门禁,响应固定为 `2025-06-18`。这不表示支持任意其他版本,也不是追求最新协议;日志只使用 `direct_success`/`compatibility_success`,不记录原始版本值。
|
||||
- SuperAgent Open API 现在由 `FIRE_SAFETY_SUPERAGENT_INCLUDE_TRACE` 控制消息流查询参数,默认 false。无 Trace 严格成功要求最终 AI 消息 `finish_reason=stop`、非空顶层 `message.final` 和顶层 `end`;Trace=true 时仍要求 `run.completed(status=success)`。无 Trace 不返回公开工具/步骤轨迹,但不等于禁止 Agent 调 MCP。受控诊断中,同一 Key 的 `include_trace=true` 因应用策略返回 HTTP 403 `open_agent_trace_disabled`;改用 false 后,真实探针返回严格成功,兼容 Chat 本地 SSE 也完成 `null -> stop`。公网完整 MCP 对话仍待验证。
|
||||
|
||||
已实现验收项:
|
||||
|
||||
@@ -40,9 +41,10 @@
|
||||
3. 页面测试保持 `FIRE_SAFETY_CHAT_PAGE_ENABLED=false` 默认边界;需要开启时配置 `https://agent.nianxx.com` 精确 Origin,重建容器并验证 `/chat` 308、`/chat/` 及 CSS/JavaScript 资源 200,以及关闭后的直接 404。
|
||||
4. reload 已通过语法检查的 Nginx 配置,只公开 HTTPS 页面/资源、兼容路径、`/mcp` 和可选 `/health`。
|
||||
5. 使用项目专属测试 Key 和已发布消防 Profile 做兼容 `completion` 首轮/多轮真实冒烟,核对最终回答、用量和会话复用;页面必须证明使用同一请求契约。
|
||||
6. 使用 SuperAgent 无版本配置的实际握手重测:通过 `direct_success` 或 `compatibility_success` 日志确认请求已按 proven profile 处理,并验证 `initialize` → `notifications/initialized` → `tools/list` → 7 个工具的 `tools/call`;当前公网地点搜索的一次成功不替代完整链路,版本字段/Header 不需要配置,也不作为拒绝门禁。
|
||||
7. 为所有查询表补齐适用 GiST 索引并验证查询计划;当前小数据可做联调,但生产前必须完成索引与并发验证。
|
||||
8. 设计真实用户身份、动态角色/区域授权、共享会话、限流、Secret 轮换、指标和持久审计。
|
||||
6. 先以 `FIRE_SAFETY_SUPERAGENT_INCLUDE_TRACE=false` 重建并运行探针/Chat,确认无 Trace 的 `finish_reason=stop` + 非空顶层 `message.final` + 顶层 `end` 严格成功;仅在外部应用策略允许时再测试 true 模式。
|
||||
7. 使用 SuperAgent 无版本配置的实际握手重测:通过 `direct_success` 或 `compatibility_success` 日志确认请求已按 proven profile 处理,并验证 `initialize` → `notifications/initialized` → `tools/list` → 7 个工具的 `tools/call`;当前公网地点搜索的一次成功不替代完整链路,版本字段/Header 不需要配置,也不作为拒绝门禁。
|
||||
8. 为所有查询表补齐适用 GiST 索引并验证查询计划;当前小数据可做联调,但生产前必须完成索引与并发验证。
|
||||
9. 设计真实用户身份、动态角色/区域授权、共享会话、限流、Secret 轮换、指标和持久审计。
|
||||
|
||||
## 3. 已确认事实
|
||||
|
||||
@@ -78,6 +80,7 @@
|
||||
- 水源/设施 `syzt`、水源 `hc_datetime` 等字段的枚举、单位、时区和更新责任人尚未确认。
|
||||
- SuperAgent MCP 的公网 URL 已有现场成功证据:Bearer、`Content-Type`、JSON-RPC 通过,随后记录了 `initialize`、`notifications/initialized`、`tools/list`,并于 2026-09-05 22:35 成功调用 `fire_safety_search_place_candidates`。同一 SuperAgent 的 th-hotel 服务已稳定调用,为 proven profile 参照;消防服务其余 6 个工具、完整多工具链、TLS、网络白名单和 Token 轮换仍待联调。是否发送协议 Header 不构成兼容阻塞。
|
||||
- `/api/chat` 静态 Bearer、兼容路径 `xtoken` 和 `/chat` 测试页面只适用于受控联调,浏览器用户可以看到手动输入的 Token;页面默认关闭,开启时要求 `FIRE_SAFETY_CHAT_ALLOWED_ORIGINS` 包含精确 `https://agent.nianxx.com`。真实用户身份、动态授权、生产速率限制和滥用防护尚未实现。Chat 短凭证仅可通过默认关闭的显式 legacy 开关在受控测试/迁移窗口使用,MCP Token 仍要求至少 32 个可打印 ASCII 字符,三种凭证必须不同。
|
||||
- SuperAgent Trace 策略当前不对本项目开放:同一 Key 请求 `include_trace=true` 返回 403 `open_agent_trace_disabled`;改为 `include_trace=false` 后真实探针已严格成功。无 Trace 只影响工具/步骤轨迹返回和成功判定,不禁止 Agent 调用 MCP;实际消防 Profile 的无 Trace 多工具对话和工具轨迹替代证据仍待公网验收。
|
||||
- 目标公网机器的 Docker/Compose 和 Nginx 版本、配置 include 层级、证书、DNS、安全组及 PostgreSQL 网络拓扑尚未完整验证;用户已开始远程部署,仓库资产与服务器现场配置仍需完成一致性核验。
|
||||
- 目标服务器此前连续两次访问 `proxy.golang.org:443` 均在约 91 秒后超时,随后已通过可达的构建路径完成 Docker 镜像构建;该事实不代表所有外部 HTTPS 都可达。
|
||||
- 目标机曾因已交付的 Chat 短凭证未通过默认配置门禁而反复重启;`801c0af` 已包含显式兼容方案。最新公网 `/mcp` 请求已完成地点候选工具调用,说明该启动阻塞已不再是当前首要问题;但独立 health、页面公网响应、兼容 Chat 多轮、MCP 其余工具和完整链路仍未形成通过证据。
|
||||
@@ -111,6 +114,7 @@
|
||||
- `GOCACHE=/private/tmp/fire-safety-ymd-go-cache go test -count=1 ./...`:通过;新增覆盖页面开关依赖、开启/关闭路由、HTML/静态资源、安全响应头和应用装配;原有 Chat、SuperAgent、MCP/PostGIS 覆盖继续通过。
|
||||
- `GOCACHE=/private/tmp/fire-safety-ymd-go-cache go vet ./...`:通过。
|
||||
- `GOCACHE=/private/tmp/fire-safety-ymd-go-cache go test -race -count=1 ./...`:通过。
|
||||
- SuperAgent 无 Trace:自动化覆盖 `include_trace=false` 请求参数、最终 AI 消息 `finish_reason=stop` + 非空顶层 `message.final` + 顶层 `end` 成功,以及 Trace=true 仍要求 `run.completed(status=success)`;真实同一 Key 诊断为 true=403 `open_agent_trace_disabled`,false 模式下 `cmd/superagent-probe` 严格成功并返回最终回答,本地兼容 Chat SSE 也完成 `finish_reason=null -> stop`,公网完整 MCP 对话仍待验证。
|
||||
- `node --check internal/handler/chatpage/app.js`:通过。
|
||||
- 本地真实浏览器:首轮兼容 SSE、第二轮 `session_id` 复用、错误凭证、Nginx 风格 429、未完成 SSE 断流后清会话、恶意 HTML 纯文本显示均通过;375px 视口无横向溢出,4 个按钮高度均为 44px,控制台无错误。
|
||||
- `ruby -e 'require "yaml"; YAML.load_file("compose.yaml")'`:通过基础 YAML 解析;开发机没有 Docker CLI,未执行 `docker compose config --quiet`。
|
||||
|
||||
@@ -44,6 +44,7 @@ func main() {
|
||||
Enabled: cfg.SuperAgent.Enabled,
|
||||
BaseURL: cfg.SuperAgent.BaseURL,
|
||||
APIKey: cfg.SuperAgent.OpenAPIKey,
|
||||
IncludeTrace: cfg.SuperAgent.IncludeTrace,
|
||||
ConnectTimeout: cfg.SuperAgent.ConnectTimeout,
|
||||
RecoveryMaxAttempts: cfg.SuperAgent.RecoveryMaxAttempts,
|
||||
RecoveryInitialBackoff: cfg.SuperAgent.RecoveryInitialBackoff,
|
||||
|
||||
@@ -51,6 +51,20 @@ Nginx 不再直接调用 DashScope。它只负责 TLS、精确公开路径、基
|
||||
|
||||
这样保留了当前应急辅助场景的失败语义:断流或 Provider 协议不完整时,客户端不会把半截模型文本误当成已完成方案。代价是首版没有逐字动画;如后续必须实时输出,需要独立安全决策、取消/失败 UX 和新 Spec。
|
||||
|
||||
### 4.1 SuperAgent Trace 模式
|
||||
|
||||
上游消息流的 `include_trace` 由 `FIRE_SAFETY_SUPERAGENT_INCLUDE_TRACE` 控制,默认值为
|
||||
`false`。两种模式都要求最终 AI 消息和顶层 `event: end`,但严格成功条件不同:
|
||||
|
||||
- 无 Trace:最终 AI 消息的 `finish_reason=stop` + 非空顶层 `message.final` + 顶层 `end`。
|
||||
- Trace:最终内容 + `run.completed(status=success)` + 顶层 `end`。
|
||||
|
||||
无 Trace 只是不向调用方返回公开工具/步骤轨迹,不等于禁止 SuperAgent 执行中调用 MCP;Agent
|
||||
是否实际调用消防工具仍必须通过完整对话和 `/mcp` 日志验证。当前受控诊断中,应用策略关闭
|
||||
Trace 时 `include_trace=true` 返回 HTTP 403、Provider code `open_agent_trace_disabled`,
|
||||
同一 Key 改为 `include_trace=false` 返回 HTTP 200。因此测试环境默认保持无 Trace;只有在外部
|
||||
应用策略显式启用 `trace_policy.enabled=true` 后才可切换为 Trace 模式。
|
||||
|
||||
测试页面使用浏览器 `fetch` 发起 POST,以便同时设置 `xtoken` 并读取 SSE;它不会把 Token
|
||||
写进 HTML、Cookie、localStorage、sessionStorage、URL 或服务端配置。用户每次打开页面都要手动
|
||||
输入测试 `xtoken`,页面只在本次页面生命周期内使用该值。页面可展示用户输入的问题和最终回答,
|
||||
@@ -79,4 +93,5 @@ Nginx 不再直接调用 DashScope。它只负责 TLS、精确公开路径、基
|
||||
- [`chat-api-v1.md`](chat-api-v1.md)
|
||||
- [`../workflows/user-chat.md`](../workflows/user-chat.md)
|
||||
- [`../project/operations/nginx-public-entry.md`](../project/operations/nginx-public-entry.md)
|
||||
- [`../project/integrations/superagent-openapi.md`](../project/integrations/superagent-openapi.md)
|
||||
- [`../specs/fire-safety-ymd-chat-page-v1.md`](../specs/fire-safety-ymd-chat-page-v1.md)
|
||||
|
||||
@@ -13,7 +13,7 @@
|
||||
| [`ai-nses-project-overlay.md`](ai-nses-project-overlay.md) | fire-safety-ymd 对通用标准的项目覆盖规则 | 低 |
|
||||
| [`backend-development-guidelines.md`](backend-development-guidelines.md) | Go 后端工程规范 | 低 |
|
||||
| [`security-access-control-boundary.md`](security-access-control-boundary.md) | AI、MCP、数据库、身份和敏感数据边界 | 中 |
|
||||
| [`integrations/superagent-openapi.md`](integrations/superagent-openapi.md) | SuperAgent Open API、Session、SSE 恢复与探针说明 | 中 |
|
||||
| [`integrations/superagent-openapi.md`](integrations/superagent-openapi.md) | SuperAgent Open API、Session、无 Trace/Trace SSE、恢复与探针说明 | 中 |
|
||||
| [`integrations/superagent-mcp-spatial.md`](integrations/superagent-mcp-spatial.md) | SuperAgent 空间只读 MCP、配置、工具和联调门禁 | 高 |
|
||||
| [`operations/postgis-srid-4326.md`](operations/postgis-srid-4326.md) | 已确认 WGS84 数据的 SRID 元数据迁移、验证与权限边界 | 高 |
|
||||
| [`operations/nginx-public-entry.md`](operations/nginx-public-entry.md) | `agent.nianxx.com` 到 Go 页面、对话/MCP 的 HTTPS 反向代理示例 | 高 |
|
||||
|
||||
@@ -4,7 +4,7 @@
|
||||
| --- | --- |
|
||||
| 项目 | `fire-safety-ymd` |
|
||||
| 协议基线 | TH Hotel 仓库保存的 2026-07-12 Open Agent API 资料 |
|
||||
| 当前状态 | Go Adapter、默认关闭的原生用户对话 API 与可选兼容入口已完成模拟联调;上线前必须与当前 SuperAgent 环境重新联调 |
|
||||
| 当前状态 | Go Adapter、默认关闭的原生用户对话 API 与可选兼容入口已完成;模拟联调、真实无 Trace CLI 探针和本地兼容 Chat 冒烟均已通过,测试服务器重建及公网完整多工具链仍待验收 |
|
||||
|
||||
## 1. 项目调用边界
|
||||
|
||||
@@ -37,10 +37,23 @@ Open API 请求不直接提交 `profile_id`。平台通过 `df_open_*` 外部应
|
||||
## 3. Open API 流程
|
||||
|
||||
1. 创建 Session:`POST /api/open/agent-sessions`。
|
||||
2. 流式发送消息:`POST /api/open/agent-sessions/{session_id}/messages/stream?include_trace=true`。
|
||||
2. 按 `FIRE_SAFETY_SUPERAGENT_INCLUDE_TRACE` 选择流式发送消息:
|
||||
`POST /api/open/agent-sessions/{session_id}/messages/stream?include_trace=false`
|
||||
(默认)或 `include_trace=true`。
|
||||
3. 初始流断开时查询 `GET {Content-Location}`。
|
||||
4. 使用 `GET {Content-Location}/events` 和 `Last-Event-ID` 恢复。
|
||||
5. 只有最终内容、成功 `run.completed` 和顶层 `end` 同时存在时返回成功。
|
||||
5. 两种模式都要求最终 AI 消息和顶层 `end`;无 Trace 模式还必须收到非空顶层
|
||||
`message.final`,且最终 AI 消息必须带 `finish_reason=stop`;Trace 模式还必须收到
|
||||
`run.completed` 且 `status=success`。
|
||||
|
||||
严格成功条件按模式区分:
|
||||
|
||||
- `include_trace=false`:`AI message.finish_reason=stop` + 非空顶层 `event: message.final` + 顶层 `event: end`。
|
||||
- `include_trace=true`:最终内容 + `run.completed(status=success)` + 顶层 `event: end`。
|
||||
- 任一模式收到顶层 `error`、`run.failed`、断流或协议不完整,都不返回部分答案。
|
||||
|
||||
无 Trace 只是不向调用方返回公开工具/步骤轨迹,不等于禁止 SuperAgent 在执行过程中调用
|
||||
已配置的 MCP 工具;工具是否实际调用仍需通过完整对话和 MCP 日志验证。
|
||||
|
||||
如果未来需要主动取消,平台资料中的接口为:
|
||||
|
||||
@@ -58,9 +71,15 @@ POST /api/open/agent-sessions/{session_id}/runs/{run_id}/cancel
|
||||
- 外部应用 Key 已绑定目标 Profile。
|
||||
- 至少具备 `agent_sessions:create`、`agent_sessions:message` 和 `agent_sessions:read` scope。
|
||||
- 后续需要取消 Run 时增加 `agent_sessions:cancel`。
|
||||
- 需要公开 Trace 时,应用策略的 `trace_policy.enabled=true`。
|
||||
- 默认无 Trace 时不要求开放 Trace 策略;如果设置 `FIRE_SAFETY_SUPERAGENT_INCLUDE_TRACE=true`,
|
||||
应用策略必须显式设置 `trace_policy.enabled=true`。
|
||||
- 工具输入、输出和步骤信息默认只暴露 summary,不开放模型原始思考过程。
|
||||
|
||||
2026-09-06 的受控诊断使用同一个外部应用 Key:`include_trace=true` 因应用策略关闭 Trace
|
||||
返回 HTTP 403,Provider code 为 `open_agent_trace_disabled`;改为 `include_trace=false`
|
||||
后返回 HTTP 200。该结果说明默认无 Trace 是当前策略下的兼容运行模式;它不代表 Agent 不能
|
||||
调用 MCP,公网完整消防对话和多工具调用仍待验证。
|
||||
|
||||
## 5. 本地配置
|
||||
|
||||
复制 `.env.example` 中的占位配置到本地 Secret 管理方式,不提交真实值。
|
||||
@@ -71,6 +90,9 @@ POST /api/open/agent-sessions/{session_id}/runs/{run_id}/cancel
|
||||
FIRE_SAFETY_SUPERAGENT_ENABLED=true
|
||||
FIRE_SAFETY_SUPERAGENT_BASE_URL=https://<superagent-domain>
|
||||
FIRE_SAFETY_SUPERAGENT_OPEN_API_KEY=<secret>
|
||||
# Default false: require AI finish_reason=stop, non-empty message.final, and end.
|
||||
# Set true only if the external app policy enables trace_policy.enabled.
|
||||
FIRE_SAFETY_SUPERAGENT_INCLUDE_TRACE=false
|
||||
|
||||
FIRE_SAFETY_CHAT_ENABLED=true
|
||||
FIRE_SAFETY_CHAT_AUTH_TOKEN=<another-secret-at-least-32-printable-ascii-characters>
|
||||
@@ -91,6 +113,10 @@ go run ./cmd/superagent-probe
|
||||
```
|
||||
|
||||
探针只发送代码内固定的无敏感信息消息,输出最终回答和必要的安全元数据。它不启动业务聊天、不查询消防数据库、不写业务状态。
|
||||
探针遵循 `FIRE_SAFETY_SUPERAGENT_INCLUDE_TRACE`:默认无 Trace 时按
|
||||
`finish_reason=stop`、非空顶层 `message.final` 和顶层 `end` 判定成功;启用 Trace 时还要求
|
||||
`run.completed(status=success)`。
|
||||
无 Trace 不会返回工具轨迹,但不等于禁止 Agent 调 MCP。
|
||||
|
||||
探针不会自动读取 TH Hotel 的 `DEERFLOW_*` 或其他项目变量。至少需要显式设置本项目的启用开关、Base URL 和 Open API Key;未配置时命令会在任何网络请求前退出。
|
||||
|
||||
@@ -123,6 +149,10 @@ Open API 与 MCP 是两条独立连接:
|
||||
|
||||
- 用户聊天 HTTP API 和单进程并发 Run 控制已实现,但默认关闭,只有静态测试 Bearer,没有最终用户认证或动态授权。
|
||||
- 本地会话未持久化;重启、多实例切换和上游失败后不能恢复旧 `conversation_id`,也尚无主动取消。
|
||||
- MCP/PostGIS 已完成本地实库冒烟,但尚未完成 SuperAgent 到公网 MCP 的联调。
|
||||
- 真实 Profile、Key、scope、Trace 策略和网络连通性必须在目标环境验证。
|
||||
- MCP/PostGIS 已完成本地实库冒烟;SuperAgent 到公网 MCP 已完成一次
|
||||
`fire_safety_search_place_candidates` 调用,但其余 6 个工具和完整多工具链仍待验收。
|
||||
- 当前测试 Profile、Key、scope 和网络已通过真实无 Trace CLI 探针;本地兼容 Chat SSE 也已完成
|
||||
`finish_reason=null -> stop`。生产凭证、轮换和目标服务器重建仍待验收;
|
||||
`FIRE_SAFETY_SUPERAGENT_INCLUDE_TRACE=true` 因当前应用策略关闭 Trace 而返回
|
||||
`open_agent_trace_disabled`,只有策略显式开启后才能验证 Trace 模式。
|
||||
- Provider 协议可能在 2026-07-12 资料后变化;出现差异时更新 Spec 和契约,不在 Adapter 中静默猜测。
|
||||
|
||||
@@ -109,6 +109,8 @@ FIRE_SAFETY_BUILD_GOPROXY=https://proxy.golang.org,direct
|
||||
FIRE_SAFETY_SUPERAGENT_ENABLED=true
|
||||
FIRE_SAFETY_SUPERAGENT_BASE_URL=https://<superagent-api-host>
|
||||
FIRE_SAFETY_SUPERAGENT_OPEN_API_KEY=<superagent-open-api-key>
|
||||
# 当前外部应用策略关闭 Trace 时保持 false;只有策略显式允许 Trace 才设置 true
|
||||
FIRE_SAFETY_SUPERAGENT_INCLUDE_TRACE=false
|
||||
|
||||
FIRE_SAFETY_CHAT_ENABLED=true
|
||||
FIRE_SAFETY_CHAT_AUTH_TOKEN=<独立的高熵xtoken>
|
||||
@@ -141,6 +143,10 @@ FIRE_SAFETY_POSTGIS_EXPECTED_SRID=4326
|
||||
- FIRE_SAFETY_CHAT_PAGE_ENABLED 默认必须为 false。开启后 Go 提供 `/chat`、`/chat/` 以及同源的
|
||||
`/chat/app.css`、`/chat/app.js`;`/chat` 会 308 到 `/chat/`,页面和资源的最终可用性仍由 Go
|
||||
开关决定。页面只用于受控测试,不嵌入或持久化 Token。
|
||||
- FIRE_SAFETY_SUPERAGENT_INCLUDE_TRACE 默认必须为 false。无 Trace 的严格成功条件是最终 AI 消息
|
||||
`finish_reason=stop`、非空顶层 `event: message.final` 加顶层 `event: end`;设置为 true 时还要求
|
||||
`run.completed(status=success)`,且外部应用策略必须开启 `trace_policy.enabled=true`。无 Trace
|
||||
只是不返回公开工具/步骤轨迹,不等于禁止 SuperAgent 调用 MCP。
|
||||
- 页面开启时 FIRE_SAFETY_CHAT_ALLOWED_ORIGINS 必须包含精确的 `https://agent.nianxx.com`,并且
|
||||
Chat 兼容 App ID、Chat Token 和 SuperAgent 配置已经可用;该静态页面不是最终用户认证。
|
||||
- 使用 FIRE_SAFETY_MCP_SCOPE_MODE=all 时,FIRE_SAFETY_MCP_ALLOWED_TOWNS 必须保持为空。all 是服务账号级的固定查询表范围,不是最终用户级授权。
|
||||
@@ -148,6 +154,10 @@ FIRE_SAFETY_POSTGIS_EXPECTED_SRID=4326
|
||||
- FIRE_SAFETY_HTTP_ADDR 在容器内应为 :8080,安全边界由 Compose 的 127.0.0.1:16587:8080 和宿主机 Nginx 提供;不要在 Compose 场景设为容器内的 127.0.0.1:8080。
|
||||
- 不要用没有 --quiet 的 docker compose config 或 docker inspect 把完整环境渲染到终端、CI 日志或工单;检查 .env 权限仍为 600。
|
||||
- 修改 `.env` 后必须重新创建容器(例如 `docker compose up -d --force-recreate --no-build`)才能加载新的 Chat 兼容开关;单独 `docker compose restart` 不会重新读取容器环境。旧短凭证仅用于受控测试/迁移,完成轮换后把开关改回 `false` 并再次 recreate。
|
||||
- 修改 `FIRE_SAFETY_SUPERAGENT_INCLUDE_TRACE` 或其他 SuperAgent 代码/配置后,至少执行
|
||||
`docker compose config --quiet`、`docker compose build --pull` 和
|
||||
`docker compose up -d --force-recreate --remove-orphans`;仅 `docker compose restart` 不会
|
||||
重新读取 `.env`,也不会把新代码构建进镜像。
|
||||
|
||||
## 4. 容器访问现有 PostgreSQL/PostGIS
|
||||
|
||||
@@ -381,7 +391,43 @@ curl -i https://agent.nianxx.com/chat/
|
||||
版本导致故障,回到维护者指定的已验证 Git revision 或 root-only Nginx 备份,先 `nginx -t`
|
||||
再 reload;不要恢复旧的全路径 DashScope 代理、关闭 Go 鉴权或把 Secret 写入 Nginx。
|
||||
|
||||
## 9. SuperAgent MCP 回调配置与冒烟
|
||||
## 9. SuperAgent Open API 无 Trace 模式
|
||||
|
||||
当前测试环境优先使用无 Trace 模式,避免外部应用策略未开放公开 Trace 导致消息流返回
|
||||
`HTTP 403` / `open_agent_trace_disabled`。在服务器 `.env` 中确认:
|
||||
|
||||
~~~text
|
||||
FIRE_SAFETY_SUPERAGENT_INCLUDE_TRACE=false
|
||||
~~~
|
||||
|
||||
无 Trace 模式仍严格要求上游最终 AI 消息的 `finish_reason=stop`、非空顶层
|
||||
`event: message.final` 和顶层 `event: end`;最终回答只取 `message.final.text`。它只是不向调用方
|
||||
返回公开工具/步骤轨迹,不等于禁止 SuperAgent 在执行中调用已配置的 MCP 工具。确认 MCP 是否
|
||||
实际被调用,必须检查 `/mcp` 日志和返回结果。只有外部应用策略显式开启
|
||||
`trace_policy.enabled=true` 时,才把该值改为 `true`;Trace 模式会额外要求
|
||||
`run.completed(status=success)`。
|
||||
|
||||
修改 `.env` 后必须重新构建并重新创建服务,使配置在运行容器中生效:
|
||||
|
||||
~~~bash
|
||||
cd /home/firee-safety-ymd
|
||||
docker compose config --quiet
|
||||
docker compose build --pull
|
||||
docker compose up -d --force-recreate --remove-orphans
|
||||
docker compose ps
|
||||
docker compose logs --tail=100 api
|
||||
curl --fail http://127.0.0.1:16587/health
|
||||
~~~
|
||||
|
||||
不要在日志或工单中粘贴 Open API Key。若仍收到 403,只记录 HTTP 状态、脱敏后的 Provider code、
|
||||
时间和请求关联 ID,交由 SuperAgent 平台核对外部应用策略;不要反复轮换无关的 Chat/MCP 凭证。
|
||||
|
||||
受控诊断已经确认:同一 Key 的 `include_trace=true` 因应用策略返回 403
|
||||
`open_agent_trace_disabled`;同一 Key 的 `include_trace=false` 已由真实探针完成严格成功判定,
|
||||
本地兼容 Chat SSE 也完成 `finish_reason=null -> stop`。公网完整消防对话、MCP 实际工具调用和
|
||||
多轮会话仍需单独验收。
|
||||
|
||||
## 10. SuperAgent MCP 回调配置与冒烟
|
||||
|
||||
在 SuperAgent 的工具/MCP 配置中新增远程 MCP 服务:
|
||||
|
||||
@@ -454,7 +500,7 @@ curl --fail \
|
||||
unset MCP_BEARER
|
||||
~~~
|
||||
|
||||
## 10. 更新、重启与回滚
|
||||
## 11. 更新、重启与回滚
|
||||
|
||||
### 发布新版本
|
||||
|
||||
@@ -515,7 +561,7 @@ cd /home/firee-safety-ymd
|
||||
docker compose down
|
||||
~~~
|
||||
|
||||
## 11. 完成判定与当前限制
|
||||
## 12. 完成判定与当前限制
|
||||
|
||||
本手册完成不代表公网部署已完成。现场交付至少应记录:
|
||||
|
||||
@@ -525,6 +571,10 @@ docker compose down
|
||||
- Chat 首轮/多轮 SSE、错误凭证、未知 app/path 的实际 HTTPS 响应。
|
||||
- 页面浏览器验收证明用户手动输入 xtoken、Token 不被页面持久化、兼容 completion SSE 及同页面 `session_id` 复用;这只是静态测试页面,不是生产认证。
|
||||
- 若为已交付旧客户端临时开启 Chat legacy 短凭证兼容,应记录受控迁移窗口,确认启动 warning 不含 Secret,并在凭证轮换后恢复 `FIRE_SAFETY_CHAT_ALLOW_LEGACY_SHORT_TOKEN=false`。
|
||||
- SuperAgent Open API 使用 `FIRE_SAFETY_SUPERAGENT_INCLUDE_TRACE=false` 时,验证最终 AI 消息
|
||||
`finish_reason=stop`、非空顶层 `message.final` 加顶层 `end`;若切换为 true,验证外部应用
|
||||
Trace 策略已开启且 `run.completed(status=success)` 仍为必要条件。无 Trace 不等同于 MCP
|
||||
禁用;完整消防对话和 MCP 工具调用仍须单独记录。
|
||||
- SuperAgent -> `/mcp` 的独立 Bearer、TLS/网络白名单、无版本配置 initialize 兼容响应、`notifications/initialized`、`tools/list` 和全部 7 个受控只读 `tools/call`。现场已有地点搜索一次成功证据,但其余工具未验收;版本字段/Header 不作为配置或拒绝门禁;公网请求到达但没有完成这条调用链,不算完成。
|
||||
- 数据库未公开 5432,运行账号保持只读,PostGIS readiness warning 和 35 条无效面几何缺口已记录。
|
||||
|
||||
|
||||
@@ -12,13 +12,14 @@
|
||||
|
||||
fire-safety-ymd 需要由 Go 后端调用既有 SuperAgent 平台,并在后续让 SuperAgent 通过本项目 MCP 工具查询 PostgreSQL/PostGIS 消防数据。
|
||||
|
||||
TH Hotel 项目已经验证了“创建 Open Agent Session、流式发送消息、解析公开 Trace、严格判断完成状态和断流恢复”的调用形态。本 checkpoint 只迁移协议经验和安全边界,使用 Go 独立实现,不复制 Java、酒店业务、AgentBus、邮件、OSS 或任务结果写入逻辑。
|
||||
TH Hotel 项目已经验证了“创建 Open Agent Session、流式发送消息、解析公开 Trace、严格判断完成状态和断流恢复”的调用形态。本 checkpoint 只迁移协议经验和安全边界,使用 Go 独立实现,不复制 Java、酒店业务、AgentBus、邮件、OSS 或任务结果写入逻辑。当前 SuperAgent 外部应用策略关闭了 Trace:同一 Key 使用 `include_trace=true` 返回 HTTP 403、Provider code 为 `open_agent_trace_disabled`,使用 `include_trace=false` 返回 HTTP 200,因此本项目默认采用无 Trace 模式,同时保留显式 Trace 开关。
|
||||
|
||||
## 2. 目标
|
||||
|
||||
- 建立独立、可测试的 Go SuperAgent Open API Adapter。
|
||||
- 分离 `CreateSession` 与 `StreamMessage`,为后续一个本地对话复用一个 SuperAgent Session 做准备。
|
||||
- 使用严格 SSE 成功条件,拒绝部分回答和不完整协议结果。
|
||||
- 通过 `FIRE_SAFETY_SUPERAGENT_INCLUDE_TRACE` 选择无 Trace 或公开 Trace;两种模式均不返回部分答案。
|
||||
- 初始 SSE 断流后通过既有 Run 恢复,不重新发送原始消息。
|
||||
- 提供默认关闭的 CLI 连通性探针,只发送固定无敏感信息消息。
|
||||
- 所有自动化测试使用本地模拟 Provider,不需要真实 Secret 或网络。
|
||||
@@ -43,6 +44,7 @@ TH Hotel 项目已经验证了“创建 Open Agent Session、流式发送消息
|
||||
- 目标与非目标已确认:是。
|
||||
- 协议基线已确认:以 TH Hotel 仓库保存的 2026-07-12 SuperAgent Open API 文档和当前实现为输入,真实环境上线前重新验证。
|
||||
- 权限和安全边界已确认:Secret 仅由本项目环境变量注入;没有用户业务数据进入探针。
|
||||
- Trace 策略已确认:默认无 Trace;如需 `include_trace=true`,外部应用必须允许 `trace_policy.enabled=true`。
|
||||
- 外部依赖已确认:实现只使用 Go 标准库。
|
||||
- 未确认问题已列出:Profile、外部应用、scope、真实 Base URL 和 API Key 均由平台管理员后续提供。
|
||||
|
||||
@@ -65,10 +67,12 @@ POST /api/open/agent-sessions
|
||||
### 6.2 流式发送消息
|
||||
|
||||
```text
|
||||
POST /api/open/agent-sessions/{session_id}/messages/stream?include_trace=true
|
||||
POST /api/open/agent-sessions/{session_id}/messages/stream?include_trace=false
|
||||
```
|
||||
|
||||
请求包含 `message`、稳定 `idempotency_key` 和安全 `metadata`。Session ID 由上层明确传入,Adapter 不在每条消息前隐式创建新 Session。
|
||||
`include_trace` 由 `FIRE_SAFETY_SUPERAGENT_INCLUDE_TRACE` 控制,默认值为 `false`;只有外部应用
|
||||
策略明确开启 Trace 时才设置为 `true`。请求包含 `message`、稳定 `idempotency_key` 和安全
|
||||
`metadata`。Session ID 由上层明确传入,Adapter 不在每条消息前隐式创建新 Session。
|
||||
|
||||
### 6.3 鉴权与关联 Header
|
||||
|
||||
@@ -79,12 +83,18 @@ POST /api/open/agent-sessions/{session_id}/messages/stream?include_trace=true
|
||||
|
||||
## 7. SSE 成功与恢复规则
|
||||
|
||||
一次调用只有同时满足以下条件才成功:
|
||||
一次调用只有满足对应模式的严格条件才成功:
|
||||
|
||||
1. 收到最终内容;优先使用 `message.final`,兼容累计 `message.delta` 和历史 `messages`/`values` AI 消息。
|
||||
2. 收到 `run.completed` 且 `status=success`。
|
||||
3. 收到顶层 `event: end`。
|
||||
4. 没有收到顶层 `error` 或 `run.failed`。
|
||||
- 无 Trace(`include_trace=false`):收到历史 `messages`/`values` 中最终 AI 消息,且其
|
||||
`response_metadata.finish_reason=stop`,同时收到非空顶层 `event: message.final` 和顶层
|
||||
`event: end`;最终回答只采用 `message.final.text`。
|
||||
- Trace(`include_trace=true`):收到最终内容(优先 `message.final`,兼容累计
|
||||
`message.delta` 和历史 `messages`/`values` AI 消息)、`run.completed` 且 `status=success`,
|
||||
并收到顶层 `event: end`。
|
||||
- 两种模式都不能收到顶层 `error` 或 `run.failed`;断流、缺少必要条件或协议错误不得返回部分答案。
|
||||
|
||||
无 Trace 不向调用方返回公开工具/步骤轨迹,但只控制 Trace 暴露和完成判定,不禁止 SuperAgent
|
||||
在执行中调用已配置的 MCP 工具。是否实际调用工具仍须使用完整对话和 MCP 服务日志验收。
|
||||
|
||||
解析器必须支持 `event:`、多行 `data:`、`id:`、心跳注释和空行分帧,并按 SSE event ID 去重。
|
||||
|
||||
@@ -104,6 +114,7 @@ POST /api/open/agent-sessions/{session_id}/messages/stream?include_trace=true
|
||||
| `FIRE_SAFETY_SUPERAGENT_ENABLED` | `false` | 总开关,默认不调用真实 Provider |
|
||||
| `FIRE_SAFETY_SUPERAGENT_BASE_URL` | 空 | SuperAgent Open API Base URL |
|
||||
| `FIRE_SAFETY_SUPERAGENT_OPEN_API_KEY` | 空 | Secret,启用时必填 |
|
||||
| `FIRE_SAFETY_SUPERAGENT_INCLUDE_TRACE` | `false` | 是否请求公开 Trace;`true` 要求外部应用策略开启 `trace_policy.enabled` |
|
||||
| `FIRE_SAFETY_SUPERAGENT_CONNECT_TIMEOUT` | `15s` | 建连超时 |
|
||||
| `FIRE_SAFETY_SUPERAGENT_RECOVERY_MAX_ATTEMPTS` | `5` | SSE 恢复最大次数;最大可配置为 20 |
|
||||
| `FIRE_SAFETY_SUPERAGENT_RECOVERY_INITIAL_BACKOFF` | `250ms` | 首次恢复退避 |
|
||||
@@ -128,16 +139,17 @@ POST /api/open/agent-sessions/{session_id}/messages/stream?include_trace=true
|
||||
| --- | --- | --- | --- | --- |
|
||||
| 环境配置与安全默认值 | Done | Passed | 本文第 8 节 | Implemented |
|
||||
| CreateSession | Done | Passed | 本文第 6.1 节 | Implemented |
|
||||
| StreamMessage 与严格成功条件 | Done | Passed | 本文第 6.2、7 节 | Implemented |
|
||||
| StreamMessage 与按模式严格成功条件 | Done | Passed | 本文第 6.2、7 节 | Implemented |
|
||||
| SSE 断流恢复 | Done | Passed | 本文第 7 节 | Implemented |
|
||||
| CLI 探针 | Done | Compile passed;live test pending | 项目集成指南 | Implemented |
|
||||
| CLI 探针 | Done | Automated passed;2026-09-06 live no-Trace passed | 项目集成指南 | Verified |
|
||||
|
||||
## 11. 验收标准
|
||||
|
||||
- Given 未启用 SuperAgent,When 执行真实调用,Then 返回受控禁用错误且不发起网络请求。
|
||||
- Given 配置完整,When 创建 Session,Then请求具备鉴权、幂等、关联和 CSRF Header,且能解析 Session ID。
|
||||
- Given完整 SSE,When 同时收到最终内容、成功完成和 `end`,Then 返回最终回答与安全元数据。
|
||||
- Given SSE 缺少任一成功条件,When 无法恢复,Then 返回协议错误且不返回部分回答。
|
||||
- Given `FIRE_SAFETY_SUPERAGENT_INCLUDE_TRACE=false` 且收到 `finish_reason=stop`、非空顶层 `message.final` 与 `end`,Then 返回 `message.final.text` 和安全元数据,不要求 `run.completed`。
|
||||
- Given `FIRE_SAFETY_SUPERAGENT_INCLUDE_TRACE=true` 且同时收到最终内容、成功完成和 `end`,Then 返回最终回答与安全元数据。
|
||||
- Given 对应模式缺少任一必要成功条件,When 无法恢复,Then 返回协议错误且不返回部分回答。
|
||||
- Given 初始 SSE 提前结束且存在 Run URL,When 恢复,Then只 GET Run/events、携带 `Last-Event-ID`,消息 POST 次数仍为 1。
|
||||
- Given未设置真实环境变量,When 运行全部测试,Then 不访问外网且测试通过。
|
||||
|
||||
@@ -145,7 +157,8 @@ POST /api/open/agent-sessions/{session_id}/messages/stream?include_trace=true
|
||||
|
||||
- 配置默认值、合法值和错误值。
|
||||
- Session 请求路径、Header、CSRF、请求体和响应解析。
|
||||
- 当前 Trace SSE、历史 messages/values 兼容、事件 ID 去重和多行 data。
|
||||
- 无 Trace 的历史 messages/values 最终 AI 消息、`finish_reason=stop`、非空顶层 `message.final` 和顶层 `end`。
|
||||
- Trace SSE 的最终内容、`run.completed(status=success)`、历史 messages/values 兼容、事件 ID 去重和多行 data。
|
||||
- 缺少 final/completed/end、顶层 error、run.failed 和非法 JSON。
|
||||
- 提前 EOF 恢复、Last-Event-ID、同源 URL 和不重复 POST。
|
||||
- HTTP 非 2xx、超大控制响应和 context 取消。
|
||||
@@ -156,4 +169,5 @@ POST /api/open/agent-sessions/{session_id}/messages/stream?include_trace=true
|
||||
- `gofmt`、`go test ./...` 和 `go vet ./...` 通过。
|
||||
- 没有真实 Secret、用户数据、构建产物或无关用户变更。
|
||||
- 项目索引、集成指南、安全边界和 `PROJECT_STATE.md` 已同步。
|
||||
- 默认无 Trace 能在不开放 `trace_policy` 时完成严格成功判定;Trace 模式仍保留 `run.completed` 成功门禁。
|
||||
- 真实环境未配置时明确说明未做 live connectivity test。
|
||||
|
||||
@@ -42,6 +42,13 @@ Network 应显示同源 POST 到精确 app ID 的 completion 路径,Request He
|
||||
发送第二个问题时,应在第二次请求中看到上一轮返回的 `session_id` 被放入 `input.session_id`。不要把初始 `finish_reason=null` 或
|
||||
断流内容当作最终答案。
|
||||
|
||||
页面客户端看到的成功契约不随上游 Trace 开关变化,始终以兼容流最后的
|
||||
`output.finish_reason=stop` 为准。服务端到 SuperAgent 的严格判定由
|
||||
`FIRE_SAFETY_SUPERAGENT_INCLUDE_TRACE` 控制:默认 `false` 时要求上游最终 AI 消息
|
||||
`finish_reason=stop`、非空顶层 `message.final` 和顶层 `event: end`;设置为 `true` 时还要求
|
||||
`run.completed(status=success)`。无 Trace 只是不返回工具/步骤轨迹,不代表 SuperAgent 不会
|
||||
调用 MCP;要确认 MCP 是否实际执行,必须同时检查服务日志和工具结果。
|
||||
|
||||
## 3. 后续对话
|
||||
|
||||
后续请求同时发送 `message` 和前一轮保存的 `conversation_id`。成功流中的 `conversation` 事件返回 `reused=true`,说明复用了原 SuperAgent Session 上下文。
|
||||
@@ -56,6 +63,12 @@ Network 应显示同源 POST 到精确 app ID 的 completion 路径,Request He
|
||||
- 上游超时、协议错误、Run 失败或客户端中途断开:当前实现会使会话映射失效,以免继续复用可能仍有活动 Run 的 Provider Session。
|
||||
- 网络断开且没有看到 `done`:结果未知;首版不自动重放原消息,避免重复 Run。
|
||||
|
||||
如果上游返回 `open_agent_trace_disabled` 或 HTTP 403,先检查是否将
|
||||
`FIRE_SAFETY_SUPERAGENT_INCLUDE_TRACE` 设置为 `true`。当前应用策略关闭 Trace 时,使用
|
||||
`include_trace=true` 会被拒绝;同一 Key 使用默认的 `include_trace=false` 可以返回 HTTP 200。
|
||||
只有在外部应用策略明确启用 `trace_policy.enabled=true` 后,才应切换为 `true`。修改环境变量后
|
||||
必须重建或重新创建运行容器,不能只依赖 `docker compose restart` 重新读取配置。
|
||||
|
||||
## 5. curl 联调
|
||||
|
||||
先把 `.env` 显式加载到当前 shell,再启动服务;Go 程序不会自动读取 `.env`:
|
||||
|
||||
@@ -60,6 +60,7 @@ func New(ctx context.Context, cfg config.Config) (*Application, error) {
|
||||
Enabled: cfg.SuperAgent.Enabled,
|
||||
BaseURL: cfg.SuperAgent.BaseURL,
|
||||
APIKey: cfg.SuperAgent.OpenAPIKey,
|
||||
IncludeTrace: cfg.SuperAgent.IncludeTrace,
|
||||
ConnectTimeout: cfg.SuperAgent.ConnectTimeout,
|
||||
RecoveryMaxAttempts: cfg.SuperAgent.RecoveryMaxAttempts,
|
||||
RecoveryInitialBackoff: cfg.SuperAgent.RecoveryInitialBackoff,
|
||||
|
||||
@@ -115,6 +115,7 @@ func TestNewWiresChatAPIToSuperAgent(t *testing.T) {
|
||||
Enabled: true,
|
||||
BaseURL: provider.URL,
|
||||
OpenAPIKey: "provider-open-api-key",
|
||||
IncludeTrace: true,
|
||||
ConnectTimeout: time.Second,
|
||||
RecoveryMaxAttempts: 1,
|
||||
RecoveryInitialBackoff: time.Millisecond,
|
||||
|
||||
@@ -18,6 +18,7 @@ const (
|
||||
SuperAgentEnabledEnv = "FIRE_SAFETY_SUPERAGENT_ENABLED"
|
||||
SuperAgentBaseURLEnv = "FIRE_SAFETY_SUPERAGENT_BASE_URL"
|
||||
SuperAgentOpenAPIKeyEnv = "FIRE_SAFETY_SUPERAGENT_OPEN_API_KEY"
|
||||
SuperAgentIncludeTraceEnv = "FIRE_SAFETY_SUPERAGENT_INCLUDE_TRACE"
|
||||
SuperAgentConnectTimeoutEnv = "FIRE_SAFETY_SUPERAGENT_CONNECT_TIMEOUT"
|
||||
SuperAgentRecoveryMaxAttemptsEnv = "FIRE_SAFETY_SUPERAGENT_RECOVERY_MAX_ATTEMPTS"
|
||||
SuperAgentRecoveryInitialBackoffEnv = "FIRE_SAFETY_SUPERAGENT_RECOVERY_INITIAL_BACKOFF"
|
||||
@@ -107,6 +108,7 @@ type SuperAgentConfig struct {
|
||||
Enabled bool
|
||||
BaseURL string
|
||||
OpenAPIKey string
|
||||
IncludeTrace bool
|
||||
ConnectTimeout time.Duration
|
||||
RecoveryMaxAttempts int
|
||||
RecoveryInitialBackoff time.Duration
|
||||
@@ -163,6 +165,10 @@ func Load() (Config, error) {
|
||||
if err != nil {
|
||||
return Config{}, err
|
||||
}
|
||||
includeTrace, err := parseBool(SuperAgentIncludeTraceEnv, false)
|
||||
if err != nil {
|
||||
return Config{}, err
|
||||
}
|
||||
connectTimeout, err := parseDuration(SuperAgentConnectTimeoutEnv, defaultSuperAgentConnectTimeout)
|
||||
if err != nil {
|
||||
return Config{}, err
|
||||
@@ -188,6 +194,7 @@ func Load() (Config, error) {
|
||||
Enabled: enabled,
|
||||
BaseURL: strings.TrimRight(strings.TrimSpace(os.Getenv(SuperAgentBaseURLEnv)), "/"),
|
||||
OpenAPIKey: strings.TrimSpace(os.Getenv(SuperAgentOpenAPIKeyEnv)),
|
||||
IncludeTrace: includeTrace,
|
||||
ConnectTimeout: connectTimeout,
|
||||
RecoveryMaxAttempts: recoveryMaxAttempts,
|
||||
RecoveryInitialBackoff: recoveryInitialBackoff,
|
||||
|
||||
@@ -20,6 +20,9 @@ func TestLoadDefaults(t *testing.T) {
|
||||
if cfg.SuperAgent.Enabled {
|
||||
t.Fatal("SuperAgent.Enabled = true, want false")
|
||||
}
|
||||
if cfg.SuperAgent.IncludeTrace {
|
||||
t.Fatal("SuperAgent.IncludeTrace = true, want false")
|
||||
}
|
||||
if cfg.SuperAgent.ConnectTimeout != 15*time.Second {
|
||||
t.Fatalf("ConnectTimeout = %v, want 15s", cfg.SuperAgent.ConnectTimeout)
|
||||
}
|
||||
@@ -169,6 +172,7 @@ func TestLoadConfiguredValues(t *testing.T) {
|
||||
t.Setenv(SuperAgentEnabledEnv, "true")
|
||||
t.Setenv(SuperAgentBaseURLEnv, " https://superagent.example.test/root/ ")
|
||||
t.Setenv(SuperAgentOpenAPIKeyEnv, " test-open-api-key ")
|
||||
t.Setenv(SuperAgentIncludeTraceEnv, "true")
|
||||
t.Setenv(SuperAgentConnectTimeoutEnv, "3s")
|
||||
t.Setenv(SuperAgentRecoveryMaxAttemptsEnv, "2")
|
||||
t.Setenv(SuperAgentRecoveryInitialBackoffEnv, "20ms")
|
||||
@@ -189,6 +193,9 @@ func TestLoadConfiguredValues(t *testing.T) {
|
||||
if cfg.SuperAgent.OpenAPIKey != "test-open-api-key" {
|
||||
t.Fatal("OpenAPIKey was not loaded")
|
||||
}
|
||||
if !cfg.SuperAgent.IncludeTrace {
|
||||
t.Fatal("IncludeTrace = false, want true")
|
||||
}
|
||||
if cfg.SuperAgent.ConnectTimeout != 3*time.Second || cfg.SuperAgent.RecoveryMaxAttempts != 2 || cfg.SuperAgent.RecoveryInitialBackoff != 20*time.Millisecond {
|
||||
t.Fatalf("unexpected recovery config: %#v", cfg.SuperAgent)
|
||||
}
|
||||
@@ -204,6 +211,7 @@ func TestLoadRejectsInvalidValues(t *testing.T) {
|
||||
value string
|
||||
}{
|
||||
{name: "boolean", key: SuperAgentEnabledEnv, value: "sometimes"},
|
||||
{name: "include trace boolean", key: SuperAgentIncludeTraceEnv, value: "sometimes"},
|
||||
{name: "connect timeout syntax", key: SuperAgentConnectTimeoutEnv, value: "soon"},
|
||||
{name: "connect timeout non-positive", key: SuperAgentConnectTimeoutEnv, value: "0s"},
|
||||
{name: "recovery attempts syntax", key: SuperAgentRecoveryMaxAttemptsEnv, value: "many"},
|
||||
@@ -574,6 +582,7 @@ func clearEnvironment(t *testing.T) {
|
||||
SuperAgentEnabledEnv,
|
||||
SuperAgentBaseURLEnv,
|
||||
SuperAgentOpenAPIKeyEnv,
|
||||
SuperAgentIncludeTraceEnv,
|
||||
SuperAgentConnectTimeoutEnv,
|
||||
SuperAgentRecoveryMaxAttemptsEnv,
|
||||
SuperAgentRecoveryInitialBackoffEnv,
|
||||
|
||||
@@ -13,6 +13,7 @@ import (
|
||||
"net"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
)
|
||||
@@ -34,6 +35,7 @@ type HTTPClient struct {
|
||||
enabled bool
|
||||
baseURL *url.URL
|
||||
apiKey string
|
||||
includeTrace bool
|
||||
httpClient *http.Client
|
||||
recoveryMaxAttempts int
|
||||
recoveryInitialBackoff time.Duration
|
||||
@@ -88,9 +90,10 @@ func NewHTTPClient(cfg Config) (*HTTPClient, error) {
|
||||
}).DialContext
|
||||
|
||||
return &HTTPClient{
|
||||
enabled: true,
|
||||
baseURL: baseURL,
|
||||
apiKey: cfg.APIKey,
|
||||
enabled: true,
|
||||
baseURL: baseURL,
|
||||
apiKey: cfg.APIKey,
|
||||
includeTrace: cfg.IncludeTrace,
|
||||
httpClient: &http.Client{
|
||||
Transport: transport,
|
||||
CheckRedirect: func(_ *http.Request, _ []*http.Request) error {
|
||||
@@ -198,7 +201,9 @@ func (c *HTTPClient) StreamMessage(
|
||||
}
|
||||
|
||||
streamURL := c.endpoint("/api/open/agent-sessions/" + request.SessionID + "/messages/stream")
|
||||
streamURL.RawQuery = "include_trace=true"
|
||||
streamQuery := streamURL.Query()
|
||||
streamQuery.Set("include_trace", strconv.FormatBool(c.includeTrace))
|
||||
streamURL.RawQuery = streamQuery.Encode()
|
||||
httpRequest, err := c.newRequest(
|
||||
ctx,
|
||||
http.MethodPost,
|
||||
@@ -229,7 +234,7 @@ func (c *HTTPClient) StreamMessage(
|
||||
return Result{}, err
|
||||
}
|
||||
|
||||
state := newStreamState(request.SessionID)
|
||||
state := newStreamState(request.SessionID, c.includeTrace)
|
||||
if contentLocation := strings.TrimSpace(response.Header.Get("Content-Location")); contentLocation != "" {
|
||||
runURL, locationErr := c.resolveRunURL(httpRequest.URL, request.SessionID, contentLocation)
|
||||
if locationErr != nil {
|
||||
|
||||
@@ -105,6 +105,39 @@ func TestHTTPClientCreatesSessionAndStreamsMessage(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestHTTPClientStreamsWithoutTraceWhenDisabled(t *testing.T) {
|
||||
server := httptest.NewServer(http.HandlerFunc(func(writer http.ResponseWriter, request *http.Request) {
|
||||
if got := request.URL.Query().Get("include_trace"); got != "false" {
|
||||
t.Errorf("include_trace = %q, want false", got)
|
||||
}
|
||||
writer.Header().Set("Content-Type", "text/event-stream")
|
||||
fmt.Fprint(writer, "event: messages\ndata: [{\"type\":\"AIMessageChunk\",\"content\":\"\",\"response_metadata\":{\"finish_reason\":\"stop\"}}]\n\n")
|
||||
fmt.Fprint(writer, "event: message.final\ndata: {\"run_id\":\"run-no-trace\",\"text\":\"no-trace answer\"}\n\n")
|
||||
fmt.Fprint(writer, "event: end\n\n")
|
||||
}))
|
||||
defer server.Close()
|
||||
|
||||
client, err := NewHTTPClient(Config{
|
||||
Enabled: true,
|
||||
BaseURL: server.URL,
|
||||
APIKey: testAPIKey,
|
||||
IncludeTrace: false,
|
||||
ConnectTimeout: time.Second,
|
||||
RecoveryInitialBackoff: time.Millisecond,
|
||||
MaxMessageBytes: 1024,
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("NewHTTPClient() error = %v", err)
|
||||
}
|
||||
result, err := client.StreamMessage(context.Background(), validMessageRequest(), nil)
|
||||
if err != nil {
|
||||
t.Fatalf("StreamMessage() error = %v", err)
|
||||
}
|
||||
if result.Answer != "no-trace answer" {
|
||||
t.Fatalf("Answer = %q, want no-trace answer", result.Answer)
|
||||
}
|
||||
}
|
||||
|
||||
func TestHTTPClientRecoversWithoutRepostingMessage(t *testing.T) {
|
||||
var messagePosts atomic.Int32
|
||||
var runQueries atomic.Int32
|
||||
@@ -405,6 +438,7 @@ func newTestHTTPClient(t *testing.T, baseURL string, recoveryAttempts int, backo
|
||||
Enabled: true,
|
||||
BaseURL: baseURL,
|
||||
APIKey: testAPIKey,
|
||||
IncludeTrace: true,
|
||||
ConnectTimeout: time.Second,
|
||||
RecoveryMaxAttempts: recoveryAttempts,
|
||||
RecoveryInitialBackoff: backoff,
|
||||
|
||||
@@ -28,13 +28,16 @@ var (
|
||||
)
|
||||
|
||||
type streamState struct {
|
||||
sessionID string
|
||||
sessionID string
|
||||
requireRunCompleted bool
|
||||
|
||||
endSeen bool
|
||||
runCompleted bool
|
||||
runID string
|
||||
runURL string
|
||||
lastEventID string
|
||||
endSeen bool
|
||||
runCompleted bool
|
||||
finalAIStopSeen bool
|
||||
topLevelFinalSeen bool
|
||||
runID string
|
||||
runURL string
|
||||
lastEventID string
|
||||
|
||||
profileID string
|
||||
profileVersionID string
|
||||
@@ -56,11 +59,12 @@ type streamState struct {
|
||||
streamBytes int64
|
||||
}
|
||||
|
||||
func newStreamState(sessionID string) *streamState {
|
||||
func newStreamState(sessionID string, requireRunCompleted bool) *streamState {
|
||||
return &streamState{
|
||||
sessionID: sessionID,
|
||||
processedIDs: make(map[string]struct{}),
|
||||
eventTypeSet: make(map[string]struct{}),
|
||||
sessionID: sessionID,
|
||||
requireRunCompleted: requireRunCompleted,
|
||||
processedIDs: make(map[string]struct{}),
|
||||
eventTypeSet: make(map[string]struct{}),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -191,6 +195,21 @@ func (s *streamState) consumeFrame(frame sseFrame, traceHandler TraceHandler) er
|
||||
return err
|
||||
}
|
||||
return s.consumeMessages(payload)
|
||||
case "message.final":
|
||||
payload, err := decodeJSONObject(data)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
text := stringValue(payload, "text")
|
||||
if strings.TrimSpace(text) == "" {
|
||||
return nil
|
||||
}
|
||||
if len(text) > maxAnswerBytes {
|
||||
return fmt.Errorf("%w: final answer exceeds size limit", ErrProtocol)
|
||||
}
|
||||
s.runID = valueOrExisting(safeValue(stringValue(payload, "run_id")), s.runID)
|
||||
s.finalContent = text
|
||||
s.topLevelFinalSeen = true
|
||||
case "values":
|
||||
payload, err := decodeJSONObject(data)
|
||||
if err != nil {
|
||||
@@ -277,16 +296,10 @@ func (s *streamState) consumeMessages(value any) error {
|
||||
if nested, exists := current["messages"]; exists {
|
||||
return s.consumeMessages(nested)
|
||||
}
|
||||
if stringValue(current, "type") != "ai" {
|
||||
messageType := stringValue(current, "type")
|
||||
if messageType != "ai" && messageType != "AIMessageChunk" {
|
||||
return nil
|
||||
}
|
||||
content := contentText(current["content"])
|
||||
if strings.TrimSpace(content) == "" {
|
||||
return nil
|
||||
}
|
||||
if len(content) > maxAnswerBytes {
|
||||
return fmt.Errorf("%w: final answer exceeds size limit", ErrProtocol)
|
||||
}
|
||||
responseMetadata, _ := current["response_metadata"].(map[string]any)
|
||||
usageMetadata, _ := current["usage_metadata"].(map[string]any)
|
||||
modelName := valueOrExisting(safeTrace(stringValue(responseMetadata, "model_name")), s.modelName)
|
||||
@@ -295,10 +308,22 @@ func (s *streamState) consumeMessages(value any) error {
|
||||
Output: int64Value(usageMetadata, "output_tokens"),
|
||||
Total: int64Value(usageMetadata, "total_tokens"),
|
||||
}
|
||||
if stringValue(responseMetadata, "finish_reason") == "stop" {
|
||||
s.finalContent = content
|
||||
stopSeen := stringValue(responseMetadata, "finish_reason") == "stop"
|
||||
if stopSeen {
|
||||
s.finalAIStopSeen = true
|
||||
s.modelName = modelName
|
||||
s.usage = usage
|
||||
}
|
||||
|
||||
content := contentText(current["content"])
|
||||
if strings.TrimSpace(content) == "" {
|
||||
return nil
|
||||
}
|
||||
if len(content) > maxAnswerBytes {
|
||||
return fmt.Errorf("%w: final answer exceeds size limit", ErrProtocol)
|
||||
}
|
||||
if stopSeen {
|
||||
s.finalContent = content
|
||||
return nil
|
||||
}
|
||||
s.fallbackContent = content
|
||||
@@ -315,14 +340,20 @@ func (s *streamState) result() (Result, error) {
|
||||
if !s.endSeen {
|
||||
return Result{}, fmt.Errorf("%w: missing end event", ErrProtocol)
|
||||
}
|
||||
if !s.runCompleted {
|
||||
if s.requireRunCompleted && !s.runCompleted {
|
||||
return Result{}, fmt.Errorf("%w: missing successful run.completed event", ErrProtocol)
|
||||
}
|
||||
if !s.requireRunCompleted && !s.finalAIStopSeen {
|
||||
return Result{}, fmt.Errorf("%w: missing AI stop finish reason", ErrProtocol)
|
||||
}
|
||||
if !s.requireRunCompleted && !s.topLevelFinalSeen {
|
||||
return Result{}, fmt.Errorf("%w: missing message.final event", ErrProtocol)
|
||||
}
|
||||
answer := s.finalContent
|
||||
if strings.TrimSpace(answer) == "" {
|
||||
if s.requireRunCompleted && strings.TrimSpace(answer) == "" {
|
||||
answer = s.deltaContent.String()
|
||||
}
|
||||
if strings.TrimSpace(answer) == "" {
|
||||
if s.requireRunCompleted && strings.TrimSpace(answer) == "" {
|
||||
answer = s.fallbackContent
|
||||
s.modelName = s.fallbackModel
|
||||
s.usage = s.fallbackUsage
|
||||
|
||||
@@ -7,7 +7,7 @@ import (
|
||||
)
|
||||
|
||||
func TestStreamStateConsumesCurrentTraceProtocol(t *testing.T) {
|
||||
state := newStreamState("session-1")
|
||||
state := newStreamState("session-1", true)
|
||||
var traces []TraceEvent
|
||||
stream := strings.Join([]string{
|
||||
"event: metadata\nid: 1\ndata: {\"run_id\":\"run-1\",\"resolved_profile_id\":\"profile-1\",\"resolved_profile_version_id\":\"version-1\"}\n",
|
||||
@@ -39,7 +39,7 @@ func TestStreamStateConsumesCurrentTraceProtocol(t *testing.T) {
|
||||
}
|
||||
|
||||
func TestStreamStateConsumesLegacyValuesAndStructuredContent(t *testing.T) {
|
||||
state := newStreamState("session-legacy")
|
||||
state := newStreamState("session-legacy", true)
|
||||
stream := "event: values\n" +
|
||||
"data: {\"messages\":[{\"type\":\"human\",\"content\":\"ignored\"},{\"type\":\"ai\",\"content\":[{\"text\":\"first\"},{\"content\":\"second\"}],\"response_metadata\":{\"finish_reason\":\"stop\",\"model_name\":\"model-1\"},\"usage_metadata\":{\"input_tokens\":2,\"output_tokens\":3,\"total_tokens\":5}}]}\n\n" +
|
||||
"event: trace\ndata: {\"event\":\"run.completed\",\"status\":\"success\"}\n\n" +
|
||||
@@ -60,8 +60,101 @@ func TestStreamStateConsumesLegacyValuesAndStructuredContent(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestStreamStateConsumesNoTraceFinalMessage(t *testing.T) {
|
||||
state := newStreamState("session-no-trace", false)
|
||||
stream := "event: messages\n" +
|
||||
"data: [{\"type\":\"AIMessageChunk\",\"content\":\"\",\"response_metadata\":{\"finish_reason\":\"stop\",\"model_name\":\"model-1\"},\"usage_metadata\":{\"input_tokens\":2,\"output_tokens\":3,\"total_tokens\":5}}]\n\n" +
|
||||
"event: message.final\n" +
|
||||
"data: {\"run_id\":\"run-no-trace\",\"text\":\"No trace answer\"}\n\n" +
|
||||
"event: end\n\n"
|
||||
|
||||
if err := state.consume(strings.NewReader(stream), nil); err != nil {
|
||||
t.Fatalf("consume() error = %v", err)
|
||||
}
|
||||
result, err := state.result()
|
||||
if err != nil {
|
||||
t.Fatalf("result() error = %v", err)
|
||||
}
|
||||
if result.Answer != "No trace answer" || result.ModelName != "model-1" || result.RunID != "run-no-trace" {
|
||||
t.Fatalf("unexpected result: %#v", result)
|
||||
}
|
||||
if result.Usage != (TokenUsage{Input: 2, Output: 3, Total: 5}) {
|
||||
t.Fatalf("Usage = %#v", result.Usage)
|
||||
}
|
||||
}
|
||||
|
||||
func TestStreamStateNoTraceRequiresStopAndEnd(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
stream string
|
||||
wantErr error
|
||||
result bool
|
||||
}{
|
||||
{
|
||||
name: "missing stop",
|
||||
stream: "event: messages\n" +
|
||||
"data: [{\"type\":\"AIMessageChunk\",\"content\":\"partial\",\"response_metadata\":{\"finish_reason\":null}}]\n\n" +
|
||||
"event: message.final\ndata: {\"run_id\":\"run-1\",\"text\":\"complete\"}\n\n" +
|
||||
"event: end\n\n",
|
||||
wantErr: ErrProtocol,
|
||||
result: true,
|
||||
},
|
||||
{
|
||||
name: "missing message final",
|
||||
stream: "event: messages\n" +
|
||||
"data: [{\"type\":\"AIMessageChunk\",\"content\":\"\",\"response_metadata\":{\"finish_reason\":\"stop\"}}]\n\n" +
|
||||
"event: end\n\n",
|
||||
wantErr: ErrProtocol,
|
||||
result: true,
|
||||
},
|
||||
{
|
||||
name: "missing end",
|
||||
stream: "event: messages\n" +
|
||||
"data: [{\"type\":\"AIMessageChunk\",\"content\":\"\",\"response_metadata\":{\"finish_reason\":\"stop\"}}]\n\n" +
|
||||
"event: message.final\ndata: {\"run_id\":\"run-1\",\"text\":\"complete\"}\n\n",
|
||||
wantErr: ErrStreamIncomplete,
|
||||
},
|
||||
{
|
||||
name: "top-level error",
|
||||
stream: "event: error\ndata: {\"code\":\"provider_error\",\"message\":\"do not expose me\"}\n\n",
|
||||
wantErr: ErrRunFailed,
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
state := newStreamState("session-no-trace", false)
|
||||
err := state.consume(strings.NewReader(tt.stream), nil)
|
||||
if tt.result && err == nil {
|
||||
_, err = state.result()
|
||||
}
|
||||
if !errors.Is(err, tt.wantErr) {
|
||||
t.Fatalf("error = %v, want errors.Is(..., %v)", err, tt.wantErr)
|
||||
}
|
||||
if err != nil && strings.Contains(strings.ToLower(err.Error()), "do not expose") {
|
||||
t.Fatalf("error leaked provider message: %v", err)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestStreamStateTraceStillRequiresRunCompleted(t *testing.T) {
|
||||
state := newStreamState("session-trace", true)
|
||||
stream := "event: values\n" +
|
||||
"data: {\"messages\":[{\"type\":\"ai\",\"content\":\"complete\",\"response_metadata\":{\"finish_reason\":\"stop\"}}]}\n\n" +
|
||||
"event: end\n\n"
|
||||
|
||||
if err := state.consume(strings.NewReader(stream), nil); err != nil {
|
||||
t.Fatalf("consume() error = %v", err)
|
||||
}
|
||||
_, err := state.result()
|
||||
if !errors.Is(err, ErrProtocol) || !strings.Contains(err.Error(), "run.completed") {
|
||||
t.Fatalf("result() error = %v, want missing run.completed protocol error", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestStreamStateSupportsMultilineDataAndDeduplicatesIDs(t *testing.T) {
|
||||
state := newStreamState("session-1")
|
||||
state := newStreamState("session-1", true)
|
||||
stream := ": heartbeat\n\n" +
|
||||
"event: trace\nid: delta-1\ndata: {\"event\":\"message.delta\",\ndata: \"text\":\"A\"}\n\n" +
|
||||
"event: trace\nid: delta-1\ndata: {\"event\":\"message.delta\",\"text\":\"duplicate\"}\n\n" +
|
||||
@@ -128,7 +221,7 @@ func TestStreamStateRejectsIncompleteOrFailedStreams(t *testing.T) {
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
state := newStreamState("session-1")
|
||||
state := newStreamState("session-1", true)
|
||||
err := state.consume(strings.NewReader(tt.stream), nil)
|
||||
if tt.result && err == nil {
|
||||
_, err = state.result()
|
||||
@@ -144,7 +237,7 @@ func TestStreamStateRejectsIncompleteOrFailedStreams(t *testing.T) {
|
||||
}
|
||||
|
||||
func TestTraceProjectionRedactsAndBoundsText(t *testing.T) {
|
||||
state := newStreamState("session-1")
|
||||
state := newStreamState("session-1", true)
|
||||
secretText := "token=highly-secret " + strings.Repeat("x", maxTraceText+100)
|
||||
stream := "event: trace\ndata: {\"event\":\"message.final\",\"text\":" + quotedJSON(secretText) + "}\n\n" +
|
||||
"event: trace\ndata: {\"event\":\"run.completed\",\"status\":\"success\"}\n\n" +
|
||||
|
||||
@@ -35,6 +35,7 @@ type Config struct {
|
||||
Enabled bool
|
||||
BaseURL string
|
||||
APIKey string
|
||||
IncludeTrace bool
|
||||
ConnectTimeout time.Duration
|
||||
RecoveryMaxAttempts int
|
||||
RecoveryInitialBackoff time.Duration
|
||||
|
||||
Reference in New Issue
Block a user