@springbrand/agent-runtime 0.3.0-alpha.16 → 0.3.0-alpha.18
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 +81 -123
- package/src/adapter/cloudflare/subagent/definition.ts +21 -9
- package/src/adapter/cloudflare/subagent/runner.ts +42 -49
- package/src/adapter/cloudflare/subagent/tools.ts +2 -2
- package/src/config/harness-runtime.ts +28 -15
- 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 +85 -70
- package/src/conversation/history.ts +153 -153
- package/src/conversation/message/contract.ts +12 -0
- package/src/conversation/session-entry.ts +29 -0
- package/src/harness/governance.ts +206 -0
- package/src/harness/index.ts +3 -0
- package/src/harness/session-harness.ts +251 -607
- package/src/harness/submission-operation.ts +15 -40
- package/src/models/index.ts +14 -3
- package/src/models/openrouter/protocol-response.ts +36 -112
- package/src/session/cloudflare/migration.ts +382 -924
- package/src/session/cloudflare/storage.ts +13 -536
- package/src/session/index.ts +22 -35
- package/src/submission/finalization.ts +29 -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 +32 -50
- package/src/tools/runtime/decision-gate.ts +5 -5
|
@@ -1,7 +1,8 @@
|
|
|
1
|
-
import type { AgentEvent
|
|
2
|
-
import type {
|
|
1
|
+
import type { AgentEvent as LoopEvent } from "@earendil-works/pi-agent-core";
|
|
2
|
+
import type { AgentEvent, SnapshotEvent } from "@earendil-works/pi-durable";
|
|
3
3
|
import type { AssistantMessage } from "@earendil-works/pi-ai";
|
|
4
4
|
import type { UIMessage, UIMessageChunk } from "ai";
|
|
5
|
+
import type { SessionHarness, SessionRunOutcome } from "../harness/session-harness";
|
|
5
6
|
import { PiChunkEncoder, type ToolInputPreparer } from "./message";
|
|
6
7
|
|
|
7
8
|
export interface HarnessStreamOptions {
|
|
@@ -11,89 +12,103 @@ export interface HarnessStreamOptions {
|
|
|
11
12
|
readonly userMessageId?: string | null;
|
|
12
13
|
readonly prepareToolInput?: ToolInputPreparer;
|
|
13
14
|
}
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
record: OperationResultRecord,
|
|
18
|
-
submission: { status: string; completedAt?: number | null; error?: string | null },
|
|
19
|
-
): OperationResultRecord {
|
|
20
|
-
const status = submission.status === "aborted" ? "aborted"
|
|
21
|
-
: submission.status === "error" ? "failed"
|
|
22
|
-
: submission.status === "skipped" ? "declined" : "completed";
|
|
23
|
-
const { error: _previousError, ...result } = record;
|
|
24
|
-
return {
|
|
25
|
-
...result, status, endedAt: submission.completedAt ?? record.endedAt,
|
|
26
|
-
...((status === "failed" || status === "aborted") && submission.error
|
|
27
|
-
? { error: { code: status, message: submission.error } } : {}),
|
|
28
|
-
};
|
|
15
|
+
export function projectSubmissionOutcome(record: SessionRunOutcome, submission: { status: string; completedAt?: number | null; error?: string | null }): SessionRunOutcome {
|
|
16
|
+
const status = submission.status === "aborted" ? "aborted" : submission.status === "error" ? "failed" : submission.status === "skipped" ? "declined" : "completed";
|
|
17
|
+
return { status, endedAt: submission.completedAt ?? record.endedAt, ...(submission.error ? { error: { code: status, message: submission.error } } : {}) };
|
|
29
18
|
}
|
|
30
19
|
|
|
31
|
-
/**
|
|
20
|
+
/** Translates native committed Pi deltas into the browser's AI SDK wire protocol. */
|
|
32
21
|
export class HarnessStreamProjection {
|
|
33
22
|
private readonly encoder: PiChunkEncoder;
|
|
34
|
-
private terminal
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
}
|
|
39
|
-
|
|
40
|
-
|
|
23
|
+
private terminal?: AssistantMessage;
|
|
24
|
+
private readonly shown = new Map<number, string>();
|
|
25
|
+
private readonly toolInputStarted = new Set<string>();
|
|
26
|
+
private readonly toolOutput = new Map<string, string>();
|
|
27
|
+
constructor(options: HarnessStreamOptions) { this.encoder = new PiChunkEncoder(options); }
|
|
28
|
+
restore(message: UIMessage, streaming: boolean, snapshot?: SnapshotEvent): UIMessageChunk[] {
|
|
29
|
+
this.shown.clear();
|
|
30
|
+
snapshot?.generation?.message?.content.forEach((block, index) => {
|
|
31
|
+
if (block.type === "text") this.shown.set(index, block.text);
|
|
32
|
+
if (block.type === "thinking") this.shown.set(index, block.thinking);
|
|
33
|
+
});
|
|
34
|
+
for (const tool of snapshot?.tools ?? []) if (tool.output !== undefined) this.toolOutput.set(tool.callId, tool.output);
|
|
41
35
|
return this.encoder.restore(message, streaming);
|
|
42
36
|
}
|
|
43
|
-
|
|
44
|
-
|
|
37
|
+
private block(block: AssistantMessage["content"][number], contentIndex: number, settled = false): UIMessageChunk[] {
|
|
38
|
+
if (block.type === "toolCall" && (!block.id || !block.name)) return [];
|
|
39
|
+
if (block.type === "toolCall") {
|
|
40
|
+
// The encoder keeps the first input it sees, so only a settled message may publish arguments.
|
|
41
|
+
if (settled) return this.encoder.encode({ type: "tool_execution_start", toolCallId: block.id, toolName: block.name, args: block.arguments } as LoopEvent);
|
|
42
|
+
if (this.toolInputStarted.has(block.id)) return [];
|
|
43
|
+
this.toolInputStarted.add(block.id);
|
|
44
|
+
return [{ type: "tool-input-start", toolCallId: block.id, toolName: block.name, dynamic: true }];
|
|
45
|
+
}
|
|
46
|
+
if (block.type !== "text" && block.type !== "thinking") return [];
|
|
47
|
+
const start = block.type === "thinking"
|
|
48
|
+
? this.encoder.encode({ type: "message_update", assistantMessageEvent: { type: "thinking_start", contentIndex } } as LoopEvent)
|
|
49
|
+
: [];
|
|
50
|
+
const text = block.type === "text" ? block.text : block.thinking;
|
|
51
|
+
const previous = this.shown.get(contentIndex) ?? "";
|
|
52
|
+
const delta = text.startsWith(previous) ? text.slice(previous.length) : text;
|
|
53
|
+
this.shown.set(contentIndex, text);
|
|
54
|
+
if (!delta) return start;
|
|
55
|
+
return [...start, ...this.encoder.encode({ type: "message_update", assistantMessageEvent: { type: block.type === "text" ? "text_delta" : "thinking_delta", contentIndex, delta } } as LoopEvent)];
|
|
56
|
+
}
|
|
57
|
+
/**
|
|
58
|
+
* Pi emits `run_start` only when the live run changes, so a stream bound to a continued run (after an approval or a
|
|
59
|
+
* slice release) sees tool or message frames first. The encoder's start is idempotent; lead the first frames with it.
|
|
60
|
+
*/
|
|
61
|
+
private started(chunks: UIMessageChunk[]): UIMessageChunk[] {
|
|
62
|
+
return chunks.length === 0 ? chunks : [...this.encoder.start(), ...chunks];
|
|
63
|
+
}
|
|
64
|
+
event(event: AgentEvent): UIMessageChunk[] { return this.started(this.frames(event)); }
|
|
65
|
+
private frames(event: AgentEvent): UIMessageChunk[] {
|
|
45
66
|
switch (event.type) {
|
|
46
|
-
case "run_start":
|
|
47
|
-
case "
|
|
48
|
-
case "
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
} as
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
67
|
+
case "run_start": return this.encoder.start();
|
|
68
|
+
case "turn_start": this.terminal = undefined; this.shown.clear(); return this.encoder.encode({ type: "turn_start" } as LoopEvent);
|
|
69
|
+
case "message_start": return event.message.role === "assistant" ? event.message.content.flatMap((block, index) => this.block(block, index)) : [];
|
|
70
|
+
case "message_update": return event.changes.flatMap((change) => {
|
|
71
|
+
if (change.type === "message") return change.message.content.flatMap((block, index) => this.block(block, index));
|
|
72
|
+
if (change.type === "text_start" || change.type === "thinking_start" || change.type === "block" || change.type === "toolcall_start") return this.block(change.block, change.contentIndex);
|
|
73
|
+
if (change.type !== "text_delta" && change.type !== "thinking_delta") return [];
|
|
74
|
+
this.shown.set(change.contentIndex, (this.shown.get(change.contentIndex) ?? "") + change.delta);
|
|
75
|
+
return this.encoder.encode({ type: "message_update", assistantMessageEvent: change } as LoopEvent);
|
|
76
|
+
});
|
|
77
|
+
case "tool_execution_start": return this.encoder.encode(event as LoopEvent);
|
|
78
|
+
case "tool_execution_update": {
|
|
79
|
+
let output = this.toolOutput.get(event.toolCallId) ?? "";
|
|
80
|
+
if (event.output) output = "set" in event.output ? event.output.set : output.slice(event.output.trimStart ?? 0) + (event.output.append ?? "");
|
|
81
|
+
this.toolOutput.set(event.toolCallId, output);
|
|
82
|
+
return this.encoder.encode({ type: "tool_execution_update", toolCallId: event.toolCallId, toolName: event.toolName, args: {}, partialResult: { content: [{ type: "text", text: output }], details: event.details } } as LoopEvent);
|
|
83
|
+
}
|
|
84
|
+
case "tool_execution_end": {
|
|
85
|
+
const result = event.entry?.model?.[0];
|
|
86
|
+
return this.encoder.encode({ type: "tool_execution_end", toolCallId: event.toolCallId, toolName: event.toolName, result: result?.role === "toolResult" ? result : { content: [{ type: "text", text: "Tool execution interrupted" }] }, isError: result?.role !== "toolResult" || result.isError } as LoopEvent);
|
|
87
|
+
}
|
|
88
|
+
case "message_end": {
|
|
89
|
+
const message = event.entry.model?.[0];
|
|
90
|
+
if (message?.role !== "assistant") return [];
|
|
91
|
+
this.terminal = message;
|
|
92
|
+
return [...message.content.flatMap((block, index) => this.block(block, index, true)), ...this.encoder.encode({ type: "message_end", message }, { terminal: false })];
|
|
93
|
+
}
|
|
68
94
|
default: return [];
|
|
69
95
|
}
|
|
70
96
|
}
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
return this.encoder.
|
|
74
|
-
}
|
|
75
|
-
|
|
76
|
-
finish(result: Pick<OperationResultRecord, "status" | "endedAt" | "error">): UIMessageChunk[] {
|
|
77
|
-
return this.encoder.finish({
|
|
78
|
-
stopReason: result.status === "aborted" ? "aborted" : result.status === "failed" ? "error" :
|
|
79
|
-
this.terminal?.stopReason === "length" ? "length" : "stop",
|
|
80
|
-
timestamp: result.endedAt,
|
|
81
|
-
...(result.error?.message ? { errorMessage: result.error.message } : {}),
|
|
82
|
-
});
|
|
97
|
+
approvalRequested(toolCallId: string, approvalId: string): UIMessageChunk[] { return this.started(this.encoder.approvalRequested(toolCallId, approvalId)); }
|
|
98
|
+
finish(result: SessionRunOutcome): UIMessageChunk[] {
|
|
99
|
+
return this.started(this.encoder.finish({ stopReason: result.status === "aborted" ? "aborted" : result.status === "failed" ? "error" : this.terminal?.stopReason === "length" ? "length" : "stop", timestamp: result.endedAt, ...(result.error ? { errorMessage: result.error.message } : {}) }));
|
|
83
100
|
}
|
|
84
101
|
}
|
|
85
102
|
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
export function bindHarnessStream(
|
|
89
|
-
events: Events,
|
|
90
|
-
options: HarnessStreamOptions & { readonly publish: (chunks: readonly UIMessageChunk[]) => Promise<void> },
|
|
91
|
-
) {
|
|
103
|
+
export async function bindHarnessStream(runtime: SessionHarness, options: HarnessStreamOptions & { readonly publish: (chunks: readonly UIMessageChunk[]) => Promise<void> }) {
|
|
92
104
|
const projection = new HarnessStreamProjection(options);
|
|
93
|
-
const
|
|
105
|
+
const watch = await runtime.watch((await import("@earendil-works/chord/context")).BACKGROUND_CONTEXT);
|
|
106
|
+
let delivery: Promise<void> = Promise.resolve();
|
|
107
|
+
watch.start((events) => delivery = (async () => { for (const event of events) await options.publish(projection.event(event)); })());
|
|
94
108
|
return {
|
|
95
109
|
publishApprovalRequest: (toolCallId: string, approvalId: string) => options.publish(projection.approvalRequested(toolCallId, approvalId)),
|
|
96
|
-
|
|
97
|
-
|
|
110
|
+
// The Host follows the terminal frame with the canonical transcript, which completes any frame Pi's stop dropped.
|
|
111
|
+
flushTerminal: async (result: SessionRunOutcome) => { await watch.stop(); await delivery; await options.publish(projection.finish(result)); },
|
|
112
|
+
unsubscribe: async () => { await watch.stop(); await delivery; },
|
|
98
113
|
};
|
|
99
114
|
}
|
|
@@ -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,31 @@ import {
|
|
|
18
17
|
type PiToolApprovalView,
|
|
19
18
|
type UIUserSidecar,
|
|
20
19
|
} from "./message";
|
|
21
|
-
import {
|
|
22
|
-
import {
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
20
|
+
import { openCloudflareSession, RuntimeConversation } from "../session";
|
|
21
|
+
import { dropLegacyPiTables } from "../session/cloudflare/migration";
|
|
22
|
+
|
|
23
|
+
/**
|
|
24
|
+
* Pi Durable 1.1 has no public API that deletes a conversation; `Tx` can only create, fork and retire documents.
|
|
25
|
+
* Clear therefore deletes every record outside the newly selected conversation from the official SQLite schema
|
|
26
|
+
* version 1. A different schema version keeps the records rather than guessing at its layout.
|
|
27
|
+
*/
|
|
28
|
+
function deleteConversationsExcept(sql: SqlStorage, keep: number): void {
|
|
29
|
+
const schema = sql.exec<{ version: number }>("SELECT version FROM durable_schema WHERE singleton=1").toArray()[0]?.version;
|
|
30
|
+
if (schema !== 1) {
|
|
31
|
+
console.warn("[conversation-clear:degraded]", `Unsupported Pi Durable SQLite schema ${String(schema)}; previous records kept`);
|
|
32
|
+
return;
|
|
33
|
+
}
|
|
34
|
+
const documents = `SELECT id FROM documents WHERE (scope_kind='conversation' AND owner_id<>?1)
|
|
35
|
+
OR (scope_kind='task' AND owner_id IN (SELECT id FROM tasks WHERE conversation_id<>?1))`;
|
|
36
|
+
sql.exec(`DELETE FROM document_revisions WHERE document_id IN (${documents})`, keep);
|
|
37
|
+
sql.exec(`DELETE FROM record_ids WHERE id IN (${documents}
|
|
38
|
+
UNION SELECT id FROM submissions WHERE conversation_id<>?1 UNION SELECT id FROM tasks WHERE conversation_id<>?1
|
|
39
|
+
UNION SELECT id FROM entries WHERE conversation_id<>?1 UNION SELECT id FROM conversations WHERE id<>?1)`, keep);
|
|
40
|
+
sql.exec(`DELETE FROM documents WHERE id IN (${documents})`, keep);
|
|
41
|
+
for (const [table, column] of [["submissions", "conversation_id"], ["tasks", "conversation_id"], ["entries", "conversation_id"], ["conversations", "id"]]) {
|
|
42
|
+
sql.exec(`DELETE FROM ${table} WHERE ${column}<>?`, keep);
|
|
43
|
+
}
|
|
44
|
+
}
|
|
26
45
|
|
|
27
46
|
/**
|
|
28
47
|
* 本文件负责保存 Pi 的规范消息,并按浏览器或 Extension Host 的需要投影历史。
|
|
@@ -204,52 +223,42 @@ function hostText(message: UserMessage | AssistantMessage): string {
|
|
|
204
223
|
* Pi Session 保存规范分支,Runtime 数据库保存 Submission、审批和用户 sidecar;分开保存可避免把 UI 字段写进模型上下文。
|
|
205
224
|
*/
|
|
206
225
|
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
226
|
constructor(
|
|
248
|
-
storage: DurableObjectStorage,
|
|
227
|
+
private readonly storage: DurableObjectStorage,
|
|
249
228
|
private readonly durability: PiTranscriptDurability,
|
|
250
229
|
private readonly hasActiveTurn: () => boolean,
|
|
251
|
-
) {
|
|
252
|
-
|
|
230
|
+
) {}
|
|
231
|
+
|
|
232
|
+
private async withSession<T>(run: (session: Session, store: Storage) => Promise<T>): Promise<T> {
|
|
233
|
+
const store = await openCloudflareSession(this.storage);
|
|
234
|
+
const session = createSession(store);
|
|
235
|
+
try { return await run(session, store); }
|
|
236
|
+
finally { await session.close(BACKGROUND_CONTEXT); }
|
|
237
|
+
}
|
|
238
|
+
private async nativeEntries(maxEntryId?: EntryId): Promise<EntryRecord[]> {
|
|
239
|
+
return this.withSession(async (session, store) => {
|
|
240
|
+
const pointer = await session.snapshot(RuntimeConversation, BACKGROUND_CONTEXT);
|
|
241
|
+
if (!pointer?.id) return [];
|
|
242
|
+
const entries: EntryRecord[] = [];
|
|
243
|
+
let cursor;
|
|
244
|
+
do {
|
|
245
|
+
const page = await store.scanEntries({ conversationId: pointer.id, ...(maxEntryId !== undefined ? { maxEntryId } : {}), order: "ascending" }, 200, cursor, BACKGROUND_CONTEXT);
|
|
246
|
+
entries.push(...page.items); cursor = page.next;
|
|
247
|
+
} while (cursor);
|
|
248
|
+
return entries;
|
|
249
|
+
});
|
|
250
|
+
}
|
|
251
|
+
private async storedMessageId(id: string): Promise<string> {
|
|
252
|
+
const links = this.durability.listSessionEntrySubmissionLinks();
|
|
253
|
+
for (const entry of await this.nativeEntries()) {
|
|
254
|
+
const key = String(entry.id);
|
|
255
|
+
if (key === id || (entry.data as { displayId?: string } | undefined)?.displayId === id) return key;
|
|
256
|
+
const body = this.durability.readUserSidecar(key);
|
|
257
|
+
if (body && (JSON.parse(body) as { id?: string }).id === id) return key;
|
|
258
|
+
const owner = links.get(key);
|
|
259
|
+
if (owner && this.durability.findSubmissionProjection(owner)?.userMessageId === id) return key;
|
|
260
|
+
}
|
|
261
|
+
return id;
|
|
253
262
|
}
|
|
254
263
|
|
|
255
264
|
// #region 规范消息写入
|
|
@@ -261,33 +270,39 @@ export class ConversationHistory {
|
|
|
261
270
|
*
|
|
262
271
|
* sidecar 只随首次成功插入写入,使消息 ID 同时承担幂等键,重复请求不能覆盖原视图。
|
|
263
272
|
*/
|
|
264
|
-
append(
|
|
273
|
+
async append(
|
|
265
274
|
id: string,
|
|
266
275
|
message: AgentMessage,
|
|
267
|
-
options: {
|
|
268
|
-
|
|
269
|
-
|
|
270
|
-
|
|
271
|
-
|
|
272
|
-
|
|
273
|
-
|
|
274
|
-
|
|
275
|
-
|
|
276
|
-
|
|
277
|
-
|
|
278
|
-
|
|
279
|
-
|
|
280
|
-
|
|
281
|
-
|
|
282
|
-
|
|
283
|
-
|
|
276
|
+
options: { submissionId?: string; userMessage?: UIMessage & { role: "user" } } = {},
|
|
277
|
+
): Promise<boolean> {
|
|
278
|
+
if ((await this.nativeEntries()).some((entry) => (entry.data as { displayId?: string } | undefined)?.displayId === id)) return false;
|
|
279
|
+
return this.storage.transaction(() => this.withSession(async (session) => {
|
|
280
|
+
const pointer = await session.snapshot(RuntimeConversation, BACKGROUND_CONTEXT);
|
|
281
|
+
let conversationId = pointer?.id;
|
|
282
|
+
if (!conversationId) conversationId = await session.commit(async (tx) => {
|
|
283
|
+
const conversation = await tx.createConversation({ ownership: { kind: "ownerless" } });
|
|
284
|
+
(await tx.doc(RuntimeConversation)).id = conversation.id;
|
|
285
|
+
return conversation.id;
|
|
286
|
+
}, BACKGROUND_CONTEXT);
|
|
287
|
+
await session.commit(async (tx) => {
|
|
288
|
+
const custom = message.role === "custom";
|
|
289
|
+
const entry = await tx.appendEntry(conversationId!, {
|
|
290
|
+
kind: custom ? "springbrand.custom" : message.role === "toolResult" ? "pi.tool-result" : `pi.${message.role}`,
|
|
291
|
+
// Custom messages reach the model as user context, as Pi's convertToLlm did before the native harness.
|
|
292
|
+
model: [custom ? { role: "user", content: [...message.content], timestamp: message.timestamp } : message],
|
|
293
|
+
data: JSON.parse(JSON.stringify({ displayId: id, createdAt: message.timestamp, ...(custom ? { message } : {}), ...(options.submissionId ? { submissionId: options.submissionId } : {}), ...(message.role === "toolResult" ? { diagnostics: [] } : {}) })),
|
|
294
|
+
});
|
|
295
|
+
if (options.userMessage) this.durability.upsertUserSidecar(String(entry.id), JSON.stringify({ id: options.userMessage.id, ...captureUIUserSidecar(options.userMessage) }));
|
|
296
|
+
}, BACKGROUND_CONTEXT);
|
|
297
|
+
return true;
|
|
298
|
+
}));
|
|
284
299
|
}
|
|
285
300
|
|
|
286
301
|
/** Persist a stable marker so browser history can distinguish a user stop from other aborts. */
|
|
287
302
|
appendTurnAbortedMarker(
|
|
288
303
|
submissionId: string,
|
|
289
304
|
timestamp: number,
|
|
290
|
-
): boolean {
|
|
305
|
+
): Promise<boolean> {
|
|
291
306
|
return this.append(`${submissionId}:turn-aborted`, {
|
|
292
307
|
role: "custom",
|
|
293
308
|
customType: "turn-aborted",
|
|
@@ -312,7 +327,7 @@ export class ConversationHistory {
|
|
|
312
327
|
* 这里只返回 `message` entry;compaction 等 Session entry 由上下文转换阶段处理,不能混入原始消息列表。
|
|
313
328
|
*/
|
|
314
329
|
async canonicalMessages(): Promise<AgentMessage[]> {
|
|
315
|
-
return this.
|
|
330
|
+
return (await this.storedEntries()).map(({ entry }) => entry).flatMap((entry) =>
|
|
316
331
|
entry.type === "message" ? [entry.message] : []
|
|
317
332
|
);
|
|
318
333
|
}
|
|
@@ -325,10 +340,13 @@ export class ConversationHistory {
|
|
|
325
340
|
* 同时检查 entry 类型和消息角色,防止其他 Session entry 或助手消息被当作 regenerate 起点。
|
|
326
341
|
*/
|
|
327
342
|
async findUserMessage(id: string): Promise<UserMessage | undefined> {
|
|
328
|
-
const
|
|
329
|
-
|
|
330
|
-
|
|
331
|
-
|
|
343
|
+
const key = await this.storedMessageId(id);
|
|
344
|
+
if (!Number.isSafeInteger(Number(key)) || Number(key) <= 0) return undefined;
|
|
345
|
+
return this.withSession(async (_session, store) => {
|
|
346
|
+
const native = await store.entry(Number(key) as EntryId, BACKGROUND_CONTEXT);
|
|
347
|
+
const message = native?.entry.model?.[0];
|
|
348
|
+
return message?.role === "user" ? message : undefined;
|
|
349
|
+
});
|
|
332
350
|
}
|
|
333
351
|
|
|
334
352
|
// #endregion
|
|
@@ -336,12 +354,12 @@ export class ConversationHistory {
|
|
|
336
354
|
// #region 分支、压缩与导入
|
|
337
355
|
|
|
338
356
|
/** Resolve the branch position before the user prompt that will be regenerated. */
|
|
339
|
-
regenerationTarget(id: string):
|
|
340
|
-
const
|
|
341
|
-
|
|
342
|
-
|
|
343
|
-
}
|
|
344
|
-
return
|
|
357
|
+
async regenerationTarget(id: string): Promise<EntryId | null> {
|
|
358
|
+
const key = await this.storedMessageId(id);
|
|
359
|
+
const entries = await this.nativeEntries();
|
|
360
|
+
const index = entries.findIndex((entry) => String(entry.id) === key && entry.model?.[0]?.role === "user");
|
|
361
|
+
if (index < 0) throw new Error(`Unknown regeneration user message: ${id}`);
|
|
362
|
+
return entries[index - 1]?.id ?? null;
|
|
345
363
|
}
|
|
346
364
|
|
|
347
365
|
/**
|
|
@@ -353,35 +371,18 @@ export class ConversationHistory {
|
|
|
353
371
|
*
|
|
354
372
|
* 待确认:当前代码没有拒绝以助手消息开头的历史,而这类消息没有 Submission 关联,会被浏览器投影略过。
|
|
355
373
|
*/
|
|
356
|
-
importSnapshot(snapshot: PiCanonicalTranscriptSnapshot): void {
|
|
357
|
-
if (this.hasActiveTurn())
|
|
358
|
-
|
|
374
|
+
async importSnapshot(snapshot: PiCanonicalTranscriptSnapshot): Promise<void> {
|
|
375
|
+
if (this.hasActiveTurn()) throw new Error("Cannot import messages while a Turn is active");
|
|
376
|
+
await this.storage.transaction(async () => {
|
|
377
|
+
let submissionId: string | undefined;
|
|
378
|
+
for (const entry of snapshot.entries) {
|
|
379
|
+
if (entry.message.role === "user") submissionId = `import:${entry.id}`;
|
|
380
|
+
const inserted = await this.append(entry.id, entry.message, {
|
|
381
|
+
submissionId,
|
|
382
|
+
...(entry.message.role === "user" && entry.userSidecar ? { userMessage: { id: entry.id, role: "user" as const, parts: [...entry.userSidecar.parts], metadata: entry.userSidecar.metadata } } : {}),
|
|
383
|
+
});
|
|
384
|
+
if (!inserted) throw new Error(`Session message "${entry.id}" already exists`);
|
|
359
385
|
}
|
|
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
386
|
});
|
|
386
387
|
}
|
|
387
388
|
|
|
@@ -390,7 +391,7 @@ export class ConversationHistory {
|
|
|
390
391
|
throughMessageId: string,
|
|
391
392
|
): Promise<PiCanonicalTranscriptSnapshot> {
|
|
392
393
|
const stored = await this.storedMessages();
|
|
393
|
-
const storedId = this.storedMessageId(throughMessageId);
|
|
394
|
+
const storedId = await this.storedMessageId(throughMessageId);
|
|
394
395
|
let cutoff = stored.findIndex((entry) => entry.id === storedId);
|
|
395
396
|
if (cutoff < 0) {
|
|
396
397
|
const targetSubmissionId = stored.find((entry) => {
|
|
@@ -442,8 +443,19 @@ export class ConversationHistory {
|
|
|
442
443
|
*
|
|
443
444
|
* 两类 Transcript 数据必须一起删除,否则复用消息 ID 时可能重新读到旧附件或 UI 元数据。
|
|
444
445
|
*/
|
|
445
|
-
clear(): void {
|
|
446
|
-
this.
|
|
446
|
+
async clear(): Promise<void> {
|
|
447
|
+
await this.storage.transaction(() => this.withSession(async (session) => {
|
|
448
|
+
const next = await session.commit(async (tx) => {
|
|
449
|
+
const conversation = await tx.createConversation({ ownership: { kind: "ownerless" } });
|
|
450
|
+
(await tx.doc(RuntimeConversation)).id = conversation.id;
|
|
451
|
+
return conversation.id;
|
|
452
|
+
}, BACKGROUND_CONTEXT);
|
|
453
|
+
deleteConversationsExcept(this.storage.sql, next);
|
|
454
|
+
await dropLegacyPiTables(this.storage, session);
|
|
455
|
+
// Entry associations name records that no longer exist.
|
|
456
|
+
this.storage.sql.exec("CREATE TABLE IF NOT EXISTS pi_session_entry_submission (entry_id TEXT PRIMARY KEY, submission_id TEXT NOT NULL)");
|
|
457
|
+
this.storage.sql.exec("DELETE FROM pi_session_entry_submission");
|
|
458
|
+
}));
|
|
447
459
|
this.durability.clearUserSidecars();
|
|
448
460
|
}
|
|
449
461
|
|
|
@@ -463,39 +475,17 @@ export class ConversationHistory {
|
|
|
463
475
|
}
|
|
464
476
|
|
|
465
477
|
/** 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
|
-
}
|
|
478
|
+
async browserSnapshot(snapshot: SnapshotEvent, submissionId: string): Promise<UIMessage[]> {
|
|
479
|
+
const entries = await this.storedMessages((await this.nativeEntries(snapshot.entries.at(-1)?.id)).map(projectSessionEntry));
|
|
480
|
+
const streaming = snapshot.generation?.message;
|
|
481
|
+
const streamingId = streaming ? `streaming:${submissionId}` : undefined;
|
|
482
|
+
if (streaming && streamingId) entries.push({ id: streamingId, submissionId, message: streaming, createdAt: streaming.timestamp });
|
|
479
483
|
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
|
-
});
|
|
484
|
+
for (const tool of snapshot.tools) {
|
|
485
|
+
if (tool.output === undefined && tool.details === undefined) continue;
|
|
486
|
+
messages = messages.map((message) => message.role === "assistant" && (message.metadata as { turnId?: string } | undefined)?.turnId === submissionId
|
|
487
|
+
? 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" })
|
|
488
|
+
: message);
|
|
499
489
|
}
|
|
500
490
|
return messages;
|
|
501
491
|
}
|
|
@@ -504,6 +494,10 @@ export class ConversationHistory {
|
|
|
504
494
|
entries: readonly StoredTranscriptMessage[],
|
|
505
495
|
streamingId?: string,
|
|
506
496
|
): Promise<UIMessage[]> {
|
|
497
|
+
const lastAnswer = new Map<string, number>();
|
|
498
|
+
entries.forEach((entry, index) => {
|
|
499
|
+
if (entry.submissionId && entry.message.role === "assistant" && entry.message.stopReason !== "aborted") lastAnswer.set(entry.submissionId, index);
|
|
500
|
+
});
|
|
507
501
|
const result: UIMessage[] = [];
|
|
508
502
|
const seenAssistantSubmissions = new Set<string>();
|
|
509
503
|
const seenUserSubmissions = new Set<string>();
|
|
@@ -519,7 +513,7 @@ export class ConversationHistory {
|
|
|
519
513
|
const submissionId = pendingFailedSubmissionId;
|
|
520
514
|
pendingFailedSubmissionId = undefined;
|
|
521
515
|
const submission = this.durability.findSubmissionProjection(submissionId);
|
|
522
|
-
if (!submission || (submission.status !== "error" &&
|
|
516
|
+
if (!submission || (submission.status !== "error" && submission.status !== "aborted" &&
|
|
523
517
|
!(submission.status === "skipped" && submission.error))) return;
|
|
524
518
|
const completedAt = submission.completedAt ?? submission.createdAt;
|
|
525
519
|
result.push({
|
|
@@ -537,7 +531,8 @@ export class ConversationHistory {
|
|
|
537
531
|
0,
|
|
538
532
|
completedAt - submission.createdAt,
|
|
539
533
|
),
|
|
540
|
-
turnStatus: "error",
|
|
534
|
+
turnStatus: submission.status === "aborted" ? "aborted" : "error",
|
|
535
|
+
...(submission.status === "aborted" ? { interruptedByUser: true } : {}),
|
|
541
536
|
...(submission.error
|
|
542
537
|
? { error: publicAssistantError(submission.error) }
|
|
543
538
|
: {}),
|
|
@@ -548,7 +543,9 @@ export class ConversationHistory {
|
|
|
548
543
|
let activeAssistant:
|
|
549
544
|
| { submissionId: string; index: number }
|
|
550
545
|
| undefined;
|
|
551
|
-
for (const entry of entries) {
|
|
546
|
+
for (const [index, entry] of entries.entries()) {
|
|
547
|
+
// Pi retains interrupted attempts; a later answer replaces them only in the browser.
|
|
548
|
+
if (entry.submissionId && entry.message.role === "assistant" && entry.message.stopReason === "aborted" && (lastAnswer.get(entry.submissionId) ?? -1) > index) continue;
|
|
552
549
|
if (
|
|
553
550
|
pendingFailedSubmissionId &&
|
|
554
551
|
entry.submissionId !== pendingFailedSubmissionId
|
|
@@ -599,6 +596,8 @@ export class ConversationHistory {
|
|
|
599
596
|
entry.message.role === "custom" &&
|
|
600
597
|
entry.message.customType === "turn-aborted"
|
|
601
598
|
) {
|
|
599
|
+
// The marker is this stop's terminal; the no-answer fallback would add a second one.
|
|
600
|
+
if (pendingFailedSubmissionId === entry.submissionId) pendingFailedSubmissionId = undefined;
|
|
602
601
|
const submission = this.durability.findSubmissionProjection(
|
|
603
602
|
entry.submissionId,
|
|
604
603
|
);
|
|
@@ -812,7 +811,7 @@ export class ConversationHistory {
|
|
|
812
811
|
* UI uses the message event time; Pi entry timestamps describe storage commits.
|
|
813
812
|
*/
|
|
814
813
|
async storedMessages(
|
|
815
|
-
snapshotEntries
|
|
814
|
+
snapshotEntries?: readonly Entry[],
|
|
816
815
|
): Promise<StoredTranscriptMessage[]> {
|
|
817
816
|
return (await this.storedEntries(snapshotEntries)).flatMap((stored) => {
|
|
818
817
|
const { entry } = stored;
|
|
@@ -828,13 +827,14 @@ export class ConversationHistory {
|
|
|
828
827
|
|
|
829
828
|
/** Read every Session entry with its recoverable Submission association. */
|
|
830
829
|
async storedEntries(
|
|
831
|
-
snapshotEntries
|
|
830
|
+
snapshotEntries?: readonly Entry[],
|
|
832
831
|
): Promise<StoredSessionEntry[]> {
|
|
832
|
+
const projected = snapshotEntries ?? (await this.nativeEntries()).map(projectSessionEntry);
|
|
833
833
|
const submissionByEntry =
|
|
834
834
|
this.durability.listSessionEntrySubmissionLinks();
|
|
835
|
-
return
|
|
835
|
+
return projected.map((entry) => ({
|
|
836
836
|
id: entry.id,
|
|
837
|
-
submissionId: submissionByEntry.get(entry.id) ?? null,
|
|
837
|
+
submissionId: submissionByEntry.get(entry.id) ?? entry.submissionId ?? null,
|
|
838
838
|
entry,
|
|
839
839
|
createdAt: entry.timestamp,
|
|
840
840
|
}));
|
|
@@ -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";
|