chore(vendor): import pi 1.3.0

Imported as a linear vendor commit so forge rebases do not replay the upstream root tree.

git-subtree-dir: third_party/pi

git-subtree-split: 71023aec1546d6d677a65669c5665c0927ce0dd0
This commit is contained in:
2026-10-15 17:58:48 -04:00
parent 719370c8f7
commit 6081e3e325
1743 changed files with 205460 additions and 131705 deletions

View File

@@ -0,0 +1,21 @@
// A Session stores conversations, transcript entries, tasks, and documents.
// Run from packages/durable:
// node --conditions=source --experimental-strip-types test/examples/00-conversation.ts
import { BACKGROUND_CONTEXT } from "@earendil-works/chord/context";
import { createSession, MemoryStorage } from "../../src/index.ts";
// MemoryStorage keeps everything in memory; other storage backends keep it on disk.
const session = createSession(new MemoryStorage());
// Every Session call takes a context, which is used for cancellation.
// BACKGROUND_CONTEXT means "never cancel".
const context = BACKGROUND_CONTEXT;
// All writes happen inside session.commit(). The callback receives a
// transaction `tx`; everything it writes is saved together when the callback
// returns, or discarded if it throws.
// "ownerless" means no task created this conversation.
const standalone = await session.commit((tx) => tx.createConversation({ ownership: { kind: "ownerless" } }), context);
console.log("standalone conversation:", standalone);
await session.close(context);

View File

@@ -0,0 +1,45 @@
// Store document state next to transcript entries.
// Run from packages/durable:
// node --conditions=source --experimental-strip-types test/examples/01-documents.ts
import { BACKGROUND_CONTEXT } from "@earendil-works/chord/context";
import { createSession, defineDoc, MemoryStorage } from "../../src/index.ts";
const context = BACKGROUND_CONTEXT;
const session = createSession(new MemoryStorage());
// A document is a JSON object attached to something; here, one per conversation.
// "rewindable" keeps old values readable, so you can ask what the document
// looked like when a particular entry was written.
// `fork` says what a forked copy of the conversation starts with (see 02-forks.ts).
const Notes = defineDoc<{ text: string }>({
kind: "example.notes",
version: 1,
scope: "conversation",
history: "rewindable",
fork: "asOf", // a fork starts with the value these notes had at the fork entry
initial: () => ({ text: "" }),
});
const chat = await session.commit((tx) => tx.createConversation({ ownership: { kind: "ownerless" } }), context);
// tx.doc() returns an editable copy of the document (created on first use).
// Plain assignments to it are saved when the commit finishes.
const firstEntry = await session.commit(async (tx) => {
const entry = await tx.appendEntry(chat.id, { kind: "note", data: "hello" });
(await tx.doc(Notes, chat.id)).text = "after hello";
return entry;
}, context);
const secondEntry = await session.commit(async (tx) => {
const entry = await tx.appendEntry(chat.id, { kind: "note", data: "goodbye" });
(await tx.doc(Notes, chat.id)).text = "after goodbye";
return entry;
}, context);
// snapshot() reads the latest value. snapshotAsOf() reads the value that was
// saved in the same commit as the given entry.
console.log("latest notes:", await session.snapshot(Notes, chat.id, context));
console.log("notes at first entry:", await session.snapshotAsOf(Notes, chat.id, firstEntry.id, context));
console.log("notes at second entry:", await session.snapshotAsOf(Notes, chat.id, secondEntry.id, context));
await session.close(context);

View File

@@ -0,0 +1,55 @@
// Fork a conversation.
// Run from packages/durable:
// node --conditions=source --experimental-strip-types test/examples/02-forks.ts
import { BACKGROUND_CONTEXT } from "@earendil-works/chord/context";
import { createSession, defineDoc, MemoryStorage } from "../../src/index.ts";
const context = BACKGROUND_CONTEXT;
const session = createSession(new MemoryStorage());
const Notes = defineDoc<{ text: string }>({
kind: "example.notes",
version: 1,
scope: "conversation",
history: "rewindable",
fork: "asOf",
initial: () => ({ text: "" }),
});
const chat = await session.commit((tx) => tx.createConversation({ ownership: { kind: "ownerless" } }), context);
const firstEntry = await session.commit(async (tx) => {
const entry = await tx.appendEntry(chat.id, { kind: "note", data: "hello" });
(await tx.doc(Notes, chat.id)).text = "after hello";
return entry;
}, context);
await session.commit(async (tx) => {
await tx.appendEntry(chat.id, { kind: "note", data: "goodbye" });
(await tx.doc(Notes, chat.id)).text = "after goodbye";
}, context);
// A fork is a new conversation that continues from one entry of another. It
// sees the parent's transcript up to that entry, and each document follows its
// own `fork` setting. Notes uses "asOf", so the fork starts with the notes
// value from the fork entry.
const branch = await session.commit(
(tx) => tx.forkConversation(chat.id, firstEntry.id, { ownership: { kind: "ownerless" } }),
context,
);
// scanEntries() pages through visible entries, newest first. The fork sees
// "hello" (inherited from the parent) but not "goodbye", which came later.
const branchEntries = await session.commit((tx) => tx.scanEntries({ conversationId: branch.id }, 10), context);
console.log(
"fork transcript:",
branchEntries.items.map((entry) => entry.data),
);
console.log("fork notes:", await session.snapshot(Notes, branch.id, context));
// The fork's copy is independent: editing it leaves the parent unchanged.
await session.commit(async (tx) => {
(await tx.doc(Notes, branch.id)).text = "changed only in the fork";
}, context);
console.log("fork notes after edit:", await session.snapshot(Notes, branch.id, context));
console.log("parent notes after edit:", await session.snapshot(Notes, chat.id, context));
await session.close(context);

View File

@@ -0,0 +1,69 @@
// A background task that owns a child conversation.
// Run from packages/durable:
// node --conditions=source --experimental-strip-types test/examples/03-owned-conversations.ts
import { BACKGROUND_CONTEXT } from "@earendil-works/chord/context";
import { type ConversationId, createSession, defineDoc, defineTask, MemoryStorage } from "../../src/index.ts";
const context = BACKGROUND_CONTEXT;
const session = createSession(new MemoryStorage());
// A typical agent setup: a background task supervises a helper conversation,
// and the main conversation keeps a registry that maps agent names to their
// conversations. All three are created in one commit, so after a crash either
// all of them exist or none do.
// A task definition needs a name, a version, the task's starting state, a
// handler for every phase, and an abort handler (12-tasks.ts runs a task).
// This example only creates the task record; a plain Session never runs it.
const Supervisor = defineTask<null, { phase: "ready" }, null>({
name: "example.supervisor",
version: 1,
initial: () => ({ phase: "ready" }),
phases: { ready: async () => {} },
abort: async () => {},
});
// "latest" keeps only the current value. "initial" means forks of this
// conversation start without a registry, so a child doesn't inherit its
// parent's list of agents.
const AgentRegistry = defineDoc<{
agents: Record<string, { conversationId: ConversationId; requestId: string }>;
}>({
kind: "example.agent-registry",
version: 1,
scope: "conversation",
history: "latest",
fork: "initial",
initial: () => ({ agents: {} }),
});
const main = await session.commit((tx) => tx.createConversation({ ownership: { kind: "ownerless" } }), context);
const setup = await session.commit(async (tx) => {
// `background: true` means the task is side work: waiting for the main
// conversation to finish does not wait for it.
const supervisorId = await tx.createTask(Supervisor, null, {
ownership: { kind: "conversation" },
conversationId: main.id,
background: true,
});
// The child records that it belongs to the supervisor task. The task was
// created a few lines above in this same commit, which is allowed.
const child = await tx.createConversation({ ownership: { kind: "task", taskId: supervisorId } });
// requestId is a fixed name for the child's first message. Later code sends
// that message using this requestId, so a retry after a crash cannot
// deliver it twice.
(await tx.doc(AgentRegistry, main.id)).agents.researcher = {
conversationId: child.id,
requestId: `researcher:first-message:${supervisorId}`,
};
return { supervisorId, child };
}, context);
console.log("supervisor task:", setup.supervisorId);
console.log("child conversation:", setup.child);
console.log("registry:", await session.snapshot(AgentRegistry, main.id, context));
await session.close(context);

View File

@@ -0,0 +1,37 @@
// Expose a document through Chord.
// Run from packages/durable:
// node --conditions=source --experimental-strip-types test/examples/04-chord-state.ts
import { BACKGROUND_CONTEXT } from "@earendil-works/chord/context";
import { createSession, defineDoc, MemoryStorage } from "../../src/index.ts";
const context = BACKGROUND_CONTEXT;
const session = createSession(new MemoryStorage());
const Notes = defineDoc<{ text: string }>({
kind: "example.notes",
version: 1,
scope: "conversation",
history: "rewindable",
fork: "asOf",
initial: () => ({ text: "" }),
});
const chat = await session.commit(async (tx) => {
const conversation = await tx.createConversation({ ownership: { kind: "ownerless" } });
(await tx.doc(Notes, conversation.id)).text = "first";
return conversation;
}, context);
// documentState() never creates a document. It returns a hydrated read-only
// Chord state bound to the current concrete incarnation.
const notesState = await session.documentState(Notes, chat.id, context);
if (notesState === undefined) throw new Error("notes are absent");
const stopNotes = notesState.subscribe((value, _deliveryContext, delivery) => {
console.log("Chord notes:", delivery.kind, delivery.sequence, value);
});
await session.commit(async (tx) => {
(await tx.doc(Notes, chat.id)).text = "published through Chord";
}, context);
stopNotes();
notesState.dispose();
await session.close(context);

View File

@@ -0,0 +1,41 @@
// Serialize asynchronous document work with a watch.
// Run from packages/durable:
// node --conditions=source --experimental-strip-types test/examples/05-watches.ts
import { BACKGROUND_CONTEXT } from "@earendil-works/chord/context";
import { createSession, defineDoc, MemoryStorage } from "../../src/index.ts";
const context = BACKGROUND_CONTEXT;
const session = createSession(new MemoryStorage());
const Notes = defineDoc<{ text: string }>({
kind: "example.notes",
version: 1,
scope: "conversation",
history: "rewindable",
fork: "asOf",
initial: () => ({ text: "" }),
});
const chat = await session.commit(async (tx) => {
const conversation = await tx.createConversation({ ownership: { kind: "ownerless" } });
(await tx.doc(Notes, conversation.id)).text = "first";
return conversation;
}, context);
// A watch starts from one stable acquisition revision. Slow callbacks never
// overlap; exact committed frames buffer, with a full-value reset after 100.
const notesWatch = await session.watchDoc(Notes, chat.id, context);
if (notesWatch === undefined) throw new Error("notes are absent");
console.log("watch baseline:", notesWatch.value);
const delivered = new Promise<void>((resolve) => {
notesWatch.start(async (value, _ops, _deliveryContext) => {
console.log("watch update:", value);
resolve();
});
});
await session.commit(async (tx) => {
(await tx.doc(Notes, chat.id)).text = "observed asynchronously";
}, context);
await delivered;
await notesWatch.stop();
await session.close(context);

View File

@@ -0,0 +1,66 @@
// Open a Harness with a registry of extensions.
// Run from packages/durable:
// node --conditions=source --experimental-strip-types test/examples/06-harness.ts
import { BACKGROUND_CONTEXT } from "@earendil-works/chord/context";
import { Type } from "@earendil-works/pi-ai";
import { createModels } from "@earendil-works/pi-ai/models";
import {
AgentDoc,
createRegistry,
defineDoc,
defineExtension,
defineTool,
Harness,
MemoryStorage,
} from "../../src/index.ts";
const context = BACKGROUND_CONTEXT;
// A Harness is a Session plus conversation handles and durable tasks. Extension
// code (tools, prompt sections, hooks, tasks) comes in named extensions
// installed in a registry the application owns. Nothing in the registry is
// saved; it is this process's code.
const read = defineTool({
name: "read",
description: "Read a file",
parameters: Type.Object({ path: Type.String() }),
execute: async (args) => ({ output: [{ type: "text", text: `contents of ${args.path}` }] }),
});
const Files = defineExtension({ name: "files", tools: [read] });
const registry = createRegistry();
registry.install(Files);
// `models` is pi-ai's model access; generation calls models through it.
const harness = await Harness.open(new MemoryStorage(), { models: createModels(), registry }, context);
const Notes = defineDoc<{ text: string }>({
kind: "example.notes",
version: 1,
scope: "conversation",
history: "rewindable",
fork: "asOf",
initial: () => ({ text: "" }),
});
// The root conversation always has ID 1. The first root() call creates it,
// its built-in documents, the `agent` choices, and whatever `init` writes, all
// in one commit. Later calls, including after a restart, return it and ignore
// both options.
const root = await harness.root(context, {
agent: { thinkingLevel: "low" },
init: async (tx, rootId) => {
(await tx.doc(Notes, rootId)).text = "root notes";
},
});
console.log("root:", root.id, await harness.snapshot(Notes, root.id, context));
// The stored choices are names; agent() resolves them against the registry.
console.log("stored agent:", await harness.snapshot(AgentDoc, root.id, context));
const agent = await root.agent(context);
console.log(
"resolved:",
agent.thinkingLevel,
agent.extensions.map((extension) => extension.name),
agent.tools.map((tool) => tool.name),
);
await harness.close(context);

View File

@@ -0,0 +1,99 @@
// Per-conversation agent choices and Harness-wide settings.
// Run from packages/durable:
// node --conditions=source --experimental-strip-types test/examples/07-configuration.ts
import { BACKGROUND_CONTEXT } from "@earendil-works/chord/context";
import { Type } from "@earendil-works/pi-ai";
import { createModels } from "@earendil-works/pi-ai/models";
import {
AgentDoc,
createRegistry,
defineExtension,
defineTool,
Harness,
type HarnessSettings,
MemoryStorage,
section,
type ToolRegistration,
} from "../../src/index.ts";
const context = BACKGROUND_CONTEXT;
// Apps may attach their own metadata to tools, such as a prompt snippet.
type AppTool = ToolRegistration & { readonly snippet?: string };
function exampleTool(name: string, description: string): AppTool {
const tool = defineTool({
name,
description,
parameters: Type.Object({ path: Type.String() }),
execute: async (args) => ({ output: [{ type: "text", text: `${name} ${args.path}` }] }),
});
return { ...tool, snippet: `Use ${name} for files.` };
}
const read = exampleTool("read", "Read a file");
const write = exampleTool("write", "Write a file");
const grep = exampleTool("grep", "Search files");
const Files = defineExtension<AppTool>({ name: "files", tools: [read, write] });
const Search = defineExtension<AppTool>({ name: "search", tools: [grep] });
// Sections see the offered tools as app tools, so one can render their snippets.
const Snippets = defineExtension<AppTool>({
name: "snippets",
sections: [section("tool_snippets", (input) => input.agent.tools.map((tool) => tool.snippet).join("\n"))],
});
const registry = createRegistry<AppTool>();
registry.install(Files);
registry.install(Search);
registry.install(Snippets);
// Settings are Harness-wide run policy, read at every use and never stored.
// A getter makes a value live, such as one backed by the app's settings file.
let timeoutMs = 60_000;
const settings: HarnessSettings = {
get stream() {
return { timeoutMs };
},
retry: { maxRetries: 5 },
toolExecution: "sequential",
};
const harness = await Harness.open(new MemoryStorage(), { models: createModels(), registry, settings }, context);
const root = await harness.root(context);
// A new conversation stores no choices and follows the host: every installed
// extension, all of their tools.
const tools = async () => (await root.agent(context)).tools.map((tool) => tool.name);
console.log("default tools:", await tools());
// configure() stores choices in the conversation's pi.agent document, one
// commit per call. Extensions and tools are passed as objects and stored by
// name, so a typo cannot slip in.
await root.configure(
{
model: { provider: "anthropic", modelId: "claude-sonnet-4-5" },
thinkingLevel: "high",
tools: [write, read],
},
context,
);
console.log("stored:", await harness.snapshot(AgentDoc, root.id, context));
const agent = await root.agent(context);
console.log("model:", agent.model, "thinking:", agent.thinkingLevel, "tools:", await tools());
// Deselect an extension; `null` clears a stored field back to the host default.
await root.configure({ extensions: { remove: [Search] }, tools: null }, context);
console.log("without search:", await tools());
// Stored names outlive the code. The conversation selects exactly these two
// extensions; uninstalling "files" leaves it selected, and requests just stop
// offering its tools until it is back.
await root.configure({ extensions: [Files, Search] }, context);
registry.uninstall(Files);
console.log("files uninstalled:", await tools());
registry.install(Files);
console.log("files reinstalled:", await tools());
// Settings changes need no commit; the next request uses the new timeout.
timeoutMs = 120_000;
await harness.close(context);

View File

@@ -0,0 +1,42 @@
// Conversation handles, typed entries, created conversations, and forks.
// Run from packages/durable:
// node --conditions=source --experimental-strip-types test/examples/08-harness-conversations.ts
import { BACKGROUND_CONTEXT } from "@earendil-works/chord/context";
import { createModels } from "@earendil-works/pi-ai/models";
import { createRegistry, defineEntry, Harness, MemoryStorage } from "../../src/index.ts";
const context = BACKGROUND_CONTEXT;
const harness = await Harness.open(
new MemoryStorage(),
{ models: createModels(), registry: createRegistry() },
context,
);
const root = await harness.root(context);
await root.configure({ thinkingLevel: "high" }, context);
// Conversation handles are stateless; compare them by id. They bind commits
// to their conversation. An entry token types an entry kind's `data`.
const Message = defineEntry<{ from: string }>("message");
const hello = await root.commit(
(tx) =>
tx.appendEntry(Message, root.id, {
data: { from: "example" },
model: [{ role: "user", content: "hello", timestamp: 1 }],
}),
context,
);
console.log("typed entry:", Message.is(hello), hello.data.from);
// createConversation() and fork() apply `agent` and run `init` in the
// creating commit. A fork starts with the agent the parent had at the fork
// entry.
const helper = await harness.createConversation(
{ ownership: { kind: "ownerless" }, agent: { thinkingLevel: "minimal" } },
context,
);
const retry = await root.fork(hello.id, { ownership: { kind: "ownerless" } }, context);
console.log("helper thinking:", (await helper.agent(context)).thinkingLevel);
console.log("fork thinking:", (await retry.agent(context)).thinkingLevel);
console.log("lookup:", (await harness.conversation(retry.id, context))?.id === retry.id);
await harness.close(context);

View File

@@ -0,0 +1,136 @@
// 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, Message, StopReason, ToolResultMessage } from "@earendil-works/pi-ai";
import { createModels } from "@earendil-works/pi-ai/models";
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);

View File

@@ -0,0 +1,48 @@
// Reload extension code through the registry.
// Run from packages/durable:
// node --conditions=source --experimental-strip-types test/examples/10-registry-reload.ts
import { BACKGROUND_CONTEXT } from "@earendil-works/chord/context";
import { Type } from "@earendil-works/pi-ai";
import { createModels } from "@earendil-works/pi-ai/models";
import { createRegistry, defineExtension, defineTool, Harness, MemoryStorage, wrapTool } from "../../src/index.ts";
const context = BACKGROUND_CONTEXT;
function exampleTool(name: string, description: string) {
return defineTool({
name,
description,
parameters: Type.Object({ path: Type.String() }),
execute: async (args) => ({ output: [{ type: "text", text: `${name} ${args.path}` }] }),
});
}
const read = exampleTool("read", "Read a file");
const registry = createRegistry();
registry.install(defineExtension({ name: "files", tools: [read, exampleTool("grep", "Search files")] }));
const harness = await Harness.open(new MemoryStorage(), { models: createModels(), registry }, context);
const root = await harness.root(context);
const tools = async () => (await root.agent(context)).tools.map((tool) => `${tool.name}: ${tool.description}`);
// Reloading is installing a new object with the same name: it replaces the old
// one in place, in one publication, so no conversation ever sees it missing.
// A task phase that already started keeps the snapshot it took; the next phase
// uses the new code.
registry.install(defineExtension({ name: "files", tools: [read, exampleTool("grep", "Search files, faster")] }));
console.log("after reload:", await tools());
// Another extension may wrap a tool by name. The wrap applies where the
// wrapping extension is selected, and survives reloads of the wrapped tool.
const Audit = defineExtension({
name: "audit",
wraps: [wrapTool(read, (tool) => ({ ...tool, description: `${tool.description} (audited)` }))],
});
registry.install(Audit);
console.log("with audit:", await tools());
// Uninstall removes an extension by name; conversations that select it stop
// getting its code.
registry.uninstall(Audit);
console.log("after uninstall:", await tools());
await harness.close(context);

View File

@@ -0,0 +1,86 @@
// An extension that keeps its own per-conversation document.
// Run from packages/durable:
// node --conditions=source --experimental-strip-types test/examples/11-extension-state.ts
import { BACKGROUND_CONTEXT } from "@earendil-works/chord/context";
import { Type } from "@earendil-works/pi-ai";
import { createModels } from "@earendil-works/pi-ai/models";
import { fauxAssistantMessage, fauxProvider, fauxToolCall } from "@earendil-works/pi-ai/providers/faux";
import {
createRegistry,
defineDoc,
defineExtension,
defineTool,
Harness,
MemoryStorage,
section,
} from "../../src/index.ts";
const context = BACKGROUND_CONTEXT;
// A todo list per conversation. Rewindable with `asOf`, so a fork starts with
// the todos its parent had at the fork entry.
const Todos = defineDoc<{ items: string[] }>({
kind: "example.todos",
version: 1,
scope: "conversation",
history: "rewindable",
fork: "asOf",
initial: () => ({ items: [] }),
});
// The tool writes the document; the section shows it to the model before every
// request. Nothing needs to create it up front: tx.doc() creates it on first
// write, and the section treats a missing document as an empty list.
const Todo = defineExtension({
name: "todo",
tools: [
defineTool({
name: "todo",
description: "Add an item to your todo list",
parameters: Type.Object({ item: Type.String() }),
execute: async (args, api, callContext) => {
await api.commit(async (tx) => {
(await tx.doc(Todos, api.conversationId)).items.push(args.item);
}, callContext);
return { output: [{ type: "text", text: `added ${args.item}` }] };
},
}),
],
sections: [
section("todos", async (input, renderContext) => {
const todos = await input.read.snapshot(Todos, input.conversationId, renderContext);
return todos === undefined || todos.items.length === 0 ? undefined : todos.items.join("\n");
}),
],
});
const faux = fauxProvider();
const models = createModels();
models.setProvider(faux.provider);
const registry = createRegistry();
registry.install(Todo);
const harness = await Harness.open(new MemoryStorage(), { models, registry }, context);
const root = await harness.root(context, { agent: { model: { provider: "faux", modelId: "faux-1" } } });
faux.setResponses([
fauxAssistantMessage(fauxToolCall("todo", { item: "fix the build" }), { stopReason: "toolUse" }),
fauxAssistantMessage("Noted."),
fauxAssistantMessage("Working on it."),
]);
await (await root.submit({ type: "input", content: "Remember to fix the build." }, context)).wait(context);
console.log("todos:", await harness.snapshot(Todos, root.id, context));
// The next request's system prompt carries the list. The first system message
// only announced the todo tool; no section had text yet.
await (await root.submit({ type: "input", content: "What is next?" }, context)).wait(context);
const { messages } = await root.context(context);
console.log(
"system messages:",
messages.flatMap((message) =>
message.role === "system"
? [{ sections: message.sections, toolsAdded: message.toolsAdded?.map((tool) => tool.name) }]
: [],
),
);
await harness.close(context);

View File

@@ -0,0 +1,64 @@
// Run a durable task.
// Run from packages/durable:
// node --conditions=source --experimental-strip-types test/examples/12-tasks.ts
import { BACKGROUND_CONTEXT } from "@earendil-works/chord/context";
import { createModels } from "@earendil-works/pi-ai/models";
import { createRegistry, defineExtension, defineTask, Harness, MemoryStorage } from "../../src/index.ts";
const context = BACKGROUND_CONTEXT;
// A task is a small state machine. Its state, the checkpoint, is saved after
// every step, so after a crash the next open continues from the last saved
// step. The usual pattern: save what you are about to do, do it, then save
// the result. A crash between doing and saving reruns that step, so the step
// must be safe to repeat; here the fake payment service ignores a repeated key.
const payments = new Map<string, number>();
type PaymentState = { phase: "prepare" } | { phase: "charge"; key: string };
const Payment = defineTask<{ amount: number }, PaymentState, { receipt: number }>({
name: "example.payment",
version: 1,
initial: () => ({ phase: "prepare" }),
// One handler per phase. Each must save progress through runtime.commit():
// its callback returns the next checkpoint or the final outcome, and that
// state is saved in the same commit as everything else the callback wrote.
phases: {
prepare: async (task, runtime, taskContext) => {
await runtime.commit(
() => ({ status: "running", checkpoint: { phase: "charge", key: `payment-${task.id}` } }),
taskContext,
);
},
charge: async (task, runtime, taskContext) => {
const key = task.state.checkpoint.key;
if (!payments.has(key)) payments.set(key, task.input.amount * 100);
const receipt = payments.get(key)!;
await runtime.commit(
() => ({ status: "terminal", outcome: { status: "completed", result: { receipt } } }),
taskContext,
);
},
},
// Runs instead of the phases after harness.abortTask(); it decides the outcome.
abort: async (_task, runtime, taskContext) => {
await runtime.commit(() => ({ status: "terminal", outcome: { status: "aborted" } }), taskContext);
},
});
// Extensions bring task code; the Harness finds it by name in the registry.
// Nothing runs until resume() or a call that waits for progress, such as
// waitForTask().
const registry = createRegistry();
registry.install(defineExtension({ name: "payments", tasks: [Payment] }));
const harness = await Harness.open(new MemoryStorage(), { models: createModels(), registry }, context);
const root = await harness.root(context);
// Every task names its owner. This one belongs to the conversation; a task can
// also own child tasks and wait for them (24-child-tasks.ts).
const paymentId = await root.commit(
(tx) => tx.createTask(Payment, { amount: 5 }, { ownership: { kind: "conversation" } }),
context,
);
// The finished task record is the durable receipt; waitForTask() knows its result type.
const paid = await harness.waitForTask(paymentId, context);
console.log("payment outcome:", paid.state.outcome);
await harness.close(context);

View File

@@ -0,0 +1,90 @@
// Close, reopen, and continue where a task stopped.
// Run from packages/durable:
// node --conditions=source --experimental-strip-types test/examples/13-recovery.ts
import { mkdtemp, rm } from "node:fs/promises";
import { tmpdir } from "node:os";
import { join } from "node:path";
import { BACKGROUND_CONTEXT } from "@earendil-works/chord/context";
import { createModels } from "@earendil-works/pi-ai/models";
import { createRegistry, defineExtension, defineTask, Harness } from "../../src/index.ts";
import { openNodeSqliteStorage } from "../../src/storage/sqlite/node.ts";
const context = BACKGROUND_CONTEXT;
// Everything a task needs to continue is in storage, so a new Harness over the
// same storage picks up where the last one stopped. This example keeps its
// storage in a SQLite file so it survives closing.
const directory = await mkdtemp(join(tmpdir(), "pi-durable-example-"));
const databasePath = join(directory, "session.sqlite");
let reachedTick = (_n: number): void => {};
const Ticker = defineTask<{ to: number }, { phase: "tick"; n: number }, string>({
name: "example.ticker",
version: 1,
initial: () => ({ phase: "tick", n: 1 }),
phases: {
tick: async (task, runtime, taskContext) => {
const n = task.state.checkpoint.n;
// Save the intent before the effect. A memo keeps the first value
// written under its name, so if the process dies after printing but
// before the next checkpoint is saved, the rerun sees the memo and
// does not print the same tick twice.
if ((await runtime.memo(`printed-${n}`, taskContext)) === undefined) {
await runtime.memo(`printed-${n}`, true, taskContext);
console.log(`tick ${n}`);
}
reachedTick(n);
// Save the outcome: the next tick, or the final result.
await runtime.commit(
() =>
n === task.input.to
? { status: "terminal", outcome: { status: "completed", result: `counted to ${n}` } }
: { status: "running", checkpoint: { phase: "tick", n: n + 1 } },
taskContext,
);
// Wait a little between ticks. Closing the Harness cancels this wait;
// the checkpoint saved above is where the next Harness continues.
await runtime.sleep(Date.now() + 50, taskContext);
},
},
abort: async (_task, runtime, taskContext) => {
await runtime.commit(() => ({ status: "terminal", outcome: { status: "aborted" } }), taskContext);
},
});
const registry = createRegistry();
registry.install(defineExtension({ name: "ticker", tasks: [Ticker] }));
const open = async () =>
Harness.open(await openNodeSqliteStorage(databasePath), { models: createModels(), registry }, context);
// First run: start counting to 5, and close the Harness right after tick 2 is
// printed, before its next checkpoint is saved. That is the same situation as
// a crash between the effect and saving its outcome.
const firstRun = await open();
const tickerId = await (await firstRun.root(context)).commit(
(tx) => tx.createTask(Ticker, { to: 5 }, { ownership: { kind: "conversation" } }),
context,
);
const tickTwo = new Promise<void>((resolve) => {
reachedTick = (n) => {
if (n === 2) resolve();
};
});
firstRun.resume();
await tickTwo;
await firstRun.close(context);
reachedTick = () => {};
// Read the saved record through a Harness that never resumes, so nothing runs.
const reader = await open();
const saved = (await reader.getTask(tickerId, context))!;
await reader.close(context);
console.log("closed; saved checkpoint:", saved.state, "memos:", saved.memos);
// Second run: nothing to restart by hand. Waiting for the unfinished task
// enables scheduling and continues it. Tick 2 runs again because its outcome
// was never saved, but its memo says it was already printed.
const secondRun = await open();
const counted = await secondRun.waitForTask(tickerId, context);
console.log("after reopen:", counted.state.outcome);
await secondRun.close(context);
await rm(directory, { recursive: true, force: true });

View File

@@ -0,0 +1,45 @@
// A chat turn.
// Run from packages/durable:
// node --conditions=source --experimental-strip-types test/examples/14-chat.ts
import { BACKGROUND_CONTEXT } from "@earendil-works/chord/context";
import { createModels } from "@earendil-works/pi-ai/models";
import { fauxAssistantMessage, fauxProvider } from "@earendil-works/pi-ai/providers/faux";
import { AssistantEntry, createRegistry, defineExtension, Harness, MemoryStorage, section } from "../../src/index.ts";
const context = BACKGROUND_CONTEXT;
// The faux provider stands in for a real model (see 16-real-model.ts).
const faux = fauxProvider();
const models = createModels();
models.setProvider(faux.provider);
faux.setResponses([fauxAssistantMessage("Paris.")]);
const registry = createRegistry();
registry.install(
defineExtension({
name: "terse",
sections: [section("preamble", () => "You answer in one word.", { tag: false })],
}),
);
const harness = await Harness.open(new MemoryStorage(), { models, registry }, context);
const root = await harness.root(context, { agent: { model: { provider: "faux", modelId: "faux-1" } } });
// submit() durably admits user input and returns a Submission. The built-in
// pi.generation task prepares the system prompt from the agent's sections,
// calls the model, and appends the answer; wait() resolves once the input is
// answered or has failed.
const capital = await root.submit({ type: "input", content: "Capital of France?" }, context);
const answered = await capital.wait(context);
if (answered.status === "done" && answered.type === "input") {
const entry = await root.commit((tx) => tx.entry(AssistantEntry, answered.answer), context);
const reply = entry?.model?.[0];
console.log("answer:", reply?.role === "assistant" ? reply.content : reply);
}
// The transcript holds the user input, the positional system prompt, and the answer.
const transcript = await root.entries({}, 10, undefined, context);
console.log(
"transcript:",
[...transcript.items].reverse().map((entry) => entry.kind),
);
await harness.close(context);

View File

@@ -0,0 +1,93 @@
// System prompt sections and per-conversation instructions.
// Run from packages/durable:
// node --conditions=source --experimental-strip-types test/examples/15-system-prompt.ts
import { BACKGROUND_CONTEXT } from "@earendil-works/chord/context";
import { createModels } from "@earendil-works/pi-ai/models";
import { fauxAssistantMessage, fauxProvider } from "@earendil-works/pi-ai/providers/faux";
import { NodeExecutionEnv } from "../../src/env/node.ts";
import {
type Conversation,
createRegistry,
defineExtension,
Harness,
MemoryStorage,
SystemEntry,
section,
wrapSection,
} from "../../src/index.ts";
const context = BACKGROUND_CONTEXT;
const faux = fauxProvider();
const models = createModels();
models.setProvider(faux.provider);
faux.setResponses([fauxAssistantMessage("Done."), fauxAssistantMessage("Done."), fauxAssistantMessage("Done.")]);
// Pico stores no prompt state. Before each model request, the sections of the
// conversation's selected extensions render the desired prompt, and only the
// difference to what the model already saw is appended to the transcript as a
// `pi.system` entry.
const Coding = defineExtension({
name: "coding",
sections: [
// `tag: false` sends the text as is; by default it is wrapped in <key>...</key>.
section("preamble", () => "You are a coding agent.", { tag: false }),
// Sections see the environment built for this request, here its working directory.
section("cwd", (input) => input.env?.cwd),
],
});
const AgentsMd = defineExtension({
name: "agents-md",
sections: [section("agents_md", () => "Run npm run check after changes.")],
});
// Another extension decorates a section by key without replacing it.
const Terse = defineExtension({
name: "terse",
wraps: [
wrapSection("preamble", (preamble) => ({
...preamble,
render: async (input, renderContext) => `${await preamble.render(input, renderContext)} Be terse.`,
})),
],
});
const registry = createRegistry();
registry.install(Coding);
registry.install(AgentsMd);
registry.install(Terse);
// The environment follows each conversation's agent `cwd`.
const env = ({ cwd = "/" }: { readonly cwd?: string }) => new NodeExecutionEnv({ cwd });
const harness = await Harness.open(new MemoryStorage(), { models, registry, env }, context);
const model = { provider: "faux", modelId: "faux-1" };
const root = await harness.root(context, { agent: { model, cwd: "/repo" } });
// A subagent deselects AGENTS.md and gets its own instructions, rendered last
// as the `instructions` section.
const subagent = await harness.createConversation(
{
ownership: { kind: "ownerless" },
agent: {
model,
cwd: "/repo",
extensions: { remove: [AgentsMd] },
instructions: "Only read; never edit files.",
},
},
context,
);
async function systemEntries(conversation: Conversation) {
const page = await conversation.entries({}, 20, undefined, context);
return [...page.items].reverse().flatMap((entry) => (SystemEntry.is(entry) ? (entry.model ?? []) : []));
}
await (await root.submit({ type: "input", content: "Fix the build." }, context)).wait(context);
await (await subagent.submit({ type: "input", content: "Read the logs." }, context)).wait(context);
console.log("root system prompt:", await systemEntries(root));
console.log("subagent system prompt:", await systemEntries(subagent));
// When a section's output changes, the next request appends only the change.
await root.configure({ cwd: "/repo/packages" }, context);
await (await root.submit({ type: "input", content: "Now the package." }, context)).wait(context);
console.log("root system entries after cwd change:", await systemEntries(root));
await harness.close(context);

View File

@@ -0,0 +1,69 @@
// A real model: stream an answer from OpenAI.
// Run from packages/durable (needs OPENAI_API_KEY):
// node --conditions=source --experimental-strip-types test/examples/16-real-model.ts
import { BACKGROUND_CONTEXT } from "@earendil-works/chord/context";
import { createModels } from "@earendil-works/pi-ai/models";
import { openaiProvider } from "@earendil-works/pi-ai/providers/openai";
import {
AssistantEntry,
createRegistry,
defineExtension,
Harness,
LiveDoc,
MemoryStorage,
section,
} from "../../src/index.ts";
const context = BACKGROUND_CONTEXT;
// Production code passes a Models collection with real providers; the Harness
// never talks to a provider any other way. openaiProvider() reads
// OPENAI_API_KEY from the environment. While the answer streams, generation
// commits throttled partials to the conversation's pi.live document. Watching
// that document streams the answer; the watch sees only committed values.
if (process.env.OPENAI_API_KEY === undefined) {
console.log("skipped: OPENAI_API_KEY is not set");
} else {
const models = createModels();
models.setProvider(openaiProvider());
const registry = createRegistry();
registry.install(
defineExtension({
name: "concise",
sections: [section("preamble", () => "You are a concise assistant.", { tag: false })],
}),
);
const harness = await Harness.open(new MemoryStorage(), { models, registry }, context);
const root = await harness.root(context, {
agent: { model: { provider: "openai", modelId: "gpt-6-sol" }, thinkingLevel: "high" },
});
const liveWatch = (await harness.watchDoc(LiveDoc, root.id, context))!;
// Print only what each committed partial adds to the text printed so far.
let printed = "";
const printText = (text: string): void => {
if (text.length <= printed.length || !text.startsWith(printed)) return;
process.stdout.write(text.slice(printed.length));
printed = text;
};
liveWatch.start(async (value) => {
const block = value?.generation?.message?.content.find((content) => content.type === "text");
if (block?.type === "text") printText(block.text);
});
harness.resume();
process.stdout.write("answer: ");
const poem = await root.submit({ type: "input", content: "Write a long poem" }, context);
const settledPoem = await poem.wait(context);
await liveWatch.stop();
if (settledPoem.status === "done" && settledPoem.type === "input") {
// The last throttle window may not have been committed as a partial; the answer entry has the rest.
const entry = await root.commit((tx) => tx.entry(AssistantEntry, settledPoem.answer), context);
const message = entry?.model?.[0];
const block =
message?.role === "assistant" ? message.content.find((content) => content.type === "text") : undefined;
if (block?.type === "text") printText(block.text);
process.stdout.write("\n");
} else {
console.log("unanswered:", settledPoem.reason, settledPoem.detail);
}
await harness.close(context);
}

View File

@@ -0,0 +1,101 @@
// A coding-agent tool turn on JSONL storage: the model reads, edits, and runs commands, then answers. A hook times a
// `cat` of /tmp/1gb.txt (create it first to measure; without it that call ends in an error result). The storage
// directory is left behind for inspection.
// Run from packages/durable:
// node --conditions=source --experimental-strip-types test/examples/17-coding-tools.ts
import { mkdtemp, readFile, writeFile } from "node:fs/promises";
import { tmpdir } from "node:os";
import { join } from "node:path";
import { BACKGROUND_CONTEXT } from "@earendil-works/chord/context";
import type { ToolResultMessage } from "@earendil-works/pi-ai";
import { createModels } from "@earendil-works/pi-ai/models";
import { fauxAssistantMessage, fauxProvider, fauxText, fauxToolCall } from "@earendil-works/pi-ai/providers/faux";
import { NodeExecutionEnv } from "../../src/env/node.ts";
import {
AssistantEntry,
createRegistry,
defineExtension,
Harness,
hook,
ToolResultEntry,
ToolTask,
} from "../../src/index.ts";
import { openNodeJsonlStorage } from "../../src/storage/jsonl/node.ts";
import { type BashToolInput, CodingTools } from "../../src/tools/index.ts";
const context = BACKGROUND_CONTEXT;
const directory = await mkdtemp(join(tmpdir(), "pi-durable-example-"));
await writeFile(join(directory, "notes.txt"), "hello world\n");
// The faux provider plays the model: four tool-calling answers, then a final answer.
const faux = fauxProvider();
const models = createModels();
models.setProvider(faux.provider);
const toolCall = (name: string, args: Parameters<typeof fauxToolCall>[1], id: string) =>
fauxAssistantMessage([fauxToolCall(name, args, { id })], { stopReason: "toolUse" });
faux.setResponses([
toolCall("read", { path: "notes.txt" }, "r"),
toolCall("edit", { path: "notes.txt", edits: [{ oldText: "world", newText: "durable" }] }, "e"),
toolCall("bash", { command: "cat notes.txt" }, "b"),
toolCall("bash", { command: "cat /tmp/1gb.txt" }, "c"),
fauxAssistantMessage([fauxText("The file now greets durable.")]),
]);
// Hooks see every call before and after execution; these time the big `cat`.
const isBigCat = (call: { name: string; arguments: unknown }) =>
call.name === "bash" && (call.arguments as BashToolInput).command.includes("1gb.txt");
let start = 0;
const Timing = defineExtension({
name: "timing",
hooks: [
hook(ToolTask, {
beforeTool: (call) => {
if (isBigCat(call)) start = Date.now();
return undefined;
},
afterTool: (call) => {
if (isBigCat(call)) console.log(`cat /tmp/1gb.txt took ${Date.now() - start} ms`);
return undefined;
},
}),
],
});
const registry = createRegistry();
// CodingTools brings read, write, edit, and bash. Tools reach files and processes only through the environment the
// Harness builds for each call.
registry.install(CodingTools);
registry.install(Timing);
// The environment is built per use and follows the conversation's agent `cwd`.
const storage = await openNodeJsonlStorage(directory, context);
const harness = await Harness.open(
storage,
{ models, registry, env: (target) => new NodeExecutionEnv({ cwd: target.cwd ?? process.cwd() }) },
context,
);
const root = await harness.root(context, { agent: { model: { provider: "faux", modelId: "faux-1" }, cwd: directory } });
// Each tool call runs as a durable pi.tool task owned by the generation, which waits for them and continues the run with the next generation.
const settled = await (await root.submit({ type: "input", content: "Greet durable instead." }, context)).wait(context);
console.log("status:", settled.status);
const transcript = await root.entries({}, 20, undefined, context);
for (const entry of [...transcript.items].reverse()) {
if (!ToolResultEntry.is(entry)) {
console.log(entry.kind);
continue;
}
const result = entry.model![0] as ToolResultMessage;
const text = result.content.map((item) => (item.type === "text" ? item.text : "")).join("");
console.log(
`${entry.kind} ${result.toolName}:`,
JSON.stringify(text.length > 200 ? `${text.slice(0, 200)}…` : text),
);
}
if (settled.status === "done" && settled.type === "input") {
const answer = await root.commit((tx) => tx.entry(AssistantEntry, settled.answer), context);
const message = answer?.model?.[0];
console.log("answer:", message?.role === "assistant" ? message.content : message);
}
console.log("file:", JSON.stringify(await readFile(join(directory, "notes.txt"), "utf8")));
await harness.close(context);
console.log("storage:", directory);

View File

@@ -0,0 +1,53 @@
// Print mode: submit one prompt and print its answer, like `pi -p`. The host awaits its own Submission, not global
// idle. Uses OpenAI when OPENAI_API_KEY is set, and a scripted faux model otherwise.
// Run from packages/durable:
// node --conditions=source --experimental-strip-types test/examples/18-print.ts "What is in this directory?"
import { BACKGROUND_CONTEXT } from "@earendil-works/chord/context";
import type { AssistantMessage } from "@earendil-works/pi-ai";
import { createModels } from "@earendil-works/pi-ai/models";
import { fauxAssistantMessage, fauxProvider, fauxText, fauxToolCall } from "@earendil-works/pi-ai/providers/faux";
import { openaiProvider } from "@earendil-works/pi-ai/providers/openai";
import { NodeExecutionEnv } from "../../src/env/node.ts";
import { AssistantEntry, createRegistry, defineExtension, Harness, MemoryStorage, section } from "../../src/index.ts";
import { createBashTool, createReadTool } from "../../src/tools/index.ts";
const context = BACKGROUND_CONTEXT;
const prompt = process.argv[2] ?? "What is in this directory?";
const models = createModels();
let model = { provider: "openai", modelId: "gpt-6-sol" };
if (process.env.OPENAI_API_KEY !== undefined) {
models.setProvider(openaiProvider());
} else {
const faux = fauxProvider();
models.setProvider(faux.provider);
model = { provider: "faux", modelId: "faux-1" };
faux.setResponses([
fauxAssistantMessage([fauxToolCall("bash", { command: "ls" }, { id: "call-1" })], { stopReason: "toolUse" }),
fauxAssistantMessage([fauxText("This directory holds the durable package sources, tests, and docs.")]),
]);
}
const registry = createRegistry();
registry.install(
defineExtension({
name: "coding",
tools: [createReadTool(), createBashTool()],
sections: [section("preamble", () => "You are a concise coding assistant.", { tag: false })],
}),
);
const env = new NodeExecutionEnv({ cwd: process.cwd() });
const harness = await Harness.open(new MemoryStorage(), { models, registry, env: () => env }, context);
const root = await harness.root(context, { agent: { model } });
const submission = await root.submit({ type: "input", content: prompt }, context);
const settled = await submission.wait(context);
if (settled.status === "done" && settled.type === "input") {
const entry = await root.commit((tx) => tx.entry(AssistantEntry, settled.answer), context);
const answer = entry?.model?.[0] as AssistantMessage;
console.log(answer.content.flatMap((content) => (content.type === "text" ? [content.text] : [])).join(""));
} else {
console.error(`unanswered: ${settled.status === "unanswered" ? settled.reason : settled.status}`);
process.exitCode = 1;
}
await harness.close(context);

View File

@@ -0,0 +1,103 @@
// JSON mode: stream one prompt's run as JSON lines, like `pi --mode json`. Two modes:
// --events (default): the experimental agent events of watchEvents(), starting with a `snapshot` event.
// --ops: the raw ConversationView frames, starting with the view itself; each later line holds one commit's ops.
// --storage sqlite (default) | jsonl | memory: sqlite writes a temporary database file, jsonl a temporary directory;
// neither is deleted, and the path is printed to stderr at the end.
// Uses OpenAI when OPENAI_API_KEY is set, and a scripted faux model otherwise.
// Run from packages/durable:
// node --conditions=source --experimental-strip-types test/examples/19-json.ts --events "What is in this directory?"
import { mkdtemp } from "node:fs/promises";
import { tmpdir } from "node:os";
import { join } from "node:path";
import { BACKGROUND_CONTEXT } from "@earendil-works/chord/context";
import { createModels } from "@earendil-works/pi-ai/models";
import { fauxAssistantMessage, fauxProvider, fauxText, fauxToolCall } from "@earendil-works/pi-ai/providers/faux";
import { openaiProvider } from "@earendil-works/pi-ai/providers/openai";
import { NodeExecutionEnv } from "../../src/env/node.ts";
import {
createRegistry,
defineExtension,
Harness,
MemoryStorage,
type Storage,
section,
watchEvents,
} from "../../src/index.ts";
import { openNodeJsonlStorage } from "../../src/storage/jsonl/node.ts";
import { openNodeSqliteStorage } from "../../src/storage/sqlite/node.ts";
import { createBashTool, createReadTool } from "../../src/tools/index.ts";
const context = BACKGROUND_CONTEXT;
const args = process.argv.slice(2);
const mode = args.includes("--ops") ? "ops" : "events";
const storageIndex = args.indexOf("--storage");
const storageKind = storageIndex < 0 ? "sqlite" : args[storageIndex + 1];
const prompt =
args.find((arg, index) => !arg.startsWith("--") && (storageIndex < 0 || index !== storageIndex + 1)) ??
"What is in this directory?";
let storage: Storage;
let location: string | undefined;
if (storageKind === "sqlite") {
location = join(tmpdir(), `pi-durable-json-${Date.now()}.sqlite`);
storage = await openNodeSqliteStorage(location);
} else if (storageKind === "jsonl") {
location = await mkdtemp(join(tmpdir(), "pi-durable-json-"));
storage = await openNodeJsonlStorage(location, context);
} else if (storageKind === "memory") {
storage = new MemoryStorage();
} else {
throw new Error(`Unknown --storage ${storageKind}; use sqlite, jsonl, or memory`);
}
const print = (value: unknown): void => void process.stdout.write(`${JSON.stringify(value)}\n`);
const models = createModels();
let model = { provider: "openai", modelId: "gpt-6-sol" };
if (process.env.OPENAI_API_KEY !== undefined) {
models.setProvider(openaiProvider());
} else {
const faux = fauxProvider({ tokensPerSecond: 200 });
models.setProvider(faux.provider);
model = { provider: "faux", modelId: "faux-1" };
faux.setResponses([
fauxAssistantMessage([fauxToolCall("bash", { command: "ls" }, { id: "call-1" })], { stopReason: "toolUse" }),
fauxAssistantMessage([fauxText("This directory holds the durable package sources, tests, and docs.")]),
]);
}
const registry = createRegistry();
registry.install(
defineExtension({
name: "coding",
tools: [createReadTool(), createBashTool()],
sections: [section("preamble", () => "You are a concise coding assistant.", { tag: false })],
}),
);
const env = new NodeExecutionEnv({ cwd: process.cwd() });
const harness = await Harness.open(storage, { models, registry, env: () => env }, context);
const root = await harness.root(context, { agent: { model } });
// Attach before submitting, so the stream covers the whole run.
let stop: () => Promise<unknown>;
if (mode === "events") {
const stream = await watchEvents(harness, root.id, context);
print(stream.snapshot);
stream.start(async (events) => {
for (const event of events) print(event);
});
stop = () => stream.stop();
} else {
const watch = await root.watch(context);
print({ view: watch.value });
watch.start(async (_value, ops) => print({ ops }));
stop = () => watch.stop();
}
const submission = await root.submit({ type: "input", content: prompt }, context);
await submission.wait(context);
await harness.waitForIdle(context);
// Let the last batch reach the listener before stopping.
await new Promise((resolve) => setTimeout(resolve, 0));
await stop();
await harness.close(context);
if (location !== undefined) console.error(`${storageKind} storage: ${location}`);

View File

@@ -0,0 +1,72 @@
// The inbox: what happens to submissions while a conversation is busy.
// Run from packages/durable:
// node --conditions=source --experimental-strip-types test/examples/20-inbox.ts
import { BACKGROUND_CONTEXT } from "@earendil-works/chord/context";
import type { FauxResponseStep } from "@earendil-works/pi-ai";
import { createModels } from "@earendil-works/pi-ai/models";
import { fauxAssistantMessage, fauxProvider } from "@earendil-works/pi-ai/providers/faux";
import { createRegistry, Harness, InboxDoc, MemoryStorage, type Submission } from "../../src/index.ts";
const context = BACKGROUND_CONTEXT;
// The first answer waits until we let it go, so the conversation stays busy while we submit more.
let release!: () => void;
const held = new Promise<void>((resolve) => {
release = resolve;
});
const slow: FauxResponseStep = async () => {
await held;
return fauxAssistantMessage("Answer to the first question.");
};
const faux = fauxProvider();
faux.setResponses([slow, fauxAssistantMessage("Answer to the follow-up and the steer.")]);
const models = createModels();
models.setProvider(faux.provider);
// Settings apply to every conversation: place every queued follow-up at once instead of one per run.
const harness = await Harness.open(
new MemoryStorage(),
{ models, registry: createRegistry(), settings: { followUpMode: "all" } },
context,
);
const root = await harness.root(context, { agent: { model: { provider: "faux", modelId: "faux-1" } } });
const first = await root.submit({ type: "input", content: "First question" }, context);
// While busy, input queues as a follow-up (the default) or a steer, and writes queue too.
const followUp = await root.submit({ type: "input", content: "A follow-up" }, context);
const steer = await root.submit({ type: "input", content: "A steer", whenBusy: "steer" }, context);
const note = await root.submit({ type: "write", entry: { kind: "app.note", data: "noted while busy" } }, context);
const withdrawn = await root.submit({ type: "input", content: "Never mind" }, context);
// whenBusy: "reject" refuses instead of queueing.
await root.submit({ type: "input", content: "Now or never", whenBusy: "reject" }, context).catch((error: Error) => {
console.log("rejected:", error.message);
});
// A queued submission can be withdrawn until a boundary places it.
console.log("withdraw:", await withdrawn.abort(context));
const inbox = await harness.snapshot(InboxDoc, root.id, context);
console.log(
"inbox:",
inbox?.items.map((item) => `${item.id} ${item.mode}`),
);
// The first answer ends the run at a final boundary: the write is placed first, then the steer and the
// follow-up, which start the next run together.
release();
const status = async (name: string, submission: Submission) => {
const record = await submission.wait(context);
console.log(`${name}:`, record.status, record.status === "unanswered" ? record.reason : "");
};
await status("first", first);
await status("follow-up", followUp);
await status("steer", steer);
await status("note", note);
await status("withdrawn", withdrawn);
const page = await root.entries({}, 20, undefined, context);
console.log(
"transcript:",
[...page.items].reverse().map((entry) => entry.kind),
);
await harness.close(context);

View File

@@ -0,0 +1,100 @@
// Late join: a client attaches while a run is already underway. It gets the current state first, the conversation
// view or the snapshot event, and then only what changes after that.
// Run from packages/durable:
// node --conditions=source --experimental-strip-types test/examples/21-late-join.ts
import { BACKGROUND_CONTEXT } from "@earendil-works/chord/context";
import { Type } from "@earendil-works/pi-ai";
import { createModels } from "@earendil-works/pi-ai/models";
import { fauxAssistantMessage, fauxProvider, fauxText, fauxToolCall } from "@earendil-works/pi-ai/providers/faux";
import {
type AgentEvent,
createRegistry,
defineExtension,
defineTool,
Harness,
type LiveState,
MemoryStorage,
watchEvents,
} from "../../src/index.ts";
const context = BACKGROUND_CONTEXT;
const pause = (ms: number) => new Promise((resolve) => setTimeout(resolve, ms));
// A tool that prints a line every 100 ms, then a slowly streamed answer.
const registry = createRegistry();
registry.install(
defineExtension({
name: "count",
tools: [
defineTool({
name: "count",
description: "Counts to ten",
parameters: Type.Object({}),
execute: async (_args, api) => {
for (let n = 1; n <= 10; n++) {
api.output(`${n}\n`);
await pause(100);
}
return {};
},
}),
],
}),
);
const faux = fauxProvider({ tokensPerSecond: 40, tokenSize: { min: 1, max: 1 } });
faux.setResponses([
fauxAssistantMessage([fauxToolCall("count", {}, { id: "call-1" })], { stopReason: "toolUse" }),
fauxAssistantMessage([fauxText("Counted to ten, and this answer streams slowly.")]),
]);
const models = createModels();
models.setProvider(faux.provider);
const harness = await Harness.open(new MemoryStorage(), { models, registry }, context);
const root = await harness.root(context, { agent: { model: { provider: "faux", modelId: "faux-1" } } });
const submission = await root.submit({ type: "input", content: "Count to ten, then tell me." }, context);
// Join while the tool is halfway through.
await pause(500);
// Structural client: the view holds the committed transcript and documents, including the running tool's output.
const view = await root.viewState(context);
const live = view.value.docs["pi.live"] as LiveState;
console.log(
"view entries:",
view.value.entries.map((entry) => entry.kind),
);
console.log("view tool slot:", live.tools?.[0]?.status, JSON.stringify(live.tools?.[0]?.output));
view.subscribe((value) => {
const slot = (value.docs["pi.live"] as LiveState).tools?.[0];
if (slot?.status === "running") console.log("view output now:", JSON.stringify(slot.output));
});
// Event client: the snapshot event carries the same state; later events apply on top of it.
const stream = await watchEvents(harness, root.id, context);
console.log(
"snapshot tools:",
stream.snapshot.tools.map((slot) => `${slot.name} ${slot.status}`),
);
let output = stream.snapshot.tools[0]?.output ?? "";
stream.start(async (events: readonly AgentEvent[]) => {
for (const event of events) {
if (event.type === "tool_execution_update" && event.output !== undefined) {
output =
"set" in event.output
? event.output.set
: output.slice(event.output.trimStart ?? 0) + (event.output.append ?? "");
console.log("event output now:", JSON.stringify(output));
} else if (event.type === "message_update") {
const deltas = event.changes.flatMap((change) => (change.type === "text_delta" ? [change.delta] : []));
console.log("event text delta:", JSON.stringify(deltas.join("")));
} else {
console.log("event:", event.type);
}
}
});
await submission.wait(context);
await harness.waitForIdle(context);
await pause(0);
await stream.stop();
view.dispose();
await harness.close(context);

