@chenacc/agentbridge 0.0.0-stage → 0.1.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.
@@ -0,0 +1,413 @@
1
+ import { randomUUID } from "node:crypto";
2
+ import { lines, probeCommand, spawnCommand } from "./process-utils.js";
3
+
4
+ const REQUEST_TIMEOUT_MS = 30_000;
5
+
6
+ function rpcError(error, method) {
7
+ const result = new Error(error?.message ?? ("DeepSeek Harness request failed: " + method));
8
+ result.rpcCode = error?.code;
9
+ return result;
10
+ }
11
+
12
+ function safePayload(value) {
13
+ const serialized = JSON.stringify(value ?? {});
14
+ return serialized.length <= 12_000 ? value ?? {} : { truncated: true, preview: serialized.slice(0, 12_000) };
15
+ }
16
+
17
+ function toolSummary(update) {
18
+ return {
19
+ id: update?.toolCallId ?? null,
20
+ type: update?.kind ?? "tool",
21
+ status: update?.status ?? null,
22
+ command: update?.rawInput?.command ?? null,
23
+ title: update?.title ?? null,
24
+ };
25
+ }
26
+
27
+ function clipped(value, limit) {
28
+ return typeof value === "string" ? value.slice(0, limit) : "";
29
+ }
30
+
31
+ /**
32
+ * Project an ACP `elicitation/create` form schema onto the phone shape.
33
+ *
34
+ * ACP form schemas are flat objects of primitive or enum properties, so each
35
+ * property becomes one question: enums become options, everything else becomes
36
+ * a text field. `message` is the agent's own overall prompt and rides along on
37
+ * every question, because the phone card has no separate place for it.
38
+ */
39
+ export function elicitationQuestions(params) {
40
+ const schema = params?.requestedSchema;
41
+ const properties = schema && typeof schema.properties === "object" && schema.properties ? schema.properties : {};
42
+ const message = clipped(params?.message, 2_000);
43
+ const required = new Set(Array.isArray(schema?.required) ? schema.required.map(String) : []);
44
+ return Object.entries(properties).slice(0, 10).flatMap(([key, property]) => {
45
+ if (!property || typeof property !== "object") return [];
46
+ const enumValues = Array.isArray(property.enum)
47
+ ? property.enum.slice(0, 12).map((value) => String(value))
48
+ : Array.isArray(property.items?.enum)
49
+ ? property.items.enum.slice(0, 12).map((value) => String(value))
50
+ : [];
51
+ const names = Array.isArray(property.enumNames) ? property.enumNames.map((value) => String(value)) : [];
52
+ const options = enumValues.map((value, index) => ({ label: value, ...(names[index] ? { description: clipped(names[index], 200) } : {}) }));
53
+ if (property.type === "boolean") {
54
+ options.length = 0;
55
+ options.push({ label: "是" }, { label: "否" });
56
+ }
57
+ const title = clipped(property.title, 200) || key;
58
+ const description = clipped(property.description, 500);
59
+ return [{
60
+ id: key,
61
+ header: title,
62
+ question: [message, description].filter(Boolean).join(" · ") || title,
63
+ ...(options.length ? { options } : {}),
64
+ ...(property.type === "array" ? { multiSelect: true } : {}),
65
+ }];
66
+ });
67
+ }
68
+
69
+ /**
70
+ * Convert the phone's answer batch into an ACP `accept` content object.
71
+ *
72
+ * Values are typed from the schema rather than trusted as strings: an ACP agent
73
+ * validates `content` again, and a "true" that stays a string fails its check.
74
+ */
75
+ export function elicitationContent(params, answers) {
76
+ const properties = params?.requestedSchema?.properties ?? {};
77
+ const required = new Set(Array.isArray(params?.requestedSchema?.required) ? params.requestedSchema.required.map(String) : []);
78
+ const byId = new Map((Array.isArray(answers) ? answers : []).map((answer) => [answer.id, answer]));
79
+ const content = {};
80
+ for (const [key, property] of Object.entries(properties)) {
81
+ const answer = byId.get(key) ?? { selected: [] };
82
+ const selected = Array.isArray(answer.selected) ? answer.selected : [];
83
+ const custom = String(answer.custom ?? "").trim();
84
+ const raw = selected[0] ?? custom;
85
+ if (!selected.length && !custom) {
86
+ if (required.has(key)) throw Object.assign(new Error(`The agent requires an answer for "${clipped(property?.title, 200) || key}"`), { statusCode: 400, code: "INPUT_ANSWER_INVALID" });
87
+ continue;
88
+ }
89
+ if (property?.type === "boolean") {
90
+ content[key] = raw === "是" || raw === "true";
91
+ } else if (property?.type === "array") {
92
+ content[key] = selected.length ? selected : [custom];
93
+ } else if (property?.type === "number" || property?.type === "integer") {
94
+ const parsed = Number(raw);
95
+ if (!Number.isFinite(parsed)) throw Object.assign(new Error(`"${clipped(property?.title, 200) || key}" must be a number`), { statusCode: 400, code: "INPUT_ANSWER_INVALID" });
96
+ content[key] = property.type === "integer" ? Math.trunc(parsed) : parsed;
97
+ } else {
98
+ content[key] = raw;
99
+ }
100
+ }
101
+ return content;
102
+ }
103
+
104
+ export function acpUpdateToEvent(update) {
105
+ if (update?.sessionUpdate === "agent_message_chunk" && update.content?.type === "text") {
106
+ return { kind: "message.delta", payload: { role: "assistant", text: update.content.text ?? "" } };
107
+ }
108
+ if (update?.sessionUpdate === "tool_call") return { kind: "tool.started", payload: toolSummary(update) };
109
+ if (update?.sessionUpdate === "tool_call_update") {
110
+ const done = ["completed", "failed", "cancelled"].includes(update.status);
111
+ return { kind: done ? "tool.completed" : "tool.started", payload: toolSummary(update) };
112
+ }
113
+ return null;
114
+ }
115
+
116
+ export class DeepSeekHarnessAdapter {
117
+ id = "deepseek-harness";
118
+ label = "DeepSeek Harness";
119
+ capabilities = {
120
+ create: true,
121
+ attach: true,
122
+ discover: false,
123
+ streaming: true,
124
+ approve: true,
125
+ answer: true,
126
+ resume: true,
127
+ cancel: true,
128
+ selectModel: false,
129
+ realAgent: true,
130
+ };
131
+ #spawnProcess;
132
+ #probe;
133
+ #handles = new Map();
134
+
135
+ constructor({ spawnProcess = spawnCommand, probe = probeCommand } = {}) {
136
+ this.#spawnProcess = spawnProcess;
137
+ this.#probe = probe;
138
+ }
139
+
140
+ async detect() {
141
+ const result = await this.#probe("dsh");
142
+ if (!result.available) {
143
+ return {
144
+ ...result,
145
+ supported: false,
146
+ note: "未检测到官方 dsh。请先按 DeepSeek Harness 官方文档安装并配置模型,然后重启 AgentBridge。",
147
+ docsUrl: "https://deepseek-harness.github.io/deepseek-harness/en/guide/quickstart",
148
+ };
149
+ }
150
+ return {
151
+ ...result,
152
+ supported: true,
153
+ note: "通过官方 dsh --profile acp 创建独立会话;输出为 Harness 已提交的回答块,模型与凭据由本机 Harness 配置决定。",
154
+ docsUrl: "https://deepseek-harness.github.io/deepseek-harness/en/guide/quickstart",
155
+ };
156
+ }
157
+
158
+ async start(context) {
159
+ const handle = await this.#connect(context);
160
+ const result = await this.#request(handle, "session/new", {
161
+ cwd: context.cwd,
162
+ additionalDirectories: [],
163
+ mcpServers: [],
164
+ });
165
+ handle.providerSessionId = result?.sessionId;
166
+ if (!handle.providerSessionId) throw new Error("DeepSeek Harness did not return a session id");
167
+ this.#handles.set(handle.providerSessionId, handle);
168
+ return handle;
169
+ }
170
+
171
+ async attach(context, providerSessionId) {
172
+ const handle = await this.#connect(context);
173
+ const result = await this.#request(handle, "session/resume", {
174
+ sessionId: providerSessionId,
175
+ cwd: context.cwd,
176
+ additionalDirectories: [],
177
+ mcpServers: [],
178
+ });
179
+ handle.providerSessionId = result?.sessionId ?? providerSessionId;
180
+ this.#handles.set(handle.providerSessionId, handle);
181
+ return handle;
182
+ }
183
+
184
+ async send(handle, { text, attachments = [], emit }) {
185
+ if (handle.closed) throw Object.assign(new Error("DeepSeek Harness ACP process is not running"), { statusCode: 409 });
186
+ if (handle.activeTurnId) throw Object.assign(new Error("DeepSeek Harness is already running a turn"), { statusCode: 409 });
187
+ const turnId = randomUUID();
188
+ handle.emit = emit;
189
+ handle.activeTurnId = turnId;
190
+ handle.messageText = "";
191
+ handle.messageStarted = false;
192
+ await emit("turn.started", { adapter: this.id }, { turnId });
193
+ try {
194
+ const blocks = [];
195
+ if (text) blocks.push({ type: "text", text });
196
+ for (const attachment of attachments) {
197
+ // ACP carries images as their own content block; other files travel as a
198
+ // path the agent can read, because the protocol has no file block.
199
+ if (attachment.isImage && attachment.dataBase64) {
200
+ blocks.push({ type: "image", data: attachment.dataBase64, mimeType: attachment.mimeType });
201
+ } else {
202
+ blocks.push({ type: "text", text: `附件 ${attachment.name} 的路径:${attachment.path}` });
203
+ }
204
+ }
205
+ if (!blocks.length) blocks.push({ type: "text", text: "" });
206
+ const result = await this.#request(handle, "session/prompt", {
207
+ sessionId: handle.providerSessionId,
208
+ prompt: blocks,
209
+ });
210
+ const stopReason = result?.stopReason ?? "end_turn";
211
+ if (handle.messageStarted || handle.messageText) {
212
+ await emit("message.completed", { role: "assistant", text: handle.messageText }, { turnId });
213
+ }
214
+ await emit(stopReason === "cancelled" ? "turn.cancelled" : "turn.completed", { status: stopReason }, { turnId });
215
+ } finally {
216
+ handle.activeTurnId = null;
217
+ }
218
+ }
219
+
220
+ async stop(handle) {
221
+ if (!handle?.activeTurnId || handle.closed) return false;
222
+ this.#notify(handle, "session/cancel", { sessionId: handle.providerSessionId });
223
+ return true;
224
+ }
225
+
226
+ async resolveApproval(handle, approvalId, decision) {
227
+ const approval = handle.approvals.get(approvalId);
228
+ if (!approval) throw Object.assign(new Error("Approval is missing or already resolved"), { statusCode: 409 });
229
+ handle.approvals.delete(approvalId);
230
+ const allow = approval.options.find((option) => option?.kind === "allow_once") ?? approval.options.find((option) => /^allow/.test(option?.kind ?? ""));
231
+ const result = decision === "approve" && allow?.optionId
232
+ ? { outcome: { outcome: "selected", optionId: allow.optionId } }
233
+ : { outcome: { outcome: "cancelled" } };
234
+ this.#send(handle, { jsonrpc: "2.0", id: approval.rpcId, result });
235
+ return true;
236
+ }
237
+
238
+ // ACP's answer to "ask the human" is `elicitation/create`; the reply is an
239
+ // action plus schema-typed content, not a free-form message.
240
+ async resolveInput(handle, requestId, { answers = [], cancel = false } = {}) {
241
+ const pending = handle.inputs.get(requestId);
242
+ if (!pending) throw Object.assign(new Error("This question is missing or already answered"), { statusCode: 409 });
243
+ if (cancel) {
244
+ handle.inputs.delete(requestId);
245
+ this.#send(handle, { jsonrpc: "2.0", id: pending.rpcId, result: { action: "decline" } });
246
+ return true;
247
+ }
248
+ // Content is validated before the request is retired: an unusable answer
249
+ // must leave the question answerable instead of consuming it.
250
+ const content = elicitationContent(pending.params, answers);
251
+ handle.inputs.delete(requestId);
252
+ this.#send(handle, { jsonrpc: "2.0", id: pending.rpcId, result: { action: "accept", content } });
253
+ return true;
254
+ }
255
+
256
+ async dispose(handle) {
257
+ if (!handle || handle.closed) return;
258
+ handle.closed = true;
259
+ handle.emit = null;
260
+ handle.child.kill();
261
+ }
262
+
263
+ async #connect(context) {
264
+ const child = this.#spawnProcess("dsh", ["--profile", "acp"], {
265
+ cwd: context.cwd,
266
+ windowsHide: true,
267
+ stdio: ["pipe", "pipe", "pipe"],
268
+ });
269
+ const handle = {
270
+ context,
271
+ child,
272
+ providerSessionId: null,
273
+ activeTurnId: null,
274
+ nextRequestId: 1,
275
+ pending: new Map(),
276
+ approvals: new Map(),
277
+ inputs: new Map(),
278
+ stderr: "",
279
+ messageText: "",
280
+ messageStarted: false,
281
+ emit: null,
282
+ closed: false,
283
+ };
284
+ lines(child.stdout, (line) => this.#onLine(handle, line));
285
+ child.stderr.on("data", (chunk) => { handle.stderr = (handle.stderr + chunk).slice(-8_000); });
286
+ child.on("exit", (code, signal) => {
287
+ handle.closed = true;
288
+ for (const pending of handle.pending.values()) pending.reject(new Error("DeepSeek Harness ACP exited (" + (code ?? signal) + ")"));
289
+ handle.pending.clear();
290
+ if (handle.emit) void handle.emit("adapter.exited", { adapter: this.id, code, signal, stderr: handle.stderr.slice(-2_000) });
291
+ });
292
+ await new Promise((resolve, reject) => {
293
+ child.once("spawn", resolve);
294
+ child.once("error", reject);
295
+ });
296
+ await this.#request(handle, "initialize", {
297
+ protocolVersion: 1,
298
+ // ACP requires each elicitation mode to be advertised explicitly, and an
299
+ // agent must not request a mode the client never offered. Form mode is
300
+ // what the phone can render; URL mode needs a browser hand-off we do not
301
+ // provide, so it stays unadvertised.
302
+ clientCapabilities: { elicitation: { form: {} } },
303
+ });
304
+ return handle;
305
+ }
306
+
307
+ #send(handle, message) {
308
+ if (handle.closed || !handle.child.stdin.writable) throw new Error("DeepSeek Harness ACP stdin is closed");
309
+ handle.child.stdin.write(JSON.stringify(message) + "\n");
310
+ }
311
+
312
+ #notify(handle, method, params) {
313
+ this.#send(handle, { jsonrpc: "2.0", method, params });
314
+ }
315
+
316
+ #request(handle, method, params, timeoutMs = REQUEST_TIMEOUT_MS) {
317
+ const id = handle.nextRequestId++;
318
+ return new Promise((resolve, reject) => {
319
+ const timer = setTimeout(() => {
320
+ handle.pending.delete(id);
321
+ reject(new Error("DeepSeek Harness request timed out: " + method));
322
+ }, timeoutMs);
323
+ handle.pending.set(id, {
324
+ resolve: (value) => { clearTimeout(timer); resolve(value); },
325
+ reject: (error) => { clearTimeout(timer); reject(error); },
326
+ });
327
+ this.#send(handle, { jsonrpc: "2.0", id, method, params });
328
+ });
329
+ }
330
+
331
+ #onLine(handle, line) {
332
+ let message;
333
+ try {
334
+ message = JSON.parse(line);
335
+ } catch {
336
+ if (handle.emit) void handle.emit("adapter.output", { stream: "stdout", text: line }, { turnId: handle.activeTurnId });
337
+ return;
338
+ }
339
+ if (message.id !== undefined && (message.result !== undefined || message.error !== undefined)) {
340
+ const pending = handle.pending.get(message.id);
341
+ if (!pending) return;
342
+ handle.pending.delete(message.id);
343
+ if (message.error) pending.reject(rpcError(message.error, "ACP"));
344
+ else pending.resolve(message.result);
345
+ return;
346
+ }
347
+ if (message.id !== undefined && message.method) {
348
+ this.#onServerRequest(handle, message);
349
+ return;
350
+ }
351
+ if (message.method === "session/update" && handle.emit) this.#onSessionUpdate(handle, message.params ?? {});
352
+ }
353
+
354
+ #onServerRequest(handle, message) {
355
+ if (!handle.emit) {
356
+ this.#send(handle, { jsonrpc: "2.0", id: message.id, error: { code: -32601, message: "Unsupported client request" } });
357
+ return;
358
+ }
359
+ if (message.method === "elicitation/create") {
360
+ const params = message.params ?? {};
361
+ if (params.mode !== "form") {
362
+ // Only form mode is advertised, so a URL-mode request is declined rather
363
+ // than left pending: there is no browser to hand off to from the phone.
364
+ this.#send(handle, { jsonrpc: "2.0", id: message.id, result: { action: "decline" } });
365
+ return;
366
+ }
367
+ const questions = elicitationQuestions(params);
368
+ if (!questions.length) {
369
+ this.#send(handle, { jsonrpc: "2.0", id: message.id, result: { action: "decline" } });
370
+ return;
371
+ }
372
+ const requestId = randomUUID();
373
+ handle.inputs.set(requestId, { rpcId: message.id, params });
374
+ void handle.emit("input.requested", {
375
+ requestId,
376
+ adapter: this.id,
377
+ questions,
378
+ expiresAt: Date.now() + 30 * 60_000,
379
+ }, { turnId: handle.activeTurnId });
380
+ return;
381
+ }
382
+ if (message.method !== "session/request_permission") {
383
+ this.#send(handle, { jsonrpc: "2.0", id: message.id, error: { code: -32601, message: "Unsupported client request" } });
384
+ return;
385
+ }
386
+ const approvalId = randomUUID();
387
+ const params = message.params ?? {};
388
+ handle.approvals.set(approvalId, { rpcId: message.id, options: Array.isArray(params.options) ? params.options : [] });
389
+ void handle.emit("approval.requested", {
390
+ approvalId,
391
+ adapter: this.id,
392
+ method: message.method,
393
+ request: safePayload({ toolCall: params.toolCall, options: params.options }),
394
+ expiresAt: Date.now() + 5 * 60_000,
395
+ }, { turnId: handle.activeTurnId });
396
+ }
397
+
398
+ #onSessionUpdate(handle, params) {
399
+ if (params.sessionId !== handle.providerSessionId) return;
400
+ const mapped = acpUpdateToEvent(params.update);
401
+ if (!mapped) return;
402
+ if (mapped.kind === "message.delta") {
403
+ const text = mapped.payload.text;
404
+ if (!text) return;
405
+ if (!handle.messageStarted) {
406
+ handle.messageStarted = true;
407
+ void handle.emit("message.started", { role: "assistant" }, { turnId: handle.activeTurnId });
408
+ }
409
+ handle.messageText += text;
410
+ }
411
+ void handle.emit(mapped.kind, mapped.payload, { turnId: handle.activeTurnId });
412
+ }
413
+ }