@botlearn-course/daemon 0.0.1 → 0.0.2
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 +3 -1
- package/dist/cli.d.ts +1 -2
- package/dist/cli.js +2 -11
- package/dist/course-client.js +4 -2
- package/dist/index.d.ts +2 -0
- package/dist/index.js +2 -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.js +84 -0
- package/dist/runtime-env.d.ts +4 -0
- package/dist/runtime-env.js +17 -0
- package/dist/runtimes/deepseek-tui.d.ts +6 -2
- package/dist/runtimes/deepseek-tui.js +232 -36
- package/dist/runtimes/engine.d.ts +13 -3
- package/dist/runtimes/engine.js +29 -4
- package/dist/runtimes/progress.d.ts +50 -0
- package/dist/runtimes/progress.js +339 -0
- package/dist/transcript.js +6 -0
- package/dist/types.d.ts +20 -2
- package/package.json +1 -1
|
@@ -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 } 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,37 @@ 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(progressMcpConfig?.path),
|
|
214
|
+
stdio: ["ignore", "pipe", "pipe"],
|
|
215
|
+
// 自成进程组:解析到的二进制可能是会再 spawn 真实 deepseek-tui
|
|
216
|
+
// server 的 dispatcher,shutdown 必须对整组发信号而非仅直接子进程。
|
|
217
|
+
detached: true,
|
|
218
|
+
});
|
|
219
|
+
}
|
|
220
|
+
catch (error) {
|
|
221
|
+
cleanupProgressMcpConfig(progressMcpConfig);
|
|
222
|
+
throw error;
|
|
223
|
+
}
|
|
171
224
|
installExitCleanupHook();
|
|
172
225
|
const handle = {
|
|
173
226
|
child,
|
|
@@ -176,6 +229,7 @@ export class DeepseekTuiAdapter {
|
|
|
176
229
|
closed: false,
|
|
177
230
|
inFlight: 0,
|
|
178
231
|
stderrTail: "",
|
|
232
|
+
progressMcpConfig,
|
|
179
233
|
};
|
|
180
234
|
child.stderr?.setEncoding("utf8");
|
|
181
235
|
child.stderr?.on("data", (chunk) => {
|
|
@@ -183,13 +237,25 @@ export class DeepseekTuiAdapter {
|
|
|
183
237
|
});
|
|
184
238
|
child.on("close", () => {
|
|
185
239
|
handle.closed = true;
|
|
186
|
-
PROCESS_POOL.
|
|
240
|
+
if (PROCESS_POOL.get(POOL_KEY) === handle)
|
|
241
|
+
PROCESS_POOL.delete(POOL_KEY);
|
|
242
|
+
cleanupProgressMcpConfig(handle.progressMcpConfig);
|
|
243
|
+
handle.progressMcpConfig = undefined;
|
|
187
244
|
});
|
|
188
245
|
child.on("error", () => {
|
|
189
246
|
handle.closed = true;
|
|
190
|
-
PROCESS_POOL.
|
|
247
|
+
if (PROCESS_POOL.get(POOL_KEY) === handle)
|
|
248
|
+
PROCESS_POOL.delete(POOL_KEY);
|
|
249
|
+
cleanupProgressMcpConfig(handle.progressMcpConfig);
|
|
250
|
+
handle.progressMcpConfig = undefined;
|
|
191
251
|
});
|
|
192
|
-
|
|
252
|
+
try {
|
|
253
|
+
await waitForHealth(baseUrl, this.fetchFn, child, STARTUP_TIMEOUT_MS, signal);
|
|
254
|
+
}
|
|
255
|
+
catch (error) {
|
|
256
|
+
shutdownHandle(handle, "startup-failed");
|
|
257
|
+
throw error;
|
|
258
|
+
}
|
|
193
259
|
PROCESS_POOL.set(POOL_KEY, handle);
|
|
194
260
|
resetIdle(handle, POOL_KEY);
|
|
195
261
|
return handle;
|
|
@@ -198,12 +264,15 @@ export class DeepseekTuiAdapter {
|
|
|
198
264
|
* 不设置 DEEPSEEK_RUNTIME_DIR:server 跨 run 池化共享,per-run 目录不成立;
|
|
199
265
|
* BYOA 直接用用户本机 deepseek 自身的默认状态目录(含已登录凭据)。
|
|
200
266
|
*/
|
|
201
|
-
spawnEnv() {
|
|
202
|
-
|
|
203
|
-
...
|
|
267
|
+
spawnEnv(progressMcpConfigPath) {
|
|
268
|
+
const env = {
|
|
269
|
+
...runtimeChildEnv(),
|
|
204
270
|
FORCE_COLOR: "0",
|
|
205
271
|
NO_COLOR: "1",
|
|
206
272
|
};
|
|
273
|
+
if (progressMcpConfigPath)
|
|
274
|
+
env.DEEPSEEK_MCP_CONFIG = progressMcpConfigPath;
|
|
275
|
+
return env;
|
|
207
276
|
}
|
|
208
277
|
async createThread(baseUrl, headers, opts, signal) {
|
|
209
278
|
const body = {
|
|
@@ -219,8 +288,11 @@ export class DeepseekTuiAdapter {
|
|
|
219
288
|
body.model = selection.model;
|
|
220
289
|
if (selection.reasoningEffort)
|
|
221
290
|
body.reasoning_effort = selection.reasoningEffort;
|
|
222
|
-
|
|
223
|
-
|
|
291
|
+
const systemContext = this.progressPromptInjectionEnabled
|
|
292
|
+
? progressSystemContext(opts.systemContext)
|
|
293
|
+
: opts.systemContext;
|
|
294
|
+
if (systemContext)
|
|
295
|
+
body.system_prompt = systemContext;
|
|
224
296
|
const res = await this.requestJson(`${baseUrl}/v1/threads`, {
|
|
225
297
|
method: "POST",
|
|
226
298
|
headers,
|
|
@@ -236,16 +308,37 @@ export class DeepseekTuiAdapter {
|
|
|
236
308
|
await this.requestJson(`${baseUrl}/v1/threads/${encodeURIComponent(threadId)}`, {
|
|
237
309
|
method: "PATCH",
|
|
238
310
|
headers,
|
|
239
|
-
body: JSON.stringify({
|
|
311
|
+
body: JSON.stringify({
|
|
312
|
+
system_prompt: this.progressPromptInjectionEnabled
|
|
313
|
+
? progressSystemContext(systemContext)
|
|
314
|
+
: (systemContext ?? ""),
|
|
315
|
+
}),
|
|
240
316
|
signal,
|
|
241
317
|
});
|
|
242
318
|
}
|
|
243
319
|
async startTurnAndReadEvents(args) {
|
|
244
|
-
const { baseUrl, headers, threadId, opts, signal } = args;
|
|
320
|
+
const { baseUrl, headers, threadId, opts, signal, handle } = args;
|
|
245
321
|
// 事件流必须先于 turn 打开,否则 turn 早期事件会丢。
|
|
246
322
|
const eventsUrl = `${baseUrl}/v1/threads/${encodeURIComponent(threadId)}/events?since_seq=0`;
|
|
247
323
|
const eventsAbort = new AbortController();
|
|
248
|
-
|
|
324
|
+
let turnId = "";
|
|
325
|
+
let interruptPromise;
|
|
326
|
+
const interrupt = () => {
|
|
327
|
+
if (!turnId)
|
|
328
|
+
return Promise.resolve();
|
|
329
|
+
interruptPromise ??= this.interruptTurn(baseUrl, headers, threadId, turnId).catch((error) => {
|
|
330
|
+
log.warn("deepseek-tui turn interrupt failed", {
|
|
331
|
+
error: error instanceof Error ? error.message : String(error),
|
|
332
|
+
});
|
|
333
|
+
if (!this.explicitServerUrl)
|
|
334
|
+
shutdownHandle(handle, "turn-interrupt-failed");
|
|
335
|
+
});
|
|
336
|
+
return interruptPromise;
|
|
337
|
+
};
|
|
338
|
+
const onAbort = () => {
|
|
339
|
+
eventsAbort.abort();
|
|
340
|
+
void interrupt();
|
|
341
|
+
};
|
|
249
342
|
signal.addEventListener("abort", onAbort, { once: true });
|
|
250
343
|
let eventsError;
|
|
251
344
|
const eventsReaderPromise = this.readEvents(eventsUrl, headers, opts, eventsAbort.signal).catch((err) => {
|
|
@@ -265,23 +358,44 @@ export class DeepseekTuiAdapter {
|
|
|
265
358
|
body.model = selection.model;
|
|
266
359
|
if (selection.reasoningEffort)
|
|
267
360
|
body.reasoning_effort = selection.reasoningEffort;
|
|
361
|
+
if (signal.aborted)
|
|
362
|
+
throw abortReason(signal);
|
|
268
363
|
const started = await this.requestJson(`${baseUrl}/v1/threads/${encodeURIComponent(threadId)}/turns`, {
|
|
269
364
|
method: "POST",
|
|
270
365
|
headers,
|
|
271
366
|
body: JSON.stringify(body),
|
|
272
|
-
|
|
367
|
+
// Once the request is sent, keep a short independent timeout so we can obtain
|
|
368
|
+
// turnId and explicitly interrupt it even if the outer run is cancelled.
|
|
369
|
+
signal: AbortSignal.timeout(5_000),
|
|
273
370
|
});
|
|
274
|
-
|
|
371
|
+
turnId = stringField(started?.turn, "id") ?? stringField(started, "turn_id") ?? "";
|
|
372
|
+
if (signal.aborted)
|
|
373
|
+
await interrupt();
|
|
275
374
|
const eventsReader = await eventsReaderPromise;
|
|
276
375
|
if (!eventsReader)
|
|
277
376
|
throw eventsError ?? new Error("events stream failed");
|
|
278
377
|
return await eventsReader(turnId);
|
|
279
378
|
}
|
|
280
379
|
finally {
|
|
380
|
+
if (signal.aborted) {
|
|
381
|
+
if (turnId)
|
|
382
|
+
await interrupt();
|
|
383
|
+
else if (!this.explicitServerUrl)
|
|
384
|
+
shutdownHandle(handle, "cancelled-before-turn-id");
|
|
385
|
+
}
|
|
281
386
|
eventsAbort.abort();
|
|
282
387
|
signal.removeEventListener("abort", onAbort);
|
|
283
388
|
}
|
|
284
389
|
}
|
|
390
|
+
async interruptTurn(baseUrl, headers, threadId, turnId) {
|
|
391
|
+
const res = await this.fetchFn(`${baseUrl}/v1/threads/${encodeURIComponent(threadId)}/turns/${encodeURIComponent(turnId)}/interrupt`, {
|
|
392
|
+
method: "POST",
|
|
393
|
+
headers,
|
|
394
|
+
signal: AbortSignal.timeout(2_000),
|
|
395
|
+
});
|
|
396
|
+
if (!res.ok)
|
|
397
|
+
throw new Error(`interrupt failed HTTP ${res.status}`);
|
|
398
|
+
}
|
|
285
399
|
async readEvents(url, headers, opts, signal) {
|
|
286
400
|
const res = await this.fetchFn(url, { method: "GET", headers, signal });
|
|
287
401
|
if (!res.ok)
|
|
@@ -296,6 +410,7 @@ export class DeepseekTuiAdapter {
|
|
|
296
410
|
let text = "";
|
|
297
411
|
let errorText = "";
|
|
298
412
|
let capped = false;
|
|
413
|
+
const progressState = createDeepseekProgressState();
|
|
299
414
|
const append = (chunk) => {
|
|
300
415
|
if (!chunk || capped)
|
|
301
416
|
return;
|
|
@@ -317,10 +432,30 @@ export class DeepseekTuiAdapter {
|
|
|
317
432
|
if (turnId && eventTurnId && eventTurnId !== turnId)
|
|
318
433
|
return false;
|
|
319
434
|
seq += 1;
|
|
320
|
-
const
|
|
435
|
+
const toolStarted = eventName === "tool.started" || isToolStarted(eventName, payload);
|
|
436
|
+
const toolCompleted = eventName === "tool.completed" || isToolCompleted(eventName, payload);
|
|
437
|
+
const progressStarted = toolStarted && this.progressEventMappingEnabled
|
|
438
|
+
? adaptDeepseekProgressStarted(payload, seq, progressState)
|
|
439
|
+
: { matched: false };
|
|
440
|
+
if (progressStarted.limitExceeded) {
|
|
441
|
+
log.warn("deepseek-tui progress event limit exceeded", {
|
|
442
|
+
limit: MAX_PROGRESS_EVENTS_PER_ATTEMPT,
|
|
443
|
+
});
|
|
444
|
+
}
|
|
445
|
+
const suppressProgressResult = this.progressEventMappingEnabled &&
|
|
446
|
+
toolCompleted &&
|
|
447
|
+
isDeepseekProgressCompletion(payload, progressState);
|
|
448
|
+
const block = progressStarted.matched
|
|
449
|
+
? (progressStarted.block ?? null)
|
|
450
|
+
: suppressProgressResult
|
|
451
|
+
? null
|
|
452
|
+
: normalizeDeepseekEvent(eventName, payload, seq);
|
|
321
453
|
if (block)
|
|
322
454
|
opts.onBlock?.(block);
|
|
323
|
-
|
|
455
|
+
// report_progress is non-authoritative telemetry: its tool failure never fails the task.
|
|
456
|
+
const extractedError = suppressProgressResult
|
|
457
|
+
? undefined
|
|
458
|
+
: extractDeepseekError(eventName, payload);
|
|
324
459
|
if (extractedError)
|
|
325
460
|
errorText = extractedError;
|
|
326
461
|
if (eventName === "message.delta") {
|
|
@@ -332,7 +467,7 @@ export class DeepseekTuiAdapter {
|
|
|
332
467
|
if (eventName === "turn.started" || embeddedDeepseekEvent(payload) === "turn.started") {
|
|
333
468
|
opts.onStatus?.({ kind: "thinking", phase: "started", label: "Thinking" });
|
|
334
469
|
}
|
|
335
|
-
else if (
|
|
470
|
+
else if (toolStarted && !progressStarted.matched) {
|
|
336
471
|
const label = stringField(payload, "name") ??
|
|
337
472
|
stringField(payload?.tool, "name") ??
|
|
338
473
|
stringField(payload?.payload?.tool, "name") ??
|
|
@@ -359,7 +494,12 @@ export class DeepseekTuiAdapter {
|
|
|
359
494
|
continue;
|
|
360
495
|
if (emit(frame.event, frame.data)) {
|
|
361
496
|
await reader.cancel().catch(() => undefined);
|
|
362
|
-
|
|
497
|
+
const progressDispositions = deepseekProgressDispositions(progressState);
|
|
498
|
+
return {
|
|
499
|
+
text: text.trim(),
|
|
500
|
+
...(errorText ? { error: errorText } : {}),
|
|
501
|
+
...(progressDispositions ? { progressDispositions } : {}),
|
|
502
|
+
};
|
|
363
503
|
}
|
|
364
504
|
}
|
|
365
505
|
}
|
|
@@ -368,7 +508,12 @@ export class DeepseekTuiAdapter {
|
|
|
368
508
|
if (frame)
|
|
369
509
|
emit(frame.event, frame.data);
|
|
370
510
|
}
|
|
371
|
-
|
|
511
|
+
const progressDispositions = deepseekProgressDispositions(progressState);
|
|
512
|
+
return {
|
|
513
|
+
text: text.trim(),
|
|
514
|
+
...(errorText ? { error: errorText } : {}),
|
|
515
|
+
...(progressDispositions ? { progressDispositions } : {}),
|
|
516
|
+
};
|
|
372
517
|
};
|
|
373
518
|
}
|
|
374
519
|
async requestJson(url, init) {
|
|
@@ -395,6 +540,7 @@ export function __resetDeepseekTuiPoolForTests() {
|
|
|
395
540
|
shutdownHandle(handle, "test-reset");
|
|
396
541
|
PROCESS_POOL.delete(key);
|
|
397
542
|
}
|
|
543
|
+
DEEPSEEK_TURN_QUEUES.clear();
|
|
398
544
|
}
|
|
399
545
|
function normalizeDeepseekEvent(eventName, payload, seq) {
|
|
400
546
|
if (eventName === "message.delta") {
|
|
@@ -564,6 +710,47 @@ function nextArgValue(args, index) {
|
|
|
564
710
|
return next;
|
|
565
711
|
return /^-\d/.test(next) ? next : undefined;
|
|
566
712
|
}
|
|
713
|
+
async function acquireDeepseekTurn(key, signal) {
|
|
714
|
+
let released = false;
|
|
715
|
+
let releaseGate;
|
|
716
|
+
const gate = new Promise((resolve) => {
|
|
717
|
+
releaseGate = resolve;
|
|
718
|
+
});
|
|
719
|
+
const previous = DEEPSEEK_TURN_QUEUES.get(key) ?? Promise.resolve();
|
|
720
|
+
const tail = previous.then(() => gate);
|
|
721
|
+
DEEPSEEK_TURN_QUEUES.set(key, tail);
|
|
722
|
+
void tail.then(() => {
|
|
723
|
+
if (DEEPSEEK_TURN_QUEUES.get(key) === tail)
|
|
724
|
+
DEEPSEEK_TURN_QUEUES.delete(key);
|
|
725
|
+
});
|
|
726
|
+
const release = () => {
|
|
727
|
+
if (released)
|
|
728
|
+
return;
|
|
729
|
+
released = true;
|
|
730
|
+
releaseGate();
|
|
731
|
+
};
|
|
732
|
+
let rejectAbort;
|
|
733
|
+
const aborted = new Promise((_resolve, reject) => {
|
|
734
|
+
rejectAbort = reject;
|
|
735
|
+
});
|
|
736
|
+
const onAbort = () => rejectAbort(abortReason(signal));
|
|
737
|
+
signal.addEventListener("abort", onAbort, { once: true });
|
|
738
|
+
if (signal.aborted)
|
|
739
|
+
onAbort();
|
|
740
|
+
try {
|
|
741
|
+
await Promise.race([previous, aborted]);
|
|
742
|
+
if (signal.aborted)
|
|
743
|
+
throw abortReason(signal);
|
|
744
|
+
return release;
|
|
745
|
+
}
|
|
746
|
+
catch (error) {
|
|
747
|
+
release();
|
|
748
|
+
throw error;
|
|
749
|
+
}
|
|
750
|
+
finally {
|
|
751
|
+
signal.removeEventListener("abort", onAbort);
|
|
752
|
+
}
|
|
753
|
+
}
|
|
567
754
|
function resetIdle(handle, key) {
|
|
568
755
|
if (handle.idleTimer)
|
|
569
756
|
clearTimeout(handle.idleTimer);
|
|
@@ -584,6 +771,8 @@ function shutdownHandle(handle, reason) {
|
|
|
584
771
|
handle.closed = true;
|
|
585
772
|
if (handle.idleTimer)
|
|
586
773
|
clearTimeout(handle.idleTimer);
|
|
774
|
+
cleanupProgressMcpConfig(handle.progressMcpConfig);
|
|
775
|
+
handle.progressMcpConfig = undefined;
|
|
587
776
|
try {
|
|
588
777
|
const pid = handle.child.pid;
|
|
589
778
|
if (typeof pid === "number" && pid > 0) {
|
|
@@ -614,20 +803,24 @@ function shutdownHandle(handle, reason) {
|
|
|
614
803
|
}
|
|
615
804
|
log.debug("deepseek-tui.shutdown", { reason });
|
|
616
805
|
}
|
|
617
|
-
async function waitForHealth(baseUrl, fetchFn, child, timeoutMs) {
|
|
806
|
+
async function waitForHealth(baseUrl, fetchFn, child, timeoutMs, signal) {
|
|
618
807
|
const deadline = Date.now() + timeoutMs;
|
|
619
808
|
let lastError = "";
|
|
620
809
|
while (Date.now() < deadline) {
|
|
810
|
+
if (signal.aborted)
|
|
811
|
+
throw abortReason(signal);
|
|
621
812
|
if (child.exitCode !== null) {
|
|
622
813
|
throw new Error(`deepseek serve exited with code ${child.exitCode}`);
|
|
623
814
|
}
|
|
624
815
|
try {
|
|
625
|
-
const res = await fetchFn(`${baseUrl}/health`, { method: "GET" });
|
|
816
|
+
const res = await fetchFn(`${baseUrl}/health`, { method: "GET", signal });
|
|
626
817
|
if (res.ok)
|
|
627
818
|
return;
|
|
628
819
|
lastError = `HTTP ${res.status}`;
|
|
629
820
|
}
|
|
630
821
|
catch (err) {
|
|
822
|
+
if (signal.aborted)
|
|
823
|
+
throw abortReason(signal);
|
|
631
824
|
lastError = err instanceof Error ? err.message : String(err);
|
|
632
825
|
}
|
|
633
826
|
await sleep(STARTUP_POLL_MS);
|
|
@@ -655,6 +848,9 @@ function randomToken() {
|
|
|
655
848
|
function sleep(ms) {
|
|
656
849
|
return new Promise((resolve) => setTimeout(resolve, ms));
|
|
657
850
|
}
|
|
851
|
+
function abortReason(signal) {
|
|
852
|
+
return signal.reason instanceof Error ? signal.reason : new Error("deepseek-tui aborted");
|
|
853
|
+
}
|
|
658
854
|
function stringField(obj, key) {
|
|
659
855
|
const v = obj?.[key];
|
|
660
856
|
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";
|
|
@@ -34,6 +42,8 @@ export interface EngineRunResult {
|
|
|
34
42
|
text: string;
|
|
35
43
|
newSessionId: string;
|
|
36
44
|
costUsd?: number;
|
|
45
|
+
/** adapter 自身在 emit 前丢弃的进度计数;不包含 accepted,避免 dispatcher 重复计数。 */
|
|
46
|
+
progressDispositions?: RuntimeProgressDispositions;
|
|
37
47
|
/** 非空表示硬失败;由包装层折叠为 RuntimeExecutionError。 */
|
|
38
48
|
error?: string;
|
|
39
49
|
runtimeFailure?: Partial<RuntimeFailureSummary>;
|
package/dist/runtimes/engine.js
CHANGED
|
@@ -100,6 +100,18 @@ 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
117
|
sessionId: null,
|
|
@@ -108,16 +120,29 @@ export function wrapEngineAdapter(id, engine, opts) {
|
|
|
108
120
|
...(extraArgs.length > 0 ? { extraArgs } : {}),
|
|
109
121
|
...(systemContext !== undefined ? { systemContext } : {}),
|
|
110
122
|
onBlock: (block) => {
|
|
123
|
+
if (block.kind === "progress") {
|
|
124
|
+
queueBlock({
|
|
125
|
+
kind: "progress",
|
|
126
|
+
runtime: id,
|
|
127
|
+
summary: block.progress.summary,
|
|
128
|
+
status: block.progress.status,
|
|
129
|
+
}, true);
|
|
130
|
+
return;
|
|
131
|
+
}
|
|
111
132
|
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
|
-
});
|
|
133
|
+
queueBlock({ kind, raw: block.raw });
|
|
116
134
|
},
|
|
117
135
|
onStatus: (event) => {
|
|
118
136
|
consoleLogger.debug(`${id} status`, { kind: event.kind, phase: event.phase });
|
|
119
137
|
},
|
|
120
138
|
});
|
|
139
|
+
// Preserve provider event order through durable run.block before the final run.message.
|
|
140
|
+
await blockChain;
|
|
141
|
+
if (progressBlockFailed)
|
|
142
|
+
throw progressBlockError;
|
|
143
|
+
if (result.progressDispositions) {
|
|
144
|
+
await sink.progressDispositions?.(result.progressDispositions);
|
|
145
|
+
}
|
|
121
146
|
if (result.error) {
|
|
122
147
|
throw new RuntimeExecutionError(result.error, "runtime_error", result.runtimeFailure);
|
|
123
148
|
}
|
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
import type { ProgressStreamBlock } from "./engine.js";
|
|
2
|
+
import type { RuntimeProgressDispositions } from "../types.js";
|
|
3
|
+
export declare const DEEPSEEK_PROGRESS_TOOL_ALIASES: Set<string>;
|
|
4
|
+
export declare const DEEPSEEK_PROGRESS_SYSTEM_INSTRUCTION: string;
|
|
5
|
+
export interface DeepseekProgressState {
|
|
6
|
+
emitted: number;
|
|
7
|
+
lastProgressKey: string | null;
|
|
8
|
+
callIds: Set<string>;
|
|
9
|
+
limitReported: boolean;
|
|
10
|
+
invalid: number;
|
|
11
|
+
duplicate: number;
|
|
12
|
+
overLimit: number;
|
|
13
|
+
}
|
|
14
|
+
export interface DeepseekProgressStartedResult {
|
|
15
|
+
matched: boolean;
|
|
16
|
+
block?: ProgressStreamBlock;
|
|
17
|
+
limitExceeded?: true;
|
|
18
|
+
}
|
|
19
|
+
export interface ProgressMcpConfig {
|
|
20
|
+
dir: string;
|
|
21
|
+
path: string;
|
|
22
|
+
}
|
|
23
|
+
export interface ProgressMcpConfigOptions {
|
|
24
|
+
/** undefined auto-discovers explicit/default config; null creates a progress-only config. */
|
|
25
|
+
baseConfigPath?: string | null;
|
|
26
|
+
platform?: NodeJS.Platform;
|
|
27
|
+
}
|
|
28
|
+
export declare class ProgressMcpConfigError extends Error {
|
|
29
|
+
constructor(message: string);
|
|
30
|
+
}
|
|
31
|
+
export declare function createDeepseekProgressState(): DeepseekProgressState;
|
|
32
|
+
/** Return only adapter-side drops; emitted blocks are counted by RunDispatcher after reporting. */
|
|
33
|
+
export declare function deepseekProgressDispositions(state: DeepseekProgressState): RuntimeProgressDispositions | undefined;
|
|
34
|
+
export declare function progressSystemContext(systemContext: string | undefined): string;
|
|
35
|
+
/**
|
|
36
|
+
* Recognize a progress tool start and emit only a typed, whitelisted block. Invalid,
|
|
37
|
+
* duplicate, and over-budget calls remain handled so no generic tool card leaks arguments.
|
|
38
|
+
*/
|
|
39
|
+
export declare function adaptDeepseekProgressStarted(payload: unknown, seq: number, state: DeepseekProgressState): DeepseekProgressStartedResult;
|
|
40
|
+
/** Suppress the result for a recognized progress call, including result envelopes with only ids. */
|
|
41
|
+
export declare function isDeepseekProgressCompletion(payload: unknown, state: DeepseekProgressState): boolean;
|
|
42
|
+
/**
|
|
43
|
+
* DeepSeek MCP auto-injection uses a stdio server launched through `env -i`, so it cannot
|
|
44
|
+
* inherit Course or model credentials from the DeepSeek process.
|
|
45
|
+
*/
|
|
46
|
+
export declare function progressMcpAutoInjectionSupported(platform?: NodeJS.Platform): boolean;
|
|
47
|
+
export declare function resolveExistingDeepseekMcpConfig(env?: NodeJS.ProcessEnv, home?: string): string | null;
|
|
48
|
+
/** Create an ephemeral config that preserves a user's existing MCP servers and settings. */
|
|
49
|
+
export declare function createProgressMcpConfig(options?: ProgressMcpConfigOptions): ProgressMcpConfig;
|
|
50
|
+
export declare function cleanupProgressMcpConfig(config: ProgressMcpConfig | undefined): void;
|