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:
co-authored by
Claude Opus 4.8
parent
5138d9a370
commit
dd96b73b2d
@@ -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
@@ -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。
|
||||||
|
|||||||
@@ -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.* 终态) |
|
||||||
|
|||||||
@@ -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 端点 |
|
||||||
|
|||||||
@@ -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")
|
||||||
|
|||||||
@@ -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()
|
||||||
Reference in New Issue
Block a user