fix: separate swarm runtime integration

Add separate Runtime mode selection for ordinary sub and swarm flows, including Swarm-specific create payload shaping and Azure VM env wiring. Document the ordinary sub artifact callback gap, swarm runtime findings, PayPal billing boundaries, deployment migration requirements, and desktop/API progress.

Constraint: Keep ordinary sub and HeiCode-Swarm Runtime deployments separate

Confidence: high

Scope-risk: moderate

Tests: go test ./...
This commit is contained in:
gongzhiyong
2026-05-29 17:31:24 +08:00
parent ed9136d29d
commit 70663f47ee
10 changed files with 1888 additions and 38 deletions
@@ -0,0 +1,297 @@
# Heicode Manager 更换部署服务配置清单
本文用于后续将 Heicode Manager 从当前 Azure VM 迁移到 Azure Container Apps、AKS、App Service Container 或其他容器托管服务时核对配置。结论先写清楚:**更换部署服务不是只切换域名**,必须同时迁移身份、密钥、环境变量、持久化、网络访问和回调地址。
## 1. 当前生产环境事实
| 项目 | 当前情况 |
|------|----------|
| 生产入口 | `https://code.xinghanlab.com` |
| 当前承载方式 | Azure VM 上运行 Docker 容器 |
| VM | `heicode` |
| Resource Group | `HEICODE` |
| 区域 | `southeastasia` |
| 应用容器 | `heicode` |
| 数据库 | Azure PostgreSQL: `heicode.postgres.database.azure.com` / DB `heicode` |
| Redis | Azure Redis: `heicode.redis.cache.windows.net:6380`,TLS |
| Runtime/Agent Manager | `http://20.212.121.126` |
| 当前 Runtime 创建入口 | `/api/swarms` |
| 当前线上版本参考 | `1.4.18` |
当前 VM 的 Managed Identity 状态:生产验证时 Azure metadata 返回 `Identity not found`,说明当前 VM 还没有可用的托管身份,或没有配置正确的 user-assigned identity。
## 2. 迁移时必须保留的核心环境变量
### 2.1 Manager 基础配置
这些变量要从当前 VM `.env` 迁到新服务,具体值以生产 `.env` 为准:
```env
SQL_DSN=<Azure PostgreSQL 连接串>
REDIS_CONN_STRING=<Azure Redis TLS 连接串>
SESSION_SECRET=<现有生产会话密钥>
CRYPTO_SECRET=<现有生产加密密钥>
FRONTEND_BASE_URL=https://code.xinghanlab.com
HEICODE_PUBLIC_BASE_URL=https://code.xinghanlab.com
```
注意:
- `SESSION_SECRET` / `CRYPTO_SECRET` 不能更换,否则可能影响现有登录态、加密数据或历史兼容。
- 数据库和 Redis 不应迁成容器内本地服务,继续使用 Azure 托管服务。
### 2.2 Agent Manager / 普通 sub / 蜂群联调配置
```env
AGNET_RUNTIME_ENABLED=true
AGNET_RUNTIME_BASE_URL=http://20.212.121.126
AGNET_RUNTIME_CREATE_PATH=/api/swarms
AGNET_RUNTIME_STOP_PATH=/api/swarms/{swarm_id}/stop
AGNET_RUNTIME_APPROVAL_DECISION_PATH=/api/swarms/{swarm_id}/approvals/{approval_id}
AGNET_RUNTIME_HEALTH_PATH=/api/agnet/health
AGNET_RUNTIME_TIMEOUT_SECONDS=15
AGNET_RUNTIME_SERVICE_TOKEN=<生产 service token>
AGNET_CALLBACK_TOKEN=<生产 callback 兼容 token>
AGNET_RUNTIME_CALLBACK_SIGNING_SECRET_REF=azkv://heicode-kv.vault.azure.net/secrets/agnet-callback-signing-key
```
当前生产为了让 Agent Manager 自动 callback 先跑通,配置了 HMAC fallback:
```env
AGNET_CALLBACK_SIGNING_SECRET=<生产 callback 签名密钥>
```
正式方案建议用 Azure Key Vault 托管该签名密钥,减少明文环境变量。
### 2.3 Azure Key Vault 配置
```env
AZURE_KEY_VAULT_URL=https://heicode-kv.vault.azure.net
```
如果新服务使用 user-assigned managed identity,还需要:
```env
AZURE_CLIENT_ID=<user-assigned managed identity client id>
```
如果使用 system-assigned managed identity,通常不需要配置 `AZURE_CLIENT_ID`。
## 3. Managed Identity 和 Key Vault 授权
迁到新服务后,VM 的身份不会自动跟过去。每种承载服务都要重新确认身份。
| 部署服务 | 要做什么 |
|----------|----------|
| Azure Container Apps | 给 Container App 开启 system-assigned identity,或挂载 user-assigned identity |
| AKS | 使用 workload identity 或 pod identity,并给对应身份授权 |
| App Service Container | 给 App Service 开启 managed identity |
| 新 Azure VM | 给新 VM 开启 system-assigned identity,或绑定 user-assigned identity |
Key Vault:`heicode-kv`
至少需要授权:
| 用途 | 权限 / RBAC |
|------|-------------|
| 读取 callback 签名密钥 | `secrets/get` |
| 健康检查列举能力 | `secrets/list` |
| Manager 写入资源密钥 PutSecret | `secrets/set` |
使用 Azure RBAC 时可参考:
| 场景 | 角色 |
|------|------|
| 只读取 Secret | `Key Vault Secrets User` |
| 读取并写入 Secret | `Key Vault Secrets Officer` |
Key Vault 里必须存在:
```text
agnet-callback-signing-key
```
建议 Secret value 为 JSON:
```json
{"callback_signing_secret":"实际签名密钥"}
```
## 4. 持久化文件和日志
当前 VM 容器通过 bind mount 保存容器内文件。迁移到容器托管服务时不能继续依赖 VM 本地磁盘。
需要确认这些路径是否存在生产数据:
| 路径 | 用途 | 迁移建议 |
|------|------|----------|
| `/data` | 上传文件、运行时数据、应用持久文件 | 迁到 Azure Files、Blob Storage 或服务支持的持久卷 |
| `/app/logs` | 应用日志 | 迁到平台日志、Azure Monitor 或持久卷 |
迁移前要确认:
- 哪些文件必须保留。
- 哪些文件可以重建。
- 新服务重启、扩缩容后文件是否仍存在。
- 多副本部署时文件是否共享一致。
## 5. 网络和防火墙
迁移后新服务必须能访问:
| 目标 | 端口 / 协议 | 说明 |
|------|-------------|------|
| Azure PostgreSQL | `5432` / TLS | Manager 主库 |
| Azure Redis | `6380` / TLS | 缓存、会话或队列 |
| Agent Manager | `http://20.212.121.126` | 普通 sub / 蜂群 Runtime |
| Azure Key Vault | `https://heicode-kv.vault.azure.net` | 读取 callback 签名密钥和资源密钥 |
| NewAPI / 模型网关 | 以生产配置为准 | 模型调用、计费、渠道 |
需要特别核对:
- PostgreSQL 防火墙是否允许新服务出站 IP。
- Redis 防火墙/VNet 是否允许新服务访问。
- Key Vault 防火墙是否允许新服务访问。
- 如果使用 VNet 集成,DNS 解析是否正常。
- Agent Manager 使用 IP 联调时,后续切域名要同步更新 `AGNET_RUNTIME_BASE_URL`。
## 6. 域名和回调地址
域名是最后切换项,不是迁移的第一步。
迁移前建议流程:
1. 新服务先使用临时域名或默认域名启动。
2. 配好数据库、Redis、Key Vault、Agent Manager 环境变量。
3. 使用临时域名完成全量冒烟。
4. 确认 callback URL 是否仍为正式域名。
5. 再切 `code.xinghanlab.com` 到新服务。
需要保证:
```env
HEICODE_PUBLIC_BASE_URL=https://code.xinghanlab.com
```
因为 Manager 发给 Agent Manager 的 callback 地址会基于它生成:
```text
https://code.xinghanlab.com/api/agnet/callbacks/swarm-events
```
如果临时域名联调,要么设置临时 `HEICODE_PUBLIC_BASE_URL`,要么确保正式域名已经能路由到新服务。
## 7. Container Apps 迁移示例核对
如果目标是 Azure Container Apps,至少要完成:
| 项目 | 必须确认 |
|------|----------|
| 镜像 | 使用与生产代码一致的镜像版本 |
| 环境变量 | 从 VM `.env` 迁移到 Container App secrets/env |
| Managed Identity | 开启 system-assigned 或绑定 user-assigned |
| Key Vault 权限 | 给 Container App identity 授权 `secrets/get/list/set` |
| PostgreSQL | 防火墙允许 Container App 出站 |
| Redis | 防火墙/VNet/TLS 正常 |
| 持久化 | `/data`、`/app/logs` 替换为 Azure Files/Blob/平台日志 |
| 域名 | `code.xinghanlab.com` 绑定和证书 |
| HTTPS | callback URL 必须是 HTTPS |
| 副本数 | 多副本下文件、会话、任务状态不能依赖本地内存 |
## 8. 上线前真实验证清单
迁移后必须真实点击或接口验证,不只看容器启动。
### 8.1 基础接口
```bash
curl -fsS https://code.xinghanlab.com/api/status
```
确认:
- `success=true`
- `version` 为本次发布版本
- `start_time` 已更新
### 8.2 登录和用户态接口
使用测试用户登录后验证:
- 登录成功。
- `/api/agnet/user/deployments` 返回正常。
- 页面 `https://code.xinghanlab.com/deployments` 可打开。
### 8.3 Key Vault 健康
```text
GET /api/secret-store/status
```
期望:
- `configured=true`
- `reachable=true`
如果仍返回 `Identity not found`,说明新服务 identity 没配置好。
### 8.4 普通 sub / Agent Manager 联调
通过 Manager 创建普通 sub 敏捷 run,确认:
- Manager 返回本地 `deployment_id=dep_*`
- Manager 持久化 `runtime_swarm_id=swm_*`
- Agent Manager 自动 callback 写入 Manager timeline
- `/events` 有 `callback.*`
- `/timeline` 有 callback 项
- 不出现明文密钥
### 8.5 蜂群模式联调
确认:
- 蜂群创建入口可用。
- Runtime callback 可入库。
- 任务、事件、artifact、timeline 查询正常。
- 蜂群模式与普通 sub 模式不要混用字段语义。
### 8.6 模型调用和 NewAPI
确认:
- 模型列表可加载。
- 测试用户能拿到模型分组。
- 客户端模型调用有返回。
- NewAPI 日志能看到请求。
- 计费/额度扣减符合预期。
## 9. 切换和回滚建议
切换前:
- 保留 VM 当前部署,不要立即销毁。
- 新服务通过临时域名跑通全流程。
- 备份当前 `.env`。
- 记录当前 git commit、镜像 tag、数据库连接、Redis 连接。
切换时:
- 降低 DNS TTL。
- 切 `code.xinghanlab.com`。
- 立刻跑第 8 节冒烟。
回滚时:
- 将域名切回 VM。
- 保留数据库不回滚,除非确认新服务做了不兼容迁移。
- 检查 callback 是否有重复事件;Manager callback 按 `event_id` / `idempotency_key` 去重。
## 10. 当前遗留事项
| 项目 | 当前状态 | 后续动作 |
|------|----------|----------|
| 普通 sub 创建链路 | 已通过生产真实接口验证 | 保持 |
| Agent Manager 自动 callback | 已通过 HMAC fallback 验证入库 | 后续切到 Key Vault 正式密钥 |
| Azure Key Vault | URL 已配置,但 Managed Identity 返回 `Identity not found` | 给当前 VM 或未来服务配置 Managed Identity 并授权 Key Vault |
| Agent Manager 运行结果 | Runtime 状态可查,但目前返回 `tokens_used=0`、`artifacts=[]`,agent 仍显示 `running` | 需要 Agent Manager 侧继续补真实执行数据一致性和产物回写 |
@@ -1,6 +1,6 @@
# Heicode Manager 蜂群模式进度清单
更新时间:2026-05-27
更新时间:2026-05-28
负责人范围:Heicode Manager 端
用途:给负责人、上级和联调同学快速确认 Manager 端在蜂群模式下已经具备什么、还要做什么、哪些需要客户端或蜂群项目配合。
@@ -12,6 +12,7 @@
| `http://gitee.ath.cx:3000/taijibaga/HeiCode-Swarm` | 蜂群项目实现资料,当前 Orchestrator/Agent/Redis/K8s/桌面演示客户端的实际结构 |
| `docs/product-package/07-integration-boundaries.md` | Heicode、Manager、Agnet 平台、CodeGW、Azure Key Vault 的边界 |
| `docs/integration/heicode-desktop-sub-agile-api.md` | Heicode 桌面客户端接 Manager 的普通 sub 敏捷流程 |
| `docs/integration/Heicode-Manager-PayPal支付接入与计费关系说明.md` | PayPal 收款、Heicode 余额/订阅、NewAPI 模型扣费和 Agent 运行费用边界 |
| 当前仓库 `heicode/` 代码 | Manager 端实际实现核查 |
## 一、核心边界
@@ -21,8 +22,8 @@
| 系统 | 定位 | 应该做什么 | 不应该做什么 |
|---|---|---|---|
| Heicode 桌面客户端 | 用户主体验 | 输入目标、持续补充需求、查看反馈、审批高危操作、接收交付结果 | 直接配置 AKS、模型供应商、完整蜂群 payload |
| Heicode Manager | 控制面和记录面 | 资源绑定、`secret_ref`、权限清单、生成启动请求、记录 deployment/swarm 映射、回调、artifact、timeline、审批、审计 | 替代客户端做主开发对话,或替代 Runtime 执行任务 |
| HeiCode-Swarm / Agnet Runtime | 执行层 | 创建 Swarm Run、任务图、Agent 编队、claim、heartbeat、handoff、执行、结果回传 | 保存长期明文密钥,直接暴露给普通用户 |
| Heicode Manager | 控制面、记录面和用户侧账本入口 | 资源绑定、`secret_ref`、权限清单、生成启动请求、记录 deployment/swarm 映射、回调、artifact、timeline、审批、审计、余额/订阅展示 | 替代客户端做主开发对话,替代 Runtime 执行任务,或把 PayPal 收款当成模型扣费链路 |
| HeiCode-Swarm / Agnet Runtime | 执行层 | 创建 Swarm Run、任务图、Agent 编队、claim、heartbeat、handoff、执行、结果回传、真实运行 usage 回传 | 保存长期明文密钥,直接暴露给普通用户,或自行决定用户账本扣费 |
## 二、目标调用链
@@ -63,8 +64,9 @@ Heicode 桌面客户端
| timeline 聚合 | 用户态 timeline 聚合 audit、callbacks、artifacts、sk_snapshots | `AgnetGetUserDeploymentTimeline` |
| SK snapshot 持久化 | 已有 `agnet_sk_snapshots` 模型和列表查询 | `heicode/model/agnet_sk_snapshot.go` |
| 本地模拟事件 | 已有用户态 `simulate-events`;默认模拟会写入 callback、artifact、approval、timeline 记录,用于 Manager 自测展示链路和脱敏检查 | `AgnetSimulateUserDeploymentEvents` |
| V2 body 加密 | `/api/agnet/user/*` 和 `/api/heicode-auth/*` 已支持桌面端 V2 加密 body | `heicode/middleware/auth.go` |
| 生产普通 sub 烟测 | 已验证 production create/detail/metrics/events/logs/artifacts/sk-snapshots/timeline/stop 链路 | `docs/integration/heicode-desktop-sub-agile-api.md` |
| V2 body 加密 | `/api/agnet/user/*`、`/api/heicode-auth/*` 已在生产支持;`/api/swarms` 代码已补齐同一套 V2 加密鉴权,需随下一次生产部署生效 | `heicode/middleware/auth.go`、`heicode/router/api-router.go` |
| 生产普通 sub 烟测记录 | 已有 create/detail/metrics/events/logs/artifacts/sk-snapshots/timeline/stop 链路烟测记录;本次核查确认生产 `api/status` 返回 Manager `1.4.9`,但公开 callback schema 路径当前返回 404,需重新上线或复核路由后再跑完整生产烟测 | `docs/integration/heicode-desktop-sub-agile-api.md` |
| PayPal/计费边界文档 | 已明确 PayPal 只是收款渠道;模型调用仍走 Heicode/NewAPI 的钱包或订阅额度;Agent 运行费用目前只有预算字段,真实收费需 Runtime usage 回传 | `docs/integration/Heicode-Manager-PayPal支付接入与计费关系说明.md` |
## 四、Manager 端还需要继续做的蜂群任务
@@ -78,8 +80,10 @@ Heicode 桌面客户端
| P1 | Artifact 展示优化 | Manager 端已完成:页面展示 artifact 类型、摘要和 URI;真实 `code_patch/document/test_report/deployment_manifest` 仍需 Runtime 输出 | 需要 Runtime 数据 | artifact 页面/详情能按类型展示摘要和链接 |
| P1 | 任务图/Agent 状态展示占位 | 已完成:页面从 `task.*` / `handoff.*` callback 聚合 Agent task map;无真实数据时显示 Runtime callback 空态 | Manager 可先做展示结构,真实数据需 Runtime | 有空态和字段,不宣称真实已运行 |
| P1 | 日志/指标真实来源标识 | 已完成:logs/metrics API 返回 `data_source`、`runtime_source`,当前明确是 Manager control-plane / estimated,不伪装 Runtime 真实指标 | 需要 Runtime 数据 | 页面和 API 响应能区分来源 |
| P1 | Agent 运行费用口径收敛 | 已完成文档口径:`budget.max_tokens/max_cost_usd/max_duration_sec` 是预算约束,不等于真实扣费账本;真实收费必须依赖 Runtime 回传 usage | Manager 已完成文档,真实数据需 Runtime | 页面/文档不把 estimated budget 说成真实扣费 |
| P1 | 高危审批客户端联动文档 | 已完成:`docs/integration/heicode-desktop-sub-agile-api.md` 已包含 approval 查询、approve/reject、awaiting_approval 流程 | 是 | 客户端文档补齐 approval flow |
| P2 | 蜂群模式验收脚本 | 已完成:`scripts/agnet_sub_mode_smoke.py` 支持 schema 检查、生产健康检查、可选 simulate-events、可选真实 callback smoke | 是 | 本地/生产能跑出 callback、artifact、approval、timeline 可见 |
| P2 | 生产 schema / 认证链路复测 | 本地代码和测试已覆盖;生产公开 `GET /api/agnet/callbacks/swarm-events/schema` 当前返回 404,认证接口需有效登录态或后台 token 才能测 | 是,部署后复测 | 生产 schema 返回 200,用户态/后台态 smoke 能拿到真实数据 |
## 五、需要蜂群项目配合的事项
@@ -91,6 +95,7 @@ Heicode 桌面客户端
| handoff / retry / blocked | 任务交接和失败恢复属于 Runtime | 标准事件、重试次数、失败原因、下一步动作 |
| Agent 执行结果 | Manager 不能生成真实代码产物 | artifact schema、Git branch/commit、测试报告、部署结果 |
| Runtime 指标 | CPU、内存、耗时、Agent 存活、任务耗时来自集群 | metrics 查询或 Prometheus 指标映射 |
| Runtime 真实用量和成本 | Manager 只能保存预算和回传结果,不能凭本地估算扣真实 Agent 运行费用 | `model_tokens`、`model_cost_usd`、`runtime_seconds`、`cpu_core_seconds`、`memory_mb_seconds` 等 usage callback |
| 审批等待状态机 | Runtime 要能暂停高危动作并等待 Manager/客户端审批 | approval request 和 approval decision API |
## 六、需要桌面客户端配合的事项
@@ -112,6 +117,9 @@ Heicode 桌面客户端
| `/api/swarms` 已等于真实 Runtime Swarm Run | 不是。Manager 侧已有 adapter 入口,但是否真实创建 Swarm Run 取决于 Runtime 配置和蜂群接口 |
| artifact/timeline 有接口就等于有真实产物 | 不是。Manager 能接和展示,真实产物必须由 Runtime 回调 |
| 高危审批在 Manager 里点完就闭环 | 不是。产品要求桌面客户端主审批,并且 Runtime 要收到 decision |
| 本地测试通过就等于生产接口全通 | 不是。本地 router/controller/middleware 测试能证明代码能力;生产仍必须确认对应镜像、路由和认证配置已生效 |
| Agent budget 就等于真实收费 | 不是。当前 `budget` 是执行上限和审计字段;真实收费需要 Runtime/Agent Manager 回传可核对 usage |
| PayPal 接入会改变模型扣费方式 | 不是。PayPal 只是充值/购买订阅的收款渠道,模型调用仍从钱包余额或内部订阅额度扣 |
## 八、后续执行顺序
@@ -122,4 +130,5 @@ Heicode 桌面客户端
| 3 | 给蜂群项目配置 callback URL 和 service token | Manager + 蜂群 | 不传明文长期密钥 |
| 4 | 用 HeiCode-Swarm 当前 Orchestrator 做兼容测试 | Manager + 蜂群 | 先判断是否走 `/tasks` 适配,还是蜂群侧补 `/api/swarms` |
| 5 | 桌面客户端按文档跑任务 -> draft -> create -> timeline -> approval | 客户端 + Manager | 使用 V2 加密 POST |
| 6 | 补页面来源标识和任务图空态 | Manager | 防止把 simulated/control-plane 误认为 runtime |
| 6 | 核对 Runtime usage 回传字段 | Manager + 蜂群 | 至少覆盖模型 token/cost、运行时长、Agent role、deployment/task/correlation |
| 7 | 补页面来源标识和任务图空态 | Manager | 防止把 simulated/control-plane 误认为 runtime |
@@ -0,0 +1,389 @@
# Agent Manager 普通 sub 产物回调缺失问题
更新时间:2026-05-29
发给:Agent Manager / Agnet Runtime 负责人
范围:普通 sub 敏捷模式,不包含 HeiCode-Swarm 独立蜂群 Runtime。
## 1. 问题现象
Heicode 桌面客户端执行任务:
```text
给我做一个 oracle 云的代理商网站,做前后端分离
```
客户端最终只显示:
```text
Swarm initialized and planning started
Swarm execution completed
completed
```
但右侧运行数据中:
```text
ARTIFACTS = 0
SK SNAPSHOTS = 0
```
用户无法看到真正交付物,例如代码分支、提交、预览地址、部署清单、文档或最终结果摘要。
## 2. 本次链路归属
本次不是走 HeiCode-Swarm 独立蜂群 Runtime。
Heicode Manager 生产配置确认当前普通 sub 走:
```text
AGNET_RUNTIME_BASE_URL=http://20.212.121.126
AGNET_RUNTIME_CREATE_PATH=/api/swarms
```
`SWARM_RUNTIME_BASE_URL` 当前为空。
因此本问题归属:
```text
Heicode 桌面客户端
-> Heicode Manager 生产
-> Agent Manager / Agnet Runtime 普通 sub 入口
-> Heicode Manager callback
-> 桌面客户端查询 artifacts/timeline
```
不是独立蜂群 Runtime `http://52.139.240.116:8000` 的问题。
## 3. Heicode Manager 生产侧实查结果
Heicode Manager deployment:
```text
dep_fa4f43da9e0a
```
Runtime 返回并保存的 swarm/deployment id:
```text
swm_f9ce3f6c90aa
```
Heicode Manager 生产库记录:
| 字段 | 值 |
|---|---|
| `deployment_id` | `dep_fa4f43da9e0a` |
| `status` | `completed` |
| `phase` | `deploy` |
| `runtime_state` | `completed` |
| `runtime_deployment_id` | `swm_f9ce3f6c90aa` |
| `runtime_swarm_id` | `swm_f9ce3f6c90aa` |
| `created_at_text` | `2026-05-29T08:40:50Z` |
| `updated_at_text` | `2026-05-29T08:40:51Z` |
Heicode Manager 收到的 callback 类型统计:
| callback event_type | count |
|---|---:|
| `agent.started` | 2 |
| `budget.alert` | 1 |
| `deployment.status_changed` | 3 |
| `phase.changed` | 2 |
| `timeline.updated` | 2 |
关键缺失:
```text
artifact.created = 0
task.completed = 0
sk_tool.completed = 0
```
Heicode Manager artifact 表查询结果:
```text
agnet_artifacts where deployment_id = 'dep_fa4f43da9e0a'
=> 0 rows
```
说明:Heicode Manager 没有收到任何产物事件,因此客户端显示 `ARTIFACTS 0` 是真实数据,不是客户端漏显示。
## 4. Heicode Manager callback 接收链路是通的
生产日志中,Agent Manager 在任务完成时间段连续请求:
```text
POST /api/agnet/callbacks/swarm-events
```
HTTP 状态均为:
```text
200
```
这说明:
1. Agent Manager 能打到 Heicode Manager callback 地址。
2. Heicode Manager 没有拒收这些 callback。
3. 问题不是 callback 鉴权失败。
4. 问题不是 Heicode Manager callback endpoint 不通。
但 callback 事件内容中没有 `artifact.created`,因此 Heicode Manager 无法落库 artifacts。
## 5. Agent Manager 侧直查结果
直查 Agent Manager Runtime:
```http
GET http://20.212.121.126/api/swarms/swm_f9ce3f6c90aa
```
返回关键信息:
```json
{
"deployment_id": "swm_f9ce3f6c90aa",
"swarm_id": "swm_f9ce3f6c90aa",
"status": "completed",
"phase": "planning",
"progress": 100,
"agents": [
{
"agent_id": "agi_backend_59717a71",
"role": "backend",
"status": "running",
"output": null
},
{
"agent_id": "agi_frontend_3063c6c9",
"role": "frontend",
"status": "running",
"output": null
}
],
"metrics": {
"total_messages": 0,
"tokens_used": 0
},
"artifacts": []
}
```
直查日志:
```http
GET http://20.212.121.126/api/swarms/swm_f9ce3f6c90aa/logs
```
返回:
```text
Logs will be fetched from K8s in Phase 2
```
这说明 Agent Manager 自己也没有保存或返回真实产物。
## 6. 当前判断
本问题根因不在 Heicode Manager,也不在桌面客户端。
当前证据指向 Agent Manager / Agnet Runtime:
1. Runtime 将 deployment 标记为 `completed`。
2. Runtime 自身返回 `artifacts: []`。
3. Runtime 没有 callback `artifact.created`。
4. Runtime agents 仍显示 `running`,但 deployment 已 `completed`,状态不一致。
5. Runtime metrics 中 `tokens_used=0`、`total_messages=0`,没有真实模型执行用量。
6. Runtime logs 仍是占位文本,没有真实 K8s / Agent 日志。
因此客户端只能展示完成状态,不能展示交付结果。
## 7. Agent Manager 需要修复的内容
### P0:任务完成必须回调 artifact
普通 sub 任务完成时,Agent Manager 必须向 Heicode Manager callback:
```text
POST https://code.xinghanlab.com/api/agnet/callbacks/swarm-events
event_type = artifact.created
```
即使没有 Git 分支,也必须返回一个可展示的交付物。
建议 artifact 类型:
| 场景 | artifact_type | uri / 内容 |
|---|---|---|
| 代码已提交 | `code_patch` | `git://repo#<branch>` 或 repo URL + branch + commit |
| 生成了前后端项目 | `deployment_manifest` | 项目结构、启动方式、服务端口、部署说明 |
| 只产出设计/说明 | `document` | 文档地址或文档摘要 |
| 无法生成正式产物 | `other` | 明确失败原因、已完成内容、下一步动作 |
最低要求:不能只发 `completed`,必须有一个 `artifact.created` 或明确失败事件。
### P0:completed 状态与 Agent 状态一致
当前 Runtime 返回:
```text
deployment.status = completed
agents[].status = running
```
需要修复为一致状态:
1. 如果 deployment completed,则相关 agent 应为 `completed`、`stopped` 或明确的终态。
2. 如果 agent 仍 running,则 deployment 不应为 completed。
3. 如果任务未真实执行,应返回 `failed` 或 `blocked`,并说明原因。
### P0:回调 task.completed / task.failed
当前 Heicode Manager 没收到:
```text
task.completed
task.failed
task.blocked
```
Agent Manager 应在每个子 Agent / 子任务结束时回调任务状态,至少包含:
```json
{
"event_type": "task.completed",
"deployment_id": "dep_fa4f43da9e0a",
"swarm_id": "swm_f9ce3f6c90aa",
"task_id": "task-backend-xxx",
"agent_instance_id": "agi_backend_59717a71",
"payload": {
"task_id": "task-backend-xxx",
"agent_role": "backend",
"status": "completed",
"summary": "后端实现完成,产物见 artifact"
}
}
```
### P1:回传真实 usage / cost
当前 Runtime 返回:
```text
tokens_used = 0
total_messages = 0
```
如果任务真实调用了模型,应回传:
```json
{
"model_usage": {
"input_tokens": 1234,
"output_tokens": 5678,
"total_tokens": 6912,
"model_cost_usd": 0.0123,
"model_id": "xxx"
}
}
```
如果没有真实调用模型,应明确返回 `blocked` 或 `failed`,不能标记为正常 completed。
### P1:提供真实日志
当前日志仍是:
```text
Logs will be fetched from K8s in Phase 2
```
需要至少返回可排障日志摘要:
1. Agent 是否启动成功。
2. 是否领取任务。
3. 是否调用模型。
4. 是否生成文件/分支/产物。
5. 失败原因。
## 8. 建议的 artifact.created callback 示例
```json
{
"event_id": "evt_artifact_<unique>",
"event_type": "artifact.created",
"deployment_id": "dep_fa4f43da9e0a",
"swarm_id": "swm_f9ce3f6c90aa",
"agent_instance_id": "agi_backend_59717a71",
"task_id": "task-backend-001",
"occurred_at": "2026-05-29T08:40:51Z",
"correlation_id": "corr_xxx",
"source": "agent-manager",
"payload": {
"artifact_id": "art_swm_f9ce3f6c90aa_backend_001",
"artifact_type": "code_patch",
"title": "Oracle 云代理商网站后端实现",
"summary": "已生成后端接口、数据模型和启动说明",
"uri": "git://repo#feature/swm_f9ce3f6c90aa",
"checksum": "commit_sha_if_available",
"metadata": {
"redacted": true,
"agent_role": "backend",
"runtime_deployment_id": "swm_f9ce3f6c90aa"
}
}
}
```
如果没有 Git 分支,也可以先回:
```json
{
"event_type": "artifact.created",
"deployment_id": "dep_fa4f43da9e0a",
"swarm_id": "swm_f9ce3f6c90aa",
"payload": {
"artifact_id": "art_swm_f9ce3f6c90aa_summary",
"artifact_type": "document",
"title": "任务执行结果摘要",
"summary": "Runtime 已完成任务,但未返回 Git 分支。这里应写清生成内容、文件位置或未生成原因。",
"uri": "runtime://swm_f9ce3f6c90aa/artifacts/summary",
"metadata": {
"redacted": true
}
}
}
```
## 9. 验收标准
修复后请用同类任务重新跑一次:
```text
给我做一个 oracle 云的代理商网站,做前后端分离
```
必须满足:
| 验收项 | 标准 |
|---|---|
| Runtime 状态 | deployment 和 agents 状态一致 |
| callback | Heicode Manager 收到 `artifact.created` |
| Manager artifacts | `/api/agnet/user/deployments/{deployment_id}/artifacts` 返回 total > 0 |
| 客户端展示 | 右侧 `ARTIFACTS` 不再是 0 |
| 交付物 | 能看到 Git 分支、提交、预览地址、部署清单或最终结果文档 |
| usage | 如果真实调用模型,tokens/cost 不应一直为 0 |
| logs | 不再只有 Phase 2 占位文本 |
## 10. 当前结论
Heicode Manager 和桌面客户端当前表现是正确反映 Runtime 数据。
真正缺口在 Agent Manager / Agnet Runtime:
```text
任务被标记 completed,但 Runtime 没有生成或回传 artifact.created。
```
请 Agent Manager 优先修复任务完成后的产物生成、产物回调、状态一致性、usage 和日志回传。
@@ -0,0 +1,491 @@
# Agent Manager 蜂群 Runtime 联调待确认与补充要求
更新时间:2026-05-29
发给:Agent Manager / HeiCode-Swarm Runtime 负责人
范围:仅针对蜂群模式,不包含普通 sub 敏捷模式。
## 1. 先明确边界
普通 sub 模式和蜂群模式是两套不同的运行时部署,不能混用。
| 模式 | Runtime | 当前已知地址 | 说明 |
|---|---|---|---|
| 普通 sub 敏捷模式 | Agent Manager / Agnet Runtime | `http://20.212.121.126` | 用于普通子 Agent 敏捷开发流程 |
| 蜂群模式 | HeiCode-Swarm Orchestrator | `http://52.139.240.116:8000` | 用于蜂群任务图、Agent 协作、handoff、task graph |
注意:两套服务可能都提供 `/api/swarms` 这类路径,但业务含义不同。Heicode Manager 后续需要按模式分别配置,不应只用一套 `AGNET_RUNTIME_BASE_URL` 混跑。
## 2. 本次读取到的蜂群 Runtime 新能力
根据 HeiCode-Swarm 最新 `蜂群对接文档.md` 和代码,蜂群 Runtime 已经补充以下接口:
| 能力 | 路径 | 当前判断 |
|---|---|---|
| 健康检查 | `GET /api/agnet/health` | 已提供 |
| 创建蜂群 run | `POST /api/swarms` | 已提供 |
| 兼容创建入口 | `POST /api/agnet/deployments` | 已提供 |
| 查询 swarm 详情 | `GET /api/swarms/{swarm_id}` | 已提供 |
| 查询任务图 | `GET /api/swarms/{swarm_id}/tasks` | 已提供 |
| 查询日志 | `GET /api/swarms/{swarm_id}/logs` | 已提供 |
| 查询指标 | `GET /api/swarms/{swarm_id}/metrics` | 已提供 |
| 停止 swarm | `POST /api/swarms/{swarm_id}/stop` | 已提供 |
| 审批结果回传 | `POST /api/swarms/{swarm_id}/approvals/{approval_id}` | 已提供 |
| callback token / HMAC | Runtime callback Manager | 代码支持 |
| `azkv://` 校验 | 请求体 secret 引用 | 代码支持 |
| 幂等创建 | `X-Idempotency-Key` | 代码支持 |
本地契约测试也已确认通过:
```text
python3 scripts/test-runtime-contract.py
runtime contract checks passed
```
## 3. 实测结果
### 3.1 健康检查通过
请求:
```bash
curl http://52.139.240.116:8000/api/agnet/health
```
返回核心内容:
```json
{
"success": true,
"data": {
"status": "healthy",
"service": "heicode-swarm-runtime",
"version": "1.0.0",
"runtime": "aks",
"capabilities": [
"swarm.create",
"task.flow",
"handoff.events",
"artifact.events",
"approval.pause_resume",
"deployment.stop",
"runtime.tasks.query",
"runtime.logs.query",
"runtime.metrics.query"
]
}
}
```
### 3.2 创建接口鉴权已通过
Agent Manager / 蜂群 Runtime 已提供当前部署认可的 Runtime Bearer token。使用该 token 后,`POST /api/swarms` 不再返回 401。
```bash
curl -X POST http://52.139.240.116:8000/api/swarms \
-H "Authorization: Bearer <runtime-service-token>" \
-H "Content-Type: application/json" \
-H "X-Correlation-Id: corr_token_check_local" \
-H "X-Idempotency-Key: codex-swarm-token-check" \
-d '<valid-swarm-create-body>'
```
返回:
```json
{
"success": true,
"data": {
"deployment_id": "runtime-dep-e9ef617146fd",
"runtime_deployment_id": "runtime-dep-e9ef617146fd",
"manager_deployment_id": "dep_token_check_local",
"swarm_id": "swarm-64004a6aaf67",
"status": "running",
"created": true
}
}
```
结论:Runtime token 阻塞已解决。具体 token 不写入 Markdown 或 Git。
### 3.3 查询和停止接口已通过
对 `swarm-64004a6aaf67` 查询结果:
| 接口 | 结果 |
|---|---|
| `GET /api/swarms/{swarm_id}` | 成功,返回 Runtime run 详情 |
| `GET /api/swarms/{swarm_id}/tasks` | 成功 |
| `GET /api/swarms/{swarm_id}/logs` | 成功,返回 Runtime event 列表 |
| `GET /api/swarms/{swarm_id}/metrics` | 成功,返回任务数、运行时长、Agent 数等轻量指标 |
| `POST /api/swarms/{swarm_id}/stop` | 成功,状态变为 `stopped` |
### 3.4 新发现:线上蜂群 Runtime 仍只生成单任务
2026-05-29 已按 `HeiCode-Swarm/蜂群对接文档.md` 的结构真实请求线上蜂群 Runtime:
```text
POST http://52.139.240.116:8000/api/swarms
Authorization: Bearer <runtime-service-token>
```
第一组使用文档中的单 Agent 结构,`agents` 放在 `orchestration_plan.agents`,角色为 `backend`。创建成功:
```json
{
"success": true,
"data": {
"runtime_deployment_id": "runtime-dep-fc99510eb2c5",
"manager_deployment_id": "dep_doc_single",
"swarm_id": "swarm-004f1ae3a114",
"status": "running"
}
}
```
但查询任务后,线上 Runtime 没有保留文档请求中的 `backend` 角色,而是生成 `general` 单任务:
```json
{
"task_id": "swarm-004f1ae3a114-task-1",
"title": "Swarm objective",
"agent_role": "general",
"context": {
"workflow_mode": "single_agent",
"allow_handoff": false
}
}
```
第二组使用同样文档结构,但传入 3 个 agents:`planner`、`builder`、`reviewer`。创建成功:
```json
{
"success": true,
"data": {
"runtime_deployment_id": "runtime-dep-ba9de6fe1df3",
"manager_deployment_id": "dep_doc_multi",
"swarm_id": "swarm-ee319f1d4705",
"status": "running"
}
}
```
但查询任务后仍只生成 1 个 `general` 任务:
```json
{
"tasks": [
{
"title": "Swarm objective",
"agent_role": "general",
"status": "pending",
"task_graph_id": "task-1",
"context": {
"workflow_mode": "single_agent",
"allow_handoff": false
}
}
]
}
```
两组测试的日志均只出现 `Created 1 task(s)`,停止接口均返回 `status=stopped`。
这与蜂群设计要求的“动态任务图、能力编队、handoff、协作任务链”仍有差距。当前线上 Runtime 看起来仍是 single-agent fallback 路径,尚未按请求中的 3 个 planner/builder/reviewer 任务生成真实 task graph。
### 3.5 Gitee main 与线上 Runtime 行为不一致
已检查项目地址:
```text
http://gitee.ath.cx:3000/taijibaga/HeiCode-Swarm
```
当前 main commit:
```text
802bbe97ca278d2da3c70a3e471219db62c01c98 对接文档
```
仓库 main 中 `orchestrator/swarm_runtime.py` 的 `build_task_descriptions()` 逻辑会读取:
```python
agents = plan.get("agents") or body.get("agents") or []
```
如果请求里传入 3 个 agents,本地按同样 payload 测试该函数,结果会生成 3 个任务:
```text
plan-1 planner
build-1 builder
verify-1 reviewer
count=3
```
但线上 `http://52.139.240.116:8000` 对同样结构只生成:
```text
task-1 general
workflow_mode=single_agent
allow_handoff=false
```
另外,线上返回中的 `workflow_mode`、`allow_handoff`、`required_capabilities`、`source=runtime_bridge` 等字段,在当前 Gitee main 的 `orchestrator/swarm_runtime.py` 中没有对应代码。
因此当前判断是:
1. 线上 `52.139.240.116:8000` 运行的镜像/代码不是 Gitee main 当前代码;或
2. 线上部署使用了未提交到 Gitee main 的分支/镜像;或
3. 线上通过环境变量或另一套 runtime bridge 强制走 single-agent fallback。
请 Agent Manager / HeiCode-Swarm 负责人确认线上部署的镜像 tag、代码 commit、分支,以及是否启用了 single-agent fallback 配置。
## 4. Agent Manager / 蜂群 Runtime 需要提供或确认
### P0 必须先提供
| 项 | 需要提供 / 确认 | 原因 |
|---|---|---|
| 蜂群 Runtime 调用 token | 已提供并验证可用;后续需要通过安全渠道配置到 Manager,不写入 Git | Manager 调 `POST /api/swarms` 需要 Bearer 鉴权 |
| callback 凭据配置 | Runtime 配置 Manager 认可的 `AGNET_CALLBACK_SERVICE_TOKEN` 或 `AGNET_CALLBACK_SIGNING_SECRET` | Runtime 回调 Manager 必须通过鉴权 |
| 两套部署边界 | 明确普通 sub 继续走 `20.212.121.126`,蜂群走 `52.139.240.116:8000` | 防止普通 sub 和蜂群混用 |
| 创建响应字段 | `deployment_id`、`runtime_deployment_id`、`swarm_id`、`status` 字段保持稳定 | Manager 需要保存映射 |
| callback deployment id 规则 | callback 中 `deployment_id` 应为 Manager deployment id,`runtime_deployment_id` 为 Runtime id,`swarm_id` 为 Runtime swarm id | Manager 根据这些字段落库和展示 |
callback 地址:
```text
https://code.xinghanlab.com/api/agnet/callbacks/swarm-events
```
callback schema 查询:
```text
GET https://code.xinghanlab.com/api/agnet/callbacks/swarm-events/schema
```
说明:具体 token / signing secret 不应写入 Markdown 或 Git,请通过安全渠道提供。
### P0 端到端能力
| 能力 | 当前情况 | 需要补充或证明 |
|---|---|---|
| Manager 创建蜂群 run | Runtime 直连创建已跑通;Manager 生产尚未切蜂群专用配置 | 将 Runtime token 安全配置到 Manager 后跑真实 Manager 创建 |
| 动态任务图 | 当前线上 Runtime 只生成 1 个 `general` 任务,`workflow_mode=single_agent` | 至少根据目标生成 2-3 个可追踪任务,并回调多条 `task.created` |
| Agent claim / running / heartbeat | 代码里有 dispatch 和事件能力 | 需要真实 Agent 连接、claim、running、heartbeat 证据 |
| handoff | 事件类型和部分逻辑存在 | 需要真实 `handoff.requested` / `handoff.completed` 场景 |
| 失败回流 | 有 `task.blocked`、`task.failed`、`task.retried` 事件类型 | 需要一次真实失败、重试或 blocked 证据 |
| artifact | Agent 返回 `git_branch` 时会发 artifact | 需要真实 `artifact.created` 回 Manager,带 `uri`、`summary`、`artifact_type` |
| 审批暂停 / 恢复 | high risk 会 `waiting_approval`,支持 decision 接口 | 需要真实 `approval.requested` -> Manager approve/reject -> Runtime 继续/阻塞 |
| 日志 | `/logs` 当前主要来自 Runtime 事件 | 需要说明是否提供真实 Agent / Pod 日志,或至少返回可排障日志摘要 |
| 指标 | `/metrics` 返回轻量聚合 | 需要补充真实运行时长、任务耗时、Agent 数、失败数、资源用量 |
| 用量 / 成本 | 只有 Agent result 带 `usage` 时才回传 | 需要真实 NewAPI token、model_cost_usd、runtime_seconds 或明确暂无 |
| callback 可靠性 | 当前代码 callback 失败记录 warning | 需要确认是否有重试、死信、人工重放;没有则列为未完成 |
## 5. 建议双方统一的蜂群创建请求
Heicode Manager 调蜂群 Runtime:
```http
POST http://52.139.240.116:8000/api/swarms
Authorization: Bearer <runtime-service-token>
X-User-ID: <manager-user-id>
X-Binding-Scope: <binding-scope>
X-Correlation-ID: <correlation-id>
X-Idempotency-Key: manager-<manager-deployment-id>
Content-Type: application/json
```
请求体建议:
```json
{
"orchestration_plan": {
"intent_id": "task_xxx",
"objective": "完成本轮用户目标",
"sub_mode": "goal_driven_swarm",
"risk_level": "medium",
"budget": {
"duration_seconds": 3600,
"token_limit": 20000,
"max_cost_usd": 8
},
"user_context": {
"user_id": "22",
"channel_id": "heicode",
"binding_scope": "task-task-xxx"
},
"agents": [
{
"task_id": "plan-1",
"role": "planner",
"title": "目标理解与任务生成",
"description": "整理用户目标、资源约束和验收标准",
"depends_on": []
},
{
"task_id": "build-1",
"role": "builder",
"title": "代码或文档变更",
"description": "根据目标生成可验证交付物",
"depends_on": ["plan-1"]
},
{
"task_id": "verify-1",
"role": "reviewer",
"title": "验证与交付整理",
"description": "检查交付物并形成验收摘要",
"depends_on": ["build-1"]
}
]
},
"callback": {
"url": "https://code.xinghanlab.com/api/agnet/callbacks/swarm-events",
"signing_secret_ref": "azkv://heicode-kv.vault.azure.net/secrets/agnet-callback-signing-key",
"subscribed_events": [
"deployment.status_changed",
"task.created",
"task.claimed",
"task.running",
"task.heartbeat",
"task.blocked",
"task.retried",
"task.failed",
"task.completed",
"handoff.requested",
"handoff.completed",
"approval.requested",
"artifact.created",
"budget.alert",
"timeline.updated"
]
},
"metadata": {
"manager_deployment_id": "dep_xxx",
"heicode_deployment_id": "dep_xxx",
"correlation_id": "corr_xxx",
"heicode_runtime_bridge": true,
"runtime_mode": "swarm"
},
"billing_context": {
"provider": "newapi",
"default_model_id": "model_xxx",
"allowed_model_ids": ["model_xxx"],
"secret_ref": "azkv://heicode-kv.vault.azure.net/secrets/model-gateway-key"
},
"resource_grants": [
{
"grant_id": "grant-task-git",
"resource_id": "repo-main",
"resource_type": "git",
"permission_scope": ["repo:read", "repo:write:feature-branches"],
"secret_ref": "azkv://heicode-kv.vault.azure.net/secrets/repo-main",
"ref": "azkv://heicode-kv.vault.azure.net/secrets/repo-main",
"target_role": "builder"
}
]
}
```
## 6. Secret 引用格式需要统一
Heicode Manager 当前正式约束建议使用:
```text
azkv://<vault>/secrets/<name>
```
例如:
```text
azkv://heicode-kv.vault.azure.net/secrets/repo-main
azkv://heicode-kv.vault.azure.net/secrets/model-gateway-key
```
HeiCode-Swarm 文档中示例为:
```text
azkv://heicode/git-write-token
```
建议 Agent Manager / 蜂群 Runtime 确认是否可以接受标准 Key Vault 形式,并在示例文档中同步,避免后续联调时因为 `secret_ref` 格式不一致失败。
## 7. Manager 侧后续配置建议
Heicode Manager 需要保留普通 sub 和蜂群两套 Runtime 配置。建议后续拆成:
```text
# 普通 sub 敏捷模式
AGNET_RUNTIME_BASE_URL=http://20.212.121.126
AGNET_RUNTIME_CREATE_PATH=/api/swarms
# 蜂群模式
SWARM_RUNTIME_BASE_URL=http://52.139.240.116:8000
SWARM_RUNTIME_CREATE_PATH=/api/swarms
SWARM_RUNTIME_STOP_PATH=/api/swarms/{swarm_id}/stop
SWARM_RUNTIME_APPROVAL_DECISION_PATH=/api/swarms/{swarm_id}/approvals/{approval_id}
SWARM_RUNTIME_SERVICE_TOKEN=<runtime-service-token>
```
如果短期 Manager 还只有一套 `AGNET_RUNTIME_*`,只能临时切换到蜂群 Runtime 做专项联调,不能同时代表普通 sub 和蜂群都在线。
## 8. 蜂群 MVP 验收口径
根据蜂群设计文档和 Heicode 产品资料包,蜂群 MVP 不是“接口能创建一个 run”就完成。最小验收应至少满足:
| 验收项 | 成功标准 |
|---|---|
| 创建 | Manager 调 `POST /api/swarms` 成功,Runtime 返回真实 `swarm_id` |
| 任务图 | Runtime 至少生成 2 个可追踪任务,并可通过 `/tasks` 查询 |
| Agent 执行 | 至少一个真实 Agent claim、running、completed |
| 交接或失败回流 | 至少出现一次 handoff,或一次失败/blocked/retry 并可解释 |
| artifact | 至少 1 个真实 artifact 回 Manager,可在 Manager 查询 |
| 审批 | high risk 流程能 `approval.requested`,Manager approve/reject 后 Runtime 状态变化 |
| 日志 | Manager 或 Runtime 能查到可排障日志,不只是空占位 |
| 指标 | 能看到任务数、状态、耗时、Agent 数、失败数等真实指标 |
| 用量 | 能回传 token、模型成本或明确当前未接真实模型成本 |
| 安全 | 请求、callback、日志、artifact metadata 不出现明文长期密钥 |
| 幂等 | 同一个 `X-Idempotency-Key` 重试不会创建重复 swarm |
| 停止 | Manager stop 后 Runtime 状态为 `stopped`,不继续执行 |
## 9. 建议下一次联调步骤
1. Heicode Manager 新增或临时配置蜂群 Runtime base URL 和 Runtime Bearer token。
2. Agent Manager / 蜂群 Runtime 确认 callback token 或 HMAC secret 已和 Heicode Manager 生产端一致。
3. 使用测试用户通过 Manager 创建蜂群 run。
4. 验证 Manager 保存 `runtime_deployment_id` 和 `runtime_swarm_id`。
5. 验证 Runtime 不再只生成 single-agent fallback,而是生成多任务 task graph。
6. 验证 Runtime 主动 callback:
- `deployment.status_changed`
- `task.created`
- `task.claimed`
- `task.running`
- `task.completed`
- `artifact.created`
- `timeline.updated`
7. 验证查询:
- `GET /api/swarms/{swarm_id}`
- `GET /api/swarms/{swarm_id}/tasks`
- `GET /api/swarms/{swarm_id}/logs`
- `GET /api/swarms/{swarm_id}/metrics`
8. 验证 high risk 审批:
- Runtime 回调 `approval.requested`
- Manager approve
- Runtime 收到 decision 并继续
9. 验证 stop:
- Manager stop
- Runtime stopped
- 不再继续发 running/completed callback
## 10. 当前结论
蜂群 Runtime 当前已经具备可联调的接口骨架和一部分运行时能力,但还没有完成 Heicode 产品设计要求的完整蜂群 MVP 生产验收。
当前最优先阻塞项是:
1. 将已验证可用的蜂群 Runtime Bearer token 通过安全渠道配置到 Heicode Manager。
2. 确认 Runtime 回调 Manager 的 callback token 或 HMAC secret 已生效。
3. 修正线上 Runtime 只生成 1 个 `general` 任务的问题,使其按目标生成真实多任务 task graph。
4. 确认线上镜像/代码 commit 与 Gitee main 是否一致;如果不一致,需要先部署包含多任务 task graph 逻辑的版本。
5. 跑通一次真实 Manager -> 蜂群 Runtime -> Manager callback 的端到端链路。
6. 补齐真实 Agent 执行、handoff/失败回流、artifact、日志、指标、usage/cost 的生产证据。
@@ -0,0 +1,384 @@
# Heicode Manager PayPal 支付接入与计费关系说明
更新时间:2026-05-28
## 1. 结论
Heicode Manager 目前已经有余额充值、套餐订阅、订单、回调、补单、模型调用扣费和订阅扣费的基础能力。当前要接 PayPal,优先不要重做计费体系,而是在现有支付网关抽象中新增 `paypal` 支付提供方。
建议分两期:
| 阶段 | 范围 | 说明 |
|---|---|---|
| 第一期 | PayPal Checkout 一次性支付 | 覆盖余额充值和 Heicode 内部订阅套餐购买。支付成功后沿用现有 `top_ups`、`subscription_orders`、`user_subscriptions` 逻辑。 |
| 第二期 | PayPal 原生自动续订 | 只有业务明确需要自动续费时再做。需要新增 PayPal product / plan / subscription 映射、续费 webhook、取消和过期同步。 |
当前更稳妥的方案是第一期:用户通过 PayPal 支付一笔钱,Manager 在回调确认成功后给用户增加余额或开通一个 Heicode 内部订阅套餐。
## 2. 现有计费对象
| 对象 | 当前代码/表 | 作用 |
|---|---|---|
| 钱包余额 | `users.quota` | 用户模型调用的可用余额。 |
| 充值订单 | `top_ups` | 记录一次余额充值订单,成功后增加用户 `quota`。 |
| 订阅套餐 | `subscription_plans` | 管理员配置套餐价格、周期、总额度、重置周期、升级分组。 |
| 订阅订单 | `subscription_orders` | 用户购买套餐时生成订单,支付成功后创建用户订阅。 |
| 用户订阅 | `user_subscriptions` | 用户已购买的套餐实例,模型调用可优先从订阅额度扣。 |
| 订阅预扣记录 | `subscription_pre_consume_records` | 模型调用时对订阅额度做预扣、结算、失败退款。 |
| 模型调用日志 | `logs`、`task` 相关表 | 记录模型、用量、扣费、请求状态。 |
## 3. 充值后模型费用怎么走
余额充值和模型扣费是两个独立阶段。
### 3.1 充值阶段
用户发起充值:
```text
用户选择充值金额
-> Manager 创建支付订单
-> PayPal 创建 checkout order
-> 用户跳转 PayPal 支付
-> PayPal 回调 Manager
-> Manager 验证回调和订单状态
-> top_ups 标记 success
-> users.quota 增加对应额度
-> 记录充值日志
```
现有非 PayPal 代码里,充值金额换算逻辑大致是:
```text
支付金额 = 用户选择的充值数量
* Price
* TopupGroupRatio
* AmountDiscount
```
如果系统展示类型是 tokens,则会先按 `QuotaPerUnit` 换算成金额单位。当前生产公开状态中 `quota_display_type=USD`,`quota_per_unit=500000`,`price=7.3`,说明用户侧余额展示和内部 quota 之间已有换算关系。
PayPal 接入后也应复用同一套换算规则,不应单独发明 PayPal 专属余额单位。
### 3.2 模型调用扣费阶段
模型调用时不是从 PayPal 扣费,而是从 Heicode / NewAPI 的余额或订阅额度扣。
流程:
```text
客户端/网页发起模型调用
-> Manager / NewAPI relay 计算模型费用
-> 根据用户计费偏好选择资金来源
-> 预扣额度
-> 请求上游模型
-> 根据真实 usage 结算差额
-> 成功记录日志,失败则退款预扣
```
当前资金来源有两类:
| 资金来源 | 代码标识 | 说明 |
|---|---|---|
| 钱包余额 | `wallet` | 从 `users.quota` 扣。 |
| 订阅额度 | `subscription` | 从 `user_subscriptions.amount_used` 扣。 |
用户计费偏好:
| 偏好 | 说明 |
|---|---|
| `subscription_first` | 默认。优先订阅额度,不足或无订阅时回退钱包。 |
| `wallet_first` | 优先钱包余额,不足时回退订阅。 |
| `subscription_only` | 只用订阅额度。 |
| `wallet_only` | 只用钱包余额。 |
因此 PayPal 只负责把钱变成 Heicode 的余额或订阅权益;后续模型费用仍由现有 NewAPI/Manager 计费链路处理。
## 4. NewAPI / CodeGW 的订阅模式
产品文档里 CodeGW / NewAPI 的定位是模型网关和计费服务。普通用户不进入 NewAPI 后台,只在 Heicode Manager / 客户端看模型、余额、额度、调用日志。
当前代码里的“订阅”不是 PayPal 原生订阅,而是 Heicode 内部套餐:
```text
subscription_plans
-> 用户购买
-> subscription_orders
-> 支付成功
-> user_subscriptions
-> 模型调用扣订阅额度
```
套餐可配置:
| 字段 | 说明 |
|---|---|
| `price_amount` | 套餐展示价格。 |
| `duration_unit` / `duration_value` | 套餐有效期,如月、年、天。 |
| `total_amount` | 套餐总额度,0 可表示不限量。 |
| `quota_reset_period` | 额度重置周期,如 daily / weekly / monthly / never。 |
| `upgrade_group` | 购买后升级用户分组。 |
| `max_purchase_per_user` | 单用户购买上限。 |
PayPal 接入时有两种做法:
| 做法 | 推荐度 | 说明 |
|---|---:|---|
| PayPal 一次性支付购买内部套餐 | 高 | 最小改动,支付成功后创建 `user_subscriptions`。适合当前系统。 |
| PayPal 原生订阅自动续费 | 中 | 需要维护 PayPal subscription 状态和续费 webhook,复杂度更高。 |
建议第一期先实现“一次性支付购买内部套餐”。这样不改变 NewAPI 订阅扣费逻辑,也不要求 PayPal 成为系统账本。
## 5. Agent 运行费用是否在项目设计文档里
有,但目前文档里表达的是“预算和用量上限”,不是已经完整落地的独立收费账本。
相关设计口径:
| 文档口径 | 含义 |
|---|---|
| `budget.max_tokens` | 本次 Agent / Agnet 部署允许消耗的 token 上限。 |
| `budget.max_cost_usd` | 本次 Agent / Agnet 部署允许消耗的美元成本上限。 |
| `budget.max_duration_sec` | 本次 Agent / Agnet 部署允许运行的时间上限。 |
| `billing_context.provider = newapi` | 表示模型调用费用应映射到 NewAPI 用户、Token、Group 或 quota。 |
| `agent_runtime` | 表示子 Agent 角色、模型 profile、实例数,不能和 NewAPI 扣费对象混在一起。 |
产品文档明确:子 Agnet 的运行模型属于 Agnet 平台部署配置,不等同于 CodeGW 后台模型供应商配置。NewAPI 负责模型网关、余额、用量、日志和扣费;Agnet 平台负责真实执行和运行态。
所以当前要分成两类费用:
| 费用类型 | 当前是否有闭环 | 说明 |
|---|---|---|
| 模型调用费用 | 有 | 通过 Manager/NewAPI 的 quota、subscription、usage 日志扣费。 |
| Agent 运行预算 | 有字段和展示/回调基础 | deployment payload 和事件里有 budget,但真实成本依赖 Agent Manager / Runtime 回传。 |
| Agent 基础设施费用 | 未形成用户账本 | CPU、内存、Pod、运行时资源成本目前不是 Manager 本地自动扣费项。 |
如果后续要把 Agent 运行也收费,需要 Agent Manager 回传真实用量:
```json
{
"deployment_id": "dep_xxx",
"agent_instance_id": "agi_backend_001",
"usage": {
"model_tokens": 32000,
"model_cost_usd": 4.21,
"runtime_seconds": 930,
"cpu_core_seconds": 1200,
"memory_mb_seconds": 2048000
},
"billing_source": "newapi",
"correlation_id": "corr_xxx"
}
```
没有 Runtime 真实回传前,Manager 不能把页面上的预算估算当成真实扣费依据。
## 6. PayPal 接入建议接口
PayPal 官方当前推荐一次性结账使用 Orders v2 API:创建订单后,用户批准,再 capture 订单。订阅自动续费使用 PayPal Subscriptions API 和订阅 webhook。
### 6.1 钱包充值
新增接口:
```http
POST /api/user/paypal/pay
```
请求:
```json
{
"amount": 20,
"success_url": "https://code.xinghanlab.com/console/topup?pay=success",
"cancel_url": "https://code.xinghanlab.com/console/topup?pay=cancel"
}
```
响应:
```json
{
"message": "success",
"data": {
"approval_url": "https://www.paypal.com/checkoutnow?token=ORDER_ID",
"order_id": "PAYPAL_ORDER_ID",
"trade_no": "PPUSR1NO..."
}
}
```
回调:
```http
POST /api/paypal/webhook
```
处理规则:
1. 验证 PayPal webhook 签名。
2. 根据 PayPal order / capture id 找到本地 `top_ups.trade_no` 或 provider reference。
3. 确认支付状态为完成。
4. 幂等加锁,避免重复加余额。
5. 将 `top_ups.status` 改为 success。
6. 增加 `users.quota`。
7. 记录充值日志。
### 6.2 内部订阅套餐购买
新增接口:
```http
POST /api/subscription/paypal/pay
```
请求:
```json
{
"plan_id": 3,
"success_url": "https://code.xinghanlab.com/console/topup?pay=success",
"cancel_url": "https://code.xinghanlab.com/console/topup?pay=cancel"
}
```
响应:
```json
{
"message": "success",
"data": {
"approval_url": "https://www.paypal.com/checkoutnow?token=ORDER_ID",
"order_id": "PAYPAL_ORDER_ID",
"trade_no": "SUBPPUSR1NO..."
}
}
```
支付成功后复用现有逻辑:
```text
CompleteSubscriptionOrder(trade_no, provider_payload, "paypal", "")
```
最终效果:
```text
subscription_orders.status = success
-> 创建 user_subscriptions
-> 可按 billing_preference 从订阅额度扣模型费用
```
## 7. 需要新增或调整的数据字段
最小实现可以复用现有字段:
| 表 | 字段 | 用途 |
|---|---|---|
| `top_ups.payment_provider` | `paypal` | 标识支付提供方。 |
| `top_ups.payment_method` | `paypal` | 标识支付方式。 |
| `top_ups.trade_no` | 本地订单号 | 本地幂等主键。 |
| `subscription_orders.payment_provider` | `paypal` | 标识订阅订单来自 PayPal。 |
| `subscription_orders.provider_payload` | PayPal 回调摘要 | 保存脱敏后的回调信息。 |
建议新增字段或配置:
| 类型 | 名称 | 说明 |
|---|---|---|
| Option | `PaypalClientId` | PayPal REST app client id。 |
| Option | `PaypalClientSecret` | PayPal REST app secret,保存时不回显。 |
| Option | `PaypalWebhookId` | PayPal webhook 验签需要。 |
| Option | `PaypalEnvironment` | `sandbox` / `live`。 |
| Option | `PaypalCurrency` | 默认 `USD`。 |
| Option | `PaypalMinTopUp` | PayPal 最小充值金额。 |
| Model const | `PaymentMethodPaypal` | 值为 `paypal`。 |
| Model const | `PaymentProviderPaypal` | 值为 `paypal`。 |
如果第二期做 PayPal 原生自动续订,再新增:
| 字段 | 说明 |
|---|---|
| `subscription_plans.paypal_plan_id` | PayPal 端 plan id。 |
| `user_subscriptions.provider_subscription_id` | PayPal subscription id。 |
| `user_subscriptions.auto_renew` | 是否自动续费。 |
| `subscription_orders.provider_order_id` | PayPal order/capture/subscription 关联 ID。 |
## 8. 前端页面变化
钱包充值页:
1. 充值方式增加 PayPal。
2. 点击 PayPal 后调用 `/api/user/paypal/pay`。
3. 成功后打开 `approval_url`。
4. 回到 `success_url` 后刷新余额和充值记录。
套餐订阅页:
1. 购买弹窗增加 PayPal。
2. 点击后调用 `/api/subscription/paypal/pay`。
3. 支付成功后刷新我的订阅。
管理员设置页:
1. 支付设置增加 PayPal 配置块。
2. Secret 类型字段保存后不回显原文。
3. 展示 webhook 地址:`https://code.xinghanlab.com/api/paypal/webhook`。
## 9. 安全和幂等要求
| 要求 | 说明 |
|---|---|
| webhook 必须验签 | 不能只信任前端 return URL。 |
| 本地订单必须先创建 | 不能收到 PayPal 回调才创建订单。 |
| 回调必须幂等 | 同一 `trade_no` 或 PayPal capture id 重复通知不能重复加余额或重复开通订阅。 |
| 支付金额必须二次校验 | PayPal 回调金额、币种必须等于本地订单金额、币种。 |
| 支付提供方必须匹配 | 防止用其他支付网关回调完成 PayPal 订单。 |
| 密钥不进日志 | `client_secret`、webhook 签名、access token 不得写日志。 |
| 退款先不自动扣回 | 第一期可只记录 PayPal refund/dispute 事件,由管理员人工处理;后续再做自动扣减。 |
## 10. 当前项目还缺什么
| 项 | 当前状态 | PayPal 接入需要做 |
|---|---|---|
| 钱包充值闭环 | 已有,非 PayPal | 新增 PayPal provider/controller/service。 |
| 内部订阅闭环 | 已有,非 PayPal | 新增 PayPal 购买入口并复用 `CompleteSubscriptionOrder`。 |
| 模型费用扣费 | 已有 | 不需要因 PayPal 重写。 |
| NewAPI/CodeGW 用户侧余额和日志 | 已有基础 | PayPal 只影响充值入口,不影响模型计费规则。 |
| Agent 运行预算 | 有字段和回调基础 | 真实收费需要 Agent Manager 回传真实 usage。 |
| Agent 基础设施收费 | 未闭环 | 需要单独设计资源计价规则和 Runtime 用量回传。 |
| PayPal 管理配置 | 未实现 | 新增系统设置项和前端配置。 |
| PayPal webhook | 未实现 | 新增 `/api/paypal/webhook`。 |
## 11. 建议实施顺序
1. 新增 PayPal 配置项和支付 provider 常量。
2. 实现 PayPal OAuth access token 获取和 Orders v2 create/capture 查询封装。
3. 实现余额充值 PayPal 下单接口。
4. 实现 PayPal webhook 验签和充值订单完成。
5. 实现内部订阅套餐 PayPal 下单接口。
6. webhook 成功后复用 `CompleteSubscriptionOrder`。
7. 前端充值页和套餐购买弹窗增加 PayPal。
8. 增加单测:金额校验、重复 webhook、provider mismatch、订单状态异常。
9. Sandbox 冒烟:创建订单、支付、回调、余额增加、订阅开通、模型调用扣费。
10. 生产上线前配置 live client、secret、webhook id、currency、回调域名。
## 12. 对外口径
可以这样向上级说明:
```text
Heicode Manager 当前已有余额和订阅计费闭环。PayPal 接入不改变模型调用扣费规则,只作为新的收款渠道接入。
用户通过 PayPal 支付后,Manager 将支付结果转换为 Heicode 钱包余额或内部订阅套餐。模型调用仍由 NewAPI/CodeGW 计费链路按余额或订阅额度扣费。
项目设计文档中存在 Agent 运行预算字段,例如 token、美元成本和运行时长上限,但这目前是部署预算和审计约束,不等同于已完成的 Agent 基础设施收费账本。若要对 Agent 运行单独收费,需要 Agent Manager 回传真实模型用量、运行时长、CPU/内存等数据后再纳入扣费。
```
## 13. 参考资料
- Heicode 产品资料包:`docs/product-package/`
- NewAPI / CodeGW 边界:`docs/heicode-runtime-auth-newapi-secret-design.md`
- Agnet 请求契约:`docs/integration/agnet-platform-request-contract.md`
- PayPal Orders v2:`https://developer.paypal.com/docs/api/orders/v2/`
- PayPal Subscriptions:`https://developer.paypal.com/docs/subscriptions/reference/`
- PayPal Webhook 事件:`https://developer.paypal.com/api/rest/webhooks/event-names`
@@ -150,7 +150,7 @@ Accept: application/json
9. 完成后继续迭代或停止 deployment
```
### 3.1 2026-05-27 生产验证结果
### 3.1 2026-05-28 生产验证结果
本节记录已经按“桌面客户端应调用的顺序”在生产环境跑过的结果,客户端可按同一顺序和参数形状对接。
@@ -159,9 +159,10 @@ Accept: application/json
| 项 | 值 |
|---|---|
| Manager | `https://code.xinghanlab.com` |
| Manager 版本 | `1.4.6` |
| Manager 版本 | `1.4.19` |
| Agent Manager Runtime | `http://20.212.121.126` |
| Runtime health | `healthy` |
| Manager callback | `https://code.xinghanlab.com/api/agnet/callbacks/swarm-events` |
已验证成功的链路:
@@ -171,8 +172,9 @@ Manager 登录
-> /api/agnet/runtime/health
-> /api/agnet/user/tasks/{task_id}/deployment-draft
-> /api/agnet/user/deployments
-> Manager 调 Agent Manager Runtime create
-> Runtime 自动 callback 到 Manager
-> /api/agnet/user/deployments/{deployment_id}
-> 直查 Agent Manager runtime deployment
-> /api/agnet/user/deployments/{deployment_id}/metrics
-> /api/agnet/user/deployments/{deployment_id}/events
-> /api/agnet/user/deployments/{deployment_id}/logs
@@ -180,18 +182,26 @@ Manager 登录
-> /api/agnet/user/deployments/{deployment_id}/sk-snapshots
-> /api/agnet/user/deployments/{deployment_id}/timeline
-> /api/agnet/user/deployments/{deployment_id}/stop
-> 直查 Agent Manager runtime deployment 状态为 stopped
```
本次生产烟测 ID:
最新生产烟测 ID:
| 对象 | ID / 结果 |
|---|---|
| task snapshot | `task-client-sim-1779875397` |
| Manager deployment | `dep_40730ad87435` |
| Runtime deployment | `dep_6747d5eeb54c` |
| create 结果 | 200,Manager 成功保存 runtime deployment id |
| stop 结果 | 200,Manager 和 Agent Manager 均为 `stopped` |
| Manager deployment | `dep_be665a25f6bc` |
| Runtime swarm | `swm_4c471d60972f` |
| detail status | `completed` |
| detail phase | `deploy` |
| runtime_state | `completed` |
| agent state | `completed` |
| callback 数 | `9` |
| event 数 | `12` |
已确认事实:
- Manager 端普通 sub 控制面已经可创建 deployment、调用 Runtime、接收 callback、反写 deployment 状态、聚合 events/timeline。
- Manager 端 callback 支持 HMAC 和旧 token 两种校验;生产当前 HMAC fallback 和 legacy token 均使用同一个值,由运维私下提供给 Agent Manager,不写入本文。
- Agent Manager / Runtime 仍需补真实 artifact、真实 usage/cost、真实日志、失败原因和高危审批闭环;这些是 Runtime 执行数据质量,不阻塞桌面客户端按本文接口开始联调。
本次未由 Codex 直接跑通的步骤:
@@ -203,7 +213,19 @@ Manager 登录
- 如果客户端已经有 HeicodeTask snapshot,可以直接从 `deployment-draft` 开始跑,生产已验证可通。
- 如果客户端需要从自然语言创建任务,必须先完成 Heicode 登录并拿到 `heicode_access_token`。
- 当前 create / detail / metrics / stop 已真实有效;events / logs / artifacts / sk-snapshots / timeline 查询接口均 200,但本次 Runtime 没有产生真实回调数据,所以列表为空。
- 当前 create / callback / detail / metrics / events / timeline / stop 已真实有效;artifacts / sk-snapshots 需要 Runtime 在真实任务中回写 `artifact.created` / `sk_tool.*` 后才会有数据。
### 3.2 桌面客户端联调结论
普通 sub 模式可以开始桌面客户端联调。建议先按 Windows 最新客户端跑通,因为生产已有 Windows 设备绑定记录;macOS 端必须先确认客户端版本和 Keychain 凭据。
| 项 | 当前结论 | 客户端动作 |
|---|---|---|
| Windows 设备绑定 | 生产已有 `windows` 设备绑定记录 | 可直接按本文流程联调 |
| macOS 设备绑定 | 生产库当前没有 `darwin/macOS` 设备绑定记录 | 升级到最新 macOS 包,清理 Keychain 中旧 Heicode 凭据后重新登录 |
| 用户模型列表 | Manager 端真实 token 请求 `/v1/models` 正常 | 如果桌面端 401,优先排查本地 token / device pair,不要先改模型配置 |
| 普通 sub POST 请求 | Manager 支持 V2 body 加密 | 复用模型调用的 `encryptedFetch` |
| 普通 sub GET 查询 | 当前仍建议使用 session + `New-Api-User` 兼容路径 | 后续如需完全无 cookie,再补无 body 签名 GET 协议 |
## 4. 当前用户信息
@@ -1235,12 +1257,13 @@ setInterval(async () => {
## 15. 当前生产注意事项
1. `https://code.xinghanlab.com` 的 Manager 用户态接口已上线;本次蜂群入口 V2 加密修复随 Manager `1.4.10` 发布。
1. `https://code.xinghanlab.com` 的 Manager 用户态接口已上线;当前生产版本为 `1.4.19`。
2. Manager 本地控制面可创建 `sub_mode=agile/waterfall` deployment。
3. 生产 Manager 已配置 Agent Manager Runtime,当前直接走 `http://20.212.121.126`;域名和 HTTPS 后续单独处理,不作为客户端当前接入阻塞项。
4. V2 加密 `deployment-draft` 已在生产验证通过:真实构造 `Content-Encoding: heicode-aead-v1` 请求返回 200,`sub_mode=agile`,`user_id=22`。
5. `events/logs/artifacts/sk-snapshots/timeline` 查询接口已验证不报错;真实阶段事件、产物、SK 调用结果需要 Agent Manager 执行任务并回调后才会出现。
6. `deployment-draft -> create -> detail -> stop` 已在生产验证通过,客户端可按本文参数形状接入。
5. `deployment-draft -> create -> Runtime callback -> detail -> events/timeline -> stop` 已在生产验证通过,客户端可按本文参数形状接入。
6. `events/logs/artifacts/sk-snapshots/timeline` 查询接口已验证不报错;真实 artifact、SK 调用结果和真实成本金额需要 Agent Manager / Runtime 在真实任务中回传。
7. `POST /api/heicode-auth/api/user/tasks/intent` 需要桌面客户端提供 `heicode_access_token`;没有该 token 会返回 401。
8. malformed V2 请求已在生产验证会返回 `X-Heicode-Auth-Error`,客户端应把该头转成可读错误提示。
9. 当前 `GET` 查询接口没有请求 body,仍按 session + `New-Api-User` 验证;这不影响 body 加密要求,但客户端若要全链路无 cookie,需要后续补无 body 签名 GET。
10. macOS 联调前必须确认客户端已完成设备绑定;若 Manager 设备页没有 macOS 设备,模型列表 401 应优先处理客户端本地凭据和 Keychain,而不是改 Manager 模型配置。
+10 -3
View File
@@ -144,6 +144,7 @@ type agnetMetadata struct {
TenantID string `json:"tenant_id,omitempty"` // legacy compatibility only.
ProjectID string `json:"project_id,omitempty"` // legacy compatibility only.
CorrelationID string `json:"correlation_id"`
RuntimeMode string `json:"runtime_mode,omitempty"`
}
type agnetOrchestrationPlan struct {
@@ -1042,7 +1043,7 @@ func requireAuthenticatedUserAgnetDeployment(c *gin.Context) (agnetDeploymentRec
return record, true
}
func createAgnetDeploymentRecord(c *gin.Context, enforceUserScope bool) (agnetDeploymentRecord, bool) {
func createAgnetDeploymentRecord(c *gin.Context, enforceUserScope bool, runtimeMode ...string) (agnetDeploymentRecord, bool) {
var req agnetDeploymentRequest
if err := c.ShouldBindJSON(&req); err != nil {
agnetError(c, "POLICY_REJECTED", err.Error())
@@ -1060,6 +1061,12 @@ func createAgnetDeploymentRecord(c *gin.Context, enforceUserScope bool) (agnetDe
return agnetDeploymentRecord{}, false
}
plan.SubMode = normalizeAgnetSubMode(plan.SubMode)
if len(runtimeMode) > 0 && strings.TrimSpace(plan.Metadata.RuntimeMode) == "" {
plan.Metadata.RuntimeMode = normalizeAgnetRuntimeMode(runtimeMode[0])
}
if strings.TrimSpace(plan.Metadata.RuntimeMode) == "" {
plan.Metadata.RuntimeMode = agnetRuntimeModeAgnet
}
now := agnetNow()
deploymentID := "dep_" + common.GetUUID()[:12]
@@ -1121,7 +1128,7 @@ func writeAgnetDeploymentCreateSuccess(c *gin.Context, record agnetDeploymentRec
}
func createAgnetDeployment(c *gin.Context, enforceUserScope bool) {
record, ok := createAgnetDeploymentRecord(c, enforceUserScope)
record, ok := createAgnetDeploymentRecord(c, enforceUserScope, agnetRuntimeModeAgnet)
if !ok {
return
}
@@ -1130,7 +1137,7 @@ func createAgnetDeployment(c *gin.Context, enforceUserScope bool) {
}
func AgnetCreateUserSwarm(c *gin.Context) {
record, ok := createAgnetDeploymentRecord(c, true)
record, ok := createAgnetDeploymentRecord(c, true, agnetRuntimeModeSwarm)
if !ok {
return
}
+157 -15
View File
@@ -20,6 +20,9 @@ const (
agnetRuntimeStateSyncing = "runtime_syncing"
agnetRuntimeStateSynced = "runtime_accepted"
agnetRuntimeStateFailed = "runtime_sync_failed"
agnetRuntimeModeAgnet = "agnet"
agnetRuntimeModeSwarm = "swarm"
)
type agnetRuntimeConfig struct {
@@ -41,20 +44,65 @@ type agnetRuntimeSyncResult struct {
RawStatusCode int
}
func normalizeAgnetRuntimeMode(value string) string {
switch strings.ToLower(strings.TrimSpace(value)) {
case agnetRuntimeModeSwarm:
return agnetRuntimeModeSwarm
default:
return agnetRuntimeModeAgnet
}
}
func agnetRuntimeModeForSource(source string) string {
if strings.TrimSpace(source) == "api_swarms_adapter" {
return agnetRuntimeModeSwarm
}
return agnetRuntimeModeAgnet
}
func agnetRuntimeModeForRecord(record agnetDeploymentRecord) string {
return normalizeAgnetRuntimeMode(record.Plan.Metadata.RuntimeMode)
}
func agnetRuntimeClientConfig() agnetRuntimeConfig {
return agnetRuntimeClientConfigForMode(agnetRuntimeModeAgnet)
}
func agnetRuntimeClientConfigForMode(mode string) agnetRuntimeConfig {
timeoutSec := common.GetEnvOrDefault("AGNET_RUNTIME_TIMEOUT_SECONDS", 5)
if timeoutSec <= 0 {
timeoutSec = 5
}
mode = normalizeAgnetRuntimeMode(mode)
prefix := "AGNET_RUNTIME_"
defaultCreatePath := "/api/agnet/deployments"
defaultStopPath := "/api/agnet/deployments/{deployment_id}/stop"
defaultApprovalPath := "/api/swarms/{swarm_id}/approvals/{approval_id}"
if mode == agnetRuntimeModeSwarm {
prefix = "SWARM_RUNTIME_"
defaultCreatePath = "/api/swarms"
defaultStopPath = "/api/swarms/{swarm_id}/stop"
defaultApprovalPath = "/api/swarms/{swarm_id}/approvals/{approval_id}"
if swarmTimeout := common.GetEnvOrDefault("SWARM_RUNTIME_TIMEOUT_SECONDS", timeoutSec); swarmTimeout > 0 {
timeoutSec = swarmTimeout
}
}
baseURL := strings.TrimRight(strings.TrimSpace(common.GetEnvOrDefaultString(prefix+"BASE_URL", "")), "/")
enabledDefault := false
if mode == agnetRuntimeModeAgnet {
enabledDefault = common.GetEnvOrDefaultBool("AGNET_RUNTIME_ENABLED", false)
} else {
enabledDefault = baseURL != ""
}
return agnetRuntimeConfig{
Enabled: common.GetEnvOrDefaultBool("AGNET_RUNTIME_ENABLED", false),
Async: common.GetEnvOrDefaultBool("AGNET_RUNTIME_ASYNC", true),
BaseURL: strings.TrimRight(strings.TrimSpace(common.GetEnvOrDefaultString("AGNET_RUNTIME_BASE_URL", "")), "/"),
Token: strings.TrimSpace(common.GetEnvOrDefaultString("AGNET_RUNTIME_SERVICE_TOKEN", "")),
CreatePath: common.GetEnvOrDefaultString("AGNET_RUNTIME_CREATE_PATH", "/api/agnet/deployments"),
HealthPath: common.GetEnvOrDefaultString("AGNET_RUNTIME_HEALTH_PATH", "/api/agnet/health"),
StopPath: common.GetEnvOrDefaultString("AGNET_RUNTIME_STOP_PATH", "/api/agnet/deployments/{deployment_id}/stop"),
ApprovalDecisionPath: common.GetEnvOrDefaultString("AGNET_RUNTIME_APPROVAL_DECISION_PATH", "/api/swarms/{swarm_id}/approvals/{approval_id}"),
Enabled: common.GetEnvOrDefaultBool(prefix+"ENABLED", enabledDefault),
Async: common.GetEnvOrDefaultBool(prefix+"ASYNC", common.GetEnvOrDefaultBool("AGNET_RUNTIME_ASYNC", true)),
BaseURL: baseURL,
Token: strings.TrimSpace(common.GetEnvOrDefaultString(prefix+"SERVICE_TOKEN", "")),
CreatePath: common.GetEnvOrDefaultString(prefix+"CREATE_PATH", defaultCreatePath),
HealthPath: common.GetEnvOrDefaultString(prefix+"HEALTH_PATH", "/api/agnet/health"),
StopPath: common.GetEnvOrDefaultString(prefix+"STOP_PATH", defaultStopPath),
ApprovalDecisionPath: common.GetEnvOrDefaultString(prefix+"APPROVAL_DECISION_PATH", defaultApprovalPath),
Timeout: time.Duration(timeoutSec) * time.Second,
}
}
@@ -85,6 +133,16 @@ func agnetRuntimeSubscribedEvents() []string {
"budget.alert",
"artifact.created",
"timeline.updated",
"task.created",
"task.claimed",
"task.running",
"task.heartbeat",
"task.blocked",
"task.retried",
"task.failed",
"task.completed",
"handoff.requested",
"handoff.completed",
}
}
@@ -180,6 +238,43 @@ func agnetRuntimeRequestAgents(plan agnetOrchestrationPlan) []gin.H {
return items
}
func agnetRuntimeRequestSwarmAgents(plan agnetOrchestrationPlan) []gin.H {
runtimeModels := make(map[string]string, len(plan.AgentRuntime.Agents))
for _, runtimeAgent := range plan.AgentRuntime.Agents {
role := strings.TrimSpace(runtimeAgent.Role)
if role != "" && strings.TrimSpace(runtimeAgent.ModelRef) != "" {
runtimeModels[role] = strings.TrimSpace(runtimeAgent.ModelRef)
}
}
items := make([]gin.H, 0, len(plan.Agents))
for index, agent := range plan.Agents {
role := strings.TrimSpace(agent.RoleTemplate)
if role == "" {
continue
}
taskID := fmt.Sprintf("%s-%d", sanitizeAgnetRef(role), index+1)
item := gin.H{
"task_id": taskID,
"role": role,
"title": role + " task",
"description": firstNonEmpty(agent.Goal, "Execute the Heicode swarm task as "+role),
"depends_on": []string{},
}
if modelRef := firstNonEmpty(runtimeModels[role], agent.DefaultModelID); modelRef != "" {
item["model_ref"] = modelRef
}
if len(agent.SKSources) > 0 {
item["sk_sources"] = agent.SKSources
}
if len(agent.ResourceGrants) > 0 {
item["resource_grants"] = agnetRuntimeResourceGrantPayloads(agent.ResourceGrants)
}
items = append(items, item)
}
return items
}
func agnetRuntimeResourceGrantPayloads(grants []agnetResourceGrant) []gin.H {
items := make([]gin.H, 0, len(grants))
for _, grant := range grants {
@@ -240,6 +335,7 @@ func agnetRuntimeRequestMetadata(record agnetDeploymentRecord, source string) gi
"source": source,
"heicode_deployment_id": record.DeploymentID,
"heicode_runtime_bridge": true,
"runtime_mode": agnetRuntimeModeForRecord(record),
}
if record.Plan.Metadata.TenantID != "" {
metadata["tenant_id"] = record.Plan.Metadata.TenantID
@@ -250,6 +346,42 @@ func agnetRuntimeRequestMetadata(record agnetDeploymentRecord, source string) gi
return metadata
}
func agnetRuntimeBudgetPayload(budget agnetBudget) gin.H {
return gin.H{
"max_tokens": budget.MaxTokens,
"token_limit": budget.MaxTokens,
"max_cost_usd": budget.MaxCostUSD,
"max_duration_sec": budget.MaxDurationSec,
"duration_seconds": budget.MaxDurationSec,
"max_duration_seconds": budget.MaxDurationSec,
}
}
func agnetRuntimeOrchestrationPlanPayload(record agnetDeploymentRecord) any {
if agnetRuntimeModeForRecord(record) != agnetRuntimeModeSwarm {
return record.Plan
}
plan := record.Plan
return gin.H{
"intent_id": plan.IntentID,
"template_hint": plan.TemplateHint,
"objective": plan.Objective,
"sub_mode": firstNonEmpty(plan.SubMode, "goal_driven_swarm"),
"risk_level": plan.RiskLevel,
"budget": agnetRuntimeBudgetPayload(plan.Budget),
"user_context": plan.UserContext,
"billing_context": plan.BillingContext,
"agile_context": plan.AgileContext,
"agents": agnetRuntimeRequestSwarmAgents(plan),
"resource_grants": agnetRuntimeRequestResourceGrants(plan),
"constraints": plan.Constraints,
"metadata": agnetRuntimeRequestMetadata(record, "orchestration_plan"),
"agent_runtime": plan.AgentRuntime,
"acceptance": plan.AgileContext.AcceptanceCriteria,
"acceptance_tests": plan.AgileContext.AcceptanceCriteria,
}
}
func agnetRuntimeCreatePayload(record agnetDeploymentRecord, source string) gin.H {
callback := gin.H{
"url": agnetRuntimeCallbackURL(),
@@ -259,10 +391,10 @@ func agnetRuntimeCreatePayload(record agnetDeploymentRecord, source string) gin.
callback["signing_secret_ref"] = ref
}
return gin.H{
"orchestration_plan": record.Plan,
"orchestration_plan": agnetRuntimeOrchestrationPlanPayload(record),
"agents": agnetRuntimeRequestAgents(record.Plan),
"risk_level": record.Plan.RiskLevel,
"budget": record.Plan.Budget,
"budget": agnetRuntimeBudgetPayload(record.Plan.Budget),
"billing_context": record.Plan.BillingContext,
"resource_grants": agnetRuntimeRequestResourceGrants(record.Plan),
"callback": callback,
@@ -463,8 +595,7 @@ func syncAgnetRuntimeApprovalDecision(c *gin.Context, approval *model.AgnetAppro
if approval == nil || strings.TrimSpace(approval.DeploymentID) == "" {
return
}
cfg := agnetRuntimeClientConfig()
if !cfg.Enabled {
if !agnetRuntimeClientConfigForMode(agnetRuntimeModeAgnet).Enabled && !agnetRuntimeClientConfigForMode(agnetRuntimeModeSwarm).Enabled {
return
}
record, ok := findAgnetDeploymentRecord(approval.DeploymentID)
@@ -472,6 +603,10 @@ func syncAgnetRuntimeApprovalDecision(c *gin.Context, approval *model.AgnetAppro
recordAgnetApprovalAudit("runtime.approval_decision.skipped", approval, lease, "skipped", "deployment not found")
return
}
cfg := agnetRuntimeClientConfigForMode(agnetRuntimeModeForRecord(record))
if !cfg.Enabled {
return
}
if strings.TrimSpace(record.RuntimeSwarmID) == "" && strings.TrimSpace(record.RuntimeDeploymentID) == "" {
recordAgnetApprovalAudit("runtime.approval_decision.skipped", approval, lease, "skipped", "runtime identifiers missing")
return
@@ -539,7 +674,7 @@ func callAgnetRuntimeStop(ctx context.Context, cfg agnetRuntimeConfig, record ag
}
func syncAgnetRuntimeStop(c *gin.Context, record agnetDeploymentRecord, reason string) (agnetDeploymentRecord, bool) {
cfg := agnetRuntimeClientConfig()
cfg := agnetRuntimeClientConfigForMode(agnetRuntimeModeForRecord(record))
if !cfg.Enabled || strings.TrimSpace(record.RuntimeDeploymentID) == "" {
return record, true
}
@@ -622,7 +757,13 @@ func dispatchAgnetRuntimeCreate(record agnetDeploymentRecord, source string, cfg
}
func maybeDispatchAgnetRuntimeCreate(c *gin.Context, record agnetDeploymentRecord, source string) agnetDeploymentRecord {
cfg := agnetRuntimeClientConfig()
mode := agnetRuntimeModeForSource(source)
if strings.TrimSpace(record.Plan.Metadata.RuntimeMode) == "" {
record.Plan.Metadata.RuntimeMode = mode
} else {
mode = agnetRuntimeModeForRecord(record)
}
cfg := agnetRuntimeClientConfigForMode(mode)
if !cfg.Enabled {
return record
}
@@ -654,13 +795,14 @@ func maybeDispatchAgnetRuntimeCreate(c *gin.Context, record agnetDeploymentRecor
}
func AgnetRuntimeHealth(c *gin.Context) {
cfg := agnetRuntimeClientConfig()
cfg := agnetRuntimeClientConfigForMode(c.Query("mode"))
data := gin.H{
"enabled": cfg.Enabled,
"configured": cfg.BaseURL != "",
"create_path": cfg.CreatePath,
"health_path": cfg.HealthPath,
"stop_path": cfg.StopPath,
"mode": normalizeAgnetRuntimeMode(c.Query("mode")),
}
if cfg.BaseURL == "" {
data["status"] = "not_configured"
+8
View File
@@ -61,6 +61,14 @@ services:
- AGNET_RUNTIME_STOP_PATH=${AGNET_RUNTIME_STOP_PATH:-/api/agnet/deployments/{deployment_id}/stop}
- AGNET_RUNTIME_SERVICE_TOKEN=${AGNET_RUNTIME_SERVICE_TOKEN:-}
- AGNET_RUNTIME_CALLBACK_SIGNING_SECRET_REF=${AGNET_RUNTIME_CALLBACK_SIGNING_SECRET_REF:-}
# HeiCode-Swarm Runtime is separate from ordinary sub Agnet Runtime.
- SWARM_RUNTIME_ENABLED=${SWARM_RUNTIME_ENABLED:-false}
- SWARM_RUNTIME_BASE_URL=${SWARM_RUNTIME_BASE_URL:-}
- SWARM_RUNTIME_CREATE_PATH=${SWARM_RUNTIME_CREATE_PATH:-/api/swarms}
- SWARM_RUNTIME_HEALTH_PATH=${SWARM_RUNTIME_HEALTH_PATH:-/api/agnet/health}
- SWARM_RUNTIME_STOP_PATH=${SWARM_RUNTIME_STOP_PATH:-/api/swarms/{swarm_id}/stop}
- SWARM_RUNTIME_APPROVAL_DECISION_PATH=${SWARM_RUNTIME_APPROVAL_DECISION_PATH:-/api/swarms/{swarm_id}/approvals/{approval_id}}
- SWARM_RUNTIME_SERVICE_TOKEN=${SWARM_RUNTIME_SERVICE_TOKEN:-}
networks:
- heicode-network
healthcheck:
@@ -209,3 +209,103 @@ func TestAgnetRuntimeRealHTTPHealthAndShadowCreateSmoke(t *testing.T) {
require.Contains(t, metricsBody, `"data_source":"manager_control_plane"`)
require.Contains(t, metricsBody, `"platform_estimated":true`)
}
func TestSwarmRuntimeHTTPCreateUsesSwarmConfigAndPayload(t *testing.T) {
db := setupAgnetRuntimeHTTPSmokeDB(t)
swarmCreateCalled := false
swarmStopCalled := false
swarm := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
switch {
case r.Method == http.MethodGet && r.URL.Path == "/api/agnet/health":
require.Equal(t, "Bearer swarm-runtime-token", r.Header.Get("Authorization"))
w.Header().Set("Content-Type", "application/json")
_, _ = w.Write([]byte(`{"success":true,"data":{"status":"healthy","service":"heicode-swarm-runtime"}}`))
case r.Method == http.MethodPost && r.URL.Path == "/api/swarms":
swarmCreateCalled = true
require.Equal(t, "Bearer swarm-runtime-token", r.Header.Get("Authorization"))
require.Equal(t, "corr-swarm-smoke", r.Header.Get("X-Correlation-ID"))
body, err := io.ReadAll(r.Body)
require.NoError(t, err)
require.Contains(t, string(body), `"runtime_mode":"swarm"`)
require.Contains(t, string(body), `"manager_deployment_id"`)
var runtimeBody map[string]any
require.NoError(t, common.Unmarshal(body, &runtimeBody))
plan := runtimeBody["orchestration_plan"].(map[string]any)
agents := plan["agents"].([]any)
require.Len(t, agents, 3)
require.Equal(t, "planner", agents[0].(map[string]any)["role"])
require.Equal(t, "builder", agents[1].(map[string]any)["role"])
require.Equal(t, "reviewer", agents[2].(map[string]any)["role"])
require.NotContains(t, agents[0].(map[string]any), "role_template")
metadata := runtimeBody["metadata"].(map[string]any)
require.Equal(t, "swarm", metadata["runtime_mode"])
require.Equal(t, "api_swarms_adapter", metadata["source"])
w.Header().Set("Content-Type", "application/json")
_, _ = w.Write([]byte(`{"success":true,"data":{"deployment_id":"swarm-runtime-dep","swarm_id":"swarm-runtime-id","status":"running"}}`))
case r.Method == http.MethodPost && r.URL.Path == "/api/swarms/swarm-runtime-id/stop":
swarmStopCalled = true
require.Equal(t, "Bearer swarm-runtime-token", r.Header.Get("Authorization"))
w.Header().Set("Content-Type", "application/json")
_, _ = w.Write([]byte(`{"success":true,"data":{"deployment_id":"swarm-runtime-dep","swarm_id":"swarm-runtime-id","status":"stopped"}}`))
default:
http.NotFound(w, r)
}
}))
defer swarm.Close()
t.Setenv("AGNET_RUNTIME_ENABLED", "false")
t.Setenv("SWARM_RUNTIME_ENABLED", "true")
t.Setenv("SWARM_RUNTIME_ASYNC", "false")
t.Setenv("SWARM_RUNTIME_BASE_URL", swarm.URL)
t.Setenv("SWARM_RUNTIME_SERVICE_TOKEN", "swarm-runtime-token")
managerURL := startAgnetRuntimeManagerSmokeServer(t)
healthResp := agnetRuntimeAdminRequest(t, http.MethodGet, managerURL+"/api/agnet/runtime/health?mode=swarm", "")
healthBody := readAgnetRuntimeSmokeBody(t, healthResp)
require.Equal(t, http.StatusOK, healthResp.StatusCode)
require.Contains(t, healthBody, `"mode":"swarm"`)
require.Contains(t, healthBody, `"status":"healthy"`)
require.Contains(t, healthBody, `"heicode-swarm-runtime"`)
createBody := `{
"orchestration_plan":{
"intent_id":"intent-swarm-smoke",
"template_hint":"heicode-swarm",
"objective":"swarm runtime smoke",
"sub_mode":"agile",
"risk_level":"low",
"budget":{"max_tokens":10000,"max_cost_usd":1,"max_duration_sec":600},
"user_context":{"user_id":"101","channel_id":"default"},
"billing_context":{"provider":"newapi","newapi_user_ref":"newapi-swarm-smoke"},
"agile_context":{"iteration":"2026-05-29","stage":"testing","checkpoint":"runtime_accepted","acceptance_criteria":["Runtime creates a swarm run"],"next_action":"submit_test_result","requires_user_approval":false},
"agent_runtime":{"platform":"agnet","agents":[{"role":"planner","model_ref":"model-swarm","instance_count":1},{"role":"builder","model_ref":"model-swarm","instance_count":1},{"role":"reviewer","model_ref":"model-swarm","instance_count":1}]},
"agents":[
{"role_template":"planner","goal":"plan the swarm task","default_model_id":"model-swarm","resource_grants":[{"grant_id":"grant-swarm-plan","resource_id":"doc-swarm","resource_type":"project_doc","user_id":"101","binding_scope":"task-swarm-smoke","target_role":"planner","target_agent_ref":"agent-planner-1","permission_scope":["doc:read"],"metadata":{"resource_ref":"task-swarm-smoke"},"status":"active"}]},
{"role_template":"builder","goal":"build the swarm output","default_model_id":"model-swarm","resource_grants":[{"grant_id":"grant-swarm-build","resource_id":"git-swarm","resource_type":"git","user_id":"101","binding_scope":"task-swarm-smoke","target_role":"builder","target_agent_ref":"agent-builder-1","permission_scope":["repo:read"],"metadata":{"repo_url":"https://example.invalid/heicode/swarm.git"},"secret_ref":"azkv://heicode-kv.vault.azure.net/secrets/swarm-git","status":"active"}]},
{"role_template":"reviewer","goal":"verify the swarm output","default_model_id":"model-swarm","resource_grants":[{"grant_id":"grant-swarm-review","resource_id":"doc-swarm","resource_type":"project_doc","user_id":"101","binding_scope":"task-swarm-smoke","target_role":"reviewer","target_agent_ref":"agent-reviewer-1","permission_scope":["doc:read"],"metadata":{"resource_ref":"task-swarm-smoke"},"status":"active"}]}
],
"constraints":{"allowed_model_ids":["model-swarm"]},
"metadata":{"correlation_id":"corr-swarm-smoke"}
}
}`
createResp := agnetRuntimeAdminRequest(t, http.MethodPost, managerURL+"/api/swarms", createBody)
responseBody := readAgnetRuntimeSmokeBody(t, createResp)
require.Equal(t, http.StatusOK, createResp.StatusCode)
require.Contains(t, responseBody, `"success":true`)
require.Contains(t, responseBody, `"runtime_deployment_id":"swarm-runtime-dep"`)
require.Contains(t, responseBody, `"runtime_swarm_id":"swarm-runtime-id"`)
require.True(t, swarmCreateCalled)
var stored model.AgnetDeployment
require.NoError(t, db.Where("runtime_swarm_id = ?", "swarm-runtime-id").First(&stored).Error)
require.Equal(t, "agile", stored.SubMode)
require.Contains(t, stored.PlanJSON, `"runtime_mode":"swarm"`)
stopResp := agnetRuntimeAdminRequest(t, http.MethodPost, managerURL+"/api/agnet/user/deployments/"+stored.DeploymentID+"/stop", `{"reason":"smoke done"}`)
stopBody := readAgnetRuntimeSmokeBody(t, stopResp)
require.Equal(t, http.StatusOK, stopResp.StatusCode)
require.Contains(t, stopBody, `"success":true`)
require.Contains(t, stopBody, `"runtime_state":"stopped"`)
require.True(t, swarmStopCalled)
}