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
+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()