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:
tzt
2026-09-01 11:45:33 +08:00
parent 8e2123343c
commit 43e2bceae7
20 changed files with 568 additions and 59 deletions
+215 -21
View File
@@ -41,6 +41,42 @@ AGENT_SYSTEM_PROMPT = (
"直接输出给用户的最终答复(中文,不要再调用工具)。"
)
# ── 两级智能体(D7):规划者(大模型)+ 执行者(本地小模型),交接走 handoff 文档 ──
PLANNER_SYSTEM_PROMPT = (
"你是两级智能体中的**规划者**(大模型)。执行者是一个能力有限的本地小模型,"
"只能机械地使用工具。你的职责:把用户任务拆成执行者可照做的**具体指令**,"
"并在执行后审查其汇报。输出必须是合法 JSON 对象(不要 markdown 围栏)。"
)
EXECUTOR_SYSTEM_PROMPT = (
"你是两级智能体中的**执行者**(本地小模型)。规划者已给你具体指令,"
"你只负责用工具完成指令并在最后**汇报**:做了什么、结果如何、有什么问题。"
"严格遵守指令范围,不要自行扩大任务。汇报用中文,是给规划者看的,"
"要列出:修改的文件、关键命令输出、未完成项。"
)
# 规划者首轮:产出指令(JSON
_PLAN_SCHEMA_HINT = {
"instructions": "string(给执行者的具体步骤指令,<=600字)",
"acceptance": "string(验收标准,<=200字)",
}
# 规划者审查轮:裁决(JSON
_REVIEW_SCHEMA_HINT = {
"verdict": "enum(done|redo)",
"reply_to_executor": "string(verdict=redo 时给执行者的补充指令;done 时可空)",
"final_answer": "string(verdict=done 时给用户的最终答复)",
}
DEFAULT_MAX_HANDOFFS = 2 # 规划者<->执行者交接轮数上限
def _parse_json_loose(content: str) -> Dict[str, Any]:
"""宽松解析规划者的 JSON 输出(剥围栏/取首个对象);失败返回 {}"""
try:
from router_system.architect import ArchitectClient
return ArchitectClient._parse_json(content)
except Exception:
return {}
# ─────────────────────────────────────────────────────────────────────────────
# OpenAI 兼容工具调用客户端
@@ -129,6 +165,8 @@ class AgentRunInfo:
completion_tokens: int = 0
pool_id: str = ""
workspace: str = "" # 本次运行使用的工作区根目录(绝对路径)
executor_model: str = "" # 两级模式:执行者模型名(空 = 单模型模式)
mode: str = "single" # single | dual
asyncio_task: Optional[asyncio.Task] = field(default=None, repr=False)
def to_dict(self) -> Dict[str, Any]:
@@ -146,6 +184,8 @@ class AgentRunInfo:
"completion_tokens": self.completion_tokens,
"pool_id": self.pool_id,
"workspace": self.workspace,
"executor_model": self.executor_model,
"mode": self.mode,
}
@@ -169,12 +209,15 @@ class AgentService:
# ---------- 注册与查询 ----------
def register(self, request_id: str, task: str, model: str, pool_id: str,
workspace: str = "") -> Optional[AgentRunInfo]:
workspace: str = "", executor_model: str = "",
mode: str = "single") -> Optional[AgentRunInfo]:
running = [r for r in self._runs.values() if r.state == STATE_RUNNING]
if len(running) >= self.max_running:
return None
info = AgentRunInfo(request_id=request_id, task=task, model=model,
pool_id=pool_id, workspace=workspace, started_at=time.time())
pool_id=pool_id, workspace=workspace,
executor_model=executor_model, mode=mode,
started_at=time.time())
self._runs[request_id] = info
self._dir(request_id).mkdir(parents=True, exist_ok=True)
self._write_status(info)
@@ -186,27 +229,28 @@ class AgentService:
# ---------- 执行 ----------
async def run(self, info: AgentRunInfo, chat: Any, workspace_dir: str | Path,
max_rounds: int = 8, token_cap: int = 0,
allow_shell: bool = False, shell_timeout_s: int = 20) -> None:
"""执行智能体任务(由调用方包成后台协程)。"""
tools = WorkspaceTools(workspace_dir, allow_shell=allow_shell,
shell_timeout_s=shell_timeout_s)
loop = ToolLoop(tools, chat, max_rounds=max_rounds, token_cap=token_cap,
on_event=self._make_event_writer(info))
allow_shell: bool = False, shell_timeout_s: int = 20,
executor_chat: Any = None,
max_handoffs: int = DEFAULT_MAX_HANDOFFS) -> None:
"""执行智能体任务(由调用方包成后台协程)。
executor_chat 为空 = 单模型模式(chat 全程包办);
提供时进入两级模式:chat 作规划者,executor_chat 作执行者(D7)。
"""
try:
result = await loop.run(info.task, system=AGENT_SYSTEM_PROMPT)
info.response = result.get("response", "")
info.rounds = int(result.get("rounds", 0))
info.prompt_tokens = int(result.get("prompt_tokens", 0))
info.completion_tokens = int(result.get("completion_tokens", 0))
if result.get("reason") == "error":
info.state = STATE_FAILED
info.error = result.get("error")
elif result.get("reason") in ("token_cap", "max_rounds"):
# 触顶属于护栏行为:结果仍交付,但标记部分完成信息
info.state = STATE_DONE
info.error = result.get("error")
if executor_chat is not None:
result = await self.run_dual(
info, chat, executor_chat, workspace_dir,
max_rounds=max_rounds, token_cap=token_cap,
allow_shell=allow_shell, shell_timeout_s=shell_timeout_s,
max_handoffs=max_handoffs)
else:
info.state = STATE_DONE
tools = WorkspaceTools(workspace_dir, allow_shell=allow_shell,
shell_timeout_s=shell_timeout_s)
loop = ToolLoop(tools, chat, max_rounds=max_rounds, token_cap=token_cap,
on_event=self._make_event_writer(info))
result = await loop.run(info.task, system=AGENT_SYSTEM_PROMPT)
self._apply_result(info, result)
except Exception as exc: # pragma: no cover
info.state = STATE_FAILED
info.error = f"{type(exc).__name__}: {exc}"
@@ -216,6 +260,156 @@ class AgentService:
info.finished_at = time.time()
self._write_status(info)
def _apply_result(self, info: AgentRunInfo, result: Dict[str, Any]) -> None:
"""把循环结果落到运行状态(单/两级模式共用)。"""
info.response = result.get("response", "")
info.rounds = int(result.get("rounds", 0))
info.prompt_tokens = int(result.get("prompt_tokens", 0))
info.completion_tokens = int(result.get("completion_tokens", 0))
if result.get("reason") == "error":
info.state = STATE_FAILED
info.error = result.get("error")
elif result.get("reason") in ("token_cap", "max_rounds", "max_handoffs"):
# 触顶属于护栏行为:结果仍交付,但标记部分完成信息
info.state = STATE_DONE
info.error = result.get("error")
else:
info.state = STATE_DONE
# ---------- 两级模式(D7):规划者 + 执行者 ----------
async def run_dual(self, info: AgentRunInfo, planner_chat: Any, executor_chat: Any,
workspace_dir: str | Path, max_rounds: int = 8,
token_cap: int = 0, allow_shell: bool = False,
shell_timeout_s: int = 20,
max_handoffs: int = DEFAULT_MAX_HANDOFFS) -> Dict[str, Any]:
"""大模型拆解/审查 + 小模型执行工具轮,交接状态写 handoff.json(智能体版交流文本)。"""
info.mode = "dual"
tools = WorkspaceTools(workspace_dir, allow_shell=allow_shell,
shell_timeout_s=shell_timeout_s)
handoff: Dict[str, Any] = {
"task": info.task, "planner_model": info.model,
"executor_model": info.executor_model, "workspace": info.workspace,
"instructions": "", "acceptance": "", "exchanges": [],
}
spent = {"in": 0, "out": 0}
total_rounds = 0
def _account(usage: Dict[str, Any] | None) -> None:
spent["in"] += int((usage or {}).get("prompt_tokens", 0))
spent["out"] += int((usage or {}).get("completion_tokens", 0))
def _save_handoff() -> None:
try:
(self._dir(info.request_id) / "handoff.json").write_text(
json.dumps(handoff, ensure_ascii=False, indent=2), encoding="utf-8")
except OSError:
pass
def _remaining_cap() -> int:
return (token_cap - spent["in"] - spent["out"]) if token_cap else 1
async def _planner_json(user_msg: str) -> Dict[str, Any]:
"""调规划者并解析 JSON;解析失败回喂重试一次,再失败降级为 {}(禁止带病继续的软版本)。"""
messages = [{"role": "system", "content": PLANNER_SYSTEM_PROMPT},
{"role": "user", "content": user_msg}]
content = ""
for attempt in (1, 2):
resp = await planner_chat(messages, [])
_account(resp.get("usage"))
content = resp.get("content") or ""
obj = _parse_json_loose(content)
if obj:
break
if attempt == 1:
messages += [{"role": "assistant", "content": content},
{"role": "user",
"content": "你的输出不是合法 JSON。请重新只输出合法 JSON 对象。"}]
self._append_event(info, {"type": "message", "role": "planner",
"content": content[:2000]})
return obj
try:
# ---- 阶段 1:规划(大模型拆解为执行者指令) ----
self._append_event(info, {"type": "phase", "phase": "plan", "model": info.model})
plan = await _planner_json(
f"用户任务:{info.task}\n\n"
"请产出给执行者的指令,仅输出符合如下结构的 JSON:\n"
+ json.dumps(_PLAN_SCHEMA_HINT, ensure_ascii=False))
instructions = (plan.get("instructions") or info.task).strip()
handoff["instructions"] = instructions
handoff["acceptance"] = str(plan.get("acceptance", ""))
_save_handoff()
final_text = ""
reason = "answer"
error = None
exec_rounds_total = 0
# ---- 阶段 2/3:执行 <-> 审查(有界交接) ----
for h in range(1, max_handoffs + 1):
# 执行(本地小模型跑工具轮)
self._append_event(info, {"type": "phase", "phase": "execute",
"handoff": h, "model": info.executor_model})
loop = ToolLoop(tools, executor_chat, max_rounds=max_rounds,
token_cap=max(1, _remaining_cap()),
on_event=self._make_event_writer(info),
emit_final=False)
exec_result = await loop.run(instructions, system=EXECUTOR_SYSTEM_PROMPT)
_account({"prompt_tokens": exec_result.get("prompt_tokens", 0),
"completion_tokens": exec_result.get("completion_tokens", 0)})
exec_rounds_total += int(exec_result.get("rounds", 0))
report = exec_result.get("response", "")
# 执行者汇报作为消息事件透出(前端可读)
self._append_event(info, {"type": "message", "role": "executor",
"handoff": h, "content": (report or "")[:4000]})
if exec_result.get("reason") == "error":
reason, error = "error", exec_result.get("error")
final_text = report
break
# 审查(大模型裁决)
self._append_event(info, {"type": "phase", "phase": "review",
"handoff": h, "model": info.model})
review = await _planner_json(
f"用户任务:{info.task}\n你之前给出的指令:{instructions}\n"
f"验收标准:{handoff['acceptance'] or '(未明确)'}\n\n"
f"执行者第 {h} 轮汇报:\n{report[:4000]}\n\n"
"请审查是否已按验收标准完成,仅输出符合如下结构的 JSON:\n"
+ json.dumps(_REVIEW_SCHEMA_HINT, ensure_ascii=False))
verdict = str(review.get("verdict", "done")).lower()
handoff["exchanges"].append({
"handoff": h, "executor_report": report,
"verdict": verdict,
"reply_to_executor": str(review.get("reply_to_executor", "")),
})
_save_handoff()
if verdict == "done":
final_text = str(review.get("final_answer") or report)
break
# redo:裁决意见作为下一轮执行者指令(带上一轮上下文)
instructions = str(review.get("reply_to_executor") or instructions)
if h == max_handoffs:
reason = "max_handoffs"
error = f"交接轮数达上限({max_handoffs}),以执行者汇报收尾"
final_text = report
else:
final_text = final_text or ""
self._append_event(info, {"type": "final", "round": total_rounds + exec_rounds_total,
"reason": reason, "error": error})
return {"response": final_text, "rounds": total_rounds + exec_rounds_total,
"reason": reason, "error": error,
"prompt_tokens": spent["in"], "completion_tokens": spent["out"]}
except Exception as exc:
reason = "error"
error = f"{type(exc).__name__}: {exc}"
self._append_event(info, {"type": "final", "round": total_rounds,
"reason": reason, "error": error})
return {"response": "", "rounds": total_rounds, "reason": reason,
"error": error,
"prompt_tokens": spent["in"], "completion_tokens": spent["out"]}
# ---------- 事件 ----------
def _make_event_writer(self, info: AgentRunInfo):
def _on_event(ev: Dict[str, Any]) -> None: