@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.
- package/dist/canonical-context.d.ts +661 -0
- package/dist/canonical-context.js +122 -0
- package/package.json +16 -8
- package/scripts/build-canonical-context.mjs +38 -0
- package/src/adapter/cloudflare/agent.ts +2 -2
- package/src/adapter/cloudflare/define-agent.ts +4 -6
- package/src/config/public-contracts.ts +1 -0
- package/src/conversation/canonical-context.ts +144 -0
- package/src/conversation/history.ts +41 -45
- package/src/conversation/message/contract.ts +2 -11
- package/src/conversation/message/conversion.ts +3 -5
- package/src/conversation/message/user-sidecar.ts +7 -0
- package/src/conversation/native-extensions.ts +13 -0
- package/src/index.ts +1 -1
- package/src/models/openrouter/protocol-response.ts +1 -11
- package/src/submission/lifecycle.ts +10 -1
- package/src/submission/steer-delivery.ts +4 -2
- package/src/telemetry/coordinator.ts +13 -3
|
@@ -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.
|
|
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.
|
|
26
|
+
"@cloudflare/codemode": "0.5.4",
|
|
21
27
|
"@cloudflare/sandbox": "0.12.4",
|
|
22
28
|
"@cloudflare/shell": "0.4.3",
|
|
23
|
-
"@cloudflare/think": "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": "
|
|
41
|
+
"agents": ">=0.28.1 <0.29.0",
|
|
36
42
|
"ai": "^7.0.0"
|
|
37
43
|
},
|
|
38
44
|
"devDependencies": {
|
|
39
|
-
"agents": "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
|
|
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
|
-
*
|
|
200
|
+
* 导出完整 canonical transcript;提供消息 ID 时只导出至该条消息。
|
|
201
201
|
*
|
|
202
202
|
* @remarks
|
|
203
|
-
*
|
|
204
|
-
*
|
|
205
|
-
* 普通发送必须经过 `submitPrompt`,否则会绕过 admission 和回执状态。
|
|
203
|
+
* 用于 fork、import,以及初始化重试时核对已导入的完整上下文。
|
|
206
204
|
*/
|
|
207
205
|
getCanonicalSnapshot(
|
|
208
|
-
throughMessageId
|
|
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;
|
|
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.
|
|
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
|
-
*
|
|
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
|
|
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.
|
|
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
|
|
382
|
+
throughMessageId?: string,
|
|
392
383
|
): Promise<PiCanonicalTranscriptSnapshot> {
|
|
393
384
|
const stored = await this.storedMessages();
|
|
394
|
-
|
|
395
|
-
|
|
396
|
-
|
|
397
|
-
|
|
398
|
-
|
|
399
|
-
const
|
|
400
|
-
entry.submissionId
|
|
401
|
-
|
|
402
|
-
|
|
403
|
-
|
|
404
|
-
|
|
405
|
-
|
|
406
|
-
|
|
407
|
-
|
|
408
|
-
|
|
409
|
-
|
|
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
|
-
|
|
415
|
-
|
|
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
|
-
...(
|
|
432
|
-
? { userSidecar:
|
|
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
|
-
|
|
5
|
-
|
|
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
|
|
121
|
-
|
|
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<
|
|
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
|
+
}
|