@springbrand/agent-runtime 0.3.0-alpha.15 → 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.
@@ -1,8 +1,7 @@
1
- import type {
2
- AgentMessage,
3
- Entry,
4
- LaneSnapshot,
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 { openPiSessionStorage, type CloudflareSessionStorage } from "../session";
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
- this.store = openPiSessionStorage(storage);
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
- submissionId?: string;
269
- userMessage?: UIMessage & { role: "user" };
270
- } = {},
271
- ): boolean {
272
- const inserted = this.store.appendMessage({
273
- id,
274
- message,
275
- ...(options.submissionId
276
- ? { submissionId: options.submissionId }
277
- : {}),
278
- });
279
- if (inserted && options.userMessage) {
280
- const sidecar = captureUIUserSidecar(options.userMessage);
281
- this.durability.upsertUserSidecar(id, JSON.stringify(sidecar));
282
- }
283
- return inserted;
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.modelBranch().flatMap((entry) =>
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 entry = this.store.entry(this.storedMessageId(id));
329
- return entry?.type === "message" && entry.message.role === "user"
330
- ? entry.message
331
- : undefined;
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): string | null {
340
- const entry = this.store.entry(this.storedMessageId(id));
341
- if (entry?.type !== "message" || entry.message.role !== "user") {
342
- throw new Error(`Unknown regeneration user message: ${id}`);
343
- }
344
- return entry.parentId;
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
- throw new Error("Cannot import messages while a SpringBrand Turn is active");
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.store.clear();
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: LaneSnapshot): Promise<UIMessage[]> {
467
- const entries = await this.storedMessages(snapshot.transcript);
468
- const operation = snapshot.operation;
469
- const streaming = operation?.streamingMessage;
470
- const streamingId = streaming ? `streaming:${operation.id}` : undefined;
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 operation?.runningTools ?? []) {
481
- const toolResult = tool.result;
482
- if (!toolResult) continue;
483
- messages = messages.map((message) => {
484
- const metadata = message.metadata as { turnId?: string } | undefined;
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: readonly Entry[] = this.store.branch(),
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: readonly Entry[] = this.store.branch(),
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 snapshotEntries.map((entry) => ({
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
+ }
@@ -3,5 +3,7 @@ export type {
3
3
  SessionHarnessLimits,
4
4
  SessionHarnessOptions,
5
5
  SessionHarnessRunResult,
6
+ SessionRunOutcome,
7
+ SessionRunRequest,
6
8
  } from "./session-harness";
7
9
  export { prepareSubmissionOperation } from "./submission-operation";