From 3ac64a074d6be9ead2ab4c6fc774edb6e64207d9 Mon Sep 17 00:00:00 2001 From: Mario Zechner Date: Wed, 28 Oct 2026 14:03:36 +0000 Subject: [PATCH] docs(durable): examples for turn limits and continue, and admission inside a commit --- packages/durable/README.md | 2 + .../examples/33-turn-limit-and-continue.ts | 73 +++++++++++++ .../test/examples/34-submit-in-commit.ts | 103 ++++++++++++++++++ 3 files changed, 178 insertions(+) create mode 100644 packages/durable/test/examples/33-turn-limit-and-continue.ts create mode 100644 packages/durable/test/examples/34-submit-in-commit.ts diff --git a/packages/durable/README.md b/packages/durable/README.md index 703731bf9..bdf59282e 100644 --- a/packages/durable/README.md +++ b/packages/durable/README.md @@ -954,6 +954,8 @@ node --conditions=source --experimental-strip-types test/examples/13-chat.ts | [30-reload-and-restart](test/examples/30-reload-and-restart.ts) | Reloading an extension mid-call, and stored choices surviving a restart | | [31-transcript](test/examples/31-transcript.ts) | A client's transcript over the windowed view: live appends, a stall with a gap, loading it and earlier pages, and resuming with `from` | | [32-tests-after-edits](test/examples/32-tests-after-edits.ts) | An `afterRun` hook that runs the tests after every run that edited files, a fix run when they fail, and input waiting for them | +| [33-turn-limit-and-continue](test/examples/33-turn-limit-and-continue.ts) | A per-conversation turn limit, `runFailure`, and continuing without a user message | +| [34-submit-in-commit](test/examples/34-submit-in-commit.ts) | Admitting a visitor's steer inside the host's own commit with `submit(tx, draft)` and `whenIdle: "reject"` | | [00](test/examples/00-conversation.ts)–[12](test/examples/12-recovery.ts) | The layers underneath: sessions, documents, forks, watches, the Harness, agent configuration, reload, extension state, tasks, recovery | Examples that call OpenAI need `OPENAI_API_KEY`; most use the faux provider otherwise. diff --git a/packages/durable/test/examples/33-turn-limit-and-continue.ts b/packages/durable/test/examples/33-turn-limit-and-continue.ts new file mode 100644 index 000000000..cac9d315c --- /dev/null +++ b/packages/durable/test/examples/33-turn-limit-and-continue.ts @@ -0,0 +1,73 @@ +// A per-conversation turn limit stops a long run after a number of tool rounds; a continue resumes it without a user +// message, with a fresh budget. A host learns that a run stopped from `pi.live.runFailure`. +// Run from packages/durable: +// node --conditions=source --experimental-strip-types test/examples/33-turn-limit-and-continue.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 { + ConversationBusy, + createRegistry, + defineExtension, + defineTool, + Harness, + LiveDoc, + MemoryStorage, +} from "../../src/index.ts"; + +const context = BACKGROUND_CONTEXT; + +const step = defineTool({ + name: "step", + description: "Do one step of the work", + parameters: Type.Object({ n: Type.Number() }), + execute: async (args) => ({ output: [{ type: "text", text: `step ${args.n} done` }] }), +}); +const call = (n: number) => fauxAssistantMessage(fauxToolCall("step", { n }), { stopReason: "toolUse" }); + +const faux = fauxProvider(); +const models = createModels(); +models.setProvider(faux.provider); +// Four steps, then the answer. Two tool rounds per run are allowed. +faux.setResponses([call(1), call(2), call(3), call(4), fauxAssistantMessage("All four steps are done.")]); + +const registry = createRegistry(); +registry.install(defineExtension({ name: "steps", tools: [step] })); +const harness = await Harness.open(new MemoryStorage(), { models, registry }, context); +// Stored on the conversation, like its model: children and forks inherit it. +const root = await harness.root(context, { agent: { model: { provider: "faux", modelId: "faux-1" }, maxTurns: 2 } }); + +const first = await (await root.submit({ type: "input", content: "Do the four steps." }, context)).wait(context); +const failure = (await harness.snapshot(LiveDoc, root.id, context))?.runFailure; +console.log("first run:", first.status, first.reason, `(${failure?.detail})`); + +// An idle conversation runs a continue on the context as it is, the round's results last, with a fresh budget. +const resumed = await root.submit({ type: "continue" }, context); +// It never queues: while that run works, another continue is refused. +try { + await root.submit({ type: "continue" }, context); +} catch (error) { + if (!(error instanceof ConversationBusy)) throw error; + console.log("second continue while busy: ConversationBusy"); +} +const second = await resumed.wait(context); +console.log("first continue:", second.status, second.status === "done" ? "" : second.reason); +const third = await (await root.submit({ type: "continue" }, context)).wait(context); +const live = await harness.snapshot(LiveDoc, root.id, context); +console.log("second continue:", third.status, live?.runFailure === undefined ? "(runFailure cleared)" : ""); + +const page = await root.entries({ order: "ascending" }, 100, undefined, context); +for (const entry of page.items) { + const message = entry.model?.[0]; + if (message?.role === "user") console.log(">", message.content); + if (message?.role === "toolResult") + console.log(" tool:", message.content[0]?.type === "text" ? message.content[0].text : ""); + if (message?.role === "assistant" && message.stopReason === "stop") { + console.log("agent:", message.content.flatMap((part) => (part.type === "text" ? [part.text] : [])).join("")); + } +} +// A continue after an answer has nothing to answer: it ends without a model request. +const after = await (await root.submit({ type: "continue" }, context)).wait(context); +console.log("continue after the answer:", after.status, after.reason); +await harness.close(context); diff --git a/packages/durable/test/examples/34-submit-in-commit.ts b/packages/durable/test/examples/34-submit-in-commit.ts new file mode 100644 index 000000000..291305cc6 --- /dev/null +++ b/packages/durable/test/examples/34-submit-in-commit.ts @@ -0,0 +1,103 @@ +// A host lets people message an agent while it works: a message must join the current run as a steer and never start a +// run of its own. `submit(tx, draft)` admits it inside the host's own commit, together with the host's check and its own +// state; `whenIdle: "reject"` refuses it when no run is working. +// Run from packages/durable: +// node --conditions=source --experimental-strip-types test/examples/34-submit-in-commit.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 { + ConversationIdle, + createRegistry, + defineDoc, + defineExtension, + defineTool, + Harness, + MemoryStorage, +} from "../../src/index.ts"; + +const context = BACKGROUND_CONTEXT; + +// The host's own state: who may message this conversation, and what they sent. +const Visitors = defineDoc<{ muted: string[]; sent: { from: string; text: string }[] }>({ + kind: "app.visitors", + version: 1, + scope: "conversation", + history: "latest", + fork: "initial", + initial: () => ({ muted: [], sent: [] }), + checkpointWhen: () => false, +}); + +// A tool that waits until released, so messages arrive while the run works. +let release!: () => void; +const working = new Promise((resolve) => { + release = resolve; +}); +const build = defineTool({ + name: "build", + description: "Build the project", + parameters: Type.Object({}), + execute: async () => { + await working; + return { output: [{ type: "text", text: "built" }] }; + }, +}); + +const faux = fauxProvider(); +const models = createModels(); +models.setProvider(faux.provider); +faux.setResponses([ + fauxAssistantMessage(fauxToolCall("build", {}), { stopReason: "toolUse" }), + fauxAssistantMessage("Built, and I added the test Ana asked for."), +]); +const registry = createRegistry(); +registry.install(defineExtension({ name: "build", tools: [build] })); +const harness = await Harness.open(new MemoryStorage(), { models, registry }, context); +const root = await harness.root(context, { agent: { model: { provider: "faux", modelId: "faux-1" } } }); +await root.commit(async (tx) => { + (await tx.doc(Visitors, root.id)).muted.push("bob"); +}, context); + +/** A visitor's message: checked, admitted as a steer of the current run, and recorded, in one commit or not at all. */ +async function message(from: string, text: string, requestId: string): Promise { + try { + return await harness.commit(async (tx) => { + const visitors = await tx.doc(Visitors, root.id); + if (visitors.muted.includes(from)) return `${from} is muted`; + // Before the commit's own table writes; document writes may come before or after. + const id = await root.submit(tx, { + type: "input", + content: `${from}: ${text}`, + whenBusy: "steer", + whenIdle: "reject", + requestId, + }); + visitors.sent.push({ from, text }); + return `${from}'s message is submission ${id}`; + }, context); + } catch (error) { + if (error instanceof ConversationIdle) return `${from}: the agent isn't working now`; + throw error; + } +} + +console.log(await message("ana", "Add a test for the build.", "ana-1")); // idle: refused, nothing written +const run = await root.submit({ type: "input", content: "Build the project." }, context); +console.log(await message("ana", "Add a test for the build.", "ana-2")); // busy: joins the run as a steer +console.log(await message("bob", "Delete everything.", "bob-1")); // the host's own check refuses it +release(); +await run.wait(context); +await root.waitForIdle(context); + +const page = await root.entries({ order: "ascending" }, 100, undefined, context); +for (const entry of page.items) { + const message = entry.model?.[0]; + if (message?.role === "user") console.log(">", message.content); + if (message?.role === "assistant" && message.stopReason === "stop") { + console.log("agent:", message.content.flatMap((part) => (part.type === "text" ? [part.text] : [])).join("")); + } +} +console.log("recorded:", (await harness.snapshot(Visitors, root.id, context))?.sent); +await harness.close(context);