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

1007 lines
37 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.
"""app/core/node_registry.py — #874「工具即节点」统一注册表(双 Claw 共用 · 单一实现)。
why
---
改造前,平台里「一个能力」有三套互不相识的表达:
1. function calling —— `_build_system_prompt()` 把 skill 拼成 function_definitions;
2. MCP 工具 —— `build_mcp_tool_defs()` 另拼一套 `mcp__srv__tool__idx`;
3. DAG 编排 —— `orchestrator.VALID_NODE_TYPES = (llm, local, external, reduce)`,
skill / MCP 根本无法作为图节点出现。
结果:画布里画不出「调某个技能」,YAML 工作流校验和函数调用各说各话,
IM Claw 想跑编排还得再写一份 prompt 拼装。三套表达 = 三份 bug。
本模块把「能力」收敛为唯一抽象 —— **节点(Node)**:
BaseNode(Protocol): node_type / name / input_schema / output_schema / risk_level
+ async run(ctx: NodeContext) -> NodeResult
同一个注册表同时供给三个消费方(设计书 §B 验收口径):
① 函数调用 discover() → CapabilitySnapshot.function_definitions
② DAG 编排 orchestrator.run_graph → registry.get(node_type).run(ctx)
③ YAML 校验 yaml_workflow.VALID_NODE_TYPES → registry.node_types()
单引擎原则
---------
本模块 **不实现任何业务逻辑**,只做「分派 + 事件发射」。真正干活的原语一律复用:
LLM -> orchestrator._llm_complete
local 智能体 -> orchestrator._run_local_agent_turn
external -> chat_agents._call_external_agent
skill -> chat_skill._execute_skill_tool
mcp -> mcp_client.call_mcp_tool
对 orchestrator 的引用全部为函数内延迟导入 —— orchestrator 反向 import 本模块,
模块级互引会死锁在 import 阶段。
双 Claw 对齐
-----------
节点过程通过 `NodeContext.emit_*` 发射 **统一 EngineEvent**,Web Claw(SseAdapter)
与 IM Claw(BufferAdapter) 吃同一份事件流,编排过程在两端天然对齐,IM 侧零特殊逻辑。
线格式取舍(重要):设计书写的是 `EngineEvent(kind="node")`,但前端 AiProcessBlock
只认 thinking/skill/mcp/agent/kb/media,发未知 type 会静默丢帧。故这里做**语义映射**:
节点按自身性质映射到前端已识别的 step 类型(SkillNode→skill、McpToolNode→mcp、
AgentNode→agent、编排类→thinking),节点身份则通过 `step["node"]={id,type,label}`
附加字段承载 —— 新增键非空才输出,老前端忽略,新前端可据此画节点进度条。
"""
from __future__ import annotations
import asyncio
import inspect
import json
import logging
import time
from dataclasses import dataclass, field
from typing import Any, Awaitable, Callable, Dict, List, Optional, Protocol, runtime_checkable
from app.core.engine_event import (
CLAW_IM,
CLAW_WEB,
PHASE_FAIL,
PHASE_OK,
PHASE_START,
SEV_ERROR,
STEP_AGENT,
STEP_MCP,
STEP_SKILL,
STEP_THINKING,
Diagnosis,
make_step,
)
logger = logging.getLogger("aiflow.node_registry")
__all__ = [
"NODE_LLM", "NODE_REDUCE", "NODE_LOCAL", "NODE_EXTERNAL",
"NODE_SKILL", "NODE_MCP", "NODE_AGENT", "NODE_LOOP",
"RISK_LOW", "RISK_MEDIUM", "RISK_HIGH",
"BaseNode", "NodeBase", "NodeContext", "NodeResult", "NodeError", "StepCounter",
"NodeRegistry", "CapabilitySnapshot",
"registry", "normalize_claw",
]
# ---- 节点类型枚举(设计书 §B)------------------------------------------
NODE_LLM = "llm" # 纯文本变换 / 路由判断
NODE_REDUCE = "reduce" # 多路结果汇总
NODE_LOCAL = "local" # 本地智能体(带工具轮)
NODE_EXTERNAL = "external" # 外部智能体(Dify / n8n / Coze)
NODE_SKILL = "skill" # 单个技能直调(新)
NODE_MCP = "mcp" # 单个 MCP 工具直调(新)
NODE_AGENT = "agent" # 智能体自动分派 local/external(新)
NODE_LOOP = "loop" # 对列表批量执行子节点(新)
# ---- 风险分级:供审批策略 / 成本分层复用 --------------------------------
RISK_LOW = "low"
RISK_MEDIUM = "medium"
RISK_HIGH = "high"
_MAX_RESULT_CHARS = 600 # 事件里回传的结果摘要上限,防止撑爆 SSE 帧
_LOOP_HARD_LIMIT = 20 # 循环迭代硬顶,防止图里写个 10000 把机器打满
_LOOP_CONCURRENCY_CAP = 5
class NodeError(RuntimeError):
"""节点执行失败。run_graph 按 on_error=halt|continue 处理。"""
def __init__(self, message: str, *, diagnosis: Optional[Diagnosis] = None):
super().__init__(message)
self.diagnosis = diagnosis
def normalize_claw(claw_type: Any) -> str:
"""claw 维度归一。
设计书/otel 用 `web|im_wechat|im_platform`,engine_event 用 `web|im`。
不归一会让 Grafana 维度碎成四片,看板永远对不上。
"""
raw = str(claw_type or "").strip().lower()
if not raw:
return ""
return CLAW_IM if raw.startswith("im") else CLAW_WEB
def _truncate(text: Any, limit: int = _MAX_RESULT_CHARS) -> str:
s = text if isinstance(text, str) else str(text or "")
return s if len(s) <= limit else s[:limit] + f"...(共 {len(s)} 字)"
class StepCounter:
"""全图共享的步骤序号发生器。
同一 index 先发 phase=start 占位、后发 phase=ok|fail 覆盖,
前端即得到「原地更新」的实时进度,而非追加一堆重复卡片。
"""
__slots__ = ("_n",)
def __init__(self, start: int = 0):
self._n = start
def next(self) -> int:
n = self._n
self._n += 1
return n
# =========================================================================
# 上下文 / 结果
# =========================================================================
@dataclass
class NodeContext:
"""节点运行上下文(设计书 §B:tenant_id / claw_type / trace_id / blackboard / emit)。"""
db: Any
node: Dict[str, Any]
graph: Dict[str, Any] = field(default_factory=dict)
blackboard: Dict[str, Any] = field(default_factory=dict)
query: str = ""
upstream: str = ""
is_entry: bool = True
user_id: str = ""
conv_id: Any = None
trace_id: str = ""
tenant_id: str = ""
claw_type: str = ""
snapshot: Optional["CapabilitySnapshot"] = None
on_event: Optional[Callable[[Any], Any]] = None
counter: "StepCounter" = field(default_factory=lambda: StepCounter())
# -- 便捷取值 -----------------------------------------------------
@property
def claw(self) -> str:
return normalize_claw(self.claw_type)
@property
def node_id(self) -> str:
return str(self.node.get("id") or "")
def cfg(self, key: str, default: Any = None) -> Any:
val = self.node.get(key)
return default if val in (None, "") else val
def node_input(self) -> str:
"""入口节点吃用户原问题;下游节点综合原问题 + 上游输出。
与 orchestrator._assemble_node_input 保持同一口径(该函数为其唯一实现,
此处复用而非复制)。
"""
from app.core.orchestrator import _assemble_node_input
return _assemble_node_input(self.query, self.upstream, self.is_entry)
def child(self, node: Dict[str, Any], *, upstream: str = "") -> "NodeContext":
"""派生子节点上下文(LoopNode 用)。共享 counter/emit,保证事件序不乱。"""
return NodeContext(
db=self.db,
node=node,
graph=self.graph,
blackboard=self.blackboard,
query=self.query,
upstream=upstream or self.upstream,
is_entry=False,
user_id=self.user_id,
conv_id=self.conv_id,
trace_id=self.trace_id,
tenant_id=self.tenant_id,
claw_type=self.claw_type,
snapshot=self.snapshot,
on_event=self.on_event,
counter=self.counter,
)
# -- 事件发射(唯一出口,失败安全)--------------------------------
async def emit(self, event: Any) -> None:
if self.on_event is None:
return
try:
res = self.on_event(event)
if inspect.isawaitable(res):
await res
except Exception as e: # 观测不得影响主流程
logger.debug("[node] emit 失败(忽略) node=%s: %s", self.node_id, e)
async def emit_step(
self,
wire_kind: str,
*,
name: str = "",
server: str = "",
text: str = "",
args: Optional[Dict[str, Any]] = None,
result: str = "",
phase: str = "",
index: Optional[int] = None,
diagnosis: Any = None,
node_type: str = "",
) -> None:
"""构造并发射一条节点过程事件。
step["node"] 承载节点身份(设计书 kind="node" 的兼容落地形式)。
"""
node_meta = {
"id": self.node_id,
"type": node_type or str(self.node.get("type") or ""),
}
label = self.node.get("label") or self.node.get("name")
if label:
node_meta["label"] = str(label)
evt = make_step(
wire_kind,
name=name,
server=server,
text=text,
args=args or {},
result=result,
phase=phase,
index=index,
diagnosis=diagnosis,
node=node_meta,
trace_id=self.trace_id,
tenant_id=self.tenant_id,
claw=self.claw,
)
await self.emit(evt)
@dataclass
class NodeResult:
"""节点成功产物。失败一律抛 NodeError(保持 run_graph 的 on_error 语义不变)。"""
output: str = ""
node_id: str = ""
node_type: str = ""
used_tools: List[str] = field(default_factory=list)
meta: Dict[str, Any] = field(default_factory=dict)
def to_blackboard(self) -> Dict[str, Any]:
"""转成 orchestrator 黑板结构(字段集与改造前逐键一致)。"""
out: Dict[str, Any] = {
"ok": True,
"node_id": self.node_id,
"node_type": self.node_type,
"output": self.output,
}
if self.used_tools:
out["used_tools"] = self.used_tools
if self.meta:
out["meta"] = self.meta
return out
# =========================================================================
# 节点协议 + 公共基类
# =========================================================================
@runtime_checkable
class BaseNode(Protocol):
"""节点协议(设计书 §B)。第三方节点只要满足此协议即可 register()。"""
node_type: str
name: str
input_schema: Dict[str, Any]
output_schema: Dict[str, Any]
risk_level: str
async def run(self, ctx: NodeContext) -> NodeResult: ...
_TEXT_OUT = {
"type": "object",
"properties": {"output": {"type": "string", "description": "节点文本输出"}},
}
class NodeBase:
"""公共实现:生命周期事件 + 异常归一。子类只实现 `_execute`。"""
node_type: str = ""
name: str = ""
input_schema: Dict[str, Any] = {"type": "object", "properties": {}}
output_schema: Dict[str, Any] = _TEXT_OUT
risk_level: str = RISK_LOW
wire_kind: str = STEP_THINKING # 映射到前端已识别的 step 类型
fail_code: str = "NODE_FAILED"
# -- 子类钩子 -----------------------------------------------------
async def _execute(self, ctx: NodeContext) -> NodeResult:
raise NotImplementedError
def _start_label(self, ctx: NodeContext) -> str:
return str(ctx.node.get("label") or ctx.node.get("name") or ctx.node_id or self.name)
def _start_args(self, ctx: NodeContext) -> Dict[str, Any]:
return {}
def _fail_hint(self, ctx: NodeContext) -> str:
return "检查该节点配置与上游输入,或将 on_error 设为 continue 以跳过"
# -- 统一生命周期 -------------------------------------------------
async def run(self, ctx: NodeContext) -> NodeResult:
idx = ctx.counter.next()
label = self._start_label(ctx)
t0 = time.time()
await ctx.emit_step(
self.wire_kind,
name=label,
text=f"执行节点 {label}" if self.wire_kind == STEP_THINKING else "",
args=self._start_args(ctx),
phase=PHASE_START,
index=idx,
node_type=self.node_type,
)
try:
res = await self._execute(ctx)
except NodeError as e:
diag = e.diagnosis or Diagnosis(
code=self.fail_code,
severity=SEV_ERROR,
summary=f"节点 {label} 执行失败",
detail=str(e),
hint=self._fail_hint(ctx),
retryable=True,
)
await ctx.emit_step(
self.wire_kind, name=label, phase=PHASE_FAIL, index=idx,
diagnosis=diag, node_type=self.node_type,
)
raise
except Exception as e:
diag = Diagnosis(
code=self.fail_code,
severity=SEV_ERROR,
summary=f"节点 {label} 执行失败",
detail=f"{type(e).__name__}: {e}",
hint=self._fail_hint(ctx),
retryable=True,
)
await ctx.emit_step(
self.wire_kind, name=label, phase=PHASE_FAIL, index=idx,
diagnosis=diag, node_type=self.node_type,
)
raise NodeError(str(e), diagnosis=diag) from e
res.node_id = res.node_id or ctx.node_id
res.node_type = res.node_type or self.node_type
res.meta.setdefault("elapsed_ms", int((time.time() - t0) * 1000))
await ctx.emit_step(
self.wire_kind,
name=label,
args=self._start_args(ctx),
result=_truncate(res.output),
phase=PHASE_OK,
index=idx,
node_type=self.node_type,
)
return res
# =========================================================================
# 内建节点 —— 全部委托既有原语,零业务重复
# =========================================================================
class LlmNode(NodeBase):
node_type = NODE_LLM
name = "LLM 处理"
risk_level = RISK_LOW
fail_code = "LLM_NODE_FAILED"
input_schema = {
"type": "object",
"properties": {
"system_prompt": {"type": "string", "description": "节点系统提示词"},
"model_id": {"type": "string", "description": "指定模型;留空走 auto 路由"},
},
}
_DEFAULT_PROMPT = (
"你是一个处理节点,请根据上游输入完成指定的文本变换、摘要或路由判断,"
"只输出处理后的结果,不要多余解释。"
)
async def _execute(self, ctx: NodeContext) -> NodeResult:
from app.core.orchestrator import _llm_complete
text = await _llm_complete(
ctx.cfg("system_prompt", self._DEFAULT_PROMPT),
ctx.node_input(),
ctx.node.get("model_id"),
ctx.db,
ctx.user_id,
)
return NodeResult(output=text)
class ReduceNode(LlmNode):
node_type = NODE_REDUCE
name = "结果汇总"
fail_code = "REDUCE_NODE_FAILED"
_DEFAULT_PROMPT = (
"你是一个结果汇总器,请将多个子任务的输出综合成连贯、完整、面向用户的最终回答。"
)
class LocalAgentNode(NodeBase):
node_type = NODE_LOCAL
name = "本地智能体"
risk_level = RISK_MEDIUM
wire_kind = STEP_AGENT
fail_code = "LOCAL_AGENT_FAILED"
input_schema = {
"type": "object",
"required": ["agent_id"],
"properties": {"agent_id": {"type": "string", "description": "MarketApp(agent) ID"}},
}
def _resolve(self, ctx: NodeContext) -> Dict[str, Any]:
from app.api.chat_skill import _resolve_market_agents
aid = ctx.node.get("agent_id")
if not aid:
raise NodeError(f"{self.node_type} 节点 {ctx.node_id} 缺少 agent_id")
agents = _resolve_market_agents(ctx.db, [aid], ctx.user_id)
if not agents:
raise NodeError(f"节点 {ctx.node_id} 引用的 agent_id={aid} 不存在")
return agents[0]
def _start_args(self, ctx: NodeContext) -> Dict[str, Any]:
return {"agent_id": ctx.node.get("agent_id")}
async def _execute(self, ctx: NodeContext) -> NodeResult:
from app.core.orchestrator import _run_local_agent_turn
agent = self._resolve(ctx)
res = await _run_local_agent_turn(agent, ctx.node_input(), ctx.db, ctx.user_id, ctx.conv_id)
return NodeResult(
output=res.get("text") or "",
used_tools=list(res.get("used_tools") or []),
meta={"agent_name": agent.get("name")},
)
class ExternalAgentNode(LocalAgentNode):
node_type = NODE_EXTERNAL
name = "外部智能体"
risk_level = RISK_HIGH
fail_code = "EXTERNAL_AGENT_FAILED"
async def _execute(self, ctx: NodeContext) -> NodeResult:
from app.api.chat_agents import _call_external_agent
agent = self._resolve(ctx)
answer, _ = await _call_external_agent(
ctx.db, agent, query=ctx.node_input(), user_id=ctx.user_id,
ext_conversation_id=None, history=[],
)
return NodeResult(
output=answer or "",
meta={"agent_name": agent.get("name"), "platform": agent.get("platform")},
)
def _fail_hint(self, ctx: NodeContext) -> str:
return "确认该 Agent 的 platform / agent_endpoint / 凭据配置有效且远端可达"
class AgentNode(LocalAgentNode):
"""智能体节点:按配置自动分派本地 / 远程,画布上不用再区分两种节点。"""
node_type = NODE_AGENT
name = "智能体"
risk_level = RISK_MEDIUM
fail_code = "AGENT_NODE_FAILED"
@staticmethod
def is_external(agent: Dict[str, Any]) -> bool:
return bool((agent.get("platform") or "").strip() and (agent.get("agent_endpoint") or "").strip())
async def _execute(self, ctx: NodeContext) -> NodeResult:
agent = self._resolve(ctx)
if self.is_external(agent):
from app.api.chat_agents import _call_external_agent
answer, _ = await _call_external_agent(
ctx.db, agent, query=ctx.node_input(), user_id=ctx.user_id,
ext_conversation_id=None, history=[],
)
return NodeResult(
output=answer or "",
meta={"agent_name": agent.get("name"), "mode": "external",
"platform": agent.get("platform")},
)
from app.core.orchestrator import _run_local_agent_turn
res = await _run_local_agent_turn(agent, ctx.node_input(), ctx.db, ctx.user_id, ctx.conv_id)
return NodeResult(
output=res.get("text") or "",
used_tools=list(res.get("used_tools") or []),
meta={"agent_name": agent.get("name"), "mode": "local"},
)
class SkillNode(NodeBase):
"""技能直调节点 —— 把 function calling 里的技能搬到画布上。"""
node_type = NODE_SKILL
name = "技能"
risk_level = RISK_MEDIUM
wire_kind = STEP_SKILL
fail_code = "SKILL_NODE_FAILED"
input_schema = {
"type": "object",
"properties": {
"skill_id": {"type": "string", "description": "MarketApp(skill) ID"},
"func_name": {"type": "string", "description": "技能函数名;与 skill_id 二选一"},
"args": {"type": "object", "description": "技能入参;留空则以上游文本作为 query"},
},
}
@staticmethod
def func_name_of(skill: Dict[str, Any]) -> str:
"""与 _build_system_prompt 同源的函数名规则,禁止另立一套。"""
fn = skill.get("func_name")
if fn:
return str(fn)
return str(skill.get("name") or "").replace(" ", "_").replace("-", "_").lower()
def _resolve(self, ctx: NodeContext) -> tuple:
from app.api.chat_skill import _resolve_market_skills
sid = ctx.node.get("skill_id")
want_fn = str(ctx.node.get("func_name") or "").strip().lower()
skills: List[Dict[str, Any]] = []
if sid:
skills = _resolve_market_skills(ctx.db, [sid], ctx.user_id) or []
elif ctx.snapshot and ctx.snapshot.skills:
skills = [s for s in ctx.snapshot.skills if self.func_name_of(s) == want_fn]
if not skills:
raise NodeError(
f"技能节点 {ctx.node_id} 未解析到技能"
f"(skill_id={sid or '-'} func_name={want_fn or '-'})"
)
return skills, want_fn or self.func_name_of(skills[0])
def _start_args(self, ctx: NodeContext) -> Dict[str, Any]:
return dict(ctx.node.get("args") or {})
async def _execute(self, ctx: NodeContext) -> NodeResult:
from app.api.chat_skill import _execute_skill_tool
skills, func_name = self._resolve(ctx)
args = dict(ctx.node.get("args") or {})
node_input = ctx.node_input()
if not args:
args = {"query": node_input}
# _execute_skill_tool 为同步实现,放线程池避免阻塞事件循环里的同层并行节点
result = await asyncio.to_thread(
_execute_skill_tool, ctx.db, func_name, args, skills, node_input
)
text = result if isinstance(result, str) else json.dumps(result, ensure_ascii=False)
return NodeResult(output=text, used_tools=[func_name], meta={"skill": func_name})
def _fail_hint(self, ctx: NodeContext) -> str:
return "确认技能已发布且入参符合其 parameters 定义"
class McpToolNode(NodeBase):
"""MCP 工具直调节点 —— 外部副作用,风险等级最高。"""
node_type = NODE_MCP
name = "MCP 工具"
risk_level = RISK_HIGH
wire_kind = STEP_MCP
fail_code = "MCP_NODE_FAILED"
input_schema = {
"type": "object",
"required": ["tool"],
"properties": {
"server_id": {"type": "string", "description": "MCP 服务 ID"},
"tool": {"type": "string", "description": "MCP 工具名"},
"args": {"type": "object", "description": "工具入参"},
},
}
def _resolve_server(self, ctx: NodeContext) -> Dict[str, Any]:
sid = ctx.node.get("server_id")
if ctx.snapshot and sid:
for s in ctx.snapshot.mcp_servers:
if s.get("id") == sid:
return s
from app.api.chat_mcp import _resolve_active_mcp_servers
if not sid:
raise NodeError(f"MCP 节点 {ctx.node_id} 缺少 server_id")
servers = _resolve_active_mcp_servers(ctx.db, [sid]) or []
if not servers:
raise NodeError(f"MCP 节点 {ctx.node_id} 引用的服务 {sid} 不存在或未启用")
return servers[0]
def _start_args(self, ctx: NodeContext) -> Dict[str, Any]:
return dict(ctx.node.get("args") or {})
def _start_label(self, ctx: NodeContext) -> str:
return str(ctx.node.get("tool") or super()._start_label(ctx))
async def _execute(self, ctx: NodeContext) -> NodeResult:
from app.mcp_client import call_mcp_tool
tool = str(ctx.node.get("tool") or "").strip()
if not tool:
raise NodeError(f"MCP 节点 {ctx.node_id} 缺少 tool")
srv = self._resolve_server(ctx)
args = dict(ctx.node.get("args") or {})
if not args:
args = {"query": ctx.node_input()}
result = await call_mcp_tool(srv, tool, args)
text = result if isinstance(result, str) else json.dumps(result, ensure_ascii=False)
return NodeResult(
output=text,
used_tools=[f"mcp:{srv.get('name')}:{tool}"],
meta={"server": srv.get("name"), "tool": tool},
)
async def run(self, ctx: NodeContext) -> NodeResult:
# MCP 事件需带 server 字段(前端按 server 分组渲染),故覆写生命周期头帧
idx = ctx.counter.next()
tool = str(ctx.node.get("tool") or "?")
srv_name = ""
try:
srv_name = str(self._resolve_server(ctx).get("name") or "")
except Exception:
srv_name = str(ctx.node.get("server_id") or "")
await ctx.emit_step(
STEP_MCP, name=tool, server=srv_name, args=self._start_args(ctx),
phase=PHASE_START, index=idx, node_type=self.node_type,
)
t0 = time.time()
try:
res = await self._execute(ctx)
except Exception as e:
diag = Diagnosis(
code=self.fail_code, severity=SEV_ERROR,
summary=f"MCP 工具 {tool} 调用失败",
detail=f"{type(e).__name__}: {e}",
hint="确认 MCP 服务在线、凭据有效,且未被出网代理拦截",
retryable=True,
)
await ctx.emit_step(
STEP_MCP, name=tool, server=srv_name, phase=PHASE_FAIL,
index=idx, diagnosis=diag, node_type=self.node_type,
)
raise NodeError(str(e), diagnosis=diag) from e
res.node_id = res.node_id or ctx.node_id
res.node_type = res.node_type or self.node_type
res.meta.setdefault("elapsed_ms", int((time.time() - t0) * 1000))
await ctx.emit_step(
STEP_MCP, name=tool, server=srv_name, args=self._start_args(ctx),
result=_truncate(res.output), phase=PHASE_OK, index=idx,
node_type=self.node_type,
)
return res
class LoopNode(NodeBase):
"""批量节点:把上游输出拆成条目,对每条跑一次子节点(body),再按策略聚合。"""
node_type = NODE_LOOP
name = "循环"
risk_level = RISK_MEDIUM
fail_code = "LOOP_NODE_FAILED"
input_schema = {
"type": "object",
"required": ["body"],
"properties": {
"body": {"type": "object", "description": "子节点定义(含 type 及其配置)"},
"items": {"type": "array", "description": "静态条目;留空则拆分上游输出"},
"split": {"type": "string", "enum": ["lines", "json"], "default": "lines"},
"max_iterations": {"type": "integer", "default": 5, "maximum": _LOOP_HARD_LIMIT},
"concurrency": {"type": "integer", "default": 1, "maximum": _LOOP_CONCURRENCY_CAP},
"join": {"type": "string", "enum": ["concat", "first", "last"], "default": "concat"},
},
}
def _items(self, ctx: NodeContext) -> List[str]:
raw = ctx.node.get("items")
if isinstance(raw, list) and raw:
items = [str(x) for x in raw]
else:
src = ctx.upstream or ctx.query or ""
if str(ctx.node.get("split") or "lines") == "json":
try:
parsed = json.loads(src)
items = [json.dumps(x, ensure_ascii=False) if not isinstance(x, str) else x
for x in (parsed if isinstance(parsed, list) else [parsed])]
except Exception:
items = [ln.strip() for ln in src.splitlines() if ln.strip()]
else:
items = [ln.strip() for ln in src.splitlines() if ln.strip()]
limit = ctx.cfg("max_iterations", 5)
try:
limit = max(1, min(int(limit), _LOOP_HARD_LIMIT))
except Exception:
limit = 5
return items[:limit]
async def _execute(self, ctx: NodeContext) -> NodeResult:
body = ctx.node.get("body")
if not isinstance(body, dict) or not body.get("type"):
raise NodeError(f"循环节点 {ctx.node_id} 缺少合法 body(需含 type)")
impl = registry.get(body["type"])
if impl is None:
raise NodeError(f"循环节点 {ctx.node_id} 的 body 类型非法: {body['type']}")
items = self._items(ctx)
if not items:
return NodeResult(output="", meta={"iterations": 0})
try:
conc = max(1, min(int(ctx.cfg("concurrency", 1)), _LOOP_CONCURRENCY_CAP))
except Exception:
conc = 1
sem = asyncio.Semaphore(conc)
async def _one(i: int, item: str) -> str:
child_def = dict(body)
child_def.setdefault("id", f"{ctx.node_id}#{i}")
async with sem:
res = await impl.run(ctx.child(child_def, upstream=item))
return res.output or ""
outs = await asyncio.gather(
*[_one(i, it) for i, it in enumerate(items)], return_exceptions=True
)
texts, used, failed = [], [], 0
for i, o in enumerate(outs):
if isinstance(o, Exception):
failed += 1
texts.append(f"[第 {i + 1} 项失败] {o}")
else:
texts.append(o)
used.append(f"loop:{body['type']}x{len(items)}")
join = str(ctx.cfg("join", "concat"))
if join == "first":
output = texts[0] if texts else ""
elif join == "last":
output = texts[-1] if texts else ""
else:
output = "\n\n".join(t for t in texts if t)
return NodeResult(
output=output, used_tools=used,
meta={"iterations": len(items), "failed": failed, "body_type": body["type"]},
)
# =========================================================================
# 能力快照 —— 把「发现到的能力」物化成节点候选 + function definitions
# =========================================================================
@dataclass
class CapabilitySnapshot:
"""一次请求可用能力的只读快照(双 Claw 共用同一份)。"""
skills: List[Dict[str, Any]] = field(default_factory=list)
mcp_servers: List[Dict[str, Any]] = field(default_factory=list)
agents: List[Dict[str, Any]] = field(default_factory=list)
function_definitions: List[Dict[str, Any]] = field(default_factory=list)
mcp_tool_map: Dict[str, Any] = field(default_factory=dict)
claw: str = ""
def node_candidates(self) -> List[Dict[str, Any]]:
"""画布左侧「可拖拽能力」列表 / IM `/flow` 的可选节点清单。"""
out: List[Dict[str, Any]] = []
for s in self.skills:
out.append({
"type": NODE_SKILL,
"label": s.get("name"),
"skill_id": s.get("id"),
"func_name": SkillNode.func_name_of(s),
"description": s.get("description") or "",
"risk_level": RISK_MEDIUM,
})
for fname, info in self.mcp_tool_map.items():
out.append({
"type": NODE_MCP,
"label": f"{info.get('server_name')} / {info.get('name')}",
"server_id": info.get("server_id"),
"tool": info.get("name"),
"func_name": fname,
"risk_level": RISK_HIGH,
})
for a in self.agents:
out.append({
"type": NODE_AGENT,
"label": a.get("name"),
"agent_id": a.get("id"),
"description": a.get("description") or "",
"risk_level": RISK_HIGH if AgentNode.is_external(a) else RISK_MEDIUM,
})
return out
def summary(self) -> Dict[str, int]:
return {
"skills": len(self.skills),
"mcp_servers": len(self.mcp_servers),
"mcp_tools": len(self.mcp_tool_map),
"agents": len(self.agents),
"functions": len(self.function_definitions),
}
# =========================================================================
# 注册表
# =========================================================================
class NodeRegistry:
"""进程级单例:节点类型 → 实现。
- 静态部分:内建节点类型,启动即可用,`node_types()` 供 YAML/图校验;
- 动态部分:`discover()` 按请求扫描已装 skill / 已启用 MCP / 已配置 Agent,
产出 CapabilitySnapshot(function_definitions + 节点候选),
让「函数调用」与「画布节点」共用同一份能力清单。
"""
_inst: Optional["NodeRegistry"] = None
def __init__(self) -> None:
self._nodes: Dict[str, Any] = {}
# -- 单例 ---------------------------------------------------------
@classmethod
def instance(cls) -> "NodeRegistry":
if cls._inst is None:
cls._inst = cls()
cls._inst.register_builtins()
return cls._inst
# -- 注册 ---------------------------------------------------------
def register(self, node: Any, *, override: bool = False) -> Any:
"""注册节点实现(类或实例均可)。返回注册后的实例。"""
impl = node() if inspect.isclass(node) else node
ntype = getattr(impl, "node_type", "")
if not ntype:
raise ValueError("节点必须声明非空 node_type")
if not callable(getattr(impl, "run", None)):
raise ValueError(f"节点 {ntype} 缺少 run() 实现")
if ntype in self._nodes and not override:
logger.debug("[registry] 节点类型 %s 已存在,跳过重复注册", ntype)
return self._nodes[ntype]
self._nodes[ntype] = impl
return impl
def register_builtins(self) -> None:
for cls in (LlmNode, ReduceNode, LocalAgentNode, ExternalAgentNode,
AgentNode, SkillNode, McpToolNode, LoopNode):
self.register(cls)
logger.info("[registry] 内建节点注册完成: %s", ",".join(self.node_types()))
# -- 查询 ---------------------------------------------------------
def get(self, node_type: str) -> Optional[Any]:
return self._nodes.get(str(node_type or ""))
def has(self, node_type: str) -> bool:
return str(node_type or "") in self._nodes
def node_types(self) -> tuple:
"""供 orchestrator.validate_graph / yaml_workflow 校验(唯一真相源)。"""
return tuple(sorted(self._nodes.keys()))
def describe(self) -> List[Dict[str, Any]]:
"""节点类型元信息,供前端画布渲染配置表单。"""
out = []
for ntype in self.node_types():
impl = self._nodes[ntype]
out.append({
"node_type": ntype,
"name": getattr(impl, "name", ntype),
"risk_level": getattr(impl, "risk_level", RISK_LOW),
"input_schema": getattr(impl, "input_schema", {}),
"output_schema": getattr(impl, "output_schema", _TEXT_OUT),
})
return out
def requires_agent(self, node_type: str) -> bool:
return node_type in (NODE_LOCAL, NODE_EXTERNAL, NODE_AGENT)
# -- 动态能力发现 --------------------------------------------------
async def discover(
self,
db,
user_id: str = "",
query: str = "",
*,
claw: str = "web",
explicit_mcp_ids: Optional[List[str]] = None,
agent_ids: Optional[List[str]] = None,
with_functions: bool = True,
) -> CapabilitySnapshot:
"""扫描当前可用能力,产出快照。
能力发现只走 capability_discovery(双 Claw 唯一实现),
本方法不得内联任何第二套发现逻辑 —— 这是 #866 回退事故的教训。
"""
from app.core import capability_discovery as cap
snap = CapabilitySnapshot(claw=normalize_claw(claw) or claw)
try:
snap.skills = cap.discover_skills(db, user_id, query=query, claw=claw) or []
except Exception as e:
logger.warning("[registry] 技能发现失败(降级空): %s", e)
try:
snap.mcp_servers = cap.discover_mcp_servers(
db, explicit_ids=explicit_mcp_ids, claw=claw
) or []
except Exception as e:
logger.warning("[registry] MCP 发现失败(降级空): %s", e)
discover_agents = getattr(cap, "discover_agents", None)
if callable(discover_agents):
try:
snap.agents = discover_agents(
db, user_id, query=query, explicit_ids=agent_ids, claw=claw
) or []
except Exception as e:
logger.warning("[registry] Agent 发现失败(降级空): %s", e)
if with_functions:
try:
from app.api.chat_context import _build_system_prompt
_, skill_defs = _build_system_prompt(
skills=snap.skills, agents=[], kb_context="",
user_content=query, auto_discovered=True,
)
snap.function_definitions = list(skill_defs or [])
except Exception as e:
logger.warning("[registry] 技能 function 定义生成失败: %s", e)
if snap.mcp_servers:
try:
mcp_defs, tool_map = await cap.build_mcp_tool_defs(
snap.mcp_servers, idx_start=len(snap.function_definitions)
)
snap.function_definitions.extend(mcp_defs or [])
snap.mcp_tool_map = tool_map or {}
except Exception as e:
logger.warning("[registry] MCP function 定义生成失败: %s", e)
logger.info("[registry] discover claw=%s %s", claw, snap.summary())
return snap
# 模块级单例(导入即完成内建注册,供 yaml_workflow 在 import 期取 node_types)
registry: NodeRegistry = NodeRegistry.instance()