diff --git a/AGENTS.md b/AGENTS.md index 4669ee1..b106a11 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -111,7 +111,7 @@ - AgentBus 是消息入口适配器,不是 AI Provider。 - SuperAgent 是外部 AI / Agent 能力提供方,不是业务事实来源。 -- AgentBus 实时链路只落 SourceMessage Inbox,不直接生成 Case、Task、Operation、Receipt 或客户回复。 +- AgentBus 实时入口必须先落 SourceMessage Inbox;如需推送 SuperAgent,只能通过入库后的受控异步 dispatch / outbox 链路完成,不在 WebSocket 回调内直接生成 Case、Task、Operation、Receipt 或客户回复。 - SuperAgent 返回内容只能作为建议、证据或审计结果,不能直接改变最终业务状态。 - 所有业务写操作必须经过规则校验、权限控制、幂等控制和人工确认。 diff --git a/README.md b/README.md index e1b574f..b7e79c1 100644 --- a/README.md +++ b/README.md @@ -121,7 +121,7 @@ GET /api/health - 正文、HTML、原始 payload 和媒体 URL 分表保存。 - 列表和普通详情接口只返回安全摘要。 - `/api/source-messages/{id}/original` 通过受控访问 key 读取原文,并写入访问审计。 -- AgentBus WebSocket 长连接默认关闭,开启后只接收入站 frame 并写入 SourceMessage Inbox。 +- AgentBus WebSocket 长连接默认关闭;开启后先接收入站 frame 并写入 SourceMessage Inbox,M007 可在配置开启时异步创建 SuperAgent dispatch run。 - `/api/system/agentbus-probe` 返回 AgentBus 连接状态和安全计数器,不返回 Token 或原始 frame。 - 缺少外部邮件 ID 的 payload 保存为 `FAILED`,错误摘要不暴露正文或 Secret。 diff --git a/docs/project/README.md b/docs/project/README.md index 56ec6f7..7f9e3fe 100644 --- a/docs/project/README.md +++ b/docs/project/README.md @@ -40,6 +40,7 @@ | `requirements/M004-debug-eml-superagent-upload-v1.md` | 当前有效 | M004 Debug EML 上传 SuperAgent 调试方案。 | | `requirements/M005-hotel-context-unification-plan.md` | 当前有效 | M005 酒店上下文统一收口方案。 | | `requirements/M006-system-admin-management-console-v1.md` | 草案 | M006 系统管理后台方案,覆盖用户、角色、权限、菜单、酒店和用户酒店授权维护。 | +| `requirements/M007-agentbus-superagent-auto-dispatch-v1.md` | 当前有效 | M007 AgentBus 新邮件入库后异步分发 SuperAgent 的后端 V1 方案,当前默认关闭,等待测试机联调。 | ## 集成契约 diff --git a/docs/project/backend-development-guidelines.md b/docs/project/backend-development-guidelines.md index b7c3c7e..221e7a4 100644 --- a/docs/project/backend-development-guidelines.md +++ b/docs/project/backend-development-guidelines.md @@ -177,7 +177,7 @@ groupCode ## 8. AgentBus 边界 - AgentBus 是消息入口适配器,不是 AI Provider。 -- 实时 AgentBus 链路只写 `platform_source_message_inbox`。 +- 实时 AgentBus 入口必须先写 `platform_source_message_inbox`;如需调用 SuperAgent,必须通过入库后的受控异步 dispatch / outbox 链路完成。 - 不直接生成 `platform_message_event`、Evidence、Recognition、Case、Task、Operation、Receipt 或 OHIP 写入。 - 不自动发送 ACK、`task.result` 或客户回复。 - SourceMessage Inbox 到 MessageEvent / Evidence 必须通过受控 replay。 diff --git a/docs/project/frontend-backend/debug-eml-page-integration-guide.md b/docs/project/frontend-backend/debug-eml-page-integration-guide.md index b772a8c..297d5ff 100644 --- a/docs/project/frontend-backend/debug-eml-page-integration-guide.md +++ b/docs/project/frontend-backend/debug-eml-page-integration-guide.md @@ -287,7 +287,7 @@ idle - Debug EML 页面是调试工具,不是 Message Notification 页面。 - Debug EML 页面写入的 SourceMessage `provider=DEBUG_EML_UPLOAD`,用于和 AgentBus 来源区分。 - Debug EML 页面第一版不创建订单和任务,所以上传成功后任务列表和订单列表不会因为这次上传自动新增业务数据。 -- AgentBus 实时收到邮件后自动推 SuperAgent 当前还没做,不能用 Debug EML 页面代表生产实时链路。 +- AgentBus 实时收到邮件后自动推 SuperAgent 已由 M007 后端 V1 实现,但默认关闭且不走 Debug 页面;不能用 Debug EML 页面代表生产实时链路。 ## 11. 当前后置事项 diff --git a/docs/project/go-live-notes.md b/docs/project/go-live-notes.md index d293957..7957404 100644 --- a/docs/project/go-live-notes.md +++ b/docs/project/go-live-notes.md @@ -11,7 +11,7 @@ - `GET /api/source-messages/{id}`:查询单条 SourceMessage 安全摘要。 - `GET /api/source-messages/{id}/original`:受控读取邮件原文、HTML 和媒体 URL,并记录访问审计。 - `GET /api/system/agentbus-probe`:查看 AgentBus WebSocket 连接状态和安全计数器。 -- AgentBus WebSocket 入站链路:默认关闭,开启后只把业务 frame 写入 SourceMessage Inbox。 +- AgentBus WebSocket 入站链路:默认关闭,开启后把业务 frame 写入 SourceMessage Inbox;如开启 M007,则在入库后异步创建 SuperAgent dispatch run。 - SuperAgent 任务结果接收接口:当前代码接收一个外部 `source_message_id` 下的 AI 任务结果,反查 SourceMessage Inbox 后写入 AI 过渡层、订单、任务和任务卡;支持 V3 结构化 `S10/S99` 和业务根基础解析,同时兼容旧 `S000/S999,source_message_id` 文本入口结果并创建只读特殊任务。 - Reservation 任务详情接口:返回任务字段、队列可处理状态和 OPERA 模拟操作摘要。 - Reservation 任务草稿保存和最终确认接口:按任务卡矩阵做第一版后端校验,确认后生成 `confirmed_payload_json`。 @@ -27,6 +27,7 @@ - 真实 AI 识别服务实现、Case 完整模型、Operation、Receipt。 - 自动 ACK、`task.result` 或客户回复。 - 业务前端页面展示邮件原文。 +- AgentBus SourceMessage 入库后自动分发 SuperAgent 虽然后端 V1 已实现,但默认关闭;未经测试机验收前不要在生产启用。 - OHIP / OPERA 或其他业务系统真实写操作。 - 普通任务切换订单接口。 - M002 V3 字段矩阵从当前扁平结构整体迁移到 0711 P0 新结构。 @@ -44,6 +45,7 @@ - 所有 Secret 都通过环境变量、部署平台 Secret 或密钥管理系统注入,不写入仓库、镜像、前端环境变量或普通配置文件。 - 生产默认不保存 AgentBus raw frame 样本。 - AgentBus 实时链路开启前,已经确认 WebSocket URL、Token、Bot Address、外部消息幂等键和断线重连语义。 +- M007 自动分发开启前,必须先确认 SourceMessage 入库稳定、SuperAgent Open API Key 可用、SSE 断流恢复通过测试、dispatch worker 开关和回滚方式明确。 - 源邮件只读通知卡上线前,必须确认 `platform_hotel` 中存在且只存在一家 `ACTIVE` 酒店,并且已有 SourceMessage Inbox 数据的 `hotel_id` 与该酒店一致。旧 `S000/S999` 和新结构化 `S10/S99` 都沿用该酒店解析约束。 - 系统管理后台上线前,必须确认至少存在一个 `ACTIVE` 超级管理员账号,且该账号拥有 `SYSTEM_ADMIN_CONSOLE_ACCESS` 和各系统管理权限。 - 单酒店阶段上线前,必须确认 `platform_hotel` 中只有一家 `ACTIVE` 酒店;新增酒店可以存在但应保持 `DISABLED`。 @@ -112,6 +114,14 @@ | `AGENTBUS_CONNECT_TIMEOUT` | 否 | 连接超时,默认 `15s`。 | | `AGENTBUS_MAX_FRAME_BYTES` | 否 | 单个入站 frame 最大字节数,默认 `1048576`。 | | `AGENTBUS_CAPTURE_ENABLED` | 否 | 是否把业务 frame 写入 SourceMessage Inbox。 | +| `AGENTBUS_SUPERAGENT_DISPATCH_ENABLED` | 否 | AgentBus 新邮件入库后是否创建 SuperAgent dispatch 记录,默认关闭。 | +| `AGENTBUS_SUPERAGENT_DISPATCH_WORKER_ENABLED` | 否 | 是否启动异步 worker 调用 SuperAgent Open API,默认关闭。 | +| `AGENTBUS_SUPERAGENT_DISPATCH_MAX_ATTEMPTS` | 否 | 单条 dispatch 最大尝试次数,建议默认 `3`。 | +| `AGENTBUS_SUPERAGENT_DISPATCH_BATCH_SIZE` | 否 | worker 每轮领取数量,建议默认 `10`。 | +| `AGENTBUS_SUPERAGENT_DISPATCH_LOCK_TTL` | 否 | worker 抢占锁有效期,建议默认 `5m`。 | +| `AGENTBUS_SUPERAGENT_DISPATCH_INITIAL_BACKOFF` | 否 | 首次失败后的重试等待时间,建议默认 `30s`。 | +| `AGENTBUS_SUPERAGENT_DISPATCH_MAX_BACKOFF` | 否 | 最大重试等待时间,建议默认 `15m`。 | +| `AGENTBUS_SUPERAGENT_DISPATCH_WORKER_FIXED_DELAY_MS` | 否 | worker 调度间隔毫秒,建议默认 `10000`。 | 注意: @@ -119,6 +129,7 @@ - `AGENTBUS_CAPTURE_ENABLED=false` 时,业务 frame 会被忽略,不会写入 SourceMessage Inbox。 - `AGENTBUS_DEFAULT_HOTEL_ID` 是 M005 前旧变量,当前后端不再读取;系统酒店来自 `platform_hotel` 唯一 `ACTIVE` 酒店。 - 当前实现不发送 ACK、不发送 `task.result`、不自动回复客户。 +- M007 自动分发即使开启,也只能在 SourceMessage 入库后通过 dispatch / outbox 异步调用 SuperAgent,不能在 AgentBus WebSocket 回调内同步等待外部返回。 ### 3.5 SuperAgent HMAC @@ -158,13 +169,15 @@ | `DEBUG_EML_UPLOAD_PROD_ACCESS_KEY` | 是 | prod Debug EML 上传访问口令;生产通常不应启用该接口。 | | `DEBUG_EML_UPLOAD_MAX_FILE_BYTES` | 否 | `.eml` 上传大小上限,默认 `10485760`。 | | `DEBUG_EML_UPLOAD_SSE_HEARTBEAT_INTERVAL` | 否 | Debug EML 页面到后端的 SSE 心跳间隔,默认 `15s`;测试机如仍遇到空闲断流可调小到 `10s`。 | -| `DEBUG_EML_UPLOAD_SSE_REQUEST_TIMEOUT` | 否 | Spring MVC 异步请求总超时,默认 `1800s`;当前用于保障 Debug EML SSE 不先于 SuperAgent read timeout 关闭。注意 Spring MVC async timeout 是应用级全局设置,后续如增加其他 async/SSE 接口需一起评估。 | +| `DEBUG_EML_UPLOAD_SSE_REQUEST_TIMEOUT` | 否 | Spring MVC 异步请求总超时,默认 `1800s`;用于保障 Debug EML 页面长流程不被后端 MVC 容器提前关闭。注意 Spring MVC async timeout 是应用级全局设置,后续如增加其他 async/SSE 接口需一起评估。 | | `DEERFLOW_DEV_BASE_URL` / `DEERFLOW_TEST_BASE_URL` / `DEERFLOW_PROD_BASE_URL` | 否 | SuperAgent / DeerFlow Open API 基础地址,未配置时可兜底 `DEERFLOW_BASE_URL`。 | | `DEERFLOW_DEV_OPEN_API_KEY` / `DEERFLOW_TEST_OPEN_API_KEY` / `DEERFLOW_PROD_OPEN_API_KEY` | 是 | SuperAgent Open API Key,未配置时可兜底 `DEERFLOW_OPEN_API_KEY`。 | | `SUPERAGENT_DEV_OPEN_API_ENABLED` / `SUPERAGENT_TEST_OPEN_API_ENABLED` / `SUPERAGENT_PROD_OPEN_API_ENABLED` | 否 | 是否启用真实 SuperAgent Open API 调用;prod 默认关闭。 | | `SUPERAGENT_DEBUG_EML_EXTERNAL_SUBJECT_ID` | 否 | Debug EML 创建 SuperAgent session 的 external subject id。 | +| `SUPERAGENT_AGENTBUS_EXTERNAL_SUBJECT_ID` | 否 | AgentBus 自动分发创建 SuperAgent session 的 external subject id。 | | `SUPERAGENT_DEBUG_EML_CONNECT_TIMEOUT` | 否 | SuperAgent Open API 建连超时,默认 `15s`。 | -| `SUPERAGENT_DEBUG_EML_READ_TIMEOUT` | 否 | SuperAgent SSE 读取超时,默认 `180s`。 | +| `SUPERAGENT_DEBUG_EML_READ_TIMEOUT` | 否 | 旧版 RestClient 读取超时兼容变量;当前 JDK SSE 客户端不设置整段 SSE 固定读取超时,断流恢复由 run/events 机制处理。 | +| `SUPERAGENT_OPEN_API_SSE_RECOVERY_MAX_ATTEMPTS` | 否 | SSE EOF 后通过 `/runs/{run_id}/events` 恢复的最大尝试次数,默认 `5`。 | | `ALIYUN_OSS_DEV_ENDPOINT` / `ALIYUN_OSS_TEST_ENDPOINT` / `ALIYUN_OSS_PROD_ENDPOINT` | 否 | 阿里云 OSS Endpoint,未配置时可兜底 `ALIYUN_OSS_ENDPOINT`。 | | `ALIYUN_OSS_DEV_BUCKET` / `ALIYUN_OSS_TEST_BUCKET` / `ALIYUN_OSS_PROD_BUCKET` | 否 | 阿里云 OSS Bucket,未配置时可兜底 `ALIYUN_OSS_BUCKET`。 | | `ALIYUN_OSS_DEV_ACCESS_KEY_ID` / `ALIYUN_OSS_TEST_ACCESS_KEY_ID` / `ALIYUN_OSS_PROD_ACCESS_KEY_ID` | 是 | 阿里云 OSS AccessKey ID,未配置时可兜底 `ALIYUN_OSS_ACCESS_KEY_ID`。 | @@ -181,7 +194,8 @@ - Debug EML 返回 `html_body_sanitized`、`html_sanitize_required`、`html_render_mode`,前端展示 HTML 时应优先使用清洗字段。 - Debug EML 当前会识别 SuperAgent Open API 返回的旧 `S000/S999,source_message_id`,并在 `superagent_parsed_json` 中返回结构化入口结果;这不是 JSON 解析失败。结构化 `S10/S99` 可通过任务结果通知接口入站,Debug EML 页面若要直接展示完整 V3 入口结构,前端展示仍需继续补齐。 - Debug EML 第一版只展示 SuperAgent 结果,不创建订单、不创建任务、不调用任务结果通知接口。 -- AgentBus 实时收到邮件后自动推 SuperAgent 当前未实现,不能把 Debug EML 链路等同于生产实时自动处理链路。 +- AgentBus 实时收到邮件后自动推 SuperAgent 已由 M007 后端 V1 实现,不能把 Debug EML 链路等同于生产实时自动处理链路。 +- Debug EML 和 AgentBus 自动分发复用同一个 SuperAgent Open API SSE 稳定客户端;上线前必须验证 `run.completed + end + final answer` 严格成功条件和 EOF 后 `/events` 恢复。 ## 4. 数据库上线注意事项 @@ -207,6 +221,10 @@ - `server/src/main/resources/db/migration/V8__create_source_message_payload_duplicate.sql` +当前 M007 AgentBus 自动分发 SuperAgent 相关 migration: + +- `server/src/main/resources/db/migration/V20__create_superagent_dispatch_run.sql` + 当前 M003 登录权限相关 migration: - `server/src/main/resources/db/migration/V9__create_identity_access_hotel_menu.sql` @@ -295,8 +313,8 @@ Header: X-TH-Hotel-Access-Scene AgentBus 是消息入口,不是 AI Provider。生产实时链路必须保持以下边界: -- 只写 SourceMessage Inbox。 -- 不自动调用 SuperAgent。 +- WebSocket 回调内只做 SourceMessage Inbox 入库和轻量 dispatch 标记。 +- 如启用 M007,只能在入库后通过受控异步 dispatch / outbox 调用 SuperAgent。 - 不创建 MessageEvent、Evidence、Case、Task、Operation、Receipt。 - 不调用 OHIP、ERP、支付系统等业务写接口。 - 不自动发送 ACK、`task.result` 或客户回复。 @@ -311,11 +329,14 @@ AgentBus 是消息入口,不是 AI Provider。生产实时链路必须保持 5. 观察 `/api/system/agentbus-probe`,确认连接状态、`sessionReady`、计数器和最近错误代码。 6. 用合成测试邮件验证 SourceMessage Inbox 是否写入。 7. 确认日志和监控没有泄露 raw frame、正文或附件 URL。 +8. 如需开启 M007 自动分发,先保持 worker 关闭并确认 dispatch run 能正确创建,再打开 worker 处理少量合成邮件。 +9. 验证 SuperAgent dispatch 成功不会直接创建订单或任务,业务任务仍只来自 SuperAgent 后续任务结果通知或 MCP 提交。 如果出现异常: - 先关闭 `AGENTBUS_PROBE_ENABLED`,停止接收入站 frame。 - 如果只是想暂停入库但保留连接,可关闭 `AGENTBUS_CAPTURE_ENABLED`。 +- 如果只想暂停 M007 自动分发,优先关闭 `AGENTBUS_SUPERAGENT_DISPATCH_WORKER_ENABLED`;如需停止新建 dispatch,再关闭 `AGENTBUS_SUPERAGENT_DISPATCH_ENABLED`。 - 保留状态接口、应用日志和数据库记录用于排查,但不要导出真实邮件正文或附件 URL。 ## 7. 上线后冒烟验证 diff --git a/docs/project/integrations/superagent-agentbus-project-integration-guide.md b/docs/project/integrations/superagent-agentbus-project-integration-guide.md index e5a733a..c0788db 100644 --- a/docs/project/integrations/superagent-agentbus-project-integration-guide.md +++ b/docs/project/integrations/superagent-agentbus-project-integration-guide.md @@ -27,7 +27,7 @@ SuperAgent 和 AgentBus 不应被设计成同一个模块。 AgentBus → 接收 Email / LINE 等外部渠道消息 → 保存 SourceMessage Inbox -→ 受控 Replay 为 MessageEvent / Evidence +→ 受控异步分发 / Replay → 调用 AI 能力端口 → SuperAgent Provider Adapter → 保存 AI Capability Invocation @@ -46,7 +46,7 @@ AgentBus 核心原则: - 前端不直接调用 SuperAgent 或 AgentBus,不接触任何 Provider Secret。 -- AgentBus 实时链路只落来源事实,不直接生成 Case、Task、Operation 或客户回复。 +- AgentBus 实时入口必须先落来源事实;如需推送 SuperAgent,只能通过入库后的受控异步 dispatch / outbox 链路完成,不直接生成 Case、Task、Operation 或客户回复。 - SuperAgent 返回内容只能作为建议、证据或审计结果,不能直接改变业务最终状态。 - 业务写操作必须经过平台规则校验、权限控制、幂等控制和人工确认。 @@ -94,6 +94,8 @@ TH Hotel 当前 M001 相关代码中的可参考文件: | SourceMessage 原文读取审计表 | `server/src/main/resources/db/migration/V2__create_source_message_original_access_audit.sql` | SourceMessage Replay 到 MessageEvent / Evidence 尚未实现,需等 MessageEvent、Evidence 字段模型确认后再进入后续 checkpoint。 +AgentBus SourceMessage 入库后自动分发 SuperAgent 后端 V1 已实现,默认关闭,详见 +`docs/project/requirements/M007-agentbus-superagent-auto-dispatch-v1.md`。 ## 4. SuperAgent 对接 @@ -123,7 +125,7 @@ SUPERAGENT_EXTERNAL_SUBJECT_ID=your-project-superagent-probe | `SUPERAGENT_PROBE_ENABLED` | 否 | 是否开放本项目自己的探针接口。生产默认关闭。 | | `SUPERAGENT_PROBE_ACCESS_KEY` | 是 | 调用探针接口的本地访问密钥,不是 Provider API Key。 | | `SUPERAGENT_CONNECT_TIMEOUT` | 否 | 建立连接超时。 | -| `SUPERAGENT_READ_TIMEOUT` | 否 | SSE 读取超时。 | +| `SUPERAGENT_READ_TIMEOUT` | 否 | 旧版 RestClient SSE 读取超时兼容变量;当前 JDK SSE 客户端不设置整段 SSE 固定读取超时,断流恢复由 run/events 机制处理。 | | `SUPERAGENT_MAX_MESSAGE_CHARS` | 否 | 单次发送给 Provider 的消息长度上限。 | | `SUPERAGENT_EXTERNAL_SUBJECT_ID` | 否 | 创建 Agent Session 时使用的外部主体标识。 | @@ -139,15 +141,16 @@ POST /api/open/agent-sessions → 解析最终 answer、run、profile、model、token usage ``` -状态变更请求需要 CSRF double-submit: +2026-07-12 Open API 文档中,Java SSE 调用使用 `Authorization: Bearer ` 或 +`X-DeerFlow-Open-API-Key` 鉴权,并通过 `X-Request-ID`、`idempotency_key` 和 metadata 做调用关联。 +当前 TH Hotel 后端 `SuperAgentOpenApiClientImpl` 不发送 CSRF double-submit。 ```text -X-CSRF-Token: -Cookie: csrf_token= Authorization: Bearer +X-Request-ID: ``` -CSRF Token 由客户端实例临时生成,不需要写入配置,也不能当作 Secret 长期保存。 +如果 SuperAgent 服务端后续重新要求 CSRF double-submit,应先更新本文和 Open API client,再开启 M007 worker。 ### 4.3 Session 请求示例 @@ -207,6 +210,23 @@ content 非空 如果没有收到 `end`,或无法找到最终 AI 回答,应视为协议失败,不要伪造成成功结果。 +### 4.5.1 2026-07-12 SSE 断流恢复要求 + +2026-07-12 导入的 `docs/import/20260712/OPEN_AGENT_API_JAVA_SSE_CLIENT.md` 已补充 Java 后端调用 +SuperAgent Open API 的稳定性要求。后续 TH Hotel 的共享 SuperAgent Open API client 必须满足: + +- 初始 `messages/stream?include_trace=true` 请求携带稳定 `X-Request-ID`。 +- 同一业务 SourceMessage 的 `idempotency_key` 在所有尝试中保持不变。 +- 初始 POST 成功后保存响应头 `Content-Location`,解析并保存 SuperAgent `run_id`。 +- SSE 必须按帧解析 `event:`、`data:`、`id:` 和 heartbeat comment,并保存 `lastEventId`。 +- 成功条件必须同时满足最终 AI 内容、`run.completed status=success`、顶层 `event: end`,且没有顶层 `error` 或 `run.failed`。 +- EOF、Premature EOF、incomplete chunked response 不能当成功。 +- 如果已有 `run_id`,断流后不得重新 POST 初始消息,应先查询 `GET /runs/{run_id}`,再通过 `GET /runs/{run_id}/events` 携带 `Last-Event-ID` 恢复。 +- 恢复失败应记录为可诊断失败,不返回部分回答。 + +中文说明:该要求同时适用于 Debug EML 和 M007 AgentBus 自动分发链路。实现时应优先改造共享 +SuperAgent Open API client,避免调试链路和生产链路行为分叉。 + ### 4.6 平台能力端口 其他项目建议定义一个稳定端口,例如: @@ -268,6 +288,8 @@ AGENTBUS_SAMPLE_DIR=var/agentbus-samples AGENTBUS_MAX_FRAME_BYTES=1048576 AGENTBUS_MAX_SAMPLES=100 AGENTBUS_CAPTURE_ENABLED=true +AGENTBUS_SUPERAGENT_DISPATCH_ENABLED=false +AGENTBUS_SUPERAGENT_DISPATCH_WORKER_ENABLED=false AGENTBUS_REPLY_MODE=NONE AUTH_DEV_BOOTSTRAP_DEFAULT_HOTEL_ID=HOTEL-DEV SOURCE_MESSAGE_DEV_ORIGINAL_READ_ACCESS_KEY= @@ -289,6 +311,13 @@ SOURCE_MESSAGE_ORIGINAL_READ_ACCESS_KEY= | `AGENTBUS_MAX_FRAME_BYTES` | 否 | 单个入站 frame 最大字节数。 | | `AGENTBUS_MAX_SAMPLES` | 否 | 最多保留的本地样本数。 | | `AGENTBUS_CAPTURE_ENABLED` | 否 | 是否写入 SourceMessage Inbox。 | +| `AGENTBUS_SUPERAGENT_DISPATCH_ENABLED` | 否 | AgentBus 新邮件入库后是否创建 SuperAgent 自动分发记录,默认关闭。 | +| `AGENTBUS_SUPERAGENT_DISPATCH_WORKER_ENABLED` | 否 | 是否启动 SuperAgent 自动分发 worker,默认关闭。 | +| `AGENTBUS_SUPERAGENT_DISPATCH_MAX_ATTEMPTS` | 否 | 单条 dispatch 最大尝试次数,默认 `3`。 | +| `AGENTBUS_SUPERAGENT_DISPATCH_BATCH_SIZE` | 否 | worker 每轮领取数量,默认 `10`。 | +| `AGENTBUS_SUPERAGENT_DISPATCH_LOCK_TTL` | 否 | worker 抢占锁有效期,默认 `5m`。 | +| `SUPERAGENT_AGENTBUS_EXTERNAL_SUBJECT_ID` | 否 | AgentBus 自动分发创建 SuperAgent session 的 external subject id。 | +| `SUPERAGENT_OPEN_API_SSE_RECOVERY_MAX_ATTEMPTS` | 否 | SSE 断流恢复最大次数,默认 `5`。 | | `AUTH_DEV_BOOTSTRAP_DEFAULT_HOTEL_ID` | 否 | dev 初始化平台酒店;M005 后 AgentBus 捕获运行时从 `platform_hotel` 唯一 `ACTIVE` 酒店解析系统酒店,不再依赖 `AGENTBUS_DEFAULT_HOTEL_ID`。 | | `AGENTBUS_REPLY_MODE` | 否 | 调试回复模式。真实客户渠道应保持 `NONE`。 | | `SOURCE_MESSAGE_DEV_ORIGINAL_READ_ACCESS_KEY` | 是 | dev 原文读取接口的临时受控访问 key,后续可替换为正式权限体系。 | @@ -327,6 +356,7 @@ GET /api/system/agentbus-probe → 忽略 session.ready / task.progress / task.result 等控制事件 → 将业务 payload 映射为 CaptureSourceMessageCommand → 写入 SourceMessage Inbox +→ 如 M007 dispatch 配置开启且为新建 RECEIVED Inbox,创建 SuperAgent dispatch run ``` 实时链路禁止: @@ -336,6 +366,7 @@ GET /api/system/agentbus-probe - 自动回复客户。 - 直接创建 MessageEvent、Evidence、AI Recognition、Case、Task、Operation 或 Receipt。 - 直接调用 OHIP、ERP、支付系统等业务写接口。 +- 在 WebSocket 回调事务内同步等待 SuperAgent 返回。 ### 5.4 当前已确认的 Outlook Payload 关键字段 @@ -427,6 +458,31 @@ Replay 负责: Replay 接口默认关闭,仅在本地、UAT 或受控生产运维场景开启。 +### 5.7 AgentBus 入库后自动分发 SuperAgent + +M007 已实现的自动分发链路不是 Debug EML,也不是 SourceMessage Replay。它只负责把 AgentBus 新入库邮件异步交给 SuperAgent Open API: + +```text +AgentBus 新业务 frame +→ SourceMessage Inbox RECEIVED +→ platform_superagent_dispatch_run PENDING +→ worker 调用 SuperAgent Open API +→ 保存 session_id / run_id / raw answer / parsed json / 状态 +→ 等待 SuperAgent 后续通过 task-results 或 MCP 提交业务结果 +``` + +第一版规则: + +- 仅处理 `provider=AGENTBUS` 且 `capture_status=RECEIVED` 的新 SourceMessage。 +- 重复 AgentBus 投递不重复创建 dispatch run。 +- `provider=DEBUG_EML_UPLOAD` 不进入生产 dispatch。 +- `FAILED` SourceMessage 不进入 dispatch。 +- dispatch 成功不代表已经创建订单或任务。 +- 任务创建仍由 SuperAgent 后续调用本系统任务结果通知接口或 MCP 写入工具触发。 + +当前表名为 `platform_superagent_dispatch_run`,详细字段、状态流转、错误分类和验收标准见 +`docs/project/requirements/M007-agentbus-superagent-auto-dispatch-v1.md`。 + ## 6. 其他项目最小落地顺序 ### 阶段 1:SuperAgent 连通性 @@ -481,7 +537,26 @@ AgentBus 入站业务 frame - 重复外部消息不重复入库。 - 查询接口只返回安全摘要。 -### 阶段 4:手动 Replay +### 阶段 4:自动分发 SuperAgent + +目标: + +```text +SourceMessage Inbox +→ SuperAgent dispatch / outbox +→ SuperAgent Open API +→ 保存 dispatch run +``` + +验收: + +- 仅新建 `AGENTBUS` + `RECEIVED` SourceMessage 创建 dispatch。 +- 重复邮件不重复 dispatch。 +- Debug EML 不进入生产 dispatch。 +- SuperAgent SSE 成功条件和断流恢复符合 2026-07-12 新文档。 +- dispatch 成功不直接创建订单或任务。 + +### 阶段 5:手动 Replay 目标: @@ -497,7 +572,7 @@ SourceMessage Inbox - Replay 失败有 attempt 记录。 - 响应不返回客户正文、HTML、附件 URL 或 Token。 -### 阶段 5:业务接入 SuperAgent +### 阶段 6:业务接入 SuperAgent 目标: @@ -534,7 +609,6 @@ MessageEvent - Authorization - Cookie -- CSRF Token - Provider API Key - AgentBus Token - 邮件正文和 HTML @@ -554,13 +628,15 @@ MessageEvent SuperAgent 建议覆盖: - 缺失 API Key 时启动或调用失败。 -- CSRF Header / Cookie 不一致时转换为受控错误。 +- Bearer API Key 缺失、错误或权限不足时转换为受控错误。 - 创建 Session 成功。 - SSE 正常结束并解析最终回答。 - SSE 缺少 `end` 时失败。 - SSE 缺少最终回答时失败。 +- SSE 缺少 `run.completed status=success` 时失败。 +- SSE 断流后携带 `Last-Event-ID` 通过 `/runs/{run_id}/events` 恢复,且不重发初始 POST。 - HTTP 401 / 403 / 404 / 409 / 5xx 错误转换。 -- 连接超时和读取超时。 +- 连接超时,以及 SSE 断流后的 run/events 恢复。 AgentBus 建议覆盖: @@ -604,7 +680,7 @@ SuperAgent 返回的是 Provider 输出。即使未来返回结构化 JSON,也 接入 SuperAgent 前确认: - [ ] 已获得 Open API Key 和允许访问的 Base URL。 -- [ ] 已确认是否需要 CSRF double-submit。 +- [ ] 已确认当前 Open API 鉴权方式;如需 CSRF double-submit,已同步更新后端 client。 - [ ] 已确认 Session、Message、Run 的生命周期。 - [ ] 已确认 SSE 最终答案或结构化结果所在字段。 - [ ] 已定义 `AgentCapabilityPort` 和调用审计表。 diff --git a/docs/project/requirements/M001-source-message-inbox-prd.md b/docs/project/requirements/M001-source-message-inbox-prd.md index d6c6efd..9c88029 100644 --- a/docs/project/requirements/M001-source-message-inbox-prd.md +++ b/docs/project/requirements/M001-source-message-inbox-prd.md @@ -52,7 +52,7 @@ ### 证据 - 已有 AgentBus 对接文档确认 Outlook 邮件 payload 包含 `source.external_message_id`、`source.external_conversation_id`、`body.html`、`body.text`、`inline_images[]`、`attachments[]` 等字段。 -- 已有后端规范要求 AgentBus 实时链路只写 SourceMessage Inbox,不直接生成 MessageEvent、Evidence、Case、Task、Operation 或 Receipt。 +- 已有后端规范要求 AgentBus 实时入口必须先写 SourceMessage Inbox;后续如需调用 SuperAgent,必须通过入库后的受控异步 dispatch / outbox 链路完成,不直接生成 MessageEvent、Evidence、Case、Task、Operation 或 Receipt。 - 🔶 Assumption:业务人员后续会在某些业务功能中查看邮件原文,但具体功能和页面还未确定。 ## 3. Target Users & Personas @@ -238,7 +238,7 @@ SOURCE_MESSAGE_ORIGINAL_READ ### Guardrail Metrics - 列表接口、普通详情接口、日志、错误响应不得暴露完整正文、HTML、附件 URL、Token 或 Secret。 -- AgentBus 实时链路不得直接创建 Case、Task、Operation、Receipt 或客户回复。 +- AgentBus 实时入口不得在 WebSocket 回调内直接创建 Case、Task、Operation、Receipt 或客户回复;M007 SuperAgent 自动分发也必须以 SourceMessage 入库后的异步 dispatch 为边界。 ## 7. User Stories & Requirements diff --git a/docs/project/requirements/M004-debug-eml-superagent-upload-v1.md b/docs/project/requirements/M004-debug-eml-superagent-upload-v1.md index 3d6269d..774edb8 100644 --- a/docs/project/requirements/M004-debug-eml-superagent-upload-v1.md +++ b/docs/project/requirements/M004-debug-eml-superagent-upload-v1.md @@ -12,7 +12,7 @@ ## 1. 文档定位 -本文记录 Debug EML 上传链路的第一版后端设计。该能力用于在不依赖 AgentBus 实时自动处理的情况下, +本文记录 Debug EML 上传链路的第一版后端设计。该能力用于在不依赖 AgentBus 实时生产链路的情况下, 由调试页面上传 `.eml` 邮件文件,后端解析邮件、转存附件和内联图片到本系统阿里云 OSS, 再组装成 SuperAgent 可处理的邮件输入并调用 SuperAgent Open API。 @@ -26,7 +26,7 @@ Inbox 作为来源事实,但不创建订单、不创建任务、不写 AI 任 - 第一版只展示 SuperAgent 生成结果,不落业务订单和任务。 - 上传邮件仍要写入 SourceMessage Inbox。 - Debug 上传来源必须和 AgentBus 来源区分,建议 `provider=DEBUG_EML_UPLOAD`。 -- AgentBus 实时收到邮件后自动推 SuperAgent 当前未实现;现有 AgentBus 实时链路只负责写入 SourceMessage Inbox。 +- AgentBus 实时收到邮件后自动推 SuperAgent 已由 M007 后端 V1 实现;M004 仍只代表人工 Debug 上传链路,不代表生产实时自动处理链路。 - 使用真实阿里云 OSS,不使用本地 mock OSS。 - 原始 `.eml` 文件本身也上传到阿里云 OSS,便于后续调试追溯。 - 内联图片和普通附件都上传到阿里云 OSS。 @@ -82,7 +82,7 @@ Inbox 作为来源事实,但不创建订单、不创建任务、不写 AI 任 - 不做邮件多封批量上传。 - 不做 ZIP、MSG、PDF、图片 OCR 或 Excel 解析。 - 不做 SourceMessage Replay 到 MessageEvent / Evidence。 -- 不做 AgentBus 实时邮件自动调用 SuperAgent;该能力后续需要单独设计触发、幂等、限流和失败补偿。 +- 不做 AgentBus 实时邮件自动调用 SuperAgent;该能力由 M007 单独设计触发、幂等、限流、断流恢复和失败补偿。 - 不做普通任务切换订单。 - 不做 SuperAgent 查询接口 3、4。 - 不接入完整用户 / 权限体系。 diff --git a/docs/project/requirements/M007-agentbus-superagent-auto-dispatch-v1.md b/docs/project/requirements/M007-agentbus-superagent-auto-dispatch-v1.md new file mode 100644 index 0000000..58a686d --- /dev/null +++ b/docs/project/requirements/M007-agentbus-superagent-auto-dispatch-v1.md @@ -0,0 +1,245 @@ +# M007 AgentBus SourceMessage 自动分发 SuperAgent 链路 V1 + +## 文档信息 + +| 项目 | 内容 | +| --- | --- | +| 文档版本 | 1.0 | +| 日期 | 2026-07-12 | +| 状态 | 后端 V1 已实现,等待测试机联调 | +| 适用范围 | AgentBus 实时邮件入库后,异步调用 SuperAgent Open API 的生产链路 | +| 主要读者 | 产品、后端、测试、运维、SuperAgent 对接方、后续协作 agent | + +## 1. 背景和目标 + +M001 已完成 AgentBus 邮件来源事实入库,M004 已完成 Debug EML 人工上传并调用 SuperAgent 的调试链路。当前缺口是:真实 AgentBus WebSocket 收到邮件并写入 SourceMessage Inbox 后,还没有生产链路自动把该邮件推送给 SuperAgent。 + +本 checkpoint 目标是补齐这条链路: + +```text +AgentBus WebSocket 收到邮件 +→ 写入 SourceMessage Inbox +→ 创建 SuperAgent dispatch / outbox 记录 +→ 异步 worker 调用 SuperAgent Open API +→ 保存 session_id / run_id / raw_answer / parsed_json / 状态 +→ 等待 SuperAgent 通过现有任务结果通知接口或 MCP 提交业务结果 +``` + +中文说明:AgentBus 仍然是消息入口,SuperAgent 仍然是外部 AI / Agent 能力提供方。自动分发只负责把已入库 SourceMessage 交给 SuperAgent,不直接创建订单、任务、OPERA 操作或客户回复。 + +## 2. 设计原则 + +- AgentBus WebSocket 回调内只做快速入库和轻量投递标记,不同步等待 SuperAgent。 +- SourceMessage 捕获事务和 SuperAgent 外部调用事务分离,避免外部慢请求影响邮件入库。 +- 使用 outbox / dispatch run 表记录状态、重试、幂等键和 SuperAgent run 信息。 +- Debug EML 链路和生产自动分发链路复用 SuperAgent Open API client,但运行记录表和 provider 必须区分。 +- SuperAgent 返回内容只能保存为外部能力输出,不直接改变业务最终状态。 +- 业务任务创建继续依赖 SuperAgent 后续调用本系统任务结果通知接口或 MCP 工具。 + +## 3. 触发规则 + +第一版只对以下 SourceMessage 创建自动分发记录: + +| 条件 | 规则 | +| --- | --- | +| 来源 provider | 必须是 `AGENTBUS` | +| 捕获状态 | 必须是 `RECEIVED` | +| 幂等结果 | 仅 `SourceMessageCaptureResult.newlyCreated=true` 时触发 | +| 重复投递 | 不重复创建 dispatch;如 payload changed,只记录 SourceMessage 重复诊断 | +| Debug EML | 不参与生产自动分发 | +| FAILED Inbox | 不分发,保留入库失败原因供排查 | + +中文说明:后续如果需要对历史 SourceMessage 补发 SuperAgent,应单独设计人工 replay / backfill 接口,不在本 checkpoint 内隐式补发。 + +## 4. SuperAgent Open API 稳定性要求 + +2026-07-12 导入的 `OPEN_AGENT_API_JAVA_SSE_CLIENT.md` 对 Java SSE 调用提出新的强约束。本项目后续 Open API client 必须满足: + +- 请求 `messages/stream?include_trace=true` 时生成稳定 `X-Request-ID`。 +- 同一业务 SourceMessage 的 `idempotency_key` 在所有尝试中保持不变。 +- 初始 POST 成功后保存响应头 `Content-Location`,解析并保存 SuperAgent `run_id`。 +- SSE 必须按帧解析 `event:`、`data:`、`id:` 和 heartbeat comment。 +- 成功条件必须同时满足:最终 AI 内容、`run.completed status=success`、顶层 `event: end`,且没有顶层 `error` 或 `run.failed`。 +- EOF、Premature EOF、incomplete chunked response 不能当成功。 +- 断流后如果已有 `run_id`,不得重新 POST 初始消息;应先查询 `GET /runs/{run_id}`,再通过 `GET /runs/{run_id}/events` 携带 `Last-Event-ID` 恢复。 +- 恢复失败应记录为可诊断失败,不返回部分回答。 + +中文说明:这条规则同时适用于 Debug EML 和 AgentBus 自动分发。实现时应优先改造共享 SuperAgent Open API client,避免两条链路行为不一致。 + +## 5. 数据模型建议 + +新增表建议命名为 `platform_superagent_dispatch_run`,作为 SourceMessage 到 SuperAgent Open API 的生产分发运行记录。 + +核心字段建议: + +| 字段 | 中文说明 | +| --- | --- | +| `id` | 内部主键 ID | +| `hotel_id` | SourceMessage 所属酒店 ID | +| `source_message_id` | 本系统内部 SourceMessage Inbox ID | +| `source_provider` | 来源 provider,第一版主要为 `AGENTBUS` | +| `source_channel` | 来源 channel,例如 `OUTLOOK` 或 `EMAIL` | +| `external_message_id` | AgentBus 邮件外部消息 ID | +| `external_conversation_id` | AgentBus 邮件会话 ID | +| `dispatch_source` | 分发来源代码,第一版为 `AGENTBUS_REALTIME` | +| `idempotency_key` | 发送给 SuperAgent 的业务幂等键,同一 SourceMessage 固定 | +| `request_id` | 本系统生成的调用关联 ID,同时写入 `X-Request-ID` | +| `dispatch_status` | `PENDING`、`RUNNING`、`SUCCEEDED`、`RETRYABLE_FAILED`、`FAILED`、`IGNORED` | +| `attempt_count` | 已尝试次数 | +| `max_attempts` | 最大尝试次数 | +| `next_attempt_at` | 下次可重试 UTC 时间 | +| `locked_until` | worker 抢占锁过期 UTC 时间 | +| `superagent_session_id` | SuperAgent Open API session ID | +| `superagent_run_id` | SuperAgent run ID | +| `superagent_run_uri` | SuperAgent run 查询路径或 URI | +| `last_event_id` | 最后完整处理的 SSE event id | +| `superagent_raw_answer` | SuperAgent 最终原始回答 | +| `superagent_parsed_json` | 能解析为 JSON 时保存的结构化结果 | +| `superagent_trace_json` | 可安全保存的公开 trace 摘要 | +| `safe_error_code` | 安全错误代码 | +| `safe_error_summary` | 安全错误摘要,不包含正文、HTML、附件 URL 或 Secret | +| `created_at` / `updated_at` | 记录创建和更新时间,UTC | + +约束建议: + +```text +UNIQUE KEY uk_dispatch_source_message (source_message_id, dispatch_source) +KEY idx_dispatch_status_next_attempt (dispatch_status, next_attempt_at) +KEY idx_dispatch_external_message (hotel_id, external_message_id) +``` + +中文说明:`source_message_id + dispatch_source` 是本系统内的分发幂等边界。外部 `idempotency_key` 是给 SuperAgent 的幂等边界,两者都需要稳定。 + +## 6. 配置建议 + +第一版新增配置建议: + +| 配置 | 默认值 | 中文说明 | +| --- | --- | --- | +| `agentbus.superagent-dispatch.enabled` | `false` | 是否在 AgentBus 新邮件入库后创建自动分发记录 | +| `agentbus.superagent-dispatch.worker-enabled` | `false` | 是否启动异步 worker 处理 dispatch | +| `agentbus.superagent-dispatch.max-attempts` | `3` | 单条 dispatch 最大尝试次数 | +| `agentbus.superagent-dispatch.batch-size` | `10` | worker 每轮领取数量 | +| `agentbus.superagent-dispatch.lock-ttl` | `5m` | worker 处理锁有效期 | +| `agentbus.superagent-dispatch.initial-backoff` | `30s` | 首次失败后的重试等待时间 | +| `agentbus.superagent-dispatch.max-backoff` | `15m` | 最大重试等待时间 | +| `superagent.open-api.agentbus-external-subject-id` | `th-hotel-agentbus-source-message` | AgentBus 自动分发创建 session 时使用的 external subject id | +| `superagent.open-api.sse-recovery-max-attempts` | `5` | SSE 断流恢复最大次数 | + +中文说明:生产首次上线建议先开启 AgentBus 入库,确认稳定后再单独开启自动分发和 worker。Debug EML 使用的访问 key 和生产自动分发无关。 + +## 7. 消息组装 + +AgentBus 自动分发发送给 SuperAgent 的 message 第一版应基于 SourceMessage 原始 payload 构造,保持和 Debug EML 的 AgentBus-like payload 语义一致。 + +发送 metadata 建议包含: + +```json +{ + "source": "th-hotel-agentbus-realtime", + "dispatch_run_id": "内部 dispatch run ID", + "source_message_id": "内部 SourceMessage Inbox ID", + "external_message_id": "AgentBus external_message_id", + "external_conversation_id": "AgentBus external_conversation_id", + "hotel_id": "系统酒店 ID" +} +``` + +注意:metadata 里的内部 SourceMessage ID 只用于本系统排查;SuperAgent 后续任务结果通知中的 `source_message_id` 仍应使用 AgentBus `source.external_message_id`。 + +## 8. 状态流转 + +```text +PENDING / RETRYABLE_FAILED +→ RUNNING +→ SUCCEEDED + +RUNNING +→ RETRYABLE_FAILED +→ RUNNING + +RUNNING / RETRYABLE_FAILED +→ FAILED + +任意未启用或不应处理场景 +→ IGNORED +``` + +失败分类建议: + +| 当前错误代码 | 中文说明 | 是否可重试 | +| --- | --- | --- | +| `SUPERAGENT_DISPATCH_FAILED` | SuperAgent Open API 调用失败,具体 HTTP / SSE / run 失败原因写入安全摘要 | 是,直到超过 max attempts | +| `SOURCE_MESSAGE_PAYLOAD_NOT_FOUND` | SourceMessage 原始 payload 缺失 | 是,直到超过 max attempts;通常需要人工排查数据 | +| `SUPERAGENT_DISPATCH_UNEXPECTED` | dispatch worker 内部非预期错误 | 是,直到超过 max attempts | + +中文说明:如果已经获得 `superagent_run_id`,后续恢复不得重新发送初始 POST,避免同一业务消息被执行两次。 + +## 9. 非目标范围 + +本 checkpoint 不做: + +- 不创建 Reservation 订单。 +- 不创建 Reservation 任务。 +- 不调用 OPERA / OHIP。 +- 不自动 ACK AgentBus。 +- 不自动发送 `task.result`。 +- 不自动回复客户。 +- 不做历史 SourceMessage 批量补发。 +- 不做前端页面。 +- 不改变 SuperAgent 任务结果通知接口的业务处理规则。 + +## 10. 验收标准 + +- AgentBus 新邮件入库成功后,在开启配置时创建一条 dispatch run。 +- 重复 AgentBus 邮件投递不会创建第二条 dispatch run。 +- Debug EML 不会进入生产 dispatch run。 +- `FAILED` SourceMessage 不会触发 dispatch。 +- worker 能领取 `PENDING` 记录并调用 SuperAgent Open API。 +- SuperAgent 调用成功时保存 session、run、raw answer、parsed json、trace 摘要和 `SUCCEEDED` 状态。 +- SuperAgent SSE 缺少 `end`、缺少最终内容或 `run.completed` 时不能成功。 +- SSE EOF 后如果已有 `run_id`,使用 `/events` 恢复,不重发初始 POST。 +- 恢复耗尽后记录安全错误摘要和可重试状态。 +- 任务创建仍只由 SuperAgent 后续任务结果通知接口或 MCP 提交触发。 +- 测试覆盖 AgentBus 触发、幂等、失败、不处理 Debug EML、SSE 严格成功条件和断流恢复。 + +## 11. 已实现代码范围 + +本次后端 V1 已落地: + +- 新增 `platform_superagent_dispatch_run` 表和 `V20__create_superagent_dispatch_run.sql`。 +- 新增 `SuperAgentDispatchRunEntity`、Mapper、Repository、Service 和配置类。 +- AgentBus 捕获 `AGENTBUS + RECEIVED + newlyCreated=true` 后,在配置开启时创建 dispatch run。 +- worker 处理 `PENDING / RETRYABLE_FAILED`,调用共享 SuperAgent Open API client。 +- Open API client 支持 `Content-Location`、`run_id`、SSE `id`、`Last-Event-ID`、`GET /runs/{run_id}` 和 `GET /runs/{run_id}/events` 恢复。 +- Debug EML 继续复用共享 Open API client。 +- dispatch 成功保存 session、run、last event id、raw answer、parsed json、trace 摘要和状态。 + +## 12. 后续目标模式建议 + +```text +进入目标模式,目标:实现 M007 AgentBus SourceMessage 自动分发 SuperAgent 链路 V1。 + +范围: +1. 按 docs/project/requirements/M007-agentbus-superagent-auto-dispatch-v1.md 实现 AgentBus 入库后的异步 SuperAgent dispatch / outbox。 +2. 新增 platform_superagent_dispatch_run 表、Entity、Mapper、Repository、Service、ServiceImpl 和必要枚举 / DTO。 +3. AgentBus 捕获新 SourceMessage 成功后,在配置开启时创建 dispatch run;重复投递不重复创建。 +4. 新增 worker 处理 PENDING / RETRYABLE_FAILED dispatch,调用 SuperAgent Open API。 +5. 改造 SuperAgent Open API client,满足 20260712 SSE 稳定性要求:严格成功条件、Content-Location/run_id、SSE id、Last-Event-ID、/runs 查询和 /events 恢复,EOF 后不重发初始 POST。 +6. Debug EML 继续复用改造后的 SuperAgent Open API client,行为不回退。 +7. 保存 SuperAgent session_id、run_id、last_event_id、raw answer、parsed json、trace 摘要、状态和安全错误摘要。 +8. 更新集成文档、上线注意事项和配置说明。 +9. 补充测试,覆盖触发、幂等、失败、Debug EML 不触发、SSE 严格成功条件和断流恢复。 + +不做: +1. 不创建订单。 +2. 不创建任务。 +3. 不调用 OPERA / OHIP。 +4. 不自动 ACK AgentBus。 +5. 不自动发送 task.result 或客户回复。 +6. 不做历史 SourceMessage 批量补发。 +7. 不做前端页面。 + +完成后: +code review,运行测试,中文提交。 +``` diff --git a/server/src/main/java/cn/nianxx/thhotel/ThHotelApplication.java b/server/src/main/java/cn/nianxx/thhotel/ThHotelApplication.java index 57a2f67..37372c2 100644 --- a/server/src/main/java/cn/nianxx/thhotel/ThHotelApplication.java +++ b/server/src/main/java/cn/nianxx/thhotel/ThHotelApplication.java @@ -3,6 +3,7 @@ package cn.nianxx.thhotel; import org.mybatis.spring.annotation.MapperScan; import org.springframework.boot.SpringApplication; import org.springframework.boot.autoconfigure.SpringBootApplication; +import org.springframework.scheduling.annotation.EnableScheduling; /** * TH Hotel 后端应用入口。 @@ -18,6 +19,7 @@ import org.springframework.boot.autoconfigure.SpringBootApplication; "cn.nianxx.thhotel.workflows.reservation.mapper", "cn.nianxx.thhotel.integrations.ai.superagent.mapper" }) +@EnableScheduling @SpringBootApplication public class ThHotelApplication { diff --git a/server/src/main/java/cn/nianxx/thhotel/integrations/ai/superagent/common/dto/SuperAgentDispatchRunDraft.java b/server/src/main/java/cn/nianxx/thhotel/integrations/ai/superagent/common/dto/SuperAgentDispatchRunDraft.java new file mode 100644 index 0000000..d16a6bf --- /dev/null +++ b/server/src/main/java/cn/nianxx/thhotel/integrations/ai/superagent/common/dto/SuperAgentDispatchRunDraft.java @@ -0,0 +1,37 @@ +package cn.nianxx.thhotel.integrations.ai.superagent.common.dto; + +import java.time.LocalDateTime; + +/** + * SuperAgent dispatch 待入库草稿。由 Service 生成,Repository 负责转换为数据库 Entity。 + * + * @param hotelId 酒店 ID + * @param sourceMessageId 内部 SourceMessage Inbox ID + * @param sourceProvider 来源 provider + * @param sourceChannel 来源 channel + * @param externalMessageId 外部邮件 ID + * @param externalConversationId 外部邮件会话 ID + * @param dispatchSource 分发来源代码 + * @param idempotencyKey SuperAgent 消息幂等键 + * @param requestId X-Request-ID 调用关联 ID + * @param dispatchStatus 初始分发状态 + * @param maxAttempts 最大尝试次数 + * @param nextAttemptAt 下次可处理 UTC 时间 + * @param createdAt 创建 UTC 时间 + */ +public record SuperAgentDispatchRunDraft( + String hotelId, + Long sourceMessageId, + String sourceProvider, + String sourceChannel, + String externalMessageId, + String externalConversationId, + String dispatchSource, + String idempotencyKey, + String requestId, + String dispatchStatus, + int maxAttempts, + LocalDateTime nextAttemptAt, + LocalDateTime createdAt +) { +} diff --git a/server/src/main/java/cn/nianxx/thhotel/integrations/ai/superagent/common/dto/SuperAgentDispatchRunFailureUpdate.java b/server/src/main/java/cn/nianxx/thhotel/integrations/ai/superagent/common/dto/SuperAgentDispatchRunFailureUpdate.java new file mode 100644 index 0000000..8436b58 --- /dev/null +++ b/server/src/main/java/cn/nianxx/thhotel/integrations/ai/superagent/common/dto/SuperAgentDispatchRunFailureUpdate.java @@ -0,0 +1,16 @@ +package cn.nianxx.thhotel.integrations.ai.superagent.common.dto; + +import java.time.LocalDateTime; + +/** + * SuperAgent dispatch 失败更新对象。错误摘要必须是安全文本,不包含邮件正文或 Secret。 + */ +public record SuperAgentDispatchRunFailureUpdate( + Long id, + String dispatchStatus, + String safeErrorCode, + String safeErrorSummary, + LocalDateTime nextAttemptAt, + LocalDateTime updatedAt +) { +} diff --git a/server/src/main/java/cn/nianxx/thhotel/integrations/ai/superagent/common/dto/SuperAgentDispatchRunSnapshot.java b/server/src/main/java/cn/nianxx/thhotel/integrations/ai/superagent/common/dto/SuperAgentDispatchRunSnapshot.java new file mode 100644 index 0000000..d88cd46 --- /dev/null +++ b/server/src/main/java/cn/nianxx/thhotel/integrations/ai/superagent/common/dto/SuperAgentDispatchRunSnapshot.java @@ -0,0 +1,36 @@ +package cn.nianxx.thhotel.integrations.ai.superagent.common.dto; + +import java.time.LocalDateTime; + +/** + * SuperAgent dispatch 持久化快照。Service 使用该对象处理 worker 调度,不直接接触 Entity。 + */ +public record SuperAgentDispatchRunSnapshot( + Long id, + String hotelId, + Long sourceMessageId, + String sourceProvider, + String sourceChannel, + String externalMessageId, + String externalConversationId, + String dispatchSource, + String idempotencyKey, + String requestId, + String dispatchStatus, + int attemptCount, + int maxAttempts, + LocalDateTime nextAttemptAt, + LocalDateTime lockedUntil, + String superagentSessionId, + String superagentRunId, + String superagentRunUri, + String lastEventId, + String superagentRawAnswer, + String superagentParsedJson, + String superagentTraceJson, + String safeErrorCode, + String safeErrorSummary, + LocalDateTime createdAt, + LocalDateTime updatedAt +) { +} diff --git a/server/src/main/java/cn/nianxx/thhotel/integrations/ai/superagent/common/dto/SuperAgentDispatchRunSuccessUpdate.java b/server/src/main/java/cn/nianxx/thhotel/integrations/ai/superagent/common/dto/SuperAgentDispatchRunSuccessUpdate.java new file mode 100644 index 0000000..e039038 --- /dev/null +++ b/server/src/main/java/cn/nianxx/thhotel/integrations/ai/superagent/common/dto/SuperAgentDispatchRunSuccessUpdate.java @@ -0,0 +1,19 @@ +package cn.nianxx.thhotel.integrations.ai.superagent.common.dto; + +import java.time.LocalDateTime; + +/** + * SuperAgent dispatch 成功更新对象。只保存最终结果和安全元数据。 + */ +public record SuperAgentDispatchRunSuccessUpdate( + Long id, + String superagentSessionId, + String superagentRunId, + String superagentRunUri, + String lastEventId, + String superagentRawAnswer, + String superagentParsedJson, + String superagentTraceJson, + LocalDateTime updatedAt +) { +} diff --git a/server/src/main/java/cn/nianxx/thhotel/integrations/ai/superagent/common/enums/SuperAgentDispatchSource.java b/server/src/main/java/cn/nianxx/thhotel/integrations/ai/superagent/common/enums/SuperAgentDispatchSource.java new file mode 100644 index 0000000..da35c36 --- /dev/null +++ b/server/src/main/java/cn/nianxx/thhotel/integrations/ai/superagent/common/enums/SuperAgentDispatchSource.java @@ -0,0 +1,20 @@ +package cn.nianxx.thhotel.integrations.ai.superagent.common.enums; + +/** + * SuperAgent 自动分发来源稳定代码。 + */ +public enum SuperAgentDispatchSource { + + /** AgentBus 实时新邮件入库后触发的自动分发。 */ + AGENTBUS_REALTIME("AGENTBUS_REALTIME"); + + private final String code; + + SuperAgentDispatchSource(String code) { + this.code = code; + } + + public String code() { + return code; + } +} diff --git a/server/src/main/java/cn/nianxx/thhotel/integrations/ai/superagent/common/enums/SuperAgentDispatchStatus.java b/server/src/main/java/cn/nianxx/thhotel/integrations/ai/superagent/common/enums/SuperAgentDispatchStatus.java new file mode 100644 index 0000000..c52202e --- /dev/null +++ b/server/src/main/java/cn/nianxx/thhotel/integrations/ai/superagent/common/enums/SuperAgentDispatchStatus.java @@ -0,0 +1,30 @@ +package cn.nianxx.thhotel.integrations.ai.superagent.common.enums; + +/** + * SuperAgent 自动分发运行状态稳定代码。 + */ +public enum SuperAgentDispatchStatus { + + /** 等待 worker 处理。 */ + PENDING("PENDING"), + /** worker 已领取并正在处理。 */ + RUNNING("RUNNING"), + /** SuperAgent 已返回严格成功结果。 */ + SUCCEEDED("SUCCEEDED"), + /** 可重试失败,等待下次调度。 */ + RETRYABLE_FAILED("RETRYABLE_FAILED"), + /** 不再重试的失败。 */ + FAILED("FAILED"), + /** 按规则忽略。 */ + IGNORED("IGNORED"); + + private final String code; + + SuperAgentDispatchStatus(String code) { + this.code = code; + } + + public String code() { + return code; + } +} diff --git a/server/src/main/java/cn/nianxx/thhotel/integrations/ai/superagent/common/request/SuperAgentOpenApiMessageRequest.java b/server/src/main/java/cn/nianxx/thhotel/integrations/ai/superagent/common/request/SuperAgentOpenApiMessageRequest.java new file mode 100644 index 0000000..0c193e3 --- /dev/null +++ b/server/src/main/java/cn/nianxx/thhotel/integrations/ai/superagent/common/request/SuperAgentOpenApiMessageRequest.java @@ -0,0 +1,21 @@ +package cn.nianxx.thhotel.integrations.ai.superagent.common.request; + +import java.util.Map; + +/** + * SuperAgent Open API 通用消息调用请求。用于 Debug EML 和 AgentBus 自动分发共享同一调用客户端。 + * + * @param message 要发送给 SuperAgent 的消息文本 + * @param idempotencyKey 同一业务消息稳定不变的 SuperAgent 幂等键 + * @param externalSubjectId 创建 SuperAgent session 时使用的外部主体 ID + * @param requestId 写入 X-Request-ID 的调用关联 ID + * @param metadata 调用元数据,不包含 Secret + */ +public record SuperAgentOpenApiMessageRequest( + String message, + String idempotencyKey, + String externalSubjectId, + String requestId, + Map metadata +) { +} diff --git a/server/src/main/java/cn/nianxx/thhotel/integrations/ai/superagent/common/result/SuperAgentOpenApiResult.java b/server/src/main/java/cn/nianxx/thhotel/integrations/ai/superagent/common/result/SuperAgentOpenApiResult.java index 8e24609..b09b799 100644 --- a/server/src/main/java/cn/nianxx/thhotel/integrations/ai/superagent/common/result/SuperAgentOpenApiResult.java +++ b/server/src/main/java/cn/nianxx/thhotel/integrations/ai/superagent/common/result/SuperAgentOpenApiResult.java @@ -17,6 +17,8 @@ import java.util.List; * @param totalTokens 总 token 数 * @param eventTypes SSE 事件类型列表 * @param traceEvents SuperAgent 公开 Trace 事件列表,只包含可展示摘要字段 + * @param runUri SuperAgent run 查询 URI 或路径,用于断流恢复和排查 + * @param lastEventId 最后处理的 SSE event id,用于 Last-Event-ID 恢复 */ public record SuperAgentOpenApiResult( String sessionId, @@ -30,9 +32,42 @@ public record SuperAgentOpenApiResult( Integer totalTokens, List eventTypes, @JsonProperty("trace_events") - List traceEvents + List traceEvents, + String runUri, + String lastEventId ) { + /** + * 兼容旧调用方,默认没有 runUri 和 lastEventId。 + */ + public SuperAgentOpenApiResult( + String sessionId, + String runId, + String profileId, + String profileVersionId, + String modelName, + String rawAnswer, + Integer inputTokens, + Integer outputTokens, + Integer totalTokens, + List eventTypes, + List traceEvents) { + this( + sessionId, + runId, + profileId, + profileVersionId, + modelName, + rawAnswer, + inputTokens, + outputTokens, + totalTokens, + eventTypes, + traceEvents, + null, + null); + } + /** * 兼容未启用 Trace 的历史调用方。 */ @@ -58,7 +93,9 @@ public record SuperAgentOpenApiResult( outputTokens, totalTokens, eventTypes, - List.of()); + List.of(), + null, + null); } /** diff --git a/server/src/main/java/cn/nianxx/thhotel/integrations/ai/superagent/domain/SuperAgentDispatchRunEntity.java b/server/src/main/java/cn/nianxx/thhotel/integrations/ai/superagent/domain/SuperAgentDispatchRunEntity.java new file mode 100644 index 0000000..ff08831 --- /dev/null +++ b/server/src/main/java/cn/nianxx/thhotel/integrations/ai/superagent/domain/SuperAgentDispatchRunEntity.java @@ -0,0 +1,275 @@ +package cn.nianxx.thhotel.integrations.ai.superagent.domain; + +import com.baomidou.mybatisplus.annotation.IdType; +import com.baomidou.mybatisplus.annotation.TableId; +import com.baomidou.mybatisplus.annotation.TableName; +import java.time.LocalDateTime; + +/** + * SuperAgent 自动分发运行实体。记录 SourceMessage 入库后调用 SuperAgent Open API 的状态和结果。 + */ +@TableName("platform_superagent_dispatch_run") +public class SuperAgentDispatchRunEntity { + + /** 内部主键 ID。 */ + @TableId(type = IdType.ASSIGN_ID) + private Long id; + /** SourceMessage 所属酒店 ID。 */ + private String hotelId; + /** 内部 SourceMessage Inbox ID。 */ + private Long sourceMessageId; + /** 来源 provider,例如 AGENTBUS。 */ + private String sourceProvider; + /** 来源 channel,例如 OUTLOOK 或 EMAIL。 */ + private String sourceChannel; + /** 外部邮件消息 ID。 */ + private String externalMessageId; + /** 外部邮件会话 ID。 */ + private String externalConversationId; + /** 分发来源代码,例如 AGENTBUS_REALTIME。 */ + private String dispatchSource; + /** 发送给 SuperAgent 的稳定幂等键。 */ + private String idempotencyKey; + /** 写入 X-Request-ID 的调用关联 ID。 */ + private String requestId; + /** 分发状态:PENDING、RUNNING、SUCCEEDED、RETRYABLE_FAILED、FAILED、IGNORED。 */ + private String dispatchStatus; + /** 已尝试次数。 */ + private Integer attemptCount; + /** 最大尝试次数。 */ + private Integer maxAttempts; + /** 下次可处理 UTC 时间。 */ + private LocalDateTime nextAttemptAt; + /** worker 抢占锁过期 UTC 时间。 */ + private LocalDateTime lockedUntil; + /** SuperAgent Open API session ID。 */ + private String superagentSessionId; + /** SuperAgent run ID。 */ + private String superagentRunId; + /** SuperAgent run 查询 URI 或路径。 */ + private String superagentRunUri; + /** 最后完整处理的 SSE event id。 */ + private String lastEventId; + /** SuperAgent 最终原始回答。 */ + private String superagentRawAnswer; + /** 从最终回答解析出的 JSON,无法解析时为空。 */ + private String superagentParsedJson; + /** SuperAgent 公开 trace 摘要 JSON,不包含敏感工具细节。 */ + private String superagentTraceJson; + /** 安全错误代码。 */ + private String safeErrorCode; + /** 安全错误摘要,不包含正文、HTML、附件 URL 或 Secret。 */ + private String safeErrorSummary; + /** 记录创建 UTC 时间。 */ + private LocalDateTime createdAt; + /** 记录更新 UTC 时间。 */ + private LocalDateTime updatedAt; + + public Long getId() { + return id; + } + + public void setId(Long id) { + this.id = id; + } + + public String getHotelId() { + return hotelId; + } + + public void setHotelId(String hotelId) { + this.hotelId = hotelId; + } + + public Long getSourceMessageId() { + return sourceMessageId; + } + + public void setSourceMessageId(Long sourceMessageId) { + this.sourceMessageId = sourceMessageId; + } + + public String getSourceProvider() { + return sourceProvider; + } + + public void setSourceProvider(String sourceProvider) { + this.sourceProvider = sourceProvider; + } + + public String getSourceChannel() { + return sourceChannel; + } + + public void setSourceChannel(String sourceChannel) { + this.sourceChannel = sourceChannel; + } + + public String getExternalMessageId() { + return externalMessageId; + } + + public void setExternalMessageId(String externalMessageId) { + this.externalMessageId = externalMessageId; + } + + public String getExternalConversationId() { + return externalConversationId; + } + + public void setExternalConversationId(String externalConversationId) { + this.externalConversationId = externalConversationId; + } + + public String getDispatchSource() { + return dispatchSource; + } + + public void setDispatchSource(String dispatchSource) { + this.dispatchSource = dispatchSource; + } + + public String getIdempotencyKey() { + return idempotencyKey; + } + + public void setIdempotencyKey(String idempotencyKey) { + this.idempotencyKey = idempotencyKey; + } + + public String getRequestId() { + return requestId; + } + + public void setRequestId(String requestId) { + this.requestId = requestId; + } + + public String getDispatchStatus() { + return dispatchStatus; + } + + public void setDispatchStatus(String dispatchStatus) { + this.dispatchStatus = dispatchStatus; + } + + public Integer getAttemptCount() { + return attemptCount; + } + + public void setAttemptCount(Integer attemptCount) { + this.attemptCount = attemptCount; + } + + public Integer getMaxAttempts() { + return maxAttempts; + } + + public void setMaxAttempts(Integer maxAttempts) { + this.maxAttempts = maxAttempts; + } + + public LocalDateTime getNextAttemptAt() { + return nextAttemptAt; + } + + public void setNextAttemptAt(LocalDateTime nextAttemptAt) { + this.nextAttemptAt = nextAttemptAt; + } + + public LocalDateTime getLockedUntil() { + return lockedUntil; + } + + public void setLockedUntil(LocalDateTime lockedUntil) { + this.lockedUntil = lockedUntil; + } + + public String getSuperagentSessionId() { + return superagentSessionId; + } + + public void setSuperagentSessionId(String superagentSessionId) { + this.superagentSessionId = superagentSessionId; + } + + public String getSuperagentRunId() { + return superagentRunId; + } + + public void setSuperagentRunId(String superagentRunId) { + this.superagentRunId = superagentRunId; + } + + public String getSuperagentRunUri() { + return superagentRunUri; + } + + public void setSuperagentRunUri(String superagentRunUri) { + this.superagentRunUri = superagentRunUri; + } + + public String getLastEventId() { + return lastEventId; + } + + public void setLastEventId(String lastEventId) { + this.lastEventId = lastEventId; + } + + public String getSuperagentRawAnswer() { + return superagentRawAnswer; + } + + public void setSuperagentRawAnswer(String superagentRawAnswer) { + this.superagentRawAnswer = superagentRawAnswer; + } + + public String getSuperagentParsedJson() { + return superagentParsedJson; + } + + public void setSuperagentParsedJson(String superagentParsedJson) { + this.superagentParsedJson = superagentParsedJson; + } + + public String getSuperagentTraceJson() { + return superagentTraceJson; + } + + public void setSuperagentTraceJson(String superagentTraceJson) { + this.superagentTraceJson = superagentTraceJson; + } + + public String getSafeErrorCode() { + return safeErrorCode; + } + + public void setSafeErrorCode(String safeErrorCode) { + this.safeErrorCode = safeErrorCode; + } + + public String getSafeErrorSummary() { + return safeErrorSummary; + } + + public void setSafeErrorSummary(String safeErrorSummary) { + this.safeErrorSummary = safeErrorSummary; + } + + public LocalDateTime getCreatedAt() { + return createdAt; + } + + public void setCreatedAt(LocalDateTime createdAt) { + this.createdAt = createdAt; + } + + public LocalDateTime getUpdatedAt() { + return updatedAt; + } + + public void setUpdatedAt(LocalDateTime updatedAt) { + this.updatedAt = updatedAt; + } +} diff --git a/server/src/main/java/cn/nianxx/thhotel/integrations/ai/superagent/mapper/SuperAgentDispatchRunMapper.java b/server/src/main/java/cn/nianxx/thhotel/integrations/ai/superagent/mapper/SuperAgentDispatchRunMapper.java new file mode 100644 index 0000000..d59fbd8 --- /dev/null +++ b/server/src/main/java/cn/nianxx/thhotel/integrations/ai/superagent/mapper/SuperAgentDispatchRunMapper.java @@ -0,0 +1,12 @@ +package cn.nianxx.thhotel.integrations.ai.superagent.mapper; + +import cn.nianxx.thhotel.integrations.ai.superagent.domain.SuperAgentDispatchRunEntity; +import com.baomidou.mybatisplus.core.mapper.BaseMapper; +import org.apache.ibatis.annotations.Mapper; + +/** + * SuperAgent 自动分发运行 Mapper,只供 Repository 实现层访问。 + */ +@Mapper +public interface SuperAgentDispatchRunMapper extends BaseMapper { +} diff --git a/server/src/main/java/cn/nianxx/thhotel/integrations/ai/superagent/repository/MybatisSuperAgentDispatchRunRepository.java b/server/src/main/java/cn/nianxx/thhotel/integrations/ai/superagent/repository/MybatisSuperAgentDispatchRunRepository.java new file mode 100644 index 0000000..c320ad2 --- /dev/null +++ b/server/src/main/java/cn/nianxx/thhotel/integrations/ai/superagent/repository/MybatisSuperAgentDispatchRunRepository.java @@ -0,0 +1,229 @@ +package cn.nianxx.thhotel.integrations.ai.superagent.repository; + +import cn.nianxx.thhotel.integrations.ai.superagent.common.dto.SuperAgentDispatchRunDraft; +import cn.nianxx.thhotel.integrations.ai.superagent.common.dto.SuperAgentDispatchRunFailureUpdate; +import cn.nianxx.thhotel.integrations.ai.superagent.common.dto.SuperAgentDispatchRunSnapshot; +import cn.nianxx.thhotel.integrations.ai.superagent.common.dto.SuperAgentDispatchRunSuccessUpdate; +import cn.nianxx.thhotel.integrations.ai.superagent.common.enums.SuperAgentDispatchStatus; +import cn.nianxx.thhotel.integrations.ai.superagent.domain.SuperAgentDispatchRunEntity; +import cn.nianxx.thhotel.integrations.ai.superagent.mapper.SuperAgentDispatchRunMapper; +import com.baomidou.mybatisplus.core.toolkit.Wrappers; +import com.baomidou.mybatisplus.extension.plugins.pagination.Page; +import java.time.LocalDateTime; +import java.util.List; +import java.util.Optional; +import org.springframework.dao.DuplicateKeyException; +import org.springframework.stereotype.Repository; + +/** + * SuperAgent 自动分发运行 MyBatis-Plus 持久化实现。数据库并发和幂等细节留在本层。 + */ +@Repository +public class MybatisSuperAgentDispatchRunRepository implements SuperAgentDispatchRunRepository { + + private final SuperAgentDispatchRunMapper mapper; + + /** + * 注入 SuperAgent dispatch run Mapper。 + */ + public MybatisSuperAgentDispatchRunRepository(SuperAgentDispatchRunMapper mapper) { + this.mapper = mapper; + } + + /** + * 幂等写入 dispatch run;重复键命中时回查已有记录,避免 AgentBus 重复投递重复分发。 + */ + @Override + public Long insertIfAbsent(SuperAgentDispatchRunDraft draft) { + SuperAgentDispatchRunEntity entity = toEntity(draft); + try { + mapper.insert(entity); + return entity.getId(); + } catch (DuplicateKeyException exception) { + return findBySourceMessageAndDispatchSource(draft.sourceMessageId(), draft.dispatchSource()) + .map(SuperAgentDispatchRunSnapshot::id) + .orElseThrow(() -> exception); + } + } + + /** + * 抢占 PENDING/RETRYABLE_FAILED 记录。先查候选再逐条条件更新,降低多 worker 重复执行概率。 + */ + @Override + public List claimDue( + String workerId, + List statuses, + LocalDateTime now, + LocalDateTime lockedUntil, + int limit) { + if (statuses == null || statuses.isEmpty() || now == null || lockedUntil == null || limit < 1) { + return List.of(); + } + List candidates = mapper.selectPage(Page.of(1, limit), + Wrappers.lambdaQuery() + .in(SuperAgentDispatchRunEntity::getDispatchStatus, statuses) + .le(SuperAgentDispatchRunEntity::getNextAttemptAt, now) + .and(wrapper -> wrapper + .isNull(SuperAgentDispatchRunEntity::getLockedUntil) + .or() + .lt(SuperAgentDispatchRunEntity::getLockedUntil, now)) + .orderByAsc(SuperAgentDispatchRunEntity::getNextAttemptAt) + .orderByAsc(SuperAgentDispatchRunEntity::getId)) + .getRecords(); + return candidates.stream() + .map(candidate -> claimOne(candidate, now, lockedUntil)) + .flatMap(Optional::stream) + .toList(); + } + + /** + * 按 ID 查询运行快照。 + */ + @Override + public Optional findById(Long id) { + if (id == null) { + return Optional.empty(); + } + return Optional.ofNullable(mapper.selectById(id)).map(this::toSnapshot); + } + + /** + * 更新成功结果并释放 worker 锁。 + */ + @Override + public void markSucceeded(SuperAgentDispatchRunSuccessUpdate update) { + mapper.update(null, Wrappers.lambdaUpdate() + .eq(SuperAgentDispatchRunEntity::getId, update.id()) + .set(SuperAgentDispatchRunEntity::getDispatchStatus, SuperAgentDispatchStatus.SUCCEEDED.code()) + .set(SuperAgentDispatchRunEntity::getSuperagentSessionId, update.superagentSessionId()) + .set(SuperAgentDispatchRunEntity::getSuperagentRunId, update.superagentRunId()) + .set(SuperAgentDispatchRunEntity::getSuperagentRunUri, update.superagentRunUri()) + .set(SuperAgentDispatchRunEntity::getLastEventId, update.lastEventId()) + .set(SuperAgentDispatchRunEntity::getSuperagentRawAnswer, update.superagentRawAnswer()) + .set(SuperAgentDispatchRunEntity::getSuperagentParsedJson, update.superagentParsedJson()) + .set(SuperAgentDispatchRunEntity::getSuperagentTraceJson, update.superagentTraceJson()) + .set(SuperAgentDispatchRunEntity::getSafeErrorCode, null) + .set(SuperAgentDispatchRunEntity::getSafeErrorSummary, null) + .set(SuperAgentDispatchRunEntity::getNextAttemptAt, null) + .set(SuperAgentDispatchRunEntity::getLockedUntil, null) + .set(SuperAgentDispatchRunEntity::getUpdatedAt, update.updatedAt())); + } + + /** + * 更新失败状态和安全错误摘要,并释放 worker 锁。 + */ + @Override + public void markFailed(SuperAgentDispatchRunFailureUpdate update) { + mapper.update(null, Wrappers.lambdaUpdate() + .eq(SuperAgentDispatchRunEntity::getId, update.id()) + .set(SuperAgentDispatchRunEntity::getDispatchStatus, update.dispatchStatus()) + .set(SuperAgentDispatchRunEntity::getSafeErrorCode, update.safeErrorCode()) + .set(SuperAgentDispatchRunEntity::getSafeErrorSummary, update.safeErrorSummary()) + .set(SuperAgentDispatchRunEntity::getNextAttemptAt, update.nextAttemptAt()) + .set(SuperAgentDispatchRunEntity::getLockedUntil, null) + .set(SuperAgentDispatchRunEntity::getUpdatedAt, update.updatedAt())); + } + + /** + * 通过 SourceMessage 和分发来源回查幂等记录。 + */ + private Optional findBySourceMessageAndDispatchSource( + Long sourceMessageId, + String dispatchSource) { + if (sourceMessageId == null || dispatchSource == null || dispatchSource.isBlank()) { + return Optional.empty(); + } + SuperAgentDispatchRunEntity entity = mapper.selectOne(Wrappers.lambdaQuery() + .eq(SuperAgentDispatchRunEntity::getSourceMessageId, sourceMessageId) + .eq(SuperAgentDispatchRunEntity::getDispatchSource, dispatchSource)); + return Optional.ofNullable(entity).map(this::toSnapshot); + } + + /** + * 条件抢占单条记录,抢占成功后回读最新快照。 + */ + private Optional claimOne( + SuperAgentDispatchRunEntity candidate, + LocalDateTime now, + LocalDateTime lockedUntil) { + int updated = mapper.update(null, Wrappers.lambdaUpdate() + .eq(SuperAgentDispatchRunEntity::getId, candidate.getId()) + .eq(SuperAgentDispatchRunEntity::getDispatchStatus, candidate.getDispatchStatus()) + .le(SuperAgentDispatchRunEntity::getNextAttemptAt, now) + .and(wrapper -> wrapper + .isNull(SuperAgentDispatchRunEntity::getLockedUntil) + .or() + .lt(SuperAgentDispatchRunEntity::getLockedUntil, now)) + .set(SuperAgentDispatchRunEntity::getDispatchStatus, SuperAgentDispatchStatus.RUNNING.code()) + .set(SuperAgentDispatchRunEntity::getLockedUntil, lockedUntil) + .set(SuperAgentDispatchRunEntity::getUpdatedAt, now) + .setSql("attempt_count = attempt_count + 1")); + if (updated != 1) { + return Optional.empty(); + } + return findById(candidate.getId()); + } + + /** + * 将入库草稿转换为数据库实体。 + */ + private SuperAgentDispatchRunEntity toEntity(SuperAgentDispatchRunDraft draft) { + SuperAgentDispatchRunEntity entity = new SuperAgentDispatchRunEntity(); + entity.setHotelId(draft.hotelId()); + entity.setSourceMessageId(draft.sourceMessageId()); + entity.setSourceProvider(draft.sourceProvider()); + entity.setSourceChannel(draft.sourceChannel()); + entity.setExternalMessageId(draft.externalMessageId()); + entity.setExternalConversationId(draft.externalConversationId()); + entity.setDispatchSource(draft.dispatchSource()); + entity.setIdempotencyKey(draft.idempotencyKey()); + entity.setRequestId(draft.requestId()); + entity.setDispatchStatus(draft.dispatchStatus()); + entity.setAttemptCount(0); + entity.setMaxAttempts(draft.maxAttempts()); + entity.setNextAttemptAt(draft.nextAttemptAt()); + entity.setCreatedAt(draft.createdAt()); + entity.setUpdatedAt(draft.createdAt()); + return entity; + } + + /** + * 将数据库实体转换为 Service 快照。 + */ + private SuperAgentDispatchRunSnapshot toSnapshot(SuperAgentDispatchRunEntity entity) { + return new SuperAgentDispatchRunSnapshot( + entity.getId(), + entity.getHotelId(), + entity.getSourceMessageId(), + entity.getSourceProvider(), + entity.getSourceChannel(), + entity.getExternalMessageId(), + entity.getExternalConversationId(), + entity.getDispatchSource(), + entity.getIdempotencyKey(), + entity.getRequestId(), + entity.getDispatchStatus(), + valueOrZero(entity.getAttemptCount()), + valueOrZero(entity.getMaxAttempts()), + entity.getNextAttemptAt(), + entity.getLockedUntil(), + entity.getSuperagentSessionId(), + entity.getSuperagentRunId(), + entity.getSuperagentRunUri(), + entity.getLastEventId(), + entity.getSuperagentRawAnswer(), + entity.getSuperagentParsedJson(), + entity.getSuperagentTraceJson(), + entity.getSafeErrorCode(), + entity.getSafeErrorSummary(), + entity.getCreatedAt(), + entity.getUpdatedAt()); + } + + /** + * 数据库历史空值按 0 处理,避免 worker 计算尝试次数时报空指针。 + */ + private int valueOrZero(Integer value) { + return value == null ? 0 : value; + } +} diff --git a/server/src/main/java/cn/nianxx/thhotel/integrations/ai/superagent/repository/SuperAgentDispatchRunRepository.java b/server/src/main/java/cn/nianxx/thhotel/integrations/ai/superagent/repository/SuperAgentDispatchRunRepository.java new file mode 100644 index 0000000..ca866cd --- /dev/null +++ b/server/src/main/java/cn/nianxx/thhotel/integrations/ai/superagent/repository/SuperAgentDispatchRunRepository.java @@ -0,0 +1,45 @@ +package cn.nianxx.thhotel.integrations.ai.superagent.repository; + +import cn.nianxx.thhotel.integrations.ai.superagent.common.dto.SuperAgentDispatchRunDraft; +import cn.nianxx.thhotel.integrations.ai.superagent.common.dto.SuperAgentDispatchRunFailureUpdate; +import cn.nianxx.thhotel.integrations.ai.superagent.common.dto.SuperAgentDispatchRunSnapshot; +import cn.nianxx.thhotel.integrations.ai.superagent.common.dto.SuperAgentDispatchRunSuccessUpdate; +import java.time.LocalDateTime; +import java.util.List; +import java.util.Optional; + +/** + * SuperAgent 自动分发运行持久化端口。Service 依赖该端口,不直接访问 Mapper 或 Entity。 + */ +public interface SuperAgentDispatchRunRepository { + + /** + * 幂等插入一条自动分发运行记录;同一 SourceMessage 和分发来源已存在时返回已有记录 ID。 + */ + Long insertIfAbsent(SuperAgentDispatchRunDraft draft); + + /** + * 抢占到期可处理的分发记录,并将其标记为 RUNNING,供 worker 安全执行。 + */ + List claimDue( + String workerId, + List statuses, + LocalDateTime now, + LocalDateTime lockedUntil, + int limit); + + /** + * 按 ID 查询分发运行快照。 + */ + Optional findById(Long id); + + /** + * 标记分发成功并保存 SuperAgent 返回的安全结果。 + */ + void markSucceeded(SuperAgentDispatchRunSuccessUpdate update); + + /** + * 标记分发失败;可重试失败会写入下一次处理时间。 + */ + void markFailed(SuperAgentDispatchRunFailureUpdate update); +} diff --git a/server/src/main/java/cn/nianxx/thhotel/integrations/ai/superagent/service/SuperAgentDispatchService.java b/server/src/main/java/cn/nianxx/thhotel/integrations/ai/superagent/service/SuperAgentDispatchService.java new file mode 100644 index 0000000..02c8f34 --- /dev/null +++ b/server/src/main/java/cn/nianxx/thhotel/integrations/ai/superagent/service/SuperAgentDispatchService.java @@ -0,0 +1,25 @@ +package cn.nianxx.thhotel.integrations.ai.superagent.service; + +import cn.nianxx.thhotel.platform.message.common.request.CaptureSourceMessageCommand; +import cn.nianxx.thhotel.platform.message.common.result.SourceMessageCaptureResult; + +/** + * SuperAgent 自动分发服务。负责把已入库的 SourceMessage 异步交给 SuperAgent Open API。 + */ +public interface SuperAgentDispatchService { + + /** + * AgentBus 实时入口捕获 SourceMessage 后,按幂等规则创建 SuperAgent dispatch 记录。 + * + * @param command SourceMessage 捕获命令 + * @param captureResult SourceMessage 捕获结果 + */ + void enqueueAgentBusRealtime(CaptureSourceMessageCommand command, SourceMessageCaptureResult captureResult); + + /** + * 处理一批到期 dispatch 记录,供定时 worker 调用。 + * + * @return 本轮处理的 dispatch 数量 + */ + int processDueDispatches(); +} diff --git a/server/src/main/java/cn/nianxx/thhotel/integrations/ai/superagent/service/SuperAgentOpenApiClient.java b/server/src/main/java/cn/nianxx/thhotel/integrations/ai/superagent/service/SuperAgentOpenApiClient.java index 832b622..5866f1e 100644 --- a/server/src/main/java/cn/nianxx/thhotel/integrations/ai/superagent/service/SuperAgentOpenApiClient.java +++ b/server/src/main/java/cn/nianxx/thhotel/integrations/ai/superagent/service/SuperAgentOpenApiClient.java @@ -1,6 +1,7 @@ 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.request.SuperAgentOpenApiMessageRequest; 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; @@ -24,4 +25,19 @@ public interface SuperAgentOpenApiClient { SuperAgentOpenApiResult invokeMailDebug( SuperAgentMailDebugRequest request, Consumer traceConsumer); + + /** + * 创建 SuperAgent session 并发送通用消息,供 AgentBus 自动分发等生产链路使用。 + */ + default SuperAgentOpenApiResult invokeMessage(SuperAgentOpenApiMessageRequest request) { + return invokeMessage(request, traceEvent -> { + }); + } + + /** + * 创建 SuperAgent session 并发送通用消息,边解析边回调公开 Trace 事件。 + */ + SuperAgentOpenApiResult invokeMessage( + SuperAgentOpenApiMessageRequest request, + Consumer traceConsumer); } diff --git a/server/src/main/java/cn/nianxx/thhotel/integrations/ai/superagent/service/impl/SuperAgentDispatchProperties.java b/server/src/main/java/cn/nianxx/thhotel/integrations/ai/superagent/service/impl/SuperAgentDispatchProperties.java new file mode 100644 index 0000000..0d27ee4 --- /dev/null +++ b/server/src/main/java/cn/nianxx/thhotel/integrations/ai/superagent/service/impl/SuperAgentDispatchProperties.java @@ -0,0 +1,94 @@ +package cn.nianxx.thhotel.integrations.ai.superagent.service.impl; + +import java.time.Duration; +import org.springframework.boot.context.properties.ConfigurationProperties; +import org.springframework.stereotype.Component; + +/** + * AgentBus SourceMessage 自动分发 SuperAgent 配置。生产默认关闭,由环境变量显式开启。 + */ +@Component +@ConfigurationProperties(prefix = "agentbus.superagent-dispatch") +public class SuperAgentDispatchProperties { + + /** 是否在 AgentBus 捕获成功后创建 SuperAgent dispatch run。 */ + private boolean enabled = false; + /** 是否启用后台 worker 处理待分发记录。 */ + private boolean workerEnabled = false; + /** 单次 worker 最多抢占记录数。 */ + private int batchSize = 10; + /** 单条 dispatch 最大尝试次数。 */ + private int maxAttempts = 3; + /** worker 抢占锁有效时长。 */ + private Duration lockTtl = Duration.ofMinutes(5); + /** 首次失败后的退避时长。 */ + private Duration initialBackoff = Duration.ofSeconds(30); + /** 失败退避最大时长。 */ + private Duration maxBackoff = Duration.ofMinutes(15); + /** 定时 worker 调度间隔毫秒。 */ + private long workerFixedDelayMs = 10000L; + + public boolean isEnabled() { + return enabled; + } + + public void setEnabled(boolean enabled) { + this.enabled = enabled; + } + + public boolean isWorkerEnabled() { + return workerEnabled; + } + + public void setWorkerEnabled(boolean workerEnabled) { + this.workerEnabled = workerEnabled; + } + + public int getBatchSize() { + return batchSize; + } + + public void setBatchSize(int batchSize) { + this.batchSize = batchSize; + } + + public int getMaxAttempts() { + return maxAttempts; + } + + public void setMaxAttempts(int maxAttempts) { + this.maxAttempts = maxAttempts; + } + + public Duration getLockTtl() { + return lockTtl; + } + + public void setLockTtl(Duration lockTtl) { + this.lockTtl = lockTtl; + } + + public Duration getInitialBackoff() { + return initialBackoff; + } + + public void setInitialBackoff(Duration initialBackoff) { + this.initialBackoff = initialBackoff; + } + + public Duration getMaxBackoff() { + return maxBackoff; + } + + public void setMaxBackoff(Duration maxBackoff) { + this.maxBackoff = maxBackoff; + } + + public long getWorkerFixedDelayMs() { + return workerFixedDelayMs; + } + + public void setWorkerFixedDelayMs(long workerFixedDelayMs) { + this.workerFixedDelayMs = workerFixedDelayMs; + } +} diff --git a/server/src/main/java/cn/nianxx/thhotel/integrations/ai/superagent/service/impl/SuperAgentDispatchServiceImpl.java b/server/src/main/java/cn/nianxx/thhotel/integrations/ai/superagent/service/impl/SuperAgentDispatchServiceImpl.java new file mode 100644 index 0000000..a9bf3bc --- /dev/null +++ b/server/src/main/java/cn/nianxx/thhotel/integrations/ai/superagent/service/impl/SuperAgentDispatchServiceImpl.java @@ -0,0 +1,351 @@ +package cn.nianxx.thhotel.integrations.ai.superagent.service.impl; + +import cn.nianxx.thhotel.integrations.ai.superagent.common.dto.SuperAgentDispatchRunDraft; +import cn.nianxx.thhotel.integrations.ai.superagent.common.dto.SuperAgentDispatchRunFailureUpdate; +import cn.nianxx.thhotel.integrations.ai.superagent.common.dto.SuperAgentDispatchRunSnapshot; +import cn.nianxx.thhotel.integrations.ai.superagent.common.dto.SuperAgentDispatchRunSuccessUpdate; +import cn.nianxx.thhotel.integrations.ai.superagent.common.enums.SuperAgentDispatchSource; +import cn.nianxx.thhotel.integrations.ai.superagent.common.enums.SuperAgentDispatchStatus; +import cn.nianxx.thhotel.integrations.ai.superagent.common.request.SuperAgentOpenApiMessageRequest; +import cn.nianxx.thhotel.integrations.ai.superagent.common.result.SuperAgentOpenApiResult; +import cn.nianxx.thhotel.integrations.ai.superagent.repository.SuperAgentDispatchRunRepository; +import cn.nianxx.thhotel.integrations.ai.superagent.service.SuperAgentDispatchService; +import cn.nianxx.thhotel.integrations.ai.superagent.service.SuperAgentOpenApiClient; +import cn.nianxx.thhotel.platform.message.common.request.CaptureSourceMessageCommand; +import cn.nianxx.thhotel.platform.message.common.result.SourceMessageCaptureResult; +import cn.nianxx.thhotel.platform.message.repository.SourceMessageInboxRepository; +import com.fasterxml.jackson.core.JsonProcessingException; +import com.fasterxml.jackson.databind.ObjectMapper; +import java.time.Duration; +import java.time.LocalDateTime; +import java.time.ZoneOffset; +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Map; +import java.util.regex.Pattern; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; +import org.springframework.scheduling.annotation.Scheduled; +import org.springframework.stereotype.Service; + +/** + * AgentBus SourceMessage 自动分发 SuperAgent 服务实现。只负责 outbox 调度,不创建订单或任务。 + */ +@Service +public class SuperAgentDispatchServiceImpl implements SuperAgentDispatchService { + + private static final Logger log = LoggerFactory.getLogger(SuperAgentDispatchServiceImpl.class); + private static final String SOURCE_PROVIDER_AGENTBUS = "AGENTBUS"; + private static final String CAPTURE_STATUS_RECEIVED = "RECEIVED"; + private static final String SAFE_ERROR_CODE_SUPERAGENT_FAILED = "SUPERAGENT_DISPATCH_FAILED"; + private static final String SAFE_ERROR_CODE_PAYLOAD_NOT_FOUND = "SOURCE_MESSAGE_PAYLOAD_NOT_FOUND"; + private static final String SAFE_ERROR_CODE_UNEXPECTED = "SUPERAGENT_DISPATCH_UNEXPECTED"; + private static final int SAFE_ERROR_MAX_LENGTH = 512; + private static final Pattern SECRET_PATTERN = Pattern.compile( + "(?i)(api[_-]?key|token|secret|password|cookie|authorization)\\s*[:=]\\s*[^\\s,;}]+"); + + private final SuperAgentDispatchProperties properties; + private final SuperAgentOpenApiProperties openApiProperties; + private final SuperAgentDispatchRunRepository runRepository; + private final SourceMessageInboxRepository sourceMessageRepository; + private final SuperAgentOpenApiClient openApiClient; + private final ObjectMapper objectMapper; + + /** + * 注入 dispatch/outbox 所需的持久化端口、SourceMessage 端口和 SuperAgent Open API 端口。 + */ + public SuperAgentDispatchServiceImpl( + SuperAgentDispatchProperties properties, + SuperAgentOpenApiProperties openApiProperties, + SuperAgentDispatchRunRepository runRepository, + SourceMessageInboxRepository sourceMessageRepository, + SuperAgentOpenApiClient openApiClient, + ObjectMapper objectMapper) { + this.properties = properties; + this.openApiProperties = openApiProperties; + this.runRepository = runRepository; + this.sourceMessageRepository = sourceMessageRepository; + this.openApiClient = openApiClient; + this.objectMapper = objectMapper; + } + + /** + * AgentBus 新 SourceMessage 捕获成功后创建待分发记录;重复投递由数据库唯一键保证幂等。 + */ + @Override + public void enqueueAgentBusRealtime( + CaptureSourceMessageCommand command, + SourceMessageCaptureResult captureResult) { + if (!properties.isEnabled() || command == null || captureResult == null) { + return; + } + if (captureResult.inboxId() == null + || !captureResult.created() + || !CAPTURE_STATUS_RECEIVED.equals(captureResult.captureStatus())) { + return; + } + if (!SOURCE_PROVIDER_AGENTBUS.equals(command.provider())) { + return; + } + LocalDateTime now = nowUtc(); + Long sourceMessageId = captureResult.inboxId(); + runRepository.insertIfAbsent(new SuperAgentDispatchRunDraft( + command.hotelId(), + sourceMessageId, + command.provider(), + command.channel(), + command.externalMessageId(), + command.externalConversationId(), + SuperAgentDispatchSource.AGENTBUS_REALTIME.code(), + idempotencyKey(sourceMessageId), + requestId(sourceMessageId), + SuperAgentDispatchStatus.PENDING.code(), + safeMaxAttempts(), + now, + now)); + } + + /** + * 处理到期的 dispatch run。该方法可由定时 worker 或测试直接调用。 + */ + @Override + public int processDueDispatches() { + if (!properties.isWorkerEnabled()) { + return 0; + } + LocalDateTime now = nowUtc(); + List runs = runRepository.claimDue( + "local-worker", + List.of(SuperAgentDispatchStatus.PENDING.code(), SuperAgentDispatchStatus.RETRYABLE_FAILED.code()), + now, + now.plus(lockTtl()), + safeBatchSize()); + int processed = 0; + for (SuperAgentDispatchRunSnapshot run : runs) { + processOne(run); + processed++; + } + return processed; + } + + /** + * 定时处理待分发记录。开关关闭时立即返回,避免本地或测试环境误调用 SuperAgent。 + */ + @Scheduled(fixedDelayString = "${agentbus.superagent-dispatch.worker-fixed-delay-ms:10000}") + public void scheduledProcessDueDispatches() { + if (!properties.isWorkerEnabled()) { + return; + } + try { + processDueDispatches(); + } catch (RuntimeException exception) { + log.warn("SuperAgent dispatch worker 运行失败,后续调度会继续重试。", exception); + } + } + + /** + * 处理单条 dispatch run,成功保存结果,失败保存安全摘要和下一次尝试时间。 + */ + private void processOne(SuperAgentDispatchRunSnapshot run) { + try { + String payloadJson = sourceMessageRepository.findPayloadJson(run.sourceMessageId()) + .orElseThrow(() -> new SuperAgentDispatchException( + SAFE_ERROR_CODE_PAYLOAD_NOT_FOUND, + "SourceMessage payload 不存在。")); + SuperAgentOpenApiResult result = openApiClient.invokeMessage(new SuperAgentOpenApiMessageRequest( + buildMessage(payloadJson), + run.idempotencyKey(), + openApiProperties.getAgentbusExternalSubjectId(), + run.requestId(), + metadata(run))); + runRepository.markSucceeded(new SuperAgentDispatchRunSuccessUpdate( + run.id(), + result.sessionId(), + result.runId(), + result.runUri(), + result.lastEventId(), + result.rawAnswer(), + parsedJson(result.rawAnswer()), + traceJson(result), + nowUtc())); + } catch (SuperAgentDispatchException exception) { + markFailure(run, exception.safeErrorCode(), exception.getMessage()); + } catch (SuperAgentOpenApiException exception) { + markFailure(run, SAFE_ERROR_CODE_SUPERAGENT_FAILED, exception.getMessage()); + } catch (RuntimeException exception) { + markFailure(run, SAFE_ERROR_CODE_UNEXPECTED, "SuperAgent dispatch 处理失败。"); + } + } + + /** + * 标记失败状态。尝试次数达到上限后进入 FAILED,否则进入 RETRYABLE_FAILED 等待退避重试。 + */ + private void markFailure(SuperAgentDispatchRunSnapshot run, String safeErrorCode, String safeErrorSummary) { + LocalDateTime now = nowUtc(); + boolean exhausted = run.attemptCount() >= Math.max(1, run.maxAttempts()); + runRepository.markFailed(new SuperAgentDispatchRunFailureUpdate( + run.id(), + exhausted ? SuperAgentDispatchStatus.FAILED.code() : SuperAgentDispatchStatus.RETRYABLE_FAILED.code(), + safeErrorCode, + safeSummary(safeErrorSummary), + exhausted ? null : now.plus(backoff(run.attemptCount())), + now)); + } + + /** + * 构造发给 SuperAgent 的 AgentBus 邮件处理提示,业务结果仍由后续回调或人工确认链路处理。 + */ + private String buildMessage(String payloadJson) { + return """ + 请基于以下 AgentBus 邮件 JSON 输出结构化任务抽取结果。 + 只返回 SuperAgent 当前约定的 JSON 或 S10/S99 特殊结果,不要创建订单、任务、OPERA 操作或客户回复。 + + AgentBus 邮件 JSON: + """ + payloadJson; + } + + /** + * 构造 SuperAgent Open API metadata,便于外部系统排查 run 和 SourceMessage 的对应关系。 + */ + private Map metadata(SuperAgentDispatchRunSnapshot run) { + Map metadata = new LinkedHashMap<>(); + metadata.put("source", "th-hotel-agentbus-realtime"); + metadata.put("dispatch_run_id", run.id()); + metadata.put("source_message_id", run.sourceMessageId()); + metadata.put("hotel_id", run.hotelId()); + metadata.put("external_message_id", run.externalMessageId()); + metadata.put("external_conversation_id", run.externalConversationId()); + metadata.put("source_provider", run.sourceProvider()); + metadata.put("source_channel", run.sourceChannel()); + return metadata; + } + + /** + * 尝试把 SuperAgent 最终回答压缩成 JSON 字符串。S10/S99 等纯字符串结果保留 raw answer,不写 parsed json。 + */ + private String parsedJson(String rawAnswer) { + if (rawAnswer == null || rawAnswer.isBlank()) { + return null; + } + try { + return objectMapper.writeValueAsString(objectMapper.readTree(rawAnswer)); + } catch (JsonProcessingException exception) { + return null; + } + } + + /** + * 将公开 trace 摘要序列化保存,供后续排查 SuperAgent 运行过程。 + */ + private String traceJson(SuperAgentOpenApiResult result) { + try { + return objectMapper.writeValueAsString(result.traceEvents()); + } catch (JsonProcessingException exception) { + throw new SuperAgentDispatchException(SAFE_ERROR_CODE_UNEXPECTED, "SuperAgent trace 摘要序列化失败。", exception); + } + } + + /** + * 根据尝试次数计算退避时长,避免外部服务异常时频繁重试。 + */ + private Duration backoff(int attemptCount) { + long multiplier = 1L << Math.min(Math.max(0, attemptCount - 1), 10); + Duration candidate = initialBackoff().multipliedBy(multiplier); + return candidate.compareTo(maxBackoff()) > 0 ? maxBackoff() : candidate; + } + + /** + * 生成稳定 SuperAgent 幂等键。 + */ + private String idempotencyKey(Long sourceMessageId) { + return "agentbus-source-message-" + sourceMessageId; + } + + /** + * 生成稳定调用关联 ID,写入 X-Request-ID。 + */ + private String requestId(Long sourceMessageId) { + return "agentbus-dispatch-source-" + sourceMessageId; + } + + /** + * 安全清理错误摘要,避免日志和数据库保存 Secret 或大段外部响应。 + */ + private String safeSummary(String value) { + String summary = value == null || value.isBlank() ? "SuperAgent dispatch 失败。" : value; + summary = SECRET_PATTERN.matcher(summary).replaceAll("$1:***"); + if (summary.length() > SAFE_ERROR_MAX_LENGTH) { + summary = summary.substring(0, SAFE_ERROR_MAX_LENGTH); + } + return summary; + } + + /** + * 当前 UTC 时间,数据库 LocalDateTime 均按 UTC 解释。 + */ + private LocalDateTime nowUtc() { + return LocalDateTime.now(ZoneOffset.UTC); + } + + /** + * 读取并归一化最大尝试次数。 + */ + private int safeMaxAttempts() { + return Math.max(1, properties.getMaxAttempts()); + } + + /** + * 读取并归一化 worker 批量大小。 + */ + private int safeBatchSize() { + return Math.max(1, properties.getBatchSize()); + } + + /** + * 读取并归一化 worker 锁时长。 + */ + private Duration lockTtl() { + return properties.getLockTtl() == null || properties.getLockTtl().isNegative() + ? Duration.ofMinutes(5) + : properties.getLockTtl(); + } + + /** + * 读取并归一化初始退避时长。 + */ + private Duration initialBackoff() { + return properties.getInitialBackoff() == null || properties.getInitialBackoff().isNegative() + ? Duration.ofSeconds(30) + : properties.getInitialBackoff(); + } + + /** + * 读取并归一化最大退避时长。 + */ + private Duration maxBackoff() { + return properties.getMaxBackoff() == null || properties.getMaxBackoff().isNegative() + ? Duration.ofMinutes(15) + : properties.getMaxBackoff(); + } + + /** + * dispatch 内部异常,携带可保存的安全错误代码。 + */ + private static final class SuperAgentDispatchException extends RuntimeException { + private final String safeErrorCode; + + private SuperAgentDispatchException(String safeErrorCode, String message) { + super(message); + this.safeErrorCode = safeErrorCode; + } + + private SuperAgentDispatchException(String safeErrorCode, String message, Throwable cause) { + super(message, cause); + this.safeErrorCode = safeErrorCode; + } + + private String safeErrorCode() { + return safeErrorCode; + } + } +} diff --git a/server/src/main/java/cn/nianxx/thhotel/integrations/ai/superagent/service/impl/SuperAgentOpenApiClientImpl.java b/server/src/main/java/cn/nianxx/thhotel/integrations/ai/superagent/service/impl/SuperAgentOpenApiClientImpl.java index 7ebdcd4..5330a47 100644 --- a/server/src/main/java/cn/nianxx/thhotel/integrations/ai/superagent/service/impl/SuperAgentOpenApiClientImpl.java +++ b/server/src/main/java/cn/nianxx/thhotel/integrations/ai/superagent/service/impl/SuperAgentOpenApiClientImpl.java @@ -1,23 +1,24 @@ 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.request.SuperAgentOpenApiMessageRequest; 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.io.InputStream; +import java.io.IOException; import java.nio.charset.StandardCharsets; +import java.net.URI; +import java.net.http.HttpClient; +import java.net.http.HttpRequest; +import java.net.http.HttpResponse; +import java.io.InputStreamReader; 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 消息和解析最终回答。 @@ -48,14 +49,27 @@ public class SuperAgentOpenApiClientImpl implements SuperAgentOpenApiClient { public SuperAgentOpenApiResult invokeMailDebug( SuperAgentMailDebugRequest request, Consumer traceConsumer) { + return invokeMessage(new SuperAgentOpenApiMessageRequest( + request.message(), + request.idempotencyKey(), + properties.getExternalSubjectId(), + request.idempotencyKey(), + request.metadata()), traceConsumer); + } + + /** + * 调用 SuperAgent Open API 通用消息流程,严格校验 SSE 终止事件并支持断流恢复。 + */ + @Override + public SuperAgentOpenApiResult invokeMessage( + SuperAgentOpenApiMessageRequest request, + Consumer traceConsumer) { validateProperties(); try { - RestClient restClient = RestClient.builder() - .baseUrl(properties.getBaseUrl()) - .requestFactory(requestFactory()) - .build(); - String sessionId = createSession(restClient, request); - return sendMessage(restClient, sessionId, request, traceConsumer); + HttpClient httpClient = httpClient(); + URI baseUri = baseUri(); + String sessionId = createSession(httpClient, baseUri, request); + return sendMessage(httpClient, baseUri, sessionId, request, traceConsumer); } catch (SuperAgentOpenApiException exception) { throw exception; } catch (Exception exception) { @@ -66,21 +80,22 @@ public class SuperAgentOpenApiClientImpl implements SuperAgentOpenApiClient { /** * 创建 SuperAgent session。 */ - private String createSession(RestClient restClient, SuperAgentMailDebugRequest request) throws Exception { - String csrfToken = UUID.randomUUID().toString(); + private String createSession(HttpClient httpClient, URI baseUri, SuperAgentOpenApiMessageRequest request) + throws Exception { Map body = new LinkedHashMap<>(); - body.put("external_subject_id", properties.getExternalSubjectId()); + body.put("external_subject_id", textOrDefault(request.externalSubjectId(), properties.getExternalSubjectId())); body.put("idempotency_key", request.idempotencyKey() + "-session"); body.put("metadata", request.metadata()); - String response = restClient.post() - .uri("/api/open/agent-sessions") - .contentType(MediaType.APPLICATION_JSON) - .header("Authorization", "Bearer " + properties.getApiKey()) - .header("X-CSRF-Token", csrfToken) - .header("Cookie", "csrf_token=" + csrfToken) - .body(body) - .retrieve() - .body(String.class); + HttpRequest httpRequest = baseRequest(baseUri.resolve("/api/open/agent-sessions"), request.requestId()) + .header("Content-Type", "application/json") + .header("Accept", "application/json") + .POST(HttpRequest.BodyPublishers.ofString(objectMapper.writeValueAsString(body), StandardCharsets.UTF_8)) + .build(); + HttpResponse httpResponse = httpClient.send( + httpRequest, + HttpResponse.BodyHandlers.ofString(StandardCharsets.UTF_8)); + require2xx(httpResponse.statusCode(), httpResponse.body()); + String response = httpResponse.body(); JsonNode json = objectMapper.readTree(response == null ? "{}" : response); String sessionId = text(json, "session_id"); if (sessionId == null) { @@ -96,43 +111,37 @@ public class SuperAgentOpenApiClientImpl implements SuperAgentOpenApiClient { * 发送 Debug 邮件消息并流式解析 SSE 文本响应。 */ private SuperAgentOpenApiResult sendMessage( - RestClient restClient, + HttpClient httpClient, + URI baseUri, String sessionId, - SuperAgentMailDebugRequest request, - Consumer traceConsumer) { - String csrfToken = UUID.randomUUID().toString(); + SuperAgentOpenApiMessageRequest request, + Consumer traceConsumer) throws Exception { Map body = new LinkedHashMap<>(); body.put("message", request.message()); body.put("idempotency_key", request.idempotencyKey() + "-message"); body.put("metadata", request.metadata()); - return restClient.post() - .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) - .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); - } - }); + URI streamUri = baseUri.resolve("/api/open/agent-sessions/" + sessionId + "/messages/stream?include_trace=true"); + HttpRequest httpRequest = baseRequest(streamUri, request.requestId()) + .header("Content-Type", "application/json") + .header("Accept", "text/event-stream") + .POST(HttpRequest.BodyPublishers.ofString(objectMapper.writeValueAsString(body), StandardCharsets.UTF_8)) + .build(); + HttpResponse httpResponse = httpClient.send( + httpRequest, + HttpResponse.BodyHandlers.ofInputStream()); + require2xx(httpResponse.statusCode(), null); + SuperAgentOpenApiSseParser.ParsedState state = sseParser.newState(sessionId); + applyRunLocation(baseUri, httpResponse, state); + try { + consumeResponse(httpResponse.body(), state, traceConsumer); + } catch (SuperAgentOpenApiException exception) { + if (!state.endSeen() && state.runUri() != null) { + recover(httpClient, request.requestId(), state, traceConsumer); + } else { + throw exception; + } + } + return sseParser.requireSuccessfulResult(state); } /** @@ -148,13 +157,148 @@ public class SuperAgentOpenApiClientImpl implements SuperAgentOpenApiClient { } /** - * 构造带超时设置的 HTTP 请求工厂,避免 SSE 调用无限等待。 + * 构造 JDK HttpClient;SSE 请求不设置整体 read timeout,避免长任务被固定超时截断。 */ - private SimpleClientHttpRequestFactory requestFactory() { - SimpleClientHttpRequestFactory requestFactory = new SimpleClientHttpRequestFactory(); - requestFactory.setConnectTimeout(properties.getConnectTimeout()); - requestFactory.setReadTimeout(properties.getReadTimeout()); - return requestFactory; + private HttpClient httpClient() { + return HttpClient.newBuilder() + .connectTimeout(properties.getConnectTimeout()) + .version(HttpClient.Version.HTTP_1_1) + .build(); + } + + /** + * 解析基础 URI,统一处理末尾斜杠。 + */ + private URI baseUri() { + String normalizedBaseUrl = properties.getBaseUrl().endsWith("/") + ? properties.getBaseUrl() + : properties.getBaseUrl() + "/"; + return URI.create(normalizedBaseUrl); + } + + /** + * 构造 SuperAgent Open API 通用请求头,不记录或返回 API Key。 + */ + private HttpRequest.Builder baseRequest(URI uri, String requestId) { + HttpRequest.Builder builder = HttpRequest.newBuilder(uri) + .header("Authorization", "Bearer " + properties.getApiKey()) + .header("Cache-Control", "no-cache"); + if (!blank(requestId)) { + builder.header("X-Request-ID", requestId); + } + return builder; + } + + /** + * 校验 HTTP 状态码,错误摘要不包含 Secret。 + */ + private void require2xx(int statusCode, String responseBody) { + if (statusCode < 200 || statusCode >= 300) { + String safeBody = responseBody == null ? "" : responseBody; + if (safeBody.length() > 512) { + safeBody = safeBody.substring(0, 512); + } + throw new SuperAgentOpenApiException("SuperAgent Open API HTTP 调用失败,status=" + statusCode + ",body=" + safeBody); + } + } + + /** + * 从 Content-Location 保存 run URI 和 runId,供 EOF 后恢复使用。 + */ + private void applyRunLocation( + URI baseUri, + HttpResponse response, + SuperAgentOpenApiSseParser.ParsedState state) { + String contentLocation = response.headers().firstValue("Content-Location").orElse(null); + if (blank(contentLocation)) { + return; + } + URI runUri = baseUri.resolve(contentLocation); + state.runUri(runUri.toString()); + String path = runUri.getPath(); + int markerIndex = path.lastIndexOf("/runs/"); + if (markerIndex >= 0) { + state.runId(path.substring(markerIndex + "/runs/".length())); + } + } + + /** + * 消费一段 SSE 响应流,读取完成前不会把部分回答视为成功。 + */ + private void consumeResponse( + InputStream body, + SuperAgentOpenApiSseParser.ParsedState state, + Consumer traceConsumer) { + try (InputStreamReader reader = new InputStreamReader(body, StandardCharsets.UTF_8)) { + sseParser.consume(state, reader, traceConsumer); + } catch (IOException exception) { + throw new SuperAgentOpenApiException("SuperAgent SSE 读取失败。", exception); + } + } + + /** + * EOF 后通过已有 run 的 events 接口恢复,不重新 POST 初始消息。 + */ + private void recover( + HttpClient httpClient, + String requestId, + SuperAgentOpenApiSseParser.ParsedState state, + Consumer traceConsumer) throws Exception { + int maxAttempts = Math.max(0, properties.getSseRecoveryMaxAttempts()); + SuperAgentOpenApiException lastFailure = null; + for (int attempt = 1; attempt <= maxAttempts; attempt++) { + String runStatus = queryRunStatus(httpClient, URI.create(state.runUri()), requestId); + if ("error".equals(runStatus) || "timeout".equals(runStatus) || "interrupted".equals(runStatus)) { + throw new SuperAgentOpenApiException("SuperAgent run 已进入失败终态,runId=" + state.runId() + ",status=" + runStatus); + } + try { + subscribeRunEvents(httpClient, URI.create(state.runUri() + "/events"), requestId, state, traceConsumer); + if (state.endSeen()) { + return; + } + } catch (SuperAgentOpenApiException exception) { + lastFailure = exception; + } + } + throw new SuperAgentOpenApiException("SuperAgent SSE 断流恢复耗尽,runId=" + state.runId(), lastFailure); + } + + /** + * 查询 SuperAgent run 当前状态。 + */ + private String queryRunStatus(HttpClient httpClient, URI runUri, String requestId) throws Exception { + HttpRequest httpRequest = baseRequest(runUri, requestId) + .header("Accept", "application/json") + .GET() + .build(); + HttpResponse response = httpClient.send( + httpRequest, + HttpResponse.BodyHandlers.ofString(StandardCharsets.UTF_8)); + require2xx(response.statusCode(), response.body()); + JsonNode json = objectMapper.readTree(response.body() == null ? "{}" : response.body()); + return text(json, "status", "running"); + } + + /** + * 订阅已有 run 的公开 events,并携带 Last-Event-ID 去重恢复。 + */ + private void subscribeRunEvents( + HttpClient httpClient, + URI eventsUri, + String requestId, + SuperAgentOpenApiSseParser.ParsedState state, + Consumer traceConsumer) throws Exception { + HttpRequest.Builder builder = baseRequest(eventsUri, requestId) + .header("Accept", "text/event-stream") + .GET(); + if (!blank(state.lastEventId())) { + builder.header("Last-Event-ID", state.lastEventId()); + } + HttpResponse response = httpClient.send( + builder.build(), + HttpResponse.BodyHandlers.ofInputStream()); + require2xx(response.statusCode(), null); + consumeResponse(response.body(), state, traceConsumer); } /** @@ -165,10 +309,25 @@ public class SuperAgentOpenApiClientImpl implements SuperAgentOpenApiClient { return value.isMissingNode() || value.isNull() || value.asText().isBlank() ? null : value.asText(); } + /** + * 读取 JSON 文本字段,缺失时返回兜底值。 + */ + private String text(JsonNode json, String fieldName, String fallback) { + String value = text(json, fieldName); + return value == null ? fallback : value; + } + /** * 判断字符串是否为空白。 */ private boolean blank(String value) { return value == null || value.isBlank(); } + + /** + * 返回非空文本,否则使用兜底值。 + */ + private String textOrDefault(String value, String fallback) { + return blank(value) ? fallback : value; + } } diff --git a/server/src/main/java/cn/nianxx/thhotel/integrations/ai/superagent/service/impl/SuperAgentOpenApiProperties.java b/server/src/main/java/cn/nianxx/thhotel/integrations/ai/superagent/service/impl/SuperAgentOpenApiProperties.java index 6e0b277..cc0592a 100644 --- a/server/src/main/java/cn/nianxx/thhotel/integrations/ai/superagent/service/impl/SuperAgentOpenApiProperties.java +++ b/server/src/main/java/cn/nianxx/thhotel/integrations/ai/superagent/service/impl/SuperAgentOpenApiProperties.java @@ -19,10 +19,14 @@ public class SuperAgentOpenApiProperties { private String apiKey = ""; /** Debug EML 创建 session 时使用的 external_subject_id。 */ private String externalSubjectId = "th-hotel-debug-eml-upload"; + /** AgentBus 自动分发创建 session 时使用的 external_subject_id。 */ + private String agentbusExternalSubjectId = "th-hotel-agentbus-source-message"; /** 建立连接超时。 */ private Duration connectTimeout = Duration.ofSeconds(15); - /** SSE 读取超时。 */ + /** 旧版 RestClient SSE 读取超时兼容配置;当前 JDK SSE 客户端不设置整体读取超时。 */ private Duration readTimeout = Duration.ofSeconds(180); + /** SSE 断流恢复最大尝试次数。 */ + private int sseRecoveryMaxAttempts = 5; public boolean isEnabled() { return enabled; @@ -56,6 +60,14 @@ public class SuperAgentOpenApiProperties { this.externalSubjectId = externalSubjectId; } + public String getAgentbusExternalSubjectId() { + return agentbusExternalSubjectId; + } + + public void setAgentbusExternalSubjectId(String agentbusExternalSubjectId) { + this.agentbusExternalSubjectId = agentbusExternalSubjectId; + } + public Duration getConnectTimeout() { return connectTimeout; } @@ -71,4 +83,12 @@ public class SuperAgentOpenApiProperties { public void setReadTimeout(Duration readTimeout) { this.readTimeout = readTimeout; } + + public int getSseRecoveryMaxAttempts() { + return sseRecoveryMaxAttempts; + } + + public void setSseRecoveryMaxAttempts(int sseRecoveryMaxAttempts) { + this.sseRecoveryMaxAttempts = sseRecoveryMaxAttempts; + } } diff --git a/server/src/main/java/cn/nianxx/thhotel/integrations/ai/superagent/service/impl/SuperAgentOpenApiSseParser.java b/server/src/main/java/cn/nianxx/thhotel/integrations/ai/superagent/service/impl/SuperAgentOpenApiSseParser.java index d892b63..ac8dcb4 100644 --- a/server/src/main/java/cn/nianxx/thhotel/integrations/ai/superagent/service/impl/SuperAgentOpenApiSseParser.java +++ b/server/src/main/java/cn/nianxx/thhotel/integrations/ai/superagent/service/impl/SuperAgentOpenApiSseParser.java @@ -73,51 +73,89 @@ public class SuperAgentOpenApiSseParser { String sessionId, Reader reader, Consumer traceConsumer) { + ParsedState state = newState(sessionId); + consume(state, reader, traceConsumer); + return buildResult(state); + } + + /** + * 创建可跨初始流和恢复流复用的 SSE 解析状态。 + */ + public ParsedState newState(String sessionId) { + return new ParsedState(sessionId); + } + + /** + * 消费一段 SSE 流。断流时保留已处理状态,调用方可据此用 Last-Event-ID 恢复。 + */ + public void consume( + ParsedState state, + Reader reader, + Consumer traceConsumer) { if (reader == null) { throw new SuperAgentOpenApiException("SuperAgent SSE 响应为空。"); } - Set eventTypes = new LinkedHashSet<>(); - ParsedState state = new ParsedState(); try { parseLines(reader, event -> { - eventTypes.add(event.eventType()); + state.eventTypes.add(event.eventType()); consumeEvent(event, state, safeTraceConsumer(traceConsumer)); }, state); } catch (IOException exception) { throw new SuperAgentOpenApiException("SuperAgent SSE 读取失败。", exception); } - return buildResult(sessionId, eventTypes, state); + if (!state.endSeen) { + throw new SuperAgentOpenApiException("SuperAgent SSE 提前结束,尚未收到 end 事件。"); + } + } + + /** + * 按严格成功条件生成最终调用结果。 + */ + public SuperAgentOpenApiResult requireSuccessfulResult(ParsedState state) { + return buildResult(state); } /** * 汇总解析状态并生成结果对象。 */ - private SuperAgentOpenApiResult buildResult(String sessionId, Set eventTypes, ParsedState state) { - if ((state.rawAnswer == null || state.rawAnswer.isBlank()) && state.endSeen && state.fallbackRawAnswer != null) { - state.rawAnswer = state.fallbackRawAnswer; + private SuperAgentOpenApiResult buildResult(ParsedState state) { + if (state.failureCode != null) { + throw new SuperAgentOpenApiException("SuperAgent SSE 运行失败:" + state.failureCode + ":" + state.failureMessage); + } + if (!state.endSeen) { + throw new SuperAgentOpenApiException("SuperAgent SSE 未收到结束事件。"); + } + if (!state.runCompleted) { + throw new SuperAgentOpenApiException("SuperAgent SSE 未收到成功 run.completed 事件。"); + } + String rawAnswer = state.finalContent; + if (rawAnswer == null || rawAnswer.isBlank()) { + rawAnswer = state.deltaContent.toString(); + } + if ((rawAnswer == null || rawAnswer.isBlank()) && state.fallbackRawAnswer != null) { + rawAnswer = state.fallbackRawAnswer; state.modelName = state.fallbackModelName; state.inputTokens = state.fallbackInputTokens; state.outputTokens = state.fallbackOutputTokens; state.totalTokens = state.fallbackTotalTokens; } - if (!state.endSeen && (state.rawAnswer == null || state.rawAnswer.isBlank())) { - throw new SuperAgentOpenApiException("SuperAgent SSE 未收到结束事件。"); - } - if (state.rawAnswer == null || state.rawAnswer.isBlank()) { + if (rawAnswer == null || rawAnswer.isBlank()) { throw new SuperAgentOpenApiException("SuperAgent SSE 未找到最终 AI 回答。"); } return new SuperAgentOpenApiResult( - sessionId, + state.sessionId, state.runId, state.profileId, state.profileVersionId, state.modelName, - state.rawAnswer, + rawAnswer, state.inputTokens, state.outputTokens, state.totalTokens, - List.copyOf(eventTypes), - List.copyOf(state.traceEvents)); + List.copyOf(state.eventTypes), + List.copyOf(state.traceEvents), + state.runUri, + state.lastEventId); } /** @@ -128,10 +166,22 @@ public class SuperAgentOpenApiSseParser { ParsedState state, Consumer traceConsumer) { try { + if (event.id() != null && !event.id().isBlank()) { + if (!state.processedEventIds.add(event.id())) { + return; + } + state.lastEventId = event.id(); + } if ("end".equals(event.eventType())) { state.endSeen = true; return; } + if ("error".equals(event.eventType())) { + JsonNode error = parseJson(event.data()); + state.failureCode = text(error, "code", "stream_error"); + state.failureMessage = text(error, "message", event.data()); + return; + } JsonNode data = objectMapper.readTree(event.data()); if ("metadata".equals(event.eventType())) { state.runId = text(data, "run_id", state.runId); @@ -197,7 +247,7 @@ public class SuperAgentOpenApiSseParser { String modelName = text(responseMetadata, "model_name", state.modelName); String finishReason = responseMetadata.path("finish_reason").asText(); if ("stop".equals(finishReason)) { - state.rawAnswer = content; + state.finalContent = content; state.modelName = modelName; state.inputTokens = intValue(usage, "input_tokens", state.inputTokens); state.outputTokens = intValue(usage, "output_tokens", state.outputTokens); @@ -221,6 +271,7 @@ public class SuperAgentOpenApiSseParser { JsonNode trace = data.path("data").isObject() && data.path("event").isMissingNode() ? data.path("data") : data; + consumeTraceProtocolFields(trace, state); JsonNode toolCalls = trace.path("tool_calls"); if (toolCalls.isArray() && !toolCalls.isEmpty()) { for (JsonNode toolCall : toolCalls) { @@ -231,6 +282,41 @@ public class SuperAgentOpenApiSseParser { emitTraceEvent(traceEvent(trace, trace), state, traceConsumer); } + /** + * 处理 2026-07-12 Open API trace 协议中的最终内容、完成和失败事件。 + */ + private void consumeTraceProtocolFields(JsonNode trace, ParsedState state) { + state.runId = text(trace, "run_id", state.runId); + String traceEvent = text(trace, "event", null); + if ("message.delta".equals(traceEvent)) { + String text = text(trace, "text", null); + if (text != null) { + state.deltaContent.append(text); + } + return; + } + if ("message.final".equals(traceEvent)) { + state.finalContent = text(trace, "text", state.finalContent); + return; + } + if ("run.completed".equals(traceEvent)) { + state.runCompleted = "success".equals(trace.path("status").asText()); + return; + } + if ("run.failed".equals(traceEvent)) { + JsonNode error = trace.path("error"); + state.failureCode = text(error, "code", "run_failed"); + state.failureMessage = text(error, "message", error.toString()); + } + } + + /** + * 解析可能为空的 JSON data。 + */ + private JsonNode parseJson(String data) throws IOException { + return data == null || data.isBlank() ? objectMapper.createObjectNode() : objectMapper.readTree(data); + } + /** * 构造公开 Trace 事件对象。 */ @@ -286,6 +372,10 @@ public class SuperAgentOpenApiSseParser { builder.eventType(line.substring("event:".length()).trim()); continue; } + if (line.startsWith("id:")) { + builder.id(line.substring("id:".length()).trim()); + continue; + } if (line.startsWith("data:")) { builder.appendData(line.substring("data:".length()).trim()); } @@ -403,7 +493,7 @@ public class SuperAgentOpenApiSseParser { /** * SSE 单事件。 */ - private record SseEvent(String eventType, String data) { + private record SseEvent(String eventType, String id, String data) { } /** @@ -411,12 +501,17 @@ public class SuperAgentOpenApiSseParser { */ private static final class SseEventBuilder { private String eventType = "message"; + private String id; private final StringBuilder data = new StringBuilder(); private void eventType(String eventType) { this.eventType = eventType == null || eventType.isBlank() ? "message" : eventType; } + private void id(String id) { + this.id = id == null || id.isBlank() ? null : id; + } + private void appendData(String value) { if (!data.isEmpty()) { data.append('\n'); @@ -428,11 +523,12 @@ public class SuperAgentOpenApiSseParser { if (data.isEmpty() && !"end".equals(eventType)) { return null; } - return new SseEvent(eventType, data.toString()); + return new SseEvent(eventType, id, data.toString()); } private void reset() { eventType = "message"; + id = null; data.setLength(0); } } @@ -440,13 +536,23 @@ public class SuperAgentOpenApiSseParser { /** * SSE 解析过程中的可变状态。 */ - private static final class ParsedState { + public static final class ParsedState { + private final String sessionId; + private final StringBuilder deltaContent = new StringBuilder(); + private final Set processedEventIds = new LinkedHashSet<>(); + private final Set eventTypes = new LinkedHashSet<>(); + private final List traceEvents = new ArrayList<>(); private boolean endSeen; + private boolean runCompleted; private String runId; + private String runUri; + private String lastEventId; private String profileId; private String profileVersionId; private String modelName; - private String rawAnswer; + private String finalContent; + private String failureCode; + private String failureMessage; private Integer inputTokens; private Integer outputTokens; private Integer totalTokens; @@ -455,6 +561,33 @@ public class SuperAgentOpenApiSseParser { private Integer fallbackInputTokens; private Integer fallbackOutputTokens; private Integer fallbackTotalTokens; - private final List traceEvents = new ArrayList<>(); + + private ParsedState(String sessionId) { + this.sessionId = sessionId; + } + + public boolean endSeen() { + return endSeen; + } + + public String runId() { + return runId; + } + + public void runId(String runId) { + this.runId = runId; + } + + public String runUri() { + return runUri; + } + + public void runUri(String runUri) { + this.runUri = runUri; + } + + public String lastEventId() { + return lastEventId; + } } } diff --git a/server/src/main/java/cn/nianxx/thhotel/integrations/messaging/agentbus/adapter/AgentBusFrameProcessor.java b/server/src/main/java/cn/nianxx/thhotel/integrations/messaging/agentbus/adapter/AgentBusFrameProcessor.java index 3002981..4db7234 100644 --- a/server/src/main/java/cn/nianxx/thhotel/integrations/messaging/agentbus/adapter/AgentBusFrameProcessor.java +++ b/server/src/main/java/cn/nianxx/thhotel/integrations/messaging/agentbus/adapter/AgentBusFrameProcessor.java @@ -1,5 +1,6 @@ package cn.nianxx.thhotel.integrations.messaging.agentbus.adapter; +import cn.nianxx.thhotel.integrations.ai.superagent.service.SuperAgentDispatchService; import cn.nianxx.thhotel.platform.message.common.request.CaptureSourceMessageCommand; import cn.nianxx.thhotel.platform.message.common.result.SourceMessageCaptureResult; import cn.nianxx.thhotel.platform.message.service.SourceMessageCaptureService; @@ -28,6 +29,7 @@ public class AgentBusFrameProcessor { private final AgentBusConnectionStatus status; private final AgentBusProperties properties; private final HotelContextService hotelContextService; + private final SuperAgentDispatchService dispatchService; /** * 注入 AgentBus frame 处理依赖。外部协议转换与平台捕获服务通过稳定命令隔离。 @@ -36,12 +38,14 @@ public class AgentBusFrameProcessor { ObjectMapper objectMapper, AgentBusSourceMessageAdapter sourceMessageAdapter, SourceMessageCaptureService captureService, + SuperAgentDispatchService dispatchService, AgentBusConnectionStatus status, AgentBusProperties properties, HotelContextService hotelContextService) { this.objectMapper = objectMapper; this.sourceMessageAdapter = sourceMessageAdapter; this.captureService = captureService; + this.dispatchService = dispatchService; this.status = status; this.properties = properties; this.hotelContextService = hotelContextService; @@ -78,6 +82,9 @@ public class AgentBusFrameProcessor { hotelId, frame); SourceMessageCaptureResult result = captureService.capture(command); + if ("RECEIVED".equals(result.captureStatus())) { + dispatchService.enqueueAgentBusRealtime(command, result); + } status.markFrameCaptured(); return AgentBusFrameProcessResult.captured(result.inboxId(), result.captureStatus()); } catch (RuntimeException exception) { diff --git a/server/src/main/java/cn/nianxx/thhotel/platform/message/repository/MybatisSourceMessageInboxRepository.java b/server/src/main/java/cn/nianxx/thhotel/platform/message/repository/MybatisSourceMessageInboxRepository.java index e7df978..232eb4b 100644 --- a/server/src/main/java/cn/nianxx/thhotel/platform/message/repository/MybatisSourceMessageInboxRepository.java +++ b/server/src/main/java/cn/nianxx/thhotel/platform/message/repository/MybatisSourceMessageInboxRepository.java @@ -267,6 +267,19 @@ public class MybatisSourceMessageInboxRepository implements SourceMessageInboxRe return inbox.getId(); } + /** + * 读取指定 SourceMessage 的原始 payload JSON。该方法只供内部 AI 分发和排查使用,不向普通前端查询暴露。 + */ + @Override + public Optional findPayloadJson(Long id) { + if (id == null) { + return Optional.empty(); + } + SourceMessagePayloadEntity payload = payloadMapper.selectOne(Wrappers.lambdaQuery() + .eq(SourceMessagePayloadEntity::getInboxId, id)); + return Optional.ofNullable(payload).map(SourceMessagePayloadEntity::getPayloadJson); + } + /** * 记录重复投递 payload 差异,不覆盖第一次保存的原始 payload。 */ diff --git a/server/src/main/java/cn/nianxx/thhotel/platform/message/repository/SourceMessageInboxRepository.java b/server/src/main/java/cn/nianxx/thhotel/platform/message/repository/SourceMessageInboxRepository.java index 168b79b..ed63c70 100644 --- a/server/src/main/java/cn/nianxx/thhotel/platform/message/repository/SourceMessageInboxRepository.java +++ b/server/src/main/java/cn/nianxx/thhotel/platform/message/repository/SourceMessageInboxRepository.java @@ -78,6 +78,11 @@ public interface SourceMessageInboxRepository { */ Long insert(SourceMessageInboxDraft draft); + /** + * 按内部 SourceMessage ID 读取原始 payload JSON,仅供受控内部重放或 AI 分发链路使用。 + */ + Optional findPayloadJson(Long id); + /** * 幂等命中但 payload 变化时,标记差异并保存安全排查摘要。 */ diff --git a/server/src/main/resources/application-dev.yml b/server/src/main/resources/application-dev.yml index ed7f2a3..980418a 100644 --- a/server/src/main/resources/application-dev.yml +++ b/server/src/main/resources/application-dev.yml @@ -30,6 +30,16 @@ agentbus: connect-timeout: 15s capture: enabled: true + superagent-dispatch: + # dev 默认不自动调用 SuperAgent,需要联调时显式开启。 + enabled: ${AGENTBUS_DEV_SUPERAGENT_DISPATCH_ENABLED:${AGENTBUS_SUPERAGENT_DISPATCH_ENABLED:false}} + worker-enabled: ${AGENTBUS_DEV_SUPERAGENT_DISPATCH_WORKER_ENABLED:${AGENTBUS_SUPERAGENT_DISPATCH_WORKER_ENABLED:false}} + batch-size: ${AGENTBUS_DEV_SUPERAGENT_DISPATCH_BATCH_SIZE:${AGENTBUS_SUPERAGENT_DISPATCH_BATCH_SIZE:10}} + max-attempts: ${AGENTBUS_DEV_SUPERAGENT_DISPATCH_MAX_ATTEMPTS:${AGENTBUS_SUPERAGENT_DISPATCH_MAX_ATTEMPTS:3}} + lock-ttl: ${AGENTBUS_DEV_SUPERAGENT_DISPATCH_LOCK_TTL:${AGENTBUS_SUPERAGENT_DISPATCH_LOCK_TTL:5m}} + initial-backoff: ${AGENTBUS_DEV_SUPERAGENT_DISPATCH_INITIAL_BACKOFF:${AGENTBUS_SUPERAGENT_DISPATCH_INITIAL_BACKOFF:30s}} + max-backoff: ${AGENTBUS_DEV_SUPERAGENT_DISPATCH_MAX_BACKOFF:${AGENTBUS_SUPERAGENT_DISPATCH_MAX_BACKOFF:15m}} + worker-fixed-delay-ms: ${AGENTBUS_DEV_SUPERAGENT_DISPATCH_WORKER_FIXED_DELAY_MS:${AGENTBUS_SUPERAGENT_DISPATCH_WORKER_FIXED_DELAY_MS:10000}} superagent: task-result: @@ -41,8 +51,10 @@ superagent: base-url: ${DEERFLOW_DEV_BASE_URL:${DEERFLOW_BASE_URL:}} api-key: ${DEERFLOW_DEV_OPEN_API_KEY:${DEERFLOW_OPEN_API_KEY:}} external-subject-id: ${SUPERAGENT_DEV_DEBUG_EML_EXTERNAL_SUBJECT_ID:${SUPERAGENT_DEBUG_EML_EXTERNAL_SUBJECT_ID:th-hotel-debug-eml-upload}} + agentbus-external-subject-id: ${SUPERAGENT_DEV_AGENTBUS_EXTERNAL_SUBJECT_ID:${SUPERAGENT_AGENTBUS_EXTERNAL_SUBJECT_ID:th-hotel-agentbus-source-message}} connect-timeout: ${SUPERAGENT_DEV_DEBUG_EML_CONNECT_TIMEOUT:${SUPERAGENT_DEBUG_EML_CONNECT_TIMEOUT:15s}} read-timeout: ${SUPERAGENT_DEV_DEBUG_EML_READ_TIMEOUT:${SUPERAGENT_DEBUG_EML_READ_TIMEOUT:180s}} + sse-recovery-max-attempts: ${SUPERAGENT_DEV_OPEN_API_SSE_RECOVERY_MAX_ATTEMPTS:${SUPERAGENT_OPEN_API_SSE_RECOVERY_MAX_ATTEMPTS:5}} aliyun: oss: diff --git a/server/src/main/resources/application-prod.yml b/server/src/main/resources/application-prod.yml index 7f35799..7809594 100644 --- a/server/src/main/resources/application-prod.yml +++ b/server/src/main/resources/application-prod.yml @@ -28,6 +28,16 @@ agentbus: connect-timeout: 15s capture: enabled: true + superagent-dispatch: + # prod 默认关闭自动分发;上线前必须确认 SuperAgent Open API、监控和回滚方案。 + enabled: ${AGENTBUS_PROD_SUPERAGENT_DISPATCH_ENABLED:false} + worker-enabled: ${AGENTBUS_PROD_SUPERAGENT_DISPATCH_WORKER_ENABLED:false} + batch-size: ${AGENTBUS_PROD_SUPERAGENT_DISPATCH_BATCH_SIZE:${AGENTBUS_SUPERAGENT_DISPATCH_BATCH_SIZE:10}} + max-attempts: ${AGENTBUS_PROD_SUPERAGENT_DISPATCH_MAX_ATTEMPTS:${AGENTBUS_SUPERAGENT_DISPATCH_MAX_ATTEMPTS:3}} + lock-ttl: ${AGENTBUS_PROD_SUPERAGENT_DISPATCH_LOCK_TTL:${AGENTBUS_SUPERAGENT_DISPATCH_LOCK_TTL:5m}} + initial-backoff: ${AGENTBUS_PROD_SUPERAGENT_DISPATCH_INITIAL_BACKOFF:${AGENTBUS_SUPERAGENT_DISPATCH_INITIAL_BACKOFF:30s}} + max-backoff: ${AGENTBUS_PROD_SUPERAGENT_DISPATCH_MAX_BACKOFF:${AGENTBUS_SUPERAGENT_DISPATCH_MAX_BACKOFF:15m}} + worker-fixed-delay-ms: ${AGENTBUS_PROD_SUPERAGENT_DISPATCH_WORKER_FIXED_DELAY_MS:${AGENTBUS_SUPERAGENT_DISPATCH_WORKER_FIXED_DELAY_MS:10000}} superagent: task-result: @@ -39,8 +49,10 @@ superagent: base-url: ${DEERFLOW_PROD_BASE_URL:${DEERFLOW_BASE_URL}} api-key: ${DEERFLOW_PROD_OPEN_API_KEY:${DEERFLOW_OPEN_API_KEY}} external-subject-id: ${SUPERAGENT_PROD_DEBUG_EML_EXTERNAL_SUBJECT_ID:${SUPERAGENT_DEBUG_EML_EXTERNAL_SUBJECT_ID:th-hotel-debug-eml-upload}} + agentbus-external-subject-id: ${SUPERAGENT_PROD_AGENTBUS_EXTERNAL_SUBJECT_ID:${SUPERAGENT_AGENTBUS_EXTERNAL_SUBJECT_ID:th-hotel-agentbus-source-message}} connect-timeout: ${SUPERAGENT_PROD_DEBUG_EML_CONNECT_TIMEOUT:${SUPERAGENT_DEBUG_EML_CONNECT_TIMEOUT:15s}} read-timeout: ${SUPERAGENT_PROD_DEBUG_EML_READ_TIMEOUT:${SUPERAGENT_DEBUG_EML_READ_TIMEOUT:180s}} + sse-recovery-max-attempts: ${SUPERAGENT_PROD_OPEN_API_SSE_RECOVERY_MAX_ATTEMPTS:${SUPERAGENT_OPEN_API_SSE_RECOVERY_MAX_ATTEMPTS:5}} aliyun: oss: diff --git a/server/src/main/resources/application-test.yml b/server/src/main/resources/application-test.yml index 1779c98..e2aad1d 100644 --- a/server/src/main/resources/application-test.yml +++ b/server/src/main/resources/application-test.yml @@ -30,6 +30,16 @@ agentbus: connect-timeout: 15s capture: enabled: true + superagent-dispatch: + # test 默认不自动调用 SuperAgent,联调时分别打开 outbox 和 worker。 + enabled: ${AGENTBUS_TEST_SUPERAGENT_DISPATCH_ENABLED:${AGENTBUS_SUPERAGENT_DISPATCH_ENABLED:false}} + worker-enabled: ${AGENTBUS_TEST_SUPERAGENT_DISPATCH_WORKER_ENABLED:${AGENTBUS_SUPERAGENT_DISPATCH_WORKER_ENABLED:false}} + batch-size: ${AGENTBUS_TEST_SUPERAGENT_DISPATCH_BATCH_SIZE:${AGENTBUS_SUPERAGENT_DISPATCH_BATCH_SIZE:10}} + max-attempts: ${AGENTBUS_TEST_SUPERAGENT_DISPATCH_MAX_ATTEMPTS:${AGENTBUS_SUPERAGENT_DISPATCH_MAX_ATTEMPTS:3}} + lock-ttl: ${AGENTBUS_TEST_SUPERAGENT_DISPATCH_LOCK_TTL:${AGENTBUS_SUPERAGENT_DISPATCH_LOCK_TTL:5m}} + initial-backoff: ${AGENTBUS_TEST_SUPERAGENT_DISPATCH_INITIAL_BACKOFF:${AGENTBUS_SUPERAGENT_DISPATCH_INITIAL_BACKOFF:30s}} + max-backoff: ${AGENTBUS_TEST_SUPERAGENT_DISPATCH_MAX_BACKOFF:${AGENTBUS_SUPERAGENT_DISPATCH_MAX_BACKOFF:15m}} + worker-fixed-delay-ms: ${AGENTBUS_TEST_SUPERAGENT_DISPATCH_WORKER_FIXED_DELAY_MS:${AGENTBUS_SUPERAGENT_DISPATCH_WORKER_FIXED_DELAY_MS:10000}} superagent: task-result: @@ -43,8 +53,10 @@ superagent: base-url: ${DEERFLOW_TEST_BASE_URL:${DEERFLOW_BASE_URL:}} api-key: ${DEERFLOW_TEST_OPEN_API_KEY:${DEERFLOW_OPEN_API_KEY:}} external-subject-id: ${SUPERAGENT_TEST_DEBUG_EML_EXTERNAL_SUBJECT_ID:${SUPERAGENT_DEBUG_EML_EXTERNAL_SUBJECT_ID:th-hotel-debug-eml-upload}} + agentbus-external-subject-id: ${SUPERAGENT_TEST_AGENTBUS_EXTERNAL_SUBJECT_ID:${SUPERAGENT_AGENTBUS_EXTERNAL_SUBJECT_ID:th-hotel-agentbus-source-message}} connect-timeout: ${SUPERAGENT_TEST_DEBUG_EML_CONNECT_TIMEOUT:${SUPERAGENT_DEBUG_EML_CONNECT_TIMEOUT:15s}} read-timeout: ${SUPERAGENT_TEST_DEBUG_EML_READ_TIMEOUT:${SUPERAGENT_DEBUG_EML_READ_TIMEOUT:180s}} + sse-recovery-max-attempts: ${SUPERAGENT_TEST_OPEN_API_SSE_RECOVERY_MAX_ATTEMPTS:${SUPERAGENT_OPEN_API_SSE_RECOVERY_MAX_ATTEMPTS:5}} aliyun: oss: diff --git a/server/src/main/resources/application.yml b/server/src/main/resources/application.yml index cc96fb6..2500556 100644 --- a/server/src/main/resources/application.yml +++ b/server/src/main/resources/application.yml @@ -28,6 +28,16 @@ agentbus: max-frame-bytes: 1048576 capture: enabled: ${AGENTBUS_CAPTURE_ENABLED:true} + superagent-dispatch: + # M007 AgentBus 入库后自动分发 SuperAgent,默认关闭,联调环境显式开启。 + enabled: ${AGENTBUS_SUPERAGENT_DISPATCH_ENABLED:false} + worker-enabled: ${AGENTBUS_SUPERAGENT_DISPATCH_WORKER_ENABLED:false} + batch-size: ${AGENTBUS_SUPERAGENT_DISPATCH_BATCH_SIZE:10} + max-attempts: ${AGENTBUS_SUPERAGENT_DISPATCH_MAX_ATTEMPTS:3} + lock-ttl: ${AGENTBUS_SUPERAGENT_DISPATCH_LOCK_TTL:5m} + initial-backoff: ${AGENTBUS_SUPERAGENT_DISPATCH_INITIAL_BACKOFF:30s} + max-backoff: ${AGENTBUS_SUPERAGENT_DISPATCH_MAX_BACKOFF:15m} + worker-fixed-delay-ms: ${AGENTBUS_SUPERAGENT_DISPATCH_WORKER_FIXED_DELAY_MS:10000} superagent: task-result: diff --git a/server/src/main/resources/db/migration/V20__create_superagent_dispatch_run.sql b/server/src/main/resources/db/migration/V20__create_superagent_dispatch_run.sql new file mode 100644 index 0000000..e21b465 --- /dev/null +++ b/server/src/main/resources/db/migration/V20__create_superagent_dispatch_run.sql @@ -0,0 +1,35 @@ +-- M007 AgentBus -> SuperAgent 自动分发:outbox 运行表,只记录 AI 调用过程,不创建业务订单或任务。 +CREATE TABLE platform_superagent_dispatch_run ( + id BIGINT NOT NULL COMMENT 'SuperAgent 自动分发运行 ID', + hotel_id VARCHAR(64) NOT NULL COMMENT '酒店或业务上下文 ID', + source_message_id BIGINT NOT NULL COMMENT '关联的内部 SourceMessage Inbox ID', + source_provider VARCHAR(32) NOT NULL COMMENT '来源 provider,例如 AGENTBUS', + source_channel VARCHAR(32) NOT NULL COMMENT '来源 channel,例如 OUTLOOK 或 EMAIL', + external_message_id VARCHAR(256) NULL COMMENT '外部邮件消息 ID,对应 SourceMessage external_message_id', + external_conversation_id VARCHAR(256) NULL COMMENT '外部邮件会话 ID,对应 SourceMessage external_conversation_id', + dispatch_source VARCHAR(64) NOT NULL COMMENT '分发来源代码,第一版使用 AGENTBUS_REALTIME', + idempotency_key VARCHAR(128) NOT NULL COMMENT '发送给 SuperAgent 的稳定幂等键', + request_id VARCHAR(128) NOT NULL COMMENT '写入 X-Request-ID 的调用关联 ID', + dispatch_status VARCHAR(32) NOT NULL COMMENT '分发状态:PENDING、RUNNING、SUCCEEDED、RETRYABLE_FAILED、FAILED、IGNORED', + attempt_count INT NOT NULL DEFAULT 0 COMMENT '已尝试次数,worker 抢占时递增', + max_attempts INT NOT NULL DEFAULT 3 COMMENT '最大尝试次数', + next_attempt_at DATETIME(6) NULL COMMENT '下次可处理 UTC 时间;终态可为空', + locked_until DATETIME(6) NULL COMMENT 'worker 抢占锁过期 UTC 时间', + superagent_session_id VARCHAR(128) NULL COMMENT 'SuperAgent Open API session ID', + superagent_run_id VARCHAR(128) NULL COMMENT 'SuperAgent run ID', + superagent_run_uri VARCHAR(1024) NULL COMMENT 'SuperAgent run 查询 URI 或路径', + last_event_id VARCHAR(128) NULL COMMENT '最后完整处理的 SSE event id,用于 Last-Event-ID 恢复', + superagent_raw_answer LONGTEXT NULL COMMENT 'SuperAgent 最终原始回答', + superagent_parsed_json LONGTEXT NULL COMMENT '从最终回答解析出的 JSON,无法解析时为空', + superagent_trace_json LONGTEXT NULL COMMENT 'SuperAgent 公开 trace 摘要 JSON,不保存 Secret 或工具敏感入参', + safe_error_code VARCHAR(64) NULL COMMENT '安全错误代码', + safe_error_summary VARCHAR(512) NULL COMMENT '安全错误摘要,不包含正文、HTML、附件 URL 或 Secret', + created_at DATETIME(6) NOT NULL COMMENT '记录创建 UTC 时间', + updated_at DATETIME(6) NOT NULL COMMENT '记录更新 UTC 时间', + PRIMARY KEY (id), + UNIQUE KEY uk_superagent_dispatch_source_message (source_message_id, dispatch_source), + KEY idx_superagent_dispatch_status_next (dispatch_status, next_attempt_at), + KEY idx_superagent_dispatch_lock (dispatch_status, locked_until), + KEY idx_superagent_dispatch_external_message (hotel_id, external_message_id) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_bin +COMMENT='SuperAgent 自动分发 outbox 运行表,记录 SourceMessage 入库后异步调用 SuperAgent 的状态和结果'; diff --git a/server/src/test/java/cn/nianxx/thhotel/integrations/ai/superagent/service/impl/SuperAgentDispatchServiceImplTest.java b/server/src/test/java/cn/nianxx/thhotel/integrations/ai/superagent/service/impl/SuperAgentDispatchServiceImplTest.java new file mode 100644 index 0000000..c0ea830 --- /dev/null +++ b/server/src/test/java/cn/nianxx/thhotel/integrations/ai/superagent/service/impl/SuperAgentDispatchServiceImplTest.java @@ -0,0 +1,193 @@ +package cn.nianxx.thhotel.integrations.ai.superagent.service.impl; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.never; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; + +import cn.nianxx.thhotel.integrations.ai.superagent.common.dto.SuperAgentDispatchRunDraft; +import cn.nianxx.thhotel.integrations.ai.superagent.common.dto.SuperAgentDispatchRunSnapshot; +import cn.nianxx.thhotel.integrations.ai.superagent.common.dto.SuperAgentDispatchRunSuccessUpdate; +import cn.nianxx.thhotel.integrations.ai.superagent.common.enums.SuperAgentDispatchSource; +import cn.nianxx.thhotel.integrations.ai.superagent.common.enums.SuperAgentDispatchStatus; +import cn.nianxx.thhotel.integrations.ai.superagent.common.request.SuperAgentOpenApiMessageRequest; +import cn.nianxx.thhotel.integrations.ai.superagent.common.result.SuperAgentOpenApiResult; +import cn.nianxx.thhotel.integrations.ai.superagent.repository.SuperAgentDispatchRunRepository; +import cn.nianxx.thhotel.integrations.ai.superagent.service.SuperAgentOpenApiClient; +import cn.nianxx.thhotel.platform.message.common.request.CaptureSourceMessageCommand; +import cn.nianxx.thhotel.platform.message.common.result.SourceMessageCaptureResult; +import cn.nianxx.thhotel.platform.message.repository.SourceMessageInboxRepository; +import com.fasterxml.jackson.databind.ObjectMapper; +import java.time.LocalDateTime; +import java.util.List; +import java.util.Map; +import java.util.Optional; +import org.junit.jupiter.api.Test; +import org.mockito.ArgumentCaptor; + +class SuperAgentDispatchServiceImplTest { + + @Test + void shouldCreatePendingDispatchForReceivedAgentBusSourceMessage() { + SuperAgentDispatchRunRepository runRepository = mock(SuperAgentDispatchRunRepository.class); + when(runRepository.insertIfAbsent(any(SuperAgentDispatchRunDraft.class))).thenReturn(99001L); + SuperAgentDispatchServiceImpl service = service(runRepository, mock(SourceMessageInboxRepository.class), + mock(SuperAgentOpenApiClient.class), properties(true, false)); + + service.enqueueAgentBusRealtime(agentBusCommand("AGENTBUS"), new SourceMessageCaptureResult( + 88001L, + true, + false, + "RECEIVED")); + + ArgumentCaptor captor = ArgumentCaptor.forClass(SuperAgentDispatchRunDraft.class); + verify(runRepository).insertIfAbsent(captor.capture()); + assertThat(captor.getValue().sourceMessageId()).isEqualTo(88001L); + assertThat(captor.getValue().dispatchSource()).isEqualTo(SuperAgentDispatchSource.AGENTBUS_REALTIME.code()); + assertThat(captor.getValue().dispatchStatus()).isEqualTo(SuperAgentDispatchStatus.PENDING.code()); + assertThat(captor.getValue().idempotencyKey()).isEqualTo("agentbus-source-message-88001"); + } + + @Test + void shouldIgnoreNonAgentBusOrFailedCaptureWhenEnqueueDispatch() { + SuperAgentDispatchRunRepository runRepository = mock(SuperAgentDispatchRunRepository.class); + SuperAgentDispatchServiceImpl service = service(runRepository, mock(SourceMessageInboxRepository.class), + mock(SuperAgentOpenApiClient.class), properties(true, false)); + + service.enqueueAgentBusRealtime(agentBusCommand("DEBUG_EML_UPLOAD"), new SourceMessageCaptureResult( + 88002L, + true, + false, + "RECEIVED")); + service.enqueueAgentBusRealtime(agentBusCommand("AGENTBUS"), new SourceMessageCaptureResult( + 88003L, + true, + false, + "FAILED")); + service.enqueueAgentBusRealtime(agentBusCommand("AGENTBUS"), new SourceMessageCaptureResult( + 88004L, + false, + false, + "RECEIVED")); + + verify(runRepository, never()).insertIfAbsent(any(SuperAgentDispatchRunDraft.class)); + } + + @Test + void shouldProcessDueDispatchAndPersistSuperAgentSuccess() { + SuperAgentDispatchRunRepository runRepository = mock(SuperAgentDispatchRunRepository.class); + SourceMessageInboxRepository sourceMessageRepository = mock(SourceMessageInboxRepository.class); + SuperAgentOpenApiClient openApiClient = mock(SuperAgentOpenApiClient.class); + SuperAgentDispatchServiceImpl service = service(runRepository, sourceMessageRepository, openApiClient, + properties(true, true)); + SuperAgentDispatchRunSnapshot snapshot = snapshot(); + when(runRepository.claimDue(any(), any(), any(), any(), any(Integer.class))).thenReturn(List.of(snapshot)); + when(sourceMessageRepository.findPayloadJson(88001L)).thenReturn(Optional.of(""" + {"source":{"external_message_id":"mail-agentbus-001"},"body":{"text":"Please book a room"}} + """)); + when(openApiClient.invokeMessage(any(SuperAgentOpenApiMessageRequest.class))).thenReturn(new SuperAgentOpenApiResult( + "session-dispatch-001", + "run-dispatch-001", + "profile-dispatch", + "version-dispatch", + "model-dispatch", + "{\"route_code\":\"S10\"}", + 10, + 5, + 15, + List.of("trace", "end"), + List.of(), + "/api/open/agent-sessions/session-dispatch-001/runs/run-dispatch-001", + "4")); + + int processed = service.processDueDispatches(); + + assertThat(processed).isEqualTo(1); + ArgumentCaptor requestCaptor = + ArgumentCaptor.forClass(SuperAgentOpenApiMessageRequest.class); + verify(openApiClient).invokeMessage(requestCaptor.capture()); + assertThat(requestCaptor.getValue().idempotencyKey()).isEqualTo("agentbus-source-message-88001"); + assertThat(requestCaptor.getValue().metadata()).containsEntry("external_message_id", "mail-agentbus-001"); + ArgumentCaptor updateCaptor = + ArgumentCaptor.forClass(SuperAgentDispatchRunSuccessUpdate.class); + verify(runRepository).markSucceeded(updateCaptor.capture()); + assertThat(updateCaptor.getValue().id()).isEqualTo(99001L); + assertThat(updateCaptor.getValue().superagentRunId()).isEqualTo("run-dispatch-001"); + assertThat(updateCaptor.getValue().lastEventId()).isEqualTo("4"); + assertThat(updateCaptor.getValue().superagentParsedJson()).isEqualTo("{\"route_code\":\"S10\"}"); + } + + private SuperAgentDispatchServiceImpl service( + SuperAgentDispatchRunRepository runRepository, + SourceMessageInboxRepository sourceMessageRepository, + SuperAgentOpenApiClient openApiClient, + SuperAgentDispatchProperties properties) { + return new SuperAgentDispatchServiceImpl( + properties, + new SuperAgentOpenApiProperties(), + runRepository, + sourceMessageRepository, + openApiClient, + new ObjectMapper()); + } + + private SuperAgentDispatchProperties properties(boolean enabled, boolean workerEnabled) { + SuperAgentDispatchProperties properties = new SuperAgentDispatchProperties(); + properties.setEnabled(enabled); + properties.setWorkerEnabled(workerEnabled); + return properties; + } + + private CaptureSourceMessageCommand agentBusCommand(String provider) { + return new CaptureSourceMessageCommand( + "HOTEL-TEST", + provider, + "OUTLOOK", + "mail-agentbus-001", + "conversation-agentbus-001", + "frame-agentbus-001", + "session-agentbus-001", + null, + null, + "guest@example.test", + "Booking", + "Please book a room", + "Please book a room", + "{\"source\":{\"external_message_id\":\"mail-agentbus-001\"}}", + "agentbus-outlook-v1", + List.of()); + } + + private SuperAgentDispatchRunSnapshot snapshot() { + LocalDateTime now = LocalDateTime.of(2026, 7, 12, 12, 0); + return new SuperAgentDispatchRunSnapshot( + 99001L, + "HOTEL-TEST", + 88001L, + "AGENTBUS", + "OUTLOOK", + "mail-agentbus-001", + "conversation-agentbus-001", + SuperAgentDispatchSource.AGENTBUS_REALTIME.code(), + "agentbus-source-message-88001", + "agentbus-dispatch-source-88001", + SuperAgentDispatchStatus.RUNNING.code(), + 1, + 3, + now, + now.plusMinutes(5), + null, + null, + null, + null, + null, + null, + null, + null, + null, + now, + now); + } +} diff --git a/server/src/test/java/cn/nianxx/thhotel/integrations/ai/superagent/service/impl/SuperAgentOpenApiClientImplTest.java b/server/src/test/java/cn/nianxx/thhotel/integrations/ai/superagent/service/impl/SuperAgentOpenApiClientImplTest.java index bb96f19..038c11f 100644 --- a/server/src/test/java/cn/nianxx/thhotel/integrations/ai/superagent/service/impl/SuperAgentOpenApiClientImplTest.java +++ b/server/src/test/java/cn/nianxx/thhotel/integrations/ai/superagent/service/impl/SuperAgentOpenApiClientImplTest.java @@ -15,6 +15,7 @@ import java.time.Duration; import java.util.ArrayList; import java.util.List; import java.util.Map; +import java.util.concurrent.atomic.AtomicInteger; import java.util.concurrent.atomic.AtomicReference; import org.junit.jupiter.api.Test; @@ -35,20 +36,25 @@ class SuperAgentOpenApiClientImplTest { server.createContext("/api/open/agent-sessions/session-http-001/messages/stream", exchange -> { streamQuery.set(exchange.getRequestURI().getRawQuery()); byte[] response = """ - event: metadata - data: {"run_id":"run-http-001","resolved_profile_id":"profile-http","resolved_profile_version_id":"version-http"} - + id: 1 event: trace data: {"event":"reasoning.summary","run_id":"run-http-001","text":"正在分析 Debug 邮件。","ts":"2026-07-11T10:00:01Z"} - event: values - data: {"messages":[{"type":"ai","content":"{\\"route_code\\":\\"S10\\"}","response_metadata":{"finish_reason":"stop","model_name":"debug-model"},"usage_metadata":{"input_tokens":9,"output_tokens":4,"total_tokens":13}}]} + id: 2 + event: trace + data: {"event":"message.final","run_id":"run-http-001","text":"{\\"route_code\\":\\"S10\\"}"} + id: 3 + event: trace + data: {"event":"run.completed","run_id":"run-http-001","status":"success"} + + id: 4 event: end data: {} """.getBytes(StandardCharsets.UTF_8); exchange.getResponseHeaders().add("Content-Type", "text/event-stream"); + exchange.getResponseHeaders().add("Content-Location", "/api/open/agent-sessions/session-http-001/runs/run-http-001"); exchange.sendResponseHeaders(200, response.length); try (OutputStream outputStream = exchange.getResponseBody()) { outputStream.write(response); @@ -77,12 +83,101 @@ class SuperAgentOpenApiClientImplTest { assertThat(streamQuery.get()).isEqualTo("include_trace=true"); assertThat(result.sessionId()).isEqualTo("session-http-001"); assertThat(result.runId()).isEqualTo("run-http-001"); - assertThat(result.traceEvents()).hasSize(1); - assertThat(traceEvents).hasSize(1); + assertThat(result.lastEventId()).isEqualTo("4"); + assertThat(result.traceEvents()).hasSize(3); + assertThat(traceEvents).hasSize(3); assertThat(traceEvents.get(0).event()).isEqualTo("reasoning.summary"); assertThat(traceEvents.get(0).text()).isEqualTo("正在分析 Debug 邮件。"); } finally { server.stop(0); } } + + @Test + void shouldRecoverPrematureEofBySubscribingRunEventsWithoutRepostingInitialMessage() throws Exception { + HttpServer server = HttpServer.create(new InetSocketAddress(InetAddress.getLoopbackAddress(), 0), 0); + AtomicInteger initialMessagePostCount = new AtomicInteger(); + AtomicReference lastEventId = new AtomicReference<>(); + server.createContext("/api/open/agent-sessions", exchange -> { + byte[] response = "{\"session_id\":\"session-recover-001\"}".getBytes(StandardCharsets.UTF_8); + exchange.getResponseHeaders().add("Content-Type", "application/json"); + exchange.sendResponseHeaders(200, response.length); + try (OutputStream outputStream = exchange.getResponseBody()) { + outputStream.write(response); + } + }); + server.createContext("/api/open/agent-sessions/session-recover-001/messages/stream", exchange -> { + initialMessagePostCount.incrementAndGet(); + byte[] response = """ + id: 1 + event: trace + data: {"event":"message.delta","run_id":"run-recover-001","text":"{\\"route"} + + """.getBytes(StandardCharsets.UTF_8); + exchange.getResponseHeaders().add("Content-Type", "text/event-stream"); + exchange.getResponseHeaders().add("Content-Location", "/api/open/agent-sessions/session-recover-001/runs/run-recover-001"); + exchange.sendResponseHeaders(200, response.length); + try (OutputStream outputStream = exchange.getResponseBody()) { + outputStream.write(response); + } + }); + server.createContext("/api/open/agent-sessions/session-recover-001/runs/run-recover-001", exchange -> { + byte[] response = "{\"status\":\"running\"}".getBytes(StandardCharsets.UTF_8); + exchange.getResponseHeaders().add("Content-Type", "application/json"); + exchange.sendResponseHeaders(200, response.length); + try (OutputStream outputStream = exchange.getResponseBody()) { + outputStream.write(response); + } + }); + server.createContext("/api/open/agent-sessions/session-recover-001/runs/run-recover-001/events", exchange -> { + lastEventId.set(exchange.getRequestHeaders().getFirst("Last-Event-ID")); + byte[] response = """ + id: 2 + event: trace + data: {"event":"message.final","run_id":"run-recover-001","text":"{\\"route_code\\":\\"S10\\"}"} + + id: 3 + event: trace + data: {"event":"run.completed","run_id":"run-recover-001","status":"success"} + + id: 4 + event: end + data: {} + + """.getBytes(StandardCharsets.UTF_8); + exchange.getResponseHeaders().add("Content-Type", "text/event-stream"); + exchange.sendResponseHeaders(200, response.length); + try (OutputStream outputStream = exchange.getResponseBody()) { + outputStream.write(response); + } + }); + server.start(); + try { + SuperAgentOpenApiProperties properties = new SuperAgentOpenApiProperties(); + properties.setEnabled(true); + properties.setBaseUrl("http://127.0.0.1:" + server.getAddress().getPort()); + properties.setApiKey("df_open_test"); + properties.setExternalSubjectId("debug-subject"); + properties.setConnectTimeout(Duration.ofSeconds(5)); + properties.setReadTimeout(Duration.ofSeconds(5)); + properties.setSseRecoveryMaxAttempts(2); + SuperAgentOpenApiClientImpl client = new SuperAgentOpenApiClientImpl( + properties, + new SuperAgentOpenApiSseParser(new ObjectMapper()), + new ObjectMapper()); + + SuperAgentOpenApiResult result = client.invokeMailDebug(new SuperAgentMailDebugRequest( + "debug message", + "debug-recover-idempotency", + Map.of("source", "unit-test"))); + + assertThat(initialMessagePostCount).hasValue(1); + assertThat(lastEventId).hasValue("1"); + assertThat(result.rawAnswer()).isEqualTo("{\"route_code\":\"S10\"}"); + assertThat(result.runId()).isEqualTo("run-recover-001"); + assertThat(result.lastEventId()).isEqualTo("4"); + } finally { + server.stop(0); + } + } } diff --git a/server/src/test/java/cn/nianxx/thhotel/integrations/ai/superagent/service/impl/SuperAgentOpenApiSseParserTest.java b/server/src/test/java/cn/nianxx/thhotel/integrations/ai/superagent/service/impl/SuperAgentOpenApiSseParserTest.java index 5bba1f9..9664f77 100644 --- a/server/src/test/java/cn/nianxx/thhotel/integrations/ai/superagent/service/impl/SuperAgentOpenApiSseParserTest.java +++ b/server/src/test/java/cn/nianxx/thhotel/integrations/ai/superagent/service/impl/SuperAgentOpenApiSseParserTest.java @@ -25,6 +25,9 @@ class SuperAgentOpenApiSseParserTest { event: values data: {"messages":[{"type":"human","content":"input"},{"type":"ai","content":"{\\"ai_task_results\\":[{\\"task_type\\":\\"New Booking\\"}]}","response_metadata":{"finish_reason":"stop","model_name":"debug-model"},"usage_metadata":{"input_tokens":11,"output_tokens":7,"total_tokens":18}}]} + event: trace + data: {"event":"run.completed","run_id":"run-debug-001","status":"success"} + event: end data: {} @@ -40,7 +43,7 @@ class SuperAgentOpenApiSseParserTest { assertThat(result.inputTokens()).isEqualTo(11); assertThat(result.outputTokens()).isEqualTo(7); assertThat(result.totalTokens()).isEqualTo(18); - assertThat(result.eventTypes()).containsExactly("metadata", "messages", "values", "end"); + assertThat(result.eventTypes()).containsExactly("metadata", "messages", "values", "trace", "end"); } @Test @@ -52,6 +55,9 @@ class SuperAgentOpenApiSseParserTest { event: messages data: {"type":"ai","content":"{\\"ai_task_results\\":[{\\"task_type\\":\\"Update Booking\\"}]}","response_metadata":{"finish_reason":"stop","model_name":"debug-model-from-message"},"usage_metadata":{"input_tokens":21,"output_tokens":13,"total_tokens":34}} + event: trace + data: {"event":"run.completed","run_id":"run-debug-002","status":"success"} + event: end data: {} @@ -65,7 +71,7 @@ class SuperAgentOpenApiSseParserTest { assertThat(result.inputTokens()).isEqualTo(21); assertThat(result.outputTokens()).isEqualTo(13); assertThat(result.totalTokens()).isEqualTo(34); - assertThat(result.eventTypes()).containsExactly("metadata", "messages", "end"); + assertThat(result.eventTypes()).containsExactly("metadata", "messages", "trace", "end"); } @Test @@ -77,6 +83,9 @@ class SuperAgentOpenApiSseParserTest { event: messages data: {"type":"ai","content":"{\\"ai_task_results\\":[{\\"task_type\\":\\"Rooming List\\"}]}","response_metadata":{"model_name":"debug-model-fallback"}} + event: trace + data: {"event":"run.completed","run_id":"run-debug-003","status":"success"} + event: end data: {} @@ -98,21 +107,23 @@ class SuperAgentOpenApiSseParserTest { assertThatThrownBy(() -> parser.parse("session-debug-partial", sse)) .isInstanceOf(SuperAgentOpenApiException.class) - .hasMessageContaining("SuperAgent SSE 未收到结束事件。"); + .hasMessageContaining("SuperAgent SSE 提前结束,尚未收到 end 事件。"); } @Test - void shouldAcceptMissingEndEventWhenFinalAiContentExists() { + void shouldFailWhenEndEventMissingEvenIfFinalAiContentExists() { String sse = """ event: messages data: {"type":"ai","content":"{\\"ai_task_results\\":[]}","response_metadata":{"finish_reason":"stop"}} + event: trace + data: {"event":"run.completed","run_id":"run-debug-004","status":"success"} + """; - SuperAgentOpenApiResult result = parser.parse("session-debug-004", sse); - - assertThat(result.rawAnswer()).isEqualTo("{\"ai_task_results\":[]}"); - assertThat(result.eventTypes()).containsExactly("messages"); + assertThatThrownBy(() -> parser.parse("session-debug-004", sse)) + .isInstanceOf(SuperAgentOpenApiException.class) + .hasMessageContaining("SuperAgent SSE 提前结束,尚未收到 end 事件。"); } @Test @@ -124,6 +135,9 @@ class SuperAgentOpenApiSseParserTest { event: values data: {"messages":[{"type":"human","content":"input"}]} + event: trace + data: {"event":"run.completed","run_id":"run-debug-005","status":"success"} + event: end data: {} @@ -137,6 +151,9 @@ class SuperAgentOpenApiSseParserTest { @Test void shouldNotUseMessagesAfterEndAsFinalAnswer() { String sse = """ + event: trace + data: {"event":"run.completed","run_id":"run-debug-006","status":"success"} + event: end data: {} @@ -156,6 +173,9 @@ class SuperAgentOpenApiSseParserTest { event: messages data: {"type":"ai","content":"{\\"ai_task_results\\":[{\\"task_type\\":\\"Voucher Payment\\"}]}","response_metadata":{"finish_reason":"stop"}} + event: trace + data: {"event":"run.completed","run_id":"run-debug-007","status":"success"} + event: end """; @@ -163,7 +183,7 @@ class SuperAgentOpenApiSseParserTest { SuperAgentOpenApiResult result = parser.parse("session-debug-007", sse); assertThat(result.rawAnswer()).isEqualTo("{\"ai_task_results\":[{\"task_type\":\"Voucher Payment\"}]}"); - assertThat(result.eventTypes()).containsExactly("messages", "end"); + assertThat(result.eventTypes()).containsExactly("messages", "trace", "end"); } @Test @@ -172,6 +192,9 @@ class SuperAgentOpenApiSseParserTest { event: messages data: {"type":"ai","content":{"ai_task_results":[{"task_type":"Cancel Booking"}]},"response_metadata":{"finish_reason":"stop"}} + event: trace + data: {"event":"run.completed","run_id":"run-debug-008","status":"success"} + event: end data: {} @@ -200,6 +223,9 @@ class SuperAgentOpenApiSseParserTest { event: values data: {"messages":[{"type":"ai","content":"{\\"route_code\\":\\"S10\\"}","response_metadata":{"finish_reason":"stop","model_name":"debug-model"},"usage_metadata":{"input_tokens":31,"output_tokens":5,"total_tokens":36}}]} + event: trace + data: {"event":"run.completed","run_id":"run-debug-trace","status":"success"} + event: end data: {} @@ -210,8 +236,8 @@ class SuperAgentOpenApiSseParserTest { assertThat(result.rawAnswer()).isEqualTo("{\"route_code\":\"S10\"}"); assertThat(result.eventTypes()).containsExactly("metadata", "trace", "values", "end"); - assertThat(result.traceEvents()).hasSize(3); - assertThat(emittedTraceEvents).hasSize(3); + assertThat(result.traceEvents()).hasSize(4); + assertThat(emittedTraceEvents).hasSize(4); assertThat(result.traceEvents().get(0).event()).isEqualTo("reasoning.summary"); assertThat(result.traceEvents().get(0).text()).isEqualTo("正在分析邮件和附件。"); assertThat(result.traceEvents().get(1).toolName()).isEqualTo("th_hotel_query_case_context"); diff --git a/server/src/test/java/cn/nianxx/thhotel/integrations/messaging/agentbus/adapter/AgentBusFrameProcessorTest.java b/server/src/test/java/cn/nianxx/thhotel/integrations/messaging/agentbus/adapter/AgentBusFrameProcessorTest.java index d437443..213517c 100644 --- a/server/src/test/java/cn/nianxx/thhotel/integrations/messaging/agentbus/adapter/AgentBusFrameProcessorTest.java +++ b/server/src/test/java/cn/nianxx/thhotel/integrations/messaging/agentbus/adapter/AgentBusFrameProcessorTest.java @@ -7,6 +7,7 @@ import static org.mockito.Mockito.verify; import static org.mockito.Mockito.verifyNoInteractions; import static org.mockito.Mockito.when; +import cn.nianxx.thhotel.integrations.ai.superagent.service.SuperAgentDispatchService; import cn.nianxx.thhotel.platform.message.common.request.CaptureSourceMessageCommand; import cn.nianxx.thhotel.platform.message.common.result.SourceMessageCaptureResult; import cn.nianxx.thhotel.platform.message.service.SourceMessageCaptureService; @@ -38,10 +39,12 @@ class AgentBusFrameProcessorTest { @Test void shouldCaptureBusinessFrameIntoSourceMessageInbox() { SourceMessageCaptureService captureService = mock(SourceMessageCaptureService.class); + SourceMessageCaptureResult captureResult = new SourceMessageCaptureResult(88001L, true, false, "RECEIVED"); when(captureService.capture(any(CaptureSourceMessageCommand.class))) - .thenReturn(new SourceMessageCaptureResult(88001L, true, false, "RECEIVED")); + .thenReturn(captureResult); + SuperAgentDispatchService dispatchService = mock(SuperAgentDispatchService.class); AgentBusConnectionStatus status = new AgentBusConnectionStatus(); - AgentBusFrameProcessor processor = processor(captureService, status, properties(true, 4096)); + AgentBusFrameProcessor processor = processor(captureService, dispatchService, status, properties(true, 4096)); AgentBusFrameProcessResult result = processor.process(""" { @@ -68,6 +71,31 @@ class AgentBusFrameProcessorTest { assertThat(captor.getValue().hotelId()).isEqualTo("HOTEL-SYSTEM"); assertThat(captor.getValue().externalMessageId()).isEqualTo("mail-agentbus-capture-001"); assertThat(captor.getValue().providerFrameId()).isEqualTo("frame-agentbus-capture-001"); + verify(dispatchService).enqueueAgentBusRealtime(captor.getValue(), captureResult); + } + + @Test + void shouldNotEnqueueSuperAgentDispatchWhenCaptureFailed() { + SourceMessageCaptureService captureService = mock(SourceMessageCaptureService.class); + when(captureService.capture(any(CaptureSourceMessageCommand.class))) + .thenReturn(new SourceMessageCaptureResult(88002L, true, false, "FAILED")); + SuperAgentDispatchService dispatchService = mock(SuperAgentDispatchService.class); + AgentBusConnectionStatus status = new AgentBusConnectionStatus(); + AgentBusFrameProcessor processor = processor(captureService, dispatchService, status, properties(true, 4096)); + + AgentBusFrameProcessResult result = processor.process(""" + { + "id": "frame-agentbus-failed-001", + "payload": { + "body": {"text": "Missing source message id"}, + "source": {"channel": "email"} + } + } + """); + + assertThat(result.outcome()).isEqualTo("CAPTURED"); + assertThat(result.captureStatus()).isEqualTo("FAILED"); + verifyNoInteractions(dispatchService); } @Test @@ -108,12 +136,21 @@ class AgentBusFrameProcessorTest { SourceMessageCaptureService captureService, AgentBusConnectionStatus status, AgentBusProperties properties) { + return processor(captureService, mock(SuperAgentDispatchService.class), status, properties); + } + + private AgentBusFrameProcessor processor( + SourceMessageCaptureService captureService, + SuperAgentDispatchService dispatchService, + AgentBusConnectionStatus status, + AgentBusProperties properties) { HotelContextService hotelContextService = mock(HotelContextService.class); when(hotelContextService.resolveSystemHotelId()).thenReturn("HOTEL-SYSTEM"); return new AgentBusFrameProcessor( objectMapper, new AgentBusSourceMessageAdapter(objectMapper), captureService, + dispatchService, status, properties, hotelContextService);