@springbrand/agent-runtime 0.1.3-alpha.1 → 0.1.3-alpha.2
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/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/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 +8 -3
- package/src/pi/runtime-adapter/models.ts +221 -29
- 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 +190 -40
|
@@ -2,8 +2,10 @@ import type { StreamFn } from "@earendil-works/pi-agent-core";
|
|
|
2
2
|
import {
|
|
3
3
|
createModels,
|
|
4
4
|
createProvider,
|
|
5
|
+
isRetryableAssistantError,
|
|
5
6
|
lazyStream,
|
|
6
7
|
type Api,
|
|
8
|
+
type AssistantMessage,
|
|
7
9
|
type AssistantMessageEvent,
|
|
8
10
|
type Model,
|
|
9
11
|
type MutableModels,
|
|
@@ -35,6 +37,12 @@ import {
|
|
|
35
37
|
import {
|
|
36
38
|
openaiCodexProvider,
|
|
37
39
|
} from "@earendil-works/pi-ai/providers/openai-codex";
|
|
40
|
+
import {
|
|
41
|
+
cloudflareStreams,
|
|
42
|
+
} from "@earendil-works/pi-ai/providers/cloudflare-stream";
|
|
43
|
+
import {
|
|
44
|
+
ChatStreamStalledError,
|
|
45
|
+
} from "agents/chat";
|
|
38
46
|
import type {
|
|
39
47
|
RuntimeModelEndpoint,
|
|
40
48
|
RuntimeModelProtocol,
|
|
@@ -50,46 +58,216 @@ const CATALOGS = {
|
|
|
50
58
|
} satisfies Record<RuntimeModelProtocol, readonly Model<Api>[]>;
|
|
51
59
|
|
|
52
60
|
const PROVIDER_MAX_RETRIES = 2;
|
|
53
|
-
const MODEL_STREAM_STALL_TIMEOUT_MS =
|
|
61
|
+
export const MODEL_STREAM_STALL_TIMEOUT_MS = 60_000;
|
|
62
|
+
export const MODEL_STREAM_STALL_MESSAGE =
|
|
63
|
+
`Chat stream stalled: no activity for ${MODEL_STREAM_STALL_TIMEOUT_MS}ms; the turn was aborted by the stall watchdog.`;
|
|
64
|
+
const MODEL_STREAM_STALL_DETAILS_PREFIX = `${MODEL_STREAM_STALL_MESSAGE}\n`;
|
|
65
|
+
|
|
66
|
+
export interface ModelStreamStallDetails {
|
|
67
|
+
lastMeaningfulActivityAt: number;
|
|
68
|
+
lastMeaningfulActivityType:
|
|
69
|
+
| AssistantMessageEvent["type"]
|
|
70
|
+
| "model_stream_started";
|
|
71
|
+
idleMs: number;
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
export function isModelStreamStallMessage(message?: string): message is string {
|
|
75
|
+
return message === MODEL_STREAM_STALL_MESSAGE ||
|
|
76
|
+
message?.startsWith(MODEL_STREAM_STALL_DETAILS_PREFIX) === true;
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
export function readModelStreamStallDetails(
|
|
80
|
+
message?: string,
|
|
81
|
+
): ModelStreamStallDetails | undefined {
|
|
82
|
+
if (!message?.startsWith(MODEL_STREAM_STALL_DETAILS_PREFIX)) return;
|
|
83
|
+
try {
|
|
84
|
+
const value = JSON.parse(
|
|
85
|
+
message.slice(MODEL_STREAM_STALL_DETAILS_PREFIX.length),
|
|
86
|
+
) as Partial<ModelStreamStallDetails>;
|
|
87
|
+
if (
|
|
88
|
+
typeof value.lastMeaningfulActivityAt !== "number" ||
|
|
89
|
+
typeof value.lastMeaningfulActivityType !== "string" ||
|
|
90
|
+
typeof value.idleMs !== "number"
|
|
91
|
+
) return;
|
|
92
|
+
return value as ModelStreamStallDetails;
|
|
93
|
+
} catch {
|
|
94
|
+
return;
|
|
95
|
+
}
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
export function isRecoverableAssistantError(
|
|
99
|
+
message: AssistantMessage,
|
|
100
|
+
): boolean {
|
|
101
|
+
if (
|
|
102
|
+
message.stopReason === "error" &&
|
|
103
|
+
/thought[_ ]signature/i.test(message.errorMessage ?? "")
|
|
104
|
+
) return false;
|
|
105
|
+
return isRetryableAssistantError(message) ||
|
|
106
|
+
(message.stopReason === "error" &&
|
|
107
|
+
/upstream http\/2 stream failed/i.test(message.errorMessage ?? ""));
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
function pdfPayload(payload: unknown, api: Api): unknown {
|
|
111
|
+
if (Array.isArray(payload)) {
|
|
112
|
+
return payload.map((value) => pdfPayload(value, api));
|
|
113
|
+
}
|
|
114
|
+
if (typeof payload !== "object" || payload === null) return payload;
|
|
115
|
+
|
|
116
|
+
const value = payload as Record<string, unknown>;
|
|
117
|
+
if (api === "anthropic-messages" && value.type === "image") {
|
|
118
|
+
const source = value.source as Record<string, unknown> | undefined;
|
|
119
|
+
if (source?.media_type === "application/pdf") {
|
|
120
|
+
return { ...value, type: "document" };
|
|
121
|
+
}
|
|
122
|
+
}
|
|
123
|
+
if (api === "openai-completions" && value.type === "image_url") {
|
|
124
|
+
const image = value.image_url as Record<string, unknown> | undefined;
|
|
125
|
+
if (
|
|
126
|
+
typeof image?.url === "string" &&
|
|
127
|
+
image.url.startsWith("data:application/pdf;base64,")
|
|
128
|
+
) {
|
|
129
|
+
return {
|
|
130
|
+
type: "file",
|
|
131
|
+
file: { filename: "workspace.pdf", file_data: image.url },
|
|
132
|
+
};
|
|
133
|
+
}
|
|
134
|
+
}
|
|
135
|
+
if (api === "openai-codex-responses" && value.type === "input_image") {
|
|
136
|
+
if (
|
|
137
|
+
typeof value.image_url === "string" &&
|
|
138
|
+
value.image_url.startsWith("data:application/pdf;base64,")
|
|
139
|
+
) {
|
|
140
|
+
return {
|
|
141
|
+
type: "input_file",
|
|
142
|
+
filename: "workspace.pdf",
|
|
143
|
+
file_data: value.image_url,
|
|
144
|
+
};
|
|
145
|
+
}
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
return Object.fromEntries(
|
|
149
|
+
Object.entries(value).map(([key, child]) => [
|
|
150
|
+
key,
|
|
151
|
+
pdfPayload(child, api),
|
|
152
|
+
]),
|
|
153
|
+
);
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
function meaningfulModelProgress(
|
|
157
|
+
event: AssistantMessageEvent,
|
|
158
|
+
streamedContent: Set<string>,
|
|
159
|
+
): AssistantMessageEvent["type"] | undefined {
|
|
160
|
+
switch (event.type) {
|
|
161
|
+
case "text_delta":
|
|
162
|
+
case "thinking_delta": {
|
|
163
|
+
if (!event.delta.trim()) return undefined;
|
|
164
|
+
streamedContent.add(`${event.type}:${event.contentIndex}`);
|
|
165
|
+
return event.type;
|
|
166
|
+
}
|
|
167
|
+
case "text_end":
|
|
168
|
+
if (streamedContent.has(`text_delta:${event.contentIndex}`)) return;
|
|
169
|
+
return event.content.trim() ? event.type : undefined;
|
|
170
|
+
case "thinking_end":
|
|
171
|
+
if (streamedContent.has(`thinking_delta:${event.contentIndex}`)) return;
|
|
172
|
+
return event.content.trim() ? event.type : undefined;
|
|
173
|
+
case "toolcall_end":
|
|
174
|
+
return event.type;
|
|
175
|
+
default:
|
|
176
|
+
return undefined;
|
|
177
|
+
}
|
|
178
|
+
}
|
|
54
179
|
|
|
55
180
|
async function* stopStalledModelStream(
|
|
56
181
|
source: AsyncIterable<AssistantMessageEvent>,
|
|
57
182
|
watchdog: AbortController,
|
|
58
183
|
): AsyncGenerator<AssistantMessageEvent> {
|
|
59
184
|
const iterator = source[Symbol.asyncIterator]();
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
185
|
+
let timer: ReturnType<typeof setTimeout> | undefined;
|
|
186
|
+
let stalled = false;
|
|
187
|
+
let stallError: ChatStreamStalledError | undefined;
|
|
188
|
+
let idleWaitMs = 0;
|
|
189
|
+
const streamedContent = new Set<string>();
|
|
190
|
+
let lastMeaningfulActivityAt = Date.now();
|
|
191
|
+
let lastMeaningfulActivityType:
|
|
192
|
+
ModelStreamStallDetails["lastMeaningfulActivityType"] =
|
|
193
|
+
"model_stream_started";
|
|
194
|
+
const stop = (idleMs: number) => {
|
|
195
|
+
stalled = true;
|
|
196
|
+
stallError = new ChatStreamStalledError(
|
|
197
|
+
MODEL_STREAM_STALL_DETAILS_PREFIX + JSON.stringify({
|
|
198
|
+
lastMeaningfulActivityAt,
|
|
199
|
+
lastMeaningfulActivityType,
|
|
200
|
+
idleMs,
|
|
201
|
+
} satisfies ModelStreamStallDetails),
|
|
202
|
+
);
|
|
203
|
+
watchdog.abort(stallError);
|
|
204
|
+
return stallError;
|
|
205
|
+
};
|
|
206
|
+
const arm = (
|
|
207
|
+
delayMs: number,
|
|
208
|
+
waitStartedAt: number,
|
|
209
|
+
) => new Promise<never>((_, reject) => {
|
|
210
|
+
timer = setTimeout(() => {
|
|
211
|
+
reject(stop(idleWaitMs + Date.now() - waitStartedAt));
|
|
212
|
+
}, delayMs);
|
|
213
|
+
});
|
|
214
|
+
try {
|
|
215
|
+
while (true) {
|
|
216
|
+
const waitStartedAt = Date.now();
|
|
217
|
+
const nextPromise = iterator.next();
|
|
218
|
+
nextPromise.catch(() => {});
|
|
219
|
+
let next: IteratorResult<AssistantMessageEvent>;
|
|
220
|
+
try {
|
|
221
|
+
next = await Promise.race([
|
|
222
|
+
nextPromise,
|
|
223
|
+
arm(
|
|
224
|
+
Math.max(0, MODEL_STREAM_STALL_TIMEOUT_MS - idleWaitMs),
|
|
225
|
+
waitStartedAt,
|
|
226
|
+
),
|
|
227
|
+
]);
|
|
228
|
+
} catch (error) {
|
|
229
|
+
if (stalled) throw stallError;
|
|
230
|
+
throw error;
|
|
231
|
+
} finally {
|
|
232
|
+
clearTimeout(timer);
|
|
233
|
+
timer = undefined;
|
|
234
|
+
}
|
|
235
|
+
if (stalled) throw stallError;
|
|
236
|
+
if (next.done) break;
|
|
237
|
+
const event = next.value;
|
|
238
|
+
if (event.type === "done" || event.type === "error") {
|
|
239
|
+
yield event;
|
|
240
|
+
return;
|
|
241
|
+
}
|
|
242
|
+
idleWaitMs += Date.now() - waitStartedAt;
|
|
243
|
+
const progress = meaningfulModelProgress(event, streamedContent);
|
|
244
|
+
if (progress) {
|
|
245
|
+
lastMeaningfulActivityAt = Date.now();
|
|
246
|
+
lastMeaningfulActivityType = progress;
|
|
247
|
+
idleWaitMs = 0;
|
|
248
|
+
}
|
|
249
|
+
yield event;
|
|
78
250
|
}
|
|
79
|
-
|
|
80
|
-
|
|
251
|
+
} finally {
|
|
252
|
+
clearTimeout(timer);
|
|
253
|
+
if (!stalled) await iterator.return?.().catch(() => {});
|
|
81
254
|
}
|
|
255
|
+
throw new Error("Model stream ended without a terminal event");
|
|
82
256
|
}
|
|
83
257
|
|
|
84
258
|
/**
|
|
85
|
-
*
|
|
259
|
+
* 给模型请求补上可中断的空闲终止边界。
|
|
86
260
|
*
|
|
87
261
|
* Runtime Turn 和 SubAgent 在把 `Models.streamSimple` 交给 Pi 前调用;显式传入的重试次数优先。
|
|
88
262
|
*
|
|
89
|
-
*
|
|
90
|
-
*
|
|
263
|
+
* 调用方可以覆盖默认的 2 次 Provider 重试;主 Turn 传 0,由 Submission
|
|
264
|
+
* 统一持有恢复预算。连续 60 秒没有可展示进展时中止 provider。
|
|
91
265
|
*/
|
|
92
|
-
export function withProviderRetry(
|
|
266
|
+
export function withProviderRetry(
|
|
267
|
+
streamFn: StreamFn,
|
|
268
|
+
defaultMaxRetries = PROVIDER_MAX_RETRIES,
|
|
269
|
+
defaultSessionId?: string,
|
|
270
|
+
): StreamFn {
|
|
93
271
|
return (model, context, options) =>
|
|
94
272
|
lazyStream(model, async () => {
|
|
95
273
|
const watchdog = new AbortController();
|
|
@@ -98,7 +276,13 @@ export function withProviderRetry(streamFn: StreamFn): StreamFn {
|
|
|
98
276
|
signal: options?.signal
|
|
99
277
|
? AbortSignal.any([options.signal, watchdog.signal])
|
|
100
278
|
: watchdog.signal,
|
|
101
|
-
maxRetries: options?.maxRetries ??
|
|
279
|
+
maxRetries: options?.maxRetries ?? defaultMaxRetries,
|
|
280
|
+
sessionId: options?.sessionId ?? defaultSessionId,
|
|
281
|
+
onPayload: async (payload, activeModel) =>
|
|
282
|
+
pdfPayload(
|
|
283
|
+
await options?.onPayload?.(payload, activeModel) ?? payload,
|
|
284
|
+
activeModel.api,
|
|
285
|
+
),
|
|
102
286
|
});
|
|
103
287
|
return stopStalledModelStream(source, watchdog);
|
|
104
288
|
});
|
|
@@ -188,8 +372,14 @@ function configuredModel(
|
|
|
188
372
|
api: apiFor(endpoint.protocol),
|
|
189
373
|
provider: providerId(endpoint, index),
|
|
190
374
|
baseUrl: endpoint.baseURL,
|
|
191
|
-
...(endpoint.protocol === "openrouter-chat"
|
|
192
|
-
? {
|
|
375
|
+
...(endpoint.protocol === "openrouter-chat"
|
|
376
|
+
? {
|
|
377
|
+
compat: {
|
|
378
|
+
...compat,
|
|
379
|
+
sendSessionAffinityHeaders: true,
|
|
380
|
+
sessionAffinityFormat: "openrouter" as const,
|
|
381
|
+
},
|
|
382
|
+
}
|
|
193
383
|
: {}),
|
|
194
384
|
...(Object.keys(headers).length > 0 ? { headers } : {}),
|
|
195
385
|
};
|
|
@@ -265,7 +455,9 @@ export function configurePiModels(
|
|
|
265
455
|
models: endpoint.models.map((modelId) =>
|
|
266
456
|
configuredModel(endpoint, index, modelId),
|
|
267
457
|
),
|
|
268
|
-
|
|
458
|
+
// cloudflareStreams 在运行时把 baseURL 里的 {CLOUDFLARE_ACCOUNT_ID}/{CLOUDFLARE_GATEWAY_ID}
|
|
459
|
+
// 占位符替换成真实值;如果 baseURL 不含占位符则直接透传,不影响现有端点。
|
|
460
|
+
api: cloudflareStreams(piApiFor(endpoint.protocol)),
|
|
269
461
|
})
|
|
270
462
|
);
|
|
271
463
|
models.clearProviders();
|
|
@@ -29,6 +29,10 @@ import {
|
|
|
29
29
|
import type { PiCanonicalMessageCommit } from "./execution";
|
|
30
30
|
import type { PiRecoveredToolSettlement } from "./recovery";
|
|
31
31
|
import type { RuntimeModelUsageEvent } from "../../kernel/bindings";
|
|
32
|
+
import {
|
|
33
|
+
isModelStreamStallMessage,
|
|
34
|
+
isRecoverableAssistantError,
|
|
35
|
+
} from "./models";
|
|
32
36
|
|
|
33
37
|
/**
|
|
34
38
|
* 本文件负责保存 Pi 的规范消息,并按浏览器或 Extension Host 的需要投影历史。
|
|
@@ -196,6 +200,15 @@ function hostText(message: UserMessage | AssistantMessage): string {
|
|
|
196
200
|
export class PiRuntimeTranscript {
|
|
197
201
|
private readonly store: PiTranscriptStore;
|
|
198
202
|
|
|
203
|
+
private modelBranch() {
|
|
204
|
+
return this.store.branch().filter((entry) =>
|
|
205
|
+
entry.type !== "message" ||
|
|
206
|
+
entry.message.role !== "assistant" ||
|
|
207
|
+
(!isModelStreamStallMessage(entry.message.errorMessage) &&
|
|
208
|
+
!isRecoverableAssistantError(entry.message))
|
|
209
|
+
);
|
|
210
|
+
}
|
|
211
|
+
|
|
199
212
|
/**
|
|
200
213
|
* 用当前 Agent 的 SQLite、Runtime 持久化端口和 Pi 模型表创建 Transcript。
|
|
201
214
|
*
|
|
@@ -383,7 +396,7 @@ export class PiRuntimeTranscript {
|
|
|
383
396
|
* 这里只返回 `message` entry;compaction 等 Session entry 由上下文转换阶段处理,不能混入原始消息列表。
|
|
384
397
|
*/
|
|
385
398
|
async canonicalMessages(): Promise<AgentMessage[]> {
|
|
386
|
-
return this.
|
|
399
|
+
return this.modelBranch().flatMap((entry) =>
|
|
387
400
|
entry.type === "message" ? [entry.message] : []
|
|
388
401
|
);
|
|
389
402
|
}
|
|
@@ -449,7 +462,7 @@ export class PiRuntimeTranscript {
|
|
|
449
462
|
onCompactionPersisted?: (event: RuntimeModelUsageEvent) => void;
|
|
450
463
|
},
|
|
451
464
|
): Promise<AgentMessage[]> {
|
|
452
|
-
const branch = this.
|
|
465
|
+
const branch = this.modelBranch();
|
|
453
466
|
const current = branch.flatMap((entry) =>
|
|
454
467
|
entry.type === "message" ? [entry.message] : []
|
|
455
468
|
);
|
|
@@ -616,10 +629,47 @@ export class PiRuntimeTranscript {
|
|
|
616
629
|
async browserMessages(): Promise<UIMessage[]> {
|
|
617
630
|
const result: UIMessage[] = [];
|
|
618
631
|
const seenAssistantSubmissions = new Set<string>();
|
|
632
|
+
const entries = await this.storedMessages();
|
|
633
|
+
const assistantSubmissions = new Set(entries.flatMap((entry) =>
|
|
634
|
+
entry.submissionId && entry.message.role === "assistant"
|
|
635
|
+
? [entry.submissionId]
|
|
636
|
+
: []
|
|
637
|
+
));
|
|
638
|
+
let pendingFailedSubmissionId: string | undefined;
|
|
639
|
+
const flushFailedSubmission = () => {
|
|
640
|
+
if (!pendingFailedSubmissionId) return;
|
|
641
|
+
const submissionId = pendingFailedSubmissionId;
|
|
642
|
+
pendingFailedSubmissionId = undefined;
|
|
643
|
+
const submission = this.durability.findSubmissionProjection(submissionId);
|
|
644
|
+
if (submission?.status !== "error") return;
|
|
645
|
+
const completedAt = submission.completedAt ?? submission.createdAt;
|
|
646
|
+
result.push({
|
|
647
|
+
id: submission.assistantMessageId,
|
|
648
|
+
role: "assistant",
|
|
649
|
+
parts: [],
|
|
650
|
+
metadata: {
|
|
651
|
+
createdAt: submission.createdAt,
|
|
652
|
+
completedAt,
|
|
653
|
+
turnDurationMs: Math.max(
|
|
654
|
+
0,
|
|
655
|
+
completedAt - submission.createdAt,
|
|
656
|
+
),
|
|
657
|
+
turnStatus: "error",
|
|
658
|
+
...(submission.error ? { error: submission.error } : {}),
|
|
659
|
+
},
|
|
660
|
+
});
|
|
661
|
+
seenAssistantSubmissions.add(submissionId);
|
|
662
|
+
};
|
|
619
663
|
let activeAssistant:
|
|
620
664
|
| { submissionId: string; index: number }
|
|
621
665
|
| undefined;
|
|
622
|
-
for (const entry of
|
|
666
|
+
for (const entry of entries) {
|
|
667
|
+
if (
|
|
668
|
+
pendingFailedSubmissionId &&
|
|
669
|
+
entry.submissionId !== pendingFailedSubmissionId
|
|
670
|
+
) {
|
|
671
|
+
flushFailedSubmission();
|
|
672
|
+
}
|
|
623
673
|
if (entry.message.role === "user") {
|
|
624
674
|
const body = this.durability.readUserSidecar(entry.id);
|
|
625
675
|
result.push(userUIMessage(entry.message, {
|
|
@@ -629,6 +679,13 @@ export class PiRuntimeTranscript {
|
|
|
629
679
|
? JSON.parse(body) as UIUserSidecar
|
|
630
680
|
: undefined,
|
|
631
681
|
}));
|
|
682
|
+
if (
|
|
683
|
+
entry.submissionId &&
|
|
684
|
+
!assistantSubmissions.has(entry.submissionId) &&
|
|
685
|
+
!seenAssistantSubmissions.has(entry.submissionId)
|
|
686
|
+
) {
|
|
687
|
+
pendingFailedSubmissionId = entry.submissionId;
|
|
688
|
+
}
|
|
632
689
|
activeAssistant = undefined;
|
|
633
690
|
continue;
|
|
634
691
|
}
|
|
@@ -744,6 +801,7 @@ export class PiRuntimeTranscript {
|
|
|
744
801
|
}
|
|
745
802
|
}
|
|
746
803
|
}
|
|
804
|
+
flushFailedSubmission();
|
|
747
805
|
return result;
|
|
748
806
|
}
|
|
749
807
|
|
|
@@ -38,6 +38,52 @@ function resolveDescription(tool: AiTool): string {
|
|
|
38
38
|
return typeof raw === "string" ? raw : "";
|
|
39
39
|
}
|
|
40
40
|
|
|
41
|
+
function modelContent(
|
|
42
|
+
output: unknown,
|
|
43
|
+
): AgentToolResult<unknown>["content"] | null {
|
|
44
|
+
if (typeof output !== "object" || output === null) return null;
|
|
45
|
+
const modelOutput = output as { type?: unknown; value?: unknown };
|
|
46
|
+
if (
|
|
47
|
+
(modelOutput.type === "text" || modelOutput.type === "error-text") &&
|
|
48
|
+
typeof modelOutput.value === "string"
|
|
49
|
+
) {
|
|
50
|
+
return [{ type: "text", text: modelOutput.value }];
|
|
51
|
+
}
|
|
52
|
+
if (modelOutput.type === "json") {
|
|
53
|
+
return [{ type: "text", text: serializeOutput(modelOutput.value).text }];
|
|
54
|
+
}
|
|
55
|
+
if (modelOutput.type !== "content" || !Array.isArray(modelOutput.value)) {
|
|
56
|
+
return null;
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
const content: AgentToolResult<unknown>["content"] = [];
|
|
60
|
+
for (const block of modelOutput.value) {
|
|
61
|
+
if (typeof block !== "object" || block === null) continue;
|
|
62
|
+
const item = block as {
|
|
63
|
+
type?: unknown;
|
|
64
|
+
text?: unknown;
|
|
65
|
+
data?: unknown;
|
|
66
|
+
mediaType?: unknown;
|
|
67
|
+
};
|
|
68
|
+
if (item.type === "text" && typeof item.text === "string") {
|
|
69
|
+
content.push({ type: "text", text: item.text });
|
|
70
|
+
continue;
|
|
71
|
+
}
|
|
72
|
+
if (
|
|
73
|
+
(item.type === "file-data" || item.type === "image-data") &&
|
|
74
|
+
typeof item.data === "string" &&
|
|
75
|
+
typeof item.mediaType === "string"
|
|
76
|
+
) {
|
|
77
|
+
content.push({
|
|
78
|
+
type: "image",
|
|
79
|
+
data: item.data,
|
|
80
|
+
mimeType: item.mediaType,
|
|
81
|
+
});
|
|
82
|
+
}
|
|
83
|
+
}
|
|
84
|
+
return content.length > 0 ? content : null;
|
|
85
|
+
}
|
|
86
|
+
|
|
41
87
|
/**
|
|
42
88
|
* 把一个可执行的 ai-sdk Tool 转成 Pi AgentTool。
|
|
43
89
|
*
|
|
@@ -91,8 +137,19 @@ export function aiToolToPi(
|
|
|
91
137
|
context: undefined,
|
|
92
138
|
});
|
|
93
139
|
const details = options.details ? options.details(raw) : raw;
|
|
140
|
+
const toModelOutput = (tool as { toModelOutput?: unknown })
|
|
141
|
+
.toModelOutput;
|
|
142
|
+
const content = typeof toModelOutput === "function"
|
|
143
|
+
? modelContent(await (toModelOutput as (input: {
|
|
144
|
+
input: unknown;
|
|
145
|
+
output: unknown;
|
|
146
|
+
}) => unknown)({ input: params, output: raw }))
|
|
147
|
+
: null;
|
|
94
148
|
return {
|
|
95
|
-
content: [{
|
|
149
|
+
content: content ?? [{
|
|
150
|
+
type: "text",
|
|
151
|
+
text: serializeOutput(details).text,
|
|
152
|
+
}],
|
|
96
153
|
details,
|
|
97
154
|
};
|
|
98
155
|
},
|
package/src/pi/tool/base.ts
CHANGED
|
@@ -2,7 +2,10 @@ import type {
|
|
|
2
2
|
AgentTool,
|
|
3
3
|
AgentToolResult,
|
|
4
4
|
} from "@earendil-works/pi-agent-core";
|
|
5
|
-
import { Type, type TSchema } from "@earendil-works/pi-ai";
|
|
5
|
+
import { Type, type Static, type TSchema } from "@earendil-works/pi-ai";
|
|
6
|
+
import { estimateStringTokens } from "agents/experimental/memory/utils";
|
|
7
|
+
import type { RuntimeMemoryPort } from "../../kernel/bindings";
|
|
8
|
+
import type { RuntimeMemoryProfile } from "../../kernel/profile";
|
|
6
9
|
import { serializeOutput } from "../../lib/artifacts";
|
|
7
10
|
import type { PiToolCandidate } from "./compiler";
|
|
8
11
|
import { webSearchPiToolCandidate } from "./web-search";
|
|
@@ -42,13 +45,63 @@ const updatePlanParameters = Type.Object({
|
|
|
42
45
|
Type.Literal("pending"),
|
|
43
46
|
Type.Literal("in_progress"),
|
|
44
47
|
Type.Literal("done"),
|
|
45
|
-
], {
|
|
48
|
+
], {
|
|
49
|
+
description:
|
|
50
|
+
'Current status. Use "done" for a finished step; never use "completed".',
|
|
51
|
+
}),
|
|
46
52
|
}), {
|
|
47
53
|
description:
|
|
48
54
|
"The complete, ordered plan. Overwrites any previously reported plan.",
|
|
49
55
|
}),
|
|
50
56
|
});
|
|
51
57
|
|
|
58
|
+
type UpdatePlanArguments = Static<typeof updatePlanParameters>;
|
|
59
|
+
|
|
60
|
+
function normalizeStepStatus(
|
|
61
|
+
status: unknown,
|
|
62
|
+
): UpdatePlanArguments["steps"][number]["status"] {
|
|
63
|
+
switch (status) {
|
|
64
|
+
case "done":
|
|
65
|
+
case "completed": // Anthropic models sometimes output "completed"
|
|
66
|
+
return "done";
|
|
67
|
+
case "in_progress":
|
|
68
|
+
case "inProgress": // camelCase variant
|
|
69
|
+
return "in_progress";
|
|
70
|
+
default: // covers "pending", "todo" (Google), and any other unknown value
|
|
71
|
+
return "pending";
|
|
72
|
+
}
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
export function normalizeUpdatePlanArguments(
|
|
76
|
+
input: unknown,
|
|
77
|
+
): UpdatePlanArguments {
|
|
78
|
+
if (input === null || typeof input !== "object") {
|
|
79
|
+
return input as UpdatePlanArguments;
|
|
80
|
+
}
|
|
81
|
+
const value = input as Record<string, unknown>;
|
|
82
|
+
if (!Array.isArray(value.steps)) return input as UpdatePlanArguments;
|
|
83
|
+
return {
|
|
84
|
+
...value,
|
|
85
|
+
steps: value.steps.map((step) => {
|
|
86
|
+
if (step === null || typeof step !== "object") return step;
|
|
87
|
+
const s = step as Record<string, unknown>;
|
|
88
|
+
return { ...s, status: normalizeStepStatus(s.status) };
|
|
89
|
+
}),
|
|
90
|
+
} as UpdatePlanArguments;
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
const setContextParameters = Type.Object({
|
|
94
|
+
label: Type.Union([
|
|
95
|
+
Type.Literal("memory"),
|
|
96
|
+
Type.Literal("preferences"),
|
|
97
|
+
]),
|
|
98
|
+
content: Type.String(),
|
|
99
|
+
action: Type.Optional(Type.Union([
|
|
100
|
+
Type.Literal("replace"),
|
|
101
|
+
Type.Literal("append"),
|
|
102
|
+
])),
|
|
103
|
+
});
|
|
104
|
+
|
|
52
105
|
function result<T>(details: T): AgentToolResult<T> {
|
|
53
106
|
return {
|
|
54
107
|
content: [{ type: "text", text: serializeOutput(details).text }],
|
|
@@ -97,6 +150,7 @@ export function basePiToolCandidates(
|
|
|
97
150
|
description:
|
|
98
151
|
"Maintain the user-visible plan for the current task. Call this whenever a task involves 2 or more steps, and again every time the plan or a step's status changes. Always pass the FULL plan — it replaces the previous plan entirely (idempotent overwrite), so omitted steps disappear.",
|
|
99
152
|
parameters: updatePlanParameters,
|
|
153
|
+
prepareArguments: normalizeUpdatePlanArguments,
|
|
100
154
|
async execute(_toolCallId, input) {
|
|
101
155
|
return result({
|
|
102
156
|
ok: true,
|
|
@@ -108,3 +162,59 @@ export function basePiToolCandidates(
|
|
|
108
162
|
...(webSearch ? [webSearchPiToolCandidate(webSearch)] : []),
|
|
109
163
|
];
|
|
110
164
|
}
|
|
165
|
+
|
|
166
|
+
/** Writable hot-memory Tool restored from Think Session context blocks. */
|
|
167
|
+
export function memoryPiToolCandidate(
|
|
168
|
+
memory: RuntimeMemoryPort,
|
|
169
|
+
profile: Pick<
|
|
170
|
+
RuntimeMemoryProfile,
|
|
171
|
+
"memoryTokens" | "preferencesTokens"
|
|
172
|
+
>,
|
|
173
|
+
): PiToolCandidate {
|
|
174
|
+
const queues = new Map<string, Promise<void>>();
|
|
175
|
+
const tool: AgentTool<typeof setContextParameters> = {
|
|
176
|
+
name: "set_context",
|
|
177
|
+
label: "Set context",
|
|
178
|
+
description:
|
|
179
|
+
"Replace or append durable working context. Use memory for facts and active context; use preferences for tone, format, tools, and workflow preferences.",
|
|
180
|
+
parameters: setContextParameters,
|
|
181
|
+
async execute(_toolCallId, { label, content, action = "replace" }) {
|
|
182
|
+
const run = async () => {
|
|
183
|
+
const existing = action === "append"
|
|
184
|
+
? await memory.get(label) ?? ""
|
|
185
|
+
: "";
|
|
186
|
+
const separator = existing && !content.startsWith("\n") ? "\n" : "";
|
|
187
|
+
const updated = `${existing}${separator}${content}`;
|
|
188
|
+
const maxTokens = label === "memory"
|
|
189
|
+
? profile.memoryTokens
|
|
190
|
+
: profile.preferencesTokens;
|
|
191
|
+
const tokens = estimateStringTokens(updated);
|
|
192
|
+
if (tokens > maxTokens) {
|
|
193
|
+
throw new Error(
|
|
194
|
+
`Block "${label}" exceeds maxTokens: ${tokens} > ${maxTokens}`,
|
|
195
|
+
);
|
|
196
|
+
}
|
|
197
|
+
await memory.set(label, updated);
|
|
198
|
+
return result({ label, action, tokens, maxTokens });
|
|
199
|
+
};
|
|
200
|
+
const tail = queues.get(label) ?? Promise.resolve();
|
|
201
|
+
const output = tail.then(run);
|
|
202
|
+
const next = output.then(
|
|
203
|
+
() => {},
|
|
204
|
+
() => {},
|
|
205
|
+
);
|
|
206
|
+
queues.set(label, next);
|
|
207
|
+
void next.then(() => {
|
|
208
|
+
if (queues.get(label) === next) queues.delete(label);
|
|
209
|
+
});
|
|
210
|
+
return output;
|
|
211
|
+
},
|
|
212
|
+
};
|
|
213
|
+
return {
|
|
214
|
+
owner: "runtime-memory",
|
|
215
|
+
authorized: true,
|
|
216
|
+
requiredExecutionLevel: "safe",
|
|
217
|
+
source: "action",
|
|
218
|
+
tool,
|
|
219
|
+
};
|
|
220
|
+
}
|