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:
21
third_party/pi/packages/durable/test/examples/00-conversation.ts
vendored
Normal file
21
third_party/pi/packages/durable/test/examples/00-conversation.ts
vendored
Normal 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);
|
||||
45
third_party/pi/packages/durable/test/examples/01-documents.ts
vendored
Normal file
45
third_party/pi/packages/durable/test/examples/01-documents.ts
vendored
Normal 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);
|
||||
55
third_party/pi/packages/durable/test/examples/02-forks.ts
vendored
Normal file
55
third_party/pi/packages/durable/test/examples/02-forks.ts
vendored
Normal 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);
|
||||
69
third_party/pi/packages/durable/test/examples/03-owned-conversations.ts
vendored
Normal file
69
third_party/pi/packages/durable/test/examples/03-owned-conversations.ts
vendored
Normal 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);
|
||||
37
third_party/pi/packages/durable/test/examples/04-chord-state.ts
vendored
Normal file
37
third_party/pi/packages/durable/test/examples/04-chord-state.ts
vendored
Normal 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);
|
||||
41
third_party/pi/packages/durable/test/examples/05-watches.ts
vendored
Normal file
41
third_party/pi/packages/durable/test/examples/05-watches.ts
vendored
Normal 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);
|
||||
66
third_party/pi/packages/durable/test/examples/06-harness.ts
vendored
Normal file
66
third_party/pi/packages/durable/test/examples/06-harness.ts
vendored
Normal 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);
|
||||
99
third_party/pi/packages/durable/test/examples/07-configuration.ts
vendored
Normal file
99
third_party/pi/packages/durable/test/examples/07-configuration.ts
vendored
Normal 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);
|
||||
42
third_party/pi/packages/durable/test/examples/08-harness-conversations.ts
vendored
Normal file
42
third_party/pi/packages/durable/test/examples/08-harness-conversations.ts
vendored
Normal 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);
|
||||
136
third_party/pi/packages/durable/test/examples/09-context.ts
vendored
Normal file
136
third_party/pi/packages/durable/test/examples/09-context.ts
vendored
Normal 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);
|
||||
48
third_party/pi/packages/durable/test/examples/10-registry-reload.ts
vendored
Normal file
48
third_party/pi/packages/durable/test/examples/10-registry-reload.ts
vendored
Normal 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);
|
||||
86
third_party/pi/packages/durable/test/examples/11-extension-state.ts
vendored
Normal file
86
third_party/pi/packages/durable/test/examples/11-extension-state.ts
vendored
Normal 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);
|
||||
64
third_party/pi/packages/durable/test/examples/12-tasks.ts
vendored
Normal file
64
third_party/pi/packages/durable/test/examples/12-tasks.ts
vendored
Normal 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);
|
||||
90
third_party/pi/packages/durable/test/examples/13-recovery.ts
vendored
Normal file
90
third_party/pi/packages/durable/test/examples/13-recovery.ts
vendored
Normal 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 });
|
||||
45
third_party/pi/packages/durable/test/examples/14-chat.ts
vendored
Normal file
45
third_party/pi/packages/durable/test/examples/14-chat.ts
vendored
Normal 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);
|
||||
93
third_party/pi/packages/durable/test/examples/15-system-prompt.ts
vendored
Normal file
93
third_party/pi/packages/durable/test/examples/15-system-prompt.ts
vendored
Normal 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);
|
||||
69
third_party/pi/packages/durable/test/examples/16-real-model.ts
vendored
Normal file
69
third_party/pi/packages/durable/test/examples/16-real-model.ts
vendored
Normal 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);
|
||||
}
|
||||
101
third_party/pi/packages/durable/test/examples/17-coding-tools.ts
vendored
Normal file
101
third_party/pi/packages/durable/test/examples/17-coding-tools.ts
vendored
Normal 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);
|
||||
53
third_party/pi/packages/durable/test/examples/18-print.ts
vendored
Normal file
53
third_party/pi/packages/durable/test/examples/18-print.ts
vendored
Normal 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);
|
||||
103
third_party/pi/packages/durable/test/examples/19-json.ts
vendored
Normal file
103
third_party/pi/packages/durable/test/examples/19-json.ts
vendored
Normal 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}`);
|
||||
72
third_party/pi/packages/durable/test/examples/20-inbox.ts
vendored
Normal file
72
third_party/pi/packages/durable/test/examples/20-inbox.ts
vendored
Normal 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);
|
||||
100
third_party/pi/packages/durable/test/examples/21-late-join.ts
vendored
Normal file
100
third_party/pi/packages/durable/test/examples/21-late-join.ts
vendored
Normal 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);
|
||||
136
third_party/pi/packages/durable/test/examples/22-subagent-foreground.ts
vendored
Normal file
136
third_party/pi/packages/durable/test/examples/22-subagent-foreground.ts
vendored
Normal 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);
|
||||
363
third_party/pi/packages/durable/test/examples/23-subagent-background.ts
vendored
Normal file
363
third_party/pi/packages/durable/test/examples/23-subagent-background.ts
vendored
Normal 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 });
|
||||
141
third_party/pi/packages/durable/test/examples/24-child-tasks.ts
vendored
Normal file
141
third_party/pi/packages/durable/test/examples/24-child-tasks.ts
vendored
Normal 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 });
|
||||
123
third_party/pi/packages/durable/test/examples/25-compaction.ts
vendored
Normal file
123
third_party/pi/packages/durable/test/examples/25-compaction.ts
vendored
Normal 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", " ") : "";
|
||||
}
|
||||
81
third_party/pi/packages/durable/test/examples/26-coding-agent.ts
vendored
Normal file
81
third_party/pi/packages/durable/test/examples/26-coding-agent.ts
vendored
Normal 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 });
|
||||
115
third_party/pi/packages/durable/test/examples/27-plan-mode.ts
vendored
Normal file
115
third_party/pi/packages/durable/test/examples/27-plan-mode.ts
vendored
Normal 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 });
|
||||
111
third_party/pi/packages/durable/test/examples/28-reviewer.ts
vendored
Normal file
111
third_party/pi/packages/durable/test/examples/28-reviewer.ts
vendored
Normal 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 });
|
||||
82
third_party/pi/packages/durable/test/examples/29-sandbox-per-conversation.ts
vendored
Normal file
82
third_party/pi/packages/durable/test/examples/29-sandbox-per-conversation.ts
vendored
Normal 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 });
|
||||
102
third_party/pi/packages/durable/test/examples/30-tool-override.ts
vendored
Normal file
102
third_party/pi/packages/durable/test/examples/30-tool-override.ts
vendored
Normal 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 });
|
||||
90
third_party/pi/packages/durable/test/examples/31-reload-and-restart.ts
vendored
Normal file
90
third_party/pi/packages/durable/test/examples/31-reload-and-restart.ts
vendored
Normal 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 });
|
||||
Reference in New Issue
Block a user