agent-Specialization/server/workflow_flow.py
JOJO f43b03484d refactor(runtime): 拆除 session_data 兼容桥,任务上下文三层结构化直传
- TaskRecord 去 session_data dict,直存 principal/task_params/directives
  + 可变 goal_progress 字段(__slots__)
- create_chat_task 签名改为收 RuntimeContext 必填,TaskRecord.__init__
  同步精简(身份/消息/模式等全部从 ctx 派生)
- _run_chat_task 身份还原/门闸认领/事件注入/terminal 属性设置
  全部改为三层属性访问;goal_progress 回写改独立可变字段
- RuntimeService.create_task 原样透传 ctx;to_session_data() 删除
- task_public_payload / api.py 单任务载荷改三层读取
- 契约/注释/i18n 文案同步去 session_data 化;测试重写为三层断言

验证:75 测试全量回归,失败恰为 4 项存量(host_workspace_manager /
skills_manager / token_usage_extractor / conversation_workspace_storage)
2026-09-08 02:08:33 +08:00

752 lines
31 KiB
Python
Raw 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运行时编排定稿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,
)
from modules.i18n import tr
_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": tr("workflow_flow.missing_workflow_name")}
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": tr("workflow_flow.already_active_other", name=current_name),
}
try:
wf = load_workflow(name, data_dir)
except FileNotFoundError:
return {"success": False, "error": tr("workflow_flow.workflow_not_found", name=name)}
except ValueError as exc:
return {"success": False, "error": str(exc)}
errors = validate_structure(wf)
if errors:
return {"success": False, "error": tr("workflow_flow.structure_invalid", errors="".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": tr("workflow_flow.missing_entry_node")}
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": tr("workflow_flow.no_active_workflow_hint"),
}
current = wsm.get_node(wsm.get_current_node_id())
if not current:
return {"success": False, "error": tr("workflow_flow.state_node_missing")}
if current.get("kind") == "branch":
return {
"success": False,
"error": tr("workflow_flow.at_branch_need_choose", name=current.get('name')),
}
if current.get("kind") != "stage":
return {"success": False, "error": tr("workflow_flow.not_in_stage", name=current.get('name'))}
nxt = wsm.get_node(current.get("next"))
if not nxt:
return {"success": False, "error": tr("workflow_flow.stage_no_next")}
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": tr("workflow_flow.no_active_workflow")}
current = wsm.get_node(wsm.get_current_node_id())
if not current or current.get("kind") != "branch":
return {"success": False, "error": tr("workflow_flow.not_at_branch")}
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": tr("workflow_flow.target_not_in_routes", target=target_node_id, menu=menu)}
target = wsm.get_node(target_node_id)
if not target:
return {"success": False, "error": tr("workflow_flow.target_node_not_found", target=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": tr("workflow_flow.branch_selected", route=valid.get('condition') or target.get('name'), text=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
记入 historyreview 驳回不记录(阶段未完成)。
"""
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 = (tr("workflow_flow.stage_recorded", name=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": tr("workflow_flow.completed_footnote")},
"event": "workflow_completed",
"conversation_id": conversation_id,
})
try:
sender("workflow_progress", snap)
except Exception:
pass
wsm.deactivate(status=STATUS_COMPLETED, reason=REASON_COMPLETED)
head = (tr("workflow_flow.stage_recorded", name=stage_info['name']) + "\n\n") if stage_info else ""
return head + tr("workflow_flow.reached_end", name=wsm.state.get('workflow_name'), end=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 tr("workflow_flow.branch_no_route")
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 = (tr("workflow_flow.stage_recorded", name=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 tr("workflow_flow.review_pass_no_next", name=name)
inner = await _arrive_at(
node=nxt, wsm=wsm, web_terminal=web_terminal, sender=sender,
conversation_id=conversation_id, stage_info=stage_info,
)
return tr("workflow_flow.review_pass", name=name, message=result.get('message'), inner=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 tr(
"workflow_flow.review_max_rejects",
name=name, count=count, max=max_rejects, message=review_message,
)
reject_target = wsm.get_node(node.get("rejectTo"))
if not reject_target:
return tr("workflow_flow.review_reject_to_missing", name=name, message=review_message)
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 tr(
"workflow_flow.review_rejected",
name=name, count=count, max=max_rejects, message=review_message,
target=reject_target.get('name'),
)
return tr("workflow_flow.unknown_node_kind", kind=repr(kind), name=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": tr("workflow_flow.review_start_event", name=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": tr("workflow_flow.review_exec_exception", exc=exc),
"source": "workflow_review_agent",
}
if not isinstance(result, dict) or result.get("decision") not in ("pass", "reject"):
result = {
"decision": "reject",
"message": tr("workflow_flow.review_no_conclusion"),
"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": tr("workflow_flow.no_open_conversation")}
wsm = get_active_manager(data_dir, conversation_id)
if wsm is None:
return {"success": False, "error": tr("workflow_flow.no_active_workflow")}
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()
note_label = note or tr("workflow_flow.model_auto_exited")
return {
"success": True,
"message": tr("workflow_flow.deactivated_model", name=name, note=note_label),
}
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": tr("workflow_flow.no_active_workflow")}
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=tr("workflow_flow.deactivated_by_user_notice", name=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 tr(
"workflow_flow.round_limit_reached",
name=definition.get('name'), step=current.get('name'),
rounds=rounds, 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 tr("workflow_flow.no_active_workflow")
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 → 任务事件流 → 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