@botlearn-course/daemon 0.0.13-beta.1 → 0.0.14

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.
@@ -1,6 +1,7 @@
1
1
  import { type CourseRuntime, type RuntimeFailureSummary, type RuntimeProgressDispositions, type RuntimeUsage } from "../types.js";
2
2
  import type { Logger } from "../log.js";
3
3
  import type { ProgressReport } from "../mcp/report-progress.js";
4
+ import type { PreparedRuntimeSkillProvider, RuntimeSkillEvent } from "../runtime-skills.js";
4
5
  /**
5
6
  * 内部引擎契约:CLI/ACP 基类实现的是 pull-style options + 回调,
6
7
  * 由 wrapEngineAdapter 折叠成对外的 CourseRuntime(sink 语义)。
@@ -34,19 +35,28 @@ export type RuntimeStatusEvent = {
34
35
  };
35
36
  export interface EngineRunOptions {
36
37
  text: string;
38
+ /** Full durable conversation bootstrap, used only if a resumed native thread is missing. */
39
+ recoveryText?: string;
37
40
  /** runtime 原生会话 id(resume 用);null 表示新会话。 */
38
41
  sessionId: string | null;
39
42
  cwd: string;
43
+ /** Daemon-owned state directory for runtime metadata that the model must not mutate. */
44
+ runtimeStateDir?: string;
40
45
  signal: AbortSignal;
41
46
  extraArgs?: string[];
42
47
  systemContext?: string;
43
48
  onBlock?: (block: StreamBlock) => void;
44
49
  onStatus?: (event: RuntimeStatusEvent) => void;
45
50
  env?: NodeJS.ProcessEnv;
51
+ /** Control-side binding. Runtime adapters must not copy its credential into child env. */
52
+ skillProvider?: PreparedRuntimeSkillProvider;
53
+ onSkillEvent?: (event: RuntimeSkillEvent) => Promise<void>;
46
54
  }