View File

@@ -0,0 +1,136 @@
// A foreground subagent tool: the parent's tool call creates a child conversation it owns, runs one task there, and
// returns the child's answer. Aborting the tool call aborts the child. A UI finds the child through the tool's running
// details and shows its events under the call.
// Uses OpenAI when OPENAI_API_KEY is set, and a scripted faux model otherwise.
// Run from packages/durable:
// node --conditions=source --experimental-strip-types test/examples/22-subagent-foreground.ts
import type { Context } from "@earendil-works/chord";
import { BACKGROUND_CONTEXT } from "@earendil-works/chord/context";
import { type AssistantMessage, type FauxResponseStep, Type } from "@earendil-works/pi-ai";
import { createModels } from "@earendil-works/pi-ai/models";
import { fauxAssistantMessage, fauxProvider, fauxText, fauxToolCall } from "@earendil-works/pi-ai/providers/faux";
import { openaiProvider } from "@earendil-works/pi-ai/providers/openai";
import {
type AgentEvent,
AssistantEntry,
type ConversationId,
configure,
createRegistry,
defineExtension,
defineTool,
type EntryId,
type Extension,
Harness,
MemoryStorage,
type ToolExecutionApi,
watchEvents,
} from "../../src/index.ts";
const context = BACKGROUND_CONTEXT;
// ─── Product code: the subagent extension ───────────────────────────────────
async function answerText(api: ToolExecutionApi, answer: EntryId, callContext: Context): Promise<string> {
const entry = await api.commit((tx) => tx.entry(AssistantEntry, answer), callContext);
const message = entry?.model?.[0] as AssistantMessage;
return message.content.flatMap((c) => (c.type === "text" ? [c.text] : [])).join("");
}
const Subagent: Extension = defineExtension({
name: "subagent",
tools: [
defineTool({
name: "subagent",
description: "Delegate a self-contained task to a subagent and get its answer back.",
parameters: Type.Object({ task: Type.String({ description: "What the subagent should do" }) }),
// Safe to rerun after a crash: a rerun finds the child it already created and the submission it already made.
replay: "safe",
execute: async (args, api, callContext) => {
const { task } = args;
// The child is owned by this tool call's task, so aborting the call aborts the child, and the call
// finishes only once the child's work is done.
const child = await api.commit(async (tx) => {
// Ownership records the child: a rerun of this call finds it instead of creating another.
const existing = (await tx.scanConversations({ ownerTaskId: api.taskId }, 1)).items[0];
if (existing !== undefined) return existing.id;
// Starts as a copy of this conversation's agent: model, thinking level, cwd, extensions, tools.
const created = await tx.createConversation({ ownership: { kind: "task", taskId: api.taskId } });
// Without this extension, the child is not offered this tool.
await configure(tx, created.id, { extensions: { remove: [Subagent] } });
return created.id;
}, callContext);
// A UI watching the parent sees this and can attach to the child.
await api.details({ conversationId: child }, callContext);
const handle = (await api.conversation(child, callContext))!;
// The request ID makes a rerun get back the submission it made before the crash.
const request = { type: "input", content: task, requestId: `subagent:${api.taskId}` } as const;
const settled = await (await handle.submit(request, callContext)).wait(callContext);
if (settled.status !== "done" || settled.type !== "input") {
throw new Error(`Subagent failed: ${settled.status}`);
}
const text = await answerText(api, settled.answer, callContext);
return { output: [{ type: "text", text }], details: { conversationId: child } };
},
}),
],
});
// ─── Host setup ─────────────────────────────────────────────────────────────
const models = createModels();
let model = { provider: "openai", modelId: "gpt-6-sol" };
if (process.env.OPENAI_API_KEY !== undefined) {
models.setProvider(openaiProvider());
} else {
// The parent delegates, the child answers, and the parent reports.
const faux = fauxProvider();
models.setProvider(faux.provider);
model = { provider: "faux", modelId: "faux-1" };
const delegate = fauxToolCall("subagent", { task: "Name three prime numbers." }, { id: "call-1" });
faux.setResponses([
fauxAssistantMessage([delegate], { stopReason: "toolUse" }),
fauxAssistantMessage([fauxText("2, 3, and 5.")]),
fauxAssistantMessage([fauxText("The subagent says: 2, 3, and 5.")]),
] satisfies FauxResponseStep[]);
}
const registry = createRegistry();
registry.install(Subagent);
const harness = await Harness.open(new MemoryStorage(), { models, registry }, context);
const root = await harness.root(context, { agent: { model } });
// ─── UI: the parent's events, with each subagent's events indented under its call ───
const print = (indent: string, event: AgentEvent): void => {
if (event.type === "message_end" && event.entry.kind === "pi.assistant") {
const message = event.entry.model?.[0] as AssistantMessage;
const text = message.content.flatMap((c) => (c.type === "text" ? [c.text] : [])).join("");
if (text !== "") console.log(`${indent}assistant: ${text}`);
} else if (event.type === "tool_execution_start") {
console.log(`${indent}tool ${event.toolName}(${JSON.stringify(event.args)})`);
}
};
const attached = new Set<ConversationId>();
const attach = async (id: ConversationId, indent: string): Promise<void> => {
attached.add(id);
const stream = await watchEvents(harness, id, context);
stream.start(async (events) => {
for (const event of events) {
print(indent, event);
if (event.type !== "tool_execution_update") continue;
const child = (event.details as { conversationId?: ConversationId } | undefined)?.conversationId;
if (child !== undefined && !attached.has(child)) await attach(child, `${indent} `);
}
});
};
await attach(root.id, "");
const submission = await root.submit(
{ type: "input", content: "Use the subagent tool to find three prime numbers, then tell me what it said." },
context,
);
await submission.wait(context);
await harness.waitForIdle(context);
// Event callbacks run after their commit; let the last ones print.
await new Promise((resolve) => setTimeout(resolve, 0));
await harness.close(context);

View File

@@ -0,0 +1,363 @@
// Persistent background subagents. One `subagent` tool lets the main agent start named subagents, message them
// (steer or follow up), stop them mid-answer, and list them. Subagents keep working while the main agent
// answers the user, and each answer is delivered back to the main agent as a new message once it arrives. Everything
// survives a restart: the example closes the Harness while a subagent works and reopens it.
// Uses OpenAI when OPENAI_API_KEY is set, and a scripted faux model otherwise.
// Run from packages/durable:
// node --conditions=source --experimental-strip-types test/examples/23-subagent-background.ts
import { mkdtemp, rm } from "node:fs/promises";
import { tmpdir } from "node:os";
import { join } from "node:path";
import { BACKGROUND_CONTEXT } from "@earendil-works/chord/context";
import { type AssistantMessage, type FauxResponseStep, Type } from "@earendil-works/pi-ai";
import { createModels } from "@earendil-works/pi-ai/models";
import { fauxAssistantMessage, fauxProvider, fauxText, fauxToolCall } from "@earendil-works/pi-ai/providers/faux";
import { openaiProvider } from "@earendil-works/pi-ai/providers/openai";
import {
type AgentEvent,
AssistantEntry,
type ConversationId,
configure,
createRegistry,
defineDoc,
defineExtension,
defineTask,
defineTool,
type EntryId,
Harness,
LiveDoc,
type TaskId,
watchEvents,
} from "../../src/index.ts";
import { openNodeSqliteStorage } from "../../src/storage/sqlite/node.ts";
const context = BACKGROUND_CONTEXT;
// ─── Product code ────────────────────────────────────────────────────────────
// Each subagent is its own conversation with its own transcript, so it remembers earlier messages. The main
// conversation keeps a small document that maps subagent names to their conversations. Documents are durable state
// next to a transcript, changed in commits like entries.
type Subagent = {
conversationId: ConversationId;
/** Answers already reported to the main agent: several messages can end in one answer, reported once. */
reported: EntryId[];
};
const Subagents = defineDoc<{ agents: Record<string, Subagent>; reporters: Record<string, TaskId> }>({
kind: "app.subagents",
version: 1,
scope: "conversation",
history: "latest",
// A fork of the main conversation starts without subagents.
fork: "initial",
initial: () => ({ agents: {}, reporters: {} }),
});
// Every task and conversation has an owner, and that decides what an abort or an idle wait reaches: aborting a task
// aborts what it owns, and waiting for a conversation to be idle waits for its work. A subagent must outlive the main
// agent's turns, so its conversation is owned by an anchor: a background task that finishes at once. A background task
// is a boundary: the main agent's Esc and idle waits stop there, but `abort({ background: true })` still reaches past
// it, so a host can stop everything.
const Anchor = defineTask<null, { phase: "done" }, null>({
name: "app.subagent-anchor",
version: 1,
initial: () => ({ phase: "done" }),
phases: {
done: (_anchor, runtime, taskContext) =>
runtime.commit(() => ({ status: "terminal", outcome: { status: "completed", result: null } }), taskContext),
},
abort: (_anchor, runtime, taskContext) =>
runtime.commit(() => ({ status: "terminal", outcome: { status: "aborted" } }), taskContext),
});
// A reporter delivers one message to a subagent and reports the answer to the main agent. It is a background task
// too, so the main agent's Esc and idle waits leave it alone. Tasks are durable: each phase ends with a saved
// checkpoint, and after a restart the task continues from the last one. Request IDs make a repeated submission return
// the first one, so the subagent gets the message once and the main agent gets the report once.
type ReporterInput = { name: string; conversationId: ConversationId; message: string; followUp: boolean };
type ReporterState = { phase: "deliver" } | { phase: "report"; report?: string };
const Reporter = defineTask<ReporterInput, ReporterState, null>({
name: "app.subagent-reporter",
version: 1,
initial: () => ({ phase: "deliver" }),
phases: {
// Send the message, wait for its answer, and decide what to report.
deliver: async (reporter, runtime, taskContext) => {
const { name, conversationId, message, followUp } = reporter.input;
// While the subagent is busy, a steer reaches it at its next step and a follow-up after its current answer.
const subagent = (await runtime.conversation(conversationId, taskContext))!;
const request = { type: "input", content: message, whenBusy: followUp ? "followUp" : "steer" } as const;
const submission = await subagent.submit({ ...request, requestId: `subagent:${reporter.id}` }, taskContext);
const settled = await submission.wait(taskContext);
// One commit decides the report and records the answer as delivered, so a restart does not decide again.
await runtime.commit(async (tx) => {
const next = (report?: string) => ({ status: "running", checkpoint: { phase: "report", report } }) as const;
// `aborted`: stopped, or withdrawn while queued. Nothing to report.
if (settled.status === "unanswered") {
return next(settled.reason === "aborted" ? undefined : `[subagent ${name} failed: ${settled.reason}]`);
}
// Always an answered input here; the check tells TypeScript.
if (settled.type !== "input") return next();
const agent = (await tx.doc(Subagents, runtime.conversationId)).agents[name]!;
if (agent.reported.includes(settled.answer)) return next();
agent.reported.push(settled.answer);
const answer = (await tx.entry(AssistantEntry, settled.answer))?.model?.[0] as AssistantMessage;
return next(`[subagent ${name} answered, no reply needed] ${textOf(answer)}`);
}, taskContext);
},
// Post the report as a follow-up input: it starts a turn when the main agent is idle, or waits for its current
// answer. If the user presses Esc while it waits in the main agent's queue, it is dropped with the other input; a
// report that arrives after Esc starts a new turn.
report: async (reporter, runtime, taskContext) => {
const report = reporter.state.checkpoint.report;
if (report !== undefined) {
const main = (await runtime.conversation(runtime.conversationId, taskContext))!;
const input = { type: "input", content: report, whenBusy: "followUp" } as const;
await main.submit({ ...input, requestId: `subagent-report:${reporter.id}` }, taskContext);
}
await runtime.commit(
() => ({ status: "terminal", outcome: { status: "completed", result: null } }),
taskContext,
);
},
},
abort: (_reporter, runtime, taskContext) =>
runtime.commit(() => ({ status: "terminal", outcome: { status: "aborted" } }), taskContext),
});
function textOf(message: AssistantMessage | undefined): string {
return (message?.content ?? []).flatMap((c) => (c.type === "text" ? [c.text] : [])).join("");
}
const subagentTool = defineTool({
name: "subagent",
description:
"Manage persistent subagents that work in the background. Actions: spawn (name, message), send (name, message; " +
"followUp: true queues it after the current answer instead of steering), stop (name: aborts its current work), " +
"status (name, or all subagents without one). Answers are reported back to you when they arrive.",
parameters: Type.Object({
action: Type.Union([Type.Literal("spawn"), Type.Literal("send"), Type.Literal("stop"), Type.Literal("status")]),
name: Type.Optional(Type.String()),
message: Type.Optional(Type.String()),
followUp: Type.Optional(Type.Boolean()),
}),
// A call interrupted by a crash is not rerun: repeating `stop` could stop newer work. The model sees that the call
// was interrupted and can check with `status`.
replay: "unsafe",
execute: async (args, api, callContext) => {
const { action, name, message, followUp } = args;
const reply = (text: string, conversationId?: ConversationId) => ({
content: [{ type: "text" as const, text }],
// A UI can attach to the subagent's conversation through the call's details.
...(conversationId === undefined || name === undefined ? {} : { details: { name, conversationId } }),
});
const registry = (await api.snapshot(Subagents, api.conversationId, callContext)) ?? {
agents: {},
reporters: {},
};
if (action === "status") {
const names = name === undefined ? Object.keys(registry.agents) : [name];
const lines: string[] = [];
for (const each of names) {
const found = Object.hasOwn(registry.agents, each) ? registry.agents[each] : undefined;
if (found === undefined) continue;
// A conversation is busy while it has a run: from an input until its final answer.
const busy = (await api.snapshot(LiveDoc, found.conversationId, callContext))?.run !== undefined;
lines.push(`${each}: ${busy ? "working" : "idle"}`);
}
return reply(lines.length === 0 ? "No subagents." : lines.join("\n"));
}
if (name === undefined) return reply(`${action} needs a name.`);
const agent = Object.hasOwn(registry.agents, name) ? registry.agents[name] : undefined;
if (action !== "spawn" && agent === undefined) return reply(`No subagent named ${name}.`);
if (action === "stop") {
// Aborts the subagent's current answer and tools and drops its queued messages. It stays usable.
await (await api.conversation(agent!.conversationId, callContext))!.abort(callContext);
return reply(`Stopped ${name}.`, agent!.conversationId);
}
if (message === undefined) return reply(`${action} needs a message.`);
// spawn and send: one commit starts a reporter for the message.
const result = await api.commit(async (tx) => {
const state = await tx.doc(Subagents, api.conversationId);
// Both tasks belong to the main conversation and are background: its Esc and idle waits skip them.
const background = { ownership: { kind: "conversation" }, background: true } as const;
if (action === "spawn") {
if (Object.hasOwn(state.agents, name)) return `${name} already exists; use send.`;
const anchor = await tx.createTask(Anchor, null, background);
// Owned by a task of the main conversation: starts as a copy of the main agent.
const child = await tx.createConversation({ ownership: { kind: "task", taskId: anchor } });
// Subagents cannot start subagents, and know who they are.
await configure(tx, child.id, {
extensions: { remove: [SubagentTools] },
instructions: `You are the subagent "${name}". Answer the main agent's requests.`,
});
state.agents[name] = { conversationId: child.id, reported: [] };
}
const conversationId = state.agents[name]!.conversationId;
const input = { name, conversationId, message, followUp: action === "send" && followUp === true };
state.reporters[api.taskId] = await tx.createTask(Reporter, input, background);
return action === "send" ? `Sent to ${name}.` : `Started ${name}.`;
}, callContext);
const current = (await api.snapshot(Subagents, api.conversationId, callContext))?.agents[name];
return reply(result, current?.conversationId);
},
});
// The task definitions come with the extension, so pending reporters resume after a restart once the host installs
// it again.
const SubagentTools = defineExtension({ name: "subagent-tools", tasks: [Anchor, Reporter], tools: [subagentTool] });
// ─── Host setup ─────────────────────────────────────────────────────────────
const models = createModels();
let model = { provider: "openai", modelId: "gpt-6-sol" };
if (process.env.OPENAI_API_KEY !== undefined) {
models.setProvider(openaiProvider());
} else {
// The main agent and the subagent share one scripted model, which answers each request by its last message.
// It streams its answers at 50 tokens per second, like a slow real model.
const faux = fauxProvider({ tokensPerSecond: 50 });
models.setProvider(faux.provider);
model = { provider: "faux", modelId: "faux-1" };
const call = (input: Record<string, string>) =>
fauxAssistantMessage([fauxToolCall("subagent", input)], { stopReason: "toolUse" });
const answer = (text: string) => fauxAssistantMessage([fauxText(text)]);
const route: FauxResponseStep = (request) => {
// System messages carry prompt changes; the request is about the message before them.
const last = request.messages.findLast((message) => message.role !== "system")!;
const content = typeof last.content === "string" ? [{ type: "text", text: last.content }] : last.content;
const text = content.flatMap((part) => (part.type === "text" ? [part.text] : [])).join("");
// The main agent repeats what the tool said.
if (last.role === "toolResult") return answer(`OK. ${text}`);
// The main agent.
if (text.includes("Start a subagent")) {
return call({ action: "spawn", name: "reader", message: "Summarize the plot of Moby Dick." });
}
if (text.includes("whale's name"))
return call({ action: "send", name: "reader", message: "What is the whale called?" });
if (text.includes("every chapter")) {
return call({ action: "send", name: "reader", message: "Now go through all chapters in detail." });
}
if (text.includes("Stop reader")) return call({ action: "stop", name: "reader" });
if (text.includes("my subagents")) return call({ action: "status" });
if (text.includes("[subagent")) return answer("Noted.");
// The subagent: short answers, and a long chapter walk-through that is stopped halfway.
if (text.includes("Summarize the plot")) return answer("A whale, a captain, an obsession.");
if (text.includes("whale called")) return answer("Moby Dick.");
const chapters = Array.from({ length: 135 }, (_, index) => `Chapter ${index + 1}: more whaling.`);
return answer(chapters.join("\n"));
};
// More responses than the script needs; each request takes the next one.
faux.setResponses(Array.from({ length: 40 }, () => route));
}
const registry = createRegistry();
registry.install(SubagentTools);
const directory = await mkdtemp(join(tmpdir(), "pi-durable-subagents-"));
const open = async () => {
const harness = await Harness.open(
await openNodeSqliteStorage(join(directory, "session.sqlite")),
{ models, registry },
context,
);
const root = await harness.root(context, { agent: { model } });
return { harness, root };
};
// ─── UI: the main conversation's transcript, as a user would see it ───
const color = (code: number) => (text: string) => (process.stdout.isTTY ? `\x1b[${code}m${text}\x1b[0m` : text);
const bold = color(1);
const dim = color(2);
const cyan = color(36);
const yellow = color(33);
const magenta = color(35);
function contentText(content: string | readonly { type: string; text?: string }[]): string {
return typeof content === "string"
? content
: content.flatMap((part) => (part.text === undefined ? [] : [part.text])).join("");
}
/** Print the main conversation's messages as they are committed. Subagents work in their own conversations. */
async function follow(harness: Harness, id: ConversationId): Promise<void> {
const stream = await watchEvents(harness, id, context);
stream.start(async (events: readonly AgentEvent[]) => {
for (const event of events) {
if (event.type !== "message_end") continue;
const message = event.entry.model?.[0];
if (message?.role === "user") {
const text = contentText(message.content);
// Input is either the user or a subagent's report, which arrives whenever the subagent is done.
const report = /^\[subagent (\S+) ([^\]]*)\] ?(.*)$/s.exec(text);
if (report === null) console.log(`\n${cyan(bold(">"))} ${bold(text)}`);
else console.log(`\n${magenta(bold(`> ${report[1]}:`))} ${report[3] || report[2]}`);
} else if (message?.role === "assistant") {
for (const part of message.content) {
if (part.type !== "toolCall") continue;
const { action, name, message: sent } = part.arguments as Record<string, string | undefined>;
const quoted = sent === undefined ? "" : ` ${JSON.stringify(sent)}`;
console.log(yellow(` ${part.name} ${action}${name === undefined ? "" : ` ${name}`}${quoted}`));
}
if (textOf(message) !== "") console.log(textOf(message));
} else if (message?.role === "toolResult") {
console.log(dim(` → ${contentText(message.content)}`));
}
}
});
}
let { harness, root } = await open();
await follow(harness, root.id);
/** Say something to the main agent and wait for its answer. */
const say = async (text: string): Promise<void> => {
await (await root.submit({ type: "input", content: text }, context)).wait(context);
};
/** Wait until every message to a subagent was answered and reported, and the main agent has reacted. */
const settle = async (): Promise<void> => {
const reporters = (await harness.snapshot(Subagents, root.id, context))?.reporters ?? {};
for (const id of Object.values(reporters)) await harness.waitForTask(id, context);
await root.waitForIdle(context);
// Event callbacks run after their commit; let the last ones print. A slow machine may need longer.
await new Promise((resolve) => setTimeout(resolve, 50));
};
/** Poll `check` for up to 10 seconds. */
const until = async (check: () => Promise<boolean>): Promise<void> => {
for (let tries = 0; tries < 1000 && !(await check()); tries++)
await new Promise((resolve) => setTimeout(resolve, 10));
};
const working = async (name: string): Promise<boolean> => {
const agent = (await harness.snapshot(Subagents, root.id, context))?.agents[name];
return agent !== undefined && (await harness.snapshot(LiveDoc, agent.conversationId, context))?.run !== undefined;
};
// The main agent answers at once; the subagent's answer is reported back when it arrives.
await say("Start a subagent named reader that summarizes Moby Dick.");
await settle();
// A long request, stopped while the subagent is still answering.
await say("Ask reader to summarize every chapter.");
await until(() => working("reader"));
await say("Stop reader.");
await settle();
await say("What are my subagents doing?");
await settle();
// The process stops while the subagent works on a message; after the restart its answer still arrives.
const reporters = async () =>
Object.keys((await harness.snapshot(Subagents, root.id, context))?.reporters ?? {}).length;
const before = await reporters();
await root.submit({ type: "input", content: "Ask reader for the whale's name." }, context);
await until(async () => (await reporters()) > before);
await harness.close(context);
console.log(dim("\n (process restarts)"));
({ harness, root } = await open());
await follow(harness, root.id);
await settle();
await harness.close(context);
await rm(directory, { recursive: true, force: true });

View File

@@ -0,0 +1,141 @@
// A task that owns child tasks: a checkout charges four payments at once and waits for them.
// The task graph view prints the checkout's tree while its payments run.
// Run from packages/durable:
// node --conditions=source --experimental-strip-types test/examples/24-child-tasks.ts
import { mkdtemp, rm } from "node:fs/promises";
import { tmpdir } from "node:os";
import { join } from "node:path";
import { BACKGROUND_CONTEXT } from "@earendil-works/chord/context";
import { createModels } from "@earendil-works/pi-ai/models";
import {
type Conversation,
createRegistry,
defineExtension,
defineTask,
Harness,
type TaskGraph,
type TaskGraphNode,
type TaskId,
} from "../../src/index.ts";
import { openNodeSqliteStorage } from "../../src/storage/sqlite/node.ts";
const context = BACKGROUND_CONTEXT;
// A fake bank: it declines an expired card at once, and confirms other charges after a moment.
const charged = new Set<string>();
type PaymentInput = { card: string };
type PaymentResult = { card: string };
const Payment = defineTask<PaymentInput, { phase: "charge"; at: number }, PaymentResult>({
name: "example.payment",
version: 1,
initial: () => ({ phase: "charge", at: Date.now() + 100 }),
phases: {
charge: async (task, runtime, taskContext) => {
const card = task.input.card;
if (card.startsWith("expired")) {
const error = { message: `${card} declined` };
await runtime.commit(() => ({ status: "terminal", outcome: { status: "failed", error } }), taskContext);
return;
}
charged.add(card);
await runtime.sleep(task.state.checkpoint.at, taskContext);
await runtime.commit(
() => ({ status: "terminal", outcome: { status: "completed", result: { card } } }),
taskContext,
);
},
},
// Each payment undoes its own effect when aborted.
abort: async (task, runtime, taskContext) => {
const refunded = charged.delete(task.input.card);
console.log(` payment ${task.input.card} aborted${refunded ? ", refunded" : ""}`);
await runtime.commit(() => ({ status: "terminal", outcome: { status: "aborted" } }), taskContext);
},
});
// The checkout creates its payments as child tasks and waits for all of them.
// With `failFast`, the first payment that fails aborts the others; the checkout
// itself is not aborted and decides its outcome once every payment is done.
type CheckoutState = { phase: "pay" } | { phase: "decide"; payments: TaskId<PaymentResult>[] };
const Checkout = defineTask<{ cards: string[] }, CheckoutState, string>({
name: "example.checkout",
version: 1,
initial: () => ({ phase: "pay" }),
phases: {
pay: async (task, runtime, taskContext) => {
await runtime.commit(async (tx) => {
const payments: TaskId<PaymentResult>[] = [];
for (const card of task.input.cards) {
payments.push(await tx.createTask(Payment, { card }, { ownership: { kind: "task", taskId: task.id } }));
}
return { status: "waiting", checkpoint: { phase: "decide", payments }, on: payments, policy: "failFast" };
}, taskContext);
},
decide: async (task, runtime, taskContext) => {
const outcomes = await runtime.outcomes(task.state.checkpoint.payments, taskContext);
console.log(` payments: ${outcomes.map((outcome) => outcome.status).join(", ")}`);
const paid = outcomes.every((outcome) => outcome.status === "completed");
await runtime.commit(
() =>
paid
? { status: "terminal", outcome: { status: "completed", result: "order placed" } }
: { status: "terminal", outcome: { status: "failed", error: { message: "payment failed" } } },
taskContext,
);
},
},
// Runs only after every payment is done, so the refunds have already happened.
abort: async (_task, runtime, taskContext) => {
console.log(" checkout aborted");
await runtime.commit(() => ({ status: "terminal", outcome: { status: "aborted" } }), taskContext);
},
});
const registry = createRegistry();
registry.install(defineExtension({ name: "checkout", tasks: [Payment, Checkout] }));
const directory = await mkdtemp(join(tmpdir(), "pi-durable-example-"));
const databasePath = join(directory, "session.sqlite");
const open = async () =>
Harness.open(await openNodeSqliteStorage(databasePath), { models: createModels(), registry }, context);
/** Print the live tasks as a tree along their owner edges. */
function printGraph(graph: TaskGraph): void {
const nodes = Object.values(graph.tasks);
const print = (node: TaskGraphNode, depth: number): void => {
const state = node.state;
const status = state.status === "waiting" ? `waiting on ${state.on.join(", ")}` : state.status;
console.log(` ${" ".repeat(depth)}${node.kind} ${node.id}: ${status}`);
for (const child of nodes.filter((candidate) => candidate.owner === node.id)) print(child, depth + 1);
};
for (const node of nodes.filter((candidate) => candidate.owner === undefined)) print(node, 0);
}
const checkout = (root: Conversation, cards: string[]) =>
root.commit((tx) => tx.createTask(Checkout, { cards }, { ownership: { kind: "conversation" } }), context);
let harness = await open();
let root = await harness.root(context);
console.log("One card is declined:");
let id = await checkout(root, ["visa-1", "expired-2", "visa-3", "visa-4"]);
console.log(` checkout: ${(await harness.waitForTask(id, context)).state.outcome.status}`);
console.log("The customer cancels:");
id = await checkout(root, ["visa-5", "visa-6", "visa-7", "visa-8"]);
harness.resume();
await new Promise((resolve) => setTimeout(resolve, 20));
const graph = await harness.taskGraph(context);
printGraph(graph.value);
graph.dispose();
await harness.abortTask(id, context);
console.log(` checkout: ${(await harness.waitForTask(id, context)).state.outcome.status}`);
console.log("The process stops while the payments run, and a new one continues:");
id = await checkout(root, ["visa-9", "visa-10", "visa-11", "visa-12"]);
await new Promise((resolve) => setTimeout(resolve, 20));
await harness.close(context);
harness = await open();
root = await harness.root(context);
console.log(` checkout: ${(await harness.waitForTask(id, context)).state.outcome.status}`);
await harness.close(context);
await rm(directory, { recursive: true, force: true });

