#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。
371 lines
17 KiB
Python
371 lines
17 KiB
Python
"""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)
|