47
55
  export interface EngineRunResult {
48
56
  text: string;
49
57
  newSessionId: string;
58
+ /** Model identity returned by the runtime API for this turn. */
59
+ model?: string;
50
60
  costUsd?: number;
51
61
  usage?: RuntimeUsage;
52
62
  /** adapter 自身在 emit 前丢弃的进度计数;不包含 accepted,避免 dispatcher 重复计数。 */
@@ -152,14 +152,14 @@ export function wrapEngineAdapter(id, engine, opts) {
152
152
  const payload = run.payload;
153
153
  // DeepSeek persists and replays the native thread history. The durable Course Service
154
154
  // conversation is recovery/bootstrap data, not a second history to inject on every turn.
155
- // Agent Service activations intentionally start a fresh local server because their model
156
- // credential expires with the turn, so a cached thread id cannot suppress durable context.
157
- const managedActivation = Boolean(run.runtimeEnv?.BOTLEARN_AGENT_SERVICE_ACTIVATION_ID?.trim());
158
- const resumesDeepseekThread = id === "deepseek-tui" && !managedActivation && Boolean(run.nativeSessionId?.trim());
155
+ const resumesDeepseekThread = id === "deepseek-tui" && Boolean(run.nativeSessionId?.trim());
159
156
  const learnerInput = resumesDeepseekThread
160
157
  ? renderCurrentLearnerRequest(payload)
161
158
  : renderConversationInput(payload);
162
159
  const text = renderInputAttachments(run, learnerInput);
160
+ const recoveryText = resumesDeepseekThread
161
+ ? renderInputAttachments(run, renderConversationInput(payload))
162
+ : undefined;
163
163
  if (!text.trim()) {
164
164
  throw new RuntimeExecutionError("empty task brief");
165
165
  }
@@ -190,10 +190,16 @@ export function wrapEngineAdapter(id, engine, opts) {
190
190
  };
191
191
  const result = await engine.run({
192
192
  text,
193
+ ...(recoveryText !== undefined ? { recoveryText } : {}),
193
194
  sessionId: run.nativeSessionId ?? null,
194
195
  cwd: run.workspaceDir,
196
+ ...(run.runtimeStateDir ? { runtimeStateDir: run.runtimeStateDir } : {}),
195
197
  signal,
196
198
  ...(run.runtimeEnv ? { env: run.runtimeEnv } : {}),
199
+ ...(run.skillProvider ? { skillProvider: run.skillProvider } : {}),
200
+ ...(run.skillProvider && sink.skillEvent
201
+ ? { onSkillEvent: (event) => sink.skillEvent(event) }
202
+ : {}),
197
203
  ...(extraArgs.length > 0 ? { extraArgs } : {}),
198
204
  ...(systemContext !== undefined ? { systemContext } : {}),
199
205
  onBlock: (block) => {
@@ -232,9 +238,10 @@ export function wrapEngineAdapter(id, engine, opts) {
232
238
  if (result.progressDispositions) {
233
239
  await sink.progressDispositions?.(result.progressDispositions);
234
240
  }
235
- if (result.usage || result.costUsd !== undefined) {
241
+ if (result.model || result.usage || result.costUsd !== undefined) {
236
242
  await sink.usage?.({
237
243
  ...(result.usage ?? {}),
244
+ ...(result.model ? { model: result.model } : {}),
238
245
  ...(result.costUsd !== undefined ? { cost_usd: result.costUsd } : {}),
239
246
  });
240
247
  }
@@ -28,6 +28,8 @@ export interface ProgressMcpConfigOptions {
28
28
  * config, and a path writes a sanitized runtime-readable config below that root.
29
29
  */
30
30
  managedRoot?: string | null;
31
+ /** Activation-scoped daemon socket exposed through the clean-env course_skills relay. */
32
+ courseSkillsSocketPath?: string;
31
33
  platform?: NodeJS.Platform;
32
34
  }
33
35
  export declare class ProgressMcpConfigError extends Error {
@@ -141,6 +141,7 @@ export function createProgressMcpConfig(options = {}) {
141
141
  throw new ProgressMcpConfigError("managed progress MCP config cannot preserve an existing DeepSeek MCP config");
142
142
  }
143
143
  const serverPath = fileURLToPath(new URL("../mcp/report-progress-server.js", import.meta.url));
144
+ const courseSkillsRelayPath = fileURLToPath(new URL("../mcp/course-skills-relay.js", import.meta.url));
144
145
  const baseConfigPath = managedRoot
145
146
  ? null
146
147
  : options.baseConfigPath === undefined
@@ -151,6 +152,12 @@ export function createProgressMcpConfig(options = {}) {
151
152
  if (Object.hasOwn(baseServers, "botlearn")) {
152
153
  throw new ProgressMcpConfigError("DeepSeek MCP server key 'botlearn' is reserved for BotLearn progress reporting");
153
154
  }
155
+ const courseSkillsSocketPath = options.courseSkillsSocketPath
156
+ ? validCourseSkillsSocketPath(options.courseSkillsSocketPath)
157
+ : undefined;
158
+ if (courseSkillsSocketPath && Object.hasOwn(baseServers, "course_skills")) {
159
+ throw new ProgressMcpConfigError("DeepSeek MCP server key 'course_skills' is reserved for BotLearn Course Skills");
160
+ }
154
161
  const dir = mkdtempSync(path.join(managedRoot ?? tmpdir(), "botlearn-progress-mcp-"));
155
162
  const configPath = path.join(dir, "mcp.json");
156
163
  const stagingPath = path.join(dir, ".mcp.json.tmp");
@@ -170,6 +177,25 @@ export function createProgressMcpConfig(options = {}) {
170
177
  enabled: true,
171
178
  required: false,
172
179
  },
180
+ ...(courseSkillsSocketPath
181
+ ? {
182
+ course_skills: {
183
+ command: "/usr/bin/env",
184
+ args: [
185
+ "-i",
186
+ `PATH=${minimalPath}`,
187
+ process.execPath,
188
+ courseSkillsRelayPath,
189
+ "--socket",
190
+ courseSkillsSocketPath,
191
+ ],
192
+ env: {},
193
+ disabled: false,
194
+ enabled: true,
195
+ required: true,
196
+ },
197
+ }
198
+ : {}),
173
199
  },
174
200
  };
175
201
  try {
@@ -186,6 +212,14 @@ export function createProgressMcpConfig(options = {}) {
186
212
  throw error;
187
213
  }
188
214
  }
215
+ function validCourseSkillsSocketPath(value) {
216
+ if (!path.isAbsolute(value)
217
+ || value.includes("\0")
218
+ || Buffer.byteLength(value, "utf8") > 240) {
219
+ throw new ProgressMcpConfigError("course_skills MCP socket path must be a bounded absolute path");
220
+ }
221
+ return value;
222
+ }
189
223
  function resolveManagedProgressRoot(explicit) {
190
224
  if (explicit === null)
191
225
  return null;
@@ -0,0 +1,21 @@
1
+ import type { RuntimeBlock } from "./types.js";
2
+ export declare const AGENT_TOOL_OBSERVATION_SCHEMA_VERSION = "agent-tool-observation/0.1";
3
+ export interface ToolObservationPayload extends Record<string, unknown> {
4
+ schema_version: typeof AGENT_TOOL_OBSERVATION_SCHEMA_VERSION;
5
+ kind: "tool_call" | "tool_result";
6
+ runtime: string;
7
+ status: "started" | "completed" | "error";
8
+ name?: string;
9
+ detail_preview?: string;
10
+ detail_truncated: boolean;
11
+ redacted: boolean;
12
+ }
13
+ /**
14
+ * Project a provider tool envelope into durable teacher evidence.
15
+ *
16
+ * The projection is deliberately lossy: only operational argument/result fields survive,
17
+ * credentials are removed, host paths become workspace-relative, and the JSON preview is
18
+ * bounded. Provider reasoning, message text, request IDs, and arbitrary envelope metadata
19
+ * never cross this boundary.
20
+ */
21
+ export declare function buildToolObservation(block: RuntimeBlock, runtime: string, workspaceDir: string): ToolObservationPayload | null;
@@ -0,0 +1,120 @@
1
+ import path from "node:path";
2
+ import { REDACTED, redactSecretsDeep, } from "./redaction.js";
3
+ export const AGENT_TOOL_OBSERVATION_SCHEMA_VERSION = "agent-tool-observation/0.1";
4
+ const DETAIL_MAX_CHARS = 4_000;
5
+ const MAX_PROJECT_DEPTH = 8;
6
+ const SAFE_TOOL_NAME = /^[A-Za-z0-9][A-Za-z0-9._:/-]{0,79}$/;
7
+ const CALL_FIELDS = new Set([
8
+ "arguments",
9
+ "args",
10
+ "input",
11
+ "command",
12
+ "cmd",
13
+ "cwd",
14
+ "path",
15
+ "file_path",
16
+ "query",
17
+ "url",
18
+ "pattern",
19
+ "patch",
20
+ "changes",
21
+ "edits",
22
+ ]);
23
+ const RESULT_FIELDS = new Set([
24
+ "result",
25
+ "output",
26
+ "stdout",
27
+ "stderr",
28
+ "exit_code",
29
+ "status",
30
+ "status_code",
31
+ "error",
32
+ "diff",
33
+ "path",
34
+ "file_path",
35
+ "bytes",
36
+ ]);
37
+ /**
38
+ * Project a provider tool envelope into durable teacher evidence.
39
+ *
40
+ * The projection is deliberately lossy: only operational argument/result fields survive,
41
+ * credentials are removed, host paths become workspace-relative, and the JSON preview is
42
+ * bounded. Provider reasoning, message text, request IDs, and arbitrary envelope metadata
43
+ * never cross this boundary.
44
+ */
45
+ export function buildToolObservation(block, runtime, workspaceDir) {
46
+ if (block.kind !== "tool_call" && block.kind !== "tool_result")
47
+ return null;
48
+ const fields = block.kind === "tool_call" ? CALL_FIELDS : RESULT_FIELDS;
49
+ const projected = collectAllowedFields(block.raw, fields);
50
+ if (typeof block.text === "string" && block.text) {
51
+ projected[block.kind === "tool_call" ? "target" : "summary"] = block.text;
52
+ }
53
+ const normalized = normalizeWorkspacePaths(redactSecretsDeep(projected), path.resolve(workspaceDir));
54
+ const serialized = Object.keys(projected).length > 0
55
+ ? JSON.stringify(normalized, null, 2)
56
+ : "";
57
+ const detailTruncated = serialized.length > DETAIL_MAX_CHARS;
58
+ const detailPreview = detailTruncated
59
+ ? `${serialized.slice(0, DETAIL_MAX_CHARS - 1)}…`
60
+ : serialized;
61
+ return {
62
+ schema_version: AGENT_TOOL_OBSERVATION_SCHEMA_VERSION,
63
+ kind: block.kind,
64
+ runtime,
65
+ status: block.kind === "tool_call"
66
+ ? "started"
67
+ : block.status === "error"
68
+ ? "error"
69
+ : "completed",
70
+ ...(block.name && SAFE_TOOL_NAME.test(block.name) ? { name: block.name } : {}),
71
+ ...(detailPreview ? { detail_preview: detailPreview } : {}),
72
+ detail_truncated: detailTruncated,
73
+ redacted: detailPreview.includes(REDACTED),
74
+ };
75
+ }
76
+ function collectAllowedFields(value, allowed, depth = 0, out = {}) {
77
+ if (value === null || typeof value !== "object" || depth >= MAX_PROJECT_DEPTH) {
78
+ return out;
79
+ }
80
+ if (Array.isArray(value)) {
81
+ for (const item of value.slice(0, 50)) {
82
+ collectAllowedFields(item, allowed, depth + 1, out);
83
+ }
84
+ return out;
85
+ }
86
+ for (const [key, item] of Object.entries(value)) {
87
+ if (allowed === RESULT_FIELDS
88
+ && key === "content"
89
+ && value.type === "tool_result") {
90
+ if (!("content" in out))
91
+ out.content = item;
92
+ continue;
93
+ }
94
+ if (allowed.has(key)) {
95
+ if (!(key in out))
96
+ out[key] = item;
97
+ continue;
98
+ }
99
+ collectAllowedFields(item, allowed, depth + 1, out);
100
+ }
101
+ return out;
102
+ }
103
+ function normalizeWorkspacePaths(value, workspaceDir) {
104
+ if (typeof value === "string") {
105
+ const normalizedWorkspace = workspaceDir.replaceAll("\\", "/").replace(/\/+$/, "");
106
+ const normalizedValue = value.replaceAll("\\", "/");
107
+ return normalizedValue
108
+ .replaceAll(`${normalizedWorkspace}/`, "")
109
+ .replaceAll(normalizedWorkspace, ".");
110
+ }
111
+ if (Array.isArray(value)) {
112
+ return value.map((item) => normalizeWorkspacePaths(item, workspaceDir));
113
+ }
114
+ if (value === null || typeof value !== "object")
115
+ return value;
116
+ return Object.fromEntries(Object.entries(value).map(([key, item]) => [
117
+ key,
118
+ normalizeWorkspacePaths(item, workspaceDir),
119
+ ]));
120
+ }
package/dist/types.d.ts CHANGED
@@ -5,6 +5,7 @@
5
5
  * `services/course-api/botlearn_course/schemas.py` 的 daemon 契约严格对齐。
6
6
  */
7
7
  import type { ProgressStatus } from "./mcp/report-progress.js";
8
+ import type { PreparedRuntimeSkillProvider, RuntimeSkillEvent } from "./runtime-skills.js";
8
9
  export interface RunInputAttachment {
9
10
  attachment_id: string;
10
11
  filename: string;
@@ -53,7 +54,7 @@ export interface RunStartPayload {
53
54
  * `POST /daemon/runs/{id}/events` 接受的事件类型。
54
55
  * 与后端 DaemonRunEventIn.type 的 Literal 严格一致 —— 发送其他类型会得到 422。
55
56
  */
56
- export type RunEventType = "run.accepted" | "run.started" | "run.block" | "run.message" | "run.completed" | "run.failed" | "run.cancelled";
57
+ export type RunEventType = "run.accepted" | "run.started" | "run.block" | "run.observation" | "run.message" | "run.completed" | "run.failed" | "run.cancelled";
57
58
  export interface RunEvent {
58
59
  type: RunEventType;
59
60
  /** End-to-end correlation id assigned by Course Service. */
@@ -125,7 +126,10 @@ export interface AppliedRunRuntimeProfile {
125
126
  export interface RuntimeContentBlock {
126
127
  kind: "text_delta" | "text" | "thinking" | "tool_call" | "tool_result" | "status" | "error";
127
128
  text?: string;
128
- /** Safe provider-normalized tool identifier. Raw arguments/results stay in the transcript only. */
129
+ /**
130
+ * Safe provider-normalized tool identifier. Raw envelopes stay local; the dispatcher may
131
+ * derive a separate bounded/redacted teacher observation from explicit operation fields.
132
+ */
129
133
  name?: string;
130
134
  /** Public lifecycle metadata; never carries provider reasoning or tool output. */
131
135
  phase?: "in_progress" | "completed";
@@ -149,13 +153,26 @@ export interface RuntimeProgressDispositions {
149
153
  }
150
154
  /** Content-free model usage; adapters populate only fields actually reported upstream. */
151
155
  export interface RuntimeUsage {
156
+ /** Model identity resolved by the runtime/provider, not inferred by the daemon. */
157
+ model?: string;
152
158
  input_tokens?: number;
153
159
  cached_input_tokens?: number;
154
160
  output_tokens?: number;
161
+ reasoning_tokens?: number;
155
162
  total_tokens?: number;
156
163
  cost_usd?: number;
157
164
  provider_request_ids?: string[];
158
165
  }
166
+ /** Content-free shape of one runtime-native completion. */
167
+ export interface RuntimeCompletionSummary {
168
+ assistant_message_count: number;
169
+ reasoning_message_count: number;
170
+ assistant_content_present: boolean;
171
+ reasoning_content_present: boolean;
172
+ tool_call_count: number;
173
+ turn_status?: string;
174
+ finish_reason?: string;
175
+ }
159
176
  export interface RuntimeAuthProbe {
160
177
  checked: boolean;
161
178
  ok: boolean;
@@ -179,12 +196,16 @@ export interface CourseRuntimeSink {
179
196
  usage?(usage: RuntimeUsage): Promise<void>;
180
197
  /** Persist the runtime-native thread/session id before a terminal turn event is emitted. */
181
198
  runtimeSession?(sessionId: string): Promise<void>;
199
+ /** Content-free technical evidence from the control-side Runtime Skill Provider. */
200
+ skillEvent?(event: RuntimeSkillEvent): Promise<void>;
182
201
  }
183
202
  /** 一次 run 的本地执行上下文:服务器 payload + daemon 本地准备产物。 */
184
203
  export interface RunExecution {
185
204
  payload: RunStartPayload;
186
205
  /** Runtime cwd. Managed persistent sessions reuse one session-scoped workspace. */
187
206
  workspaceDir: string;
207
+ /** Daemon-owned state directory for one managed RuntimeSession; never exposed to the model. */
208
+ runtimeStateDir?: string;
188
209
  /** Runtime-native thread/session id to resume; null creates the first native session. */
189
210
  nativeSessionId?: string | null;
190
211
  /** Monotonic Course Service context revision accepted for this turn. */
@@ -193,6 +214,8 @@ export interface RunExecution {
193
214
  runtimeEnv?: NodeJS.ProcessEnv;
194
215
  /** Verified learner files materialized beneath the runtime workspace. */
195
216
  inputAttachments?: MaterializedInputAttachment[];
217
+ /** Activation-scoped provider held by the daemon control process, never runtime env. */
218
+ skillProvider?: PreparedRuntimeSkillProvider;
196
219
  }
197
220
  export interface CourseRuntime {
198
221
  id: string;
@@ -224,7 +247,10 @@ export interface RuntimeFailureSummary {
224
247
  stderr_tail?: string;
225
248
  stdout_tail?: string;
226
249
  error_name?: string;
250
+ error_code?: string;
227
251
  error_message?: string;
252
+ model?: string;
253
+ completion?: RuntimeCompletionSummary;
228
254
  }
229
255
  export interface RuntimeModule {
230
256
  id: string;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@botlearn-course/daemon",
3
- "version": "0.0.13-beta.1",
3
+ "version": "0.0.14",
4
4
  "description": "Lightweight BotLearn Course daemon: run course tasks on your own machine with your own agent runtime (BYOA).",
5
5
  "type": "module",
6
6
  "bin": {