Swarm I/O:接收用户 prompt(追加输入)+ 返回结果(/result + swarm.completed 带答案)(Refs #40)

补齐「客户端如何把 prompt 给我们 + 如何拿到结果」的端到端路径。

输入:
- POST /api/swarms/{id}/input(+别名):接收用户后续 prompt,注入 source=user_append 任务进共享池
  (终态 run 自动 reopen 为 running;stopped 拒绝 RUN_STOPPED)。指令原文作任务描述下发给 agent,
  **不回显进事件流**——仅产一条 task.created(user_append) 类别 message。(初始 prompt 仍走 create
  的 requirement.objective)

输出:
- GET /api/swarms/{id}/result(+别名):返回 {summary, deliverable, artifacts[], termination_reason,
  status}(build_run_result)。产物内容在 artifact.uri(git/runtime),result 给摘要+定位。
- swarm.completed 事件 payload 增带 summary + deliverable,客户端看一条终态事件即得答案。

文档:runtime-contract §3 增 input/result 行;event-schema swarm.completed 标注带 summary/deliverable;
CLIENT_GUIDE §3.5/§3.6(input/result)+ §3.2 表 + 修正 §5「Agent 平台拉起」为「Swarm 拉起 + 从
secret_ref 解析 key」(对齐 team 决议)+ 新增 §9 完整工作流地图(client→HM→Swarm→output→client)。

测试 scripts/test-swarm-io.py(TestClient:input 注入 + 原文不入事件流 + 终态 reopen + stopped 拒绝 +
result 形状)接入 CI。e2e/contract 回归通过。

影响范围:仅 agent_swarm(新增 2 只读/写端点 + 终态事件增字段 + 文档 + 测试 + CI)。
追加输入原文不入事件流/回调/日志;不改鉴权/计费账本/审批链。SSE 实时流仍归 HM Phase2(#46)。

Refs #40

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
Songhaoz666
2026-06-11 16:28:37 +08:00
co-authored by Claude Opus 4.8
parent 5138d9a370
commit dd96b73b2d
6 changed files with 285 additions and 3 deletions
+4
View File
@@ -63,6 +63,10 @@ jobs:
env: { REDIS_FAKE: "1" } env: { REDIS_FAKE: "1" }
run: python scripts/test-contract-events.py run: python scripts/test-contract-events.py
- name: Swarm I/O — receive prompt + return result (#40)
env: { REDIS_FAKE: "1" }
run: python scripts/test-swarm-io.py
- name: Audit / lineage trace (replayable) (#17) - name: Audit / lineage trace (replayable) (#17)
env: { REDIS_FAKE: "1" } env: { REDIS_FAKE: "1" }
run: python scripts/test-audit-trace.py run: python scripts/test-audit-trace.py
+65 -2
View File
@@ -24,7 +24,7 @@ WebSocket `/ws/{agent_id}` 仅供 **Agent 执行单元** 接入,**不是客户
--- ---
## 3. 生命周期:创建 → 观察 → (审批)→ 停止 ## 3. 生命周期:创建 → 观察 →(追加输入)→(审批)→ 取结果 → 停止
### 3.1 创建部署 ### 3.1 创建部署
`POST /api/swarms`(别名 `/api/agent/swarm/deployments`、`/api/agnet/deployments`) `POST /api/swarms`(别名 `/api/agent/swarm/deployments`、`/api/agnet/deployments`)
@@ -55,6 +55,8 @@ curl -s -X POST http://<host>:8000/api/swarms \
| `GET /api/swarms/{deployment_id}/logs`(`/events`) | 事件流,分页 `?limit=&cursor=`(断线后用 cursor 回补) | | `GET /api/swarms/{deployment_id}/logs`(`/events`) | 事件流,分页 `?limit=&cursor=`(断线后用 cursor 回补) |
| `GET /api/swarms/{deployment_id}/metrics` | 任务/Agent/预算指标 | | `GET /api/swarms/{deployment_id}/metrics` | 任务/Agent/预算指标 |
| `GET /api/swarms/{deployment_id}/diagnostics` | 失败、回调尝试、审批诊断 | | `GET /api/swarms/{deployment_id}/diagnostics` | 失败、回调尝试、审批诊断 |
| `GET /api/swarms/{deployment_id}/result` | **结果**:`{summary, deliverable, artifacts[], termination_reason, status}`(见 §3.6) |
| `GET /api/swarms/{deployment_id}/audit` | 可回放审计/链路(谁/何模型/何工具/何审批,脱敏) |
状态机:`waiting_approval → running → (blocked ⇄ running) → completed | failed | stopped`。 状态机:`waiting_approval → running → (blocked ⇄ running) → completed | failed | stopped`。
@@ -73,6 +75,22 @@ curl -s -X POST http://<host>:8000/api/swarms/<deployment_id>/stop \
-d '{ "reason": "client requested stop" }' -d '{ "reason": "client requested stop" }'
``` ```
### 3.5 追加输入(用户后续 prompt,运行中/已完成均可续)
用户在驾驶舱继续补充要求时调用。指令被注入为 `source=user_append` 的新任务进入共享池,agent 自选执行;若 run 已 `completed`/`failed` 会**自动 reopen 为 running**(「继续」语义),`stopped` 的 run 会被拒绝(请另起新 run)。
```bash
curl -s -X POST http://<host>:8000/api/swarms/<deployment_id>/input \
-H "Authorization: Bearer $TOKEN" -H "Content-Type: application/json" \
-d '{ "instruction": "再补一个 subtract(a,b) 并加测试" }'
```
响应 `data`:`task_id`、`status`、`reopened`。**红线**:指令原文仅作任务描述下发给执行 agent,**绝不回显进事件流**——事件流只产一条 `task.created`(`source=user_append`,类别 message,无原文)。
### 3.6 取结果
run 进入终态(推送 `swarm.completed`,其 payload 已带 `summary`+`deliverable`)后,或任意时刻拉取聚合结果:
```bash
curl -s http://<host>:8000/api/swarms/<deployment_id>/result -H "Authorization: Bearer $TOKEN"
```
响应 `data`:`summary`(综述)、`deliverable`(`{has_deliverable, commit_sha, files_modified, artifact_ids, …}`)、`artifacts[]`(`{uri, checksum, …}`)、`termination_reason`、`status`。**产物内容**(代码等)在各 artifact 的 `uri`(如 `git://repo#branch`)——`result` 给的是摘要 + 定位,内容按 uri 取。
--- ---
## 4. 健康检查 ## 4. 健康检查
@@ -88,7 +106,8 @@ curl -s -X POST http://<host>:8000/api/swarms/<deployment_id>/stop \
- `MAX_REVIEW_CYCLES`(默认 2):交叉评审/重做的轮次预算(保留)。 - `MAX_REVIEW_CYCLES`(默认 2):交叉评审/重做的轮次预算(保留)。
- `MAX_AGENTS_PER_USER`(默认 10):每用户并发连接的 Agent 上限(见 security-boundary §6)。 - `MAX_AGENTS_PER_USER`(默认 10):每用户并发连接的 Agent 上限(见 security-boundary §6)。
- 仅剩的能力开关:`ENABLE_QUALITY_EVAL` + `HEICODE_SANDBOX_ISOLATED`(代码沙箱评分,见 security-boundary §8.1)、`ENABLE_SUBTASK_HANDOFF`(agent 侧子任务移交)。 - 仅剩的能力开关:`ENABLE_QUALITY_EVAL` + `HEICODE_SANDBOX_ISOLATED`(代码沙箱评分,见 security-boundary §8.1)、`ENABLE_SUBTASK_HANDOFF`(agent 侧子任务移交)。
- 模型走 HM 模型网关(OpenAI 兼容 `/v1`):`OPENAI_API_KEY` = **HM 为该用户现签的 per-user `sk-`**(NewAPI token),`OPENAI_API_BASE` = **HM `/v1`**,`OPENAI_MODEL` 为所选模型。由 Agent 平台在拉起 agent 时注入其进程 env(server→server,**不入 create 请求 / 回调 / 日志**)。模型调用**扣发起用户 `user.Quota`**、按 token 名归集计量;详见 [usage-billing-schema.md §2](docs/integration/usage-billing-schema.md)。 - **Agent 拉起 + 限额由 Swarm 运行时负责**(team 决议,runtime-contract §3.3):编排器在 create 播种后由 `agent_launcher` 拉起能力多样的 agent 池(拉起数 `= min(AGENT_LAUNCH_POOL_SIZE, MAX_AGENTS_PER_USER − 已连)`;后端 `AGENT_LAUNCH_BACKEND` = none/subprocess/command)。AM 不再拉蜂群 agent。
- 模型走 HM 模型网关(OpenAI 兼容 `/v1`):`OPENAI_API_KEY` = 该用户 per-user `sk-`(NewAPI token),`OPENAI_API_BASE` = HM `/v1`。**由 Swarm 从 `billing_context.secret_ref`(`azkv://`) 服务端解析**后注入被拉起 agent 的进程 env(**不入 create 请求 / 回调 / 日志 / argv**)。模型调用**扣发起用户 `user.Quota`**、按 token 名归集;HM 侧只需 mint `sk-` → 写 KV → 传 `secret_ref` → 吊销。详见 [usage-billing-schema.md §2](docs/integration/usage-billing-schema.md)。
--- ---
@@ -123,3 +142,47 @@ python scripts/stub_agent.py
- **桌面/前端 UI**:当前桌面客户端为单 Agent A2A 直连模型;**蜂群图 / 协作时间线**的前端事件 API 尚未冻结(见 [frontend-event-api.md](docs/integration/frontend-event-api.md))。 - **桌面/前端 UI**:当前桌面客户端为单 Agent A2A 直连模型;**蜂群图 / 协作时间线**的前端事件 API 尚未冻结(见 [frontend-event-api.md](docs/integration/frontend-event-api.md))。
- **不得在请求/回调/日志中出现明文密钥**;凭据一律 `secret_ref`(`azkv://`)。见 [security-boundary.md](docs/integration/security-boundary.md)。 - **不得在请求/回调/日志中出现明文密钥**;凭据一律 `secret_ref`(`azkv://`)。见 [security-boundary.md](docs/integration/security-boundary.md)。
- **计费主线**:Agent 模型调用走 HM `/v1`,用 **HM 为该用户现签的 per-user `sk-`** 作 `OPENAI_API_KEY`,**扣发起用户 `user.Quota`**;NewAPI 是账本、按请求计量,**Swarm 不是账本、仅上报用量**(随模型请求带 `X-Agent-*` 归因头)。归因主轴 = `user.id`/`channelId`,不引入 tenant。见 [usage-billing-schema.md §1/§2](docs/integration/usage-billing-schema.md)。 - **计费主线**:Agent 模型调用走 HM `/v1`,用 **HM 为该用户现签的 per-user `sk-`** 作 `OPENAI_API_KEY`,**扣发起用户 `user.Quota`**;NewAPI 是账本、按请求计量,**Swarm 不是账本、仅上报用量**(随模型请求带 `X-Agent-*` 归因头)。归因主轴 = `user.id`/`channelId`,不引入 tenant。见 [usage-billing-schema.md §1/§2](docs/integration/usage-billing-schema.md)。
---
## 9. 完整工作流地图(client → swarm → output → client)
端到端调用模型(生产链路:终端用户 → HM 控制面/查询面 → Swarm 执行面;Swarm 不在对话回路):
```
[终端用户] 在桌面客户端提交需求
│ 设备配对签名鉴权
▼
[HM Heicode Manager] 控制面 + 查询面
│ ① mint per-user sk- → 写 Azure Key Vault
│ ② POST /api/agent/swarm/deployments (service token)
│ body: requirement.objective(=用户 prompt) + callback.url + metadata.manager_deployment_id
│ + billing_context.secret_ref=azkv://.../<sk-name> (key 仅引用,不传明文)
▼
[Swarm 编排器 orchestrator] 执行面(本仓)
│ ③ 播种单一种子任务(objective) —— 接收「初始 prompt」
│ ④ agent_launcher 拉起 per-user agent 池(≤ MAX_AGENTS_PER_USER);
│ 从 secret_ref 解析 sk- 注入 agent env(OPENAI_API_KEY/BASE/ORCHESTRATOR_URL/...)
▼
[专家 Agent ×N] 主动出站连 WS /ws/{agent_id},register(user_id) → 受每用户限额
│ ⑤ 自选种子 → 自主分解(#7) → 竞争/接管(#8) → 执行(调 HM /v1 模型,扣发起用户 Quota)
│ → 同伴交叉评审(#11) → 收敛(#12) → master_agent.synthesize 汇总
│ 每步经编排器 emit_event → 带签名回调 POST 到 HM callback.url(task.*/handoff.created/
│ approval.*/artifact.created/review.*/rework.*/swarm.*,每条带 per-swarm 严格递增 sequence)
▼
[HM 查询面/回调摄入] 持久化事件 + 透传 sequence
▼
[桌面客户端] ⑥ 经 HM 拉取/SSE:GET /{id}(状态)、/events?after=<sequence>(事件流)、
/tasks(DAG)、/workflow、/metrics、/audit、**/result**(综述+deliverable+artifacts)
终态事件 swarm.completed 已直接带 summary+deliverable
追加输入(运行中或已完成续):客户端 → HM → POST /{id}/input {instruction}
→ 注入 user_append 任务(终态自动 reopen)→ 回 ⑤ 继续;原文不进事件流
停止:客户端 → HM → POST /{id}/stop → 取消任务 + 发 swarm.stopped + 拆除已拉起 agent
```
**要点**:
- **客户端不直连 Swarm / 不直连 agent**:读写都经 HM(鉴权/计费/审计在 HM;Swarm 运行时私网、service-token;agent 无公网子域,仅出站连编排器)。
- **输入**:初始 prompt = create 的 `requirement.objective`;后续 prompt = `POST /{id}/input`(#40)。
- **输出**:`summary`(synthesize 综述)+ `deliverable`(git 分支/commit/文件 + artifact_ids)+ `artifacts[].uri`(内容定位);经 `swarm.completed` 事件与 `GET /{id}/result` 两条路拿到。
- **密钥**:HM mint sk-→写 KV→传 `secret_ref`;Swarm 服务端解析注入 agent;全程不入 create 体/回调/日志/argv。
+1 -1
View File
@@ -78,7 +78,7 @@
| ⭐ `approval.approved` | `approval_id` | approval | ✅(审批决定落地时) | | ⭐ `approval.approved` | `approval_id` | approval | ✅(审批决定落地时) |
| ⭐ `approval.rejected` | `approval_id` | approval | ✅(审批决定落地时) | | ⭐ `approval.rejected` | `approval_id` | approval | ✅(审批决定落地时) |
| ⭐ `artifact.created` | `artifact_id`, `uri`, `checksum` | artifact | ✅(带 §3 扁平字段) | | ⭐ `artifact.created` | `artifact_id`, `uri`, `checksum` | artifact | ✅(带 §3 扁平字段) |
| ⭐ `swarm.completed` | `status` | swarm_terminal | ✅(终态,与 `deployment.status_changed` 并发) | | ⭐ `swarm.completed` | `status`(+ `summary`/`deliverable` 可选) | swarm_terminal | ✅(终态;**携带用户面结果** = 综述 + deliverable 摘要,客户端可直接取;完整见 `GET …/{id}/result`,#40) |
| ⭐ `swarm.failed` | `status` | swarm_terminal | ✅(终态) | | ⭐ `swarm.failed` | `status` | swarm_terminal | ✅(终态) |
| ⭐ `swarm.stopped` | `status` | swarm_terminal | ✅(`stop_run`) | | ⭐ `swarm.stopped` | `status` | swarm_terminal | ✅(`stop_run`) |
| `deployment.status_changed` | `status` | deployment | ✅(HM 用于 AgentDeployment.Status;客户端用 swarm.* 终态) | | `deployment.status_changed` | `status` | deployment | ✅(HM 用于 AgentDeployment.Status;客户端用 swarm.* 终态) |
+2
View File
@@ -36,6 +36,8 @@ Swarm 暴露以下接口(三组别名等价,便于 HM 路径覆盖):
| task graph | `GET …/{deployment_id}/tasks` | ✅ 已实现 | 任务 DAG | | task graph | `GET …/{deployment_id}/tasks` | ✅ 已实现 | 任务 DAG |
| logs / events | `GET …/{deployment_id}/logs`、`/events` | ✅ 已实现 | 事件流(分页 `cursor`/`limit`) | | logs / events | `GET …/{deployment_id}/logs`、`/events` | ✅ 已实现 | 事件流(分页 `cursor`/`limit`) |
| metrics / workflow / diagnostics | `GET …/{deployment_id}/metrics`、`/workflow`、`/diagnostics` | ✅ 已实现 | 指标与编排视图 | | metrics / workflow / diagnostics | `GET …/{deployment_id}/metrics`、`/workflow`、`/diagnostics` | ✅ 已实现 | 指标与编排视图 |
| **input(追加输入)** | `POST …/{deployment_id}/input` | ✅ 已实现 | 接收用户后续 prompt,注入为 `source=user_append` 任务(终态 run 自动 reopen 为 running;`stopped` 拒绝)。指令文本仅作任务描述下发给 agent,**不回显进事件流**(task.created 仅类别 message)。见 #40 |
| **result(结果)** | `GET …/{deployment_id}/result` | ✅ 已实现 | 返回用户面结果:`{summary, deliverable, artifacts[], termination_reason, status}`;产物内容在各 artifact 的 `uri`(git/runtime),非内联 |
| **cancel** | `POST …/{deployment_id}/stop` | ✅ 已实现 | 停止并取消非终态任务,向 Agent 下发 `cancel_task` | | **cancel** | `POST …/{deployment_id}/stop` | ✅ 已实现 | 停止并取消非终态任务,向 Agent 下发 `cancel_task` |
| **approve** | `POST …/{deployment_id}/approvals/{approval_id}` | ✅ 已实现 | 接收 Manager 审批决定(approved/rejected) | | **approve** | `POST …/{deployment_id}/approvals/{approval_id}` | ✅ 已实现 | 接收 Manager 审批决定(approved/rejected) |
| **resume** | — | 🟡 待对齐 | 当前仅「审批通过」隐式恢复(approvals);无独立 resume 端点 | | **resume** | — | 🟡 待对齐 | 当前仅「审批通过」隐式恢复(approvals);无独立 resume 端点 |
+115
View File
@@ -646,6 +646,14 @@ async def refresh_swarm_run_status(run):
} }
if termination_reason: if termination_reason:
swarm_terminal_payload["termination_reason"] = termination_reason swarm_terminal_payload["termination_reason"] = termination_reason
# Carry the user-facing answer in the terminal event so the client gets the result from the
# one event it already watches (no reconstruction): the synthesized summary + deliverable
# facts (git branch/commit/files + artifact_ids). Full result also at GET …/{id}/result.
if next_status == "completed":
if final_summary:
swarm_terminal_payload["summary"] = final_summary
if deliverable:
swarm_terminal_payload["deliverable"] = deliverable
await swarm_runtime.emit_event(run, f"swarm.{next_status}", payload=swarm_terminal_payload) await swarm_runtime.emit_event(run, f"swarm.{next_status}", payload=swarm_terminal_payload)
timeline_payload = { timeline_payload = {
"summary": final_summary or f"Swarm run {next_status}", "summary": final_summary or f"Swarm run {next_status}",
@@ -2118,6 +2126,113 @@ async def get_swarm_audit(deployment_id: str, request: Request):
return {"success": True, "data": await build_audit_trace_for_run(run)} return {"success": True, "data": await build_audit_trace_for_run(run)}
async def build_run_result(run) -> Dict[str, Any]:
"""The swarm's user-facing answer for a run: synthesized summary + deliverable + artifacts.
A first-class result object so the client doesn't reconstruct it from /workflow + /artifacts.
Carries only non-content descriptors (summary text + deliverable facts: git branch/commit/
files + artifact uri/checksum) — the actual artifact content lives at its uri (git/runtime).
"""
tasks = await load_runtime_tasks(run)
termination_reason = (run.metadata.get("convergence") or {}).get("termination_reason")
return {
"deployment_id": run.deployment_id,
"swarm_id": run.swarm_id,
"status": run.status,
"runtime_execution_status": run.status,
"summary": run.metadata.get("final_summary"),
"termination_reason": termination_reason,
"deliverable": build_run_deliverable(run, tasks),
"artifacts": collect_run_artifacts(run, tasks),
}
@app.get("/api/agent/swarm/deployments/{deployment_id}/result")
@app.get("/api/swarms/{deployment_id}/result")
@app.get("/api/agnet/deployments/{deployment_id}/result")
async def get_swarm_result(deployment_id: str, request: Request):
"""Return the swarm's result (summary + deliverable + artifacts) for a deployment."""
run, auth_error = await get_runtime_run_or_404(request, deployment_id)
if auth_error:
return auth_error
return {"success": True, "data": await build_run_result(run)}
@app.post("/api/agent/swarm/deployments/{deployment_id}/input")
@app.post("/api/swarms/{deployment_id}/input")
@app.post("/api/agnet/deployments/{deployment_id}/input")
async def append_swarm_input(deployment_id: str, request: Request):
"""Receive a user follow-up prompt and inject it into the running swarm (agent_swarm#40).
The instruction becomes a new `source="user_append"` task in the run's shared pool (agents
self-select it). If the run already finished (completed/failed), it is reopened to `running`
("continue"); a `stopped` run is rejected (start a new run). The instruction text is the task
description (delivered to the claiming agent over WS) but is **redacted from the event stream**
— the `task.created` event carries only a category message, never the raw prompt.
"""
auth_error = await require_runtime_auth(request)
if auth_error:
return auth_error
try:
body = await request.json()
except json.JSONDecodeError:
body = {}
instruction = (body.get("instruction") or body.get("input") or "").strip()
correlation_id = request.headers.get("x-correlation-id")
if not instruction:
return error_response(422, "INVALID_REQUEST", "instruction is required", correlation_id)
run = await swarm_runtime.get_run_by_identifier(deployment_id)
if not run:
raise HTTPException(status_code=404, detail="Swarm run not found")
if run.status == "stopped":
return error_response(409, "RUN_STOPPED",
"run was stopped; start a new run for new input", correlation_id)
reopened = run.status in ("completed", "failed")
n = len([t for t in run.task_ids if "-input-" in t]) + 1
task = await task_queue.create_task(
task_id=f"{run.swarm_id}-input-{n}",
title="用户追加输入",
description=instruction, # delivered to the agent; NOT echoed to events
agent_role="general",
required_capabilities=[],
root_task_id="seed",
source="user_append",
context={
"swarm_id": run.swarm_id,
"runtime_deployment_id": run.deployment_id,
"manager_deployment_id": run.manager_deployment_id,
"correlation_id": run.correlation_id,
"source": "user_append",
"agent_role": "general",
"workflow_mode": "swarm",
"allow_handoff": True,
},
enqueue=True,
)
await swarm_runtime.attach_task(run, task.task_id)
if reopened:
run.status = "running"
await swarm_runtime.save_run(run)
await swarm_runtime.emit_event(run, "deployment.status_changed",
payload=swarm_runtime.status_payload(run, phase="Execute", reason="user appended input"))
# Redacted task.created — the raw instruction is NOT in the event (security-boundary).
await swarm_runtime.emit_event(run, "task.created", task_id=task.task_id, payload={
"task_id": task.task_id,
"title": "用户追加输入",
"source": "user_append",
"agent_role": "general",
"message": "用户追加输入已注入运行中的蜂群",
})
return {"success": True, "data": {
"deployment_id": run.deployment_id,
"swarm_id": run.swarm_id,
"task_id": task.task_id,
"status": run.status,
"reopened": reopened,
}}
@app.post("/api/agent/swarm/deployments/{deployment_id}/stop") @app.post("/api/agent/swarm/deployments/{deployment_id}/stop")
@app.post("/api/swarms/{deployment_id}/stop") @app.post("/api/swarms/{deployment_id}/stop")
@app.post("/api/agnet/deployments/{deployment_id}/stop") @app.post("/api/agnet/deployments/{deployment_id}/stop")
+98
View File
@@ -0,0 +1,98 @@
"""Swarm I/O tests (agent_swarm#40 + result delivery): receive user prompts + return result.
Exercises the real HTTP endpoints via FastAPI TestClient (hermetic: REDIS_FAKE, planner offline,
no connected agents, launch backend = none):
* POST /api/swarms/{id}/input — injects a `source="user_append"` task; the raw instruction is
delivered to the agent (task description) but **redacted from the event stream** (task.created
carries only a category message); rejects a stopped run.
* GET /api/swarms/{id}/result — returns {summary, deliverable, artifacts, termination_reason}.
Run from agent_swarm_v6 (install deps first):
pip install -r orchestrator/requirements.txt
REDIS_FAKE=1 python scripts/test-swarm-io.py
"""
import json
import logging
import os
import sys
from pathlib import Path
os.environ["REDIS_FAKE"] = "1"
sys.path.insert(0, str(Path(__file__).resolve().parents[1]))
from fastapi.testclient import TestClient
from orchestrator import main as orch
orch.planner.client = None # force planner offline (no model calls)
logging.getLogger("orchestrator.swarm_runtime").setLevel(logging.ERROR)
failures = []
SECRET_PROMPT = "SUPER-SECRET-USER-FOLLOWUP-PROMPT-9173-do-the-thing"
def check(name, cond):
print(("PASS" if cond else "FAIL"), "-", name)
if not cond:
failures.append(name)
def create_run(client):
body = {"mode": "swarm", "requirement": {"objective": "build add(a,b)"},
"callback": {"url": "http://127.0.0.1:9/cb", "subscribed_events": []},
"metadata": {"manager_deployment_id": "io-1"}}
r = client.post("/api/swarms", json=body)
return r.json()["data"]["deployment_id"]
def main():
with TestClient(orch.app) as client:
dep = create_run(client)
check("run created", bool(dep))
# --- receive user prompt (append) ---
ri = client.post(f"/api/swarms/{dep}/input", json={"instruction": SECRET_PROMPT})
check("input accepted (200/success)", ri.status_code == 200 and ri.json().get("success"))
idata = ri.json().get("data", {})
check("input created a user_append task", bool(idata.get("task_id")))
# the appended task carries the instruction (agent-facing) ...
tasks = client.get(f"/api/swarms/{dep}/tasks").json()["data"]["tasks"]
appended = [t for t in tasks if t.get("source") == "user_append"]
check("appended task present with source=user_append", len(appended) == 1)
check("appended task description = the instruction (agent-facing)",
appended and appended[0].get("description") == SECRET_PROMPT)
# ... but the instruction is NOT echoed into the event stream (redaction).
events = client.get(f"/api/swarms/{dep}/logs").json()["data"]["events"]
blob = json.dumps(events, ensure_ascii=False)
check("instruction NOT in any event payload (redacted)", SECRET_PROMPT not in blob)
tc = [e for e in events if e["event_type"] == "task.created"
and (e.get("payload") or {}).get("source") == "user_append"]
check("a redacted task.created(user_append) event was emitted", len(tc) == 1)
check("that event carries a category message, not the prompt",
tc and "message" in tc[0]["payload"] and SECRET_PROMPT not in json.dumps(tc[0]["payload"], ensure_ascii=False))
# --- result endpoint ---
res = client.get(f"/api/swarms/{dep}/result")
check("result endpoint 200/success", res.status_code == 200 and res.json().get("success"))
rdata = res.json().get("data", {})
check("result has summary/deliverable/artifacts keys",
{"summary", "deliverable", "artifacts", "status"}.issubset(rdata.keys()))
check("result deliverable is a dict", isinstance(rdata.get("deliverable"), dict))
# --- stopped run rejects append ---
client.post(f"/api/swarms/{dep}/stop", json={"reason": "test stop"})
rstop = client.post(f"/api/swarms/{dep}/input", json={"instruction": "more"})
body = rstop.json()
check("append to stopped run rejected (RUN_STOPPED)",
body.get("success") is False and (body.get("error") or {}).get("code") == "RUN_STOPPED")
print()
if failures:
print(f"{len(failures)} swarm-io check(s) FAILED: {failures}")
sys.exit(1)
print("all swarm-io checks passed")
if __name__ == "__main__":
main()