@dsh-plugin/dsh-synapse 0.1.0-dev.41044228975 → 0.1.0-dev.41051739681
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 +12 -24
- package/lib/host/agent.d.ts +8 -4
- package/lib/host/agent.js +16 -99
- package/lib/host/agent.js.map +1 -1
- package/lib/host/bot.js +9 -7
- package/lib/host/bot.js.map +1 -1
- package/lib/host/config-migration.d.ts +3 -0
- package/lib/host/config-migration.js +103 -0
- package/lib/host/config-migration.js.map +1 -0
- package/lib/host/config.d.ts +4 -18
- package/lib/host/config.js +77 -130
- package/lib/host/config.js.map +1 -1
- package/lib/host/feature-config.d.ts +20 -0
- package/lib/host/feature-config.js +264 -0
- package/lib/host/feature-config.js.map +1 -0
- package/lib/host/gateway.d.ts +4 -6
- package/lib/host/gateway.js +42 -104
- package/lib/host/gateway.js.map +1 -1
- package/lib/host/github.d.ts +2 -2
- package/lib/host/harness.d.ts +23 -0
- package/lib/host/harness.js +209 -0
- package/lib/host/harness.js.map +1 -0
- package/lib/host/index.js +22 -12
- package/lib/host/index.js.map +1 -1
- package/lib/host/mcp.d.ts +1 -1
- package/lib/host/mcp.js +15 -11
- package/lib/host/mcp.js.map +1 -1
- package/lib/host/panel.html +2 -1
- package/lib/host/panel.js +40 -24
- package/lib/host/panel.js.map +1 -1
- package/lib/host/types.d.ts +104 -16
- package/package.json +1 -1
- package/scripts/dump-npm-latest.mjs +29 -10
- package/scripts/host-smoke.mjs +139 -0
- package/src/agent.ts +34 -114
- package/src/bot.ts +9 -7
- package/src/config-migration.ts +148 -0
- package/src/config.ts +116 -152
- package/src/feature-config.ts +351 -0
- package/src/gateway.ts +48 -115
- package/src/harness.ts +253 -0
- package/src/index.ts +25 -18
- package/src/mcp.ts +23 -11
- package/src/panel.html +2 -1
- package/src/panel.ts +53 -24
- package/src/types.ts +75 -13
- package/test/config-migration.test.mjs +137 -0
- package/test/core.test.mjs +82 -16
- package/test/fixtures/host.mjs +133 -0
- package/test/harness.test.mjs +90 -0
- package/test/integration.test.mjs +10 -12
- package/test/panel.test.mjs +71 -3
package/src/gateway.ts
CHANGED
|
@@ -1,10 +1,8 @@
|
|
|
1
1
|
import type { ConfigSource, ErrorSink } from "./types.js";
|
|
2
2
|
import { createServer } from "node:http";
|
|
3
3
|
import { randomUUID, randomBytes } from "node:crypto";
|
|
4
|
-
import { Readable } from "node:stream";
|
|
5
|
-
import { pipeline } from "node:stream/promises";
|
|
6
|
-
import { StringDecoder } from "node:string_decoder";
|
|
7
4
|
import { body, json, equalSecret } from "./http.js";
|
|
5
|
+
import type { ApprovalAnswer } from "./harness.js";
|
|
8
6
|
interface Run {
|
|
9
7
|
created: number;
|
|
10
8
|
events: any[];
|
|
@@ -12,6 +10,8 @@ interface Run {
|
|
|
12
10
|
done: boolean;
|
|
13
11
|
controller: AbortController;
|
|
14
12
|
task?: Promise<void>;
|
|
13
|
+
approval?: (answer: ApprovalAnswer) => void;
|
|
14
|
+
approvalQueue: Promise<unknown>;
|
|
15
15
|
}
|
|
16
16
|
export class Gateway {
|
|
17
17
|
agent: import("./agent.js").Agent;
|
|
@@ -19,8 +19,6 @@ export class Gateway {
|
|
|
19
19
|
onError: ErrorSink;
|
|
20
20
|
token: string;
|
|
21
21
|
runs: Map<string, Run>;
|
|
22
|
-
external: Map<string, { input: any; created: number }>;
|
|
23
|
-
controllers: Set<AbortController>;
|
|
24
22
|
closed: boolean;
|
|
25
23
|
server: import("node:http").Server;
|
|
26
24
|
url: string;
|
|
@@ -36,8 +34,6 @@ export class Gateway {
|
|
|
36
34
|
Object.assign(this, { agent, config, onError });
|
|
37
35
|
this.token = randomBytes(32).toString("hex");
|
|
38
36
|
this.runs = new Map();
|
|
39
|
-
this.external = new Map();
|
|
40
|
-
this.controllers = new Set();
|
|
41
37
|
this.closed = false;
|
|
42
38
|
}
|
|
43
39
|
async start() {
|
|
@@ -58,8 +54,6 @@ export class Gateway {
|
|
|
58
54
|
this.gc = setInterval(() => {
|
|
59
55
|
for (const [id, run] of this.runs)
|
|
60
56
|
if (run.done && Date.now() - run.created > 600000) this.runs.delete(id);
|
|
61
|
-
for (const [id, run] of this.external)
|
|
62
|
-
if (Date.now() - run.created > 3600000) this.external.delete(id);
|
|
63
57
|
}, 60000).unref();
|
|
64
58
|
}
|
|
65
59
|
async handle(req, res) {
|
|
@@ -73,7 +67,6 @@ export class Gateway {
|
|
|
73
67
|
)
|
|
74
68
|
return json(res, { error: "Not found" }, 404);
|
|
75
69
|
const c = this.config();
|
|
76
|
-
if (c.externalHermes) return this.proxy(req, res, url.pathname, c);
|
|
77
70
|
if (url.pathname === "/v1/runs" && req.method === "POST") {
|
|
78
71
|
if (
|
|
79
72
|
[...this.runs.values()].filter((r) => !r.done).length >= 16 ||
|
|
@@ -89,6 +82,7 @@ export class Gateway {
|
|
|
89
82
|
listeners: new Set(),
|
|
90
83
|
done: false,
|
|
91
84
|
controller,
|
|
85
|
+
approvalQueue: Promise.resolve(),
|
|
92
86
|
};
|
|
93
87
|
this.runs.set(id, run);
|
|
94
88
|
const emit = (event, payload) => {
|
|
@@ -110,6 +104,9 @@ export class Gateway {
|
|
|
110
104
|
controller.signal,
|
|
111
105
|
emit,
|
|
112
106
|
data.instructions || "",
|
|
107
|
+
c.modules.chat.approvalEnabled
|
|
108
|
+
? (request) => this.askApproval(run, request, emit)
|
|
109
|
+
: undefined,
|
|
113
110
|
)
|
|
114
111
|
.then(
|
|
115
112
|
(output) => emit("run.completed", { output }),
|
|
@@ -150,113 +147,50 @@ export class Gateway {
|
|
|
150
147
|
run.controller.abort();
|
|
151
148
|
return json(res, { ok: true });
|
|
152
149
|
}
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
);
|
|
158
|
-
}
|
|
159
|
-
async proxy(req, res, pathname, c) {
|
|
160
|
-
let payload;
|
|
161
|
-
const controller = new AbortController();
|
|
162
|
-
this.controllers.add(controller);
|
|
163
|
-
res.on("close", () => controller.abort());
|
|
164
|
-
try {
|
|
165
|
-
if (req.method === "POST")
|
|
166
|
-
payload = JSON.parse((await body(req)).toString() || "{}");
|
|
167
|
-
const input = payload?.input;
|
|
168
|
-
if (pathname === "/v1/runs")
|
|
169
|
-
payload.instructions =
|
|
170
|
-
(await this.agent.context(input, "chat")) +
|
|
171
|
-
"\n\n" +
|
|
172
|
-
(payload.instructions || "");
|
|
173
|
-
const upstream = await fetch(c.hermesUrl.replace(/\/$/, "") + pathname, {
|
|
174
|
-
method: req.method,
|
|
175
|
-
headers: {
|
|
176
|
-
"Content-Type": "application/json",
|
|
177
|
-
Authorization: `Bearer ${c.hermesKey}`,
|
|
178
|
-
},
|
|
179
|
-
body: payload ? JSON.stringify(payload) : undefined,
|
|
180
|
-
signal: AbortSignal.any([
|
|
181
|
-
controller.signal,
|
|
182
|
-
AbortSignal.timeout(c.timeoutSec * 1000),
|
|
183
|
-
]),
|
|
184
|
-
});
|
|
185
|
-
if (pathname === "/v1/runs") {
|
|
186
|
-
const result = await upstream.json();
|
|
187
|
-
if (upstream.ok)
|
|
188
|
-
this.external.set(result.run_id || result.id, {
|
|
189
|
-
input,
|
|
190
|
-
created: Date.now(),
|
|
191
|
-
});
|
|
192
|
-
return json(res, result, upstream.status);
|
|
193
|
-
}
|
|
194
|
-
res.writeHead(upstream.status, {
|
|
195
|
-
"Content-Type":
|
|
196
|
-
upstream.headers.get("content-type") || "application/json",
|
|
197
|
-
"Cache-Control": "no-store",
|
|
198
|
-
});
|
|
199
|
-
if (pathname.endsWith("/events") && upstream.ok) {
|
|
200
|
-
let buffer = "",
|
|
201
|
-
eventName = "";
|
|
202
|
-
const decoder = new StringDecoder("utf8");
|
|
203
|
-
const id = pathname.split("/")[3];
|
|
204
|
-
for await (const chunk of Readable.fromWeb(
|
|
205
|
-
upstream.body as unknown as import("node:stream/web").ReadableStream,
|
|
206
|
-
)) {
|
|
207
|
-
res.write(chunk);
|
|
208
|
-
buffer += decoder.write(chunk);
|
|
209
|
-
let end;
|
|
210
|
-
while ((end = buffer.indexOf("\n")) >= 0) {
|
|
211
|
-
const line = buffer.slice(0, end).trim();
|
|
212
|
-
buffer = buffer.slice(end + 1);
|
|
213
|
-
if (line.startsWith("event:")) {
|
|
214
|
-
eventName = line.slice(6).trim();
|
|
215
|
-
continue;
|
|
216
|
-
}
|
|
217
|
-
if (!line.startsWith("data:")) continue;
|
|
218
|
-
let event;
|
|
219
|
-
try {
|
|
220
|
-
event = JSON.parse(line.slice(5));
|
|
221
|
-
} catch {
|
|
222
|
-
continue;
|
|
223
|
-
}
|
|
224
|
-
if (
|
|
225
|
-
event.event === "run.completed" ||
|
|
226
|
-
event.type === "run.completed" ||
|
|
227
|
-
eventName === "run.completed"
|
|
228
|
-
) {
|
|
229
|
-
const run = this.external.get(id);
|
|
230
|
-
this.external.delete(id);
|
|
231
|
-
if (run) {
|
|
232
|
-
const task = this.agent
|
|
233
|
-
.learn(
|
|
234
|
-
typeof run.input === "string"
|
|
235
|
-
? run.input
|
|
236
|
-
: JSON.stringify(run.input),
|
|
237
|
-
event.output || "",
|
|
238
|
-
"chat",
|
|
239
|
-
this.shutdown.signal,
|
|
240
|
-
)
|
|
241
|
-
.catch(this.onError);
|
|
242
|
-
this.background.add(task);
|
|
243
|
-
void task.finally(() => this.background.delete(task));
|
|
244
|
-
}
|
|
245
|
-
}
|
|
246
|
-
eventName = "";
|
|
247
|
-
}
|
|
248
|
-
}
|
|
249
|
-
res.end();
|
|
250
|
-
} else
|
|
251
|
-
await pipeline(
|
|
252
|
-
Readable.fromWeb(
|
|
253
|
-
upstream.body as unknown as import("node:stream/web").ReadableStream,
|
|
254
|
-
),
|
|
150
|
+
if (action === "approval" && req.method === "POST") {
|
|
151
|
+
const { choice } = JSON.parse((await body(req)).toString());
|
|
152
|
+
if (!["once", "deny"].includes(choice))
|
|
153
|
+
return json(
|
|
255
154
|
res,
|
|
155
|
+
{ error: "DSH 审批仅对本次操作生效,请选择 once 或 deny" },
|
|
156
|
+
400,
|
|
256
157
|
);
|
|
257
|
-
|
|
258
|
-
|
|
158
|
+
if (!run.approval) return json(res, { error: "当前没有待审批请求" }, 409);
|
|
159
|
+
run.approval(choice === "once" ? "allowed-once" : "rejected");
|
|
160
|
+
return json(res, { ok: true });
|
|
259
161
|
}
|
|
162
|
+
return json(res, { error: "Method not allowed" }, 405);
|
|
163
|
+
}
|
|
164
|
+
private askApproval(run: Run, request, emit): Promise<ApprovalAnswer> {
|
|
165
|
+
const task = run.approvalQueue.then(
|
|
166
|
+
() =>
|
|
167
|
+
new Promise<ApprovalAnswer>((resolve) => {
|
|
168
|
+
const signal = AbortSignal.any([
|
|
169
|
+
run.controller.signal,
|
|
170
|
+
...(request.signal ? [request.signal] : []),
|
|
171
|
+
]);
|
|
172
|
+
if (signal.aborted) return resolve("cancelled");
|
|
173
|
+
let timer: NodeJS.Timeout;
|
|
174
|
+
const finish = (answer: ApprovalAnswer) => {
|
|
175
|
+
clearTimeout(timer);
|
|
176
|
+
signal.removeEventListener("abort", abort);
|
|
177
|
+
if (run.approval === finish) run.approval = undefined;
|
|
178
|
+
resolve(answer);
|
|
179
|
+
};
|
|
180
|
+
const abort = () => finish("cancelled");
|
|
181
|
+
run.approval = finish;
|
|
182
|
+
signal.addEventListener("abort", abort, { once: true });
|
|
183
|
+
const seconds = this.config().modules.chat.approvalTimeoutSec;
|
|
184
|
+
if (seconds > 0)
|
|
185
|
+
timer = setTimeout(() => finish("rejected"), seconds * 1000);
|
|
186
|
+
emit("approval.request", {
|
|
187
|
+
command: request.toolName,
|
|
188
|
+
description: `${request.reason || "DSH 工具操作"}(仅支持本次允许 once / 拒绝 deny)`,
|
|
189
|
+
});
|
|
190
|
+
}),
|
|
191
|
+
);
|
|
192
|
+
run.approvalQueue = task.catch(() => {});
|
|
193
|
+
return task;
|
|
260
194
|
}
|
|
261
195
|
async close() {
|
|
262
196
|
this.closed = true;
|
|
@@ -265,7 +199,6 @@ export class Gateway {
|
|
|
265
199
|
for (const run of this.runs.values()) {
|
|
266
200
|
run.controller.abort();
|
|
267
201
|
}
|
|
268
|
-
for (const c of this.controllers) c.abort();
|
|
269
202
|
this.server?.closeAllConnections();
|
|
270
203
|
await new Promise<void>((resolve) =>
|
|
271
204
|
this.server ? this.server.close(() => resolve()) : resolve(),
|
package/src/harness.ts
ADDED
|
@@ -0,0 +1,253 @@
|
|
|
1
|
+
import { randomUUID } from "node:crypto";
|
|
2
|
+
import { mkdir } from "node:fs/promises";
|
|
3
|
+
import path from "node:path";
|
|
4
|
+
import type { HostAPI } from "@dsh-plugin/dsh-loader";
|
|
5
|
+
import type {
|
|
6
|
+
ConfigSource,
|
|
7
|
+
PluginPaths,
|
|
8
|
+
ChatMessage,
|
|
9
|
+
AgentEvent,
|
|
10
|
+
AvailableTool,
|
|
11
|
+
} from "./types.js";
|
|
12
|
+
import type { McpManager } from "./mcp.js";
|
|
13
|
+
|
|
14
|
+
export type ApprovalAnswer = "allowed-once" | "rejected" | "cancelled";
|
|
15
|
+
export type AskApproval = (request: {
|
|
16
|
+
toolName: string;
|
|
17
|
+
reason?: string;
|
|
18
|
+
signal?: AbortSignal;
|
|
19
|
+
}) => Promise<ApprovalAnswer>;
|
|
20
|
+
|
|
21
|
+
/** DSH owns model routing, the tool loop, policies, cancellation and session logs. */
|
|
22
|
+
export class Harness {
|
|
23
|
+
private shutdown = new AbortController();
|
|
24
|
+
private tasks = new Set<Promise<string>>();
|
|
25
|
+
constructor(
|
|
26
|
+
private loader: HostAPI,
|
|
27
|
+
private config: ConfigSource,
|
|
28
|
+
private paths: PluginPaths,
|
|
29
|
+
private mcp: McpManager,
|
|
30
|
+
) {}
|
|
31
|
+
|
|
32
|
+
run(
|
|
33
|
+
system: string,
|
|
34
|
+
input: unknown,
|
|
35
|
+
history: ChatMessage[] = [],
|
|
36
|
+
tools: AvailableTool[] = [],
|
|
37
|
+
signal?: AbortSignal,
|
|
38
|
+
onEvent: AgentEvent = () => {},
|
|
39
|
+
ask?: AskApproval,
|
|
40
|
+
) {
|
|
41
|
+
const task = this.execute(
|
|
42
|
+
system,
|
|
43
|
+
input,
|
|
44
|
+
history,
|
|
45
|
+
tools,
|
|
46
|
+
signal,
|
|
47
|
+
onEvent,
|
|
48
|
+
ask,
|
|
49
|
+
);
|
|
50
|
+
this.tasks.add(task);
|
|
51
|
+
void task.finally(() => this.tasks.delete(task)).catch(() => {});
|
|
52
|
+
return task;
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
private async content(input: unknown): Promise<any[]> {
|
|
56
|
+
if (typeof input === "string") return [{ type: "text", text: input }];
|
|
57
|
+
if (!Array.isArray(input)) throw new Error("聊天输入必须是文本或消息数组");
|
|
58
|
+
const parts = input.flatMap((item) =>
|
|
59
|
+
item.role
|
|
60
|
+
? item.role === "user"
|
|
61
|
+
? typeof item.content === "string"
|
|
62
|
+
? [{ type: "text", text: item.content }]
|
|
63
|
+
: item.content
|
|
64
|
+
: []
|
|
65
|
+
: [item],
|
|
66
|
+
);
|
|
67
|
+
const result = [];
|
|
68
|
+
for (const part of parts) {
|
|
69
|
+
if (part.type === "text" && typeof part.text === "string")
|
|
70
|
+
result.push({ type: "text", text: part.text });
|
|
71
|
+
else if (part.type === "image_url") {
|
|
72
|
+
const match = String(part.image_url?.url).match(
|
|
73
|
+
/^data:(image\/(?:png|jpeg|webp|gif));base64,\s*([A-Za-z0-9+/=\s]+)$/,
|
|
74
|
+
);
|
|
75
|
+
if (!match) throw new Error("图片须由聊天通道先转换为 data URL");
|
|
76
|
+
if (match[2].length > 28_000_000) throw new Error("图片超过大小上限");
|
|
77
|
+
const attachments = this.loader.services.get("attachments");
|
|
78
|
+
if (!attachments?.saveImage) throw new Error("DSH 附件服务不可用");
|
|
79
|
+
result.push({
|
|
80
|
+
type: "image",
|
|
81
|
+
attachment: await attachments.saveImage({
|
|
82
|
+
data: Buffer.from(match[2], "base64"),
|
|
83
|
+
mediaType: match[1],
|
|
84
|
+
}),
|
|
85
|
+
});
|
|
86
|
+
} else throw new Error(`不支持的消息内容:${part.type}`);
|
|
87
|
+
}
|
|
88
|
+
return result;
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
private async execute(
|
|
92
|
+
system: string,
|
|
93
|
+
input: unknown,
|
|
94
|
+
history: ChatMessage[],
|
|
95
|
+
available: AvailableTool[],
|
|
96
|
+
callerSignal: AbortSignal,
|
|
97
|
+
onEvent: AgentEvent,
|
|
98
|
+
ask?: AskApproval,
|
|
99
|
+
): Promise<string> {
|
|
100
|
+
const signal = AbortSignal.any([
|
|
101
|
+
this.shutdown.signal,
|
|
102
|
+
AbortSignal.timeout(this.config().timeoutSec * 1000),
|
|
103
|
+
...(callerSignal ? [callerSignal] : []),
|
|
104
|
+
]);
|
|
105
|
+
signal.throwIfAborted();
|
|
106
|
+
const agents = this.loader.services.get("agents");
|
|
107
|
+
const defaults = this.loader.services.get("agentDefaultModel");
|
|
108
|
+
if (!agents?.create || !defaults?.currentSelection)
|
|
109
|
+
throw new Error("DSH Agent / 默认模型服务不可用,请检查宿主加载状态");
|
|
110
|
+
const selection = defaults.currentSelection();
|
|
111
|
+
if (!selection?.provider || !selection?.model)
|
|
112
|
+
throw new Error("请先在 DSH 的模型设置中选择默认模型");
|
|
113
|
+
const content = await this.content(input);
|
|
114
|
+
const cwd = path.join(this.paths.data, "sessions");
|
|
115
|
+
await mkdir(cwd, { recursive: true });
|
|
116
|
+
let error: Error;
|
|
117
|
+
let output = "",
|
|
118
|
+
attemptText = "";
|
|
119
|
+
let steps = 0;
|
|
120
|
+
const handle = await agents.create({
|
|
121
|
+
sessionId: randomUUID(),
|
|
122
|
+
meta: { cwd },
|
|
123
|
+
agentOptions: selection,
|
|
124
|
+
signal,
|
|
125
|
+
setup: (scope, agent) => {
|
|
126
|
+
const prompt = scope.get("systemPrompt"),
|
|
127
|
+
tools = scope.get("tools");
|
|
128
|
+
if (
|
|
129
|
+
!prompt?.section ||
|
|
130
|
+
!prompt?.variable ||
|
|
131
|
+
!tools?.restrict ||
|
|
132
|
+
!tools?.register
|
|
133
|
+
)
|
|
134
|
+
throw new Error("DSH 提示词或工具作用域服务不可用");
|
|
135
|
+
prompt.variable("synapse_context", () => system);
|
|
136
|
+
prompt.section({
|
|
137
|
+
name: "deployment:persona-prefix",
|
|
138
|
+
order: 0,
|
|
139
|
+
text: "{{synapse_context}}",
|
|
140
|
+
});
|
|
141
|
+
// Preserve the existing channel boundary: only explicitly allowed MCP tools.
|
|
142
|
+
// Actual calls still pass through the DSH executor and permission policies.
|
|
143
|
+
tools.restrict({ allow: [] });
|
|
144
|
+
for (const tool of available)
|
|
145
|
+
tools.register({
|
|
146
|
+
name: tool.name,
|
|
147
|
+
description: tool.description,
|
|
148
|
+
parameters: tool.parameters,
|
|
149
|
+
output: {
|
|
150
|
+
schema: { type: "object", additionalProperties: true },
|
|
151
|
+
render: (_args, value) => [
|
|
152
|
+
{ type: "text", text: JSON.stringify(value).slice(0, 40000) },
|
|
153
|
+
],
|
|
154
|
+
},
|
|
155
|
+
execute: async (args, exec) => {
|
|
156
|
+
const started = Date.now();
|
|
157
|
+
let failed = false;
|
|
158
|
+
onEvent("tool.started", {
|
|
159
|
+
tool: `${tool.server}/${tool.rawName}`,
|
|
160
|
+
});
|
|
161
|
+
try {
|
|
162
|
+
return await this.mcp.call(tool, args, exec.signal);
|
|
163
|
+
} catch (e) {
|
|
164
|
+
failed = true;
|
|
165
|
+
throw e;
|
|
166
|
+
} finally {
|
|
167
|
+
onEvent("tool.completed", {
|
|
168
|
+
tool: `${tool.server}/${tool.rawName}`,
|
|
169
|
+
duration: (Date.now() - started) / 1000,
|
|
170
|
+
error: failed,
|
|
171
|
+
});
|
|
172
|
+
}
|
|
173
|
+
},
|
|
174
|
+
});
|
|
175
|
+
scope.on("agent/pre-step", (event, next) => {
|
|
176
|
+
if (event.agent !== agent) return next();
|
|
177
|
+
if (++steps > this.config().maxToolRounds)
|
|
178
|
+
throw new Error("已达到任务步骤上限");
|
|
179
|
+
return next();
|
|
180
|
+
});
|
|
181
|
+
scope.on("agent/error", (event) => {
|
|
182
|
+
if (event.agent === agent)
|
|
183
|
+
error =
|
|
184
|
+
event.error instanceof Error
|
|
185
|
+
? event.error
|
|
186
|
+
: new Error(String(event.error));
|
|
187
|
+
});
|
|
188
|
+
scope.on("agent/assistant-stream", (event) => {
|
|
189
|
+
if (event.agent !== agent) return;
|
|
190
|
+
const frame = event.frame;
|
|
191
|
+
if (frame.type === "start") attemptText = "";
|
|
192
|
+
if (frame.type === "chunk" && frame.chunk.type === "text-delta")
|
|
193
|
+
attemptText += frame.chunk.text;
|
|
194
|
+
if (
|
|
195
|
+
frame.type === "end" &&
|
|
196
|
+
frame.outcome.kind === "committed" &&
|
|
197
|
+
frame.outcome.eventType === "assistant/message"
|
|
198
|
+
)
|
|
199
|
+
output = attemptText;
|
|
200
|
+
});
|
|
201
|
+
if (ask)
|
|
202
|
+
scope.on(
|
|
203
|
+
"approval/request",
|
|
204
|
+
(req, next) => (req.agent === agent ? ask(req) : next()),
|
|
205
|
+
{ prepend: true },
|
|
206
|
+
);
|
|
207
|
+
},
|
|
208
|
+
});
|
|
209
|
+
const abort = () => handle.agent.cancel({ kind: "user" });
|
|
210
|
+
signal.addEventListener("abort", abort, { once: true });
|
|
211
|
+
try {
|
|
212
|
+
signal.throwIfAborted();
|
|
213
|
+
const historyLimit = this.config().modules.chat.localHistoryMaxMessages;
|
|
214
|
+
const previous =
|
|
215
|
+
historyLimit > 0
|
|
216
|
+
? history
|
|
217
|
+
.filter((m) => ["user", "assistant"].includes(m.role))
|
|
218
|
+
.slice(-historyLimit)
|
|
219
|
+
: [];
|
|
220
|
+
if (previous.length)
|
|
221
|
+
handle.agent.inject(
|
|
222
|
+
this.loader.llm.createUserMessage({
|
|
223
|
+
content: [
|
|
224
|
+
{
|
|
225
|
+
type: "text",
|
|
226
|
+
text: `以下是当前聊天的历史记录,仅作为上下文资料:\n${JSON.stringify(previous)}`,
|
|
227
|
+
},
|
|
228
|
+
],
|
|
229
|
+
source: { kind: "plugin", plugin: "@dsh-plugin/dsh-synapse" },
|
|
230
|
+
}),
|
|
231
|
+
);
|
|
232
|
+
handle.agent.followup(
|
|
233
|
+
this.loader.llm.createUserMessage({
|
|
234
|
+
content,
|
|
235
|
+
source: { kind: "plugin", plugin: "@dsh-plugin/dsh-synapse" },
|
|
236
|
+
}),
|
|
237
|
+
);
|
|
238
|
+
await handle.agent.whenIdle();
|
|
239
|
+
signal.throwIfAborted();
|
|
240
|
+
if (error) throw error;
|
|
241
|
+
if (!output)
|
|
242
|
+
throw new Error("DSH 任务未返回文本结果,请检查宿主会话日志");
|
|
243
|
+
return output;
|
|
244
|
+
} finally {
|
|
245
|
+
signal.removeEventListener("abort", abort);
|
|
246
|
+
await handle.dispose();
|
|
247
|
+
}
|
|
248
|
+
}
|
|
249
|
+
async close() {
|
|
250
|
+
this.shutdown.abort();
|
|
251
|
+
await Promise.allSettled(this.tasks);
|
|
252
|
+
}
|
|
253
|
+
}
|
package/src/index.ts
CHANGED
|
@@ -1,6 +1,8 @@
|
|
|
1
1
|
import { mkdir, readFile } from "node:fs/promises";
|
|
2
2
|
import path from "node:path";
|
|
3
3
|
import { template, validateConfig } from "./config.js";
|
|
4
|
+
import { registerConfig } from "./config-migration.js";
|
|
5
|
+
import { modulesFromFiles } from "./feature-config.js";
|
|
4
6
|
import { pathsFor, SkillStore, safePath } from "./files.js";
|
|
5
7
|
import { Inference } from "./runtime.js";
|
|
6
8
|
import { Memory, parseHermes } from "./memory.js";
|
|
@@ -32,7 +34,15 @@ export async function createService(
|
|
|
32
34
|
config,
|
|
33
35
|
),
|
|
34
36
|
mcp = new McpManager(config);
|
|
35
|
-
const agent = new Agent({
|
|
37
|
+
const agent = new Agent({
|
|
38
|
+
loader: ctx.dshLoader,
|
|
39
|
+
config,
|
|
40
|
+
paths,
|
|
41
|
+
memory,
|
|
42
|
+
skills,
|
|
43
|
+
mcp,
|
|
44
|
+
onError,
|
|
45
|
+
}),
|
|
36
46
|
gateway = new Gateway(agent, config, onError);
|
|
37
47
|
const bot = new Bot(paths, config, gateway, onError),
|
|
38
48
|
reviews = new Reviews(memory.db, agent, config, bot, onError);
|
|
@@ -41,6 +51,7 @@ export async function createService(
|
|
|
41
51
|
reviews.close(),
|
|
42
52
|
bot.stop(),
|
|
43
53
|
gateway.close(),
|
|
54
|
+
agent.close(),
|
|
44
55
|
mcp.close(),
|
|
45
56
|
inference.close(),
|
|
46
57
|
]);
|
|
@@ -71,10 +82,7 @@ export async function createService(
|
|
|
71
82
|
};
|
|
72
83
|
}
|
|
73
84
|
export async function apply(ctx: SynapseContext) {
|
|
74
|
-
const handle = await ctx
|
|
75
|
-
ctx,
|
|
76
|
-
template,
|
|
77
|
-
);
|
|
85
|
+
const handle = await registerConfig(ctx);
|
|
78
86
|
let service,
|
|
79
87
|
disposed = false,
|
|
80
88
|
lastError = "",
|
|
@@ -129,21 +137,20 @@ export async function apply(ctx: SynapseContext) {
|
|
|
129
137
|
res,
|
|
130
138
|
service.memory.list(url.searchParams.get("channel") || "chat"),
|
|
131
139
|
);
|
|
132
|
-
if (req.method === "GET" && action === "features") {
|
|
133
|
-
const live = await service.bot.liveFeatures();
|
|
134
|
-
return json(
|
|
135
|
-
res,
|
|
136
|
-
service.config().features.map((f) => ({
|
|
137
|
-
id: f.id,
|
|
138
|
-
value: {
|
|
139
|
-
...JSON.parse(f.json),
|
|
140
|
-
...(live.find((x) => x.id === f.id)?.value || {}),
|
|
141
|
-
},
|
|
142
|
-
})),
|
|
143
|
-
);
|
|
144
|
-
}
|
|
145
140
|
if (req.method === "POST") {
|
|
146
141
|
const data = JSON.parse((await body(req)).toString() || "{}");
|
|
142
|
+
if (action === "features/refresh") {
|
|
143
|
+
const current = handle.get();
|
|
144
|
+
if (current.revision !== data.expectedRevision)
|
|
145
|
+
return json(res, { error: "配置已变更,请重新载入后同步" }, 409);
|
|
146
|
+
const modules = modulesFromFiles(
|
|
147
|
+
current.value.modules,
|
|
148
|
+
await service.bot.liveFeatures(),
|
|
149
|
+
);
|
|
150
|
+
validateConfig({ ...current.value, modules });
|
|
151
|
+
await handle.update({ modules }, current.revision);
|
|
152
|
+
return json(res, { ok: true });
|
|
153
|
+
}
|
|
147
154
|
if (action === "skills/save")
|
|
148
155
|
return json(
|
|
149
156
|
res,
|
package/src/mcp.ts
CHANGED
|
@@ -15,7 +15,9 @@ export class McpManager {
|
|
|
15
15
|
}
|
|
16
16
|
async connect(name) {
|
|
17
17
|
if (this.closed) throw new Error("MCP 已关闭");
|
|
18
|
-
const spec = this.config().
|
|
18
|
+
const spec = this.config().mcpServers.find(
|
|
19
|
+
(x) => x.name === name && x.enabled,
|
|
20
|
+
);
|
|
19
21
|
if (!spec) throw new Error("MCP 服务未启用");
|
|
20
22
|
if (!this.connections.has(name))
|
|
21
23
|
this.connections.set(
|
|
@@ -26,22 +28,32 @@ export class McpManager {
|
|
|
26
28
|
spec.transport === "stdio"
|
|
27
29
|
? new StdioClientTransport({
|
|
28
30
|
command: spec.command,
|
|
29
|
-
args:
|
|
31
|
+
args: spec.args,
|
|
30
32
|
env: {
|
|
31
33
|
PATH: process.env.PATH ?? "",
|
|
32
34
|
...(process.env.SystemRoot
|
|
33
35
|
? { SystemRoot: process.env.SystemRoot }
|
|
34
36
|
: {}),
|
|
35
|
-
...
|
|
37
|
+
...Object.fromEntries(
|
|
38
|
+
spec.env.map((e) => [e.name, e.value]),
|
|
39
|
+
),
|
|
36
40
|
},
|
|
37
41
|
stderr: "ignore",
|
|
38
42
|
})
|
|
39
43
|
: spec.transport === "sse"
|
|
40
44
|
? new SSEClientTransport(new URL(spec.url), {
|
|
41
|
-
requestInit: {
|
|
45
|
+
requestInit: {
|
|
46
|
+
headers: Object.fromEntries(
|
|
47
|
+
spec.headers.map((e) => [e.name, e.value]),
|
|
48
|
+
),
|
|
49
|
+
},
|
|
42
50
|
})
|
|
43
51
|
: new StreamableHTTPClientTransport(new URL(spec.url), {
|
|
44
|
-
requestInit: {
|
|
52
|
+
requestInit: {
|
|
53
|
+
headers: Object.fromEntries(
|
|
54
|
+
spec.headers.map((e) => [e.name, e.value]),
|
|
55
|
+
),
|
|
56
|
+
},
|
|
45
57
|
});
|
|
46
58
|
try {
|
|
47
59
|
await client.connect(transport, { timeout: 15000 });
|
|
@@ -75,8 +87,8 @@ export class McpManager {
|
|
|
75
87
|
}
|
|
76
88
|
async available(): Promise<AvailableTool[]> {
|
|
77
89
|
const result: AvailableTool[] = [];
|
|
78
|
-
for (const spec of this.config().
|
|
79
|
-
const allowed = new Set(spec.allowedTools
|
|
90
|
+
for (const spec of this.config().mcpServers.filter((s) => s.enabled)) {
|
|
91
|
+
const allowed = new Set(spec.allowedTools);
|
|
80
92
|
if (!allowed.size) continue;
|
|
81
93
|
for (const tool of await this.list(spec.name))
|
|
82
94
|
if (allowed.has(tool.name))
|
|
@@ -90,16 +102,16 @@ export class McpManager {
|
|
|
90
102
|
}
|
|
91
103
|
return result;
|
|
92
104
|
}
|
|
93
|
-
async call(tool, args) {
|
|
94
|
-
const spec = this.config().
|
|
105
|
+
async call(tool, args, signal?: AbortSignal) {
|
|
106
|
+
const spec = this.config().mcpServers.find(
|
|
95
107
|
(s) => s.name === tool.server && s.enabled,
|
|
96
108
|
);
|
|
97
|
-
if (!spec?.allowedTools.
|
|
109
|
+
if (!spec?.allowedTools.includes(tool.rawName))
|
|
98
110
|
throw new Error("工具不在允许列表");
|
|
99
111
|
return (await this.connect(tool.server)).callTool(
|
|
100
112
|
{ name: tool.rawName, arguments: args },
|
|
101
113
|
undefined,
|
|
102
|
-
{ timeout: 60000 },
|
|
114
|
+
{ timeout: 60000, signal },
|
|
103
115
|
);
|
|
104
116
|
}
|
|
105
117
|
async close() {
|
package/src/panel.html
CHANGED
|
@@ -166,7 +166,8 @@
|
|
|
166
166
|
<div id="content"></div>
|
|
167
167
|
<footer class="muted">
|
|
168
168
|
配置保存在当前 DSH
|
|
169
|
-
profile
|
|
169
|
+
profile。本地记忆模型首次使用时下载;聊天与审查记忆分开检索。<br />AGPL-3.0
|
|
170
|
+
·
|
|
170
171
|
<a href="https://github.com/dsh-plugins/dsh-synapse">获取完整源码</a>
|
|
171
172
|
</footer>
|
|
172
173
|
</main>
|