feat(v2): T6 CollaborativePipeline 编排(快路径/协作循环/熔断/终审)

This commit is contained in:
tzt
2026-08-30 21:12:03 +08:00
parent 4fbbdb5290
commit ad81e1afb7
5 changed files with 484 additions and 5 deletions
+161
View File
@@ -0,0 +1,161 @@
"""T6 CollaborativePipeline 编排单测(封闭:MockTransport + 假 generate)。"""
import json
import httpx
import pytest
from router_system.architect import ArchitectClient
from router_system.pipeline import CollaborativePipeline
from router_system.verifier import Verifier
from router_system.worker import WorkerLoop
BRIEF_JSON = json.dumps({
"goal": "用 Python 实现一个函数",
"constraints": ["标准库"],
"tags": ["code"],
"acceptance": [{"id": "a1", "check": "可运行", "machine_checkable": True}],
"plan": [{"id": "s1", "task": "实现函数", "deps": [], "done_criteria": "函数可运行"}],
}, ensure_ascii=False)
DECIDE_JSON = json.dumps({"reply": "改用更简单的实现", "patch_plan": [{"id": "s1", "task": "简化实现"}]},
ensure_ascii=False)
REVIEW_DONE = json.dumps({"verdict": "done", "notes": "通过", "fix_issues": []}, ensure_ascii=False)
def _architect_handler(brief=BRIEF_JSON, decide=DECIDE_JSON, review=REVIEW_DONE):
def handler(request):
body = json.loads(request.content)
msg = (body.get("messages") or [{}])[-1].get("content", "")
if "任务 brief" in msg or "任务分析" in msg:
return _resp(brief)
if "裁决" in msg:
return _resp(decide)
if "终审" in msg:
return _resp(review)
# 兜底(breach:architect_do
return _resp("(兜底答案)")
return handler
def _resp(content):
return httpx.Response(200, json={
"choices": [{"message": {"content": content}}],
"usage": {"prompt_tokens": 100, "completion_tokens": 20},
})
def _make_architect(handler):
return ArchitectClient(model="deepseek-chat", base_url="https://api.deepseek.com/v1",
api_key="sk-test", transport=httpx.MockTransport(handler))
def _fake_generate(texts):
it = iter(texts)
async def _g(prompt):
try:
return next(it)
except StopIteration:
return ""
return _g
def _make_worker(generate):
return WorkerLoop(generate=generate,
verifier=Verifier(sandbox_runner=lambda c, e, t: (0, "", "")),
kb=None, max_fix_attempts=2)
def _pipeline(architect, worker, **kw):
return CollaborativePipeline(architect=architect, worker=worker,
fast_path=kw.get("fast_path", False),
rounds_cap=kw.get("rounds_cap", 6),
api_token_cap=kw.get("api_token_cap", 8000),
breach_policy=kw.get("breach_policy", "architect_do"),
run_dir=kw.get("run_dir", "runs"))
def asyncio_run(coro):
import asyncio
return asyncio.run(coro)
# ---------- 快路径 ----------
def test_fast_path_returns_direct(tmp_path):
architect = _make_architect(_architect_handler())
worker = _make_worker(_fake_generate(["这是一段足够长的、非占位的直接回答内容,用于快路径。"]))
pipe = _pipeline(architect, worker, fast_path=True, run_dir=str(tmp_path / "runs"))
res = asyncio_run(pipe.run("讲一下排序"))
assert res.status == "fast_path"
assert res.fast_path is True
assert res.api_input_tokens == 0 # 未调用 Architect
assert "快路径" in res.response or "直接" in res.response
# ---------- 协作:一次通过 ----------
def test_collab_one_pass(tmp_path):
architect = _make_architect(_architect_handler())
worker = _make_worker(_fake_generate(["def my_func():\n return 42"]))
pipe = _pipeline(architect, worker, fast_path=False, run_dir=str(tmp_path / "runs"))
res = asyncio_run(pipe.run("写个函数"))
assert res.status == "done"
assert res.fast_path is False
assert res.api_input_tokens >= 100 # brief + 终审 用量已计量
assert res.workspace_path is not None
# 交流文本已落盘
import os
assert os.path.exists(res.workspace_path)
assert "done" in res.route
# ---------- 协作:带 issue 修复 ----------
def test_collab_issue_fix(tmp_path):
architect = _make_architect(_architect_handler())
# 前两次失败(占位)-> issuedecide 后重跑成功
worker = _make_worker(_fake_generate([
"待实现", "待实现", "def ok():\n return 1",
]))
pipe = _pipeline(architect, worker, fast_path=False, run_dir=str(tmp_path / "runs"))
res = asyncio_run(pipe.run("写个函数"))
assert res.status == "done"
assert any("issue" in r for r in res.route)
assert any("decide" in r for r in res.route)
# 至少有 1 条 decision
import json as _json
ws = json.load(open(res.workspace_path, encoding="utf-8"))
assert len(ws["decisions"]) >= 1
# ---------- 熔断兜底:本地降级 ----------
def test_breach_local_degrade(tmp_path):
architect = _make_architect(_architect_handler())
worker = _make_worker(_fake_generate(["不通过"]))
pipe = _pipeline(architect, worker, fast_path=False, api_token_cap=1,
breach_policy="local_only", run_dir=str(tmp_path / "runs"))
res = asyncio_run(pipe.run("写个函数"))
assert res.status == "failed"
assert "本地降级" in res.response
assert any("breach" in r for r in res.route)
# ---------- 熔断兜底:Architect 代做 ----------
def test_breach_architect_do(tmp_path):
# review 一直打回 fix(制造压力),最终走 breach:architect_do
def handler(request):
body = json.loads(request.content)
msg = (body.get("messages") or [{}])[-1].get("content", "")
if "任务 brief" in msg or "任务分析" in msg:
return _resp(BRIEF_JSON)
if "自动升级" in msg:
return _resp("Architect 兜底答案)")
return _resp(DECIDE_JSON)
architect = _make_architect(handler)
worker = _make_worker(_fake_generate(["待实现"]))
pipe = _pipeline(architect, worker, fast_path=False, rounds_cap=1,
api_token_cap=8000, breach_policy="architect_do",
run_dir=str(tmp_path / "runs"))
res = asyncio_run(pipe.run("写个函数"))
# rounds_cap=1:一轮后预算未满但回合触顶 -> breach -> architect_do
assert "breach" in " ".join(res.route)
assert res.status in ("escalated", "failed")