@springbrand/agent-runtime 0.1.3-alpha.1 → 0.1.3-alpha.3
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/db/schema.ts +10 -1
- package/src/db/submission.repo.ts +34 -1
- package/src/index.ts +1 -0
- package/src/kernel/bindings.ts +1 -3
- package/src/kernel/recoverable-chat-agent.ts +82 -4
- package/src/kernel/state.ts +4 -0
- package/src/kernel/submission-lifecycle.ts +3 -2
- package/src/lib/prompt.ts +1 -1
- package/src/lib/telemetry-dev.ts +7 -4
- package/src/pi/assembly/snapshot.ts +5 -2
- package/src/pi/runtime-adapter/assembly.ts +9 -20
- package/src/pi/runtime-adapter/execution.ts +89 -6
- package/src/pi/runtime-adapter/index.ts +9 -3
- package/src/pi/runtime-adapter/models.ts +373 -35
- package/src/pi/runtime-adapter/transcript.ts +61 -3
- package/src/pi/tool/ai-adapter.ts +58 -1
- package/src/pi/tool/base.ts +112 -2
- package/src/pi/tool/core-host.ts +19 -24
- package/src/pi/tool/core.ts +18 -118
- package/src/pi/tool/skill.ts +112 -420
- package/src/pi/tool/web-fetch.ts +282 -0
- package/src/pi/tool/web-search/api.ts +34 -18
- package/src/pi/tool/workspace-sandbox.ts +92 -258
- package/src/plugins.ts +79 -42
- package/src/runtime.ts +201 -40
package/package.json
CHANGED
package/src/db/schema.ts
CHANGED
|
@@ -21,7 +21,9 @@ export function initializeSchema(sql: SqlTaggedTemplate): void {
|
|
|
21
21
|
assembly_revision TEXT NOT NULL,
|
|
22
22
|
assembly_descriptor TEXT NOT NULL,
|
|
23
23
|
assistant_message_id TEXT NOT NULL,
|
|
24
|
-
abort_reason TEXT
|
|
24
|
+
abort_reason TEXT,
|
|
25
|
+
recovery_error_count INTEGER NOT NULL DEFAULT 0,
|
|
26
|
+
recovery_reason TEXT
|
|
25
27
|
)`;
|
|
26
28
|
const submissionColumns = new Set(
|
|
27
29
|
sql<{ name: string }>`PRAGMA table_info(pi_submissions)`
|
|
@@ -39,6 +41,13 @@ export function initializeSchema(sql: SqlTaggedTemplate): void {
|
|
|
39
41
|
if (!submissionColumns.has("regenerate_message_id")) {
|
|
40
42
|
sql`ALTER TABLE pi_submissions ADD COLUMN regenerate_message_id TEXT`;
|
|
41
43
|
}
|
|
44
|
+
if (!submissionColumns.has("recovery_error_count")) {
|
|
45
|
+
sql`ALTER TABLE pi_submissions
|
|
46
|
+
ADD COLUMN recovery_error_count INTEGER NOT NULL DEFAULT 0`;
|
|
47
|
+
}
|
|
48
|
+
if (!submissionColumns.has("recovery_reason")) {
|
|
49
|
+
sql`ALTER TABLE pi_submissions ADD COLUMN recovery_reason TEXT`;
|
|
50
|
+
}
|
|
42
51
|
sql`CREATE UNIQUE INDEX IF NOT EXISTS pi_submissions_one_running
|
|
43
52
|
ON pi_submissions(status)
|
|
44
53
|
WHERE status = 'running'`;
|
|
@@ -9,6 +9,9 @@ export type SubmissionStatus =
|
|
|
9
9
|
| "aborted"
|
|
10
10
|
| "skipped"
|
|
11
11
|
| "error";
|
|
12
|
+
export type SubmissionRecoveryReason =
|
|
13
|
+
| "no_meaningful_model_progress"
|
|
14
|
+
| "transient_model_error";
|
|
12
15
|
|
|
13
16
|
const TERMINAL_SUBMISSION_STATUSES: ReadonlySet<SubmissionStatus> = new Set([
|
|
14
17
|
"completed",
|
|
@@ -43,6 +46,8 @@ export interface StoredSubmission {
|
|
|
43
46
|
queuedUiMessageJson: string | null;
|
|
44
47
|
userMessageId: string | null;
|
|
45
48
|
regenerateMessageId: string | null;
|
|
49
|
+
recoveryErrorCount: number;
|
|
50
|
+
recoveryReason: SubmissionRecoveryReason | null;
|
|
46
51
|
}
|
|
47
52
|
|
|
48
53
|
export interface NewSubmission {
|
|
@@ -76,6 +81,8 @@ type SubmissionRow = {
|
|
|
76
81
|
queued_ui_message_json: string | null;
|
|
77
82
|
user_message_id: string | null;
|
|
78
83
|
regenerate_message_id: string | null;
|
|
84
|
+
recovery_error_count: number;
|
|
85
|
+
recovery_reason: string | null;
|
|
79
86
|
};
|
|
80
87
|
|
|
81
88
|
// #endregion
|
|
@@ -101,6 +108,8 @@ function mapRow(row: SubmissionRow): StoredSubmission {
|
|
|
101
108
|
queuedUiMessageJson: row.queued_ui_message_json,
|
|
102
109
|
userMessageId: row.user_message_id,
|
|
103
110
|
regenerateMessageId: row.regenerate_message_id,
|
|
111
|
+
recoveryErrorCount: row.recovery_error_count,
|
|
112
|
+
recoveryReason: row.recovery_reason as SubmissionRecoveryReason | null,
|
|
104
113
|
};
|
|
105
114
|
}
|
|
106
115
|
|
|
@@ -121,7 +130,7 @@ export class SubmissionRepository {
|
|
|
121
130
|
error, created_at, completed_at, assembly_revision,
|
|
122
131
|
assembly_descriptor, assistant_message_id, abort_reason,
|
|
123
132
|
queued_input_json, queued_ui_message_json, user_message_id,
|
|
124
|
-
regenerate_message_id
|
|
133
|
+
regenerate_message_id, recovery_error_count, recovery_reason
|
|
125
134
|
FROM pi_submissions
|
|
126
135
|
WHERE submission_id = ${id}
|
|
127
136
|
`[0];
|
|
@@ -309,6 +318,30 @@ export class SubmissionRepository {
|
|
|
309
318
|
`;
|
|
310
319
|
}
|
|
311
320
|
|
|
321
|
+
// chatRecovery 遇到 partial 会重置 attempt;Submission 计数保证同一 Turn 的可恢复模型错误仍绝对封顶。
|
|
322
|
+
incrementRecoveryErrorCount(
|
|
323
|
+
id: string,
|
|
324
|
+
reason: SubmissionRecoveryReason,
|
|
325
|
+
): number {
|
|
326
|
+
return this.sql<{ recovery_error_count: number }>`
|
|
327
|
+
UPDATE pi_submissions
|
|
328
|
+
SET recovery_error_count = recovery_error_count + 1,
|
|
329
|
+
recovery_reason = ${reason}
|
|
330
|
+
WHERE submission_id = ${id}
|
|
331
|
+
AND status IN ('pending', 'running')
|
|
332
|
+
RETURNING recovery_error_count
|
|
333
|
+
`[0]?.recovery_error_count ?? 0;
|
|
334
|
+
}
|
|
335
|
+
|
|
336
|
+
clearRecoveryReason(id: string): void {
|
|
337
|
+
this.sql`
|
|
338
|
+
UPDATE pi_submissions
|
|
339
|
+
SET recovery_reason = NULL
|
|
340
|
+
WHERE submission_id = ${id}
|
|
341
|
+
AND status IN ('pending', 'running')
|
|
342
|
+
`;
|
|
343
|
+
}
|
|
344
|
+
|
|
312
345
|
// 返回所有标准会话消息到 Submission 的关联映射。
|
|
313
346
|
// Transcript.storedMessages 读取 Pi Session 分支时调用,它用 Map 为每条消息补回 submissionId。
|
|
314
347
|
// 关联表由 Pi Session 存储拥有,本方法只读不写;不能在 RuntimeDatabase.clearAll 中单独删它而破坏 Transcript 的所有权。
|
package/src/index.ts
CHANGED
package/src/kernel/bindings.ts
CHANGED
|
@@ -294,9 +294,7 @@ export interface RuntimeMemoryPort {
|
|
|
294
294
|
* 按标签覆盖一块热记忆。
|
|
295
295
|
*
|
|
296
296
|
* @remarks
|
|
297
|
-
* Host
|
|
298
|
-
*
|
|
299
|
-
* TODO(待确认): 确认后续写入方是否仍需通过这个公开 Port 提交热记忆。
|
|
297
|
+
* `set_context` 在校验标签和 token 预算后调用,Host 负责按当前 Agent 与会话持久化。
|
|
300
298
|
*/
|
|
301
299
|
set(label: string, content: string): Promise<void>;
|
|
302
300
|
}
|
|
@@ -73,15 +73,15 @@ export type RuntimeRecoveryDecision<Data> =
|
|
|
73
73
|
| { readonly kind: "ignore"; readonly reason: string };
|
|
74
74
|
|
|
75
75
|
/**
|
|
76
|
-
*
|
|
76
|
+
* 记录一次已执行恢复的结果,或表明该恢复已接力给下一次持久调度。
|
|
77
77
|
*
|
|
78
78
|
* @remarks
|
|
79
|
-
* `_chatRecoveryRetry` 在调用端口的 `retry`
|
|
79
|
+
* `_chatRecoveryRetry` 在调用端口的 `retry` 后读取该结果;终态关闭事故,`scheduled` 保持事故活跃。
|
|
80
80
|
*
|
|
81
81
|
* 只有失败时才需要 `error`;结果不承载流内容,避免在调度数据与流缓冲区之间复制状态。
|
|
82
82
|
*/
|
|
83
83
|
export interface RuntimeRecoveryResult {
|
|
84
|
-
readonly status: "completed" | "failed";
|
|
84
|
+
readonly status: "completed" | "scheduled" | "failed";
|
|
85
85
|
readonly error?: string;
|
|
86
86
|
}
|
|
87
87
|
|
|
@@ -103,7 +103,7 @@ export interface RuntimeRecoveryPort<Data> {
|
|
|
103
103
|
/**
|
|
104
104
|
* 使用已分类的业务数据重放一次对话。
|
|
105
105
|
*
|
|
106
|
-
* @remarks
|
|
106
|
+
* @remarks 持久调度回调会调用它;实现应返回权威终态,或明确说明下一次恢复已经持久调度。
|
|
107
107
|
*/
|
|
108
108
|
retry(data: Data): Promise<RuntimeRecoveryResult>;
|
|
109
109
|
/**
|
|
@@ -544,6 +544,7 @@ export abstract class RecoverableChatAgent<
|
|
|
544
544
|
this.activeRecoveryRootRequestId = data.requestId;
|
|
545
545
|
try {
|
|
546
546
|
const result = await this.recoveryPort().retry(data);
|
|
547
|
+
if (result.status === "scheduled") return;
|
|
547
548
|
await this.engine().updateIncident(
|
|
548
549
|
data.incidentId,
|
|
549
550
|
result.status === "completed" ? "completed" : "failed",
|
|
@@ -563,6 +564,83 @@ export abstract class RecoverableChatAgent<
|
|
|
563
564
|
}
|
|
564
565
|
}
|
|
565
566
|
|
|
567
|
+
/**
|
|
568
|
+
* 把活跃进程内检测到的模型流停滞交给同一套持久恢复预算。
|
|
569
|
+
*
|
|
570
|
+
* @remarks
|
|
571
|
+
* 子类在确认当前 Turn 可以从 durable checkpoint 重放后调用;`beforeSchedule`
|
|
572
|
+
* 必须先结束旧流并恢复权威消息快照,随后引擎才广播 recovering 状态。
|
|
573
|
+
*/
|
|
574
|
+
protected async scheduleChatRecoveryRetry(
|
|
575
|
+
data: RecoveryData,
|
|
576
|
+
beforeSchedule: () => void | Promise<void>,
|
|
577
|
+
): Promise<"disabled" | "scheduled" | "exhausted"> {
|
|
578
|
+
if (!resolveChatRecoveryConfig(this.chatRecovery).enabled) {
|
|
579
|
+
return "disabled";
|
|
580
|
+
}
|
|
581
|
+
const recoveryRootRequestId =
|
|
582
|
+
this.activeRecoveryRootRequestId ?? data.requestId;
|
|
583
|
+
const { incident, exhausted } = await this.engine().beginIncident({
|
|
584
|
+
requestId: data.requestId,
|
|
585
|
+
recoveryRootRequestId,
|
|
586
|
+
recoveryKind: "retry",
|
|
587
|
+
});
|
|
588
|
+
await beforeSchedule();
|
|
589
|
+
if (exhausted) {
|
|
590
|
+
await this.engine().exhaustRecoveryGiveUp({
|
|
591
|
+
callback: "_chatRecoveryRetry",
|
|
592
|
+
data: {
|
|
593
|
+
incidentId: incident.incidentId,
|
|
594
|
+
originalRequestId: recoveryRootRequestId,
|
|
595
|
+
},
|
|
596
|
+
reason: incident.reason ?? "max_attempts_exceeded",
|
|
597
|
+
});
|
|
598
|
+
return "exhausted";
|
|
599
|
+
}
|
|
600
|
+
await this.engine().scheduleRecovery({
|
|
601
|
+
incident,
|
|
602
|
+
recoveryKind: "retry",
|
|
603
|
+
callback: "_chatRecoveryRetry",
|
|
604
|
+
data: {
|
|
605
|
+
...data,
|
|
606
|
+
incidentId: incident.incidentId,
|
|
607
|
+
},
|
|
608
|
+
reason: this.activeRecoveryRootRequestId
|
|
609
|
+
? "stable_timeout_retry"
|
|
610
|
+
: "initial",
|
|
611
|
+
});
|
|
612
|
+
return "scheduled";
|
|
613
|
+
}
|
|
614
|
+
|
|
615
|
+
/** 终态化一个 Turn 时取消同请求的恢复调度,并关闭恢复事故与客户端状态。 */
|
|
616
|
+
protected async settleChatRecovery(
|
|
617
|
+
requestId: string,
|
|
618
|
+
status: "completed" | "skipped" | "failed",
|
|
619
|
+
reason?: string,
|
|
620
|
+
): Promise<void> {
|
|
621
|
+
const incidentIds = new Set<string>();
|
|
622
|
+
try {
|
|
623
|
+
for (const schedule of await this.listSchedules()) {
|
|
624
|
+
if (schedule.callback !== "_chatRecoveryRetry") continue;
|
|
625
|
+
const payload = schedule.payload;
|
|
626
|
+
if (!payload || typeof payload !== "object") continue;
|
|
627
|
+
const recovery = payload as Partial<
|
|
628
|
+
RecoveryScheduleData<RecoveryData>
|
|
629
|
+
>;
|
|
630
|
+
if (recovery.requestId !== requestId) continue;
|
|
631
|
+
if (typeof recovery.incidentId === "string") {
|
|
632
|
+
incidentIds.add(recovery.incidentId);
|
|
633
|
+
}
|
|
634
|
+
await this.cancelSchedule(schedule.id);
|
|
635
|
+
}
|
|
636
|
+
for (const incidentId of incidentIds) {
|
|
637
|
+
await this.engine().updateIncident(incidentId, status, reason);
|
|
638
|
+
}
|
|
639
|
+
} finally {
|
|
640
|
+
await this.setRecovering(false, requestId);
|
|
641
|
+
}
|
|
642
|
+
}
|
|
643
|
+
|
|
566
644
|
/**
|
|
567
645
|
* 把 Agents SDK 发现的孤立 Fiber 交给聊天恢复引擎。
|
|
568
646
|
*
|
package/src/kernel/state.ts
CHANGED
|
@@ -51,6 +51,10 @@ export interface RuntimeQueuedSubmission {
|
|
|
51
51
|
|
|
52
52
|
export interface RuntimeTurnState {
|
|
53
53
|
activeSubmissionId?: string;
|
|
54
|
+
activeRequestId?: string;
|
|
55
|
+
recoveryAttempt?: number;
|
|
56
|
+
recoveryMax?: number;
|
|
57
|
+
recoveryReason?: "no_meaningful_model_progress" | "transient_model_error";
|
|
54
58
|
steerable: boolean;
|
|
55
59
|
hasPendingSteer: boolean;
|
|
56
60
|
queued: RuntimeQueuedSubmission[];
|
|
@@ -587,13 +587,14 @@ export class SubmissionLifecycle<
|
|
|
587
587
|
return submission ? [submission.submissionId] : [];
|
|
588
588
|
})()
|
|
589
589
|
: (() => {
|
|
590
|
-
const submission = this.options.store.findRunning()
|
|
590
|
+
const submission = this.options.store.findRunning() ??
|
|
591
|
+
this.options.store.findNextPending();
|
|
591
592
|
return submission ? [submission.submissionId] : [];
|
|
592
593
|
})();
|
|
593
594
|
for (const submissionId of submissionIds) {
|
|
594
595
|
await this.cancel(submissionId, reason);
|
|
595
596
|
}
|
|
596
|
-
return { ok:
|
|
597
|
+
return { ok: submissionIds.length > 0 };
|
|
597
598
|
}
|
|
598
599
|
|
|
599
600
|
// #endregion
|
package/src/lib/prompt.ts
CHANGED
|
@@ -46,7 +46,7 @@ export const BEHAVIOR =
|
|
|
46
46
|
export const PLANNING =
|
|
47
47
|
"Planning: For work with 3 or more distinct steps, or any non-trivial / multi-file change, call " +
|
|
48
48
|
"update_plan with the complete plan (it fully replaces the previous one) so the user sees live " +
|
|
49
|
-
"progress. Mark a step in_progress before starting it and
|
|
49
|
+
"progress. Mark a step in_progress before starting it and done the moment it's done; keep only " +
|
|
50
50
|
"one step in_progress at a time and don't batch completions. Do not make a plan for a single trivial " +
|
|
51
51
|
"step or a purely conversational reply — just do it.";
|
|
52
52
|
|
package/src/lib/telemetry-dev.ts
CHANGED
|
@@ -1,9 +1,12 @@
|
|
|
1
1
|
/**
|
|
2
|
-
*
|
|
3
|
-
*
|
|
2
|
+
* 观测降级:把 observability 事件打到 console,让 `wrangler dev` / `wrangler tail` 里能实时看到埋点,
|
|
3
|
+
* 并且(生产上开了 `observability.logs` 时)进 Workers Logs 供事后查询。
|
|
4
|
+
* 不装它,事件就 publish 到零订阅 channel = 静默 no-op。
|
|
4
5
|
*
|
|
5
|
-
*
|
|
6
|
-
*
|
|
6
|
+
* 由 onStart 在 env `TELEMETRY_CONSOLE==="1"` 时装载 —— `.dev.vars` 和 `wrangler.jsonc` 的 vars 里都开着。
|
|
7
|
+
* 注意:上游注释宣称"生产上所有 channel 事件自动转发 Tail Worker",那条路径需要 `tail_consumers`,
|
|
8
|
+
* 本仓没有配,所以生产**不能**指望它;这个 console sink 就是生产的唯一消费面(2026-08-06 实测:
|
|
9
|
+
* 未开启前 Observability API 查 universal-agent 24h 事件 count=0)。
|
|
7
10
|
*
|
|
8
11
|
* 用 `agents/observability` 的类型化 `subscribe`(按 channel key 订阅),避免引 `node:diagnostics_channel`
|
|
9
12
|
* 与 `@types/node`(tsconfig 只带 workers-types)。
|
|
@@ -3,6 +3,7 @@ import type {
|
|
|
3
3
|
ThinkingLevel,
|
|
4
4
|
} from "@earendil-works/pi-agent-core";
|
|
5
5
|
import type { Api, Model } from "@earendil-works/pi-ai";
|
|
6
|
+
import { clampReasoning } from "@earendil-works/pi-ai/api/simple-options";
|
|
6
7
|
import type {
|
|
7
8
|
RuntimeMcpServer,
|
|
8
9
|
RuntimeProfile,
|
|
@@ -79,11 +80,13 @@ function freezeModel(model: Model<Api>): Model<Api> {
|
|
|
79
80
|
|
|
80
81
|
// 作用:把 Runtime 的思考强度名称转成 Pi Agent Core 接受的名称。
|
|
81
82
|
// 调用:创建 Runtime 装配快照时对 profile 中的 `thinking` 调用。
|
|
82
|
-
//
|
|
83
|
+
// 原因:`none` 映射成 `off`;xhigh/max 用 clampReasoning 夹到 API 真正支持的最高档,
|
|
84
|
+
// 避免超出范围的等级在 provider 侧静默失败。
|
|
83
85
|
function toPiThinkingLevel(
|
|
84
86
|
thinking: ThinkingEffort,
|
|
85
87
|
): ThinkingLevel {
|
|
86
|
-
|
|
88
|
+
if (thinking === "none") return "off";
|
|
89
|
+
return clampReasoning(thinking) ?? "off";
|
|
87
90
|
}
|
|
88
91
|
|
|
89
92
|
// 作用:复制并冻结一个 Extension 配置的 manifest 和权限集合。
|
|
@@ -160,7 +160,7 @@ export interface PinnedPiRuntime {
|
|
|
160
160
|
* @remarks
|
|
161
161
|
* `PreparedPiTurnAdapter` 在重建 Turn 前读取这个 JSON 结构。
|
|
162
162
|
*
|
|
163
|
-
*
|
|
163
|
+
* 基础版本作为准入时元数据保留,创建 Turn 时不与当前 Runtime 比较。
|
|
164
164
|
*/
|
|
165
165
|
export interface PinnedPiRuntimeDescriptor {
|
|
166
166
|
readonly version: 1;
|
|
@@ -200,6 +200,8 @@ const IDEMPOTENT_TOOL_NAMES = new Set([
|
|
|
200
200
|
"browser_markdown",
|
|
201
201
|
"browser_scrape",
|
|
202
202
|
"bind_resource",
|
|
203
|
+
"delete",
|
|
204
|
+
"edit",
|
|
203
205
|
"find",
|
|
204
206
|
"get_time",
|
|
205
207
|
"grep",
|
|
@@ -211,6 +213,7 @@ const IDEMPOTENT_TOOL_NAMES = new Set([
|
|
|
211
213
|
"read_skill_resource",
|
|
212
214
|
"sandbox_process_logs",
|
|
213
215
|
"web_search",
|
|
216
|
+
"write",
|
|
214
217
|
"unbind_resource",
|
|
215
218
|
]);
|
|
216
219
|
|
|
@@ -251,7 +254,7 @@ function describeTools(candidates: readonly PiToolCandidate[]) {
|
|
|
251
254
|
|
|
252
255
|
// 把本模块认定会影响版本的准备结果序列化出来。
|
|
253
256
|
// preparePiRuntime 创建一次,Runtime 接受快照前再对结果求哈希。
|
|
254
|
-
//
|
|
257
|
+
// 集合排序保证等价输入稳定;增删字段会改变版本哈希。
|
|
255
258
|
function describeRuntime(
|
|
256
259
|
snapshot: RuntimeSnapshot,
|
|
257
260
|
candidates: readonly PiToolCandidate[],
|
|
@@ -334,18 +337,17 @@ export function readPreparedPiRuntime(
|
|
|
334
337
|
}
|
|
335
338
|
|
|
336
339
|
/**
|
|
337
|
-
*
|
|
340
|
+
* 读取已存的固定描述。
|
|
338
341
|
*
|
|
339
342
|
* @remarks
|
|
340
343
|
* `PreparedPiTurnAdapter` 在新建或恢复 Pi Turn 前调用它。
|
|
341
344
|
*
|
|
342
|
-
*
|
|
345
|
+
* Prepared Runtime 提供当前执行能力,descriptor 仅保留 Submission 固定的 Turn 字段。
|
|
343
346
|
*/
|
|
344
347
|
export function readPinnedPiRuntime(
|
|
345
348
|
prepared: PreparedPiRuntime,
|
|
346
349
|
owner: object,
|
|
347
350
|
descriptorBody: string,
|
|
348
|
-
baseRevision: string,
|
|
349
351
|
): {
|
|
350
352
|
readonly state: PreparedPiRuntimeState;
|
|
351
353
|
readonly descriptor: PinnedPiRuntimeDescriptor;
|
|
@@ -361,24 +363,11 @@ export function readPinnedPiRuntime(
|
|
|
361
363
|
throw new Error("Pinned Runtime assembly descriptor is invalid");
|
|
362
364
|
}
|
|
363
365
|
const descriptor = value as PinnedPiRuntimeDescriptor;
|
|
364
|
-
const expected = JSON.parse(prepared.revisionDescriptor) as Record<
|
|
365
|
-
string,
|
|
366
|
-
unknown
|
|
367
|
-
>;
|
|
368
|
-
const actual = descriptor as unknown as Record<string, unknown>;
|
|
369
|
-
const incompatible = Object.keys(expected).some((key) =>
|
|
370
|
-
key !== "systemPrompt" &&
|
|
371
|
-
JSON.stringify(actual[key]) !== JSON.stringify(expected[key])
|
|
372
|
-
);
|
|
373
366
|
if (
|
|
374
367
|
descriptor.version !== 1 ||
|
|
375
|
-
descriptor.
|
|
376
|
-
typeof descriptor.systemPrompt !== "string" ||
|
|
377
|
-
incompatible
|
|
368
|
+
typeof descriptor.systemPrompt !== "string"
|
|
378
369
|
) {
|
|
379
|
-
throw new Error(
|
|
380
|
-
"Pinned Runtime revision is unavailable for Pi recovery",
|
|
381
|
-
);
|
|
370
|
+
throw new Error("Pinned Runtime assembly descriptor is invalid");
|
|
382
371
|
}
|
|
383
372
|
return { state, descriptor };
|
|
384
373
|
}
|
|
@@ -9,17 +9,21 @@ import {
|
|
|
9
9
|
import type {
|
|
10
10
|
Api,
|
|
11
11
|
AssistantMessage,
|
|
12
|
+
Message,
|
|
12
13
|
Model,
|
|
13
14
|
Models,
|
|
14
15
|
ToolResultMessage,
|
|
15
16
|
UserMessage,
|
|
16
17
|
} from "@earendil-works/pi-ai";
|
|
18
|
+
import { transformMessages } from "@earendil-works/pi-ai/api/transform-messages";
|
|
17
19
|
import { requiresPiToolApproval, parkPiToolApproval } from "../turn";
|
|
18
20
|
import { PiChunkEncoder } from "../message";
|
|
19
21
|
import type { UIMessageChunk } from "ai";
|
|
22
|
+
import { ChatStreamStalledError } from "agents/chat";
|
|
20
23
|
import {
|
|
21
24
|
compilePiTools,
|
|
22
25
|
createPiToolGovernance,
|
|
26
|
+
normalizeUpdatePlanArguments,
|
|
23
27
|
type PiToolCandidate,
|
|
24
28
|
type PiToolGovernance,
|
|
25
29
|
type PiToolTelemetry,
|
|
@@ -30,12 +34,28 @@ import {
|
|
|
30
34
|
readPinnedPiRuntime,
|
|
31
35
|
type PreparedPiRuntime,
|
|
32
36
|
} from "./assembly";
|
|
33
|
-
import {
|
|
37
|
+
import {
|
|
38
|
+
isRecoverableAssistantError,
|
|
39
|
+
isModelStreamStallMessage,
|
|
40
|
+
resolvePiApiKey,
|
|
41
|
+
withProviderRetry,
|
|
42
|
+
} from "./models";
|
|
34
43
|
|
|
35
44
|
// #region Single-run Pi bridge
|
|
36
45
|
|
|
37
46
|
const MAX_MODEL_TURNS = 30;
|
|
38
47
|
|
|
48
|
+
export class RetryableModelError extends Error {}
|
|
49
|
+
|
|
50
|
+
function normalizePlanToolCalls(message: AgentMessage): void {
|
|
51
|
+
if (message.role !== "assistant") return;
|
|
52
|
+
for (const part of message.content) {
|
|
53
|
+
if (part.type === "toolCall" && part.name === "update_plan") {
|
|
54
|
+
part.arguments = normalizeUpdatePlanArguments(part.arguments);
|
|
55
|
+
}
|
|
56
|
+
}
|
|
57
|
+
}
|
|
58
|
+
|
|
39
59
|
/**
|
|
40
60
|
* 把 Pi 的终止原因翻译成本仓的回合结局。
|
|
41
61
|
*
|
|
@@ -72,6 +92,16 @@ function classifyPiStopReason(stopReason: string | undefined): {
|
|
|
72
92
|
}
|
|
73
93
|
}
|
|
74
94
|
|
|
95
|
+
function visibleAssistantContent(message: AssistantMessage) {
|
|
96
|
+
return message.content.filter((part) =>
|
|
97
|
+
part.type === "text"
|
|
98
|
+
? part.text.trim()
|
|
99
|
+
: part.type === "thinking"
|
|
100
|
+
? part.thinking.trim()
|
|
101
|
+
: false
|
|
102
|
+
);
|
|
103
|
+
}
|
|
104
|
+
|
|
75
105
|
interface PiTurnAdapterOptions {
|
|
76
106
|
readonly pi: {
|
|
77
107
|
readonly model: Model<Api>;
|
|
@@ -79,6 +109,7 @@ interface PiTurnAdapterOptions {
|
|
|
79
109
|
};
|
|
80
110
|
readonly models: Models;
|
|
81
111
|
readonly apiKey: string;
|
|
112
|
+
readonly modelSessionId: string;
|
|
82
113
|
// 读取这次执行真正要交给 Pi 的 canonical transcript。
|
|
83
114
|
// PiTurnAdapter.run 在创建 PiCore 前调用它,宿主应返回当时最新的 transcript。
|
|
84
115
|
// 这里保留为延迟读取,是为了避免适配器创建后新增的恢复消息被旧快照漏掉。
|
|
@@ -129,10 +160,12 @@ class PiTurnAdapter {
|
|
|
129
160
|
candidate: PiToolCandidate,
|
|
130
161
|
toolCallId: string,
|
|
131
162
|
input: unknown,
|
|
163
|
+
signal?: AbortSignal,
|
|
132
164
|
): Promise<boolean> {
|
|
133
165
|
const [tool] = this.compile([candidate]);
|
|
134
166
|
if (!tool) return false;
|
|
135
|
-
|
|
167
|
+
signal?.throwIfAborted();
|
|
168
|
+
await tool.execute(toolCallId, input, signal);
|
|
136
169
|
return true;
|
|
137
170
|
}
|
|
138
171
|
|
|
@@ -173,9 +206,17 @@ class PiTurnAdapter {
|
|
|
173
206
|
convertToLlm,
|
|
174
207
|
streamFn: withProviderRetry(
|
|
175
208
|
this.opts.models.streamSimple.bind(this.opts.models),
|
|
209
|
+
0,
|
|
210
|
+
this.opts.modelSessionId,
|
|
176
211
|
),
|
|
177
212
|
getApiKey: () => this.opts.apiKey,
|
|
178
|
-
|
|
213
|
+
// transformMessages 在宿主上下文变换之后运行,确保跨 provider 的 tool call ID 格式兼容。
|
|
214
|
+
// OpenAI Responses API 生成含 `|` 的 450+ 字符 ID,Anthropic 只接受 ^[a-zA-Z0-9_-]+$(64 字符上限);
|
|
215
|
+
// 切换 provider 或消息跨 provider 回放时若不规范化,provider 会静默拒绝。
|
|
216
|
+
transformContext: async (messages, signal) => {
|
|
217
|
+
const ctx = await this.opts.transformContext(messages, signal);
|
|
218
|
+
return transformMessages(ctx as Message[], this.opts.pi.model) as AgentMessage[];
|
|
219
|
+
},
|
|
179
220
|
afterToolCall: this.governance.afterToolCall,
|
|
180
221
|
shouldStopAfterTurn: () => {
|
|
181
222
|
reachedModelTurnLimit = ++modelTurns >= MAX_MODEL_TURNS;
|
|
@@ -295,7 +336,6 @@ export interface PiTurnDurability {
|
|
|
295
336
|
*/
|
|
296
337
|
export interface CreatePreparedPiTurnOptions {
|
|
297
338
|
readonly prepared: PreparedPiRuntime;
|
|
298
|
-
readonly baseRevision: string;
|
|
299
339
|
readonly pinnedDescriptor: string;
|
|
300
340
|
readonly submission: {
|
|
301
341
|
readonly id: string;
|
|
@@ -398,6 +438,7 @@ export interface PiPreparedTurnRunOptions {
|
|
|
398
438
|
*/
|
|
399
439
|
export class PreparedPiTurnAdapter {
|
|
400
440
|
private readonly turn: PiTurnAdapter;
|
|
441
|
+
private readonly abortController = new AbortController();
|
|
401
442
|
private readonly candidates: readonly PiToolCandidate[];
|
|
402
443
|
private assistantOrdinal: number;
|
|
403
444
|
private readonly encoder: PiChunkEncoder;
|
|
@@ -422,7 +463,6 @@ export class PreparedPiTurnAdapter {
|
|
|
422
463
|
options.prepared,
|
|
423
464
|
dependencies.owner,
|
|
424
465
|
options.pinnedDescriptor,
|
|
425
|
-
options.baseRevision,
|
|
426
466
|
);
|
|
427
467
|
this.assistantOrdinal = options.submission.assistantOrdinal;
|
|
428
468
|
this.encoder = new PiChunkEncoder({
|
|
@@ -530,6 +570,7 @@ export class PreparedPiTurnAdapter {
|
|
|
530
570
|
state.snapshot.bindings.provider,
|
|
531
571
|
state.snapshot.pi.model.id,
|
|
532
572
|
),
|
|
573
|
+
modelSessionId: options.submission.requestId,
|
|
533
574
|
canonicalMessages: options.canonicalMessages,
|
|
534
575
|
settle: options.durability.settleTool,
|
|
535
576
|
onToolTelemetry: options.onToolTelemetry,
|
|
@@ -550,6 +591,7 @@ export class PreparedPiTurnAdapter {
|
|
|
550
591
|
* 转交给 PiTurnAdapter 可保留启动前取消标记;run() 之前还没有可直接调用的 PiCore。
|
|
551
592
|
*/
|
|
552
593
|
abort(): void {
|
|
594
|
+
this.abortController.abort();
|
|
553
595
|
this.turn.abort();
|
|
554
596
|
}
|
|
555
597
|
|
|
@@ -588,6 +630,7 @@ export class PreparedPiTurnAdapter {
|
|
|
588
630
|
candidate,
|
|
589
631
|
request.toolCallId,
|
|
590
632
|
request.input,
|
|
633
|
+
this.abortController.signal,
|
|
591
634
|
)
|
|
592
635
|
: false;
|
|
593
636
|
}
|
|
@@ -626,9 +669,49 @@ export class PreparedPiTurnAdapter {
|
|
|
626
669
|
// PiTurnAdapter 会对每个 AgentEvent 调用它,PiCore 会等待该 Promise 后再越过订阅事件屏障。
|
|
627
670
|
// canonical message 先于流投影提交,恢复才不会依赖仅供浏览器消费的记录;调整顺序必须复核 recovery effect。
|
|
628
671
|
private async handleEvent(event: AgentEvent): Promise<void> {
|
|
672
|
+
if (
|
|
673
|
+
event.type === "message_start" ||
|
|
674
|
+
event.type === "message_update" ||
|
|
675
|
+
event.type === "message_end"
|
|
676
|
+
) {
|
|
677
|
+
normalizePlanToolCalls(event.message);
|
|
678
|
+
}
|
|
679
|
+
if (
|
|
680
|
+
event.type === "message_update" &&
|
|
681
|
+
event.assistantMessageEvent.type === "error" &&
|
|
682
|
+
(isModelStreamStallMessage(
|
|
683
|
+
event.assistantMessageEvent.error.errorMessage,
|
|
684
|
+
) ||
|
|
685
|
+
isRecoverableAssistantError(event.assistantMessageEvent.error))
|
|
686
|
+
) {
|
|
687
|
+
return;
|
|
688
|
+
}
|
|
629
689
|
let projectedEvent = event;
|
|
630
690
|
if (event.type === "message_end") {
|
|
631
691
|
const message = event.message;
|
|
692
|
+
if (message.role === "assistant") {
|
|
693
|
+
const stalled = message.stopReason === "error" &&
|
|
694
|
+
isModelStreamStallMessage(message.errorMessage);
|
|
695
|
+
const retryable = isRecoverableAssistantError(message);
|
|
696
|
+
if (stalled || retryable) {
|
|
697
|
+
const content = visibleAssistantContent(message);
|
|
698
|
+
if (content.length > 0) {
|
|
699
|
+
this.assistantOrdinal += 1;
|
|
700
|
+
await this.options.onCanonicalMessage({
|
|
701
|
+
kind: "commit-turn",
|
|
702
|
+
ordinal: this.assistantOrdinal,
|
|
703
|
+
message: {
|
|
704
|
+
...message,
|
|
705
|
+
content,
|
|
706
|
+
},
|
|
707
|
+
});
|
|
708
|
+
}
|
|
709
|
+
if (stalled) {
|
|
710
|
+
throw new ChatStreamStalledError(message.errorMessage!);
|
|
711
|
+
}
|
|
712
|
+
throw new RetryableModelError(message.errorMessage);
|
|
713
|
+
}
|
|
714
|
+
}
|
|
632
715
|
if (message.role === "user") {
|
|
633
716
|
await this.options.onCanonicalMessage({
|
|
634
717
|
kind: "append-user",
|
|
@@ -646,7 +729,7 @@ export class PreparedPiTurnAdapter {
|
|
|
646
729
|
abortReason
|
|
647
730
|
? {
|
|
648
731
|
...message,
|
|
649
|
-
content:
|
|
732
|
+
content: visibleAssistantContent(message),
|
|
650
733
|
stopReason: "aborted" as const,
|
|
651
734
|
errorMessage: abortReason,
|
|
652
735
|
}
|
|
@@ -2,13 +2,19 @@ import {
|
|
|
2
2
|
PreparedPiTurnAdapter,
|
|
3
3
|
type CreatePreparedPiTurnOptions,
|
|
4
4
|
} from "./execution";
|
|
5
|
+
export { RetryableModelError } from "./execution";
|
|
5
6
|
import {
|
|
6
7
|
uiUserMessageToPi,
|
|
7
8
|
} from "../message";
|
|
8
9
|
import type { UIMessage } from "ai";
|
|
9
10
|
import { createModels, type MutableModels } from "@earendil-works/pi-ai";
|
|
10
11
|
import { configurePiModels, resolvePiApiKey } from "./models";
|
|
11
|
-
export {
|
|
12
|
+
export {
|
|
13
|
+
MODEL_STREAM_STALL_TIMEOUT_MS,
|
|
14
|
+
modelRequestUrl,
|
|
15
|
+
readModelStreamStallDetails,
|
|
16
|
+
withProviderRetry,
|
|
17
|
+
} from "./models";
|
|
12
18
|
import {
|
|
13
19
|
pinPiRuntime,
|
|
14
20
|
preparePiRuntime,
|
|
@@ -148,7 +154,7 @@ export class PiRuntimeAdapter {
|
|
|
148
154
|
* @remarks
|
|
149
155
|
* 调用方:Runtime 的准入流程为每个新 Submission 调用,并把 descriptor 与其哈希一起持久化。
|
|
150
156
|
*
|
|
151
|
-
* 实现理由:memory、workspace 和 Extension context
|
|
157
|
+
* 实现理由:memory、workspace 和 Extension context 可能随时间变化,所以把准入时的 Turn 字段保存在 descriptor 中。
|
|
152
158
|
* 不要把 pin 推迟到 Turn 启动后,否则已持久化的 Submission 将失去可验证的执行基线。
|
|
153
159
|
*/
|
|
154
160
|
pin(options: PinPiRuntimeOptions): Promise<PinnedPiRuntime> {
|
|
@@ -187,7 +193,7 @@ export class PiRuntimeAdapter {
|
|
|
187
193
|
* @remarks
|
|
188
194
|
* 调用方:Runtime 在首次执行和恢复继续时各创建一个新 Turn,并提供 canonical messages、durable Tool 回调和流事件回调。
|
|
189
195
|
*
|
|
190
|
-
* 实现理由:构造时会校验 Prepared Runtime 的 owner
|
|
196
|
+
* 实现理由:构造时会校验 Prepared Runtime 的 owner 和 pinned descriptor 格式,再建立本 Turn 独享的 Tool governance 与执行状态。
|
|
191
197
|
* 不要跨 Turn 复用返回对象;abort、steer、assistant ordinal 和 Tool governance 都是单次执行状态。
|
|
192
198
|
*/
|
|
193
199
|
createTurn(
|