@springbrand/agent-runtime 0.1.3-alpha.8 → 0.2.0-alpha.14

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (50) hide show
  1. package/package.json +4 -3
  2. package/src/adapter/cloudflare/index.ts +1 -0
  3. package/src/adapter/cloudflare/sandbox/adapter.ts +4 -0
  4. package/src/adapter/cloudflare/subagent/definition.ts +60 -5
  5. package/src/adapter/cloudflare/universal-agent/hooks.ts +23 -1
  6. package/src/adapter/cloudflare/universal-agent/preparation.ts +36 -2
  7. package/src/adapter/cloudflare/universal-agent/tools.ts +9 -12
  8. package/src/adapter/cloudflare/workspace/git-fs.ts +178 -0
  9. package/src/adapter/cloudflare/workspace/version-control.ts +374 -0
  10. package/src/db/index.ts +4 -0
  11. package/src/db/runtime-event-outbox.repo.ts +34 -1
  12. package/src/db/schema.ts +42 -0
  13. package/src/db/submission-admission.repo.ts +127 -0
  14. package/src/db/submission.repo.ts +54 -3
  15. package/src/index.ts +5 -2
  16. package/src/kernel/bindings.ts +52 -0
  17. package/src/kernel/durable-lifecycle.ts +100 -0
  18. package/src/kernel/public-contracts.ts +11 -0
  19. package/src/kernel/receipts.ts +1 -0
  20. package/src/kernel/recoverable-chat-agent.ts +0 -19
  21. package/src/kernel/subagent-runtime.ts +137 -0
  22. package/src/kernel/submission-authority.ts +114 -0
  23. package/src/kernel/submission-lifecycle.ts +35 -10
  24. package/src/layers/context/budget/gate.ts +99 -0
  25. package/src/lib/prompt.ts +7 -3
  26. package/src/pi/message/projection.ts +2 -2
  27. package/src/pi/runtime-adapter/assembly.ts +4 -3
  28. package/src/pi/runtime-adapter/execution.ts +55 -16
  29. package/src/pi/runtime-adapter/index.ts +34 -0
  30. package/src/pi/runtime-adapter/models.ts +10 -1
  31. package/src/pi/runtime-adapter/recovery.ts +17 -17
  32. package/src/pi/runtime-adapter/transcript.ts +2 -2
  33. package/src/pi/tool/base.ts +113 -27
  34. package/src/pi/tool/compiler.ts +66 -6
  35. package/src/pi/tool/core-host.ts +50 -23
  36. package/src/pi/tool/core.ts +2 -23
  37. package/src/pi/tool/index.ts +1 -0
  38. package/src/pi/tool/mcp.ts +2 -2
  39. package/src/pi/tool/skill.ts +78 -3
  40. package/src/pi/tool/subagent.ts +142 -16
  41. package/src/pi/tool/workspace-revision.ts +64 -0
  42. package/src/pi/tool/workspace-sandbox.ts +4 -4
  43. package/src/pi/turn/tool-recovery.ts +7 -7
  44. package/src/runtime-agent-context.ts +1 -0
  45. package/src/runtime-agent.ts +8 -3
  46. package/src/runtime-assembler.ts +61 -9
  47. package/src/runtime-definition.ts +12 -0
  48. package/src/runtime.ts +485 -66
  49. package/src/tool-registry.ts +10 -1
  50. package/src/workspace-versioning.ts +46 -0
@@ -31,6 +31,7 @@ export interface SubmissionRecord {
31
31
  idempotencyKey?: string | null;
32
32
  status: SubmissionStatus;
33
33
  accepted: boolean;
34
+ admissionRejected?: boolean;
34
35
  abortReason?: string | null;
35
36
  }
36
37
 
