@springbrand/agent-runtime 0.3.0-alpha.21 → 0.3.0-alpha.23

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.
@@ -0,0 +1,122 @@
1
+ import { z } from "zod";
2
+ const identifier = z.string().min(1);
3
+ const amount = z.number().nonnegative();
4
+ const jsonObject = z.record(z.string(), z.json());
5
+ const providerMetadata = z.record(z.string(), z.record(z.string(), z.json().optional()));
6
+ const text = z.looseObject({
7
+ type: z.literal("text"), text: z.string(), textSignature: z.string().exactOptional(),
8
+ });
9
+ const thinking = z.looseObject({
10
+ type: z.literal("thinking"), thinking: z.string(), thinkingSignature: z.string().exactOptional(), redacted: z.boolean().exactOptional(),
11
+ });
12
+ const image = z.looseObject({
13
+ type: z.literal("image"), data: z.string(), mimeType: identifier,
14
+ });
15
+ const file = z.looseObject({
16
+ type: z.literal("file"), url: z.string(), mimeType: identifier, filename: z.string().exactOptional(),
17
+ });
18
+ const toolCall = z.looseObject({
19
+ type: z.literal("toolCall"), id: identifier, name: identifier, arguments: jsonObject,
20
+ thoughtSignature: z.string().exactOptional(), namespace: z.string().exactOptional(),
21
+ });
22
+ const providerNative = z.looseObject({
23
+ type: z.literal("providerNative"), api: identifier, block: z.record(z.string(), z.unknown()),
24
+ });
25
+ const usage = z.looseObject({
26
+ input: amount, output: amount, cacheRead: amount, cacheWrite: amount, totalTokens: amount,
27
+ cacheWrite1h: amount.exactOptional(), reasoning: amount.exactOptional(),
28
+ cost: z.looseObject({ input: amount, output: amount, cacheRead: amount, cacheWrite: amount, total: amount }),
29
+ });
30
+ const deferred = z.looseObject({
31
+ provider: identifier, modelId: identifier, api: identifier, id: identifier,
32
+ expiresAt: amount.exactOptional(), pollAfterMs: amount.exactOptional(), data: z.json().exactOptional(),
33
+ });
34
+ const diagnostics = z.array(z.looseObject({
35
+ type: z.string(), timestamp: amount,
36
+ error: z.looseObject({
37
+ name: z.string().exactOptional(), message: z.string(), stack: z.string().exactOptional(), code: z.union([z.string(), z.number()]).exactOptional(),
38
+ }).exactOptional(),
39
+ details: jsonObject.exactOptional(),
40
+ }));
41
+ const nestedCalls = z.looseObject({
42
+ calls: z.array(z.looseObject({
43
+ id: identifier, name: identifier, arguments: jsonObject.exactOptional(), argumentsBytes: amount.exactOptional(),
44
+ status: z.enum(["ok", "error", "unfinished"]), durationMs: amount.exactOptional(), error: z.string().exactOptional(),
45
+ })),
46
+ complete: z.boolean(),
47
+ });
48
+ const protocolResponseSchema = z.looseObject({ output: z.array(z.record(z.string(), z.unknown())) });
49
+ const user = z.looseObject({
50
+ role: z.literal("user"), content: z.union([z.string(), z.array(z.discriminatedUnion("type", [text, image, file]))]), timestamp: amount,
51
+ });
52
+ const assistant = z.looseObject({
53
+ role: z.literal("assistant"), content: z.array(z.discriminatedUnion("type", [text, thinking, toolCall, providerNative])),
54
+ api: identifier, provider: identifier, model: identifier, timestamp: amount, usage,
55
+ stopReason: z.enum(["pending", "stop", "length", "toolUse", "error", "aborted", "deferred"]),
56
+ responseModel: z.string().exactOptional(), responseId: z.string().exactOptional(), providerThinkingLevel: z.string().exactOptional(),
57
+ thinkingLevel: z.enum(["off", "minimal", "low", "medium", "high", "xhigh", "max"]).exactOptional(), diagnostics: diagnostics.exactOptional(),
58
+ deferred: deferred.exactOptional(), errorMessage: z.string().exactOptional(), rawStopReason: z.string().exactOptional(),
59
+ endTurn: z.boolean().exactOptional(), durationMs: amount.exactOptional(), protocolResponse: protocolResponseSchema.exactOptional(),
60
+ });
61
+ const toolResult = z.looseObject({
62
+ role: z.literal("toolResult"), toolCallId: identifier, toolName: identifier,
63
+ content: z.array(z.discriminatedUnion("type", [text, image])), isError: z.boolean(), timestamp: amount,
64
+ details: z.json().exactOptional(), usage: usage.exactOptional(), nestedCalls: nestedCalls.exactOptional(), durationMs: amount.exactOptional(),
65
+ });
66
+ const tool = z.looseObject({
67
+ name: identifier, description: z.string(), parameters: z.record(z.string(), z.unknown()),
68
+ constrainedSampling: z.union([z.literal(false), z.discriminatedUnion("type", [
69
+ z.looseObject({ type: z.literal("json_schema"), strict: z.enum(["prefer", "require"]) }),
70
+ z.looseObject({ type: z.literal("grammar"), variants: z.looseObject({ openai_lark: z.string().exactOptional(), openai_regex: z.string().exactOptional() }) }),
71
+ ])]).exactOptional(),
72
+ deferLoading: z.literal(true).exactOptional(),
73
+ });
74
+ const system = z.looseObject({
75
+ role: z.literal("system"), content: z.union([z.string(), z.array(text)]), timestamp: amount,
76
+ sections: z.record(z.string(), z.union([z.string(), z.null()])).exactOptional(), toolsAdded: z.array(tool).exactOptional(),
77
+ toolsRemoved: z.array(z.looseObject({ name: identifier })).exactOptional(),
78
+ });
79
+ const custom = z.looseObject({
80
+ role: z.literal("custom"), customType: identifier, content: z.array(z.looseObject({ type: z.literal("text"), text: z.string() })).readonly(),
81
+ display: z.boolean().exactOptional(), timestamp: amount,
82
+ });
83
+ /** Native model history and Runtime extensions share one import/export contract. */
84
+ const messageSchemas = { user, assistant, toolResult, system, custom };
85
+ const { user: userSchema, ...otherMessageSchemas } = messageSchemas;
86
+ export const canonicalMessageSchema = z.discriminatedUnion("role", [userSchema, ...Object.values(otherMessageSchemas)]);
87
+ const userSidecarSchema = z.strictObject({
88
+ metadata: z.unknown().exactOptional(),
89
+ parts: z.array(z.discriminatedUnion("type", [
90
+ z.looseObject({ type: z.literal("text"), text: z.string(), state: z.enum(["streaming", "done"]).exactOptional(), providerMetadata: providerMetadata.exactOptional() }),
91
+ z.looseObject({ type: z.literal("file"), mediaType: z.string(), url: z.string(), filename: z.string().exactOptional(),
92
+ providerReference: z.record(z.string(), z.string()).exactOptional(), providerMetadata: providerMetadata.exactOptional() }),
93
+ ])),
94
+ });
95
+ const entrySchema = z.strictObject({
96
+ id: identifier, message: canonicalMessageSchema, createdAt: z.number().int().nonnegative().safe(), userSidecar: userSidecarSchema.exactOptional(),
97
+ }).superRefine((entry, context) => {
98
+ if (entry.userSidecar && entry.message.role !== "user") {
99
+ context.addIssue({ code: "custom", path: ["userSidecar"], message: "Only user messages can carry a user sidecar" });
100
+ }
101
+ });
102
+ /** Validate the whole snapshot before any import writes; retain opaque native extension values. */
103
+ const snapshotSchema = z.strictObject({ entries: z.array(entrySchema).readonly() }).superRefine((snapshot, context) => {
104
+ const seen = new Set();
105
+ for (const [index, entry] of snapshot.entries.entries()) {
106
+ if (seen.has(entry.id))
107
+ context.addIssue({ code: "custom", path: ["entries", index, "id"], message: "Duplicate canonical message ID" });
108
+ seen.add(entry.id);
109
+ }
110
+ const first = snapshot.entries.findIndex(({ message }) => message.role === "user" || message.role === "assistant" || message.role === "toolResult");
111
+ if (first >= 0 && snapshot.entries[first].message.role !== "user") {
112
+ context.addIssue({ code: "custom", path: ["entries", first, "message", "role"], message: "Canonical history must begin with a user message" });
113
+ }
114
+ });
115
+ export const canonicalTranscriptSnapshotSchema = snapshotSchema.superRefine((snapshot, context) => {
116
+ const json = z.json().safeParse(snapshot);
117
+ if (!json.success)
118
+ for (const issue of json.error.issues)
119
+ context.addIssue({
120
+ code: "custom", path: issue.path, message: "Canonical history must contain only JSON values",
121
+ });
122
+ });
package/package.json CHANGED
@@ -1,10 +1,12 @@
1
1
  {
2
2
  "name": "@springbrand/agent-runtime",
3
- "version": "0.3.0-alpha.21",
3
+ "version": "0.3.0-alpha.23",
4
4
  "type": "module",
5
5
  "files": [
6
6
  "src",
7
- "!src/**/*.test.ts"
7
+ "!src/**/*.test.ts",
8
+ "dist",
9
+ "scripts"
8
10
  ],
9
11
  "publishConfig": {
10
12
  "access": "public"
@@ -13,14 +15,18 @@
13
15
  ".": "./src/index.ts",
14
16
  "./adapter/cloudflare": "./src/adapter/cloudflare/index.ts",
15
17
  "./contracts": "./src/config/public-contracts.ts",
18
+ "./canonical-context": {
19
+ "types": "./dist/canonical-context.d.ts",
20
+ "import": "./dist/canonical-context.js"
21
+ },
16
22
  "./declared-tool": "./src/tools/adapters/declared.ts",
17
23
  "./models": "./src/lib/model-catalog.ts"
18
24
  },
19
25
  "dependencies": {
20
- "@cloudflare/codemode": "0.5.2",
26
+ "@cloudflare/codemode": "0.5.4",
21
27
  "@cloudflare/sandbox": "0.12.4",
22
28
  "@cloudflare/shell": "0.4.3",
23
- "@cloudflare/think": "0.18.0",
29
+ "@cloudflare/think": "0.20.2",
24
30
  "@earendil-works/pi-agent-core": "1.1.0",
25
31
  "@earendil-works/pi-ai": "1.1.0",
26
32
  "@types/lodash-es": "^4.17.12",
@@ -32,11 +38,13 @@
32
38
  "@earendil-works/chord": "1.1.0"
33
39
  },
34
40
  "peerDependencies": {
35
- "agents": "^0.23.0",
41
+ "agents": ">=0.28.1 <0.29.0",
36
42
  "ai": "^7.0.0"
37
43
  },
38
44
  "devDependencies": {
39
- "agents": "0.23.0",
40
- "ai": "7.0.56"
41
- }
45
+ "agents": "0.28.1",
46
+ "ai": "7.0.56",
47
+ "typescript": "^6.0.3"
48
+ },
49
+ "scripts": {}
42
50
  }
