@botlearn-course/daemon 0.0.6 → 0.0.8

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.
@@ -18,10 +18,28 @@ const MAX_TIMEOUT_SECONDS = 7200;
18
18
  const DEFAULT_MAX_OUTPUT_CHARS = 100_000;
19
19
  // wire 上单块文本上限;完整文本在本地 transcript。
20
20
  const BLOCK_TEXT_MAX_CHARS = 4000;
21
+ const CONTENT_FLUSH_MAX_CHARS = 512;
22
+ const CONTENT_FLUSH_INTERVAL_MS = 100;
23
+ const AGENT_STREAM_SCHEMA_VERSION = "agent-stream/0.1";
24
+ const SAFE_TOOL_NAME = /^[A-Za-z0-9][A-Za-z0-9._:/-]{0,79}$/;
21
25
  function clampTimeoutSeconds(value) {
22
26
  const n = typeof value === "number" && Number.isFinite(value) ? value : DEFAULT_TIMEOUT_SECONDS;
23
27
  return Math.min(MAX_TIMEOUT_SECONDS, Math.max(MIN_TIMEOUT_SECONDS, n));
24
28
  }
29
+ function normalizeToolName(value) {
30
+ if (typeof value !== "string")
31
+ return undefined;
32
+ const name = value.trim();
33
+ return SAFE_TOOL_NAME.test(name) ? name : undefined;
34
+ }
35
+ function chunkUnicodeText(value, maxCodePoints) {
36
+ const codePoints = Array.from(value);
37
+ const chunks = [];
38
+ for (let offset = 0; offset < codePoints.length; offset += maxCodePoints) {
39
+ chunks.push(codePoints.slice(offset, offset + maxCodePoints).join(""));
40
+ }
41
+ return chunks;
42
+ }
25
43
  /**
26
44
  * Run dispatcher:把 Course Service 下发的 run.start 交给 runtime,
27
45
  * 并把 runtime 输出归一化成 run.block / run.message / run.completed 回报 Course Service。
@@ -54,7 +72,10 @@ export class RunDispatcher {
54
72
  }
55
73
  /** 排队执行一个 run。返回的 Promise 不 reject(失败已归一化回报为 run.failed)。 */
