@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.
- package/package.json +4 -3
- package/src/adapter/cloudflare/index.ts +1 -0
- package/src/adapter/cloudflare/sandbox/adapter.ts +4 -0
- package/src/adapter/cloudflare/subagent/definition.ts +60 -5
- package/src/adapter/cloudflare/universal-agent/hooks.ts +23 -1
- package/src/adapter/cloudflare/universal-agent/preparation.ts +36 -2
- package/src/adapter/cloudflare/universal-agent/tools.ts +9 -12
- package/src/adapter/cloudflare/workspace/git-fs.ts +178 -0
- package/src/adapter/cloudflare/workspace/version-control.ts +374 -0
- package/src/db/index.ts +4 -0
- package/src/db/runtime-event-outbox.repo.ts +34 -1
- package/src/db/schema.ts +42 -0
- package/src/db/submission-admission.repo.ts +127 -0
- package/src/db/submission.repo.ts +54 -3
- package/src/index.ts +5 -2
- package/src/kernel/bindings.ts +52 -0
- package/src/kernel/durable-lifecycle.ts +100 -0
- package/src/kernel/public-contracts.ts +11 -0
- package/src/kernel/receipts.ts +1 -0
- package/src/kernel/recoverable-chat-agent.ts +0 -19
- package/src/kernel/subagent-runtime.ts +137 -0
- package/src/kernel/submission-authority.ts +114 -0
- package/src/kernel/submission-lifecycle.ts +35 -10
- package/src/layers/context/budget/gate.ts +99 -0
- package/src/lib/prompt.ts +7 -3
- package/src/pi/message/projection.ts +2 -2
- package/src/pi/runtime-adapter/assembly.ts +4 -3
- package/src/pi/runtime-adapter/execution.ts +55 -16
- package/src/pi/runtime-adapter/index.ts +34 -0
- package/src/pi/runtime-adapter/models.ts +10 -1
- package/src/pi/runtime-adapter/recovery.ts +17 -17
- package/src/pi/runtime-adapter/transcript.ts +2 -2
- package/src/pi/tool/base.ts +113 -27
- package/src/pi/tool/compiler.ts +66 -6
- package/src/pi/tool/core-host.ts +50 -23
- package/src/pi/tool/core.ts +2 -23
- package/src/pi/tool/index.ts +1 -0
- package/src/pi/tool/mcp.ts +2 -2
- package/src/pi/tool/skill.ts +78 -3
- package/src/pi/tool/subagent.ts +142 -16
- package/src/pi/tool/workspace-revision.ts +64 -0
- package/src/pi/tool/workspace-sandbox.ts +4 -4
- package/src/pi/turn/tool-recovery.ts +7 -7
- package/src/runtime-agent-context.ts +1 -0
- package/src/runtime-agent.ts +8 -3
- package/src/runtime-assembler.ts +61 -9
- package/src/runtime-definition.ts +12 -0
- package/src/runtime.ts +485 -66
- package/src/tool-registry.ts +10 -1
- 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(`
|
|
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:
|
|
270
|
-
admitted.submission
|
|
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<{
|
|
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
|
-
|
|
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
|
|
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)
|
|
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
|
|
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
|
|
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
|
|
81
|
-
"instead of writing 'please choose A/B/C' as text
|
|
82
|
-
"
|
|
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
|
|
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 ?? "
|
|
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
|
|
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
|
|
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
|
|
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: `
|
|
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<
|
|
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(
|
|
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 >=
|
|
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
|
-
`
|
|
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
|
-
|
|
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<
|
|
790
|
+
): Promise<PiTurnRunResult> {
|
|
748
791
|
this.terminalIntent = undefined;
|
|
749
|
-
const
|
|
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
|
|
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
|
: {}),
|