- 新增 list_workflows / save_workflow 工具:归档式创建与覆盖, 归档时完整结构校验、覆盖前备份可恢复、name 与目录名一致性检查 - 新增内置技能 workflow-authoring:WORKFLOW.md 编写指南 + init/validate 脚本 + 节点详解与三个完整示例,skill_hints 挂载 - deactivate_workflow 挪工具循环特判层并广播摘牌快照, 修复模型自主退出后 slash 菜单与快捷卡片残留 - 工作流卡片消失改为退出动画:store exiting 状态保留快照播完动画 再清空,QuickDock hasContent 动画期间保持展开 - 运行期 workflow 通知 visibility 修正为 compact(原误走用户消息渲染) - 激活/退出通知文案中性化,不向模型透露内部操作路径 - 修复空闲对话刷新后输入栏 token 圆环无数值(bootstrapRoute 补拉取) - 修复 slash 激活工作流后输入栏 "/" 残留(跳转新对话前落盘草稿) - 侧边栏工作流导航图标放大至 20px
759 lines
32 KiB
Python
759 lines
32 KiB
Python
"""工作流(Workflow)运行时编排(定稿:docs/workflow_feature_plan.md §4)。
|
||
|
||
职责集中在此,尽量减少对主循环/工具循环的侵入:
|
||
- 激活(工具与 REST 共用):幂等规则 + 快照复制 + 激活上下文文本
|
||
- system 段构建(不冻结,每次 build_messages 现生成)
|
||
- report_workflow_stage / choose_workflow_branch 的推进矩阵(_arrive_at 递归消解)
|
||
- 审核调用(payload 构建 + 消息游标痕迹截取 + WorkflowReviewAgent)
|
||
- 柔性通知文本构造(用户退出 / maxRejects / max_stage_rounds)
|
||
- 进度事件广播(对齐 goal 的 sender → 轮询透传链路)
|
||
|
||
柔性原则:一切终态只「摘牌 + 通知」,绝不掐断智能体工作。
|
||
review 是瞬态节点:同步审核完直接走到下一站,current 只停 stage / branch / end。
|
||
"""
|
||
|
||
from __future__ import annotations
|
||
|
||
import json
|
||
import time
|
||
from typing import Any, Dict, List, Optional
|
||
|
||
from modules.workflow_manager import load_workflow, validate_structure, workflow_to_markdown
|
||
from modules.workflow_review_agent import WorkflowReviewAgent
|
||
from modules.workflow_state_manager import (
|
||
REASON_COMPLETED,
|
||
REASON_MAX_REJECTS,
|
||
REASON_MODEL,
|
||
REASON_USER,
|
||
STATUS_COMPLETED,
|
||
STATUS_FAILED,
|
||
STATUS_STOPPED,
|
||
WorkflowStateManager,
|
||
)
|
||
|
||
_KIND_LABELS = {"stage": "阶段", "review": "审核", "branch": "分支", "start": "开始", "end": "结束"}
|
||
|
||
# 审核痕迹截取长度上限(防 payload 膨胀)
|
||
_STAGE_TRACE_MAX_CHARS = 3500
|
||
|
||
|
||
# ---------------------------------------------------------------- 基础
|
||
|
||
|
||
def get_active_manager(data_dir, conversation_id: Optional[str]) -> Optional[WorkflowStateManager]:
|
||
"""返回本对话处于 active 的工作流状态管理器;无则 None。"""
|
||
if not conversation_id:
|
||
return None
|
||
try:
|
||
wsm = WorkflowStateManager(data_dir, conversation_id)
|
||
except ValueError:
|
||
return None
|
||
return wsm if wsm.is_active() else None
|
||
|
||
|
||
def workflow_is_active(data_dir, conversation_id: Optional[str]) -> bool:
|
||
return get_active_manager(data_dir, conversation_id) is not None
|
||
|
||
|
||
def _history_len(web_terminal) -> int:
|
||
try:
|
||
return len(web_terminal.context_manager.conversation_history or [])
|
||
except Exception:
|
||
return 0
|
||
|
||
|
||
# ---------------------------------------------------------------- 激活
|
||
|
||
|
||
def activate_workflow(
|
||
*,
|
||
data_dir,
|
||
conversation_id: str,
|
||
name: str,
|
||
msg_index: int,
|
||
) -> Dict[str, Any]:
|
||
"""激活入口(AI 工具与 REST 共用)。
|
||
|
||
幂等规则:同工作流已激活 → 返回当前进度;不同工作流 → 拒绝(提示先退出)。
|
||
成功返回 {"success": True, "text": 激活上下文文本, "manager": wsm, "already": bool}。
|
||
"""
|
||
name = str(name or "").strip()
|
||
if not name:
|
||
return {"success": False, "error": "缺少工作流名称。"}
|
||
existing = get_active_manager(data_dir, conversation_id)
|
||
if existing is not None:
|
||
current_name = str(existing.state.get("workflow_name") or "")
|
||
if current_name == name:
|
||
text = build_activation_text(wsm=existing, already=True)
|
||
return {"success": True, "already": True, "text": text, "manager": existing}
|
||
return {
|
||
"success": False,
|
||
"error": (
|
||
f"当前对话已激活工作流「{current_name}」,同一时间只能激活一个工作流。"
|
||
"请先调用 deactivate_workflow 退出,再激活新的。"
|
||
),
|
||
}
|
||
try:
|
||
wf = load_workflow(name, data_dir)
|
||
except FileNotFoundError:
|
||
return {"success": False, "error": f"工作流不存在:{name}"}
|
||
except ValueError as exc:
|
||
return {"success": False, "error": str(exc)}
|
||
errors = validate_structure(wf)
|
||
if errors:
|
||
return {"success": False, "error": "工作流结构校验未通过:" + ";".join(errors)}
|
||
wsm = WorkflowStateManager(data_dir, conversation_id)
|
||
entry = None
|
||
for node in wf.get("nodes") or []:
|
||
if node.get("kind") == "start":
|
||
entry = node.get("next")
|
||
break
|
||
entry_node = None
|
||
for node in wf.get("nodes") or []:
|
||
if node.get("id") == entry:
|
||
entry_node = node
|
||
break
|
||
if not entry_node:
|
||
return {"success": False, "error": "工作流缺少有效的入口节点(开始节点未连接)。"}
|
||
wsm.activate(
|
||
workflow_name=str(wf.get("name") or name),
|
||
definition_markdown=workflow_to_markdown(wf),
|
||
entry_node_id=str(entry_node["id"]),
|
||
stage_start_msg_index=msg_index,
|
||
)
|
||
text = build_activation_text(wsm=wsm, already=False)
|
||
return {"success": True, "already": False, "text": text, "manager": wsm}
|
||
|
||
|
||
def _ordered_nodes(nodes: List[Dict[str, Any]]) -> List[Dict[str, Any]]:
|
||
"""按流程拓扑顺序排列节点:从开始节点沿边 BFS,未连通的孤立节点排在最后(保持原相对顺序)。
|
||
|
||
定义文件里的 nodes 数组顺序是编辑器保存顺序,不代表流程顺序(用户可能先拖了结束节点),
|
||
直接遍历会让目录错乱(如「结束」排在阶段前面)。
|
||
"""
|
||
valid = [n for n in nodes if isinstance(n, dict) and n.get("id")]
|
||
by_id = {str(n.get("id")): n for n in valid}
|
||
|
||
def _targets(node: Dict[str, Any]) -> List[str]:
|
||
kind = node.get("kind")
|
||
if kind in ("start", "stage"):
|
||
nxt = node.get("next")
|
||
return [str(nxt)] if nxt else []
|
||
if kind == "review":
|
||
out = []
|
||
if node.get("next"):
|
||
out.append(str(node["next"]))
|
||
if node.get("rejectTo"):
|
||
out.append(str(node["rejectTo"]))
|
||
return out
|
||
if kind == "branch":
|
||
return [
|
||
str(r.get("target"))
|
||
for r in (node.get("next") or [])
|
||
if isinstance(r, dict) and r.get("target")
|
||
]
|
||
return []
|
||
|
||
ordered: List[Dict[str, Any]] = []
|
||
seen: set = set()
|
||
queue = [n for n in valid if n.get("kind") == "start"]
|
||
while queue:
|
||
node = queue.pop(0)
|
||
nid = str(node.get("id"))
|
||
if nid in seen:
|
||
continue
|
||
seen.add(nid)
|
||
ordered.append(node)
|
||
for target in _targets(node):
|
||
if target in by_id and target not in seen:
|
||
queue.append(by_id[target])
|
||
for n in valid:
|
||
if str(n.get("id")) not in seen:
|
||
ordered.append(n)
|
||
return ordered
|
||
|
||
|
||
def build_activation_text(*, wsm: WorkflowStateManager, already: bool = False) -> str:
|
||
"""激活上下文:全景目录 + 当前节点详情(工具返回 / REST 激活消息共用)。"""
|
||
definition = wsm.load_definition() or {}
|
||
current = wsm.get_node(wsm.get_current_node_id()) or {}
|
||
lines: List[str] = []
|
||
if already:
|
||
lines.append(f"【工作流已处于激活状态】{definition.get('name')}:{definition.get('description')}")
|
||
else:
|
||
lines.append(f"【工作流已激活】{definition.get('name')}:{definition.get('description')}")
|
||
lines.append("")
|
||
body = str(definition.get("body") or "").strip()
|
||
if body:
|
||
lines.append("【流程约定】")
|
||
lines.append(body)
|
||
lines.append("")
|
||
lines.append("【节点目录】")
|
||
for node in _ordered_nodes(definition.get("nodes") or []):
|
||
if not isinstance(node, dict):
|
||
continue
|
||
kind = node.get("kind")
|
||
label = _KIND_LABELS.get(kind, kind or "?")
|
||
desc = ""
|
||
if kind == "stage":
|
||
desc = str(node.get("goal") or "")
|
||
elif kind == "review":
|
||
desc = f"把关:{node.get('prompt') or '阶段产出审核'}"
|
||
elif kind == "branch":
|
||
desc = "按条件选择路径"
|
||
marker = " ← 当前" if node.get("id") == current.get("id") else ""
|
||
lines.append(f"- [{label}] {node.get('name')}:{desc}{marker}")
|
||
lines.append("")
|
||
lines.append(_current_node_brief(wsm=wsm, current=current))
|
||
return "\n".join(lines)
|
||
|
||
|
||
def _current_node_brief(*, wsm: WorkflowStateManager, current: Dict[str, Any]) -> str:
|
||
"""当前节点详情文本(激活/推进/状态查询共用)。"""
|
||
kind = current.get("kind")
|
||
name = current.get("name")
|
||
if kind == "stage":
|
||
lines = [f"【当前阶段】{name}", f"目标:{current.get('goal') or '(未填写)'}"]
|
||
instructions = str(current.get("instructions") or "").strip()
|
||
if instructions:
|
||
lines.append(f"要求:{instructions}")
|
||
lines.append("完成后调用 report_workflow_stage(summary) 汇报以推进流程。")
|
||
return "\n".join(lines)
|
||
if kind == "branch":
|
||
routes = current.get("next") or []
|
||
menu = "\n".join(
|
||
f"- {r.get('target')}({r.get('condition') or '无条件描述'})" for r in routes if isinstance(r, dict)
|
||
)
|
||
return (
|
||
f"【当前位于分支点】{name}\n请选择后续路径(调用 choose_workflow_branch(target_node_id)):\n{menu}"
|
||
)
|
||
return f"【当前位置】{name}"
|
||
|
||
|
||
# ---------------------------------------------------------------- system 段(不冻结,每次现生成)
|
||
|
||
WORKFLOW_SYSTEM_PREFIX = "【工作流进行中】"
|
||
|
||
|
||
def refresh_workflow_system_segment(messages, *, data_dir, conversation_id: Optional[str]) -> None:
|
||
"""阶段推进/退出后同步刷新 messages 里的工作流 system 段。
|
||
|
||
单任务内 messages 只在入口构建一次,阶段推进后后续迭代会看到滞后的当前位置;
|
||
推进工具(report/choose)执行成功后由工具循环层调用本函数刷新。
|
||
工作流已退出(新内容为空)时移除该段。
|
||
"""
|
||
if not isinstance(messages, list):
|
||
return
|
||
for idx, msg in enumerate(messages):
|
||
if not isinstance(msg, dict) or msg.get("role") != "system":
|
||
continue
|
||
content = str(msg.get("content") or "")
|
||
if not content.startswith(WORKFLOW_SYSTEM_PREFIX):
|
||
continue
|
||
try:
|
||
new_content = build_workflow_system_prompt(data_dir=data_dir, conversation_id=conversation_id)
|
||
except Exception:
|
||
return
|
||
if new_content:
|
||
msg["content"] = new_content
|
||
else:
|
||
messages.pop(idx)
|
||
return
|
||
|
||
|
||
def build_workflow_system_prompt(*, data_dir, conversation_id: Optional[str]) -> str:
|
||
"""工作流进行中的 system 上下文段。无激活工作流时返回空串(不注入)。"""
|
||
wsm = get_active_manager(data_dir, conversation_id)
|
||
if wsm is None:
|
||
return ""
|
||
definition = wsm.load_definition() or {}
|
||
current = wsm.get_node(wsm.get_current_node_id()) or {}
|
||
lines: List[str] = [
|
||
f"【工作流进行中】{definition.get('name')}:{definition.get('description')}",
|
||
"【节点目录】(完整定义见激活时的上下文;迷失时可调用 get_workflow_status 自查)",
|
||
]
|
||
for node in definition.get("nodes") or []:
|
||
if not isinstance(node, dict) or node.get("kind") in ("start", "end"):
|
||
continue
|
||
kind = node.get("kind")
|
||
label = _KIND_LABELS.get(kind, kind or "?")
|
||
if kind == "stage":
|
||
desc = str(node.get("goal") or "")
|
||
elif kind == "review":
|
||
desc = f"把关:{node.get('prompt') or '阶段产出审核'}"
|
||
else:
|
||
desc = "按条件选择路径"
|
||
marker = " ← 当前" if node.get("id") == current.get("id") else ""
|
||
lines.append(f"- [{label}] {node.get('name')}:{desc}{marker}")
|
||
lines.append("")
|
||
lines.append(_current_node_brief(wsm=wsm, current=current))
|
||
lines.append(
|
||
"工作流只是辅助流程:期间可以正常与用户讨论其他内容;"
|
||
"阶段完成必须显式调用 report_workflow_stage 汇报,不要在没有汇报的情况下宣称阶段完成。"
|
||
)
|
||
return "\n".join(lines)
|
||
|
||
|
||
# ---------------------------------------------------------------- 推进矩阵
|
||
|
||
|
||
async def handle_stage_report(
|
||
*,
|
||
web_terminal,
|
||
data_dir,
|
||
sender,
|
||
conversation_id: str,
|
||
summary: str,
|
||
) -> Dict[str, Any]:
|
||
"""report_workflow_stage 核心:当前必为 stage,按下一节点类型分派(定稿 §4.3)。"""
|
||
wsm = get_active_manager(data_dir, conversation_id)
|
||
if wsm is None:
|
||
return {
|
||
"success": False,
|
||
"error": "当前对话没有激活的工作流(可能已被退出)。如需重新开始,请调用 activate_workflow。",
|
||
}
|
||
current = wsm.get_node(wsm.get_current_node_id())
|
||
if not current:
|
||
return {"success": False, "error": "工作流状态异常:当前节点不存在于定义快照中。可调用 get_workflow_status 自查。"}
|
||
if current.get("kind") == "branch":
|
||
return {
|
||
"success": False,
|
||
"error": f"当前停在分支点「{current.get('name')}」,请先调用 choose_workflow_branch(target_node_id) 选择路径。",
|
||
}
|
||
if current.get("kind") != "stage":
|
||
return {"success": False, "error": f"当前不在执行阶段(位于「{current.get('name')}」),无法汇报阶段完成。"}
|
||
nxt = wsm.get_node(current.get("next"))
|
||
if not nxt:
|
||
return {"success": False, "error": "流程定义异常:当前阶段没有有效的后续节点。"}
|
||
stage_info = {
|
||
"node_id": current.get("id"),
|
||
"name": str(current.get("name") or ""),
|
||
"summary": str(summary or "").strip(),
|
||
"rounds": wsm.get_stage_rounds(),
|
||
}
|
||
text = await _arrive_at(
|
||
node=nxt,
|
||
wsm=wsm,
|
||
web_terminal=web_terminal,
|
||
sender=sender,
|
||
conversation_id=conversation_id,
|
||
stage_info=stage_info,
|
||
)
|
||
return {"success": True, "message": text}
|
||
|
||
|
||
async def handle_branch_choice(
|
||
*,
|
||
web_terminal,
|
||
data_dir,
|
||
sender,
|
||
conversation_id: str,
|
||
target_node_id: str,
|
||
) -> Dict[str, Any]:
|
||
"""choose_workflow_branch 核心:仅当前停在 branch 时可调,校验候选集后推进。"""
|
||
wsm = get_active_manager(data_dir, conversation_id)
|
||
if wsm is None:
|
||
return {"success": False, "error": "当前对话没有激活的工作流。"}
|
||
current = wsm.get_node(wsm.get_current_node_id())
|
||
if not current or current.get("kind") != "branch":
|
||
return {"success": False, "error": "当前不在分支点,无需选择路径。"}
|
||
target_node_id = str(target_node_id or "").strip()
|
||
routes = [r for r in (current.get("next") or []) if isinstance(r, dict)]
|
||
valid = next((r for r in routes if r.get("target") == target_node_id), None)
|
||
if valid is None:
|
||
menu = "、".join(str(r.get("target")) for r in routes)
|
||
return {"success": False, "error": f"「{target_node_id}」不在候选路径中。可选:{menu}"}
|
||
target = wsm.get_node(target_node_id)
|
||
if not target:
|
||
return {"success": False, "error": f"目标节点不存在:{target_node_id}"}
|
||
text = await _arrive_at(
|
||
node=target,
|
||
wsm=wsm,
|
||
web_terminal=web_terminal,
|
||
sender=sender,
|
||
conversation_id=conversation_id,
|
||
stage_info=None,
|
||
)
|
||
return {"success": True, "message": f"已选择路径:{valid.get('condition') or target.get('name')}\n\n{text}"}
|
||
|
||
|
||
async def _arrive_at(
|
||
*,
|
||
node: Dict[str, Any],
|
||
wsm: WorkflowStateManager,
|
||
web_terminal,
|
||
sender,
|
||
conversation_id: str,
|
||
stage_info: Optional[Dict[str, Any]],
|
||
) -> str:
|
||
"""到达节点的统一处理(递归穿透 review / 单出线 branch)。
|
||
|
||
stage_info 非空表示「刚汇报完成的 stage」——只在落地分支(stage/branch多/end)
|
||
记入 history;review 驳回不记录(阶段未完成)。
|
||
"""
|
||
kind = node.get("kind")
|
||
name = str(node.get("name") or node.get("id") or "")
|
||
|
||
if kind == "stage":
|
||
if stage_info:
|
||
wsm.record_stage_completion(summary=stage_info["summary"], rounds=stage_info["rounds"])
|
||
wsm.move_to(str(node["id"]), msg_index=_history_len(web_terminal))
|
||
emit_workflow_progress(wsm=wsm, sender=sender, conversation_id=conversation_id)
|
||
head = f"阶段「{stage_info['name']}」已记录完成。\n\n" if stage_info else ""
|
||
return head + _current_node_brief(wsm=wsm, current=node)
|
||
|
||
if kind == "end":
|
||
if stage_info:
|
||
wsm.record_stage_completion(summary=stage_info["summary"], rounds=stage_info["rounds"])
|
||
# 先广播「完成态」快照(前端播最后一行落定+「结束」行动画),再摘牌。
|
||
# 注意顺序不能反:deactivate 后 progress_snapshot 只剩 {"active": False},
|
||
# 前端会瞬间卸载窗口,完成动画与「结束」行都播不出来。
|
||
if callable(sender) and conversation_id:
|
||
snap = wsm.progress_snapshot()
|
||
snap.update({
|
||
"status": "completed",
|
||
"current": None,
|
||
"next": None,
|
||
"reviewing": False,
|
||
"footnote": {"kind": "success", "text": "工作流已完成"},
|
||
"event": "workflow_completed",
|
||
"conversation_id": conversation_id,
|
||
})
|
||
try:
|
||
sender("workflow_progress", snap)
|
||
except Exception:
|
||
pass
|
||
wsm.deactivate(status=STATUS_COMPLETED, reason=REASON_COMPLETED)
|
||
head = f"阶段「{stage_info['name']}」已记录完成。\n\n" if stage_info else ""
|
||
return head + f"工作流「{wsm.state.get('workflow_name')}」已到达终点「{name}」,全部完成。请向用户输出总结后结束。"
|
||
|
||
if kind == "branch":
|
||
routes = [r for r in (node.get("next") or []) if isinstance(r, dict) and r.get("target")]
|
||
if len(routes) <= 1:
|
||
# 并线器(单出线):自动穿过,不记完成、不停留
|
||
target = wsm.get_node(routes[0].get("target")) if routes else None
|
||
if not target:
|
||
return "流程定义异常:分支节点没有有效的出线。"
|
||
return await _arrive_at(
|
||
node=target, wsm=wsm, web_terminal=web_terminal, sender=sender,
|
||
conversation_id=conversation_id, stage_info=stage_info,
|
||
)
|
||
# AI 决策点(多出线):记完成 + 停留等选择
|
||
if stage_info:
|
||
wsm.record_stage_completion(summary=stage_info["summary"], rounds=stage_info["rounds"])
|
||
wsm.move_to(str(node["id"]), msg_index=_history_len(web_terminal))
|
||
emit_workflow_progress(wsm=wsm, sender=sender, conversation_id=conversation_id)
|
||
head = f"阶段「{stage_info['name']}」已记录完成。\n\n" if stage_info else ""
|
||
return head + _current_node_brief(wsm=wsm, current=node)
|
||
|
||
if kind == "review":
|
||
result = await run_stage_review(
|
||
web_terminal=web_terminal, wsm=wsm, sender=sender, conversation_id=conversation_id,
|
||
stage_info=stage_info, review_node=node,
|
||
)
|
||
wsm.record_review(
|
||
node_id=str(node.get("id")), name=name,
|
||
decision=str(result.get("decision") or ""), message=str(result.get("message") or ""),
|
||
)
|
||
if result.get("decision") == "pass":
|
||
nxt = wsm.get_node(node.get("next"))
|
||
if not nxt:
|
||
return f"审核「{name}」通过,但通过路由指向不存在的节点。流程定义异常,工作流无法继续。"
|
||
inner = await _arrive_at(
|
||
node=nxt, wsm=wsm, web_terminal=web_terminal, sender=sender,
|
||
conversation_id=conversation_id, stage_info=stage_info,
|
||
)
|
||
return f"审核「{name}」通过:{result.get('message')}\n\n{inner}"
|
||
# 驳回
|
||
count = wsm.increment_reject(str(node.get("id")))
|
||
max_rejects = int(node.get("maxRejects") or 3)
|
||
review_message = str(result.get("message") or "")
|
||
if count >= max_rejects:
|
||
wsm.deactivate(status=STATUS_FAILED, reason=REASON_MAX_REJECTS)
|
||
emit_workflow_progress(
|
||
wsm=wsm, sender=sender, conversation_id=conversation_id,
|
||
extra={"event": "workflow_failed", "reason": REASON_MAX_REJECTS},
|
||
)
|
||
return (
|
||
f"审核「{name}」未通过(第 {count} 次,已达连续驳回上限 {max_rejects}):{review_message}\n\n"
|
||
"工作流已连续驳回超限而终止(failed)。请告知用户审核意见与终止原因;"
|
||
"如需重新开始,可在调整流程或准备充分后重新激活。"
|
||
)
|
||
reject_target = wsm.get_node(node.get("rejectTo"))
|
||
if not reject_target:
|
||
return f"审核「{name}」未通过:{review_message}\n\n但驳回路由指向不存在的节点,流程定义异常,工作流无法继续。"
|
||
wsm.move_to(str(reject_target["id"]), msg_index=_history_len(web_terminal))
|
||
emit_workflow_progress(
|
||
wsm=wsm, sender=sender, conversation_id=conversation_id,
|
||
extra={"event": "workflow_rejected", "review_node": name, "reject_count": count},
|
||
)
|
||
return (
|
||
f"审核「{name}」未通过(第 {count}/{max_rejects} 次):{review_message}\n\n"
|
||
f"你已回到「{reject_target.get('name')}」。请按整改意见修改后,重新调用 report_workflow_stage 汇报。"
|
||
)
|
||
|
||
return f"流程定义异常:未知节点类型 {kind!r}(节点「{name}」)。"
|
||
|
||
|
||
# ---------------------------------------------------------------- 审核
|
||
|
||
|
||
def _summarize_tool_args(raw_args: Any) -> str:
|
||
"""工具参数摘要(取关键字段,截断控长)。"""
|
||
args = raw_args
|
||
if isinstance(raw_args, str):
|
||
try:
|
||
args = json.loads(raw_args)
|
||
except Exception:
|
||
args = {}
|
||
if not isinstance(args, dict):
|
||
return ""
|
||
for key in ("command", "path", "skill_name", "query", "file_path", "url", "task", "summary", "name"):
|
||
value = args.get(key)
|
||
if isinstance(value, str) and value.strip():
|
||
text = " ".join(value.split())
|
||
return text[:80] + ("…" if len(text) > 80 else "")
|
||
return ""
|
||
|
||
|
||
def build_stage_trace(web_terminal, start_index: int) -> str:
|
||
"""消息游标截取本阶段的工具调用时间线(审核 payload 的证据段)。"""
|
||
try:
|
||
history = web_terminal.context_manager.conversation_history or []
|
||
except Exception:
|
||
history = []
|
||
slice_ = history[start_index:] if 0 <= start_index < len(history) else []
|
||
lines: List[str] = []
|
||
for msg in slice_:
|
||
if not isinstance(msg, dict):
|
||
continue
|
||
role = msg.get("role")
|
||
if role == "assistant":
|
||
for call in msg.get("tool_calls") or []:
|
||
fn = (call or {}).get("function") or {}
|
||
fname = fn.get("name")
|
||
if not fname:
|
||
continue
|
||
summary = _summarize_tool_args(fn.get("arguments"))
|
||
lines.append(f"→ {fname}:{summary}" if summary else f"→ {fname}")
|
||
elif role == "tool":
|
||
content = " ".join(str(msg.get("content") or "").split())
|
||
if content:
|
||
lines.append(f" ↳ {content[:160]}{'…' if len(content) > 160 else ''}")
|
||
text = "\n".join(lines)
|
||
if len(text) > _STAGE_TRACE_MAX_CHARS:
|
||
text = "…(前段略)\n" + text[-_STAGE_TRACE_MAX_CHARS:]
|
||
return text or "(本阶段暂无工具调用记录)"
|
||
|
||
|
||
def build_review_payload(
|
||
*,
|
||
web_terminal,
|
||
wsm: WorkflowStateManager,
|
||
stage_info: Dict[str, Any],
|
||
review_node: Dict[str, Any],
|
||
) -> str:
|
||
"""审核 payload:工作流信息 + 审核关注点 + 阶段目标要求 + 执行痕迹 + 汇报 + 历史驳回。"""
|
||
definition = wsm.load_definition() or {}
|
||
stage_node = wsm.get_node(stage_info.get("node_id")) or {}
|
||
lines: List[str] = [
|
||
f"【工作流】{definition.get('name')}:{definition.get('description')}",
|
||
"",
|
||
f"【本次审核把关】{review_node.get('name')}",
|
||
f"审核关注点:{review_node.get('prompt') or '阶段产出是否达到进入下一阶段的门槛'}",
|
||
"",
|
||
f"【被审核阶段】{stage_info.get('name')}",
|
||
f"阶段目标:{stage_node.get('goal') or '(未填写)'}",
|
||
]
|
||
instructions = str(stage_node.get("instructions") or "").strip()
|
||
if instructions:
|
||
lines.append(f"阶段要求:{instructions}")
|
||
lines += [
|
||
"",
|
||
"【阶段执行痕迹】(本阶段内的工具调用时间线)",
|
||
build_stage_trace(web_terminal, wsm.get_stage_start_msg_index()),
|
||
"",
|
||
"【主执行模型阶段汇报】",
|
||
stage_info.get("summary") or "(无)",
|
||
]
|
||
rejects = [
|
||
h for h in (wsm.state.get("history") or [])
|
||
if isinstance(h, dict)
|
||
and h.get("kind") == "review"
|
||
and h.get("node_id") == review_node.get("id")
|
||
and h.get("decision") == "reject"
|
||
]
|
||
if rejects:
|
||
lines.append("")
|
||
lines.append("【历史审核意见】")
|
||
for idx, item in enumerate(rejects, start=1):
|
||
lines.append(f"第 {idx} 次驳回:{item.get('message') or ''}")
|
||
return "\n".join(lines)
|
||
|
||
|
||
async def run_stage_review(
|
||
*,
|
||
web_terminal,
|
||
wsm: WorkflowStateManager,
|
||
sender,
|
||
conversation_id: str,
|
||
stage_info: Dict[str, Any],
|
||
review_node: Dict[str, Any],
|
||
) -> Dict[str, Any]:
|
||
"""调审核智能体。异常兜底 = reject(定稿:视为驳回 + 请告知用户)。"""
|
||
review_name = str(review_node.get("name") or "")
|
||
if callable(sender):
|
||
try:
|
||
sender(
|
||
"workflow_review_progress",
|
||
{"conversation_id": conversation_id, "progress": {"stage": "start", "message": f"审核「{review_name}」开始"}},
|
||
)
|
||
except Exception:
|
||
pass
|
||
payload = build_review_payload(
|
||
web_terminal=web_terminal, wsm=wsm, stage_info=stage_info, review_node=review_node,
|
||
)
|
||
definition = wsm.load_definition() or {}
|
||
review_mode = definition.get("reviewMode") or "active"
|
||
|
||
def _progress(progress: Dict[str, Any]) -> None:
|
||
if callable(sender):
|
||
try:
|
||
sender("workflow_review_progress", {"conversation_id": conversation_id, "progress": progress})
|
||
except Exception:
|
||
pass
|
||
|
||
try:
|
||
agent = WorkflowReviewAgent(web_terminal=web_terminal)
|
||
result = await agent.review(payload_text=payload, review_mode=review_mode, progress_cb=_progress)
|
||
except Exception as exc:
|
||
result = {
|
||
"decision": "reject",
|
||
"message": (
|
||
f"审核智能体执行异常({exc})。本次按驳回处理:请告知用户审核服务可能异常;"
|
||
"若属偶发,可稍后重新汇报本阶段。"
|
||
),
|
||
"source": "workflow_review_agent",
|
||
}
|
||
if not isinstance(result, dict) or result.get("decision") not in ("pass", "reject"):
|
||
result = {
|
||
"decision": "reject",
|
||
"message": "审核未产出有效结论。本次按驳回处理:请告知用户审核服务可能异常;若属偶发,可稍后重新汇报本阶段。",
|
||
"source": "workflow_review_agent",
|
||
}
|
||
return result
|
||
|
||
|
||
# ---------------------------------------------------------------- 退出 / 通知文本
|
||
|
||
|
||
def deactivate_workflow(*, data_dir, conversation_id: str, reason: str, sender=None) -> Dict[str, Any]:
|
||
"""模型自主退出(deactivate_workflow 工具):摘牌,工具返回闭环,不发 user 通知。"""
|
||
if not conversation_id:
|
||
return {"success": False, "error": "当前没有打开的对话。"}
|
||
wsm = get_active_manager(data_dir, conversation_id)
|
||
if wsm is None:
|
||
return {"success": False, "error": "当前对话没有激活的工作流。"}
|
||
name = str(wsm.state.get("workflow_name") or "")
|
||
wsm.deactivate(status=STATUS_STOPPED, reason=REASON_MODEL)
|
||
# 摘牌后广播 {active: False} 快照(此时 progress_snapshot 只剩 active=False):
|
||
# 前端实时摘除快捷窗口卡片与 slash 菜单「进行中」状态,
|
||
# 否则只能等下次刷新/切换对话静态校正才消失。
|
||
emit_workflow_progress(wsm=wsm, sender=sender, conversation_id=conversation_id)
|
||
note = str(reason or "").strip()
|
||
return {
|
||
"success": True,
|
||
"message": f"工作流「{name}」已退出({note or '模型自主退出'})。工作流状态已摘牌,你可以继续自由工作。",
|
||
}
|
||
|
||
|
||
def deactivate_workflow_by_user(*, data_dir, conversation_id: str) -> Dict[str, Any]:
|
||
"""用户 slash 退出(REST):摘牌 + 柔性通知入池(忙时工具循环消费 / 闲时 REST 直发)。"""
|
||
wsm = get_active_manager(data_dir, conversation_id)
|
||
if wsm is None:
|
||
return {"success": False, "error": "当前对话没有激活的工作流。"}
|
||
name = str(wsm.state.get("workflow_name") or "")
|
||
wsm.deactivate(status=STATUS_STOPPED, reason=REASON_USER)
|
||
wsm.push_notice(
|
||
notice_type="deactivated_by_user",
|
||
message=(
|
||
f"用户已退出工作流「{name}」。工作流已摘牌,无需继续按流程推进;"
|
||
"你可以继续自由工作。若用户之后要求恢复,可重新激活。"
|
||
),
|
||
)
|
||
return {"success": True, "workflow_name": name}
|
||
|
||
|
||
def build_round_limit_notice(*, data_dir, conversation_id: str) -> Optional[str]:
|
||
"""max_stage_rounds 撞限通知文本(主循环层注入)。未撞限返回 None。"""
|
||
wsm = get_active_manager(data_dir, conversation_id)
|
||
if wsm is None or wsm.round_limit_notified():
|
||
return None
|
||
definition = wsm.load_definition() or {}
|
||
max_rounds = int(definition.get("maxStageRounds") or 20)
|
||
rounds = wsm.get_stage_rounds()
|
||
if rounds < max_rounds:
|
||
return None
|
||
wsm.mark_round_limit_notified()
|
||
current = wsm.get_node(wsm.get_current_node_id()) or {}
|
||
return (
|
||
f"工作流「{definition.get('name')}」的当前步骤「{current.get('name')}」已进行 {rounds} 轮,"
|
||
f"达到单步轮数上限({max_rounds})。请立刻停下当前工作,告知用户已超过 {max_rounds} 轮,"
|
||
"并询问是否还要继续。(工作流仍在进行中,等待用户决定;用户回复后可继续推进或退出。)"
|
||
)
|
||
|
||
|
||
# ---------------------------------------------------------------- 状态查询 / 进度事件
|
||
|
||
|
||
def build_status_text(*, data_dir, conversation_id: str) -> str:
|
||
"""get_workflow_status 工具返回文本。"""
|
||
wsm = get_active_manager(data_dir, conversation_id)
|
||
if wsm is None:
|
||
return "当前对话没有激活的工作流。"
|
||
definition = wsm.load_definition() or {}
|
||
current = wsm.get_node(wsm.get_current_node_id()) or {}
|
||
lines: List[str] = [
|
||
f"【工作流状态】{definition.get('name')}:{definition.get('description')}",
|
||
f"已进行时长:{int(time.time() - float(wsm.state.get('started_at') or time.time()))} 秒",
|
||
"",
|
||
"【已完成的步骤】",
|
||
]
|
||
stage_records = [h for h in (wsm.state.get("history") or []) if isinstance(h, dict) and h.get("kind") == "stage"]
|
||
if stage_records:
|
||
for item in stage_records:
|
||
lines.append(f"- {item.get('name')}({item.get('rounds') or 0} 轮):{(item.get('summary') or '')[:80]}")
|
||
else:
|
||
lines.append("(暂无)")
|
||
review_records = [h for h in (wsm.state.get("history") or []) if isinstance(h, dict) and h.get("kind") == "review"]
|
||
if review_records:
|
||
lines.append("")
|
||
lines.append("【审核记录】")
|
||
for item in review_records:
|
||
label = "通过" if item.get("decision") == "pass" else "驳回"
|
||
lines.append(f"- {item.get('name')}:{label} — {(item.get('message') or '')[:80]}")
|
||
lines.append("")
|
||
lines.append(_current_node_brief(wsm=wsm, current=current))
|
||
lines.append(f"当前步骤已进行轮数:{wsm.get_stage_rounds()}")
|
||
return "\n".join(lines)
|
||
|
||
|
||
def emit_workflow_progress(
|
||
*,
|
||
wsm: WorkflowStateManager,
|
||
sender,
|
||
conversation_id: Optional[str],
|
||
extra: Optional[Dict[str, Any]] = None,
|
||
) -> None:
|
||
"""广播工作流进度快照(sender → session_data → REST 轮询透传,对齐 goal 链路)。"""
|
||
if not callable(sender) or not conversation_id:
|
||
return
|
||
snap = wsm.progress_snapshot()
|
||
snap["conversation_id"] = conversation_id
|
||
if extra:
|
||
snap.update(extra)
|
||
try:
|
||
sender("workflow_progress", snap)
|
||
except Exception:
|
||
pass
|