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。
This commit is contained in:
QihuAI Design committed 2026-08-04 15:16:00 +08:00
1 parent 04a0641202
commit 33d0f9c32e
7 files changed
+1703 -57

No files matched your search

+370
View File
@@ -0,0 +1,370 @@
"""NodeRegistry 回归哨兵 —— R-NODE-*(设计书 leagent_accel_belt_design.md §B,任务 #874)。
守护两条硬约束:
1. **单一真相源**:节点类型只有 NodeRegistry 一份。任何人把
orchestrator / yaml_workflow 的类型集合改回硬编码,这里立刻变红 ——
历史上「画布能跑、YAML 报非法」的分裂 bug 正是两份清单各写各的。
2. **双 Claw 对齐**:同一张图在 Web Claw(SseAdapter) 与 IM Claw(BufferAdapter)
上必须产出**逐键相同**的过程事件,且事件带 tenant_id + 归一后的 claw
(im_wechat → im,否则 Grafana 维度碎成多片)。
全部用例确定性、无网络、无 DB:节点执行被替换为测试节点,SessionLocal 被 mock。
"""
import asyncio
import os
import sys
import pytest
_HERE = os.path.dirname(os.path.abspath(__file__))
sys.path.insert(0, os.path.join(_HERE, ".."))
from app.core import orchestrator as orch # noqa: E402
from app.core import yaml_workflow as ywf # noqa: E402
from app.core.claw_adapters import BufferAdapter, SseAdapter # noqa: E402
from app.core.engine_event import SEV_ERROR # noqa: E402
from app.core.node_registry import ( # noqa: E402
NODE_AGENT,
NODE_EXTERNAL,
NODE_LLM,
NODE_LOCAL,
NODE_LOOP,
NODE_MCP,
NODE_REDUCE,
NODE_SKILL,
NodeBase,
NodeContext,
NodeError,
NodeResult,
StepCounter,
normalize_claw,
registry,
)
BUILTIN_TYPES = {
NODE_LLM, NODE_REDUCE, NODE_LOCAL, NODE_EXTERNAL,
NODE_SKILL, NODE_MCP, NODE_AGENT, NODE_LOOP,
}
TENANT = "t-node-874"
TRACE = "trace-node-874"
# ─────────────────────────────────────────────────────────────────────────────
# 测试替身
# ─────────────────────────────────────────────────────────────────────────────
class EchoNode(NodeBase):
"""确定性节点:回显节点输入,不碰 DB / 网络。"""
node_type = "test_echo"
name = "Echo(test)"
async def _execute(self, ctx: NodeContext) -> NodeResult:
return NodeResult(output=f"echo<{ctx.node_id}>:{ctx.node_input()[:20]}")
class BoomNode(NodeBase):
"""确定性失败节点:验证 phase=fail + 结构化诊断在两端都可见。"""
node_type = "test_boom"
name = "Boom(test)"
async def _execute(self, ctx: NodeContext) -> NodeResult:
raise RuntimeError("boom for test")
class _DummySession:
def __enter__(self):
return self
def __exit__(self, *a):
return False
def close(self):
pass
@pytest.fixture(autouse=True)
def _test_nodes(monkeypatch):
"""注册测试节点 + 屏蔽真实 DB 会话;用完清理,避免污染注册表单例。"""
registry.register(EchoNode, override=True)
registry.register(BoomNode, override=True)
monkeypatch.setattr("app.database.SessionLocal", lambda: _DummySession())
yield
registry._nodes.pop("test_echo", None)
registry._nodes.pop("test_boom", None)
def _graph(ntype: str = "test_echo") -> dict:
return {
"nodes": [
{"id": "a", "type": ntype, "label": "节点A"},
{"id": "b", "type": ntype, "label": "节点B"},
],
"edges": [{"from": "a", "to": "b"}],
"output_node": "b",
}
def _fold_sse(adapter: SseAdapter) -> dict:
"""把 SSE 帧按 index 折叠成最终态,口径与 BufferAdapter 的原地覆盖一致。"""
out: dict = {}
while not adapter.queue.empty():
wire = adapter.queue.get_nowait()
if wire.get("type") != "step":
continue
out[wire.get("index")] = wire["step"]
return out
def _fold_buffer(adapter: BufferAdapter) -> dict:
return {i: s for i, s in enumerate(adapter.steps)}
# ─────────────────────────────────────────────────────────────────────────────
# R-NODE-1:内建节点齐全
# ─────────────────────────────────────────────────────────────────────────────
def test_rnode1_builtin_types_registered():
types = set(registry.node_types())
missing = BUILTIN_TYPES - types
assert not missing, f"内建节点缺失: {missing}"
for t in BUILTIN_TYPES:
impl = registry.get(t)
assert impl is not None and callable(getattr(impl, "run", None))
# ─────────────────────────────────────────────────────────────────────────────
# R-NODE-2:单一真相源 —— 校验器必须实时读注册表
# ─────────────────────────────────────────────────────────────────────────────
def test_rnode2_single_source_of_truth():
"""orchestrator 与 yaml_workflow 的合法类型集合必须来自 registry。
测试节点是运行期注册的:若任一侧改回硬编码常量,它就不会出现在集合里。
"""
reg = set(registry.node_types())
assert "test_echo" in reg
assert set(ywf.valid_node_types()) == reg, "yaml_workflow 未实时读注册表"
# orchestrator 侧:运行期注册的类型必须直接通过图校验
orch.validate_graph(_graph())
# 反向:未注册类型必须被两侧同时拒绝
bad = {"nodes": [{"id": "x", "type": "nope_not_registered"}], "edges": []}
with pytest.raises(orch.OrchestrationError):
orch.validate_graph(bad)
res = ywf.validate_yaml({"version": ywf.SPEC_VERSION, "nodes": [{"id": "x", "type": "nope_not_registered"}]})
assert not res["ok"]
# ─────────────────────────────────────────────────────────────────────────────
# R-NODE-3:节点元信息契约(画布表单依赖)
# ─────────────────────────────────────────────────────────────────────────────
def test_rnode3_describe_contract():
for d in registry.describe():
assert d["node_type"]
assert d["name"]
assert d["risk_level"] in ("low", "medium", "high"), d
assert isinstance(d["input_schema"], dict)
assert isinstance(d["output_schema"], dict)
# ─────────────────────────────────────────────────────────────────────────────
# R-NODE-4:双 Claw 对齐(核心哨兵)
# ─────────────────────────────────────────────────────────────────────────────
def test_rnode4_dual_claw_event_alignment():
"""同一张图在 Web / IM 两端产出逐键相同的过程事件。
这是 #874 的验收门禁:Web 画布与 IM `/flow` 共用 run_graph,
若谁给某一端加了「专属分支」,两端事件立刻不等,此处变红。
"""
async def _run(adapter, claw_type):
return await orch.run_graph(
_graph(), "用户问题", None, "u1", None, TRACE,
on_event=adapter, claw_type=claw_type, tenant_id=TENANT,
)
sse = SseAdapter(wire_meta=True, trace_id=TRACE, tenant_id=TENANT)
buf = BufferAdapter(trace_id=TRACE, tenant_id=TENANT)
ans_web, trace_web = asyncio.run(_run(sse, "web"))
ans_im, trace_im = asyncio.run(_run(buf, "im_wechat"))
assert ans_web == ans_im, "同一张图两端答案不一致"
assert [n["id"] for n in trace_web["nodes"]] == [n["id"] for n in trace_im["nodes"]]
web_steps, im_steps = _fold_sse(sse), _fold_buffer(buf)
assert web_steps, "Web Claw 未收到任何节点事件"
assert web_steps == im_steps, (
"双 Claw 事件不一致(有人给某一端加了专属逻辑):\n"
f"web={web_steps}\nim={im_steps}"
)
# 节点身份必须随事件下发,否则前端画不出 DAG 进度
node_steps = [s for s in web_steps.values() if "node" in s]
assert node_steps, "节点事件缺少 node 身份字段"
assert {s["node"]["id"] for s in node_steps} == {"a", "b"}
assert all(s["node"]["type"] == "test_echo" for s in node_steps)
# ─────────────────────────────────────────────────────────────────────────────
# R-NODE-5:claw 维度归一 + 租户标签(#876 看板依赖)
# ─────────────────────────────────────────────────────────────────────────────
def test_rnode5_claw_normalized_and_tenant_tagged():
assert normalize_claw("web") == "web"
assert normalize_claw("im") == "im"
assert normalize_claw("im_wechat") == "im", "IM 变体未归一 → Grafana 维度会碎片化"
assert normalize_claw("im_platform") == "im"
assert normalize_claw("") == ""
seen = []
async def collector(ev):
seen.append(ev)
asyncio.run(orch.run_graph(
_graph(), "q", None, "u1", None, TRACE,
on_event=collector, claw_type="im_wechat", tenant_id=TENANT,
))
assert seen, "未发出任何引擎事件"
for ev in seen:
assert ev.tenant_id == TENANT, f"事件缺 tenant_id: {ev}"
assert ev.claw == "im", f"claw 未归一: {ev.claw}"
assert ev.trace_id == TRACE
# ─────────────────────────────────────────────────────────────────────────────
# R-NODE-6:新节点类型的必填字段校验(两侧一致)
# ─────────────────────────────────────────────────────────────────────────────
@pytest.mark.parametrize("node,keyword", [
({"id": "n", "type": NODE_SKILL}, "skill_id"),
({"id": "n", "type": NODE_MCP}, "tool"),
({"id": "n", "type": NODE_AGENT}, "agent_id"),
({"id": "n", "type": NODE_LOOP}, "body"),
({"id": "n", "type": NODE_LOOP, "body": {"type": NODE_LOOP}}, "body"),
])
def test_rnode6_required_fields(node, keyword):
with pytest.raises(orch.OrchestrationError) as ei:
orch.validate_graph({"nodes": [node], "edges": []})
assert keyword in str(ei.value)
res = ywf.validate_yaml({"version": ywf.SPEC_VERSION, "nodes": [node]})
assert not res["ok"] and any(keyword in e for e in res["errors"])
def test_rnode6b_valid_new_nodes_pass():
ok_nodes = [
{"id": "s", "type": NODE_SKILL, "skill_id": "sk-1"},
{"id": "m", "type": NODE_MCP, "server_id": "srv-1", "tool": "search"},
{"id": "g", "type": NODE_AGENT, "agent_id": "ag-1"},
{"id": "l", "type": NODE_LOOP, "body": {"type": NODE_LLM}},
]
orch.validate_graph({"nodes": ok_nodes, "edges": []})
res = ywf.validate_yaml({"version": ywf.SPEC_VERSION, "nodes": ok_nodes})
assert res["ok"], res["errors"]
# ─────────────────────────────────────────────────────────────────────────────
# R-NODE-7:YAML ↔ JSON 往返不得吞掉新节点字段
# ─────────────────────────────────────────────────────────────────────────────
def test_rnode7_yaml_roundtrip_preserves_fields():
src = {
"nodes": [
{"id": "s", "type": NODE_SKILL, "skill_id": "sk-1", "args": {"q": "x"}},
{"id": "m", "type": NODE_MCP, "server_id": "srv-1", "tool": "search"},
{"id": "l", "type": NODE_LOOP, "body": {"type": NODE_LLM},
"max_iterations": 3, "join": "concat"},
],
"edges": [{"from": "s", "to": "m"}],
"output_node": "m",
}
back = ywf.yaml_to_orchestration(ywf.orchestration_to_yaml(src))
got = {n["id"]: n for n in back["nodes"]}
assert got["s"]["skill_id"] == "sk-1"
assert got["s"]["args"] == {"q": "x"}
assert got["m"]["tool"] == "search" and got["m"]["server_id"] == "srv-1"
assert got["l"]["body"] == {"type": NODE_LLM}
assert got["l"]["max_iterations"] == 3 and got["l"]["join"] == "concat"
# ─────────────────────────────────────────────────────────────────────────────
# R-NODE-8:LoopNode 限流与聚合
# ─────────────────────────────────────────────────────────────────────────────
def test_rnode8_loop_respects_limit_and_joins():
node = {
"id": "loop1", "type": NODE_LOOP,
"body": {"type": "test_echo"},
"items": [f"item{i}" for i in range(50)],
"max_iterations": 3,
}
ctx = NodeContext(
db=None, node=node, query="q", upstream="", is_entry=True,
tenant_id=TENANT, claw_type="web", counter=StepCounter(),
)
res = asyncio.run(registry.get(NODE_LOOP).run(ctx))
assert res.meta["iterations"] == 3, "max_iterations 未生效 —— 图里写个大数就能打满机器"
assert res.meta["failed"] == 0
assert res.output.count("echo<") == 3
# ─────────────────────────────────────────────────────────────────────────────
# R-NODE-9:失败必须带结构化诊断,且两端都看得见
# ─────────────────────────────────────────────────────────────────────────────
def test_rnode9_failure_diagnosis_visible_on_both_claws():
graph = {"nodes": [{"id": "a", "type": "test_boom", "label": "会炸的节点"}], "edges": []}
async def _run(adapter, claw_type):
return await orch.run_graph(
graph, "q", None, "u1", None, TRACE,
on_event=adapter, claw_type=claw_type, tenant_id=TENANT,
)
sse = SseAdapter(wire_meta=True, trace_id=TRACE, tenant_id=TENANT)
buf = BufferAdapter(trace_id=TRACE, tenant_id=TENANT)
answer_web, _ = asyncio.run(_run(sse, "web"))
answer_im, _ = asyncio.run(_run(buf, "im_wechat"))
assert "失败" in answer_web and answer_web == answer_im
web_steps, im_steps = _fold_sse(sse), _fold_buffer(buf)
assert web_steps == im_steps, "失败路径上双 Claw 事件不一致"
diags = [s["diagnosis"] for s in web_steps.values() if s.get("diagnosis")]
assert diags, "节点失败未产出结构化诊断(前端只能靠字符串猜错误)"
d = diags[0]
assert d["severity"] == SEV_ERROR and d["code"] and d["summary"]
fail_steps = [s for s in web_steps.values() if s.get("phase") == "fail"]
assert fail_steps and fail_steps[0]["result"], "失败步骤 result 为空会被前端渲染成「运行中」"
# ─────────────────────────────────────────────────────────────────────────────
# R-NODE-10:禁止第二份能力发现实现(#866 事故的精确回归签名)
# ─────────────────────────────────────────────────────────────────────────────
def test_rnode10_no_second_capability_discovery():
path = os.path.join(_HERE, "..", "app", "core", "node_registry.py")
with open(path, "r", encoding="utf-8") as f:
src = f.read()
assert "capability_discovery" in src, "注册表必须走统一能力发现层"
for banned in ('MarketApp.status == "published"', "AppInstallation", "_list_enabled_mcp_servers"):
assert banned not in src, (
f"node_registry 内出现第二份能力发现逻辑({banned});"
"能力发现只允许 capability_discovery 一份实现"
)
def test_rnode11_registry_rejects_invalid_node():
class NoType:
node_type = ""
class NoRun:
node_type = "x_no_run"
with pytest.raises(ValueError):
registry.register(NoType)
with pytest.raises(ValueError):
registry.register(NoRun)