View File

@@ -0,0 +1,123 @@
// Compaction: a long trip-planning chat whose older messages are summarized so the model context stays small.
// Run from packages/durable:
// node --conditions=source --experimental-strip-types test/examples/25-compaction.ts
import { BACKGROUND_CONTEXT } from "@earendil-works/chord/context";
import type { AssistantMessage, Message, TranscriptContext } from "@earendil-works/pi-ai";
import { createModels } from "@earendil-works/pi-ai/models";
import { fauxAssistantMessage, fauxProvider } from "@earendil-works/pi-ai/providers/faux";
import {
CompactionEntry,
type Conversation,
createRegistry,
Harness,
LiveDoc,
MemoryStorage,
} from "../../src/index.ts";
const context = BACKGROUND_CONTEXT;
// A fake model with a tiny 3000-token window. It answers chat messages, writes summaries when asked to summarize,
// and once rejects a request as too long, the way real providers report a context overflow.
let overflowOnce = false;
let summaries = 0;
// While set, the next chat answer waits for it, which keeps the conversation busy.
let hold: Promise<void> | undefined;
async function respond(transcript: TranscriptContext): Promise<AssistantMessage> {
const first = transcript.messages[0];
if (first?.role === "system" && typeof first.content === "string" && first.content.includes("summarization")) {
summaries++;
return fauxAssistantMessage(`## Goal\nPlan a week in Lisbon (summary #${summaries}).`);
}
if (overflowOnce) {
overflowOnce = false;
return fauxAssistantMessage("", { stopReason: "error", errorMessage: "prompt is too long" });
}
const held = hold;
hold = undefined;
await held;
const question = transcript.messages.findLast((message) => message.role === "user");
return fauxAssistantMessage(`A detailed answer to "${text(question)}": ${"details ".repeat(200)}`);
}
const faux = fauxProvider({ models: [{ id: "tiny", contextWindow: 3000, maxTokens: 1000 }] });
faux.setResponses(Array.from({ length: 100 }, () => respond));
const models = createModels();
models.setProvider(faux.provider);
// Generation blocks to compact above 3000 - 1000 = 2000 tokens and starts a background compaction above 2000 - 800.
// Settings are read at every use, so the getter makes `backgroundTokens` live.
let backgroundTokens = 800;
const settings = {
get compaction() {
return { reserveTokens: 1000, keepRecentTokens: 400, backgroundTokens };
},
};
const harness = await Harness.open(new MemoryStorage(), { models, registry: createRegistry(), settings }, context);
const root = await harness.root(context, { agent: { model: { provider: "faux", modelId: "tiny" } } });
async function ask(question: string): Promise<void> {
const submission = await root.submit({ type: "input", content: question }, context);
const record = await submission.wait(context);
// Let a background compaction started by this turn finish, so its summary shows below.
const running = (await harness.snapshot(LiveDoc, root.id, context))?.compactions ?? [];
for (const { taskId } of running) await harness.waitForTask(taskId, context);
await show(root, `after "${question}" (${record.status === "done" ? "answered" : record.reason})`);
}
// 1. A long chat: once the context crosses the background threshold, a compaction runs while the chat goes on, and
// its summary lands at once when the conversation is idle, otherwise at the next turn boundary.
for (const question of [
"Where should we stay?",
"What should we eat?",
"Which day trips?",
"Any museums?",
"Nightlife?",
]) {
await ask(question);
}
// 2. A manual compaction while an answer is still being written. The summary is ready first, waits in the inbox,
// and is placed right after the answer.
let finishAnswer!: () => void;
hold = new Promise((resolve) => {
finishAnswer = resolve;
});
const busy = await root.submit({ type: "input", content: "How do we get around?" }, context);
const manual = await root.compact("Keep the hotel shortlist", context);
const { state } = await harness.waitForTask(manual, context);
const placement = state.outcome.status === "completed" ? state.outcome.result.submissionId : undefined;
const summary = (await harness.submission(placement!, context))!;
console.log("\nmanual compaction finished; its summary is", (await summary.status(context)).status);
finishAnswer();
await busy.wait(context);
console.log("after the answer, the summary is", (await summary.wait(context)).status);
await show(root, "after compact()");
// 3. The provider rejects a request as too long: generation compacts and retries it once. Background compaction is
// turned off so the summary below is the overflow one.
backgroundTokens = 0;
await ask("What should we pack?");
overflowOnce = true;
await ask("Summarize the plan for my partner");
await harness.close(context);
/** Print the model context: one line per message, and the number of stored entries behind it. */
async function show(conversation: Conversation, label: string): Promise<void> {
const view = await conversation.context(context);
const stored = (await conversation.entries({}, 1000, undefined, context)).items.length;
console.log(`\n${label}: ${view.messages.length} messages in context, ${stored} entries stored`);
const reason = CompactionEntry.is(view.head) ? view.head.data.reason : undefined;
if (reason !== undefined) console.log(` (${reason} compaction summary first)`);
for (const message of view.messages) {
console.log(
` ${message.role.padEnd(9)} ${message.role === "system" ? "(system prompt)" : text(message).slice(0, 70)}`,
);
}
}
function text(message: Message | undefined): string {
if (message === undefined || message.role === "system") return "";
if (typeof message.content === "string") return message.content;
const block = message.content.find((content) => content.type === "text");
return block?.type === "text" ? block.text.replaceAll("\n", " ") : "";
}

View File

@@ -0,0 +1,81 @@
// A small coding agent: the built-in coding tools, a prompt that knows the working directory, settings backed by the
// app's settings file, and an environment that follows the conversation's directory.
// Run from packages/durable:
// node --conditions=source --experimental-strip-types test/examples/26-coding-agent.ts
import { mkdir, mkdtemp, rm } from "node:fs/promises";
import { tmpdir } from "node:os";
import { join } from "node:path";
import { BACKGROUND_CONTEXT } from "@earendil-works/chord/context";
import type { ToolResultMessage } from "@earendil-works/pi-ai";
import { createModels } from "@earendil-works/pi-ai/models";
import { fauxAssistantMessage, fauxProvider, fauxToolCall } from "@earendil-works/pi-ai/providers/faux";
import { NodeExecutionEnv } from "../../src/env/node.ts";
import {
createRegistry,
defineExtension,
Harness,
type HarnessSettings,
MemoryStorage,
section,
ToolResultEntry,
} from "../../src/index.ts";
import { CodingTools } from "../../src/tools/index.ts";
const context = BACKGROUND_CONTEXT;
const workspace = await mkdtemp(join(tmpdir(), "pi-durable-agent-"));
await mkdir(join(workspace, "app"));
// The app's own prompt, next to the coding tools.
const Coding = defineExtension({
name: "coding",
sections: [
section("preamble", () => "You are a coding agent. Use the tools to inspect the project.", { tag: false }),
section("cwd", (input) => input.env?.cwd),
],
});
const registry = createRegistry();
registry.install(CodingTools);
registry.install(Coding);
// Settings the user edits while the agent runs, like a settings.json the app watches. The getters make every
// Harness read see the current file; nothing is copied or stored.
const userSettings = { parallelTools: true, maxRetries: 3 };
const settings: HarnessSettings = {
get toolExecution() {
return userSettings.parallelTools ? "parallel" : "sequential";
},
get retry() {
return { maxRetries: userSettings.maxRetries };
},
};
// The Harness calls this for every tool call and request with the conversation's `cwd`, so changing a conversation's
// directory needs no restart.
const env = ({ cwd = workspace }: { readonly cwd?: string }) => new NodeExecutionEnv({ cwd });
const faux = fauxProvider();
const models = createModels();
models.setProvider(faux.provider);
const pwd = () => fauxAssistantMessage(fauxToolCall("bash", { command: "pwd" }), { stopReason: "toolUse" });
faux.setResponses([pwd(), fauxAssistantMessage("Done."), pwd(), fauxAssistantMessage("Done.")]);
const harness = await Harness.open(new MemoryStorage(), { models, registry, settings, env }, context);
const root = await harness.root(context, { agent: { model: { provider: "faux", modelId: "faux-1" }, cwd: workspace } });
const lastBashOutput = async () => {
const page = await root.entries({}, 10, undefined, context);
const result = page.items.find((entry) => ToolResultEntry.is(entry))?.model?.[0] as ToolResultMessage;
return result.content.map((part) => (part.type === "text" ? part.text.trim() : "")).join("");
};
await (await root.submit({ type: "input", content: "Where are we?" }, context)).wait(context);
console.log("bash ran in:", await lastBashOutput());
// The user switches the project directory and turns off parallel tools. Both apply from the next use.
await root.configure({ cwd: join(workspace, "app") }, context);
userSettings.parallelTools = false;
await (await root.submit({ type: "input", content: "And now?" }, context)).wait(context);
console.log("bash ran in:", await lastBashOutput());
await harness.close(context);
await rm(workspace, { recursive: true, force: true });

View File

@@ -0,0 +1,115 @@
// Plan mode: the user switches a conversation into a read-only planning mode, the agent writes a plan into the
// extension's own document, and switching back restores the full tool set.
// Run from packages/durable:
// node --conditions=source --experimental-strip-types test/examples/27-plan-mode.ts
import { mkdtemp, rm, writeFile } from "node:fs/promises";
import { tmpdir } from "node:os";
import { join } from "node:path";
import { BACKGROUND_CONTEXT } from "@earendil-works/chord/context";
import { Type } from "@earendil-works/pi-ai";
import { createModels } from "@earendil-works/pi-ai/models";
import { fauxAssistantMessage, fauxProvider, fauxToolCall } from "@earendil-works/pi-ai/providers/faux";
import { NodeExecutionEnv } from "../../src/env/node.ts";
import {
createRegistry,
defineDoc,
defineExtension,
defineTool,
Harness,
MemoryStorage,
section,
} from "../../src/index.ts";
import { CodingTools, createReadTool } from "../../src/tools/index.ts";
const context = BACKGROUND_CONTEXT;
// ─── Product code: the plan extension ───────────────────────────────────────
// The current plan of a conversation. A fork keeps the plan it had at the fork entry.
const PlanDoc = defineDoc<{ steps: string[] }>({
kind: "app.plan",
version: 1,
scope: "conversation",
history: "rewindable",
fork: "asOf",
initial: () => ({ steps: [] }),
});
const submitPlan = defineTool({
name: "submit_plan",
description: "Submit the plan as a list of steps.",
parameters: Type.Object({ steps: Type.Array(Type.String()) }),
execute: async (args, api, callContext) => {
await api.commit(async (tx) => {
(await tx.doc(PlanDoc, api.conversationId)).steps = args.steps;
}, callContext);
return { output: [{ type: "text", text: "Plan submitted." }], control: { terminate: true } };
},
});
const Plan = defineExtension({
name: "plan",
tools: [submitPlan],
sections: [
section("plan_mode", () => "You are in plan mode. Read the code, then call submit_plan. Change nothing."),
],
});
// Plan mode is a change to the conversation's agent: select the plan extension and offer only reading and
// submitting. Clearing both returns to the host's default selection and every tool.
const read = createReadTool();
const enterPlanMode = { extensions: { add: [Plan] }, tools: [read, submitPlan] };
const leavePlanMode = { extensions: null, tools: null };
// ─── Host setup ─────────────────────────────────────────────────────────────
const directory = await mkdtemp(join(tmpdir(), "pi-durable-plan-"));
await writeFile(join(directory, "server.ts"), "app.listen(3000);\n");
const faux = fauxProvider();
const models = createModels();
models.setProvider(faux.provider);
faux.setResponses([
fauxAssistantMessage(fauxToolCall("read", { path: "server.ts" }), { stopReason: "toolUse" }),
fauxAssistantMessage(fauxToolCall("submit_plan", { steps: ["Read PORT from the environment", "Default to 3000"] }), {
stopReason: "toolUse",
}),
fauxAssistantMessage("Implementing step 1."),
]);
const registry = createRegistry();
registry.install(CodingTools);
registry.install(Plan);
const harness = await Harness.open(
new MemoryStorage(),
// Installed, but only CodingTools is selected by default: conversations opt into plan mode.
{ models, registry, settings: { extensions: [CodingTools] }, env: () => new NodeExecutionEnv({ cwd: directory }) },
context,
);
const root = await harness.root(context, { agent: { model: { provider: "faux", modelId: "faux-1" } } });
const tools = async () => (await root.agent(context)).tools.map((tool) => tool.name);
console.log("tools:", await tools());
await root.configure(enterPlanMode, context);
console.log("plan mode tools:", await tools());
await (await root.submit({ type: "input", content: "Make the port configurable." }, context)).wait(context);
console.log("plan:", (await harness.snapshot(PlanDoc, root.id, context))?.steps);
await root.configure(leavePlanMode, context);
console.log("tools again:", await tools());
await (await root.submit({ type: "input", content: "Go ahead." }, context)).wait(context);
// The model saw each switch as a system prompt change in its transcript.
const { messages } = await root.context(context);
for (const message of messages) {
if (message.role !== "system") continue;
console.log("system:", {
// A null section value removes it.
sections: message.sections,
added: message.toolsAdded?.map((tool) => tool.name),
removed: message.toolsRemoved?.map((tool) => tool.name),
});
}
await harness.close(context);
await rm(directory, { recursive: true, force: true });

View File

