@springbrand/agent-runtime 0.2.0-alpha.43 → 0.2.0-alpha.45
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/subagent/definition.ts +0 -3
- package/src/adapter/cloudflare/subagent/runner.ts +19 -6
- package/src/db/schema.ts +6 -1
- package/src/db/submission.repo.ts +13 -1
- package/src/index.ts +0 -1
- package/src/kernel/submission-lifecycle.ts +25 -8
- package/src/pi/assembly/context.ts +23 -10
- package/src/pi/assembly/snapshot.ts +14 -8
- package/src/pi/message/projection.ts +47 -21
- package/src/pi/runtime-adapter/assembly.ts +2 -30
- package/src/pi/runtime-adapter/execution.ts +56 -46
- package/src/pi/runtime-adapter/index.ts +2 -8
- package/src/pi/runtime-adapter/models.ts +63 -26
- package/src/pi/runtime-adapter/transcript.ts +6 -3
- package/src/pi/tool/base.ts +10 -15
- package/src/pi/tool/compiler.ts +1 -1
- package/src/pi/tool/core.ts +2 -0
- package/src/pi/tool/schedule.ts +15 -11
- package/src/pi/tool/skill.ts +3 -0
- package/src/pi/tool/subagent.ts +2 -0
- package/src/pi/tool/time.ts +1 -0
- package/src/pi/tool/web-search/api.ts +8 -0
- package/src/pi/tool/web-search/web-search.ts +1 -0
- package/src/pi/tool/workspace-sandbox.ts +3 -0
- package/src/runtime-agent.ts +9 -0
- package/src/runtime-assembler.ts +17 -11
- package/src/runtime.ts +103 -20
package/package.json
CHANGED
|
@@ -19,7 +19,6 @@ import type {
|
|
|
19
19
|
import { PiChunkEncoder } from "../../../pi/message";
|
|
20
20
|
import {
|
|
21
21
|
createPiModels,
|
|
22
|
-
resolvePiApiKey,
|
|
23
22
|
resolvePiModel,
|
|
24
23
|
withProviderRetry,
|
|
25
24
|
} from "../../../pi/runtime-adapter/models";
|
|
@@ -398,8 +397,6 @@ export abstract class CloudflareSubAgent<
|
|
|
398
397
|
input,
|
|
399
398
|
model,
|
|
400
399
|
streamFn: withProviderRetry(models.streamSimple.bind(models)),
|
|
401
|
-
getApiKey: () =>
|
|
402
|
-
resolvePiApiKey(provider, provider.defaultModel),
|
|
403
400
|
tools,
|
|
404
401
|
signal,
|
|
405
402
|
onEvent: (event) => {
|
|
@@ -12,6 +12,7 @@ import type {
|
|
|
12
12
|
import { z } from "zod";
|
|
13
13
|
import { assembleSubagentPrompt } from "../../../lib/prompt";
|
|
14
14
|
import type { AgentType } from "../../../layers/orchestration/subagents/agent-types/contract";
|
|
15
|
+
import { structuredOutputStream } from "../../../pi/runtime-adapter/models";
|
|
15
16
|
|
|
16
17
|
const RECURSIVE_TOOLS = new Set([
|
|
17
18
|
"run_agent",
|
|
@@ -28,7 +29,6 @@ export interface CloudflareSubAgentRun {
|
|
|
28
29
|
model: Model<Api>;
|
|
29
30
|
streamFn: StreamFn;
|
|
30
31
|
tools: AgentTool[];
|
|
31
|
-
getApiKey?: (provider: string) => string | undefined;
|
|
32
32
|
signal?: AbortSignal;
|
|
33
33
|
timeoutMs?: number;
|
|
34
34
|
onEvent?: (event: AgentEvent, signal: AbortSignal) => void | Promise<void>;
|
|
@@ -67,11 +67,16 @@ function toolErrorText(result: unknown): string {
|
|
|
67
67
|
return "tool execution failed";
|
|
68
68
|
}
|
|
69
69
|
|
|
70
|
-
function taskPrompt(
|
|
70
|
+
function taskPrompt(
|
|
71
|
+
input: unknown,
|
|
72
|
+
outputSchema?: Record<string, unknown>,
|
|
73
|
+
): string {
|
|
71
74
|
return [
|
|
72
75
|
"Complete this bounded task.",
|
|
73
76
|
`Input:\n${JSON.stringify(input)}`,
|
|
74
|
-
|
|
77
|
+
outputSchema
|
|
78
|
+
? `Return only one JSON object matching this schema:\n${JSON.stringify(outputSchema)}`
|
|
79
|
+
: "Return only one JSON object matching the configured output schema.",
|
|
75
80
|
].join("\n\n");
|
|
76
81
|
}
|
|
77
82
|
|
|
@@ -90,13 +95,18 @@ export async function runCloudflareSubAgent(
|
|
|
90
95
|
}
|
|
91
96
|
}
|
|
92
97
|
if (run.signal?.aborted) throw new Error("SubAgent run aborted");
|
|
98
|
+
const { $schema: _, ...outputSchema } = z.toJSONSchema(run.type.outputSchema);
|
|
99
|
+
const constrainedStream = structuredOutputStream(
|
|
100
|
+
run.streamFn,
|
|
101
|
+
run.model,
|
|
102
|
+
outputSchema,
|
|
103
|
+
);
|
|
93
104
|
|
|
94
105
|
// 这里没有审批闸,也装不了:到这一步工具已经被 compilePiTools 编译成 AgentTool,
|
|
95
106
|
// requiredExecutionLevel 只挂在编译前的 PiToolCandidate 上,这里读不到。「子 agent 不许挂需审批的工具」
|
|
96
107
|
// 这条不变量由装配处 createCloudflareSubAgentTools 的启动期断言强制执行。
|
|
97
108
|
const agent = new Agent({
|
|
98
|
-
streamFn: run.streamFn,
|
|
99
|
-
getApiKey: run.getApiKey,
|
|
109
|
+
streamFn: constrainedStream ?? run.streamFn,
|
|
100
110
|
initialState: {
|
|
101
111
|
model: run.model,
|
|
102
112
|
systemPrompt: assembleSubagentPrompt(run.type.persona),
|
|
@@ -125,7 +135,10 @@ export async function runCloudflareSubAgent(
|
|
|
125
135
|
}, run.timeoutMs ?? DEFAULT_TIMEOUT_MS);
|
|
126
136
|
|
|
127
137
|
try {
|
|
128
|
-
await agent.prompt(taskPrompt(
|
|
138
|
+
await agent.prompt(taskPrompt(
|
|
139
|
+
input.data,
|
|
140
|
+
constrainedStream ? undefined : outputSchema,
|
|
141
|
+
));
|
|
129
142
|
} finally {
|
|
130
143
|
clearTimeout(timeout);
|
|
131
144
|
run.signal?.removeEventListener("abort", abort);
|
package/src/db/schema.ts
CHANGED
|
@@ -25,7 +25,8 @@ export function initializeSchema(sql: SqlTaggedTemplate): void {
|
|
|
25
25
|
assistant_message_id TEXT NOT NULL,
|
|
26
26
|
abort_reason TEXT,
|
|
27
27
|
recovery_error_count INTEGER NOT NULL DEFAULT 0,
|
|
28
|
-
recovery_reason TEXT
|
|
28
|
+
recovery_reason TEXT,
|
|
29
|
+
context_overflow_retried INTEGER NOT NULL DEFAULT 0
|
|
29
30
|
)`;
|
|
30
31
|
const submissionColumns = new Set(
|
|
31
32
|
sql<{ name: string }>`PRAGMA table_info(pi_submissions)`
|
|
@@ -77,6 +78,10 @@ export function initializeSchema(sql: SqlTaggedTemplate): void {
|
|
|
77
78
|
if (!submissionColumns.has("recovery_reason")) {
|
|
78
79
|
sql`ALTER TABLE pi_submissions ADD COLUMN recovery_reason TEXT`;
|
|
79
80
|
}
|
|
81
|
+
if (!submissionColumns.has("context_overflow_retried")) {
|
|
82
|
+
sql`ALTER TABLE pi_submissions
|
|
83
|
+
ADD COLUMN context_overflow_retried INTEGER NOT NULL DEFAULT 0`;
|
|
84
|
+
}
|
|
80
85
|
sql`CREATE UNIQUE INDEX IF NOT EXISTS pi_submissions_one_running
|
|
81
86
|
ON pi_submissions(status)
|
|
82
87
|
WHERE status = 'running'`;
|
|
@@ -11,7 +11,8 @@ export type SubmissionStatus =
|
|
|
11
11
|
| "error";
|
|
12
12
|
export type SubmissionRecoveryReason =
|
|
13
13
|
| "no_meaningful_model_progress"
|
|
14
|
-
| "transient_model_error"
|
|
14
|
+
| "transient_model_error"
|
|
15
|
+
| "context_overflow";
|
|
15
16
|
|
|
16
17
|
const TERMINAL_SUBMISSION_STATUSES: ReadonlySet<SubmissionStatus> = new Set([
|
|
17
18
|
"completed",
|
|
@@ -422,6 +423,17 @@ export class SubmissionRepository {
|
|
|
422
423
|
`[0]?.recovery_error_count ?? 0;
|
|
423
424
|
}
|
|
424
425
|
|
|
426
|
+
claimContextOverflowRecovery(id: string): boolean {
|
|
427
|
+
return Boolean(this.sql<{ claimed: number }>`
|
|
428
|
+
UPDATE pi_submissions
|
|
429
|
+
SET context_overflow_retried = 1
|
|
430
|
+
WHERE submission_id = ${id}
|
|
431
|
+
AND status IN ('pending', 'running')
|
|
432
|
+
AND context_overflow_retried = 0
|
|
433
|
+
RETURNING 1 AS claimed
|
|
434
|
+
`[0]?.claimed);
|
|
435
|
+
}
|
|
436
|
+
|
|
425
437
|
/**
|
|
426
438
|
* 记录一个让步执行片的累计模型回合数和续跑标识。
|
|
427
439
|
*
|
package/src/index.ts
CHANGED
|
@@ -92,14 +92,7 @@ export interface SubmissionStore<TSubmission extends SubmissionRecord> {
|
|
|
92
92
|
countPending(): number;
|
|
93
93
|
findRunning(): TSubmission | null;
|
|
94
94
|
findNextPending(): TSubmission | null;
|
|
95
|
-
|
|
96
|
-
* 列出全部等待或运行中的提交标识。
|
|
97
|
-
*
|
|
98
|
-
* @remarks
|
|
99
|
-
* `stop` 没有指定请求时调用,并逐条走统一取消路径。
|
|
100
|
-
*
|
|
101
|
-
* 只返回标识可以避免停止入口复制完整记录或状态判断。
|
|
102
|
-
*/
|
|
95
|
+
listPending(): TSubmission[];
|
|
103
96
|
/**
|
|
104
97
|
* 持久化一条取消原因。
|
|
105
98
|
*
|
|
@@ -694,5 +687,29 @@ export class SubmissionLifecycle<
|
|
|
694
687
|
return { ok: submissionIds.length > 0 };
|
|
695
688
|
}
|
|
696
689
|
|
|
690
|
+
/** Stops every running or queued Submission without pumping between them. */
|
|
691
|
+
async stopAll(reason = "Stopped"): Promise<{ ok: boolean }> {
|
|
692
|
+
const running = this.options.store.findRunning();
|
|
693
|
+
const submissions = [
|
|
694
|
+
...(running ? [running] : []),
|
|
695
|
+
...this.options.store.listPending(),
|
|
696
|
+
];
|
|
697
|
+
if (submissions.length === 0) return { ok: false };
|
|
698
|
+
|
|
699
|
+
this.options.store.transaction(() => {
|
|
700
|
+
for (const submission of submissions) {
|
|
701
|
+
this.options.store.updateAbortReason(submission.submissionId, reason);
|
|
702
|
+
this.options.appendAbortIntent(submission, reason);
|
|
703
|
+
}
|
|
704
|
+
});
|
|
705
|
+
for (const submission of submissions) {
|
|
706
|
+
const active = this.activeBySubmission.get(submission.submissionId);
|
|
707
|
+
if (active) this.options.abortActive(active);
|
|
708
|
+
await this.finish(submission, "aborted", reason);
|
|
709
|
+
}
|
|
710
|
+
this.pump();
|
|
711
|
+
return { ok: true };
|
|
712
|
+
}
|
|
713
|
+
|
|
697
714
|
// #endregion
|
|
698
715
|
}
|
|
@@ -3,6 +3,7 @@ import {
|
|
|
3
3
|
compact as compactPiTranscript,
|
|
4
4
|
DEFAULT_COMPACTION_SETTINGS,
|
|
5
5
|
estimateContextTokens,
|
|
6
|
+
estimateTokens,
|
|
6
7
|
prepareCompaction,
|
|
7
8
|
type AgentMessage,
|
|
8
9
|
type CompactionEntry,
|
|
@@ -86,9 +87,9 @@ function sumUsage(responses: readonly AssistantMessage[]): Usage | undefined {
|
|
|
86
87
|
export async function compactPiContext(input: Readonly<{
|
|
87
88
|
branch: readonly SessionTreeEntry[];
|
|
88
89
|
compactAfterTokens: number;
|
|
90
|
+
force?: boolean;
|
|
89
91
|
models: Models;
|
|
90
92
|
model: Model<any>;
|
|
91
|
-
apiKey?: string;
|
|
92
93
|
signal?: AbortSignal;
|
|
93
94
|
}>): Promise<{
|
|
94
95
|
messages: AgentMessage[];
|
|
@@ -116,8 +117,8 @@ export async function compactPiContext(input: Readonly<{
|
|
|
116
117
|
const settings = DEFAULT_COMPACTION_SETTINGS;
|
|
117
118
|
if (
|
|
118
119
|
!settings.enabled ||
|
|
119
|
-
estimateContextTokens(currentMessages).tokens <
|
|
120
|
-
input.compactAfterTokens
|
|
120
|
+
(!input.force && estimateContextTokens(currentMessages).tokens <
|
|
121
|
+
input.compactAfterTokens)
|
|
121
122
|
) {
|
|
122
123
|
return {
|
|
123
124
|
messages: currentMessages,
|
|
@@ -149,10 +150,7 @@ export async function compactPiContext(input: Readonly<{
|
|
|
149
150
|
context: Parameters<Models["completeSimple"]>[1],
|
|
150
151
|
options?: Parameters<Models["completeSimple"]>[2],
|
|
151
152
|
) => {
|
|
152
|
-
const response = await input.models.completeSimple(model, context,
|
|
153
|
-
...options,
|
|
154
|
-
...(input.apiKey ? { apiKey: input.apiKey } : {}),
|
|
155
|
-
});
|
|
153
|
+
const response = await input.models.completeSimple(model, context, options);
|
|
156
154
|
responses.push(response);
|
|
157
155
|
return response;
|
|
158
156
|
},
|
|
@@ -199,10 +197,25 @@ export async function compactPiContext(input: Readonly<{
|
|
|
199
197
|
? firstResponse.stopReason
|
|
200
198
|
: undefined;
|
|
201
199
|
const usage = sumUsage(responses);
|
|
200
|
+
const messages = buildSessionContext(
|
|
201
|
+
[...branch, previewEntry],
|
|
202
|
+
).messages;
|
|
203
|
+
if (
|
|
204
|
+
input.force &&
|
|
205
|
+
messages.reduce((total, message) => total + estimateTokens(message), 0) >=
|
|
206
|
+
currentMessages.reduce(
|
|
207
|
+
(total, message) => total + estimateTokens(message),
|
|
208
|
+
0,
|
|
209
|
+
)
|
|
210
|
+
) {
|
|
211
|
+
return {
|
|
212
|
+
messages: currentMessages,
|
|
213
|
+
didCompact: false,
|
|
214
|
+
degraded: true,
|
|
215
|
+
};
|
|
216
|
+
}
|
|
202
217
|
return {
|
|
203
|
-
messages
|
|
204
|
-
[...branch, previewEntry],
|
|
205
|
-
).messages,
|
|
218
|
+
messages,
|
|
206
219
|
compactionEntry,
|
|
207
220
|
...(firstResponse && usage
|
|
208
221
|
? {
|
|
@@ -2,8 +2,11 @@ import type {
|
|
|
2
2
|
AgentMessage,
|
|
3
3
|
ThinkingLevel,
|
|
4
4
|
} from "@earendil-works/pi-agent-core";
|
|
5
|
-
import
|
|
6
|
-
|
|
5
|
+
import {
|
|
6
|
+
clampThinkingLevel,
|
|
7
|
+
type Api,
|
|
8
|
+
type Model,
|
|
9
|
+
} from "@earendil-works/pi-ai";
|
|
7
10
|
import type {
|
|
8
11
|
RuntimeMcpServer,
|
|
9
12
|
RuntimeProfile,
|
|
@@ -84,15 +87,14 @@ function freezeModel(model: Model<Api>): Model<Api> {
|
|
|
84
87
|
});
|
|
85
88
|
}
|
|
86
89
|
|
|
87
|
-
// 作用:把 Runtime
|
|
88
|
-
// 调用:创建 Runtime
|
|
89
|
-
// 原因:`none` 映射成 `off`;xhigh/max 用 clampReasoning 夹到 API 真正支持的最高档,
|
|
90
|
-
// 避免超出范围的等级在 provider 侧静默失败。
|
|
90
|
+
// 作用:把 Runtime 的思考强度夹到当前模型真正支持的 Pi 档位。
|
|
91
|
+
// 调用:创建 Runtime 装配快照时对已解析模型和 profile `thinking` 调用。
|
|
91
92
|
function toPiThinkingLevel(
|
|
93
|
+
model: Model<Api>,
|
|
92
94
|
thinking: ThinkingEffort,
|
|
93
95
|
): ThinkingLevel {
|
|
94
96
|
if (thinking === "none") return "off";
|
|
95
|
-
return
|
|
97
|
+
return clampThinkingLevel(model, thinking);
|
|
96
98
|
}
|
|
97
99
|
|
|
98
100
|
// 作用:复制并冻结一个 Extension 配置的 manifest 和权限集合。
|
|
@@ -197,10 +199,14 @@ export function createPiRuntimeAssembly(
|
|
|
197
199
|
Object.freeze(messages);
|
|
198
200
|
Object.freeze(mcpServers);
|
|
199
201
|
Object.freeze(extensions);
|
|
202
|
+
const model = freezeModel(
|
|
203
|
+
resolvePiModel(input.provider, input.profile.model),
|
|
204
|
+
);
|
|
200
205
|
|
|
201
206
|
return Object.freeze({
|
|
202
|
-
model
|
|
207
|
+
model,
|
|
203
208
|
thinkingLevel: toPiThinkingLevel(
|
|
209
|
+
model,
|
|
204
210
|
input.profile.thinking,
|
|
205
211
|
),
|
|
206
212
|
systemPrompt: assembleSystemPrompt(
|
|
@@ -123,17 +123,40 @@ function toolCallAt(
|
|
|
123
123
|
return part?.type === "toolCall" ? part : undefined;
|
|
124
124
|
}
|
|
125
125
|
|
|
126
|
-
|
|
127
|
-
|
|
126
|
+
/** The one interpretation of a non-Tool Pi assistant terminal. */
|
|
127
|
+
export function classifyPiTerminalStopReason(
|
|
128
|
+
stopReason: string | undefined,
|
|
129
|
+
): {
|
|
130
|
+
outcome: "succeeded" | "failed" | "aborted";
|
|
131
|
+
turnStatus: "completed" | "error" | "aborted";
|
|
132
|
+
finishReason?: FinishReason;
|
|
133
|
+
message?: string;
|
|
134
|
+
} {
|
|
135
|
+
switch (stopReason) {
|
|
128
136
|
case "stop":
|
|
129
137
|
case "length":
|
|
130
|
-
return
|
|
131
|
-
|
|
132
|
-
|
|
138
|
+
return {
|
|
139
|
+
outcome: "succeeded",
|
|
140
|
+
turnStatus: "completed",
|
|
141
|
+
finishReason: stopReason,
|
|
142
|
+
};
|
|
143
|
+
case "aborted":
|
|
144
|
+
return { outcome: "aborted", turnStatus: "aborted" };
|
|
133
145
|
case "error":
|
|
134
|
-
return
|
|
146
|
+
return {
|
|
147
|
+
outcome: "failed",
|
|
148
|
+
turnStatus: "error",
|
|
149
|
+
finishReason: "error",
|
|
150
|
+
};
|
|
135
151
|
default:
|
|
136
|
-
return
|
|
152
|
+
return {
|
|
153
|
+
outcome: "failed",
|
|
154
|
+
turnStatus: "error",
|
|
155
|
+
finishReason: "error",
|
|
156
|
+
message: `SpringBrand ended the turn with an unrecognized stop reason: ${
|
|
157
|
+
stopReason ?? "(none)"
|
|
158
|
+
}`,
|
|
159
|
+
};
|
|
137
160
|
}
|
|
138
161
|
}
|
|
139
162
|
|
|
@@ -345,8 +368,15 @@ export class PiChunkEncoder {
|
|
|
345
368
|
update.error.errorMessage ?? update.reason,
|
|
346
369
|
) ?? "SpringBrand turn failed",
|
|
347
370
|
}];
|
|
348
|
-
|
|
371
|
+
case "start":
|
|
372
|
+
case "done":
|
|
349
373
|
return [];
|
|
374
|
+
default:
|
|
375
|
+
throw new Error(
|
|
376
|
+
`Unsupported SpringBrand AssistantMessageEvent: ${
|
|
377
|
+
String((update as { type?: unknown }).type)
|
|
378
|
+
}`,
|
|
379
|
+
);
|
|
350
380
|
}
|
|
351
381
|
}
|
|
352
382
|
|
|
@@ -448,43 +478,39 @@ export class PiChunkEncoder {
|
|
|
448
478
|
}
|
|
449
479
|
this.finished = true;
|
|
450
480
|
const publicError = publicAssistantError(message.errorMessage);
|
|
481
|
+
const terminal = classifyPiTerminalStopReason(message.stopReason);
|
|
451
482
|
if (this.startedAt !== undefined) {
|
|
452
|
-
const turnStatus = message.stopReason === "aborted"
|
|
453
|
-
? "aborted"
|
|
454
|
-
: message.stopReason === "error"
|
|
455
|
-
? "error"
|
|
456
|
-
: "completed";
|
|
457
483
|
chunks.push({
|
|
458
484
|
type: "message-metadata",
|
|
459
485
|
messageMetadata: {
|
|
460
486
|
createdAt: this.startedAt,
|
|
461
487
|
completedAt: message.timestamp,
|
|
462
488
|
turnDurationMs: Math.max(0, message.timestamp - this.startedAt),
|
|
463
|
-
turnStatus,
|
|
489
|
+
turnStatus: terminal.turnStatus,
|
|
464
490
|
...(this.turnId ? { turnId: this.turnId } : {}),
|
|
465
|
-
...(publicError &&
|
|
466
|
-
(
|
|
491
|
+
...((publicError ?? terminal.message) &&
|
|
492
|
+
(terminal.outcome !== "aborted" ||
|
|
467
493
|
message.errorMessage !== USER_STOP_REASON)
|
|
468
|
-
? { error: publicError }
|
|
494
|
+
? { error: publicError ?? terminal.message }
|
|
469
495
|
: {}),
|
|
470
496
|
},
|
|
471
497
|
});
|
|
472
498
|
}
|
|
473
|
-
if (
|
|
499
|
+
if (terminal.outcome === "aborted") {
|
|
474
500
|
chunks.push({ type: "abort", reason: message.errorMessage });
|
|
475
501
|
return chunks;
|
|
476
502
|
}
|
|
477
|
-
if (
|
|
503
|
+
if (terminal.outcome === "failed") {
|
|
478
504
|
chunks.push({
|
|
479
505
|
type: "error",
|
|
480
|
-
errorText: publicError ?? "SpringBrand turn failed",
|
|
506
|
+
errorText: publicError ?? terminal.message ?? "SpringBrand turn failed",
|
|
481
507
|
});
|
|
482
508
|
return chunks;
|
|
483
509
|
}
|
|
484
510
|
chunks.push({ type: "finish-step" });
|
|
485
511
|
chunks.push({
|
|
486
512
|
type: "finish",
|
|
487
|
-
finishReason: finishReason
|
|
513
|
+
finishReason: terminal.finishReason ?? "other",
|
|
488
514
|
});
|
|
489
515
|
return chunks;
|
|
490
516
|
}
|
|
@@ -200,46 +200,18 @@ export interface PreparedPiRuntimeState {
|
|
|
200
200
|
|
|
201
201
|
// #region 版本描述与准备句柄
|
|
202
202
|
|
|
203
|
-
const IDEMPOTENT_TOOL_NAMES = new Set([
|
|
204
|
-
"activate_skill",
|
|
205
|
-
"browser_extract",
|
|
206
|
-
"browser_links",
|
|
207
|
-
"browser_markdown",
|
|
208
|
-
"browser_scrape",
|
|
209
|
-
"bind_resource",
|
|
210
|
-
"delete",
|
|
211
|
-
"edit",
|
|
212
|
-
"execute",
|
|
213
|
-
"find",
|
|
214
|
-
"get_time",
|
|
215
|
-
"grep",
|
|
216
|
-
"list",
|
|
217
|
-
"list_resources",
|
|
218
|
-
"list_extensions",
|
|
219
|
-
"list_schedules",
|
|
220
|
-
"read",
|
|
221
|
-
"read_skill_resource",
|
|
222
|
-
"sandbox_process_logs",
|
|
223
|
-
"web_search",
|
|
224
|
-
"write",
|
|
225
|
-
"unbind_resource",
|
|
226
|
-
]);
|
|
227
|
-
|
|
228
203
|
/**
|
|
229
204
|
* 判断工具在执行结果不确定时能否安全重试。
|
|
230
205
|
*
|
|
231
206
|
* @remarks
|
|
232
207
|
* Turn 执行在记录第一次尝试前调用它,恢复流程随后使用这条持久化结论。
|
|
233
208
|
*
|
|
234
|
-
*
|
|
209
|
+
* 重试语义由 Tool Candidate 自己声明;未声明的工具失败关闭为不可重复执行。
|
|
235
210
|
*/
|
|
236
211
|
export function piToolRetryPolicy(
|
|
237
212
|
candidate: PiToolCandidate,
|
|
238
213
|
): "idempotent" | "non-idempotent" {
|
|
239
|
-
return candidate.retry ??
|
|
240
|
-
candidate.owner.startsWith("subagent:")
|
|
241
|
-
? "idempotent"
|
|
242
|
-
: "non-idempotent");
|
|
214
|
+
return candidate.retry ?? "non-idempotent";
|
|
243
215
|
}
|
|
244
216
|
|
|
245
217
|
// 把工具候选整理成版本描述里要保存的稳定字段。
|
|
@@ -15,13 +15,17 @@ import type {
|
|
|
15
15
|
ToolResultMessage,
|
|
16
16
|
UserMessage,
|
|
17
17
|
} from "@earendil-works/pi-ai";
|
|
18
|
+
import { isContextOverflow } from "@earendil-works/pi-ai";
|
|
18
19
|
import { transformMessages } from "@earendil-works/pi-ai/api/transform-messages";
|
|
19
20
|
import {
|
|
20
21
|
parkPiToolApproval,
|
|
21
22
|
parkPiToolInteraction,
|
|
22
23
|
requiresPiToolApproval,
|
|
23
24
|
} from "../turn";
|
|
24
|
-
import {
|
|
25
|
+
import {
|
|
26
|
+
classifyPiTerminalStopReason,
|
|
27
|
+
PiChunkEncoder,
|
|
28
|
+
} from "../message";
|
|
25
29
|
import type { UIMessageChunk } from "ai";
|
|
26
30
|
import { ChatStreamStalledError } from "agents/chat";
|
|
27
31
|
import {
|
|
@@ -42,7 +46,6 @@ import {
|
|
|
42
46
|
import {
|
|
43
47
|
isRecoverableAssistantError,
|
|
44
48
|
isModelStreamStallMessage,
|
|
45
|
-
resolvePiApiKey,
|
|
46
49
|
withProviderRetry,
|
|
47
50
|
type PiGenerationLifecycleObserver,
|
|
48
51
|
} from "./models";
|
|
@@ -79,6 +82,8 @@ const MAX_WRAP_UP_TURNS = 2;
|
|
|
79
82
|
|
|
80
83
|
export class RetryableModelError extends Error {}
|
|
81
84
|
|
|
85
|
+
export class ContextOverflowError extends RetryableModelError {}
|
|
86
|
+
|
|
82
87
|
function normalizePlanToolCalls(message: AgentMessage): void {
|
|
83
88
|
if (message.role !== "assistant") return;
|
|
84
89
|
for (const part of message.content) {
|
|
@@ -88,42 +93,6 @@ function normalizePlanToolCalls(message: AgentMessage): void {
|
|
|
88
93
|
}
|
|
89
94
|
}
|
|
90
95
|
|
|
91
|
-
/**
|
|
92
|
-
* 把 Pi 的终止原因翻译成本仓的回合结局。
|
|
93
|
-
*
|
|
94
|
-
* 调用:终态提交(`onTerminal`)与流式记录的 `turnStatus` 共用这一处,
|
|
95
|
-
* 保证服务端只有一份判据 —— 两处各写一遍三元链正是它们悄悄分叉的原因。
|
|
96
|
-
*
|
|
97
|
-
* 为什么未知取值判失败而不是成功:`stopReason` 是上游会扩的联合体
|
|
98
|
-
* (0.83 就加了 `"pending"`)。把认不出的结局宣称成「成功」,等于让一次
|
|
99
|
-
* 异常结束伪装成正常完成 —— 这是本仓明确禁止的假完成,
|
|
100
|
-
* 也是 Pi 自己在 0.83 的选择(认不出的终止原因直接抛错而非压平成 stop)。
|
|
101
|
-
* 前端对未知取值取 `"running"`(未知即未定),最终由服务端这份权威判据校正。
|
|
102
|
-
*/
|
|
103
|
-
function classifyPiStopReason(stopReason: string | undefined): {
|
|
104
|
-
outcome: PiTurnTerminalIntent["outcome"];
|
|
105
|
-
turnStatus: "completed" | "error" | "aborted";
|
|
106
|
-
message?: string;
|
|
107
|
-
} {
|
|
108
|
-
switch (stopReason) {
|
|
109
|
-
case "stop":
|
|
110
|
-
case "length":
|
|
111
|
-
return { outcome: "succeeded", turnStatus: "completed" };
|
|
112
|
-
case "aborted":
|
|
113
|
-
return { outcome: "aborted", turnStatus: "aborted" };
|
|
114
|
-
case "error":
|
|
115
|
-
return { outcome: "failed", turnStatus: "error" };
|
|
116
|
-
default:
|
|
117
|
-
return {
|
|
118
|
-
outcome: "failed",
|
|
119
|
-
turnStatus: "error",
|
|
120
|
-
message: `SpringBrand ended the turn with an unrecognized stop reason: ${
|
|
121
|
-
stopReason ?? "(none)"
|
|
122
|
-
}`,
|
|
123
|
-
};
|
|
124
|
-
}
|
|
125
|
-
}
|
|
126
|
-
|
|
127
96
|
function visibleAssistantContent(message: AssistantMessage) {
|
|
128
97
|
return message.content.filter((part) =>
|
|
129
98
|
part.type === "text"
|
|
@@ -140,7 +109,6 @@ interface PiTurnAdapterOptions {
|
|
|
140
109
|
readonly thinkingLevel?: ThinkingLevel;
|
|
141
110
|
};
|
|
142
111
|
readonly models: Models;
|
|
143
|
-
readonly apiKey: string;
|
|
144
112
|
readonly modelSessionId: string;
|
|
145
113
|
// 读取这次执行真正要交给 Pi 的 canonical transcript。
|
|
146
114
|
// PiTurnAdapter.run 在创建 PiCore 前调用它,宿主应返回当时最新的 transcript。
|
|
@@ -331,7 +299,6 @@ class PiTurnAdapter {
|
|
|
331
299
|
this.opts.modelSessionId,
|
|
332
300
|
this.opts.onGeneration,
|
|
333
301
|
),
|
|
334
|
-
getApiKey: () => this.opts.apiKey,
|
|
335
302
|
// transformMessages 在宿主上下文变换之后运行,确保跨 provider 的 tool call ID 格式兼容。
|
|
336
303
|
// OpenAI Responses API 生成含 `|` 的 450+ 字符 ID,Anthropic 只接受 ^[a-zA-Z0-9_-]+$(64 字符上限);
|
|
337
304
|
// 切换 provider 或消息跨 provider 回放时若不规范化,provider 会静默拒绝。
|
|
@@ -564,6 +531,21 @@ export interface CreatePreparedPiTurnOptions {
|
|
|
564
531
|
error?: unknown;
|
|
565
532
|
occurredAt: number;
|
|
566
533
|
}>) => void;
|
|
534
|
+
/** Reports model-requested Tools absent from the pinned Runtime surface. */
|
|
535
|
+
readonly onUnknownToolStarted?: (input: Readonly<{
|
|
536
|
+
toolCallId: string;
|
|
537
|
+
toolName: string;
|
|
538
|
+
input: unknown;
|
|
539
|
+
occurredAt: number;
|
|
540
|
+
}>) => void;
|
|
541
|
+
/** Reports the immediate Pi failure for a Tool absent from the pinned Runtime surface. */
|
|
542
|
+
readonly onUnknownToolFinished?: (input: Readonly<{
|
|
543
|
+
toolCallId: string;
|
|
544
|
+
toolName: string;
|
|
545
|
+
outcome: "failed";
|
|
546
|
+
output: import("@earendil-works/pi-agent-core").AgentToolResult<unknown>;
|
|
547
|
+
occurredAt: number;
|
|
548
|
+
}>) => void;
|
|
567
549
|
/** Per-Submission executors for tools whose metadata is fixed at assembly time. */
|
|
568
550
|
readonly toolExecutors?: Readonly<Record<
|
|
569
551
|
string,
|
|
@@ -659,6 +641,7 @@ export class PreparedPiTurnAdapter {
|
|
|
659
641
|
private readonly encoder: PiChunkEncoder;
|
|
660
642
|
private readonly steerMessageIds: string[] = [];
|
|
661
643
|
private terminalIntent?: PiTurnTerminalIntent;
|
|
644
|
+
private readonly contextWindow: number;
|
|
662
645
|
|
|
663
646
|
/**
|
|
664
647
|
* 把 Prepared Runtime 和 Submission 持久化端口绑定成一个可执行 Turn。
|
|
@@ -679,6 +662,7 @@ export class PreparedPiTurnAdapter {
|
|
|
679
662
|
dependencies.owner,
|
|
680
663
|
options.pinnedDescriptor,
|
|
681
664
|
);
|
|
665
|
+
this.contextWindow = state.snapshot.pi.model.contextWindow;
|
|
682
666
|
this.assistantOrdinal = options.submission.assistantOrdinal;
|
|
683
667
|
this.encoder = new PiChunkEncoder({
|
|
684
668
|
messageId: options.submission.messageId,
|
|
@@ -903,10 +887,6 @@ export class PreparedPiTurnAdapter {
|
|
|
903
887
|
thinkingLevel: state.snapshot.pi.thinkingLevel,
|
|
904
888
|
},
|
|
905
889
|
models: dependencies.models,
|
|
906
|
-
apiKey: resolvePiApiKey(
|
|
907
|
-
state.snapshot.bindings.provider,
|
|
908
|
-
state.snapshot.pi.model.id,
|
|
909
|
-
),
|
|
910
890
|
modelSessionId: options.modelSessionId,
|
|
911
891
|
...(options.onGeneration ? { onGeneration: options.onGeneration } : {}),
|
|
912
892
|
canonicalMessages: options.canonicalMessages,
|
|
@@ -1038,6 +1018,28 @@ export class PreparedPiTurnAdapter {
|
|
|
1038
1018
|
// PiTurnAdapter 会对每个 AgentEvent 调用它,PiCore 会等待该 Promise 后再越过订阅事件屏障。
|
|
1039
1019
|
// canonical message 先于流投影提交,恢复才不会依赖仅供浏览器消费的记录;调整顺序必须复核 recovery effect。
|
|
1040
1020
|
private async handleEvent(event: AgentEvent): Promise<void> {
|
|
1021
|
+
if (
|
|
1022
|
+
event.type === "tool_execution_start" &&
|
|
1023
|
+
!this.candidates.some(({ tool }) => tool.name === event.toolName)
|
|
1024
|
+
) {
|
|
1025
|
+
this.options.onUnknownToolStarted?.({
|
|
1026
|
+
toolCallId: event.toolCallId,
|
|
1027
|
+
toolName: event.toolName,
|
|
1028
|
+
input: event.args,
|
|
1029
|
+
occurredAt: Date.now(),
|
|
1030
|
+
});
|
|
1031
|
+
} else if (
|
|
1032
|
+
event.type === "tool_execution_end" &&
|
|
1033
|
+
!this.candidates.some(({ tool }) => tool.name === event.toolName)
|
|
1034
|
+
) {
|
|
1035
|
+
this.options.onUnknownToolFinished?.({
|
|
1036
|
+
toolCallId: event.toolCallId,
|
|
1037
|
+
toolName: event.toolName,
|
|
1038
|
+
outcome: "failed",
|
|
1039
|
+
output: event.result,
|
|
1040
|
+
occurredAt: Date.now(),
|
|
1041
|
+
});
|
|
1042
|
+
}
|
|
1041
1043
|
if (
|
|
1042
1044
|
event.type === "message_start" ||
|
|
1043
1045
|
event.type === "message_update" ||
|
|
@@ -1048,7 +1050,10 @@ export class PreparedPiTurnAdapter {
|
|
|
1048
1050
|
if (
|
|
1049
1051
|
event.type === "message_update" &&
|
|
1050
1052
|
event.assistantMessageEvent.type === "error" &&
|
|
1051
|
-
(
|
|
1053
|
+
(isContextOverflow(
|
|
1054
|
+
event.assistantMessageEvent.error,
|
|
1055
|
+
this.contextWindow,
|
|
1056
|
+
) || isModelStreamStallMessage(
|
|
1052
1057
|
event.assistantMessageEvent.error.errorMessage,
|
|
1053
1058
|
) ||
|
|
1054
1059
|
isRecoverableAssistantError(event.assistantMessageEvent.error))
|
|
@@ -1059,6 +1064,11 @@ export class PreparedPiTurnAdapter {
|
|
|
1059
1064
|
if (event.type === "message_end") {
|
|
1060
1065
|
const message = event.message;
|
|
1061
1066
|
if (message.role === "assistant") {
|
|
1067
|
+
if (isContextOverflow(message, this.contextWindow)) {
|
|
1068
|
+
throw new ContextOverflowError(
|
|
1069
|
+
message.errorMessage ?? "Model context window overflow",
|
|
1070
|
+
);
|
|
1071
|
+
}
|
|
1062
1072
|
const stalled = message.stopReason === "error" &&
|
|
1063
1073
|
isModelStreamStallMessage(message.errorMessage);
|
|
1064
1074
|
const retryable = isRecoverableAssistantError(message);
|
|
@@ -1119,7 +1129,7 @@ export class PreparedPiTurnAdapter {
|
|
|
1119
1129
|
authoritativeMessage.role === "assistant" &&
|
|
1120
1130
|
authoritativeMessage.stopReason !== "toolUse"
|
|
1121
1131
|
) {
|
|
1122
|
-
const terminal =
|
|
1132
|
+
const terminal = classifyPiTerminalStopReason(
|
|
1123
1133
|
authoritativeMessage.stopReason,
|
|
1124
1134
|
);
|
|
1125
1135
|
this.terminalIntent = {
|