@@ -116,7 +117,7 @@ export class SubmissionQueueFullError extends Error {
116
117
  readonly code = "queue_full";
117
118
 
118
119
  constructor() {
119
- super(`Pi submission queue is full (${MAX_PENDING_SUBMISSIONS})`);
120
+ super(`SpringBrand submission queue is full (${MAX_PENDING_SUBMISSIONS})`);
120
121
  }
121
122
  }
122
123
 
@@ -266,9 +267,9 @@ export class SubmissionLifecycle<
266
267
  if (admitted.admitted) this.pump();
267
268
  return {
268
269
  receipt: admitted.submission,
269
- completion: this.waitForTerminal(
270
- admitted.submission.submissionId,
271
- ),
270
+ completion: admitted.rejected
271
+ ? Promise.resolve(admitted.submission)
272
+ : this.waitForTerminal(admitted.submission.submissionId),
272
273
  };
273
274
  }
274
275
 
@@ -302,6 +303,16 @@ export class SubmissionLifecycle<
302
303
  return this.start(submissionId, true);
303
304
  }
304
305
 
306
+ /** 等当前切片退出后恢复同一条非终态 Submission,避免 planned wake 被实例内去重吞掉。 */
307
+ async recoverAfterCurrent(submissionId: string): Promise<TSubmission> {
308
+ const current = this.executions.get(submissionId);
309
+ if (current) {
310
+ const outcome = await current;
311
+ if (isTerminalSubmissionStatus(outcome.status)) return outcome;
312
+ }
313
+ return this.start(submissionId, true);
314
+ }
315
+
305
316
  recoverHead(): Promise<TSubmission | null> {
306
317
  const running = this.options.store.findRunning();
307
318
  if (running) return this.start(running.submissionId, true);
@@ -317,7 +328,11 @@ export class SubmissionLifecycle<
317
328
  private async admit(
318
329
  input: SubmissionInput<TSubmission>,
319
330
  create: () => TSubmission,
320
- ): Promise<{ submission: TSubmission; admitted: boolean }> {
331
+ ): Promise<{
332
+ submission: TSubmission;
333
+ admitted: boolean;
334
+ rejected: boolean;
335
+ }> {
321
336
  const result = this.options.store.transaction(() => {
322
337
  if (input.idempotencyKey) {
323
338
  const duplicate = this.options.store.findByIdempotencyKey(
@@ -327,6 +342,7 @@ export class SubmissionLifecycle<
327
342
  return {
328
343
  submission: { ...duplicate, accepted: false } as TSubmission,
329
344
  admitted: false,
345
+ rejected: false,
330
346
  };
331
347
  }
332
348
  }
@@ -340,6 +356,7 @@ export class SubmissionLifecycle<
340
356
  accepted: false,
341
357
  } as TSubmission,
342
358
  admitted: false,
359
+ rejected: false,
343
360
  };
344
361
  }
345
362
  if (
@@ -348,7 +365,12 @@ export class SubmissionLifecycle<
348
365
  ) {
349
366
  throw new SubmissionQueueFullError();
350
367
  }
351
- return { submission: create(), admitted: true };
368
+ const submission = create();
369
+ return {
370
+ submission,
371
+ admitted: submission.admissionRejected !== true,
372
+ rejected: submission.admissionRejected === true,
373
+ };
352
374
  });
353
375
  if (result.admitted) await this.options.clearTerminal();
354
376
  return result;
@@ -407,7 +429,7 @@ export class SubmissionLifecycle<
407
429
  }
408
430
  const submission = this.options.store.find(submissionId);
409
431
  if (!submission) {
410
- throw new Error(`Unknown Pi submission: ${submissionId}`);
432
+ throw new Error(`Unknown SpringBrand submission: ${submissionId}`);
411
433
  }
412
434
  shouldPump = isTerminalSubmissionStatus(submission.status);
413
435
  return submission;
@@ -438,10 +460,13 @@ export class SubmissionLifecycle<
438
460
  // TODO(待确认): 持久层若长期保持非终态且没有执行者,这里没有超时或外部唤醒上限。
