@springbrand/agent-runtime 0.3.0-alpha.16 → 0.3.0-alpha.17
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 +6 -4
- package/src/adapter/cloudflare/agent.ts +66 -122
- package/src/adapter/cloudflare/subagent/definition.ts +11 -9
- package/src/adapter/cloudflare/subagent/runner.ts +20 -47
- package/src/adapter/cloudflare/subagent/tools.ts +2 -2
- package/src/config/harness-runtime.ts +27 -14
- package/src/config/model-guidance.ts +16 -17
- package/src/config/ports.ts +2 -0
- package/src/conversation/harness-resume.ts +69 -234
- package/src/conversation/harness-stream.ts +69 -70
- package/src/conversation/history.ts +118 -153
- package/src/conversation/message/contract.ts +12 -0
- package/src/conversation/session-entry.ts +29 -0
- package/src/harness/index.ts +2 -0
- package/src/harness/session-harness.ts +178 -617
- package/src/harness/submission-operation.ts +15 -40
- package/src/models/index.ts +3 -1
- package/src/models/openrouter/protocol-response.ts +36 -112
- package/src/session/cloudflare/migration.ts +221 -939
- package/src/session/cloudflare/storage.ts +13 -536
- package/src/session/index.ts +22 -35
- package/src/submission/finalization.ts +2 -13
- package/src/submission/repository.ts +1 -22
- package/src/submission/steer-delivery.ts +32 -76
- package/src/telemetry/turn-events.ts +34 -24
- package/src/tools/runtime/compiler.ts +25 -50
- package/src/tools/runtime/decision-gate.ts +5 -5
|
@@ -1,8 +1,7 @@
|
|
|
1
|
-
import type {
|
|
2
|
-
|
|
3
|
-
|
|
4
|
-
|
|
5
|
-
} from "@earendil-works/pi-agent-core";
|
|
1
|
+
import type { AgentMessage } from "@earendil-works/pi-agent-core";
|
|
2
|
+
import { createSession, type EntryId, type EntryRecord, type Session, type Storage, type SnapshotEvent } from "@earendil-works/pi-durable";
|
|
3
|
+
import { BACKGROUND_CONTEXT } from "@earendil-works/chord/context";
|
|
4
|
+
import { projectSessionEntry, type ProjectedSessionEntry as Entry } from "./session-entry";
|
|
6
5
|
import type {
|
|
7
6
|
AssistantMessage,
|
|
8
7
|
JsonValue,
|
|
@@ -18,11 +17,7 @@ import {
|
|
|
18
17
|
type PiToolApprovalView,
|
|
19
18
|
type UIUserSidecar,
|
|
20
19
|
} from "./message";
|
|
21
|
-
import {
|
|
22
|
-
import {
|
|
23
|
-
isModelStreamStallMessage,
|
|
24
|
-
isRecoverableAssistantError,
|
|
25
|
-
} from "../models";
|
|
20
|
+
import { openCloudflareSession, RuntimeConversation } from "../session";
|
|
26
21
|
|
|
27
22
|
/**
|
|
28
23
|
* 本文件负责保存 Pi 的规范消息,并按浏览器或 Extension Host 的需要投影历史。
|
|
@@ -204,52 +199,42 @@ function hostText(message: UserMessage | AssistantMessage): string {
|
|
|
204
199
|
* Pi Session 保存规范分支,Runtime 数据库保存 Submission、审批和用户 sidecar;分开保存可避免把 UI 字段写进模型上下文。
|
|
205
200
|
*/
|
|
206
201
|
export class ConversationHistory {
|
|
207
|
-
private readonly store: CloudflareSessionStorage;
|
|
208
|
-
|
|
209
|
-
private modelBranch() {
|
|
210
|
-
return this.store.branch().filter((entry) =>
|
|
211
|
-
entry.type !== "message" ||
|
|
212
|
-
entry.message.role !== "assistant" ||
|
|
213
|
-
(!isModelStreamStallMessage(entry.message.errorMessage) &&
|
|
214
|
-
!isRecoverableAssistantError(entry.message))
|
|
215
|
-
);
|
|
216
|
-
}
|
|
217
|
-
|
|
218
|
-
/** Resolve a browser-stable user ID to the Session entry generated by Harness. */
|
|
219
|
-
private storedMessageId(id: string): string {
|
|
220
|
-
const direct = this.store.entry(id);
|
|
221
|
-
if (direct?.type === "message") return id;
|
|
222
|
-
const submissionByMessage =
|
|
223
|
-
this.durability.listSessionEntrySubmissionLinks();
|
|
224
|
-
return this.store.branch().find((entry) => {
|
|
225
|
-
if (entry.type !== "message" || entry.message.role !== "user") {
|
|
226
|
-
return false;
|
|
227
|
-
}
|
|
228
|
-
const body = this.durability.readUserSidecar(entry.id);
|
|
229
|
-
if (body) {
|
|
230
|
-
const sidecar = JSON.parse(body) as UIUserSidecar & { id?: unknown };
|
|
231
|
-
if (sidecar.id === id) return true;
|
|
232
|
-
}
|
|
233
|
-
const submissionId = submissionByMessage.get(entry.id);
|
|
234
|
-
return submissionId !== undefined &&
|
|
235
|
-
this.durability.findSubmissionProjection(submissionId)?.userMessageId ===
|
|
236
|
-
id;
|
|
237
|
-
})?.id ?? id;
|
|
238
|
-
}
|
|
239
|
-
|
|
240
|
-
/**
|
|
241
|
-
* 用当前 Agent 的 SQLite、Runtime 持久化端口和 Pi 模型表创建 Transcript。
|
|
242
|
-
*
|
|
243
|
-
* `AgentRuntimeKernel` 每个实例构造时调用一次;`hasActiveTurn` 必须反映当前 Submission 生命周期。
|
|
244
|
-
*
|
|
245
|
-
* 官方 Storage 契约由同一 DO SQLite 实现;模型目录用于压缩,活动 Turn 检查阻止并发导入。
|
|
246
|
-
*/
|
|
247
202
|
constructor(
|
|
248
|
-
storage: DurableObjectStorage,
|
|
203
|
+
private readonly storage: DurableObjectStorage,
|
|
249
204
|
private readonly durability: PiTranscriptDurability,
|
|
250
205
|
private readonly hasActiveTurn: () => boolean,
|
|
251
|
-
) {
|
|
252
|
-
|
|
206
|
+
) {}
|
|
207
|
+
|
|
208
|
+
private async withSession<T>(run: (session: Session, store: Storage) => Promise<T>): Promise<T> {
|
|
209
|
+
const store = await openCloudflareSession(this.storage);
|
|
210
|
+
const session = createSession(store);
|
|
211
|
+
try { return await run(session, store); }
|
|
212
|
+
finally { await session.close(BACKGROUND_CONTEXT); }
|
|
213
|
+
}
|
|
214
|
+
private async nativeEntries(maxEntryId?: EntryId): Promise<EntryRecord[]> {
|
|
215
|
+
return this.withSession(async (session, store) => {
|
|
216
|
+
const pointer = await session.snapshot(RuntimeConversation, BACKGROUND_CONTEXT);
|
|
217
|
+
if (!pointer?.id) return [];
|
|
218
|
+
const entries: EntryRecord[] = [];
|
|
219
|
+
let cursor;
|
|
220
|
+
do {
|
|
221
|
+
const page = await store.scanEntries({ conversationId: pointer.id, ...(maxEntryId !== undefined ? { maxEntryId } : {}), order: "ascending" }, 200, cursor, BACKGROUND_CONTEXT);
|
|
222
|
+
entries.push(...page.items); cursor = page.next;
|
|
223
|
+
} while (cursor);
|
|
224
|
+
return entries;
|
|
225
|
+
});
|
|
226
|
+
}
|
|
227
|
+
private async storedMessageId(id: string): Promise<string> {
|
|
228
|
+
const links = this.durability.listSessionEntrySubmissionLinks();
|
|
229
|
+
for (const entry of await this.nativeEntries()) {
|
|
230
|
+
const key = String(entry.id);
|
|
231
|
+
if (key === id || (entry.data as { displayId?: string } | undefined)?.displayId === id) return key;
|
|
232
|
+
const body = this.durability.readUserSidecar(key);
|
|
233
|
+
if (body && (JSON.parse(body) as { id?: string }).id === id) return key;
|
|
234
|
+
const owner = links.get(key);
|
|
235
|
+
if (owner && this.durability.findSubmissionProjection(owner)?.userMessageId === id) return key;
|
|
236
|
+
}
|
|
237
|
+
return id;
|
|
253
238
|
}
|
|
254
239
|
|
|
255
240
|
// #region 规范消息写入
|
|
@@ -261,33 +246,38 @@ export class ConversationHistory {
|
|
|
261
246
|
*
|
|
262
247
|
* sidecar 只随首次成功插入写入,使消息 ID 同时承担幂等键,重复请求不能覆盖原视图。
|
|
263
248
|
*/
|
|
264
|
-
append(
|
|
249
|
+
async append(
|
|
265
250
|
id: string,
|
|
266
251
|
message: AgentMessage,
|
|
267
|
-
options: {
|
|
268
|
-
|
|
269
|
-
|
|
270
|
-
|
|
271
|
-
|
|
272
|
-
|
|
273
|
-
|
|
274
|
-
|
|
275
|
-
|
|
276
|
-
|
|
277
|
-
|
|
278
|
-
|
|
279
|
-
|
|
280
|
-
|
|
281
|
-
|
|
282
|
-
|
|
283
|
-
|
|
252
|
+
options: { submissionId?: string; userMessage?: UIMessage & { role: "user" } } = {},
|
|
253
|
+
): Promise<boolean> {
|
|
254
|
+
if ((await this.nativeEntries()).some((entry) => (entry.data as { displayId?: string } | undefined)?.displayId === id)) return false;
|
|
255
|
+
return this.storage.transaction(() => this.withSession(async (session) => {
|
|
256
|
+
const pointer = await session.snapshot(RuntimeConversation, BACKGROUND_CONTEXT);
|
|
257
|
+
let conversationId = pointer?.id;
|
|
258
|
+
if (!conversationId) conversationId = await session.commit(async (tx) => {
|
|
259
|
+
const conversation = await tx.createConversation({ ownership: { kind: "ownerless" } });
|
|
260
|
+
(await tx.doc(RuntimeConversation)).id = conversation.id;
|
|
261
|
+
return conversation.id;
|
|
262
|
+
}, BACKGROUND_CONTEXT);
|
|
263
|
+
await session.commit(async (tx) => {
|
|
264
|
+
const custom = message.role === "custom";
|
|
265
|
+
const entry = await tx.appendEntry(conversationId!, {
|
|
266
|
+
kind: custom ? "springbrand.custom" : message.role === "toolResult" ? "pi.tool-result" : `pi.${message.role}`,
|
|
267
|
+
...(custom ? {} : { model: [message] }),
|
|
268
|
+
data: JSON.parse(JSON.stringify({ displayId: id, createdAt: message.timestamp, ...(custom ? { message } : {}), ...(options.submissionId ? { submissionId: options.submissionId } : {}), ...(message.role === "toolResult" ? { diagnostics: [] } : {}) })),
|
|
269
|
+
});
|
|
270
|
+
if (options.userMessage) this.durability.upsertUserSidecar(String(entry.id), JSON.stringify({ id: options.userMessage.id, ...captureUIUserSidecar(options.userMessage) }));
|
|
271
|
+
}, BACKGROUND_CONTEXT);
|
|
272
|
+
return true;
|
|
273
|
+
}));
|
|
284
274
|
}
|
|
285
275
|
|
|
286
276
|
/** Persist a stable marker so browser history can distinguish a user stop from other aborts. */
|
|
287
277
|
appendTurnAbortedMarker(
|
|
288
278
|
submissionId: string,
|
|
289
279
|
timestamp: number,
|
|
290
|
-
): boolean {
|
|
280
|
+
): Promise<boolean> {
|
|
291
281
|
return this.append(`${submissionId}:turn-aborted`, {
|
|
292
282
|
role: "custom",
|
|
293
283
|
customType: "turn-aborted",
|
|
@@ -312,7 +302,7 @@ export class ConversationHistory {
|
|
|
312
302
|
* 这里只返回 `message` entry;compaction 等 Session entry 由上下文转换阶段处理,不能混入原始消息列表。
|
|
313
303
|
*/
|
|
314
304
|
async canonicalMessages(): Promise<AgentMessage[]> {
|
|
315
|
-
return this.
|
|
305
|
+
return (await this.storedEntries()).map(({ entry }) => entry).flatMap((entry) =>
|
|
316
306
|
entry.type === "message" ? [entry.message] : []
|
|
317
307
|
);
|
|
318
308
|
}
|
|
@@ -325,10 +315,13 @@ export class ConversationHistory {
|
|
|
325
315
|
* 同时检查 entry 类型和消息角色,防止其他 Session entry 或助手消息被当作 regenerate 起点。
|
|
326
316
|
*/
|
|
327
317
|
async findUserMessage(id: string): Promise<UserMessage | undefined> {
|
|
328
|
-
const
|
|
329
|
-
|
|
330
|
-
|
|
331
|
-
|
|
318
|
+
const key = await this.storedMessageId(id);
|
|
319
|
+
if (!Number.isSafeInteger(Number(key)) || Number(key) <= 0) return undefined;
|
|
320
|
+
return this.withSession(async (_session, store) => {
|
|
321
|
+
const native = await store.entry(Number(key) as EntryId, BACKGROUND_CONTEXT);
|
|
322
|
+
const message = native?.entry.model?.[0];
|
|
323
|
+
return message?.role === "user" ? message : undefined;
|
|
324
|
+
});
|
|
332
325
|
}
|
|
333
326
|
|
|
334
327
|
// #endregion
|
|
@@ -336,12 +329,12 @@ export class ConversationHistory {
|
|
|
336
329
|
// #region 分支、压缩与导入
|
|
337
330
|
|
|
338
331
|
/** Resolve the branch position before the user prompt that will be regenerated. */
|
|
339
|
-
regenerationTarget(id: string):
|
|
340
|
-
const
|
|
341
|
-
|
|
342
|
-
|
|
343
|
-
}
|
|
344
|
-
return
|
|
332
|
+
async regenerationTarget(id: string): Promise<EntryId | null> {
|
|
333
|
+
const key = await this.storedMessageId(id);
|
|
334
|
+
const entries = await this.nativeEntries();
|
|
335
|
+
const index = entries.findIndex((entry) => String(entry.id) === key && entry.model?.[0]?.role === "user");
|
|
336
|
+
if (index < 0) throw new Error(`Unknown regeneration user message: ${id}`);
|
|
337
|
+
return entries[index - 1]?.id ?? null;
|
|
345
338
|
}
|
|
346
339
|
|
|
347
340
|
/**
|
|
@@ -353,35 +346,18 @@ export class ConversationHistory {
|
|
|
353
346
|
*
|
|
354
347
|
* 待确认:当前代码没有拒绝以助手消息开头的历史,而这类消息没有 Submission 关联,会被浏览器投影略过。
|
|
355
348
|
*/
|
|
356
|
-
importSnapshot(snapshot: PiCanonicalTranscriptSnapshot): void {
|
|
357
|
-
if (this.hasActiveTurn())
|
|
358
|
-
|
|
349
|
+
async importSnapshot(snapshot: PiCanonicalTranscriptSnapshot): Promise<void> {
|
|
350
|
+
if (this.hasActiveTurn()) throw new Error("Cannot import messages while a Turn is active");
|
|
351
|
+
await this.storage.transaction(async () => {
|
|
352
|
+
let submissionId: string | undefined;
|
|
353
|
+
for (const entry of snapshot.entries) {
|
|
354
|
+
if (entry.message.role === "user") submissionId = `import:${entry.id}`;
|
|
355
|
+
const inserted = await this.append(entry.id, entry.message, {
|
|
356
|
+
submissionId,
|
|
357
|
+
...(entry.message.role === "user" && entry.userSidecar ? { userMessage: { id: entry.id, role: "user" as const, parts: [...entry.userSidecar.parts], metadata: entry.userSidecar.metadata } } : {}),
|
|
358
|
+
});
|
|
359
|
+
if (!inserted) throw new Error(`Session message "${entry.id}" already exists`);
|
|
359
360
|
}
|
|
360
|
-
this.durability.transaction(() => {
|
|
361
|
-
let currentSubmissionId: string | undefined;
|
|
362
|
-
for (const entry of snapshot.entries) {
|
|
363
|
-
if (entry.message.role === "user") {
|
|
364
|
-
currentSubmissionId = `import:${entry.id}`;
|
|
365
|
-
}
|
|
366
|
-
const appended = this.append(entry.id, entry.message, {
|
|
367
|
-
submissionId: currentSubmissionId,
|
|
368
|
-
...(entry.message.role === "user" && entry.userSidecar
|
|
369
|
-
? {
|
|
370
|
-
userMessage: {
|
|
371
|
-
id: entry.id,
|
|
372
|
-
role: "user",
|
|
373
|
-
parts: [...entry.userSidecar.parts],
|
|
374
|
-
...(entry.userSidecar.metadata === undefined
|
|
375
|
-
? {}
|
|
376
|
-
: { metadata: entry.userSidecar.metadata }),
|
|
377
|
-
} satisfies UIMessage & { role: "user" },
|
|
378
|
-
}
|
|
379
|
-
: {}),
|
|
380
|
-
});
|
|
381
|
-
if (!appended) {
|
|
382
|
-
throw new Error(`SpringBrand Session message "${entry.id}" already exists`);
|
|
383
|
-
}
|
|
384
|
-
}
|
|
385
361
|
});
|
|
386
362
|
}
|
|
387
363
|
|
|
@@ -390,7 +366,7 @@ export class ConversationHistory {
|
|
|
390
366
|
throughMessageId: string,
|
|
391
367
|
): Promise<PiCanonicalTranscriptSnapshot> {
|
|
392
368
|
const stored = await this.storedMessages();
|
|
393
|
-
const storedId = this.storedMessageId(throughMessageId);
|
|
369
|
+
const storedId = await this.storedMessageId(throughMessageId);
|
|
394
370
|
let cutoff = stored.findIndex((entry) => entry.id === storedId);
|
|
395
371
|
if (cutoff < 0) {
|
|
396
372
|
const targetSubmissionId = stored.find((entry) => {
|
|
@@ -442,8 +418,11 @@ export class ConversationHistory {
|
|
|
442
418
|
*
|
|
443
419
|
* 两类 Transcript 数据必须一起删除,否则复用消息 ID 时可能重新读到旧附件或 UI 元数据。
|
|
444
420
|
*/
|
|
445
|
-
clear(): void {
|
|
446
|
-
this.
|
|
421
|
+
async clear(): Promise<void> {
|
|
422
|
+
await this.withSession((session) => session.commit(async (tx) => {
|
|
423
|
+
const conversation = await tx.createConversation({ ownership: { kind: "ownerless" } });
|
|
424
|
+
(await tx.doc(RuntimeConversation)).id = conversation.id;
|
|
425
|
+
}, BACKGROUND_CONTEXT));
|
|
447
426
|
this.durability.clearUserSidecars();
|
|
448
427
|
}
|
|
449
428
|
|
|
@@ -463,39 +442,17 @@ export class ConversationHistory {
|
|
|
463
442
|
}
|
|
464
443
|
|
|
465
444
|
/** Reconnect uses Pi's complete lane view, including uncommitted model/tool progress. */
|
|
466
|
-
async browserSnapshot(snapshot:
|
|
467
|
-
const entries = await this.storedMessages(snapshot.
|
|
468
|
-
const
|
|
469
|
-
const
|
|
470
|
-
|
|
471
|
-
if (streaming && streamingId) {
|
|
472
|
-
entries.push({
|
|
473
|
-
id: streamingId,
|
|
474
|
-
submissionId: operation.id,
|
|
475
|
-
message: streaming,
|
|
476
|
-
createdAt: streaming.timestamp,
|
|
477
|
-
});
|
|
478
|
-
}
|
|
445
|
+
async browserSnapshot(snapshot: SnapshotEvent, submissionId: string): Promise<UIMessage[]> {
|
|
446
|
+
const entries = await this.storedMessages((await this.nativeEntries(snapshot.entries.at(-1)?.id)).map(projectSessionEntry));
|
|
447
|
+
const streaming = snapshot.generation?.message;
|
|
448
|
+
const streamingId = streaming ? `streaming:${submissionId}` : undefined;
|
|
449
|
+
if (streaming && streamingId) entries.push({ id: streamingId, submissionId, message: streaming, createdAt: streaming.timestamp });
|
|
479
450
|
let messages = await this.projectBrowserMessages(entries, streamingId);
|
|
480
|
-
for (const tool of
|
|
481
|
-
|
|
482
|
-
|
|
483
|
-
|
|
484
|
-
|
|
485
|
-
if (message.role !== "assistant" || metadata?.turnId !== operation?.id) {
|
|
486
|
-
return message;
|
|
487
|
-
}
|
|
488
|
-
return applyPiToolResult(message, {
|
|
489
|
-
role: "toolResult",
|
|
490
|
-
toolCallId: tool.toolCallId,
|
|
491
|
-
toolName: tool.toolName,
|
|
492
|
-
...toolResult,
|
|
493
|
-
content: toolResult.content ?? [],
|
|
494
|
-
details: toolResult.details as JsonValue | undefined,
|
|
495
|
-
isError: tool.status === "settled" && tool.isError,
|
|
496
|
-
timestamp: snapshot.operation!.startedAt,
|
|
497
|
-
}, { preliminary: tool.status === "running" });
|
|
498
|
-
});
|
|
451
|
+
for (const tool of snapshot.tools) {
|
|
452
|
+
if (tool.output === undefined && tool.details === undefined) continue;
|
|
453
|
+
messages = messages.map((message) => message.role === "assistant" && (message.metadata as { turnId?: string } | undefined)?.turnId === submissionId
|
|
454
|
+
? applyPiToolResult(message, { role: "toolResult", toolCallId: tool.callId, toolName: tool.name, content: tool.output ? [{ type: "text", text: tool.output }] : [], details: tool.details, isError: false, timestamp: streaming?.timestamp ?? 0 }, { preliminary: tool.status !== "done" })
|
|
455
|
+
: message);
|
|
499
456
|
}
|
|
500
457
|
return messages;
|
|
501
458
|
}
|
|
@@ -504,6 +461,10 @@ export class ConversationHistory {
|
|
|
504
461
|
entries: readonly StoredTranscriptMessage[],
|
|
505
462
|
streamingId?: string,
|
|
506
463
|
): Promise<UIMessage[]> {
|
|
464
|
+
const lastAnswer = new Map<string, number>();
|
|
465
|
+
entries.forEach((entry, index) => {
|
|
466
|
+
if (entry.submissionId && entry.message.role === "assistant" && entry.message.stopReason !== "aborted") lastAnswer.set(entry.submissionId, index);
|
|
467
|
+
});
|
|
507
468
|
const result: UIMessage[] = [];
|
|
508
469
|
const seenAssistantSubmissions = new Set<string>();
|
|
509
470
|
const seenUserSubmissions = new Set<string>();
|
|
@@ -519,7 +480,7 @@ export class ConversationHistory {
|
|
|
519
480
|
const submissionId = pendingFailedSubmissionId;
|
|
520
481
|
pendingFailedSubmissionId = undefined;
|
|
521
482
|
const submission = this.durability.findSubmissionProjection(submissionId);
|
|
522
|
-
if (!submission || (submission.status !== "error" &&
|
|
483
|
+
if (!submission || (submission.status !== "error" && submission.status !== "aborted" &&
|
|
523
484
|
!(submission.status === "skipped" && submission.error))) return;
|
|
524
485
|
const completedAt = submission.completedAt ?? submission.createdAt;
|
|
525
486
|
result.push({
|
|
@@ -537,7 +498,8 @@ export class ConversationHistory {
|
|
|
537
498
|
0,
|
|
538
499
|
completedAt - submission.createdAt,
|
|
539
500
|
),
|
|
540
|
-
turnStatus: "error",
|
|
501
|
+
turnStatus: submission.status === "aborted" ? "aborted" : "error",
|
|
502
|
+
...(submission.status === "aborted" ? { interruptedByUser: true } : {}),
|
|
541
503
|
...(submission.error
|
|
542
504
|
? { error: publicAssistantError(submission.error) }
|
|
543
505
|
: {}),
|
|
@@ -548,7 +510,9 @@ export class ConversationHistory {
|
|
|
548
510
|
let activeAssistant:
|
|
549
511
|
| { submissionId: string; index: number }
|
|
550
512
|
| undefined;
|
|
551
|
-
for (const entry of entries) {
|
|
513
|
+
for (const [index, entry] of entries.entries()) {
|
|
514
|
+
// Pi retains interrupted attempts; a later answer replaces them only in the browser.
|
|
515
|
+
if (entry.submissionId && entry.message.role === "assistant" && entry.message.stopReason === "aborted" && (lastAnswer.get(entry.submissionId) ?? -1) > index) continue;
|
|
552
516
|
if (
|
|
553
517
|
pendingFailedSubmissionId &&
|
|
554
518
|
entry.submissionId !== pendingFailedSubmissionId
|
|
@@ -812,7 +776,7 @@ export class ConversationHistory {
|
|
|
812
776
|
* UI uses the message event time; Pi entry timestamps describe storage commits.
|
|
813
777
|
*/
|
|
814
778
|
async storedMessages(
|
|
815
|
-
snapshotEntries
|
|
779
|
+
snapshotEntries?: readonly Entry[],
|
|
816
780
|
): Promise<StoredTranscriptMessage[]> {
|
|
817
781
|
return (await this.storedEntries(snapshotEntries)).flatMap((stored) => {
|
|
818
782
|
const { entry } = stored;
|
|
@@ -828,13 +792,14 @@ export class ConversationHistory {
|
|
|
828
792
|
|
|
829
793
|
/** Read every Session entry with its recoverable Submission association. */
|
|
830
794
|
async storedEntries(
|
|
831
|
-
snapshotEntries
|
|
795
|
+
snapshotEntries?: readonly Entry[],
|
|
832
796
|
): Promise<StoredSessionEntry[]> {
|
|
797
|
+
const projected = snapshotEntries ?? (await this.nativeEntries()).map(projectSessionEntry);
|
|
833
798
|
const submissionByEntry =
|
|
834
799
|
this.durability.listSessionEntrySubmissionLinks();
|
|
835
|
-
return
|
|
800
|
+
return projected.map((entry) => ({
|
|
836
801
|
id: entry.id,
|
|
837
|
-
submissionId: submissionByEntry.get(entry.id) ?? null,
|
|
802
|
+
submissionId: submissionByEntry.get(entry.id) ?? entry.submissionId ?? null,
|
|
838
803
|
entry,
|
|
839
804
|
createdAt: entry.timestamp,
|
|
840
805
|
}));
|
|
@@ -1,6 +1,18 @@
|
|
|
1
1
|
import type { UIMessage } from "ai";
|
|
2
2
|
import type { UserMessage } from "@earendil-works/pi-ai";
|
|
3
3
|
|
|
4
|
+
declare module "@earendil-works/pi-agent-core" {
|
|
5
|
+
interface CustomAgentMessages {
|
|
6
|
+
springbrand: {
|
|
7
|
+
role: "custom";
|
|
8
|
+
customType: string;
|
|
9
|
+
content: readonly { type: "text"; text: string }[];
|
|
10
|
+
display?: boolean;
|
|
11
|
+
timestamp: number;
|
|
12
|
+
};
|
|
13
|
+
}
|
|
14
|
+
}
|
|
15
|
+
|
|
4
16
|
export type PiCanonicalUserInput = UserMessage;
|
|
5
17
|
|
|
6
18
|
export type UIChatTrigger = "submit-message" | "regenerate-message";
|
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
import type { AgentMessage } from "@earendil-works/pi-agent-core";
|
|
2
|
+
import type { EntryRecord } from "@earendil-works/pi-durable";
|
|
3
|
+
import type { Usage } from "@earendil-works/pi-ai";
|
|
4
|
+
|
|
5
|
+
/** Browser/Host projection only; Pi's EntryRecord remains the canonical record. */
|
|
6
|
+
export type ProjectedSessionEntry = {
|
|
7
|
+
id: string;
|
|
8
|
+
identity: string;
|
|
9
|
+
timestamp: number;
|
|
10
|
+
submissionId?: string;
|
|
11
|
+
} & ({ type: "message"; message: AgentMessage } | {
|
|
12
|
+
type: "compaction" | "branch_summary";
|
|
13
|
+
summary: string;
|
|
14
|
+
usage?: Usage;
|
|
15
|
+
});
|
|
16
|
+
|
|
17
|
+
export function projectSessionEntry(entry: EntryRecord): ProjectedSessionEntry {
|
|
18
|
+
const data = entry.data as { createdAt?: number; displayId?: string; submissionId?: string; message?: AgentMessage } | undefined;
|
|
19
|
+
const message = entry.model?.[0];
|
|
20
|
+
const base = { id: String(entry.id), identity: data?.displayId ?? String(entry.id), timestamp: data?.createdAt ?? message?.timestamp ?? 0, ...(data?.submissionId ? { submissionId: data.submissionId } : {}) };
|
|
21
|
+
if (entry.kind === "pi.compaction") return {
|
|
22
|
+
...base, type: "compaction",
|
|
23
|
+
summary: message?.role === "user" ? typeof message.content === "string" ? message.content : message.content.flatMap((part) => part.type === "text" ? [part.text] : []).join("") : "",
|
|
24
|
+
};
|
|
25
|
+
return {
|
|
26
|
+
...base, type: "message",
|
|
27
|
+
message: entry.kind === "springbrand.retained-context" ? { role: "custom", customType: entry.kind, content: [], timestamp: base.timestamp } : data?.message ?? message ?? { role: "custom", customType: entry.kind, content: [], timestamp: base.timestamp },
|
|
28
|
+
};
|
|
29
|
+
}
|