@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.
@@ -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.inFlight -= 1;
134
- if (!this.explicitServerUrl)
135
- resetIdle(handle, POOL_KEY);
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 child = this.spawnFn(this.resolveBinary(), ["serve", "--http", "--host", "127.0.0.1", "--port", String(port), "--auth-token", token], {
164
- cwd: opts.cwd,
165
- env: this.spawnEnv(),
166
- stdio: ["ignore", "pipe", "pipe"],
167
- // 自成进程组:解析到的二进制可能是会再 spawn 真实 deepseek-tui
168
- // server dispatcher,shutdown 必须对整组发信号而非仅直接子进程。
169
- detached: true,
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.delete(POOL_KEY);
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.delete(POOL_KEY);
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
- await waitForHealth(baseUrl, this.fetchFn, child, STARTUP_TIMEOUT_MS);
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
- return {
203
- ...process.env,
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
- if (opts.systemContext)
223
- body.system_prompt = opts.systemContext;
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({ system_prompt: systemContext ?? "" }),
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
- const onAbort = () => eventsAbort.abort();
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
- signal,
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
- const turnId = stringField(started?.turn, "id") ?? stringField(started, "turn_id") ?? "";
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 block = normalizeDeepseekEvent(eventName, payload, seq);
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
- const extractedError = extractDeepseekError(eventName, payload);
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 (eventName === "tool.started" || isToolStarted(eventName, payload)) {
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
- return { text: text.trim(), ...(errorText ? { error: errorText } : {}) };
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
- return { text: text.trim(), ...(errorText ? { error: errorText } : {}) };
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
- /** 底层 CLI 逐事件归一化出的块。seq 为本轮 1-based 单调递增。 */
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>;
@@ -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
- // 上报失败不打断 runtime 执行。
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;