@springbrand/agent-runtime 0.3.0-alpha.20 → 0.3.0-alpha.22

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,3 @@
1
- import { sha256Hex } from "../lib/artifacts";
2
1
  import type {
3
2
  RuntimeBindings,
4
3
  RuntimeGatewaySession,
@@ -9,7 +8,6 @@ import type {
9
8
  RuntimeExtensionPermissions,
10
9
  } from "../context/extension/contracts";
11
10
  import type { RuntimeProfile } from "./profile";
12
- import type { ExecutionLevel } from "../lib/execution-level";
13
11
  import type { RuntimeSnapshot } from "./assembler";
14
12
  import type { FinalizedPiToolSurface, PiRuntimeAssembly } from "../context";
15
13
  import {
@@ -28,29 +26,8 @@ import {
28
26
  type PiToolCandidate,
29
27
  } from "../tools";
30
28
 
31
- export interface RuntimeAssemblyPin {
32
- readonly revision: string;
33
- readonly descriptor: string;
34
- }
35
-
36
- /** A saved pin is reusable only with its original Runtime key and intact descriptor. */
37
- export async function validateRuntimeAssemblyPin(
38
- runtimeKey: string,
39
- pin: RuntimeAssemblyPin,
40
- ): Promise<boolean> {
41
- if (!runtimeKey) {
42
- throw new Error("Cannot safely recover a pinned Submission without its original Runtime key");
43
- }
44
- if (!pin.revision && !pin.descriptor) return false;
45
- if (!pin.revision || !pin.descriptor) throw new Error("Submission assembly pin is incomplete");
46
- if (pin.revision !== await sha256Hex(pin.descriptor)) {
47
- throw new Error("Pinned Runtime assembly descriptor is invalid");
48
- }
49
- return true;
50
- }
51
-
52
29
  /**
53
- * 本文件负责准备 Pi 的扩展与工具,并为每次提交固定可恢复的运行描述。
30
+ * 本文件负责准备当前 Pi 的扩展、工具与系统上下文。
54
31
  *
55
32
  * @remarks
56
33
  * 核心术语见本目录入口 `./index.ts`,这里不重复定义。
@@ -93,8 +70,8 @@ interface PreparePiAssemblyOptions {
93
70
  ) => Fetcher;
94
71
  }
95
72
 
96
- // 暴露后续准备和固定阶段会读取的扩展装配结果。
97
- // preparePiRuntime 读取工具与降级,pinPiRuntime 读取上下文贡献。
73
+ // 暴露后续准备和执行阶段会读取的扩展装配结果。
74
+ // preparePiRuntime 读取工具与降级,assembleCurrentPiContext 读取上下文贡献。
98
75
  // 访问器直接代理扩展装配,避免维护会漂移的副本。
99
76
  interface PreparedPiAssembly {
100
77
  readonly loaded: readonly PiLoadedExtension[];
@@ -133,10 +110,10 @@ export interface PreparePiRuntimeOptions {
133
110
  }
134
111
 
135
112
  /**
136
- * 表示已经准备好、但尚未固定到某次提交的 Pi 配置。
113
+ * 表示已准备好供当前执行使用的 Pi 配置。
137
114
  *
138
115
  * @remarks
139
- * Runtime 在配置完成后保存这个句柄,并在提交准入或创建 Turn 时传回。
116
+ * Runtime 在配置完成后保存这个句柄,并在创建 Turn 时传回。
140
117
  *
141
118
  * 公开形状只给出版本描述和加载降级,调用方不会直接拿到可变的装配状态。
142
119
  */
@@ -147,26 +124,25 @@ export interface PreparedPiRuntime {
147
124
  }
148
125
 
149
126
  /**
150
- * 提供把一次提交固定到当前 Runtime 所需的值。
127
+ * 当前执行切片读取系统上下文所需的值。
151
128
  *
152
129
  * @remarks
153
- * Runtime 在写入提交前的准入阶段把它交给 `HarnessRuntimeConfig.pin`。
130
+ * Runtime 在打开或恢复 Harness 前把它交给 `HarnessRuntimeConfig.harnessInputs`。
154
131
  *
155
132
  * 扩展没有可读上下文时不需要读取函数,所以该端口保持可选。
156
133
  */
157
- export interface PinPiRuntimeOptions {
134
+ export interface CurrentPiContextOptions {
158
135
  readonly prepared: PreparedPiRuntime;
159
- readonly baseRevision: string;
160
136
  /**
161
137
  * Whether this Submission may inherit automatic hot and cold Memory context.
162
138
  * Scheduled task input is intentionally isolated from those sources.
163
139
  */
164
140
  readonly includeMemory?: boolean;
165
141
  /**
166
- * 读取一个扩展要写入固定系统提示词的上下文。
142
+ * 读取一个扩展对当前系统提示词的上下文贡献。
167
143
  *
168
144
  * @remarks
169
- * pinPiRuntime 在准入阶段为已加载扩展声明的上下文贡献调用它。
145
+ * 每次装配 Harness 输入时为已加载扩展读取当前上下文。
170
146
  *
171
147
  * 本模块不持有扩展上下文存储,所以读取端口必须留在 Host。
172
148
  */
@@ -175,40 +151,6 @@ export interface PinPiRuntimeOptions {
175
151
  ) => Promise<string | null>;
176
152
  }
177
153
 
178
- /**
179
- * 返回已经固定到一次提交的描述和上下文降级。
180
- *
181
- * @remarks
182
- * Runtime 在准入时持久化描述,并在创建 Turn 时传回。
183
- *
184
- * 固定这些值可让恢复继续使用当时已经准入的配置。
185
- */
186
- export interface PinnedPiRuntime {
187
- readonly descriptor: string;
188
- readonly degradations: readonly RuntimeDegradation[];
189
- }
190
-
191
- /**
192
- * 描述一条已存提交可以恢复到哪份 Pi 配置。
193
- *
194
- * @remarks
195
- * `HarnessRuntimeConfig.harnessInputs` 在打开或恢复 operation 前读取这个 JSON 结构。
196
- *
197
- * 基础版本作为准入时元数据保留,创建 Turn 时不与当前 Runtime 比较。
198
- */
199
- export interface PinnedPiRuntimeDescriptor {
200
- readonly version: 1;
201
- readonly baseRevision: string;
202
- readonly model: string;
203
- readonly provider: string;
204
- readonly thinking: RuntimeSnapshot["pi"]["thinkingLevel"];
205
- readonly systemPrompt: string;
206
- readonly executionLevel: ExecutionLevel;
207
- readonly deny: readonly string[];
208
- readonly tools: readonly ReturnType<typeof describeTools>[number][];
209
- readonly [key: string]: unknown;
210
- }
211
-
212
154
  /**
213
155
  * 保存创建 Pi Turn 所需的内部准备结果。
214
156
  *
@@ -345,7 +287,7 @@ class PreparedRuntime implements PreparedPiRuntime {
345
287
  ) {}
346
288
 
347
289
  // 在调用方证明适配器身份后返回内部准备状态。
348
- // readPreparedPiRuntime 在固定配置或创建 Turn 前调用它。
290
+ // readPreparedPiRuntime 在创建 Turn 前调用它。
349
291
  // 公开接口没有可信的来源字段,所以这里必须比较对象身份。
350
292
  read(owner: object): PreparedPiRuntimeState {
351
293
  if (owner !== this.owner) {
@@ -361,7 +303,7 @@ class PreparedRuntime implements PreparedPiRuntime {
361
303
  * 为创建它的 HarnessRuntimeConfig 打开准备句柄。
362
304
  *
363
305
  * @remarks
364
- * 固定配置和创建 Turn 都会先调用它,再读取快照、装配结果或工具候选。
306
+ * 创建 Turn 前调用它,读取快照、装配结果或工具候选。
365
307
  *
366
308
  * 具体类检查与 owner 检查一起拒绝伪造句柄和跨适配器复用。
367
309
  */
@@ -375,42 +317,6 @@ export function readPreparedPiRuntime(
375
317
  return prepared.read(owner);
376
318
  }
377
319
 
378
- /**
379
- * 读取已存的固定描述。
380
- *
381
- * @remarks
382
- * `HarnessRuntimeConfig.harnessInputs` 在新建或恢复 Pi operation 前调用它。
383
- *
384
- * Prepared Runtime 提供当前执行能力,descriptor 仅保留 Submission 固定的 Turn 字段。
385
- */
386
- export function readPinnedPiRuntime(
387
- prepared: PreparedPiRuntime,
388
- owner: object,
389
- descriptorBody: string,
390
- ): {
391
- readonly state: PreparedPiRuntimeState;
392
- readonly descriptor: PinnedPiRuntimeDescriptor;
393
- } {
394
- const state = readPreparedPiRuntime(prepared, owner);
395
- let value: unknown;
396
- try {
397
- value = JSON.parse(descriptorBody);
398
- } catch {
399
- throw new Error("Pinned Runtime assembly descriptor is invalid");
400
- }
401
- if (!value || typeof value !== "object") {
402
- throw new Error("Pinned Runtime assembly descriptor is invalid");
403
- }
404
- const descriptor = value as PinnedPiRuntimeDescriptor;
405
- if (
406
- descriptor.version !== 1 ||
407
- typeof descriptor.systemPrompt !== "string"
408
- ) {
409
- throw new Error("Pinned Runtime assembly descriptor is invalid");
410
- }
411
- return { state, descriptor };
412
- }
413
-
414
320
  // #endregion
415
321
 
416
322
  // #region 扩展与工具装配
@@ -462,7 +368,7 @@ async function preparePiAssembly(
462
368
 
463
369
  // #endregion
464
370
 
465
- // #region Runtime 准备与提交固定
371
+ // #region Runtime 准备与当前上下文
466
372
 
467
373
  /**
468
374
  * 为一份已校验的 Runtime 快照准备扩展和工具状态。
@@ -499,17 +405,17 @@ export async function preparePiRuntime(
499
405
  }
500
406
 
501
407
  /**
502
- * 把请求时的上下文和执行档位固定到一次提交的描述中。
408
+ * 从当前 Runtime 装配模型要用的系统上下文。
503
409
  *
504
410
  * @remarks
505
- * Runtime 在持久化提交并启动 Turn 前的准入阶段调用它。
411
+ * Runtime 每次打开或恢复 Harness 前调用它。
506
412
  *
507
- * 此时重建上下文可避免把动态记忆和扩展上下文写进 Pi 消息,同时让恢复继续使用已准入的结果。
413
+ * 重新读取动态记忆和扩展上下文,不从旧 Submission 描述恢复配置。
508
414
  */
509
- export async function pinPiRuntime(
510
- options: PinPiRuntimeOptions,
415
+ export async function assembleCurrentPiContext(
416
+ options: CurrentPiContextOptions,
511
417
  owner: object,
512
- ): Promise<PinnedPiRuntime> {
418
+ ): Promise<{ systemPrompt: string; degradations: readonly RuntimeDegradation[] }> {
513
419
  const state = readPreparedPiRuntime(options.prepared, owner);
514
420
  const { snapshot, assembly } = state;
515
421
  const includeMemory = options.includeMemory !== false;
@@ -525,11 +431,7 @@ export async function pinPiRuntime(
525
431
  readExtensionContext: options.readExtensionContext,
526
432
  });
527
433
  return Object.freeze({
528
- descriptor: JSON.stringify({
529
- ...JSON.parse(options.prepared.revisionDescriptor),
530
- systemPrompt: context.systemPrompt,
531
- baseRevision: options.baseRevision,
532
- }),
434
+ systemPrompt: context.systemPrompt,
533
435
  degradations: context.degradations,
534
436
  });
535
437
  }
@@ -1,4 +1,5 @@
1
1
  export interface RuntimeConfigUpdateResult<Change> {
2
2
  readonly change: Change;
3
- readonly runtime: "unchanged" | "reloaded" | "reload-failed";
3
+ /** Deferred changes are persisted but activated at the next execution slice. */
4
+ readonly runtime: "unchanged" | "reloaded" | "deferred" | "reload-failed";
4
5
  }
@@ -17,13 +17,25 @@ export type PiCanonicalUserInput = UserMessage;
17
17
 
18
18
  export type UIChatTrigger = "submit-message" | "regenerate-message";
19
19
 
20
- /** A user-selected Runtime capability persisted with the visible UIMessage. */
20
+ /**
21
+ * A user-selected Runtime capability persisted with the visible UIMessage.
22
+ *
23
+ * - `skill`: `name` is the installed Skill name.
24
+ * - `tool`: `name` is a Tool materialized on the Runtime surface.
25
+ * - `plan`: `name` is always `"plan"`.
26
+ * - `action`: `name` is a host-owned Action capability id. The Runtime only adds
27
+ * `requested capability: action/<id>` to private model context; the host is
28
+ * responsible for telling the model how to act on it.
29
+ *
30
+ * `label` is the display name; capability ids never enter visible message text.
31
+ * `iconUrl` is a display-only `https:` icon (for example an Action supplier's
32
+ * logo); it is persisted with the message and never sent to the model.
33
+ */
21
34
  export interface RequestedCapability {
22
- readonly kind: "skill" | "tool" | "plan";
35
+ readonly kind: "skill" | "tool" | "plan" | "action";
23
36
  readonly name: string;
24
37
  readonly label: string;
25
- /** Stable Resource identity used only to group capabilities in presentation. */
26
- readonly resourceId?: string;
38
+ readonly iconUrl?: string;
27
39
  }
28
40
 
29
41
  export interface UIChatRequestBody {
@@ -2,6 +2,32 @@ import type { UserMessage } from "@earendil-works/pi-ai";
2
2
  import type { UIMessage } from "ai";
3
3
  import type { RequestedCapability } from "./contract";
4
4
 
5
+ const REQUESTED_CAPABILITY_KINDS: ReadonlySet<RequestedCapability["kind"]> = new Set([
6
+ "skill",
7
+ "tool",
8
+ "plan",
9
+ "action",
10
+ ]);
11
+
12
+ function isRequestedCapabilityKind(value: unknown): value is RequestedCapability["kind"] {
13
+ return typeof value === "string" &&
14
+ REQUESTED_CAPABILITY_KINDS.has(value as RequestedCapability["kind"]);
15
+ }
16
+
17
+ const MAX_CAPABILITY_ICON_URL_LENGTH = 2048;
18
+
19
+ /** Keep a display-only icon only when it is an `https:` URL; drop anything else silently. */
20
+ function capabilityIconUrlOf(value: unknown): string | undefined {
21
+ if (typeof value !== "string" || value.length > MAX_CAPABILITY_ICON_URL_LENGTH) {
22
+ return undefined;
23
+ }
24
+ try {
25
+ return new URL(value).protocol === "https:" ? value : undefined;
26
+ } catch {
27
+ return undefined;
28
+ }
29
+ }
30
+
5
31
  export function requestedCapabilitiesOf(metadata: unknown): RequestedCapability[] {
6
32
  if (metadata === null || typeof metadata !== "object" || Array.isArray(metadata)) {
7
33
  return [];
@@ -19,7 +45,7 @@ export function requestedCapabilitiesOf(metadata: unknown): RequestedCapability[
19
45
  }
20
46
  const capability = value as Record<string, unknown>;
21
47
  if (
22
- (capability.kind !== "skill" && capability.kind !== "tool" && capability.kind !== "plan") ||
48
+ !isRequestedCapabilityKind(capability.kind) ||
23
49
  typeof capability.name !== "string" ||
24
50
  !capability.name.trim() ||
25
51
  typeof capability.label !== "string" ||
@@ -30,7 +56,15 @@ export function requestedCapabilitiesOf(metadata: unknown): RequestedCapability[
30
56
  const key = `${capability.kind}:${capability.name}`;
31
57
  if (seen.has(key)) continue;
32
58
  seen.add(key);
33
- result.push({ kind: capability.kind, name: capability.name, label: capability.label });
59
+ // Copy only the current fields: persisted messages may still carry legacy
60
+ // fields such as the retired `resourceId`, which are ignored.
61
+ const iconUrl = capabilityIconUrlOf(capability.iconUrl);
62
+ result.push({
63
+ kind: capability.kind,
64
+ name: capability.name,
65
+ label: capability.label,
66
+ ...(iconUrl ? { iconUrl } : {}),
67
+ });
34
68
  }
35
69
  return result;
36
70
  }
@@ -65,6 +99,13 @@ export function normalizeUIUserInput(
65
99
  );
66
100
  continue;
67
101
  }
102
+ if (capability.kind === "action") {
103
+ // Actions are host capabilities, not Runtime Tools: the Runtime only
104
+ // names the request and does not check installation. The host owns how
105
+ // the model acts on `action/<id>` (its tools and system guidance).
106
+ context.push(`requested capability: action/${capability.name}`);
107
+ continue;
108
+ }
68
109
  if (capability.name !== "plan") {
69
110
  throw new Error(`Unknown plan capability: ${capability.name}`);
70
111
  }
@@ -184,6 +184,8 @@ export class SessionHarness {
184
184
  runtime.requestId = unfinished[0].requestId;
185
185
  }
186
186
  await runtime.restoreGovernance(context);
187
+ // Harness.open is passive; install the current Config before anything resumes scheduling.
188
+ await runtime.applyCurrentConfiguration(context);
187
189
  return runtime;
188
190
  }
189
191
 
@@ -236,6 +238,22 @@ export class SessionHarness {
236
238
  this.conversation = (await this.harness.conversation(created.id, context))!;
237
239
  this.turnsAtOpen = (await this.entries(context)).filter((entry) => entry.kind === "pi.assistant").length;
238
240
  }
241
+ private async applyCurrentConfiguration(context: Context): Promise<void> {
242
+ const names = new Set(this.options.activeToolNames ?? this.options.tools?.map((tool) => tool.name) ?? []);
243
+ const previous = await this.harness.snapshot(AgentDoc, this.conversation.id, context);
244
+ if (Array.isArray(previous?.tools)) {
245
+ for (const name of previous.tools) if (this.options.onDemandToolNames?.includes(name)) names.add(name);
246
+ }
247
+ await this.conversation.configure({
248
+ model: { provider: this.options.model.provider, modelId: this.options.model.id },
249
+ thinkingLevel: this.options.thinkingLevel ?? "off",
250
+ tools: this.wrapUpStarted || this.governance.wrapUp
251
+ ? []
252
+ : this.options.tools?.filter((tool) => names.has(tool.name)) ?? [],
253
+ // Wrap-up instructions belong to one Submission and are supplied by the request hook.
254
+ instructions: null,
255
+ }, context);
256
+ }
239
257
  async admit(request: SessionRunRequest, context: Context): Promise<void> {
240
258
  if (this.submission && this.requestId !== request.operationId) throw new Error("A native Pi Submission is already active");
241
259
  const existing = await this.submissionFor(request.operationId, context);
@@ -244,12 +262,9 @@ export class SessionHarness {
244
262
  this.submission = await this.harness.submission(existing.id, context);
245
263
  } else {
246
264
  if (request.prompt === undefined) throw new Error("A new native Pi Submission needs input");
247
- const names = new Set(this.options.activeToolNames ?? this.options.tools?.map((tool) => tool.name) ?? []);
248
- const previous = await this.harness.snapshot(AgentDoc, this.conversation.id, context);
249
- if (Array.isArray(previous?.tools)) for (const name of previous.tools) if (this.options.onDemandToolNames?.includes(name)) names.add(name);
250
- await this.conversation.configure({ model: { provider: this.options.model.provider, modelId: this.options.model.id }, thinkingLevel: this.options.thinkingLevel ?? "off", tools: this.options.tools?.filter((tool) => names.has(tool.name)) ?? [],
251
- // Earlier runtimes stored the wrap-up instruction in the agent; it applies to one Submission only.
252
- instructions: null }, context);
265
+ this.wrapUpStarted = false;
266
+ this.governance = new ToolGovernance();
267
+ await this.applyCurrentConfiguration(context);
253
268
  this.requestId = request.operationId;
254
269
  this.submission = await this.conversation.submit({ type: "input", requestId: request.operationId, content: typeof request.prompt === "string" ? request.prompt : request.prompt.content, whenBusy: "reject" }, context);
255
270
  }
@@ -1,5 +1,4 @@
1
1
  import type { UIMessage } from "ai";
2
- import { validateRuntimeAssemblyPin, type RuntimeAssemblyPin } from "../config/pinning";
3
2
  import type {
4
3
  RuntimeSubmissionAdmissionHook,
5
4
  RuntimeSubmissionAdmissionResult,
@@ -17,7 +16,7 @@ import type { RuntimeUnattendedExecutionGrant } from "./contracts";
17
16
  import type { ExecutionLevel } from "../lib/execution-level";
18
17
 
19
18
  // Pending records retain identity indefinitely; only recent attempts reserve capacity.
20
- const ADMISSION_ATTEMPT_WINDOW_MS = 60_000;
19
+ export const ADMISSION_ATTEMPT_WINDOW_MS = 60_000;
21
20
 
22
21
  export interface SubmitMessageOptions {
23
22
  requestId: string;
@@ -177,8 +176,6 @@ export class SubmissionAdmission {
177
176
  requestId,
178
177
  idempotencyKey: idempotencyKey ?? null,
179
178
  createdAt: userMessage.timestamp,
180
- assemblyRevision: "",
181
- assemblyDescriptor: "",
182
179
  runtimeKey: runtime.runtimeKey,
183
180
  assistantMessageId: crypto.randomUUID(),
184
181
  queuedInputJson: JSON.stringify(userMessage),
@@ -210,38 +207,20 @@ export class SubmissionAdmission {
210
207
  };
211
208
  }
212
209
 
213
- /** Validate before loading capabilities, then reuse or atomically persist the first-run pin. */
214
- async pin(
210
+ /** Resolve the saved Agent identity against its current Config before every execution slice. */
211
+ async prepareCurrentRuntime(
215
212
  submission: StoredSubmission,
216
- preparePin: () => Promise<RuntimeAssemblyPin>,
217
- restoreRuntime?: (runtimeKey: string) => Promise<void>,
213
+ restoreRuntime: (runtimeKey: string) => Promise<void>,
218
214
  ): Promise<StoredSubmission> {
219
215
  const current = this.options.submissions.find(submission.submissionId);
220
216
  if (!current) throw new Error(`Unknown SpringBrand submission: ${submission.submissionId}`);
221
- const hasPin = await validateRuntimeAssemblyPin(current.runtimeKey, {
222
- revision: current.assemblyRevision,
223
- descriptor: current.assemblyDescriptor,
224
- });
225
- await restoreRuntime?.(current.runtimeKey);
217
+ if (!current.runtimeKey) throw new Error("Cannot resume a Submission without its Agent Config ID");
218
+ await restoreRuntime(current.runtimeKey);
226
219
  const restored = this.options.submissions.find(current.submissionId);
227
220
  if (!restored || isTerminalSubmissionStatus(restored.status)) {
228
- throw new Error("Submission ended before Runtime assembly was pinned");
229
- }
230
- if (hasPin) return restored;
231
- const pin = await preparePin();
232
- if (!await validateRuntimeAssemblyPin(current.runtimeKey, pin)) {
233
- throw new Error("Submission assembly pin is incomplete");
221
+ throw new Error("Submission ended while loading current Agent Config");
234
222
  }
235
- this.options.submissions.pinAssembly(current.submissionId, pin.revision, pin.descriptor);
236
- const pinned = this.options.submissions.find(current.submissionId);
237
- if (!pinned?.assemblyRevision || !pinned.assemblyDescriptor || isTerminalSubmissionStatus(pinned.status)) {
238
- throw new Error("Submission ended before Runtime assembly was pinned");
239
- }
240
- await validateRuntimeAssemblyPin(pinned.runtimeKey, {
241
- revision: pinned.assemblyRevision,
242
- descriptor: pinned.assemblyDescriptor,
243
- });
244
- return pinned;
223
+ return restored;
245
224
  }
246
225
 
247
226
  /** The caller shares this synchronous write with its admission/timeout transaction. */
@@ -130,9 +130,9 @@ export type SubmissionOutcome = "succeeded" | "failed" | "aborted";
130
130
  * @remarks
131
131
  * `SubmissionLifecycle` 把它交给 `execute`,Runtime 据此选择执行前置动作。
132
132
  *
133
- * `recovery` 和 `continuation` 必须分开:前者的进程内配置已经丢失,需重新装配原 runtime key 再打开 Session;
134
- * 后者是本轮主动让出执行片,持久 operation 和已固定配置保持完好。
135
- * 区分两者可避免主动让出时重复加载原 runtime key。
133
+ * `recovery` 和 `continuation` 都以持久化的 Agent 身份重新读取当前配置,
134
+ * 然后继续同一个持久 operation;前者处理进程恢复,后者处理本轮主动让出执行片。
135
+ * 两者都不沿用旧执行片的固定配置。
136
136
  */
137
137
  export type SubmissionExecutionMode =
138
138
  /** 首次执行一条新接收的提交。 */
@@ -244,6 +244,7 @@ export class SubmissionLifecycle<
244
244
  private readonly executions = new Map<string, Promise<TSubmission>>();
245
245
  private readonly activeBySubmission = new Map<string, TActive>();
246
246
  private readonly terminalCommits = new Map<string, Promise<TSubmission>>();
247
+ private preparingAdmissions = 0;
247
248
 
248
249
  // #region 提交与恢复
249
250
 
@@ -262,6 +263,13 @@ export class SubmissionLifecycle<
262
263
  >,
263
264
  ) {}
264
265
 
266
+ /** Serialize an explicit Runtime change with this Chat's execution queue. */
267
+ async runConfigControl<T>(run: () => Promise<T>): Promise<T> {
268
+ const result = await this.queue.enqueue(`runtime-config:${crypto.randomUUID()}`, run);
269
+ if (result.status === "stale") throw new Error("Runtime configuration control was reset");
270
+ return result.value;
271
+ }
272
+
265
273
  /**
266
274
  * 接收新提交,或让重复请求加入已有提交。
267
275
  *
@@ -275,17 +283,22 @@ export class SubmissionLifecycle<
275
283
  ): Promise<SubmissionHandle<TSubmission>> {
276
284
  const duplicate = this.findDuplicate(input);
277
285
  if (duplicate) return this.join(duplicate);
278
- const admitted = await this.admit(
279
- input,
280
- await input.prepareAdmission(),
281
- );
282
- if (admitted.admitted) this.pump();
283
- return {
284
- receipt: admitted.submission,
285
- completion: admitted.rejected
286
- ? Promise.resolve(admitted.submission)
287
- : this.waitForTerminal(admitted.submission.submissionId),
288
- };
286
+ this.preparingAdmissions += 1;
287
+ try {
288
+ const admitted = await this.admit(
289
+ input,
290
+ await input.prepareAdmission(),
291
+ );
292
+ if (admitted.admitted) this.pump();
293
+ return {
294
+ receipt: admitted.submission,
295
+ completion: admitted.rejected
296
+ ? Promise.resolve(admitted.submission)
297
+ : this.waitForTerminal(admitted.submission.submissionId),
298
+ };
299
+ } finally {
300
+ this.preparingAdmissions -= 1;
301
+ }
289
302
  }
290
303
 
291
304
  /**
@@ -551,7 +564,7 @@ export class SubmissionLifecycle<
551
564
  * 读取持久层而不是只看 `executions`,才能覆盖重启后尚未恢复的提交。
552
565
  */
553
566
  isBusy(): boolean {
554
- return this.options.store.countUnfinished() > 0;
567
+ return this.preparingAdmissions > 0 || this.options.store.countUnfinished() > 0;
555
568
  }
556
569
 
557
570
  // 作用:在有限时间内轮询 Runtime 是否已无活动提交。
@@ -584,7 +597,16 @@ export class SubmissionLifecycle<
584
597
  activate(submission: TSubmission, active: TActive): () => void {
585
598
  this.activeBySubmission.set(submission.submissionId, active);
586
599
  const latest = this.options.store.find(submission.submissionId);
587
- if (latest?.abortReason) this.options.abortActive(active);
600
+ if (latest?.abortReason) {
601
+ // The cancel is already committed; this abort only stops the executor and may lose to it settling.
602
+ void (async () => this.options.abortActive(active))().catch((error: unknown) => {
603
+ console.warn(
604
+ "[runtime-early-abort:failed]",
605
+ submission.submissionId,
606
+ error instanceof Error ? error.message : String(error),
607
+ );
608
+ });
609
+ }
588
610
  return () => {
589
611
  if (this.activeBySubmission.get(submission.submissionId) === active) {
590
612
  this.activeBySubmission.delete(submission.submissionId);