agent-Specialization/modules/workflow_review_agent.py
JOJO 4d9b709a9e feat(workflow): 实现工作流运行时并统一审核智能体配置
工作流运行时:
- 状态机与编排:modules/workflow_state_manager.py + server/workflow_flow.py
  (激活快照/阶段推进/审核节点/分支决策/柔性通知/max_stage_rounds 撞限询问)
- 五个工具(activate/report_stage/choose_branch/get_status/deactivate)
  与 REST API(server/workflow_runtime_api.py)
- 前端:QuickDock 工作流窗口(三段式进度,推进/驳回/完成/退出动画)、
  slash 菜单激活与退出、轮询事件消费、进入对话状态回填
- 审核:modules/workflow_review_agent.py(pass/reject 把关节点)

审核智能体统一配置:
- 个人空间新增「审核智能体」标签页:自动审批/目标/工作流三个审核智能体
  统一选择模型+思考模式+超时/轮次参数
- modules/review_agent_config.py 统一解析(复用子智能体模型库),
  废除独立 json 配置(auto_approval/goal_review/workflow_review)
- goal 审核接入 max_rounds 上限(原常量未接线);workflow 审核硬编码 6 轮改为可配

联调修复:
- /new 空对话激活:后端自动创建对话并完整继承模式参数
  (work_mode/permission/execution/reasoning_effort,修复思考模式丢失)
- 激活/通知消息 starts_work=True,恢复智能体回复头部与工作计时
- 节点目录改为从开始节点拓扑遍历(修复按保存顺序显示错乱)
- QuickDock 乐观掩码不再掩盖工作流实时状态(修复 /new 激活窗口瞬关+延迟瞬开);
  /new 路由不套用全局内容缓存(修复空对话展开空白数秒后收回)
- 工作流完成先广播完成态快照再摘牌,窗口播完落定+退出动画再收起
- 激活提示中的工具名修正为 report_workflow_stage
2026-08-21 16:50:35 +08:00

349 lines
17 KiB
Python
Raw Permalink Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

