Files
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

371 lines
17 KiB
Python
Raw Permalink 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.
"""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)