@@ -0,0 +1,111 @@
// A reviewer agent next to the main one: a cheaper model, a review role and loop, read-only tools, and its own
// checkout of the project.
// Run from packages/durable:
// node --conditions=source --experimental-strip-types test/examples/28-reviewer.ts
import { mkdtemp, rm, writeFile } from "node:fs/promises";
import { tmpdir } from "node:os";
import { join } from "node:path";
import { BACKGROUND_CONTEXT } from "@earendil-works/chord/context";
import type { AssistantMessage } from "@earendil-works/pi-ai";
import { createModels } from "@earendil-works/pi-ai/models";
import { fauxAssistantMessage, fauxProvider, fauxToolCall } from "@earendil-works/pi-ai/providers/faux";
import { NodeExecutionEnv } from "../../src/env/node.ts";
import {
AssistantEntry,
createRegistry,
defineExtension,
GenerationTask,
Harness,
hook,
MemoryStorage,
section,
} from "../../src/index.ts";
import { CodingTools, createReadTool } from "../../src/tools/index.ts";
const context = BACKGROUND_CONTEXT;
const textOf = (message: AssistantMessage) =>
message.content.flatMap((part) => (part.type === "text" ? [part.text] : [])).join("");
// ─── Product code: the reviewer extension ───────────────────────────────────
const DONE = "No further findings.";
// A role and a review loop: every answer that still has findings gets a second pass.
const Reviewer = defineExtension({
name: "reviewer",
sections: [section("role", () => `You review diffs. Report problems as a list. Never edit files.`)],
hooks: [
hook(GenerationTask, {
onYield: (answer) =>
textOf(answer).includes(DONE)
? undefined
: { continue: `Look again for anything you missed. Say "${DONE}" when there is nothing left.` },
}),
],
});
// ─── Host setup ─────────────────────────────────────────────────────────────
// The reviewer works in its own checkout, in practice a `git worktree add`.
const worktree = await mkdtemp(join(tmpdir(), "pi-durable-review-"));
await writeFile(join(worktree, "user.ts"), "export const name = (user) => user.name;\n");
const faux = fauxProvider({ models: [{ id: "big" }, { id: "small" }] });
const models = createModels();
models.setProvider(faux.provider);
faux.setResponses([
fauxAssistantMessage(fauxToolCall("read", { path: "user.ts" }), { stopReason: "toolUse" }),
fauxAssistantMessage("1. `name` does not handle a missing user."),
fauxAssistantMessage(`2. \`user\` has no type. ${DONE}`),
]);
const registry = createRegistry();
registry.install(CodingTools);
registry.install(Reviewer);
const harness = await Harness.open(
new MemoryStorage(),
// The main agent selects only CodingTools; the reviewer opts in.
{
models,
registry,
settings: { extensions: [CodingTools] },
env: ({ cwd = process.cwd() }) => new NodeExecutionEnv({ cwd }),
},
context,
);
await harness.root(context, { agent: { model: { provider: "faux", modelId: "big" } } });
// Everything the reviewer is, stored on its conversation: the model, exactly these extensions in this order, only
// the read tool, and its directory. A restart keeps all of it.
const reviewer = await harness.createConversation(
{
ownership: { kind: "ownerless" },
agent: {
model: { provider: "faux", modelId: "small" },
extensions: [CodingTools, Reviewer],
tools: [createReadTool()],
cwd: worktree,
},
},
context,
);
const agent = await reviewer.agent(context);
console.log(
"reviewer:",
agent.model?.modelId,
agent.extensions.map((extension) => extension.name),
agent.tools.map((tool) => tool.name),
agent.cwd === worktree,
);
await (await reviewer.submit({ type: "input", content: "Review user.ts." }, context)).wait(context);
const page = await reviewer.entries({}, 20, undefined, context);
for (const entry of [...page.items].reverse()) {
const message = entry.model?.[0];
if (message?.role === "user") console.log(">", message.content);
if (AssistantEntry.is(entry) && textOf(message as AssistantMessage) !== "") {
console.log("reviewer:", textOf(message as AssistantMessage));
}
}
await harness.close(context);
await rm(worktree, { recursive: true, force: true });

View File

@@ -0,0 +1,82 @@
// One sandbox per conversation: the app records each conversation's sandbox in its own document, and the Harness
// environment function looks it up for every tool call. Here a sandbox is a directory; a hosted product would return
// an ExecutionEnv that runs inside the conversation's container.
// Run from packages/durable:
// node --conditions=source --experimental-strip-types test/examples/29-sandbox-per-conversation.ts
import { mkdtemp, readFile, rm } from "node:fs/promises";
import { tmpdir } from "node:os";
import { join } from "node:path";
import { BACKGROUND_CONTEXT } from "@earendil-works/chord/context";
import { createModels } from "@earendil-works/pi-ai/models";
import { fauxAssistantMessage, fauxProvider, fauxToolCall } from "@earendil-works/pi-ai/providers/faux";
import { NodeExecutionEnv } from "../../src/env/node.ts";
import { createRegistry, defineDoc, Harness, MemoryStorage } from "../../src/index.ts";
import { CodingTools } from "../../src/tools/index.ts";
const context = BACKGROUND_CONTEXT;
// Which sandbox a conversation runs in. `fork: "initial"`: a fork gets no sandbox until the app assigns one.
const Sandbox = defineDoc<{ path?: string }>({
kind: "app.sandbox",
version: 1,
scope: "conversation",
history: "latest",
fork: "initial",
initial: () => ({}),
});
const faux = fauxProvider();
const models = createModels();
models.setProvider(faux.provider);
const note = (text: string) =>
fauxAssistantMessage(fauxToolCall("write", { path: "note.txt", content: text }), { stopReason: "toolUse" });
faux.setResponses([
note("from alice"),
fauxAssistantMessage("Saved."),
note("from bob"),
fauxAssistantMessage("Saved."),
]);
const registry = createRegistry();
registry.install(CodingTools);
const harness = await Harness.open(
new MemoryStorage(),
{
models,
registry,
// Committed reads only; a conversation without a sandbox gets no environment, so its tools fail cleanly.
env: async ({ conversationId, read }, envContext) => {
const sandbox = await read.snapshot(Sandbox, conversationId, envContext);
return sandbox?.path === undefined ? undefined : new NodeExecutionEnv({ cwd: sandbox.path });
},
},
context,
);
// Each user's conversation gets a fresh sandbox in the creating commit.
const model = { provider: "faux", modelId: "faux-1" };
async function conversationFor(user: string) {
const path = await mkdtemp(join(tmpdir(), `pi-durable-sandbox-${user}-`));
const conversation = await harness.createConversation(
{
ownership: { kind: "ownerless" },
agent: { model },
init: async (tx, id) => {
(await tx.doc(Sandbox, id)).path = path;
},
},
context,
);
return { conversation, path };
}
const alice = await conversationFor("alice");
const bob = await conversationFor("bob");
await (await alice.conversation.submit({ type: "input", content: "Leave a note." }, context)).wait(context);
await (await bob.conversation.submit({ type: "input", content: "Leave a note." }, context)).wait(context);
console.log("alice's sandbox:", await readFile(join(alice.path, "note.txt"), "utf8"));
console.log("bob's sandbox:", await readFile(join(bob.path, "note.txt"), "utf8"));
await harness.close(context);
await rm(alice.path, { recursive: true, force: true });
await rm(bob.path, { recursive: true, force: true });

View File

@@ -0,0 +1,102 @@
// Replace a tool for some conversations, and decorate whichever tool wins: a bash that runs inside a Python
// virtualenv, and a wrapper that times every bash call.
// Run from packages/durable:
// node --conditions=source --experimental-strip-types test/examples/30-tool-override.ts
import { mkdir, mkdtemp, rm, writeFile } from "node:fs/promises";
import { tmpdir } from "node:os";
import { join } from "node:path";
import { BACKGROUND_CONTEXT } from "@earendil-works/chord/context";
import type { ToolResultMessage } from "@earendil-works/pi-ai";
import { createModels } from "@earendil-works/pi-ai/models";
import { fauxAssistantMessage, fauxProvider, fauxToolCall } from "@earendil-works/pi-ai/providers/faux";
import { NodeExecutionEnv } from "../../src/env/node.ts";
import {
type Conversation,
createRegistry,
defineExtension,
Harness,
MemoryStorage,
ToolResultEntry,
wrapTool,
} from "../../src/index.ts";
import { CodingTools, createBashTool } from "../../src/tools/index.ts";
const context = BACKGROUND_CONTEXT;
// ─── Product code ───────────────────────────────────────────────────────────
// A bash with the same name: where selected after CodingTools, it replaces CodingTools' bash in place.
const Venv = defineExtension({
name: "venv",
tools: [createBashTool({ commandPrefix: "source .venv/bin/activate" })],
});
// Wraps the bash that won, whichever it is. Wrappers never capture a base tool, so reloading either bash keeps it.
const timings: number[] = [];
const Timing = defineExtension({
name: "timing",
wraps: [
wrapTool(createBashTool(), (bash) => ({
...bash,
execute: async (args, api, callContext) => {
const start = Date.now();
try {
return await bash.execute(args, api, callContext);
} finally {
timings.push(Date.now() - start);
}
},
})),
],
});
// ─── Host setup ─────────────────────────────────────────────────────────────
const project = await mkdtemp(join(tmpdir(), "pi-durable-venv-"));
await mkdir(join(project, ".venv/bin"), { recursive: true });
await writeFile(join(project, ".venv/bin/activate"), `export VIRTUAL_ENV="${project}/.venv"\n`);
const faux = fauxProvider();
const models = createModels();
models.setProvider(faux.provider);
const probe = () =>
fauxAssistantMessage(fauxToolCall("bash", { command: "echo venv: $VIRTUAL_ENV" }), {
stopReason: "toolUse",
});
faux.setResponses([probe(), fauxAssistantMessage("Done."), probe(), fauxAssistantMessage("Done.")]);
const registry = createRegistry();
registry.install(CodingTools);
registry.install(Timing);
registry.install(Venv);
const harness = await Harness.open(
new MemoryStorage(),
{
models,
registry,
// Venv is installed but not selected by default.
settings: { extensions: [CodingTools, Timing] },
env: () => new NodeExecutionEnv({ cwd: project }),
},
context,
);
const model = { provider: "faux", modelId: "faux-1" };
const plain = await harness.root(context, { agent: { model } });
const python = await harness.createConversation(
{ ownership: { kind: "ownerless" }, agent: { model, extensions: { add: [Venv] } } },
context,
);
async function probeBash(conversation: Conversation): Promise<string> {
await (await conversation.submit({ type: "input", content: "Which venv?" }, context)).wait(context);
const page = await conversation.entries({}, 10, undefined, context);
const result = page.items.find((entry) => ToolResultEntry.is(entry))?.model?.[0] as ToolResultMessage;
return result.content.map((part) => (part.type === "text" ? part.text.trim() : "")).join("");
}
console.log("plain conversation:", await probeBash(plain));
console.log("python conversation:", (await probeBash(python)).replace(project, "<project>"));
console.log("timed bash calls:", timings.length);
await harness.close(context);
await rm(project, { recursive: true, force: true });

View File

@@ -0,0 +1,90 @@
// Reload an extension while a call runs, then restart the process. Running work finishes on the code it started
// with; stored choices are names, so they survive a restart and bind to whatever code the new process installs.
// Run from packages/durable:
// node --conditions=source --experimental-strip-types test/examples/31-reload-and-restart.ts
import { mkdtemp, rm } from "node:fs/promises";
import { tmpdir } from "node:os";
import { join } from "node:path";
import { BACKGROUND_CONTEXT } from "@earendil-works/chord/context";
import { type ToolResultMessage, Type } from "@earendil-works/pi-ai";
import { createModels } from "@earendil-works/pi-ai/models";
import { fauxAssistantMessage, fauxProvider, fauxToolCall } from "@earendil-works/pi-ai/providers/faux";
import { createRegistry, defineExtension, defineTool, Harness, ToolResultEntry } from "../../src/index.ts";
import { openNodeSqliteStorage } from "../../src/storage/sqlite/node.ts";
const context = BACKGROUND_CONTEXT;
// Stand-in for code loaded from disk: each call builds the extension as the file currently reads.
let started = (): void => {};
let gate = Promise.resolve();
function loadVersioned(version: string) {
return defineExtension({
name: "versioned",
tools: [
defineTool({
name: "version",
description: "Report the tool's code version",
parameters: Type.Object({}),
execute: async () => {
started();
await gate;
return { output: [{ type: "text", text: version }] };
},
}),
],
});
}
const faux = fauxProvider();
const models = createModels();
models.setProvider(faux.provider);
const callVersion = () => fauxAssistantMessage(fauxToolCall("version", {}), { stopReason: "toolUse" });
faux.setResponses([callVersion(), fauxAssistantMessage("Done."), callVersion(), fauxAssistantMessage("Done.")]);
const directory = await mkdtemp(join(tmpdir(), "pi-durable-reload-"));
const open = async (registry: ReturnType<typeof createRegistry>) =>
Harness.open(await openNodeSqliteStorage(join(directory, "session.sqlite")), { models, registry }, context);
// First process.
let registry = createRegistry();
registry.install(loadVersioned("v1"));
let harness = await open(registry);
let root = await harness.root(context, {
// Selected by name. The name is what is stored, never the code.
agent: { model: { provider: "faux", modelId: "faux-1" }, extensions: [loadVersioned("v1")] },
});
const lastResult = async () => {
const page = await root.entries({}, 10, undefined, context);
const result = page.items.find((entry) => ToolResultEntry.is(entry))?.model?.[0] as ToolResultMessage;
return result.content.map((part) => (part.type === "text" ? part.text : "")).join("");
};
// The file changes while a call runs: the running call finishes on v1, the next call uses v2.
let release = (): void => {};
gate = new Promise((resolve) => {
release = resolve;
});
const running = new Promise<void>((resolve) => {
started = resolve;
});
const submission = await root.submit({ type: "input", content: "Which version?" }, context);
await running;
registry.install(loadVersioned("v2"));
release();
await submission.wait(context);
console.log("call running during the reload:", await lastResult());
await (await root.submit({ type: "input", content: "And now?" }, context)).wait(context);
console.log("next call:", await lastResult());
await harness.close(context);
// Second process: the conversation still selects "versioned", but this process has not installed it yet.
registry = createRegistry();
harness = await open(registry);
root = await harness.root(context);
const tools = async () => (await root.agent(context)).tools.map((tool) => tool.name);
console.log("after restart, before install:", await tools());
registry.install(loadVersioned("v3"));
console.log("after install:", await tools());
await harness.close(context);
await rm(directory, { recursive: true, force: true });