@botlearn-course/daemon 0.0.1 → 0.0.3
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 -1
- package/dist/agent-service-session.d.ts +67 -0
- package/dist/agent-service-session.js +796 -0
- package/dist/agent-service-ws-protocol.d.ts +28 -0
- package/dist/agent-service-ws-protocol.js +128 -0
- package/dist/cli.d.ts +1 -2
- package/dist/cli.js +114 -13
- package/dist/course-client.js +4 -2
- package/dist/index.d.ts +4 -0
- package/dist/index.js +4 -0
- package/dist/mcp/report-progress-server.d.ts +21 -0
- package/dist/mcp/report-progress-server.js +136 -0
- package/dist/mcp/report-progress.d.ts +29 -0
- package/dist/mcp/report-progress.js +60 -0
- package/dist/run-dispatcher.d.ts +15 -0
- package/dist/run-dispatcher.js +126 -6
- package/dist/runtime-env.d.ts +9 -0
- package/dist/runtime-env.js +40 -0
- package/dist/runtime-profile.js +25 -13
- package/dist/runtimes/acp-stream.js +2 -0
- package/dist/runtimes/codex.d.ts +1 -1
- package/dist/runtimes/codex.js +2 -2
- package/dist/runtimes/deepseek-tui.d.ts +6 -2
- package/dist/runtimes/deepseek-tui.js +238 -41
- package/dist/runtimes/engine.d.ts +14 -3
- package/dist/runtimes/engine.js +32 -5
- package/dist/runtimes/hermes-agent.d.ts +1 -1
- package/dist/runtimes/hermes-agent.js +3 -2
- package/dist/runtimes/ndjson-stream.d.ts +1 -1
- package/dist/runtimes/ndjson-stream.js +4 -2
- package/dist/runtimes/openclaw-acp.js +3 -1
- package/dist/runtimes/progress.d.ts +50 -0
- package/dist/runtimes/progress.js +339 -0
- package/dist/sandbox-supervisor.d.ts +3 -0
- package/dist/sandbox-supervisor.js +176 -0
- package/dist/transcript.js +6 -0
- package/dist/types.d.ts +29 -3
- package/dist/websocket-client.d.ts +43 -0
- package/dist/websocket-client.js +320 -0
- package/dist/workspace.d.ts +9 -0
- package/dist/workspace.js +43 -2
- package/package.json +3 -2
|
@@ -2,7 +2,10 @@ import { spawn } from "node:child_process";
|
|
|
2
2
|
import { existsSync, realpathSync } from "node:fs";
|
|
3
3
|
import path from "node:path";
|
|
4
4
|
import net from "node:net";
|
|
5
|
+
import { MAX_PROGRESS_EVENTS_PER_ATTEMPT } from "../mcp/report-progress.js";
|
|
6
|
+
import { runtimeChildEnv, runtimeChildIdentity } from "../runtime-env.js";
|
|
5
7
|
import { readCommandVersion, resolveCommandOnPath } from "./probe.js";
|
|
8
|
+
import { adaptDeepseekProgressStarted, cleanupProgressMcpConfig, createDeepseekProgressState, createProgressMcpConfig, deepseekProgressDispositions, isDeepseekProgressCompletion, progressMcpAutoInjectionSupported, progressSystemContext, } from "./progress.js";
|
|
6
9
|
import { consoleLogger, wrapEngineAdapter, } from "./engine.js";
|
|
7
10
|
const log = consoleLogger;
|
|
8
11
|
const DEEPSEEK_IDLE_TIMEOUT_MS = 5 * 60 * 1000;
|
|
@@ -14,6 +17,7 @@ const PROCESS_POOL = new Map();
|
|
|
14
17
|
/** 单 daemon、单套 env 配置:池里最多一个 server。 */
|
|
15
18
|
const POOL_KEY = "default";
|
|
16
19
|
let exitCleanupHookInstalled = false;
|
|
20
|
+
const DEEPSEEK_TURN_QUEUES = new Map();
|
|
17
21
|
/**
|
|
18
22
|
* daemon 退出时杀掉池中 deepseek server。池是内存态的,没有这个钩子
|
|
19
23
|
* daemon 重启会留下孤儿 server。首次 spawn 时惰性安装,保持模块导入无副作用。
|
|
@@ -64,6 +68,8 @@ export class DeepseekTuiAdapter {
|
|
|
64
68
|
explicitAuthToken;
|
|
65
69
|
fetchFn;
|
|
66
70
|
spawnFn;
|
|
71
|
+
progressEventMappingEnabled;
|
|
72
|
+
progressPromptInjectionEnabled;
|
|
67
73
|
resolvedBinary = null;
|
|
68
74
|
constructor(deps = {}) {
|
|
69
75
|
this.explicitBinary = deps.binary ?? process.env.BOTLEARN_DEEPSEEK_TUI_BIN;
|
|
@@ -71,6 +77,11 @@ export class DeepseekTuiAdapter {
|
|
|
71
77
|
this.explicitAuthToken = deps.authToken ?? process.env.BOTLEARN_DEEPSEEK_TUI_TOKEN;
|
|
72
78
|
this.fetchFn = deps.fetchFn ?? fetch;
|
|
73
79
|
this.spawnFn = deps.spawnFn ?? spawn;
|
|
80
|
+
this.progressPromptInjectionEnabled =
|
|
81
|
+
!this.explicitServerUrl && progressMcpAutoInjectionSupported();
|
|
82
|
+
this.progressEventMappingEnabled = this.explicitServerUrl
|
|
83
|
+
? deps.progressEventMapping === true
|
|
84
|
+
: this.progressPromptInjectionEnabled;
|
|
74
85
|
}
|
|
75
86
|
async run(opts) {
|
|
76
87
|
if (opts.signal.aborted) {
|
|
@@ -80,14 +91,28 @@ export class DeepseekTuiAdapter {
|
|
|
80
91
|
error: "deepseek-tui aborted before start",
|
|
81
92
|
};
|
|
82
93
|
}
|
|
83
|
-
const handle = await this.acquireHandle(opts);
|
|
84
|
-
handle.inFlight += 1;
|
|
85
|
-
if (handle.idleTimer)
|
|
86
|
-
clearTimeout(handle.idleTimer);
|
|
87
94
|
const turnAbort = new AbortController();
|
|
88
95
|
const onAbort = () => turnAbort.abort();
|
|
89
96
|
opts.signal.addEventListener("abort", onAbort, { once: true });
|
|
97
|
+
if (opts.signal.aborted)
|
|
98
|
+
turnAbort.abort();
|
|
99
|
+
let handle;
|
|
100
|
+
let countedInFlight = false;
|
|
101
|
+
let releaseTurn;
|
|
90
102
|
try {
|
|
103
|
+
// The local server has a process-level kill fallback when turn-scoped interrupt
|
|
104
|
+
// fails. Serialize turns so cancelling one run can never terminate another run.
|
|
105
|
+
const turnQueueKey = this.explicitServerUrl
|
|
106
|
+
? `external:${trimTrailingSlash(this.explicitServerUrl)}`
|
|
107
|
+
: POOL_KEY;
|
|
108
|
+
releaseTurn = await acquireDeepseekTurn(turnQueueKey, turnAbort.signal);
|
|
109
|
+
handle = await this.acquireHandle(opts, turnAbort.signal);
|
|
110
|
+
if (turnAbort.signal.aborted)
|
|
111
|
+
throw abortReason(turnAbort.signal);
|
|
112
|
+
handle.inFlight += 1;
|
|
113
|
+
countedInFlight = true;
|
|
114
|
+
if (handle.idleTimer)
|
|
115
|
+
clearTimeout(handle.idleTimer);
|
|
91
116
|
const headers = authHeaders(handle.token);
|
|
92
117
|
let threadId = opts.sessionId?.trim() || "";
|
|
93
118
|
if (threadId && !isValidThreadId(threadId)) {
|
|
@@ -109,12 +134,16 @@ export class DeepseekTuiAdapter {
|
|
|
109
134
|
threadId,
|
|
110
135
|
opts,
|
|
111
136
|
signal: turnAbort.signal,
|
|
137
|
+
handle,
|
|
112
138
|
});
|
|
113
139
|
const text = runResult.text;
|
|
114
140
|
const error = runResult.error ?? (text === "" ? emptyCompletionError(handle.stderrTail) : undefined);
|
|
115
141
|
return {
|
|
116
142
|
text,
|
|
117
143
|
newSessionId: threadId,
|
|
144
|
+
...(runResult.progressDispositions
|
|
145
|
+
? { progressDispositions: runResult.progressDispositions }
|
|
146
|
+
: {}),
|
|
118
147
|
...(error ? { error } : {}),
|
|
119
148
|
};
|
|
120
149
|
}
|
|
@@ -130,9 +159,12 @@ export class DeepseekTuiAdapter {
|
|
|
130
159
|
}
|
|
131
160
|
finally {
|
|
132
161
|
opts.signal.removeEventListener("abort", onAbort);
|
|
133
|
-
handle
|
|
134
|
-
|
|
135
|
-
|
|
162
|
+
if (handle && countedInFlight) {
|
|
163
|
+
handle.inFlight = Math.max(0, handle.inFlight - 1);
|
|
164
|
+
if (!this.explicitServerUrl)
|
|
165
|
+
resetIdle(handle, POOL_KEY);
|
|
166
|
+
}
|
|
167
|
+
releaseTurn?.();
|
|
136
168
|
}
|
|
137
169
|
}
|
|
138
170
|
resolveBinary() {
|
|
@@ -143,7 +175,7 @@ export class DeepseekTuiAdapter {
|
|
|
143
175
|
this.resolvedBinary = resolveDeepseekCommand() ?? "deepseek";
|
|
144
176
|
return this.resolvedBinary;
|
|
145
177
|
}
|
|
146
|
-
async acquireHandle(opts) {
|
|
178
|
+
async acquireHandle(opts, signal) {
|
|
147
179
|
if (this.explicitServerUrl) {
|
|
148
180
|
return {
|
|
149
181
|
child: nullChild(),
|
|
@@ -158,16 +190,38 @@ export class DeepseekTuiAdapter {
|
|
|
158
190
|
if (existing && !existing.closed)
|
|
159
191
|
return existing;
|
|
160
192
|
const port = await findFreePort();
|
|
193
|
+
if (signal.aborted)
|
|
194
|
+
throw abortReason(signal);
|
|
161
195
|
const token = randomToken();
|
|
162
196
|
const baseUrl = `http://127.0.0.1:${port}`;
|
|
163
|
-
const
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
197
|
+
const progressMcpConfig = this.progressPromptInjectionEnabled
|
|
198
|
+
? createProgressMcpConfig()
|
|
199
|
+
: undefined;
|
|
200
|
+
let child;
|
|
201
|
+
try {
|
|
202
|
+
child = this.spawnFn(this.resolveBinary(), [
|
|
203
|
+
"serve",
|
|
204
|
+
"--http",
|
|
205
|
+
"--host",
|
|
206
|
+
"127.0.0.1",
|
|
207
|
+
"--port",
|
|
208
|
+
String(port),
|
|
209
|
+
"--auth-token",
|
|
210
|
+
token,
|
|
211
|
+
], {
|
|
212
|
+
cwd: opts.cwd,
|
|
213
|
+
env: this.spawnEnv(opts, progressMcpConfig?.path),
|
|
214
|
+
...runtimeChildIdentity(),
|
|
215
|
+
stdio: ["ignore", "pipe", "pipe"],
|
|
216
|
+
// 自成进程组:解析到的二进制可能是会再 spawn 真实 deepseek-tui
|
|
217
|
+
// server 的 dispatcher,shutdown 必须对整组发信号而非仅直接子进程。
|
|
218
|
+
detached: true,
|
|
219
|
+
});
|
|
220
|
+
}
|
|
221
|
+
catch (error) {
|
|
222
|
+
cleanupProgressMcpConfig(progressMcpConfig);
|
|
223
|
+
throw error;
|
|
224
|
+
}
|
|
171
225
|
installExitCleanupHook();
|
|
172
226
|
const handle = {
|
|
173
227
|
child,
|
|
@@ -176,6 +230,7 @@ export class DeepseekTuiAdapter {
|
|
|
176
230
|
closed: false,
|
|
177
231
|
inFlight: 0,
|
|
178
232
|
stderrTail: "",
|
|
233
|
+
progressMcpConfig,
|
|
179
234
|
};
|
|
180
235
|
child.stderr?.setEncoding("utf8");
|
|
181
236
|
child.stderr?.on("data", (chunk) => {
|
|
@@ -183,13 +238,25 @@ export class DeepseekTuiAdapter {
|
|
|
183
238
|
});
|
|
184
239
|
child.on("close", () => {
|
|
185
240
|
handle.closed = true;
|
|
186
|
-
PROCESS_POOL.
|
|
241
|
+
if (PROCESS_POOL.get(POOL_KEY) === handle)
|
|
242
|
+
PROCESS_POOL.delete(POOL_KEY);
|
|
243
|
+
cleanupProgressMcpConfig(handle.progressMcpConfig);
|
|
244
|
+
handle.progressMcpConfig = undefined;
|
|
187
245
|
});
|
|
188
246
|
child.on("error", () => {
|
|
189
247
|
handle.closed = true;
|
|
190
|
-
PROCESS_POOL.
|
|
248
|
+
if (PROCESS_POOL.get(POOL_KEY) === handle)
|
|
249
|
+
PROCESS_POOL.delete(POOL_KEY);
|
|
250
|
+
cleanupProgressMcpConfig(handle.progressMcpConfig);
|
|
251
|
+
handle.progressMcpConfig = undefined;
|
|
191
252
|
});
|
|
192
|
-
|
|
253
|
+
try {
|
|
254
|
+
await waitForHealth(baseUrl, this.fetchFn, child, STARTUP_TIMEOUT_MS, signal);
|
|
255
|
+
}
|
|
256
|
+
catch (error) {
|
|
257
|
+
shutdownHandle(handle, "startup-failed");
|
|
258
|
+
throw error;
|
|
259
|
+
}
|
|
193
260
|
PROCESS_POOL.set(POOL_KEY, handle);
|
|
194
261
|
resetIdle(handle, POOL_KEY);
|
|
195
262
|
return handle;
|
|
@@ -198,12 +265,15 @@ export class DeepseekTuiAdapter {
|
|
|
198
265
|
* 不设置 DEEPSEEK_RUNTIME_DIR:server 跨 run 池化共享,per-run 目录不成立;
|
|
199
266
|
* BYOA 直接用用户本机 deepseek 自身的默认状态目录(含已登录凭据)。
|
|
200
267
|
*/
|
|
201
|
-
spawnEnv() {
|
|
202
|
-
|
|
203
|
-
...process.env,
|
|
268
|
+
spawnEnv(opts, progressMcpConfigPath) {
|
|
269
|
+
const env = {
|
|
270
|
+
...runtimeChildEnv(opts.env ?? process.env),
|
|
204
271
|
FORCE_COLOR: "0",
|
|
205
272
|
NO_COLOR: "1",
|
|
206
273
|
};
|
|
274
|
+
if (progressMcpConfigPath)
|
|
275
|
+
env.DEEPSEEK_MCP_CONFIG = progressMcpConfigPath;
|
|
276
|
+
return env;
|
|
207
277
|
}
|
|
208
278
|
async createThread(baseUrl, headers, opts, signal) {
|
|
209
279
|
const body = {
|
|
@@ -219,8 +289,11 @@ export class DeepseekTuiAdapter {
|
|
|
219
289
|
body.model = selection.model;
|
|
220
290
|
if (selection.reasoningEffort)
|
|
221
291
|
body.reasoning_effort = selection.reasoningEffort;
|
|
222
|
-
|
|
223
|
-
|
|
292
|
+
const systemContext = this.progressPromptInjectionEnabled
|
|
293
|
+
? progressSystemContext(opts.systemContext)
|
|
294
|
+
: opts.systemContext;
|
|
295
|
+
if (systemContext)
|
|
296
|
+
body.system_prompt = systemContext;
|
|
224
297
|
const res = await this.requestJson(`${baseUrl}/v1/threads`, {
|
|
225
298
|
method: "POST",
|
|
226
299
|
headers,
|
|
@@ -236,16 +309,37 @@ export class DeepseekTuiAdapter {
|
|
|
236
309
|
await this.requestJson(`${baseUrl}/v1/threads/${encodeURIComponent(threadId)}`, {
|
|
237
310
|
method: "PATCH",
|
|
238
311
|
headers,
|
|
239
|
-
body: JSON.stringify({
|
|
312
|
+
body: JSON.stringify({
|
|
313
|
+
system_prompt: this.progressPromptInjectionEnabled
|
|
314
|
+
? progressSystemContext(systemContext)
|
|
315
|
+
: (systemContext ?? ""),
|
|
316
|
+
}),
|
|
240
317
|
signal,
|
|
241
318
|
});
|
|
242
319
|
}
|
|
243
320
|
async startTurnAndReadEvents(args) {
|
|
244
|
-
const { baseUrl, headers, threadId, opts, signal } = args;
|
|
321
|
+
const { baseUrl, headers, threadId, opts, signal, handle } = args;
|
|
245
322
|
// 事件流必须先于 turn 打开,否则 turn 早期事件会丢。
|
|
246
323
|
const eventsUrl = `${baseUrl}/v1/threads/${encodeURIComponent(threadId)}/events?since_seq=0`;
|
|
247
324
|
const eventsAbort = new AbortController();
|
|
248
|
-
|
|
325
|
+
let turnId = "";
|
|
326
|
+
let interruptPromise;
|
|
327
|
+
const interrupt = () => {
|
|
328
|
+
if (!turnId)
|
|
329
|
+
return Promise.resolve();
|
|
330
|
+
interruptPromise ??= this.interruptTurn(baseUrl, headers, threadId, turnId).catch((error) => {
|
|
331
|
+
log.warn("deepseek-tui turn interrupt failed", {
|
|
332
|
+
error: error instanceof Error ? error.message : String(error),
|
|
333
|
+
});
|
|
334
|
+
if (!this.explicitServerUrl)
|
|
335
|
+
shutdownHandle(handle, "turn-interrupt-failed");
|
|
336
|
+
});
|
|
337
|
+
return interruptPromise;
|
|
338
|
+
};
|
|
339
|
+
const onAbort = () => {
|
|
340
|
+
eventsAbort.abort();
|
|
341
|
+
void interrupt();
|
|
342
|
+
};
|
|
249
343
|
signal.addEventListener("abort", onAbort, { once: true });
|
|
250
344
|
let eventsError;
|
|
251
345
|
const eventsReaderPromise = this.readEvents(eventsUrl, headers, opts, eventsAbort.signal).catch((err) => {
|
|
@@ -265,23 +359,44 @@ export class DeepseekTuiAdapter {
|
|
|
265
359
|
body.model = selection.model;
|
|
266
360
|
if (selection.reasoningEffort)
|
|
267
361
|
body.reasoning_effort = selection.reasoningEffort;
|
|
362
|
+
if (signal.aborted)
|
|
363
|
+
throw abortReason(signal);
|
|
268
364
|
const started = await this.requestJson(`${baseUrl}/v1/threads/${encodeURIComponent(threadId)}/turns`, {
|
|
269
365
|
method: "POST",
|
|
270
366
|
headers,
|
|
271
367
|
body: JSON.stringify(body),
|
|
272
|
-
|
|
368
|
+
// Once the request is sent, keep a short independent timeout so we can obtain
|
|
369
|
+
// turnId and explicitly interrupt it even if the outer run is cancelled.
|
|
370
|
+
signal: AbortSignal.timeout(5_000),
|
|
273
371
|
});
|
|
274
|
-
|
|
372
|
+
turnId = stringField(started?.turn, "id") ?? stringField(started, "turn_id") ?? "";
|
|
373
|
+
if (signal.aborted)
|
|
374
|
+
await interrupt();
|
|
275
375
|
const eventsReader = await eventsReaderPromise;
|
|
276
376
|
if (!eventsReader)
|
|
277
377
|
throw eventsError ?? new Error("events stream failed");
|
|
278
378
|
return await eventsReader(turnId);
|
|
279
379
|
}
|
|
280
380
|
finally {
|
|
381
|
+
if (signal.aborted) {
|
|
382
|
+
if (turnId)
|
|
383
|
+
await interrupt();
|
|
384
|
+
else if (!this.explicitServerUrl)
|
|
385
|
+
shutdownHandle(handle, "cancelled-before-turn-id");
|
|
386
|
+
}
|
|
281
387
|
eventsAbort.abort();
|
|
282
388
|
signal.removeEventListener("abort", onAbort);
|
|
283
389
|
}
|
|
284
390
|
}
|
|
391
|
+
async interruptTurn(baseUrl, headers, threadId, turnId) {
|
|
392
|
+
const res = await this.fetchFn(`${baseUrl}/v1/threads/${encodeURIComponent(threadId)}/turns/${encodeURIComponent(turnId)}/interrupt`, {
|
|
393
|
+
method: "POST",
|
|
394
|
+
headers,
|
|
395
|
+
signal: AbortSignal.timeout(2_000),
|
|
396
|
+
});
|
|
397
|
+
if (!res.ok)
|
|
398
|
+
throw new Error(`interrupt failed HTTP ${res.status}`);
|
|
399
|
+
}
|
|
285
400
|
async readEvents(url, headers, opts, signal) {
|
|
286
401
|
const res = await this.fetchFn(url, { method: "GET", headers, signal });
|
|
287
402
|
if (!res.ok)
|
|
@@ -296,6 +411,7 @@ export class DeepseekTuiAdapter {
|
|
|
296
411
|
let text = "";
|
|
297
412
|
let errorText = "";
|
|
298
413
|
let capped = false;
|
|
414
|
+
const progressState = createDeepseekProgressState();
|
|
299
415
|
const append = (chunk) => {
|
|
300
416
|
if (!chunk || capped)
|
|
301
417
|
return;
|
|
@@ -317,10 +433,30 @@ export class DeepseekTuiAdapter {
|
|
|
317
433
|
if (turnId && eventTurnId && eventTurnId !== turnId)
|
|
318
434
|
return false;
|
|
319
435
|
seq += 1;
|
|
320
|
-
const
|
|
436
|
+
const toolStarted = eventName === "tool.started" || isToolStarted(eventName, payload);
|
|
437
|
+
const toolCompleted = eventName === "tool.completed" || isToolCompleted(eventName, payload);
|
|
438
|
+
const progressStarted = toolStarted && this.progressEventMappingEnabled
|
|
439
|
+
? adaptDeepseekProgressStarted(payload, seq, progressState)
|
|
440
|
+
: { matched: false };
|
|
441
|
+
if (progressStarted.limitExceeded) {
|
|
442
|
+
log.warn("deepseek-tui progress event limit exceeded", {
|
|
443
|
+
limit: MAX_PROGRESS_EVENTS_PER_ATTEMPT,
|
|
444
|
+
});
|
|
445
|
+
}
|
|
446
|
+
const suppressProgressResult = this.progressEventMappingEnabled &&
|
|
447
|
+
toolCompleted &&
|
|
448
|
+
isDeepseekProgressCompletion(payload, progressState);
|
|
449
|
+
const block = progressStarted.matched
|
|
450
|
+
? (progressStarted.block ?? null)
|
|
451
|
+
: suppressProgressResult
|
|
452
|
+
? null
|
|
453
|
+
: normalizeDeepseekEvent(eventName, payload, seq);
|
|
321
454
|
if (block)
|
|
322
455
|
opts.onBlock?.(block);
|
|
323
|
-
|
|
456
|
+
// report_progress is non-authoritative telemetry: its tool failure never fails the task.
|
|
457
|
+
const extractedError = suppressProgressResult
|
|
458
|
+
? undefined
|
|
459
|
+
: extractDeepseekError(eventName, payload);
|
|
324
460
|
if (extractedError)
|
|
325
461
|
errorText = extractedError;
|
|
326
462
|
if (eventName === "message.delta") {
|
|
@@ -332,7 +468,7 @@ export class DeepseekTuiAdapter {
|
|
|
332
468
|
if (eventName === "turn.started" || embeddedDeepseekEvent(payload) === "turn.started") {
|
|
333
469
|
opts.onStatus?.({ kind: "thinking", phase: "started", label: "Thinking" });
|
|
334
470
|
}
|
|
335
|
-
else if (
|
|
471
|
+
else if (toolStarted && !progressStarted.matched) {
|
|
336
472
|
const label = stringField(payload, "name") ??
|
|
337
473
|
stringField(payload?.tool, "name") ??
|
|
338
474
|
stringField(payload?.payload?.tool, "name") ??
|
|
@@ -359,7 +495,12 @@ export class DeepseekTuiAdapter {
|
|
|
359
495
|
continue;
|
|
360
496
|
if (emit(frame.event, frame.data)) {
|
|
361
497
|
await reader.cancel().catch(() => undefined);
|
|
362
|
-
|
|
498
|
+
const progressDispositions = deepseekProgressDispositions(progressState);
|
|
499
|
+
return {
|
|
500
|
+
text: text.trim(),
|
|
501
|
+
...(errorText ? { error: errorText } : {}),
|
|
502
|
+
...(progressDispositions ? { progressDispositions } : {}),
|
|
503
|
+
};
|
|
363
504
|
}
|
|
364
505
|
}
|
|
365
506
|
}
|
|
@@ -368,7 +509,12 @@ export class DeepseekTuiAdapter {
|
|
|
368
509
|
if (frame)
|
|
369
510
|
emit(frame.event, frame.data);
|
|
370
511
|
}
|
|
371
|
-
|
|
512
|
+
const progressDispositions = deepseekProgressDispositions(progressState);
|
|
513
|
+
return {
|
|
514
|
+
text: text.trim(),
|
|
515
|
+
...(errorText ? { error: errorText } : {}),
|
|
516
|
+
...(progressDispositions ? { progressDispositions } : {}),
|
|
517
|
+
};
|
|
372
518
|
};
|
|
373
519
|
}
|
|
374
520
|
async requestJson(url, init) {
|
|
@@ -395,6 +541,7 @@ export function __resetDeepseekTuiPoolForTests() {
|
|
|
395
541
|
shutdownHandle(handle, "test-reset");
|
|
396
542
|
PROCESS_POOL.delete(key);
|
|
397
543
|
}
|
|
544
|
+
DEEPSEEK_TURN_QUEUES.clear();
|
|
398
545
|
}
|
|
399
546
|
function normalizeDeepseekEvent(eventName, payload, seq) {
|
|
400
547
|
if (eventName === "message.delta") {
|
|
@@ -490,11 +637,11 @@ function extractDeepseekError(eventName, payload) {
|
|
|
490
637
|
stringField(payload?.payload, "message") ??
|
|
491
638
|
stringField(payload?.payload, "error"));
|
|
492
639
|
}
|
|
493
|
-
|
|
494
|
-
|
|
495
|
-
|
|
496
|
-
|
|
497
|
-
|
|
640
|
+
// An item failure is a tool result inside an otherwise live turn. DeepSeek may use it
|
|
641
|
+
// to correct the arguments, choose another tool, and still complete the turn. Treating
|
|
642
|
+
// its detail as a sticky runtime error would discard that recovery and fail the run
|
|
643
|
+
// even after a later assistant message or successful turn completion. Only provider
|
|
644
|
+
// errors and terminal turn failures are run-level errors here.
|
|
498
645
|
if (isDeepseekTerminalEvent(eventName, payload)) {
|
|
499
646
|
const turn = payload?.payload?.turn ?? payload?.turn;
|
|
500
647
|
const status = stringField(turn, "status");
|
|
@@ -564,6 +711,47 @@ function nextArgValue(args, index) {
|
|
|
564
711
|
return next;
|
|
565
712
|
return /^-\d/.test(next) ? next : undefined;
|
|
566
713
|
}
|
|
714
|
+
async function acquireDeepseekTurn(key, signal) {
|
|
715
|
+
let released = false;
|
|
716
|
+
let releaseGate;
|
|
717
|
+
const gate = new Promise((resolve) => {
|
|
718
|
+
releaseGate = resolve;
|
|
719
|
+
});
|
|
720
|
+
const previous = DEEPSEEK_TURN_QUEUES.get(key) ?? Promise.resolve();
|
|
721
|
+
const tail = previous.then(() => gate);
|
|
722
|
+
DEEPSEEK_TURN_QUEUES.set(key, tail);
|
|
723
|
+
void tail.then(() => {
|
|
724
|
+
if (DEEPSEEK_TURN_QUEUES.get(key) === tail)
|
|
725
|
+
DEEPSEEK_TURN_QUEUES.delete(key);
|
|
726
|
+
});
|
|
727
|
+
const release = () => {
|
|
728
|
+
if (released)
|
|
729
|
+
return;
|
|
730
|
+
released = true;
|
|
731
|
+
releaseGate();
|
|
732
|
+
};
|
|
733
|
+
let rejectAbort;
|
|
734
|
+
const aborted = new Promise((_resolve, reject) => {
|
|
735
|
+
rejectAbort = reject;
|
|
736
|
+
});
|
|
737
|
+
const onAbort = () => rejectAbort(abortReason(signal));
|
|
738
|
+
signal.addEventListener("abort", onAbort, { once: true });
|
|
739
|
+
if (signal.aborted)
|
|
740
|
+
onAbort();
|
|
741
|
+
try {
|
|
742
|
+
await Promise.race([previous, aborted]);
|
|
743
|
+
if (signal.aborted)
|
|
744
|
+
throw abortReason(signal);
|
|
745
|
+
return release;
|
|
746
|
+
}
|
|
747
|
+
catch (error) {
|
|
748
|
+
release();
|
|
749
|
+
throw error;
|
|
750
|
+
}
|
|
751
|
+
finally {
|
|
752
|
+
signal.removeEventListener("abort", onAbort);
|
|
753
|
+
}
|
|
754
|
+
}
|
|
567
755
|
function resetIdle(handle, key) {
|
|
568
756
|
if (handle.idleTimer)
|
|
569
757
|
clearTimeout(handle.idleTimer);
|
|
@@ -584,6 +772,8 @@ function shutdownHandle(handle, reason) {
|
|
|
584
772
|
handle.closed = true;
|
|
585
773
|
if (handle.idleTimer)
|
|
586
774
|
clearTimeout(handle.idleTimer);
|
|
775
|
+
cleanupProgressMcpConfig(handle.progressMcpConfig);
|
|
776
|
+
handle.progressMcpConfig = undefined;
|
|
587
777
|
try {
|
|
588
778
|
const pid = handle.child.pid;
|
|
589
779
|
if (typeof pid === "number" && pid > 0) {
|
|
@@ -614,20 +804,24 @@ function shutdownHandle(handle, reason) {
|
|
|
614
804
|
}
|
|
615
805
|
log.debug("deepseek-tui.shutdown", { reason });
|
|
616
806
|
}
|
|
617
|
-
async function waitForHealth(baseUrl, fetchFn, child, timeoutMs) {
|
|
807
|
+
async function waitForHealth(baseUrl, fetchFn, child, timeoutMs, signal) {
|
|
618
808
|
const deadline = Date.now() + timeoutMs;
|
|
619
809
|
let lastError = "";
|
|
620
810
|
while (Date.now() < deadline) {
|
|
811
|
+
if (signal.aborted)
|
|
812
|
+
throw abortReason(signal);
|
|
621
813
|
if (child.exitCode !== null) {
|
|
622
814
|
throw new Error(`deepseek serve exited with code ${child.exitCode}`);
|
|
623
815
|
}
|
|
624
816
|
try {
|
|
625
|
-
const res = await fetchFn(`${baseUrl}/health`, { method: "GET" });
|
|
817
|
+
const res = await fetchFn(`${baseUrl}/health`, { method: "GET", signal });
|
|
626
818
|
if (res.ok)
|
|
627
819
|
return;
|
|
628
820
|
lastError = `HTTP ${res.status}`;
|
|
629
821
|
}
|
|
630
822
|
catch (err) {
|
|
823
|
+
if (signal.aborted)
|
|
824
|
+
throw abortReason(signal);
|
|
631
825
|
lastError = err instanceof Error ? err.message : String(err);
|
|
632
826
|
}
|
|
633
827
|
await sleep(STARTUP_POLL_MS);
|
|
@@ -655,6 +849,9 @@ function randomToken() {
|
|
|
655
849
|
function sleep(ms) {
|
|
656
850
|
return new Promise((resolve) => setTimeout(resolve, ms));
|
|
657
851
|
}
|
|
852
|
+
function abortReason(signal) {
|
|
853
|
+
return signal.reason instanceof Error ? signal.reason : new Error("deepseek-tui aborted");
|
|
854
|
+
}
|
|
658
855
|
function stringField(obj, key) {
|
|
659
856
|
const v = obj?.[key];
|
|
660
857
|
return typeof v === "string" ? v : undefined;
|
|
@@ -1,15 +1,23 @@
|
|
|
1
|
-
import { type CourseRuntime, type RuntimeFailureSummary } from "../types.js";
|
|
1
|
+
import { type CourseRuntime, type RuntimeFailureSummary, type RuntimeProgressDispositions } from "../types.js";
|
|
2
2
|
import type { Logger } from "../log.js";
|
|
3
|
+
import type { ProgressReport } from "../mcp/report-progress.js";
|
|
3
4
|
/**
|
|
4
5
|
* 内部引擎契约:CLI/ACP 基类实现的是 pull-style options + 回调,
|
|
5
6
|
* 由 wrapEngineAdapter 折叠成对外的 CourseRuntime(sink 语义)。
|
|
6
7
|
*/
|
|
7
|
-
|
|
8
|
-
export interface StreamBlock {
|
|
8
|
+
export interface ContentStreamBlock {
|
|
9
9
|
raw: unknown;
|
|
10
10
|
kind: "assistant_text" | "tool_use" | "tool_result" | "system" | "thinking" | "other";
|
|
11
11
|
seq: number;
|
|
12
12
|
}
|
|
13
|
+
/** provider 已严格校验的进度块;禁止携带原始 tool envelope。 */
|
|
14
|
+
export interface ProgressStreamBlock {
|
|
15
|
+
kind: "progress";
|
|
16
|
+
seq: number;
|
|
17
|
+
progress: ProgressReport;
|
|
18
|
+
}
|
|
19
|
+
/** 底层 CLI 逐事件归一化出的块。seq 为本轮 1-based 单调递增。 */
|
|
20
|
+
export type StreamBlock = ContentStreamBlock | ProgressStreamBlock;
|
|
13
21
|
export type RuntimeStatusEvent = {
|
|
14
22
|
kind: "typing";
|
|
15
23
|
phase: "started" | "stopped";
|
|
@@ -29,11 +37,14 @@ export interface EngineRunOptions {
|
|
|
29
37
|
systemContext?: string;
|
|
30
38
|
onBlock?: (block: StreamBlock) => void;
|
|
31
39
|
onStatus?: (event: RuntimeStatusEvent) => void;
|
|
40
|
+
env?: NodeJS.ProcessEnv;
|
|
32
41
|
}
|
|
33
42
|
export interface EngineRunResult {
|
|
34
43
|
text: string;
|
|
35
44
|
newSessionId: string;
|
|
36
45
|
costUsd?: number;
|
|
46
|
+
/** adapter 自身在 emit 前丢弃的进度计数;不包含 accepted,避免 dispatcher 重复计数。 */
|
|
47
|
+
progressDispositions?: RuntimeProgressDispositions;
|
|
37
48
|
/** 非空表示硬失败;由包装层折叠为 RuntimeExecutionError。 */
|
|
38
49
|
error?: string;
|
|
39
50
|
runtimeFailure?: Partial<RuntimeFailureSummary>;
|
package/dist/runtimes/engine.js
CHANGED
|
@@ -100,24 +100,51 @@ export function wrapEngineAdapter(id, engine, opts) {
|
|
|
100
100
|
...(model ? modelArgs(model) : []),
|
|
101
101
|
...selectionArgs,
|
|
102
102
|
];
|
|
103
|
+
let blockChain = Promise.resolve();
|
|
104
|
+
let progressBlockFailed = false;
|
|
105
|
+
let progressBlockError;
|
|
106
|
+
const queueBlock = (block, propagateFailure = false) => {
|
|
107
|
+
blockChain = blockChain.then(() => sink.block(block)).catch((err) => {
|
|
108
|
+
if (propagateFailure && !progressBlockFailed) {
|
|
109
|
+
progressBlockFailed = true;
|
|
110
|
+
progressBlockError = err;
|
|
111
|
+
}
|
|
112
|
+
consoleLogger.debug(`${id} sink.block failed`, { err: String(err) });
|
|
113
|
+
});
|
|
114
|
+
};
|
|
103
115
|
const result = await engine.run({
|
|
104
116
|
text,
|
|
105
|
-
sessionId: null,
|
|
117
|
+
sessionId: run.nativeSessionId ?? null,
|
|
106
118
|
cwd: run.workspaceDir,
|
|
107
119
|
signal,
|
|
120
|
+
...(run.runtimeEnv ? { env: run.runtimeEnv } : {}),
|
|
108
121
|
...(extraArgs.length > 0 ? { extraArgs } : {}),
|
|
109
122
|
...(systemContext !== undefined ? { systemContext } : {}),
|
|
110
123
|
onBlock: (block) => {
|
|
124
|
+
if (block.kind === "progress") {
|
|
125
|
+
queueBlock({
|
|
126
|
+
kind: "progress",
|
|
127
|
+
runtime: id,
|
|
128
|
+
summary: block.progress.summary,
|
|
129
|
+
status: block.progress.status,
|
|
130
|
+
}, true);
|
|
131
|
+
return;
|
|
132
|
+
}
|
|
111
133
|
const kind = BLOCK_KIND_MAP[block.kind] ?? "status";
|
|
112
|
-
|
|
113
|
-
void sink.block({ kind, raw: block.raw }).catch((err) => {
|
|
114
|
-
consoleLogger.debug(`${id} sink.block failed`, { err: String(err) });
|
|
115
|
-
});
|
|
134
|
+
queueBlock({ kind, raw: block.raw });
|
|
116
135
|
},
|
|
117
136
|
onStatus: (event) => {
|
|
118
137
|
consoleLogger.debug(`${id} status`, { kind: event.kind, phase: event.phase });
|
|
119
138
|
},
|
|
120
139
|
});
|
|
140
|
+
// Preserve provider event order through durable run.block before the final run.message.
|
|
141
|
+
await blockChain;
|
|
142
|
+
if (progressBlockFailed)
|
|
143
|
+
throw progressBlockError;
|
|
144
|
+
if (result.progressDispositions) {
|
|
145
|
+
await sink.progressDispositions?.(result.progressDispositions);
|
|
146
|
+
}
|
|
147
|
+
await sink.runtimeSession?.(result.newSessionId);
|
|
121
148
|
if (result.error) {
|
|
122
149
|
throw new RuntimeExecutionError(result.error, "runtime_error", result.runtimeFailure);
|
|
123
150
|
}
|
|
@@ -41,7 +41,7 @@ export declare class HermesAgentAdapter extends AcpRuntimeAdapter {
|
|
|
41
41
|
* 用户 `~/.hermes` 的 `.env` / config.yaml 里。
|
|
42
42
|
*/
|
|
43
43
|
protected buildArgs(_opts: EngineRunOptions): string[];
|
|
44
|
-
protected spawnEnv(
|
|
44
|
+
protected spawnEnv(opts: EngineRunOptions): NodeJS.ProcessEnv;
|
|
45
45
|
/** spawn 前把 systemContext 原子写入 `<cwd>/AGENTS.md`(tmp 0600 + rename)。 */
|
|
46
46
|
protected prepareTurn(opts: EngineRunOptions): void;
|
|
47
47
|
/**
|
|
@@ -3,6 +3,7 @@ import path from "node:path";
|
|
|
3
3
|
import { AcpRuntimeAdapter, } from "./acp-stream.js";
|
|
4
4
|
import { firstExistingPath, readCommandVersion, resolveCommandOnPath, resolveHomePath, } from "./probe.js";
|
|
5
5
|
import { wrapEngineAdapter, } from "./engine.js";
|
|
6
|
+
import { runtimeChildEnv } from "../runtime-env.js";
|
|
6
7
|
/**
|
|
7
8
|
* `hermes-acp` 不在 PATH 上时的已知绝对位置。上游 `scripts/install.sh`
|
|
8
9
|
* (curl|bash 安装器)把私有 virtualenv 装到 `~/.hermes/hermes-agent/venv/`,
|
|
@@ -80,9 +81,9 @@ export class HermesAgentAdapter extends AcpRuntimeAdapter {
|
|
|
80
81
|
buildArgs(_opts) {
|
|
81
82
|
return [];
|
|
82
83
|
}
|
|
83
|
-
spawnEnv(
|
|
84
|
+
spawnEnv(opts) {
|
|
84
85
|
return {
|
|
85
|
-
...process.env,
|
|
86
|
+
...runtimeChildEnv(opts.env ?? process.env),
|
|
86
87
|
// 保持 ACP stdout 无 ANSI 码。
|
|
87
88
|
NO_COLOR: "1",
|
|
88
89
|
// 危险工具调用走 ACP request_permission。
|
|
@@ -46,6 +46,6 @@ export declare abstract class NdjsonStreamAdapter implements EngineAdapter {
|
|
|
46
46
|
protected abstract buildArgs(opts: EngineRunOptions): string[];
|
|
47
47
|
protected abstract handleEvent(obj: unknown, ctx: NdjsonEventCtx): void;
|
|
48
48
|
/** 覆盖以调整 env(FORCE_COLOR=0、NO_COLOR=1 等)。 */
|
|
49
|
-
protected spawnEnv(
|
|
49
|
+
protected spawnEnv(opts: EngineRunOptions): NodeJS.ProcessEnv;
|
|
50
50
|
run(opts: EngineRunOptions): Promise<EngineRunResult>;
|
|
51
51
|
}
|
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import { spawn } from "node:child_process";
|
|
2
|
+
import { runtimeChildEnv, runtimeChildIdentity } from "../runtime-env.js";
|
|
2
3
|
import { safeCommand, sanitizeRuntimeFailureText, tailText } from "../redaction.js";
|
|
3
4
|
import { sliceUtf8Bytes, utf8ByteLength } from "./text-cap.js";
|
|
4
5
|
import { consoleLogger, } from "./engine.js";
|
|
@@ -21,8 +22,8 @@ export class NdjsonStreamAdapter {
|
|
|
21
22
|
this.log = logger ?? consoleLogger;
|
|
22
23
|
}
|
|
23
24
|
/** 覆盖以调整 env(FORCE_COLOR=0、NO_COLOR=1 等)。 */
|
|
24
|
-
spawnEnv(
|
|
25
|
-
return
|
|
25
|
+
spawnEnv(opts) {
|
|
26
|
+
return runtimeChildEnv(opts.env ?? process.env);
|
|
26
27
|
}
|
|
27
28
|
async run(opts) {
|
|
28
29
|
if (opts.signal.aborted) {
|
|
@@ -43,6 +44,7 @@ export class NdjsonStreamAdapter {
|
|
|
43
44
|
const child = spawn(binary, args, {
|
|
44
45
|
cwd: opts.cwd,
|
|
45
46
|
env: this.spawnEnv(opts),
|
|
47
|
+
...runtimeChildIdentity(),
|
|
46
48
|
stdio: ["ignore", "pipe", "pipe"],
|
|
47
49
|
});
|
|
48
50
|
// spawn 是同步的,但若在 spawn 与稍后挂监听之间发生 abort 会被漏掉,
|