"""工作流阶段审核智能体Workflow Review Agent
由 modules/goal_review_agent.py fork 而来。职责不同:
- goal_review_agent 判断"长期目标是否真正达成"done/continue
- workflow_review_agent 判断"工作流当前阶段的产出是否达到进入下一阶段的门槛"pass/reject
两种审核模式(与 goal_review 同构):
- readonly只给 report_workflow_review 一个工具,不注入 run_command。
- active额外注入只读 run_command 工具,允许其取证后再下结论。
返回结构:{"decision": "pass"|"reject", "message": str, "source": "workflow_review_agent"}。
兜底(异常/超时/未产出结论/配置缺失)一律返回 rejectmessage 说明审核服务异常
并提示主智能体「请告知用户」——定稿决策:审核异常视为驳回(计入 reject_counts
用户看到消息后能发现是审核服务问题而非真实驳回。
"""
from __future__ import annotations
import json
import time
import uuid
from pathlib import Path
from typing import Any, Callable, Dict, List, Optional
import httpx
from config import PROMPTS_DIR, LOGS_DIR
from modules.goal_state_manager import REVIEW_MODE_ACTIVE, REVIEW_MODE_READONLY
from modules.review_agent_config import resolve_review_agent_config
PROMPT_NAME = "workflow_review_agent.txt"
DEFAULT_TIMEOUT_SECONDS = 120
DEFAULT_MAX_ROUNDS = 6
DEFAULT_MAX_COMMAND_TIMEOUT = 60
DEBUG_SAVE_WORKFLOW_REVIEW_TRANSCRIPT = True
DEBUG_TRANSCRIPT_DIR = Path(LOGS_DIR) / "workflow_review_agent"
# 兜底驳回消息(审核智能体未能产出明确结论时使用)
FALLBACK_REJECT_MESSAGE = (
"本次审核未能正常完成(审核服务异常或未产出结论),按驳回处理。"
"请告知用户审核服务可能异常;若属偶发,可稍后重新汇报本阶段。"
)
def load_workflow_review_agent_config() -> Dict[str, Any]:
"""加载工作流审核智能体配置:统一走个人空间「审核智能体」设置 + 子智能体模型库。"""
cfg = resolve_review_agent_config("workflow_review")
cfg["name"] = "workflow-review-agent"
return cfg
class WorkflowReviewAgent:
def __init__(self, *, web_terminal: Any):
self.web_terminal = web_terminal
self.cfg = load_workflow_review_agent_config()
@staticmethod
def _system_prompt(review_mode: str) -> str:
try:
template = (Path(PROMPTS_DIR) / PROMPT_NAME).read_text(encoding="utf-8")
except Exception:
template = "你是工作流阶段审核智能体。你必须调用 report_workflow_review 返回 pass 或 reject。"
active_start = "{{ACTIVE_REVIEW_ONLY}}"
active_end = "{{/ACTIVE_REVIEW_ONLY}}"
if review_mode == REVIEW_MODE_ACTIVE:
return template.replace(active_start, "").replace(active_end, "").strip()
while active_start in template and active_end in template:
start = template.find(active_start)
end = template.find(active_end, start)
if start < 0 or end < 0:
break
template = template[:start] + template[end + len(active_end):]
return template.strip()
@staticmethod
def _report_tool() -> Dict[str, Any]:
return {
"type": "function",
"function": {
"name": "report_workflow_review",
"description": (
"提交工作流阶段审核结论并结束本次审核。pass=阶段产出合格,进入下一阶段;"
"reject=阶段产出不合格,打回整改。"
),
"parameters": {
"type": "object",
"properties": {
"decision": {
"type": "string",
"enum": ["pass", "reject"],
"description": "pass=通过reject=驳回。",
},
"message": {
"type": "string",
"description": (
"decision=pass 时简述通过理由(给主智能体与用户看);"
"decision=reject 时写给主执行模型的整改意见,需具体可执行,"
"指出缺什么、怎么补、重新审核时要看到什么证据。"
),
},
},
"required": ["decision", "message"],
},
},
}
@staticmethod
def _run_command_tool() -> Dict[str, Any]:
return {
"type": "function",
"function": {
"name": "run_command",
"description": (
"在终端中执行只读指令。可用于查看文件内容、搜索代码/文本、检查目录结构、"
"查看 git 状态与差异、运行测试以核实阶段产出是否合格。"
"禁止用于写入、删除、改权限或其他修改性操作。"
),
"parameters": {
"type": "object",
"properties": {
"command": {
"type": "string",
"description": (
"要执行的终端命令字符串。应优先使用只读命令,例如 ls、cat、grep、find、"
"rg、git status、git diff、pwd、stat、以及运行测试的命令等。"
),
}
},
"required": ["command"],
},
},
}
def _build_tools(self, review_mode: str) -> List[Dict[str, Any]]:
if review_mode == REVIEW_MODE_ACTIVE:
return [self._run_command_tool(), self._report_tool()]
return [self._report_tool()]
async def _run_readonly_command(self, command: str) -> Dict[str, Any]:
work_path = Path(getattr(self.web_terminal.context_manager, "project_path", "."))
timeout = int(self.cfg.get("max_command_timeout") or DEFAULT_MAX_COMMAND_TIMEOUT)
try:
result = await self.web_terminal.terminal_ops.run_command(
command=command,
working_dir=str(work_path),
timeout=timeout,
sandbox_write_access=False,
)
return result if isinstance(result, dict) else {"success": False, "error": "run_command 返回格式异常"}
except Exception as exc:
return {"success": False, "error": str(exc)}
async def review(
self,
*,
payload_text: str,
review_mode: str = REVIEW_MODE_READONLY,
progress_cb: Optional[Callable[[Dict[str, Any]], None]] = None,
cancel_check: Optional[Callable[[], Optional[Dict[str, Any]]]] = None,
) -> Dict[str, Any]:
review_mode = review_mode if review_mode in (REVIEW_MODE_READONLY, REVIEW_MODE_ACTIVE) else REVIEW_MODE_READONLY
debug_transcript: List[Dict[str, Any]] = []
def _trace(event: str, payload: Dict[str, Any]) -> None:
if not DEBUG_SAVE_WORKFLOW_REVIEW_TRANSCRIPT:
return
debug_transcript.append({"ts": int(time.time() * 1000), "event": event, "payload": payload})
def _flush_trace(final_result: Dict[str, Any]) -> None:
if not DEBUG_SAVE_WORKFLOW_REVIEW_TRANSCRIPT:
return
try:
DEBUG_TRANSCRIPT_DIR.mkdir(parents=True, exist_ok=True)
row = {
"created_at": int(time.time() * 1000),
"review_mode": review_mode,
"messages": messages,
"trace": debug_transcript,
"final_result": final_result,
}
file = DEBUG_TRANSCRIPT_DIR / f"workflow_review_{int(time.time() * 1000)}.json"
file.write_text(json.dumps(row, ensure_ascii=False, indent=2), encoding="utf-8")
except Exception:
pass
def _reject(message: str) -> Dict[str, Any]:
return {"decision": "reject", "message": message or FALLBACK_REJECT_MESSAGE, "source": "workflow_review_agent"}
url = str(self.cfg.get("url") or "").strip()
key = str(self.cfg.get("key") or "").strip()
model = str(self.cfg.get("model") or "").strip()
if not url or not key or not model:
out = _reject(f"工作流审核智能体配置缺失,无法完成审核。{FALLBACK_REJECT_MESSAGE}")
_flush_trace(out)
return out
endpoint = f"{url.rstrip('/')}/chat/completions"
headers = {"Authorization": f"Bearer {key}", "Content-Type": "application/json"}
timeout_seconds = int(self.cfg.get("timeout_seconds") or DEFAULT_TIMEOUT_SECONDS)
max_rounds = max(1, int(self.cfg.get("max_rounds") or DEFAULT_MAX_ROUNDS))
extra_params = dict(self.cfg.get("extra_params") or {})
tools = self._build_tools(review_mode)
messages: List[Dict[str, Any]] = [
{"role": "system", "content": self._system_prompt(review_mode)},
{"role": "user", "content": payload_text},
]
async with httpx.AsyncClient(timeout=timeout_seconds) as client:
rounds = 0
forced_tool_retry_used = False
while True:
rounds += 1
if cancel_check:
external = cancel_check()
if external:
return external
if rounds > max_rounds:
out = _reject(f"审核超过最大轮次仍未产出结论。{FALLBACK_REJECT_MESSAGE}")
_flush_trace(out)
return out
if progress_cb:
progress_cb({"stage": "model_call", "round": rounds, "message": f"审核轮次 {rounds}"})
req = {
"model": model,
"messages": messages,
"tools": tools,
"tool_choice": "auto",
"temperature": 0.0,
**extra_params,
}
_trace("request", {"round": rounds, "request": req})
try:
resp = await client.post(endpoint, headers=headers, json=req)
resp.raise_for_status()
except httpx.HTTPStatusError as exc:
body = ""
try:
body = exc.response.text
except Exception:
body = str(exc)
_trace("http_error", {"round": rounds, "status_code": exc.response.status_code if exc.response else None, "body": body})
# 兼容某些模型/网关不接受额外参数:自动降级重试一次(去掉 extra_params
if extra_params:
retry_req = {
"model": model,
"messages": messages,
"tools": tools,
"tool_choice": "auto",
"temperature": 0.0,
}
_trace("retry_request_without_extra_params", {"round": rounds, "request": retry_req})
retry_resp = await client.post(endpoint, headers=headers, json=retry_req)
try:
retry_resp.raise_for_status()
resp = retry_resp
except httpx.HTTPStatusError:
out = _reject(f"审核请求失败({retry_resp.status_code})。{FALLBACK_REJECT_MESSAGE}")
_flush_trace(out)
return out
else:
out = _reject(
f"审核请求失败({exc.response.status_code if exc.response else 'unknown'})。{FALLBACK_REJECT_MESSAGE}"
)
_flush_trace(out)
return out
except Exception as exc:
_trace("request_exception", {"round": rounds, "error": str(exc)})
out = _reject(f"审核请求异常({exc})。{FALLBACK_REJECT_MESSAGE}")
_flush_trace(out)
return out
choice = ((resp.json().get("choices") or [{}])[0] or {}).get("message") or {}
reasoning_content = (
choice.get("reasoning_content") or choice.get("reasoning") or choice.get("thinking") or ""
)
_trace("response", {"round": rounds, "message": choice, "reasoning_content": reasoning_content})
tool_calls = choice.get("tool_calls") or []
content = choice.get("content")
if not tool_calls:
if content or reasoning_content:
messages.append(
{
"role": "assistant",
"content": str(content or ""),
"reasoning_content": str(reasoning_content or ""),
}
)
reminder = (
"禁止直接输出结论文本。必须调用工具:必要时先调用 run_command 核实,"
"再调用 report_workflow_review 给出最终结论。"
)
if not forced_tool_retry_used:
forced_tool_retry_used = True
reminder = "你刚刚未通过工具给出明确结果。必须调用工具,禁止直接输出内容。请立即调用 report_workflow_review 返回最终结论。"
messages.append({"role": "user", "content": reminder})
continue
# 有 tool_calls追加完整 assistant 消息(与主智能体结构对齐)
assistant_message = {
"role": "assistant",
"content": str(content or ""),
"reasoning_content": str(reasoning_content or ""),
"tool_calls": tool_calls,
}
messages.append(assistant_message)
for call in tool_calls:
fn = (call.get("function") or {}).get("name")
raw_args = (call.get("function") or {}).get("arguments") or "{}"
try:
args = json.loads(raw_args) if isinstance(raw_args, str) else (raw_args or {})
except Exception:
args = {}
if fn == "report_workflow_review":
decision = str(args.get("decision") or "").strip().lower()
message = str(args.get("message") or "").strip()
if decision == "pass":
out = {"decision": "pass", "message": message or "审核通过。", "source": "workflow_review_agent"}
_flush_trace(out)
return out
if decision == "reject":
out = _reject(message or "审核未通过,请按整改意见补充后重新汇报。")
_flush_trace(out)
return out
# 非法 decision保守驳回
out = _reject(message or f"审核返回了无法识别的结论。{FALLBACK_REJECT_MESSAGE}")
_flush_trace(out)
return out
if fn == "run_command":
cmd = str(args.get("command") or "").strip()
if progress_cb:
progress_cb({"stage": "run_command", "round": rounds, "command": cmd})
tool_result = await self._run_readonly_command(cmd) if cmd else {"success": False, "error": "command 不能为空"}
_trace("tool_result", {"round": rounds, "tool": "run_command", "command": cmd, "result": tool_result})
tool_content = json.dumps(tool_result, ensure_ascii=False)
messages.append(
{
"role": "tool",
"content": tool_content,
"timestamp": time.strftime("%Y-%m-%dT%H:%M:%S", time.localtime())
+ f".{int((time.time() % 1) * 1000000):06d}",
"message_id": f"msg_{uuid.uuid4().hex}",
"tool_call_id": call.get("id"),
"name": "run_command",
}
)