56
74
  dispatch(payload) {
57
- const key = payload.agent_instance_id ?? payload.agent_run_id;
75
+ // 持久 sandbox 内跨 session 全局串行:整个 sandbox 同时只有一个 active turn(ADR-015 §7/§9)。
76
+ const key = this.persistentSession
77
+ ? "persistent-sandbox"
78
+ : payload.agent_instance_id ?? payload.agent_run_id;
58
79
  this.scheduledRunIds.add(payload.agent_run_id);
59
80
  return this.queue
60
81
  .enqueue(key, () => this.execute(payload))
@@ -85,6 +106,17 @@ export class RunDispatcher {
85
106
  get activeCount() {
86
107
  return this.inflight.size;
87
108
  }
109
+ /** Wait until the selected runs have left both the active and queued sets. */
110
+ async waitForRuns(agentRunIds, timeoutMs) {
111
+ const selected = new Set(agentRunIds);
112
+ const deadline = this.now() + timeoutMs;
113
+ while ([...selected].some((runId) => this.inflight.has(runId) || this.scheduledRunIds.has(runId))) {
114
+ if (this.now() >= deadline)
115
+ return false;
116
+ await new Promise((resolve) => setTimeout(resolve, 25));
117
+ }
118
+ return true;
119
+ }
88
120
  /** 等待所有 run(含排队中的)结束;超时返回 false。 */
89
121
  async drain(timeoutMs) {
90
122
  const deadline = this.now() + timeoutMs;
@@ -110,6 +142,7 @@ export class RunDispatcher {
110
142
  let terminalSent = false;
111
143
  let timedOut = false;
112
144
  let timer;
145
+ let contentFlushTimer;
113
146
  let profileApplied = false;
114
147
  let toolCalls = 0;
115
148
  let toolLimitExceeded = false;
@@ -265,8 +298,80 @@ export class RunDispatcher {
265
298
  controller.abort();
266
299
  }, timeoutSeconds * 1000);
267
300
  let finalText = "";
268
- // 上一次真正上了 wire 的块 kind:thinking/status 只在 kind 切换时上报一次,避免刷屏。
301
+ // 上一次真正上了 wire 的块 kind:status 只在 kind 切换时上报一次,避免刷屏。
269
302
  let lastReportedKind = null;
303
+ let lastReasoningPhase = null;
304
+ let pendingContent = "";
305
+ let streamEventFailure;
306
+ let streamEventChain = Promise.resolve();
307
+ const queueStreamEvent = (event) => {
308
+ const operation = streamEventChain.then(async () => {
309
+ if (streamEventFailure)
310
+ throw streamEventFailure;
311
+ await send(event);
312
+ });
313
+ streamEventChain = operation.catch((error) => {
314
+ streamEventFailure ??= error;
315
+ });
316
+ return operation;
317
+ };
318
+ const awaitStreamEvents = async () => {
319
+ await streamEventChain;
320
+ if (streamEventFailure)
321
+ throw streamEventFailure;
322
+ };
323
+ const flushContent = async () => {
324
+ if (contentFlushTimer !== undefined) {
325
+ clearTimeout(contentFlushTimer);
326
+ contentFlushTimer = undefined;
327
+ }
328
+ if (!pendingContent) {
329
+ await awaitStreamEvents();
330
+ return;
331
+ }
332
+ const content = pendingContent;
333
+ pendingContent = "";
334
+ for (const text of chunkUnicodeText(redactSecretString(content), BLOCK_TEXT_MAX_CHARS)) {
335
+ if (!text)
336
+ continue;
337
+ await queueStreamEvent({
338
+ type: "run.block",
339
+ text,
340
+ payload: {
341
+ schema_version: AGENT_STREAM_SCHEMA_VERSION,
342
+ kind: "text_delta",
343
+ runtime: runtime.id,
344
+ },
345
+ });
346
+ }
347
+ await awaitStreamEvents();
348
+ };
349
+ const scheduleContentFlush = () => {
350
+ if (contentFlushTimer !== undefined)
351
+ return;
352
+ contentFlushTimer = setTimeout(() => {
353
+ contentFlushTimer = undefined;
354
+ void flushContent().catch((error) => {
355
+ streamEventFailure ??= error;
356
+ });
357
+ }, CONTENT_FLUSH_INTERVAL_MS);
358
+ };
359
+ const sendReasoningPhase = async (phase) => {
360
+ if (phase === "completed" && lastReasoningPhase !== "in_progress")
361
+ return;
362
+ if (phase === lastReasoningPhase)
363
+ return;
364
+ lastReasoningPhase = phase;
365
+ await queueStreamEvent({
366
+ type: "run.block",
367
+ payload: {
368
+ schema_version: AGENT_STREAM_SCHEMA_VERSION,
369
+ kind: "reasoning",
370
+ runtime: runtime.id,
371
+ phase,
372
+ },
373
+ });
374
+ };
270
375
  const sink = {
271
376
  progressDispositions: async (dispositions) => {
272
377
  progressDisposition.invalid += dispositions.invalid;
@@ -287,6 +392,7 @@ export class RunDispatcher {
287
392
  },
288
393
  block: async (block) => {
289
394
  if (block.kind === "progress") {
395
+ await flushContent();
290
396
  const normalized = tryNormalizeProgressReport({
291
397
  summary: block.summary,
292
398
  status: block.status,
@@ -363,19 +469,87 @@ export class RunDispatcher {
363
469
  }
364
470
  if (serverTerminal)
365
471
  return;
366
- const perBlock = block.kind === "tool_call" || block.kind === "tool_result" || block.kind === "error";
367
- const onTransition = (block.kind === "thinking" || block.kind === "status") &&
368
- block.kind !== lastReportedKind;
369
- if (!perBlock && !onTransition)
472
+ if (block.kind === "text_delta") {
473
+ if (!block.text)
474
+ return;
475
+ pendingContent += block.text;
476
+ if (pendingContent.length >= CONTENT_FLUSH_MAX_CHARS) {
477
+ await flushContent();
478
+ }
479
+ else {
480
+ scheduleContentFlush();
481
+ }
370
482
  return;
371
- lastReportedKind = block.kind;
372
- await send({
373
- type: "run.block",
374
- text: redactSecretString(truncateText(block.text ?? "", BLOCK_TEXT_MAX_CHARS)),
375
- payload: { kind: block.kind, runtime: runtime.id },
376
- });
483
+ }
484
+ await flushContent();
485
+ if (block.kind === "thinking") {
486
+ await sendReasoningPhase(block.phase ?? "in_progress");
487
+ return;
488
+ }
489
+ if (block.kind === "status") {
490
+ if (lastReportedKind === "status")
491
+ return;
492
+ lastReportedKind = "status";
493
+ await queueStreamEvent({
494
+ type: "run.block",
495
+ payload: {
496
+ schema_version: AGENT_STREAM_SCHEMA_VERSION,
497
+ kind: "status",
498
+ runtime: runtime.id,
499
+ phase: "executing",
500
+ },
501
+ });
502
+ return;
503
+ }
504
+ if (block.kind === "tool_call") {
505
+ lastReportedKind = block.kind;
506
+ lastReasoningPhase = null;
507
+ await queueStreamEvent({
508
+ type: "run.block",
509
+ payload: {
510
+ schema_version: AGENT_STREAM_SCHEMA_VERSION,
511
+ kind: "tool_call",
512
+ runtime: runtime.id,
513
+ status: "started",
514
+ ...(normalizeToolName(block.name)
515
+ ? { name: normalizeToolName(block.name) }
516
+ : {}),
517
+ },
518
+ });
519
+ return;
520
+ }
521
+ if (block.kind === "tool_result") {
522
+ lastReportedKind = block.kind;
523
+ lastReasoningPhase = null;
524
+ await queueStreamEvent({
525
+ type: "run.block",
526
+ payload: {
527
+ schema_version: AGENT_STREAM_SCHEMA_VERSION,
528
+ kind: "tool_result",
529
+ runtime: runtime.id,
530
+ status: block.status === "error" ? "error" : "completed",
531
+ ...(normalizeToolName(block.name)
532
+ ? { name: normalizeToolName(block.name) }
533
+ : {}),
534
+ },
535
+ });
536
+ return;
537
+ }
538
+ if (block.kind === "error") {
539
+ lastReportedKind = block.kind;
540
+ await queueStreamEvent({
541
+ type: "run.block",
542
+ payload: {
543
+ schema_version: AGENT_STREAM_SCHEMA_VERSION,
544
+ kind: "error",
545
+ runtime: runtime.id,
546
+ status: "error",
547
+ },
548
+ });
549
+ }
377
550
  },
378
551
  message: async (text) => {
552
+ await flushContent();
379
553
  finalText = text;
380
554
  },
381
555
  file: async (file) => {
@@ -483,6 +657,8 @@ export class RunDispatcher {
483
657
  finally {
484
658
  if (timer !== undefined)
485
659
  clearTimeout(timer);
660
+ if (contentFlushTimer !== undefined)
661
+ clearTimeout(contentFlushTimer);
486
662
  if (profileApplied)
487
663
  cleanupRunRuntimeProfile(runId);
488
664
  if (Object.values(progressDisposition).some((count) => count > 0)) {
@@ -492,7 +668,12 @@ export class RunDispatcher {
492
668
  ...progressDisposition,
493
669
  });
494
670
  }
495
- this.inflight.delete(runId);
671
+ try {
672
+ this.persistentSession?.finishTurn(payload);
673
+ }
674
+ finally {
675
+ this.inflight.delete(runId);
676
+ }
496
677
  }
497
678
  }
498
679
  }
@@ -2,6 +2,12 @@
2
2
  export declare function clearAgentServiceControlEnv(env?: NodeJS.ProcessEnv): void;
3
3
  /** Copy an environment for a runtime child without leaking Agent Service control values. */
4
4
  export declare function runtimeChildEnv(env?: NodeJS.ProcessEnv): NodeJS.ProcessEnv;
5
+ /**
6
+ * Validate the short-lived model environment delivered by session.activate.
7
+ * Only model adapter coordinates are accepted; Course/Agent Service control credentials
8
+ * and supervisor settings can never be reintroduced through the wire payload.
9
+ */
10
+ export declare function activationRuntimeEnv(value: unknown): Record<string, string>;
5
11
  /** Run model processes as the Template's untrusted runtime UID when configured. */
6
12
  export declare function runtimeChildIdentity(env?: NodeJS.ProcessEnv): {
7
13
  uid?: number;
@@ -4,8 +4,9 @@ const AGENT_SERVICE_CONTROL_ENV_KEYS = [
4
4
  "BOTLEARN_AGENT_SERVICE_RUN_TOKEN",
5
5
  "BOTLEARN_AGENT_SERVICE_WORKER_ID",
6
6
  "BOTLEARN_AGENT_SERVICE_WS_URL",
7
- "BOTLEARN_AGENT_SERVICE_RUNTIME_SESSION_ID",
8
- "BOTLEARN_AGENT_SERVICE_SESSION_TOKEN",
7
+ "BOTLEARN_AGENT_SERVICE_SANDBOX_ID",
8
+ "BOTLEARN_AGENT_SERVICE_SANDBOX_TOKEN",
9
+ "BOTLEARN_AGENT_SERVICE_ACTIVATION_ID",
9
10
  ];
10
11
  const AGENT_SERVICE_SUPERVISOR_ENV_KEYS = [
11
12
  "BOTLEARN_DAEMON_HOME",
@@ -18,6 +19,10 @@ const AGENT_SERVICE_SUPERVISOR_ENV_KEYS = [
18
19
  "BOTLEARN_AGENT_SERVICE_WORKSPACE_ROOT",
19
20
  "BOTLEARN_AGENT_SERVICE_PROFILE_ROOT",
20
21
  ];
22
+ const ACTIVATION_RUNTIME_ENV_KEYS = new Set([
23
+ "DEEPSEEK_API_KEY",
24
+ "DEEPSEEK_BASE_URL",
25
+ ]);
21
26
  const RUNTIME_USER_PATTERN = /^[a-z_][a-z0-9_-]{0,31}$/;
22
27
  const RUNTIME_SUDO_BINARY = "/usr/bin/sudo";
23
28
  const MANAGED_RUNTIME_LAUNCHER = "/opt/botlearn/bin/botlearn-runtime-launcher";
@@ -38,6 +43,34 @@ export function runtimeChildEnv(env = process.env) {
38
43
  childEnv.HOME = runtimeHome;
39
44
  return childEnv;
40
45
  }
46
+ /**
47
+ * Validate the short-lived model environment delivered by session.activate.
48
+ * Only model adapter coordinates are accepted; Course/Agent Service control credentials
49
+ * and supervisor settings can never be reintroduced through the wire payload.
50
+ */
51
+ export function activationRuntimeEnv(value) {
52
+ if (value === undefined || value === null)
53
+ return {};
54
+ if (typeof value !== "object" || Array.isArray(value)) {
55
+ throw new Error("activation runtime_env must be an object");
56
+ }
57
+ const result = {};
58
+ for (const [key, item] of Object.entries(value)) {
59
+ if (!ACTIVATION_RUNTIME_ENV_KEYS.has(key) ||
60
+ typeof item !== "string" ||
61
+ item.length < 1 ||
62
+ item.length > 8192 ||
63
+ item.includes("\0")) {
64
+ throw new Error(`activation runtime env is forbidden: ${key}`);
65
+ }
66
+ result[key] = item;
67
+ }
68
+ if (Object.keys(result).length > 0 &&
69
+ (!result.DEEPSEEK_API_KEY || !result.DEEPSEEK_BASE_URL)) {
70
+ throw new Error("activation runtime env requires the DeepSeek key and base URL pair");
71
+ }
72
+ return result;
73
+ }
41
74
  /** Run model processes as the Template's untrusted runtime UID when configured. */
42
75
  export function runtimeChildIdentity(env = process.env) {
43
76
  const uid = Number(env.BOTLEARN_RUNTIME_UID);
@@ -149,7 +149,18 @@ function archiveEntries(skill) {
149
149
  if (total > MAX_PACKAGE_BYTES) {
150
150
  throw new RuntimeProfileApplyError(`Skill ${skill.id} archive is too large`);
151
151
  }
152
- return entries.sort((a, b) => a.path.localeCompare(b.path));
152
+ return entries.sort((a, b) => compareUnicodeCodePoints(a.path, b.path));
153
+ }
154
+ function compareUnicodeCodePoints(left, right) {
155
+ const leftCodePoints = Array.from(left, (value) => value.codePointAt(0));
156
+ const rightCodePoints = Array.from(right, (value) => value.codePointAt(0));
157
+ const length = Math.min(leftCodePoints.length, rightCodePoints.length);
158
+ for (let index = 0; index < length; index += 1) {
159
+ const difference = leftCodePoints[index] - rightCodePoints[index];
160
+ if (difference !== 0)
161
+ return difference;
162
+ }
163
+ return leftCodePoints.length - rightCodePoints.length;
153
164
  }
154
165
  function validateArchiveFile(file, skillId) {
155
166
  if (!file || typeof file.path !== "string" || typeof file.content !== "string") {
@@ -324,22 +324,33 @@ function claudeStatusEvent(obj) {
324
324
  }
325
325
  function normalizeBlock(obj, seq) {
326
326
  let kind = "other";
327
+ const contents = Array.isArray(obj?.message?.content) ? obj.message.content : [];
327
328
  if (obj?.type === "assistant") {
328
- const contents = Array.isArray(obj.message?.content) ? obj.message.content : [];
329
329
  if (contents.some((c) => c?.type === "tool_use"))
330
330
  kind = "tool_use";
331
331
  else if (contents.some((c) => c?.type === "text"))
332
332
  kind = "assistant_text";
333
333
  }
334
334
  else if (obj?.type === "user") {
335
- const contents = Array.isArray(obj.message?.content) ? obj.message.content : [];
336
335
  if (contents.some((c) => c?.type === "tool_result"))
337
336
  kind = "tool_result";
338
337
  }
339
338
  else if (obj?.type === "system") {
340
339
  kind = "system";
341
340
  }
342
- return { raw: obj, kind, seq };
341
+ const tool = contents.find((c) => c?.type === "tool_use");
342
+ const text = contents
343
+ .filter((c) => c?.type === "text" && typeof c.text === "string")
344
+ .map((c) => c.text)
345
+ .join("");
346
+ return {
347
+ raw: obj,
348
+ kind,
349
+ seq,
350
+ ...(kind === "assistant_text" && text ? { text } : {}),
351
+ ...(kind === "tool_use" && typeof tool?.name === "string" ? { name: tool.name } : {}),
352
+ ...(kind === "tool_result" ? { status: "completed" } : {}),
353
+ };
343
354
  }
344
355
  export const claudeCodeModule = {
345
356
  id: "claude-code",
@@ -318,7 +318,23 @@ function normalizeBlock(obj, seq) {
318
318
  kind = type === "item.completed" ? "tool_result" : "tool_use";
319
319
  }
320
320
  }
321
- return { raw: obj, kind, seq };
321
+ const toolName = itemType === "mcp_tool_call"
322
+ ? obj?.item?.tool ?? obj?.item?.name ?? itemType
323
+ : itemType;
324
+ return {
325
+ raw: obj,
326
+ kind,
327
+ seq,
328
+ ...(kind === "assistant_text" && typeof obj?.item?.text === "string"
329
+ ? { text: obj.item.text }
330
+ : {}),
331
+ ...((kind === "tool_use" || kind === "tool_result") && typeof toolName === "string"
332
+ ? { name: toolName }
333
+ : {}),
334
+ ...(kind === "tool_result"
335
+ ? { status: obj?.item?.status === "failed" ? "error" : "completed" }
336
+ : {}),
337
+ };
322
338
  }
323
339
  export const codexModule = {
324
340
  id: "codex",
@@ -38,10 +38,11 @@ export declare class DeepseekTuiAdapter implements EngineAdapter {
38
38
  private resolveBinary;
39
39
  private acquireHandle;
40
40
  /**
41
- * 不设置 DEEPSEEK_RUNTIME_DIR:server run 池化共享,per-run 目录不成立;
42
- * BYOA 直接用用户本机 deepseek 自身的默认状态目录(含已登录凭据)。
41
+ * 不设置 DEEPSEEK_RUNTIME_DIR:BYOA server 可跨 run 池化,直接使用用户本机
42
+ * deepseek 默认状态目录(含已登录凭据);Agent Service server 按 activation 回收。
43
43
  */
44
44
  private spawnEnv;
45
+ private managedActivationId;
45
46
  private createThread;
46
47
  private patchThreadSystemContext;
47
48
  private startTurnAndReadEvents;
@@ -7,6 +7,18 @@ import { runtimeChildEnv, runtimeChildLaunch } from "../runtime-env.js";
7
7
  import { readCommandVersion, resolveCommandOnPath } from "./probe.js";
8
8
  import { adaptDeepseekProgressStarted, cleanupProgressMcpConfig, createDeepseekProgressState, createProgressMcpConfig, deepseekProgressDispositions, isDeepseekProgressCompletion, progressMcpAutoInjectionSupported, progressSystemContext, } from "./progress.js";
9
9
  import { consoleLogger, wrapEngineAdapter, } from "./engine.js";
10
+ class DeepseekHttpError extends Error {
11
+ status;
12
+ constructor(status, detail = "", operation = "") {
13
+ super(`${operation ? `${operation} ` : ""}HTTP ${status}${detail ? `: ${detail.slice(0, 300)}` : ""}`);
14
+ this.status = status;
15
+ this.name = "DeepseekHttpError";
16
+ }
17
+ }
18
+ function isMissingThreadHttpError(error) {
19
+ return (error instanceof DeepseekHttpError
20
+ && (error.status === 404 || error.status === 410));
21
+ }
10
22
  const log = consoleLogger;
11
23
  const DEEPSEEK_IDLE_TIMEOUT_MS = 5 * 60 * 1000;
12
24
  const STARTUP_TIMEOUT_MS = 30_000;
@@ -99,6 +111,7 @@ export class DeepseekTuiAdapter {
99
111
  let handle;
100
112
  let countedInFlight = false;
101
113
  let releaseTurn;
114
+ const managedActivationId = this.managedActivationId(opts);
102
115
  try {
103
116
  // The local server has a process-level kill fallback when turn-scoped interrupt
104
117
  // fails. Serialize turns so cancelling one run can never terminate another run.
@@ -114,7 +127,10 @@ export class DeepseekTuiAdapter {
114
127
  if (handle.idleTimer)
115
128
  clearTimeout(handle.idleTimer);
116
129
  const headers = authHeaders(handle.token);
117
- let threadId = opts.sessionId?.trim() || "";
130
+ // Agent Service model credentials are activation-scoped. The local DeepSeek
131
+ // server reads them only at process startup, so its native thread cache cannot
132
+ // safely cross activations; durable Course context rebuilds the new thread.
133
+ let threadId = managedActivationId ? "" : (opts.sessionId?.trim() || "");
118
134
  if (threadId && !isValidThreadId(threadId)) {
119
135
  return {
120
136
  text: "",
@@ -140,7 +156,7 @@ export class DeepseekTuiAdapter {
140
156
  const error = runResult.error ?? (text === "" ? emptyCompletionError(handle.stderrTail) : undefined);
141
157
  return {
142
158
  text,
143
- newSessionId: threadId,
159
+ newSessionId: managedActivationId ? "" : threadId,
144
160
  ...(runResult.progressDispositions
145
161
  ? { progressDispositions: runResult.progressDispositions }
146
162
  : {}),
@@ -150,11 +166,11 @@ export class DeepseekTuiAdapter {
150
166
  }
151
167
  catch (err) {
152
168
  const message = err instanceof Error ? err.message : String(err);
153
- // 服务端已丢失该线程(重启、GC)→ 清空 sessionId 让下次重建。
154
- const staleSession = opts.sessionId && /404|not found|missing/i.test(message);
169
+ // 服务端明确确认线程不存在/已过期时才清空 sessionId,让下一轮从 durable context 重建。
170
+ const staleSession = Boolean(opts.sessionId) && isMissingThreadHttpError(err);
155
171
  return {
156
172
  text: "",
157
- newSessionId: staleSession ? "" : (opts.sessionId ?? ""),
173
+ newSessionId: managedActivationId || staleSession ? "" : (opts.sessionId ?? ""),
158
174
  error: `deepseek-tui: ${message}`,
159
175
  };
160
176
  }
@@ -162,8 +178,14 @@ export class DeepseekTuiAdapter {
162
178
  opts.signal.removeEventListener("abort", onAbort);
163
179
  if (handle && countedInFlight) {
164
180
  handle.inFlight = Math.max(0, handle.inFlight - 1);
165
- if (!this.explicitServerUrl)
181
+ if (managedActivationId && !this.explicitServerUrl && handle.inFlight === 0) {
182
+ if (PROCESS_POOL.get(POOL_KEY) === handle)
183
+ PROCESS_POOL.delete(POOL_KEY);
184
+ shutdownHandle(handle, "managed-activation-finished");
185
+ }
186
+ else if (!this.explicitServerUrl) {
166
187
  resetIdle(handle, POOL_KEY);
188
+ }
167
189
  }
168
190
  releaseTurn?.();
169
191
  }
@@ -182,14 +204,23 @@ export class DeepseekTuiAdapter {
182
204
  child: nullChild(),
183
205
  baseUrl: trimTrailingSlash(this.explicitServerUrl),
184
206
  token: this.explicitAuthToken ?? "",
207
+ managedActivationId: null,
185
208
  closed: false,
186
209
  inFlight: 0,
187
210
  stderrTail: "",
188
211
  };
189
212
  }
213
+ const managedActivationId = this.managedActivationId(opts);
190
214
  const existing = PROCESS_POOL.get(POOL_KEY);
191
- if (existing && !existing.closed)
215
+ if (existing
216
+ && !existing.closed
217
+ && existing.managedActivationId === managedActivationId) {
192
218
  return existing;
219
+ }
220
+ if (existing) {
221
+ PROCESS_POOL.delete(POOL_KEY);
222
+ shutdownHandle(existing, "activation-scope-changed");
223
+ }
193
224
  const port = await findFreePort();
194
225
  if (signal.aborted)
195
226
  throw abortReason(signal);
@@ -231,6 +262,7 @@ export class DeepseekTuiAdapter {
231
262
  child,
232
263
  baseUrl,
233
264
  token,
265
+ managedActivationId,
234
266
  closed: false,
235
267
  inFlight: 0,
236
268
  stderrTail: "",
@@ -266,8 +298,8 @@ export class DeepseekTuiAdapter {
266
298
  return handle;
267
299
  }
268
300
  /**
269
- * 不设置 DEEPSEEK_RUNTIME_DIR:server run 池化共享,per-run 目录不成立;
270
- * BYOA 直接用用户本机 deepseek 自身的默认状态目录(含已登录凭据)。
301
+ * 不设置 DEEPSEEK_RUNTIME_DIR:BYOA server 可跨 run 池化,直接使用用户本机
302
+ * deepseek 默认状态目录(含已登录凭据);Agent Service server 按 activation 回收。
271
303
  */
272
304
  spawnEnv(opts, progressMcpConfigPath) {
273
305
  const env = {
@@ -279,6 +311,12 @@ export class DeepseekTuiAdapter {
279
311
  env.DEEPSEEK_MCP_CONFIG = progressMcpConfigPath;
280
312
  return env;
281
313
  }
314
+ managedActivationId(opts) {
315
+ if (this.explicitServerUrl)
316
+ return null;
317
+ const value = opts.env?.BOTLEARN_AGENT_SERVICE_ACTIVATION_ID?.trim();
318
+ return value || null;
319
+ }
282
320
  async createThread(baseUrl, headers, opts, signal) {
283
321
  const body = {
284
322
  workspace: opts.cwd,
@@ -404,7 +442,7 @@ export class DeepseekTuiAdapter {
404
442
  async readEvents(url, headers, opts, signal) {
405
443
  const res = await this.fetchFn(url, { method: "GET", headers, signal });
406
444
  if (!res.ok)
407
- throw new Error(`events stream failed HTTP ${res.status}`);
445
+ throw new DeepseekHttpError(res.status, "", "events stream failed");
408
446
  if (!res.body)
409
447
  throw new Error("events stream response missing body");
410
448
  const reader = res.body.getReader();
@@ -538,7 +576,7 @@ export class DeepseekTuiAdapter {
538
576
  catch {
539
577
  // ignore
540
578
  }
541
- throw new Error(`HTTP ${res.status}${detail ? `: ${detail.slice(0, 300)}` : ""}`);
579
+ throw new DeepseekHttpError(res.status, detail);
542
580
  }
543
581
  return (await res.json());
544
582
  }
@@ -553,16 +591,37 @@ export function __resetDeepseekTuiPoolForTests() {
553
591
  }
554
592
  function normalizeDeepseekEvent(eventName, payload, seq) {
555
593
  if (eventName === "message.delta") {
556
- return { raw: { event: eventName, payload }, kind: "assistant_text", seq };
594
+ return {
595
+ raw: { event: eventName, payload },
596
+ kind: "assistant_text",
597
+ seq,
598
+ text: stringField(payload, "content") ?? "",
599
+ };
557
600
  }
558
601
  if (eventName === "tool.started" || isToolStarted(eventName, payload)) {
559
- return { raw: { event: eventName, payload }, kind: "tool_use", seq };
602
+ return {
603
+ raw: { event: eventName, payload },
604
+ kind: "tool_use",
605
+ seq,
606
+ ...(deepseekToolName(payload) ? { name: deepseekToolName(payload) } : {}),
607
+ };
560
608
  }
561
609
  if (eventName === "tool.completed" || isToolCompleted(eventName, payload)) {
562
- return { raw: { event: eventName, payload }, kind: "tool_result", seq };
610
+ return {
611
+ raw: { event: eventName, payload },
612
+ kind: "tool_result",
613
+ seq,
614
+ ...(deepseekToolName(payload) ? { name: deepseekToolName(payload) } : {}),
615
+ status: deepseekToolFailed(payload) ? "error" : "completed",
616
+ };
563
617
  }
564
618
  if (eventName === "item.delta" && isAgentMessageDelta(payload)) {
565
- return { raw: { event: eventName, payload }, kind: "assistant_text", seq };
619
+ return {
620
+ raw: { event: eventName, payload },
621
+ kind: "assistant_text",
622
+ seq,
623
+ text: extractDeepseekDelta(payload),
624
+ };
566
625
  }
567
626
  if (eventName === "item.completed" && isAgentReasoningItem(payload)) {
568
627
  return { raw: { event: eventName, payload }, kind: "thinking", seq };
@@ -674,6 +733,19 @@ function inferDeepseekToolName(item) {
674
733
  }
675
734
  return undefined;
676
735
  }
736
+ function deepseekToolName(payload) {
737
+ return (stringField(payload, "name")
738
+ ?? stringField(payload?.tool, "name")
739
+ ?? stringField(payload?.payload?.tool, "name")
740
+ ?? inferDeepseekToolName(payload?.item ?? payload?.payload?.item));
741
+ }
742
+ function deepseekToolFailed(payload) {
743
+ const status = (stringField(payload, "status")
744
+ ?? stringField(payload?.tool, "status")
745
+ ?? stringField(payload?.payload?.tool, "status")
746
+ ?? "").toLowerCase();
747
+ return status.includes("fail") || status.includes("error");
748
+ }
677
749
  function emptyCompletionError(stderrTail) {
678
750
  const tail = stderrTail.trim();
679
751
  if (!tail) {
@@ -9,6 +9,11 @@ export interface ContentStreamBlock {
9
9
  raw: unknown;
10
10
  kind: "assistant_text" | "tool_use" | "tool_result" | "system" | "thinking" | "other";
11
11
  seq: number;
12
+ /** Assistant-visible text only. Provider raw payloads must never be used as browser text. */
13
+ text?: string;
14
+ /** Safe provider-normalized tool identifier; arguments and results remain private. */
15
+ name?: string;
16
+ status?: "completed" | "error";
12
17
  }
13
18
  /** provider 已严格校验的进度块;禁止携带原始 tool envelope。 */
14
19
  export interface ProgressStreamBlock {