@botlearn-course/daemon 0.0.1
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/LICENSE +21 -0
- package/README.md +108 -0
- package/dist/agent-service-client.d.ts +18 -0
- package/dist/agent-service-client.js +108 -0
- package/dist/auth-store.d.ts +16 -0
- package/dist/auth-store.js +106 -0
- package/dist/cli.d.ts +24 -0
- package/dist/cli.js +354 -0
- package/dist/course-client.d.ts +46 -0
- package/dist/course-client.js +143 -0
- package/dist/doctor.d.ts +15 -0
- package/dist/doctor.js +85 -0
- package/dist/file-candidates.d.ts +34 -0
- package/dist/file-candidates.js +173 -0
- package/dist/index.d.ts +19 -0
- package/dist/index.js +19 -0
- package/dist/log.d.ts +20 -0
- package/dist/log.js +154 -0
- package/dist/path-env.d.ts +8 -0
- package/dist/path-env.js +42 -0
- package/dist/redaction.d.ts +24 -0
- package/dist/redaction.js +158 -0
- package/dist/run-dispatcher.d.ts +43 -0
- package/dist/run-dispatcher.js +294 -0
- package/dist/run-queue.d.ts +11 -0
- package/dist/run-queue.js +26 -0
- package/dist/runtime-capabilities.d.ts +3 -0
- package/dist/runtime-capabilities.js +42 -0
- package/dist/runtime-profile.d.ts +8 -0
- package/dist/runtime-profile.js +213 -0
- package/dist/runtimes/acp-stream.d.ts +96 -0
- package/dist/runtimes/acp-stream.js +488 -0
- package/dist/runtimes/claude-code.d.ts +41 -0
- package/dist/runtimes/claude-code.js +353 -0
- package/dist/runtimes/codex.d.ts +44 -0
- package/dist/runtimes/codex.js +332 -0
- package/dist/runtimes/deepseek-tui.d.ts +50 -0
- package/dist/runtimes/deepseek-tui.js +701 -0
- package/dist/runtimes/engine.d.ts +52 -0
- package/dist/runtimes/engine.js +127 -0
- package/dist/runtimes/fake.d.ts +13 -0
- package/dist/runtimes/fake.js +45 -0
- package/dist/runtimes/gemini.d.ts +39 -0
- package/dist/runtimes/gemini.js +251 -0
- package/dist/runtimes/hermes-agent.d.ts +61 -0
- package/dist/runtimes/hermes-agent.js +173 -0
- package/dist/runtimes/index.d.ts +15 -0
- package/dist/runtimes/index.js +74 -0
- package/dist/runtimes/kimi.d.ts +35 -0
- package/dist/runtimes/kimi.js +335 -0
- package/dist/runtimes/ndjson-stream.d.ts +51 -0
- package/dist/runtimes/ndjson-stream.js +207 -0
- package/dist/runtimes/openclaw-acp.d.ts +52 -0
- package/dist/runtimes/openclaw-acp.js +872 -0
- package/dist/runtimes/probe.d.ts +17 -0
- package/dist/runtimes/probe.js +54 -0
- package/dist/runtimes/runtime-errors.d.ts +20 -0
- package/dist/runtimes/runtime-errors.js +95 -0
- package/dist/runtimes/text-cap.d.ts +7 -0
- package/dist/runtimes/text-cap.js +25 -0
- package/dist/transcript.d.ts +13 -0
- package/dist/transcript.js +46 -0
- package/dist/types.d.ts +199 -0
- package/dist/types.js +17 -0
- package/dist/workspace.d.ts +23 -0
- package/dist/workspace.js +54 -0
- package/package.json +40 -0
|
@@ -0,0 +1,294 @@
|
|
|
1
|
+
import { randomUUID } from "node:crypto";
|
|
2
|
+
import { CourseClientError, isRunTerminal } from "./course-client.js";
|
|
3
|
+
import { reportFileCandidates } from "./file-candidates.js";
|
|
4
|
+
import { log as defaultLog } from "./log.js";
|
|
5
|
+
import { errorInfo, redactSecretString, truncateText } from "./redaction.js";
|
|
6
|
+
import { missingRunCapabilities } from "./runtime-capabilities.js";
|
|
7
|
+
import { RunQueue } from "./run-queue.js";
|
|
8
|
+
import { applyRunRuntimeProfile, cleanupRunRuntimeProfile, RuntimeProfileApplyError, runtimeProfileInstructions, } from "./runtime-profile.js";
|
|
9
|
+
import { TranscriptWriter } from "./transcript.js";
|
|
10
|
+
import { RuntimeExecutionError, } from "./types.js";
|
|
11
|
+
import { ensureRunWorkspace, transcriptPath } from "./workspace.js";
|
|
12
|
+
// 与 runtimes/index.ts 的 DEFAULT_RUNTIME_ID 保持一致(此处不 import registry,避免拉入全部 adapter)。
|
|
13
|
+
const DEFAULT_RUNTIME_ID = "codex";
|
|
14
|
+
const DEFAULT_TIMEOUT_SECONDS = 900;
|
|
15
|
+
const MIN_TIMEOUT_SECONDS = 30;
|
|
16
|
+
const MAX_TIMEOUT_SECONDS = 7200;
|
|
17
|
+
const DEFAULT_MAX_OUTPUT_CHARS = 100_000;
|
|
18
|
+
// wire 上单块文本上限;完整文本在本地 transcript。
|
|
19
|
+
const BLOCK_TEXT_MAX_CHARS = 4000;
|
|
20
|
+
function clampTimeoutSeconds(value) {
|
|
21
|
+
const n = typeof value === "number" && Number.isFinite(value) ? value : DEFAULT_TIMEOUT_SECONDS;
|
|
22
|
+
return Math.min(MAX_TIMEOUT_SECONDS, Math.max(MIN_TIMEOUT_SECONDS, n));
|
|
23
|
+
}
|
|
24
|
+
/**
|
|
25
|
+
* Run dispatcher:把 Course Service 下发的 run.start 交给 runtime,
|
|
26
|
+
* 并把 runtime 输出归一化成 run.block / run.message / run.completed 回报 Course Service。
|
|
27
|
+
*
|
|
28
|
+
* - 串行:同一 agent_instance 经 RunQueue 排队。
|
|
29
|
+
* - 取消:AbortController;runtime 抛错归一化为 run.failed。
|
|
30
|
+
* - 服务端 409(run 已终态)→ abort 并静默停止后续上报。
|
|
31
|
+
* - daemon 不推进进度,只汇报事实。
|
|
32
|
+
*/
|
|
33
|
+
export class RunDispatcher {
|
|
34
|
+
client;
|
|
35
|
+
runtimes;
|
|
36
|
+
queue = new RunQueue();
|
|
37
|
+
inflight = new Map();
|
|
38
|
+
defaultRuntimeId;
|
|
39
|
+
log;
|
|
40
|
+
scanLimits;
|
|
41
|
+
now;
|
|
42
|
+
constructor(client, runtimes, opts = {}) {
|
|
43
|
+
this.client = client;
|
|
44
|
+
this.runtimes = runtimes;
|
|
45
|
+
this.defaultRuntimeId = opts.defaultRuntimeId ?? DEFAULT_RUNTIME_ID;
|
|
46
|
+
this.log = opts.log ?? defaultLog;
|
|
47
|
+
this.scanLimits = opts.scanLimits;
|
|
48
|
+
this.now = opts.now ?? Date.now;
|
|
49
|
+
}
|
|
50
|
+
/** 排队执行一个 run。返回的 Promise 不 reject(失败已归一化回报为 run.failed)。 */
|
|
51
|
+
dispatch(payload) {
|
|
52
|
+
const key = payload.agent_instance_id ?? payload.agent_run_id;
|
|
53
|
+
return this.queue.enqueue(key, () => this.execute(payload));
|
|
54
|
+
}
|
|
55
|
+
cancel(agentRunId) {
|
|
56
|
+
const controller = this.inflight.get(agentRunId);
|
|
57
|
+
if (!controller)
|
|
58
|
+
return false;
|
|
59
|
+
controller.abort();
|
|
60
|
+
return true;
|
|
61
|
+
}
|
|
62
|
+
cancelAll() {
|
|
63
|
+
for (const controller of this.inflight.values())
|
|
64
|
+
controller.abort();
|
|
65
|
+
}
|
|
66
|
+
get activeCount() {
|
|
67
|
+
return this.inflight.size;
|
|
68
|
+
}
|
|
69
|
+
/** 等待所有 run(含排队中的)结束;超时返回 false。 */
|
|
70
|
+
async drain(timeoutMs) {
|
|
71
|
+
const deadline = this.now() + timeoutMs;
|
|
72
|
+
while (this.inflight.size > 0 || this.queue.activeKeys.length > 0) {
|
|
73
|
+
if (this.now() >= deadline)
|
|
74
|
+
return false;
|
|
75
|
+
await new Promise((r) => setTimeout(r, 25));
|
|
76
|
+
}
|
|
77
|
+
return true;
|
|
78
|
+
}
|
|
79
|
+
async execute(payload) {
|
|
80
|
+
const runId = payload.agent_run_id;
|
|
81
|
+
const controller = new AbortController();
|
|
82
|
+
this.inflight.set(runId, controller);
|
|
83
|
+
let seq = 0;
|
|
84
|
+
// 服务端已把本 run 判为终态(409):停止一切后续上报。
|
|
85
|
+
let serverTerminal = false;
|
|
86
|
+
// 终态事件(completed/failed/cancelled)至多发一个。
|
|
87
|
+
let terminalSent = false;
|
|
88
|
+
let timedOut = false;
|
|
89
|
+
let timer;
|
|
90
|
+
let profileApplied = false;
|
|
91
|
+
let toolCalls = 0;
|
|
92
|
+
let toolLimitExceeded = false;
|
|
93
|
+
const startedAt = this.now();
|
|
94
|
+
const usage = () => ({
|
|
95
|
+
wall_time_ms: Math.max(0, this.now() - startedAt),
|
|
96
|
+
tool_calls: toolCalls,
|
|
97
|
+
});
|
|
98
|
+
const send = async (event) => {
|
|
99
|
+
if (serverTerminal)
|
|
100
|
+
return;
|
|
101
|
+
seq += 1;
|
|
102
|
+
const outgoing = { ...event, seq, event_id: event.event_id ?? randomUUID() };
|
|
103
|
+
for (let attempt = 0; attempt < 3; attempt += 1) {
|
|
104
|
+
try {
|
|
105
|
+
await this.client.postEvent(runId, outgoing);
|
|
106
|
+
return;
|
|
107
|
+
}
|
|
108
|
+
catch (err) {
|
|
109
|
+
if (isRunTerminal(err)) {
|
|
110
|
+
serverTerminal = true;
|
|
111
|
+
controller.abort();
|
|
112
|
+
return;
|
|
113
|
+
}
|
|
114
|
+
const retryable = !(err instanceof CourseClientError) || err.status === 429 || err.status >= 500;
|
|
115
|
+
if (!retryable || attempt === 2)
|
|
116
|
+
throw err;
|
|
117
|
+
await new Promise((resolve) => setTimeout(resolve, 100 * (attempt + 1)));
|
|
118
|
+
}
|
|
119
|
+
}
|
|
120
|
+
};
|
|
121
|
+
const sendTerminal = async (event) => {
|
|
122
|
+
if (serverTerminal || terminalSent)
|
|
123
|
+
return;
|
|
124
|
+
terminalSent = true;
|
|
125
|
+
try {
|
|
126
|
+
await send(event);
|
|
127
|
+
}
|
|
128
|
+
catch (err) {
|
|
129
|
+
this.log.error("failed to report terminal run event", {
|
|
130
|
+
agentRunId: runId,
|
|
131
|
+
eventType: event.type,
|
|
132
|
+
error: err instanceof Error ? err.message : String(err),
|
|
133
|
+
});
|
|
134
|
+
}
|
|
135
|
+
};
|
|
136
|
+
const runtimeId = payload.runtime.id ?? this.defaultRuntimeId;
|
|
137
|
+
try {
|
|
138
|
+
await send({ type: "run.started" });
|
|
139
|
+
const runtime = this.runtimes.get(runtimeId);
|
|
140
|
+
if (!runtime) {
|
|
141
|
+
// 不做静默回退:用户在前端选了的 runtime 不可用必须显式失败。
|
|
142
|
+
await sendTerminal({
|
|
143
|
+
type: "run.failed",
|
|
144
|
+
error: `runtime '${runtimeId}' is not available on this daemon`,
|
|
145
|
+
payload: { error_type: "runtime_unavailable", runtime: runtimeId, usage: usage() },
|
|
146
|
+
});
|
|
147
|
+
return;
|
|
148
|
+
}
|
|
149
|
+
if (serverTerminal)
|
|
150
|
+
return;
|
|
151
|
+
const expectedProfileHash = payload.context.profileHash;
|
|
152
|
+
if (typeof expectedProfileHash === "string") {
|
|
153
|
+
if (!this.client.getRunRuntimeProfile) {
|
|
154
|
+
throw new RuntimeProfileApplyError("runtime profile client is unavailable");
|
|
155
|
+
}
|
|
156
|
+
const profile = await this.client.getRunRuntimeProfile(runId);
|
|
157
|
+
const applied = applyRunRuntimeProfile(payload, profile);
|
|
158
|
+
profileApplied = true;
|
|
159
|
+
payload.context.instructions = [
|
|
160
|
+
...(Array.isArray(payload.context.instructions)
|
|
161
|
+
? payload.context.instructions.filter((item) => typeof item === "string")
|
|
162
|
+
: []),
|
|
163
|
+
...runtimeProfileInstructions(payload, applied),
|
|
164
|
+
];
|
|
165
|
+
}
|
|
166
|
+
const { workspaceDir } = ensureRunWorkspace(runId);
|
|
167
|
+
const missingCapabilities = missingRunCapabilities(payload, workspaceDir);
|
|
168
|
+
if (missingCapabilities.length > 0) {
|
|
169
|
+
throw new RuntimeExecutionError(`runtime is missing required capabilities: ${missingCapabilities.join(", ")}`, "runtime_unavailable");
|
|
170
|
+
}
|
|
171
|
+
const transcript = new TranscriptWriter(transcriptPath(runId));
|
|
172
|
+
const timeoutSeconds = clampTimeoutSeconds(payload.limits.timeout_seconds);
|
|
173
|
+
timer = setTimeout(() => {
|
|
174
|
+
timedOut = true;
|
|
175
|
+
controller.abort();
|
|
176
|
+
}, timeoutSeconds * 1000);
|
|
177
|
+
let finalText = "";
|
|
178
|
+
// 上一次真正上了 wire 的块 kind:thinking/status 只在 kind 切换时上报一次,避免刷屏。
|
|
179
|
+
let lastReportedKind = null;
|
|
180
|
+
const sink = {
|
|
181
|
+
block: async (block) => {
|
|
182
|
+
transcript.writeBlock(block);
|
|
183
|
+
if (block.kind === "tool_call") {
|
|
184
|
+
toolCalls += 1;
|
|
185
|
+
const configured = payload.limits.max_tool_calls;
|
|
186
|
+
const maxToolCalls = typeof configured === "number" && Number.isFinite(configured)
|
|
187
|
+
? Math.max(1, Math.min(1000, Math.floor(configured)))
|
|
188
|
+
: 30;
|
|
189
|
+
if (toolCalls > maxToolCalls) {
|
|
190
|
+
toolLimitExceeded = true;
|
|
191
|
+
controller.abort();
|
|
192
|
+
return;
|
|
193
|
+
}
|
|
194
|
+
}
|
|
195
|
+
if (serverTerminal)
|
|
196
|
+
return;
|
|
197
|
+
const perBlock = block.kind === "tool_call" || block.kind === "tool_result" || block.kind === "error";
|
|
198
|
+
const onTransition = (block.kind === "thinking" || block.kind === "status") &&
|
|
199
|
+
block.kind !== lastReportedKind;
|
|
200
|
+
if (!perBlock && !onTransition)
|
|
201
|
+
return;
|
|
202
|
+
lastReportedKind = block.kind;
|
|
203
|
+
await send({
|
|
204
|
+
type: "run.block",
|
|
205
|
+
text: redactSecretString(truncateText(block.text ?? "", BLOCK_TEXT_MAX_CHARS)),
|
|
206
|
+
payload: { kind: block.kind, runtime: runtime.id },
|
|
207
|
+
});
|
|
208
|
+
},
|
|
209
|
+
message: async (text) => {
|
|
210
|
+
finalText = text;
|
|
211
|
+
},
|
|
212
|
+
file: async (file) => {
|
|
213
|
+
await this.client.postFile(runId, file);
|
|
214
|
+
},
|
|
215
|
+
};
|
|
216
|
+
await runtime.run({ payload, workspaceDir }, sink, controller.signal);
|
|
217
|
+
clearTimeout(timer);
|
|
218
|
+
if (serverTerminal)
|
|
219
|
+
return;
|
|
220
|
+
if (controller.signal.aborted)
|
|
221
|
+
throw new Error("aborted");
|
|
222
|
+
const maxOutputChars = typeof payload.limits.max_output_chars === "number" &&
|
|
223
|
+
Number.isFinite(payload.limits.max_output_chars) &&
|
|
224
|
+
payload.limits.max_output_chars > 0
|
|
225
|
+
? payload.limits.max_output_chars
|
|
226
|
+
: DEFAULT_MAX_OUTPUT_CHARS;
|
|
227
|
+
const output = redactSecretString(truncateText(finalText, maxOutputChars));
|
|
228
|
+
transcript.writeFinal(output);
|
|
229
|
+
const fileReport = await reportFileCandidates(this.client, runId, workspaceDir, this.log, this.scanLimits);
|
|
230
|
+
if (this.client.uploadFileContent && (fileReport.failed > 0 || fileReport.truncated)) {
|
|
231
|
+
throw new RuntimeExecutionError(fileReport.truncated
|
|
232
|
+
? "workspace file collection was truncated"
|
|
233
|
+
: `failed to persist ${fileReport.failed} workspace file(s)`);
|
|
234
|
+
}
|
|
235
|
+
await send({ type: "run.message", role: "assistant", text: output });
|
|
236
|
+
await sendTerminal({
|
|
237
|
+
type: "run.completed",
|
|
238
|
+
payload: { runtime: runtimeId, usage: usage() },
|
|
239
|
+
});
|
|
240
|
+
}
|
|
241
|
+
catch (err) {
|
|
242
|
+
if (!serverTerminal) {
|
|
243
|
+
if (toolLimitExceeded) {
|
|
244
|
+
await sendTerminal({
|
|
245
|
+
type: "run.failed",
|
|
246
|
+
error: "run exceeded max_tool_calls",
|
|
247
|
+
payload: {
|
|
248
|
+
error_type: "tool_budget_exceeded",
|
|
249
|
+
runtime: runtimeId,
|
|
250
|
+
usage: usage(),
|
|
251
|
+
},
|
|
252
|
+
});
|
|
253
|
+
}
|
|
254
|
+
else if (controller.signal.aborted && timedOut) {
|
|
255
|
+
const timeoutSeconds = clampTimeoutSeconds(payload.limits.timeout_seconds);
|
|
256
|
+
await sendTerminal({
|
|
257
|
+
type: "run.failed",
|
|
258
|
+
error: `run timed out after ${timeoutSeconds}s`,
|
|
259
|
+
payload: { error_type: "timeout", runtime: runtimeId, usage: usage() },
|
|
260
|
+
});
|
|
261
|
+
}
|
|
262
|
+
else if (controller.signal.aborted) {
|
|
263
|
+
await sendTerminal({
|
|
264
|
+
type: "run.cancelled",
|
|
265
|
+
payload: { runtime: runtimeId, usage: usage() },
|
|
266
|
+
});
|
|
267
|
+
}
|
|
268
|
+
else {
|
|
269
|
+
const info = errorInfo(err);
|
|
270
|
+
const errorType = err instanceof RuntimeExecutionError ? err.errorType : "runtime_error";
|
|
271
|
+
await sendTerminal({
|
|
272
|
+
type: "run.failed",
|
|
273
|
+
error: info.error_message,
|
|
274
|
+
payload: {
|
|
275
|
+
error_type: errorType,
|
|
276
|
+
runtime: runtimeId,
|
|
277
|
+
usage: usage(),
|
|
278
|
+
...(err instanceof RuntimeProfileApplyError
|
|
279
|
+
? { code: err.code, profile_apply_status: "failed" }
|
|
280
|
+
: {}),
|
|
281
|
+
},
|
|
282
|
+
});
|
|
283
|
+
}
|
|
284
|
+
}
|
|
285
|
+
}
|
|
286
|
+
finally {
|
|
287
|
+
if (timer !== undefined)
|
|
288
|
+
clearTimeout(timer);
|
|
289
|
+
if (profileApplied)
|
|
290
|
+
cleanupRunRuntimeProfile(runId);
|
|
291
|
+
this.inflight.delete(runId);
|
|
292
|
+
}
|
|
293
|
+
}
|
|
294
|
+
}
|
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Run queue:第一版单机串行,同一 agent_instance 同时只跑一个 run。
|
|
3
|
+
*
|
|
4
|
+
* 不同 agent_instance 可并发;同一 instance 的任务排队执行,避免 workspace 竞争。
|
|
5
|
+
*/
|
|
6
|
+
export declare class RunQueue {
|
|
7
|
+
private readonly chains;
|
|
8
|
+
/** 把 task 排到 instanceKey 对应的串行链尾,返回其结果 Promise。 */
|
|
9
|
+
enqueue<T>(instanceKey: string, task: () => Promise<T>): Promise<T>;
|
|
10
|
+
get activeKeys(): string[];
|
|
11
|
+
}
|
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Run queue:第一版单机串行,同一 agent_instance 同时只跑一个 run。
|
|
3
|
+
*
|
|
4
|
+
* 不同 agent_instance 可并发;同一 instance 的任务排队执行,避免 workspace 竞争。
|
|
5
|
+
*/
|
|
6
|
+
export class RunQueue {
|
|
7
|
+
chains = new Map();
|
|
8
|
+
/** 把 task 排到 instanceKey 对应的串行链尾,返回其结果 Promise。 */
|
|
9
|
+
enqueue(instanceKey, task) {
|
|
10
|
+
const prev = this.chains.get(instanceKey) ?? Promise.resolve();
|
|
11
|
+
const next = prev.then(task, task);
|
|
12
|
+
let tail;
|
|
13
|
+
tail = next
|
|
14
|
+
.then(() => undefined, () => undefined)
|
|
15
|
+
.finally(() => {
|
|
16
|
+
if (this.chains.get(instanceKey) === tail) {
|
|
17
|
+
this.chains.delete(instanceKey);
|
|
18
|
+
}
|
|
19
|
+
});
|
|
20
|
+
this.chains.set(instanceKey, tail);
|
|
21
|
+
return next;
|
|
22
|
+
}
|
|
23
|
+
get activeKeys() {
|
|
24
|
+
return [...this.chains.keys()];
|
|
25
|
+
}
|
|
26
|
+
}
|
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
import { randomUUID } from "node:crypto";
|
|
2
|
+
import { unlinkSync, writeFileSync } from "node:fs";
|
|
3
|
+
import path from "node:path";
|
|
4
|
+
const WEB_SEARCH_RUNTIMES = new Set([
|
|
5
|
+
"claude-code",
|
|
6
|
+
"deepseek-tui",
|
|
7
|
+
"gemini",
|
|
8
|
+
"kimi-cli",
|
|
9
|
+
"hermes-agent",
|
|
10
|
+
"fake",
|
|
11
|
+
]);
|
|
12
|
+
export function availableRunCapabilities(payload, workspaceDir) {
|
|
13
|
+
const capabilities = new Set();
|
|
14
|
+
const probePath = path.join(workspaceDir, `.botlearn-write-probe-${process.pid}-${randomUUID()}`);
|
|
15
|
+
try {
|
|
16
|
+
writeFileSync(probePath, "", { flag: "wx" });
|
|
17
|
+
unlinkSync(probePath);
|
|
18
|
+
capabilities.add("workspace.write");
|
|
19
|
+
}
|
|
20
|
+
catch {
|
|
21
|
+
try {
|
|
22
|
+
unlinkSync(probePath);
|
|
23
|
+
}
|
|
24
|
+
catch {
|
|
25
|
+
// The probe may fail before creating a file.
|
|
26
|
+
}
|
|
27
|
+
// An unwritable workspace must never be advertised from static deployment config.
|
|
28
|
+
}
|
|
29
|
+
const runtimeId = payload.runtime.id ?? "codex";
|
|
30
|
+
if (WEB_SEARCH_RUNTIMES.has(runtimeId) ||
|
|
31
|
+
(runtimeId === "codex" && payload.runtime.web_search === true)) {
|
|
32
|
+
capabilities.add("web.search");
|
|
33
|
+
}
|
|
34
|
+
return Array.from(capabilities).sort();
|
|
35
|
+
}
|
|
36
|
+
export function missingRunCapabilities(payload, workspaceDir) {
|
|
37
|
+
const required = Array.isArray(payload.context.requiredCapabilities)
|
|
38
|
+
? payload.context.requiredCapabilities.filter((item) => typeof item === "string" && item.length > 0)
|
|
39
|
+
: [];
|
|
40
|
+
const available = new Set(availableRunCapabilities(payload, workspaceDir));
|
|
41
|
+
return Array.from(new Set(required.filter((item) => !available.has(item)))).sort();
|
|
42
|
+
}
|
|
@@ -0,0 +1,8 @@
|
|
|
1
|
+
import type { AppliedRunRuntimeProfile, RunStartPayload } from "./types.js";
|
|
2
|
+
export declare class RuntimeProfileApplyError extends Error {
|
|
3
|
+
readonly code = "profile_apply_failed";
|
|
4
|
+
constructor(message: string);
|
|
5
|
+
}
|
|
6
|
+
export declare function applyRunRuntimeProfile(payload: RunStartPayload, raw: unknown): AppliedRunRuntimeProfile;
|
|
7
|
+
export declare function cleanupRunRuntimeProfile(agentRunId: string): void;
|
|
8
|
+
export declare function runtimeProfileInstructions(payload: RunStartPayload, applied: AppliedRunRuntimeProfile): string[];
|
|
@@ -0,0 +1,213 @@
|
|
|
1
|
+
import { createHash } from "node:crypto";
|
|
2
|
+
import { mkdirSync, mkdtempSync, renameSync, rmSync, writeFileSync } from "node:fs";
|
|
3
|
+
import path from "node:path";
|
|
4
|
+
import { runtimeProfileDir, runRootDir } from "./workspace.js";
|
|
5
|
+
const SAFE_ASSET_ID = /^[a-z0-9][a-z0-9._-]{0,159}$/;
|
|
6
|
+
const SHA256 = /^sha256:[0-9a-f]{64}$/;
|
|
7
|
+
const MAX_SKILL_PACKAGES = 32;
|
|
8
|
+
const MAX_PACKAGE_FILES = 128;
|
|
9
|
+
const MAX_PACKAGE_BYTES = 2 * 1024 * 1024;
|
|
10
|
+
const MAX_PROMPT_CHARS = 50_000;
|
|
11
|
+
export class RuntimeProfileApplyError extends Error {
|
|
12
|
+
code = "profile_apply_failed";
|
|
13
|
+
constructor(message) {
|
|
14
|
+
super(message);
|
|
15
|
+
this.name = "RuntimeProfileApplyError";
|
|
16
|
+
}
|
|
17
|
+
}
|
|
18
|
+
export function applyRunRuntimeProfile(payload, raw) {
|
|
19
|
+
const profile = validateRuntimeProfile(raw);
|
|
20
|
+
if (payload.context.profileHash !== profile.profileHash) {
|
|
21
|
+
throw new RuntimeProfileApplyError("run.start profileHash does not match runtime profile");
|
|
22
|
+
}
|
|
23
|
+
const runDir = runRootDir(payload.agent_run_id);
|
|
24
|
+
const target = runtimeProfileDir(payload.agent_run_id);
|
|
25
|
+
mkdirSync(runDir, { recursive: true, mode: 0o700 });
|
|
26
|
+
const staging = mkdtempSync(path.join(runDir, ".runtime-profile-"));
|
|
27
|
+
try {
|
|
28
|
+
const skillsRoot = path.join(staging, "skills");
|
|
29
|
+
mkdirSync(skillsRoot, { recursive: true, mode: 0o700 });
|
|
30
|
+
const skillRefs = [];
|
|
31
|
+
const skillIds = new Set();
|
|
32
|
+
for (const skill of profile.skillPackages) {
|
|
33
|
+
if (skillIds.has(skill.id)) {
|
|
34
|
+
throw new RuntimeProfileApplyError(`duplicate runtime Skill id: ${skill.id}`);
|
|
35
|
+
}
|
|
36
|
+
skillIds.add(skill.id);
|
|
37
|
+
installSkillPackage(skillsRoot, skill);
|
|
38
|
+
skillRefs.push(`${skill.id}@${skill.version}`);
|
|
39
|
+
}
|
|
40
|
+
const promptPackPath = path.join(staging, "prompt-pack.md");
|
|
41
|
+
writeFileSync(promptPackPath, profile.promptPack.systemInstructions, { mode: 0o600 });
|
|
42
|
+
writeFileSync(path.join(staging, "profile.json"), `${JSON.stringify({
|
|
43
|
+
schemaVersion: profile.schemaVersion,
|
|
44
|
+
profileId: profile.profileId,
|
|
45
|
+
profileHash: profile.profileHash,
|
|
46
|
+
courseVersionId: profile.courseVersionId,
|
|
47
|
+
promptPack: {
|
|
48
|
+
id: profile.promptPack.id,
|
|
49
|
+
version: profile.promptPack.version,
|
|
50
|
+
digest: profile.promptPack.digest,
|
|
51
|
+
},
|
|
52
|
+
skillRefs,
|
|
53
|
+
requiredCapabilities: profile.requiredCapabilities,
|
|
54
|
+
}, null, 2)}\n`, { mode: 0o600 });
|
|
55
|
+
rmSync(target, { recursive: true, force: true });
|
|
56
|
+
renameSync(staging, target);
|
|
57
|
+
return {
|
|
58
|
+
profileId: profile.profileId,
|
|
59
|
+
profileHash: profile.profileHash,
|
|
60
|
+
promptPackPath: path.join(target, "prompt-pack.md"),
|
|
61
|
+
skillsRoot: path.join(target, "skills"),
|
|
62
|
+
skillRefs,
|
|
63
|
+
};
|
|
64
|
+
}
|
|
65
|
+
catch (err) {
|
|
66
|
+
rmSync(staging, { recursive: true, force: true });
|
|
67
|
+
if (err instanceof RuntimeProfileApplyError)
|
|
68
|
+
throw err;
|
|
69
|
+
throw new RuntimeProfileApplyError(err instanceof Error ? err.message : String(err));
|
|
70
|
+
}
|
|
71
|
+
}
|
|
72
|
+
export function cleanupRunRuntimeProfile(agentRunId) {
|
|
73
|
+
rmSync(runtimeProfileDir(agentRunId), { recursive: true, force: true });
|
|
74
|
+
}
|
|
75
|
+
export function runtimeProfileInstructions(payload, applied) {
|
|
76
|
+
const selected = stringValues(payload.context.skillRefs);
|
|
77
|
+
const available = new Set(applied.skillRefs);
|
|
78
|
+
const missing = selected.filter((ref) => !available.has(ref));
|
|
79
|
+
if (missing.length > 0) {
|
|
80
|
+
throw new RuntimeProfileApplyError(`runtime profile is missing Skills: ${missing.join(", ")}`);
|
|
81
|
+
}
|
|
82
|
+
const enabled = selected.length > 0 ? selected : applied.skillRefs;
|
|
83
|
+
return [
|
|
84
|
+
`[BotLearn Course Prompt Pack: ${applied.profileId}]`,
|
|
85
|
+
`Read the trusted prompt pack at ${applied.promptPackPath}.`,
|
|
86
|
+
...(enabled.length > 0
|
|
87
|
+
? [
|
|
88
|
+
"The following Skills are enabled only for this AgentRun:",
|
|
89
|
+
...enabled.map((ref) => {
|
|
90
|
+
const id = ref.split("@", 1)[0];
|
|
91
|
+
return `- ${ref}: ${path.join(applied.skillsRoot, id, "SKILL.md")}`;
|
|
92
|
+
}),
|
|
93
|
+
]
|
|
94
|
+
: []),
|
|
95
|
+
];
|
|
96
|
+
}
|
|
97
|
+
function installSkillPackage(skillsRoot, skill) {
|
|
98
|
+
assertSafeAssetId(skill.id, "Skill id");
|
|
99
|
+
if (!SHA256.test(skill.digest)) {
|
|
100
|
+
throw new RuntimeProfileApplyError(`Skill ${skill.id} has an invalid digest`);
|
|
101
|
+
}
|
|
102
|
+
const entries = archiveEntries(skill);
|
|
103
|
+
if (packageDigest(entries) !== skill.digest) {
|
|
104
|
+
throw new RuntimeProfileApplyError(`Skill ${skill.id}@${skill.version} digest mismatch`);
|
|
105
|
+
}
|
|
106
|
+
const skillDir = path.join(skillsRoot, skill.id);
|
|
107
|
+
mkdirSync(skillDir, { recursive: true, mode: 0o700 });
|
|
108
|
+
for (const entry of entries) {
|
|
109
|
+
const destination = path.join(skillDir, entry.path);
|
|
110
|
+
mkdirSync(path.dirname(destination), { recursive: true, mode: 0o700 });
|
|
111
|
+
writeFileSync(destination, entry.content, { mode: 0o600 });
|
|
112
|
+
}
|
|
113
|
+
}
|
|
114
|
+
function archiveEntries(skill) {
|
|
115
|
+
const archive = skill.archiveManifest;
|
|
116
|
+
if (!archive || typeof archive.skillMd !== "string" || !archive.skillMd.trim()) {
|
|
117
|
+
throw new RuntimeProfileApplyError(`Skill ${skill.id} has no SKILL.md content`);
|
|
118
|
+
}
|
|
119
|
+
if (archive.name !== undefined && archive.name !== skill.id) {
|
|
120
|
+
throw new RuntimeProfileApplyError(`Skill ${skill.id} archive name does not match its id`);
|
|
121
|
+
}
|
|
122
|
+
if ((archive.files?.length ?? 0) + 1 > MAX_PACKAGE_FILES) {
|
|
123
|
+
throw new RuntimeProfileApplyError(`Skill ${skill.id} archive has too many files`);
|
|
124
|
+
}
|
|
125
|
+
const entries = [{ path: "SKILL.md", content: archive.skillMd }];
|
|
126
|
+
const seen = new Set(["SKILL.md"]);
|
|
127
|
+
for (const file of archive.files ?? []) {
|
|
128
|
+
validateArchiveFile(file, skill.id);
|
|
129
|
+
const relative = safeRelativePath(file.path);
|
|
130
|
+
if (seen.has(relative)) {
|
|
131
|
+
throw new RuntimeProfileApplyError(`Skill ${skill.id} has duplicate file ${relative}`);
|
|
132
|
+
}
|
|
133
|
+
seen.add(relative);
|
|
134
|
+
entries.push({ path: relative, content: file.content });
|
|
135
|
+
}
|
|
136
|
+
const total = entries.reduce((sum, entry) => sum + Buffer.byteLength(entry.content), 0);
|
|
137
|
+
if (total > MAX_PACKAGE_BYTES) {
|
|
138
|
+
throw new RuntimeProfileApplyError(`Skill ${skill.id} archive is too large`);
|
|
139
|
+
}
|
|
140
|
+
return entries.sort((a, b) => a.path.localeCompare(b.path));
|
|
141
|
+
}
|
|
142
|
+
function validateArchiveFile(file, skillId) {
|
|
143
|
+
if (!file || typeof file.path !== "string" || typeof file.content !== "string") {
|
|
144
|
+
throw new RuntimeProfileApplyError(`Skill ${skillId} contains an invalid archive file`);
|
|
145
|
+
}
|
|
146
|
+
const bytes = Buffer.from(file.content, "utf8");
|
|
147
|
+
if (file.size !== undefined && file.size !== bytes.length) {
|
|
148
|
+
throw new RuntimeProfileApplyError(`Skill ${skillId} file ${file.path} size mismatch`);
|
|
149
|
+
}
|
|
150
|
+
if (file.sha256 !== undefined) {
|
|
151
|
+
const digest = createHash("sha256").update(bytes).digest("hex");
|
|
152
|
+
if (file.sha256 !== digest) {
|
|
153
|
+
throw new RuntimeProfileApplyError(`Skill ${skillId} file ${file.path} digest mismatch`);
|
|
154
|
+
}
|
|
155
|
+
}
|
|
156
|
+
}
|
|
157
|
+
function packageDigest(entries) {
|
|
158
|
+
const digest = createHash("sha256");
|
|
159
|
+
for (const entry of entries) {
|
|
160
|
+
digest.update(Buffer.from(entry.path, "utf8"));
|
|
161
|
+
digest.update(Buffer.from([0]));
|
|
162
|
+
digest.update(Buffer.from(entry.content, "utf8"));
|
|
163
|
+
digest.update(Buffer.from([0]));
|
|
164
|
+
}
|
|
165
|
+
return `sha256:${digest.digest("hex")}`;
|
|
166
|
+
}
|
|
167
|
+
function validateRuntimeProfile(raw) {
|
|
168
|
+
if (!raw || typeof raw !== "object") {
|
|
169
|
+
throw new RuntimeProfileApplyError("runtime profile must be an object");
|
|
170
|
+
}
|
|
171
|
+
const profile = raw;
|
|
172
|
+
if (profile.schemaVersion !== "botlearn-course-runtime-profile/0.1") {
|
|
173
|
+
throw new RuntimeProfileApplyError("unsupported runtime profile schemaVersion");
|
|
174
|
+
}
|
|
175
|
+
assertSafeAssetId(profile.profileId, "Profile id");
|
|
176
|
+
if (typeof profile.profileHash !== "string" || !SHA256.test(profile.profileHash)) {
|
|
177
|
+
throw new RuntimeProfileApplyError("runtime profile has an invalid profileHash");
|
|
178
|
+
}
|
|
179
|
+
if (!profile.promptPack || typeof profile.promptPack.systemInstructions !== "string") {
|
|
180
|
+
throw new RuntimeProfileApplyError("runtime profile has an invalid Prompt Pack");
|
|
181
|
+
}
|
|
182
|
+
assertSafeAssetId(profile.promptPack.id, "Prompt Pack id");
|
|
183
|
+
if (!SHA256.test(profile.promptPack.digest) ||
|
|
184
|
+
!profile.promptPack.systemInstructions.trim() ||
|
|
185
|
+
profile.promptPack.systemInstructions.length > MAX_PROMPT_CHARS) {
|
|
186
|
+
throw new RuntimeProfileApplyError("runtime profile Prompt Pack is invalid");
|
|
187
|
+
}
|
|
188
|
+
if (!Array.isArray(profile.skillPackages) || !Array.isArray(profile.requiredCapabilities)) {
|
|
189
|
+
throw new RuntimeProfileApplyError("runtime profile has invalid Skill or capability lists");
|
|
190
|
+
}
|
|
191
|
+
if (profile.skillPackages.length > MAX_SKILL_PACKAGES) {
|
|
192
|
+
throw new RuntimeProfileApplyError("runtime profile has too many Skill packages");
|
|
193
|
+
}
|
|
194
|
+
return profile;
|
|
195
|
+
}
|
|
196
|
+
function safeRelativePath(value) {
|
|
197
|
+
if (!value ||
|
|
198
|
+
value.includes("\\") ||
|
|
199
|
+
value.includes("\0") ||
|
|
200
|
+
path.posix.isAbsolute(value) ||
|
|
201
|
+
value.split("/").some((part) => part === "" || part === "." || part === "..")) {
|
|
202
|
+
throw new RuntimeProfileApplyError(`unsafe Skill archive path: ${JSON.stringify(value)}`);
|
|
203
|
+
}
|
|
204
|
+
return value;
|
|
205
|
+
}
|
|
206
|
+
function assertSafeAssetId(value, label) {
|
|
207
|
+
if (typeof value !== "string" || !SAFE_ASSET_ID.test(value)) {
|
|
208
|
+
throw new RuntimeProfileApplyError(`${label} is unsafe`);
|
|
209
|
+
}
|
|
210
|
+
}
|
|
211
|
+
function stringValues(raw) {
|
|
212
|
+
return Array.isArray(raw) ? raw.filter((item) => typeof item === "string") : [];
|
|
213
|
+
}
|