mirror of
https://github.com/badlogic/pi-mono.git
synced 2026-11-05 23:37:25 +00:00
Submissions with request-ID deduplication and handles, the pi.live run control and presentation document, the built-in pi.generation task (system prompt preparation, streamed durable partials, retries, deferred polling, abort), typed entry tokens, stream options and retry policy, TaskRuntime reads, Tx.settleSubmission, registry conversation setups, Storage.scanSubmissions, Harness.inspect, and byTaskId attribution. Waiting calls enable scheduling. The scratch tour is split into test/examples.
141 lines
4.8 KiB
TypeScript
141 lines
4.8 KiB
TypeScript
// Transcript history and model context.
|
|
// Run from packages/durable:
|
|
// node --conditions=source --experimental-strip-types test/examples/09-context.ts
|
|
import { BACKGROUND_CONTEXT } from "@earendil-works/chord/context";
|
|
import {
|
|
type AssistantMessage,
|
|
createModels,
|
|
type Message,
|
|
type StopReason,
|
|
type ToolResultMessage,
|
|
} from "@earendil-works/pi-ai";
|
|
import { createRegistry, Harness, MemoryStorage } from "../../src/index.ts";
|
|
|
|
const context = BACKGROUND_CONTEXT;
|
|
const harness = await Harness.open(
|
|
new MemoryStorage(),
|
|
{ models: createModels(), registry: createRegistry() },
|
|
context,
|
|
);
|
|
|
|
// Entries are immutable. `model` holds the messages an entry contributes to
|
|
// the next model request; `data` is for the app only. context() turns the
|
|
// stored transcript into those request messages:
|
|
// - an entry with `head` starts a new context; older entries stay stored,
|
|
// - `edits` replace or omit what an earlier entry contributes,
|
|
// - aborted, error, and deferred assistant messages are not sent,
|
|
// - tool results are sent right after their call, in call order,
|
|
// - a call without a result gets a synthesized error result.
|
|
function assistantMessage(text: string, calls: readonly string[] = [], stopReason?: StopReason): AssistantMessage {
|
|
return {
|
|
role: "assistant",
|
|
content: [
|
|
{ type: "text", text },
|
|
...calls.map((id) => ({ type: "toolCall" as const, id, name: "read", arguments: {} })),
|
|
],
|
|
api: "example",
|
|
provider: "example",
|
|
model: "example",
|
|
usage: {
|
|
input: 0,
|
|
output: 0,
|
|
cacheRead: 0,
|
|
cacheWrite: 0,
|
|
totalTokens: 0,
|
|
cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0, total: 0 },
|
|
},
|
|
stopReason: stopReason ?? (calls.length > 0 ? "toolUse" : "stop"),
|
|
timestamp: 2,
|
|
};
|
|
}
|
|
|
|
function toolResultMessage(id: string): ToolResultMessage {
|
|
return {
|
|
role: "toolResult",
|
|
toolCallId: id,
|
|
toolName: "read",
|
|
content: [{ type: "text", text: `file ${id}` }],
|
|
isError: false,
|
|
timestamp: 3,
|
|
};
|
|
}
|
|
|
|
function show(message: Message): string {
|
|
switch (message.role) {
|
|
case "user":
|
|
return `user: ${message.content as string}`;
|
|
case "system":
|
|
return `system: ${JSON.stringify(message.sections)}`;
|
|
case "assistant":
|
|
return `assistant: ${message.content
|
|
.map((part) => (part.type === "text" ? part.text : part.type === "toolCall" ? `call(${part.id})` : ""))
|
|
.join(" ")}`;
|
|
case "toolResult":
|
|
return `result(${message.toolCallId})${message.isError ? " error" : ""}`;
|
|
}
|
|
}
|
|
|
|
const transcript = await harness.createConversation({ ownership: { kind: "ownerless" } }, context);
|
|
const say = (kind: string, ...model: Message[]) =>
|
|
transcript.commit((tx) => tx.appendEntry(transcript.id, { kind, model }), context);
|
|
|
|
const question = await say("message", { role: "user", content: "read a and b", timestamp: 1 });
|
|
await say("message", assistantMessage("I crashed", [], "aborted")); // stored, never sent
|
|
const calls = await say("message", assistantMessage("reading", ["a", "b"]));
|
|
await say("message", toolResultMessage("b")); // results finish out of order
|
|
await say("pi.system", { role: "system", content: "", sections: { cwd: "<cwd>/repo</cwd>" }, timestamp: 4 });
|
|
await say("message", toolResultMessage("a"));
|
|
await say("message", assistantMessage("a and b look fine"));
|
|
await transcript.commit(
|
|
(tx) =>
|
|
tx.appendEntry(transcript.id, {
|
|
kind: "edit",
|
|
data: "user fixed a typo",
|
|
edits: [
|
|
{
|
|
target: question.id,
|
|
action: "replace",
|
|
messages: [{ role: "user", content: "read files a and b", timestamp: 1 }],
|
|
},
|
|
],
|
|
}),
|
|
context,
|
|
);
|
|
await transcript.commit((tx) => tx.appendEntry(transcript.id, { kind: "note", data: "display only" }), context);
|
|
|
|
let view = await transcript.context(context);
|
|
console.log(
|
|
"raw active entries:",
|
|
view.entries.map((entry) => entry.kind),
|
|
);
|
|
console.log("request messages:", view.messages.map(show));
|
|
|
|
// A fork at the tool call has no results yet; context() fills them in.
|
|
const cut = await transcript.fork(calls.id, { ownership: { kind: "ownerless" } }, context);
|
|
console.log("fork messages:", (await cut.context(context)).messages.map(show));
|
|
|
|
// A headed summary replaces everything before the entry it points at.
|
|
// "self" points the head at the summary entry itself.
|
|
await transcript.commit(
|
|
(tx) =>
|
|
tx.appendEntry(transcript.id, {
|
|
kind: "summary",
|
|
head: "self",
|
|
model: [{ role: "user", content: "Summary: a and b are fine.", timestamp: 5 }],
|
|
}),
|
|
context,
|
|
);
|
|
view = await transcript.context(context);
|
|
console.log("after summary:", view.head?.kind, view.messages.map(show));
|
|
|
|
// entries() pages the stored transcript, newest first, including inherited
|
|
// parent entries. Nothing is ever deleted by heads or edits.
|
|
const history = await transcript.entries({}, 3, undefined, context);
|
|
console.log(
|
|
"newest stored entries:",
|
|
history.items.map((entry) => entry.kind),
|
|
"more:",
|
|
history.next !== undefined,
|
|
);
|
|
|
|
await harness.close(context);
|