@botlearn-course/daemon 0.0.15 → 0.0.16

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,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<void>;
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<void>;
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;
@@ -418,9 +418,8 @@ export class AgentServiceSandboxClient {
418
418
  const scope = this.turnScopes.get(payload.agent_run_id);
419
419
  if (!scope)
420
420
  return;
421
- const activation = this.activationContexts.get(scope.sessionId);
422
421
  const session = this.state.sessions[scope.sessionId];
423
- if (session && activation?.activationId === scope.activationId) {
422
+ if (session?.activationId === scope.activationId) {
424
423
  // Persist the cleanup obligation before attempting chmod. A terminal event has
425
424
  // already been durably ACKed at this point, so reconnect/restart must finish this
426
425
  // revoke even when the server desired state has already moved to idle.
@@ -603,8 +602,7 @@ export class AgentServiceSandboxClient {
603
602
  reject: rejectAck,
604
603
  });
605
604
  await this.sendFrame(frame);
606
- if (event.type !== "run.block")
607
- await ack;
605
+ return event.type !== "run.block" ? await ack : { disposition: "accepted" };
608
606
  }
609
607
  async postFile(agentRunId, file) {
610
608
  const scope = this.turnScopes.get(agentRunId);
@@ -1236,15 +1234,54 @@ export class AgentServiceSandboxClient {
1236
1234
  });
1237
1235
  }
1238
1236
  async handleTurnCancel(frame) {
1239
- if (!this.matchesTurnScope(frame, this.turnScopes.get(frame.agent_run_id))) {
1237
+ const sessionId = frame.runtime_session_id;
1238
+ const runId = frame.agent_run_id;
1239
+ const workerAttempt = frame.worker_attempt;
1240
+ const activationId = frame.activation_id;
1241
+ const requestedScope = { sessionId, workerAttempt, activationId };
1242
+ const existingScope = this.turnScopes.get(runId);
1243
+ if (existingScope && !this.sameTurnScope(existingScope, requestedScope)) {
1240
1244
  await this.sendCommandAck(frame, "rejected", "turn_scope_mismatch");
1241
1245
  return;
1242
1246
  }
1243
- if (!this.dispatcher.cancel(frame.agent_run_id)) {
1244
- await this.sendCommandAck(frame, "rejected", "turn_not_running");
1247
+ const session = this.state.sessions[sessionId];
1248
+ if (!session) {
1249
+ await this.sendCommandAck(frame, "rejected", "session_not_open");
1250
+ return;
1251
+ }
1252
+ const accepted = Object.values(session.acceptedCommands).find((command) => command.agentRunId === runId);
1253
+ if (accepted && (accepted.workerAttempt !== workerAttempt ||
1254
+ accepted.activationId !== activationId)) {
1255
+ await this.sendCommandAck(frame, "rejected", "turn_scope_mismatch");
1245
1256
  return;
1246
1257
  }
1258
+ this.turnScopes.set(runId, requestedScope);
1259
+ if (this.dispatcher.cancel(runId)) {
1260
+ await this.sendCommandAck(frame, "ok");
1261
+ return;
1262
+ }
1263
+ // ``turn.cancel`` may win the race before ``turn.start`` is delivered, or arrive
1264
+ // after a daemon restart lost the runtime process. The fenced server command is
1265
+ // authoritative: ACK it and synthesize the durable terminal event without touching
1266
+ // the sandbox generation or deleting the runtime-session workspace.
1247
1267
  await this.sendCommandAck(frame, "ok");
1268
+ await this.postEvent(runId, {
1269
+ type: "run.cancelled",
1270
+ event_id: `cancel-${runId}-${workerAttempt}`,
1271
+ seq: 1,
1272
+ payload: {
1273
+ reason: "cancelled_by_request",
1274
+ runtime: session.runtimeId,
1275
+ },
1276
+ });
1277
+ const commandId = `run:${runId}:${workerAttempt}`;
1278
+ if (!session.completedCommands.includes(commandId)) {
1279
+ session.completedCommands.push(commandId);
1280
+ session.completedCommands = session.completedCommands.slice(-256);
1281
+ }
1282
+ delete session.acceptedCommands[commandId];
1283
+ this.persist();
1284
+ this.finishTurn({ agent_run_id: runId });
1248
1285
  }
1249
1286
  /**
1250
1287
  * 幂等关闭一个 runtime session:终止其 runtime 子进程、删除 workspace/native state、
@@ -1474,7 +1511,17 @@ export class AgentServiceSandboxClient {
1474
1511
  this.persist();
1475
1512
  if (pending) {
1476
1513
  this.pendingAcks.delete(frameId);
1477
- pending.resolve();
1514
+ pending.resolve(frame.payload.disposition === "retry"
1515
+ ? {
1516
+ disposition: "retry",
1517
+ ...(typeof frame.payload.candidate_attempt === "number"
1518
+ ? { candidate_attempt: frame.payload.candidate_attempt }
1519
+ : {}),
1520
+ ...(typeof frame.payload.retry_feedback === "string"
1521
+ ? { retry_feedback: frame.payload.retry_feedback }
1522
+ : {}),
1523
+ }
1524
+ : { disposition: "accepted" });
1478
1525
  }
1479
1526
  }
1480
1527
  async replaySpool() {
@@ -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<void>;
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
  }
@@ -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);
@@ -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
  }
@@ -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 retryable = !(err instanceof CourseClientError) || err.status === 429 || err.status >= 500;
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
- await runtime.run({
739
- payload,
740
- workspaceDir,
741
- inputAttachments,
742
- ...(persistentTurn
743
- ? {
744
- ...(persistentTurn.runtimeStateDir
745
- ? { runtimeStateDir: persistentTurn.runtimeStateDir }
746
- : {}),
747
- nativeSessionId: persistentTurn.nativeSessionId,
748
- contextRevision: persistentTurn.contextRevision,
749
- ...(persistentTurn.runtimeEnv
750
- ? { runtimeEnv: persistentTurn.runtimeEnv }
751
- : {}),
752
- ...(persistentTurn.skillProvider
753
- ? { skillProvider: persistentTurn.skillProvider }
754
- : {}),
755
- }
756
- : {}),
757
- }, sink, controller.signal);
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
- const maxOutputChars = typeof payload.limits.max_output_chars === "number" &&
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
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@botlearn-course/daemon",
3
- "version": "0.0.15",
3
+ "version": "0.0.16",
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": {