439
461
  for (;;) {
440
462
  const execution = this.executions.get(submissionId);
441
- if (execution) return execution;
463
+ if (execution) {
464
+ await execution;
465
+ continue;
466
+ }
442
467
  const submission = this.options.store.find(submissionId);
443
468
  if (!submission) {
444
- throw new Error(`Unknown Pi submission: ${submissionId}`);
469
+ throw new Error(`Unknown SpringBrand submission: ${submissionId}`);
445
470
  }
446
471
  if (isTerminalSubmissionStatus(submission.status)) return submission;
447
472
  await new Promise((resolve) => setTimeout(resolve, 50));
@@ -479,7 +504,7 @@ export class SubmissionLifecycle<
479
504
  ): Promise<TSubmission> {
480
505
  const latest = this.options.store.find(submission.submissionId);
481
506
  if (!latest) {
482
- throw new Error(`Unknown Pi submission: ${submission.submissionId}`);
507
+ throw new Error(`Unknown SpringBrand submission: ${submission.submissionId}`);
483
508
  }
484
509
  if (isTerminalSubmissionStatus(latest.status)) return latest;
485
510
  return this.options.commitTerminal(latest, outcome, message);
@@ -1,3 +1,10 @@
1
+ import {
2
+ type ArtifactRef,
3
+ serializeOutput,
4
+ sha256Hex,
5
+ type SpillWorkspace,
6
+ } from "../../../lib/artifacts";
7
+
1
8
  /**
2
9
  * Durable history needs bounded values so one Tool response cannot make a
3
10
  * session impossible to restore.
@@ -11,7 +18,15 @@
11
18
  /** Maximum string leaf retained in durable tool output. */
12
19
  const STORAGE_LEAF_MAX_CHARS = 32 * 1024;
13
20
 
21
+ /** Serialized outputs above this threshold get a narrower model view. */
22
+ const MODEL_VIEW_THRESHOLD = 8 * 1024;
23
+
24
+ /** Maximum string leaf exposed in a structure-preserving model view. */
25
+ const MODEL_LEAF_MAX_CHARS = 500;
26
+
14
27
  const ELISION_RESERVE = 40;
28
+ const PREVIEW_CHARS = 600;
29
+ const DEFAULT_SPILL_DIR = "/scratch/tool-output";
15
30
 
16
31
  // 作用:把过长文本从中间截短,同时保留开头和结尾。
17
32
  // 调用:`truncateStringLeaves` 遇到字符串叶子时调用,传入该叶子的上限。
@@ -86,3 +101,87 @@ export function truncateStringLeaves(value: unknown, maxChars: number): unknown
86
101
  export function boundDurableToolOutput(value: unknown): unknown {
87
102
  return truncateStringLeaves(value, STORAGE_LEAF_MAX_CHARS);
88
103
  }
104
+
105
+ /** Selects whether a Tool result is externalized or kept structurally durable. */
106
+ export type BudgetPolicy = { kind: "spill" } | { kind: "structure" };
107
+
108
+ /**
109
+ * Writes one large payload to Workspace and returns its narrow durable reference.
110
+ *
111
+ * Small values and failed/unavailable Workspace writes return `null`; callers
112
+ * must then retain the existing bounded-structure fallback.
113
+ */
114
+ export async function spillDurableToolOutput(
115
+ value: unknown,
116
+ options: {
117
+ readonly workspace?: SpillWorkspace;
118
+ },
119
+ ): Promise<ArtifactRef | null> {
120
+ const { text, ext } = serializeOutput(value);
121
+ if (text.length <= MODEL_VIEW_THRESHOLD || !options.workspace) return null;
122
+ try {
123
+ const hash = await sha256Hex(text);
124
+ const path = `${DEFAULT_SPILL_DIR}/${hash.slice(0, 24)}.${ext}`;
125
+ await options.workspace.writeFile(
126
+ path,
127
+ text,
128
+ ext === "json" ? "application/json" : "text/plain",
129
+ );
130
+ return {
131
+ kind: "artifact_ref",
132
+ path,
133
+ bytes: new TextEncoder().encode(text).byteLength,
134
+ hash: hash.slice(0, 16),
135
+ preview: text.slice(0, PREVIEW_CHARS),
136
+ note:
137
+ "Output was large and has been saved to the Workspace file above. " +
138
+ "Use the read tool with that path to retrieve the full content when needed.",
139
+ };
140
+ } catch {
141
+ return null;
142
+ }
143
+ }
144
+
145
+ // Bounds leaves first, then removes oldest array entries until the view fits.
146
+ function structureView(output: unknown): unknown {
147
+ const capped = truncateStringLeaves(output, MODEL_LEAF_MAX_CHARS);
148
+ if (serializeOutput(capped).text.length <= MODEL_VIEW_THRESHOLD) return capped;
149
+ if (typeof capped !== "object" || capped === null) return capped;
150
+
151
+ const record = { ...(capped as Record<string, unknown>) };
152
+ const arrayKey = Object.entries(record)
153
+ .filter((entry): entry is [string, unknown[]] => Array.isArray(entry[1]))
154
+ .sort((left, right) => right[1].length - left[1].length)[0]?.[0];
155
+ if (!arrayKey) return capped;
156
+
157
+ const items = [...(record[arrayKey] as unknown[])];
158
+ let dropped = 0;
159
+ while (
160
+ items.length > 1 &&
161
+ serializeOutput({ ...record, [arrayKey]: items }).text.length >
162
+ MODEL_VIEW_THRESHOLD
163
+ ) {
164
+ items.shift();
165
+ dropped += 1;
166
+ }
167
+ if (dropped === 0) return capped;
168
+ record[arrayKey] = items;
169
+ record[`${arrayKey}Omitted`] = {
170
+ count: dropped,
171
+ note:
172
+ `${dropped} earlier entr(ies) omitted to fit the model context budget; ` +
173
+ "the most recent are kept. The durable record retains the bounded value.",
174
+ };
175
+ return record;
176
+ }
177
+
178
+ /** Creates a non-destructive, smaller Tool-result projection for the model. */
179
+ export function projectToolOutputForModel(output: unknown): unknown {
180
+ try {
181
+ return serializeOutput(output).text.length <= MODEL_VIEW_THRESHOLD
182
+ ? output
183
+ : structureView(output);
184
+ } catch {
185
+ return truncateStringLeaves(output, MODEL_LEAF_MAX_CHARS);
186
+ }
187
+ }
package/src/lib/prompt.ts CHANGED
@@ -63,6 +63,9 @@ export const TOOLS =
63
63
  "raw or customized network cases described above; every response and failure it sees is visible to you. " +
64
64
  "bash is a shell over the workspace filesystem only " +
65
65
  "(no network, no system utilities) and is approval-gated — don't reach for it to read files or fetch. " +
66
+ "For more than three related changes in one file, use one write or bash operation instead of serial edits; " +
67
+ "do not re-plan or explain between consecutive tool calls. When a run is within the last five model turns, stop expanding scope and " +
68
+ "prioritize verification, saving durable results, and the final response. " +
66
69
  "When tool calls are independent, issue them in one turn so they run in parallel. When you reference " +
67
70
  "code, cite it as file_path:line_number.";
68
71
 
@@ -77,9 +80,10 @@ export const FILES =
77
80
 
78
81
  // User-interaction actions are called only when their UI semantics are useful.
79
82
  export const INTERACTION =
80
- "Interaction: When the user must pick among a small set of options, call ask_user (2-6 options) " +
81
- "instead of writing 'please choose A/B/C' as text, then end your turn and wait. Don't use it for " +
82
- "open-ended questions or when a sensible default lets you proceed. " +
83
+ "Interaction: When progress requires user decisions, put all 1-4 necessary questions in one ask_user call " +
84
+ "instead of asking each question separately or writing 'please choose A/B/C' as text. Use 2-6 options for " +
85
+ "a bounded choice, or no options when a required free-text answer is necessary; continue the same turn when its result arrives. " +
86
+ "Don't use it when a sensible default lets you proceed. " +
83
87
  "After completing a substantive task (report, analysis, multi-step job): write your full answer " +
84
88
  "FIRST, then call suggest_followups (2-4 directions) as the very last action and end the turn — " +
85
89
  "write no text after it (the tool renders its own closing block). Never for small talk or while a " +
@@ -282,7 +282,7 @@ export class PiChunkEncoder {
282
282
  return [];
283
283
  default:
284
284
  throw new Error(
285
- `Unsupported Pi AgentEvent: ${String((event as { type?: unknown }).type)}`,
285
+ `Unsupported SpringBrand AgentEvent: ${String((event as { type?: unknown }).type)}`,
286
286
  );
287
287
  }
288
288
  }
