dsh-plugin-agentflow 0.0.0-stage → 0.2.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +88 -2
- package/cordis.patch.yml +195 -0
- package/index.js +448 -0
- package/lib/bridge.js +858 -0
- package/lib/client.js +4 -0
- package/package.json +42 -4
- package/runtime/boards.mjs +486 -0
- package/runtime/bridge.mjs +230 -0
- package/runtime/dsh-source.mjs +289 -0
- package/runtime/engine.mjs +352 -0
- package/runtime/index.html +3299 -0
- package/runtime/llm.mjs +252 -0
- package/runtime/mcp.mjs +823 -0
- package/runtime/preset-gen.mjs +1117 -0
- package/runtime/presets.mjs +399 -0
- package/runtime/run.mjs +76 -0
- package/runtime/server.mjs +1151 -0
- package/runtime/skills.mjs +131 -0
- package/runtime/store.mjs +103 -0
- package/runtime/verify.mjs +219 -0
- package/runtime/web/dist/assets/index-CkM6YPl4.js +348 -0
- package/runtime/web/dist/assets/index-DEZcpxgr.css +1 -0
- package/runtime/web/dist/index.html +13 -0
- package/scripts/build-client.mjs +36 -0
- package/scripts/sync-runtime.mjs +84 -0
package/runtime/llm.mjs
ADDED
|
@@ -0,0 +1,252 @@
|
|
|
1
|
+
// llm.mjs —— 模型调用。只走 DSH 内核的 llm bridge(docs/bridge-contract.md),
|
|
2
|
+
// 不保留任何直连 provider 的退路:bridge 连不上 = 「DSH 内核未连接」,节点直接失败。
|
|
3
|
+
//
|
|
4
|
+
// 一个节点可以挂两类东西:
|
|
5
|
+
// - skills:把技能正文拼进 system prompt(提示层)
|
|
6
|
+
// - tools :把 MCP 工具交给模型做 function calling(执行层)
|
|
7
|
+
// 工具循环仍在本地:bridge 发 tool 事件 → 本地 mcpPool 执行 → 追加 assistant(tool-call)
|
|
8
|
+
// 与 tool 结果消息 → 再发一轮,直到模型给出最终回答或触达轮数上限。
|
|
9
|
+
// 这个循环只在节点内部,不影响整图调度。
|
|
10
|
+
|
|
11
|
+
import { composeSkillPrompt } from './skills.mjs';
|
|
12
|
+
import { bridgeStream, bridgeUnavailableReason } from './bridge.mjs';
|
|
13
|
+
|
|
14
|
+
const MOCK_DELAY_MS = 900;
|
|
15
|
+
const MAX_TOOL_ROUNDS = 6;
|
|
16
|
+
|
|
17
|
+
export function normalizeConfig(cfg) {
|
|
18
|
+
const mock = cfg?.mock !== false; // 默认开 mock,关掉才真的连 bridge
|
|
19
|
+
return {
|
|
20
|
+
mock,
|
|
21
|
+
mcpServers: cfg?.mcpServers || {},
|
|
22
|
+
skillDirs: Array.isArray(cfg?.skillDirs) ? cfg.skillDirs : [],
|
|
23
|
+
jev: cfg?.jev || {},
|
|
24
|
+
};
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
/**
|
|
28
|
+
* 节点上的 provider/model 直接交给 bridge。这里只做一件小事:
|
|
29
|
+
* engine 的 fallback 会传 {...node, model: 'provider/model'},把合并写法拆开;
|
|
30
|
+
* 不查任何 provider 表 —— 能不能调由 DSH 内核决定。
|
|
31
|
+
* @returns {{provider: string, model: string}}
|
|
32
|
+
*/
|
|
33
|
+
function targetOf(node) {
|
|
34
|
+
const model = typeof node?.model === 'string' ? node.model.trim() : '';
|
|
35
|
+
const provider = typeof node?.provider === 'string' ? node.provider.trim() : '';
|
|
36
|
+
const slash = model.indexOf('/');
|
|
37
|
+
if (slash > 0 && slash < model.length - 1) {
|
|
38
|
+
return { provider: model.slice(0, slash), model: model.slice(slash + 1) };
|
|
39
|
+
}
|
|
40
|
+
return { provider, model };
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
/**
|
|
44
|
+
* 造一个 callLLM 给引擎用。
|
|
45
|
+
* @param opts.bridgeDir bridge 发现文件所在目录(<dir>/data/dsh-bridge.json),省略用 bridge.mjs 默认
|
|
46
|
+
* @param opts.mock true = 离线假调用,不碰 bridge
|
|
47
|
+
* @param opts.mcpPool 可选,提供 toolsFor(node.tools) 与 callTool(server, tool, args)
|
|
48
|
+
* @param opts.onDelta 可选,默认流式回调 onDelta(chunk);引擎会在每次调用时通过
|
|
49
|
+
* callLLM(node, prompt, { onDelta }) 传一个带节点 id 的版本,优先生效
|
|
50
|
+
*/
|
|
51
|
+
export function makeCaller({ bridgeDir, mock, signal, mcpPool, skillDirs, onDelta }) {
|
|
52
|
+
return async function callLLM(node, prompt, ctx = {}) {
|
|
53
|
+
// 每次调用的回调优先(engine 用它把 chunk 绑到节点 id),否则用构造时的默认值;
|
|
54
|
+
// node.onDelta 是兜底:上层包装没透传第三个参数时也能流式。
|
|
55
|
+
const emitDelta =
|
|
56
|
+
typeof ctx?.onDelta === 'function'
|
|
57
|
+
? ctx.onDelta
|
|
58
|
+
: typeof onDelta === 'function'
|
|
59
|
+
? onDelta
|
|
60
|
+
: typeof node?.onDelta === 'function'
|
|
61
|
+
? node.onDelta
|
|
62
|
+
: undefined;
|
|
63
|
+
|
|
64
|
+
const target = targetOf(node);
|
|
65
|
+
// skill 正文拼进 system(每次现读,改文件即时生效)
|
|
66
|
+
const skillPrompt = await composeSkillPrompt(node.skills, skillDirs).catch(() => '');
|
|
67
|
+
|
|
68
|
+
if (mock) {
|
|
69
|
+
return mockCall(node, prompt, target.provider || target.model || 'local', skillPrompt, emitDelta);
|
|
70
|
+
}
|
|
71
|
+
if (!target.model) {
|
|
72
|
+
throw new Error('节点没有配置模型(模型调用只走 DSH 内核,先在节点上选一个模型)');
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
// bridge 没连上就别发请求:说清楚是哪一环断了(发现文件 / pid / health / token)
|
|
76
|
+
const reason = await bridgeUnavailableReason(bridgeDir).catch(
|
|
77
|
+
(err) => `bridge 健康检查失败:${String((err && err.message) || err)}`,
|
|
78
|
+
);
|
|
79
|
+
if (reason) throw new Error(`DSH 内核未连接(${reason})`);
|
|
80
|
+
|
|
81
|
+
// 挂载的 MCP 工具 → bridge 的 tools 定义
|
|
82
|
+
const tools = collectTools(node, mcpPool);
|
|
83
|
+
const system = buildSystem(skillPrompt);
|
|
84
|
+
const messages = [{ role: 'user', content: prompt }];
|
|
85
|
+
const usage = { promptTokens: 0, completionTokens: 0 };
|
|
86
|
+
const requestHead = () => {
|
|
87
|
+
const req = { provider: target.provider, model: target.model, system };
|
|
88
|
+
// 推理强度:空 = 用模型自己的默认档,不发这个字段
|
|
89
|
+
if (node.reasoningEffort) req.reasoningEffort = node.reasoningEffort;
|
|
90
|
+
return req;
|
|
91
|
+
};
|
|
92
|
+
let content = '';
|
|
93
|
+
|
|
94
|
+
for (let round = 0; round < MAX_TOOL_ROUNDS; round++) {
|
|
95
|
+
const turn = await runBridgeTurn(
|
|
96
|
+
{ ...requestHead(), messages, ...(tools.length ? { tools } : {}) },
|
|
97
|
+
{ dir: bridgeDir, signal, onDelta: emitDelta, usage },
|
|
98
|
+
);
|
|
99
|
+
if (!turn.calls.length) {
|
|
100
|
+
content = turn.text.trim();
|
|
101
|
+
break;
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
// 把模型的工具请求原样塞回历史,逐个在本地执行后追加 tool 结果
|
|
105
|
+
messages.push({
|
|
106
|
+
role: 'assistant',
|
|
107
|
+
content: turn.calls.map((call) => ({
|
|
108
|
+
type: 'tool-call',
|
|
109
|
+
id: call.id,
|
|
110
|
+
name: call.name,
|
|
111
|
+
arguments: call.arguments,
|
|
112
|
+
})),
|
|
113
|
+
});
|
|
114
|
+
for (const call of turn.calls) {
|
|
115
|
+
messages.push({
|
|
116
|
+
role: 'tool',
|
|
117
|
+
toolCallId: call.id,
|
|
118
|
+
content: await runToolCall(mcpPool, call.name, call.arguments),
|
|
119
|
+
});
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
// 最后一轮如果还是工具调用,强制走一次收尾(不带工具,免得无限要工具)
|
|
123
|
+
if (round === MAX_TOOL_ROUNDS - 1) {
|
|
124
|
+
const finalTurn = await runBridgeTurn(
|
|
125
|
+
{ ...requestHead(), messages },
|
|
126
|
+
{ dir: bridgeDir, signal, onDelta: emitDelta, usage },
|
|
127
|
+
);
|
|
128
|
+
content = finalTurn.text.trim();
|
|
129
|
+
}
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
return { content, usage };
|
|
133
|
+
};
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
/**
|
|
137
|
+
* 发一轮 /stream,收集本轮的全部事件。
|
|
138
|
+
* @returns {Promise<{text: string, calls: Array<{id:string,name:string,arguments:string}>}>}
|
|
139
|
+
*/
|
|
140
|
+
async function runBridgeTurn(request, { dir, signal, onDelta, usage }) {
|
|
141
|
+
const { events } = await bridgeStream(request, { dir: dir || undefined, signal });
|
|
142
|
+
let text = '';
|
|
143
|
+
const calls = [];
|
|
144
|
+
|
|
145
|
+
for await (const evt of events) {
|
|
146
|
+
if (!evt || typeof evt.type !== 'string') continue;
|
|
147
|
+
if (evt.type === 'delta') {
|
|
148
|
+
const chunk = typeof evt.text === 'string' ? evt.text : '';
|
|
149
|
+
if (!chunk) continue;
|
|
150
|
+
text += chunk;
|
|
151
|
+
if (typeof onDelta === 'function') onDelta(chunk);
|
|
152
|
+
} else if (evt.type === 'tool') {
|
|
153
|
+
calls.push({
|
|
154
|
+
id: String(evt.id || `call_${calls.length + 1}`),
|
|
155
|
+
name: String(evt.name || ''),
|
|
156
|
+
arguments:
|
|
157
|
+
typeof evt.arguments === 'string' ? evt.arguments : JSON.stringify(evt.arguments ?? {}),
|
|
158
|
+
});
|
|
159
|
+
} else if (evt.type === 'usage') {
|
|
160
|
+
usage.promptTokens += Number(evt.promptTokens) || 0;
|
|
161
|
+
usage.completionTokens += Number(evt.completionTokens) || 0;
|
|
162
|
+
}
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
return { text, calls };
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
function buildSystem(skillPrompt) {
|
|
169
|
+
const base = '你是一个执行明确任务的助手。只输出任务要求的内容,不要寒暄。';
|
|
170
|
+
return skillPrompt ? `${base}\n\n${skillPrompt}` : base;
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
/** 把节点挂的 `server.tool` 变成 bridge 的 tools 定义 */
|
|
174
|
+
function collectTools(node, mcpPool) {
|
|
175
|
+
if (!mcpPool || !Array.isArray(node.tools) || !node.tools.length) return [];
|
|
176
|
+
const out = [];
|
|
177
|
+
for (const full of node.tools) {
|
|
178
|
+
const dot = String(full).indexOf('.');
|
|
179
|
+
if (dot < 0) continue;
|
|
180
|
+
const server = full.slice(0, dot);
|
|
181
|
+
const tool = full.slice(dot + 1);
|
|
182
|
+
const def = mcpPool.toolDef(server, tool);
|
|
183
|
+
if (!def) continue; // 工具不存在就跳过,不让整张卡炸掉
|
|
184
|
+
out.push({
|
|
185
|
+
name: fnName(server, tool),
|
|
186
|
+
description: def.description || tool,
|
|
187
|
+
parameters: def.inputSchema || { type: 'object', properties: {} },
|
|
188
|
+
});
|
|
189
|
+
}
|
|
190
|
+
return out;
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
/** 工具名不允许点号,这里做映射 server.tool → server__tool */
|
|
194
|
+
function fnName(server, tool) {
|
|
195
|
+
return String(server).replace(/[^\w-]/g, '_') + '__' + String(tool).replace(/[^\w-]/g, '_');
|
|
196
|
+
}
|
|
197
|
+
|
|
198
|
+
function parseFnName(name) {
|
|
199
|
+
const i = name.indexOf('__');
|
|
200
|
+
if (i < 0) return null;
|
|
201
|
+
return { server: name.slice(0, i), tool: name.slice(i + 2) };
|
|
202
|
+
}
|
|
203
|
+
|
|
204
|
+
async function runToolCall(mcpPool, fnNameRaw, argsJson) {
|
|
205
|
+
if (!mcpPool) return '错误:没有可用的 MCP 连接池';
|
|
206
|
+
const parsed = parseFnName(fnNameRaw);
|
|
207
|
+
if (!parsed) return `错误:函数名 ${fnNameRaw} 不合法`;
|
|
208
|
+
let args = {};
|
|
209
|
+
try {
|
|
210
|
+
args = argsJson ? JSON.parse(argsJson) : {};
|
|
211
|
+
} catch {
|
|
212
|
+
return `错误:工具参数不是合法 JSON:${String(argsJson).slice(0, 200)}`;
|
|
213
|
+
}
|
|
214
|
+
try {
|
|
215
|
+
const text = await mcpPool.callTool(parsed.server, parsed.tool, args);
|
|
216
|
+
return String(text).slice(0, 20000); // 防止超长结果把上下文撑爆
|
|
217
|
+
} catch (err) {
|
|
218
|
+
return `工具执行失败:${err && err.message ? err.message : String(err)}`;
|
|
219
|
+
}
|
|
220
|
+
}
|
|
221
|
+
|
|
222
|
+
/** 假调用:延迟一下,回一段能看出是 mock 的文字;有 onDelta 就按 ~40 字切块流出来。 */
|
|
223
|
+
async function mockCall(node, prompt, providerName, skillPrompt, onDelta) {
|
|
224
|
+
await new Promise((r) => setTimeout(r, MOCK_DELAY_MS + Math.random() * 600));
|
|
225
|
+
const head = (node.task || prompt || '').split('\n')[0].slice(0, 40);
|
|
226
|
+
const lines = [
|
|
227
|
+
`【mock · ${providerName}】${head}`,
|
|
228
|
+
'',
|
|
229
|
+
'(这是假输出,没有真的调模型。原因:当前是 mock 模式)',
|
|
230
|
+
'',
|
|
231
|
+
`收到材料 ${prompt.length} 字。真实调用后,这里会是模型返回的内容。`,
|
|
232
|
+
];
|
|
233
|
+
if (skillPrompt) lines.push('', `已挂载技能:${(node.skills || []).join('、')}`);
|
|
234
|
+
if (node.tools && node.tools.length) {
|
|
235
|
+
lines.push(`已挂载工具:${node.tools.join('、')}(mock 模式下不会真的执行)`);
|
|
236
|
+
}
|
|
237
|
+
const content = lines.join('\n');
|
|
238
|
+
|
|
239
|
+
// mock 也走一遍「打字机」:不传 onDelta 时保持原来的一次性返回
|
|
240
|
+
if (typeof onDelta === 'function') {
|
|
241
|
+
const size = 40;
|
|
242
|
+
for (let i = 0; i < content.length; i += size) {
|
|
243
|
+
await new Promise((r) => setTimeout(r, 30 + Math.random() * 30));
|
|
244
|
+
onDelta(content.slice(i, i + size));
|
|
245
|
+
}
|
|
246
|
+
}
|
|
247
|
+
|
|
248
|
+
return {
|
|
249
|
+
content,
|
|
250
|
+
usage: { promptTokens: Math.ceil(prompt.length / 2), completionTokens: 60 },
|
|
251
|
+
};
|
|
252
|
+
}
|