agent-Specialization/modules/workflow_state_manager.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

391 lines
15 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对话级运行状态管理。
状态目录:`{data_dir}/workflow_states/<conversation_id>/`
- ``state.json``运行状态current / reject_counts / stage_rounds / history / pending_notices …)
- ``WORKFLOW.md``:激活时刻的工作流定义原样快照。运行期一切读取(下一节点详情、
分支候选、审核 prompt、maxRejects只读快照——库文件在运行期间被修改不影响本实例。
设计要点(定稿文档 docs/workflow_feature_plan.md
- 柔性原则工作流是智能体的辅助流程不是宿主。所有终态completed/stopped/failed
都只是「摘牌」——改状态 + 停止注入 + 柔性通知,绝不掐断智能体自身的工作循环。
- review 是瞬态节点:同步审核完直接走到下一站,``current_node_id`` 只停
stage / branch / endreview 只作为 history 记录。
- 消息游标 ``stage_start_msg_index``:进入阶段时记录 conversation_history 长度,
审核 payload 据此截取本阶段的工作痕迹。
- ``pending_notices``:柔性通知池(用户退出等),由统一完成通知轮询器消费。
不依赖 web_terminal只接受 data_dir 与 conversation_id便于单测与复用。
"""
from __future__ import annotations
import json
import re
import time
from copy import deepcopy
from pathlib import Path
from typing import Any, Dict, List, Optional, Union
from modules.workflow_manager import workflow_from_markdown
WORKFLOW_STATES_DIRNAME = "workflow_states"
WORKFLOW_SNAPSHOT_FILENAME = "WORKFLOW.md"
# 状态机
STATUS_ACTIVE = "active"
STATUS_COMPLETED = "completed"
STATUS_STOPPED = "stopped"
STATUS_FAILED = "failed"
# 退出原因
REASON_USER = "user" # 用户主动退出slash / 对话指令)
REASON_MODEL = "model" # 模型自主退出
REASON_MAX_REJECTS = "max_rejects" # 连续驳回撞上限
REASON_COMPLETED = "completed" # 走到 end 正常完成
PathLike = Union[str, Path]
_SAFE_CONVERSATION_ID = re.compile(r"^[A-Za-z0-9_-]+$")
def _validate_conversation_id(conversation_id: str) -> str:
cid = str(conversation_id or "").strip()
if not cid or not _SAFE_CONVERSATION_ID.match(cid):
raise ValueError(f"非法 conversation_id: {conversation_id!r}")
return cid
def _empty_state() -> Dict[str, Any]:
return {
"active": False,
"workflow_name": "",
"status": None,
"exit_reason": None,
"current_node_id": None,
"stage_rounds": 0,
"round_limit_notified": False,
"stage_start_msg_index": 0,
"reject_counts": {},
"history": [],
"pending_notices": [],
"started_at": None,
}
class WorkflowStateManager:
"""对话级工作流状态。一个实例对应一个对话的 workflow_states/<conversation_id>/ 目录。"""
def __init__(self, data_dir: PathLike, conversation_id: str):
self.data_dir = Path(data_dir).expanduser()
self.conversation_id = _validate_conversation_id(conversation_id)
self._definition_cache: Optional[Dict[str, Any]] = None
self.state: Dict[str, Any] = self.load()
# ------------------------------------------------------------------ 路径/持久化
def _dir(self) -> Path:
return self.data_dir / WORKFLOW_STATES_DIRNAME / self.conversation_id
def _state_path(self) -> Path:
return self._dir() / "state.json"
def _snapshot_path(self) -> Path:
return self._dir() / WORKFLOW_SNAPSHOT_FILENAME
@classmethod
def load_from(cls, data_dir: PathLike, conversation_id: str) -> "WorkflowStateManager":
return cls(data_dir, conversation_id)
def load(self) -> Dict[str, Any]:
path = self._state_path()
if not path.exists():
self.state = _empty_state()
return self.state
try:
with open(path, "r", encoding="utf-8") as fh:
raw = json.load(fh) or {}
merged = _empty_state()
if isinstance(raw, dict):
merged.update(raw)
if not isinstance(merged.get("history"), list):
merged["history"] = []
if not isinstance(merged.get("pending_notices"), list):
merged["pending_notices"] = []
if not isinstance(merged.get("reject_counts"), dict):
merged["reject_counts"] = {}
self.state = merged
except (OSError, json.JSONDecodeError, ValueError):
self.state = _empty_state()
return self.state
def save(self) -> None:
path = self._state_path()
path.parent.mkdir(parents=True, exist_ok=True)
tmp = path.with_suffix(".json.tmp")
with open(tmp, "w", encoding="utf-8") as fh:
json.dump(self.state, fh, ensure_ascii=False, indent=2)
tmp.replace(path)
# ------------------------------------------------------------------ 定义快照(只读)
def load_definition(self) -> Optional[Dict[str, Any]]:
"""解析 WORKFLOW.md 快照为 camelCase 定义 dict带缓存"""
if self._definition_cache is not None:
return self._definition_cache
path = self._snapshot_path()
if not path.exists():
return None
try:
self._definition_cache = workflow_from_markdown(path.read_text(encoding="utf-8"), "snapshot")
except Exception:
self._definition_cache = None
return self._definition_cache
def get_node(self, node_id: Optional[str]) -> Optional[Dict[str, Any]]:
if not node_id:
return None
definition = self.load_definition() or {}
for node in definition.get("nodes") or []:
if isinstance(node, dict) and node.get("id") == node_id:
return node
return None
def entry_node(self) -> Optional[Dict[str, Any]]:
"""入口节点start 节点的 next 指向。"""
definition = self.load_definition() or {}
for node in definition.get("nodes") or []:
if isinstance(node, dict) and node.get("kind") == "start":
return self.get_node(node.get("next"))
return None
# ------------------------------------------------------------------ 生命周期
def activate(
self,
*,
workflow_name: str,
definition_markdown: str,
entry_node_id: str,
stage_start_msg_index: int = 0,
) -> Dict[str, Any]:
"""激活:复制定义快照 + 初始化状态(覆盖式,重新激活即从头开始)。"""
target_dir = self._dir()
target_dir.mkdir(parents=True, exist_ok=True)
self._snapshot_path().write_text(definition_markdown, encoding="utf-8")
self._definition_cache = None
self.state = _empty_state()
self.state.update(
{
"active": True,
"workflow_name": str(workflow_name or "").strip(),
"status": STATUS_ACTIVE,
"current_node_id": entry_node_id,
"stage_start_msg_index": max(0, int(stage_start_msg_index or 0)),
"started_at": time.time(),
}
)
self.save()
return deepcopy(self.state)
def is_active(self) -> bool:
return bool(self.state.get("active")) and self.state.get("status") == STATUS_ACTIVE
def deactivate(self, *, status: str, reason: str) -> Dict[str, Any]:
"""摘牌:标记终态。状态目录保留供追溯,重新 activate 时整体重置。"""
if status not in (STATUS_COMPLETED, STATUS_STOPPED, STATUS_FAILED):
status = STATUS_STOPPED
self.state["active"] = False
self.state["status"] = status
self.state["exit_reason"] = reason
self.save()
return deepcopy(self.state)
# ------------------------------------------------------------------ 推进
def get_current_node_id(self) -> Optional[str]:
nid = self.state.get("current_node_id")
return str(nid) if nid else None
def record_stage_completion(self, *, summary: str, rounds: int) -> None:
"""把当前 stage 记入 history不前移。只在「确定不再被驳回」的落地分支调用
审核驳回时当前 stage 不算完成,不记录。"""
finished_node_id = self.get_current_node_id()
finished_node = self.get_node(finished_node_id) or {}
history = self.state.get("history")
if not isinstance(history, list):
history = []
history.append(
{
"node_id": finished_node_id,
"kind": "stage",
"name": str(finished_node.get("name") or finished_node_id or ""),
"summary": str(summary or ""),
"rounds": max(0, int(rounds or 0)),
"at": time.time(),
}
)
self.state["history"] = history
self.save()
def move_to(self, node_id: str, *, msg_index: int) -> None:
"""当前节点前移(不记 history重置阶段计数并更新消息游标。"""
self.state["current_node_id"] = str(node_id)
self.state["stage_rounds"] = 0
self.state["round_limit_notified"] = False
self.state["stage_start_msg_index"] = max(0, int(msg_index or 0))
self.save()
def advance_to(
self,
node_id: str,
*,
summary: str,
rounds: int,
msg_index: int,
) -> None:
"""组合便捷方法:记 stage 完成 + 前移(等价 record_stage_completion + move_to"""
self.record_stage_completion(summary=summary, rounds=rounds)
self.move_to(node_id, msg_index=msg_index)
def record_review(self, *, node_id: str, name: str, decision: str, message: str) -> None:
"""审核(瞬态节点)记入 history。"""
history = self.state.get("history")
if not isinstance(history, list):
history = []
history.append(
{
"node_id": str(node_id),
"kind": "review",
"name": str(name or node_id),
"decision": str(decision or ""),
"message": str(message or ""),
"at": time.time(),
}
)
self.state["history"] = history
self.save()
def increment_reject(self, node_id: str) -> int:
counts = self.state.get("reject_counts")
if not isinstance(counts, dict):
counts = {}
counts[node_id] = int(counts.get(node_id) or 0) + 1
self.state["reject_counts"] = counts
self.save()
return counts[node_id]
def get_reject_count(self, node_id: str) -> int:
counts = self.state.get("reject_counts")
if not isinstance(counts, dict):
return 0
try:
return int(counts.get(node_id) or 0)
except (TypeError, ValueError):
return 0
# ------------------------------------------------------------------ 阶段轮数(跨任务累计)
def increment_stage_rounds(self) -> int:
self.state["stage_rounds"] = int(self.state.get("stage_rounds") or 0) + 1
self.save()
return self.state["stage_rounds"]
def reset_stage_rounds(self) -> None:
"""用户新消息到达(知情交互)时清零,撞限询问后可再次计数。"""
self.state["stage_rounds"] = 0
self.state["round_limit_notified"] = False
self.save()
def get_stage_rounds(self) -> int:
try:
return int(self.state.get("stage_rounds") or 0)
except (TypeError, ValueError):
return 0
def round_limit_notified(self) -> bool:
return bool(self.state.get("round_limit_notified"))
def mark_round_limit_notified(self) -> None:
self.state["round_limit_notified"] = True
self.save()
# ------------------------------------------------------------------ 消息游标
def get_stage_start_msg_index(self) -> int:
try:
return int(self.state.get("stage_start_msg_index") or 0)
except (TypeError, ValueError):
return 0
# ------------------------------------------------------------------ 柔性通知池
def push_notice(self, *, notice_type: str, message: str) -> None:
notices = self.state.get("pending_notices")
if not isinstance(notices, list):
notices = []
notices.append(
{
"type": str(notice_type or "workflow"),
"message": str(message or ""),
"created_at": time.time(),
}
)
self.state["pending_notices"] = notices
self.save()
def has_pending_notices(self) -> bool:
notices = self.state.get("pending_notices")
return isinstance(notices, list) and len(notices) > 0
def poll_notices(self) -> List[Dict[str, Any]]:
"""取出全部待通知项(取出即清除,确保不被重复消费)。"""
notices = self.state.get("pending_notices")
if not isinstance(notices, list) or not notices:
return []
out = [n for n in notices if isinstance(n, dict)]
self.state["pending_notices"] = []
self.save()
return out
def restore_notices(self, notices: List[Dict[str, Any]]) -> None:
"""派发失败时把通知放回池(回滚,避免静默丢失)。"""
if not notices:
return
existing = self.state.get("pending_notices")
if not isinstance(existing, list):
existing = []
self.state["pending_notices"] = list(notices) + existing
self.save()
# ------------------------------------------------------------------ 前端快照
def progress_snapshot(self) -> Dict[str, Any]:
"""对齐前端 stores/workflow.ts 的 WorkflowSnapshot。"""
if not self.is_active():
return {"active": False}
history = [
{"name": str(h.get("name") or ""), "rounds": h.get("rounds")}
for h in (self.state.get("history") or [])
if isinstance(h, dict) and h.get("kind") == "stage"
]
current_node = self.get_node(self.get_current_node_id())
current = None
next_name: Optional[str] = None
if current_node:
current = {"name": str(current_node.get("name") or ""), "rounds": self.get_stage_rounds()}
if current_node.get("kind") == "stage":
nxt = self.get_node(current_node.get("next"))
if nxt:
next_name = str(nxt.get("name") or "")
# branch待选择/ 其他next 为 None未来不可知
return {
"active": True,
"name": str(self.state.get("workflow_name") or ""),
"status": self.state.get("status"),
"history": history,
"current": current,
"next": next_name,
"reviewing": False,
"footnote": None,
}