插件开发:自定义编排节点(DSL Node)
面向插件开发者。讲清三件事:怎么注册、execute 怎么写、前端怎么显示与过校验。 设计背景见 rundls-extension-api.md;Agent 工具(LLM 可调用)的扩展见 AGENT_TOOL_DEVELOPMENT.md;插件体系总览见 PLUGIN_DEVELOPMENT.md。
1. 总览:两条扩展点
编排引擎(harness/js)对外只开两个扩展口,都在插件 main.cjs 里经 Cordis 服务注册:
| 扩展点 | 注册方式 | 用途 |
|---|---|---|
| 自定义节点 | ctx.harness.registerNode(def) |
编排画布上可摆放、连线、执行的新节点类型 |
| 自定义工具 | ctx.harness.registerTool(def) |
tool 节点 / agent 可调用的工具(另有 ctx.registerAgentTools 通道,见 AGENT_TOOL_DEVELOPMENT.md) |
注册表实现在 harness/js/dsl-registry.cjs(两张内存 Map:nodeRegistry / toolRegistry)。owner(插件 id)由 Cordis 上下文自动注入,插件卸载/热重载时按 owner 一次性摘掉,不会残留旧节点。
⚠ 重要:插件内禁止
require('../../harness/...')。插件运行时加载的是构建产物目录(dist/),相对路径不可达;且 dsl-nodes 等模块带 langchain 依赖。harness 能力一律经 SDK 门面ctx.harness.*使用:
| SDK 成员(ctx.harness) | 用途 |
|---|---|
registerNode(def) / registerTool(def) |
注册节点/工具(owner 自动归属) |
unregisterNode(type) / unregisterTool(name) |
手动注销(一般不用,卸载自动清理) |
listNodes() / listTools() |
注册表元数据查询 |
makeEnvelope |
事件信封构造器集合(graphNodeLog / toolProgress / contextCompressed / graphWegirlOutput 等) |
resolveShown(cfg, smartValue) |
「智能显示」shown 判定(logMode 优先) |
interpolate(str, scope) / buildScope(state, runtime) |
模板插值(惰性加载) |
数据流:
插件 main.cjs apply(ctx)
└─ ctx.harness.registerNode(def) # owner 自动 = 当前插件 id
└─ dsl-registry.cjs nodeRegistry.set(type, def)
├─ 主进程:dsl-nodes.cjs executeDSLNode 分发执行
│ HANDLERS[内置类型] 优先 → dslRegistry.getNodeExecute(type) 兜底
├─ IPC harness:listNodes → 渲染端(校验放行 + 元数据查询)
└─ 渲染端:画布通用渲染 / 配置面板回退表单
2. 最小示例
插件入口是 Cordis 风格的 apply(ctx)(加载器只认 mod.apply;清理钩子经 ctx.on('dispose') 或 apply 返回 { dispose })。在插件 main.cjs 里:
'use strict';
function apply(ctx) {
// 自定义节点:拉取邮箱最新邮件,写入 state.vars
ctx.harness.registerNode({
type: 'mail.fetch', // 全局唯一类型 id(建议 <命名空间>.<动作>)
title: '拉取邮件', // 调色板/画布显示名
category: '邮件', // 分组(预留;当前画布按内置分组展示)
kind: 'action', // 'action' 数据流节点 | 'config' 配置子节点
defaultConfig: { mailbox: 'INBOX', limit: 10, outputVar: 'mails' },
inputs: [], // 声明式输入/输出(预留,编辑器可读)
outputs: [],
constraints: null,
// 执行闭包:只在主进程运行,签名与内置 handler 完全一致
execute: async (node, dsl, state, runtime) => {
const cfg = node.config || {};
const mails = await fetchMail(cfg.mailbox, cfg.limit); // 你的业务逻辑
const vars = Object.assign({}, state.vars || {});
vars[cfg.outputVar || 'mails'] = mails;
return { vars }; // ← 返回 state 补丁
}
});
}
module.exports = { apply };
插件目录结构不变(plugins/<id>/main.cjs → 编译进 dist/,宿主自动扫描加载)。harness 在主进程内,注册/改码后须完全重启应用生效。
3. def 字段说明
| 字段 | 必填 | 说明 |
|---|---|---|
type |
✅ | 唯一类型 id。与内置类型重名会被内置 HANDLERS 优先覆盖(等于注册无效),务必用命名空间前缀 |
execute |
✅ | async (node, dsl, state, runtime) => patch,见 §4 |
title |
建议 | 显示名,缺省用 type |
category |
建议 | 分组名,缺省 'plugin' |
kind |
建议 | 'action'(默认,数据流节点)或 'config'(配置子节点,如 Checkpointer 那类) |
defaultConfig |
建议 | 默认配置对象;用户在配置面板改完后存进 node.config。公共默认 logMode:'smart' 会由前端回退逻辑补上,不必自己写 |
inputs / outputs |
可选 | 声明式端口描述(预留,编辑器可读,不参与校验) |
constraints |
可选 | 声明式结构约束(数据而非函数,跨语言可解释;预留) |
4. execute 契约
签名与参数
execute: async (node, dsl, state, runtime) => ({ ...statePatch })
| 参数 | 内容 |
|---|---|
node |
当前节点 { id, type, label, config },config 已含用户在面板填的值(与 defaultConfig 合并过) |
dsl |
整个图(主图或复合 tab),一般用不到 |
state |
图状态:{ vars, input, messages, context, output }。vars 是跨节点变量池;input.query 是用户输入 |
runtime |
运行时门面,见下 |
runtime 常用成员(完整定义见 harness/js/harness-service.cjs 的 runtime 构造处):
| 成员 | 用途 |
|---|---|
runtime.emit(envelope) |
广播事件(trace 落库 + 前端渲染)。信封构造用 ctx.harness.makeEnvelope.* |
runtime.query / runtime.variables |
用户输入与编排启动变量 |
runtime.signal |
AbortSignal,长时间操作应监听取消 |
runtime.resolveModel(alias) / resolveTools(names) |
解析模型/工具定义(需要调 LLM 时用) |
runtime.kbRetrieve(...) |
知识库检索 |
runtime.requestApproval(info) |
请求人工审批,返回 boolean |
runtime.runId / sessionId / projectId |
关联标识,发事件时带上 |
返回值
返回一个对象补丁,引擎把它合并进 state。只返回你改动的 key(典型是 { vars: {...} } 或 { output: ... })。返回 {} 表示无状态变化。
错误处理
execute 抛错会被编译器捕获并广播 graph.node.error(画布节点标红、过程区显示错误),运行继续按错误路径走或终止——与内置节点行为一致。
模板插值
config 里的字符串支持 {{ path }} 取值(vars.x / input.query 等)与 {{ =expr }} 表达式。插值器经 SDK 取用(插件不可直接 require harness 内部模块——安装后路径不可达,且 dsl-nodes 带 langchain 依赖):
const { interpolate, buildScope } = ctx.harness; // apply(ctx) 内或 execute 闭包里均可
const scope = buildScope(state, runtime);
const rendered = interpolate(cfg.template, scope); // 失败保留原样
条件边表达式
边上的 condition 用同一作用域求值(受限表达式:路径、比较、&&/||/!,不支持函数调用)。作用域字段:vars / input / output / messages / context / query,以及两个引擎预计算的派生变量:
| 变量 | 含义 |
|---|---|
shouldContinue |
末条消息是否带待处理 tool_calls(LangGraph should_continue 条件边等效)。条件边直接写 shouldContinue / !shouldContinue,不要手拼 messages[messages.length - 1].tool_calls ... 冗长表达式(旧写法仍兼容) |
vars.loopDetected / vars.loopReason |
由 loopDetect 节点每轮写入('true'/'false'),供护栏分支路由 |
典型 ReAct 路由:shouldContinue → tools;!shouldContinue → end;vars.loopDetected == 'true' → replan。
实例覆盖(overrides)空值语义
composite 节点 config.overrides[子节点id][字段] 合并进子节点默认 config,空值 = 回退共享默认:
- 空字符串
''→ 回退; - 空数组
[]→ 回退(典型:工具多选清空 = 继承共享复合的工具列表,共享新增工具自动跟随)。
编译期(expandComposites)与编辑器覆盖面板两侧行为一致。
追加工具(extraTools)
llm 节点的正式配置字段(defaultConfig.extraTools: []):运行时与 tools 合并(按工具名去重),
不是替换。复合节点实例的典型用法:
| overrides.agent.tools | overrides.agent.extraTools | 最终工具 |
|---|---|---|
| 有勾选 | 有 | 勾选的 + 追加的 |
[] / 缺省 |
有 | 共享复合默认 tools + 追加的(共享新增自动跟随) |
| 有勾选 | [] / 缺省 |
勾选的 |
编辑器属性面板(普通 llm 节点与复合覆盖分组共用 LLM_FIELDS)在 mode=tools 时显示
「可调用的工具(多选)」与「追加的工具(多选)」两行,无需在插件侧做任何登记。
5. 事件与「智能显示」
节点执行期广播的事件决定前端过程区显示什么。规则(2026-09-06 起,全节点统一):
shown在节点算好、随事件广播;渲染层只认shown,不做二次判断。- 判定优先级:节点配置
logMode优先 ——always→ 恒显示;hidden→ 恒隐藏;smart(默认)→ 用推算值(如 compress 用modified,memory 用loaded>0)。 - 统一入口:
resolveShown(cfg, smartValue),你的节点如果想遵循同一规则(经 SDK 取用,勿 require harness 路径):
function apply(ctx) {
const { makeEnvelope, resolveShown } = ctx.harness; // SDK:事件信封 + shown 判定
ctx.harness.registerNode({
type: 'example.stats',
// ...
execute: async (node, dsl, state, runtime) => {
const shown = resolveShown(node.config, resultCount > 0);
runtime.emit({ type: 'graph.wegirl.output', runId: runtime.runId, nodeId: node.id, output, shown });
return {};
}
});
}
- 节点执行框(started/completed/error)由编译器自动广播,
logMode对它同样生效——你的defaultConfig不用管,前端回退会补logMode:'smart'。
6. 前端:显示与校验怎么生效
6.1 画布渲染(自动,无需登记)
画布对未知类型有通用回退:NODE_META[type] 缺失时用 { title: type, 灰色, 无配置字段 } 渲染,defaultConfig 来自 registry 元数据,配置面板至少能改 logMode。所以节点能跑、能存、能显示,全链路不需要改宿主前端。
6.2 调色板出现入口(两途径)
主编辑器左侧调色板由宿主的 NODE_META + NODE_ORDER(AgentGraphPanel.jsx)静态生成,插件节点不会自动出现。两种接入方式:
- 宿主登记(推荐给随应用分发的官方节点):在
AgentGraphPanel.jsx的NODE_META加条目(type/color/desc/fields 配置表单)、NODE_ORDER加排序位。注意配置面板字段由NODE_META[type].fields驱动,不登记就只能靠回退表单(无字段可编辑)。 - 插件自建面板:插件渲染端经宿主 SDK 查询注册表元数据,自绘节点列表与拖放:
const nodes = await hostSdk.harness.listNodes(); // [{ type, title, category, defaultConfig, owner }, ...]
const tools = await hostSdk.harness.listTools();
6.3 保存期校验放行(自动,经 IPC 同步)
保存/运行前的 DSL 校验复用 validateDSL(harness/js/dsl-types.cjs),它对类型合法性的判定是:
VALID_TYPES.indexOf(n.type) < 0 && !dslRegistry.hasNodeType(n.type) // → 报「类型非法」
渲染端打包的 dsl-registry.cjs 是与主进程独立的实例(空表),因此 orchestrationService.ensurePluginNodeMetas() 会在校验前经 harness:listNodes IPC 拉一次插件节点元数据,registerNodeMeta 登记进渲染端 registry(幂等 + Promise 缓存)。这一步是自动的(AgentGraphPanel 保存/运行/设计时校验三处都会先 await 它),插件侧无需做任何事。
如果你的自定义编排校验入口是自己新写的,记得先 await ensurePluginNodeMetas()。
6.4 人工交互规则同样约束插件节点
若你的节点绑定了 interrupt 类工具(AskUserQuestion / ImageDeliver),或你的节点本身调 interrupt 等待用户输入——编排必须挂 Checkpointer(backend=memory),否则保存期校验直接拦截(checkHumanInterruptRequirements / nodeNeedsCheckpointer,见 dsl-types.cjs)。规则识别的是「节点 config 里绑定的工具名」(INTERRUPT_TOOL_RE = /AskUserQuestion|ImageDeliver/);如果你的自定义节点自身做 interrupt 而不绑定这些工具,请把节点 type 加进该正则,才能被保存期规则覆盖。
7. 调试与注意事项
- 改了 harness 侧代码(含插件 main.cjs 的 registerNode)必须完全重启应用;渲染层改动刷新即可。
- 节点抛错看过程区
graph.node.error;更细的行为排查直接查 trace 库~/.wegirl/agent-traces/agent_traces.sqlite(用 python sqlite3 查,better-sqlite3 是 Electron ABI)。 - 注册冲突:
registerNode同 type 后注册者覆盖先注册者;但执行分发内置 HANDLERS 永远优先,别用内置类型名(llm/tool/human等)。 - 热重载安全:插件卸载经
unregisterByOwner(owner)摘除本插件全部节点/工具,无跨插件误伤。 registerNodeMeta是渲染端专用入口(不要求 execute),主进程注册请一律走registerNode。- 现成参考:内置 handler 全集在
harness/js/dsl-nodes.cjs的HANDLERS表;注册表契约在harness/js/dsl-registry.cjs头注释;工具类扩展模板见plugins/_template/。
8. FAQ
Q:插件节点能在复合节点(tab 子图 / 共享复合 .orch.json)里用吗?
能。校验对复合内层节点同样查 hasNodeType;执行期展开后走同一张注册表。
Q:编排里已经有我的节点类型,但插件被卸载了会怎样?
保存期校验会报「类型非法」(渲染端 registry 拉不到元数据);运行期 executeDSLNode 抛「未知节点类型」。重新启用插件即恢复。
Q:execute 里能调 LLM 吗?
能。用 runtime.resolveModel 拿模型定义后自行调用 provider;但更推荐把「LLM 调用」留给内置 llm 节点、你的节点只做数据加工/外部 IO,组合使用职责更清晰。
Q:想让节点出现在画布左侧节点库怎么办?
见 §6.2——官方节点走宿主 NODE_META/NODE_ORDER 登记;纯插件场景自建面板用 hostSdk.harness.listNodes()。