@@ -0,0 +1,38 @@
1
+ import { mkdir, writeFile } from "node:fs/promises";
2
+ import path from "node:path";
3
+ import { fileURLToPath } from "node:url";
4
+ import ts from "typescript";
5
+
6
+ // The public validation entry must work without compiling Runtime implementation
7
+ // or inheriting the publisher workspace's patched Pi type declarations.
8
+ const root = path.resolve(path.dirname(fileURLToPath(import.meta.url)), "..");
9
+ const entry = path.join(root, "src/conversation/canonical-context.ts");
10
+ const program = ts.createProgram([entry, path.join(root, "src/conversation/native-extensions.ts")], {
11
+ target: ts.ScriptTarget.ES2022,
12
+ module: ts.ModuleKind.ES2022,
13
+ moduleResolution: ts.ModuleResolutionKind.Bundler,
14
+ strict: true,
15
+ skipLibCheck: true,
16
+ declaration: true,
17
+ noEmitOnError: true,
18
+ types: [],
19
+ });
20
+ const diagnostics = ts.getPreEmitDiagnostics(program);
21
+ if (diagnostics.length > 0) {
22
+ throw new Error(ts.formatDiagnosticsWithColorAndContext(diagnostics, {
23
+ getCanonicalFileName: (name) => name,
24
+ getCurrentDirectory: () => root,
25
+ getNewLine: () => "\n",
26
+ }));
27
+ }
28
+ const output = new Map();
29
+ const result = program.emit(program.getSourceFile(entry), (filename, content) => {
30
+ output.set(path.basename(filename), content);
31
+ });
32
+ if (result.emitSkipped || !output.has("canonical-context.js") || !output.has("canonical-context.d.ts")) {
33
+ throw new Error("Canonical context build did not emit its JavaScript and declarations");
34
+ }
35
+ await mkdir(path.join(root, "dist"), { recursive: true });
36
+ for (const [filename, content] of output) {
37
+ await writeFile(path.join(root, "dist", filename), content);
38
+ }
@@ -1316,9 +1316,9 @@ export abstract class AgentRuntimeKernel<
1316
1316
 
