#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。
359 lines
12 KiB
Python
359 lines
12 KiB
Python
"""双 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,
|
||
)
|