Files
qihuaiflow/backend/python/app/core/engine_event.py
T
QihuAI Design 33d0f9c32e feat(workflow #874): NodeRegistry 统一工作流节点抽象 + 双 Claw 事件对齐
#874 P3 工作流引擎 NodeRegistry 统一(加速带「工具即节点」思想落地,零源码拷贝重写):

- app/core/node_registry.py(新增):把 skill / MCP / Agent / DAG 四套割裂表达收敛为
  单一 Node 抽象。BaseNode(Protocol) 定义 node_type/name/input_schema/output_schema/
  risk_level + async run(ctx);NodeContext 携带 db/tenant_id/claw_type/trace_id/黑板/emit;
  NodeResult.to_blackboard() 与既有 orchestrator 黑板结构无缝互转。内建八型节点
  llm/reduce/local/external/agent/skill/mcp/loop,均继承 NodeBase 公共生命周期
  (发 start 步骤 -> 执行 -> ok/fail 覆盖同 index),异常统一归一为 NodeError(带结构化
  diagnosis)。SkillNode 用 asyncio.to_thread 包裹同步执行器避免阻塞事件循环;
  LoopNode 硬限流 20 次迭代 / 并发上限 5,子节点经 ctx.child() 复用同一 StepCounter 与
  emit,保证步骤序号全图唯一。CapabilitySnapshot 统一承载 skills/mcp_servers/agents/
  function_definitions/mcp_tool_map,供 _build_system_prompt、run_graph、yaml 校验三处
  共用同一份能力真相。normalize_claw() 将 im_wechat/im_platform 归一为 "im",避免
  Grafana 维度碎片化。

- app/core/capability_discovery.py:新增 discover_agents(),与 discover_skills 同构的
  三级降级策略(显式安装 -> published 兜底 -> rank_by_query 裁剪),补齐 Agent 维度的
  统一能力发现,杜绝各处内联第二份发现逻辑(#866 回退事故教训)。

- app/core/orchestrator.py:VALID_NODE_TYPES 与 validate_graph 改为运行期读
  registry.node_types()/requires_agent(),注册表成为节点类型的单一真相源;_run_node 改为
  拼装 NodeContext 后委托 registry.get(ntype).run(ctx),本体只保留调度与拓扑;run_graph
  新增 on_event/claw_type/tenant_id/snapshot 参数,编排全过程以统一 EngineEvent 下发。

- app/core/yaml_workflow.py:valid_node_types() 实时读注册表;validate_yaml 补 skill/mcp/
  loop 必填校验;yaml_to_orchestration / orchestration_to_yaml 双向透传节点扩展字段
  (label/skill_id/func_name/tool/server_id/args/body/items/split/max_iterations/
  concurrency/join),修复配置往返丢字段。

- app/core/engine_event.py:make_step 新增 node 参数,以 step["node"]={id,type,label}
  承载节点身份。设计书原文的 EngineEvent(kind="node") 与前端仅识别 thinking/skill/mcp/
  agent/kb/media 的现状冲突(未知 kind 会被静默丢帧),故映射为已知 kind + 附加字段,
  线格式对旧前端字节兼容。

- app/api/chat_service.py:Web Claw 编排调用点透传 on_event/claw_type/tenant_id,
  租户与通道标签复用 #875 的 ContextVar(get_route_ctx),不再新增一层参数透传。
  IM Claw 经 EngineCore 自动继承同一事件流(单引擎原则,im_llm.py 零改动)。

- tests/test_node_registry.py(新增):R-NODE-1~11 确定性哨兵,无网络无外部依赖。守卫
  内建八型齐全、注册表为单一真相源(orchestrator 与 yaml 校验实时同源)、describe 契约、
  双 Claw 事件对齐(SseAdapter 与 BufferAdapter 逐键相等且均带 node 身份)、claw 归一与
  tenant 标签、新节点必填校验、YAML 往返不吞字段、Loop 限流聚合、失败结构化诊断两端可见、
  禁止第二份能力发现、register 拒绝非法节点。

验证:容器内 py_compile 全通过;pytest tests/ 退出码 0(85 项 = 83 passed + 2 skipped,
0 FAILED/ERROR),含 R-NODE 16 项与 R-DUAL 双 Claw 哨兵。

safe-align: 仅复制 7 个 git-tracked 文件,未触碰 secrets/.venv/node_modules/dist/caches。
本地提交不 push。
2026-08-04 15:16:00 +08:00

359 lines
12 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.
"""双 Claw 引擎事件契约(#873 · P0)。
why:
平台有且只有两个 Claw 出口 —— Web Claw(SSE 逐帧) 与 IM Bot Claw(整条推送)。
在此之前,引擎事件是「散落在 chat_service 里的裸 dict」:
* 构造点分散,字段全靠记忆,漏一个键前端就静默不渲染;
* 没有 trace_id / tenant_id / claw 标签,可观测(#876)无处落脚;
* 失败只有一句人话字符串,前端靠 result.includes('失败') 猜错误 —— 极脆。
本模块把事件收敛为「唯一构造入口」:所有事件必须经 make_* 工厂产出,
再由 to_wire() 统一序列化。新增字段一律「非空才输出」,
保证既有前端(ChatPage.tsx)零改动。
线格式契约(authoritative —— 前端已消费,禁止破坏):
delta -> {"type":"delta", "content": str}
reasoning -> {"type":"reasoning", "content": str}
step -> {"type":"step", "step": {...}, "index"?: int}
done -> {"type":"done", "message_id", "conversation_id", "content",
"process_steps", "used_skills", "used_mcp", "generating"}
error -> {"type":"error", "message": str}
step 对象契约(AiProcessBlock 消费):
thinking -> {"type":"thinking", "text": str}
skill -> {"type":"skill", "name": str, "args": dict, "result": str}
mcp -> {"type":"mcp", "server": str, "name": str, "args": dict, "result": str}
附加(新增,前端可选消费): "diagnosis": {...}, "phase": "start|ok|fail",
"node": {"id","type","label"} # #874 节点身份
"""
from __future__ import annotations
import time
from dataclasses import dataclass, field
from typing import Any, Dict, Optional
__all__ = [
"SCHEMA_VERSION",
"DELTA", "REASONING", "STEP", "DONE", "ERROR", "EVENT_TYPES",
"STEP_THINKING", "STEP_SKILL", "STEP_MCP", "STEP_AGENT", "STEP_KB", "STEP_MEDIA",
"PHASE_START", "PHASE_OK", "PHASE_FAIL",
"CLAW_WEB", "CLAW_IM",
"SEV_INFO", "SEV_WARN", "SEV_ERROR",
"Diagnosis", "EngineEvent",
"make_delta", "make_reasoning", "make_step", "make_done", "make_error",
"from_wire",
]
SCHEMA_VERSION = 1
# ---- 事件类型(线格式 type 值,前端已硬编码,禁止改名)----
DELTA = "delta"
REASONING = "reasoning"
STEP = "step"
DONE = "done"
ERROR = "error"
EVENT_TYPES = frozenset({DELTA, REASONING, STEP, DONE, ERROR})
# ---- step 子类(前端按此选图标 / 渲染分支)----
STEP_THINKING = "thinking"
STEP_SKILL = "skill"
STEP_MCP = "mcp"
STEP_AGENT = "agent" # 新增:外部 Agent 调用
STEP_KB = "kb" # 新增:知识库检索
STEP_MEDIA = "media" # 新增:媒体生成
# ---- 执行阶段 ----
PHASE_START = "start"
PHASE_OK = "ok"
PHASE_FAIL = "fail"
# ---- Claw 通道标签(可观测 #876 的核心维度)----
CLAW_WEB = "web"
CLAW_IM = "im"
# ---- 诊断严重级 ----
SEV_INFO = "info"
SEV_WARN = "warn"
SEV_ERROR = "error"
_SEV_ICON = {SEV_INFO: "ℹ️", SEV_WARN: "⚠️", SEV_ERROR: "❌"}
@dataclass
class Diagnosis:
"""结构化诊断。
取代「把失败原因塞进 result 字符串、前端再 includes('失败') 猜」的旧做法:
* Web Claw 可据此渲染诊断卡片(图标/配色/重试按钮);
* IM Bot Claw 无卡片 UI,用 to_text() 降级为纯文本行。
"""
code: str = "" # 机器可读,如 "MCP_TIMEOUT" / "SKILL_NOT_FOUND"
severity: str = SEV_INFO # info | warn | error
summary: str = "" # 一句话结论(面向用户)
detail: str = "" # 细节(可含技术信息,前端折叠展示)
hint: str = "" # 可执行建议
retryable: bool = False # 是否值得重试
def to_dict(self) -> Dict[str, Any]:
return {
"code": self.code,
"severity": self.severity,
"summary": self.summary,
"detail": self.detail,
"hint": self.hint,
"retryable": self.retryable,
}
def to_text(self) -> str:
"""IM Bot Claw 的纯文本降级渲染。"""
icon = _SEV_ICON.get(self.severity, "•")
lines = [f"{icon} {self.summary or self.code or '执行提示'}"]
if self.detail:
lines.append(self.detail)
if self.hint:
lines.append(f"建议:{self.hint}")
return "\n".join(lines)
@classmethod
def from_dict(cls, d: Any) -> "Optional[Diagnosis]":
if isinstance(d, Diagnosis):
return d
if not isinstance(d, dict):
return None
return cls(
code=str(d.get("code") or ""),
severity=str(d.get("severity") or SEV_INFO),
summary=str(d.get("summary") or ""),
detail=str(d.get("detail") or ""),
hint=str(d.get("hint") or ""),
retryable=bool(d.get("retryable")),
)
@dataclass
class EngineEvent:
"""引擎事件的唯一表示。禁止在业务代码里手搓等价 dict —— 用 make_* 工厂。"""
type: str
content: str = ""
seq: int = 0
ts: float = field(default_factory=time.time)
trace_id: str = ""
tenant_id: str = ""
claw: str = ""
diagnosis: Optional[Diagnosis] = None
# extra 承载各事件类型的专属键(message_id / step / process_steps ...),
# 序列化时平铺到顶层,以保持与既有线格式逐字节兼容。
extra: Dict[str, Any] = field(default_factory=dict)
# -- 谓词 ---------------------------------------------------------
def is_terminal(self) -> bool:
return self.type in (DONE, ERROR)
def is_text_chunk(self) -> bool:
return self.type in (DELTA, REASONING)
# -- 序列化 -------------------------------------------------------
def to_wire(self) -> Dict[str, Any]:
"""序列化为通道线格式。
兼容铁律:既有键原样输出;新增键(seq/ts/claw/trace_id/tenant_id/diagnosis)
一律「非空才输出」,既不撑大帧体积,也不触发前端未知分支。
"""
wire: Dict[str, Any] = {"type": self.type}
if self.type in (DELTA, REASONING):
wire["content"] = self.content
elif self.content:
wire["content"] = self.content
for key, val in self.extra.items():
wire[key] = val
if self.seq:
wire["seq"] = self.seq
if self.claw:
wire["claw"] = self.claw
if self.trace_id:
wire["trace_id"] = self.trace_id
if self.tenant_id:
wire["tenant_id"] = self.tenant_id
if self.diagnosis is not None:
wire["diagnosis"] = self.diagnosis.to_dict()
return wire
def to_text(self) -> str:
"""无结构 UI 时(IM Bot)的降级文本;返回空串表示「本事件无需展示」。"""
if self.type in (DELTA, DONE):
return self.content
if self.type == ERROR:
if self.diagnosis is not None:
return self.diagnosis.to_text()
return f"❌ {self.extra.get('message') or self.content or '执行失败'}"
if self.type == STEP and self.diagnosis is not None:
return self.diagnosis.to_text()
return ""
# =====================================================================
# 工厂 —— 唯一构造入口
# =====================================================================
def _ctx(kw: Dict[str, Any]) -> Dict[str, Any]:
"""抽出通用上下文字段,避免每个工厂重复列参数。"""
return {
"seq": int(kw.pop("seq", 0) or 0),
"trace_id": str(kw.pop("trace_id", "") or ""),
"tenant_id": str(kw.pop("tenant_id", "") or ""),
"claw": str(kw.pop("claw", "") or ""),
}
def make_delta(content: str, **kw) -> EngineEvent:
"""正文增量。"""
return EngineEvent(type=DELTA, content=content or "", **_ctx(kw))
def make_reasoning(content: str, **kw) -> EngineEvent:
"""思维链增量。"""
return EngineEvent(type=REASONING, content=content or "", **_ctx(kw))
def make_step(
kind: str,
*,
name: str = "",
text: str = "",
server: str = "",
args: Optional[Dict[str, Any]] = None,
result: str = "",
phase: str = "",
index: Optional[int] = None,
diagnosis: Any = None,
node: Optional[Dict[str, Any]] = None,
**kw,
) -> EngineEvent:
"""过程步骤(思考 / 技能 / MCP / Agent / KB / 媒体)。
index 用于「原地更新同一步骤」:先发 phase=start 占位,
执行完再用同一 index 发 phase=ok|fail 覆盖,前端即得到实时进度。
node(#874)承载图节点身份 {id, type, label}。设计书原文写的是
`EngineEvent(kind="node")`,但前端只认既有 step 类型,发未知 type 会静默丢帧;
故 kind 仍取节点映射后的已知类型,节点身份改由本字段附加 —— 非空才输出,
老前端忽略,新前端可据此渲染 DAG 节点进度。
"""
step: Dict[str, Any] = {"type": kind}
if kind == STEP_THINKING:
step["text"] = text or name or ""
else:
if name:
step["name"] = name
if server:
step["server"] = server
if text:
step["text"] = text
step["args"] = args or {}
step["result"] = result or ""
if phase:
step["phase"] = phase
if isinstance(node, dict) and node:
step["node"] = node
diag = Diagnosis.from_dict(diagnosis) if diagnosis is not None else None
if diag is not None:
step["diagnosis"] = diag.to_dict()
# result 为空时前端会判定为「运行中」,故失败必须给出可读 result 兜底
if not step["result"] and diag.severity == SEV_ERROR:
step["result"] = diag.to_text()
extra: Dict[str, Any] = {"step": step}
if isinstance(index, int) and index >= 0:
extra["index"] = index
return EngineEvent(type=STEP, diagnosis=diag, extra=extra, **_ctx(kw))
def make_done(
*,
message_id: Any = None,
conversation_id: Any = None,
content: str = "",
process_steps: Optional[list] = None,
used_skills: Optional[list] = None,
used_mcp: Optional[list] = None,
generating: bool = False,
**kw,
) -> EngineEvent:
"""终帧。字段集必须与既有 done 帧完全一致。"""
extra = {
"message_id": message_id,
"conversation_id": conversation_id,
"content": content or "",
"process_steps": process_steps or [],
"used_skills": used_skills or [],
"used_mcp": used_mcp or [],
"generating": bool(generating),
}
return EngineEvent(type=DONE, content=content or "", extra=extra, **_ctx(kw))
def make_error(message: str, *, diagnosis: Any = None, **kw) -> EngineEvent:
"""错误终帧。"""
diag = Diagnosis.from_dict(diagnosis) if diagnosis is not None else None
return EngineEvent(
type=ERROR,
diagnosis=diag,
extra={"message": message or ""},
**_ctx(kw),
)
def from_wire(payload: Any) -> Optional[EngineEvent]:
"""把遗留裸 dict 提升为 EngineEvent(迁移期双向兼容的关键)。
chat_service.py 里仍有直接 await on_event({...}) 的老代码,
Adapter 收到后一律经此函数归一化,之后全链路只面对 EngineEvent。
"""
if isinstance(payload, EngineEvent):
return payload
if not isinstance(payload, dict):
return None
d = dict(payload)
etype = str(d.pop("type", "") or "")
if not etype:
return None
content = d.pop("content", "")
if not isinstance(content, str):
# done 帧的 content 理论上是 str;异常类型保留原值到 extra,避免丢数据
d["content"] = content
content = ""
diag = Diagnosis.from_dict(d.pop("diagnosis", None))
seq = d.pop("seq", 0)
trace_id = d.pop("trace_id", "")
tenant_id = d.pop("tenant_id", "")
claw = d.pop("claw", "")
d.pop("ts", None)
# done 帧的 content 需回填到 extra,才能在 to_wire() 时原样吐回
if etype == DONE:
d["content"] = content
return EngineEvent(
type=etype,
content=content,
seq=int(seq or 0),
trace_id=str(trace_id or ""),
tenant_id=str(tenant_id or ""),
claw=str(claw or ""),
diagnosis=diag,
extra=d,
)