feat: document and expose swarm runtime contract

This commit is contained in:
gongzhiyong
2026-05-28 11:57:58 +08:00
parent ee5342c404
commit 8075891770
10 changed files with 1025 additions and 37 deletions
@@ -70,16 +70,16 @@ Heicode 桌面客户端
| 优先级 | 任务 | 当前缺口 | 是否 Manager 可独立做 | 验收标准 | | 优先级 | 任务 | 当前缺口 | 是否 Manager 可独立做 | 验收标准 |
|---|---|---|---|---| |---|---|---|---|---|
| P0 | 把 `/api/swarms` adapter 文档化并固定字段 | 代码已有入口,但文档要明确它和 Runtime `POST /api/swarms` / `/api/agnet/deployments` 的映射 | 是 | 文档写清 request/response、source、`deployment_id <-> swarm_id` 映射 | | P0 | 把 `/api/swarms` adapter 文档化并固定字段 | 已完成:`docs/integration/蜂群模式-AgentManager对接任务清单.md` 和 `docs/integration/普通sub敏捷模式-AgentManager对接任务清单.md` 已写清 Runtime create、callback、source、`deployment_id <-> swarm_id` 映射 | 是 | 文档可直接发给 Agent Manager / 蜂群侧核对 |
| P0 | 增加 Swarm Run 显示字段 | 页面需要明确展示 `runtime_deployment_id`、`runtime_swarm_id`、`runtime_state`、source | 是 | 用户态 deployment 详情和后台页面能看到 Runtime 映射 | | P0 | 增加 Swarm Run 显示字段 | 已完成:用户态 deployment 详情展示 `runtime_deployment_id`、`runtime_swarm_id`、`runtime_state`,并在相关记录里展示 source | 是 | 用户态 deployment 详情和后台页面能看到 Runtime 映射 |
| P0 | 回调事件类型收敛 | 目前统一 `swarm-events` 已能接,但需要按蜂群事件标准列出必填字段和展示规则 | 是 | `task.created/claimed/running/completed/failed/handoff/approval/artifact` 都有 schema | | P0 | 回调事件类型收敛 | 已完成:`GET /api/agnet/callbacks/swarm-events/schema` 输出事件类型、分类和必填字段;callback 接收端按 schema 校验关键 task/handoff/artifact/approval 字段 | 是 | `task.created/claimed/running/completed/failed/handoff/approval/artifact` 都有 schema |
| P0 | Runtime 联调配置模板 | 需要把 `AGNET_RUNTIME_*` 配置、token、callback URL、签名方式整理给蜂群侧 | 是 | 蜂群项目按模板能调用 Manager callback | | P0 | Runtime 联调配置模板 | 已完成:两份 Agent Manager 对接任务清单已写清 `AGNET_RUNTIME_*`、callback URL、service token/HMAC 方式和验收步骤 | 是 | 蜂群项目按模板能调用 Manager callback |
| P1 | 审批结果回传 Runtime 联调 | Manager adapter 已有;仍需要 Runtime 提供接收接口并验证状态继续/停止 | 需要 Runtime 接口 | 审批通过/拒绝后 Runtime 状态能继续或停止 | | P1 | 审批结果回传 Runtime 联调 | Manager adapter 已有;仍需要 Runtime 提供接收接口并验证状态继续/停止 | 需要 Runtime 接口 | 审批通过/拒绝后 Runtime 状态能继续或停止 |
| P1 | Artifact 展示优化 | 已能在页面展示 artifact 类型和 URI;仍需等 Runtime 输出真实 code_patch/document/test_report/deployment_manifest | 需要 Runtime 数据 | artifact 页面/详情能按类型展示摘要和链接 | | P1 | Artifact 展示优化 | Manager 端已完成:页面展示 artifact 类型、摘要和 URI;真实 `code_patch/document/test_report/deployment_manifest` 仍需 Runtime 输出 | 需要 Runtime 数据 | artifact 页面/详情能按类型展示摘要和链接 |
| P1 | 任务图/Agent 状态展示占位 | Manager 还没有真实 task graph/claim/heartbeat 页面结构 | Manager 可先做展示结构,真实数据需 Runtime | 有空态和字段,不宣称真实已运行 | | P1 | 任务图/Agent 状态展示占位 | 已完成:页面从 `task.*` / `handoff.*` callback 聚合 Agent task map;无真实数据时显示 Runtime callback 空态 | Manager 可先做展示结构,真实数据需 Runtime | 有空态和字段,不宣称真实已运行 |
| P1 | 日志/指标真实来源标识 | deployment 详情、timeline 已强化 runtime/simulated 来源;logs/metrics 仍需 Runtime 真实数据 | 需要 Runtime 数据 | 页面和 API 响应能区分来源 | | P1 | 日志/指标真实来源标识 | 已完成:logs/metrics API 返回 `data_source`、`runtime_source`,当前明确是 Manager control-plane / estimated,不伪装 Runtime 真实指标 | 需要 Runtime 数据 | 页面和 API 响应能区分来源 |
| P1 | 高危审批客户端联动文档 | Manager 有 API,桌面客户端需要知道如何轮询/展示/提交审批 | 是 | 客户端文档补齐 approval flow | | P1 | 高危审批客户端联动文档 | 已完成:`docs/integration/heicode-desktop-sub-agile-api.md` 已包含 approval 查询、approve/reject、awaiting_approval 流程 | 是 | 客户端文档补齐 approval flow |
| P2 | 蜂群模式验收脚本 | Manager 已有默认模拟链路和定向单测;还需要整理生产 curl 脚本 | 是 | 本地/生产能跑出 callback、artifact、approval、timeline 可见 | | P2 | 蜂群模式验收脚本 | 已完成:`scripts/agnet_sub_mode_smoke.py` 支持 schema 检查、生产健康检查、可选 simulate-events、可选真实 callback smoke | 是 | 本地/生产能跑出 callback、artifact、approval、timeline 可见 |
## 五、需要蜂群项目配合的事项 ## 五、需要蜂群项目配合的事项
@@ -0,0 +1,624 @@
# Agent Manager 蜂群 Runtime 接口实现要求
更新时间:2026-05-28
面向对象:Agent Manager / HeiCode-Swarm Runtime 开发负责人
用途:Agent Manager 按本文实现接口、字段和回调后,Heicode Manager 可直接联调蜂群模式。
## 1. 结论
Heicode Manager 已负责控制面和记录面:
- 生成 deployment / swarm 创建请求。
- 传递 `resource_grants`、`secret_ref`、预算、模型网关上下文和 callback 地址。
- 保存 `deployment_id <-> runtime_deployment_id <-> swarm_id` 映射。
- 接收 Runtime callback,落库 artifact / approval / timeline / audit。
- 用户 approve/reject 后,把审批决定回传 Runtime。
Agent Manager / Swarm Runtime 需要负责执行层:
- 创建真实 Swarm Run。
- 生成真实 task graph。
- 管理 Agent claim / heartbeat / handoff / retry / blocked / failed 状态机。
- 真实执行任务并产出 artifact。
- 在高危动作前暂停并回调审批请求。
- 接收审批结果后继续或停止。
- 回传日志、指标、用量和最终结果。
## 2. 总体调用链
```text
Heicode Desktop Client
-> Heicode Manager
POST /api/swarms 或 /api/agnet/user/deployments
-> Agent Manager / Swarm Runtime
POST /api/swarms
<- Runtime response
runtime_deployment_id / swarm_id / status
<- Runtime callback
POST /api/agnet/callbacks/swarm-events
-> Runtime approval decision
POST /api/swarms/{swarm_id}/approvals/{approval_id}
```
## 3. Agent Manager 必须提供的接口
| 优先级 | 方法 | 路径 | 必须 | 用途 |
|---|---|---|---:|---|
| P0 | `GET` | `/api/agnet/health` | 是 | 健康检查 |
| P0 | `POST` | `/api/swarms` | 是 | 创建真实 Swarm Run |
| P0 | `POST` | `/api/swarms/{swarm_id}/stop` | 是 | 停止 Swarm Run |
| P0 | `POST` | `/api/swarms/{swarm_id}/approvals/{approval_id}` | 是 | 接收 Manager 审批决定 |
| P0 | callback | Manager `/api/agnet/callbacks/swarm-events` | 是 | 回写状态、task、handoff、artifact、approval |
| P1 | `GET` | `/api/swarms/{swarm_id}` | 建议 | 查询 Runtime 详情 |
| P1 | `GET` | `/api/swarms/{swarm_id}/tasks` | 建议 | 查询 task graph |
| P1 | `GET` | `/api/swarms/{swarm_id}/logs` | 建议 | 查询日志 |
| P1 | `GET` | `/api/swarms/{swarm_id}/metrics` | 建议 | 查询指标 |
如果短期无法提供 `/api/swarms`,可以临时确认兼容路径,例如 `/tasks`。但这只能作为联调过渡,不作为最终生产契约。
## 4. 认证与公共 Header
Manager 调 Agent Manager 时携带:
```http
Authorization: Bearer <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
```
要求:
1. `Authorization` 用于服务间鉴权。
2. `X-Correlation-ID` 必须贯穿 create、callback、approval decision、logs、metrics。
3. `X-Idempotency-Key` 必须支持幂等;重复创建请求不能生成多个真实 Swarm Run。
4. Agent Manager 不得要求 Manager 传长期明文密钥。
## 5. `GET /api/agnet/health`
### 响应
```json
{
"success": true,
"data": {
"status": "healthy",
"service": "agent-manager-swarm-runtime",
"version": "1.0.0",
"runtime": "aks",
"time": "2026-05-28T10:00:00Z"
}
}
```
验收:
- HTTP 200。
- `status` 为 `healthy` / `ok` / `up` 之一。
- 不返回密钥、Token、连接串。
## 6. `POST /api/swarms`
### 请求体
```json
{
"orchestration_plan": {
"intent_id": "task_123",
"template_hint": "heicode-task",
"objective": "完成本轮用户目标",
"sub_mode": "agile",
"risk_level": "medium",
"budget": {
"max_tokens": 120000,
"max_cost_usd": 8,
"max_duration_sec": 3600
},
"user_context": {
"user_id": "22",
"channel_id": "heicode",
"binding_scope": "task-task-123"
},
"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"
},
"agile_context": {
"iteration": "2026-05-28",
"stage": "development",
"checkpoint": "draft_created",
"acceptance_criteria": [
"接口返回成功",
"artifact 可回写到 timeline",
"不出现明文密钥"
],
"next_action": "continue",
"requires_user_approval": false
},
"agents": [
{
"role_template": "backend",
"goal": "完成后端实现和测试",
"default_model_id": "model_xxx",
"resource_grants": []
}
],
"resource_grants": []
},
"agents": [
{
"role": "backend",
"resource_grants": []
}
],
"resource_grants": [
{
"grant_id": "grant-task-123-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": "backend",
"constraints": {
"allowed_paths": "heicode/**"
},
"metadata": {
"repo": "heicode-manager"
}
}
],
"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.heartbeat",
"task.blocked",
"task.retried",
"task.failed",
"task.completed",
"handoff.requested",
"handoff.completed",
"approval.requested",
"artifact.created",
"timeline.updated",
"budget.alert"
]
},
"metadata": {
"manager_deployment_id": "dep_xxx",
"heicode_deployment_id": "dep_xxx",
"heicode_runtime_bridge": true,
"correlation_id": "corr_xxx"
}
}
```
### Agent Manager 必须消费的字段
| 字段 | 必须 | 说明 |
|---|---:|---|
| `orchestration_plan.objective` | 是 | 用户目标 |
| `orchestration_plan.sub_mode` | 是 | `agile` / `waterfall`;蜂群执行时也要保留此组织方式 |
| `orchestration_plan.risk_level` | 是 | 高危动作必须走审批 |
| `orchestration_plan.budget` | 是 | token / cost / duration 上限 |
| `billing_context.provider` | 是 | 当前为 `newapi` |
| `billing_context.secret_ref` | 是 | 模型网关密钥引用,只能是 `azkv://...` |
| `agents[].role` 或 `role_template` | 是 | 子 Agent 角色 |
| `resource_grants[]` | 是 | 资源授权清单 |
| `resource_grants[].secret_ref` | 凭据资源必填 | 只允许 Key Vault 引用,不允许明文 |
| `callback.url` | 是 | Runtime 回调 Manager 的地址 |
| `callback.signing_secret_ref` | 建议 | HMAC 签名密钥引用 |
| `metadata.manager_deployment_id` | 是 | Manager 侧 deployment id |
| `metadata.correlation_id` | 是 | 全链路追踪 |
### 响应
```json
{
"success": true,
"data": {
"deployment_id": "runtime-dep-123",
"swarm_id": "swarm-123",
"status": "running",
"created_at": "2026-05-28T10:00:00Z",
"estimated_ready_at": "2026-05-28T10:02:00Z"
}
}
```
兼容响应:
```json
{
"deployment_id": "runtime-dep-123",
"swarm_id": "swarm-123",
"status": "running"
}
```
Manager 映射规则:
| Agent Manager 返回字段 | Manager 保存字段 |
|---|---|
| `data.deployment_id` / `deployment_id` / `id` | `runtime_deployment_id` |
| `data.swarm_id` / `swarm_id` / `runtime_swarm_id` | `runtime_swarm_id` |
| `data.status` / `status` / `runtime_status` | `runtime_state` |
## 7. Runtime 必须生成的 task graph
Agent Manager 创建 Swarm Run 后,必须在 Runtime 内部生成任务图,并通过 callback 回写。
每个 task 至少包含:
```json
{
"task_id": "task-backend-1",
"title": "实现后端接口",
"description": "完成 API、校验和测试",
"agent_role": "backend",
"status": "pending",
"depends_on": ["task-design-1"],
"attempt": 1
}
```
状态建议:
| status | 说明 |
|---|---|
| `pending` | 等待执行 |
| `claimed` | 已被 Agent 领取 |
| `running` | 执行中 |
| `blocked` | 阻塞 |
| `handoff_requested` | 等待交接 |
| `retrying` | 重试中 |
| `completed` | 完成 |
| `failed` | 失败 |
## 8. Runtime 回调 Manager
Manager 回调地址:
```http
POST https://code.xinghanlab.com/api/agnet/callbacks/swarm-events
X-Agnet-Service-Token: <callback_token>
X-Agnet-Event-Id: <event_id>
X-Correlation-ID: <correlation_id>
Content-Type: application/json
```
也支持 HMAC:
```http
X-Agnet-Timestamp: <unix_ms>
X-Agnet-Signature: sha256=<hex>
```
HMAC 签名内容:
```text
timestamp + "." + event_id + "." + raw_body
```
联调前可拉取 Manager 当前接受的事件 schema:
```http
GET https://code.xinghanlab.com/api/agnet/callbacks/swarm-events/schema
```
### 通用 callback envelope
```json
{
"event_id": "evt_123",
"idempotency_key": "evt_123",
"event_type": "task.claimed",
"deployment_id": "dep_xxx",
"swarm_id": "swarm-123",
"agent_instance_id": "agent-backend-1",
"task_id": "task-backend-1",
"occurred_at": "2026-05-28T10:10:00Z",
"correlation_id": "corr_xxx",
"source": "agent-manager-runtime",
"payload": {}
}
```
要求:
1. `event_id` 全局唯一。
2. 重试同一事件必须复用相同 `event_id` 或 `idempotency_key`。
3. `deployment_id` 优先使用 Manager deployment id。
4. 如果只知道 `swarm_id`,Manager 也会按 `runtime_swarm_id` 查找 deployment。
5. `source` 要能区分真实 Runtime,例如 `agent-manager-runtime`,不要写 `simulated`。
## 9. 必须回调的事件和字段
| event_type | 必填字段 | 说明 |
|---|---|---|
| `deployment.status_changed` | `payload.status` | Runtime 整体状态变化 |
| `task.created` | `task_id`, `payload.title` | 任务图新增任务 |
| `task.claimed` | `task_id`, `payload.agent_role` | Agent 领取任务 |
| `task.running` | `task_id`, `payload.agent_role` | Agent 开始执行 |
| `task.heartbeat` | `task_id`, `payload.agent_role` | Agent 心跳 |
| `task.blocked` | `task_id`, `payload.reason` | 任务阻塞 |
| `task.retried` | `task_id`, `payload.attempt` | 任务重试 |
| `task.failed` | `task_id`, `payload.reason` | 任务失败 |
| `task.completed` | `task_id` | 任务完成 |
| `handoff.requested` | `task_id`, `payload.from_role`, `payload.to_role` | 请求交接 |
| `handoff.completed` | `task_id`, `payload.from_role`, `payload.to_role` | 完成交接 |
| `artifact.created` | `artifact.artifact_id` 或 `payload.artifact_id` | 产物生成 |
| `approval.requested` | `payload.approval_id`, `payload.operation`, `payload.risk_level` | 高危审批 |
| `timeline.updated` | `payload.title` | 用户可见时间线 |
| `budget.alert` | `payload.threshold_pct` | 预算告警 |
## 10. artifact.created
Runtime 产生中间产物或最终交付物时回调:
```json
{
"event_id": "evt_artifact_1",
"event_type": "artifact.created",
"deployment_id": "dep_xxx",
"swarm_id": "swarm-123",
"task_id": "task-backend-1",
"source": "agent-manager-runtime",
"artifact": {
"artifact_id": "art_backend_patch_1",
"artifact_type": "code_patch",
"title": "Backend API patch",
"summary": "新增任务创建和状态查询接口",
"uri": "git://heicode-manager/branches/feature/task-api",
"checksum": "sha256:abc123",
"metadata": {
"agent_role": "backend",
"redacted": true
}
}
}
```
支持的 `artifact_type`:
| 类型 | 说明 |
|---|---|
| `code_patch` | 代码补丁或分支 |
| `document` | 文档 |
| `test_report` | 测试报告 |
| `deployment_manifest` | 部署清单 |
| `log_bundle` | 日志包 |
| `other` | 其他 |
要求:
1. 大文件不要内联到 callback body。
2. `summary` 可展示给用户,不得包含密钥。
3. `uri` 可以是 `git://`、`artifact://`、`azblob://`、`https://`。
4. metadata 必须脱敏。
## 11. approval.requested 与审批结果
高危动作前 Runtime 必须暂停,并回调:
```json
{
"event_id": "evt_approval_1",
"event_type": "approval.requested",
"deployment_id": "dep_xxx",
"swarm_id": "swarm-123",
"task_id": "task-deploy-1",
"source": "agent-manager-runtime",
"payload": {
"approval_id": "runtime-approval-1",
"operation": "deploy.production",
"resource_id": "prod-env",
"resource_type": "azure",
"resource_scope": "resource-group/heicode-prod",
"target_role": "ops",
"risk_level": "high",
"requires_credential": true,
"secret_ref": "azkv://heicode-kv.vault.azure.net/secrets/prod-deploy",
"ttl_seconds": 600,
"reason": "需要部署到生产环境"
}
}
```
Manager / 客户端审批后,Manager 调 Runtime:
```http
POST /api/swarms/{swarm_id}/approvals/{approval_id}
Authorization: Bearer <service_token>
X-Correlation-ID: <correlation_id>
X-Idempotency-Key: approval-decision-<approval_id>-<decision>
Content-Type: application/json
```
请求:
```json
{
"approval_id": "runtime-approval-1",
"decision": "approved",
"reason": "用户已确认",
"manager_deployment_id": "dep_xxx",
"swarm_id": "swarm-123",
"decided_by": "user:22",
"decided_at": "2026-05-28T10:20:00Z",
"credential_lease": {
"lease_id": "lease_xxx",
"credential_ref": "lease://runtime/runtime-approval-1",
"expires_at": "2026-05-28T10:30:00Z"
}
}
```
`decision` 枚举:
| decision | Runtime 行为 |
|---|---|
| `approved` | 继续原高危动作 |
| `rejected` | 停止该动作,回调 `timeline.updated` 或 `task.failed` |
要求:
1. Runtime 收到 `approved` 后才可以继续高危动作。
2. Runtime 收到 `rejected` 后不能继续执行该动作。
3. Runtime 不得把 `credential_ref` 展开写入日志或 artifact metadata。
4. Runtime 必须把审批结果后的状态继续通过 callback 回写。
## 12. 日志、指标和用量
### 日志
建议提供:
```http
GET /api/swarms/{swarm_id}/logs?limit=100&cursor=<cursor>
```
响应:
```json
{
"success": true,
"data": {
"items": [
{
"timestamp": "2026-05-28T10:21:00Z",
"level": "info",
"agent_role": "backend",
"task_id": "task-backend-1",
"message": "test completed",
"redacted": true
}
],
"next_cursor": ""
}
}
```
### 指标
建议提供:
```http
GET /api/swarms/{swarm_id}/metrics?window=15m&step=60s
```
响应:
```json
{
"success": true,
"data": {
"swarm_id": "swarm-123",
"agent_metrics": [
{
"agent_instance_id": "agent-backend-1",
"agent_role": "backend",
"task_id": "task-backend-1",
"status": "running",
"cpu_percent": 12.5,
"memory_bytes": 268435456,
"uptime_seconds": 300
}
]
}
}
```
### 用量
Runtime 调模型时必须带关联字段:
| 字段 | 说明 |
|---|---|
| `manager_deployment_id` | Manager deployment id |
| `swarm_id` | Runtime swarm id |
| `task_id` | 当前任务 |
| `agent_role` | Agent 角色 |
| `model_id` | 使用模型 |
| `correlation_id` | 全链路追踪 |
Runtime 可通过 `budget.alert` 或 `timeline.updated` 回传用量摘要。
## 13. 安全约束
必须遵守:
1. 禁止在请求、callback、日志、artifact metadata 中出现明文密码、Token、私钥、连接串、云 access key、模型 key。
2. 长期凭据只能通过 `azkv://<vault>/secrets/<name>` 传引用。
3. Runtime 可以把短期租约写成 `lease://...`,但不能写真实密钥。
4. `secret_ref`、`credential_ref` 只能作为引用使用,不能在用户可见内容中展开。
5. 所有 callback payload 必须脱敏。
6. 高危动作必须先 `approval.requested`,不能先执行后补审批。
## 14. 错误码建议
| HTTP | code | 场景 |
|---:|---|---|
| 401 | `UNAUTHORIZED` | service token 无效 |
| 403 | `FORBIDDEN` | 权限不足 |
| 404 | `SWARM_NOT_FOUND` | swarm 不存在 |
| 409 | `IDEMPOTENCY_CONFLICT` | 幂等键冲突 |
| 422 | `VALIDATION_ERROR` | 请求字段缺失 |
| 422 | `SECRET_REF_INVALID` | `secret_ref` 非 `azkv://...` |
| 422 | `POLICY_REJECTED` | 高危策略拒绝 |
| 500 | `INTERNAL_ERROR` | Runtime 内部错误 |
错误响应:
```json
{
"success": false,
"error": {
"code": "VALIDATION_ERROR",
"message": "resource_grants[0].secret_ref is required",
"request_id": "corr_xxx"
}
}
```
## 15. 联调验收清单
| 步骤 | 操作 | 通过标准 |
|---:|---|---|
| 1 | Manager 调 `GET /api/agnet/health` | 返回 healthy |
| 2 | Manager 调 `POST /api/swarms` | 返回真实 `deployment_id` 和 `swarm_id` |
| 3 | Runtime 回调 `deployment.status_changed` | Manager timeline 可见 |
| 4 | Runtime 回调 `task.created` | Manager task flow / Agent task map 可见 |
| 5 | Runtime 回调 `task.claimed` / `task.heartbeat` | Manager 可看到 Agent 领取和心跳 |
| 6 | Runtime 回调 `handoff.requested` / `handoff.completed` | Manager 可看到交接 |
| 7 | Runtime 回调 `artifact.created` | Manager artifact 列表可见 |
| 8 | Runtime 回调 `approval.requested` | Manager approval 列表出现 pending |
| 9 | 客户端 / Manager approve | Runtime 收到 decision 并继续 |
| 10 | 客户端 / Manager reject | Runtime 收到 decision 并停止对应动作 |
| 11 | Runtime 回传 logs / metrics / usage | Manager 可查询或 timeline 可见 |
| 12 | Runtime 回调最终 `deployment.status_changed=completed` | Manager 和客户端看到完成 |
## 16. 当前不能误报完成的项
以下项只有在 Agent Manager 真实实现并联调后才能算完成:
1. 真实 Swarm Run 创建成功。
2. 真实 task graph 生成。
3. 真实 Agent claim / heartbeat。
4. 真实 handoff / retry / blocked。
5. 真实 artifact 产出。
6. 审批后 Runtime 继续或停止。
7. Runtime 真实日志和指标。
8. 子 Agent 模型用量归属。
9. 最终交付物回到客户端。
Manager 本地模拟事件、测试桩、空态页面和 schema 校验只能证明 Manager 接收和展示能力,不能证明蜂群生产闭环完成。
@@ -252,6 +252,14 @@ Agent Manager 至少要支持以下阶段语义,并通过 callback 回写当
普通 sub 敏捷下,客户端主要看 timeline。Agent Manager 应持续回写可展示事件。 普通 sub 敏捷下,客户端主要看 timeline。Agent Manager 应持续回写可展示事件。
联调前可以先请求 Manager 当前接受的 callback schema:
```http
GET https://code.xinghanlab.com/api/agnet/callbacks/swarm-events/schema
```
该接口只返回事件类型、分类和必填字段,不返回任何 token 或密钥。普通 sub 敏捷重点核对 `phase.changed`、`timeline.updated`、`artifact.created`、`approval.requested`、`sk_tool.*` 和 `budget.alert`。
```json ```json
{ {
"event_id": "evt_timeline_1", "event_id": "evt_timeline_1",
@@ -200,6 +200,14 @@ X-Agnet-Signature: sha256=<hex>
timestamp + "." + event_id + "." + raw_body timestamp + "." + event_id + "." + raw_body
``` ```
Agent Manager / 蜂群侧可以先拉取 Manager 当前接受的事件 schema:
```http
GET https://code.xinghanlab.com/api/agnet/callbacks/swarm-events/schema
```
该接口不返回任何 token 或密钥,只返回事件类型、分类和必填字段,用于联调前核对 `task.*`、`handoff.*`、`artifact.created`、`approval.requested` 等 callback payload 是否符合 Manager 接收规则。
统一 envelope: 统一 envelope:
```json ```json
+90 -15
View File
@@ -7,6 +7,7 @@ import (
"fmt" "fmt"
"io" "io"
"os" "os"
"sort"
"strconv" "strconv"
"strings" "strings"
"time" "time"
@@ -329,18 +330,90 @@ func callbackIntValue(values map[string]any, key string) int {
} }
var agnetCallbackEventRequiredFields = map[string][]string{ var agnetCallbackEventRequiredFields = map[string][]string{
"task.created": {"task_id", "title"}, "deployment.status_changed": {"status"},
"task.claimed": {"task_id", "agent_role"}, "phase.changed": {"stage", "checkpoint"},
"task.running": {"task_id", "agent_role"}, "agent.started": {"agent_role"},
"task.heartbeat": {"task_id", "agent_role"}, "agent.completed": {"agent_role"},
"task.blocked": {"task_id", "reason"}, "agent.crashed": {"agent_role", "reason"},
"task.retried": {"task_id", "attempt"}, "task.created": {"task_id", "title"},
"task.released": {"task_id", "agent_role"}, "task.claimed": {"task_id", "agent_role"},
"task.failed": {"task_id", "reason"}, "task.running": {"task_id", "agent_role"},
"task.completed": {"task_id"}, "task.heartbeat": {"task_id", "agent_role"},
"handoff.requested": {"task_id", "from_role", "to_role"}, "task.blocked": {"task_id", "reason"},
"handoff.completed": {"task_id", "from_role", "to_role"}, "task.retried": {"task_id", "attempt"},
"artifact.created": {"artifact_id"}, "task.released": {"task_id", "agent_role"},
"task.failed": {"task_id", "reason"},
"task.completed": {"task_id"},
"handoff.requested": {"task_id", "from_role", "to_role"},
"handoff.completed": {"task_id", "from_role", "to_role"},
"approval.requested": {"approval_id", "operation", "risk_level"},
"artifact.created": {"artifact_id"},
"timeline.updated": {"title"},
"sk_tool.called": {"tool_name", "tool_invocation_id"},
"sk_tool.completed": {"tool_name", "tool_invocation_id"},
"sk_tool.failed": {"tool_name", "tool_invocation_id", "reason"},
"budget.alert": {"threshold_pct"},
}
var agnetCallbackEventCategories = map[string]string{
"deployment.status_changed": "deployment",
"phase.changed": "ordinary_sub",
"agent.started": "ordinary_sub",
"agent.completed": "ordinary_sub",
"agent.crashed": "ordinary_sub",
"task.created": "swarm_task_flow",
"task.claimed": "swarm_task_flow",
"task.running": "swarm_task_flow",
"task.heartbeat": "swarm_task_flow",
"task.blocked": "swarm_task_flow",
"task.retried": "swarm_task_flow",
"task.released": "swarm_task_flow",
"task.failed": "swarm_task_flow",
"task.completed": "swarm_task_flow",
"handoff.requested": "swarm_task_flow",
"handoff.completed": "swarm_task_flow",
"approval.requested": "approval",
"artifact.created": "artifact",
"timeline.updated": "timeline",
"sk_tool.called": "sk",
"sk_tool.completed": "sk",
"sk_tool.failed": "sk",
"budget.alert": "budget",
}
func AgnetGetSwarmEventCallbackSchema(c *gin.Context) {
events := make([]string, 0, len(agnetCallbackEventRequiredFields))
for eventType := range agnetCallbackEventRequiredFields {
events = append(events, eventType)
}
sort.Strings(events)
items := make([]gin.H, 0, len(events))
for _, eventType := range events {
required := append([]string(nil), agnetCallbackEventRequiredFields[eventType]...)
sort.Strings(required)
items = append(items, gin.H{
"event_type": eventType,
"category": agnetCallbackEventCategories[eventType],
"required_fields": required,
"payload_location": "top-level envelope or payload object; artifact_id may also be in artifact object",
})
}
common.ApiSuccess(c, gin.H{
"callback_path": "/api/agnet/callbacks/swarm-events",
"auth": gin.H{
"service_token_headers": []string{"X-Agnet-Service-Token", "Authorization: Bearer <token>"},
"hmac_headers": []string{"X-Agnet-Event-Id", "X-Agnet-Timestamp", "X-Agnet-Signature"},
"hmac_payload": "timestamp + \".\" + event_id + \".\" + raw_body",
},
"dedupe_keys": []string{"X-Agnet-Event-Id", "event_id", "idempotency_key"},
"events": items,
"security": gin.H{
"plaintext_secrets_allowed": false,
"secret_ref_scheme": "azkv://<vault>/secrets/<name>",
},
})
} }
func callbackEnvelopeFieldValue(payload agnetCallbackEnvelope, key string) string { func callbackEnvelopeFieldValue(payload agnetCallbackEnvelope, key string) string {
@@ -361,9 +434,11 @@ func callbackHasFieldValue(payload agnetCallbackEnvelope, key string) bool {
if callbackStringValue(payload.Payload, key) != "" { if callbackStringValue(payload.Payload, key) != "" {
return true return true
} }
if key == "artifact_id" { for _, nestedKey := range []string{"artifact", "approval"} {
if nested := callbackMapValue(payload.Payload, "artifact"); nested != nil { if nested := callbackMapValue(payload.Payload, nestedKey); nested != nil {
return callbackStringValue(nested, key) != "" if callbackStringValue(nested, key) != "" {
return true
}
} }
} }
if key == "attempt" { if key == "attempt" {
+13 -5
View File
@@ -1639,6 +1639,8 @@ func AgnetListDeploymentLogs(c *gin.Context) {
"timestamp": record.CreatedAt, "timestamp": record.CreatedAt,
"deployment_id": deploymentID, "deployment_id": deploymentID,
"stream": "control", "stream": "control",
"source": "manager_control_plane",
"data_source": "manager_control_plane",
"level": "info", "level": "info",
"message": "deployment accepted by Manager control-plane placeholder", "message": "deployment accepted by Manager control-plane placeholder",
"phase": record.Phase, "phase": record.Phase,
@@ -1652,6 +1654,8 @@ func AgnetListDeploymentLogs(c *gin.Context) {
"timestamp": event.OccurredAt, "timestamp": event.OccurredAt,
"deployment_id": event.DeploymentID, "deployment_id": event.DeploymentID,
"stream": "event", "stream": "event",
"source": "manager_audit",
"data_source": "manager_audit",
"level": "info", "level": "info",
"message": event.Event, "message": event.Event,
"phase": record.Phase, "phase": record.Phase,
@@ -1663,11 +1667,13 @@ func AgnetListDeploymentLogs(c *gin.Context) {
} }
common.ApiSuccess(c, gin.H{ common.ApiSuccess(c, gin.H{
"deployment_id": deploymentID, "deployment_id": deploymentID,
"items": items, "items": items,
"next_cursor": "", "next_cursor": "",
"redacted": true, "redacted": true,
"total": len(items), "data_source": "manager_control_plane",
"runtime_source": "not_connected",
"total": len(items),
}) })
} }
@@ -1693,6 +1699,8 @@ func AgnetGetDeploymentMetrics(c *gin.Context) {
common.ApiSuccess(c, gin.H{ common.ApiSuccess(c, gin.H{
"deployment_id": deploymentID, "deployment_id": deploymentID,
"data_source": "manager_control_plane",
"runtime_source": "not_connected",
"window": strings.TrimSpace(c.DefaultQuery("window", "15m")), "window": strings.TrimSpace(c.DefaultQuery("window", "15m")),
"step": strings.TrimSpace(c.DefaultQuery("step", "60s")), "step": strings.TrimSpace(c.DefaultQuery("step", "60s")),
"phase": record.Phase, "phase": record.Phase,
@@ -156,6 +156,18 @@ func TestAgnetRuntimeRealHTTPHealthAndShadowCreateSmoke(t *testing.T) {
require.Contains(t, healthBody, `"success":true`) require.Contains(t, healthBody, `"success":true`)
require.Contains(t, healthBody, `"status":"healthy"`) require.Contains(t, healthBody, `"status":"healthy"`)
schemaResp, err := http.Get(managerURL + "/api/agnet/callbacks/swarm-events/schema")
require.NoError(t, err)
schemaBody := readAgnetRuntimeSmokeBody(t, schemaResp)
require.Equal(t, http.StatusOK, schemaResp.StatusCode)
require.Contains(t, schemaBody, `"event_type":"task.claimed"`)
require.Contains(t, schemaBody, `"event_type":"approval.requested"`)
var schemaEnvelope map[string]any
require.NoError(t, common.Unmarshal([]byte(schemaBody), &schemaEnvelope))
schemaData := schemaEnvelope["data"].(map[string]any)
schemaSecurity := schemaData["security"].(map[string]any)
require.Equal(t, "azkv://<vault>/secrets/<name>", schemaSecurity["secret_ref_scheme"])
createBody := `{ createBody := `{
"orchestration_plan":{ "orchestration_plan":{
"intent_id":"intent-http-smoke", "intent_id":"intent-http-smoke",
@@ -184,4 +196,16 @@ func TestAgnetRuntimeRealHTTPHealthAndShadowCreateSmoke(t *testing.T) {
var stored model.AgnetDeployment var stored model.AgnetDeployment
require.NoError(t, db.Where("runtime_swarm_id = ?", "runtime-http-swarm").First(&stored).Error) require.NoError(t, db.Where("runtime_swarm_id = ?", "runtime-http-swarm").First(&stored).Error)
require.Equal(t, "accepted", stored.RuntimeState) require.Equal(t, "accepted", stored.RuntimeState)
logsResp := agnetRuntimeAdminRequest(t, http.MethodGet, managerURL+"/api/agnet/deployments/"+stored.DeploymentID+"/logs", "")
logsBody := readAgnetRuntimeSmokeBody(t, logsResp)
require.Equal(t, http.StatusOK, logsResp.StatusCode)
require.Contains(t, logsBody, `"data_source":"manager_control_plane"`)
require.Contains(t, logsBody, `"runtime_source":"not_connected"`)
metricsResp := agnetRuntimeAdminRequest(t, http.MethodGet, managerURL+"/api/agnet/deployments/"+stored.DeploymentID+"/metrics", "")
metricsBody := readAgnetRuntimeSmokeBody(t, metricsResp)
require.Equal(t, http.StatusOK, metricsResp.StatusCode)
require.Contains(t, metricsBody, `"data_source":"manager_control_plane"`)
require.Contains(t, metricsBody, `"platform_estimated":true`)
} }
+1
View File
@@ -59,6 +59,7 @@ func SetApiRouter(router *gin.Engine) {
apiRouter.POST("/stripe/webhook", controller.StripeWebhook) apiRouter.POST("/stripe/webhook", controller.StripeWebhook)
apiRouter.POST("/creem/webhook", controller.CreemWebhook) apiRouter.POST("/creem/webhook", controller.CreemWebhook)
apiRouter.POST("/waffo/webhook", controller.WaffoWebhook) apiRouter.POST("/waffo/webhook", controller.WaffoWebhook)
apiRouter.GET("/agnet/callbacks/swarm-events/schema", controller.AgnetGetSwarmEventCallbackSchema)
apiRouter.POST("/agnet/callbacks/swarm-events", controller.AgnetReceiveSwarmEventCallback) apiRouter.POST("/agnet/callbacks/swarm-events", controller.AgnetReceiveSwarmEventCallback)
apiRouter.POST("/swarms", middleware.UserAuth(), controller.AgnetCreateUserSwarm) apiRouter.POST("/swarms", middleware.UserAuth(), controller.AgnetCreateUserSwarm)
//apiRouter.POST("/waffo-pancake/webhook", controller.WaffoPancakeWebhook) //apiRouter.POST("/waffo-pancake/webhook", controller.WaffoPancakeWebhook)
+116 -8
View File
@@ -333,6 +333,54 @@ function taskFlowDetail(item: Record<string, unknown>): string {
return parts.join(' / ') || '—' return parts.join(' / ') || '—'
} }
type TaskFlowSummary = {
taskId: string
status: string
agentRole: string
handoff: string
source: string
}
function buildTaskFlowSummaries(
records: Record<string, unknown>[]
): TaskFlowSummary[] {
const byTask = new Map<string, TaskFlowSummary>()
records.forEach((item, idx) => {
const event = String(item.event_type || item.event || '').toLowerCase()
const taskId =
String(recordPayloadValue(item, 'task_id') || '').trim() ||
`task-${idx + 1}`
const current =
byTask.get(taskId) ??
({
taskId,
status: 'observed',
agentRole: '',
handoff: '',
source: formatRecordSource(item.source),
} satisfies TaskFlowSummary)
const agentRole = String(recordPayloadValue(item, 'agent_role') || '')
const fromRole = String(recordPayloadValue(item, 'from_role') || '')
const toRole = String(recordPayloadValue(item, 'to_role') || '')
const reason = String(recordPayloadValue(item, 'reason') || '')
if (agentRole) current.agentRole = agentRole
if (event.startsWith('task.')) {
current.status = event.replace('task.', '') || current.status
}
if (event === 'handoff.requested' || event === 'handoff.completed') {
current.status = event.replace('handoff.', 'handoff:')
if (fromRole || toRole)
current.handoff = `${fromRole || '?'} -> ${toRole || '?'}`
}
if (reason && current.status === 'blocked') {
current.handoff = reason
}
current.source = formatRecordSource(item.source)
byTask.set(taskId, current)
})
return Array.from(byTask.values())
}
function collectResourceGrants( function collectResourceGrants(
dep: AgnetDeployment dep: AgnetDeployment
): Record<string, unknown>[] { ): Record<string, unknown>[] {
@@ -1001,11 +1049,13 @@ function RunRelatedRecordsPanel({ deploymentId }: { deploymentId: string }) {
const timeline = data?.timeline ?? [] const timeline = data?.timeline ?? []
const taskFlowRecords = useMemo( const taskFlowRecords = useMemo(
() => () =>
timeline.filter((item) => timeline.filter((item) => isTaskFlowEvent(item.event_type || item.event)),
isTaskFlowEvent(item.event_type || item.event)
),
[timeline] [timeline]
) )
const taskFlowSummaries = useMemo(
() => buildTaskFlowSummaries(taskFlowRecords),
[taskFlowRecords]
)
return ( return (
<div className='rounded-2xl border border-[color-mix(in_oklch,var(--primary)_18%,var(--border))] bg-[color-mix(in_oklch,var(--card)_52%,transparent)] p-4'> <div className='rounded-2xl border border-[color-mix(in_oklch,var(--primary)_18%,var(--border))] bg-[color-mix(in_oklch,var(--card)_52%,transparent)] p-4'>
@@ -1046,12 +1096,12 @@ function RunRelatedRecordsPanel({ deploymentId }: { deploymentId: string }) {
/> />
</div> </div>
<div className='mt-4 bg-background/45 rounded-xl border border-dashed border-[color-mix(in_oklch,var(--primary)_22%,var(--border))] p-3'> <div className='bg-background/45 mt-4 rounded-xl border border-dashed border-[color-mix(in_oklch,var(--primary)_22%,var(--border))] p-3'>
<div className='flex items-center justify-between gap-2'> <div className='flex items-center justify-between gap-2'>
<p className='text-foreground text-xs font-semibold'> <p className='text-foreground text-xs font-semibold'>
{t('Sub task flow')} {t('Sub task flow')}
</p> </p>
<span className='text-muted-foreground rounded-full bg-background/60 px-2 py-0.5 text-[10px]'> <span className='text-muted-foreground bg-background/60 rounded-full px-2 py-0.5 text-[10px]'>
{taskFlowRecords.length} {taskFlowRecords.length}
</span> </span>
</div> </div>
@@ -1074,7 +1124,7 @@ function RunRelatedRecordsPanel({ deploymentId }: { deploymentId: string }) {
<p className='min-w-0 truncate font-mono text-[11px]'> <p className='min-w-0 truncate font-mono text-[11px]'>
{event} {event}
</p> </p>
<span className='text-muted-foreground shrink-0 rounded-full bg-background/60 px-2 py-0.5 text-[10px]'> <span className='text-muted-foreground bg-background/60 shrink-0 rounded-full px-2 py-0.5 text-[10px]'>
{source} {source}
</span> </span>
</div> </div>
@@ -1088,6 +1138,64 @@ function RunRelatedRecordsPanel({ deploymentId }: { deploymentId: string }) {
)} )}
</div> </div>
<div className='bg-background/45 mt-4 rounded-xl border border-dashed border-[color-mix(in_oklch,var(--primary)_22%,var(--border))] p-3'>
<div className='flex items-center justify-between gap-2'>
<p className='text-foreground text-xs font-semibold'>
{t('Agent task map')}
</p>
<span className='text-muted-foreground bg-background/60 rounded-full px-2 py-0.5 text-[10px]'>
{taskFlowSummaries.length}
</span>
</div>
{taskFlowSummaries.length === 0 ? (
<p className='text-muted-foreground mt-2 text-xs'>
{t(
'Waiting for Runtime task graph callbacks. Manager will show task, Agent role, handoff and source here when callbacks arrive.'
)}
</p>
) : (
<div className='mt-2 overflow-x-auto'>
<table className='w-full min-w-[520px] border-collapse text-left text-[11px]'>
<thead className='text-muted-foreground'>
<tr>
<th className='py-1 pr-3 font-medium'>{t('Task')}</th>
<th className='py-1 pr-3 font-medium'>{t('Status')}</th>
<th className='py-1 pr-3 font-medium'>{t('Agent role')}</th>
<th className='py-1 pr-3 font-medium'>{t('Handoff')}</th>
<th className='py-1 pr-3 font-medium'>{t('Source')}</th>
</tr>
</thead>
<tbody>
{taskFlowSummaries.map((item) => (
<tr
key={item.taskId}
className='border-t border-dashed border-[color-mix(in_oklch,var(--primary)_14%,var(--border))]'
>
<td className='py-2 pr-3 font-mono text-[10px]'>
{item.taskId}
</td>
<td className='py-2 pr-3'>
<StatusBadge phase={item.status} />
</td>
<td className='text-muted-foreground py-2 pr-3 font-mono text-[10px]'>
{item.agentRole || '—'}
</td>
<td className='text-muted-foreground py-2 pr-3 text-[10px]'>
{item.handoff || '—'}
</td>
<td className='py-2 pr-3'>
<span className='text-muted-foreground bg-background/60 rounded-full px-2 py-0.5 text-[10px]'>
{item.source}
</span>
</td>
</tr>
))}
</tbody>
</table>
</div>
)}
</div>
<div className='mt-4 grid gap-3 md:grid-cols-2'> <div className='mt-4 grid gap-3 md:grid-cols-2'>
<div className='bg-background/45 rounded-xl border border-dashed border-[color-mix(in_oklch,var(--primary)_22%,var(--border))] p-3'> <div className='bg-background/45 rounded-xl border border-dashed border-[color-mix(in_oklch,var(--primary)_22%,var(--border))] p-3'>
<p className='text-foreground text-xs font-semibold'> <p className='text-foreground text-xs font-semibold'>
@@ -1159,7 +1267,7 @@ function RunRelatedRecordsPanel({ deploymentId }: { deploymentId: string }) {
</div> </div>
</div> </div>
<div className='mt-3 bg-background/45 rounded-xl border border-dashed border-[color-mix(in_oklch,var(--primary)_22%,var(--border))] p-3'> <div className='bg-background/45 mt-3 rounded-xl border border-dashed border-[color-mix(in_oklch,var(--primary)_22%,var(--border))] p-3'>
<p className='text-foreground text-xs font-semibold'> <p className='text-foreground text-xs font-semibold'>
{t('Merged timeline')} {t('Merged timeline')}
</p> </p>
@@ -1179,7 +1287,7 @@ function RunRelatedRecordsPanel({ deploymentId }: { deploymentId: string }) {
> >
<div className='flex items-center justify-between gap-2'> <div className='flex items-center justify-between gap-2'>
<p className='truncate font-mono text-[11px]'>{event}</p> <p className='truncate font-mono text-[11px]'>{event}</p>
<span className='text-muted-foreground shrink-0 rounded-full bg-background/60 px-2 py-0.5 text-[10px]'> <span className='text-muted-foreground bg-background/60 shrink-0 rounded-full px-2 py-0.5 text-[10px]'>
{source} {source}
</span> </span>
</div> </div>
+132
View File
@@ -11,6 +11,9 @@ authenticated deployment flow, provide:
Optional: Optional:
HEICODE_RUN_SIMULATE=true HEICODE_RUN_SIMULATE=true
HEICODE_RUN_CALLBACK_SMOKE=true
HEICODE_CALLBACK_TOKEN=<Manager callback service token>
HEICODE_CALLBACK_SWARM_ID=<runtime swarm id, optional>
HEICODE_MANAGER_BASE_URL=https://code.xinghanlab.com HEICODE_MANAGER_BASE_URL=https://code.xinghanlab.com
AGENT_MANAGER_HEALTH_URL=http://20.212.121.126/api/agnet/health AGENT_MANAGER_HEALTH_URL=http://20.212.121.126/api/agnet/health
""" """
@@ -31,11 +34,18 @@ AGENT_HEALTH = os.getenv(
ACCESS_TOKEN = os.getenv("HEICODE_ACCESS_TOKEN", "").strip() ACCESS_TOKEN = os.getenv("HEICODE_ACCESS_TOKEN", "").strip()
USER_ID = os.getenv("HEICODE_USER_ID", "").strip() USER_ID = os.getenv("HEICODE_USER_ID", "").strip()
DEPLOYMENT_ID = os.getenv("HEICODE_DEPLOYMENT_ID", "").strip() DEPLOYMENT_ID = os.getenv("HEICODE_DEPLOYMENT_ID", "").strip()
CALLBACK_TOKEN = os.getenv("HEICODE_CALLBACK_TOKEN", "").strip()
CALLBACK_SWARM_ID = os.getenv("HEICODE_CALLBACK_SWARM_ID", "").strip()
RUN_SIMULATE = os.getenv("HEICODE_RUN_SIMULATE", "").strip().lower() in { RUN_SIMULATE = os.getenv("HEICODE_RUN_SIMULATE", "").strip().lower() in {
"1", "1",
"true", "true",
"yes", "yes",
} }
RUN_CALLBACK_SMOKE = os.getenv("HEICODE_RUN_CALLBACK_SMOKE", "").strip().lower() in {
"1",
"true",
"yes",
}
UA = "heicode-agnet-sub-mode-smoke/1.0" UA = "heicode-agnet-sub-mode-smoke/1.0"
FAILS: list[str] = [] FAILS: list[str] = []
@@ -93,6 +103,23 @@ check("Manager /api/status success", code == 200 and status.get("success") is Tr
version = status.get("data", {}).get("version") if isinstance(status.get("data"), dict) else "" version = status.get("data", {}).get("version") if isinstance(status.get("data"), dict) else ""
check("Manager status carries version", bool(version), f"version={version!r}") check("Manager status carries version", bool(version), f"version={version!r}")
code, _headers, body = manager_request("GET", "/api/agnet/callbacks/swarm-events/schema")
schema = parse_json("callback schema JSON parseable", body)
schema_data = schema.get("data", {}) if isinstance(schema.get("data"), dict) else {}
schema_events = schema_data.get("events") or []
schema_event_types = {
item.get("event_type")
for item in schema_events
if isinstance(item, dict)
}
check(
"callback schema lists swarm and ordinary sub events",
code == 200
and schema.get("success") is True
and {"task.claimed", "handoff.requested", "phase.changed", "artifact.created"}.issubset(schema_event_types),
f"HTTP {code}",
)
code, _headers, body = request("GET", AGENT_HEALTH) code, _headers, body = request("GET", AGENT_HEALTH)
agent = parse_json("Agent Manager health JSON parseable", body) agent = parse_json("Agent Manager health JSON parseable", body)
agent_data = agent.get("data", {}) if isinstance(agent.get("data"), dict) else {} agent_data = agent.get("data", {}) if isinstance(agent.get("data"), dict) else {}
@@ -106,6 +133,91 @@ check(
if not (ACCESS_TOKEN and USER_ID and DEPLOYMENT_ID): if not (ACCESS_TOKEN and USER_ID and DEPLOYMENT_ID):
print("\nAuthenticated deployment checks skipped: set HEICODE_ACCESS_TOKEN, HEICODE_USER_ID and HEICODE_DEPLOYMENT_ID.") print("\nAuthenticated deployment checks skipped: set HEICODE_ACCESS_TOKEN, HEICODE_USER_ID and HEICODE_DEPLOYMENT_ID.")
else: else:
if RUN_CALLBACK_SMOKE:
if not CALLBACK_TOKEN:
check("callback smoke has HEICODE_CALLBACK_TOKEN", False)
else:
callback_headers = {
"Content-Type": "application/json",
"X-Agnet-Service-Token": CALLBACK_TOKEN,
"X-Correlation-ID": f"smoke-corr-{DEPLOYMENT_ID}",
}
swarm_id = CALLBACK_SWARM_ID or f"smoke-swarm-{DEPLOYMENT_ID}"
callback_events = [
{
"event_id": f"smoke-task-created-{DEPLOYMENT_ID}",
"event_type": "task.created",
"deployment_id": DEPLOYMENT_ID,
"swarm_id": swarm_id,
"task_id": f"smoke-task-{DEPLOYMENT_ID}",
"source": "smoke-runtime",
"payload": {
"title": "Smoke task created",
"agent_role": "backend",
},
},
{
"event_id": f"smoke-task-claimed-{DEPLOYMENT_ID}",
"event_type": "task.claimed",
"deployment_id": DEPLOYMENT_ID,
"swarm_id": swarm_id,
"task_id": f"smoke-task-{DEPLOYMENT_ID}",
"source": "smoke-runtime",
"payload": {
"agent_role": "backend",
},
},
{
"event_id": f"smoke-handoff-{DEPLOYMENT_ID}",
"event_type": "handoff.requested",
"deployment_id": DEPLOYMENT_ID,
"swarm_id": swarm_id,
"task_id": f"smoke-task-{DEPLOYMENT_ID}",
"source": "smoke-runtime",
"payload": {
"from_role": "backend",
"to_role": "reviewer",
"reason": "Smoke handoff validation",
},
},
{
"event_id": f"smoke-artifact-{DEPLOYMENT_ID}",
"event_type": "artifact.created",
"deployment_id": DEPLOYMENT_ID,
"swarm_id": swarm_id,
"task_id": f"smoke-task-{DEPLOYMENT_ID}",
"source": "smoke-runtime",
"artifact": {
"artifact_id": f"smoke-artifact-{DEPLOYMENT_ID}",
"artifact_type": "test_report",
"title": "Smoke artifact",
"summary": "Callback smoke wrote this artifact through the Manager callback endpoint.",
"uri": f"artifact://smoke/{DEPLOYMENT_ID}/report",
},
"payload": {
"stage": "testing",
"checkpoint": "artifact_ready",
},
},
]
for event in callback_events:
body_bytes = json.dumps(event).encode("utf-8")
callback_headers["X-Agnet-Event-Id"] = event["event_id"]
code, _headers, body = request(
"POST",
BASE + "/api/agnet/callbacks/swarm-events",
headers=callback_headers,
body=body_bytes,
)
callback_response = parse_json(
f"callback {event['event_type']} JSON parseable", body
)
check(
f"callback {event['event_type']} accepted",
code == 200 and callback_response.get("success") is True,
f"HTTP {code}",
)
if RUN_SIMULATE: if RUN_SIMULATE:
code, _headers, body = manager_request( code, _headers, body = manager_request(
"POST", "POST",
@@ -155,6 +267,11 @@ else:
for item in merged for item in merged
if isinstance(item, dict) if isinstance(item, dict)
} }
sources = {
item.get("source")
for item in merged
if isinstance(item, dict) and item.get("source")
}
check( check(
"timeline endpoint success", "timeline endpoint success",
code == 200 and timeline.get("success") is True and isinstance(merged, list), code == 200 and timeline.get("success") is True and isinstance(merged, list),
@@ -170,6 +287,21 @@ else:
"artifact.created" in event_types or "approval.requested" in event_types, "artifact.created" in event_types or "approval.requested" in event_types,
"events=" + ",".join(sorted(str(event) for event in event_types if event)[:10]), "events=" + ",".join(sorted(str(event) for event in event_types if event)[:10]),
) )
if RUN_CALLBACK_SMOKE:
check(
"timeline includes smoke callback source",
"smoke-runtime" in sources,
"sources=" + ",".join(sorted(str(source) for source in sources)[:10]),
)
check(
"callback smoke artifact is queryable",
any(
isinstance(item, dict)
and item.get("artifact_id") == f"smoke-artifact-{DEPLOYMENT_ID}"
for item in artifact_items
),
f"artifact_count={len(artifact_items) if isinstance(artifact_items, list) else 'n/a'}",
)
if FAILS: if FAILS:
print(f"\n{len(FAILS)} check(s) failed:") print(f"\n{len(FAILS)} check(s) failed:")