1317
1317
  // #region Session 消息与 Submission 公开控制面
1318
1318
 
1319
- /** Export canonical history through one message for fork/import. */
1319
+ /** Export full canonical history, or through one message for fork/import. */
1320
1320
  async getCanonicalSnapshot(
1321
- throughMessageId: string,
1321
+ throughMessageId?: string,
1322
1322
  ): Promise<PiCanonicalTranscriptSnapshot> {
1323
1323
  return this.transcript.exportSnapshot(throughMessageId);
1324
1324
  }
@@ -197,15 +197,13 @@ export interface RuntimeAgentControls {
197
197
  */
198
198
  getMessages(): Promise<UIMessage[]>;
199
199
  /**
200
- * 把一组既有消息一次性导入 canonical transcript。
200
+ * 导出完整 canonical transcript;提供消息 ID 时只导出至该条消息。
201
201
  *
202
202
  * @remarks
203
- * 只供真实 fork 或 import 流程在新 Session 初始化时调用。
204
- *
205
- * 普通发送必须经过 `submitPrompt`,否则会绕过 admission 和回执状态。
203
+ * 用于 fork、import,以及初始化重试时核对已导入的完整上下文。
206
204
  */
207
205
  getCanonicalSnapshot(
208
- throughMessageId: string,
206
+ throughMessageId?: string,
209
207
  ): Promise<PiCanonicalTranscriptSnapshot>;
210
208
  /** Import a server-authored canonical transcript snapshot into a new Session. */
211
209
  addCanonicalSnapshot(snapshot: PiCanonicalTranscriptSnapshot): Promise<void>;
@@ -376,7 +374,7 @@ const TERMINAL_AGENT_TOOL_STATUSES = [
376
374
  "interrupted",
377
375
  ] as const;
378
376
  const AGENT_TOOL_RETENTION_MS = 24 * 60 * 60 * 1_000;
379
- const STABLE_RUNTIME_KEY_MIGRATION = "agent-runtime:stable-runtime-key-migration-v1";
377
+ export const STABLE_RUNTIME_KEY_MIGRATION = "agent-runtime:stable-runtime-key-migration-v1";
380
378
 
381
379
  function parseTemporaryAgentLaunch(input: unknown): TemporaryAgentLaunchSelection {
382
380
  const record = input != null && typeof input === "object"
@@ -14,6 +14,7 @@ export * from "./profile";
14
14
  export * from "./assembly-view";
15
15
  export * from "./runtime-config";
16
16
  export * from "../conversation/message";
17
+ export * from "../conversation/canonical-context";
17
18
  export * from "../submission/contracts";
18
19
  export * from "../conversation/state";
19
20
  export * from "../lib/execution-level";
@@ -0,0 +1,144 @@
1
+ import { z } from "zod";
2
+ import type { AgentMessage } from "@earendil-works/pi-agent-core";
3
+ import type { UIMessage } from "ai";
4
+ import type { UIUserSidecar } from "./message/user-sidecar";
5
+ import type {
6
+ AssistantMessage, DeferredHandle, FileContent, ImageContent, NestedToolCallRecord,
7
+ NestedToolCalls, ProviderNativeContent, SystemMessage, TextContent, ThinkingContent,
8
+ Tool, ToolCall, ToolReference, ToolResultMessage, Usage, UserMessage,
9
+ } from "@earendil-works/pi-ai";
10
+
11
+ /** Every upstream field, including optional fields, must have a validator. */
12
+ type NativeShape<T> = { [Key in keyof T]-?: z.ZodType<T[Key]> };
13
+
14
+ const identifier = z.string().min(1);
15
+ const amount = z.number().nonnegative();
16
+ const jsonObject = z.record(z.string(), z.json());
17
+ const providerMetadata = z.record(z.string(), z.record(z.string(), z.json().optional()));
18
+
19
+ const text = z.looseObject({
20
+ type: z.literal("text"), text: z.string(), textSignature: z.string().exactOptional(),
21
+ } satisfies NativeShape<TextContent>);
22
+ const thinking = z.looseObject({
23
+ type: z.literal("thinking"), thinking: z.string(), thinkingSignature: z.string().exactOptional(), redacted: z.boolean().exactOptional(),
24
+ } satisfies NativeShape<ThinkingContent>);
25
+ const image = z.looseObject({
26
+ type: z.literal("image"), data: z.string(), mimeType: identifier,
27
+ } satisfies NativeShape<ImageContent>);
28
+ const file = z.looseObject({
29
+ type: z.literal("file"), url: z.string(), mimeType: identifier, filename: z.string().exactOptional(),
30
+ } satisfies NativeShape<FileContent>);
31
+ const toolCall = z.looseObject({
32
+ type: z.literal("toolCall"), id: identifier, name: identifier, arguments: jsonObject,
33
+ thoughtSignature: z.string().exactOptional(), namespace: z.string().exactOptional(),
34
+ } satisfies NativeShape<ToolCall>);
35
+ const providerNative = z.looseObject({
36
+ type: z.literal("providerNative"), api: identifier, block: z.record(z.string(), z.unknown()),
37
+ } satisfies NativeShape<ProviderNativeContent>);
38
+ const usage = z.looseObject({
39
+ input: amount, output: amount, cacheRead: amount, cacheWrite: amount, totalTokens: amount,
40
+ cacheWrite1h: amount.exactOptional(), reasoning: amount.exactOptional(),
41
+ cost: z.looseObject({ input: amount, output: amount, cacheRead: amount, cacheWrite: amount, total: amount } satisfies NativeShape<Usage["cost"]>),
42
+ } satisfies NativeShape<Usage>);
43
+ const deferred = z.looseObject({
44
+ provider: identifier, modelId: identifier, api: identifier, id: identifier,
45
+ expiresAt: amount.exactOptional(), pollAfterMs: amount.exactOptional(), data: z.json().exactOptional(),
46
+ } satisfies NativeShape<DeferredHandle>);
47
+ const diagnostics = z.array(z.looseObject({
48
+ type: z.string(), timestamp: amount,
49
+ error: z.looseObject({
50
+ name: z.string().exactOptional(), message: z.string(), stack: z.string().exactOptional(), code: z.union([z.string(), z.number()]).exactOptional(),
51
+ } satisfies NativeShape<NonNullable<NonNullable<AssistantMessage["diagnostics"]>[number]["error"]>>).exactOptional(),
52
+ details: jsonObject.exactOptional(),
53
+ } satisfies NativeShape<NonNullable<AssistantMessage["diagnostics"]>[number]>));
54
+ const nestedCalls = z.looseObject({
55
+ calls: z.array(z.looseObject({
56
+ id: identifier, name: identifier, arguments: jsonObject.exactOptional(), argumentsBytes: amount.exactOptional(),
57
+ status: z.enum(["ok", "error", "unfinished"]), durationMs: amount.exactOptional(), error: z.string().exactOptional(),
58
+ } satisfies NativeShape<NestedToolCallRecord>)),
59
+ complete: z.boolean(),
60
+ } satisfies NativeShape<NestedToolCalls>);
61
+
62
+ const protocolResponseSchema = z.looseObject({ output: z.array(z.record(z.string(), z.unknown())) });
63
+ export type ModelProtocolResponse = z.output<typeof protocolResponseSchema>;
64
+
65
+ const user = z.looseObject({
66
+ role: z.literal("user"), content: z.union([z.string(), z.array(z.discriminatedUnion("type", [text, image, file]))]), timestamp: amount,
67
+ } satisfies NativeShape<UserMessage>);
68
+ const assistant = z.looseObject({
69
+ role: z.literal("assistant"), content: z.array(z.discriminatedUnion("type", [text, thinking, toolCall, providerNative])),
70
+ api: identifier, provider: identifier, model: identifier, timestamp: amount, usage,
71
+ stopReason: z.enum(["pending", "stop", "length", "toolUse", "error", "aborted", "deferred"]),
72
+ responseModel: z.string().exactOptional(), responseId: z.string().exactOptional(), providerThinkingLevel: z.string().exactOptional(),
73
+ thinkingLevel: z.enum(["off", "minimal", "low", "medium", "high", "xhigh", "max"]).exactOptional(), diagnostics: diagnostics.exactOptional(),
74
+ deferred: deferred.exactOptional(), errorMessage: z.string().exactOptional(), rawStopReason: z.string().exactOptional(),
75
+ endTurn: z.boolean().exactOptional(), durationMs: amount.exactOptional(), protocolResponse: protocolResponseSchema.exactOptional(),
76
+ } satisfies NativeShape<AssistantMessage>);
77
+ const toolResult = z.looseObject({
78
+ role: z.literal("toolResult"), toolCallId: identifier, toolName: identifier,
79
+ content: z.array(z.discriminatedUnion("type", [text, image])), isError: z.boolean(), timestamp: amount,
80
+ details: z.json().exactOptional(), usage: usage.exactOptional(), nestedCalls: nestedCalls.exactOptional(), durationMs: amount.exactOptional(),
81
+ } satisfies NativeShape<ToolResultMessage>);
82
+ const tool = z.looseObject({
83
+ name: identifier, description: z.string(), parameters: z.record(z.string(), z.unknown()),
84
+ constrainedSampling: z.union([z.literal(false), z.discriminatedUnion("type", [
85
+ z.looseObject({ type: z.literal("json_schema"), strict: z.enum(["prefer", "require"]) }),
86
+ z.looseObject({ type: z.literal("grammar"), variants: z.looseObject({ openai_lark: z.string().exactOptional(), openai_regex: z.string().exactOptional() }) }),
87
+ ])]).exactOptional(),
88
+ deferLoading: z.literal(true).exactOptional(),
89
+ } satisfies NativeShape<Tool>);
90
+ const system = z.looseObject({
91
+ role: z.literal("system"), content: z.union([z.string(), z.array(text)]), timestamp: amount,
92
+ sections: z.record(z.string(), z.union([z.string(), z.null()])).exactOptional(), toolsAdded: z.array(tool).exactOptional(),
93
+ toolsRemoved: z.array(z.looseObject({ name: identifier } satisfies NativeShape<ToolReference>)).exactOptional(),
94
+ } satisfies NativeShape<SystemMessage>);
95
+ const custom = z.looseObject({
96
+ role: z.literal("custom"), customType: identifier, content: z.array(z.looseObject({ type: z.literal("text"), text: z.string() })).readonly(),
97
+ display: z.boolean().exactOptional(), timestamp: amount,
98
+ });
99
+ export type RuntimeCustomMessage = z.output<typeof custom>;
100
+
101
+ /** Native model history and Runtime extensions share one import/export contract. */
102
+ const messageSchemas = { user, assistant, toolResult, system, custom } satisfies {
103
+ [Role in AgentMessage["role"]]: z.ZodType<Extract<AgentMessage, { role: Role }>>;
104
+ };
105
+ const { user: userSchema, ...otherMessageSchemas } = messageSchemas;
106
+ export const canonicalMessageSchema = z.discriminatedUnion("role", [userSchema, ...Object.values(otherMessageSchemas)]);
107
+
108
+ const userSidecarSchema = z.strictObject({
109
+ metadata: z.unknown().exactOptional(),
110
+ parts: z.array(z.discriminatedUnion("type", [
111
+ z.looseObject({ type: z.literal("text"), text: z.string(), state: z.enum(["streaming", "done"]).exactOptional(), providerMetadata: providerMetadata.exactOptional() } satisfies NativeShape<Extract<UIMessage["parts"][number], { type: "text" }>>),
112
+ z.looseObject({ type: z.literal("file"), mediaType: z.string(), url: z.string(), filename: z.string().exactOptional(),
113
+ providerReference: z.record(z.string(), z.string()).exactOptional(), providerMetadata: providerMetadata.exactOptional() } satisfies NativeShape<Extract<UIMessage["parts"][number], { type: "file" }>>),
114
+ ])),
115
+ } satisfies NativeShape<UIUserSidecar>);
116
+
117
+ const entrySchema = z.strictObject({
118
+ id: identifier, message: canonicalMessageSchema, createdAt: z.number().int().nonnegative().safe(), userSidecar: userSidecarSchema.exactOptional(),
119
+ }).superRefine((entry, context) => {
120
+ if (entry.userSidecar && entry.message.role !== "user") {
121
+ context.addIssue({ code: "custom", path: ["userSidecar"], message: "Only user messages can carry a user sidecar" });
122
+ }
123
+ });
124
+ export type PiCanonicalTranscriptSnapshotEntry = z.output<typeof entrySchema>;
125
+
126
+ /** Validate the whole snapshot before any import writes; retain opaque native extension values. */
127
+ const snapshotSchema = z.strictObject({ entries: z.array(entrySchema).readonly() }).superRefine((snapshot, context) => {
128
+ const seen = new Set<string>();
129
+ for (const [index, entry] of snapshot.entries.entries()) {
130
+ if (seen.has(entry.id)) context.addIssue({ code: "custom", path: ["entries", index, "id"], message: "Duplicate canonical message ID" });
131
+ seen.add(entry.id);
132
+ }
133
+ const first = snapshot.entries.findIndex(({ message }) => message.role === "user" || message.role === "assistant" || message.role === "toolResult");
134
+ if (first >= 0 && snapshot.entries[first]!.message.role !== "user") {
135
+ context.addIssue({ code: "custom", path: ["entries", first, "message", "role"], message: "Canonical history must begin with a user message" });
136
+ }
137
+ });
138
+ export const canonicalTranscriptSnapshotSchema = snapshotSchema.superRefine((snapshot, context) => {
139
+ const json = z.json().safeParse(snapshot);
140
+ if (!json.success) for (const issue of json.error.issues) context.addIssue({
141
+ code: "custom", path: issue.path, message: "Canonical history must contain only JSON values",
142
+ });
143
+ });
144
+ export type PiCanonicalTranscriptSnapshot = z.output<typeof canonicalTranscriptSnapshotSchema>;
@@ -11,7 +11,6 @@ import type {
11
11
  import type { UIMessage } from "ai";
12
12
  import {
13
13
  applyPiToolResult,
14
- captureUIUserSidecar,
15
14
  piAssistantToUIMessage,
16
15
  publicAssistantError,
17
16
  type PiToolApprovalView,
@@ -19,6 +18,8 @@ import {
19
18
  } from "./message";
20
19
  import { openCloudflareSession, RuntimeConversation } from "../session";
21
20
  import { dropLegacyPiTables } from "../session/cloudflare/migration";
21
+ import { canonicalTranscriptSnapshotSchema, type PiCanonicalTranscriptSnapshot } from "./canonical-context";
22
+ export type { PiCanonicalTranscriptSnapshot, PiCanonicalTranscriptSnapshotEntry } from "./canonical-context";
22
23
 
23
24
  /**
24
25
  * Pi Durable 1.1 has no public API that deletes a conversation; `Tx` can only create, fork and retire documents.
@@ -138,17 +139,6 @@ export interface StoredSessionEntry {
138
139
  createdAt: number;
139
140
  }
140
141
 
141
- export interface PiCanonicalTranscriptSnapshotEntry {
142
- readonly id: string;
143
- readonly message: AgentMessage;
144
- readonly createdAt: number;
145
- readonly userSidecar?: UIUserSidecar;
146
- }
147
-
148
- export interface PiCanonicalTranscriptSnapshot {
149
- readonly entries: readonly PiCanonicalTranscriptSnapshotEntry[];
150
- }
151
-
152
142
  // #endregion
153
143
 
154
144
  // #region 浏览器与 Host 投影辅助函数
@@ -273,7 +263,7 @@ export class ConversationHistory {
273
263
  async append(
274
264
  id: string,
275
265
  message: AgentMessage,
276
- options: { submissionId?: string; userMessage?: UIMessage & { role: "user" } } = {},
266
+ options: { submissionId?: string; userSidecar?: UIUserSidecar & { readonly id: string }; createdAt?: number } = {},
277
267
  ): Promise<boolean> {
278
268
  if ((await this.nativeEntries()).some((entry) => (entry.data as { displayId?: string } | undefined)?.displayId === id)) return false;
279
269
  return this.storage.transaction(() => this.withSession(async (session) => {
@@ -290,9 +280,9 @@ export class ConversationHistory {
290
280
  kind: custom ? "springbrand.custom" : message.role === "toolResult" ? "pi.tool-result" : `pi.${message.role}`,
291
281
  // Custom messages reach the model as user context, as Pi's convertToLlm did before the native harness.
292
282
  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: [] } : {}) })),
283
+ data: JSON.parse(JSON.stringify({ displayId: id, createdAt: options.createdAt ?? message.timestamp, ...(custom ? { message } : {}), ...(options.submissionId ? { submissionId: options.submissionId } : {}), ...(message.role === "toolResult" ? { diagnostics: [] } : {}) })),
294
284
  });
295
- if (options.userMessage) this.durability.upsertUserSidecar(String(entry.id), JSON.stringify({ id: options.userMessage.id, ...captureUIUserSidecar(options.userMessage) }));
285
+ if (options.userSidecar) this.durability.upsertUserSidecar(String(entry.id), JSON.stringify(options.userSidecar));
296
286
  }, BACKGROUND_CONTEXT);
297
287
  return true;
298
288
  }));
@@ -369,53 +359,58 @@ export class ConversationHistory {
369
359
  *
370
360
  * 整批写入使用同一个同步事务,用户消息用 import ID 开启投影分组,后续助手步骤沿用该分组并保留首条用户 sidecar。
371
361
  *
372
- * 待确认:当前代码没有拒绝以助手消息开头的历史,而这类消息没有 Submission 关联,会被浏览器投影略过。
362
+ * 同一公开契约在事务前校验完整快照,非法消息不会留下任何持久化记录。
373
363
  */
374
364
  async importSnapshot(snapshot: PiCanonicalTranscriptSnapshot): Promise<void> {
375
365
  if (this.hasActiveTurn()) throw new Error("Cannot import messages while a Turn is active");
366
+ const validated = canonicalTranscriptSnapshotSchema.parse(snapshot);
376
367
  await this.storage.transaction(async () => {
377
368
  let submissionId: string | undefined;
378
- for (const entry of snapshot.entries) {
369
+ for (const entry of validated.entries) {
379
370
  if (entry.message.role === "user") submissionId = `import:${entry.id}`;
380
371
  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 } } : {}),
372
+ submissionId, createdAt: entry.createdAt,
373
+ ...(entry.userSidecar ? { userSidecar: { ...entry.userSidecar, id: entry.id } } : {}),
383
374
  });
384
375
  if (!inserted) throw new Error(`Session message "${entry.id}" already exists`);
385
376
  }
386
377
  });
387
378
  }
