@springbrand/agent-runtime 0.2.0-alpha.18 → 0.2.0-alpha.20
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 +1 -1
- package/src/adapter/cloudflare/sandbox/adapter.ts +20 -5
- package/src/adapter/cloudflare/universal-agent/preparation.ts +32 -5
- package/src/db/schema.ts +10 -0
- package/src/db/submission.repo.ts +44 -1
- package/src/index.ts +3 -1
- package/src/kernel/bindings.ts +22 -13
- package/src/kernel/recoverable-chat-agent.ts +37 -11
- package/src/kernel/submission-lifecycle.ts +53 -10
- package/src/kernel/tool-surface.ts +41 -0
- package/src/layers/context/budget/gate.ts +57 -11
- package/src/lib/prompt.ts +32 -15
- package/src/pi/message/projection.ts +22 -4
- package/src/pi/runtime-adapter/execution.ts +115 -9
- package/src/pi/runtime-adapter/transcript.ts +7 -2
- package/src/pi/tool/compiler.ts +9 -5
- package/src/pi/tool/core-host.ts +102 -13
- package/src/pi/tool/core.ts +209 -61
- package/src/pi/tool/schedule.ts +3 -1
- package/src/pi/tool/skill.ts +88 -1
- package/src/pi/tool/workspace-sandbox.ts +136 -4
- package/src/runtime-assembler.ts +32 -8
- package/src/runtime.ts +251 -44
- package/src/skills/index.ts +14 -0
- package/src/skills/springbrand-worker-website/index.ts +73 -0
- package/src/tool-registry.ts +15 -32
package/package.json
CHANGED
|
@@ -412,6 +412,20 @@ function timedOut(error: unknown): boolean {
|
|
|
412
412
|
return message.includes("timeout") || message.includes("timed out");
|
|
413
413
|
}
|
|
414
414
|
|
|
415
|
+
function processAlreadyStopped(error: unknown): boolean {
|
|
416
|
+
if (
|
|
417
|
+
typeof error !== "object" ||
|
|
418
|
+
error === null ||
|
|
419
|
+
!("code" in error)
|
|
420
|
+
) {
|
|
421
|
+
return false;
|
|
422
|
+
}
|
|
423
|
+
return (
|
|
424
|
+
error.code === "COMMAND_NOT_FOUND" ||
|
|
425
|
+
error.code === "PROCESS_NOT_FOUND"
|
|
426
|
+
);
|
|
427
|
+
}
|
|
428
|
+
|
|
415
429
|
function mappedError(error: unknown): SandboxPortError {
|
|
416
430
|
if (error instanceof SandboxPortError) return error;
|
|
417
431
|
const message = errorMessage(error).toLowerCase();
|
|
@@ -664,11 +678,11 @@ export class CloudflareSandboxAdapter implements RuntimeSandboxPort {
|
|
|
664
678
|
id,
|
|
665
679
|
PROCESS_SESSION_ID,
|
|
666
680
|
);
|
|
667
|
-
if (
|
|
668
|
-
|
|
669
|
-
|
|
670
|
-
|
|
671
|
-
|
|
681
|
+
if (
|
|
682
|
+
!process ||
|
|
683
|
+
(process.status !== "starting" && process.status !== "running")
|
|
684
|
+
) {
|
|
685
|
+
return { stopped: false };
|
|
672
686
|
}
|
|
673
687
|
if (this.processExpired(id)) {
|
|
674
688
|
await this.client
|
|
@@ -683,6 +697,7 @@ export class CloudflareSandboxAdapter implements RuntimeSandboxPort {
|
|
|
683
697
|
this.emit("sandbox.process.stopped", { success: true });
|
|
684
698
|
return { stopped: true };
|
|
685
699
|
} catch (error) {
|
|
700
|
+
if (processAlreadyStopped(error)) return { stopped: false };
|
|
686
701
|
throw mappedError(error);
|
|
687
702
|
}
|
|
688
703
|
});
|
|
@@ -4,7 +4,6 @@ import type {
|
|
|
4
4
|
RunAgentToolOptions,
|
|
5
5
|
} from "agents";
|
|
6
6
|
import type {
|
|
7
|
-
RuntimeBrowserPort,
|
|
8
7
|
RuntimeMemoryPort,
|
|
9
8
|
RuntimePlatformPort,
|
|
10
9
|
RuntimeSubagentPort,
|
|
@@ -15,7 +14,11 @@ import type { RuntimeDegradation } from "../../../kernel/degradation";
|
|
|
15
14
|
import type { RuntimeMemoryProfile } from "../../../kernel/profile";
|
|
16
15
|
import { AGENT_TYPES } from "../../../layers/orchestration/subagents/agent-types/registry";
|
|
17
16
|
import type { RuntimeAgentConfigContext } from "../../../runtime-agent-context";
|
|
18
|
-
import {
|
|
17
|
+
import {
|
|
18
|
+
createBrowserExecutionFactory,
|
|
19
|
+
createWorkspaceCodeExecutionFactory,
|
|
20
|
+
type RuntimeBrowserBinding,
|
|
21
|
+
} from "../../../pi/tool/core-host";
|
|
19
22
|
import {
|
|
20
23
|
createCloudflareSandboxAdapter,
|
|
21
24
|
type SandboxAdmission,
|
|
@@ -33,7 +36,8 @@ export type PlatformLoader = () => Promise<RuntimePlatformPort>;
|
|
|
33
36
|
|
|
34
37
|
export interface CloudflarePlatformBindings {
|
|
35
38
|
LOADER: WorkerLoader;
|
|
36
|
-
|
|
39
|
+
/** Browser Rendering 绑定(可选):CDP Code Mode 的 `browser_execute` 由它开出浏览器会话。 */
|
|
40
|
+
BROWSER?: RuntimeBrowserBinding;
|
|
37
41
|
}
|
|
38
42
|
|
|
39
43
|
export interface WorkspaceRequirement {
|
|
@@ -97,7 +101,18 @@ export function createWorkspaceLoader(
|
|
|
97
101
|
|
|
98
102
|
export function createPlatformLoader<
|
|
99
103
|
Env extends Cloudflare.Env & CloudflarePlatformBindings,
|
|
100
|
-
>(
|
|
104
|
+
>(
|
|
105
|
+
context: RuntimeAgentConfigContext<Env>,
|
|
106
|
+
options: {
|
|
107
|
+
/**
|
|
108
|
+
* 本 Session 的 Workspace preview URL(可以带短期签名 query)。
|
|
109
|
+
*
|
|
110
|
+
* 只有 Host 知道公开 origin、这个 Session 的身份和签名方式,所以只能由 Host 传进来。
|
|
111
|
+
* 调用方拿不到就别传 —— 浏览器工具会因此不声称自己能打开产物。
|
|
112
|
+
*/
|
|
113
|
+
previewBaseUrl?: string;
|
|
114
|
+
} = {},
|
|
115
|
+
): PlatformLoader {
|
|
101
116
|
let cached: ReturnType<PlatformLoader> | undefined;
|
|
102
117
|
return () => cached ??= Promise.resolve().then(() => {
|
|
103
118
|
const exports = (context.ctx as unknown as {
|
|
@@ -107,7 +122,19 @@ export function createPlatformLoader<
|
|
|
107
122
|
}).exports;
|
|
108
123
|
return {
|
|
109
124
|
loader: context.env.LOADER,
|
|
110
|
-
|
|
125
|
+
// 绑定缺失时整条浏览器能力不进 Platform Port,Tool Surface 因此注册空集。
|
|
126
|
+
...(context.env.BROWSER
|
|
127
|
+
? {
|
|
128
|
+
browser: createBrowserExecutionFactory({
|
|
129
|
+
ctx: context.ctx,
|
|
130
|
+
loader: context.env.LOADER,
|
|
131
|
+
browser: context.env.BROWSER,
|
|
132
|
+
...(options.previewBaseUrl
|
|
133
|
+
? { previewBaseUrl: options.previewBaseUrl }
|
|
134
|
+
: {}),
|
|
135
|
+
}),
|
|
136
|
+
}
|
|
137
|
+
: {}),
|
|
111
138
|
outbound: () => exports.HttpGateway({}),
|
|
112
139
|
};
|
|
113
140
|
});
|
package/src/db/schema.ts
CHANGED
|
@@ -46,6 +46,16 @@ export function initializeSchema(sql: SqlTaggedTemplate): void {
|
|
|
46
46
|
if (!submissionColumns.has("admission_retryable")) {
|
|
47
47
|
sql`ALTER TABLE pi_submissions ADD COLUMN admission_retryable INTEGER`;
|
|
48
48
|
}
|
|
49
|
+
// 跨执行片累计的模型回合数。让步把一个 Turn 切成多片后,每片的回合计数都从 0 开始,
|
|
50
|
+
// 只有把总数落到持久层,全局预算才拦得住「一直让步、永不收敛」的工具循环。
|
|
51
|
+
if (!submissionColumns.has("model_turns")) {
|
|
52
|
+
sql`ALTER TABLE pi_submissions
|
|
53
|
+
ADD COLUMN model_turns INTEGER NOT NULL DEFAULT 0`;
|
|
54
|
+
}
|
|
55
|
+
// 当前让步片的标识。用来丢弃重复到期的续跑 alarm,并让看门狗判断这一轮有没有推进。
|
|
56
|
+
if (!submissionColumns.has("continuation_id")) {
|
|
57
|
+
sql`ALTER TABLE pi_submissions ADD COLUMN continuation_id TEXT`;
|
|
58
|
+
}
|
|
49
59
|
sql`CREATE UNIQUE INDEX IF NOT EXISTS pi_submissions_run_id
|
|
50
60
|
ON pi_submissions(run_id) WHERE run_id IS NOT NULL`;
|
|
51
61
|
if (!submissionColumns.has("queued_input_json")) {
|
|
@@ -53,6 +53,10 @@ export interface StoredSubmission {
|
|
|
53
53
|
rateVersion: number | null;
|
|
54
54
|
slotIdentity: string | null;
|
|
55
55
|
retryable?: boolean;
|
|
56
|
+
/** 跨执行片累计的模型回合数,全局回合预算的唯一事实来源。 */
|
|
57
|
+
modelTurns: number;
|
|
58
|
+
/** 当前让步执行片的标识;没有未完成让步时为 null。 */
|
|
59
|
+
continuationId: string | null;
|
|
56
60
|
}
|
|
57
61
|
|
|
58
62
|
export interface NewSubmission {
|
|
@@ -97,6 +101,8 @@ type SubmissionRow = {
|
|
|
97
101
|
rate_version: number | null;
|
|
98
102
|
slot_identity: string | null;
|
|
99
103
|
admission_retryable: number | null;
|
|
104
|
+
model_turns: number;
|
|
105
|
+
continuation_id: string | null;
|
|
100
106
|
};
|
|
101
107
|
|
|
102
108
|
// #endregion
|
|
@@ -131,6 +137,8 @@ function mapRow(row: SubmissionRow): StoredSubmission {
|
|
|
131
137
|
retryable: row.admission_retryable === null
|
|
132
138
|
? undefined
|
|
133
139
|
: row.admission_retryable === 1,
|
|
140
|
+
modelTurns: row.model_turns ?? 0,
|
|
141
|
+
continuationId: row.continuation_id,
|
|
134
142
|
};
|
|
135
143
|
}
|
|
136
144
|
|
|
@@ -153,7 +161,8 @@ export class SubmissionRepository {
|
|
|
153
161
|
queued_input_json, queued_ui_message_json, user_message_id,
|
|
154
162
|
regenerate_message_id, recovery_error_count, recovery_reason,
|
|
155
163
|
run_id, account_id, rate_version, slot_identity,
|
|
156
|
-
admission_retryable
|
|
164
|
+
admission_retryable,
|
|
165
|
+
model_turns, continuation_id
|
|
157
166
|
FROM pi_submissions
|
|
158
167
|
WHERE submission_id = ${id}
|
|
159
168
|
`[0];
|
|
@@ -413,6 +422,40 @@ export class SubmissionRepository {
|
|
|
413
422
|
`[0]?.recovery_error_count ?? 0;
|
|
414
423
|
}
|
|
415
424
|
|
|
425
|
+
/**
|
|
426
|
+
* 记录一个让步执行片的累计模型回合数和续跑标识。
|
|
427
|
+
*
|
|
428
|
+
* @remarks
|
|
429
|
+
* Runtime 在一片主动让出后、排出续跑调度之前调用。
|
|
430
|
+
*
|
|
431
|
+
* 两个值必须一起写:全局回合预算靠 `model_turns` 拦住不收敛的工具循环,
|
|
432
|
+
* 续跑去重和看门狗靠 `continuation_id` 判断这一轮到底有没有推进。
|
|
433
|
+
* 只写非终态记录,避免一次迟到的写入复活已经结束的 Submission。
|
|
434
|
+
*/
|
|
435
|
+
recordYieldedSlice(
|
|
436
|
+
id: string,
|
|
437
|
+
modelTurns: number,
|
|
438
|
+
continuationId: string,
|
|
439
|
+
): void {
|
|
440
|
+
this.sql`
|
|
441
|
+
UPDATE pi_submissions
|
|
442
|
+
SET model_turns = ${modelTurns},
|
|
443
|
+
continuation_id = ${continuationId}
|
|
444
|
+
WHERE submission_id = ${id}
|
|
445
|
+
AND status IN ('pending', 'running')
|
|
446
|
+
`;
|
|
447
|
+
}
|
|
448
|
+
|
|
449
|
+
/** 记录累计模型回合数,不改动续跑标识。终态提交前的最后一次结算用它。 */
|
|
450
|
+
recordModelTurns(id: string, modelTurns: number): void {
|
|
451
|
+
this.sql`
|
|
452
|
+
UPDATE pi_submissions
|
|
453
|
+
SET model_turns = ${modelTurns}
|
|
454
|
+
WHERE submission_id = ${id}
|
|
455
|
+
AND status IN ('pending', 'running')
|
|
456
|
+
`;
|
|
457
|
+
}
|
|
458
|
+
|
|
416
459
|
clearRecoveryReason(id: string): void {
|
|
417
460
|
this.sql`
|
|
418
461
|
UPDATE pi_submissions
|
package/src/index.ts
CHANGED
|
@@ -135,10 +135,12 @@ export {
|
|
|
135
135
|
export { skillPiToolCandidates } from "./pi/tool";
|
|
136
136
|
export type { PiSkillBinding } from "./pi/tool";
|
|
137
137
|
export {
|
|
138
|
-
|
|
138
|
+
BROWSER_EXECUTE_TOOL_NAME,
|
|
139
|
+
browserExecutionPiToolCandidate,
|
|
139
140
|
codeExecutionPiToolCandidate,
|
|
140
141
|
} from "./pi/tool";
|
|
141
142
|
export {
|
|
143
|
+
createBrowserExecutionFactory,
|
|
142
144
|
createWorkspaceCodeExecutionFactory,
|
|
143
145
|
} from "./pi/tool";
|
|
144
146
|
export {
|
package/src/kernel/bindings.ts
CHANGED
|
@@ -2,7 +2,7 @@ import type { SkillSource } from "agents/skills";
|
|
|
2
2
|
import type { ScheduleSpec } from "./receipts";
|
|
3
3
|
import type { RuntimeActivityProjection } from "./state";
|
|
4
4
|
import type { ExecutionLevel } from "../lib/execution-level";
|
|
5
|
-
import type {
|
|
5
|
+
import type { ToolRegistry } from "./tool-surface";
|
|
6
6
|
import type {
|
|
7
7
|
RuntimeEventConfirmation,
|
|
8
8
|
RuntimeLifecycleFact,
|
|
@@ -856,26 +856,23 @@ export interface RuntimeTurnEventsPort {
|
|
|
856
856
|
}
|
|
857
857
|
|
|
858
858
|
/**
|
|
859
|
-
* 向 Runtime
|
|
859
|
+
* 向 Runtime 提供一个真实无头浏览器的 CDP Code Mode 执行能力。
|
|
860
860
|
*
|
|
861
861
|
* @remarks
|
|
862
|
-
* Platform Plugin 在 Browser
|
|
862
|
+
* Platform Plugin 在 Browser 绑定存在时准备它,Tool Surface 决定注册 `browser_execute` 时才调用 `create()`。
|
|
863
863
|
*
|
|
864
|
-
* 该 Port
|
|
864
|
+
* 该 Port 只承载「能不能开出一个浏览器执行端口」,浏览器会话生命周期与 CDP 协议细节都留在 `agents/browser`,
|
|
865
|
+
* 避免本仓复制一份会随上游漂移的协议。旧的 Quick Action `action + options` 单方法协议已于 2026-08-13 退役:
|
|
866
|
+
* 复测确认本地绑定至今不实现 `quickAction()`,该面在本仓从未执行过。
|
|
865
867
|
*/
|
|
866
868
|
export interface RuntimeBrowserPort {
|
|
867
869
|
/**
|
|
868
|
-
*
|
|
870
|
+
* 创建本次装配的浏览器代码执行端口。
|
|
869
871
|
*
|
|
870
872
|
* @remarks
|
|
871
|
-
*
|
|
872
|
-
*
|
|
873
|
-
* Runtime 不复制 Browser Run 的选项联合类型,避免两份协议随上游漂移。
|
|
873
|
+
* Tool Surface 在浏览器工具确实可见时调用一次,延迟到此刻是为了让被 deny 的装配不白建连接器。
|
|
874
874
|
*/
|
|
875
|
-
|
|
876
|
-
action: string,
|
|
877
|
-
options: unknown,
|
|
878
|
-
): Promise<Response>;
|
|
875
|
+
create(): RuntimeCodeExecutionPort;
|
|
879
876
|
}
|
|
880
877
|
|
|
881
878
|
// #endregion
|
|
@@ -942,7 +939,7 @@ export interface RuntimeProviderPort {
|
|
|
942
939
|
export interface RuntimePlatformPort {
|
|
943
940
|
/** Workspace Codemode 在创建 Dynamic Worker 执行器时使用的 Worker Loader。 */
|
|
944
941
|
loader: WorkerLoader;
|
|
945
|
-
/** Platform Plugin 存在 Browser Run
|
|
942
|
+
/** Platform Plugin 存在 Browser Run 绑定时用它生成 `browser_execute`;缺失即整条浏览器 Tool 面不注册。 */
|
|
946
943
|
browser?: RuntimeBrowserPort;
|
|
947
944
|
/**
|
|
948
945
|
* 为一次 Dynamic Worker 组装取得已限定的网络出口。
|
|
@@ -1021,6 +1018,18 @@ export interface RuntimeSkillSourceBinding {
|
|
|
1021
1018
|
*
|
|
1022
1019
|
* 它不携带业务 ID、Repository、数据库 Key、任意能力注册表或凭据配置;术语见 `../index.ts`。
|
|
1023
1020
|
*/
|
|
1021
|
+
/**
|
|
1022
|
+
* 延迟到最终 Tool Surface 完成后再创建 Code Mode 执行能力。
|
|
1023
|
+
*
|
|
1024
|
+
* @remarks
|
|
1025
|
+
* 定义在这里而不是 `tool-registry.ts`:它的返回类型是本文件的
|
|
1026
|
+
* `RuntimeCodeExecutionPort`,而 `RuntimeBindings` 又要引用这个工厂 —— 放在
|
|
1027
|
+
* tool-registry 会让两个模块互相 import。`tool-registry.ts` 继续对外导出它。
|
|
1028
|
+
*/
|
|
1029
|
+
export interface RuntimeCodeExecutionFactory {
|
|
1030
|
+
create(tools: ToolRegistry): RuntimeCodeExecutionPort;
|
|
1031
|
+
}
|
|
1032
|
+
|
|
1024
1033
|
export interface RuntimeBindings {
|
|
1025
1034
|
provider: RuntimeProviderPort;
|
|
1026
1035
|
platform: RuntimePlatformPort;
|
|
@@ -353,10 +353,41 @@ export abstract class RecoverableChatAgent<
|
|
|
353
353
|
* 清理延后执行,为断线重连的客户端保留终态流片段。
|
|
354
354
|
*/
|
|
355
355
|
protected completeRecoverableStream(streamId: string): void {
|
|
356
|
-
|
|
357
|
-
|
|
356
|
+
this.closeRecoverableStream(streamId, (id) =>
|
|
357
|
+
this.resumableStream.complete(id),
|
|
358
|
+
);
|
|
359
|
+
}
|
|
360
|
+
|
|
361
|
+
/**
|
|
362
|
+
* 密封一个只跑完当前执行切片的可续传流,把这一轮交给下一个切片继续。
|
|
363
|
+
*
|
|
364
|
+
* @remarks
|
|
365
|
+
* 子类在一次计划让步(Turn 未结束、但本次执行已让出)后调用;调用方随后必须
|
|
366
|
+
* 排出续跑,且**不能**同时发 `done` 终态帧 —— 这一轮还没有权威结果。
|
|
367
|
+
*
|
|
368
|
+
* 与 `completeRecoverableStream` 的差别只在语义:那个断言「业务终态已提交」,
|
|
369
|
+
* 这个断言「本切片不再产出,另一个切片接手」。底层动作相同是有意的 ——
|
|
370
|
+
* 让步不密封的话,`ResumableStream.start()` 会直接覆盖 `activeStreamId`,
|
|
371
|
+
* 旧行永远停在 `streaming`,缓冲区要等 abandoned 保留期才回收。
|
|
372
|
+
*/
|
|
373
|
+
protected sealRecoverableStreamSlice(streamId: string): void {
|
|
374
|
+
this.closeRecoverableStream(streamId, (id) =>
|
|
375
|
+
this.resumableStream.complete(id),
|
|
376
|
+
);
|
|
377
|
+
}
|
|
378
|
+
|
|
379
|
+
// 作用:收口一条可续传流的终止写入与内存续传状态。
|
|
380
|
+
// 调用:完成、失败和切片密封三条路径共用。
|
|
381
|
+
// 原因:清理顺序(先读 activeRequestId、再写状态、最后比对续传归属)三处必须一致,
|
|
382
|
+
// 分开维护过一次就会漏掉 pendingResumeConnections 或 continuation 归属其中之一。
|
|
383
|
+
private closeRecoverableStream(
|
|
384
|
+
streamId: string,
|
|
385
|
+
close: (streamId: string) => void,
|
|
386
|
+
): void {
|
|
387
|
+
const closedRequestId = this.resumableStream.activeRequestId;
|
|
388
|
+
close(streamId);
|
|
358
389
|
this.pendingResumeConnections.clear();
|
|
359
|
-
if (
|
|
390
|
+
if (closedRequestId === this.continuation.activeRequestId) {
|
|
360
391
|
this.continuation.activeRequestId = null;
|
|
361
392
|
this.continuation.activeConnectionId = null;
|
|
362
393
|
}
|
|
@@ -372,14 +403,9 @@ export abstract class RecoverableChatAgent<
|
|
|
372
403
|
* 流错误标志与业务失败分开,因为续传协议和提交生命周期的责任不同。
|
|
373
404
|
*/
|
|
374
405
|
protected failRecoverableStream(streamId: string): void {
|
|
375
|
-
|
|
376
|
-
|
|
377
|
-
|
|
378
|
-
if (erroredRequestId === this.continuation.activeRequestId) {
|
|
379
|
-
this.continuation.activeRequestId = null;
|
|
380
|
-
this.continuation.activeConnectionId = null;
|
|
381
|
-
}
|
|
382
|
-
void this.ensureStreamCleanupScheduled();
|
|
406
|
+
this.closeRecoverableStream(streamId, (id) =>
|
|
407
|
+
this.resumableStream.markError(id),
|
|
408
|
+
);
|
|
383
409
|
}
|
|
384
410
|
|
|
385
411
|
/**
|
|
@@ -131,6 +131,25 @@ export class SubmissionQueueFullError extends Error {
|
|
|
131
131
|
*/
|
|
132
132
|
export type SubmissionOutcome = "succeeded" | "failed" | "aborted";
|
|
133
133
|
|
|
134
|
+
/**
|
|
135
|
+
* 说明一次执行是怎么被启动的。
|
|
136
|
+
*
|
|
137
|
+
* @remarks
|
|
138
|
+
* `SubmissionLifecycle` 把它交给 `execute`,Runtime 据此选择执行前置动作。
|
|
139
|
+
*
|
|
140
|
+
* `recovery` 和 `continuation` 必须分开:前者的内存状态已经丢了、transcript 可能停在半路,
|
|
141
|
+
* 要走完整的里程碑重放;后者是本轮主动让出执行片,transcript 完好、什么都不用重建。
|
|
142
|
+
* 合成一个布尔量意味着最常跑的那条路径每次都去趟最脆弱的恢复代码,
|
|
143
|
+
* 而且重放成本会随让步次数增长。
|
|
144
|
+
*/
|
|
145
|
+
export type SubmissionExecutionMode =
|
|
146
|
+
/** 首次执行一条新接收的提交。 */
|
|
147
|
+
| "fresh"
|
|
148
|
+
/** 中断后按持久事实重建并继续。 */
|
|
149
|
+
| "recovery"
|
|
150
|
+
/** 上一执行片主动让出后,接着跑同一条提交。 */
|
|
151
|
+
| "continuation";
|
|
152
|
+
|
|
134
153
|
/**
|
|
135
154
|
* 描述接收一次提交所需的标识和延迟创建步骤。
|
|
136
155
|
*
|
|
@@ -183,10 +202,13 @@ interface SubmissionLifecycleOptions<
|
|
|
183
202
|
// 调用:`admit` 只在新提交成功写入后调用。
|
|
184
203
|
// 原因:重复提交只加入旧工作,不应清除现有终态。
|
|
185
204
|
clearTerminal(): Promise<void>;
|
|
186
|
-
//
|
|
205
|
+
// 作用:执行、恢复或续跑一条已持久化的提交。
|
|
187
206
|
// 调用:`start` 在 `TurnQueue` 轮到该提交时调用。
|
|
188
|
-
// 原因:生命周期只管调度,具体 Pi Turn 执行仍由 Runtime
|
|
189
|
-
execute(
|
|
207
|
+
// 原因:生命周期只管调度,具体 Pi Turn 执行仍由 Runtime 负责;模式决定 Runtime 的前置动作。
|
|
208
|
+
execute(
|
|
209
|
+
submissionId: string,
|
|
210
|
+
mode: SubmissionExecutionMode,
|
|
211
|
+
): Promise<TSubmission>;
|
|
190
212
|
// 作用:记录可恢复的取消意图。
|
|
191
213
|
// 调用:`cancel` 在同一存储事务内与取消原因一起写入。
|
|
192
214
|
// 原因:持久化里程碑可以让恢复路径看到已经发生的取消。
|
|
@@ -300,22 +322,30 @@ export class SubmissionLifecycle<
|
|
|
300
322
|
* 它复用 `start` 的实例内去重和串行队列,避免重复唤醒产生两个执行器。
|
|
301
323
|
*/
|
|
302
324
|
recover(submissionId: string): Promise<TSubmission> {
|
|
303
|
-
return this.start(submissionId,
|
|
325
|
+
return this.start(submissionId, "recovery");
|
|
304
326
|
}
|
|
305
327
|
|
|
306
|
-
/**
|
|
307
|
-
|
|
328
|
+
/**
|
|
329
|
+
* 等当前执行片退出后,接着跑同一条非终态 Submission。
|
|
330
|
+
*
|
|
331
|
+
* @remarks
|
|
332
|
+
* `_piPlannedContinuation` 在计划让步的唤醒里调用。先 await 当前执行片,
|
|
333
|
+
* 是为了避开 `executions` 的实例内去重 —— 否则这次唤醒会被合流到正在退场的那一片上。
|
|
334
|
+
*
|
|
335
|
+
* 走 `continuation` 而不是 `recovery`:这一轮什么都没丢,不需要重放里程碑。
|
|
336
|
+
*/
|
|
337
|
+
async continueAfterCurrent(submissionId: string): Promise<TSubmission> {
|
|
308
338
|
const current = this.executions.get(submissionId);
|
|
309
339
|
if (current) {
|
|
310
340
|
const outcome = await current;
|
|
311
341
|
if (isTerminalSubmissionStatus(outcome.status)) return outcome;
|
|
312
342
|
}
|
|
313
|
-
return this.start(submissionId,
|
|
343
|
+
return this.start(submissionId, "continuation");
|
|
314
344
|
}
|
|
315
345
|
|
|
316
346
|
recoverHead(): Promise<TSubmission | null> {
|
|
317
347
|
const running = this.options.store.findRunning();
|
|
318
|
-
if (running) return this.start(running.submissionId,
|
|
348
|
+
if (running) return this.start(running.submissionId, "recovery");
|
|
319
349
|
const pending = this.options.store.findNextPending();
|
|
320
350
|
return pending
|
|
321
351
|
? this.start(pending.submissionId)
|
|
@@ -410,14 +440,14 @@ export class SubmissionLifecycle<
|
|
|
410
440
|
// 原因:`TurnQueue` 防止不同 Pi Turn 重叠,`executions` 则防止同一标识重复入队。
|
|
411
441
|
private start(
|
|
412
442
|
submissionId: string,
|
|
413
|
-
|
|
443
|
+
mode: SubmissionExecutionMode = "fresh",
|
|
414
444
|
): Promise<TSubmission> {
|
|
415
445
|
const existing = this.executions.get(submissionId);
|
|
416
446
|
if (existing) return existing;
|
|
417
447
|
let shouldPump = false;
|
|
418
448
|
const started = this.queue
|
|
419
449
|
.enqueue(submissionId, () =>
|
|
420
|
-
this.options.execute(submissionId,
|
|
450
|
+
this.options.execute(submissionId, mode),
|
|
421
451
|
)
|
|
422
452
|
.then((outcome) => {
|
|
423
453
|
// TODO(待确认): 当前类没有调用 `queue.reset()`,按现有调用链不会产生 `stale` 结果。
|
|
@@ -583,6 +613,19 @@ export class SubmissionLifecycle<
|
|
|
583
613
|
await this.executions.get(submissionId)?.catch(() => undefined);
|
|
584
614
|
}
|
|
585
615
|
|
|
616
|
+
/**
|
|
617
|
+
* 判断某条提交此刻是否有执行器在跑。
|
|
618
|
+
*
|
|
619
|
+
* @remarks
|
|
620
|
+
* 续跑看门狗在决定要不要接手前调用。
|
|
621
|
+
*
|
|
622
|
+
* 只反映本实例的内存状态 —— 这正是它要问的:跨实例的丢失由持久事实兜底,
|
|
623
|
+
* 而「有执行器在跑」只有本实例知道,误判会派发出重复的执行片。
|
|
624
|
+
*/
|
|
625
|
+
isActive(submissionId: string): boolean {
|
|
626
|
+
return this.activeBySubmission.has(submissionId);
|
|
627
|
+
}
|
|
628
|
+
|
|
586
629
|
/**
|
|
587
630
|
* 返回当前唯一活动的执行器。
|
|
588
631
|
*
|
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
import type { ExecutionLevel } from "../lib/execution-level";
|
|
2
|
+
import type { PiToolCandidate } from "../pi/tool/compiler";
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* Tool Surface 的形状类型。
|
|
6
|
+
*
|
|
7
|
+
* @remarks
|
|
8
|
+
* `kernel/bindings.ts` 和 `tool-registry.ts` 都要用它们,所以它们不能住在其中任何
|
|
9
|
+
* 一边:`RuntimeBindings.codeExecution` 的工厂签名需要 `ToolRegistry`,而
|
|
10
|
+
* `tool-registry.ts` 又需要 bindings 里的各个 Port —— 两边互相 import 就形成了
|
|
11
|
+
* depcruise 的 no-circular 违规(`bindings → tool-registry → bindings`)。
|
|
12
|
+
*
|
|
13
|
+
* 这里只放形状,不放行为:注册表的合并、筛选和构造仍然留在 `tool-registry.ts`。
|
|
14
|
+
*
|
|
15
|
+
* 术语见 `../index.ts`。
|
|
16
|
+
*/
|
|
17
|
+
|
|
18
|
+
/** 模型调用 Tool 时传给 `execute` 的上下文。 */
|
|
19
|
+
export interface ToolContext {
|
|
20
|
+
readonly toolCallId: string;
|
|
21
|
+
readonly signal: AbortSignal;
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
/** Definition 作者声明的一个 Tool。 */
|
|
25
|
+
export interface ToolSpec extends Partial<
|
|
26
|
+
Omit<PiToolCandidate, "tool" | "requiredExecutionLevel">
|
|
27
|
+
> {
|
|
28
|
+
readonly label: string;
|
|
29
|
+
readonly description: string;
|
|
30
|
+
readonly parameters: unknown;
|
|
31
|
+
readonly requiredExecutionLevel: ExecutionLevel;
|
|
32
|
+
/** Require a fresh human decision for every call, regardless of execution level. */
|
|
33
|
+
readonly alwaysRequiresApproval?: boolean;
|
|
34
|
+
readonly execute: (
|
|
35
|
+
input: unknown,
|
|
36
|
+
ctx: ToolContext,
|
|
37
|
+
) => Promise<unknown>;
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
/** 一次装配声明的全部 Tool,键即模型可见的工具名。 */
|
|
41
|
+
export type ToolRegistry = Record<string, ToolSpec>;
|
|
@@ -15,14 +15,48 @@ import {
|
|
|
15
15
|
* must be given an explicit protected path here.
|
|
16
16
|
*/
|
|
17
17
|
|
|
18
|
-
/**
|
|
19
|
-
|
|
18
|
+
/**
|
|
19
|
+
* 预算常量的排序不变量(改任何一个都必须同时复核其余三个):
|
|
20
|
+
*
|
|
21
|
+
* MODEL_LEAF_MAX_CHARS < MODEL_REPLAY_THRESHOLD < SPILL_THRESHOLD
|
|
22
|
+
* ≤ STORAGE_LEAF_MAX_CHARS
|
|
23
|
+
* 4 KB 16 KB 32 KB 64 KB
|
|
24
|
+
*
|
|
25
|
+
* 这四个数回答四个不同问题。曾经其中两个共用一个常量,结果“放宽外置”和“收紧
|
|
26
|
+
* 历史”这两个互相冲突的诉求被绑在同一个旋钮上,只能二选一。
|
|
27
|
+
*/
|
|
20
28
|
|
|
21
|
-
/**
|
|
22
|
-
|
|
29
|
+
/**
|
|
30
|
+
* 持久化单个字符串叶子的上限。
|
|
31
|
+
*
|
|
32
|
+
* MUST ≥ 单页 read 的上限(见 `workspace-sandbox.ts` 的 `READ_PAGE_MAX_CHARS`),
|
|
33
|
+
* 否则持久记录会比模型当轮看到的内容还少,会话恢复后出现凭空缺页。
|
|
34
|
+
*/
|
|
35
|
+
export const STORAGE_LEAF_MAX_CHARS = 64 * 1024;
|
|
23
36
|
|
|
24
|
-
/**
|
|
25
|
-
|
|
37
|
+
/**
|
|
38
|
+
* 一次新的 Tool 结果多大才值得外置成 Workspace 文件。
|
|
39
|
+
*
|
|
40
|
+
* 只影响当轮新产生的结果,不影响历史重放。
|
|
41
|
+
*/
|
|
42
|
+
export const SPILL_THRESHOLD = 32 * 1024;
|
|
43
|
+
|
|
44
|
+
/**
|
|
45
|
+
* 历史里一条旧 Tool 结果重放多少。
|
|
46
|
+
*
|
|
47
|
+
* 这条最紧,因为它对每个 Turn 的**全部**历史消息生效(见
|
|
48
|
+
* `pi/runtime-adapter/execution.ts` 的 `projectToolResultsForModel`):
|
|
49
|
+
* 放宽 1 KB 就是 N 条历史 × 1 KB。
|
|
50
|
+
*/
|
|
51
|
+
export const MODEL_REPLAY_THRESHOLD = 16 * 1024;
|
|
52
|
+
|
|
53
|
+
/**
|
|
54
|
+
* 重放时单个字符串叶子的上限。
|
|
55
|
+
*
|
|
56
|
+
* 曾经是 500,小到让 Code Mode 这类返回长字符串的结果在**第二轮**就塌成碎片,
|
|
57
|
+
* 模型只能反复换姿势(`Object.values()`、`slice()`)去捞已经不存在的内容。
|
|
58
|
+
*/
|
|
59
|
+
export const MODEL_LEAF_MAX_CHARS = 4 * 1024;
|
|
26
60
|
|
|
27
61
|
const ELISION_RESERVE = 40;
|
|
28
62
|
const PREVIEW_CHARS = 600;
|
|
@@ -118,7 +152,7 @@ export async function spillDurableToolOutput(
|
|
|
118
152
|
},
|
|
119
153
|
): Promise<ArtifactRef | null> {
|
|
120
154
|
const { text, ext } = serializeOutput(value);
|
|
121
|
-
if (text.length <=
|
|
155
|
+
if (text.length <= SPILL_THRESHOLD || !options.workspace) return null;
|
|
122
156
|
try {
|
|
123
157
|
const hash = await sha256Hex(text);
|
|
124
158
|
const path = `${DEFAULT_SPILL_DIR}/${hash.slice(0, 24)}.${ext}`;
|
|
@@ -133,9 +167,19 @@ export async function spillDurableToolOutput(
|
|
|
133
167
|
bytes: new TextEncoder().encode(text).byteLength,
|
|
134
168
|
hash: hash.slice(0, 16),
|
|
135
169
|
preview: text.slice(0, PREVIEW_CHARS),
|
|
170
|
+
// 出口指令必须是可执行的:只说“去 read 这个路径”会诱导模型整读,而整读一个
|
|
171
|
+
// 刚刚因为过大被外置的文件毫无意义。这里明确要求分页,并告知 read 会返回
|
|
172
|
+
// nextOffset / eof 以便继续。
|
|
173
|
+
// 出口指令必须是可执行的,而且必须只承诺模型真的拿得到的东西:Provider 只把
|
|
174
|
+
// Tool 结果的 content 发给模型,所以这里不能引用只存在于 details 的字段。
|
|
175
|
+
// 溢出产物是 JSON,一个大字符串叶子会整块挤在一行上,而按行分页追不回被行宽
|
|
176
|
+
// 截断的内容 —— 那种情况要走 grep / bash,不能让模型以为 read 一定够用。
|
|
136
177
|
note:
|
|
137
178
|
"Output was large and has been saved to the Workspace file above. " +
|
|
138
|
-
"
|
|
179
|
+
"Read it in pages with read(path, offset, limit) — do not read it whole; " +
|
|
180
|
+
"each page ends with a footer telling you the line range and the next offset. " +
|
|
181
|
+
"This file is JSON, so a single large value can sit on one very long line: " +
|
|
182
|
+
"if a page reports that lines were cut short, use grep or bash on the path instead.",
|
|
139
183
|
};
|
|
140
184
|
} catch {
|
|
141
185
|
return null;
|
|
@@ -145,7 +189,9 @@ export async function spillDurableToolOutput(
|
|
|
145
189
|
// Bounds leaves first, then removes oldest array entries until the view fits.
|
|
146
190
|
function structureView(output: unknown): unknown {
|
|
147
191
|
const capped = truncateStringLeaves(output, MODEL_LEAF_MAX_CHARS);
|
|
148
|
-
if (serializeOutput(capped).text.length <=
|
|
192
|
+
if (serializeOutput(capped).text.length <= MODEL_REPLAY_THRESHOLD) {
|
|
193
|
+
return capped;
|
|
194
|
+
}
|
|
149
195
|
if (typeof capped !== "object" || capped === null) return capped;
|
|
150
196
|
|
|
151
197
|
const record = { ...(capped as Record<string, unknown>) };
|
|
@@ -159,7 +205,7 @@ function structureView(output: unknown): unknown {
|
|
|
159
205
|
while (
|
|
160
206
|
items.length > 1 &&
|
|
161
207
|
serializeOutput({ ...record, [arrayKey]: items }).text.length >
|
|
162
|
-
|
|
208
|
+
MODEL_REPLAY_THRESHOLD
|
|
163
209
|
) {
|
|
164
210
|
items.shift();
|
|
165
211
|
dropped += 1;
|
|
@@ -178,7 +224,7 @@ function structureView(output: unknown): unknown {
|
|
|
178
224
|
/** Creates a non-destructive, smaller Tool-result projection for the model. */
|
|
179
225
|
export function projectToolOutputForModel(output: unknown): unknown {
|
|
180
226
|
try {
|
|
181
|
-
return serializeOutput(output).text.length <=
|
|
227
|
+
return serializeOutput(output).text.length <= MODEL_REPLAY_THRESHOLD
|
|
182
228
|
? output
|
|
183
229
|
: structureView(output);
|
|
184
230
|
} catch {
|