@springbrand/agent-runtime 0.3.0-alpha.2 → 0.3.0-alpha.21
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 +11 -9
- package/src/adapter/cloudflare/agent.ts +233 -179
- package/src/adapter/cloudflare/define-agent.ts +252 -104
- package/src/adapter/cloudflare/index.ts +18 -0
- package/src/adapter/cloudflare/scheduling/schedule-rule.ts +322 -0
- package/src/adapter/cloudflare/scheduling/zoned-cron.ts +11 -6
- package/src/adapter/cloudflare/schema.ts +21 -2
- package/src/adapter/cloudflare/subagent/definition.ts +25 -13
- package/src/adapter/cloudflare/subagent/runner.ts +43 -50
- package/src/adapter/cloudflare/subagent/tools.ts +4 -4
- package/src/adapter/cloudflare/universal-agent/preparation.ts +3 -3
- package/src/adapter/cloudflare/universal-agent/tools.ts +4 -6
- package/src/adapter/cloudflare/workspace/version-control.ts +1 -1
- package/src/config/activation.ts +13 -15
- package/src/config/agent-context.ts +1 -1
- package/src/config/assembler.ts +48 -31
- package/src/config/assembly-snapshot.ts +1 -1
- package/src/config/definition.ts +1 -1
- package/src/config/harness-runtime.ts +77 -49
- package/src/config/index.ts +1 -1
- package/src/config/model-guidance.ts +20 -0
- package/src/config/ports.ts +57 -7
- package/src/config/{pinning.ts → prepared-runtime.ts} +59 -123
- package/src/config/public-contracts.ts +2 -2
- package/src/config/runtime-config.ts +2 -1
- package/src/context/assembly.ts +1 -1
- package/src/context/extension/assembly.ts +2 -2
- package/src/context/memory/budget/gate.ts +19 -3
- package/src/conversation/harness-resume.ts +89 -238
- package/src/conversation/harness-stream.ts +87 -71
- package/src/conversation/history.ts +154 -152
- package/src/conversation/message/contract.ts +28 -4
- package/src/conversation/message/conversion.ts +46 -5
- package/src/conversation/message/projection.ts +61 -21
- package/src/conversation/session-entry.ts +29 -0
- package/src/conversation/state.ts +23 -2
- package/src/harness/governance.ts +206 -0
- package/src/harness/index.ts +3 -0
- package/src/harness/session-harness.ts +269 -445
- package/src/harness/submission-operation.ts +15 -40
- package/src/index.ts +18 -8
- package/src/lib/artifacts.ts +18 -0
- package/src/lib/execution-level.ts +13 -0
- package/src/lib/prompt.ts +9 -10
- package/src/lib/token-estimate.ts +8 -0
- package/src/model-protocol/openrouter.ts +41 -0
- package/src/models/index.ts +200 -35
- package/src/models/openrouter/messages.ts +13 -8
- package/src/models/openrouter/openai.ts +200 -0
- package/src/models/openrouter/protocol-response.ts +85 -0
- package/src/models/provider-streams.ts +45 -0
- package/src/session/cloudflare/migration.ts +382 -864
- package/src/session/cloudflare/storage.ts +13 -536
- package/src/session/index.ts +22 -35
- package/src/skills/springbrand-worker-website/index.ts +1 -1
- package/src/submission/admission.ts +20 -30
- package/src/submission/contracts.ts +42 -2
- package/src/submission/delivery.ts +1 -1
- package/src/submission/finalization.ts +36 -15
- package/src/submission/lifecycle.ts +28 -15
- package/src/submission/repository.ts +97 -49
- package/src/submission/steer-delivery.ts +32 -76
- package/src/submission/unattended.ts +40 -0
- package/src/telemetry/contract.ts +0 -1
- package/src/telemetry/coordinator.ts +1 -1
- package/src/telemetry/runtime-telemetry.ts +0 -4
- package/src/telemetry/subagent-lifecycle.ts +1 -1
- package/src/telemetry/turn-events.ts +35 -25
- package/src/tools/README.md +44 -0
- package/src/tools/{ai-adapter.ts → adapters/ai-to-pi.ts} +6 -9
- package/src/tools/{declared.ts → adapters/declared.ts} +6 -2
- package/src/tools/{nested-tools.ts → adapters/pi-to-ai.ts} +4 -4
- package/src/tools/assistant/ask-user.ts +319 -0
- package/src/tools/assistant/candidate.ts +15 -0
- package/src/tools/assistant/followups.ts +34 -0
- package/src/tools/assistant/index.ts +19 -0
- package/src/tools/assistant/memory.ts +73 -0
- package/src/tools/assistant/plan.ts +98 -0
- package/src/tools/{time.ts → assistant/time.ts} +1 -1
- package/src/tools/{core-host.ts → codemode/adapter.ts} +50 -53
- package/src/tools/{core.ts → codemode/index.ts} +36 -92
- package/src/tools/codemode/workspace-connector.ts +198 -0
- package/src/tools/extension/index.ts +42 -0
- package/src/tools/index.ts +29 -26
- package/src/tools/{gateway.ts → mcp/gateway.ts} +19 -19
- package/src/tools/{mcp.ts → mcp/index.ts} +5 -5
- package/src/tools/runtime/activation.ts +40 -0
- package/src/tools/{approval → runtime/approval}/repository.ts +13 -12
- package/src/tools/runtime/compiler.ts +246 -0
- package/src/tools/runtime/contracts.ts +140 -0
- package/src/tools/{decision-gate.ts → runtime/decision-gate.ts} +149 -39
- package/src/tools/{human-decisions.ts → runtime/human-decisions.ts} +4 -4
- package/src/tools/{interaction → runtime/interaction}/repository.ts +1 -7
- package/src/tools/runtime/result.ts +22 -0
- package/src/tools/runtime/unattended-error.ts +4 -0
- package/src/tools/sandbox/index.ts +228 -0
- package/src/tools/schedule/index.ts +301 -0
- package/src/tools/skill/contracts.ts +20 -0
- package/src/tools/skill/index.ts +99 -0
- package/src/tools/skill/resources.ts +322 -0
- package/src/tools/skill/scripts.ts +168 -0
- package/src/tools/{subagent-authority.ts → subagent/authority.ts} +31 -31
- package/src/tools/{subagent.ts → subagent/index.ts} +34 -37
- package/src/tools/{subagent-run-repository.ts → subagent/repository.ts} +1 -1
- package/src/tools/{subagent-runtime.ts → subagent/runtime.ts} +1 -1
- package/src/tools/{temporary-agent → subagent/temporary}/approvals.ts +4 -7
- package/src/tools/{temporary-agent → subagent/temporary}/core.ts +2 -3
- package/src/tools/{temporary-agent → subagent/temporary}/runner.ts +1 -1
- package/src/tools/{temporary-agent → subagent/temporary}/workspace.ts +1 -4
- package/src/tools/{web-fetch.ts → web/fetch.ts} +4 -13
- package/src/tools/web/index.ts +2 -0
- package/src/tools/web/search/api.ts +1322 -0
- package/src/tools/web/search/index.ts +2 -0
- package/src/tools/{web-search/web-search.ts → web/search/search.ts} +5 -7
- package/src/tools/{workspace-sandbox.ts → workspace/index.ts} +12 -272
- package/src/tools/{workspace-revision.ts → workspace/revision.ts} +3 -3
- package/src/tools/base.ts +0 -534
- package/src/tools/compiler.ts +0 -327
- package/src/tools/schedule.ts +0 -261
- package/src/tools/skill.ts +0 -565
- package/src/tools/web-search/api.ts +0 -1318
- package/src/tools/web-search/index.ts +0 -2
- package/src/tools/workspace-state-connector.ts +0 -146
- package/src/tools/{approval → runtime/approval}/index.ts +0 -0
- package/src/tools/{interaction → runtime/interaction}/index.ts +0 -0
- package/src/tools/{temporary-agent → subagent/temporary}/coordinator.ts +0 -0
- package/src/tools/{subagent-types → subagent/types}/contract.ts +0 -0
- package/src/tools/{subagent-types → subagent/types}/extract/index.ts +0 -0
- package/src/tools/{subagent-types → subagent/types}/fanout/index.ts +0 -0
- package/src/tools/{subagent-types → subagent/types}/registry.ts +1 -1
- /package/src/tools/{workspace-versioning.ts → workspace/versioning.ts} +0 -0
|
@@ -1,10 +1,10 @@
|
|
|
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,
|
|
7
|
+
JsonValue,
|
|
8
8
|
ToolResultMessage,
|
|
9
9
|
UserMessage,
|
|
10
10
|
} from "@earendil-works/pi-ai";
|
|
@@ -17,11 +17,31 @@ import {
|
|
|
17
17
|
type PiToolApprovalView,
|
|
18
18
|
type UIUserSidecar,
|
|
19
19
|
} from "./message";
|
|
20
|
-
import {
|
|
21
|
-
import {
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
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
|
+
}
|
|
25
45
|
|
|
26
46
|
/**
|
|
27
47
|
* 本文件负责保存 Pi 的规范消息,并按浏览器或 Extension Host 的需要投影历史。
|
|
@@ -203,52 +223,42 @@ function hostText(message: UserMessage | AssistantMessage): string {
|
|
|
203
223
|
* Pi Session 保存规范分支,Runtime 数据库保存 Submission、审批和用户 sidecar;分开保存可避免把 UI 字段写进模型上下文。
|
|
204
224
|
*/
|
|
205
225
|
export class ConversationHistory {
|
|
206
|
-
private readonly store: CloudflareSessionStorage;
|
|
207
|
-
|
|
208
|
-
private modelBranch() {
|
|
209
|
-
return this.store.branch().filter((entry) =>
|
|
210
|
-
entry.type !== "message" ||
|
|
211
|
-
entry.message.role !== "assistant" ||
|
|
212
|
-
(!isModelStreamStallMessage(entry.message.errorMessage) &&
|
|
213
|
-
!isRecoverableAssistantError(entry.message))
|
|
214
|
-
);
|
|
215
|
-
}
|
|
216
|
-
|
|
217
|
-
/** Resolve a browser-stable user ID to the Session entry generated by Harness. */
|
|
218
|
-
private storedMessageId(id: string): string {
|
|
219
|
-
const direct = this.store.entry(id);
|
|
220
|
-
if (direct?.type === "message") return id;
|
|
221
|
-
const submissionByMessage =
|
|
222
|
-
this.durability.listSessionEntrySubmissionLinks();
|
|
223
|
-
return this.store.branch().find((entry) => {
|
|
224
|
-
if (entry.type !== "message" || entry.message.role !== "user") {
|
|
225
|
-
return false;
|
|
226
|
-
}
|
|
227
|
-
const body = this.durability.readUserSidecar(entry.id);
|
|
228
|
-
if (body) {
|
|
229
|
-
const sidecar = JSON.parse(body) as UIUserSidecar & { id?: unknown };
|
|
230
|
-
if (sidecar.id === id) return true;
|
|
231
|
-
}
|
|
232
|
-
const submissionId = submissionByMessage.get(entry.id);
|
|
233
|
-
return submissionId !== undefined &&
|
|
234
|
-
this.durability.findSubmissionProjection(submissionId)?.userMessageId ===
|
|
235
|
-
id;
|
|
236
|
-
})?.id ?? id;
|
|
237
|
-
}
|
|
238
|
-
|
|
239
|
-
/**
|
|
240
|
-
* 用当前 Agent 的 SQLite、Runtime 持久化端口和 Pi 模型表创建 Transcript。
|
|
241
|
-
*
|
|
242
|
-
* `AgentRuntimeKernel` 每个实例构造时调用一次;`hasActiveTurn` 必须反映当前 Submission 生命周期。
|
|
243
|
-
*
|
|
244
|
-
* 官方 Storage 契约由同一 DO SQLite 实现;模型目录用于压缩,活动 Turn 检查阻止并发导入。
|
|
245
|
-
*/
|
|
246
226
|
constructor(
|
|
247
|
-
storage: DurableObjectStorage,
|
|
227
|
+
private readonly storage: DurableObjectStorage,
|
|
248
228
|
private readonly durability: PiTranscriptDurability,
|
|
249
229
|
private readonly hasActiveTurn: () => boolean,
|
|
250
|
-
) {
|
|
251
|
-
|
|
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;
|
|
252
262
|
}
|
|
253
263
|
|
|
254
264
|
// #region 规范消息写入
|
|
@@ -260,33 +270,39 @@ export class ConversationHistory {
|
|
|
260
270
|
*
|
|
261
271
|
* sidecar 只随首次成功插入写入,使消息 ID 同时承担幂等键,重复请求不能覆盖原视图。
|
|
262
272
|
*/
|
|
263
|
-
append(
|
|
273
|
+
async append(
|
|
264
274
|
id: string,
|
|
265
275
|
message: AgentMessage,
|
|
266
|
-
options: {
|
|
267
|
-
|
|
268
|
-
|
|
269
|
-
|
|
270
|
-
|
|
271
|
-
|
|
272
|
-
|
|
273
|
-
|
|
274
|
-
|
|
275
|
-
|
|
276
|
-
|
|
277
|
-
|
|
278
|
-
|
|
279
|
-
|
|
280
|
-
|
|
281
|
-
|
|
282
|
-
|
|
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
|
+
}));
|
|
283
299
|
}
|
|
284
300
|
|
|
285
301
|
/** Persist a stable marker so browser history can distinguish a user stop from other aborts. */
|
|
286
302
|
appendTurnAbortedMarker(
|
|
287
303
|
submissionId: string,
|
|
288
304
|
timestamp: number,
|
|
289
|
-
): boolean {
|
|
305
|
+
): Promise<boolean> {
|
|
290
306
|
return this.append(`${submissionId}:turn-aborted`, {
|
|
291
307
|
role: "custom",
|
|
292
308
|
customType: "turn-aborted",
|
|
@@ -311,7 +327,7 @@ export class ConversationHistory {
|
|
|
311
327
|
* 这里只返回 `message` entry;compaction 等 Session entry 由上下文转换阶段处理,不能混入原始消息列表。
|
|
312
328
|
*/
|
|
313
329
|
async canonicalMessages(): Promise<AgentMessage[]> {
|
|
314
|
-
return this.
|
|
330
|
+
return (await this.storedEntries()).map(({ entry }) => entry).flatMap((entry) =>
|
|
315
331
|
entry.type === "message" ? [entry.message] : []
|
|
316
332
|
);
|
|
317
333
|
}
|
|
@@ -324,10 +340,13 @@ export class ConversationHistory {
|
|
|
324
340
|
* 同时检查 entry 类型和消息角色,防止其他 Session entry 或助手消息被当作 regenerate 起点。
|
|
325
341
|
*/
|
|
326
342
|
async findUserMessage(id: string): Promise<UserMessage | undefined> {
|
|
327
|
-
const
|
|
328
|
-
|
|
329
|
-
|
|
330
|
-
|
|
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
|
+
});
|
|
331
350
|
}
|
|
332
351
|
|
|
333
352
|
// #endregion
|
|
@@ -335,12 +354,12 @@ export class ConversationHistory {
|
|
|
335
354
|
// #region 分支、压缩与导入
|
|
336
355
|
|
|
337
356
|
/** Resolve the branch position before the user prompt that will be regenerated. */
|
|
338
|
-
regenerationTarget(id: string):
|
|
339
|
-
const
|
|
340
|
-
|
|
341
|
-
|
|
342
|
-
}
|
|
343
|
-
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;
|
|
344
363
|
}
|
|
345
364
|
|
|
346
365
|
/**
|
|
@@ -352,35 +371,18 @@ export class ConversationHistory {
|
|
|
352
371
|
*
|
|
353
372
|
* 待确认:当前代码没有拒绝以助手消息开头的历史,而这类消息没有 Submission 关联,会被浏览器投影略过。
|
|
354
373
|
*/
|
|
355
|
-
importSnapshot(snapshot: PiCanonicalTranscriptSnapshot): void {
|
|
356
|
-
if (this.hasActiveTurn())
|
|
357
|
-
|
|
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`);
|
|
358
385
|
}
|
|
359
|
-
this.durability.transaction(() => {
|
|
360
|
-
let currentSubmissionId: string | undefined;
|
|
361
|
-
for (const entry of snapshot.entries) {
|
|
362
|
-
if (entry.message.role === "user") {
|
|
363
|
-
currentSubmissionId = `import:${entry.id}`;
|
|
364
|
-
}
|
|
365
|
-
const appended = this.append(entry.id, entry.message, {
|
|
366
|
-
submissionId: currentSubmissionId,
|
|
367
|
-
...(entry.message.role === "user" && entry.userSidecar
|
|
368
|
-
? {
|
|
369
|
-
userMessage: {
|
|
370
|
-
id: entry.id,
|
|
371
|
-
role: "user",
|
|
372
|
-
parts: [...entry.userSidecar.parts],
|
|
373
|
-
...(entry.userSidecar.metadata === undefined
|
|
374
|
-
? {}
|
|
375
|
-
: { metadata: entry.userSidecar.metadata }),
|
|
376
|
-
} satisfies UIMessage & { role: "user" },
|
|
377
|
-
}
|
|
378
|
-
: {}),
|
|
379
|
-
});
|
|
380
|
-
if (!appended) {
|
|
381
|
-
throw new Error(`SpringBrand Session message "${entry.id}" already exists`);
|
|
382
|
-
}
|
|
383
|
-
}
|
|
384
386
|
});
|
|
385
387
|
}
|
|
386
388
|
|
|
@@ -389,7 +391,7 @@ export class ConversationHistory {
|
|
|
389
391
|
throughMessageId: string,
|
|
390
392
|
): Promise<PiCanonicalTranscriptSnapshot> {
|
|
391
393
|
const stored = await this.storedMessages();
|
|
392
|
-
const storedId = this.storedMessageId(throughMessageId);
|
|
394
|
+
const storedId = await this.storedMessageId(throughMessageId);
|
|
393
395
|
let cutoff = stored.findIndex((entry) => entry.id === storedId);
|
|
394
396
|
if (cutoff < 0) {
|
|
395
397
|
const targetSubmissionId = stored.find((entry) => {
|
|
@@ -441,8 +443,19 @@ export class ConversationHistory {
|
|
|
441
443
|
*
|
|
442
444
|
* 两类 Transcript 数据必须一起删除,否则复用消息 ID 时可能重新读到旧附件或 UI 元数据。
|
|
443
445
|
*/
|
|
444
|
-
clear(): void {
|
|
445
|
-
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
|
+
}));
|
|
446
459
|
this.durability.clearUserSidecars();
|
|
447
460
|
}
|
|
448
461
|
|
|
@@ -462,38 +475,17 @@ export class ConversationHistory {
|
|
|
462
475
|
}
|
|
463
476
|
|
|
464
477
|
/** Reconnect uses Pi's complete lane view, including uncommitted model/tool progress. */
|
|
465
|
-
async browserSnapshot(snapshot:
|
|
466
|
-
const entries = await this.storedMessages(snapshot.
|
|
467
|
-
const
|
|
468
|
-
const
|
|
469
|
-
|
|
470
|
-
if (streaming && streamingId) {
|
|
471
|
-
entries.push({
|
|
472
|
-
id: streamingId,
|
|
473
|
-
submissionId: operation.id,
|
|
474
|
-
message: streaming,
|
|
475
|
-
createdAt: streaming.timestamp,
|
|
476
|
-
});
|
|
477
|
-
}
|
|
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 });
|
|
478
483
|
let messages = await this.projectBrowserMessages(entries, streamingId);
|
|
479
|
-
for (const tool of
|
|
480
|
-
|
|
481
|
-
|
|
482
|
-
|
|
483
|
-
|
|
484
|
-
if (message.role !== "assistant" || metadata?.turnId !== operation?.id) {
|
|
485
|
-
return message;
|
|
486
|
-
}
|
|
487
|
-
return applyPiToolResult(message, {
|
|
488
|
-
role: "toolResult",
|
|
489
|
-
toolCallId: tool.toolCallId,
|
|
490
|
-
toolName: tool.toolName,
|
|
491
|
-
...toolResult,
|
|
492
|
-
content: toolResult.content ?? [],
|
|
493
|
-
isError: tool.status === "settled" && tool.isError,
|
|
494
|
-
timestamp: snapshot.operation!.startedAt,
|
|
495
|
-
}, { preliminary: tool.status === "running" });
|
|
496
|
-
});
|
|
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);
|
|
497
489
|
}
|
|
498
490
|
return messages;
|
|
499
491
|
}
|
|
@@ -502,6 +494,10 @@ export class ConversationHistory {
|
|
|
502
494
|
entries: readonly StoredTranscriptMessage[],
|
|
503
495
|
streamingId?: string,
|
|
504
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
|
+
});
|
|
505
501
|
const result: UIMessage[] = [];
|
|
506
502
|
const seenAssistantSubmissions = new Set<string>();
|
|
507
503
|
const seenUserSubmissions = new Set<string>();
|
|
@@ -517,7 +513,7 @@ export class ConversationHistory {
|
|
|
517
513
|
const submissionId = pendingFailedSubmissionId;
|
|
518
514
|
pendingFailedSubmissionId = undefined;
|
|
519
515
|
const submission = this.durability.findSubmissionProjection(submissionId);
|
|
520
|
-
if (!submission || (submission.status !== "error" &&
|
|
516
|
+
if (!submission || (submission.status !== "error" && submission.status !== "aborted" &&
|
|
521
517
|
!(submission.status === "skipped" && submission.error))) return;
|
|
522
518
|
const completedAt = submission.completedAt ?? submission.createdAt;
|
|
523
519
|
result.push({
|
|
@@ -535,7 +531,8 @@ export class ConversationHistory {
|
|
|
535
531
|
0,
|
|
536
532
|
completedAt - submission.createdAt,
|
|
537
533
|
),
|
|
538
|
-
turnStatus: "error",
|
|
534
|
+
turnStatus: submission.status === "aborted" ? "aborted" : "error",
|
|
535
|
+
...(submission.status === "aborted" ? { interruptedByUser: true } : {}),
|
|
539
536
|
...(submission.error
|
|
540
537
|
? { error: publicAssistantError(submission.error) }
|
|
541
538
|
: {}),
|
|
@@ -546,7 +543,9 @@ export class ConversationHistory {
|
|
|
546
543
|
let activeAssistant:
|
|
547
544
|
| { submissionId: string; index: number }
|
|
548
545
|
| undefined;
|
|
549
|
-
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;
|
|
550
549
|
if (
|
|
551
550
|
pendingFailedSubmissionId &&
|
|
552
551
|
entry.submissionId !== pendingFailedSubmissionId
|
|
@@ -597,6 +596,8 @@ export class ConversationHistory {
|
|
|
597
596
|
entry.message.role === "custom" &&
|
|
598
597
|
entry.message.customType === "turn-aborted"
|
|
599
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;
|
|
600
601
|
const submission = this.durability.findSubmissionProjection(
|
|
601
602
|
entry.submissionId,
|
|
602
603
|
);
|
|
@@ -810,7 +811,7 @@ export class ConversationHistory {
|
|
|
810
811
|
* UI uses the message event time; Pi entry timestamps describe storage commits.
|
|
811
812
|
*/
|
|
812
813
|
async storedMessages(
|
|
813
|
-
snapshotEntries
|
|
814
|
+
snapshotEntries?: readonly Entry[],
|
|
814
815
|
): Promise<StoredTranscriptMessage[]> {
|
|
815
816
|
return (await this.storedEntries(snapshotEntries)).flatMap((stored) => {
|
|
816
817
|
const { entry } = stored;
|
|
@@ -826,13 +827,14 @@ export class ConversationHistory {
|
|
|
826
827
|
|
|
827
828
|
/** Read every Session entry with its recoverable Submission association. */
|
|
828
829
|
async storedEntries(
|
|
829
|
-
snapshotEntries
|
|
830
|
+
snapshotEntries?: readonly Entry[],
|
|
830
831
|
): Promise<StoredSessionEntry[]> {
|
|
832
|
+
const projected = snapshotEntries ?? (await this.nativeEntries()).map(projectSessionEntry);
|
|
831
833
|
const submissionByEntry =
|
|
832
834
|
this.durability.listSessionEntrySubmissionLinks();
|
|
833
|
-
return
|
|
835
|
+
return projected.map((entry) => ({
|
|
834
836
|
id: entry.id,
|
|
835
|
-
submissionId: submissionByEntry.get(entry.id) ?? null,
|
|
837
|
+
submissionId: submissionByEntry.get(entry.id) ?? entry.submissionId ?? null,
|
|
836
838
|
entry,
|
|
837
839
|
createdAt: entry.timestamp,
|
|
838
840
|
}));
|
|
@@ -1,17 +1,41 @@
|
|
|
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";
|
|
7
19
|
|
|
8
|
-
/**
|
|
20
|
+
/**
|
|
21
|
+
* A user-selected Runtime capability persisted with the visible UIMessage.
|
|
22
|
+
*
|
|
23
|
+
* - `skill`: `name` is the installed Skill name.
|
|
24
|
+
* - `tool`: `name` is a Tool materialized on the Runtime surface.
|
|
25
|
+
* - `plan`: `name` is always `"plan"`.
|
|
26
|
+
* - `action`: `name` is a host-owned Action capability id. The Runtime only adds
|
|
27
|
+
* `requested capability: action/<id>` to private model context; the host is
|
|
28
|
+
* responsible for telling the model how to act on it.
|
|
29
|
+
*
|
|
30
|
+
* `label` is the display name; capability ids never enter visible message text.
|
|
31
|
+
* `iconUrl` is a display-only `https:` icon (for example an Action supplier's
|
|
32
|
+
* logo); it is persisted with the message and never sent to the model.
|
|
33
|
+
*/
|
|
9
34
|
export interface RequestedCapability {
|
|
10
|
-
readonly kind: "skill" | "tool" | "plan";
|
|
35
|
+
readonly kind: "skill" | "tool" | "plan" | "action";
|
|
11
36
|
readonly name: string;
|
|
12
37
|
readonly label: string;
|
|
13
|
-
|
|
14
|
-
readonly resourceId?: string;
|
|
38
|
+
readonly iconUrl?: string;
|
|
15
39
|
}
|
|
16
40
|
|
|
17
41
|
export interface UIChatRequestBody {
|
|
@@ -2,6 +2,32 @@ import type { UserMessage } from "@earendil-works/pi-ai";
|
|
|
2
2
|
import type { UIMessage } from "ai";
|
|
3
3
|
import type { RequestedCapability } from "./contract";
|
|
4
4
|
|
|
5
|
+
const REQUESTED_CAPABILITY_KINDS: ReadonlySet<RequestedCapability["kind"]> = new Set([
|
|
6
|
+
"skill",
|
|
7
|
+
"tool",
|
|
8
|
+
"plan",
|
|
9
|
+
"action",
|
|
10
|
+
]);
|
|
11
|
+
|
|
12
|
+
function isRequestedCapabilityKind(value: unknown): value is RequestedCapability["kind"] {
|
|
13
|
+
return typeof value === "string" &&
|
|
14
|
+
REQUESTED_CAPABILITY_KINDS.has(value as RequestedCapability["kind"]);
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
const MAX_CAPABILITY_ICON_URL_LENGTH = 2048;
|
|
18
|
+
|
|
19
|
+
/** Keep a display-only icon only when it is an `https:` URL; drop anything else silently. */
|
|
20
|
+
function capabilityIconUrlOf(value: unknown): string | undefined {
|
|
21
|
+
if (typeof value !== "string" || value.length > MAX_CAPABILITY_ICON_URL_LENGTH) {
|
|
22
|
+
return undefined;
|
|
23
|
+
}
|
|
24
|
+
try {
|
|
25
|
+
return new URL(value).protocol === "https:" ? value : undefined;
|
|
26
|
+
} catch {
|
|
27
|
+
return undefined;
|
|
28
|
+
}
|
|
29
|
+
}
|
|
30
|
+
|
|
5
31
|
export function requestedCapabilitiesOf(metadata: unknown): RequestedCapability[] {
|
|
6
32
|
if (metadata === null || typeof metadata !== "object" || Array.isArray(metadata)) {
|
|
7
33
|
return [];
|
|
@@ -19,7 +45,7 @@ export function requestedCapabilitiesOf(metadata: unknown): RequestedCapability[
|
|
|
19
45
|
}
|
|
20
46
|
const capability = value as Record<string, unknown>;
|
|
21
47
|
if (
|
|
22
|
-
(capability.kind
|
|
48
|
+
!isRequestedCapabilityKind(capability.kind) ||
|
|
23
49
|
typeof capability.name !== "string" ||
|
|
24
50
|
!capability.name.trim() ||
|
|
25
51
|
typeof capability.label !== "string" ||
|
|
@@ -30,7 +56,15 @@ export function requestedCapabilitiesOf(metadata: unknown): RequestedCapability[
|
|
|
30
56
|
const key = `${capability.kind}:${capability.name}`;
|
|
31
57
|
if (seen.has(key)) continue;
|
|
32
58
|
seen.add(key);
|
|
33
|
-
|
|
59
|
+
// Copy only the current fields: persisted messages may still carry legacy
|
|
60
|
+
// fields such as the retired `resourceId`, which are ignored.
|
|
61
|
+
const iconUrl = capabilityIconUrlOf(capability.iconUrl);
|
|
62
|
+
result.push({
|
|
63
|
+
kind: capability.kind,
|
|
64
|
+
name: capability.name,
|
|
65
|
+
label: capability.label,
|
|
66
|
+
...(iconUrl ? { iconUrl } : {}),
|
|
67
|
+
});
|
|
34
68
|
}
|
|
35
69
|
return result;
|
|
36
70
|
}
|
|
@@ -56,15 +90,22 @@ export function normalizeUIUserInput(
|
|
|
56
90
|
continue;
|
|
57
91
|
}
|
|
58
92
|
if (capability.kind === "tool") {
|
|
59
|
-
if (!installed.hasTool(capability.name)) {
|
|
60
|
-
|
|
61
|
-
}
|
|
93
|
+
// if (!installed.hasTool(capability.name)) {
|
|
94
|
+
// throw new Error(`Requested Tool is not installed: ${capability.name}`);
|
|
95
|
+
// }
|
|
62
96
|
context.push(
|
|
63
97
|
`requested capability: tool/${capability.name}`,
|
|
64
98
|
`required action: call tool "${capability.name}" when handling the task`,
|
|
65
99
|
);
|
|
66
100
|
continue;
|
|
67
101
|
}
|
|
102
|
+
if (capability.kind === "action") {
|
|
103
|
+
// Actions are host capabilities, not Runtime Tools: the Runtime only
|
|
104
|
+
// names the request and does not check installation. The host owns how
|
|
105
|
+
// the model acts on `action/<id>` (its tools and system guidance).
|
|
106
|
+
context.push(`requested capability: action/${capability.name}`);
|
|
107
|
+
continue;
|
|
108
|
+
}
|
|
68
109
|
if (capability.name !== "plan") {
|
|
69
110
|
throw new Error(`Unknown plan capability: ${capability.name}`);
|
|
70
111
|
}
|