@@ -449,7 +449,7 @@ export class PiChunkEncoder {
449
449
  if (message.stopReason === "error") {
450
450
  chunks.push({
451
451
  type: "error",
452
- errorText: message.errorMessage ?? "Pi turn failed",
452
+ errorText: message.errorMessage ?? "SpringBrand turn failed",
453
453
  });
454
454
  return chunks;
455
455
  }
@@ -234,7 +234,8 @@ const IDEMPOTENT_TOOL_NAMES = new Set([
234
234
  export function piToolRetryPolicy(
235
235
  candidate: PiToolCandidate,
236
236
  ): "idempotent" | "non-idempotent" {
237
- return IDEMPOTENT_TOOL_NAMES.has(candidate.tool.name)
237
+ return IDEMPOTENT_TOOL_NAMES.has(candidate.tool.name) ||
238
+ candidate.owner.startsWith("subagent:")
238
239
  ? "idempotent"
239
240
  : "non-idempotent";
240
241
  }
@@ -320,7 +321,7 @@ class PreparedRuntime implements PreparedPiRuntime {
320
321
  read(owner: object): PreparedPiRuntimeState {
321
322
  if (owner !== this.owner) {
322
323
  throw new Error(
323
- "Prepared Pi Runtime belongs to another PiRuntimeAdapter",
324
+ "Prepared SpringBrand Runtime belongs to another runtime adapter",
324
325
  );
325
326
  }
326
327
  return this.state;
@@ -340,7 +341,7 @@ export function readPreparedPiRuntime(
340
341
  owner: object,
341
342
  ): PreparedPiRuntimeState {
342
343
  if (!(prepared instanceof PreparedRuntime)) {
343
- throw new Error("Prepared Pi Runtime handle is invalid");
344
+ throw new Error("Prepared SpringBrand Runtime handle is invalid");
344
345
  }
345
346
  return prepared.read(owner);
346
347
  }
@@ -46,10 +46,12 @@ import {
46
46
  withProviderRetry,
47
47
  } from "./models";
48
48
  import { EXECUTION_LEVELS } from "../../lib/execution-level";
49
+ import { projectToolOutputForModel } from "../../layers/context/budget/gate";
50
+ import type { SpillWorkspace } from "../../lib/artifacts";
49
51
 
50
52
  // #region Single-run Pi bridge
51
53
 
52
- const MAX_MODEL_TURNS = 30;
54
+ const MAX_MODEL_TURNS_PER_SLICE = 30;
53
55
 
54
56
  export class RetryableModelError extends Error {}
55
57
 
@@ -91,7 +93,7 @@ function classifyPiStopReason(stopReason: string | undefined): {
91
93
  return {
92
94
  outcome: "failed",
93
95
  turnStatus: "error",
94
- message: `Pi ended the turn with an unrecognized stop reason: ${
96
+ message: `SpringBrand ended the turn with an unrecognized stop reason: ${
95
97
  stopReason ?? "(none)"
96
98
  }`,
97
99
  };
@@ -134,6 +136,29 @@ interface PiTurnAdapterOptions {
134
136
  // PiCore 通过 AgentOptions.transformContext 调用它,Runtime 用它接入现有上下文处理。
135
137
  // 直接复用 Pi 的回调类型可避免这里维护另一套上下文转换约定。
136
138
  readonly transformContext: NonNullable<AgentOptions["transformContext"]>;
139
+ readonly workspace?: SpillWorkspace;
140
+ }
141
+
142
+ function projectToolResultsForModel(
143
+ messages: readonly AgentMessage[],
144
+ ): AgentMessage[] {
145
+ let changed = false;
146
+ const projected = messages.map((message) => {
147
+ if (message.role !== "toolResult") return message;
148
+ const payload = {
149
+ content: message.content,
150
+ details: message.details,
151
+ };
152
+ const next = projectToolOutputForModel(payload) as typeof payload;
153
+ if (next === payload) return message;
154
+ changed = true;
155
+ return {
156
+ ...message,
157
+ content: next.content,
158
+ details: next.details,
159
+ };
160
+ });
161
+ return changed ? projected : [...messages];
137
162
  }
138
163
 
139
164
  class PiTurnAdapter {
@@ -155,6 +180,7 @@ class PiTurnAdapter {
155
180
  private compile(candidates: readonly PiToolCandidate[]) {
156
181
  return compilePiTools(candidates, {
157
182
  governance: this.governance,
183
+ workspace: this.opts.workspace,
158
184
  settle: this.opts.settle,
159
185
  });
160
186
  }
@@ -201,7 +227,7 @@ class PiTurnAdapter {
201
227
  readonly signal?: AbortSignal;
202
228
  },
203
229
  listener: (event: AgentEvent) => void | Promise<void>,
204
- ): Promise<boolean> {
230
+ ): Promise<PiTurnRunResult> {
205
231
  const { systemPrompt, signal } = opts;
206
232
  signal?.throwIfAborted();
207
233
  const canonicalMessages = await this.opts.canonicalMessages();
@@ -221,11 +247,14 @@ class PiTurnAdapter {
221
247
  // 切换 provider 或消息跨 provider 回放时若不规范化,provider 会静默拒绝。
222
248
  transformContext: async (messages, signal) => {
223
249
  const ctx = await this.opts.transformContext(messages, signal);
224
- return transformMessages(ctx as Message[], this.opts.pi.model) as AgentMessage[];
250
+ return transformMessages(
251
+ projectToolResultsForModel(ctx) as Message[],
252
+ this.opts.pi.model,
253
+ ) as AgentMessage[];
225
254
  },
226
255
  afterToolCall: this.governance.afterToolCall,
227
256
  shouldStopAfterTurn: () => {
228
- reachedModelTurnLimit = ++modelTurns >= MAX_MODEL_TURNS;
257
+ reachedModelTurnLimit = ++modelTurns >= MAX_MODEL_TURNS_PER_SLICE;
229
258
  return signal?.aborted === true || reachedModelTurnLimit;
230
259
  },
231
260
  initialState: {
@@ -256,7 +285,9 @@ class PiTurnAdapter {
256
285
  pi.abort();
257
286
  }
258
287
  await running;
259
- return reachedModelTurnLimit;
288
+ return reachedModelTurnLimit
289
+ ? { kind: "yielded" }
290
+ : { kind: "terminal" };
260
291
  } finally {
261
292
  signal?.removeEventListener("abort", abort);
262
293
  unsubscribe();
@@ -371,6 +402,11 @@ export interface CreatePreparedPiTurnOptions {
371
402
  */
372
403
  readonly canonicalMessages: () => Promise<readonly AgentMessage[]>;
373
404
  readonly durability: PiTurnDurability;
405
+ /** Per-Submission executors for tools whose metadata is fixed at assembly time. */
406
+ readonly toolExecutors?: Readonly<Record<
407
+ string,
408
+ NonNullable<PiToolCandidate["tool"]["execute"]>
409
+ >>;
374
410
  /**
375
411
  * 上报受治理工具的耗时、结果大小和成败。
376
412
  *
@@ -442,6 +478,10 @@ export interface PiPreparedTurnRunOptions {
442
478
  readonly signal?: AbortSignal;
443
479
  }
444
480
 
481
+ export type PiTurnRunResult =
482
+ | { readonly kind: "terminal" }
483
+ | { readonly kind: "yielded" };
484
+
445
485
  // #endregion
446
486
 
447
487
  // #region Durable prepared-Turn execution
@@ -500,7 +540,7 @@ export class PreparedPiTurnAdapter {
500
540
  : candidate.requiredExecutionLevel;
501
541
  if (!EXECUTION_LEVELS.includes(requiredExecutionLevel)) {
502
542
  throw new Error(
503
- `Pi Tool "${candidate.tool.name}" resolved an invalid execution level`,
543
+ `SpringBrand Tool "${candidate.tool.name}" resolved an invalid execution level`,
504
544
  );
505
545
  }
506
546
  await state.snapshot.bindings.platform.gateTool?.({
@@ -617,7 +657,9 @@ export class PreparedPiTurnAdapter {
617
657
  `Non-idempotent Tool outcome is uncertain after recovery: ${candidate.tool.name}`,
618
658
  );
619
659
  }
620
- return candidate.tool.execute(
660
+ const execute = options.toolExecutors?.[candidate.tool.name] ??
661
+ candidate.tool.execute;
662
+ return execute(
621
663
  toolCallId,
622
664
  input,
623
665
  signal,
@@ -645,6 +687,7 @@ export class PreparedPiTurnAdapter {
645
687
  this.interrupted ? undefined : options.durability.settleTool(call),
646
688
  onToolTelemetry: options.onToolTelemetry,
647
689
  transformContext: options.transformContext,
690
+ workspace: state.snapshot.bindings.workspace,
648
691
  });
649
692
  this.systemPrompt = descriptor.systemPrompt;
650
693
  this.options = options;
@@ -744,9 +787,9 @@ export class PreparedPiTurnAdapter {
744
787
  */
745
788
  async run(
746
789
  options: PiPreparedTurnRunOptions,
747
- ): Promise<void> {
790
+ ): Promise<PiTurnRunResult> {
748
791
  this.terminalIntent = undefined;
749
- const reachedModelTurnLimit = await this.turn.run(
792
+ const result = await this.turn.run(
750
793
  {
751
794
  systemPrompt: this.systemPrompt,
752
795
  tools: this.candidates,
@@ -754,15 +797,11 @@ export class PreparedPiTurnAdapter {
754
797
  },
755
798
  (event) => this.handleEvent(event),
756
799
  );
757
- if (reachedModelTurnLimit && !this.terminalIntent) {
758
- this.terminalIntent = {
759
- outcome: "failed",
760
- message: `Pi stopped after ${MAX_MODEL_TURNS} model turns`,
761
- };
762
- }
763
800
  if (this.terminalIntent) {
764
801
  await this.options.onTerminal(this.terminalIntent);
802
+ return { kind: "terminal" };
765
803
  }
804
+ return result;
766
805
  }
767
806
 
768
807
  // 提交完成的 Pi 消息、推导终态意图,并把事件投影给可恢复流。
@@ -33,6 +33,11 @@ import {
33
33
  PiRuntimeTranscript,
34
34
  type PiTranscriptDurability,
35
35
  } from "./transcript";
36
+ import { subagentPiToolCandidates } from "../tool/subagent";
37
+ import type {
38
+ RuntimeSubagentLifecyclePort,
39
+ RuntimeSubagentPort,
40
+ } from "../../kernel/bindings";
36
41
  import type { SqlTaggedTemplate } from "agents/chat";
37
42
 
38
43
  /**
@@ -201,6 +206,34 @@ export class PiRuntimeAdapter {
201
206
  * 实现理由:构造时会校验 Prepared Runtime 的 owner 和 pinned descriptor 格式,再建立本 Turn 独享的 Tool governance 与执行状态。
202
207
  * 不要跨 Turn 复用返回对象;abort、steer、assistant ordinal 和 Tool governance 都是单次执行状态。
203
208
  */
209
+ /**
210
+ * 为一个已准入的 Submission 生成绑定 durable lifecycle 的 SubAgent Tool 执行器。
211
+ *
212
+ * @remarks
213
+ * 调用方:Runtime 在创建 Turn 前调用一次,并把结果作为 `createTurn` 的 `toolExecutors` 传入。
214
+ *
215
+ * 实现理由:SubAgent Tool 的名称、Schema 和执行语义属于 Pi Tool 层,而 accountId、
216
+ * rateVersion 和 slot identity 这些计费事实属于 Submission。两者在这里汇合:Pi 提供工具,
217
+ * Runtime 提供本次 Submission 的 lifecycle 端口,因此 Runtime 不需要认识 Tool 模块本身。
218
+ * 没有可用 SubAgent 类型时返回 `undefined`,让 Turn 继续使用装配期的无 lifecycle 执行器。
219
+ */
220
+ subagentToolExecutors(
221
+ subagents: RuntimeSubagentPort,
222
+ enabledSubagents: readonly string[],
223
+ lifecycle: RuntimeSubagentLifecyclePort,
224
+ ): CreatePreparedPiTurnOptions["toolExecutors"] {
225
+ const entries = subagentPiToolCandidates(
226
+ subagents,
227
+ enabledSubagents,
228
+ lifecycle,
229
+ ).flatMap((candidate) =>
230
+ candidate.tool.execute
231
+ ? [[candidate.tool.name, candidate.tool.execute] as const]
232
+ : []
233
+ );
234
+ return entries.length > 0 ? Object.fromEntries(entries) : undefined;
235
+ }
236
+
204
237
  createTurn(
205
238
  options: CreatePreparedPiTurnOptions,
206
239
  ): PreparedPiTurnAdapter {
@@ -226,6 +259,7 @@ export type {
226
259
  PiStoredToolSettlement,
227
260
  PiToolSettlement,
228
261
  PiToolInputRecord,
262
+ PiTurnRunResult,
229
263
  PreparedPiTurnAdapter,
230
264
  } from "./execution";
231
265
  export type {
@@ -498,7 +498,7 @@ function configuredModel(
498
498
  );
499
499
  if (!catalogModel) {
500
500
  throw new Error(
501
- `Model is not in Pi's built-in catalog for ${endpoint.protocol}: ${modelId}`,
501
+ `Model is not in SpringBrand's built-in catalog for ${endpoint.protocol}: ${modelId}`,
502
502
  );
503
503
  }
504
504
  const {
@@ -513,6 +513,7 @@ function configuredModel(
513
513
  ...catalogHeaders,
514
514
  ...endpoint.headers,
515
515
  };
516
+ const openRouterProviderPin = endpoint.openRouterProviderPins?.[modelId];
516
517
  return {
517
518
  ...metadata,
518
519
  api: apiFor(endpoint.protocol),
@@ -524,6 +525,14 @@ function configuredModel(
524
525
  ...compat,
525
526
  sendSessionAffinityHeaders: true,
526
527
  sessionAffinityFormat: "openrouter" as const,
528
+ ...(openRouterProviderPin
529
+ ? {
530
+ openRouterRouting: {
531
+ order: [openRouterProviderPin],
532
+ allow_fallbacks: false,
533
+ },
534
+ }
535
+ : {}),
527
536
  },
528
537
  }
529
538
  : {}),