@botlearn-course/daemon 0.0.15 → 0.0.17-beta.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/README.md +3 -0
- package/dist/agent-service-client.d.ts +2 -2
- package/dist/agent-service-client.js +12 -1
- package/dist/agent-service-sandbox.d.ts +2 -2
- package/dist/agent-service-sandbox.js +90 -24
- package/dist/course-client.d.ts +2 -2
- package/dist/course-client.js +12 -1
- package/dist/input-attachments.d.ts +4 -2
- package/dist/input-attachments.js +34 -16
- package/dist/run-dispatcher.d.ts +2 -2
- package/dist/run-dispatcher.js +123 -31
- package/dist/types.d.ts +5 -0
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -102,6 +102,9 @@ runtime 凭据。
|
|
|
102
102
|
- 文件扫描只访问 workspace 内路径;包含 `..`、绝对路径或反斜杠的路径会被拒绝。daemon
|
|
103
103
|
只上报相对路径、大小、sha256 和有界脱敏 preview,文件正文始终留在本地或托管 sandbox
|
|
104
104
|
的持久 workspace,不上传 Course Service 或对象存储。扫描或 metadata 上报失败不影响 run 终态。
|
|
105
|
+
- 托管 Practice Agent 的学员输入附件由 Course Service 为当前 activation 签发单对象、短时效的
|
|
106
|
+
存储源站 URL;daemon 直接下载并复核 grant 集合、大小、SHA-256、MIME 与图片签名。URL 只留在
|
|
107
|
+
control process 内存,不进入 runtime env、prompt、workspace state、transcript、事件或日志。
|
|
105
108
|
- 每个 run 在独立 workspace 内执行并有超时上限;runtime 进程在取消/超时后会被终止
|
|
106
109
|
(SIGTERM → SIGKILL)。
|
|
107
110
|
- `report_progress` MCP 子进程以 clean environment 启动,不继承 Course 控制字段或模型凭据;
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import type { CourseRuntimeProfile, RunEvent, RunFileCandidate, RunFileRecord, RunStartPayload } from "./types.js";
|
|
1
|
+
import type { CourseRuntimeProfile, RunEvent, RunEventReceipt, RunFileCandidate, RunFileRecord, RunStartPayload } from "./types.js";
|
|
2
2
|
/** Run-scoped client used only inside an Agent Service sandbox. */
|
|
3
3
|
export declare class AgentServiceRunClient {
|
|
4
4
|
private readonly baseUrl;
|
|
@@ -10,7 +10,7 @@ export declare class AgentServiceRunClient {
|
|
|
10
10
|
private url;
|
|
11
11
|
private request;
|
|
12
12
|
getRun(): Promise<RunStartPayload>;
|
|
13
|
-
postEvent(agentRunId: string, event: RunEvent): Promise<
|
|
13
|
+
postEvent(agentRunId: string, event: RunEvent): Promise<RunEventReceipt>;
|
|
14
14
|
postFile(agentRunId: string, file: RunFileCandidate): Promise<RunFileRecord>;
|
|
15
15
|
getRunRuntimeProfile(agentRunId: string): Promise<CourseRuntimeProfile>;
|
|
16
16
|
heartbeat(): Promise<void>;
|
|
@@ -47,7 +47,18 @@ export class AgentServiceRunClient {
|
|
|
47
47
|
this.assertRunId(agentRunId);
|
|
48
48
|
this.traceId = event.trace_id ?? this.traceId;
|
|
49
49
|
const sanitized = redactSecretsDeep(event, 8, [this.runToken]);
|
|
50
|
-
await this.request("POST", `/course/v1/agent-service/runs/${agentRunId}/events`, sanitized);
|
|
50
|
+
const response = await this.request("POST", `/course/v1/agent-service/runs/${agentRunId}/events`, sanitized);
|
|
51
|
+
return response?.disposition === "retry"
|
|
52
|
+
? {
|
|
53
|
+
disposition: "retry",
|
|
54
|
+
...(typeof response.candidate_attempt === "number"
|
|
55
|
+
? { candidate_attempt: response.candidate_attempt }
|
|
56
|
+
: {}),
|
|
57
|
+
...(typeof response.retry_feedback === "string"
|
|
58
|
+
? { retry_feedback: response.retry_feedback }
|
|
59
|
+
: {}),
|
|
60
|
+
}
|
|
61
|
+
: { disposition: "accepted" };
|
|
51
62
|
}
|
|
52
63
|
async postFile(agentRunId, file) {
|
|
53
64
|
this.assertRunId(agentRunId);
|
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
import { type Logger } from "./log.js";
|
|
2
2
|
import { type RuntimeSkillProviderFactory } from "./runtime-skills.js";
|
|
3
3
|
import { type PersistentSessionExecution, type PreparedPersistentTurn, type RunReportingClient } from "./run-dispatcher.js";
|
|
4
|
-
import type { CourseRuntime, CourseRuntimeProfile, RunEvent, RunFileCandidate, RunFileRecord, RunStartPayload } from "./types.js";
|
|
4
|
+
import type { CourseRuntime, CourseRuntimeProfile, RunEvent, RunEventReceipt, RunFileCandidate, RunFileRecord, RunStartPayload } from "./types.js";
|
|
5
5
|
export interface AgentServiceSandboxOptions {
|
|
6
6
|
wsUrl: string;
|
|
7
7
|
sandboxId: string;
|
|
@@ -73,7 +73,7 @@ export declare class AgentServiceSandboxClient implements RunReportingClient, Pe
|
|
|
73
73
|
private recoverPendingActivationCleanups;
|
|
74
74
|
run(): Promise<void>;
|
|
75
75
|
stop(): void;
|
|
76
|
-
postEvent(agentRunId: string, event: RunEvent): Promise<
|
|
76
|
+
postEvent(agentRunId: string, event: RunEvent): Promise<RunEventReceipt>;
|
|
77
77
|
postFile(agentRunId: string, file: RunFileCandidate): Promise<RunFileRecord>;
|
|
78
78
|
getRunRuntimeProfile(agentRunId: string): Promise<CourseRuntimeProfile>;
|
|
79
79
|
private connectOnce;
|
|
@@ -15,6 +15,7 @@ const MAX_SPOOL_FRAMES = 1024;
|
|
|
15
15
|
const MAX_SPOOL_BYTES = 8 * 1024 * 1024;
|
|
16
16
|
const MAX_EVENT_ACK_WINDOW = 64;
|
|
17
17
|
const LEGACY_EVENT_ACK_WINDOW = 1;
|
|
18
|
+
const INPUT_ATTACHMENT_ID = /^[0-9a-f]{8}-[0-9a-f]{4}-[1-5][0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/i;
|
|
18
19
|
/** 出站 seq 基址:seq = connection_epoch * SEQ_EPOCH_BASE + n,跨重连单调(合同 §1.1)。 */
|
|
19
20
|
const SEQ_EPOCH_BASE = 1_000_000_000;
|
|
20
21
|
const RUNTIME_LOG_WINDOW_MS = 60_000;
|
|
@@ -88,26 +89,44 @@ function inputAttachmentGrant(value) {
|
|
|
88
89
|
throw new Error("invalid_input_attachment_grant");
|
|
89
90
|
}
|
|
90
91
|
const candidate = value;
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
92
|
+
if (!Array.isArray(candidate.downloads) ||
|
|
93
|
+
candidate.downloads.length < 1 ||
|
|
94
|
+
candidate.downloads.length > 5) {
|
|
94
95
|
throw new Error("invalid_input_attachment_grant");
|
|
95
96
|
}
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
parsed
|
|
97
|
+
const downloads = candidate.downloads.map((value) => {
|
|
98
|
+
if (!value || typeof value !== "object" || Array.isArray(value)) {
|
|
99
|
+
throw new Error("invalid_input_attachment_grant");
|
|
100
|
+
}
|
|
101
|
+
const raw = value;
|
|
102
|
+
const attachmentId = typeof raw.attachment_id === "string"
|
|
103
|
+
? raw.attachment_id.trim()
|
|
104
|
+
: "";
|
|
105
|
+
const downloadUrl = typeof raw.download_url === "string"
|
|
106
|
+
? raw.download_url.trim()
|
|
107
|
+
: "";
|
|
108
|
+
let parsed;
|
|
109
|
+
try {
|
|
110
|
+
parsed = new URL(downloadUrl);
|
|
111
|
+
}
|
|
112
|
+
catch {
|
|
113
|
+
throw new Error("invalid_input_attachment_grant");
|
|
114
|
+
}
|
|
115
|
+
if (!INPUT_ATTACHMENT_ID.test(attachmentId) ||
|
|
116
|
+
!downloadUrl ||
|
|
117
|
+
downloadUrl.length > 4096 ||
|
|
118
|
+
!["http:", "https:"].includes(parsed.protocol) ||
|
|
119
|
+
parsed.username ||
|
|
120
|
+
parsed.password ||
|
|
121
|
+
parsed.hash) {
|
|
122
|
+
throw new Error("invalid_input_attachment_grant");
|
|
123
|
+
}
|
|
124
|
+
return { attachmentId, downloadUrl };
|
|
125
|
+
});
|
|
126
|
+
if (new Set(downloads.map((item) => item.attachmentId)).size !== downloads.length) {
|
|
108
127
|
throw new Error("invalid_input_attachment_grant");
|
|
109
128
|
}
|
|
110
|
-
return {
|
|
129
|
+
return { downloads };
|
|
111
130
|
}
|
|
112
131
|
function runtimeSkillGrantKey(grants) {
|
|
113
132
|
return JSON.stringify(grants);
|
|
@@ -418,9 +437,8 @@ export class AgentServiceSandboxClient {
|
|
|
418
437
|
const scope = this.turnScopes.get(payload.agent_run_id);
|
|
419
438
|
if (!scope)
|
|
420
439
|
return;
|
|
421
|
-
const activation = this.activationContexts.get(scope.sessionId);
|
|
422
440
|
const session = this.state.sessions[scope.sessionId];
|
|
423
|
-
if (session
|
|
441
|
+
if (session?.activationId === scope.activationId) {
|
|
424
442
|
// Persist the cleanup obligation before attempting chmod. A terminal event has
|
|
425
443
|
// already been durably ACKed at this point, so reconnect/restart must finish this
|
|
426
444
|
// revoke even when the server desired state has already moved to idle.
|
|
@@ -603,8 +621,7 @@ export class AgentServiceSandboxClient {
|
|
|
603
621
|
reject: rejectAck,
|
|
604
622
|
});
|
|
605
623
|
await this.sendFrame(frame);
|
|
606
|
-
|
|
607
|
-
await ack;
|
|
624
|
+
return event.type !== "run.block" ? await ack : { disposition: "accepted" };
|
|
608
625
|
}
|
|
609
626
|
async postFile(agentRunId, file) {
|
|
610
627
|
const scope = this.turnScopes.get(agentRunId);
|
|
@@ -1236,15 +1253,54 @@ export class AgentServiceSandboxClient {
|
|
|
1236
1253
|
});
|
|
1237
1254
|
}
|
|
1238
1255
|
async handleTurnCancel(frame) {
|
|
1239
|
-
|
|
1256
|
+
const sessionId = frame.runtime_session_id;
|
|
1257
|
+
const runId = frame.agent_run_id;
|
|
1258
|
+
const workerAttempt = frame.worker_attempt;
|
|
1259
|
+
const activationId = frame.activation_id;
|
|
1260
|
+
const requestedScope = { sessionId, workerAttempt, activationId };
|
|
1261
|
+
const existingScope = this.turnScopes.get(runId);
|
|
1262
|
+
if (existingScope && !this.sameTurnScope(existingScope, requestedScope)) {
|
|
1240
1263
|
await this.sendCommandAck(frame, "rejected", "turn_scope_mismatch");
|
|
1241
1264
|
return;
|
|
1242
1265
|
}
|
|
1243
|
-
|
|
1244
|
-
|
|
1266
|
+
const session = this.state.sessions[sessionId];
|
|
1267
|
+
if (!session) {
|
|
1268
|
+
await this.sendCommandAck(frame, "rejected", "session_not_open");
|
|
1269
|
+
return;
|
|
1270
|
+
}
|
|
1271
|
+
const accepted = Object.values(session.acceptedCommands).find((command) => command.agentRunId === runId);
|
|
1272
|
+
if (accepted && (accepted.workerAttempt !== workerAttempt ||
|
|
1273
|
+
accepted.activationId !== activationId)) {
|
|
1274
|
+
await this.sendCommandAck(frame, "rejected", "turn_scope_mismatch");
|
|
1245
1275
|
return;
|
|
1246
1276
|
}
|
|
1277
|
+
this.turnScopes.set(runId, requestedScope);
|
|
1278
|
+
if (this.dispatcher.cancel(runId)) {
|
|
1279
|
+
await this.sendCommandAck(frame, "ok");
|
|
1280
|
+
return;
|
|
1281
|
+
}
|
|
1282
|
+
// ``turn.cancel`` may win the race before ``turn.start`` is delivered, or arrive
|
|
1283
|
+
// after a daemon restart lost the runtime process. The fenced server command is
|
|
1284
|
+
// authoritative: ACK it and synthesize the durable terminal event without touching
|
|
1285
|
+
// the sandbox generation or deleting the runtime-session workspace.
|
|
1247
1286
|
await this.sendCommandAck(frame, "ok");
|
|
1287
|
+
await this.postEvent(runId, {
|
|
1288
|
+
type: "run.cancelled",
|
|
1289
|
+
event_id: `cancel-${runId}-${workerAttempt}`,
|
|
1290
|
+
seq: 1,
|
|
1291
|
+
payload: {
|
|
1292
|
+
reason: "cancelled_by_request",
|
|
1293
|
+
runtime: session.runtimeId,
|
|
1294
|
+
},
|
|
1295
|
+
});
|
|
1296
|
+
const commandId = `run:${runId}:${workerAttempt}`;
|
|
1297
|
+
if (!session.completedCommands.includes(commandId)) {
|
|
1298
|
+
session.completedCommands.push(commandId);
|
|
1299
|
+
session.completedCommands = session.completedCommands.slice(-256);
|
|
1300
|
+
}
|
|
1301
|
+
delete session.acceptedCommands[commandId];
|
|
1302
|
+
this.persist();
|
|
1303
|
+
this.finishTurn({ agent_run_id: runId });
|
|
1248
1304
|
}
|
|
1249
1305
|
/**
|
|
1250
1306
|
* 幂等关闭一个 runtime session:终止其 runtime 子进程、删除 workspace/native state、
|
|
@@ -1474,7 +1530,17 @@ export class AgentServiceSandboxClient {
|
|
|
1474
1530
|
this.persist();
|
|
1475
1531
|
if (pending) {
|
|
1476
1532
|
this.pendingAcks.delete(frameId);
|
|
1477
|
-
pending.resolve(
|
|
1533
|
+
pending.resolve(frame.payload.disposition === "retry"
|
|
1534
|
+
? {
|
|
1535
|
+
disposition: "retry",
|
|
1536
|
+
...(typeof frame.payload.candidate_attempt === "number"
|
|
1537
|
+
? { candidate_attempt: frame.payload.candidate_attempt }
|
|
1538
|
+
: {}),
|
|
1539
|
+
...(typeof frame.payload.retry_feedback === "string"
|
|
1540
|
+
? { retry_feedback: frame.payload.retry_feedback }
|
|
1541
|
+
: {}),
|
|
1542
|
+
}
|
|
1543
|
+
: { disposition: "accepted" });
|
|
1478
1544
|
}
|
|
1479
1545
|
}
|
|
1480
1546
|
async replaySpool() {
|
package/dist/course-client.d.ts
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import type { CourseRuntimeProfile, DaemonAuth, RunEvent, RunFileCandidate, RunFileRecord, RunStartPayload } from "./types.js";
|
|
1
|
+
import type { CourseRuntimeProfile, DaemonAuth, RunEvent, RunEventReceipt, RunFileCandidate, RunFileRecord, RunStartPayload } from "./types.js";
|
|
2
2
|
export declare class CourseClientError extends Error {
|
|
3
3
|
readonly status: number;
|
|
4
4
|
constructor(status: number, message: string);
|
|
@@ -31,7 +31,7 @@ export declare class CourseClient {
|
|
|
31
31
|
private request;
|
|
32
32
|
/** 领取下一个分配给本 daemon 的 queued run(无则返回 null)。 */
|
|
33
33
|
claimNextRun(): Promise<RunStartPayload | null>;
|
|
34
|
-
postEvent(agentRunId: string, event: RunEvent): Promise<
|
|
34
|
+
postEvent(agentRunId: string, event: RunEvent): Promise<RunEventReceipt>;
|
|
35
35
|
postFile(agentRunId: string, file: RunFileCandidate): Promise<RunFileRecord | void>;
|
|
36
36
|
getRunRuntimeProfile(agentRunId: string): Promise<CourseRuntimeProfile>;
|
|
37
37
|
}
|
package/dist/course-client.js
CHANGED
|
@@ -103,7 +103,18 @@ export class CourseClient {
|
|
|
103
103
|
async postEvent(agentRunId, event) {
|
|
104
104
|
const credentials = [this.accessToken, this.refreshToken].filter((value) => typeof value === "string");
|
|
105
105
|
const sanitized = redactSecretsDeep(event, 8, credentials);
|
|
106
|
-
await this.request("POST", `/course/v1/daemon/runs/${agentRunId}/events`, sanitized, event.trace_id);
|
|
106
|
+
const response = await this.request("POST", `/course/v1/daemon/runs/${agentRunId}/events`, sanitized, event.trace_id);
|
|
107
|
+
return response?.disposition === "retry"
|
|
108
|
+
? {
|
|
109
|
+
disposition: "retry",
|
|
110
|
+
...(typeof response.candidate_attempt === "number"
|
|
111
|
+
? { candidate_attempt: response.candidate_attempt }
|
|
112
|
+
: {}),
|
|
113
|
+
...(typeof response.retry_feedback === "string"
|
|
114
|
+
? { retry_feedback: response.retry_feedback }
|
|
115
|
+
: {}),
|
|
116
|
+
}
|
|
117
|
+
: { disposition: "accepted" };
|
|
107
118
|
}
|
|
108
119
|
async postFile(agentRunId, file) {
|
|
109
120
|
return this.request("POST", `/course/v1/daemon/runs/${agentRunId}/files`, file);
|
|
@@ -1,6 +1,8 @@
|
|
|
1
1
|
import type { MaterializedInputAttachment, RunInputAttachment } from "./types.js";
|
|
2
2
|
export interface InputAttachmentGrant {
|
|
3
|
-
|
|
4
|
-
|
|
3
|
+
downloads: Array<{
|
|
4
|
+
attachmentId: string;
|
|
5
|
+
downloadUrl: string;
|
|
6
|
+
}>;
|
|
5
7
|
}
|
|
6
8
|
export declare function materializeInputAttachments(workspaceDir: string, attachments: RunInputAttachment[], grant: InputAttachmentGrant, signal: AbortSignal): Promise<MaterializedInputAttachment[]>;
|
|
@@ -58,26 +58,39 @@ function validateAttachment(attachment) {
|
|
|
58
58
|
}
|
|
59
59
|
return extension;
|
|
60
60
|
}
|
|
61
|
-
function
|
|
62
|
-
if (!
|
|
63
|
-
throw new Error("invalid input attachment download
|
|
61
|
+
function validateDownloadUrl(value) {
|
|
62
|
+
if (!value || value.length > 4096) {
|
|
63
|
+
throw new Error("invalid input attachment download URL");
|
|
64
64
|
}
|
|
65
|
-
let
|
|
65
|
+
let parsed;
|
|
66
66
|
try {
|
|
67
|
-
|
|
67
|
+
parsed = new URL(value);
|
|
68
68
|
}
|
|
69
69
|
catch {
|
|
70
70
|
throw new Error("invalid input attachment download URL");
|
|
71
71
|
}
|
|
72
|
-
if (!["http:", "https:"].includes(
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
base.hash ||
|
|
77
|
-
base.toString().length > 2048) {
|
|
72
|
+
if (!["http:", "https:"].includes(parsed.protocol) ||
|
|
73
|
+
parsed.username ||
|
|
74
|
+
parsed.password ||
|
|
75
|
+
parsed.hash) {
|
|
78
76
|
throw new Error("invalid input attachment download URL");
|
|
79
77
|
}
|
|
80
|
-
return
|
|
78
|
+
return value;
|
|
79
|
+
}
|
|
80
|
+
function validateGrant(grant) {
|
|
81
|
+
if (!Array.isArray(grant.downloads) ||
|
|
82
|
+
grant.downloads.length < 1 ||
|
|
83
|
+
grant.downloads.length > MAX_ATTACHMENTS) {
|
|
84
|
+
throw new Error("invalid input attachment download grant");
|
|
85
|
+
}
|
|
86
|
+
const downloads = new Map();
|
|
87
|
+
for (const item of grant.downloads) {
|
|
88
|
+
if (!ATTACHMENT_ID.test(item.attachmentId) || downloads.has(item.attachmentId)) {
|
|
89
|
+
throw new Error("invalid input attachment download grant");
|
|
90
|
+
}
|
|
91
|
+
downloads.set(item.attachmentId, validateDownloadUrl(item.downloadUrl));
|
|
92
|
+
}
|
|
93
|
+
return downloads;
|
|
81
94
|
}
|
|
82
95
|
function hasExpectedImageSignature(contentType, bytes) {
|
|
83
96
|
switch (contentType) {
|
|
@@ -177,7 +190,12 @@ export async function materializeInputAttachments(workspaceDir, attachments, gra
|
|
|
177
190
|
if (new Set(attachments.map((item) => item.attachment_id)).size !== attachments.length) {
|
|
178
191
|
throw new Error("duplicate input attachment id");
|
|
179
192
|
}
|
|
180
|
-
const
|
|
193
|
+
const extensions = new Map(attachments.map((attachment) => [attachment.attachment_id, validateAttachment(attachment)]));
|
|
194
|
+
const downloadUrls = validateGrant(grant);
|
|
195
|
+
if (downloadUrls.size !== attachments.length ||
|
|
196
|
+
attachments.some((attachment) => !downloadUrls.has(attachment.attachment_id))) {
|
|
197
|
+
throw new Error("input attachment download grant does not match run attachments");
|
|
198
|
+
}
|
|
181
199
|
const inputDir = path.resolve(workspaceDir, ".botlearn", "input-attachments");
|
|
182
200
|
const workspaceRoot = path.resolve(workspaceDir);
|
|
183
201
|
if (!inputDir.startsWith(`${workspaceRoot}${path.sep}`)) {
|
|
@@ -188,7 +206,7 @@ export async function materializeInputAttachments(workspaceDir, attachments, gra
|
|
|
188
206
|
await chmod(inputDir, 0o770);
|
|
189
207
|
const results = [];
|
|
190
208
|
for (const attachment of attachments) {
|
|
191
|
-
const extension =
|
|
209
|
+
const extension = extensions.get(attachment.attachment_id);
|
|
192
210
|
const filename = `${attachment.attachment_id}${extension}`;
|
|
193
211
|
const filePath = path.join(inputDir, filename);
|
|
194
212
|
const relativePath = path.posix.join(".botlearn", "input-attachments", filename);
|
|
@@ -196,9 +214,9 @@ export async function materializeInputAttachments(workspaceDir, attachments, gra
|
|
|
196
214
|
await rm(filePath, { force: true });
|
|
197
215
|
const partPath = `${filePath}.part`;
|
|
198
216
|
await rm(partPath, { force: true });
|
|
199
|
-
const downloadUrl =
|
|
217
|
+
const downloadUrl = downloadUrls.get(attachment.attachment_id);
|
|
200
218
|
const response = await fetch(downloadUrl, {
|
|
201
|
-
headers: {
|
|
219
|
+
headers: { "accept-encoding": "identity" },
|
|
202
220
|
redirect: "error",
|
|
203
221
|
signal,
|
|
204
222
|
});
|
package/dist/run-dispatcher.d.ts
CHANGED
|
@@ -2,7 +2,7 @@ import { type ScanLimits } from "./file-candidates.js";
|
|
|
2
2
|
import { type InputAttachmentGrant } from "./input-attachments.js";
|
|
3
3
|
import { type Logger } from "./log.js";
|
|
4
4
|
import type { PreparedRuntimeSkillProvider } from "./runtime-skills.js";
|
|
5
|
-
import { type CourseRuntimeProfile, type CourseRuntime, type RunEvent, type RunStartPayload } from "./types.js";
|
|
5
|
+
import { type CourseRuntimeProfile, type CourseRuntime, type RunEvent, type RunEventReceipt, type RunStartPayload } from "./types.js";
|
|
6
6
|
export interface RunDispatcherOptions {
|
|
7
7
|
defaultRuntimeId?: string;
|
|
8
8
|
log?: Logger;
|
|
@@ -26,7 +26,7 @@ export interface PersistentSessionExecution {
|
|
|
26
26
|
finishTurn(payload: RunStartPayload): void;
|
|
27
27
|
}
|
|
28
28
|
export interface RunReportingClient {
|
|
29
|
-
postEvent(agentRunId: string, event: RunEvent): Promise<void>;
|
|
29
|
+
postEvent(agentRunId: string, event: RunEvent): Promise<void | RunEventReceipt>;
|
|
30
30
|
postFile(agentRunId: string, file: import("./types.js").RunFileCandidate): Promise<unknown>;
|
|
31
31
|
getRunRuntimeProfile?(agentRunId: string): Promise<CourseRuntimeProfile>;
|
|
32
32
|
}
|
package/dist/run-dispatcher.js
CHANGED
|
@@ -23,6 +23,7 @@ const BLOCK_TEXT_MAX_CHARS = 4000;
|
|
|
23
23
|
const CONTENT_FLUSH_MAX_CHARS = 512;
|
|
24
24
|
const CONTENT_FLUSH_INTERVAL_MS = 100;
|
|
25
25
|
const AGENT_STREAM_SCHEMA_VERSION = "agent-stream/0.1";
|
|
26
|
+
const MAX_CANDIDATE_GENERATION_ATTEMPTS = 3;
|
|
26
27
|
const SAFE_TOOL_NAME = /^[A-Za-z0-9][A-Za-z0-9._:/-]{0,79}$/;
|
|
27
28
|
const SAFE_FAILURE_CODE = /^[A-Za-z0-9][A-Za-z0-9._:-]{0,119}$/;
|
|
28
29
|
const SAFE_FAILURE_MODEL = /^[A-Za-z0-9][A-Za-z0-9._:/-]{0,159}$/;
|
|
@@ -100,6 +101,29 @@ function chunkUnicodeText(value, maxCodePoints) {
|
|
|
100
101
|
}
|
|
101
102
|
return chunks;
|
|
102
103
|
}
|
|
104
|
+
function candidateRetryPayload(payload, feedback) {
|
|
105
|
+
const original = typeof payload.input.text === "string" ? payload.input.text : "";
|
|
106
|
+
const instruction = truncateText(redactSecretString(feedback?.trim() || "Produce a complete learner-facing reply."), 800);
|
|
107
|
+
return {
|
|
108
|
+
...payload,
|
|
109
|
+
input: {
|
|
110
|
+
...payload.input,
|
|
111
|
+
text: [
|
|
112
|
+
"[BotLearn internal response retry]",
|
|
113
|
+
"Replace the previous candidate completely. Do not mention this review or the previous reply.",
|
|
114
|
+
`Correction required: ${instruction}`,
|
|
115
|
+
original ? `Original current request:\n${original}` : "",
|
|
116
|
+
].filter(Boolean).join("\n\n"),
|
|
117
|
+
},
|
|
118
|
+
};
|
|
119
|
+
}
|
|
120
|
+
function replacesLatestConversationTurn(payload) {
|
|
121
|
+
const regeneration = payload.context.regeneration;
|
|
122
|
+
return Boolean(regeneration &&
|
|
123
|
+
typeof regeneration === "object" &&
|
|
124
|
+
!Array.isArray(regeneration) &&
|
|
125
|
+
regeneration.mode === "replace_latest");
|
|
126
|
+
}
|
|
103
127
|
/**
|
|
104
128
|
* Run dispatcher:把 Course Service 下发的 run.start 交给 runtime,
|
|
105
129
|
* 并把 runtime 输出归一化成 run.block / run.message / run.completed 回报 Course Service。
|
|
@@ -265,8 +289,7 @@ export class RunDispatcher {
|
|
|
265
289
|
};
|
|
266
290
|
for (let attempt = 0; attempt < 3; attempt += 1) {
|
|
267
291
|
try {
|
|
268
|
-
await this.client.postEvent(runId, outgoing);
|
|
269
|
-
return;
|
|
292
|
+
return await this.client.postEvent(runId, outgoing);
|
|
270
293
|
}
|
|
271
294
|
catch (err) {
|
|
272
295
|
if (isRunTerminal(err)) {
|
|
@@ -274,7 +297,13 @@ export class RunDispatcher {
|
|
|
274
297
|
controller.abort();
|
|
275
298
|
return;
|
|
276
299
|
}
|
|
277
|
-
const
|
|
300
|
+
const candidateReview = event.type === "run.message"
|
|
301
|
+
&& typeof event.payload?.candidate_attempt === "number";
|
|
302
|
+
// The Course Service already retries the side-effect-free judge once. A known
|
|
303
|
+
// 5xx from that boundary must not multiply one review into six provider calls;
|
|
304
|
+
// transport errors without a response still reuse this event id normally.
|
|
305
|
+
const retryable = !(err instanceof CourseClientError)
|
|
306
|
+
|| (!candidateReview && (err.status === 429 || err.status >= 500));
|
|
278
307
|
if (!retryable || attempt === 2)
|
|
279
308
|
throw err;
|
|
280
309
|
await new Promise((resolve) => setTimeout(resolve, 100 * (attempt + 1)));
|
|
@@ -441,6 +470,14 @@ export class RunDispatcher {
|
|
|
441
470
|
controller.abort();
|
|
442
471
|
}, timeoutSeconds * 1000);
|
|
443
472
|
let finalText = "";
|
|
473
|
+
let activeNativeSessionId = persistentTurn?.nativeSessionId ?? null;
|
|
474
|
+
if (activeNativeSessionId && replacesLatestConversationTurn(payload)) {
|
|
475
|
+
// A user retry is a replacement branch, not a follow-up to the discarded answer.
|
|
476
|
+
// Clear the provider-native cache before execution; the durable Course transcript
|
|
477
|
+
// in this payload is sufficient to rebuild the branch in the same turn.
|
|
478
|
+
activeNativeSessionId = null;
|
|
479
|
+
this.persistentSession?.persistNativeSession("");
|
|
480
|
+
}
|
|
444
481
|
// 上一次真正上了 wire 的块 kind:status 只在 kind 切换时上报一次,避免刷屏。
|
|
445
482
|
let lastReportedKind = null;
|
|
446
483
|
let lastReasoningPhase = null;
|
|
@@ -730,31 +767,93 @@ export class RunDispatcher {
|
|
|
730
767
|
await this.client.postFile(runId, file);
|
|
731
768
|
},
|
|
732
769
|
runtimeSession: async (sessionId) => {
|
|
770
|
+
activeNativeSessionId = sessionId;
|
|
733
771
|
this.persistentSession?.persistNativeSession(sessionId);
|
|
734
772
|
},
|
|
735
773
|
};
|
|
736
774
|
modelStartedAt = this.now();
|
|
775
|
+
let acceptedOutput = "";
|
|
776
|
+
let attemptPayload = payload;
|
|
777
|
+
const resetVisibleCandidate = async () => {
|
|
778
|
+
await send({
|
|
779
|
+
type: "run.block",
|
|
780
|
+
payload: {
|
|
781
|
+
schema_version: AGENT_STREAM_SCHEMA_VERSION,
|
|
782
|
+
kind: "response_reset",
|
|
783
|
+
runtime: runtime.id,
|
|
784
|
+
status: "retrying",
|
|
785
|
+
},
|
|
786
|
+
});
|
|
787
|
+
};
|
|
737
788
|
try {
|
|
738
|
-
|
|
739
|
-
|
|
740
|
-
|
|
741
|
-
|
|
742
|
-
|
|
743
|
-
|
|
744
|
-
|
|
745
|
-
|
|
746
|
-
|
|
747
|
-
|
|
748
|
-
|
|
749
|
-
|
|
750
|
-
|
|
751
|
-
|
|
752
|
-
|
|
753
|
-
|
|
754
|
-
|
|
755
|
-
|
|
756
|
-
|
|
757
|
-
|
|
789
|
+
for (let candidateAttempt = 1; candidateAttempt <= MAX_CANDIDATE_GENERATION_ATTEMPTS; candidateAttempt += 1) {
|
|
790
|
+
finalText = "";
|
|
791
|
+
await runtime.run({
|
|
792
|
+
payload: attemptPayload,
|
|
793
|
+
workspaceDir,
|
|
794
|
+
inputAttachments,
|
|
795
|
+
...(persistentTurn
|
|
796
|
+
? {
|
|
797
|
+
...(persistentTurn.runtimeStateDir
|
|
798
|
+
? { runtimeStateDir: persistentTurn.runtimeStateDir }
|
|
799
|
+
: {}),
|
|
800
|
+
nativeSessionId: activeNativeSessionId,
|
|
801
|
+
contextRevision: persistentTurn.contextRevision,
|
|
802
|
+
...(persistentTurn.runtimeEnv
|
|
803
|
+
? { runtimeEnv: persistentTurn.runtimeEnv }
|
|
804
|
+
: {}),
|
|
805
|
+
...(persistentTurn.skillProvider
|
|
806
|
+
? { skillProvider: persistentTurn.skillProvider }
|
|
807
|
+
: {}),
|
|
808
|
+
}
|
|
809
|
+
: {}),
|
|
810
|
+
}, sink, controller.signal);
|
|
811
|
+
await flushContent();
|
|
812
|
+
if (serverTerminal)
|
|
813
|
+
return;
|
|
814
|
+
if (controller.signal.aborted)
|
|
815
|
+
throw new Error("aborted");
|
|
816
|
+
const maxOutputChars = typeof payload.limits.max_output_chars === "number" &&
|
|
817
|
+
Number.isFinite(payload.limits.max_output_chars) &&
|
|
818
|
+
payload.limits.max_output_chars > 0
|
|
819
|
+
? payload.limits.max_output_chars
|
|
820
|
+
: DEFAULT_MAX_OUTPUT_CHARS;
|
|
821
|
+
const output = redactSecretString(truncateText(finalText, maxOutputChars));
|
|
822
|
+
await send({
|
|
823
|
+
type: "run.block",
|
|
824
|
+
payload: {
|
|
825
|
+
schema_version: AGENT_STREAM_SCHEMA_VERSION,
|
|
826
|
+
kind: "response_review",
|
|
827
|
+
runtime: runtime.id,
|
|
828
|
+
status: "in_progress",
|
|
829
|
+
},
|
|
830
|
+
});
|
|
831
|
+
let receipt;
|
|
832
|
+
try {
|
|
833
|
+
receipt = await send({
|
|
834
|
+
type: "run.message",
|
|
835
|
+
role: "assistant",
|
|
836
|
+
text: output,
|
|
837
|
+
payload: { candidate_attempt: candidateAttempt },
|
|
838
|
+
});
|
|
839
|
+
}
|
|
840
|
+
catch (error) {
|
|
841
|
+
await resetVisibleCandidate();
|
|
842
|
+
throw error;
|
|
843
|
+
}
|
|
844
|
+
if (receipt?.disposition !== "retry") {
|
|
845
|
+
acceptedOutput = output;
|
|
846
|
+
break;
|
|
847
|
+
}
|
|
848
|
+
await resetVisibleCandidate();
|
|
849
|
+
if (candidateAttempt >= MAX_CANDIDATE_GENERATION_ATTEMPTS) {
|
|
850
|
+
await sendFailure("candidate_rejected", "Agent could not produce a complete reply after two retries", new RuntimeExecutionError("Agent candidate rejected after bounded retries"), { candidate_attempts: candidateAttempt });
|
|
851
|
+
return;
|
|
852
|
+
}
|
|
853
|
+
lastReportedKind = null;
|
|
854
|
+
lastReasoningPhase = null;
|
|
855
|
+
attemptPayload = candidateRetryPayload(payload, receipt.retry_feedback);
|
|
856
|
+
}
|
|
758
857
|
}
|
|
759
858
|
finally {
|
|
760
859
|
modelFinishedAt = this.now();
|
|
@@ -764,13 +863,7 @@ export class RunDispatcher {
|
|
|
764
863
|
return;
|
|
765
864
|
if (controller.signal.aborted)
|
|
766
865
|
throw new Error("aborted");
|
|
767
|
-
|
|
768
|
-
Number.isFinite(payload.limits.max_output_chars) &&
|
|
769
|
-
payload.limits.max_output_chars > 0
|
|
770
|
-
? payload.limits.max_output_chars
|
|
771
|
-
: DEFAULT_MAX_OUTPUT_CHARS;
|
|
772
|
-
const output = redactSecretString(truncateText(finalText, maxOutputChars));
|
|
773
|
-
activeTranscript.writeFinal(output);
|
|
866
|
+
activeTranscript.writeFinal(acceptedOutput);
|
|
774
867
|
fileReportStartedAt = this.now();
|
|
775
868
|
try {
|
|
776
869
|
await reportFileCandidates(this.client, runId, workspaceDir, this.log, this.scanLimits);
|
|
@@ -778,7 +871,6 @@ export class RunDispatcher {
|
|
|
778
871
|
finally {
|
|
779
872
|
fileReportFinishedAt = this.now();
|
|
780
873
|
}
|
|
781
|
-
await send({ type: "run.message", role: "assistant", text: output });
|
|
782
874
|
await sendTerminal({
|
|
783
875
|
type: "run.completed",
|
|
784
876
|
payload: { runtime: runtimeId, usage: usage() },
|
package/dist/types.d.ts
CHANGED
|
@@ -68,6 +68,11 @@ export interface RunEvent {
|
|
|
68
68
|
error?: string;
|
|
69
69
|
payload?: Record<string, unknown>;
|
|
70
70
|
}
|
|
71
|
+
export interface RunEventReceipt {
|
|
72
|
+
disposition: "accepted" | "retry";
|
|
73
|
+
candidate_attempt?: number;
|
|
74
|
+
retry_feedback?: string;
|
|
75
|
+
}
|
|
71
76
|
/** `POST /daemon/runs/{id}/files` 的文件候选(与后端 DaemonRunFileIn 一致)。 */
|
|
72
77
|
export interface RunFileCandidate {
|
|
73
78
|
event?: "created" | "modified" | "deleted";
|
package/package.json
CHANGED