388
379
 
389
- /** Export canonical entries through one browser-visible message identity. */
380
+ /** Export the full canonical history, or entries through one browser-visible message identity. */
390
381
  async exportSnapshot(
391
- throughMessageId: string,
382
+ throughMessageId?: string,
392
383
  ): Promise<PiCanonicalTranscriptSnapshot> {
393
384
  const stored = await this.storedMessages();
394
- const storedId = await this.storedMessageId(throughMessageId);
395
- let cutoff = stored.findIndex((entry) => entry.id === storedId);
396
- if (cutoff < 0) {
397
- const targetSubmissionId = stored.find((entry) => {
398
- if (!entry.submissionId) return false;
399
- const submission = this.durability.findSubmissionProjection(
400
- entry.submissionId,
401
- );
402
- return submission?.assistantMessageId === throughMessageId ||
403
- submission?.userMessageId === throughMessageId;
404
- })?.submissionId;
405
- if (targetSubmissionId) {
406
- for (let index = stored.length - 1; index >= 0; index -= 1) {
407
- if (stored[index]?.submissionId === targetSubmissionId) {
408
- cutoff = index;
409
- break;
385
+ let selected = stored;
386
+ if (throughMessageId !== undefined) {
387
+ const storedId = await this.storedMessageId(throughMessageId);
388
+ let cutoff = stored.findIndex((entry) => entry.id === storedId);
389
+ if (cutoff < 0) {
390
+ const targetSubmissionId = stored.find((entry) => {
391
+ if (!entry.submissionId) return false;
392
+ const submission = this.durability.findSubmissionProjection(
393
+ entry.submissionId,
394
+ );
395
+ return submission?.assistantMessageId === throughMessageId ||
396
+ submission?.userMessageId === throughMessageId;
397
+ })?.submissionId;
398
+ if (targetSubmissionId) {
399
+ for (let index = stored.length - 1; index >= 0; index -= 1) {
400
+ if (stored[index]?.submissionId === targetSubmissionId) {
401
+ cutoff = index;
402
+ break;
403
+ }
410
404
  }
411
405
  }
412
406
  }
407
+ if (cutoff < 0) {
408
+ throw new Error(`message "${throughMessageId}" not found`);
409
+ }
410
+ selected = stored.slice(0, cutoff + 1);
413
411
  }
414
- if (cutoff < 0) {
415
- throw new Error(`message "${throughMessageId}" not found`);
416
- }
417
- return {
418
- entries: stored.slice(0, cutoff + 1).map((entry) => {
412
+ return canonicalTranscriptSnapshotSchema.parse({
413
+ entries: selected.map((entry) => {
419
414
  if (entry.message.role !== "user") {
420
415
  return {
421
416
  id: entry.id,
@@ -424,16 +419,17 @@ export class ConversationHistory {
424
419
  };
425
420
  }
426
421
  const body = this.durability.readUserSidecar(entry.id);
422
+ const sidecar = body ? JSON.parse(body) as UIUserSidecar & { id: string } : undefined;
427
423
  return {
428
424
  id: entry.id,
429
425
  message: entry.message,
430
426
  createdAt: entry.createdAt,
431
- ...(body
432
- ? { userSidecar: JSON.parse(body) as UIUserSidecar }
427
+ ...(sidecar
428
+ ? { userSidecar: { parts: sidecar.parts, ...(sidecar.metadata === undefined ? {} : { metadata: sidecar.metadata }) } }
433
429
  : {}),
434
430
  };
435
431
  }),
436
- };
432
+ });
437
433
  }
438
434
 
439
435
  /**
@@ -1,17 +1,8 @@
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
- }
4
+ export type { RuntimeCustomMessage } from "../canonical-context";
5
+ import type {} from "../native-extensions";
15
6
 
16
7
  export type PiCanonicalUserInput = UserMessage;
17
8
 
@@ -117,10 +117,8 @@ export function normalizeUIUserInput(
117
117
  return uiUserMessageToPi(message, context.join("\n"));
118
118
  }
119
119
 
120
- export interface UIUserSidecar {
121
- readonly metadata?: unknown;
122
- readonly parts: ReadonlyArray<UIMessage["parts"][number]>;
123
- }
120
+ export type { UIUserSidecar } from "./user-sidecar";
121
+ import type { UIUserSidecar } from "./user-sidecar";
124
122
 
125
123
  function recordOf(value: unknown): Record<string, unknown> {
126
124
  return value !== null && typeof value === "object" && !Array.isArray(value)
@@ -225,7 +223,7 @@ export function captureUIUserSidecar(
225
223
  ): UIUserSidecar {
226
224
  return {
227
225
  ...(message.metadata === undefined ? {} : { metadata: message.metadata }),
228
- parts: message.parts.flatMap<UIMessage["parts"][number]>((part) => {
226
+ parts: message.parts.flatMap<UIUserSidecar["parts"][number]>((part) => {
229
227
  if (part.type === "text") return [part];
230
228
  if (part.type !== "file") return [];
231
229
  const providerMetadata = recordOf(part.providerMetadata);
@@ -0,0 +1,7 @@
1
+ import type { UIMessage } from "ai";
2
+
3
+ /** Browser-only user facts travel with native history without entering model input. */
4
+ export interface UIUserSidecar {
5
+ readonly metadata?: unknown;
6
+ readonly parts: ReadonlyArray<Extract<UIMessage["parts"][number], { type: "text" | "file" }>>;
7
+ }
@@ -0,0 +1,13 @@
1
+ import type { ModelProtocolResponse, RuntimeCustomMessage } from "./canonical-context";
2
+
3
+ declare module "@earendil-works/pi-ai" {
4
+ interface AssistantMessage {
5
+ protocolResponse?: ModelProtocolResponse;
6
+ }
7
+ }
8
+
9
+ declare module "@earendil-works/pi-agent-core" {
10
+ interface CustomAgentMessages {
11
+ springbrand: RuntimeCustomMessage;
12
+ }
13
+ }