feat(v3): T26 两级智能体(大模型规划/审查 + 本地小模型执行,D7)
- run_dual 编排:规划者两阶段 JSON(plan/review,失败回喂重试一次再降级),
redo 时裁决意见回喂执行者,交接上限 agent.max_handoffs(默认 2)
- 交接文档 agent_runs/{id}/handoff.json(智能体版交流文本:instructions/acceptance/exchanges)
- /agent 新增 executor_pool_id;规划者==执行者条目拒绝;整体 token_cap 覆盖两级调用
- ToolLoop 增 emit_final 开关(内层循环不发终态,防前端 SSE 提前收口)
- 前端:执行者选择器 + phase/message 事件渲染(阶段徽标 + 双色消息卡)
- 测试 +3(done/redo/执行者故障),全量 277 passed
- fix(tests): test_config_get_put_reset 增加设置备份/恢复隔离,防止清掉用户真实配置
This commit is contained in:
@@ -244,3 +244,162 @@ def test_agent_workspace_and_file_accept_root(agent_env, client, tmp_path):
|
||||
# 非法 root -> 400
|
||||
r = client.get("/agent/workspace", params={"root": str(tmp_path / "nope")})
|
||||
assert r.status_code == 400
|
||||
|
||||
|
||||
# ---------------- 两级智能体(T26):规划者 + 执行者 ----------------
|
||||
|
||||
def _planner_resp(obj=None, raw=""):
|
||||
content = raw or json.dumps(obj, ensure_ascii=False)
|
||||
return {"content": content, "tool_calls": [],
|
||||
"usage": {"prompt_tokens": 50, "completion_tokens": 20}}
|
||||
|
||||
|
||||
def _install_dual(agent_env, monkeypatch, planner_script, executor_script):
|
||||
"""注入假规划者(build_agent_chat)与假执行者(OpenAICompatChat)。"""
|
||||
|
||||
class FakePlanner:
|
||||
api_key = "sk-fake"
|
||||
|
||||
def __init__(self, *a, **k):
|
||||
self.script = list(planner_script)
|
||||
|
||||
async def __call__(self, messages, tools_spec):
|
||||
if self.script:
|
||||
return self.script.pop(0)
|
||||
return _planner_resp({"verdict": "done", "final_answer": "(兜底)完成。"})
|
||||
|
||||
class FakeExecutorChat:
|
||||
def __init__(self, *a, **k):
|
||||
self.script = list(executor_script)
|
||||
|
||||
async def __call__(self, messages, tools_spec):
|
||||
if self.script:
|
||||
return self.script.pop(0)
|
||||
return {"content": "(执行者兜底)没有更多动作。", "tool_calls": [], "usage": {}}
|
||||
|
||||
def fake_chat_factory(acfg):
|
||||
return FakePlanner()
|
||||
|
||||
monkeypatch.setattr(ga, "build_agent_chat", fake_chat_factory)
|
||||
monkeypatch.setattr(ag, "OpenAICompatChat", FakeExecutorChat)
|
||||
|
||||
|
||||
def test_dual_agent_done_flow(agent_env, client, monkeypatch, tmp_path):
|
||||
"""规划 -> 执行(写文件) -> 审查 done:事件/交接文档/状态全部落位。"""
|
||||
_install_dual(
|
||||
agent_env, monkeypatch,
|
||||
planner_script=[
|
||||
_planner_resp({"instructions": "在 data 目录创建 report.json",
|
||||
"acceptance": "文件存在且内容为合法 JSON"}),
|
||||
_planner_resp({"verdict": "done", "reply_to_executor": "",
|
||||
"final_answer": "执行者已按指令创建数据文件,验收通过。"}),
|
||||
],
|
||||
executor_script=[
|
||||
{"content": None,
|
||||
"tool_calls": [{"id": "e1", "name": "write_file",
|
||||
"arguments": {"path": "data/report.json",
|
||||
"content": '{"ok": true}'}}],
|
||||
"usage": {"prompt_tokens": 100, "completion_tokens": 10}},
|
||||
{"content": "汇报:已创建 data/report.json,内容 {\"ok\": true}。",
|
||||
"tool_calls": [], "usage": {"prompt_tokens": 120, "completion_tokens": 15}},
|
||||
])
|
||||
r = client.post("/agent", json={"task": "建数据文件", "executor_pool_id": "no-such"})
|
||||
# 执行者条目不存在 -> 400
|
||||
assert r.status_code == 400
|
||||
|
||||
# 先放一个合法 llama_server 条目作为执行者
|
||||
client.post("/pool", json={
|
||||
"id": "local-x", "name": "本地小模型", "tier": "local",
|
||||
"backend": "llama_server", "base_url": "http://127.0.0.1:8901/v1",
|
||||
"model": "qwen-0.8b", "enabled": True})
|
||||
r2 = client.post("/agent", json={"task": "建数据文件", "executor_pool_id": "local-x"})
|
||||
assert r2.status_code == 200
|
||||
assert r2.json()["mode"] == "dual"
|
||||
assert "本地小模型" in r2.json()["executor_model"]
|
||||
|
||||
rid = r2.json()["request_id"]
|
||||
info = _wait_done(agent_env["service"], rid)
|
||||
assert info.state == "done", info.error
|
||||
assert info.mode == "dual"
|
||||
assert info.response == "执行者已按指令创建数据文件,验收通过。"
|
||||
|
||||
# 事件序列:规划 -> 执行(含工具) -> 审查 -> final
|
||||
evs = agent_env["service"].read_events(rid)
|
||||
phases = [e["phase"] for e in evs if e["type"] == "phase"]
|
||||
assert phases == ["plan", "execute", "review"]
|
||||
kinds = [e["type"] for e in evs]
|
||||
assert "message" in kinds and "tool_call" in kinds
|
||||
# 交接文档(智能体版交流文本)
|
||||
ho = json.loads((agent_env["service"]._dir(rid) / "handoff.json").read_text(encoding="utf-8"))
|
||||
assert ho["instructions"]
|
||||
assert ho["exchanges"][0]["verdict"] == "done"
|
||||
assert ho["executor_model"] == "本地小模型(qwen-0.8b)"
|
||||
|
||||
st = client.get(f"/agent/{rid}/status").json()
|
||||
assert st["mode"] == "dual" and st["executor_model"]
|
||||
|
||||
|
||||
def test_dual_agent_redo_then_done(agent_env, client, monkeypatch):
|
||||
"""第一轮裁决 redo -> 执行者带补充指令再跑 -> 第二轮 done。"""
|
||||
_install_dual(
|
||||
agent_env, monkeypatch,
|
||||
planner_script=[
|
||||
_planner_resp({"instructions": "写 hello.txt"}),
|
||||
_planner_resp({"verdict": "redo", "reply_to_executor": "文件内容不对,请写入 DONE",
|
||||
"final_answer": ""}),
|
||||
_planner_resp({"verdict": "done", "reply_to_executor": "",
|
||||
"final_answer": "第二轮通过。"}),
|
||||
],
|
||||
executor_script=[
|
||||
{"content": "汇报:已写 hello.txt(内容空白)", "tool_calls": [],
|
||||
"usage": {"prompt_tokens": 10, "completion_tokens": 5}},
|
||||
{"content": None,
|
||||
"tool_calls": [{"id": "e1", "name": "write_file",
|
||||
"arguments": {"path": "hello.txt", "content": "DONE"}}],
|
||||
"usage": {"prompt_tokens": 10, "completion_tokens": 5}},
|
||||
{"content": "汇报:已按补充指令重写 hello.txt 内容为 DONE",
|
||||
"tool_calls": [], "usage": {"prompt_tokens": 10, "completion_tokens": 5}},
|
||||
])
|
||||
client.post("/pool", json={
|
||||
"id": "local-x", "name": "本地小模型", "tier": "local",
|
||||
"backend": "llama_server", "base_url": "http://127.0.0.1:8901/v1",
|
||||
"model": "qwen-0.8b", "enabled": True})
|
||||
r = client.post("/agent", json={"task": "写 hello.txt", "executor_pool_id": "local-x"})
|
||||
rid = r.json()["request_id"]
|
||||
info = _wait_done(agent_env["service"], rid)
|
||||
assert info.state == "done"
|
||||
assert info.response == "第二轮通过。"
|
||||
ho = json.loads((agent_env["service"]._dir(rid) / "handoff.json").read_text(encoding="utf-8"))
|
||||
assert [x["verdict"] for x in ho["exchanges"]] == ["redo", "done"]
|
||||
# 第二轮执行者应收到 redo 补充指令(消息历史含 reply_to_executor 内容)
|
||||
evs = agent_env["service"].read_events(rid)
|
||||
exec_phases = [e for e in evs if e["type"] == "phase" and e["phase"] == "execute"]
|
||||
assert len(exec_phases) == 2
|
||||
|
||||
|
||||
def test_dual_agent_executor_error(agent_env, client, monkeypatch):
|
||||
"""执行者客户端异常 -> 任务 failed,错误透出。"""
|
||||
class BoomChat:
|
||||
def __init__(self, *a, **k):
|
||||
pass
|
||||
|
||||
async def __call__(self, messages, tools_spec):
|
||||
raise RuntimeError("本地模型连不上")
|
||||
|
||||
class PlanOK:
|
||||
api_key = "sk-fake"
|
||||
|
||||
async def __call__(self, messages, tools_spec):
|
||||
return _planner_resp({"instructions": "随便执行"})
|
||||
|
||||
monkeypatch.setattr(ga, "build_agent_chat", lambda acfg: PlanOK())
|
||||
monkeypatch.setattr(ag, "OpenAICompatChat", BoomChat)
|
||||
client.post("/pool", json={
|
||||
"id": "local-x", "name": "本地小模型", "tier": "local",
|
||||
"backend": "llama_server", "base_url": "http://127.0.0.1:8901/v1",
|
||||
"model": "qwen-0.8b", "enabled": True})
|
||||
r = client.post("/agent", json={"task": "t", "executor_pool_id": "local-x"})
|
||||
rid = r.json()["request_id"]
|
||||
info = _wait_done(agent_env["service"], rid)
|
||||
assert info.state == "failed"
|
||||
assert "RuntimeError" in (info.error or "")
|
||||
|
||||
+22
-12
@@ -86,18 +86,28 @@ def test_metrics(client):
|
||||
|
||||
|
||||
def test_config_get_put_reset(client):
|
||||
# GET 默认
|
||||
r = client.get("/config")
|
||||
assert r.status_code == 200
|
||||
assert r.json()["worker"]["backend"] in ("llama_server", "openai", "mock")
|
||||
# PUT 更新 worker
|
||||
r2 = client.put("/config", json={"worker": {"backend": "mock", "temperature": 0.5}})
|
||||
assert r2.status_code == 200
|
||||
assert r2.json()["worker"]["temperature"] == 0.5
|
||||
# 重置
|
||||
r3 = client.post("/config/reset")
|
||||
assert r3.status_code == 200
|
||||
assert r3.json()["worker"]["backend"] == "llama_server"
|
||||
"""/config 端到端。settings.json 是活文件(用户真实配置),
|
||||
测试前后必须备份/恢复,禁止把用户配置清掉。"""
|
||||
import json
|
||||
store = ga.settings_store()
|
||||
snapshot = json.loads(json.dumps(store._data, ensure_ascii=False))
|
||||
try:
|
||||
# GET 默认
|
||||
r = client.get("/config")
|
||||
assert r.status_code == 200
|
||||
assert r.json()["worker"]["backend"] in ("llama_server", "openai", "mock")
|
||||
# PUT 更新 worker
|
||||
r2 = client.put("/config", json={"worker": {"backend": "mock", "temperature": 0.5}})
|
||||
assert r2.status_code == 200
|
||||
assert r2.json()["worker"]["temperature"] == 0.5
|
||||
# 重置
|
||||
r3 = client.post("/config/reset")
|
||||
assert r3.status_code == 200
|
||||
assert r3.json()["worker"]["backend"] == "llama_server"
|
||||
finally:
|
||||
store._data = snapshot
|
||||
store.save()
|
||||
ga.rebuild_pipeline()
|
||||
|
||||
|
||||
def test_workspace_not_found(client):
|
||||
|
||||
Reference in New Issue
Block a user